mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-28 12:06:58 -05:00
Compare commits
59 Commits
9ee84b31ff
...
edit/edt-6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87c7cf7d40 | ||
|
|
2fbaf4ef08 | ||
|
|
3a6bb59475 | ||
|
|
d7158756ef | ||
|
|
3330a8fac9 | ||
|
|
c09a343d08 | ||
|
|
9893567a30 | ||
|
|
771405961f | ||
|
|
437e2b353c | ||
|
|
44a713af62 | ||
|
|
46275e0f1e | ||
|
|
6bd7d24a1b | ||
|
|
72e76e86af | ||
|
|
a35486fb11 | ||
|
|
3d3ae57ee3 | ||
|
|
46c08e10e8 | ||
|
|
b22546e7f2 | ||
|
|
7b0825e772 | ||
|
|
1a57b4f33f | ||
|
|
4c7731410f | ||
|
|
beacf481d8 | ||
|
|
00054f817d | ||
|
|
d18632c2b2 | ||
|
|
09c320f508 | ||
|
|
8422bc378f | ||
|
|
5531376edf | ||
|
|
b6d1b99f2b | ||
|
|
d24de6a9e6 | ||
|
|
c3cab84132 | ||
|
|
ab9d424240 | ||
|
|
617e079c5a | ||
|
|
3cb2c39acf | ||
|
|
3867d30aac | ||
|
|
fdfa1739e5 | ||
|
|
361231bfac | ||
|
|
2ccfe8c48a | ||
|
|
fd4e21734f | ||
|
|
9bab4358e3 | ||
|
|
5b5bd4d62e | ||
|
|
d435bda06a | ||
|
|
888ef0224a | ||
|
|
78e29f9e49 | ||
|
|
842861af8c | ||
|
|
348ab23d26 | ||
|
|
b58a0a7741 | ||
|
|
e2ee11b9f5 | ||
|
|
2468d3cc18 | ||
|
|
f4300de738 | ||
|
|
92e93bfc9d | ||
|
|
7d085a0702 | ||
|
|
6fef107728 | ||
|
|
42f26acb49 | ||
|
|
985454f0d9 | ||
|
|
67ce8b5a88 | ||
|
|
99c8c94e47 | ||
|
|
9a3fbb2f78 | ||
|
|
2f579b08ba | ||
|
|
dce8747651 | ||
|
|
d0c613031e |
183
docs/LOCATION_FIX_SUMMARY.md
Normal file
183
docs/LOCATION_FIX_SUMMARY.md
Normal file
@@ -0,0 +1,183 @@
|
||||
# Location Handling Fix - Complete Summary
|
||||
|
||||
## Problem Identified
|
||||
|
||||
Parks were being created without location data due to a critical bug in the approval pipeline. The `locations` table requires a `name` field (NOT NULL), but the `process_approval_transaction` function was attempting to INSERT locations without this field, causing silent failures and leaving parks with `NULL` location_id values.
|
||||
|
||||
## Root Cause
|
||||
|
||||
The function was:
|
||||
1. ✅ Correctly JOINing `park_submission_locations` table
|
||||
2. ✅ Fetching location fields like `country`, `city`, `latitude`, etc.
|
||||
3. ❌ **NOT** fetching the `name` or `display_name` fields
|
||||
4. ❌ **NOT** including `name` field in the INSERT statement
|
||||
|
||||
This caused PostgreSQL to reject the INSERT (violating NOT NULL constraint), but since there was no explicit error handling for this specific failure, the park was still created with `location_id = NULL`.
|
||||
|
||||
## What Was Fixed
|
||||
|
||||
### Phase 1: Backfill Function (✅ COMPLETED)
|
||||
**File:** `supabase/migrations/20251112000002_fix_location_name_in_backfill.sql` (auto-generated)
|
||||
|
||||
Updated `backfill_park_locations()` function to:
|
||||
- Include `name` and `display_name` fields when fetching from `park_submission_locations`
|
||||
- Construct a location name from available data (priority: display_name → name → city/state/country)
|
||||
- INSERT locations with the proper `name` field
|
||||
|
||||
### Phase 2: Backfill Existing Data (✅ COMPLETED)
|
||||
**File:** `supabase/migrations/20251112000004_fix_location_name_in_backfill.sql` (auto-generated)
|
||||
|
||||
Ran backfill to populate missing location data for existing parks:
|
||||
- Found parks with `NULL` location_id
|
||||
- Located their submission data in `park_submission_locations`
|
||||
- Created location records with proper `name` field
|
||||
- Updated parks with new location_id values
|
||||
|
||||
**Result:** Lagoon park (and any others) now have proper location data and maps display correctly.
|
||||
|
||||
### Phase 3: Approval Function Fix (⏳ PENDING)
|
||||
**File:** `docs/migrations/fix_location_handling_complete.sql`
|
||||
|
||||
Created comprehensive SQL script to fix `process_approval_transaction()` for future submissions.
|
||||
|
||||
**Key Changes:**
|
||||
1. Added to SELECT clause (line ~108):
|
||||
```sql
|
||||
psl.name as park_location_name,
|
||||
psl.display_name as park_location_display_name,
|
||||
```
|
||||
|
||||
2. Updated CREATE action location INSERT (line ~204):
|
||||
```sql
|
||||
v_location_name := COALESCE(
|
||||
v_item.park_location_display_name,
|
||||
v_item.park_location_name,
|
||||
CONCAT_WS(', ', city, state, country)
|
||||
);
|
||||
|
||||
INSERT INTO locations (name, country, ...)
|
||||
VALUES (v_location_name, v_item.park_location_country, ...)
|
||||
```
|
||||
|
||||
3. Updated UPDATE action location INSERT (line ~454):
|
||||
```sql
|
||||
-- Same logic as CREATE action
|
||||
```
|
||||
|
||||
## How to Apply the Approval Function Fix
|
||||
|
||||
The complete SQL script is ready in `docs/migrations/fix_location_handling_complete.sql`.
|
||||
|
||||
### Option 1: Via Supabase SQL Editor (Recommended)
|
||||
1. Go to [Supabase SQL Editor](https://supabase.com/dashboard/project/ydvtmnrszybqnbcqbdcy/sql/new)
|
||||
2. Copy the contents of `docs/migrations/fix_location_handling_complete.sql`
|
||||
3. Paste and execute the SQL
|
||||
4. Verify success by checking the function exists
|
||||
|
||||
### Option 2: Via Migration Tool (Later)
|
||||
The migration can be split into smaller chunks if needed, but the complete file is ready for manual application.
|
||||
|
||||
## Verification Steps
|
||||
|
||||
### 1. Verify Existing Parks Have Locations
|
||||
```sql
|
||||
SELECT p.name, p.slug, p.location_id, l.name as location_name
|
||||
FROM parks p
|
||||
LEFT JOIN locations l ON p.location_id = l.id
|
||||
WHERE p.slug = 'lagoon';
|
||||
```
|
||||
|
||||
**Expected Result:** Location data should be populated ✅
|
||||
|
||||
### 2. Test New Park Submission (After Applying Fix)
|
||||
1. Create a new park submission with location data
|
||||
2. Submit for moderation
|
||||
3. Approve the submission
|
||||
4. Verify the park has a non-NULL location_id
|
||||
5. Check the locations table has the proper name field
|
||||
6. Verify the map displays on the park detail page
|
||||
|
||||
### 3. Test Park Update with Location Change
|
||||
1. Edit an existing park and change its location
|
||||
2. Submit for moderation
|
||||
3. Approve the update
|
||||
4. Verify a new location record was created with proper name
|
||||
5. Verify the park's location_id was updated
|
||||
|
||||
## Database Schema Context
|
||||
|
||||
### locations Table Structure
|
||||
```sql
|
||||
- id: uuid (PK)
|
||||
- name: text (NOT NULL) ← This was the missing field
|
||||
- country: text
|
||||
- state_province: text
|
||||
- city: text
|
||||
- street_address: text
|
||||
- postal_code: text
|
||||
- latitude: numeric
|
||||
- longitude: numeric
|
||||
- timezone: text
|
||||
- created_at: timestamp with time zone
|
||||
```
|
||||
|
||||
### park_submission_locations Table Structure
|
||||
```sql
|
||||
- id: uuid (PK)
|
||||
- park_submission_id: uuid (FK)
|
||||
- name: text ← We weren't fetching this
|
||||
- display_name: text ← We weren't fetching this
|
||||
- country: text
|
||||
- state_province: text
|
||||
- city: text
|
||||
- street_address: text
|
||||
- postal_code: text
|
||||
- latitude: numeric
|
||||
- longitude: numeric
|
||||
- timezone: text
|
||||
- created_at: timestamp with time zone
|
||||
```
|
||||
|
||||
## Impact Assessment
|
||||
|
||||
### Before Fix
|
||||
- ❌ Parks created without location data (location_id = NULL)
|
||||
- ❌ Maps not displaying on park detail pages
|
||||
- ❌ Location-based features not working
|
||||
- ❌ Silent failures in approval pipeline
|
||||
|
||||
### After Complete Fix
|
||||
- ✅ All existing parks have location data (backfilled)
|
||||
- ✅ Maps display correctly on park detail pages
|
||||
- ✅ Future park submissions will have locations created properly
|
||||
- ✅ Park updates with location changes work correctly
|
||||
- ✅ No more silent failures in the pipeline
|
||||
|
||||
## Files Created
|
||||
|
||||
1. `docs/migrations/fix_location_handling_complete.sql` - Complete SQL script for approval function fix
|
||||
2. `docs/LOCATION_FIX_SUMMARY.md` - This document
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Immediate:** Apply the fix from `docs/migrations/fix_location_handling_complete.sql`
|
||||
2. **Testing:** Run verification steps above
|
||||
3. **Monitoring:** Watch for any location-related errors in production
|
||||
4. **Documentation:** Update team on the fix and new behavior
|
||||
|
||||
## Related Issues
|
||||
|
||||
This fix ensures compliance with the "Sacred Pipeline" architecture documented in `docs/SUBMISSION_FLOW.md`. All location data flows through:
|
||||
1. User form input
|
||||
2. Submission to `park_submission_locations` table
|
||||
3. Moderation queue review
|
||||
4. Approval via `process_approval_transaction` function
|
||||
5. Location creation in `locations` table
|
||||
6. Park creation/update with proper location_id reference
|
||||
|
||||
## Additional Notes
|
||||
|
||||
- The `display_name` field in `park_submission_locations` is used for human-readable location labels (e.g., "375, Lagoon Drive, Farmington, Davis County, Utah, 84025, United States")
|
||||
- The `name` field in `locations` must be populated for the INSERT to succeed
|
||||
- If neither display_name nor name is provided, we construct it from city/state/country as a fallback
|
||||
- This pattern should be applied to any other entities that use location data in the future
|
||||
439
docs/migrations/fix_location_handling_complete.sql
Normal file
439
docs/migrations/fix_location_handling_complete.sql
Normal file
@@ -0,0 +1,439 @@
|
||||
-- ============================================================================
|
||||
-- COMPLETE FIX: Location Name Handling in Approval Pipeline
|
||||
-- ============================================================================
|
||||
--
|
||||
-- PURPOSE:
|
||||
-- This migration fixes the process_approval_transaction function to properly
|
||||
-- handle location names when creating parks. Without this fix, locations are
|
||||
-- created without the 'name' field, causing silent failures and parks end up
|
||||
-- with NULL location_id values.
|
||||
--
|
||||
-- WHAT THIS FIXES:
|
||||
-- 1. Adds park_location_name and park_location_display_name to the SELECT
|
||||
-- 2. Creates locations with proper name field during CREATE actions
|
||||
-- 3. Creates locations with proper name field during UPDATE actions
|
||||
-- 4. Falls back to constructing name from city/state/country if not provided
|
||||
--
|
||||
-- TESTING:
|
||||
-- After applying, test by:
|
||||
-- 1. Creating a new park submission with location data
|
||||
-- 2. Approving the submission
|
||||
-- 3. Verifying the park has a location_id set
|
||||
-- 4. Checking the locations table has a record with proper name field
|
||||
--
|
||||
-- DEPLOYMENT:
|
||||
-- This can be run manually via Supabase SQL Editor or applied as a migration
|
||||
-- ============================================================================
|
||||
|
||||
DROP FUNCTION IF EXISTS process_approval_transaction(UUID, UUID[], UUID, UUID, TEXT, TEXT, TEXT);
|
||||
|
||||
CREATE OR REPLACE FUNCTION process_approval_transaction(
|
||||
p_submission_id UUID,
|
||||
p_item_ids UUID[],
|
||||
p_moderator_id UUID,
|
||||
p_submitter_id UUID,
|
||||
p_request_id TEXT DEFAULT NULL,
|
||||
p_trace_id TEXT DEFAULT NULL,
|
||||
p_parent_span_id TEXT DEFAULT NULL
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_start_time TIMESTAMPTZ;
|
||||
v_result JSONB;
|
||||
v_item RECORD;
|
||||
v_entity_id UUID;
|
||||
v_approval_results JSONB[] := ARRAY[]::JSONB[];
|
||||
v_final_status TEXT;
|
||||
v_all_approved BOOLEAN := TRUE;
|
||||
v_some_approved BOOLEAN := FALSE;
|
||||
v_items_processed INTEGER := 0;
|
||||
v_span_id TEXT;
|
||||
v_resolved_park_id UUID;
|
||||
v_resolved_manufacturer_id UUID;
|
||||
v_resolved_ride_model_id UUID;
|
||||
v_resolved_operator_id UUID;
|
||||
v_resolved_property_owner_id UUID;
|
||||
v_resolved_location_id UUID;
|
||||
v_location_name TEXT;
|
||||
BEGIN
|
||||
v_start_time := clock_timestamp();
|
||||
v_span_id := gen_random_uuid()::text;
|
||||
|
||||
IF p_trace_id IS NOT NULL THEN
|
||||
RAISE NOTICE 'SPAN: {"spanId": "%", "traceId": "%", "parentSpanId": "%", "name": "process_approval_transaction_rpc", "kind": "INTERNAL", "startTime": %, "attributes": {"submission.id": "%", "item_count": %}}',
|
||||
v_span_id, p_trace_id, p_parent_span_id, EXTRACT(EPOCH FROM v_start_time) * 1000, p_submission_id, array_length(p_item_ids, 1);
|
||||
END IF;
|
||||
|
||||
RAISE NOTICE '[%] Starting atomic approval transaction for submission %', COALESCE(p_request_id, 'NO_REQUEST_ID'), p_submission_id;
|
||||
|
||||
PERFORM set_config('app.current_user_id', p_submitter_id::text, true);
|
||||
PERFORM set_config('app.submission_id', p_submission_id::text, true);
|
||||
PERFORM set_config('app.moderator_id', p_moderator_id::text, true);
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM content_submissions
|
||||
WHERE id = p_submission_id AND (assigned_to = p_moderator_id OR assigned_to IS NULL) AND status IN ('pending', 'partially_approved')
|
||||
) THEN
|
||||
RAISE EXCEPTION 'Submission not found, locked by another moderator, or already processed' USING ERRCODE = '42501';
|
||||
END IF;
|
||||
|
||||
-- ========================================================================
|
||||
-- CRITICAL FIX: Added park_location_name and park_location_display_name
|
||||
-- ========================================================================
|
||||
FOR v_item IN
|
||||
SELECT si.*,
|
||||
ps.name as park_name, ps.slug as park_slug, ps.description as park_description, ps.park_type, ps.status as park_status,
|
||||
ps.location_id, ps.operator_id, ps.property_owner_id, ps.opening_date as park_opening_date, ps.closing_date as park_closing_date,
|
||||
ps.opening_date_precision as park_opening_date_precision, ps.closing_date_precision as park_closing_date_precision,
|
||||
ps.website_url as park_website_url, ps.phone as park_phone, ps.email as park_email,
|
||||
ps.banner_image_url as park_banner_image_url, ps.banner_image_id as park_banner_image_id,
|
||||
ps.card_image_url as park_card_image_url, ps.card_image_id as park_card_image_id,
|
||||
psl.name as park_location_name, psl.display_name as park_location_display_name,
|
||||
psl.country as park_location_country, psl.state_province as park_location_state, psl.city as park_location_city,
|
||||
psl.street_address as park_location_street, psl.postal_code as park_location_postal,
|
||||
psl.latitude as park_location_lat, psl.longitude as park_location_lng, psl.timezone as park_location_timezone,
|
||||
rs.name as ride_name, rs.slug as ride_slug, rs.park_id as ride_park_id, rs.category as ride_category, rs.status as ride_status,
|
||||
rs.manufacturer_id, rs.ride_model_id, rs.opening_date as ride_opening_date, rs.closing_date as ride_closing_date,
|
||||
rs.opening_date_precision as ride_opening_date_precision, rs.closing_date_precision as ride_closing_date_precision,
|
||||
rs.description as ride_description, rs.banner_image_url as ride_banner_image_url, rs.banner_image_id as ride_banner_image_id,
|
||||
rs.card_image_url as ride_card_image_url, rs.card_image_id as ride_card_image_id,
|
||||
cs.name as company_name, cs.slug as company_slug, cs.description as company_description, cs.company_type,
|
||||
cs.website_url as company_website_url, cs.founded_year, cs.founded_date, cs.founded_date_precision,
|
||||
cs.headquarters_location, cs.logo_url, cs.person_type,
|
||||
cs.banner_image_url as company_banner_image_url, cs.banner_image_id as company_banner_image_id,
|
||||
cs.card_image_url as company_card_image_url, cs.card_image_id as company_card_image_id,
|
||||
rms.name as ride_model_name, rms.slug as ride_model_slug, rms.manufacturer_id as ride_model_manufacturer_id,
|
||||
rms.category as ride_model_category, rms.description as ride_model_description,
|
||||
rms.banner_image_url as ride_model_banner_image_url, rms.banner_image_id as ride_model_banner_image_id,
|
||||
rms.card_image_url as ride_model_card_image_url, rms.card_image_id as ride_model_card_image_id,
|
||||
phs.entity_id as photo_entity_id, phs.entity_type as photo_entity_type, phs.title as photo_title
|
||||
FROM submission_items si
|
||||
LEFT JOIN park_submissions ps ON si.park_submission_id = ps.id
|
||||
LEFT JOIN park_submission_locations psl ON ps.id = psl.park_submission_id
|
||||
LEFT JOIN ride_submissions rs ON si.ride_submission_id = rs.id
|
||||
LEFT JOIN company_submissions cs ON si.company_submission_id = cs.id
|
||||
LEFT JOIN ride_model_submissions rms ON si.ride_model_submission_id = rms.id
|
||||
LEFT JOIN photo_submissions phs ON si.photo_submission_id = phs.id
|
||||
WHERE si.id = ANY(p_item_ids)
|
||||
ORDER BY si.order_index, si.created_at
|
||||
LOOP
|
||||
BEGIN
|
||||
v_items_processed := v_items_processed + 1;
|
||||
v_entity_id := NULL;
|
||||
v_resolved_park_id := NULL; v_resolved_manufacturer_id := NULL; v_resolved_ride_model_id := NULL;
|
||||
v_resolved_operator_id := NULL; v_resolved_property_owner_id := NULL; v_resolved_location_id := NULL;
|
||||
|
||||
IF p_trace_id IS NOT NULL THEN
|
||||
RAISE NOTICE 'SPAN_EVENT: {"traceId": "%", "parentSpanId": "%", "name": "process_item", "timestamp": %, "attributes": {"item.id": "%", "item.type": "%", "item.action": "%"}}',
|
||||
p_trace_id, v_span_id, EXTRACT(EPOCH FROM clock_timestamp()) * 1000, v_item.id, v_item.item_type, v_item.action_type;
|
||||
END IF;
|
||||
|
||||
IF v_item.action_type = 'create' THEN
|
||||
IF v_item.item_type = 'park' THEN
|
||||
-- ========================================================================
|
||||
-- CRITICAL FIX: Create location with name field
|
||||
-- ========================================================================
|
||||
IF v_item.park_location_country IS NOT NULL OR v_item.park_location_city IS NOT NULL THEN
|
||||
-- Construct a name for the location, prioritizing display_name, then name, then city/state/country
|
||||
v_location_name := COALESCE(
|
||||
v_item.park_location_display_name,
|
||||
v_item.park_location_name,
|
||||
CONCAT_WS(', ',
|
||||
NULLIF(v_item.park_location_city, ''),
|
||||
NULLIF(v_item.park_location_state, ''),
|
||||
NULLIF(v_item.park_location_country, '')
|
||||
)
|
||||
);
|
||||
|
||||
INSERT INTO locations (name, country, state_province, city, street_address, postal_code, latitude, longitude, timezone)
|
||||
VALUES (
|
||||
v_location_name,
|
||||
v_item.park_location_country,
|
||||
v_item.park_location_state,
|
||||
v_item.park_location_city,
|
||||
v_item.park_location_street,
|
||||
v_item.park_location_postal,
|
||||
v_item.park_location_lat,
|
||||
v_item.park_location_lng,
|
||||
v_item.park_location_timezone
|
||||
)
|
||||
RETURNING id INTO v_resolved_location_id;
|
||||
|
||||
RAISE NOTICE '[%] Created location % (name: %) for park submission',
|
||||
COALESCE(p_request_id, 'NO_REQUEST_ID'), v_resolved_location_id, v_location_name;
|
||||
END IF;
|
||||
|
||||
-- Resolve temporary references
|
||||
IF v_item.operator_id IS NULL THEN
|
||||
SELECT approved_entity_id INTO v_resolved_operator_id FROM submission_items
|
||||
WHERE submission_id = p_submission_id AND item_type IN ('operator', 'company') AND approved_entity_id IS NOT NULL LIMIT 1;
|
||||
END IF;
|
||||
|
||||
IF v_item.property_owner_id IS NULL THEN
|
||||
SELECT approved_entity_id INTO v_resolved_property_owner_id FROM submission_items
|
||||
WHERE submission_id = p_submission_id AND item_type IN ('property_owner', 'company') AND approved_entity_id IS NOT NULL LIMIT 1;
|
||||
END IF;
|
||||
|
||||
INSERT INTO parks (name, slug, description, park_type, status, location_id, operator_id, property_owner_id,
|
||||
opening_date, closing_date, opening_date_precision, closing_date_precision, website_url, phone, email,
|
||||
banner_image_url, banner_image_id, card_image_url, card_image_id)
|
||||
VALUES (
|
||||
v_item.park_name, v_item.park_slug, v_item.park_description, v_item.park_type, v_item.park_status,
|
||||
COALESCE(v_resolved_location_id, v_item.location_id),
|
||||
COALESCE(v_item.operator_id, v_resolved_operator_id),
|
||||
COALESCE(v_item.property_owner_id, v_resolved_property_owner_id),
|
||||
v_item.park_opening_date, v_item.park_closing_date,
|
||||
v_item.park_opening_date_precision, v_item.park_closing_date_precision,
|
||||
v_item.park_website_url, v_item.park_phone, v_item.park_email,
|
||||
v_item.park_banner_image_url, v_item.park_banner_image_id,
|
||||
v_item.park_card_image_url, v_item.park_card_image_id
|
||||
)
|
||||
RETURNING id INTO v_entity_id;
|
||||
|
||||
ELSIF v_item.item_type = 'ride' THEN
|
||||
IF v_item.ride_park_id IS NULL THEN
|
||||
SELECT approved_entity_id INTO v_resolved_park_id FROM submission_items
|
||||
WHERE submission_id = p_submission_id AND item_type = 'park' AND approved_entity_id IS NOT NULL LIMIT 1;
|
||||
END IF;
|
||||
|
||||
IF v_item.manufacturer_id IS NULL THEN
|
||||
SELECT approved_entity_id INTO v_resolved_manufacturer_id FROM submission_items
|
||||
WHERE submission_id = p_submission_id AND item_type IN ('manufacturer', 'company') AND approved_entity_id IS NOT NULL LIMIT 1;
|
||||
END IF;
|
||||
|
||||
IF v_item.ride_model_id IS NULL THEN
|
||||
SELECT approved_entity_id INTO v_resolved_ride_model_id FROM submission_items
|
||||
WHERE submission_id = p_submission_id AND item_type = 'ride_model' AND approved_entity_id IS NOT NULL LIMIT 1;
|
||||
END IF;
|
||||
|
||||
INSERT INTO rides (name, slug, park_id, category, status, manufacturer_id, ride_model_id,
|
||||
opening_date, closing_date, opening_date_precision, closing_date_precision, description,
|
||||
banner_image_url, banner_image_id, card_image_url, card_image_id)
|
||||
VALUES (
|
||||
v_item.ride_name, v_item.ride_slug, COALESCE(v_item.ride_park_id, v_resolved_park_id),
|
||||
v_item.ride_category, v_item.ride_status,
|
||||
COALESCE(v_item.manufacturer_id, v_resolved_manufacturer_id),
|
||||
COALESCE(v_item.ride_model_id, v_resolved_ride_model_id),
|
||||
v_item.ride_opening_date, v_item.ride_closing_date,
|
||||
v_item.ride_opening_date_precision, v_item.ride_closing_date_precision,
|
||||
v_item.ride_description, v_item.ride_banner_image_url, v_item.ride_banner_image_id,
|
||||
v_item.ride_card_image_url, v_item.ride_card_image_id
|
||||
)
|
||||
RETURNING id INTO v_entity_id;
|
||||
|
||||
IF v_entity_id IS NOT NULL AND v_item.ride_submission_id IS NOT NULL THEN
|
||||
INSERT INTO ride_technical_specifications (ride_id, specification_key, specification_value, unit, display_order)
|
||||
SELECT v_entity_id, specification_key, specification_value, unit, display_order
|
||||
FROM ride_technical_specifications WHERE ride_id = v_item.ride_submission_id;
|
||||
|
||||
INSERT INTO ride_coaster_stats (ride_id, stat_key, stat_value, unit, display_order)
|
||||
SELECT v_entity_id, stat_key, stat_value, unit, display_order
|
||||
FROM ride_coaster_stats WHERE ride_id = v_item.ride_submission_id;
|
||||
END IF;
|
||||
|
||||
ELSIF v_item.item_type IN ('company', 'manufacturer', 'operator', 'property_owner', 'designer') THEN
|
||||
INSERT INTO companies (name, slug, description, company_type, person_type, website_url, founded_year,
|
||||
founded_date, founded_date_precision, headquarters_location, logo_url,
|
||||
banner_image_url, banner_image_id, card_image_url, card_image_id)
|
||||
VALUES (
|
||||
v_item.company_name, v_item.company_slug, v_item.company_description, v_item.company_type,
|
||||
v_item.person_type, v_item.company_website_url, v_item.founded_year,
|
||||
v_item.founded_date, v_item.founded_date_precision, v_item.headquarters_location, v_item.logo_url,
|
||||
v_item.company_banner_image_url, v_item.company_banner_image_id,
|
||||
v_item.company_card_image_url, v_item.company_card_image_id
|
||||
)
|
||||
RETURNING id INTO v_entity_id;
|
||||
|
||||
ELSIF v_item.item_type = 'ride_model' THEN
|
||||
IF v_item.ride_model_manufacturer_id IS NULL THEN
|
||||
SELECT approved_entity_id INTO v_resolved_manufacturer_id FROM submission_items
|
||||
WHERE submission_id = p_submission_id AND item_type IN ('manufacturer', 'company') AND approved_entity_id IS NOT NULL LIMIT 1;
|
||||
END IF;
|
||||
|
||||
INSERT INTO ride_models (name, slug, manufacturer_id, category, description,
|
||||
banner_image_url, banner_image_id, card_image_url, card_image_id)
|
||||
VALUES (
|
||||
v_item.ride_model_name, v_item.ride_model_slug,
|
||||
COALESCE(v_item.ride_model_manufacturer_id, v_resolved_manufacturer_id),
|
||||
v_item.ride_model_category, v_item.ride_model_description,
|
||||
v_item.ride_model_banner_image_url, v_item.ride_model_banner_image_id,
|
||||
v_item.ride_model_card_image_url, v_item.ride_model_card_image_id
|
||||
)
|
||||
RETURNING id INTO v_entity_id;
|
||||
|
||||
ELSIF v_item.item_type = 'photo' THEN
|
||||
INSERT INTO entity_photos (entity_id, entity_type, title, photo_submission_id)
|
||||
VALUES (v_item.photo_entity_id, v_item.photo_entity_type, v_item.photo_title, v_item.photo_submission_id)
|
||||
RETURNING id INTO v_entity_id;
|
||||
|
||||
ELSE
|
||||
RAISE EXCEPTION 'Unknown item type for create: %', v_item.item_type;
|
||||
END IF;
|
||||
|
||||
ELSIF v_item.action_type = 'update' THEN
|
||||
IF v_item.entity_id IS NULL THEN
|
||||
RAISE EXCEPTION 'Update action requires entity_id';
|
||||
END IF;
|
||||
|
||||
IF v_item.item_type = 'park' THEN
|
||||
-- ========================================================================
|
||||
-- CRITICAL FIX: Create location with name field for updates too
|
||||
-- ========================================================================
|
||||
IF v_item.location_id IS NULL AND (v_item.park_location_country IS NOT NULL OR v_item.park_location_city IS NOT NULL) THEN
|
||||
v_location_name := COALESCE(
|
||||
v_item.park_location_display_name,
|
||||
v_item.park_location_name,
|
||||
CONCAT_WS(', ',
|
||||
NULLIF(v_item.park_location_city, ''),
|
||||
NULLIF(v_item.park_location_state, ''),
|
||||
NULLIF(v_item.park_location_country, '')
|
||||
)
|
||||
);
|
||||
|
||||
INSERT INTO locations (name, country, state_province, city, street_address, postal_code, latitude, longitude, timezone)
|
||||
VALUES (
|
||||
v_location_name,
|
||||
v_item.park_location_country,
|
||||
v_item.park_location_state,
|
||||
v_item.park_location_city,
|
||||
v_item.park_location_street,
|
||||
v_item.park_location_postal,
|
||||
v_item.park_location_lat,
|
||||
v_item.park_location_lng,
|
||||
v_item.park_location_timezone
|
||||
)
|
||||
RETURNING id INTO v_resolved_location_id;
|
||||
|
||||
RAISE NOTICE '[%] Created location % (name: %) for park update',
|
||||
COALESCE(p_request_id, 'NO_REQUEST_ID'), v_resolved_location_id, v_location_name;
|
||||
END IF;
|
||||
|
||||
UPDATE parks SET
|
||||
name = v_item.park_name, slug = v_item.park_slug, description = v_item.park_description,
|
||||
park_type = v_item.park_type, status = v_item.park_status,
|
||||
location_id = COALESCE(v_resolved_location_id, v_item.location_id),
|
||||
operator_id = v_item.operator_id, property_owner_id = v_item.property_owner_id,
|
||||
opening_date = v_item.park_opening_date, closing_date = v_item.park_closing_date,
|
||||
opening_date_precision = v_item.park_opening_date_precision,
|
||||
closing_date_precision = v_item.park_closing_date_precision,
|
||||
website_url = v_item.park_website_url, phone = v_item.park_phone, email = v_item.park_email,
|
||||
banner_image_url = v_item.park_banner_image_url, banner_image_id = v_item.park_banner_image_id,
|
||||
card_image_url = v_item.park_card_image_url, card_image_id = v_item.park_card_image_id,
|
||||
updated_at = now()
|
||||
WHERE id = v_item.entity_id;
|
||||
v_entity_id := v_item.entity_id;
|
||||
|
||||
ELSIF v_item.item_type = 'ride' THEN
|
||||
UPDATE rides SET
|
||||
name = v_item.ride_name, slug = v_item.ride_slug, park_id = v_item.ride_park_id,
|
||||
category = v_item.ride_category, status = v_item.ride_status,
|
||||
manufacturer_id = v_item.manufacturer_id, ride_model_id = v_item.ride_model_id,
|
||||
opening_date = v_item.ride_opening_date, closing_date = v_item.ride_closing_date,
|
||||
opening_date_precision = v_item.ride_opening_date_precision,
|
||||
closing_date_precision = v_item.ride_closing_date_precision,
|
||||
description = v_item.ride_description,
|
||||
banner_image_url = v_item.ride_banner_image_url, banner_image_id = v_item.ride_banner_image_id,
|
||||
card_image_url = v_item.ride_card_image_url, card_image_id = v_item.ride_card_image_id,
|
||||
updated_at = now()
|
||||
WHERE id = v_item.entity_id;
|
||||
v_entity_id := v_item.entity_id;
|
||||
|
||||
ELSIF v_item.item_type IN ('company', 'manufacturer', 'operator', 'property_owner', 'designer') THEN
|
||||
UPDATE companies SET
|
||||
name = v_item.company_name, slug = v_item.company_slug, description = v_item.company_description,
|
||||
company_type = v_item.company_type, person_type = v_item.person_type,
|
||||
website_url = v_item.company_website_url, founded_year = v_item.founded_year,
|
||||
founded_date = v_item.founded_date, founded_date_precision = v_item.founded_date_precision,
|
||||
headquarters_location = v_item.headquarters_location, logo_url = v_item.logo_url,
|
||||
banner_image_url = v_item.company_banner_image_url, banner_image_id = v_item.company_banner_image_id,
|
||||
card_image_url = v_item.company_card_image_url, card_image_id = v_item.company_card_image_id,
|
||||
updated_at = now()
|
||||
WHERE id = v_item.entity_id;
|
||||
v_entity_id := v_item.entity_id;
|
||||
|
||||
ELSIF v_item.item_type = 'ride_model' THEN
|
||||
UPDATE ride_models SET
|
||||
name = v_item.ride_model_name, slug = v_item.ride_model_slug,
|
||||
manufacturer_id = v_item.ride_model_manufacturer_id,
|
||||
category = v_item.ride_model_category, description = v_item.ride_model_description,
|
||||
banner_image_url = v_item.ride_model_banner_image_url, banner_image_id = v_item.ride_model_banner_image_id,
|
||||
card_image_url = v_item.ride_model_card_image_url, card_image_id = v_item.ride_model_card_image_id,
|
||||
updated_at = now()
|
||||
WHERE id = v_item.entity_id;
|
||||
v_entity_id := v_item.entity_id;
|
||||
|
||||
ELSIF v_item.item_type = 'photo' THEN
|
||||
UPDATE entity_photos SET title = v_item.photo_title, updated_at = now()
|
||||
WHERE id = v_item.entity_id;
|
||||
v_entity_id := v_item.entity_id;
|
||||
|
||||
ELSE
|
||||
RAISE EXCEPTION 'Unknown item type for update: %', v_item.item_type;
|
||||
END IF;
|
||||
|
||||
ELSE
|
||||
RAISE EXCEPTION 'Unknown action type: %', v_item.action_type;
|
||||
END IF;
|
||||
|
||||
UPDATE submission_items SET approved_entity_id = v_entity_id, approved_at = now(), status = 'approved'
|
||||
WHERE id = v_item.id;
|
||||
|
||||
v_approval_results := array_append(v_approval_results, jsonb_build_object(
|
||||
'item_id', v_item.id, 'status', 'approved', 'entity_id', v_entity_id
|
||||
));
|
||||
v_some_approved := TRUE;
|
||||
|
||||
EXCEPTION
|
||||
WHEN OTHERS THEN
|
||||
RAISE WARNING 'Failed to process item %: % - %', v_item.id, SQLERRM, SQLSTATE;
|
||||
v_approval_results := array_append(v_approval_results, jsonb_build_object(
|
||||
'item_id', v_item.id, 'status', 'failed', 'error', SQLERRM
|
||||
));
|
||||
v_all_approved := FALSE;
|
||||
RAISE;
|
||||
END;
|
||||
END LOOP;
|
||||
|
||||
IF v_all_approved THEN
|
||||
v_final_status := 'approved';
|
||||
ELSIF v_some_approved THEN
|
||||
v_final_status := 'partially_approved';
|
||||
ELSE
|
||||
v_final_status := 'rejected';
|
||||
END IF;
|
||||
|
||||
UPDATE content_submissions SET
|
||||
status = v_final_status,
|
||||
resolved_at = CASE WHEN v_all_approved THEN now() ELSE NULL END,
|
||||
reviewer_id = p_moderator_id,
|
||||
reviewed_at = now()
|
||||
WHERE id = p_submission_id;
|
||||
|
||||
IF p_trace_id IS NOT NULL THEN
|
||||
RAISE NOTICE 'SPAN: {"spanId": "%", "traceId": "%", "name": "process_approval_transaction_rpc", "kind": "INTERNAL", "endTime": %, "attributes": {"items_processed": %, "final_status": "%"}}',
|
||||
v_span_id, p_trace_id, EXTRACT(EPOCH FROM clock_timestamp()) * 1000, v_items_processed, v_final_status;
|
||||
END IF;
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'success', v_all_approved,
|
||||
'status', v_final_status,
|
||||
'items_processed', v_items_processed,
|
||||
'results', v_approval_results,
|
||||
'duration_ms', EXTRACT(EPOCH FROM (clock_timestamp() - v_start_time)) * 1000
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
GRANT EXECUTE ON FUNCTION process_approval_transaction TO authenticated;
|
||||
|
||||
COMMENT ON FUNCTION process_approval_transaction IS
|
||||
'✅ FIXED 2025-11-12: Now properly creates location records with name field during park approval/update.
|
||||
This prevents parks from being created with NULL location_id values due to silent INSERT failures.';
|
||||
|
||||
-- ============================================================================
|
||||
-- END OF MIGRATION
|
||||
-- ============================================================================
|
||||
29
src/App.tsx
29
src/App.tsx
@@ -24,6 +24,7 @@ import { ResilienceProvider } from "@/components/layout/ResilienceProvider";
|
||||
import { useAdminRoutePreload } from "@/hooks/useAdminRoutePreload";
|
||||
import { useVersionCheck } from "@/hooks/useVersionCheck";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PageTransition } from "@/components/layout/PageTransition";
|
||||
|
||||
// Core routes (eager-loaded for best UX)
|
||||
import Index from "./pages/Index";
|
||||
@@ -70,6 +71,7 @@ const AdminUsers = lazy(() => import("./pages/AdminUsers"));
|
||||
const AdminBlog = lazy(() => import("./pages/AdminBlog"));
|
||||
const AdminSettings = lazy(() => import("./pages/AdminSettings"));
|
||||
const AdminDatabaseStats = lazy(() => import("./pages/AdminDatabaseStats"));
|
||||
const DatabaseMaintenance = lazy(() => import("./pages/admin/DatabaseMaintenance"));
|
||||
const AdminContact = lazy(() => import("./pages/admin/AdminContact"));
|
||||
const AdminEmailSettings = lazy(() => import("./pages/admin/AdminEmailSettings"));
|
||||
const ErrorMonitoring = lazy(() => import("./pages/admin/ErrorMonitoring"));
|
||||
@@ -77,6 +79,7 @@ const ErrorLookup = lazy(() => import("./pages/admin/ErrorLookup"));
|
||||
const TraceViewer = lazy(() => import("./pages/admin/TraceViewer"));
|
||||
const RateLimitMetrics = lazy(() => import("./pages/admin/RateLimitMetrics"));
|
||||
const MonitoringOverview = lazy(() => import("./pages/admin/MonitoringOverview"));
|
||||
const ApprovalHistory = lazy(() => import("./pages/admin/ApprovalHistory"));
|
||||
|
||||
// User routes (lazy-loaded)
|
||||
const Profile = lazy(() => import("./pages/Profile"));
|
||||
@@ -163,8 +166,9 @@ function AppContent(): React.JSX.Element {
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<div className="flex-1">
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<RouteErrorBoundary>
|
||||
<Routes>
|
||||
<PageTransition>
|
||||
<RouteErrorBoundary>
|
||||
<Routes>
|
||||
{/* Core routes - eager loaded */}
|
||||
<Route path="/" element={<Index />} />
|
||||
<Route path="/parks" element={<Parks />} />
|
||||
@@ -384,7 +388,15 @@ function AppContent(): React.JSX.Element {
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/error-lookup"
|
||||
path="/admin/approval-history"
|
||||
element={
|
||||
<AdminErrorBoundary section="Approval History">
|
||||
<ApprovalHistory />
|
||||
</AdminErrorBoundary>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/error-lookup"
|
||||
element={
|
||||
<AdminErrorBoundary section="Error Lookup">
|
||||
<ErrorLookup />
|
||||
@@ -423,6 +435,14 @@ function AppContent(): React.JSX.Element {
|
||||
</AdminErrorBoundary>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/database-maintenance"
|
||||
element={
|
||||
<AdminErrorBoundary section="Database Maintenance">
|
||||
<DatabaseMaintenance />
|
||||
</AdminErrorBoundary>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Utility routes - lazy loaded */}
|
||||
<Route path="/force-logout" element={<ForceLogout />} />
|
||||
@@ -434,7 +454,8 @@ function AppContent(): React.JSX.Element {
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</RouteErrorBoundary>
|
||||
</Suspense>
|
||||
</PageTransition>
|
||||
</Suspense>
|
||||
</div>
|
||||
<Footer />
|
||||
</div>
|
||||
|
||||
@@ -16,8 +16,9 @@ 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 { toast } from '@/hooks/use-toast';
|
||||
import { handleError, getErrorMessage } from '@/lib/errorHandler';
|
||||
import { formToasts } from '@/lib/formToasts';
|
||||
import type { UploadedImage } from '@/types/company';
|
||||
|
||||
// Zod output type (after transformation)
|
||||
@@ -73,7 +74,7 @@ export function DesignerForm({ onSubmit, onCancel, initialData }: DesignerFormPr
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit(async (data) => {
|
||||
if (!user) {
|
||||
toast.error('You must be logged in to submit');
|
||||
formToasts.error.generic('You must be logged in to submit');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -93,9 +94,11 @@ export function DesignerForm({ onSubmit, onCancel, initialData }: DesignerFormPr
|
||||
|
||||
await onSubmit(formData);
|
||||
|
||||
// Only show success toast and close if not editing through moderation queue
|
||||
if (!initialData?.id) {
|
||||
toast.success('Designer submitted for review');
|
||||
// Show success toast
|
||||
if (initialData?.id) {
|
||||
formToasts.success.update('Designer', data.name);
|
||||
} else {
|
||||
formToasts.success.create('Designer', data.name);
|
||||
onCancel();
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
@@ -104,6 +107,9 @@ export function DesignerForm({ onSubmit, onCancel, initialData }: DesignerFormPr
|
||||
metadata: { companyName: data.name }
|
||||
});
|
||||
|
||||
// Show error toast
|
||||
formToasts.error.generic(getErrorMessage(error));
|
||||
|
||||
// Re-throw so parent can handle modal closing
|
||||
throw error;
|
||||
} finally {
|
||||
|
||||
@@ -17,8 +17,9 @@ 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 { toast } from '@/hooks/use-toast';
|
||||
import { handleError, getErrorMessage } from '@/lib/errorHandler';
|
||||
import { formToasts } from '@/lib/formToasts';
|
||||
import { toDateOnly, parseDateOnly, toDateWithPrecision } from '@/lib/dateUtils';
|
||||
import type { UploadedImage } from '@/types/company';
|
||||
|
||||
@@ -57,7 +58,7 @@ export function ManufacturerForm({ onSubmit, onCancel, initialData }: Manufactur
|
||||
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)),
|
||||
founded_date_precision: initialData?.founded_date_precision || (initialData?.founded_year ? ('year' as const) : ('exact' as const)),
|
||||
headquarters_location: initialData?.headquarters_location || '',
|
||||
source_url: initialData?.source_url || '',
|
||||
submission_notes: initialData?.submission_notes || '',
|
||||
@@ -77,7 +78,7 @@ export function ManufacturerForm({ onSubmit, onCancel, initialData }: Manufactur
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit(async (data) => {
|
||||
if (!user) {
|
||||
toast.error('You must be logged in to submit');
|
||||
formToasts.error.generic('You must be logged in to submit');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -95,9 +96,11 @@ export function ManufacturerForm({ onSubmit, onCancel, initialData }: Manufactur
|
||||
|
||||
await onSubmit(formData);
|
||||
|
||||
// Only show success toast and close if not editing through moderation queue
|
||||
if (!initialData?.id) {
|
||||
toast.success('Manufacturer submitted for review');
|
||||
// Show success toast
|
||||
if (initialData?.id) {
|
||||
formToasts.success.update('Manufacturer', data.name);
|
||||
} else {
|
||||
formToasts.success.create('Manufacturer', data.name);
|
||||
onCancel();
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
@@ -106,6 +109,9 @@ export function ManufacturerForm({ onSubmit, onCancel, initialData }: Manufactur
|
||||
metadata: { companyName: data.name }
|
||||
});
|
||||
|
||||
// Show error toast
|
||||
formToasts.error.generic(getErrorMessage(error));
|
||||
|
||||
// Re-throw so parent can handle modal closing
|
||||
throw error;
|
||||
} finally {
|
||||
|
||||
@@ -16,8 +16,9 @@ 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 { toast } from '@/hooks/use-toast';
|
||||
import { handleError, getErrorMessage } from '@/lib/errorHandler';
|
||||
import { formToasts } from '@/lib/formToasts';
|
||||
import type { UploadedImage } from '@/types/company';
|
||||
|
||||
// Zod output type (after transformation)
|
||||
@@ -73,7 +74,7 @@ export function OperatorForm({ onSubmit, onCancel, initialData }: OperatorFormPr
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit(async (data) => {
|
||||
if (!user) {
|
||||
toast.error('You must be logged in to submit');
|
||||
formToasts.error.generic('You must be logged in to submit');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -93,9 +94,11 @@ export function OperatorForm({ onSubmit, onCancel, initialData }: OperatorFormPr
|
||||
|
||||
await onSubmit(formData);
|
||||
|
||||
// Only show success toast and close if not editing through moderation queue
|
||||
if (!initialData?.id) {
|
||||
toast.success('Operator submitted for review');
|
||||
// Show success toast
|
||||
if (initialData?.id) {
|
||||
formToasts.success.update('Operator', data.name);
|
||||
} else {
|
||||
formToasts.success.create('Operator', data.name);
|
||||
onCancel();
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
@@ -104,6 +107,9 @@ export function OperatorForm({ onSubmit, onCancel, initialData }: OperatorFormPr
|
||||
metadata: { companyName: data.name }
|
||||
});
|
||||
|
||||
// Show error toast
|
||||
formToasts.error.generic(getErrorMessage(error));
|
||||
|
||||
// Re-throw so parent can handle modal closing
|
||||
throw error;
|
||||
} finally {
|
||||
|
||||
@@ -17,7 +17,8 @@ import { FlexibleDateInput, type DatePrecision } from '@/components/ui/flexible-
|
||||
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 { formToasts } from '@/lib/formToasts';
|
||||
import { MapPin, Save, X, Plus, AlertCircle, Info } from 'lucide-react';
|
||||
import { toDateOnly, parseDateOnly, toDateWithPrecision } from '@/lib/dateUtils';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
@@ -30,6 +31,10 @@ import { LocationSearch } from './LocationSearch';
|
||||
import { OperatorForm } from './OperatorForm';
|
||||
import { PropertyOwnerForm } from './PropertyOwnerForm';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { SubmissionHelpDialog } from '@/components/help/SubmissionHelpDialog';
|
||||
import { TerminologyDialog } from '@/components/help/TerminologyDialog';
|
||||
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||
import { fieldHints } from '@/lib/enhancedValidation';
|
||||
|
||||
const parkSchema = z.object({
|
||||
name: z.string().min(1, 'Park name is required'),
|
||||
@@ -38,9 +43,9 @@ const parkSchema = z.object({
|
||||
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(),
|
||||
opening_date_precision: z.enum(['exact', 'month', 'year', 'decade', 'century', 'approximate']).optional(),
|
||||
closing_date: z.string().optional().transform(val => val || undefined),
|
||||
closing_date_precision: z.enum(['day', 'month', 'year']).optional(),
|
||||
closing_date_precision: z.enum(['exact', 'month', 'year', 'decade', 'century', 'approximate']).optional(),
|
||||
location: z.object({
|
||||
name: z.string(),
|
||||
street_address: z.string().optional(),
|
||||
@@ -290,7 +295,16 @@ export function ParkForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
||||
|
||||
await onSubmit(submissionData);
|
||||
|
||||
// Parent component handles success feedback
|
||||
// Show success toast
|
||||
if (isModerator()) {
|
||||
formToasts.success.moderatorApproval('Park', data.name);
|
||||
} else if (isEditing) {
|
||||
formToasts.success.update('Park', data.name);
|
||||
} else {
|
||||
formToasts.success.create('Park', data.name);
|
||||
}
|
||||
|
||||
// Parent component handles modal closing/navigation
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = getErrorMessage(error);
|
||||
handleError(error, {
|
||||
@@ -304,6 +318,9 @@ export function ParkForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
||||
}
|
||||
});
|
||||
|
||||
// Show error toast
|
||||
formToasts.error.generic(errorMessage);
|
||||
|
||||
// Re-throw so parent can handle modal closing
|
||||
throw error;
|
||||
} finally {
|
||||
@@ -314,12 +331,19 @@ export function ParkForm({ onSubmit, onCancel, initialData, isEditing = 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>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<MapPin className="w-5 h-5" />
|
||||
{isEditing ? 'Edit Park' : 'Create New Park'}
|
||||
</CardTitle>
|
||||
<div className="flex gap-2">
|
||||
<TerminologyDialog />
|
||||
<SubmissionHelpDialog type="park" variant="icon" />
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<TooltipProvider>
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-6">
|
||||
{/* Basic Information */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
@@ -370,6 +394,10 @@ export function ParkForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex items-start gap-2 text-xs text-muted-foreground">
|
||||
<Info className="h-3.5 w-3.5 mt-0.5 flex-shrink-0" />
|
||||
<p>Choose the primary classification. Theme parks have themed areas, while amusement parks focus on rides.</p>
|
||||
</div>
|
||||
{errors.park_type && (
|
||||
<p className="text-sm text-destructive">{errors.park_type.message}</p>
|
||||
)}
|
||||
@@ -395,6 +423,10 @@ export function ParkForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex items-start gap-2 text-xs text-muted-foreground">
|
||||
<Info className="h-3.5 w-3.5 mt-0.5 flex-shrink-0" />
|
||||
<p>Current operational status. Use "Closed Temporarily" for seasonal closures or renovations.</p>
|
||||
</div>
|
||||
{errors.status && (
|
||||
<p className="text-sm text-destructive">{errors.status.message}</p>
|
||||
)}
|
||||
@@ -405,7 +437,7 @@ export function ParkForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
||||
<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'}
|
||||
precision={(watch('opening_date_precision') as DatePrecision) || 'exact'}
|
||||
onChange={(date, precision) => {
|
||||
setValue('opening_date', date ? toDateWithPrecision(date, precision) : undefined);
|
||||
setValue('opening_date_precision', precision);
|
||||
@@ -418,7 +450,7 @@ export function ParkForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
||||
|
||||
<FlexibleDateInput
|
||||
value={watch('closing_date') ? parseDateOnly(watch('closing_date')!) : undefined}
|
||||
precision={(watch('closing_date_precision') as DatePrecision) || 'day'}
|
||||
precision={(watch('closing_date_precision') as DatePrecision) || 'exact'}
|
||||
onChange={(date, precision) => {
|
||||
setValue('closing_date', date ? toDateWithPrecision(date, precision) : undefined);
|
||||
setValue('closing_date_precision', precision);
|
||||
@@ -446,6 +478,10 @@ export function ParkForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
||||
}}
|
||||
initialLocationId={watch('location_id')}
|
||||
/>
|
||||
<div className="flex items-start gap-2 text-xs text-muted-foreground">
|
||||
<Info className="h-3.5 w-3.5 mt-0.5 flex-shrink-0" />
|
||||
<p>Search by park name, address, or city. Select from results to auto-fill coordinates and timezone.</p>
|
||||
</div>
|
||||
{errors.location && (
|
||||
<p className="text-sm text-destructive flex items-center gap-1">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
@@ -462,6 +498,10 @@ export function ParkForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
||||
{/* Operator & Property Owner Selection */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">Operator & Property Owner</h3>
|
||||
<div className="flex items-start gap-2 text-xs text-muted-foreground mb-3">
|
||||
<Info className="h-3.5 w-3.5 mt-0.5 flex-shrink-0" />
|
||||
<p>The operator runs the park, while the property owner owns the land. Often the same entity.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2 mb-4">
|
||||
<Checkbox
|
||||
@@ -590,6 +630,7 @@ export function ParkForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
||||
{...register('website_url')}
|
||||
placeholder="https://..."
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{fieldHints.websiteUrl}</p>
|
||||
{errors.website_url && (
|
||||
<p className="text-sm text-destructive">{errors.website_url.message}</p>
|
||||
)}
|
||||
@@ -602,6 +643,7 @@ export function ParkForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
||||
{...register('phone')}
|
||||
placeholder="+1 (555) 123-4567"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{fieldHints.phone}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
@@ -612,6 +654,7 @@ export function ParkForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
||||
{...register('email')}
|
||||
placeholder="contact@park.com"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{fieldHints.email}</p>
|
||||
{errors.email && (
|
||||
<p className="text-sm text-destructive">{errors.email.message}</p>
|
||||
)}
|
||||
@@ -643,7 +686,7 @@ export function ParkForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
||||
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)
|
||||
{fieldHints.sourceUrl}
|
||||
</p>
|
||||
{errors.source_url && (
|
||||
<p className="text-sm text-destructive">{errors.source_url.message}</p>
|
||||
@@ -665,7 +708,7 @@ export function ParkForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
||||
maxLength={1000}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{watch('submission_notes')?.length || 0}/1000 characters
|
||||
{fieldHints.submissionNotes} ({watch('submission_notes')?.length || 0}/1000 characters)
|
||||
</p>
|
||||
{errors.submission_notes && (
|
||||
<p className="text-sm text-destructive">{errors.submission_notes.message}</p>
|
||||
@@ -704,6 +747,7 @@ export function ParkForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</TooltipProvider>
|
||||
|
||||
{/* Operator Modal */}
|
||||
<Dialog open={isOperatorModalOpen} onOpenChange={setIsOperatorModalOpen}>
|
||||
|
||||
@@ -16,8 +16,9 @@ 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 { toast } from '@/hooks/use-toast';
|
||||
import { handleError, getErrorMessage } from '@/lib/errorHandler';
|
||||
import { formToasts } from '@/lib/formToasts';
|
||||
import type { UploadedImage } from '@/types/company';
|
||||
|
||||
// Zod output type (after transformation)
|
||||
@@ -73,7 +74,7 @@ export function PropertyOwnerForm({ onSubmit, onCancel, initialData }: PropertyO
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit(async (data) => {
|
||||
if (!user) {
|
||||
toast.error('You must be logged in to submit');
|
||||
formToasts.error.generic('You must be logged in to submit');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -93,9 +94,11 @@ export function PropertyOwnerForm({ onSubmit, onCancel, initialData }: PropertyO
|
||||
|
||||
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');
|
||||
// Show success toast
|
||||
if (initialData?.id) {
|
||||
formToasts.success.update('Property Owner', data.name);
|
||||
} else {
|
||||
formToasts.success.create('Property Owner', data.name);
|
||||
onCancel();
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
@@ -104,6 +107,9 @@ export function PropertyOwnerForm({ onSubmit, onCancel, initialData }: PropertyO
|
||||
metadata: { companyName: data.name }
|
||||
});
|
||||
|
||||
// Show error toast
|
||||
formToasts.error.generic(getErrorMessage(error));
|
||||
|
||||
// Re-throw so parent can handle modal closing
|
||||
throw error;
|
||||
} finally {
|
||||
|
||||
@@ -21,9 +21,11 @@ import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } f
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import { SlugField } from '@/components/ui/slug-field';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { toast } from '@/hooks/use-toast';
|
||||
import { handleError } from '@/lib/errorHandler';
|
||||
import { Plus, Zap, Save, X, Building2, AlertCircle } from 'lucide-react';
|
||||
import { formToasts } from '@/lib/formToasts';
|
||||
import { Plus, Zap, Save, X, Building2, AlertCircle, Info, HelpCircle } from 'lucide-react';
|
||||
import { toDateOnly, parseDateOnly, toDateWithPrecision } from '@/lib/dateUtils';
|
||||
import { useUnitPreferences } from '@/hooks/useUnitPreferences';
|
||||
import { useManufacturers, useRideModels, useParks } from '@/hooks/useAutocompleteData';
|
||||
@@ -34,6 +36,10 @@ import { ParkForm } from './ParkForm';
|
||||
import { TechnicalSpecsEditor, validateTechnicalSpecs } from './editors/TechnicalSpecsEditor';
|
||||
import { CoasterStatsEditor, validateCoasterStats } from './editors/CoasterStatsEditor';
|
||||
import { FormerNamesEditor } from './editors/FormerNamesEditor';
|
||||
import { SubmissionHelpDialog } from '@/components/help/SubmissionHelpDialog';
|
||||
import { TerminologyDialog } from '@/components/help/TerminologyDialog';
|
||||
import { TermTooltip } from '@/components/ui/term-tooltip';
|
||||
import { fieldHints } from '@/lib/enhancedValidation';
|
||||
import {
|
||||
convertValueToMetric,
|
||||
convertValueFromMetric,
|
||||
@@ -227,9 +233,9 @@ export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
||||
ride_sub_type: initialData?.ride_sub_type || '',
|
||||
status: initialData?.status || 'operating' as const, // Store DB value directly
|
||||
opening_date: initialData?.opening_date || undefined,
|
||||
opening_date_precision: initialData?.opening_date_precision || 'day',
|
||||
opening_date_precision: initialData?.opening_date_precision || 'exact',
|
||||
closing_date: initialData?.closing_date || undefined,
|
||||
closing_date_precision: initialData?.closing_date_precision || 'day',
|
||||
closing_date_precision: initialData?.closing_date_precision || 'exact',
|
||||
// Convert metric values to user's preferred unit for display
|
||||
height_requirement: initialData?.height_requirement
|
||||
? convertValueFromMetric(initialData.height_requirement, getDisplayUnit('cm', measurementSystem), 'cm')
|
||||
@@ -355,14 +361,14 @@ export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
||||
// Pass clean data to parent with extended fields
|
||||
await onSubmit(metricData);
|
||||
|
||||
toast({
|
||||
title: isEditing ? "Ride Updated" : "Submission Sent",
|
||||
description: isEditing
|
||||
? "The ride information has been updated successfully."
|
||||
: tempNewManufacturer
|
||||
? "Ride, manufacturer, and model submitted for review"
|
||||
: "Ride submitted for review"
|
||||
});
|
||||
// Show success toast
|
||||
if (isModerator()) {
|
||||
formToasts.success.moderatorApproval('Ride', data.name);
|
||||
} else if (isEditing) {
|
||||
formToasts.success.update('Ride', data.name);
|
||||
} else {
|
||||
formToasts.success.create('Ride', data.name);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
handleError(error, {
|
||||
action: isEditing ? 'Update Ride' : 'Create Ride',
|
||||
@@ -373,6 +379,9 @@ export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
||||
}
|
||||
});
|
||||
|
||||
// Show error toast
|
||||
formToasts.error.generic(getErrorMessage(error));
|
||||
|
||||
// Re-throw so parent can handle modal closing
|
||||
throw error;
|
||||
} finally {
|
||||
@@ -381,15 +390,22 @@ export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-4xl mx-auto">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Zap className="w-5 h-5" />
|
||||
{isEditing ? 'Edit Ride' : 'Create New Ride'}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-6">
|
||||
<TooltipProvider>
|
||||
<Card className="w-full max-w-4xl mx-auto">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Zap className="w-5 h-5" />
|
||||
{isEditing ? 'Edit Ride' : 'Create New Ride'}
|
||||
</CardTitle>
|
||||
<div className="flex gap-2">
|
||||
<TerminologyDialog />
|
||||
<SubmissionHelpDialog type="ride" variant="icon" />
|
||||
</div>
|
||||
</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">
|
||||
@@ -529,6 +545,10 @@ export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex items-start gap-2 text-xs text-muted-foreground">
|
||||
<Info className="h-3.5 w-3.5 mt-0.5 flex-shrink-0" />
|
||||
<p>Primary ride type. Choose roller coaster for any coaster, flat ride for spinners/swings, water ride for flumes/rapids.</p>
|
||||
</div>
|
||||
{errors.category && (
|
||||
<p className="text-sm text-destructive">{errors.category.message}</p>
|
||||
)}
|
||||
@@ -541,6 +561,10 @@ export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
||||
{...register('ride_sub_type')}
|
||||
placeholder="e.g. Inverted Coaster, Log Flume"
|
||||
/>
|
||||
<div className="flex items-start gap-2 text-xs text-muted-foreground">
|
||||
<Info className="h-3.5 w-3.5 mt-0.5 flex-shrink-0" />
|
||||
<p>Specific type within category (e.g., "Inverted Coaster", "Flume").</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
@@ -563,6 +587,10 @@ export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex items-start gap-2 text-xs text-muted-foreground">
|
||||
<Info className="h-3.5 w-3.5 mt-0.5 flex-shrink-0" />
|
||||
<p>Current state. Use "Relocated" if moved to another park.</p>
|
||||
</div>
|
||||
{errors.status && (
|
||||
<p className="text-sm text-destructive">{errors.status.message}</p>
|
||||
)}
|
||||
@@ -572,6 +600,10 @@ export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
||||
{/* Manufacturer & Model Selection */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">Manufacturer & Model</h3>
|
||||
<div className="flex items-start gap-2 text-xs text-muted-foreground mb-3">
|
||||
<Info className="h-3.5 w-3.5 mt-0.5 flex-shrink-0" />
|
||||
<p>The company that built the ride. Model is the specific product line (e.g., "B&M" makes "Inverted Coaster" models).</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Manufacturer Column */}
|
||||
@@ -711,7 +743,7 @@ export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
||||
<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'}
|
||||
precision={(watch('opening_date_precision') as DatePrecision) || 'exact'}
|
||||
onChange={(date, precision) => {
|
||||
setValue('opening_date', date ? toDateWithPrecision(date, precision) : undefined);
|
||||
setValue('opening_date_precision', precision);
|
||||
@@ -724,7 +756,7 @@ export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
||||
|
||||
<FlexibleDateInput
|
||||
value={watch('closing_date') ? parseDateOnly(watch('closing_date')!) : undefined}
|
||||
precision={(watch('closing_date_precision') as DatePrecision) || 'day'}
|
||||
precision={(watch('closing_date_precision') as DatePrecision) || 'exact'}
|
||||
onChange={(date, precision) => {
|
||||
setValue('closing_date', date ? toDateWithPrecision(date, precision) : undefined);
|
||||
setValue('closing_date_precision', precision);
|
||||
@@ -747,6 +779,7 @@ export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
||||
{...register('height_requirement', { setValueAs: (v) => v === "" ? undefined : parseFloat(v) })}
|
||||
placeholder={measurementSystem === 'imperial' ? 'e.g. 47' : 'e.g. 120'}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{fieldHints.heightRequirement}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
@@ -758,6 +791,10 @@ export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
||||
{...register('age_requirement', { setValueAs: (v) => v === "" ? undefined : parseFloat(v) })}
|
||||
placeholder="e.g. 8"
|
||||
/>
|
||||
<div className="flex items-start gap-2 text-xs text-muted-foreground">
|
||||
<Info className="h-3.5 w-3.5 mt-0.5 flex-shrink-0" />
|
||||
<p>Minimum age in years, if different from height requirement.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -765,6 +802,10 @@ export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
||||
{selectedCategory === 'roller_coaster' && (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">Roller Coaster Details</h3>
|
||||
<div className="flex items-start gap-2 text-xs text-muted-foreground mb-3">
|
||||
<Info className="h-3.5 w-3.5 mt-0.5 flex-shrink-0" />
|
||||
<p>Specific attributes for roller coasters. Track/support materials help classify hybrid coasters.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="space-y-2">
|
||||
@@ -816,8 +857,16 @@ export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label>Track Material(s)</Label>
|
||||
<p className="text-sm text-muted-foreground">Select all materials used in the track</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label>
|
||||
<TermTooltip term="ibox-track" showIcon={false}>
|
||||
Track Material(s)
|
||||
</TermTooltip>
|
||||
</Label>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Common: <TermTooltip term="ibox-track" inline>Steel</TermTooltip>, Wood, <TermTooltip term="hybrid-coaster" inline>Hybrid (RMC IBox)</TermTooltip>
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{TRACK_MATERIALS.map((material) => (
|
||||
<div key={material.value} className="flex items-center space-x-2">
|
||||
@@ -842,8 +891,12 @@ export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label>Support Material(s)</Label>
|
||||
<p className="text-sm text-muted-foreground">Select all materials used in the supports</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label>Support Material(s)</Label>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Materials used for support structure (can differ from track)
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{SUPPORT_MATERIALS.map((material) => (
|
||||
<div key={material.value} className="flex items-center space-x-2">
|
||||
@@ -868,8 +921,16 @@ export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label>Propulsion Method(s)</Label>
|
||||
<p className="text-sm text-muted-foreground">Select all propulsion methods used</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label>
|
||||
<TermTooltip term="lsm" showIcon={false}>
|
||||
Propulsion Method(s)
|
||||
</TermTooltip>
|
||||
</Label>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Common: <TermTooltip term="lsm" inline>LSM Launch</TermTooltip>, <TermTooltip term="chain-lift" inline>Chain Lift</TermTooltip>, <TermTooltip term="hydraulic-launch" inline>Hydraulic Launch</TermTooltip>
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{PROPULSION_METHODS.map((method) => (
|
||||
<div key={method.value} className="flex items-center space-x-2">
|
||||
@@ -1310,6 +1371,7 @@ export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
||||
{...register('capacity_per_hour', { setValueAs: (v) => v === "" ? undefined : parseFloat(v) })}
|
||||
placeholder="e.g. 1200"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{fieldHints.capacity}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
@@ -1321,6 +1383,7 @@ export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
||||
{...register('duration_seconds', { setValueAs: (v) => v === "" ? undefined : parseFloat(v) })}
|
||||
placeholder="e.g. 180"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{fieldHints.duration}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
@@ -1333,6 +1396,7 @@ export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
||||
{...register('max_speed_kmh', { setValueAs: (v) => v === "" ? undefined : parseFloat(v) })}
|
||||
placeholder={measurementSystem === 'imperial' ? 'e.g. 50' : 'e.g. 80.5'}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{fieldHints.speed}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
@@ -1368,6 +1432,7 @@ export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
||||
{...register('inversions', { setValueAs: (v) => v === "" ? undefined : parseFloat(v) })}
|
||||
placeholder="e.g. 7"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{fieldHints.inversions}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1421,7 +1486,7 @@ export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
||||
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)
|
||||
{fieldHints.sourceUrl}
|
||||
</p>
|
||||
{errors.source_url && (
|
||||
<p className="text-sm text-destructive">{errors.source_url.message}</p>
|
||||
@@ -1443,7 +1508,7 @@ export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
||||
maxLength={1000}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{watch('submission_notes')?.length || 0}/1000 characters
|
||||
{fieldHints.submissionNotes} ({watch('submission_notes')?.length || 0}/1000 characters)
|
||||
</p>
|
||||
{errors.submission_notes && (
|
||||
<p className="text-sm text-destructive">{errors.submission_notes.message}</p>
|
||||
@@ -1574,5 +1639,6 @@ export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
||||
</Dialog>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,8 @@ 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 { toast } from '@/hooks/use-toast';
|
||||
import { formToasts } from '@/lib/formToasts';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -112,12 +113,21 @@ export function RideModelForm({
|
||||
manufacturer_id: manufacturerId,
|
||||
_technical_specifications: technicalSpecs
|
||||
});
|
||||
toast.success('Ride model submitted for review');
|
||||
|
||||
// Show success toast
|
||||
if (initialData?.id) {
|
||||
formToasts.success.update('Ride Model', data.name);
|
||||
} else {
|
||||
formToasts.success.create('Ride Model', data.name);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
handleError(error, {
|
||||
action: initialData?.id ? 'Update Ride Model' : 'Create Ride Model'
|
||||
});
|
||||
|
||||
// Show error toast
|
||||
formToasts.error.generic(getErrorMessage(error));
|
||||
|
||||
// Re-throw so parent can handle modal closing
|
||||
throw error;
|
||||
} finally {
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { Plus, Trash2, HelpCircle } 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 { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useUnitPreferences } from "@/hooks/useUnitPreferences";
|
||||
import { toast } from "sonner";
|
||||
import { fieldHints } from "@/lib/enhancedValidation";
|
||||
import {
|
||||
convertValueToMetric,
|
||||
convertValueFromMetric,
|
||||
@@ -126,14 +128,25 @@ export function TechnicalSpecsEditor({
|
||||
};
|
||||
|
||||
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>
|
||||
<TooltipProvider>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label>Technical Specifications</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<HelpCircle className="h-4 w-4 text-muted-foreground cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">
|
||||
<p>Add custom specifications like track material (Steel, Wood), propulsion method (LSM Launch, Chain Lift), train type, etc. Use metric units only.</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<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">
|
||||
@@ -145,7 +158,24 @@ export function TechnicalSpecsEditor({
|
||||
<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>
|
||||
<div className="flex items-center gap-1">
|
||||
<Label className="text-xs">Specification Name</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<HelpCircle className="h-3 w-3 text-muted-foreground cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">
|
||||
<p className="font-semibold mb-1">Examples:</p>
|
||||
<ul className="text-xs space-y-1">
|
||||
<li>• Track Material (Steel/Wood)</li>
|
||||
<li>• Propulsion Method (LSM Launch, Chain Lift)</li>
|
||||
<li>• Train Type (Sit-down, Inverted)</li>
|
||||
<li>• Restraint System (Lap bar, OTSR)</li>
|
||||
<li>• Launch Speed (km/h)</li>
|
||||
</ul>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Input
|
||||
value={spec.spec_name}
|
||||
onChange={(e) => updateSpec(index, 'spec_name', e.target.value)}
|
||||
@@ -189,7 +219,22 @@ export function TechnicalSpecsEditor({
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs">Type</Label>
|
||||
<div className="flex items-center gap-1">
|
||||
<Label className="text-xs">Type</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<HelpCircle className="h-3 w-3 text-muted-foreground cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">
|
||||
<ul className="text-xs space-y-1">
|
||||
<li>• <strong>Text:</strong> Material names, methods (e.g., "Steel", "LSM Launch")</li>
|
||||
<li>• <strong>Number:</strong> Measurements with units (e.g., speed, length)</li>
|
||||
<li>• <strong>Yes/No:</strong> Features (e.g., "Has VR")</li>
|
||||
<li>• <strong>Date:</strong> Installation dates</li>
|
||||
</ul>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Select
|
||||
value={spec.spec_type}
|
||||
onValueChange={(value) => updateSpec(index, 'spec_type', value)}
|
||||
@@ -225,7 +270,23 @@ export function TechnicalSpecsEditor({
|
||||
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex-1">
|
||||
<Label className="text-xs">Unit</Label>
|
||||
<div className="flex items-center gap-1">
|
||||
<Label className="text-xs">Unit</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<HelpCircle className="h-3 w-3 text-muted-foreground cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">
|
||||
<p className="font-semibold mb-1">Metric units only:</p>
|
||||
<ul className="text-xs space-y-1">
|
||||
<li>• Speed: km/h (not mph)</li>
|
||||
<li>• Distance: m, km, cm (not ft, mi, in)</li>
|
||||
<li>• Weight: kg, g (not lb, oz)</li>
|
||||
<li>• Leave empty for text values</li>
|
||||
</ul>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Input
|
||||
value={spec.unit || ''}
|
||||
onChange={(e) => updateSpec(index, 'unit', e.target.value)}
|
||||
@@ -257,7 +318,8 @@ export function TechnicalSpecsEditor({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,11 @@ class AnalyticsErrorBoundary extends Component<
|
||||
}
|
||||
|
||||
export function AnalyticsWrapper() {
|
||||
// Disable analytics in development to reduce console noise
|
||||
if (import.meta.env.DEV) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<AnalyticsErrorBoundary>
|
||||
<Analytics />
|
||||
|
||||
278
src/components/examples/FormFieldWrapperDemo.tsx
Normal file
278
src/components/examples/FormFieldWrapperDemo.tsx
Normal file
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* FormFieldWrapper Live Demo
|
||||
*
|
||||
* This component demonstrates the FormFieldWrapper in action
|
||||
* You can view this by navigating to /examples/form-field-wrapper
|
||||
*/
|
||||
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { FormFieldWrapper, formFieldPresets } from '@/components/ui/form-field-wrapper';
|
||||
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
|
||||
export function FormFieldWrapperDemo() {
|
||||
const { register, formState: { errors }, watch, handleSubmit } = useForm();
|
||||
|
||||
const onSubmit = (data: any) => {
|
||||
console.log('Form submitted:', data);
|
||||
alert('Check console for form data');
|
||||
};
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="container mx-auto py-8 max-w-4xl">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>FormFieldWrapper Demo</CardTitle>
|
||||
<CardDescription>
|
||||
Interactive demonstration of the unified form field component
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Tabs defaultValue="basic">
|
||||
<TabsList className="grid w-full grid-cols-4">
|
||||
<TabsTrigger value="basic">Basic</TabsTrigger>
|
||||
<TabsTrigger value="terminology">Terminology</TabsTrigger>
|
||||
<TabsTrigger value="presets">Presets</TabsTrigger>
|
||||
<TabsTrigger value="advanced">Advanced</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6 mt-6">
|
||||
{/* Basic Examples */}
|
||||
<TabsContent value="basic" className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">Basic Field Types</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
These fields automatically show appropriate hints and validation
|
||||
</p>
|
||||
|
||||
<FormFieldWrapper
|
||||
id="website_url"
|
||||
label="Website URL"
|
||||
fieldType="url"
|
||||
error={errors.website_url?.message as string}
|
||||
inputProps={{
|
||||
...register('website_url'),
|
||||
placeholder: "https://example.com"
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormFieldWrapper
|
||||
id="email"
|
||||
label="Email Address"
|
||||
fieldType="email"
|
||||
required
|
||||
error={errors.email?.message as string}
|
||||
inputProps={{
|
||||
...register('email', { required: 'Email is required' }),
|
||||
placeholder: "contact@example.com"
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormFieldWrapper
|
||||
id="phone"
|
||||
label="Phone Number"
|
||||
fieldType="phone"
|
||||
error={errors.phone?.message as string}
|
||||
inputProps={{
|
||||
...register('phone'),
|
||||
placeholder: "+1 (555) 123-4567"
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Terminology Examples */}
|
||||
<TabsContent value="terminology" className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">Fields with Terminology</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Hover over labels with icons to see terminology definitions
|
||||
</p>
|
||||
|
||||
<FormFieldWrapper
|
||||
id="inversions"
|
||||
label="Inversions"
|
||||
fieldType="inversions"
|
||||
termKey="inversion"
|
||||
error={errors.inversions?.message as string}
|
||||
inputProps={{
|
||||
...register('inversions'),
|
||||
type: "number",
|
||||
min: 0,
|
||||
placeholder: "e.g. 7"
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormFieldWrapper
|
||||
id="max_speed"
|
||||
label="Max Speed (km/h)"
|
||||
fieldType="speed"
|
||||
termKey="kilometers-per-hour"
|
||||
error={errors.max_speed?.message as string}
|
||||
inputProps={{
|
||||
...register('max_speed'),
|
||||
type: "number",
|
||||
min: 0,
|
||||
step: 0.1,
|
||||
placeholder: "e.g. 193"
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormFieldWrapper
|
||||
id="max_height"
|
||||
label="Max Height (meters)"
|
||||
fieldType="height"
|
||||
termKey="meters"
|
||||
error={errors.max_height?.message as string}
|
||||
inputProps={{
|
||||
...register('max_height'),
|
||||
type: "number",
|
||||
min: 0,
|
||||
step: 0.1,
|
||||
placeholder: "e.g. 94"
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Preset Examples */}
|
||||
<TabsContent value="presets" className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">Using Presets</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Common field configurations with one-line setup
|
||||
</p>
|
||||
|
||||
<FormFieldWrapper
|
||||
{...formFieldPresets.sourceUrl({})}
|
||||
id="source_url"
|
||||
error={errors.source_url?.message as string}
|
||||
inputProps={{
|
||||
...register('source_url'),
|
||||
placeholder: "https://source.com/article"
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormFieldWrapper
|
||||
{...formFieldPresets.heightRequirement({})}
|
||||
id="height_requirement"
|
||||
error={errors.height_requirement?.message as string}
|
||||
inputProps={{
|
||||
...register('height_requirement'),
|
||||
type: "number",
|
||||
min: 0,
|
||||
placeholder: "122"
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormFieldWrapper
|
||||
{...formFieldPresets.capacity({})}
|
||||
id="capacity"
|
||||
error={errors.capacity?.message as string}
|
||||
inputProps={{
|
||||
...register('capacity'),
|
||||
type: "number",
|
||||
min: 0,
|
||||
placeholder: "1200"
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Advanced Examples */}
|
||||
<TabsContent value="advanced" className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">Advanced Features</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Textareas, character counting, and custom hints
|
||||
</p>
|
||||
|
||||
<FormFieldWrapper
|
||||
{...formFieldPresets.submissionNotes({})}
|
||||
id="submission_notes"
|
||||
value={watch('submission_notes')}
|
||||
error={errors.submission_notes?.message as string}
|
||||
textareaProps={{
|
||||
...register('submission_notes', {
|
||||
maxLength: { value: 1000, message: 'Maximum 1000 characters' }
|
||||
}),
|
||||
placeholder: "Add context for moderators...",
|
||||
rows: 4
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormFieldWrapper
|
||||
id="custom_field"
|
||||
label="Custom Field with Override"
|
||||
fieldType="text"
|
||||
hint="This is a custom hint that overrides any automatic hint"
|
||||
error={errors.custom_field?.message as string}
|
||||
inputProps={{
|
||||
...register('custom_field'),
|
||||
placeholder: "Enter custom value"
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormFieldWrapper
|
||||
id="no_hint_field"
|
||||
label="Field Without Hint"
|
||||
fieldType="url"
|
||||
hideHint
|
||||
error={errors.no_hint_field?.message as string}
|
||||
inputProps={{
|
||||
...register('no_hint_field'),
|
||||
placeholder: "https://"
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<Button type="submit" className="w-full">
|
||||
Submit Form (Check Console)
|
||||
</Button>
|
||||
</form>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Benefits Card */}
|
||||
<Card className="mt-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Benefits</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-green-500">✓</span>
|
||||
<span><strong>Consistency:</strong> All fields follow the same structure and styling</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-green-500">✓</span>
|
||||
<span><strong>Less Code:</strong> ~50% reduction in form field boilerplate</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-green-500">✓</span>
|
||||
<span><strong>Smart Defaults:</strong> Automatic hints based on field type</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-green-500">✓</span>
|
||||
<span><strong>Built-in Terminology:</strong> Hover tooltips for technical terms</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-green-500">✓</span>
|
||||
<span><strong>Easy Updates:</strong> Change hints in one place, updates everywhere</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-green-500">✓</span>
|
||||
<span><strong>Type Safety:</strong> TypeScript ensures correct usage</span>
|
||||
</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -102,11 +102,11 @@ export function TimeZoneIndependentDateRangePicker({
|
||||
if (!fromDate && !toDate) return null;
|
||||
|
||||
if (fromDate && toDate) {
|
||||
return `${formatDateDisplay(fromDate, 'day')} - ${formatDateDisplay(toDate, 'day')}`;
|
||||
return `${formatDateDisplay(fromDate, 'exact')} - ${formatDateDisplay(toDate, 'exact')}`;
|
||||
} else if (fromDate) {
|
||||
return `From ${formatDateDisplay(fromDate, 'day')}`;
|
||||
return `From ${formatDateDisplay(fromDate, 'exact')}`;
|
||||
} else if (toDate) {
|
||||
return `Until ${formatDateDisplay(toDate, 'day')}`;
|
||||
return `Until ${formatDateDisplay(toDate, 'exact')}`;
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
385
src/components/help/SubmissionHelpDialog.tsx
Normal file
385
src/components/help/SubmissionHelpDialog.tsx
Normal file
@@ -0,0 +1,385 @@
|
||||
import { HelpCircle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
|
||||
interface SubmissionHelpDialogProps {
|
||||
type: 'park' | 'ride';
|
||||
variant?: 'button' | 'icon';
|
||||
}
|
||||
|
||||
export function SubmissionHelpDialog({ type, variant = 'button' }: SubmissionHelpDialogProps) {
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
{variant === 'button' ? (
|
||||
<Button type="button" variant="outline" size="sm">
|
||||
<HelpCircle className="h-4 w-4 mr-2" />
|
||||
Submission Guide
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="button" variant="ghost" size="icon">
|
||||
<HelpCircle className="h-5 w-5" />
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-3xl max-h-[90vh]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{type === 'park' ? 'Park' : 'Ride'} Submission Guide
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Everything you need to know about submitting {type === 'park' ? 'parks' : 'rides'} to ThrillWiki
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="h-[60vh] pr-4">
|
||||
<Accordion type="multiple" className="w-full">
|
||||
{/* Date Precision */}
|
||||
<AccordionItem value="date-precision">
|
||||
<AccordionTrigger>Date Precision Options</AccordionTrigger>
|
||||
<AccordionContent className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Choose how precise your date information is. This helps maintain accuracy when exact dates aren't known.
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="border-l-2 border-primary pl-3">
|
||||
<p className="font-semibold text-sm">Exact Day</p>
|
||||
<p className="text-xs text-muted-foreground">Use when you know the specific date (e.g., June 15, 2010)</p>
|
||||
<Badge variant="secondary" className="text-xs mt-1">Example: Opening day announcement</Badge>
|
||||
</div>
|
||||
|
||||
<div className="border-l-2 border-primary pl-3">
|
||||
<p className="font-semibold text-sm">Month & Year</p>
|
||||
<p className="text-xs text-muted-foreground">Use when you only know the month (e.g., June 2010)</p>
|
||||
<Badge variant="secondary" className="text-xs mt-1">Example: "Opened in summer 2010"</Badge>
|
||||
</div>
|
||||
|
||||
<div className="border-l-2 border-primary pl-3">
|
||||
<p className="font-semibold text-sm">Year Only</p>
|
||||
<p className="text-xs text-muted-foreground">Use when you only know the year (e.g., 2010)</p>
|
||||
<Badge variant="secondary" className="text-xs mt-1">Example: Historical records show "1985"</Badge>
|
||||
</div>
|
||||
|
||||
<div className="border-l-2 border-primary pl-3">
|
||||
<p className="font-semibold text-sm">Decade</p>
|
||||
<p className="text-xs text-muted-foreground">Use for events in a general decade (e.g., 1980s)</p>
|
||||
<Badge variant="secondary" className="text-xs mt-1">Example: "Built in the early 1970s"</Badge>
|
||||
</div>
|
||||
|
||||
<div className="border-l-2 border-primary pl-3">
|
||||
<p className="font-semibold text-sm">Century</p>
|
||||
<p className="text-xs text-muted-foreground">Use for very old dates spanning a century</p>
|
||||
<Badge variant="secondary" className="text-xs mt-1">Example: "19th century fairground"</Badge>
|
||||
</div>
|
||||
|
||||
<div className="border-l-2 border-primary pl-3">
|
||||
<p className="font-semibold text-sm">Approximate</p>
|
||||
<p className="text-xs text-muted-foreground">Use when the date is uncertain or estimated</p>
|
||||
<Badge variant="secondary" className="text-xs mt-1">Example: "circa 2005"</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
{type === 'park' && (
|
||||
<>
|
||||
{/* Park Types */}
|
||||
<AccordionItem value="park-types">
|
||||
<AccordionTrigger>Park Types Explained</AccordionTrigger>
|
||||
<AccordionContent className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<div className="border-l-2 border-primary pl-3">
|
||||
<p className="font-semibold text-sm">Theme Park</p>
|
||||
<p className="text-xs text-muted-foreground">Has distinct themed areas with immersive experiences and storytelling</p>
|
||||
<Badge variant="secondary" className="text-xs mt-1">Examples: Disneyland, Universal Studios</Badge>
|
||||
</div>
|
||||
|
||||
<div className="border-l-2 border-primary pl-3">
|
||||
<p className="font-semibold text-sm">Amusement Park</p>
|
||||
<p className="text-xs text-muted-foreground">Focuses on rides and attractions without heavy theming</p>
|
||||
<Badge variant="secondary" className="text-xs mt-1">Examples: Cedar Point, Six Flags</Badge>
|
||||
</div>
|
||||
|
||||
<div className="border-l-2 border-primary pl-3">
|
||||
<p className="font-semibold text-sm">Water Park</p>
|
||||
<p className="text-xs text-muted-foreground">Water-based attractions like slides, wave pools, lazy rivers</p>
|
||||
<Badge variant="secondary" className="text-xs mt-1">Examples: Schlitterbahn, Aquatica</Badge>
|
||||
</div>
|
||||
|
||||
<div className="border-l-2 border-primary pl-3">
|
||||
<p className="font-semibold text-sm">Family Entertainment Center</p>
|
||||
<p className="text-xs text-muted-foreground">Indoor facilities with arcade games, mini golf, go-karts</p>
|
||||
<Badge variant="secondary" className="text-xs mt-1">Examples: Chuck E. Cheese, Dave & Buster's</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
{/* Operator vs Owner */}
|
||||
<AccordionItem value="operator-owner">
|
||||
<AccordionTrigger>Operator vs. Property Owner</AccordionTrigger>
|
||||
<AccordionContent className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<div className="border-l-2 border-green-500 pl-3">
|
||||
<p className="font-semibold text-sm">Operator</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The company that runs day-to-day operations, manages staff, and operates the park
|
||||
</p>
|
||||
<Badge variant="secondary" className="text-xs mt-1">Example: Six Flags operates many parks</Badge>
|
||||
</div>
|
||||
|
||||
<div className="border-l-2 border-blue-500 pl-3">
|
||||
<p className="font-semibold text-sm">Property Owner</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The entity that owns the land and physical property
|
||||
</p>
|
||||
<Badge variant="secondary" className="text-xs mt-1">Example: Real estate investment company</Badge>
|
||||
</div>
|
||||
|
||||
<div className="bg-muted p-3 rounded-md mt-3">
|
||||
<p className="font-semibold text-sm mb-1">💡 Pro Tip</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Often the operator and owner are the same company (check the "Operator is also the property owner" box).
|
||||
But sometimes they're different - for example, a park might lease land from a property owner.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</>
|
||||
)}
|
||||
|
||||
{type === 'ride' && (
|
||||
<>
|
||||
{/* Ride Categories */}
|
||||
<AccordionItem value="ride-categories">
|
||||
<AccordionTrigger>Ride Categories</AccordionTrigger>
|
||||
<AccordionContent className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<div className="border-l-2 border-primary pl-3">
|
||||
<p className="font-semibold text-sm">Roller Coaster</p>
|
||||
<p className="text-xs text-muted-foreground">Any type of coaster with a track and gravity-based movement</p>
|
||||
<Badge variant="secondary" className="text-xs mt-1">Includes: Steel, Wood, Inverted, Flying</Badge>
|
||||
</div>
|
||||
|
||||
<div className="border-l-2 border-primary pl-3">
|
||||
<p className="font-semibold text-sm">Flat Ride</p>
|
||||
<p className="text-xs text-muted-foreground">Spinning, swinging, or rotating rides at ground level</p>
|
||||
<Badge variant="secondary" className="text-xs mt-1">Examples: Tilt-A-Whirl, Scrambler, Top Spin</Badge>
|
||||
</div>
|
||||
|
||||
<div className="border-l-2 border-primary pl-3">
|
||||
<p className="font-semibold text-sm">Water Ride</p>
|
||||
<p className="text-xs text-muted-foreground">Rides involving water, splashing, or getting wet</p>
|
||||
<Badge variant="secondary" className="text-xs mt-1">Examples: Log Flume, River Rapids, Shoot-the-Chute</Badge>
|
||||
</div>
|
||||
|
||||
<div className="border-l-2 border-primary pl-3">
|
||||
<p className="font-semibold text-sm">Dark Ride</p>
|
||||
<p className="text-xs text-muted-foreground">Indoor rides with controlled lighting and theming</p>
|
||||
<Badge variant="secondary" className="text-xs mt-1">Examples: Haunted Mansion, Pirates of the Caribbean</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
{/* Manufacturer vs Designer */}
|
||||
<AccordionItem value="manufacturer-designer">
|
||||
<AccordionTrigger>Manufacturer vs. Designer</AccordionTrigger>
|
||||
<AccordionContent className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<div className="border-l-2 border-green-500 pl-3">
|
||||
<p className="font-semibold text-sm">Manufacturer</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The company that physically built and engineered the ride
|
||||
</p>
|
||||
<Badge variant="secondary" className="text-xs mt-1">Examples: Intamin, B&M, Vekoma, RMC</Badge>
|
||||
</div>
|
||||
|
||||
<div className="border-l-2 border-blue-500 pl-3">
|
||||
<p className="font-semibold text-sm">Designer (Optional)</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The design firm or consultant that created the ride concept and layout
|
||||
</p>
|
||||
<Badge variant="secondary" className="text-xs mt-1">Examples: Werner Stengel, Ride Centerline</Badge>
|
||||
</div>
|
||||
|
||||
<div className="bg-muted p-3 rounded-md mt-3">
|
||||
<p className="font-semibold text-sm mb-1">💡 Pro Tip</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Most rides only need a manufacturer. Add a designer only if they're notably different
|
||||
(e.g., Werner Stengel designed layouts for many B&M coasters).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
{/* Technical Specs */}
|
||||
<AccordionItem value="technical-specs">
|
||||
<AccordionTrigger>Technical Specifications</AccordionTrigger>
|
||||
<AccordionContent className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Add custom specifications beyond the standard fields. Use for unique features.
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="border-l-2 border-primary pl-3">
|
||||
<p className="font-semibold text-sm">Common Spec Examples</p>
|
||||
<ul className="text-xs text-muted-foreground space-y-1 mt-1">
|
||||
<li>• Track Material: "Steel" or "Wood"</li>
|
||||
<li>• Propulsion Method: "LSM Launch", "Chain Lift"</li>
|
||||
<li>• Train Type: "Sit-down", "Inverted", "Flying"</li>
|
||||
<li>• Restraint System: "Lap bar", "Over-shoulder"</li>
|
||||
<li>• Number of Trains: "3"</li>
|
||||
<li>• Riders per Train: "28"</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="bg-destructive/10 border border-destructive/20 p-3 rounded-md">
|
||||
<p className="font-semibold text-sm mb-1 text-destructive">⚠️ Important: Metric Units Only</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
All measurements must use metric units (km/h, m, cm, kg). The system will convert
|
||||
them to your preferred units for display. Examples: "km/h" not "mph", "m" not "ft"
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Units and Measurements */}
|
||||
<AccordionItem value="units">
|
||||
<AccordionTrigger>Units and Measurements</AccordionTrigger>
|
||||
<AccordionContent className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
ThrillWiki stores all measurements in metric units but displays them in your preferred system.
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="bg-muted p-3 rounded-md">
|
||||
<p className="font-semibold text-sm mb-2">How It Works</p>
|
||||
<ol className="text-xs text-muted-foreground space-y-1 list-decimal list-inside">
|
||||
<li>Enter values in YOUR preferred units (metric or imperial)</li>
|
||||
<li>System automatically converts to metric for storage</li>
|
||||
<li>Data displays in each user's preferred unit system</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div className="border-l-2 border-primary pl-3">
|
||||
<p className="font-semibold text-sm">Speed</p>
|
||||
<p className="text-xs text-muted-foreground">Enter in km/h or mph (auto-converts)</p>
|
||||
<Badge variant="secondary" className="text-xs mt-1">Example: 120 km/h = 74.6 mph</Badge>
|
||||
</div>
|
||||
|
||||
<div className="border-l-2 border-primary pl-3">
|
||||
<p className="font-semibold text-sm">Height / Length</p>
|
||||
<p className="text-xs text-muted-foreground">Enter in meters or feet (auto-converts)</p>
|
||||
<Badge variant="secondary" className="text-xs mt-1">Example: 50m = 164ft</Badge>
|
||||
</div>
|
||||
|
||||
<div className="border-l-2 border-primary pl-3">
|
||||
<p className="font-semibold text-sm">Height Requirement</p>
|
||||
<p className="text-xs text-muted-foreground">Enter in cm or inches (auto-converts)</p>
|
||||
<Badge variant="secondary" className="text-xs mt-1">Example: 120cm = 47in</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
{/* Submission Process */}
|
||||
<AccordionItem value="submission-process">
|
||||
<AccordionTrigger>Submission Process</AccordionTrigger>
|
||||
<AccordionContent className="space-y-3">
|
||||
<div className="space-y-3">
|
||||
<div className="bg-muted p-3 rounded-md">
|
||||
<p className="font-semibold text-sm mb-2">How Submissions Work</p>
|
||||
<ol className="text-xs text-muted-foreground space-y-2 list-decimal list-inside">
|
||||
<li>Fill out the form with accurate information</li>
|
||||
<li>Your submission goes to a moderation queue</li>
|
||||
<li>Moderators review for accuracy and completeness</li>
|
||||
<li>Approved submissions become visible on the site</li>
|
||||
<li>All changes are versioned - edit history is preserved</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div className="border-l-2 border-green-500 pl-3">
|
||||
<p className="font-semibold text-sm text-green-600">✓ Required Fields</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Fields marked with * are required. You cannot submit without completing these.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="border-l-2 border-blue-500 pl-3">
|
||||
<p className="font-semibold text-sm text-blue-600">Source URL & Notes</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Always provide sources for your information. This helps moderators verify accuracy
|
||||
and gives credit to original sources. Include official websites, press releases, or news articles.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
{/* Best Practices */}
|
||||
<AccordionItem value="best-practices">
|
||||
<AccordionTrigger>Best Practices</AccordionTrigger>
|
||||
<AccordionContent className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<div className="border-l-2 border-green-500 pl-3">
|
||||
<p className="font-semibold text-sm">✓ Do</p>
|
||||
<ul className="text-xs text-muted-foreground space-y-1 mt-1 list-disc list-inside">
|
||||
<li>Use official names from park/manufacturer sources</li>
|
||||
<li>Provide accurate dates with appropriate precision</li>
|
||||
<li>Include source URLs for verification</li>
|
||||
<li>Add detailed descriptions that help users</li>
|
||||
<li>Use proper capitalization and spelling</li>
|
||||
<li>Check if the {type} already exists before creating</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="border-l-2 border-red-500 pl-3">
|
||||
<p className="font-semibold text-sm text-destructive">✗ Don't</p>
|
||||
<ul className="text-xs text-muted-foreground space-y-1 mt-1 list-disc list-inside">
|
||||
<li>Use nicknames or unofficial names</li>
|
||||
<li>Guess dates - use appropriate precision instead</li>
|
||||
<li>Submit without sources or verification</li>
|
||||
<li>Leave descriptions empty or vague</li>
|
||||
<li>Use all caps or poor formatting</li>
|
||||
<li>Create duplicates of existing entries</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 p-3 rounded-md">
|
||||
<p className="font-semibold text-sm mb-1 text-blue-700 dark:text-blue-300">💡 Quality over Speed</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Take your time to ensure accuracy. Well-documented submissions are approved faster
|
||||
and help build a reliable database for everyone.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
135
src/components/help/TerminologyDialog.tsx
Normal file
135
src/components/help/TerminologyDialog.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import { useState } from "react";
|
||||
import { BookOpen, Search } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { getAllCategories, getTermsByCategory, searchGlossary, type GlossaryTerm } from "@/lib/glossary";
|
||||
|
||||
export function TerminologyDialog() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
const categories = getAllCategories();
|
||||
const searchResults = searchQuery ? searchGlossary(searchQuery) : [];
|
||||
|
||||
const renderTermCard = (term: GlossaryTerm) => (
|
||||
<div key={term.term} className="p-4 border rounded-lg space-y-2 hover:bg-muted/50 transition-colors">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h4 className="font-semibold">{term.term}</h4>
|
||||
<Badge variant="secondary" className="text-xs shrink-0">
|
||||
{term.category.replace('-', ' ')}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{term.definition}</p>
|
||||
{term.example && (
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
<span className="font-medium">Example:</span> {term.example}
|
||||
</p>
|
||||
)}
|
||||
{term.relatedTerms && term.relatedTerms.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 pt-1">
|
||||
<span className="text-xs text-muted-foreground">Related:</span>
|
||||
{term.relatedTerms.map(rt => (
|
||||
<Badge key={rt} variant="outline" className="text-xs">
|
||||
{rt.replace(/-/g, ' ')}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<BookOpen className="w-4 h-4 mr-2" />
|
||||
Terminology
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-4xl max-h-[80vh]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Theme Park Terminology Reference</DialogTitle>
|
||||
<DialogDescription>
|
||||
Quick reference for technical terms, manufacturers, and ride types
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search terminology..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
{searchQuery ? (
|
||||
<ScrollArea className="h-[400px]">
|
||||
<div className="space-y-3">
|
||||
{searchResults.length > 0 ? (
|
||||
searchResults.map(renderTermCard)
|
||||
) : (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
No terms found matching "{searchQuery}"
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
) : (
|
||||
<Tabs defaultValue="manufacturer" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-4 lg:grid-cols-7">
|
||||
{categories.map(cat => (
|
||||
<TabsTrigger key={cat} value={cat} className="text-xs">
|
||||
{cat === 'manufacturer' ? 'Mfg.' :
|
||||
cat === 'technology' ? 'Tech' :
|
||||
cat === 'measurement' ? 'Units' :
|
||||
cat.charAt(0).toUpperCase() + cat.slice(1).substring(0, 4)}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
{categories.map(cat => {
|
||||
const terms = getTermsByCategory(cat);
|
||||
return (
|
||||
<TabsContent key={cat} value={cat}>
|
||||
<ScrollArea className="h-[400px]">
|
||||
<div className="space-y-3 pr-4">
|
||||
<div className="flex items-center gap-2 pb-2 border-b">
|
||||
<h3 className="font-semibold capitalize">
|
||||
{cat.replace('-', ' ')}
|
||||
</h3>
|
||||
<Badge variant="secondary">{terms.length} terms</Badge>
|
||||
</div>
|
||||
{terms.map(renderTermCard)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
);
|
||||
})}
|
||||
</Tabs>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 pt-4 border-t text-xs text-muted-foreground">
|
||||
<Badge variant="outline" className="text-xs">Tip</Badge>
|
||||
<span>Hover over underlined terms in forms to see quick definitions</span>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { LayoutDashboard, FileText, Flag, Users, Settings, ArrowLeft, ScrollText, BookOpen, Inbox, Mail, AlertTriangle, Shield, Activity, BarChart } from 'lucide-react';
|
||||
import { LayoutDashboard, FileText, Flag, Users, Settings, ArrowLeft, ScrollText, BookOpen, Inbox, Mail, AlertTriangle, Shield, Activity, BarChart, Database } from 'lucide-react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { useUserRole } from '@/hooks/useUserRole';
|
||||
import { useSidebar } from '@/hooks/useSidebar';
|
||||
@@ -73,6 +73,12 @@ export function AdminSidebar() {
|
||||
url: '/admin/database-stats',
|
||||
icon: BarChart,
|
||||
},
|
||||
{
|
||||
title: 'Database Maintenance',
|
||||
url: '/admin/database-maintenance',
|
||||
icon: Database,
|
||||
visible: isSuperuser, // Only superusers can access
|
||||
},
|
||||
{
|
||||
title: 'Users',
|
||||
url: '/admin/users',
|
||||
@@ -134,7 +140,7 @@ export function AdminSidebar() {
|
||||
<SidebarGroupLabel>Navigation</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{navItems.map((item) => (
|
||||
{navItems.filter(item => item.visible !== false).map((item) => (
|
||||
<SidebarMenuItem key={item.url}>
|
||||
<SidebarMenuButton asChild tooltip={collapsed ? item.title : undefined}>
|
||||
<NavLink
|
||||
|
||||
34
src/components/layout/PageTransition.tsx
Normal file
34
src/components/layout/PageTransition.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { ReactNode, useEffect, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
interface PageTransitionProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function PageTransition({ children }: PageTransitionProps) {
|
||||
const location = useLocation();
|
||||
const [displayLocation, setDisplayLocation] = useState(location);
|
||||
const [transitionStage, setTransitionStage] = useState<'fade-in' | 'fade-out'>('fade-in');
|
||||
|
||||
useEffect(() => {
|
||||
if (location !== displayLocation) {
|
||||
setTransitionStage('fade-out');
|
||||
}
|
||||
}, [location, displayLocation]);
|
||||
|
||||
const onAnimationEnd = () => {
|
||||
if (transitionStage === 'fade-out') {
|
||||
setTransitionStage('fade-in');
|
||||
setDisplayLocation(location);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${transitionStage === 'fade-out' ? 'animate-fade-out' : 'animate-fade-in'}`}
|
||||
onAnimationEnd={onAnimationEnd}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
98
src/components/loading/CompanyDetailSkeleton.tsx
Normal file
98
src/components/loading/CompanyDetailSkeleton.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
|
||||
export function CompanyDetailSkeleton() {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8 max-w-7xl animate-pulse">
|
||||
{/* Breadcrumb */}
|
||||
<div className="h-4 bg-muted rounded w-56 mb-4" />
|
||||
|
||||
{/* Edit Button Area */}
|
||||
<div className="flex justify-end mb-6">
|
||||
<div className="h-10 bg-muted rounded w-32" />
|
||||
</div>
|
||||
|
||||
{/* Hero Banner */}
|
||||
<div className="aspect-[21/9] bg-muted rounded-lg mb-8" />
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-12 max-w-6xl mx-auto">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<Card key={i} className="border-0 bg-gradient-to-br from-muted/50 to-muted/30">
|
||||
<CardContent className="p-4 text-center">
|
||||
<div className="h-8 bg-muted rounded w-16 mx-auto mb-2" />
|
||||
<div className="h-3 bg-muted rounded w-20 mx-auto" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-2 border-b mb-6">
|
||||
{['Overview', 'Rides', 'Models', 'Photos'].map((tab) => (
|
||||
<div key={tab} className="h-10 bg-muted rounded w-20" />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content Grid */}
|
||||
<div className="grid lg:grid-cols-3 gap-6">
|
||||
{/* Main Content */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Description Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="h-6 bg-muted rounded w-48" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="h-4 bg-muted rounded w-full" />
|
||||
<div className="h-4 bg-muted rounded w-full" />
|
||||
<div className="h-4 bg-muted rounded w-4/5" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Products Grid */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="h-6 bg-muted rounded w-40" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||
<div key={i} className="space-y-2">
|
||||
<div className="aspect-square bg-muted rounded-lg" />
|
||||
<div className="h-4 bg-muted rounded w-full" />
|
||||
<div className="h-3 bg-muted rounded w-2/3" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-6">
|
||||
{/* Company Info Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="h-6 bg-muted rounded w-40" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Logo */}
|
||||
<div className="w-32 h-32 bg-muted rounded mx-auto mb-4" />
|
||||
|
||||
{/* Info Items */}
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="flex items-center gap-3">
|
||||
<div className="w-4 h-4 bg-muted rounded" />
|
||||
<div className="flex-1">
|
||||
<div className="h-4 bg-muted rounded w-24 mb-1" />
|
||||
<div className="h-3 bg-muted rounded w-32" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
101
src/components/loading/ParkDetailSkeleton.tsx
Normal file
101
src/components/loading/ParkDetailSkeleton.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
|
||||
export function ParkDetailSkeleton() {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8 max-w-7xl animate-pulse">
|
||||
{/* Breadcrumb */}
|
||||
<div className="h-4 bg-muted rounded w-48 mb-4" />
|
||||
|
||||
{/* Edit Button Area */}
|
||||
<div className="flex justify-end mb-6">
|
||||
<div className="h-10 bg-muted rounded w-32" />
|
||||
</div>
|
||||
|
||||
{/* Hero Banner */}
|
||||
<div className="aspect-[21/9] bg-muted rounded-lg mb-8" />
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-12 max-w-6xl mx-auto">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<Card key={i} className="border-0 bg-gradient-to-br from-muted/50 to-muted/30">
|
||||
<CardContent className="p-4 text-center">
|
||||
<div className="h-8 bg-muted rounded w-16 mx-auto mb-2" />
|
||||
<div className="h-3 bg-muted rounded w-20 mx-auto" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-2 border-b mb-6">
|
||||
{['Overview', 'Rides', 'Reviews', 'Photos', 'History'].map((tab) => (
|
||||
<div key={tab} className="h-10 bg-muted rounded w-24" />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content Grid */}
|
||||
<div className="grid lg:grid-cols-3 gap-6">
|
||||
{/* Main Content */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Description Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="h-6 bg-muted rounded w-48" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="h-4 bg-muted rounded w-full" />
|
||||
<div className="h-4 bg-muted rounded w-full" />
|
||||
<div className="h-4 bg-muted rounded w-3/4" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Featured Rides Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="h-6 bg-muted rounded w-40" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="space-y-2">
|
||||
<div className="aspect-square bg-muted rounded-lg" />
|
||||
<div className="h-4 bg-muted rounded w-full" />
|
||||
<div className="h-3 bg-muted rounded w-3/4" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-6">
|
||||
{/* Info Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="h-6 bg-muted rounded w-40" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="flex items-center gap-3">
|
||||
<div className="w-4 h-4 bg-muted rounded" />
|
||||
<div className="flex-1">
|
||||
<div className="h-4 bg-muted rounded w-24 mb-1" />
|
||||
<div className="h-3 bg-muted rounded w-32" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Map Card */}
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<div className="aspect-square bg-muted rounded-lg" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
106
src/components/loading/RideDetailSkeleton.tsx
Normal file
106
src/components/loading/RideDetailSkeleton.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
|
||||
export function RideDetailSkeleton() {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8 max-w-7xl animate-pulse">
|
||||
{/* Breadcrumb */}
|
||||
<div className="h-4 bg-muted rounded w-64 mb-4" />
|
||||
|
||||
{/* Edit Button Area */}
|
||||
<div className="flex justify-end mb-6">
|
||||
<div className="h-10 bg-muted rounded w-32" />
|
||||
</div>
|
||||
|
||||
{/* Hero Banner */}
|
||||
<div className="aspect-[21/9] bg-muted rounded-lg mb-8" />
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4 mb-12">
|
||||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||
<Card key={i} className="border-0 bg-gradient-to-br from-muted/50 to-muted/30">
|
||||
<CardContent className="p-4 text-center">
|
||||
<div className="w-6 h-6 bg-muted rounded mx-auto mb-2" />
|
||||
<div className="h-8 bg-muted rounded w-16 mx-auto mb-1" />
|
||||
<div className="h-3 bg-muted rounded w-12 mx-auto" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-2 border-b mb-6">
|
||||
{['Overview', 'Reviews', 'Photos', 'History'].map((tab) => (
|
||||
<div key={tab} className="h-10 bg-muted rounded w-24" />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content Grid */}
|
||||
<div className="grid lg:grid-cols-3 gap-6">
|
||||
{/* Main Content */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Description Card */}
|
||||
<Card>
|
||||
<CardContent className="p-6 space-y-3">
|
||||
<div className="h-6 bg-muted rounded w-48 mb-4" />
|
||||
<div className="h-4 bg-muted rounded w-full" />
|
||||
<div className="h-4 bg-muted rounded w-full" />
|
||||
<div className="h-4 bg-muted rounded w-5/6" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Technical Specs */}
|
||||
<Card>
|
||||
<CardContent className="p-6 space-y-4">
|
||||
<div className="h-6 bg-muted rounded w-56 mb-4" />
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||
<div key={i} className="space-y-2">
|
||||
<div className="h-3 bg-muted rounded w-24" />
|
||||
<div className="h-5 bg-muted rounded w-32" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-6">
|
||||
{/* Ride Info Card */}
|
||||
<Card>
|
||||
<CardContent className="p-6 space-y-4">
|
||||
<div className="h-6 bg-muted rounded w-40 mb-4" />
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<div key={i} className="flex items-center gap-3">
|
||||
<div className="w-4 h-4 bg-muted rounded" />
|
||||
<div className="flex-1">
|
||||
<div className="h-4 bg-muted rounded w-20 mb-1" />
|
||||
<div className="h-3 bg-muted rounded w-28" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Similar Rides */}
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="h-6 bg-muted rounded w-32 mb-4" />
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="flex gap-3">
|
||||
<div className="w-16 h-16 bg-muted rounded" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-4 bg-muted rounded w-full" />
|
||||
<div className="h-3 bg-muted rounded w-3/4" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Filter, MessageSquare, FileText, Image } from 'lucide-react';
|
||||
import { Filter, MessageSquare, FileText, Image, Calendar } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import type { EntityFilter, StatusFilter } from '@/types/moderation';
|
||||
import { format } from 'date-fns';
|
||||
import type { EntityFilter, StatusFilter, ApprovalDateRangeFilter } from '@/types/moderation';
|
||||
|
||||
interface ActiveFiltersDisplayProps {
|
||||
entityFilter: EntityFilter;
|
||||
statusFilter: StatusFilter;
|
||||
approvalDateRange?: ApprovalDateRangeFilter;
|
||||
defaultEntityFilter?: EntityFilter;
|
||||
defaultStatusFilter?: StatusFilter;
|
||||
}
|
||||
@@ -23,12 +25,15 @@ const getEntityFilterIcon = (filter: EntityFilter) => {
|
||||
export const ActiveFiltersDisplay = ({
|
||||
entityFilter,
|
||||
statusFilter,
|
||||
approvalDateRange,
|
||||
defaultEntityFilter = 'all',
|
||||
defaultStatusFilter = 'pending'
|
||||
}: ActiveFiltersDisplayProps) => {
|
||||
const hasDateRange = approvalDateRange && (approvalDateRange.from || approvalDateRange.to);
|
||||
const hasActiveFilters =
|
||||
entityFilter !== defaultEntityFilter ||
|
||||
statusFilter !== defaultStatusFilter;
|
||||
statusFilter !== defaultStatusFilter ||
|
||||
hasDateRange;
|
||||
|
||||
if (!hasActiveFilters) return null;
|
||||
|
||||
@@ -46,6 +51,14 @@ export const ActiveFiltersDisplay = ({
|
||||
{statusFilter}
|
||||
</Badge>
|
||||
)}
|
||||
{hasDateRange && (
|
||||
<Badge variant="secondary" className="flex items-center gap-1">
|
||||
<Calendar className="w-3 h-3" />
|
||||
{approvalDateRange.from && format(approvalDateRange.from, 'MMM d')}
|
||||
{approvalDateRange.from && approvalDateRange.to && ' - '}
|
||||
{approvalDateRange.to && format(approvalDateRange.to, 'MMM d')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
78
src/components/moderation/DetailedViewCollapsible.tsx
Normal file
78
src/components/moderation/DetailedViewCollapsible.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface DetailedViewCollapsibleProps {
|
||||
isCollapsed: boolean;
|
||||
onToggle: () => void;
|
||||
children: React.ReactNode;
|
||||
fieldCount?: number;
|
||||
className?: string;
|
||||
staggerIndex?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapsible wrapper for detailed field-by-field view sections
|
||||
* Provides expand/collapse functionality with visual indicators
|
||||
*/
|
||||
export function DetailedViewCollapsible({
|
||||
isCollapsed,
|
||||
onToggle,
|
||||
children,
|
||||
fieldCount,
|
||||
className,
|
||||
staggerIndex = 0
|
||||
}: DetailedViewCollapsibleProps) {
|
||||
// Calculate stagger delay: 50ms per item, max 300ms
|
||||
const staggerDelay = Math.min(staggerIndex * 50, 300);
|
||||
return (
|
||||
<Collapsible open={!isCollapsed} onOpenChange={() => onToggle()}>
|
||||
<div className={cn("mt-6 pt-6 border-t", className)}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full flex items-center justify-between hover:bg-muted/50 p-2 h-auto transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
All Fields (Detailed View)
|
||||
</span>
|
||||
{fieldCount !== undefined && fieldCount > 0 && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-5 px-1.5 text-xs font-normal transition-transform duration-200 hover:scale-105"
|
||||
>
|
||||
{fieldCount}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground normal-case font-normal">
|
||||
{isCollapsed ? 'Show' : 'Hide'}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-4 w-4 text-muted-foreground transition-all duration-300 ease-out",
|
||||
!isCollapsed && "rotate-180"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
<CollapsibleContent
|
||||
className="mt-3"
|
||||
style={{
|
||||
animationDelay: `${staggerDelay}ms`,
|
||||
transitionDelay: `${staggerDelay}ms`
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</CollapsibleContent>
|
||||
</div>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
@@ -5,8 +5,10 @@ import { ArrowRight } from 'lucide-react';
|
||||
import { ArrayFieldDiff } from './ArrayFieldDiff';
|
||||
import { SpecialFieldDisplay } from './SpecialFieldDisplay';
|
||||
|
||||
import type { DatePrecision } from '@/components/ui/flexible-date-input';
|
||||
|
||||
// Helper to format compact values (truncate long strings)
|
||||
function formatCompactValue(value: unknown, precision?: 'day' | 'month' | 'year', maxLength = 30): string {
|
||||
function formatCompactValue(value: unknown, precision?: DatePrecision, maxLength = 30): string {
|
||||
const formatted = formatFieldValue(value, precision);
|
||||
if (formatted.length > maxLength) {
|
||||
return formatted.substring(0, maxLength) + '...';
|
||||
|
||||
321
src/components/moderation/ItemApprovalHistory.tsx
Normal file
321
src/components/moderation/ItemApprovalHistory.tsx
Normal file
@@ -0,0 +1,321 @@
|
||||
/**
|
||||
* Item Approval History Component
|
||||
*
|
||||
* Displays detailed audit trail of approved items with exact timestamps.
|
||||
* Features filtering, sorting, CSV export for compliance reporting.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { format } from 'date-fns';
|
||||
import { ExternalLink, Download, Clock, User, FileText } from 'lucide-react';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { handleError } from '@/lib/errorHandler';
|
||||
import type { EntityType } from '@/types/submissions';
|
||||
|
||||
interface ApprovalHistoryItem {
|
||||
item_id: string;
|
||||
submission_id: string;
|
||||
item_type: string;
|
||||
action_type: string;
|
||||
status: string;
|
||||
approved_at: string;
|
||||
approved_entity_id: string;
|
||||
created_at: string;
|
||||
approval_time_seconds: number;
|
||||
submission_type: string;
|
||||
submitter_username: string | null;
|
||||
submitter_display_name: string | null;
|
||||
submitter_avatar_url: string | null;
|
||||
approver_username: string | null;
|
||||
approver_display_name: string | null;
|
||||
approver_avatar_url: string | null;
|
||||
entity_slug: string | null;
|
||||
entity_name: string | null;
|
||||
}
|
||||
|
||||
interface ItemApprovalHistoryProps {
|
||||
submissionId?: string;
|
||||
dateRange?: { from: Date; to: Date };
|
||||
itemType?: EntityType;
|
||||
limit?: number;
|
||||
embedded?: boolean;
|
||||
}
|
||||
|
||||
const getApprovalSpeed = (seconds: number) => {
|
||||
const hours = seconds / 3600;
|
||||
if (hours < 1) return { label: 'Fast', variant: 'default' as const, color: 'text-green-600 dark:text-green-400' };
|
||||
if (hours < 24) return { label: 'Normal', variant: 'secondary' as const, color: 'text-blue-600 dark:text-blue-400' };
|
||||
return { label: 'Slow', variant: 'destructive' as const, color: 'text-orange-600 dark:text-orange-400' };
|
||||
};
|
||||
|
||||
const formatDuration = (seconds: number) => {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
|
||||
if (hours > 48) {
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d ${hours % 24}h`;
|
||||
}
|
||||
if (hours > 0) return `${hours}h ${minutes}m`;
|
||||
return `${minutes}m`;
|
||||
};
|
||||
|
||||
const getEntityPath = (itemType: string, slug: string | null) => {
|
||||
if (!slug) return null;
|
||||
|
||||
switch (itemType) {
|
||||
case 'park': return `/parks/${slug}/`;
|
||||
case 'ride': return `/rides/${slug}`; // Need park slug ideally
|
||||
case 'manufacturer':
|
||||
case 'designer':
|
||||
case 'operator':
|
||||
return `/companies/${slug}/`;
|
||||
case 'ride_model': return `/models/${slug}/`;
|
||||
default: return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const ItemApprovalHistory = ({
|
||||
submissionId,
|
||||
dateRange,
|
||||
itemType,
|
||||
limit = 100,
|
||||
embedded = false
|
||||
}: ItemApprovalHistoryProps) => {
|
||||
const [sortField, setSortField] = useState<'approved_at' | 'approval_time_seconds'>('approved_at');
|
||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
|
||||
|
||||
const { data: history, isLoading, error } = useQuery({
|
||||
queryKey: ['approval-history', { submissionId, dateRange, itemType, limit }],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const { data, error } = await supabase.rpc('get_approval_history', {
|
||||
p_item_type: itemType || undefined,
|
||||
p_approver_id: undefined,
|
||||
p_from_date: dateRange?.from?.toISOString() || undefined,
|
||||
p_to_date: dateRange?.to?.toISOString() || undefined,
|
||||
p_limit: limit,
|
||||
p_offset: 0
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
// Client-side filter by submission_id if provided
|
||||
let filtered = data as ApprovalHistoryItem[];
|
||||
if (submissionId) {
|
||||
filtered = filtered.filter(item => item.submission_id === submissionId);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
} catch (err: unknown) {
|
||||
handleError(err, { action: 'fetch_approval_history' });
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
});
|
||||
|
||||
const sortedHistory = history ? [...history].sort((a, b) => {
|
||||
const aVal = a[sortField];
|
||||
const bVal = b[sortField];
|
||||
const comparison = aVal < bVal ? -1 : aVal > bVal ? 1 : 0;
|
||||
return sortDirection === 'asc' ? comparison : -comparison;
|
||||
}) : [];
|
||||
|
||||
const handleSort = (field: typeof sortField) => {
|
||||
if (sortField === field) {
|
||||
setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortDirection('desc');
|
||||
}
|
||||
};
|
||||
|
||||
const exportToCSV = () => {
|
||||
if (!history || history.length === 0) return;
|
||||
|
||||
const headers = [
|
||||
'Timestamp',
|
||||
'Item Type',
|
||||
'Action',
|
||||
'Entity Name',
|
||||
'Submitter',
|
||||
'Approver',
|
||||
'Time to Approve (hours)',
|
||||
'Submission ID',
|
||||
'Item ID'
|
||||
];
|
||||
|
||||
const rows = history.map(item => [
|
||||
format(new Date(item.approved_at), 'yyyy-MM-dd HH:mm:ss'),
|
||||
item.item_type,
|
||||
item.action_type,
|
||||
item.entity_name || 'N/A',
|
||||
item.submitter_display_name || item.submitter_username || 'Unknown',
|
||||
item.approver_display_name || item.approver_username || 'Unknown',
|
||||
(item.approval_time_seconds / 3600).toFixed(2),
|
||||
item.submission_id,
|
||||
item.item_id
|
||||
]);
|
||||
|
||||
const csv = [headers, ...rows].map(row => row.map(cell => `"${cell}"`).join(',')).join('\n');
|
||||
const blob = new Blob([csv], { type: 'text/csv' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `approval-history-${format(new Date(), 'yyyy-MM-dd')}.csv`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card className={embedded ? '' : 'mt-6'}>
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-sm text-destructive">Failed to load approval history. Please try again.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{!embedded && (
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Item Approval History</CardTitle>
|
||||
<CardDescription>Detailed audit trail of approved submissions</CardDescription>
|
||||
</div>
|
||||
{sortedHistory.length > 0 && (
|
||||
<Button onClick={exportToCSV} variant="outline" size="sm">
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Export CSV
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
)}
|
||||
<CardContent className={embedded ? 'p-0' : ''}>
|
||||
{isLoading ? (
|
||||
<div className="space-y-3">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-16 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : sortedHistory.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<FileText className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||
<p>No approval history found</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => handleSort('approved_at')}
|
||||
>
|
||||
Approved At {sortField === 'approved_at' && (sortDirection === 'asc' ? '↑' : '↓')}
|
||||
</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Entity</TableHead>
|
||||
<TableHead>Submitter</TableHead>
|
||||
<TableHead>Approver</TableHead>
|
||||
<TableHead
|
||||
className="cursor-pointer hover:bg-muted/50 text-right"
|
||||
onClick={() => handleSort('approval_time_seconds')}
|
||||
>
|
||||
Time to Approve {sortField === 'approval_time_seconds' && (sortDirection === 'asc' ? '↑' : '↓')}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sortedHistory.map((item) => {
|
||||
const speed = getApprovalSpeed(item.approval_time_seconds);
|
||||
const entityPath = getEntityPath(item.item_type, item.entity_slug);
|
||||
|
||||
return (
|
||||
<TableRow key={item.item_id}>
|
||||
<TableCell className="font-mono text-xs">
|
||||
<div className="flex flex-col">
|
||||
<span>{format(new Date(item.approved_at), 'MMM d, yyyy')}</span>
|
||||
<span className="text-muted-foreground">{format(new Date(item.approved_at), 'HH:mm:ss')}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="capitalize">
|
||||
{item.item_type}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{item.entity_name ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{item.entity_name}</span>
|
||||
{entityPath && (
|
||||
<a
|
||||
href={entityPath}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-sm">N/A</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar className="h-6 w-6">
|
||||
<AvatarImage src={item.submitter_avatar_url || undefined} />
|
||||
<AvatarFallback className="text-xs">
|
||||
{(item.submitter_display_name || item.submitter_username || 'U')[0].toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-sm">{item.submitter_display_name || item.submitter_username || 'Unknown'}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar className="h-6 w-6">
|
||||
<AvatarImage src={item.approver_avatar_url || undefined} />
|
||||
<AvatarFallback className="text-xs">
|
||||
{(item.approver_display_name || item.approver_username || 'M')[0].toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-sm">{item.approver_display_name || item.approver_username || 'Unknown'}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Clock className={`w-4 h-4 ${speed.color}`} />
|
||||
<span className="font-mono text-sm">{formatDuration(item.approval_time_seconds)}</span>
|
||||
<Badge variant={speed.variant} className="ml-1">
|
||||
{speed.label}
|
||||
</Badge>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</>
|
||||
);
|
||||
|
||||
return embedded ? content : <Card className="mt-6">{content}</Card>;
|
||||
};
|
||||
125
src/components/moderation/ItemLevelApprovalHistory.tsx
Normal file
125
src/components/moderation/ItemLevelApprovalHistory.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import { memo } from 'react';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { CheckCircle2, User } from 'lucide-react';
|
||||
import type { SubmissionItem } from '@/types/moderation';
|
||||
|
||||
interface ItemLevelApprovalHistoryProps {
|
||||
items: SubmissionItem[];
|
||||
reviewerProfile?: {
|
||||
user_id: string;
|
||||
username: string;
|
||||
display_name?: string | null;
|
||||
avatar_url?: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export const ItemLevelApprovalHistory = memo(({
|
||||
items,
|
||||
reviewerProfile,
|
||||
}: ItemLevelApprovalHistoryProps) => {
|
||||
// Filter to only approved items with timestamps
|
||||
const approvedItems = items.filter(
|
||||
item => item.status === 'approved' && (item as any).approved_at
|
||||
);
|
||||
|
||||
if (approvedItems.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Sort by approval time (newest first)
|
||||
const sortedItems = [...approvedItems].sort((a, b) => {
|
||||
const timeA = new Date((a as any).approved_at).getTime();
|
||||
const timeB = new Date((b as any).approved_at).getTime();
|
||||
return timeB - timeA;
|
||||
});
|
||||
|
||||
// Helper to get item display name
|
||||
const getItemName = (item: SubmissionItem): string => {
|
||||
const entityData = item.entity_data || item.item_data;
|
||||
if (entityData && typeof entityData === 'object' && 'name' in entityData) {
|
||||
return String(entityData.name);
|
||||
}
|
||||
return `${item.item_type} #${item.order_index}`;
|
||||
};
|
||||
|
||||
// Helper to get action label
|
||||
const getActionLabel = (actionType: string): string => {
|
||||
switch (actionType) {
|
||||
case 'create': return 'Created';
|
||||
case 'edit': return 'Edited';
|
||||
case 'delete': return 'Deleted';
|
||||
default: return 'Modified';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-2">
|
||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||
Item Approvals
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{sortedItems.map((item) => {
|
||||
const approvedAt = (item as any).approved_at;
|
||||
const itemName = getItemName(item);
|
||||
const actionLabel = getActionLabel(item.action_type);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-start gap-3 text-sm bg-success/5 border border-success/20 rounded-md p-3"
|
||||
>
|
||||
{/* Approval Icon */}
|
||||
<div className="flex-shrink-0 mt-0.5">
|
||||
<CheckCircle2 className="h-4 w-4 text-success" />
|
||||
</div>
|
||||
|
||||
{/* Item Info */}
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-medium text-foreground truncate">
|
||||
{itemName}
|
||||
</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{actionLabel}
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="text-xs font-mono">
|
||||
{item.item_type}
|
||||
</Badge>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{formatDistanceToNow(new Date(approvedAt), { addSuffix: true })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Reviewer Info */}
|
||||
{reviewerProfile && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Avatar className="h-5 w-5">
|
||||
<AvatarImage src={reviewerProfile.avatar_url ?? undefined} />
|
||||
<AvatarFallback className="text-[10px]">
|
||||
<User className="h-3 w-3" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span>
|
||||
Approved by{' '}
|
||||
<span className="font-medium text-foreground">
|
||||
{reviewerProfile.display_name || reviewerProfile.username}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
ItemLevelApprovalHistory.displayName = 'ItemLevelApprovalHistory';
|
||||
@@ -501,11 +501,14 @@ export const ModerationQueue = forwardRef<ModerationQueueRef, ModerationQueuePro
|
||||
activeEntityFilter={queueManager.filters.entityFilter}
|
||||
activeStatusFilter={queueManager.filters.statusFilter}
|
||||
sortConfig={queueManager.filters.sortConfig}
|
||||
activeTab={queueManager.filters.activeTab}
|
||||
approvalDateRange={queueManager.filters.approvalDateRange}
|
||||
isMobile={isMobile ?? false}
|
||||
isLoading={queueManager.loadingState === 'loading'}
|
||||
onEntityFilterChange={queueManager.filters.setEntityFilter}
|
||||
onStatusFilterChange={queueManager.filters.setStatusFilter}
|
||||
onSortChange={queueManager.filters.setSortConfig}
|
||||
onApprovalDateRangeChange={queueManager.filters.setApprovalDateRange}
|
||||
onClearFilters={queueManager.filters.clearFilters}
|
||||
showClearButton={queueManager.filters.hasActiveFilters}
|
||||
onRefresh={queueManager.refresh}
|
||||
@@ -517,6 +520,7 @@ export const ModerationQueue = forwardRef<ModerationQueueRef, ModerationQueuePro
|
||||
<ActiveFiltersDisplay
|
||||
entityFilter={queueManager.filters.entityFilter}
|
||||
statusFilter={queueManager.filters.statusFilter}
|
||||
approvalDateRange={queueManager.filters.approvalDateRange}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,23 +1,29 @@
|
||||
import { Filter, MessageSquare, FileText, Image, X, ChevronDown } from 'lucide-react';
|
||||
import { Filter, MessageSquare, FileText, Image, X, ChevronDown, Calendar, Maximize2, Minimize2 } from 'lucide-react';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { RefreshButton } from '@/components/ui/refresh-button';
|
||||
import { QueueSortControls } from './QueueSortControls';
|
||||
import { useFilterPanelState } from '@/hooks/useFilterPanelState';
|
||||
import type { EntityFilter, StatusFilter, SortConfig } from '@/types/moderation';
|
||||
import { useDetailedViewState } from '@/hooks/useDetailedViewState';
|
||||
import { FilterDateRangePicker } from '@/components/filters/FilterDateRangePicker';
|
||||
import type { EntityFilter, StatusFilter, SortConfig, QueueTab, ApprovalDateRangeFilter } from '@/types/moderation';
|
||||
|
||||
interface QueueFiltersProps {
|
||||
activeEntityFilter: EntityFilter;
|
||||
activeStatusFilter: StatusFilter;
|
||||
sortConfig: SortConfig;
|
||||
activeTab: QueueTab;
|
||||
approvalDateRange: ApprovalDateRangeFilter;
|
||||
isMobile: boolean;
|
||||
isLoading?: boolean;
|
||||
onEntityFilterChange: (filter: EntityFilter) => void;
|
||||
onStatusFilterChange: (filter: StatusFilter) => void;
|
||||
onSortChange: (config: SortConfig) => void;
|
||||
onApprovalDateRangeChange: (range: ApprovalDateRangeFilter) => void;
|
||||
onClearFilters: () => void;
|
||||
showClearButton: boolean;
|
||||
onRefresh?: () => void;
|
||||
@@ -37,22 +43,27 @@ export const QueueFilters = ({
|
||||
activeEntityFilter,
|
||||
activeStatusFilter,
|
||||
sortConfig,
|
||||
activeTab,
|
||||
approvalDateRange,
|
||||
isMobile,
|
||||
isLoading = false,
|
||||
onEntityFilterChange,
|
||||
onStatusFilterChange,
|
||||
onSortChange,
|
||||
onApprovalDateRangeChange,
|
||||
onClearFilters,
|
||||
showClearButton,
|
||||
onRefresh,
|
||||
isRefreshing = false
|
||||
}: QueueFiltersProps) => {
|
||||
const { isCollapsed, toggle } = useFilterPanelState();
|
||||
const { isCollapsed: detailsCollapsed, toggle: toggleDetails } = useDetailedViewState();
|
||||
|
||||
// Count active filters
|
||||
const activeFilterCount = [
|
||||
activeEntityFilter !== 'all' ? 1 : 0,
|
||||
activeStatusFilter !== 'all' ? 1 : 0,
|
||||
approvalDateRange.from || approvalDateRange.to ? 1 : 0,
|
||||
].reduce((sum, val) => sum + val, 0);
|
||||
|
||||
return (
|
||||
@@ -68,14 +79,51 @@ export const QueueFilters = ({
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{isMobile && (
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
|
||||
<ChevronDown className={`h-4 w-4 transition-transform duration-250 ${isCollapsed ? '' : 'rotate-180'}`} />
|
||||
<span className="sr-only">{isCollapsed ? 'Expand filters' : 'Collapse filters'}</span>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Global toggle for detailed views */}
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={toggleDetails}
|
||||
className="h-8 gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{detailsCollapsed ? (
|
||||
<>
|
||||
<Maximize2 className="h-3.5 w-3.5" />
|
||||
{!isMobile && <span>Expand All</span>}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Minimize2 className="h-3.5 w-3.5" />
|
||||
{!isMobile && <span>Collapse All</span>}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="max-w-xs">
|
||||
<p className="text-xs">
|
||||
{detailsCollapsed
|
||||
? "Show detailed field-by-field view for all items in the queue"
|
||||
: "Hide detailed field-by-field view for all items in the queue"}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
This preference is saved to your account
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
{isMobile && (
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
|
||||
<ChevronDown className={`h-4 w-4 transition-transform duration-250 ${isCollapsed ? '' : 'rotate-180'}`} />
|
||||
<span className="sr-only">{isCollapsed ? 'Expand filters' : 'Collapse filters'}</span>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CollapsibleContent className="space-y-4">
|
||||
@@ -164,6 +212,21 @@ export const QueueFilters = ({
|
||||
isMobile={isMobile}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
|
||||
{/* Approval Date Range Filter - Only show on archive tab */}
|
||||
{activeTab === 'archive' && (
|
||||
<div className={`space-y-2 ${isMobile ? 'w-full' : 'min-w-[280px]'}`}>
|
||||
<FilterDateRangePicker
|
||||
label="Approved Between"
|
||||
fromDate={approvalDateRange.from}
|
||||
toDate={approvalDateRange.to}
|
||||
onFromChange={(date) => onApprovalDateRangeChange({ ...approvalDateRange, from: date || null })}
|
||||
onToChange={(date) => onApprovalDateRangeChange({ ...approvalDateRange, to: date || null })}
|
||||
fromPlaceholder="Start Date"
|
||||
toPlaceholder="End Date"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Clear Filters & Apply Buttons (mobile only) */}
|
||||
|
||||
@@ -23,6 +23,7 @@ import { QueueItemActions } from './renderers/QueueItemActions';
|
||||
import { SubmissionMetadataPanel } from './SubmissionMetadataPanel';
|
||||
import { AuditTrailViewer } from './AuditTrailViewer';
|
||||
import { RawDataViewer } from './RawDataViewer';
|
||||
import { ItemLevelApprovalHistory } from './ItemLevelApprovalHistory';
|
||||
|
||||
interface QueueItemProps {
|
||||
item: ModerationItem;
|
||||
@@ -330,6 +331,15 @@ export const QueueItem = memo(({
|
||||
{item.type === 'content_submission' && (
|
||||
<div className="mt-6 space-y-4">
|
||||
<SubmissionMetadataPanel item={item} />
|
||||
|
||||
{/* Item-level approval history */}
|
||||
{item.submission_items && item.submission_items.length > 0 && (
|
||||
<ItemLevelApprovalHistory
|
||||
items={item.submission_items}
|
||||
reviewerProfile={item.reviewer_profile}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AuditTrailViewer submissionId={item.id} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -211,7 +211,13 @@ function DateFieldDisplay({ change, compact }: { change: FieldChange; compact: b
|
||||
{formatFieldName(change.field)}
|
||||
{precision && (
|
||||
<Badge variant="outline" className="text-xs ml-2">
|
||||
{precision === 'year' ? 'Year Only' : precision === 'month' ? 'Month & Year' : 'Full Date'}
|
||||
{precision === 'exact' ? 'Exact Day' :
|
||||
precision === 'month' ? 'Month & Year' :
|
||||
precision === 'year' ? 'Year Only' :
|
||||
precision === 'decade' ? 'Decade' :
|
||||
precision === 'century' ? 'Century' :
|
||||
precision === 'approximate' ? 'Approximate' :
|
||||
'Full Date'}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { RichRideDisplay } from './displays/RichRideDisplay';
|
||||
import { RichCompanyDisplay } from './displays/RichCompanyDisplay';
|
||||
import { RichRideModelDisplay } from './displays/RichRideModelDisplay';
|
||||
import { RichTimelineEventDisplay } from './displays/RichTimelineEventDisplay';
|
||||
import { DetailedViewCollapsible } from './DetailedViewCollapsible';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -17,6 +18,7 @@ import type { ParkSubmissionData, RideSubmissionData, CompanySubmissionData, Rid
|
||||
import type { TimelineSubmissionData } from '@/types/timeline';
|
||||
import { getErrorMessage, handleNonCriticalError } from '@/lib/errorHandler';
|
||||
import { ModerationErrorBoundary } from '@/components/error/ModerationErrorBoundary';
|
||||
import { useDetailedViewState } from '@/hooks/useDetailedViewState';
|
||||
|
||||
interface SubmissionItemsListProps {
|
||||
submissionId: string;
|
||||
@@ -34,11 +36,18 @@ export const SubmissionItemsList = memo(function SubmissionItemsList({
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { isCollapsed, toggle } = useDetailedViewState();
|
||||
|
||||
useEffect(() => {
|
||||
fetchSubmissionItems();
|
||||
}, [submissionId]);
|
||||
|
||||
// Helper function to count non-null fields in entity data
|
||||
const countFields = (data: any): number => {
|
||||
if (!data || typeof data !== 'object') return 0;
|
||||
return Object.values(data).filter(value => value !== null && value !== undefined).length;
|
||||
};
|
||||
|
||||
const fetchSubmissionItems = async () => {
|
||||
try {
|
||||
// Only show skeleton on initial load, show refreshing indicator on refresh
|
||||
@@ -126,7 +135,7 @@ export const SubmissionItemsList = memo(function SubmissionItemsList({
|
||||
}
|
||||
|
||||
// Render item with appropriate display component
|
||||
const renderItem = (item: SubmissionItemData) => {
|
||||
const renderItem = (item: SubmissionItemData, index: number = 0) => {
|
||||
// SubmissionItemData from submissions.ts has item_data property
|
||||
const entityData = item.item_data;
|
||||
const actionType = item.action_type || 'create';
|
||||
@@ -188,17 +197,19 @@ export const SubmissionItemsList = memo(function SubmissionItemsList({
|
||||
data={entityData as unknown as ParkSubmissionData}
|
||||
actionType={actionType}
|
||||
/>
|
||||
<div className="mt-6 pt-6 border-t">
|
||||
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-3">
|
||||
All Fields (Detailed View)
|
||||
</div>
|
||||
<DetailedViewCollapsible
|
||||
isCollapsed={isCollapsed}
|
||||
onToggle={toggle}
|
||||
fieldCount={countFields(entityData)}
|
||||
staggerIndex={index}
|
||||
>
|
||||
<SubmissionChangesDisplay
|
||||
item={item}
|
||||
view="detailed"
|
||||
showImages={showImages}
|
||||
submissionId={submissionId}
|
||||
/>
|
||||
</div>
|
||||
</DetailedViewCollapsible>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -211,17 +222,19 @@ export const SubmissionItemsList = memo(function SubmissionItemsList({
|
||||
data={entityData as unknown as RideSubmissionData}
|
||||
actionType={actionType}
|
||||
/>
|
||||
<div className="mt-6 pt-6 border-t">
|
||||
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-3">
|
||||
All Fields (Detailed View)
|
||||
</div>
|
||||
<DetailedViewCollapsible
|
||||
isCollapsed={isCollapsed}
|
||||
onToggle={toggle}
|
||||
fieldCount={countFields(entityData)}
|
||||
staggerIndex={index}
|
||||
>
|
||||
<SubmissionChangesDisplay
|
||||
item={item}
|
||||
view="detailed"
|
||||
showImages={showImages}
|
||||
submissionId={submissionId}
|
||||
/>
|
||||
</div>
|
||||
</DetailedViewCollapsible>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -234,17 +247,19 @@ export const SubmissionItemsList = memo(function SubmissionItemsList({
|
||||
data={entityData as unknown as CompanySubmissionData}
|
||||
actionType={actionType}
|
||||
/>
|
||||
<div className="mt-6 pt-6 border-t">
|
||||
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-3">
|
||||
All Fields (Detailed View)
|
||||
</div>
|
||||
<DetailedViewCollapsible
|
||||
isCollapsed={isCollapsed}
|
||||
onToggle={toggle}
|
||||
fieldCount={countFields(entityData)}
|
||||
staggerIndex={index}
|
||||
>
|
||||
<SubmissionChangesDisplay
|
||||
item={item}
|
||||
view="detailed"
|
||||
showImages={showImages}
|
||||
submissionId={submissionId}
|
||||
/>
|
||||
</div>
|
||||
</DetailedViewCollapsible>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -257,17 +272,19 @@ export const SubmissionItemsList = memo(function SubmissionItemsList({
|
||||
data={entityData as unknown as RideModelSubmissionData}
|
||||
actionType={actionType}
|
||||
/>
|
||||
<div className="mt-6 pt-6 border-t">
|
||||
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-3">
|
||||
All Fields (Detailed View)
|
||||
</div>
|
||||
<DetailedViewCollapsible
|
||||
isCollapsed={isCollapsed}
|
||||
onToggle={toggle}
|
||||
fieldCount={countFields(entityData)}
|
||||
staggerIndex={index}
|
||||
>
|
||||
<SubmissionChangesDisplay
|
||||
item={item}
|
||||
view="detailed"
|
||||
showImages={showImages}
|
||||
submissionId={submissionId}
|
||||
/>
|
||||
</div>
|
||||
</DetailedViewCollapsible>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -280,17 +297,19 @@ export const SubmissionItemsList = memo(function SubmissionItemsList({
|
||||
data={entityData as unknown as TimelineSubmissionData}
|
||||
actionType={actionType}
|
||||
/>
|
||||
<div className="mt-6 pt-6 border-t">
|
||||
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-3">
|
||||
All Fields (Detailed View)
|
||||
</div>
|
||||
<DetailedViewCollapsible
|
||||
isCollapsed={isCollapsed}
|
||||
onToggle={toggle}
|
||||
fieldCount={countFields(entityData)}
|
||||
staggerIndex={index}
|
||||
>
|
||||
<SubmissionChangesDisplay
|
||||
item={item}
|
||||
view="detailed"
|
||||
showImages={showImages}
|
||||
submissionId={submissionId}
|
||||
/>
|
||||
</div>
|
||||
</DetailedViewCollapsible>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -320,9 +339,9 @@ export const SubmissionItemsList = memo(function SubmissionItemsList({
|
||||
)}
|
||||
|
||||
{/* Show regular submission items */}
|
||||
{items.map((item) => (
|
||||
{items.map((item, index) => (
|
||||
<div key={item.id} className={view === 'summary' ? 'border-l-2 border-primary/20 pl-3' : ''}>
|
||||
{renderItem(item)}
|
||||
{renderItem(item, index)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ export function RichCompanyDisplay({ data, actionType, showAllFields = true }: R
|
||||
{data.founded_date ? (
|
||||
<FlexibleDateDisplay
|
||||
date={data.founded_date}
|
||||
precision={(data.founded_date_precision as DatePrecision) || 'day'}
|
||||
precision={(data.founded_date_precision as DatePrecision) || 'exact'}
|
||||
className="font-medium"
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -165,7 +165,7 @@ export function RichParkDisplay({ data, actionType, showAllFields = true }: Rich
|
||||
<span className="text-muted-foreground">Opened:</span>{' '}
|
||||
<FlexibleDateDisplay
|
||||
date={data.opening_date}
|
||||
precision={(data.opening_date_precision as DatePrecision) || 'day'}
|
||||
precision={(data.opening_date_precision as DatePrecision) || 'exact'}
|
||||
className="font-medium"
|
||||
/>
|
||||
</div>
|
||||
@@ -175,7 +175,7 @@ export function RichParkDisplay({ data, actionType, showAllFields = true }: Rich
|
||||
<span className="text-muted-foreground">Closed:</span>{' '}
|
||||
<FlexibleDateDisplay
|
||||
date={data.closing_date}
|
||||
precision={(data.closing_date_precision as DatePrecision) || 'day'}
|
||||
precision={(data.closing_date_precision as DatePrecision) || 'exact'}
|
||||
className="font-medium"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -606,7 +606,7 @@ export function RichRideDisplay({ data, actionType, showAllFields = true }: Rich
|
||||
<span className="text-muted-foreground">Opened:</span>{' '}
|
||||
<FlexibleDateDisplay
|
||||
date={data.opening_date}
|
||||
precision={(data.opening_date_precision as DatePrecision) || 'day'}
|
||||
precision={(data.opening_date_precision as DatePrecision) || 'exact'}
|
||||
className="font-medium"
|
||||
/>
|
||||
</div>
|
||||
@@ -616,7 +616,7 @@ export function RichRideDisplay({ data, actionType, showAllFields = true }: Rich
|
||||
<span className="text-muted-foreground">Closed:</span>{' '}
|
||||
<FlexibleDateDisplay
|
||||
date={data.closing_date}
|
||||
precision={(data.closing_date_precision as DatePrecision) || 'day'}
|
||||
precision={(data.closing_date_precision as DatePrecision) || 'exact'}
|
||||
className="font-medium"
|
||||
/>
|
||||
</div>
|
||||
|
||||
87
src/components/navigation/EntityBreadcrumb.tsx
Normal file
87
src/components/navigation/EntityBreadcrumb.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Home } from 'lucide-react';
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbList,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from '@/components/ui/breadcrumb';
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card';
|
||||
import { ParkPreviewCard } from '@/components/preview/ParkPreviewCard';
|
||||
import { CompanyPreviewCard } from '@/components/preview/CompanyPreviewCard';
|
||||
|
||||
interface BreadcrumbSegment {
|
||||
label: string;
|
||||
href?: string;
|
||||
showPreview?: boolean;
|
||||
previewType?: 'park' | 'company';
|
||||
previewSlug?: string;
|
||||
}
|
||||
|
||||
interface EntityBreadcrumbProps {
|
||||
segments: BreadcrumbSegment[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function EntityBreadcrumb({ segments, className }: EntityBreadcrumbProps) {
|
||||
return (
|
||||
<Breadcrumb className={className}>
|
||||
<BreadcrumbList>
|
||||
{/* Home link */}
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link to="/" className="flex items-center gap-1 hover:text-primary transition-colors">
|
||||
<Home className="w-3.5 h-3.5" />
|
||||
<span>Home</span>
|
||||
</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
|
||||
{segments.map((segment, index) => {
|
||||
const isLast = index === segments.length - 1;
|
||||
|
||||
return (
|
||||
<BreadcrumbItem key={index}>
|
||||
<BreadcrumbSeparator />
|
||||
{isLast ? (
|
||||
<BreadcrumbPage>{segment.label}</BreadcrumbPage>
|
||||
) : segment.showPreview && segment.previewSlug ? (
|
||||
<HoverCard openDelay={300}>
|
||||
<HoverCardTrigger asChild>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link
|
||||
to={segment.href || '#'}
|
||||
className="hover:text-primary transition-colors"
|
||||
>
|
||||
{segment.label}
|
||||
</Link>
|
||||
</BreadcrumbLink>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent side="bottom" align="start" className="w-auto">
|
||||
{segment.previewType === 'park' && (
|
||||
<ParkPreviewCard slug={segment.previewSlug} />
|
||||
)}
|
||||
{segment.previewType === 'company' && (
|
||||
<CompanyPreviewCard slug={segment.previewSlug} />
|
||||
)}
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
) : (
|
||||
<BreadcrumbLink asChild>
|
||||
<Link
|
||||
to={segment.href || '#'}
|
||||
className="hover:text-primary transition-colors"
|
||||
>
|
||||
{segment.label}
|
||||
</Link>
|
||||
</BreadcrumbLink>
|
||||
)}
|
||||
</BreadcrumbItem>
|
||||
);
|
||||
})}
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
);
|
||||
}
|
||||
80
src/components/preview/CompanyPreviewCard.tsx
Normal file
80
src/components/preview/CompanyPreviewCard.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import { Building2, MapPin, Calendar } from 'lucide-react';
|
||||
import { useCompanyPreview } from '@/hooks/preview/useCompanyPreview';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
interface CompanyPreviewCardProps {
|
||||
slug: string;
|
||||
}
|
||||
|
||||
export function CompanyPreviewCard({ slug }: CompanyPreviewCardProps) {
|
||||
const { data: company, isLoading } = useCompanyPreview(slug);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="w-80">
|
||||
<div className="animate-pulse space-y-3">
|
||||
<div className="h-16 bg-muted rounded" />
|
||||
<div className="h-4 bg-muted rounded w-3/4" />
|
||||
<div className="h-4 bg-muted rounded w-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!company) {
|
||||
return (
|
||||
<div className="w-80 p-4 text-center text-muted-foreground">
|
||||
Company not found
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const formatCompanyType = (type: string) => {
|
||||
return type.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-80 space-y-3">
|
||||
{/* Header with logo */}
|
||||
<div className="flex items-start gap-3">
|
||||
{company.logo_url ? (
|
||||
<img
|
||||
src={company.logo_url}
|
||||
alt={company.name}
|
||||
className="w-12 h-12 object-contain rounded"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-12 h-12 bg-muted rounded flex items-center justify-center">
|
||||
<Building2 className="w-6 h-6 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-base line-clamp-1">{company.name}</h3>
|
||||
<Badge variant="secondary" className="mt-1">
|
||||
{formatCompanyType(company.company_type)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Location and Founded */}
|
||||
<div className="space-y-2 text-sm">
|
||||
{company.headquarters_location && (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<MapPin className="w-4 h-4 flex-shrink-0" />
|
||||
<span className="line-clamp-1">{company.headquarters_location}</span>
|
||||
</div>
|
||||
)}
|
||||
{company.founded_year && (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Calendar className="w-4 h-4 flex-shrink-0" />
|
||||
<span>Founded {company.founded_year}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Click to view full details
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
112
src/components/preview/ParkPreviewCard.tsx
Normal file
112
src/components/preview/ParkPreviewCard.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { MapPin, Star, FerrisWheel, Zap } from 'lucide-react';
|
||||
import { useParkPreview } from '@/hooks/preview/useParkPreview';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
|
||||
interface ParkPreviewCardProps {
|
||||
slug: string;
|
||||
}
|
||||
|
||||
export function ParkPreviewCard({ slug }: ParkPreviewCardProps) {
|
||||
const { data: park, isLoading } = useParkPreview(slug);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="w-80">
|
||||
<div className="animate-pulse space-y-3">
|
||||
<div className="h-32 bg-muted rounded" />
|
||||
<div className="h-4 bg-muted rounded w-3/4" />
|
||||
<div className="h-4 bg-muted rounded w-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!park) {
|
||||
return (
|
||||
<div className="w-80 p-4 text-center text-muted-foreground">
|
||||
Park not found
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'operating':
|
||||
return 'bg-green-500/20 text-green-400 border-green-500/30';
|
||||
case 'seasonal':
|
||||
return 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30';
|
||||
case 'under_construction':
|
||||
return 'bg-blue-500/20 text-blue-400 border-blue-500/30';
|
||||
default:
|
||||
return 'bg-red-500/20 text-red-400 border-red-500/30';
|
||||
}
|
||||
};
|
||||
|
||||
const formatParkType = (type: string) => {
|
||||
return type.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-80 space-y-3">
|
||||
{/* Image */}
|
||||
{park.card_image_url && (
|
||||
<div className="aspect-video rounded-lg overflow-hidden bg-muted">
|
||||
<img
|
||||
src={park.card_image_url}
|
||||
alt={park.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h3 className="font-semibold text-base line-clamp-1 mb-2">{park.name}</h3>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Badge className={`${getStatusColor(park.status)} border text-xs`}>
|
||||
{park.status.replace('_', ' ').toUpperCase()}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{formatParkType(park.park_type)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Location */}
|
||||
{park.location && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<MapPin className="w-4 h-4 flex-shrink-0" />
|
||||
<span className="line-clamp-1">
|
||||
{[park.location.city, park.location.state_province, park.location.country]
|
||||
.filter(Boolean)
|
||||
.join(', ')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<FerrisWheel className="w-4 h-4 text-primary" />
|
||||
<span className="font-medium">{park.ride_count || 0}</span>
|
||||
<span className="text-muted-foreground">rides</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap className="w-4 h-4 text-accent" />
|
||||
<span className="font-medium">{park.coaster_count || 0}</span>
|
||||
<span className="text-muted-foreground">coasters</span>
|
||||
</div>
|
||||
{park.average_rating && park.average_rating > 0 && (
|
||||
<div className="flex items-center gap-2 col-span-2">
|
||||
<Star className="w-4 h-4 text-yellow-500 fill-yellow-500" />
|
||||
<span className="font-medium">{park.average_rating.toFixed(1)}</span>
|
||||
<span className="text-muted-foreground">({park.review_count} reviews)</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,9 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Ride } from '@/types/database';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card';
|
||||
import { CompanyPreviewCard } from '@/components/preview/CompanyPreviewCard';
|
||||
|
||||
interface RideListViewProps {
|
||||
rides: Ride[];
|
||||
@@ -115,10 +118,19 @@ export function RideListView({ rides, onRideClick }: RideListViewProps) {
|
||||
{formatCategory(ride.category)}
|
||||
</Badge>
|
||||
{ride.manufacturer && (
|
||||
<Badge variant="outline" className="text-xs backdrop-blur-sm border-accent/20 group-hover:border-accent/40 transition-colors duration-300">
|
||||
<Factory className="w-3 h-3 mr-1" />
|
||||
{ride.manufacturer.name}
|
||||
</Badge>
|
||||
<HoverCard openDelay={300}>
|
||||
<HoverCardTrigger asChild>
|
||||
<Link to={`/manufacturers/${ride.manufacturer.slug}`}>
|
||||
<Badge variant="outline" className="text-xs backdrop-blur-sm border-accent/20 group-hover:border-accent/40 transition-colors duration-300 hover:bg-accent/10 cursor-pointer">
|
||||
<Factory className="w-3 h-3 mr-1" />
|
||||
{ride.manufacturer.name}
|
||||
</Badge>
|
||||
</Link>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent side="top" className="w-auto">
|
||||
<CompanyPreviewCard slug={ride.manufacturer.slug} />
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -17,7 +17,7 @@ interface TimelineEventCardProps {
|
||||
|
||||
// ⚠️ IMPORTANT: Use parseDateForDisplay to prevent timezone shifts
|
||||
// YYYY-MM-DD strings must be interpreted as local dates, not UTC
|
||||
const formatEventDate = (date: string, precision: string = 'day') => {
|
||||
const formatEventDate = (date: string, precision: string = 'exact') => {
|
||||
const dateObj = parseDateForDisplay(date);
|
||||
|
||||
switch (precision) {
|
||||
|
||||
@@ -72,7 +72,7 @@ const timelineEventSchema = z.object({
|
||||
event_date: z.date({
|
||||
message: 'Event date is required',
|
||||
}),
|
||||
event_date_precision: z.enum(['day', 'month', 'year']).default('day'),
|
||||
event_date_precision: z.enum(['exact', 'month', 'year', 'decade', 'century', 'approximate']).default('exact'),
|
||||
title: z.string().min(1, 'Title is required').max(200, 'Title is too long'),
|
||||
description: z.string().max(1000, 'Description is too long').optional(),
|
||||
|
||||
@@ -133,7 +133,7 @@ export function TimelineEventEditorDialog({
|
||||
} : {
|
||||
event_type: 'milestone',
|
||||
event_date: new Date(),
|
||||
event_date_precision: 'day',
|
||||
event_date_precision: 'exact',
|
||||
title: '',
|
||||
description: '',
|
||||
},
|
||||
@@ -319,9 +319,12 @@ export function TimelineEventEditorDialog({
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="day">Exact Day</SelectItem>
|
||||
<SelectItem value="exact">Exact Day</SelectItem>
|
||||
<SelectItem value="month">Month Only</SelectItem>
|
||||
<SelectItem value="year">Year Only</SelectItem>
|
||||
<SelectItem value="decade">Decade</SelectItem>
|
||||
<SelectItem value="century">Century</SelectItem>
|
||||
<SelectItem value="approximate">Approximate</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
|
||||
264
src/components/ui/FormFieldWrapper.README.md
Normal file
264
src/components/ui/FormFieldWrapper.README.md
Normal file
@@ -0,0 +1,264 @@
|
||||
# FormFieldWrapper Component
|
||||
|
||||
A unified form field component that automatically provides hints, validation messages, and terminology tooltips based on field type.
|
||||
|
||||
## Features
|
||||
|
||||
- ✅ **Automatic hints** based on field type (speed, height, URL, email, etc.)
|
||||
- ✅ **Built-in validation** display with error messages
|
||||
- ✅ **Terminology tooltips** on labels (hover to see definitions)
|
||||
- ✅ **Character counting** for textareas
|
||||
- ✅ **50% less boilerplate** compared to manual field creation
|
||||
- ✅ **Type-safe** with TypeScript
|
||||
- ✅ **Consistent styling** across all forms
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Before (Manual)
|
||||
```tsx
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="website_url">Website URL</Label>
|
||||
<Input
|
||||
id="website_url"
|
||||
type="url"
|
||||
{...register('website_url')}
|
||||
placeholder="https://..."
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Official website URL (must start with https:// or http://)
|
||||
</p>
|
||||
{errors.website_url && (
|
||||
<p className="text-sm text-destructive">{errors.website_url.message}</p>
|
||||
)}
|
||||
</div>
|
||||
```
|
||||
|
||||
### After (With FormFieldWrapper)
|
||||
```tsx
|
||||
<FormFieldWrapper
|
||||
id="website_url"
|
||||
label="Website URL"
|
||||
fieldType="url"
|
||||
error={errors.website_url?.message as string}
|
||||
inputProps={{
|
||||
...register('website_url'),
|
||||
placeholder: "https://..."
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```tsx
|
||||
import { FormFieldWrapper } from '@/components/ui/form-field-wrapper';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
function MyForm() {
|
||||
const { register, formState: { errors } } = useForm();
|
||||
|
||||
return (
|
||||
<form>
|
||||
{/* Basic text input with automatic hint */}
|
||||
<FormFieldWrapper
|
||||
id="email"
|
||||
label="Email Address"
|
||||
fieldType="email"
|
||||
required
|
||||
error={errors.email?.message as string}
|
||||
inputProps={{
|
||||
...register('email', { required: 'Email is required' }),
|
||||
placeholder: "contact@example.com"
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Textarea with character count */}
|
||||
<FormFieldWrapper
|
||||
id="notes"
|
||||
label="Notes for Reviewers"
|
||||
fieldType="submission-notes"
|
||||
optional
|
||||
value={watch('notes')}
|
||||
maxLength={1000}
|
||||
error={errors.notes?.message as string}
|
||||
textareaProps={{
|
||||
...register('notes'),
|
||||
placeholder: "Add context...",
|
||||
rows: 3
|
||||
}}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## With Terminology Tooltips
|
||||
|
||||
```tsx
|
||||
<FormFieldWrapper
|
||||
id="inversions"
|
||||
label="Inversions"
|
||||
fieldType="inversions"
|
||||
termKey="inversion" // Adds tooltip explaining what inversions are
|
||||
error={errors.inversions?.message as string}
|
||||
inputProps={{
|
||||
...register('inversions'),
|
||||
type: "number",
|
||||
placeholder: "e.g. 7"
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
## Using Presets
|
||||
|
||||
```tsx
|
||||
import { FormFieldWrapper, formFieldPresets } from '@/components/ui/form-field-wrapper';
|
||||
|
||||
<FormFieldWrapper
|
||||
{...formFieldPresets.sourceUrl({})}
|
||||
id="source_url"
|
||||
error={errors.source_url?.message as string}
|
||||
inputProps={{
|
||||
...register('source_url'),
|
||||
placeholder: "https://..."
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
## Available Field Types
|
||||
|
||||
- `url` - Website URLs with protocol hint
|
||||
- `email` - Email addresses with format hint
|
||||
- `phone` - Phone numbers with flexible format hint
|
||||
- `slug` - URL slugs with character restrictions
|
||||
- `height-requirement` - Height in cm with metric hint
|
||||
- `age-requirement` - Age requirements
|
||||
- `capacity` - Capacity per hour
|
||||
- `duration` - Duration in seconds
|
||||
- `speed` - Max speed (km/h)
|
||||
- `height` - Max height (meters)
|
||||
- `length` - Track length (meters)
|
||||
- `inversions` - Number of inversions
|
||||
- `g-force` - G-force values
|
||||
- `source-url` - Reference URL for verification
|
||||
- `submission-notes` - Notes for moderators (textarea with char count)
|
||||
|
||||
## Available Presets
|
||||
|
||||
```tsx
|
||||
formFieldPresets.websiteUrl({})
|
||||
formFieldPresets.email({})
|
||||
formFieldPresets.phone({})
|
||||
formFieldPresets.sourceUrl({})
|
||||
formFieldPresets.submissionNotes({})
|
||||
formFieldPresets.heightRequirement({})
|
||||
formFieldPresets.capacity({})
|
||||
formFieldPresets.duration({})
|
||||
formFieldPresets.speed({})
|
||||
formFieldPresets.height({})
|
||||
formFieldPresets.length({})
|
||||
formFieldPresets.inversions({})
|
||||
formFieldPresets.gForce({})
|
||||
```
|
||||
|
||||
## Custom Hints
|
||||
|
||||
Override automatic hints with custom text:
|
||||
|
||||
```tsx
|
||||
<FormFieldWrapper
|
||||
id="custom"
|
||||
label="Custom Field"
|
||||
fieldType="text"
|
||||
hint="This is my custom hint that overrides any automatic hint"
|
||||
inputProps={{...register('custom')}}
|
||||
/>
|
||||
```
|
||||
|
||||
## Hide Hints
|
||||
|
||||
```tsx
|
||||
<FormFieldWrapper
|
||||
id="no_hint"
|
||||
label="Field Without Hint"
|
||||
fieldType="url"
|
||||
hideHint
|
||||
inputProps={{...register('no_hint')}}
|
||||
/>
|
||||
```
|
||||
|
||||
## Migration Guide
|
||||
|
||||
To migrate existing fields:
|
||||
|
||||
1. **Identify the field structure** to replace
|
||||
2. **Choose appropriate `fieldType`** from the list above
|
||||
3. **Add `termKey`** if field relates to terminology
|
||||
4. **Replace** the entire div block with `FormFieldWrapper`
|
||||
|
||||
Example migration:
|
||||
|
||||
```tsx
|
||||
// BEFORE
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="max_speed_kmh">Max Speed (km/h)</Label>
|
||||
<Input
|
||||
id="max_speed_kmh"
|
||||
type="number"
|
||||
{...register('max_speed_kmh')}
|
||||
placeholder="e.g. 193"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Speed must be in km/h, between 0-500. Example: "193" for 193 km/h (120 mph)
|
||||
</p>
|
||||
{errors.max_speed_kmh && (
|
||||
<p className="text-sm text-destructive">{errors.max_speed_kmh.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
// AFTER
|
||||
<FormFieldWrapper
|
||||
id="max_speed_kmh"
|
||||
label="Max Speed (km/h)"
|
||||
fieldType="speed"
|
||||
termKey="kilometers-per-hour"
|
||||
error={errors.max_speed_kmh?.message as string}
|
||||
inputProps={{
|
||||
...register('max_speed_kmh'),
|
||||
type: "number",
|
||||
placeholder: "e.g. 193"
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
## Demo
|
||||
|
||||
View a live interactive demo at `/examples/form-field-wrapper` (in development mode) by visiting the `FormFieldWrapperDemo` component.
|
||||
|
||||
## Props Reference
|
||||
|
||||
| Prop | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `id` | `string` | Field identifier (required) |
|
||||
| `label` | `string` | Field label text (required) |
|
||||
| `fieldType` | `FormFieldType` | Type for automatic hints |
|
||||
| `termKey` | `string` | Terminology key for tooltip |
|
||||
| `showTermIcon` | `boolean` | Show tooltip icon (default: true) |
|
||||
| `required` | `boolean` | Show required asterisk |
|
||||
| `optional` | `boolean` | Show optional badge |
|
||||
| `hint` | `string` | Custom hint (overrides automatic) |
|
||||
| `error` | `string` | Error message from validation |
|
||||
| `value` | `string \| number` | Current value for char counting |
|
||||
| `maxLength` | `number` | Max length for char counting |
|
||||
| `inputProps` | `InputProps` | Props to pass to Input |
|
||||
| `textareaProps` | `TextareaProps` | Props to pass to Textarea |
|
||||
| `className` | `string` | Additional wrapper classes |
|
||||
| `hideHint` | `boolean` | Hide automatic hint |
|
||||
|
||||
## Benefits
|
||||
|
||||
1. **Consistency** - All fields follow the same structure
|
||||
2. **Less Code** - ~50% reduction in boilerplate
|
||||
3. **Smart Defaults** - Automatic hints based on field type
|
||||
4. **Built-in Terminology** - Hover tooltips for technical terms
|
||||
5. **Easy Updates** - Change hints in one place, updates everywhere
|
||||
6. **Type Safety** - TypeScript ensures correct usage
|
||||
@@ -1,9 +1,30 @@
|
||||
import * as React from "react";
|
||||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Collapsible = CollapsiblePrimitive.Root;
|
||||
|
||||
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
|
||||
|
||||
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;
|
||||
const CollapsibleContent = React.forwardRef<
|
||||
React.ElementRef<typeof CollapsiblePrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof CollapsiblePrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<CollapsiblePrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"overflow-hidden transition-all duration-300 ease-out",
|
||||
"data-[state=closed]:animate-accordion-up",
|
||||
"data-[state=open]:animate-accordion-down",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="animate-fade-in">
|
||||
{children}
|
||||
</div>
|
||||
</CollapsiblePrimitive.Content>
|
||||
));
|
||||
CollapsibleContent.displayName = "CollapsibleContent";
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
|
||||
|
||||
@@ -11,7 +11,7 @@ interface FlexibleDateDisplayProps {
|
||||
|
||||
export function FlexibleDateDisplay({
|
||||
date,
|
||||
precision = 'day',
|
||||
precision = 'exact',
|
||||
fallback = 'Unknown',
|
||||
className
|
||||
}: FlexibleDateDisplayProps) {
|
||||
@@ -36,7 +36,16 @@ export function FlexibleDateDisplay({
|
||||
case 'month':
|
||||
formatted = format(dateObj, 'MMMM yyyy');
|
||||
break;
|
||||
case 'day':
|
||||
case 'decade':
|
||||
formatted = `${Math.floor(dateObj.getFullYear() / 10) * 10}s`;
|
||||
break;
|
||||
case 'century':
|
||||
formatted = `${Math.ceil(dateObj.getFullYear() / 100)}th century`;
|
||||
break;
|
||||
case 'approximate':
|
||||
formatted = `circa ${format(dateObj, 'yyyy')}`;
|
||||
break;
|
||||
case 'exact':
|
||||
default:
|
||||
formatted = format(dateObj, 'PPP');
|
||||
break;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from "react";
|
||||
import { format } from "date-fns";
|
||||
import { CalendarIcon } from "lucide-react";
|
||||
import { CalendarIcon, Info } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DatePicker } from "@/components/ui/date-picker";
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { toDateOnly, toDateWithPrecision } from "@/lib/dateUtils";
|
||||
|
||||
export type DatePrecision = 'day' | 'month' | 'year';
|
||||
export type DatePrecision = 'exact' | 'month' | 'year' | 'decade' | 'century' | 'approximate';
|
||||
|
||||
interface FlexibleDateInputProps {
|
||||
value?: Date;
|
||||
@@ -34,7 +34,7 @@ interface FlexibleDateInputProps {
|
||||
|
||||
export function FlexibleDateInput({
|
||||
value,
|
||||
precision = 'day',
|
||||
precision = 'exact',
|
||||
onChange,
|
||||
placeholder = "Select date",
|
||||
disabled = false,
|
||||
@@ -71,13 +71,16 @@ export function FlexibleDateInput({
|
||||
let newDate: Date;
|
||||
switch (newPrecision) {
|
||||
case 'year':
|
||||
case 'decade':
|
||||
case 'century':
|
||||
case 'approximate':
|
||||
newDate = new Date(year, 0, 1); // January 1st (local timezone)
|
||||
setYearValue(year.toString());
|
||||
break;
|
||||
case 'month':
|
||||
newDate = new Date(year, month, 1); // 1st of month (local timezone)
|
||||
break;
|
||||
case 'day':
|
||||
case 'exact':
|
||||
default:
|
||||
newDate = value; // Keep existing date
|
||||
break;
|
||||
@@ -104,25 +107,47 @@ export function FlexibleDateInput({
|
||||
const getPlaceholderText = () => {
|
||||
switch (localPrecision) {
|
||||
case 'year':
|
||||
case 'decade':
|
||||
case 'century':
|
||||
case 'approximate':
|
||||
return 'Enter year (e.g., 2005)';
|
||||
case 'month':
|
||||
return 'Select month and year';
|
||||
case 'day':
|
||||
case 'exact':
|
||||
default:
|
||||
return placeholder;
|
||||
}
|
||||
};
|
||||
|
||||
const getPrecisionHelpText = () => {
|
||||
switch (localPrecision) {
|
||||
case 'exact':
|
||||
return 'Use when you know the specific day (e.g., June 15, 2010)';
|
||||
case 'month':
|
||||
return 'Use when you only know the month (e.g., June 2010)';
|
||||
case 'year':
|
||||
return 'Use when you only know the year (e.g., 2010)';
|
||||
case 'decade':
|
||||
return 'Use for events in a general decade (e.g., 1980s). Enter any year from that decade.';
|
||||
case 'century':
|
||||
return 'Use for very old dates spanning a century (e.g., 19th century). Enter any year from that century.';
|
||||
case 'approximate':
|
||||
return 'Use when the date is uncertain or estimated (e.g., circa 2010)';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-2", className)}>
|
||||
{label && <Label>{label}</Label>}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
{localPrecision === 'day' && (
|
||||
{(localPrecision === 'exact') && (
|
||||
<DatePicker
|
||||
date={value}
|
||||
onSelect={(date) => onChange(date, 'day')}
|
||||
onSelect={(date) => onChange(date, 'exact')}
|
||||
placeholder={getPlaceholderText()}
|
||||
disabled={disabled}
|
||||
disableFuture={disableFuture}
|
||||
@@ -143,7 +168,7 @@ export function FlexibleDateInput({
|
||||
/>
|
||||
)}
|
||||
|
||||
{localPrecision === 'year' && (
|
||||
{(localPrecision === 'year' || localPrecision === 'decade' || localPrecision === 'century' || localPrecision === 'approximate') && (
|
||||
<Input
|
||||
type="number"
|
||||
value={yearValue}
|
||||
@@ -166,12 +191,20 @@ export function FlexibleDateInput({
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="day">Use Full Date</SelectItem>
|
||||
<SelectItem value="month">Use Month/Year</SelectItem>
|
||||
<SelectItem value="year">Use Year Only</SelectItem>
|
||||
<SelectItem value="exact">Exact Day</SelectItem>
|
||||
<SelectItem value="month">Month & Year</SelectItem>
|
||||
<SelectItem value="year">Year Only</SelectItem>
|
||||
<SelectItem value="decade">Decade</SelectItem>
|
||||
<SelectItem value="century">Century</SelectItem>
|
||||
<SelectItem value="approximate">Approximate</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2 text-xs text-muted-foreground">
|
||||
<Info className="h-3.5 w-3.5 mt-0.5 flex-shrink-0" />
|
||||
<p>{getPrecisionHelpText()}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
413
src/components/ui/form-field-wrapper.tsx
Normal file
413
src/components/ui/form-field-wrapper.tsx
Normal file
@@ -0,0 +1,413 @@
|
||||
import * as React from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { TermTooltip } from "@/components/ui/term-tooltip";
|
||||
import { fieldHints } from "@/lib/enhancedValidation";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CheckCircle2, AlertCircle } from "lucide-react";
|
||||
|
||||
/**
|
||||
* Field types that automatically get hints and terminology support
|
||||
*/
|
||||
export type FormFieldType =
|
||||
| 'text'
|
||||
| 'number'
|
||||
| 'url'
|
||||
| 'email'
|
||||
| 'phone'
|
||||
| 'textarea'
|
||||
| 'slug'
|
||||
| 'height-requirement'
|
||||
| 'age-requirement'
|
||||
| 'capacity'
|
||||
| 'duration'
|
||||
| 'speed'
|
||||
| 'height'
|
||||
| 'length'
|
||||
| 'inversions'
|
||||
| 'g-force'
|
||||
| 'source-url'
|
||||
| 'submission-notes';
|
||||
|
||||
interface FormFieldWrapperProps {
|
||||
/** Field identifier */
|
||||
id: string;
|
||||
|
||||
/** Field label text */
|
||||
label: string;
|
||||
|
||||
/** Field type - determines automatic hints and validation */
|
||||
fieldType?: FormFieldType;
|
||||
|
||||
/** Terminology key for tooltip (e.g., 'lsm', 'rmc') */
|
||||
termKey?: string;
|
||||
|
||||
/** Show tooltip icon on label */
|
||||
showTermIcon?: boolean;
|
||||
|
||||
/** Whether field is required */
|
||||
required?: boolean;
|
||||
|
||||
/** Whether field is optional (shows badge) */
|
||||
optional?: boolean;
|
||||
|
||||
/** Custom hint text (overrides automatic hint) */
|
||||
hint?: string;
|
||||
|
||||
/** Error message from validation (pass errors.field?.message) */
|
||||
error?: string;
|
||||
|
||||
/** Current value for character counting */
|
||||
value?: string | number;
|
||||
|
||||
/** Maximum length for character counting */
|
||||
maxLength?: number;
|
||||
|
||||
/** Input props to pass through */
|
||||
inputProps?: React.ComponentProps<typeof Input>;
|
||||
|
||||
/** Textarea props to pass through (when fieldType is 'textarea') */
|
||||
textareaProps?: React.ComponentProps<typeof Textarea>;
|
||||
|
||||
/** Additional className for wrapper */
|
||||
className?: string;
|
||||
|
||||
/** Hide automatic hint */
|
||||
hideHint?: boolean;
|
||||
|
||||
/** When to show validation feedback */
|
||||
validationMode?: 'realtime' | 'onBlur';
|
||||
|
||||
/** Callback when field is blurred (for onBlur mode) */
|
||||
onBlur?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get automatic hint based on field type
|
||||
*/
|
||||
function getAutoHint(fieldType?: FormFieldType): string | undefined {
|
||||
if (!fieldType) return undefined;
|
||||
|
||||
const hintMap: Record<FormFieldType, string | undefined> = {
|
||||
'text': undefined,
|
||||
'number': undefined,
|
||||
'url': fieldHints.websiteUrl,
|
||||
'email': fieldHints.email,
|
||||
'phone': fieldHints.phone,
|
||||
'textarea': undefined,
|
||||
'slug': fieldHints.slug,
|
||||
'height-requirement': fieldHints.heightRequirement,
|
||||
'age-requirement': fieldHints.ageRequirement,
|
||||
'capacity': fieldHints.capacity,
|
||||
'duration': fieldHints.duration,
|
||||
'speed': fieldHints.speed,
|
||||
'height': fieldHints.height,
|
||||
'length': fieldHints.length,
|
||||
'inversions': fieldHints.inversions,
|
||||
'g-force': fieldHints.gForce,
|
||||
'source-url': fieldHints.sourceUrl,
|
||||
'submission-notes': fieldHints.submissionNotes,
|
||||
};
|
||||
|
||||
return hintMap[fieldType];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get input type from field type
|
||||
*/
|
||||
function getInputType(fieldType?: FormFieldType): string {
|
||||
if (!fieldType) return 'text';
|
||||
|
||||
const typeMap: Record<FormFieldType, string> = {
|
||||
'text': 'text',
|
||||
'number': 'number',
|
||||
'url': 'url',
|
||||
'email': 'email',
|
||||
'phone': 'tel',
|
||||
'textarea': 'text',
|
||||
'slug': 'text',
|
||||
'height-requirement': 'number',
|
||||
'age-requirement': 'number',
|
||||
'capacity': 'number',
|
||||
'duration': 'number',
|
||||
'speed': 'number',
|
||||
'height': 'number',
|
||||
'length': 'number',
|
||||
'inversions': 'number',
|
||||
'g-force': 'number',
|
||||
'source-url': 'url',
|
||||
'submission-notes': 'text',
|
||||
};
|
||||
|
||||
return typeMap[fieldType] || 'text';
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified form field wrapper with automatic hints, validation, and terminology
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <FormFieldWrapper
|
||||
* id="website_url"
|
||||
* label="Website URL"
|
||||
* fieldType="url"
|
||||
* error={errors.website_url?.message}
|
||||
* inputProps={{...register('website_url'), placeholder: "https://..."}}
|
||||
* />
|
||||
* ```
|
||||
*
|
||||
* @example With terminology tooltip
|
||||
* ```tsx
|
||||
* <FormFieldWrapper
|
||||
* id="propulsion"
|
||||
* label="Propulsion Method"
|
||||
* fieldType="text"
|
||||
* termKey="lsm"
|
||||
* hint="Common: LSM Launch, Chain Lift, Hydraulic Launch"
|
||||
* inputProps={{...register('propulsion')}}
|
||||
* />
|
||||
* ```
|
||||
*
|
||||
* @example Textarea with character count
|
||||
* ```tsx
|
||||
* <FormFieldWrapper
|
||||
* id="notes"
|
||||
* label="Notes"
|
||||
* fieldType="submission-notes"
|
||||
* optional
|
||||
* value={watch('notes')}
|
||||
* maxLength={1000}
|
||||
* textareaProps={{...register('notes'), rows: 3}}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export function FormFieldWrapper({
|
||||
id,
|
||||
label,
|
||||
fieldType,
|
||||
termKey,
|
||||
showTermIcon = true,
|
||||
required = false,
|
||||
optional = false,
|
||||
hint,
|
||||
error,
|
||||
value,
|
||||
maxLength,
|
||||
inputProps,
|
||||
textareaProps,
|
||||
className,
|
||||
hideHint = false,
|
||||
validationMode = 'realtime',
|
||||
onBlur,
|
||||
}: FormFieldWrapperProps) {
|
||||
const [hasBlurred, setHasBlurred] = React.useState(false);
|
||||
const isTextarea = fieldType === 'textarea' || fieldType === 'submission-notes';
|
||||
const autoHint = getAutoHint(fieldType);
|
||||
const displayHint = hint || autoHint;
|
||||
const inputType = getInputType(fieldType);
|
||||
|
||||
// Character count for textareas with maxLength
|
||||
const showCharCount = isTextarea && maxLength && typeof value === 'string';
|
||||
const charCount = typeof value === 'string' ? value.length : 0;
|
||||
|
||||
// Determine validation state
|
||||
const shouldShowValidation = validationMode === 'realtime' || (validationMode === 'onBlur' && hasBlurred);
|
||||
const hasValue = value !== undefined && value !== null && value !== '';
|
||||
const isValid = shouldShowValidation && !error && hasValue;
|
||||
const hasError = shouldShowValidation && !!error;
|
||||
|
||||
// Blur handler
|
||||
const handleBlur = (e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
setHasBlurred(true);
|
||||
if (validationMode === 'onBlur' && onBlur) {
|
||||
onBlur();
|
||||
}
|
||||
// Call original onBlur if provided
|
||||
if ('value' in e.target && textareaProps?.onBlur) {
|
||||
textareaProps.onBlur(e as React.FocusEvent<HTMLTextAreaElement>);
|
||||
} else if (inputProps?.onBlur) {
|
||||
inputProps.onBlur(e as React.FocusEvent<HTMLInputElement>);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-2", className)} data-error={hasError ? "true" : undefined}>
|
||||
{/* Label with optional terminology tooltip */}
|
||||
<Label htmlFor={id} className="flex items-center gap-2">
|
||||
{termKey ? (
|
||||
<TermTooltip term={termKey} showIcon={showTermIcon}>
|
||||
{label}
|
||||
</TermTooltip>
|
||||
) : (
|
||||
label
|
||||
)}
|
||||
{required && <span className="text-destructive">*</span>}
|
||||
{optional && (
|
||||
<span className="text-xs text-muted-foreground font-normal">
|
||||
(Optional)
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
|
||||
{/* Input or Textarea with validation icons */}
|
||||
<div className="relative">
|
||||
{isTextarea ? (
|
||||
<Textarea
|
||||
id={id}
|
||||
className={cn(
|
||||
"pr-10 transition-all duration-300 ease-in-out",
|
||||
"focus:ring-2 focus:ring-primary/20 focus:border-primary",
|
||||
error && "border-destructive focus:ring-destructive/20",
|
||||
isValid && "border-green-500/50 focus:ring-green-500/20"
|
||||
)}
|
||||
maxLength={maxLength}
|
||||
onBlur={handleBlur}
|
||||
{...textareaProps}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
id={id}
|
||||
type={inputType}
|
||||
className={cn(
|
||||
"pr-10 transition-all duration-300 ease-in-out",
|
||||
"focus:ring-2 focus:ring-primary/20 focus:border-primary",
|
||||
error && "border-destructive focus:ring-destructive/20",
|
||||
isValid && "border-green-500/50 focus:ring-green-500/20"
|
||||
)}
|
||||
maxLength={maxLength}
|
||||
onBlur={handleBlur}
|
||||
{...inputProps}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Validation icon with animation */}
|
||||
{(isValid || hasError) && (
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none">
|
||||
{isValid && (
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500 animate-fade-in" />
|
||||
)}
|
||||
{hasError && (
|
||||
<AlertCircle className="h-4 w-4 text-destructive animate-fade-in" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Hint text (if not hidden and exists) */}
|
||||
{!hideHint && displayHint && !error && (
|
||||
<p className="text-xs text-muted-foreground animate-slide-in-down">
|
||||
{displayHint}
|
||||
{showCharCount && ` (${charCount}/${maxLength} characters)`}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Character count only (when no hint) */}
|
||||
{!hideHint && !displayHint && showCharCount && !error && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{charCount}/{maxLength} characters
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Error message with animation */}
|
||||
{error && (
|
||||
<p className="text-sm text-destructive animate-slide-in-down">
|
||||
{error}
|
||||
{showCharCount && ` (${charCount}/${maxLength})`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Preset configurations for common field types
|
||||
*/
|
||||
export const formFieldPresets = {
|
||||
websiteUrl: (props: Partial<FormFieldWrapperProps>) => ({
|
||||
fieldType: 'url' as FormFieldType,
|
||||
label: 'Website URL',
|
||||
validationMode: 'onBlur',
|
||||
...props,
|
||||
}),
|
||||
|
||||
email: (props: Partial<FormFieldWrapperProps>) => ({
|
||||
fieldType: 'email' as FormFieldType,
|
||||
label: 'Email',
|
||||
...props,
|
||||
}),
|
||||
|
||||
phone: (props: Partial<FormFieldWrapperProps>) => ({
|
||||
fieldType: 'phone' as FormFieldType,
|
||||
label: 'Phone Number',
|
||||
...props,
|
||||
}),
|
||||
|
||||
sourceUrl: (props: Partial<FormFieldWrapperProps>) => ({
|
||||
fieldType: 'source-url' as FormFieldType,
|
||||
label: 'Source URL',
|
||||
optional: true,
|
||||
...props,
|
||||
}),
|
||||
|
||||
submissionNotes: (props: Partial<FormFieldWrapperProps>) => ({
|
||||
fieldType: 'submission-notes' as FormFieldType,
|
||||
label: 'Notes for Reviewers',
|
||||
optional: true,
|
||||
maxLength: 1000,
|
||||
...props,
|
||||
}),
|
||||
|
||||
heightRequirement: (props: Partial<FormFieldWrapperProps>) => ({
|
||||
fieldType: 'height-requirement' as FormFieldType,
|
||||
label: 'Height Requirement',
|
||||
...props,
|
||||
}),
|
||||
|
||||
capacity: (props: Partial<FormFieldWrapperProps>) => ({
|
||||
fieldType: 'capacity' as FormFieldType,
|
||||
label: 'Capacity per Hour',
|
||||
...props,
|
||||
}),
|
||||
|
||||
duration: (props: Partial<FormFieldWrapperProps>) => ({
|
||||
fieldType: 'duration' as FormFieldType,
|
||||
label: 'Duration (seconds)',
|
||||
...props,
|
||||
}),
|
||||
|
||||
speed: (props: Partial<FormFieldWrapperProps>) => ({
|
||||
fieldType: 'speed' as FormFieldType,
|
||||
label: 'Max Speed',
|
||||
termKey: 'kilometers-per-hour',
|
||||
...props,
|
||||
}),
|
||||
|
||||
height: (props: Partial<FormFieldWrapperProps>) => ({
|
||||
fieldType: 'height' as FormFieldType,
|
||||
label: 'Max Height',
|
||||
termKey: 'meters',
|
||||
...props,
|
||||
}),
|
||||
|
||||
length: (props: Partial<FormFieldWrapperProps>) => ({
|
||||
fieldType: 'length' as FormFieldType,
|
||||
label: 'Track Length',
|
||||
termKey: 'meters',
|
||||
...props,
|
||||
}),
|
||||
|
||||
inversions: (props: Partial<FormFieldWrapperProps>) => ({
|
||||
fieldType: 'inversions' as FormFieldType,
|
||||
label: 'Inversions',
|
||||
termKey: 'inversion',
|
||||
...props,
|
||||
}),
|
||||
|
||||
gForce: (props: Partial<FormFieldWrapperProps>) => ({
|
||||
fieldType: 'g-force' as FormFieldType,
|
||||
label: 'Max G-Force',
|
||||
termKey: 'g-force',
|
||||
...props,
|
||||
}),
|
||||
};
|
||||
56
src/components/ui/term-tooltip.tsx
Normal file
56
src/components/ui/term-tooltip.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { HelpCircle } from "lucide-react";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { getGlossaryTerm } from "@/lib/glossary";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface TermTooltipProps {
|
||||
term: string;
|
||||
children: React.ReactNode;
|
||||
inline?: boolean;
|
||||
showIcon?: boolean;
|
||||
}
|
||||
|
||||
export function TermTooltip({ term, children, inline = false, showIcon = true }: TermTooltipProps) {
|
||||
const glossaryEntry = getGlossaryTerm(term);
|
||||
|
||||
if (!glossaryEntry) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className={cn(
|
||||
"inline-flex items-center gap-1",
|
||||
inline && "underline decoration-dotted cursor-help"
|
||||
)}>
|
||||
{children}
|
||||
{showIcon && (
|
||||
<HelpCircle className="inline-block w-3 h-3 text-muted-foreground" />
|
||||
)}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs" side="top">
|
||||
<div className="space-y-1">
|
||||
<div className="font-semibold text-sm">{glossaryEntry.term}</div>
|
||||
<p className="text-xs text-muted-foreground capitalize">
|
||||
{glossaryEntry.category.replace('-', ' ')}
|
||||
</p>
|
||||
<p className="text-sm">{glossaryEntry.definition}</p>
|
||||
{glossaryEntry.example && (
|
||||
<p className="text-xs text-muted-foreground italic pt-1">
|
||||
Example: {glossaryEntry.example}
|
||||
</p>
|
||||
)}
|
||||
{glossaryEntry.relatedTerms && glossaryEntry.relatedTerms.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground pt-1">
|
||||
See also: {glossaryEntry.relatedTerms.map(t =>
|
||||
getGlossaryTerm(t)?.term || t
|
||||
).join(', ')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
87
src/components/ui/validated-input.tsx
Normal file
87
src/components/ui/validated-input.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import * as React from "react";
|
||||
import { Check, AlertCircle } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
|
||||
export interface ValidatedInputProps extends React.ComponentProps<typeof Input> {
|
||||
validation?: {
|
||||
isValid?: boolean;
|
||||
error?: string;
|
||||
hint?: string;
|
||||
};
|
||||
showValidation?: boolean;
|
||||
onValidate?: (value: string) => { isValid: boolean; error?: string };
|
||||
}
|
||||
|
||||
const ValidatedInput = React.forwardRef<HTMLInputElement, ValidatedInputProps>(
|
||||
({ className, validation, showValidation = true, onValidate, onChange, ...props }, ref) => {
|
||||
const [localValidation, setLocalValidation] = React.useState<{
|
||||
isValid?: boolean;
|
||||
error?: string;
|
||||
}>({});
|
||||
|
||||
const debouncedValidate = useDebouncedCallback((value: string) => {
|
||||
if (onValidate) {
|
||||
const result = onValidate(value);
|
||||
setLocalValidation(result);
|
||||
}
|
||||
}, 300);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onChange?.(e);
|
||||
if (onValidate && showValidation) {
|
||||
debouncedValidate(e.target.value);
|
||||
}
|
||||
};
|
||||
|
||||
const validationState = validation || localValidation;
|
||||
const showSuccess = showValidation && validationState.isValid && props.value;
|
||||
const showError = showValidation && validationState.error;
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="relative">
|
||||
<Input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
showError && "border-destructive focus-visible:ring-destructive",
|
||||
showSuccess && "border-green-500 focus-visible:ring-green-500",
|
||||
"pr-8",
|
||||
className
|
||||
)}
|
||||
onChange={handleChange}
|
||||
{...props}
|
||||
/>
|
||||
{showValidation && props.value && (
|
||||
<div className="absolute right-3 top-1/2 transform -translate-y-1/2">
|
||||
{validationState.isValid && (
|
||||
<Check className="w-4 h-4 text-green-500" />
|
||||
)}
|
||||
{validationState.error && (
|
||||
<AlertCircle className="w-4 h-4 text-destructive" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showValidation && validation?.hint && !validationState.error && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{validation.hint}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{showError && (
|
||||
<p className="text-xs text-destructive flex items-center gap-1">
|
||||
<AlertCircle className="w-3 h-3" />
|
||||
{validationState.error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
ValidatedInput.displayName = "ValidatedInput";
|
||||
|
||||
export { ValidatedInput };
|
||||
@@ -1,30 +1,70 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import { AlertTriangle, Plus, Minus, Edit } from 'lucide-react';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { useEntityVersions } from '@/hooks/useEntityVersions';
|
||||
import type { EntityType } from '@/types/versioning';
|
||||
|
||||
interface RollbackDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
versionId: string;
|
||||
entityType: string;
|
||||
entityType: EntityType;
|
||||
entityId: string;
|
||||
entityName: string;
|
||||
onRollback: (reason: string) => Promise<void>;
|
||||
}
|
||||
|
||||
interface VersionDiff {
|
||||
[fieldName: string]: {
|
||||
from: unknown;
|
||||
to: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export function RollbackDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
versionId,
|
||||
entityType,
|
||||
entityId,
|
||||
entityName,
|
||||
onRollback,
|
||||
}: RollbackDialogProps) {
|
||||
const [reason, setReason] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [diff, setDiff] = useState<VersionDiff | null>(null);
|
||||
const [diffLoading, setDiffLoading] = useState(false);
|
||||
|
||||
const { versions, compareVersions } = useEntityVersions(entityType, entityId);
|
||||
const currentVersion = versions[0]; // Most recent version
|
||||
|
||||
// Fetch diff when dialog opens
|
||||
useEffect(() => {
|
||||
const loadDiff = async () => {
|
||||
if (open && versionId && currentVersion?.version_id) {
|
||||
setDiffLoading(true);
|
||||
try {
|
||||
const result = await compareVersions(versionId, currentVersion.version_id);
|
||||
setDiff(result as VersionDiff);
|
||||
} catch (error) {
|
||||
console.error('Failed to load diff:', error);
|
||||
setDiff(null);
|
||||
} finally {
|
||||
setDiffLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadDiff();
|
||||
}, [open, versionId, currentVersion?.version_id, compareVersions]);
|
||||
|
||||
const handleRollback = async () => {
|
||||
if (!reason.trim()) return;
|
||||
@@ -33,15 +73,32 @@ export function RollbackDialog({
|
||||
try {
|
||||
await onRollback(reason);
|
||||
setReason('');
|
||||
setDiff(null);
|
||||
onOpenChange(false);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatValue = (value: any): string => {
|
||||
if (value === null || value === undefined) return 'null';
|
||||
if (typeof value === 'boolean') return value ? 'true' : 'false';
|
||||
if (typeof value === 'object') return JSON.stringify(value, null, 2);
|
||||
return String(value);
|
||||
};
|
||||
|
||||
const formatFieldName = (fieldName: string): string => {
|
||||
return fieldName
|
||||
.split('_')
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
};
|
||||
|
||||
const changedFieldCount = diff ? Object.keys(diff).length : 0;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogContent className="max-w-3xl max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Restore Previous Version (Moderator Action)</DialogTitle>
|
||||
<DialogDescription>
|
||||
@@ -56,6 +113,100 @@ export function RollbackDialog({
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{/* Preview Changes Section */}
|
||||
<Accordion type="single" collapsible defaultValue="preview" className="border rounded-lg">
|
||||
<AccordionItem value="preview" className="border-none">
|
||||
<AccordionTrigger className="px-4 hover:no-underline">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">Preview Changes</span>
|
||||
{changedFieldCount > 0 && (
|
||||
<Badge variant="outline" className="bg-blue-500/10 text-blue-700 dark:text-blue-400">
|
||||
{changedFieldCount} field{changedFieldCount !== 1 ? 's' : ''} will change
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-4 pb-4">
|
||||
<ScrollArea className="h-[300px] pr-4">
|
||||
{diffLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||
</div>
|
||||
) : diff && Object.keys(diff).length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{Object.entries(diff).map(([fieldName, changes]: [string, any]) => {
|
||||
const isAdded = changes.from === null;
|
||||
const isRemoved = changes.to === null;
|
||||
const isModified = changes.from !== null && changes.to !== null;
|
||||
|
||||
return (
|
||||
<div key={fieldName} className="border rounded-md p-3 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{isAdded && <Plus className="h-4 w-4 text-green-500" />}
|
||||
{isRemoved && <Minus className="h-4 w-4 text-red-500" />}
|
||||
{isModified && <Edit className="h-4 w-4 text-blue-500" />}
|
||||
<span className="font-medium text-sm">{formatFieldName(fieldName)}</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
isAdded
|
||||
? 'bg-green-500/10 text-green-700 dark:text-green-400'
|
||||
: isRemoved
|
||||
? 'bg-red-500/10 text-red-700 dark:text-red-400'
|
||||
: 'bg-blue-500/10 text-blue-700 dark:text-blue-400'
|
||||
}
|
||||
>
|
||||
{isAdded ? 'Added' : isRemoved ? 'Removed' : 'Modified'}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{/* Current value (will be replaced) */}
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-muted-foreground font-medium">Current</div>
|
||||
<div
|
||||
className={`p-2 rounded-md font-mono text-xs whitespace-pre-wrap ${
|
||||
isRemoved
|
||||
? 'bg-red-500/10 text-red-700 dark:text-red-400 line-through'
|
||||
: 'bg-muted'
|
||||
}`}
|
||||
>
|
||||
{formatValue(changes.to)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Restored value */}
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-muted-foreground font-medium">After Restore</div>
|
||||
<div
|
||||
className={`p-2 rounded-md font-mono text-xs whitespace-pre-wrap ${
|
||||
isAdded
|
||||
? 'bg-green-500/10 text-green-700 dark:text-green-400 font-semibold'
|
||||
: isModified
|
||||
? 'bg-blue-500/10 text-blue-700 dark:text-blue-400 font-semibold'
|
||||
: 'bg-muted'
|
||||
}`}
|
||||
>
|
||||
{formatValue(changes.from)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<p className="text-sm">No differences found</p>
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="rollback-reason">Reason for rollback *</Label>
|
||||
<Textarea
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { History, Clock } from 'lucide-react';
|
||||
import { History } from 'lucide-react';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { EntityVersionHistory } from './EntityVersionHistory';
|
||||
import { useEntityVersions } from '@/hooks/useEntityVersions';
|
||||
@@ -43,7 +42,7 @@ export function VersionIndicator({
|
||||
>
|
||||
<History className="h-4 w-4" />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
v{currentVersion.version_number}
|
||||
History
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
@@ -66,10 +65,6 @@ export function VersionIndicator({
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge variant="outline" className="gap-1.5">
|
||||
<Clock className="h-3 w-3" />
|
||||
Version {currentVersion.version_number}
|
||||
</Badge>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Last edited {timeAgo}
|
||||
</span>
|
||||
|
||||
135
src/hooks/admin/useDatabaseMaintenance.ts
Normal file
135
src/hooks/admin/useDatabaseMaintenance.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { queryKeys } from '@/lib/queryKeys';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export interface MaintenanceTable {
|
||||
table_name: string;
|
||||
row_count: number;
|
||||
table_size: string;
|
||||
indexes_size: string;
|
||||
total_size: string;
|
||||
}
|
||||
|
||||
export interface MaintenanceResult {
|
||||
table_name: string;
|
||||
operation: string;
|
||||
started_at: string;
|
||||
completed_at: string;
|
||||
duration_ms: number;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function useMaintenanceTables() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.admin.maintenanceTables(),
|
||||
queryFn: async () => {
|
||||
const { data, error } = await supabase.rpc('get_maintenance_tables');
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return data as unknown as MaintenanceTable[];
|
||||
},
|
||||
staleTime: 2 * 60 * 1000, // 2 minutes
|
||||
});
|
||||
}
|
||||
|
||||
export function useVacuumTable() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (tableName: string) => {
|
||||
const { data, error } = await supabase.rpc('run_vacuum_table', {
|
||||
table_name: tableName,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return data as unknown as MaintenanceResult;
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
if (result.success) {
|
||||
toast.success(`Vacuum completed on ${result.table_name}`, {
|
||||
description: `Duration: ${Math.round(result.duration_ms)}ms`,
|
||||
});
|
||||
} else {
|
||||
toast.error(`Vacuum failed on ${result.table_name}`, {
|
||||
description: result.error,
|
||||
});
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.admin.maintenanceTables() });
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error('Vacuum operation failed', {
|
||||
description: error.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useAnalyzeTable() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (tableName: string) => {
|
||||
const { data, error } = await supabase.rpc('run_analyze_table', {
|
||||
table_name: tableName,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return data as unknown as MaintenanceResult;
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
if (result.success) {
|
||||
toast.success(`Analyze completed on ${result.table_name}`, {
|
||||
description: `Duration: ${Math.round(result.duration_ms)}ms`,
|
||||
});
|
||||
} else {
|
||||
toast.error(`Analyze failed on ${result.table_name}`, {
|
||||
description: result.error,
|
||||
});
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.admin.maintenanceTables() });
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error('Analyze operation failed', {
|
||||
description: error.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useReindexTable() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (tableName: string) => {
|
||||
const { data, error } = await supabase.rpc('run_reindex_table', {
|
||||
table_name: tableName,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return data as unknown as MaintenanceResult;
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
if (result.success) {
|
||||
toast.success(`Reindex completed on ${result.table_name}`, {
|
||||
description: `Duration: ${Math.round(result.duration_ms)}ms`,
|
||||
});
|
||||
} else {
|
||||
toast.error(`Reindex failed on ${result.table_name}`, {
|
||||
description: result.error,
|
||||
});
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.admin.maintenanceTables() });
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error('Reindex operation failed', {
|
||||
description: error.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -86,7 +86,7 @@ export function useModerationActions(config: ModerationActionsConfig): Moderatio
|
||||
itemIds: string[],
|
||||
userId?: string,
|
||||
maxConflictRetries: number = 3,
|
||||
timeoutMs: number = 30000
|
||||
timeoutMs: number = 60000 // Increased from 30s to 60s
|
||||
): Promise<{
|
||||
data: T | null;
|
||||
error: any;
|
||||
@@ -337,7 +337,7 @@ export function useModerationActions(config: ModerationActionsConfig): Moderatio
|
||||
submissionItems.map((i) => i.id),
|
||||
config.user?.id,
|
||||
3, // Max 3 conflict retries
|
||||
30000 // 30s timeout
|
||||
60000 // 60s timeout (increased for slow queries)
|
||||
);
|
||||
|
||||
// Log retry attempts
|
||||
@@ -393,7 +393,7 @@ export function useModerationActions(config: ModerationActionsConfig): Moderatio
|
||||
submissionItems.map((i) => i.id),
|
||||
config.user?.id,
|
||||
3, // Max 3 conflict retries
|
||||
30000 // 30s timeout
|
||||
60000 // 60s timeout (increased for slow queries)
|
||||
);
|
||||
|
||||
// Log retry attempts
|
||||
|
||||
@@ -12,7 +12,7 @@ import { useState, useCallback, useEffect } from 'react';
|
||||
import { useDebounce } from '@/hooks/useDebounce';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { MODERATION_CONSTANTS } from '@/lib/moderation/constants';
|
||||
import type { EntityFilter, StatusFilter, QueueTab, SortConfig, SortField } from '@/types/moderation';
|
||||
import type { EntityFilter, StatusFilter, QueueTab, SortConfig, SortField, ApprovalDateRangeFilter } from '@/types/moderation';
|
||||
import * as storage from '@/lib/localStorage';
|
||||
|
||||
export interface ModerationFiltersConfig {
|
||||
@@ -36,6 +36,9 @@ export interface ModerationFiltersConfig {
|
||||
|
||||
/** Initial sort configuration */
|
||||
initialSortConfig?: SortConfig;
|
||||
|
||||
/** Initial approval date range filter */
|
||||
initialApprovalDateRange?: ApprovalDateRangeFilter;
|
||||
}
|
||||
|
||||
export interface ModerationFilters {
|
||||
@@ -87,6 +90,15 @@ export interface ModerationFilters {
|
||||
/** Reset sort to default */
|
||||
resetSort: () => void;
|
||||
|
||||
/** Approval date range filter (immediate) */
|
||||
approvalDateRange: ApprovalDateRangeFilter;
|
||||
|
||||
/** Debounced approval date range (use this for queries) */
|
||||
debouncedApprovalDateRange: ApprovalDateRangeFilter;
|
||||
|
||||
/** Set approval date range */
|
||||
setApprovalDateRange: (range: ApprovalDateRangeFilter) => void;
|
||||
|
||||
/** Reset pagination to page 1 (callback) */
|
||||
onFilterChange?: () => void;
|
||||
}
|
||||
@@ -121,6 +133,7 @@ export function useModerationFilters(
|
||||
persist = true,
|
||||
storageKey = 'moderationQueue_filters',
|
||||
initialSortConfig = { field: 'created_at', direction: 'asc' },
|
||||
initialApprovalDateRange = { from: null, to: null },
|
||||
onFilterChange,
|
||||
} = config;
|
||||
|
||||
@@ -174,6 +187,9 @@ export function useModerationFilters(
|
||||
|
||||
// Sort state
|
||||
const [sortConfig, setSortConfigState] = useState<SortConfig>(loadPersistedSort);
|
||||
|
||||
// Approval date range state
|
||||
const [approvalDateRange, setApprovalDateRangeState] = useState<ApprovalDateRangeFilter>(initialApprovalDateRange);
|
||||
|
||||
// Debounced filters for API calls
|
||||
const debouncedEntityFilter = useDebounce(entityFilter, debounceDelay);
|
||||
@@ -181,6 +197,9 @@ export function useModerationFilters(
|
||||
|
||||
// Debounced sort (0ms for immediate feedback)
|
||||
const debouncedSortConfig = useDebounce(sortConfig, 0);
|
||||
|
||||
// Debounced approval date range
|
||||
const debouncedApprovalDateRange = useDebounce(approvalDateRange, debounceDelay);
|
||||
|
||||
// Persist filters to localStorage
|
||||
useEffect(() => {
|
||||
@@ -246,6 +265,13 @@ export function useModerationFilters(
|
||||
const resetSort = useCallback(() => {
|
||||
setSortConfigState(initialSortConfig);
|
||||
}, [initialSortConfig]);
|
||||
|
||||
// Set approval date range with logging and pagination reset
|
||||
const setApprovalDateRange = useCallback((range: ApprovalDateRangeFilter) => {
|
||||
logger.log('🔍 Approval date range changed:', range);
|
||||
setApprovalDateRangeState(range);
|
||||
onFilterChange?.();
|
||||
}, [onFilterChange]);
|
||||
|
||||
// Clear all filters
|
||||
const clearFilters = useCallback(() => {
|
||||
@@ -254,7 +280,8 @@ export function useModerationFilters(
|
||||
setStatusFilterState(initialStatusFilter);
|
||||
setActiveTabState(initialTab);
|
||||
setSortConfigState(initialSortConfig);
|
||||
}, [initialEntityFilter, initialStatusFilter, initialTab, initialSortConfig]);
|
||||
setApprovalDateRangeState(initialApprovalDateRange);
|
||||
}, [initialEntityFilter, initialStatusFilter, initialTab, initialSortConfig, initialApprovalDateRange]);
|
||||
|
||||
// Check if non-default filters are active
|
||||
const hasActiveFilters =
|
||||
@@ -262,7 +289,9 @@ export function useModerationFilters(
|
||||
statusFilter !== initialStatusFilter ||
|
||||
activeTab !== initialTab ||
|
||||
sortConfig.field !== initialSortConfig.field ||
|
||||
sortConfig.direction !== initialSortConfig.direction;
|
||||
sortConfig.direction !== initialSortConfig.direction ||
|
||||
approvalDateRange.from !== null ||
|
||||
approvalDateRange.to !== null;
|
||||
|
||||
// Return without useMemo wrapper (OPTIMIZED)
|
||||
return {
|
||||
@@ -282,6 +311,9 @@ export function useModerationFilters(
|
||||
sortBy,
|
||||
toggleSortDirection,
|
||||
resetSort,
|
||||
approvalDateRange,
|
||||
debouncedApprovalDateRange,
|
||||
setApprovalDateRange,
|
||||
onFilterChange,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -174,6 +174,7 @@ export function useModerationQueueManager(config: ModerationQueueManagerConfig):
|
||||
currentPage: pagination.currentPage,
|
||||
pageSize: pagination.pageSize,
|
||||
sortConfig: filters.debouncedSortConfig,
|
||||
approvalDateRange: filters.debouncedApprovalDateRange,
|
||||
enabled: !!user,
|
||||
});
|
||||
|
||||
|
||||
@@ -98,6 +98,12 @@ export interface UseQueueQueryConfig {
|
||||
direction: SortDirection;
|
||||
};
|
||||
|
||||
/** Approval date range filter */
|
||||
approvalDateRange?: {
|
||||
from: Date | null;
|
||||
to: Date | null;
|
||||
};
|
||||
|
||||
/** Whether query is enabled (defaults to true) */
|
||||
enabled?: boolean;
|
||||
}
|
||||
@@ -145,6 +151,7 @@ export function useQueueQuery(config: UseQueueQueryConfig): UseQueueQueryReturn
|
||||
currentPage: config.currentPage,
|
||||
pageSize: config.pageSize,
|
||||
sortConfig: config.sortConfig,
|
||||
approvalDateRange: config.approvalDateRange,
|
||||
};
|
||||
|
||||
// Create stable query key (TanStack Query uses this for caching/deduplication)
|
||||
@@ -161,6 +168,8 @@ export function useQueueQuery(config: UseQueueQueryConfig): UseQueueQueryReturn
|
||||
config.pageSize,
|
||||
config.sortConfig.field,
|
||||
config.sortConfig.direction,
|
||||
config.approvalDateRange?.from?.toISOString(),
|
||||
config.approvalDateRange?.to?.toISOString(),
|
||||
];
|
||||
|
||||
// Execute query
|
||||
|
||||
36
src/hooks/preview/useCompanyPreview.ts
Normal file
36
src/hooks/preview/useCompanyPreview.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { supabase } from '@/lib/supabaseClient';
|
||||
import { queryKeys } from '@/lib/queryKeys';
|
||||
|
||||
/**
|
||||
* Hook to fetch company preview data for hover cards
|
||||
*/
|
||||
export function useCompanyPreview(slug: string | undefined, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.companies.detail(slug || ''),
|
||||
queryFn: async () => {
|
||||
if (!slug) throw new Error('Slug is required');
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('companies')
|
||||
.select(`
|
||||
id,
|
||||
name,
|
||||
slug,
|
||||
company_type,
|
||||
person_type,
|
||||
headquarters_location,
|
||||
founded_year,
|
||||
logo_url
|
||||
`)
|
||||
.eq('slug', slug)
|
||||
.maybeSingle();
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
enabled: enabled && !!slug,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
gcTime: 15 * 60 * 1000, // 15 minutes
|
||||
});
|
||||
}
|
||||
39
src/hooks/preview/useParkPreview.ts
Normal file
39
src/hooks/preview/useParkPreview.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { supabase } from '@/lib/supabaseClient';
|
||||
import { queryKeys } from '@/lib/queryKeys';
|
||||
|
||||
/**
|
||||
* Hook to fetch park preview data for hover cards
|
||||
*/
|
||||
export function useParkPreview(slug: string | undefined, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.parks.detail(slug || ''),
|
||||
queryFn: async () => {
|
||||
if (!slug) throw new Error('Slug is required');
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('parks')
|
||||
.select(`
|
||||
id,
|
||||
name,
|
||||
slug,
|
||||
park_type,
|
||||
status,
|
||||
card_image_url,
|
||||
ride_count,
|
||||
coaster_count,
|
||||
average_rating,
|
||||
review_count,
|
||||
location:locations(city, state_province, country)
|
||||
`)
|
||||
.eq('slug', slug)
|
||||
.maybeSingle();
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
enabled: enabled && !!slug,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
gcTime: 15 * 60 * 1000, // 15 minutes
|
||||
});
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { queryKeys } from '@/lib/queryKeys';
|
||||
import type { DatabaseStatistics } from '@/types/database-stats';
|
||||
|
||||
export function useAdminDatabaseStats() {
|
||||
const location = useLocation();
|
||||
const isAdminPage = location.pathname.startsWith('/admin');
|
||||
|
||||
return useQuery({
|
||||
queryKey: queryKeys.admin.databaseStats(),
|
||||
queryFn: async () => {
|
||||
@@ -15,7 +19,8 @@ export function useAdminDatabaseStats() {
|
||||
|
||||
return data as unknown as DatabaseStatistics;
|
||||
},
|
||||
enabled: isAdminPage, // Only run query on admin pages
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
refetchInterval: 60 * 1000, // Auto-refetch every 60 seconds
|
||||
refetchInterval: isAdminPage ? 60 * 1000 : false, // Only refetch on admin pages
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,12 +6,15 @@
|
||||
*/
|
||||
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { useEffect } from 'react';
|
||||
import type { CompletenessAnalysis, CompletenessFilters } from '@/types/data-completeness';
|
||||
import { handleError } from '@/lib/errorHandler';
|
||||
|
||||
export function useDataCompleteness(filters: CompletenessFilters = {}) {
|
||||
const location = useLocation();
|
||||
const isAdminPage = location.pathname.startsWith('/admin');
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const query = useQuery({
|
||||
@@ -40,6 +43,7 @@ export function useDataCompleteness(filters: CompletenessFilters = {}) {
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
enabled: isAdminPage, // Only run on admin pages
|
||||
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
129
src/hooks/useDetailedViewState.ts
Normal file
129
src/hooks/useDetailedViewState.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { supabase } from '@/lib/supabaseClient';
|
||||
import { handleNonCriticalError } from '@/lib/errorHandler';
|
||||
import type { Json } from '@/integrations/supabase/types';
|
||||
|
||||
const STORAGE_KEY = 'detailed-view-collapsed';
|
||||
|
||||
interface ModerationPreferences {
|
||||
detailed_view_collapsed: boolean;
|
||||
}
|
||||
|
||||
interface UseDetailedViewStateReturn {
|
||||
isCollapsed: boolean;
|
||||
toggle: () => void;
|
||||
setCollapsed: (value: boolean) => void;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to manage detailed view collapsed/expanded state
|
||||
* Persists to database for authenticated users, localStorage for guests
|
||||
* Defaults to collapsed to reduce visual clutter
|
||||
*/
|
||||
export function useDetailedViewState(): UseDetailedViewStateReturn {
|
||||
const { user } = useAuth();
|
||||
const [isCollapsed, setIsCollapsed] = useState<boolean>(true);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Load preferences on mount
|
||||
useEffect(() => {
|
||||
loadPreferences();
|
||||
}, [user]);
|
||||
|
||||
const loadPreferences = async () => {
|
||||
try {
|
||||
if (user) {
|
||||
// Load from database for authenticated users
|
||||
const { data, error } = await supabase
|
||||
.from('user_preferences')
|
||||
.select('moderation_preferences')
|
||||
.eq('user_id', user.id)
|
||||
.maybeSingle();
|
||||
|
||||
if (error && error.code !== 'PGRST116') {
|
||||
handleNonCriticalError(error, {
|
||||
action: 'Load moderation preferences',
|
||||
userId: user.id,
|
||||
});
|
||||
}
|
||||
|
||||
// Type assertion needed until Supabase regenerates types after migration
|
||||
const preferences = (data as any)?.moderation_preferences;
|
||||
if (preferences) {
|
||||
const prefs = preferences as ModerationPreferences;
|
||||
setIsCollapsed(prefs.detailed_view_collapsed ?? true);
|
||||
}
|
||||
} else {
|
||||
// Load from localStorage for guests
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
setIsCollapsed(stored ? JSON.parse(stored) : true);
|
||||
} catch (error) {
|
||||
logger.warn('Error reading detailed view state from localStorage', { error });
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Error loading detailed view preferences', { error });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const savePreferences = async (collapsed: boolean) => {
|
||||
try {
|
||||
if (user) {
|
||||
// Save to database for authenticated users
|
||||
const moderationPrefs: ModerationPreferences = {
|
||||
detailed_view_collapsed: collapsed,
|
||||
};
|
||||
|
||||
const { error } = await supabase
|
||||
.from('user_preferences')
|
||||
.upsert({
|
||||
user_id: user.id,
|
||||
moderation_preferences: moderationPrefs as unknown as Json,
|
||||
updated_at: new Date().toISOString(),
|
||||
}, {
|
||||
onConflict: 'user_id',
|
||||
});
|
||||
|
||||
if (error) {
|
||||
handleNonCriticalError(error, {
|
||||
action: 'Save moderation preferences',
|
||||
userId: user.id,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Save to localStorage for guests
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(collapsed));
|
||||
} catch (error) {
|
||||
logger.warn('Error saving detailed view state to localStorage', { error });
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Error saving detailed view preferences', { error });
|
||||
}
|
||||
};
|
||||
|
||||
const toggle = () => {
|
||||
const newValue = !isCollapsed;
|
||||
setIsCollapsed(newValue);
|
||||
savePreferences(newValue);
|
||||
};
|
||||
|
||||
const setCollapsed = (value: boolean) => {
|
||||
setIsCollapsed(value);
|
||||
savePreferences(value);
|
||||
};
|
||||
|
||||
return {
|
||||
isCollapsed,
|
||||
toggle,
|
||||
setCollapsed,
|
||||
loading,
|
||||
};
|
||||
}
|
||||
@@ -1,10 +1,14 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { queryKeys } from '@/lib/queryKeys';
|
||||
import type { RecentAddition } from '@/types/database-stats';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export function useRecentAdditions(limit: number = 50, entityTypeFilter?: string) {
|
||||
const location = useLocation();
|
||||
const isAdminPage = location.pathname.startsWith('/admin');
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: queryKeys.admin.recentAdditions(limit),
|
||||
queryFn: async () => {
|
||||
@@ -18,8 +22,9 @@ export function useRecentAdditions(limit: number = 50, entityTypeFilter?: string
|
||||
|
||||
return data as unknown as RecentAddition[];
|
||||
},
|
||||
enabled: isAdminPage, // Only run query on admin pages
|
||||
staleTime: 2 * 60 * 1000, // 2 minutes
|
||||
refetchInterval: 30 * 1000, // Auto-refetch every 30 seconds
|
||||
refetchInterval: isAdminPage ? 30 * 1000 : false, // Only refetch on admin pages
|
||||
});
|
||||
|
||||
// Set up real-time subscriptions
|
||||
@@ -51,7 +56,7 @@ export function useRecentAdditions(limit: number = 50, entityTypeFilter?: string
|
||||
.subscribe(),
|
||||
supabase
|
||||
.channel('recent_additions_photos')
|
||||
.on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'entity_photos' }, () => {
|
||||
.on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'photos' }, () => {
|
||||
query.refetch();
|
||||
})
|
||||
.subscribe(),
|
||||
|
||||
@@ -1872,6 +1872,13 @@ export type Database = {
|
||||
item_id?: string
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "item_edit_history_item_id_fkey"
|
||||
columns: ["item_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "approval_history_detailed"
|
||||
referencedColumns: ["item_id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "item_edit_history_item_id_fkey"
|
||||
columns: ["item_id"]
|
||||
@@ -5682,6 +5689,13 @@ export type Database = {
|
||||
submission_item_id?: string
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "submission_item_temp_refs_submission_item_id_fkey"
|
||||
columns: ["submission_item_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "approval_history_detailed"
|
||||
referencedColumns: ["item_id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "submission_item_temp_refs_submission_item_id_fkey"
|
||||
columns: ["submission_item_id"]
|
||||
@@ -5694,6 +5708,7 @@ export type Database = {
|
||||
submission_items: {
|
||||
Row: {
|
||||
action_type: string | null
|
||||
approved_at: string | null
|
||||
approved_entity_id: string | null
|
||||
company_submission_id: string | null
|
||||
created_at: string
|
||||
@@ -5714,6 +5729,7 @@ export type Database = {
|
||||
}
|
||||
Insert: {
|
||||
action_type?: string | null
|
||||
approved_at?: string | null
|
||||
approved_entity_id?: string | null
|
||||
company_submission_id?: string | null
|
||||
created_at?: string
|
||||
@@ -5734,6 +5750,7 @@ export type Database = {
|
||||
}
|
||||
Update: {
|
||||
action_type?: string | null
|
||||
approved_at?: string | null
|
||||
approved_entity_id?: string | null
|
||||
company_submission_id?: string | null
|
||||
created_at?: string
|
||||
@@ -5760,6 +5777,13 @@ export type Database = {
|
||||
referencedRelation: "company_submissions"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "submission_items_depends_on_fkey"
|
||||
columns: ["depends_on"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "approval_history_detailed"
|
||||
referencedColumns: ["item_id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "submission_items_depends_on_fkey"
|
||||
columns: ["depends_on"]
|
||||
@@ -5931,6 +5955,13 @@ export type Database = {
|
||||
test_session_id?: string | null
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "test_data_registry_submission_item_id_fkey"
|
||||
columns: ["submission_item_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "approval_history_detailed"
|
||||
referencedColumns: ["item_id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "test_data_registry_submission_item_id_fkey"
|
||||
columns: ["submission_item_id"]
|
||||
@@ -6306,6 +6337,76 @@ export type Database = {
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
approval_history_detailed: {
|
||||
Row: {
|
||||
action_type: string | null
|
||||
approval_time_seconds: number | null
|
||||
approved_at: string | null
|
||||
approved_entity_id: string | null
|
||||
approver_avatar_url: string | null
|
||||
approver_display_name: string | null
|
||||
approver_id: string | null
|
||||
approver_username: string | null
|
||||
created_at: string | null
|
||||
entity_name: string | null
|
||||
entity_slug: string | null
|
||||
item_id: string | null
|
||||
item_type: string | null
|
||||
status: string | null
|
||||
submission_id: string | null
|
||||
submission_type: string | null
|
||||
submitted_at: string | null
|
||||
submitter_avatar_url: string | null
|
||||
submitter_display_name: string | null
|
||||
submitter_id: string | null
|
||||
submitter_username: string | null
|
||||
updated_at: string | null
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "content_submissions_reviewer_id_fkey"
|
||||
columns: ["approver_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "filtered_profiles"
|
||||
referencedColumns: ["user_id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "content_submissions_reviewer_id_fkey"
|
||||
columns: ["approver_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "profiles"
|
||||
referencedColumns: ["user_id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "content_submissions_user_id_fkey"
|
||||
columns: ["submitter_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "filtered_profiles"
|
||||
referencedColumns: ["user_id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "content_submissions_user_id_fkey"
|
||||
columns: ["submitter_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "profiles"
|
||||
referencedColumns: ["user_id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "submission_items_submission_id_fkey"
|
||||
columns: ["submission_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "content_submissions"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "submission_items_submission_id_fkey"
|
||||
columns: ["submission_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "moderation_queue_with_entities"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
data_retention_stats: {
|
||||
Row: {
|
||||
last_30_days: number | null
|
||||
@@ -6628,17 +6729,19 @@ export type Database = {
|
||||
}
|
||||
}
|
||||
Functions: {
|
||||
analyze_data_completeness: {
|
||||
Args: {
|
||||
p_entity_type?: string
|
||||
p_limit?: number
|
||||
p_max_score?: number
|
||||
p_min_score?: number
|
||||
p_missing_category?: string
|
||||
p_offset?: number
|
||||
}
|
||||
Returns: Json
|
||||
}
|
||||
analyze_data_completeness:
|
||||
| {
|
||||
Args: {
|
||||
p_entity_type?: string
|
||||
p_limit?: number
|
||||
p_max_score?: number
|
||||
p_min_score?: number
|
||||
p_missing_category?: string
|
||||
p_offset?: number
|
||||
}
|
||||
Returns: Json
|
||||
}
|
||||
| { Args: never; Returns: Json }
|
||||
anonymize_user_submissions: {
|
||||
Args: { target_user_id: string }
|
||||
Returns: undefined
|
||||
@@ -6816,6 +6919,7 @@ export type Database = {
|
||||
Returns: string
|
||||
}
|
||||
extract_cf_image_id: { Args: { url: string }; Returns: string }
|
||||
filter_jsonb_array_nulls: { Args: { arr: Json }; Returns: Json }
|
||||
generate_deletion_confirmation_code: { Args: never; Returns: string }
|
||||
generate_incident_number: { Args: never; Returns: string }
|
||||
generate_notification_idempotency_key: {
|
||||
@@ -6828,6 +6932,40 @@ export type Database = {
|
||||
Returns: string
|
||||
}
|
||||
generate_ticket_number: { Args: never; Returns: string }
|
||||
get_approval_history: {
|
||||
Args: {
|
||||
p_approver_id?: string
|
||||
p_from_date?: string
|
||||
p_item_type?: string
|
||||
p_limit?: number
|
||||
p_offset?: number
|
||||
p_to_date?: string
|
||||
}
|
||||
Returns: {
|
||||
action_type: string
|
||||
approval_time_seconds: number
|
||||
approved_at: string
|
||||
approved_entity_id: string
|
||||
approver_avatar_url: string
|
||||
approver_display_name: string
|
||||
approver_id: string
|
||||
approver_username: string
|
||||
created_at: string
|
||||
entity_name: string
|
||||
entity_slug: string
|
||||
item_id: string
|
||||
item_type: string
|
||||
status: string
|
||||
submission_id: string
|
||||
submission_type: string
|
||||
submitted_at: string
|
||||
submitter_avatar_url: string
|
||||
submitter_display_name: string
|
||||
submitter_id: string
|
||||
submitter_username: string
|
||||
updated_at: string
|
||||
}[]
|
||||
}
|
||||
get_auth0_sub_from_jwt: { Args: never; Returns: string }
|
||||
get_contributor_leaderboard: {
|
||||
Args: { limit_count?: number; time_period?: string }
|
||||
@@ -6853,6 +6991,16 @@ export type Database = {
|
||||
Args: { _profile_user_id: string; _viewer_id?: string }
|
||||
Returns: Json
|
||||
}
|
||||
get_maintenance_tables: {
|
||||
Args: never
|
||||
Returns: {
|
||||
indexes_size: string
|
||||
row_count: number
|
||||
table_name: string
|
||||
table_size: string
|
||||
total_size: string
|
||||
}[]
|
||||
}
|
||||
get_my_sessions: {
|
||||
Args: never
|
||||
Returns: {
|
||||
@@ -7053,13 +7201,13 @@ export type Database = {
|
||||
monitor_slow_approvals: { Args: never; Returns: undefined }
|
||||
process_approval_transaction: {
|
||||
Args: {
|
||||
p_approval_mode?: string
|
||||
p_idempotency_key?: string
|
||||
p_item_ids: string[]
|
||||
p_moderator_id: string
|
||||
p_parent_span_id?: string
|
||||
p_request_id?: string
|
||||
p_submission_id: string
|
||||
p_submitter_id: string
|
||||
p_trace_id?: string
|
||||
}
|
||||
Returns: Json
|
||||
}
|
||||
@@ -7086,6 +7234,7 @@ export type Database = {
|
||||
}
|
||||
Returns: Json
|
||||
}
|
||||
refresh_approval_history: { Args: never; Returns: undefined }
|
||||
release_expired_locks: { Args: never; Returns: number }
|
||||
release_submission_lock: {
|
||||
Args: { moderator_id: string; submission_id: string }
|
||||
@@ -7115,6 +7264,7 @@ export type Database = {
|
||||
Returns: string
|
||||
}
|
||||
run_all_cleanup_jobs: { Args: never; Returns: Json }
|
||||
run_analyze_table: { Args: { table_name: string }; Returns: Json }
|
||||
run_data_retention_cleanup: { Args: never; Returns: Json }
|
||||
run_pipeline_monitoring: {
|
||||
Args: never
|
||||
@@ -7124,6 +7274,7 @@ export type Database = {
|
||||
status: string
|
||||
}[]
|
||||
}
|
||||
run_reindex_table: { Args: { table_name: string }; Returns: Json }
|
||||
run_system_maintenance: {
|
||||
Args: never
|
||||
Returns: {
|
||||
@@ -7132,6 +7283,7 @@ export type Database = {
|
||||
task: string
|
||||
}[]
|
||||
}
|
||||
run_vacuum_table: { Args: { table_name: string }; Returns: Json }
|
||||
set_config_value: {
|
||||
Args: {
|
||||
is_local?: boolean
|
||||
|
||||
@@ -72,7 +72,7 @@ export function getCurrentDateLocal(): string {
|
||||
*/
|
||||
export function formatDateDisplay(
|
||||
dateString: string | null | undefined,
|
||||
precision: 'day' | 'month' | 'year' = 'day'
|
||||
precision: 'exact' | 'month' | 'year' | 'decade' | 'century' | 'approximate' = 'exact'
|
||||
): string {
|
||||
if (!dateString) return '';
|
||||
|
||||
@@ -83,7 +83,13 @@ export function formatDateDisplay(
|
||||
return date.getFullYear().toString();
|
||||
case 'month':
|
||||
return date.toLocaleDateString('en-US', { year: 'numeric', month: 'long' });
|
||||
case 'day':
|
||||
case 'decade':
|
||||
return `${Math.floor(date.getFullYear() / 10) * 10}s`;
|
||||
case 'century':
|
||||
return `${Math.ceil(date.getFullYear() / 100)}th century`;
|
||||
case 'approximate':
|
||||
return `circa ${date.getFullYear()}`;
|
||||
case 'exact':
|
||||
default:
|
||||
return date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
@@ -182,7 +188,7 @@ export function parseDateForDisplay(date: string | Date): Date {
|
||||
*/
|
||||
export function toDateWithPrecision(
|
||||
date: Date,
|
||||
precision: 'day' | 'month' | 'year'
|
||||
precision: 'exact' | 'month' | 'year' | 'decade' | 'century' | 'approximate'
|
||||
): string {
|
||||
const year = date.getFullYear();
|
||||
const month = date.getMonth() + 1;
|
||||
@@ -193,7 +199,13 @@ export function toDateWithPrecision(
|
||||
return `${year}-01-01`;
|
||||
case 'month':
|
||||
return `${year}-${String(month).padStart(2, '0')}-01`;
|
||||
case 'day':
|
||||
case 'decade':
|
||||
return `${Math.floor(year / 10) * 10}-01-01`;
|
||||
case 'century':
|
||||
return `${Math.floor((year - 1) / 100) * 100 + 1}-01-01`;
|
||||
case 'approximate':
|
||||
return `${year}-01-01`;
|
||||
case 'exact':
|
||||
default:
|
||||
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
171
src/lib/enhancedValidation.ts
Normal file
171
src/lib/enhancedValidation.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Enhanced Validation Messages
|
||||
* Provides contextual, helpful error messages with examples
|
||||
*/
|
||||
|
||||
export const validationMessages = {
|
||||
slug: {
|
||||
format: 'Slug must contain only lowercase letters, numbers, and hyphens. Example: "steel-vengeance" or "millennium-force"',
|
||||
required: 'Slug is required. It will be used in the URL. Example: "fury-325"',
|
||||
duplicate: 'This slug is already in use. Try adding a location or number: "thunder-run-kentucky"',
|
||||
},
|
||||
|
||||
url: {
|
||||
format: 'Must be a valid URL starting with http:// or https://. Example: "https://www.cedarpoint.com"',
|
||||
protocol: 'URL must start with http:// or https://. Add the protocol to your URL.',
|
||||
},
|
||||
|
||||
email: {
|
||||
format: 'Must be a valid email address. Example: "contact@park.com"',
|
||||
},
|
||||
|
||||
phone: {
|
||||
format: 'Enter phone number in any format. Examples: "+1-419-555-0123" or "(419) 555-0123"',
|
||||
maxLength: (max: number) => `Phone number must be less than ${max} characters`,
|
||||
},
|
||||
|
||||
dates: {
|
||||
future: 'Opening date cannot be in the future. Use today or an earlier date.',
|
||||
closingBeforeOpening: 'Closing date must be after opening date. Check both dates for accuracy.',
|
||||
invalidFormat: 'Invalid date format. Use the date picker or enter in YYYY-MM-DD format.',
|
||||
precision: 'Select how precise this date is (exact, month, year, etc.)',
|
||||
},
|
||||
|
||||
numbers: {
|
||||
heightRequirement: 'Height must be in centimeters, between 0-300. Example: "122" for 122cm (48 inches)',
|
||||
speed: 'Speed must be in km/h, between 0-500. Example: "193" for 193 km/h (120 mph)',
|
||||
length: 'Length must be in meters. Example: "1981" for 1,981 meters (6,500 feet)',
|
||||
height: 'Height must be in meters. Example: "94" for 94 meters (310 feet)',
|
||||
gForce: 'G-force must be between -10 and 10. Example: "4.5" for 4.5 positive Gs',
|
||||
inversions: 'Number of inversions (upside-down elements). Example: "7"',
|
||||
capacity: 'Capacity per hour must be between 1-99,999. Example: "1200" for 1,200 riders/hour',
|
||||
duration: 'Duration in seconds. Example: "180" for 3 minutes',
|
||||
positive: 'Value must be a positive number',
|
||||
range: (min: number, max: number) => `Value must be between ${min} and ${max}`,
|
||||
},
|
||||
|
||||
text: {
|
||||
required: 'This field is required',
|
||||
maxLength: (max: number, current?: number) =>
|
||||
current ? `${current}/${max} characters. Please shorten by ${current - max} characters.` : `Maximum ${max} characters`,
|
||||
minLength: (min: number) => `Must be at least ${min} characters`,
|
||||
noHtml: 'HTML tags are not allowed. Use plain text only.',
|
||||
trimmed: 'Extra spaces at the beginning or end will be removed',
|
||||
},
|
||||
|
||||
park: {
|
||||
nameRequired: 'Park name is required. Example: "Cedar Point" or "Six Flags Magic Mountain"',
|
||||
typeRequired: 'Select a park type (theme park, amusement park, water park, etc.)',
|
||||
statusRequired: 'Select the current status (operating, closed, under construction, etc.)',
|
||||
locationRequired: 'Location is required. Use the search to find or add a location.',
|
||||
operatorHelp: 'The company that operates the park (e.g., Cedar Fair, Six Flags)',
|
||||
ownerHelp: 'The company that owns the property (often same as operator)',
|
||||
},
|
||||
|
||||
ride: {
|
||||
nameRequired: 'Ride name is required. Example: "Steel Vengeance" or "Maverick"',
|
||||
categoryRequired: 'Select a ride category (roller coaster, flat ride, water ride, etc.)',
|
||||
parkRequired: 'Park is required. Select or create the park where this ride is located.',
|
||||
manufacturerHelp: 'Company that manufactured the ride (e.g., RMC, Intamin, B&M)',
|
||||
designerHelp: 'Company that designed the ride (if different from manufacturer)',
|
||||
trackMaterial: 'Materials used for the track. Common: Steel, Wood, Hybrid (RMC IBox)',
|
||||
supportMaterial: 'Materials used for support structure. Common: Steel, Wood',
|
||||
propulsionMethod: 'How the ride is propelled. Common: LSM Launch, Chain Lift, Hydraulic Launch',
|
||||
},
|
||||
|
||||
company: {
|
||||
nameRequired: 'Company name is required. Example: "Rocky Mountain Construction"',
|
||||
typeRequired: 'Select company type (manufacturer, designer, operator, property owner)',
|
||||
countryHelp: 'Country where the company is headquartered',
|
||||
},
|
||||
|
||||
units: {
|
||||
metricOnly: 'All measurements must be in metric units (m, km, cm, kg, km/h, etc.)',
|
||||
metricExamples: 'Use metric: m (meters), km/h (speed), cm (centimeters), kg (weight)',
|
||||
imperialNote: 'The system will automatically convert to imperial for users who prefer it',
|
||||
temperature: 'Temperature must be in Celsius. Example: "25" for 25°C (77°F)',
|
||||
},
|
||||
|
||||
submission: {
|
||||
sourceUrl: 'Where did you find this information? Helps moderators verify accuracy. Example: manufacturer website, news article, park map',
|
||||
notes: 'Add context for moderators. Example: "Confirmed via park press release" or "Specifications approximate"',
|
||||
notesMaxLength: 'Submission notes must be less than 1000 characters',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Common validation helpers
|
||||
*/
|
||||
export const validationHelpers = {
|
||||
/**
|
||||
* Check if a URL has proper protocol
|
||||
*/
|
||||
hasProtocol: (url: string): boolean => {
|
||||
return url.startsWith('http://') || url.startsWith('https://');
|
||||
},
|
||||
|
||||
/**
|
||||
* Suggest adding protocol to URL
|
||||
*/
|
||||
suggestProtocol: (url: string): string => {
|
||||
if (!url) return '';
|
||||
if (validationHelpers.hasProtocol(url)) return url;
|
||||
return `https://${url}`;
|
||||
},
|
||||
|
||||
/**
|
||||
* Format a slug from a name
|
||||
*/
|
||||
formatSlug: (name: string): string => {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9\s-]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if date is in the future
|
||||
*/
|
||||
isFutureDate: (date: string | Date): boolean => {
|
||||
const d = new Date(date);
|
||||
return d > new Date();
|
||||
},
|
||||
|
||||
/**
|
||||
* Format character count display
|
||||
*/
|
||||
formatCharCount: (current: number, max: number): string => {
|
||||
const remaining = max - current;
|
||||
if (remaining < 0) {
|
||||
return `${current}/${max} (${Math.abs(remaining)} over limit)`;
|
||||
}
|
||||
if (remaining < 50) {
|
||||
return `${current}/${max} (${remaining} remaining)`;
|
||||
}
|
||||
return `${current}/${max}`;
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Field-specific validation hints for FormDescription
|
||||
*/
|
||||
export const fieldHints = {
|
||||
slug: 'URL-friendly identifier using lowercase letters, numbers, and hyphens only',
|
||||
websiteUrl: 'Official website URL (must start with https:// or http://)',
|
||||
email: 'Contact email for the park or ride operator',
|
||||
phone: 'Contact phone number in any format',
|
||||
heightRequirement: 'Minimum height in centimeters (metric). Will be converted for display.',
|
||||
ageRequirement: 'Minimum age requirement in years',
|
||||
capacity: 'Theoretical maximum riders per hour under optimal conditions',
|
||||
duration: 'Typical ride duration in seconds from dispatch to return',
|
||||
speed: 'Maximum speed in km/h (metric). Will be converted for display.',
|
||||
height: 'Maximum height in meters (metric). Will be converted for display.',
|
||||
length: 'Track/route length in meters (metric). Will be converted for display.',
|
||||
inversions: 'Total number of elements where riders go upside down (≥90 degrees)',
|
||||
gForce: 'Maximum positive or negative G-forces experienced',
|
||||
sourceUrl: 'Reference link to verify this information (Wikipedia, official site, news article, etc.)',
|
||||
submissionNotes: 'Help moderators understand your submission (how you verified the info, any uncertainties, etc.)',
|
||||
};
|
||||
@@ -3708,7 +3708,7 @@ export async function submitTimelineEventUpdate(
|
||||
entity_id: originalEvent.entity_id,
|
||||
event_type: changedFields.event_type !== undefined ? changedFields.event_type : originalEvent.event_type,
|
||||
event_date: changedFields.event_date !== undefined ? (typeof changedFields.event_date === 'string' ? changedFields.event_date : changedFields.event_date.toISOString().split('T')[0]) : originalEvent.event_date,
|
||||
event_date_precision: (changedFields.event_date_precision !== undefined ? changedFields.event_date_precision : originalEvent.event_date_precision) || 'day',
|
||||
event_date_precision: (changedFields.event_date_precision !== undefined ? changedFields.event_date_precision : originalEvent.event_date_precision) || 'exact',
|
||||
title: changedFields.title !== undefined ? changedFields.title : originalEvent.title,
|
||||
description: changedFields.description !== undefined ? changedFields.description : originalEvent.description,
|
||||
from_value: changedFields.from_value !== undefined ? changedFields.from_value : originalEvent.from_value,
|
||||
|
||||
@@ -51,9 +51,9 @@ export const parkValidationSchema = z.object({
|
||||
const date = new Date(val);
|
||||
return date <= new Date();
|
||||
}, 'Opening date cannot be in the future'),
|
||||
opening_date_precision: z.enum(['day', 'month', 'year']).nullable().optional(),
|
||||
opening_date_precision: z.enum(['exact', 'month', 'year', 'decade', 'century', 'approximate']).nullable().optional(),
|
||||
closing_date: z.string().nullish().transform(val => val ?? undefined),
|
||||
closing_date_precision: z.enum(['day', 'month', 'year']).nullable().optional(),
|
||||
closing_date_precision: z.enum(['exact', 'month', 'year', 'decade', 'century', 'approximate']).nullable().optional(),
|
||||
location_id: z.string().uuid().optional().nullable(),
|
||||
location: z.object({
|
||||
name: z.string(),
|
||||
@@ -139,9 +139,9 @@ export const rideValidationSchema = z.object({
|
||||
.optional()
|
||||
.nullable(),
|
||||
opening_date: z.string().nullish().transform(val => val ?? undefined),
|
||||
opening_date_precision: z.enum(['day', 'month', 'year']).nullable().optional(),
|
||||
opening_date_precision: z.enum(['exact', 'month', 'year', 'decade', 'century', 'approximate']).nullable().optional(),
|
||||
closing_date: z.string().nullish().transform(val => val ?? undefined),
|
||||
closing_date_precision: z.enum(['day', 'month', 'year']).nullable().optional(),
|
||||
closing_date_precision: z.enum(['exact', 'month', 'year', 'decade', 'century', 'approximate']).nullable().optional(),
|
||||
height_requirement: z.preprocess(
|
||||
(val) => val === '' || val === null || val === undefined ? undefined : Number(val),
|
||||
z.number().int().min(0, 'Height requirement must be positive').max(300, 'Height requirement must be less than 300cm').optional()
|
||||
@@ -322,7 +322,7 @@ export const companyValidationSchema = z.object({
|
||||
description: z.string().trim().max(2000, 'Description must be less than 2000 characters').nullish().transform(val => val ?? undefined),
|
||||
person_type: z.enum(['company', 'individual', 'firm', 'organization']),
|
||||
founded_date: z.string().nullish().transform(val => val ?? undefined),
|
||||
founded_date_precision: z.enum(['day', 'month', 'year']).nullable().optional(),
|
||||
founded_date_precision: z.enum(['exact', 'month', 'year', 'decade', 'century', 'approximate']).nullable().optional(),
|
||||
founded_year: z.preprocess(
|
||||
(val) => val === '' || val === null || val === undefined ? undefined : Number(val),
|
||||
z.number().int().min(1800, 'Founded year must be after 1800').max(currentYear, `Founded year cannot be after ${currentYear}`).optional()
|
||||
@@ -401,7 +401,7 @@ export const milestoneValidationSchema = z.object({
|
||||
fiveYearsFromNow.setFullYear(fiveYearsFromNow.getFullYear() + 5);
|
||||
return date <= fiveYearsFromNow;
|
||||
}, 'Event date cannot be more than 5 years in the future'),
|
||||
event_date_precision: z.enum(['day', 'month', 'year']).optional().default('day'),
|
||||
event_date_precision: z.enum(['exact', 'month', 'year', 'decade', 'century', 'approximate']).optional().default('exact'),
|
||||
entity_type: z.string().min(1, 'Entity type is required'),
|
||||
entity_id: z.string().uuid('Invalid entity ID'),
|
||||
is_public: z.boolean().optional(),
|
||||
|
||||
65
src/lib/formToasts.ts
Normal file
65
src/lib/formToasts.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { toast } from '@/hooks/use-toast';
|
||||
|
||||
/**
|
||||
* Standardized toast notifications for form submissions
|
||||
* Provides consistent success/error feedback across all forms
|
||||
*/
|
||||
export const formToasts = {
|
||||
success: {
|
||||
create: (entityType: string, entityName?: string) => {
|
||||
toast({
|
||||
title: '✓ Submission Created',
|
||||
description: entityName
|
||||
? `${entityName} has been submitted for review.`
|
||||
: `${entityType} has been submitted for review.`,
|
||||
variant: 'default',
|
||||
});
|
||||
},
|
||||
|
||||
update: (entityType: string, entityName?: string) => {
|
||||
toast({
|
||||
title: '✓ Update Submitted',
|
||||
description: entityName
|
||||
? `Changes to ${entityName} have been submitted for review.`
|
||||
: `${entityType} update has been submitted for review.`,
|
||||
variant: 'default',
|
||||
});
|
||||
},
|
||||
|
||||
moderatorApproval: (entityType: string, entityName?: string) => {
|
||||
toast({
|
||||
title: '✓ Published Successfully',
|
||||
description: entityName
|
||||
? `${entityName} is now live on the site.`
|
||||
: `${entityType} is now live on the site.`,
|
||||
variant: 'default',
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
error: {
|
||||
validation: (fieldCount: number) => {
|
||||
toast({
|
||||
title: 'Validation Failed',
|
||||
description: `Please fix ${fieldCount} error${fieldCount > 1 ? 's' : ''} before submitting.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
|
||||
network: () => {
|
||||
toast({
|
||||
title: 'Connection Error',
|
||||
description: 'Unable to submit. Please check your connection and try again.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
|
||||
generic: (error: string) => {
|
||||
toast({
|
||||
title: 'Submission Failed',
|
||||
description: error,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
390
src/lib/glossary.ts
Normal file
390
src/lib/glossary.ts
Normal file
@@ -0,0 +1,390 @@
|
||||
/**
|
||||
* Theme Park Terminology Glossary
|
||||
* Comprehensive definitions for technical terms used in forms
|
||||
*/
|
||||
|
||||
export interface GlossaryTerm {
|
||||
term: string;
|
||||
category: 'manufacturer' | 'technology' | 'element' | 'component' | 'measurement' | 'type' | 'material';
|
||||
definition: string;
|
||||
example?: string;
|
||||
relatedTerms?: string[];
|
||||
}
|
||||
|
||||
export const glossary: Record<string, GlossaryTerm> = {
|
||||
// Manufacturers
|
||||
'rmc': {
|
||||
term: 'RMC',
|
||||
category: 'manufacturer',
|
||||
definition: 'Rocky Mountain Construction - Manufacturer known for hybrid coasters with steel IBox track on wooden structures',
|
||||
example: 'Steel Vengeance at Cedar Point',
|
||||
relatedTerms: ['ibox-track', 'hybrid-coaster'],
|
||||
},
|
||||
'intamin': {
|
||||
term: 'Intamin',
|
||||
category: 'manufacturer',
|
||||
definition: 'Swiss manufacturer known for record-breaking coasters and innovative launch systems',
|
||||
example: 'Millennium Force, Top Thrill Dragster',
|
||||
relatedTerms: ['hydraulic-launch', 'lsm'],
|
||||
},
|
||||
'b&m': {
|
||||
term: 'B&M',
|
||||
category: 'manufacturer',
|
||||
definition: 'Bolliger & Mabillard - Swiss manufacturer known for smooth, reliable coasters',
|
||||
example: 'Fury 325, Banshee, GateKeeper',
|
||||
relatedTerms: ['inverted', 'wing-coaster', 'dive-coaster'],
|
||||
},
|
||||
'vekoma': {
|
||||
term: 'Vekoma',
|
||||
category: 'manufacturer',
|
||||
definition: 'Dutch manufacturer with wide range from family coasters to intense thrill rides',
|
||||
example: 'Space Mountain (Disney), Thunderbird (PowerPark)',
|
||||
},
|
||||
'gerstlauer': {
|
||||
term: 'Gerstlauer',
|
||||
category: 'manufacturer',
|
||||
definition: 'German manufacturer known for compact, intense coasters with vertical lifts',
|
||||
example: 'Takabisha (steepest drop), Karacho',
|
||||
relatedTerms: ['euro-fighter'],
|
||||
},
|
||||
's&s': {
|
||||
term: 'S&S',
|
||||
category: 'manufacturer',
|
||||
definition: 'S&S Worldwide - American manufacturer of compressed-air launch coasters and thrill rides',
|
||||
example: 'Hypersonic XLC, Screamin\' Swing',
|
||||
relatedTerms: ['compressed-air-launch'],
|
||||
},
|
||||
|
||||
// Launch/Propulsion Systems
|
||||
'lsm': {
|
||||
term: 'LSM Launch',
|
||||
category: 'technology',
|
||||
definition: 'Linear Synchronous Motor - Uses electromagnetic propulsion to smoothly accelerate trains',
|
||||
example: 'Maverick, Taron, Velocicoaster',
|
||||
relatedTerms: ['lim', 'magnetic-launch'],
|
||||
},
|
||||
'lim': {
|
||||
term: 'LIM Launch',
|
||||
category: 'technology',
|
||||
definition: 'Linear Induction Motor - Earlier electromagnetic launch technology, less efficient than LSM',
|
||||
example: 'Flight of Fear, Rock \'n\' Roller Coaster',
|
||||
relatedTerms: ['lsm'],
|
||||
},
|
||||
'hydraulic-launch': {
|
||||
term: 'Hydraulic Launch',
|
||||
category: 'technology',
|
||||
definition: 'Uses hydraulic winch system to rapidly accelerate train, capable of extreme speeds',
|
||||
example: 'Top Thrill Dragster, Kingda Ka (fastest launches)',
|
||||
relatedTerms: ['intamin'],
|
||||
},
|
||||
'chain-lift': {
|
||||
term: 'Chain Lift',
|
||||
category: 'technology',
|
||||
definition: 'Traditional lift system using chain and anti-rollback dogs',
|
||||
example: 'Most traditional wooden and steel coasters',
|
||||
},
|
||||
'cable-lift': {
|
||||
term: 'Cable Lift',
|
||||
category: 'technology',
|
||||
definition: 'Uses steel cable for faster lift speeds than chain',
|
||||
example: 'Millennium Force (first major use)',
|
||||
},
|
||||
'compressed-air-launch': {
|
||||
term: 'Compressed Air Launch',
|
||||
category: 'technology',
|
||||
definition: 'Uses compressed air to launch train, very powerful acceleration',
|
||||
example: 'Hypersonic XLC, Do-Dodonpa',
|
||||
relatedTerms: ['s&s'],
|
||||
},
|
||||
|
||||
// Coaster Types
|
||||
'inverted': {
|
||||
term: 'Inverted Coaster',
|
||||
category: 'type',
|
||||
definition: 'Train runs below the track with feet dangling, track above riders',
|
||||
example: 'Banshee, Montu, Raptor',
|
||||
relatedTerms: ['b&m'],
|
||||
},
|
||||
'wing-coaster': {
|
||||
term: 'Wing Coaster',
|
||||
category: 'type',
|
||||
definition: 'Seats extend to sides of track with nothing above or below riders',
|
||||
example: 'GateKeeper, The Swarm, X-Flight',
|
||||
relatedTerms: ['b&m'],
|
||||
},
|
||||
'dive-coaster': {
|
||||
term: 'Dive Coaster',
|
||||
category: 'type',
|
||||
definition: 'Features wide trains and vertical/near-vertical first drop, often with holding brake',
|
||||
example: 'Valravn, SheiKra, Griffon',
|
||||
relatedTerms: ['b&m'],
|
||||
},
|
||||
'flying-coaster': {
|
||||
term: 'Flying Coaster',
|
||||
category: 'type',
|
||||
definition: 'Riders positioned face-down in flying position',
|
||||
example: 'Tatsu, Manta, Flying Dinosaur',
|
||||
relatedTerms: ['b&m', 'vekoma'],
|
||||
},
|
||||
'hyper-coaster': {
|
||||
term: 'Hyper Coaster',
|
||||
category: 'type',
|
||||
definition: 'Coaster between 200-299 feet tall, focused on airtime',
|
||||
example: 'Diamondback, Nitro, Apollo\'s Chariot',
|
||||
relatedTerms: ['giga-coaster', 'airtime'],
|
||||
},
|
||||
'giga-coaster': {
|
||||
term: 'Giga Coaster',
|
||||
category: 'type',
|
||||
definition: 'Coaster between 300-399 feet tall',
|
||||
example: 'Millennium Force, Fury 325, Leviathan',
|
||||
relatedTerms: ['hyper-coaster', 'strata-coaster'],
|
||||
},
|
||||
'strata-coaster': {
|
||||
term: 'Strata Coaster',
|
||||
category: 'type',
|
||||
definition: 'Coaster 400+ feet tall',
|
||||
example: 'Top Thrill Dragster, Kingda Ka',
|
||||
relatedTerms: ['giga-coaster'],
|
||||
},
|
||||
'hybrid-coaster': {
|
||||
term: 'Hybrid Coaster',
|
||||
category: 'type',
|
||||
definition: 'Steel track on wooden support structure',
|
||||
example: 'Steel Vengeance, Twisted Colossus',
|
||||
relatedTerms: ['rmc', 'ibox-track'],
|
||||
},
|
||||
'euro-fighter': {
|
||||
term: 'Euro-Fighter',
|
||||
category: 'type',
|
||||
definition: 'Compact Gerstlauer coaster with vertical lift and beyond-vertical drop',
|
||||
example: 'Takabisha, Saw: The Ride',
|
||||
relatedTerms: ['gerstlauer'],
|
||||
},
|
||||
|
||||
// Track Materials
|
||||
'ibox-track': {
|
||||
term: 'IBox Track',
|
||||
category: 'material',
|
||||
definition: 'RMC\'s steel box-beam track system used on hybrid coasters, allows extreme elements',
|
||||
example: 'Steel Vengeance, Iron Rattler',
|
||||
relatedTerms: ['rmc', 'hybrid-coaster', 'topper-track'],
|
||||
},
|
||||
'topper-track': {
|
||||
term: 'Topper Track',
|
||||
category: 'material',
|
||||
definition: 'RMC\'s steel plate topper on wooden track for smoother wooden coaster experience',
|
||||
example: 'Outlaw Run, Lightning Rod',
|
||||
relatedTerms: ['rmc', 'ibox-track'],
|
||||
},
|
||||
|
||||
// Restraint Types
|
||||
'otsr': {
|
||||
term: 'OTSR',
|
||||
category: 'component',
|
||||
definition: 'Over-The-Shoulder Restraint - Safety harness that goes over shoulders and locks at waist',
|
||||
example: 'Used on most inverting coasters',
|
||||
relatedTerms: ['vest-restraint', 'lap-bar'],
|
||||
},
|
||||
'lap-bar': {
|
||||
term: 'Lap Bar',
|
||||
category: 'component',
|
||||
definition: 'Restraint that only crosses the lap/thighs, offers more freedom',
|
||||
example: 'Millennium Force, most airtime-focused rides',
|
||||
relatedTerms: ['otsr', 't-bar'],
|
||||
},
|
||||
't-bar': {
|
||||
term: 'T-Bar',
|
||||
category: 'component',
|
||||
definition: 'T-shaped lap bar restraint, common on Intamin hyper coasters',
|
||||
example: 'Intimidator 305, Skyrush',
|
||||
relatedTerms: ['lap-bar'],
|
||||
},
|
||||
'vest-restraint': {
|
||||
term: 'Vest Restraint',
|
||||
category: 'component',
|
||||
definition: 'Soft vest-style over-shoulder restraint, more comfortable than traditional OTSR',
|
||||
example: 'GateKeeper, Valravn (B&M)',
|
||||
relatedTerms: ['otsr'],
|
||||
},
|
||||
'shin-bar': {
|
||||
term: 'Shin Bar',
|
||||
category: 'component',
|
||||
definition: 'Additional restraint that holds shins in place, used on some intense rides',
|
||||
example: 'Flying coasters, some Vekoma rides',
|
||||
},
|
||||
|
||||
// Elements
|
||||
'airtime': {
|
||||
term: 'Airtime',
|
||||
category: 'element',
|
||||
definition: 'Negative G-forces that create sensation of floating or being lifted from seat',
|
||||
example: 'Camelback hills, speed hills',
|
||||
relatedTerms: ['ejector-airtime', 'floater-airtime', 'hangtime'],
|
||||
},
|
||||
'ejector-airtime': {
|
||||
term: 'Ejector Airtime',
|
||||
category: 'element',
|
||||
definition: 'Strong negative Gs that forcefully lift riders from seats',
|
||||
example: 'El Toro, Skyrush airtime hills',
|
||||
relatedTerms: ['airtime'],
|
||||
},
|
||||
'floater-airtime': {
|
||||
term: 'Floater Airtime',
|
||||
category: 'element',
|
||||
definition: 'Gentle negative Gs that create sustained floating sensation',
|
||||
example: 'B&M hyper coasters',
|
||||
relatedTerms: ['airtime'],
|
||||
},
|
||||
'hangtime': {
|
||||
term: 'Hangtime',
|
||||
category: 'element',
|
||||
definition: 'Suspension in mid-air during inversion, typically at apex of element',
|
||||
example: 'Zero-g rolls, inversions on dive coasters',
|
||||
relatedTerms: ['airtime', 'inversion'],
|
||||
},
|
||||
'inversion': {
|
||||
term: 'Inversion',
|
||||
category: 'element',
|
||||
definition: 'Element where riders are turned upside down (≥90 degrees from upright)',
|
||||
example: 'Loops, corkscrews, barrel rolls',
|
||||
relatedTerms: ['zero-g-roll', 'corkscrew', 'loop'],
|
||||
},
|
||||
'zero-g-roll': {
|
||||
term: 'Zero-G Roll',
|
||||
category: 'element',
|
||||
definition: 'Heartline inversion with sustained weightlessness',
|
||||
example: 'Common on Intamin and B&M coasters',
|
||||
relatedTerms: ['inversion', 'hangtime'],
|
||||
},
|
||||
'corkscrew': {
|
||||
term: 'Corkscrew',
|
||||
category: 'element',
|
||||
definition: 'Inversion where track twists 360 degrees while moving forward',
|
||||
example: 'Classic Arrow element',
|
||||
relatedTerms: ['inversion'],
|
||||
},
|
||||
'loop': {
|
||||
term: 'Vertical Loop',
|
||||
category: 'element',
|
||||
definition: 'Full 360-degree vertical circle',
|
||||
example: 'Classic clothoid loop shape',
|
||||
relatedTerms: ['inversion'],
|
||||
},
|
||||
'dive-loop': {
|
||||
term: 'Dive Loop',
|
||||
category: 'element',
|
||||
definition: 'Half loop up, half corkscrew down',
|
||||
example: 'Common on B&M coasters',
|
||||
relatedTerms: ['immelmann', 'inversion'],
|
||||
},
|
||||
'immelmann': {
|
||||
term: 'Immelmann',
|
||||
category: 'element',
|
||||
definition: 'Half loop up, half roll out (opposite of dive loop)',
|
||||
example: 'Named after WWI pilot maneuver',
|
||||
relatedTerms: ['dive-loop', 'inversion'],
|
||||
},
|
||||
'cobra-roll': {
|
||||
term: 'Cobra Roll',
|
||||
category: 'element',
|
||||
definition: 'Double inversion creating S-shape, reversing direction',
|
||||
example: 'Common on Vekoma and B&M loopers',
|
||||
relatedTerms: ['inversion'],
|
||||
},
|
||||
'heartline-roll': {
|
||||
term: 'Heartline Roll',
|
||||
category: 'element',
|
||||
definition: 'Barrel roll rotating around rider\'s heartline for smooth inversion',
|
||||
example: 'Maverick, many Intamin coasters',
|
||||
relatedTerms: ['zero-g-roll', 'inversion'],
|
||||
},
|
||||
|
||||
// Technical Terms
|
||||
'mcbr': {
|
||||
term: 'MCBR',
|
||||
category: 'component',
|
||||
definition: 'Mid-Course Brake Run - Safety brake zone that divides track into blocks',
|
||||
example: 'Allows multiple trains to operate safely',
|
||||
relatedTerms: ['block-section'],
|
||||
},
|
||||
'block-section': {
|
||||
term: 'Block Section',
|
||||
category: 'component',
|
||||
definition: 'Track section that only one train can occupy at a time for safety',
|
||||
example: 'Station, lift hill, brake runs',
|
||||
relatedTerms: ['mcbr'],
|
||||
},
|
||||
'trim-brake': {
|
||||
term: 'Trim Brake',
|
||||
category: 'component',
|
||||
definition: 'Brake that slows train slightly to control speed',
|
||||
example: 'Often on hills or before elements',
|
||||
},
|
||||
'transfer-track': {
|
||||
term: 'Transfer Track',
|
||||
category: 'component',
|
||||
definition: 'Movable track section for adding/removing trains from circuit',
|
||||
example: 'Allows storage of extra trains',
|
||||
},
|
||||
'anti-rollback': {
|
||||
term: 'Anti-Rollback',
|
||||
category: 'component',
|
||||
definition: 'Safety device preventing train from rolling backward on lift',
|
||||
example: 'Creates "clicking" sound on chain lifts',
|
||||
},
|
||||
|
||||
// Measurements
|
||||
'g-force': {
|
||||
term: 'G-Force',
|
||||
category: 'measurement',
|
||||
definition: 'Force of gravity felt by riders. 1G = normal gravity, positive = pushed into seat, negative = lifted from seat',
|
||||
example: '4.5G on intense loops, -1.5G on airtime hills',
|
||||
},
|
||||
'kilometers-per-hour': {
|
||||
term: 'km/h',
|
||||
category: 'measurement',
|
||||
definition: 'Speed measurement in kilometers per hour (metric)',
|
||||
example: '193 km/h = 120 mph',
|
||||
},
|
||||
'meters': {
|
||||
term: 'Meters',
|
||||
category: 'measurement',
|
||||
definition: 'Length/height measurement (metric). 1 meter ≈ 3.28 feet',
|
||||
example: '94 meters = 310 feet',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Get glossary term by key (normalized)
|
||||
*/
|
||||
export function getGlossaryTerm(term: string): GlossaryTerm | undefined {
|
||||
const key = term.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
|
||||
return glossary[key];
|
||||
}
|
||||
|
||||
/**
|
||||
* Search glossary by query
|
||||
*/
|
||||
export function searchGlossary(query: string): GlossaryTerm[] {
|
||||
const lowerQuery = query.toLowerCase();
|
||||
return Object.values(glossary).filter(term =>
|
||||
term.term.toLowerCase().includes(lowerQuery) ||
|
||||
term.definition.toLowerCase().includes(lowerQuery) ||
|
||||
term.example?.toLowerCase().includes(lowerQuery)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all terms in a category
|
||||
*/
|
||||
export function getTermsByCategory(category: GlossaryTerm['category']): GlossaryTerm[] {
|
||||
return Object.values(glossary).filter(term => term.category === category);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all categories
|
||||
*/
|
||||
export function getAllCategories(): GlossaryTerm['category'][] {
|
||||
return ['manufacturer', 'technology', 'element', 'component', 'measurement', 'type', 'material'];
|
||||
}
|
||||
@@ -26,6 +26,7 @@ export interface QueryConfig {
|
||||
currentPage: number;
|
||||
pageSize: number;
|
||||
sortConfig?: SortConfig;
|
||||
approvalDateRange?: { from: Date | null; to: Date | null };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -53,7 +54,7 @@ export function buildSubmissionQuery(
|
||||
config: QueryConfig,
|
||||
skipModeratorFilter = false
|
||||
) {
|
||||
const { entityFilter, statusFilter, tab, userId, isAdmin, isSuperuser } = config;
|
||||
const { entityFilter, statusFilter, tab, userId, isAdmin, isSuperuser, approvalDateRange } = config;
|
||||
|
||||
// Use optimized view with pre-joined profiles and entity data
|
||||
let query = supabase
|
||||
@@ -103,6 +104,20 @@ export function buildSubmissionQuery(
|
||||
}
|
||||
// 'all' and 'reviews' filters don't add any conditions
|
||||
|
||||
// Apply approval date range filter (only works on archive tab with approved items)
|
||||
if (approvalDateRange && tab === 'archive') {
|
||||
if (approvalDateRange.from) {
|
||||
// Filter by checking if submission has at least one item approved on/after this date
|
||||
query = query.gte('first_item_approved_at', approvalDateRange.from.toISOString());
|
||||
}
|
||||
if (approvalDateRange.to) {
|
||||
// Add one day and use < to include the entire "to" day
|
||||
const nextDay = new Date(approvalDateRange.to);
|
||||
nextDay.setDate(nextDay.getDate() + 1);
|
||||
query = query.lt('last_item_approved_at', nextDay.toISOString());
|
||||
}
|
||||
}
|
||||
|
||||
// CRM-style claim filtering: moderators only see unclaimed OR self-assigned submissions
|
||||
// Admins see all submissions
|
||||
// Note: For non-admin users, moderator filtering is handled by multi-query approach in fetchSubmissions
|
||||
|
||||
@@ -103,6 +103,12 @@ export const queryKeys = {
|
||||
admin: {
|
||||
databaseStats: () => ['admin', 'database-stats'] as const,
|
||||
recentAdditions: (limit: number) => ['admin', 'recent-additions', limit] as const,
|
||||
maintenanceTables: () => ['admin', 'maintenance-tables'] as const,
|
||||
},
|
||||
|
||||
// Companies queries
|
||||
companies: {
|
||||
detail: (slug: string) => ['companies', 'detail', slug] as const,
|
||||
},
|
||||
|
||||
// Analytics queries
|
||||
|
||||
@@ -21,9 +21,9 @@ export interface FieldChange {
|
||||
changeType: 'added' | 'removed' | 'modified';
|
||||
metadata?: {
|
||||
isCreatingNewLocation?: boolean;
|
||||
precision?: 'day' | 'month' | 'year';
|
||||
oldPrecision?: 'day' | 'month' | 'year';
|
||||
newPrecision?: 'day' | 'month' | 'year';
|
||||
precision?: 'exact' | 'month' | 'year' | 'decade' | 'century' | 'approximate';
|
||||
oldPrecision?: 'exact' | 'month' | 'year' | 'decade' | 'century' | 'approximate';
|
||||
newPrecision?: 'exact' | 'month' | 'year' | 'decade' | 'century' | 'approximate';
|
||||
};
|
||||
}
|
||||
|
||||
@@ -803,7 +803,7 @@ function formatEntityType(entityType: string): string {
|
||||
/**
|
||||
* Format field value for display
|
||||
*/
|
||||
export function formatFieldValue(value: any, precision?: 'day' | 'month' | 'year'): string {
|
||||
export function formatFieldValue(value: any, precision?: 'exact' | 'month' | 'year' | 'decade' | 'century' | 'approximate'): string {
|
||||
if (value === null || value === undefined) return 'None';
|
||||
if (typeof value === 'boolean') return value ? 'Yes' : 'No';
|
||||
|
||||
@@ -817,9 +817,15 @@ export function formatFieldValue(value: any, precision?: 'day' | 'month' | 'year
|
||||
return date.getFullYear().toString();
|
||||
} else if (precision === 'month') {
|
||||
return date.toLocaleDateString('en-US', { year: 'numeric', month: 'long' });
|
||||
} else if (precision === 'decade') {
|
||||
return `${Math.floor(date.getFullYear() / 10) * 10}s`;
|
||||
} else if (precision === 'century') {
|
||||
return `${Math.ceil(date.getFullYear() / 100)}th century`;
|
||||
} else if (precision === 'approximate') {
|
||||
return `circa ${date.getFullYear()}`;
|
||||
}
|
||||
|
||||
// Default: full date
|
||||
// Default: full date (exact)
|
||||
return date.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
|
||||
} catch {
|
||||
return String(value);
|
||||
|
||||
@@ -368,7 +368,7 @@ export async function fetchSystemActivities(
|
||||
}
|
||||
|
||||
// Fetch submission reviews (approved/rejected submissions)
|
||||
// Note: Content is now in submission_metadata table, but entity_name is cached in view
|
||||
// Note: Content is now in submission_metadata table - need to join and filter properly
|
||||
const { data: submissions, error: submissionsError } = await supabase
|
||||
.from('content_submissions')
|
||||
.select(`
|
||||
@@ -377,8 +377,9 @@ export async function fetchSystemActivities(
|
||||
status,
|
||||
reviewer_id,
|
||||
reviewed_at,
|
||||
submission_metadata(name)
|
||||
submission_metadata!inner(metadata_value)
|
||||
`)
|
||||
.eq('submission_metadata.metadata_key', 'name')
|
||||
.not('reviewed_at', 'is', null)
|
||||
.in('status', ['approved', 'rejected', 'partially_approved'])
|
||||
.order('reviewed_at', { ascending: false })
|
||||
@@ -415,10 +416,10 @@ export async function fetchSystemActivities(
|
||||
);
|
||||
|
||||
for (const submission of submissions) {
|
||||
// Get name from submission_metadata
|
||||
// Get name from submission_metadata - extract metadata_value from the joined result
|
||||
const metadata = submission.submission_metadata as any;
|
||||
const entityName = Array.isArray(metadata) && metadata.length > 0
|
||||
? metadata[0]?.name
|
||||
? metadata[0]?.metadata_value
|
||||
: undefined;
|
||||
|
||||
const submissionItem = itemsMap.get(submission.id);
|
||||
|
||||
@@ -257,14 +257,14 @@ export function generateRandomCompany(type: 'manufacturer' | 'operator' | 'desig
|
||||
// Add full founded date with precision
|
||||
if (shouldPopulateField(density, counter, 'medium')) {
|
||||
companyData.founded_date = `${foundedYear}-01-01`;
|
||||
companyData.founded_date_precision = randomItem(['year', 'month', 'day']);
|
||||
companyData.founded_date_precision = randomItem(['year', 'month', 'exact']);
|
||||
}
|
||||
|
||||
// Add defunct date for some companies
|
||||
if (shouldPopulateField(density, counter, 'low') && Math.random() > 0.85) {
|
||||
const defunctYear = randomInt(foundedYear + 10, 2024);
|
||||
companyData.defunct_date = `${defunctYear}-12-31`;
|
||||
companyData.defunct_date_precision = randomItem(['year', 'month', 'day']);
|
||||
companyData.defunct_date_precision = randomItem(['year', 'month', 'exact']);
|
||||
}
|
||||
|
||||
// Add source URL
|
||||
|
||||
@@ -61,8 +61,8 @@ export function randomDate(startYear: number, endYear: number): string {
|
||||
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function randomDatePrecision(): 'day' | 'month' | 'year' {
|
||||
return randomItem(['day', 'month', 'year']);
|
||||
export function randomDatePrecision(): 'exact' | 'month' | 'year' {
|
||||
return randomItem(['exact', 'month', 'year']);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useState, useEffect, lazy, Suspense } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { EntityBreadcrumb } from '@/components/navigation/EntityBreadcrumb';
|
||||
import { CompanyDetailSkeleton } from '@/components/loading/CompanyDetailSkeleton';
|
||||
import { Header } from '@/components/layout/Header';
|
||||
import { getBannerUrls } from '@/lib/cloudflareImageUtils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -149,12 +151,7 @@ export default function DesignerDetail() {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Header />
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="animate-pulse space-y-6">
|
||||
<div className="h-64 bg-muted rounded-lg"></div>
|
||||
<div className="h-8 bg-muted rounded w-1/2"></div>
|
||||
</div>
|
||||
</div>
|
||||
<CompanyDetailSkeleton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -181,13 +178,17 @@ export default function DesignerDetail() {
|
||||
<Header />
|
||||
|
||||
<main className="container mx-auto px-4 py-8 max-w-7xl">
|
||||
{/* Back Button and Edit Button */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<Button variant="ghost" onClick={() => navigate('/designers')}>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Back to Designers
|
||||
</Button>
|
||||
|
||||
{/* Breadcrumb Navigation */}
|
||||
<EntityBreadcrumb
|
||||
segments={[
|
||||
{ label: 'Designers', href: '/designers' },
|
||||
{ label: designer.name }
|
||||
]}
|
||||
className="mb-4"
|
||||
/>
|
||||
|
||||
{/* Edit Button */}
|
||||
<div className="flex justify-end mb-6">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => requireAuth(() => setIsEditModalOpen(true), "Sign in to edit this designer")}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useState, useEffect, lazy, Suspense } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { EntityBreadcrumb } from '@/components/navigation/EntityBreadcrumb';
|
||||
import { CompanyDetailSkeleton } from '@/components/loading/CompanyDetailSkeleton';
|
||||
import { Header } from '@/components/layout/Header';
|
||||
import { trackPageView } from '@/lib/viewTracking';
|
||||
import { getBannerUrls } from '@/lib/cloudflareImageUtils';
|
||||
@@ -159,12 +161,7 @@ export default function ManufacturerDetail() {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Header />
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="animate-pulse space-y-6">
|
||||
<div className="h-64 bg-muted rounded-lg"></div>
|
||||
<div className="h-8 bg-muted rounded w-1/2"></div>
|
||||
</div>
|
||||
</div>
|
||||
<CompanyDetailSkeleton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -191,14 +188,17 @@ export default function ManufacturerDetail() {
|
||||
<Header />
|
||||
|
||||
<main className="container mx-auto px-4 py-8 max-w-7xl">
|
||||
{/* Back Button and Edit Button */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<Button variant="ghost" onClick={() => navigate('/manufacturers')}>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
<span className="md:hidden">Back</span>
|
||||
<span className="hidden md:inline">Back to Manufacturers</span>
|
||||
</Button>
|
||||
|
||||
{/* Breadcrumb Navigation */}
|
||||
<EntityBreadcrumb
|
||||
segments={[
|
||||
{ label: 'Manufacturers', href: '/manufacturers' },
|
||||
{ label: manufacturer.name }
|
||||
]}
|
||||
className="mb-4"
|
||||
/>
|
||||
|
||||
{/* Edit Button */}
|
||||
<div className="flex justify-end mb-6">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => requireAuth(() => setIsEditModalOpen(true), "Sign in to edit this manufacturer")}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useState, useEffect, lazy, Suspense } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { EntityBreadcrumb } from '@/components/navigation/EntityBreadcrumb';
|
||||
import { CompanyDetailSkeleton } from '@/components/loading/CompanyDetailSkeleton';
|
||||
import { Header } from '@/components/layout/Header';
|
||||
import { trackPageView } from '@/lib/viewTracking';
|
||||
import { getBannerUrls } from '@/lib/cloudflareImageUtils';
|
||||
@@ -188,12 +190,7 @@ export default function OperatorDetail() {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Header />
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="animate-pulse space-y-6">
|
||||
<div className="h-64 bg-muted rounded-lg"></div>
|
||||
<div className="h-8 bg-muted rounded w-1/2"></div>
|
||||
</div>
|
||||
</div>
|
||||
<CompanyDetailSkeleton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -220,13 +217,17 @@ export default function OperatorDetail() {
|
||||
<Header />
|
||||
|
||||
<main className="container mx-auto px-4 py-8 max-w-7xl">
|
||||
{/* Back Button and Edit Button */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<Button variant="ghost" onClick={() => navigate('/operators')}>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Back to Operators
|
||||
</Button>
|
||||
|
||||
{/* Breadcrumb Navigation */}
|
||||
<EntityBreadcrumb
|
||||
segments={[
|
||||
{ label: 'Operators', href: '/operators' },
|
||||
{ label: operator.name }
|
||||
]}
|
||||
className="mb-4"
|
||||
/>
|
||||
|
||||
{/* Edit Button */}
|
||||
<div className="flex justify-end mb-6">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => requireAuth(() => setIsEditModalOpen(true), "Sign in to edit this operator")}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { useState, lazy, Suspense, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card';
|
||||
import { CompanyPreviewCard } from '@/components/preview/CompanyPreviewCard';
|
||||
import { EntityBreadcrumb } from '@/components/navigation/EntityBreadcrumb';
|
||||
import { ParkDetailSkeleton } from '@/components/loading/ParkDetailSkeleton';
|
||||
import { Header } from '@/components/layout/Header';
|
||||
import { getBannerUrls } from '@/lib/cloudflareImageUtils';
|
||||
import { trackPageView } from '@/lib/viewTracking';
|
||||
@@ -161,13 +165,7 @@ export default function ParkDetail() {
|
||||
if (loading) {
|
||||
return <div className="min-h-screen bg-background">
|
||||
<Header />
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="animate-pulse space-y-6">
|
||||
<div className="h-64 bg-muted rounded-lg"></div>
|
||||
<div className="h-8 bg-muted rounded w-1/2"></div>
|
||||
<div className="h-4 bg-muted rounded w-1/3"></div>
|
||||
</div>
|
||||
</div>
|
||||
<ParkDetailSkeleton />
|
||||
</div>;
|
||||
}
|
||||
if (!park) {
|
||||
@@ -191,13 +189,17 @@ export default function ParkDetail() {
|
||||
<Header />
|
||||
|
||||
<main className="container mx-auto px-4 py-8 max-w-7xl">
|
||||
{/* Back Button and Edit Button */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<Button variant="ghost" onClick={() => navigate('/parks')}>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Back to Parks
|
||||
</Button>
|
||||
|
||||
{/* Breadcrumb Navigation */}
|
||||
<EntityBreadcrumb
|
||||
segments={[
|
||||
{ label: 'Parks', href: '/parks' },
|
||||
{ label: park.name }
|
||||
]}
|
||||
className="mb-4"
|
||||
/>
|
||||
|
||||
{/* Edit Button */}
|
||||
<div className="flex justify-end mb-6">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => requireAuth(() => setIsEditParkModalOpen(true), "Sign in to edit this park")}
|
||||
@@ -435,9 +437,19 @@ export default function ParkDetail() {
|
||||
<Users className="w-4 h-4 text-muted-foreground" />
|
||||
<div>
|
||||
<div className="font-medium">Operator</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{park.operator.name}
|
||||
</div>
|
||||
<HoverCard openDelay={300}>
|
||||
<HoverCardTrigger asChild>
|
||||
<Link
|
||||
to={`/operators/${park.operator.slug}`}
|
||||
className="text-sm text-primary hover:underline"
|
||||
>
|
||||
{park.operator.name}
|
||||
</Link>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent side="right" className="w-auto">
|
||||
<CompanyPreviewCard slug={park.operator.slug} />
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
@@ -659,9 +671,9 @@ export default function ParkDetail() {
|
||||
park_type: park?.park_type,
|
||||
status: park?.status,
|
||||
opening_date: park?.opening_date ?? undefined,
|
||||
opening_date_precision: (park?.opening_date_precision as 'day' | 'month' | 'year') ?? undefined,
|
||||
opening_date_precision: (park?.opening_date_precision as 'exact' | 'month' | 'year' | 'decade' | 'century' | 'approximate') ?? undefined,
|
||||
closing_date: park?.closing_date ?? undefined,
|
||||
closing_date_precision: (park?.closing_date_precision as 'day' | 'month' | 'year') ?? undefined,
|
||||
closing_date_precision: (park?.closing_date_precision as 'exact' | 'month' | 'year' | 'decade' | 'century' | 'approximate') ?? undefined,
|
||||
location_id: park?.location?.id,
|
||||
location: park?.location ? {
|
||||
name: park.location.name || '',
|
||||
|
||||
@@ -291,8 +291,9 @@ export default function Profile() {
|
||||
submission_type,
|
||||
status,
|
||||
created_at,
|
||||
submission_metadata(name)
|
||||
submission_metadata!inner(metadata_value)
|
||||
`)
|
||||
.eq('submission_metadata.metadata_key', 'name')
|
||||
.eq('user_id', userId)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(10);
|
||||
@@ -310,10 +311,10 @@ export default function Profile() {
|
||||
const enrichedSubmissions = await Promise.all((submissions || []).map(async (sub) => {
|
||||
const enriched: any = { ...sub };
|
||||
|
||||
// Get name from submission_metadata
|
||||
// Get name from submission_metadata - extract metadata_value from the joined result
|
||||
const metadata = sub.submission_metadata as any;
|
||||
enriched.name = Array.isArray(metadata) && metadata.length > 0
|
||||
? metadata[0]?.name
|
||||
? metadata[0]?.metadata_value
|
||||
: undefined;
|
||||
|
||||
// For photo submissions, get photo count and preview
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useState, useEffect, lazy, Suspense } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { Header } from '@/components/layout/Header';
|
||||
import { EntityBreadcrumb } from '@/components/navigation/EntityBreadcrumb';
|
||||
import { CompanyDetailSkeleton } from '@/components/loading/CompanyDetailSkeleton';
|
||||
import { trackPageView } from '@/lib/viewTracking';
|
||||
import { getBannerUrls } from '@/lib/cloudflareImageUtils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -188,12 +190,7 @@ export default function PropertyOwnerDetail() {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Header />
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="animate-pulse space-y-6">
|
||||
<div className="h-64 bg-muted rounded-lg"></div>
|
||||
<div className="h-8 bg-muted rounded w-1/2"></div>
|
||||
</div>
|
||||
</div>
|
||||
<CompanyDetailSkeleton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -220,13 +217,17 @@ export default function PropertyOwnerDetail() {
|
||||
<Header />
|
||||
|
||||
<main className="container mx-auto px-4 py-8 max-w-7xl">
|
||||
{/* Back Button and Edit Button */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<Button variant="ghost" onClick={() => navigate('/owners')}>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Back to Property Owners
|
||||
</Button>
|
||||
|
||||
{/* Breadcrumb Navigation */}
|
||||
<EntityBreadcrumb
|
||||
segments={[
|
||||
{ label: 'Property Owners', href: '/owners' },
|
||||
{ label: owner.name }
|
||||
]}
|
||||
className="mb-4"
|
||||
/>
|
||||
|
||||
{/* Edit Button */}
|
||||
<div className="flex justify-end mb-6">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => requireAuth(() => setIsEditModalOpen(true), "Sign in to edit this property owner")}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { useState, lazy, Suspense, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card';
|
||||
import { CompanyPreviewCard } from '@/components/preview/CompanyPreviewCard';
|
||||
import { ParkPreviewCard } from '@/components/preview/ParkPreviewCard';
|
||||
import { EntityBreadcrumb } from '@/components/navigation/EntityBreadcrumb';
|
||||
import { RideDetailSkeleton } from '@/components/loading/RideDetailSkeleton';
|
||||
import { Header } from '@/components/layout/Header';
|
||||
import { getBannerUrls } from '@/lib/cloudflareImageUtils';
|
||||
import { trackPageView } from '@/lib/viewTracking';
|
||||
@@ -160,13 +165,7 @@ export default function RideDetail() {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Header />
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="animate-pulse space-y-6">
|
||||
<div className="h-64 bg-muted rounded-lg"></div>
|
||||
<div className="h-8 bg-muted rounded w-1/2"></div>
|
||||
<div className="h-4 bg-muted rounded w-1/3"></div>
|
||||
</div>
|
||||
</div>
|
||||
<RideDetailSkeleton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -194,18 +193,27 @@ export default function RideDetail() {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Header />
|
||||
|
||||
|
||||
<main className="container mx-auto px-4 py-8 max-w-7xl">
|
||||
{/* Back Button and Edit Button */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => navigate(`/parks/${ride.park?.slug}`)}
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Back to {ride.park?.name}
|
||||
</Button>
|
||||
|
||||
{/* Breadcrumb Navigation */}
|
||||
<EntityBreadcrumb
|
||||
segments={[
|
||||
{ label: 'Parks', href: '/parks' },
|
||||
{
|
||||
label: ride.park.name,
|
||||
href: `/parks/${ride.park.slug}`,
|
||||
showPreview: true,
|
||||
previewType: 'park',
|
||||
previewSlug: ride.park.slug
|
||||
},
|
||||
{ label: 'Rides', href: `/parks/${ride.park.slug}#rides` },
|
||||
{ label: ride.name }
|
||||
]}
|
||||
className="mb-4"
|
||||
/>
|
||||
|
||||
{/* Edit Button */}
|
||||
<div className="flex justify-end mb-6">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => requireAuth(() => setIsEditModalOpen(true), "Sign in to edit this ride")}
|
||||
@@ -255,10 +263,20 @@ export default function RideDetail() {
|
||||
<h1 className="text-4xl md:text-6xl font-bold text-white mb-2">
|
||||
{ride.name}
|
||||
</h1>
|
||||
<div className="flex items-center text-white/90 text-lg">
|
||||
<MapPin className="w-5 h-5 mr-2" />
|
||||
{ride.park.name}
|
||||
</div>
|
||||
<HoverCard openDelay={300}>
|
||||
<HoverCardTrigger asChild>
|
||||
<Link
|
||||
to={`/parks/${ride.park.slug}`}
|
||||
className="flex items-center text-white/90 text-lg hover:text-white transition-colors"
|
||||
>
|
||||
<MapPin className="w-5 h-5 mr-2" />
|
||||
<span className="hover:underline">{ride.park.name}</span>
|
||||
</Link>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent side="bottom" align="start" className="w-auto">
|
||||
<ParkPreviewCard slug={ride.park.slug} />
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
<div className="mt-3">
|
||||
<VersionIndicator
|
||||
entityType="ride"
|
||||
@@ -471,9 +489,19 @@ export default function RideDetail() {
|
||||
<Users className="w-4 h-4 text-muted-foreground" />
|
||||
<div>
|
||||
<div className="font-medium">Manufacturer</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{ride.manufacturer.name}
|
||||
</div>
|
||||
<HoverCard openDelay={300}>
|
||||
<HoverCardTrigger asChild>
|
||||
<Link
|
||||
to={`/manufacturers/${ride.manufacturer.slug}`}
|
||||
className="text-sm text-primary hover:underline"
|
||||
>
|
||||
{ride.manufacturer.name}
|
||||
</Link>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent side="right" className="w-auto">
|
||||
<CompanyPreviewCard slug={ride.manufacturer.slug} />
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -483,9 +511,19 @@ export default function RideDetail() {
|
||||
<Users className="w-4 h-4 text-muted-foreground" />
|
||||
<div>
|
||||
<div className="font-medium">Designer</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{ride.designer.name}
|
||||
</div>
|
||||
<HoverCard openDelay={300}>
|
||||
<HoverCardTrigger asChild>
|
||||
<Link
|
||||
to={`/designers/${ride.designer.slug}`}
|
||||
className="text-sm text-primary hover:underline"
|
||||
>
|
||||
{ride.designer.name}
|
||||
</Link>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent side="right" className="w-auto">
|
||||
<CompanyPreviewCard slug={ride.designer.slug} />
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useState, useEffect, useCallback, lazy, Suspense } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { Header } from '@/components/layout/Header';
|
||||
import { EntityBreadcrumb } from '@/components/navigation/EntityBreadcrumb';
|
||||
import { CompanyDetailSkeleton } from '@/components/loading/CompanyDetailSkeleton';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
@@ -167,17 +169,7 @@ export default function RideModelDetail() {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Header />
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="animate-pulse space-y-6">
|
||||
<div className="h-12 bg-muted rounded w-1/3"></div>
|
||||
<div className="h-64 bg-muted rounded"></div>
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<div key={i} className="h-48 bg-muted rounded-lg"></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<CompanyDetailSkeleton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -204,12 +196,25 @@ export default function RideModelDetail() {
|
||||
<Header />
|
||||
|
||||
<main className="container mx-auto px-4 py-8">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<Button variant="ghost" onClick={() => navigate(`/manufacturers/${manufacturerSlug}/models`)}>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Back to {manufacturer.name} Models
|
||||
</Button>
|
||||
|
||||
{/* Breadcrumb Navigation */}
|
||||
<EntityBreadcrumb
|
||||
segments={[
|
||||
{ label: 'Manufacturers', href: '/manufacturers' },
|
||||
{
|
||||
label: manufacturer.name,
|
||||
href: `/manufacturers/${manufacturerSlug}`,
|
||||
showPreview: true,
|
||||
previewType: 'company',
|
||||
previewSlug: manufacturerSlug || ''
|
||||
},
|
||||
{ label: 'Models', href: `/manufacturers/${manufacturerSlug}/models` },
|
||||
{ label: model.name }
|
||||
]}
|
||||
className="mb-4"
|
||||
/>
|
||||
|
||||
{/* Edit Button */}
|
||||
<div className="flex justify-end mb-6">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => requireAuth(() => setIsEditModalOpen(true), "Sign in to edit this ride model")}
|
||||
|
||||
136
src/pages/admin/ApprovalHistory.tsx
Normal file
136
src/pages/admin/ApprovalHistory.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Approval History Page
|
||||
*
|
||||
* Full-page view for compliance reporting with advanced filters,
|
||||
* date range selection, and export functionality.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { ItemApprovalHistory } from '@/components/moderation/ItemApprovalHistory';
|
||||
import { FilterDateRangePicker } from '@/components/filters/FilterDateRangePicker';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { X, FileCheck } from 'lucide-react';
|
||||
import { useUserRole } from '@/hooks/useUserRole';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import type { EntityType } from '@/types/submissions';
|
||||
|
||||
export default function ApprovalHistory() {
|
||||
const { isModerator, loading: rolesLoading } = useUserRole();
|
||||
const [fromDate, setFromDate] = useState<Date | null>(null);
|
||||
const [toDate, setToDate] = useState<Date | null>(null);
|
||||
const [itemType, setItemType] = useState<EntityType | 'all'>('all');
|
||||
const [limit, setLimit] = useState<number>(100);
|
||||
|
||||
// Access control: moderators only
|
||||
if (rolesLoading) {
|
||||
return (
|
||||
<div className="container mx-auto p-6">
|
||||
<div className="text-center">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isModerator()) {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
const hasFilters = fromDate || toDate || itemType !== 'all' || limit !== 100;
|
||||
|
||||
const clearFilters = () => {
|
||||
setFromDate(null);
|
||||
setToDate(null);
|
||||
setItemType('all');
|
||||
setLimit(100);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-6 max-w-7xl">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<FileCheck className="w-8 h-8 text-primary" />
|
||||
<h1 className="text-3xl font-bold">Approval History</h1>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
Complete audit trail of all approved items with exact timestamps for compliance reporting
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<Card className="mb-6">
|
||||
<CardContent className="pt-6">
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
|
||||
{/* Date Range Filter */}
|
||||
<div className="lg:col-span-2">
|
||||
<FilterDateRangePicker
|
||||
label="Approval Date Range"
|
||||
fromDate={fromDate}
|
||||
toDate={toDate}
|
||||
onFromChange={(date) => setFromDate(date || null)}
|
||||
onToChange={(date) => setToDate(date || null)}
|
||||
fromPlaceholder="Start Date"
|
||||
toPlaceholder="End Date"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Item Type Filter */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="item-type">Item Type</Label>
|
||||
<Select value={itemType} onValueChange={(val) => setItemType(val as EntityType | 'all')}>
|
||||
<SelectTrigger id="item-type">
|
||||
<SelectValue placeholder="All Types" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Types</SelectItem>
|
||||
<SelectItem value="park">Parks</SelectItem>
|
||||
<SelectItem value="ride">Rides</SelectItem>
|
||||
<SelectItem value="manufacturer">Manufacturers</SelectItem>
|
||||
<SelectItem value="designer">Designers</SelectItem>
|
||||
<SelectItem value="operator">Operators</SelectItem>
|
||||
<SelectItem value="ride_model">Ride Models</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Results Limit */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="limit">Results Limit</Label>
|
||||
<Select value={limit.toString()} onValueChange={(val) => setLimit(parseInt(val))}>
|
||||
<SelectTrigger id="limit">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="50">50</SelectItem>
|
||||
<SelectItem value="100">100</SelectItem>
|
||||
<SelectItem value="250">250</SelectItem>
|
||||
<SelectItem value="500">500</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Clear Filters */}
|
||||
{hasFilters && (
|
||||
<div className="mt-4 flex justify-end">
|
||||
<Button variant="outline" size="sm" onClick={clearFilters}>
|
||||
<X className="w-4 h-4 mr-2" />
|
||||
Clear Filters
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* History Table */}
|
||||
<ItemApprovalHistory
|
||||
dateRange={fromDate && toDate ? { from: fromDate, to: toDate } : undefined}
|
||||
itemType={itemType === 'all' ? undefined : itemType}
|
||||
limit={limit}
|
||||
embedded={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
224
src/pages/admin/DatabaseMaintenance.tsx
Normal file
224
src/pages/admin/DatabaseMaintenance.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
import { useState } from 'react';
|
||||
import { AdminLayout } from '@/components/layout/AdminLayout';
|
||||
import { AdminPageLayout } from '@/components/admin';
|
||||
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, AlertTitle } from '@/components/ui/alert';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
useMaintenanceTables,
|
||||
useVacuumTable,
|
||||
useAnalyzeTable,
|
||||
useReindexTable,
|
||||
} from '@/hooks/admin/useDatabaseMaintenance';
|
||||
import { Database, RefreshCw, Zap, Settings, AlertTriangle } from 'lucide-react';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
|
||||
export default function DatabaseMaintenance() {
|
||||
const { data: tables, isLoading, refetch } = useMaintenanceTables();
|
||||
const vacuumMutation = useVacuumTable();
|
||||
const analyzeMutation = useAnalyzeTable();
|
||||
const reindexMutation = useReindexTable();
|
||||
|
||||
const [selectedTable, setSelectedTable] = useState<string | null>(null);
|
||||
|
||||
const handleVacuum = (tableName: string) => {
|
||||
setSelectedTable(tableName);
|
||||
vacuumMutation.mutate(tableName);
|
||||
};
|
||||
|
||||
const handleAnalyze = (tableName: string) => {
|
||||
setSelectedTable(tableName);
|
||||
analyzeMutation.mutate(tableName);
|
||||
};
|
||||
|
||||
const handleReindex = (tableName: string) => {
|
||||
setSelectedTable(tableName);
|
||||
reindexMutation.mutate(tableName);
|
||||
};
|
||||
|
||||
const isOperationPending = (tableName: string) => {
|
||||
return (
|
||||
selectedTable === tableName &&
|
||||
(vacuumMutation.isPending || analyzeMutation.isPending || reindexMutation.isPending)
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminLayout>
|
||||
<AdminPageLayout
|
||||
title="Database Maintenance"
|
||||
description="Run vacuum, analyze, and reindex operations on database tables"
|
||||
>
|
||||
<Alert variant="default" className="mb-6">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle>Superuser Access Required</AlertTitle>
|
||||
<AlertDescription>
|
||||
These operations require superuser privileges. They can help improve database
|
||||
performance by reclaiming storage, updating statistics, and rebuilding indexes.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="grid gap-6 mb-6 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">VACUUM</CardTitle>
|
||||
<Database className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardDescription className="text-xs">
|
||||
Reclaims storage occupied by dead tuples and makes space available for reuse
|
||||
</CardDescription>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">ANALYZE</CardTitle>
|
||||
<Zap className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardDescription className="text-xs">
|
||||
Updates statistics used by the query planner for optimal query execution plans
|
||||
</CardDescription>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">REINDEX</CardTitle>
|
||||
<Settings className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardDescription className="text-xs">
|
||||
Rebuilds indexes to eliminate bloat and restore optimal index performance
|
||||
</CardDescription>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Database Tables</CardTitle>
|
||||
<CardDescription>
|
||||
Select maintenance operations to perform on each table
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => refetch()}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<RefreshCw className={`mr-2 h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-12 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : tables && tables.length > 0 ? (
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Table Name</TableHead>
|
||||
<TableHead className="text-right">Rows</TableHead>
|
||||
<TableHead className="text-right">Table Size</TableHead>
|
||||
<TableHead className="text-right">Indexes Size</TableHead>
|
||||
<TableHead className="text-right">Total Size</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{tables.map((table) => (
|
||||
<TableRow key={table.table_name}>
|
||||
<TableCell className="font-medium">
|
||||
<code className="text-sm">{table.table_name}</code>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{table.row_count?.toLocaleString() || 'N/A'}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Badge variant="secondary">{table.table_size}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Badge variant="secondary">{table.indexes_size}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Badge variant="outline">{table.total_size}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleVacuum(table.table_name)}
|
||||
disabled={isOperationPending(table.table_name)}
|
||||
>
|
||||
{isOperationPending(table.table_name) &&
|
||||
vacuumMutation.isPending ? (
|
||||
<RefreshCw className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
'Vacuum'
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleAnalyze(table.table_name)}
|
||||
disabled={isOperationPending(table.table_name)}
|
||||
>
|
||||
{isOperationPending(table.table_name) &&
|
||||
analyzeMutation.isPending ? (
|
||||
<RefreshCw className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
'Analyze'
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleReindex(table.table_name)}
|
||||
disabled={isOperationPending(table.table_name)}
|
||||
>
|
||||
{isOperationPending(table.table_name) &&
|
||||
reindexMutation.isPending ? (
|
||||
<RefreshCw className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
'Reindex'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
No tables available
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AdminPageLayout>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
@@ -32,7 +32,7 @@ export interface TimelineEventDatabaseRecord {
|
||||
entity_type: 'park' | 'ride' | 'company' | 'ride_model';
|
||||
event_type: string;
|
||||
event_date: string;
|
||||
event_date_precision: 'day' | 'month' | 'year';
|
||||
event_date_precision: 'exact' | 'month' | 'year' | 'decade' | 'century' | 'approximate';
|
||||
title: string;
|
||||
description?: string | null;
|
||||
from_value?: string | null;
|
||||
|
||||
@@ -313,6 +313,14 @@ export interface SortConfig {
|
||||
direction: SortDirection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Approval date range filter for moderation queue
|
||||
*/
|
||||
export interface ApprovalDateRangeFilter {
|
||||
from: Date | null;
|
||||
to: Date | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loading states for the moderation queue
|
||||
*/
|
||||
|
||||
@@ -151,7 +151,7 @@ export interface TimelineEventItemData {
|
||||
entity_type: 'park' | 'ride' | 'company' | 'ride_model';
|
||||
event_type: string;
|
||||
event_date: string; // ISO date
|
||||
event_date_precision: 'day' | 'month' | 'year';
|
||||
event_date_precision: 'exact' | 'month' | 'year' | 'decade' | 'century' | 'approximate';
|
||||
title: string;
|
||||
description?: string | null;
|
||||
from_value?: string | null;
|
||||
|
||||
@@ -43,6 +43,7 @@ export interface SubmissionItemData {
|
||||
rejection_reason: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
approved_at: string | null;
|
||||
}
|
||||
|
||||
export interface EntityPhotoGalleryProps {
|
||||
|
||||
@@ -22,7 +22,7 @@ export type TimelineEventType =
|
||||
|
||||
export type EntityType = 'park' | 'ride' | 'company' | 'ride_model';
|
||||
|
||||
export type DatePrecision = 'day' | 'month' | 'year';
|
||||
export type DatePrecision = 'exact' | 'month' | 'year' | 'decade' | 'century' | 'approximate';
|
||||
|
||||
/**
|
||||
* Timeline event stored in database after approval
|
||||
|
||||
@@ -266,7 +266,15 @@ export function wrapEdgeFunction(
|
||||
logSpanToDatabase(span, requestId);
|
||||
|
||||
// Clone response to add tracking headers
|
||||
const responseBody = await response.text();
|
||||
// Defensive check: ensure handler returned a Response object
|
||||
let responseBody: string;
|
||||
if (response instanceof Response) {
|
||||
responseBody = await response.text();
|
||||
} else {
|
||||
// Handler returned non-Response (shouldn't happen, but handle it)
|
||||
addSpanEvent(span, 'warning_non_response_object');
|
||||
responseBody = JSON.stringify(response);
|
||||
}
|
||||
const enhancedResponse = new Response(responseBody, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
|
||||
@@ -28,7 +28,7 @@ const handler = async (req: Request, { supabase, span, requestId }: EdgeFunction
|
||||
metrics.push({
|
||||
metric_name: 'api_error_count',
|
||||
metric_value: errorCount as number,
|
||||
metric_category: 'performance',
|
||||
metric_category: 'api',
|
||||
timestamp,
|
||||
});
|
||||
}
|
||||
@@ -45,7 +45,7 @@ const handler = async (req: Request, { supabase, span, requestId }: EdgeFunction
|
||||
metrics.push({
|
||||
metric_name: 'rate_limit_violations',
|
||||
metric_value: violationCount as number,
|
||||
metric_category: 'security',
|
||||
metric_category: 'rate_limit',
|
||||
timestamp,
|
||||
});
|
||||
}
|
||||
@@ -61,7 +61,7 @@ const handler = async (req: Request, { supabase, span, requestId }: EdgeFunction
|
||||
metrics.push({
|
||||
metric_name: 'pending_submissions',
|
||||
metric_value: pendingCount as number,
|
||||
metric_category: 'workflow',
|
||||
metric_category: 'moderation',
|
||||
timestamp,
|
||||
});
|
||||
}
|
||||
@@ -77,7 +77,7 @@ const handler = async (req: Request, { supabase, span, requestId }: EdgeFunction
|
||||
metrics.push({
|
||||
metric_name: 'active_incidents',
|
||||
metric_value: incidentCount as number,
|
||||
metric_category: 'monitoring',
|
||||
metric_category: 'system',
|
||||
timestamp,
|
||||
});
|
||||
}
|
||||
@@ -86,14 +86,14 @@ const handler = async (req: Request, { supabase, span, requestId }: EdgeFunction
|
||||
const { data: unresolvedAlerts, error: alertsError } = await supabase
|
||||
.from('system_alerts')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('resolved', false);
|
||||
.is('resolved_at', null);
|
||||
|
||||
if (!alertsError) {
|
||||
const alertCount = unresolvedAlerts || 0;
|
||||
metrics.push({
|
||||
metric_name: 'unresolved_alerts',
|
||||
metric_value: alertCount as number,
|
||||
metric_category: 'monitoring',
|
||||
metric_category: 'system',
|
||||
timestamp,
|
||||
});
|
||||
}
|
||||
@@ -112,7 +112,7 @@ const handler = async (req: Request, { supabase, span, requestId }: EdgeFunction
|
||||
metrics.push({
|
||||
metric_name: 'submission_approval_rate',
|
||||
metric_value: approvalRate,
|
||||
metric_category: 'workflow',
|
||||
metric_category: 'moderation',
|
||||
timestamp,
|
||||
});
|
||||
}
|
||||
@@ -136,7 +136,7 @@ const handler = async (req: Request, { supabase, span, requestId }: EdgeFunction
|
||||
metrics.push({
|
||||
metric_name: 'avg_moderation_time',
|
||||
metric_value: avgTimeMinutes,
|
||||
metric_category: 'workflow',
|
||||
metric_category: 'moderation',
|
||||
timestamp,
|
||||
});
|
||||
}
|
||||
@@ -155,11 +155,17 @@ const handler = async (req: Request, { supabase, span, requestId }: EdgeFunction
|
||||
addSpanEvent(span, 'metrics_recorded', { count: metrics.length });
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
metrics_collected: metrics.length,
|
||||
metrics: metrics.map(m => ({ name: m.metric_name, value: m.metric_value })),
|
||||
};
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
metrics_collected: metrics.length,
|
||||
metrics: metrics.map(m => ({ name: m.metric_name, value: m.metric_value })),
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
serve(createEdgeFunction({
|
||||
|
||||
@@ -474,11 +474,17 @@ const handler = async (req: Request, { supabase, span, requestId }: EdgeFunction
|
||||
|
||||
addSpanEvent(span, 'anomaly_detection_complete', { detected: anomaliesDetected.length });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
anomalies_detected: anomaliesDetected.length,
|
||||
anomalies: anomaliesDetected,
|
||||
};
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
anomalies_detected: anomaliesDetected.length,
|
||||
anomalies: anomaliesDetected,
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
serve(createEdgeFunction({
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { serve } from 'https://deno.land/std@0.190.0/http/server.ts';
|
||||
import { createEdgeFunction } from '../_shared/edgeFunctionWrapper.ts';
|
||||
import { corsHeadersWithTracing } from '../_shared/cors.ts';
|
||||
import {
|
||||
addSpanEvent,
|
||||
setSpanAttributes,
|
||||
@@ -16,11 +18,42 @@ import { ValidationError } from '../_shared/typeValidation.ts';
|
||||
const handler = async (req: Request, context: { supabase: any; user: any; span: any; requestId: string }) => {
|
||||
const { supabase, user, span: rootSpan, requestId } = context;
|
||||
|
||||
// Early logging - confirms request reached handler
|
||||
addSpanEvent(rootSpan, 'handler_entry', {
|
||||
requestId,
|
||||
userId: user.id,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
setSpanAttributes(rootSpan, {
|
||||
'user.id': user.id,
|
||||
'function.name': 'process-selective-approval'
|
||||
});
|
||||
|
||||
// Health check endpoint
|
||||
if (req.url.includes('/health')) {
|
||||
addSpanEvent(rootSpan, 'health_check_start');
|
||||
const { data, error } = await supabase
|
||||
.from('content_submissions')
|
||||
.select('count')
|
||||
.limit(1);
|
||||
|
||||
addSpanEvent(rootSpan, 'health_check_complete', {
|
||||
dbConnected: !error,
|
||||
error: error?.message
|
||||
});
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
status: 'ok',
|
||||
dbConnected: !error,
|
||||
timestamp: Date.now(),
|
||||
error: error?.message
|
||||
}), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
status: error ? 500 : 200
|
||||
});
|
||||
}
|
||||
|
||||
// STEP 1: Parse and validate request
|
||||
addSpanEvent(rootSpan, 'validation_start');
|
||||
|
||||
@@ -57,14 +90,37 @@ const handler = async (req: Request, context: { supabase: any; user: any; span:
|
||||
});
|
||||
addSpanEvent(rootSpan, 'validation_complete');
|
||||
|
||||
// STEP 2: Idempotency check
|
||||
// STEP 2: Idempotency check with timeout
|
||||
addSpanEvent(rootSpan, 'idempotency_check_start');
|
||||
const { data: existingKey } = await supabase
|
||||
|
||||
const idempotencyCheckPromise = supabase
|
||||
.from('submission_idempotency_keys')
|
||||
.select('*')
|
||||
.eq('idempotency_key', idempotencyKey)
|
||||
.single();
|
||||
|
||||
// Add 5 second timeout for idempotency check
|
||||
const timeoutPromise = new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Idempotency check timed out after 5s')), 5000)
|
||||
);
|
||||
|
||||
let existingKey;
|
||||
try {
|
||||
const result = await Promise.race([
|
||||
idempotencyCheckPromise,
|
||||
timeoutPromise
|
||||
]) as any;
|
||||
existingKey = result.data;
|
||||
} catch (timeoutError: any) {
|
||||
addSpanEvent(rootSpan, 'idempotency_check_timeout', { error: timeoutError.message });
|
||||
throw new Error(`Database query timeout: ${timeoutError.message}`);
|
||||
}
|
||||
|
||||
addSpanEvent(rootSpan, 'idempotency_check_complete', {
|
||||
foundKey: !!existingKey,
|
||||
status: existingKey?.status
|
||||
});
|
||||
|
||||
if (existingKey?.status === 'completed') {
|
||||
addSpanEvent(rootSpan, 'idempotency_cache_hit');
|
||||
setSpanAttributes(rootSpan, { 'cache.hit': true });
|
||||
@@ -235,13 +291,11 @@ const handler = async (req: Request, context: { supabase: any; user: any; span:
|
||||
};
|
||||
|
||||
// Create edge function with automatic error handling, CORS, auth, and logging
|
||||
createEdgeFunction(
|
||||
serve(createEdgeFunction(
|
||||
{
|
||||
name: 'process-selective-approval',
|
||||
requireAuth: true,
|
||||
corsEnabled: true,
|
||||
enableTracing: true,
|
||||
rateLimitTier: 'moderate'
|
||||
corsHeaders: corsHeadersWithTracing,
|
||||
},
|
||||
handler
|
||||
);
|
||||
));
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
-- Phase 3: Migrate 'day' precision to 'exact' across all tables
|
||||
-- This fixes the check constraint violations and aligns with the new precision system
|
||||
|
||||
-- Parks
|
||||
UPDATE parks
|
||||
SET opening_date_precision = 'exact'
|
||||
WHERE opening_date_precision = 'day';
|
||||
|
||||
UPDATE parks
|
||||
SET closing_date_precision = 'exact'
|
||||
WHERE closing_date_precision = 'day';
|
||||
|
||||
-- Park Submissions
|
||||
UPDATE park_submissions
|
||||
SET opening_date_precision = 'exact'
|
||||
WHERE opening_date_precision = 'day';
|
||||
|
||||
UPDATE park_submissions
|
||||
SET closing_date_precision = 'exact'
|
||||
WHERE closing_date_precision = 'day';
|
||||
|
||||
-- Park Versions
|
||||
UPDATE park_versions
|
||||
SET opening_date_precision = 'exact'
|
||||
WHERE opening_date_precision = 'day';
|
||||
|
||||
UPDATE park_versions
|
||||
SET closing_date_precision = 'exact'
|
||||
WHERE closing_date_precision = 'day';
|
||||
|
||||
-- Rides
|
||||
UPDATE rides
|
||||
SET opening_date_precision = 'exact'
|
||||
WHERE opening_date_precision = 'day';
|
||||
|
||||
UPDATE rides
|
||||
SET closing_date_precision = 'exact'
|
||||
WHERE closing_date_precision = 'day';
|
||||
|
||||
-- Ride Submissions
|
||||
UPDATE ride_submissions
|
||||
SET opening_date_precision = 'exact'
|
||||
WHERE opening_date_precision = 'day';
|
||||
|
||||
UPDATE ride_submissions
|
||||
SET closing_date_precision = 'exact'
|
||||
WHERE closing_date_precision = 'day';
|
||||
|
||||
-- Ride Versions
|
||||
UPDATE ride_versions
|
||||
SET opening_date_precision = 'exact'
|
||||
WHERE opening_date_precision = 'day';
|
||||
|
||||
UPDATE ride_versions
|
||||
SET closing_date_precision = 'exact'
|
||||
WHERE closing_date_precision = 'day';
|
||||
|
||||
-- Companies
|
||||
UPDATE companies
|
||||
SET founded_date_precision = 'exact'
|
||||
WHERE founded_date_precision = 'day';
|
||||
|
||||
-- Company Submissions
|
||||
UPDATE company_submissions
|
||||
SET founded_date_precision = 'exact'
|
||||
WHERE founded_date_precision = 'day';
|
||||
|
||||
-- Company Versions
|
||||
UPDATE company_versions
|
||||
SET founded_date_precision = 'exact'
|
||||
WHERE founded_date_precision = 'day';
|
||||
|
||||
-- Entity Timeline Events
|
||||
UPDATE entity_timeline_events
|
||||
SET event_date_precision = 'exact'
|
||||
WHERE event_date_precision = 'day';
|
||||
|
||||
-- Timeline Event Submissions
|
||||
UPDATE timeline_event_submissions
|
||||
SET event_date_precision = 'exact'
|
||||
WHERE event_date_precision = 'day';
|
||||
|
||||
-- Historical Parks
|
||||
UPDATE historical_parks
|
||||
SET opening_date_precision = 'exact'
|
||||
WHERE opening_date_precision = 'day';
|
||||
|
||||
UPDATE historical_parks
|
||||
SET closing_date_precision = 'exact'
|
||||
WHERE closing_date_precision = 'day';
|
||||
|
||||
-- Historical Rides
|
||||
UPDATE historical_rides
|
||||
SET opening_date_precision = 'exact'
|
||||
WHERE opening_date_precision = 'day';
|
||||
|
||||
UPDATE historical_rides
|
||||
SET closing_date_precision = 'exact'
|
||||
WHERE closing_date_precision = 'day';
|
||||
@@ -0,0 +1,272 @@
|
||||
-- Fix get_database_statistics function to use correct table name 'photos' instead of 'entity_photos'
|
||||
CREATE OR REPLACE FUNCTION public.get_database_statistics()
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
STABLE SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_stats jsonb;
|
||||
v_parks_total integer;
|
||||
v_parks_active integer;
|
||||
v_parks_historical integer;
|
||||
v_parks_7d integer;
|
||||
v_parks_30d integer;
|
||||
v_rides_total integer;
|
||||
v_rides_active integer;
|
||||
v_rides_historical integer;
|
||||
v_rides_7d integer;
|
||||
v_rides_30d integer;
|
||||
v_companies_total integer;
|
||||
v_manufacturers integer;
|
||||
v_operators integer;
|
||||
v_designers integer;
|
||||
v_companies_7d integer;
|
||||
v_companies_30d integer;
|
||||
v_ride_models_total integer;
|
||||
v_ride_models_7d integer;
|
||||
v_ride_models_30d integer;
|
||||
v_locations_total integer;
|
||||
v_timeline_events_total integer;
|
||||
v_photos_total integer;
|
||||
v_photos_7d integer;
|
||||
v_photos_30d integer;
|
||||
v_users_total integer;
|
||||
v_users_active_30d integer;
|
||||
v_submissions_pending integer;
|
||||
v_submissions_approved integer;
|
||||
v_submissions_rejected integer;
|
||||
BEGIN
|
||||
-- Parks statistics
|
||||
SELECT COUNT(*) INTO v_parks_total FROM parks;
|
||||
SELECT COUNT(*) INTO v_parks_active FROM parks WHERE status = 'operating';
|
||||
SELECT COUNT(*) INTO v_parks_historical FROM parks WHERE status IN ('closed', 'historical');
|
||||
SELECT COUNT(*) INTO v_parks_7d FROM parks WHERE created_at > NOW() - INTERVAL '7 days';
|
||||
SELECT COUNT(*) INTO v_parks_30d FROM parks WHERE created_at > NOW() - INTERVAL '30 days';
|
||||
|
||||
-- Rides statistics
|
||||
SELECT COUNT(*) INTO v_rides_total FROM rides;
|
||||
SELECT COUNT(*) INTO v_rides_active FROM rides WHERE status = 'operating';
|
||||
SELECT COUNT(*) INTO v_rides_historical FROM rides WHERE status IN ('closed', 'removed', 'relocated');
|
||||
SELECT COUNT(*) INTO v_rides_7d FROM rides WHERE created_at > NOW() - INTERVAL '7 days';
|
||||
SELECT COUNT(*) INTO v_rides_30d FROM rides WHERE created_at > NOW() - INTERVAL '30 days';
|
||||
|
||||
-- Companies statistics
|
||||
SELECT COUNT(*) INTO v_companies_total FROM companies;
|
||||
SELECT COUNT(*) INTO v_manufacturers FROM companies WHERE company_type = 'manufacturer';
|
||||
SELECT COUNT(*) INTO v_operators FROM companies WHERE company_type = 'operator';
|
||||
SELECT COUNT(*) INTO v_designers FROM companies WHERE company_type = 'designer';
|
||||
SELECT COUNT(*) INTO v_companies_7d FROM companies WHERE created_at > NOW() - INTERVAL '7 days';
|
||||
SELECT COUNT(*) INTO v_companies_30d FROM companies WHERE created_at > NOW() - INTERVAL '30 days';
|
||||
|
||||
-- Ride models statistics
|
||||
SELECT COUNT(*) INTO v_ride_models_total FROM ride_models;
|
||||
SELECT COUNT(*) INTO v_ride_models_7d FROM ride_models WHERE created_at > NOW() - INTERVAL '7 days';
|
||||
SELECT COUNT(*) INTO v_ride_models_30d FROM ride_models WHERE created_at > NOW() - INTERVAL '30 days';
|
||||
|
||||
-- Locations statistics
|
||||
SELECT COUNT(*) INTO v_locations_total FROM locations;
|
||||
|
||||
-- Timeline events statistics
|
||||
SELECT COUNT(*) INTO v_timeline_events_total FROM entity_timeline_events;
|
||||
|
||||
-- Photos statistics - FIXED: using 'photos' table instead of 'entity_photos'
|
||||
SELECT COUNT(*) INTO v_photos_total FROM photos;
|
||||
SELECT COUNT(*) INTO v_photos_7d FROM photos WHERE created_at > NOW() - INTERVAL '7 days';
|
||||
SELECT COUNT(*) INTO v_photos_30d FROM photos WHERE created_at > NOW() - INTERVAL '30 days';
|
||||
|
||||
-- Users statistics
|
||||
SELECT COUNT(*) INTO v_users_total FROM profiles;
|
||||
SELECT COUNT(*) INTO v_users_active_30d FROM profiles WHERE updated_at > NOW() - INTERVAL '30 days';
|
||||
|
||||
-- Submissions statistics
|
||||
SELECT COUNT(*) INTO v_submissions_pending FROM content_submissions WHERE status = 'pending';
|
||||
SELECT COUNT(*) INTO v_submissions_approved FROM content_submissions WHERE status = 'approved';
|
||||
SELECT COUNT(*) INTO v_submissions_rejected FROM content_submissions WHERE status = 'rejected';
|
||||
|
||||
-- Build result JSON
|
||||
v_stats := jsonb_build_object(
|
||||
'parks', jsonb_build_object(
|
||||
'total', v_parks_total,
|
||||
'active', v_parks_active,
|
||||
'historical', v_parks_historical,
|
||||
'added_7d', v_parks_7d,
|
||||
'added_30d', v_parks_30d
|
||||
),
|
||||
'rides', jsonb_build_object(
|
||||
'total', v_rides_total,
|
||||
'active', v_rides_active,
|
||||
'historical', v_rides_historical,
|
||||
'added_7d', v_rides_7d,
|
||||
'added_30d', v_rides_30d
|
||||
),
|
||||
'companies', jsonb_build_object(
|
||||
'total', v_companies_total,
|
||||
'manufacturers', v_manufacturers,
|
||||
'operators', v_operators,
|
||||
'designers', v_designers,
|
||||
'added_7d', v_companies_7d,
|
||||
'added_30d', v_companies_30d
|
||||
),
|
||||
'ride_models', jsonb_build_object(
|
||||
'total', v_ride_models_total,
|
||||
'added_7d', v_ride_models_7d,
|
||||
'added_30d', v_ride_models_30d
|
||||
),
|
||||
'locations', jsonb_build_object(
|
||||
'total', v_locations_total
|
||||
),
|
||||
'timeline_events', jsonb_build_object(
|
||||
'total', v_timeline_events_total
|
||||
),
|
||||
'photos', jsonb_build_object(
|
||||
'total', v_photos_total,
|
||||
'added_7d', v_photos_7d,
|
||||
'added_30d', v_photos_30d
|
||||
),
|
||||
'users', jsonb_build_object(
|
||||
'total', v_users_total,
|
||||
'active_30d', v_users_active_30d
|
||||
),
|
||||
'submissions', jsonb_build_object(
|
||||
'pending', v_submissions_pending,
|
||||
'approved', v_submissions_approved,
|
||||
'rejected', v_submissions_rejected
|
||||
)
|
||||
);
|
||||
|
||||
RETURN v_stats;
|
||||
END;
|
||||
$function$;
|
||||
|
||||
-- Fix get_recent_additions function to use correct table and column names
|
||||
CREATE OR REPLACE FUNCTION public.get_recent_additions(limit_count integer DEFAULT 50)
|
||||
RETURNS TABLE(entity_id uuid, entity_type text, entity_name text, entity_slug text, park_slug text, image_url text, created_at timestamp with time zone, created_by_id uuid, created_by_username text, created_by_avatar text)
|
||||
LANGUAGE plpgsql
|
||||
STABLE SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $function$
|
||||
BEGIN
|
||||
RETURN QUERY
|
||||
SELECT * FROM (
|
||||
-- Parks
|
||||
SELECT
|
||||
p.id as entity_id,
|
||||
'park'::text as entity_type,
|
||||
p.name as entity_name,
|
||||
p.slug as entity_slug,
|
||||
NULL::text as park_slug,
|
||||
p.card_image_url as image_url,
|
||||
p.created_at,
|
||||
p.created_by as created_by_id,
|
||||
prof.username as created_by_username,
|
||||
prof.avatar_url as created_by_avatar
|
||||
FROM parks p
|
||||
LEFT JOIN profiles prof ON prof.user_id = p.created_by
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- Rides
|
||||
SELECT
|
||||
r.id as entity_id,
|
||||
'ride'::text as entity_type,
|
||||
r.name as entity_name,
|
||||
r.slug as entity_slug,
|
||||
pk.slug as park_slug,
|
||||
r.card_image_url as image_url,
|
||||
r.created_at,
|
||||
r.created_by as created_by_id,
|
||||
prof.username as created_by_username,
|
||||
prof.avatar_url as created_by_avatar
|
||||
FROM rides r
|
||||
LEFT JOIN parks pk ON pk.id = r.park_id
|
||||
LEFT JOIN profiles prof ON prof.user_id = r.created_by
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- Companies
|
||||
SELECT
|
||||
c.id as entity_id,
|
||||
'company'::text as entity_type,
|
||||
c.name as entity_name,
|
||||
c.slug as entity_slug,
|
||||
NULL::text as park_slug,
|
||||
c.card_image_url as image_url,
|
||||
c.created_at,
|
||||
c.created_by as created_by_id,
|
||||
prof.username as created_by_username,
|
||||
prof.avatar_url as created_by_avatar
|
||||
FROM companies c
|
||||
LEFT JOIN profiles prof ON prof.user_id = c.created_by
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- Ride Models
|
||||
SELECT
|
||||
rm.id as entity_id,
|
||||
'ride_model'::text as entity_type,
|
||||
rm.name as entity_name,
|
||||
rm.slug as entity_slug,
|
||||
NULL::text as park_slug,
|
||||
rm.card_image_url as image_url,
|
||||
rm.created_at,
|
||||
rm.created_by as created_by_id,
|
||||
prof.username as created_by_username,
|
||||
prof.avatar_url as created_by_avatar
|
||||
FROM ride_models rm
|
||||
LEFT JOIN profiles prof ON prof.user_id = rm.created_by
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- Locations
|
||||
SELECT
|
||||
l.id as entity_id,
|
||||
'location'::text as entity_type,
|
||||
COALESCE(l.city || ', ' || l.country, l.country, 'Location') as entity_name,
|
||||
NULL::text as entity_slug,
|
||||
NULL::text as park_slug,
|
||||
NULL::text as image_url,
|
||||
l.created_at,
|
||||
NULL::uuid as created_by_id,
|
||||
NULL::text as created_by_username,
|
||||
NULL::text as created_by_avatar
|
||||
FROM locations l
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- Timeline Events
|
||||
SELECT
|
||||
te.id as entity_id,
|
||||
'timeline_event'::text as entity_type,
|
||||
te.event_title as entity_name,
|
||||
NULL::text as entity_slug,
|
||||
NULL::text as park_slug,
|
||||
NULL::text as image_url,
|
||||
te.created_at,
|
||||
te.created_by as created_by_id,
|
||||
prof.username as created_by_username,
|
||||
prof.avatar_url as created_by_avatar
|
||||
FROM entity_timeline_events te
|
||||
LEFT JOIN profiles prof ON prof.user_id = te.created_by
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- Photos - FIXED: using 'photos' table and correct column names
|
||||
SELECT
|
||||
p.id as entity_id,
|
||||
'photo'::text as entity_type,
|
||||
COALESCE(p.title, 'Photo') as entity_name,
|
||||
NULL::text as entity_slug,
|
||||
NULL::text as park_slug,
|
||||
p.cloudflare_image_url as image_url,
|
||||
p.created_at as created_at,
|
||||
p.submitted_by as created_by_id,
|
||||
prof.username as created_by_username,
|
||||
prof.avatar_url as created_by_avatar
|
||||
FROM photos p
|
||||
LEFT JOIN profiles prof ON prof.user_id = p.submitted_by
|
||||
) combined
|
||||
ORDER BY created_at DESC
|
||||
LIMIT limit_count;
|
||||
END;
|
||||
$function$;
|
||||
@@ -0,0 +1,188 @@
|
||||
-- Database Maintenance Functions
|
||||
-- These functions allow authorized users to perform database maintenance operations
|
||||
|
||||
-- Function to run VACUUM on a specific table
|
||||
CREATE OR REPLACE FUNCTION run_vacuum_table(table_name text)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
result jsonb;
|
||||
start_time timestamp;
|
||||
end_time timestamp;
|
||||
BEGIN
|
||||
-- Only allow superusers to run this
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM profiles
|
||||
WHERE id = auth.uid()
|
||||
AND role = 'superuser'
|
||||
) THEN
|
||||
RAISE EXCEPTION 'Only superusers can perform database maintenance';
|
||||
END IF;
|
||||
|
||||
start_time := clock_timestamp();
|
||||
|
||||
-- Execute VACUUM on the specified table
|
||||
EXECUTE format('VACUUM ANALYZE %I', table_name);
|
||||
|
||||
end_time := clock_timestamp();
|
||||
|
||||
result := jsonb_build_object(
|
||||
'table_name', table_name,
|
||||
'operation', 'VACUUM ANALYZE',
|
||||
'started_at', start_time,
|
||||
'completed_at', end_time,
|
||||
'duration_ms', EXTRACT(MILLISECONDS FROM (end_time - start_time)),
|
||||
'success', true
|
||||
);
|
||||
|
||||
RETURN result;
|
||||
EXCEPTION
|
||||
WHEN OTHERS THEN
|
||||
RETURN jsonb_build_object(
|
||||
'table_name', table_name,
|
||||
'operation', 'VACUUM ANALYZE',
|
||||
'success', false,
|
||||
'error', SQLERRM
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Function to run ANALYZE on a specific table
|
||||
CREATE OR REPLACE FUNCTION run_analyze_table(table_name text)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
result jsonb;
|
||||
start_time timestamp;
|
||||
end_time timestamp;
|
||||
BEGIN
|
||||
-- Only allow superusers to run this
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM profiles
|
||||
WHERE id = auth.uid()
|
||||
AND role = 'superuser'
|
||||
) THEN
|
||||
RAISE EXCEPTION 'Only superusers can perform database maintenance';
|
||||
END IF;
|
||||
|
||||
start_time := clock_timestamp();
|
||||
|
||||
-- Execute ANALYZE on the specified table
|
||||
EXECUTE format('ANALYZE %I', table_name);
|
||||
|
||||
end_time := clock_timestamp();
|
||||
|
||||
result := jsonb_build_object(
|
||||
'table_name', table_name,
|
||||
'operation', 'ANALYZE',
|
||||
'started_at', start_time,
|
||||
'completed_at', end_time,
|
||||
'duration_ms', EXTRACT(MILLISECONDS FROM (end_time - start_time)),
|
||||
'success', true
|
||||
);
|
||||
|
||||
RETURN result;
|
||||
EXCEPTION
|
||||
WHEN OTHERS THEN
|
||||
RETURN jsonb_build_object(
|
||||
'table_name', table_name,
|
||||
'operation', 'ANALYZE',
|
||||
'success', false,
|
||||
'error', SQLERRM
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Function to run REINDEX on a specific table
|
||||
CREATE OR REPLACE FUNCTION run_reindex_table(table_name text)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
result jsonb;
|
||||
start_time timestamp;
|
||||
end_time timestamp;
|
||||
BEGIN
|
||||
-- Only allow superusers to run this
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM profiles
|
||||
WHERE id = auth.uid()
|
||||
AND role = 'superuser'
|
||||
) THEN
|
||||
RAISE EXCEPTION 'Only superusers can perform database maintenance';
|
||||
END IF;
|
||||
|
||||
start_time := clock_timestamp();
|
||||
|
||||
-- Execute REINDEX on the specified table
|
||||
EXECUTE format('REINDEX TABLE %I', table_name);
|
||||
|
||||
end_time := clock_timestamp();
|
||||
|
||||
result := jsonb_build_object(
|
||||
'table_name', table_name,
|
||||
'operation', 'REINDEX',
|
||||
'started_at', start_time,
|
||||
'completed_at', end_time,
|
||||
'duration_ms', EXTRACT(MILLISECONDS FROM (end_time - start_time)),
|
||||
'success', true
|
||||
);
|
||||
|
||||
RETURN result;
|
||||
EXCEPTION
|
||||
WHEN OTHERS THEN
|
||||
RETURN jsonb_build_object(
|
||||
'table_name', table_name,
|
||||
'operation', 'REINDEX',
|
||||
'success', false,
|
||||
'error', SQLERRM
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Function to get list of tables that can be maintained
|
||||
CREATE OR REPLACE FUNCTION get_maintenance_tables()
|
||||
RETURNS TABLE (
|
||||
table_name text,
|
||||
row_count bigint,
|
||||
table_size text,
|
||||
indexes_size text,
|
||||
total_size text
|
||||
)
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
-- Only allow superusers to view this
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM profiles
|
||||
WHERE id = auth.uid()
|
||||
AND role = 'superuser'
|
||||
) THEN
|
||||
RAISE EXCEPTION 'Only superusers can view database maintenance information';
|
||||
END IF;
|
||||
|
||||
RETURN QUERY
|
||||
SELECT
|
||||
t.tablename::text,
|
||||
(xpath('/row/cnt/text()', xml_count))[1]::text::bigint as row_count,
|
||||
pg_size_pretty(pg_total_relation_size(quote_ident(t.tablename)::regclass) - pg_indexes_size(quote_ident(t.tablename)::regclass)) as table_size,
|
||||
pg_size_pretty(pg_indexes_size(quote_ident(t.tablename)::regclass)) as indexes_size,
|
||||
pg_size_pretty(pg_total_relation_size(quote_ident(t.tablename)::regclass)) as total_size
|
||||
FROM pg_tables t
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT table_to_xml(t.tablename::regclass, false, true, '') as xml_count
|
||||
) x ON true
|
||||
WHERE t.schemaname = 'public'
|
||||
ORDER BY pg_total_relation_size(quote_ident(t.tablename)::regclass) DESC;
|
||||
END;
|
||||
$$;
|
||||
@@ -0,0 +1,412 @@
|
||||
-- Fix JSONB array filtering in analyze_data_completeness function
|
||||
-- Replace invalid '- null::jsonb' operations with proper array filtering
|
||||
|
||||
-- Helper function to filter null values from JSONB arrays
|
||||
CREATE OR REPLACE FUNCTION filter_jsonb_array_nulls(arr JSONB)
|
||||
RETURNS JSONB
|
||||
LANGUAGE SQL
|
||||
IMMUTABLE
|
||||
AS $$
|
||||
SELECT COALESCE(
|
||||
jsonb_agg(element),
|
||||
'[]'::jsonb
|
||||
)
|
||||
FROM jsonb_array_elements_text(arr) element
|
||||
WHERE element != 'null'
|
||||
$$;
|
||||
|
||||
-- Replace analyze_data_completeness with fixed JSONB array handling
|
||||
CREATE OR REPLACE FUNCTION analyze_data_completeness(
|
||||
p_entity_type TEXT DEFAULT NULL,
|
||||
p_min_score NUMERIC DEFAULT NULL,
|
||||
p_max_score NUMERIC DEFAULT NULL,
|
||||
p_missing_category TEXT DEFAULT NULL,
|
||||
p_limit INTEGER DEFAULT 100,
|
||||
p_offset INTEGER DEFAULT 0
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_result JSONB;
|
||||
v_parks JSONB;
|
||||
v_rides JSONB;
|
||||
v_companies JSONB;
|
||||
v_ride_models JSONB;
|
||||
v_locations JSONB;
|
||||
v_timeline_events JSONB;
|
||||
v_summary JSONB;
|
||||
BEGIN
|
||||
-- Parks Analysis (including historical)
|
||||
WITH park_analysis AS (
|
||||
SELECT
|
||||
p.id,
|
||||
p.name,
|
||||
p.slug,
|
||||
'park' as entity_type,
|
||||
p.updated_at,
|
||||
-- Calculate completeness score (weighted)
|
||||
(
|
||||
-- Critical fields (10 points each) = 30 points
|
||||
(CASE WHEN p.park_type IS NOT NULL THEN 10 ELSE 0 END) +
|
||||
(CASE WHEN p.status IS NOT NULL THEN 10 ELSE 0 END) +
|
||||
(CASE WHEN p.location_id IS NOT NULL THEN 10 ELSE 0 END) +
|
||||
|
||||
-- Important fields (7 points each) = 35 points
|
||||
(CASE WHEN p.description IS NOT NULL AND length(p.description) > 50 THEN 7 ELSE 0 END) +
|
||||
(CASE WHEN p.operator_id IS NOT NULL THEN 7 ELSE 0 END) +
|
||||
(CASE WHEN p.banner_image_id IS NOT NULL THEN 7 ELSE 0 END) +
|
||||
(CASE WHEN p.card_image_id IS NOT NULL THEN 7 ELSE 0 END) +
|
||||
(CASE WHEN p.property_owner_id IS NOT NULL THEN 7 ELSE 0 END) +
|
||||
|
||||
-- Valuable fields (5 points each) = 20 points
|
||||
(CASE WHEN p.opening_date IS NOT NULL THEN 5 ELSE 0 END) +
|
||||
(CASE WHEN p.opening_date_precision IS NOT NULL THEN 5 ELSE 0 END) +
|
||||
(CASE WHEN p.website_url IS NOT NULL THEN 5 ELSE 0 END) +
|
||||
(CASE WHEN p.phone IS NOT NULL THEN 5 ELSE 0 END) +
|
||||
|
||||
-- Supplementary fields (3 points each) = 9 points
|
||||
(CASE WHEN p.email IS NOT NULL THEN 3 ELSE 0 END) +
|
||||
(CASE WHEN p.closing_date IS NOT NULL AND p.status = 'closed' THEN 3 ELSE 0 END) +
|
||||
(CASE WHEN EXISTS(SELECT 1 FROM entity_timeline_events WHERE entity_id = p.id AND entity_type = 'park') THEN 3 ELSE 0 END) +
|
||||
|
||||
-- Nice-to-have fields (1 point each) = 6 points
|
||||
(CASE WHEN EXISTS(SELECT 1 FROM locations WHERE id = p.location_id AND latitude IS NOT NULL AND longitude IS NOT NULL) THEN 1 ELSE 0 END) +
|
||||
(CASE WHEN p.closing_date_precision IS NOT NULL AND p.status = 'closed' THEN 1 ELSE 0 END)
|
||||
)::NUMERIC / 100.0 * 100 as completeness_score,
|
||||
|
||||
-- Missing fields tracking (using helper function)
|
||||
jsonb_build_object(
|
||||
'critical', filter_jsonb_array_nulls(jsonb_build_array(
|
||||
CASE WHEN p.park_type IS NULL THEN 'park_type' END,
|
||||
CASE WHEN p.status IS NULL THEN 'status' END,
|
||||
CASE WHEN p.location_id IS NULL THEN 'location_id' END
|
||||
)),
|
||||
'important', filter_jsonb_array_nulls(jsonb_build_array(
|
||||
CASE WHEN p.description IS NULL OR length(p.description) <= 50 THEN 'description' END,
|
||||
CASE WHEN p.operator_id IS NULL THEN 'operator_id' END,
|
||||
CASE WHEN p.banner_image_id IS NULL THEN 'banner_image' END,
|
||||
CASE WHEN p.card_image_id IS NULL THEN 'card_image' END,
|
||||
CASE WHEN p.property_owner_id IS NULL THEN 'property_owner_id' END
|
||||
)),
|
||||
'valuable', filter_jsonb_array_nulls(jsonb_build_array(
|
||||
CASE WHEN p.opening_date IS NULL THEN 'opening_date' END,
|
||||
CASE WHEN p.opening_date_precision IS NULL THEN 'opening_date_precision' END,
|
||||
CASE WHEN p.website_url IS NULL THEN 'website_url' END,
|
||||
CASE WHEN p.phone IS NULL THEN 'phone' END
|
||||
)),
|
||||
'supplementary', filter_jsonb_array_nulls(jsonb_build_array(
|
||||
CASE WHEN p.email IS NULL THEN 'email' END,
|
||||
CASE WHEN p.closing_date IS NULL AND p.status = 'closed' THEN 'closing_date' END
|
||||
))
|
||||
) as missing_fields
|
||||
FROM parks p
|
||||
WHERE (p_entity_type IS NULL OR p_entity_type = 'park')
|
||||
)
|
||||
SELECT jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'id', id,
|
||||
'name', name,
|
||||
'slug', slug,
|
||||
'entity_type', entity_type,
|
||||
'updated_at', updated_at,
|
||||
'completeness_score', completeness_score,
|
||||
'missing_fields', missing_fields
|
||||
) ORDER BY completeness_score ASC, name ASC
|
||||
)
|
||||
INTO v_parks
|
||||
FROM park_analysis
|
||||
WHERE (p_min_score IS NULL OR completeness_score >= p_min_score)
|
||||
AND (p_max_score IS NULL OR completeness_score <= p_max_score)
|
||||
LIMIT p_limit OFFSET p_offset;
|
||||
|
||||
-- Rides Analysis
|
||||
WITH ride_analysis AS (
|
||||
SELECT
|
||||
r.id,
|
||||
r.name,
|
||||
r.slug,
|
||||
'ride' as entity_type,
|
||||
r.updated_at,
|
||||
-- Calculate completeness score (weighted)
|
||||
(
|
||||
-- Critical fields (10 points each) = 30 points
|
||||
(CASE WHEN r.park_id IS NOT NULL THEN 10 ELSE 0 END) +
|
||||
(CASE WHEN r.category IS NOT NULL THEN 10 ELSE 0 END) +
|
||||
(CASE WHEN r.status IS NOT NULL THEN 10 ELSE 0 END) +
|
||||
|
||||
-- Important fields (7 points each) = 42 points
|
||||
(CASE WHEN r.description IS NOT NULL AND length(r.description) > 50 THEN 7 ELSE 0 END) +
|
||||
(CASE WHEN r.manufacturer_id IS NOT NULL THEN 7 ELSE 0 END) +
|
||||
(CASE WHEN r.banner_image_id IS NOT NULL THEN 7 ELSE 0 END) +
|
||||
(CASE WHEN r.card_image_id IS NOT NULL THEN 7 ELSE 0 END) +
|
||||
(CASE WHEN r.ride_model_id IS NOT NULL THEN 7 ELSE 0 END) +
|
||||
(CASE WHEN r.designer_id IS NOT NULL THEN 7 ELSE 0 END) +
|
||||
|
||||
-- Valuable fields (5 points each) = 15 points
|
||||
(CASE WHEN r.opening_date IS NOT NULL THEN 5 ELSE 0 END) +
|
||||
(CASE WHEN r.opening_date_precision IS NOT NULL THEN 5 ELSE 0 END) +
|
||||
(CASE WHEN r.ride_sub_type IS NOT NULL THEN 5 ELSE 0 END) +
|
||||
|
||||
-- Category-specific technical data (5 points each) = up to 10 points
|
||||
(CASE
|
||||
WHEN r.category = 'Roller Coaster' THEN
|
||||
(CASE WHEN r.coaster_type IS NOT NULL THEN 5 ELSE 0 END) +
|
||||
(CASE WHEN r.max_speed_kmh IS NOT NULL THEN 5 ELSE 0 END)
|
||||
WHEN r.category = 'Water Ride' THEN
|
||||
(CASE WHEN r.flume_type IS NOT NULL THEN 5 ELSE 0 END) +
|
||||
(CASE WHEN r.wetness_level IS NOT NULL THEN 5 ELSE 0 END)
|
||||
WHEN r.category = 'Dark Ride' THEN
|
||||
(CASE WHEN r.theme_name IS NOT NULL THEN 5 ELSE 0 END) +
|
||||
(CASE WHEN r.ride_system IS NOT NULL THEN 5 ELSE 0 END)
|
||||
ELSE 0
|
||||
END) +
|
||||
|
||||
-- Supplementary fields (3 points each) = 9 points
|
||||
(CASE WHEN r.max_height_meters IS NOT NULL THEN 3 ELSE 0 END) +
|
||||
(CASE WHEN r.length_meters IS NOT NULL THEN 3 ELSE 0 END) +
|
||||
(CASE WHEN r.capacity_per_hour IS NOT NULL THEN 3 ELSE 0 END)
|
||||
)::NUMERIC / 100.0 * 100 as completeness_score,
|
||||
|
||||
-- Missing fields tracking (using helper function)
|
||||
jsonb_build_object(
|
||||
'critical', filter_jsonb_array_nulls(jsonb_build_array(
|
||||
CASE WHEN r.park_id IS NULL THEN 'park_id' END,
|
||||
CASE WHEN r.category IS NULL THEN 'category' END,
|
||||
CASE WHEN r.status IS NULL THEN 'status' END
|
||||
)),
|
||||
'important', filter_jsonb_array_nulls(jsonb_build_array(
|
||||
CASE WHEN r.description IS NULL OR length(r.description) <= 50 THEN 'description' END,
|
||||
CASE WHEN r.manufacturer_id IS NULL THEN 'manufacturer_id' END,
|
||||
CASE WHEN r.banner_image_id IS NULL THEN 'banner_image' END,
|
||||
CASE WHEN r.card_image_id IS NULL THEN 'card_image' END,
|
||||
CASE WHEN r.ride_model_id IS NULL THEN 'ride_model_id' END,
|
||||
CASE WHEN r.designer_id IS NULL THEN 'designer_id' END
|
||||
)),
|
||||
'valuable', filter_jsonb_array_nulls(jsonb_build_array(
|
||||
CASE WHEN r.opening_date IS NULL THEN 'opening_date' END,
|
||||
CASE WHEN r.opening_date_precision IS NULL THEN 'opening_date_precision' END,
|
||||
CASE WHEN r.ride_sub_type IS NULL THEN 'ride_sub_type' END
|
||||
))
|
||||
) as missing_fields
|
||||
FROM rides r
|
||||
WHERE (p_entity_type IS NULL OR p_entity_type = 'ride')
|
||||
)
|
||||
SELECT jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'id', id,
|
||||
'name', name,
|
||||
'slug', slug,
|
||||
'entity_type', entity_type,
|
||||
'updated_at', updated_at,
|
||||
'completeness_score', completeness_score,
|
||||
'missing_fields', missing_fields
|
||||
) ORDER BY completeness_score ASC, name ASC
|
||||
)
|
||||
INTO v_rides
|
||||
FROM ride_analysis
|
||||
WHERE (p_min_score IS NULL OR completeness_score >= p_min_score)
|
||||
AND (p_max_score IS NULL OR completeness_score <= p_max_score)
|
||||
LIMIT p_limit OFFSET p_offset;
|
||||
|
||||
-- Companies Analysis
|
||||
WITH company_analysis AS (
|
||||
SELECT
|
||||
c.id,
|
||||
c.name,
|
||||
c.slug,
|
||||
'company' as entity_type,
|
||||
c.updated_at,
|
||||
-- Calculate completeness score (weighted)
|
||||
(
|
||||
-- Critical fields (10 points each) = 20 points
|
||||
(CASE WHEN c.company_type IS NOT NULL THEN 10 ELSE 0 END) +
|
||||
(CASE WHEN c.person_type IS NOT NULL THEN 10 ELSE 0 END) +
|
||||
|
||||
-- Important fields (7 points each) = 28 points
|
||||
(CASE WHEN c.description IS NOT NULL AND length(c.description) > 50 THEN 7 ELSE 0 END) +
|
||||
(CASE WHEN c.logo_url IS NOT NULL THEN 7 ELSE 0 END) +
|
||||
(CASE WHEN c.banner_image_id IS NOT NULL THEN 7 ELSE 0 END) +
|
||||
(CASE WHEN c.card_image_id IS NOT NULL THEN 7 ELSE 0 END) +
|
||||
|
||||
-- Valuable fields (5 points each) = 20 points
|
||||
(CASE WHEN c.founded_year IS NOT NULL THEN 5 ELSE 0 END) +
|
||||
(CASE WHEN c.founded_date IS NOT NULL THEN 5 ELSE 0 END) +
|
||||
(CASE WHEN c.website_url IS NOT NULL THEN 5 ELSE 0 END) +
|
||||
(CASE WHEN c.headquarters_location IS NOT NULL THEN 5 ELSE 0 END) +
|
||||
|
||||
-- Supplementary fields (3 points each) = 6 points
|
||||
(CASE WHEN c.founded_date_precision IS NOT NULL THEN 3 ELSE 0 END) +
|
||||
(CASE WHEN c.company_type IN ('manufacturer', 'operator') AND EXISTS(SELECT 1 FROM parks WHERE operator_id = c.id OR property_owner_id = c.id LIMIT 1) THEN 3 ELSE 0 END)
|
||||
)::NUMERIC / 100.0 * 100 as completeness_score,
|
||||
|
||||
-- Missing fields tracking (using helper function)
|
||||
jsonb_build_object(
|
||||
'critical', filter_jsonb_array_nulls(jsonb_build_array(
|
||||
CASE WHEN c.company_type IS NULL THEN 'company_type' END,
|
||||
CASE WHEN c.person_type IS NULL THEN 'person_type' END
|
||||
)),
|
||||
'important', filter_jsonb_array_nulls(jsonb_build_array(
|
||||
CASE WHEN c.description IS NULL OR length(c.description) <= 50 THEN 'description' END,
|
||||
CASE WHEN c.logo_url IS NULL THEN 'logo_url' END,
|
||||
CASE WHEN c.banner_image_id IS NULL THEN 'banner_image' END,
|
||||
CASE WHEN c.card_image_id IS NULL THEN 'card_image' END
|
||||
)),
|
||||
'valuable', filter_jsonb_array_nulls(jsonb_build_array(
|
||||
CASE WHEN c.founded_year IS NULL THEN 'founded_year' END,
|
||||
CASE WHEN c.founded_date IS NULL THEN 'founded_date' END,
|
||||
CASE WHEN c.website_url IS NULL THEN 'website_url' END,
|
||||
CASE WHEN c.headquarters_location IS NULL THEN 'headquarters_location' END
|
||||
))
|
||||
) as missing_fields
|
||||
FROM companies c
|
||||
WHERE (p_entity_type IS NULL OR p_entity_type = 'company')
|
||||
)
|
||||
SELECT jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'id', id,
|
||||
'name', name,
|
||||
'slug', slug,
|
||||
'entity_type', entity_type,
|
||||
'updated_at', updated_at,
|
||||
'completeness_score', completeness_score,
|
||||
'missing_fields', missing_fields
|
||||
) ORDER BY completeness_score ASC, name ASC
|
||||
)
|
||||
INTO v_companies
|
||||
FROM company_analysis
|
||||
WHERE (p_min_score IS NULL OR completeness_score >= p_min_score)
|
||||
AND (p_max_score IS NULL OR completeness_score <= p_max_score)
|
||||
LIMIT p_limit OFFSET p_offset;
|
||||
|
||||
-- Ride Models Analysis
|
||||
WITH model_analysis AS (
|
||||
SELECT
|
||||
rm.id,
|
||||
rm.name,
|
||||
rm.slug,
|
||||
'ride_model' as entity_type,
|
||||
rm.updated_at,
|
||||
-- Calculate completeness score (weighted)
|
||||
(
|
||||
-- Critical fields (10 points each) = 30 points
|
||||
(CASE WHEN rm.manufacturer_id IS NOT NULL THEN 10 ELSE 0 END) +
|
||||
(CASE WHEN rm.category IS NOT NULL THEN 10 ELSE 0 END) +
|
||||
(CASE WHEN rm.ride_type IS NOT NULL THEN 10 ELSE 0 END) +
|
||||
|
||||
-- Important fields (7 points each) = 21 points
|
||||
(CASE WHEN rm.description IS NOT NULL AND length(rm.description) > 50 THEN 7 ELSE 0 END) +
|
||||
(CASE WHEN rm.banner_image_id IS NOT NULL THEN 7 ELSE 0 END) +
|
||||
(CASE WHEN rm.card_image_id IS NOT NULL THEN 7 ELSE 0 END) +
|
||||
|
||||
-- Valuable fields (5 points each) = 10 points
|
||||
(CASE WHEN EXISTS(SELECT 1 FROM rides WHERE ride_model_id = rm.id LIMIT 1) THEN 5 ELSE 0 END) +
|
||||
(CASE WHEN rm.introduction_year IS NOT NULL THEN 5 ELSE 0 END)
|
||||
)::NUMERIC / 100.0 * 100 as completeness_score,
|
||||
|
||||
-- Missing fields tracking (using helper function)
|
||||
jsonb_build_object(
|
||||
'critical', filter_jsonb_array_nulls(jsonb_build_array(
|
||||
CASE WHEN rm.manufacturer_id IS NULL THEN 'manufacturer_id' END,
|
||||
CASE WHEN rm.category IS NULL THEN 'category' END,
|
||||
CASE WHEN rm.ride_type IS NULL THEN 'ride_type' END
|
||||
)),
|
||||
'important', filter_jsonb_array_nulls(jsonb_build_array(
|
||||
CASE WHEN rm.description IS NULL OR length(rm.description) <= 50 THEN 'description' END,
|
||||
CASE WHEN rm.banner_image_id IS NULL THEN 'banner_image' END,
|
||||
CASE WHEN rm.card_image_id IS NULL THEN 'card_image' END
|
||||
)),
|
||||
'valuable', filter_jsonb_array_nulls(jsonb_build_array(
|
||||
CASE WHEN rm.introduction_year IS NULL THEN 'introduction_year' END
|
||||
))
|
||||
) as missing_fields
|
||||
FROM ride_models rm
|
||||
WHERE (p_entity_type IS NULL OR p_entity_type = 'ride_model')
|
||||
)
|
||||
SELECT jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'id', id,
|
||||
'name', name,
|
||||
'slug', slug,
|
||||
'entity_type', entity_type,
|
||||
'updated_at', updated_at,
|
||||
'completeness_score', completeness_score,
|
||||
'missing_fields', missing_fields
|
||||
) ORDER BY completeness_score ASC, name ASC
|
||||
)
|
||||
INTO v_ride_models
|
||||
FROM model_analysis
|
||||
WHERE (p_min_score IS NULL OR completeness_score >= p_min_score)
|
||||
AND (p_max_score IS NULL OR completeness_score <= p_max_score)
|
||||
LIMIT p_limit OFFSET p_offset;
|
||||
|
||||
-- Generate Summary
|
||||
v_summary := jsonb_build_object(
|
||||
'total_entities', (
|
||||
SELECT COUNT(*)::INTEGER FROM (
|
||||
SELECT id FROM parks WHERE (p_entity_type IS NULL OR p_entity_type = 'park')
|
||||
UNION ALL
|
||||
SELECT id FROM rides WHERE (p_entity_type IS NULL OR p_entity_type = 'ride')
|
||||
UNION ALL
|
||||
SELECT id FROM companies WHERE (p_entity_type IS NULL OR p_entity_type = 'company')
|
||||
UNION ALL
|
||||
SELECT id FROM ride_models WHERE (p_entity_type IS NULL OR p_entity_type = 'ride_model')
|
||||
) all_entities
|
||||
),
|
||||
'avg_completeness_score', (
|
||||
SELECT ROUND(AVG(score)::NUMERIC, 2) FROM (
|
||||
SELECT ((10 + 10 + 10)::NUMERIC / 100.0 * 100) as score FROM parks WHERE park_type IS NOT NULL AND status IS NOT NULL AND location_id IS NOT NULL
|
||||
UNION ALL
|
||||
SELECT ((10 + 10 + 10)::NUMERIC / 100.0 * 100) as score FROM rides WHERE park_id IS NOT NULL AND category IS NOT NULL AND status IS NOT NULL
|
||||
UNION ALL
|
||||
SELECT ((10 + 10)::NUMERIC / 100.0 * 100) as score FROM companies WHERE company_type IS NOT NULL AND person_type IS NOT NULL
|
||||
UNION ALL
|
||||
SELECT ((10 + 10 + 10)::NUMERIC / 100.0 * 100) as score FROM ride_models WHERE manufacturer_id IS NOT NULL AND category IS NOT NULL AND ride_type IS NOT NULL
|
||||
) scores
|
||||
),
|
||||
'entities_below_50', (
|
||||
SELECT COUNT(*)::INTEGER FROM (
|
||||
SELECT id FROM parks WHERE (p_entity_type IS NULL OR p_entity_type = 'park')
|
||||
UNION ALL
|
||||
SELECT id FROM rides WHERE (p_entity_type IS NULL OR p_entity_type = 'ride')
|
||||
UNION ALL
|
||||
SELECT id FROM companies WHERE (p_entity_type IS NULL OR p_entity_type = 'company')
|
||||
UNION ALL
|
||||
SELECT id FROM ride_models WHERE (p_entity_type IS NULL OR p_entity_type = 'ride_model')
|
||||
) all_entities
|
||||
WHERE id IN (
|
||||
SELECT id FROM parks WHERE description IS NULL OR manufacturer_id IS NULL
|
||||
UNION
|
||||
SELECT id FROM rides WHERE description IS NULL OR manufacturer_id IS NULL
|
||||
UNION
|
||||
SELECT id FROM companies WHERE description IS NULL
|
||||
UNION
|
||||
SELECT id FROM ride_models WHERE description IS NULL
|
||||
)
|
||||
),
|
||||
'entities_100_complete', 0,
|
||||
'by_entity_type', jsonb_build_object(
|
||||
'parks', (SELECT COUNT(*)::INTEGER FROM parks WHERE (p_entity_type IS NULL OR p_entity_type = 'park')),
|
||||
'rides', (SELECT COUNT(*)::INTEGER FROM rides WHERE (p_entity_type IS NULL OR p_entity_type = 'ride')),
|
||||
'companies', (SELECT COUNT(*)::INTEGER FROM companies WHERE (p_entity_type IS NULL OR p_entity_type = 'company')),
|
||||
'ride_models', (SELECT COUNT(*)::INTEGER FROM ride_models WHERE (p_entity_type IS NULL OR p_entity_type = 'ride_model'))
|
||||
)
|
||||
);
|
||||
|
||||
-- Build final result
|
||||
v_result := jsonb_build_object(
|
||||
'summary', v_summary,
|
||||
'entities', jsonb_build_object(
|
||||
'parks', COALESCE(v_parks, '[]'::jsonb),
|
||||
'rides', COALESCE(v_rides, '[]'::jsonb),
|
||||
'companies', COALESCE(v_companies, '[]'::jsonb),
|
||||
'ride_models', COALESCE(v_ride_models, '[]'::jsonb)
|
||||
),
|
||||
'generated_at', now()
|
||||
);
|
||||
|
||||
RETURN v_result;
|
||||
END;
|
||||
$$;
|
||||
@@ -0,0 +1,14 @@
|
||||
-- Fix search_path security issue for filter_jsonb_array_nulls function
|
||||
CREATE OR REPLACE FUNCTION filter_jsonb_array_nulls(arr JSONB)
|
||||
RETURNS JSONB
|
||||
LANGUAGE SQL
|
||||
IMMUTABLE
|
||||
SET search_path = public
|
||||
AS $$
|
||||
SELECT COALESCE(
|
||||
jsonb_agg(element),
|
||||
'[]'::jsonb
|
||||
)
|
||||
FROM jsonb_array_elements_text(arr) element
|
||||
WHERE element != 'null'
|
||||
$$;
|
||||
@@ -0,0 +1,398 @@
|
||||
-- Fix analyze_data_completeness: Remove non-existent introduction_year column reference
|
||||
-- The ride_models table doesn't have an introduction_year column
|
||||
|
||||
CREATE OR REPLACE FUNCTION analyze_data_completeness(
|
||||
p_entity_type TEXT DEFAULT NULL,
|
||||
p_min_score NUMERIC DEFAULT NULL,
|
||||
p_max_score NUMERIC DEFAULT NULL,
|
||||
p_missing_category TEXT DEFAULT NULL,
|
||||
p_limit INTEGER DEFAULT 100,
|
||||
p_offset INTEGER DEFAULT 0
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_result JSONB;
|
||||
v_parks JSONB;
|
||||
v_rides JSONB;
|
||||
v_companies JSONB;
|
||||
v_ride_models JSONB;
|
||||
v_locations JSONB;
|
||||
v_timeline_events JSONB;
|
||||
v_summary JSONB;
|
||||
BEGIN
|
||||
-- Parks Analysis (including historical)
|
||||
WITH park_analysis AS (
|
||||
SELECT
|
||||
p.id,
|
||||
p.name,
|
||||
p.slug,
|
||||
'park' as entity_type,
|
||||
p.updated_at,
|
||||
-- Calculate completeness score (weighted)
|
||||
(
|
||||
-- Critical fields (10 points each) = 30 points
|
||||
(CASE WHEN p.park_type IS NOT NULL THEN 10 ELSE 0 END) +
|
||||
(CASE WHEN p.status IS NOT NULL THEN 10 ELSE 0 END) +
|
||||
(CASE WHEN p.location_id IS NOT NULL THEN 10 ELSE 0 END) +
|
||||
|
||||
-- Important fields (7 points each) = 35 points
|
||||
(CASE WHEN p.description IS NOT NULL AND length(p.description) > 50 THEN 7 ELSE 0 END) +
|
||||
(CASE WHEN p.operator_id IS NOT NULL THEN 7 ELSE 0 END) +
|
||||
(CASE WHEN p.banner_image_id IS NOT NULL THEN 7 ELSE 0 END) +
|
||||
(CASE WHEN p.card_image_id IS NOT NULL THEN 7 ELSE 0 END) +
|
||||
(CASE WHEN p.property_owner_id IS NOT NULL THEN 7 ELSE 0 END) +
|
||||
|
||||
-- Valuable fields (5 points each) = 20 points
|
||||
(CASE WHEN p.opening_date IS NOT NULL THEN 5 ELSE 0 END) +
|
||||
(CASE WHEN p.opening_date_precision IS NOT NULL THEN 5 ELSE 0 END) +
|
||||
(CASE WHEN p.website_url IS NOT NULL THEN 5 ELSE 0 END) +
|
||||
(CASE WHEN p.phone IS NOT NULL THEN 5 ELSE 0 END) +
|
||||
|
||||
-- Supplementary fields (3 points each) = 9 points
|
||||
(CASE WHEN p.email IS NOT NULL THEN 3 ELSE 0 END) +
|
||||
(CASE WHEN p.closing_date IS NOT NULL AND p.status = 'closed' THEN 3 ELSE 0 END) +
|
||||
(CASE WHEN EXISTS(SELECT 1 FROM entity_timeline_events WHERE entity_id = p.id AND entity_type = 'park') THEN 3 ELSE 0 END) +
|
||||
|
||||
-- Nice-to-have fields (1 point each) = 6 points
|
||||
(CASE WHEN EXISTS(SELECT 1 FROM locations WHERE id = p.location_id AND latitude IS NOT NULL AND longitude IS NOT NULL) THEN 1 ELSE 0 END) +
|
||||
(CASE WHEN p.closing_date_precision IS NOT NULL AND p.status = 'closed' THEN 1 ELSE 0 END)
|
||||
)::NUMERIC / 100.0 * 100 as completeness_score,
|
||||
|
||||
-- Missing fields tracking (using helper function)
|
||||
jsonb_build_object(
|
||||
'critical', filter_jsonb_array_nulls(jsonb_build_array(
|
||||
CASE WHEN p.park_type IS NULL THEN 'park_type' END,
|
||||
CASE WHEN p.status IS NULL THEN 'status' END,
|
||||
CASE WHEN p.location_id IS NULL THEN 'location_id' END
|
||||
)),
|
||||
'important', filter_jsonb_array_nulls(jsonb_build_array(
|
||||
CASE WHEN p.description IS NULL OR length(p.description) <= 50 THEN 'description' END,
|
||||
CASE WHEN p.operator_id IS NULL THEN 'operator_id' END,
|
||||
CASE WHEN p.banner_image_id IS NULL THEN 'banner_image' END,
|
||||
CASE WHEN p.card_image_id IS NULL THEN 'card_image' END,
|
||||
CASE WHEN p.property_owner_id IS NULL THEN 'property_owner_id' END
|
||||
)),
|
||||
'valuable', filter_jsonb_array_nulls(jsonb_build_array(
|
||||
CASE WHEN p.opening_date IS NULL THEN 'opening_date' END,
|
||||
CASE WHEN p.opening_date_precision IS NULL THEN 'opening_date_precision' END,
|
||||
CASE WHEN p.website_url IS NULL THEN 'website_url' END,
|
||||
CASE WHEN p.phone IS NULL THEN 'phone' END
|
||||
)),
|
||||
'supplementary', filter_jsonb_array_nulls(jsonb_build_array(
|
||||
CASE WHEN p.email IS NULL THEN 'email' END,
|
||||
CASE WHEN p.closing_date IS NULL AND p.status = 'closed' THEN 'closing_date' END
|
||||
))
|
||||
) as missing_fields
|
||||
FROM parks p
|
||||
WHERE (p_entity_type IS NULL OR p_entity_type = 'park')
|
||||
)
|
||||
SELECT jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'id', id,
|
||||
'name', name,
|
||||
'slug', slug,
|
||||
'entity_type', entity_type,
|
||||
'updated_at', updated_at,
|
||||
'completeness_score', completeness_score,
|
||||
'missing_fields', missing_fields
|
||||
) ORDER BY completeness_score ASC, name ASC
|
||||
)
|
||||
INTO v_parks
|
||||
FROM park_analysis
|
||||
WHERE (p_min_score IS NULL OR completeness_score >= p_min_score)
|
||||
AND (p_max_score IS NULL OR completeness_score <= p_max_score)
|
||||
LIMIT p_limit OFFSET p_offset;
|
||||
|
||||
-- Rides Analysis
|
||||
WITH ride_analysis AS (
|
||||
SELECT
|
||||
r.id,
|
||||
r.name,
|
||||
r.slug,
|
||||
'ride' as entity_type,
|
||||
r.updated_at,
|
||||
-- Calculate completeness score (weighted)
|
||||
(
|
||||
-- Critical fields (10 points each) = 30 points
|
||||
(CASE WHEN r.park_id IS NOT NULL THEN 10 ELSE 0 END) +
|
||||
(CASE WHEN r.category IS NOT NULL THEN 10 ELSE 0 END) +
|
||||
(CASE WHEN r.status IS NOT NULL THEN 10 ELSE 0 END) +
|
||||
|
||||
-- Important fields (7 points each) = 42 points
|
||||
(CASE WHEN r.description IS NOT NULL AND length(r.description) > 50 THEN 7 ELSE 0 END) +
|
||||
(CASE WHEN r.manufacturer_id IS NOT NULL THEN 7 ELSE 0 END) +
|
||||
(CASE WHEN r.banner_image_id IS NOT NULL THEN 7 ELSE 0 END) +
|
||||
(CASE WHEN r.card_image_id IS NOT NULL THEN 7 ELSE 0 END) +
|
||||
(CASE WHEN r.ride_model_id IS NOT NULL THEN 7 ELSE 0 END) +
|
||||
(CASE WHEN r.designer_id IS NOT NULL THEN 7 ELSE 0 END) +
|
||||
|
||||
-- Valuable fields (5 points each) = 15 points
|
||||
(CASE WHEN r.opening_date IS NOT NULL THEN 5 ELSE 0 END) +
|
||||
(CASE WHEN r.opening_date_precision IS NOT NULL THEN 5 ELSE 0 END) +
|
||||
(CASE WHEN r.ride_sub_type IS NOT NULL THEN 5 ELSE 0 END) +
|
||||
|
||||
-- Category-specific technical data (5 points each) = up to 10 points
|
||||
(CASE
|
||||
WHEN r.category = 'Roller Coaster' THEN
|
||||
(CASE WHEN r.coaster_type IS NOT NULL THEN 5 ELSE 0 END) +
|
||||
(CASE WHEN r.max_speed_kmh IS NOT NULL THEN 5 ELSE 0 END)
|
||||
WHEN r.category = 'Water Ride' THEN
|
||||
(CASE WHEN r.flume_type IS NOT NULL THEN 5 ELSE 0 END) +
|
||||
(CASE WHEN r.wetness_level IS NOT NULL THEN 5 ELSE 0 END)
|
||||
WHEN r.category = 'Dark Ride' THEN
|
||||
(CASE WHEN r.theme_name IS NOT NULL THEN 5 ELSE 0 END) +
|
||||
(CASE WHEN r.ride_system IS NOT NULL THEN 5 ELSE 0 END)
|
||||
ELSE 0
|
||||
END) +
|
||||
|
||||
-- Supplementary fields (3 points each) = 9 points
|
||||
(CASE WHEN r.max_height_meters IS NOT NULL THEN 3 ELSE 0 END) +
|
||||
(CASE WHEN r.length_meters IS NOT NULL THEN 3 ELSE 0 END) +
|
||||
(CASE WHEN r.capacity_per_hour IS NOT NULL THEN 3 ELSE 0 END)
|
||||
)::NUMERIC / 100.0 * 100 as completeness_score,
|
||||
|
||||
-- Missing fields tracking (using helper function)
|
||||
jsonb_build_object(
|
||||
'critical', filter_jsonb_array_nulls(jsonb_build_array(
|
||||
CASE WHEN r.park_id IS NULL THEN 'park_id' END,
|
||||
CASE WHEN r.category IS NULL THEN 'category' END,
|
||||
CASE WHEN r.status IS NULL THEN 'status' END
|
||||
)),
|
||||
'important', filter_jsonb_array_nulls(jsonb_build_array(
|
||||
CASE WHEN r.description IS NULL OR length(r.description) <= 50 THEN 'description' END,
|
||||
CASE WHEN r.manufacturer_id IS NULL THEN 'manufacturer_id' END,
|
||||
CASE WHEN r.banner_image_id IS NULL THEN 'banner_image' END,
|
||||
CASE WHEN r.card_image_id IS NULL THEN 'card_image' END,
|
||||
CASE WHEN r.ride_model_id IS NULL THEN 'ride_model_id' END,
|
||||
CASE WHEN r.designer_id IS NULL THEN 'designer_id' END
|
||||
)),
|
||||
'valuable', filter_jsonb_array_nulls(jsonb_build_array(
|
||||
CASE WHEN r.opening_date IS NULL THEN 'opening_date' END,
|
||||
CASE WHEN r.opening_date_precision IS NULL THEN 'opening_date_precision' END,
|
||||
CASE WHEN r.ride_sub_type IS NULL THEN 'ride_sub_type' END
|
||||
))
|
||||
) as missing_fields
|
||||
FROM rides r
|
||||
WHERE (p_entity_type IS NULL OR p_entity_type = 'ride')
|
||||
)
|
||||
SELECT jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'id', id,
|
||||
'name', name,
|
||||
'slug', slug,
|
||||
'entity_type', entity_type,
|
||||
'updated_at', updated_at,
|
||||
'completeness_score', completeness_score,
|
||||
'missing_fields', missing_fields
|
||||
) ORDER BY completeness_score ASC, name ASC
|
||||
)
|
||||
INTO v_rides
|
||||
FROM ride_analysis
|
||||
WHERE (p_min_score IS NULL OR completeness_score >= p_min_score)
|
||||
AND (p_max_score IS NULL OR completeness_score <= p_max_score)
|
||||
LIMIT p_limit OFFSET p_offset;
|
||||
|
||||
-- Companies Analysis
|
||||
WITH company_analysis AS (
|
||||
SELECT
|
||||
c.id,
|
||||
c.name,
|
||||
c.slug,
|
||||
'company' as entity_type,
|
||||
c.updated_at,
|
||||
-- Calculate completeness score (weighted)
|
||||
(
|
||||
-- Critical fields (10 points each) = 20 points
|
||||
(CASE WHEN c.company_type IS NOT NULL THEN 10 ELSE 0 END) +
|
||||
(CASE WHEN c.person_type IS NOT NULL THEN 10 ELSE 0 END) +
|
||||
|
||||
-- Important fields (7 points each) = 28 points
|
||||
(CASE WHEN c.description IS NOT NULL AND length(c.description) > 50 THEN 7 ELSE 0 END) +
|
||||
(CASE WHEN c.logo_url IS NOT NULL THEN 7 ELSE 0 END) +
|
||||
(CASE WHEN c.banner_image_id IS NOT NULL THEN 7 ELSE 0 END) +
|
||||
(CASE WHEN c.card_image_id IS NOT NULL THEN 7 ELSE 0 END) +
|
||||
|
||||
-- Valuable fields (5 points each) = 20 points
|
||||
(CASE WHEN c.founded_year IS NOT NULL THEN 5 ELSE 0 END) +
|
||||
(CASE WHEN c.founded_date IS NOT NULL THEN 5 ELSE 0 END) +
|
||||
(CASE WHEN c.website_url IS NOT NULL THEN 5 ELSE 0 END) +
|
||||
(CASE WHEN c.headquarters_location IS NOT NULL THEN 5 ELSE 0 END) +
|
||||
|
||||
-- Supplementary fields (3 points each) = 6 points
|
||||
(CASE WHEN c.founded_date_precision IS NOT NULL THEN 3 ELSE 0 END) +
|
||||
(CASE WHEN c.company_type IN ('manufacturer', 'operator') AND EXISTS(SELECT 1 FROM parks WHERE operator_id = c.id OR property_owner_id = c.id LIMIT 1) THEN 3 ELSE 0 END)
|
||||
)::NUMERIC / 100.0 * 100 as completeness_score,
|
||||
|
||||
-- Missing fields tracking (using helper function)
|
||||
jsonb_build_object(
|
||||
'critical', filter_jsonb_array_nulls(jsonb_build_array(
|
||||
CASE WHEN c.company_type IS NULL THEN 'company_type' END,
|
||||
CASE WHEN c.person_type IS NULL THEN 'person_type' END
|
||||
)),
|
||||
'important', filter_jsonb_array_nulls(jsonb_build_array(
|
||||
CASE WHEN c.description IS NULL OR length(c.description) <= 50 THEN 'description' END,
|
||||
CASE WHEN c.logo_url IS NULL THEN 'logo_url' END,
|
||||
CASE WHEN c.banner_image_id IS NULL THEN 'banner_image' END,
|
||||
CASE WHEN c.card_image_id IS NULL THEN 'card_image' END
|
||||
)),
|
||||
'valuable', filter_jsonb_array_nulls(jsonb_build_array(
|
||||
CASE WHEN c.founded_year IS NULL THEN 'founded_year' END,
|
||||
CASE WHEN c.founded_date IS NULL THEN 'founded_date' END,
|
||||
CASE WHEN c.website_url IS NULL THEN 'website_url' END,
|
||||
CASE WHEN c.headquarters_location IS NULL THEN 'headquarters_location' END
|
||||
))
|
||||
) as missing_fields
|
||||
FROM companies c
|
||||
WHERE (p_entity_type IS NULL OR p_entity_type = 'company')
|
||||
)
|
||||
SELECT jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'id', id,
|
||||
'name', name,
|
||||
'slug', slug,
|
||||
'entity_type', entity_type,
|
||||
'updated_at', updated_at,
|
||||
'completeness_score', completeness_score,
|
||||
'missing_fields', missing_fields
|
||||
) ORDER BY completeness_score ASC, name ASC
|
||||
)
|
||||
INTO v_companies
|
||||
FROM company_analysis
|
||||
WHERE (p_min_score IS NULL OR completeness_score >= p_min_score)
|
||||
AND (p_max_score IS NULL OR completeness_score <= p_max_score)
|
||||
LIMIT p_limit OFFSET p_offset;
|
||||
|
||||
-- Ride Models Analysis - FIXED: Removed introduction_year references (lines 306, 322)
|
||||
-- Total points reduced from 70 to 65 (removed 5 points from introduction_year)
|
||||
WITH model_analysis AS (
|
||||
SELECT
|
||||
rm.id,
|
||||
rm.name,
|
||||
rm.slug,
|
||||
'ride_model' as entity_type,
|
||||
rm.updated_at,
|
||||
-- Calculate completeness score (weighted)
|
||||
(
|
||||
-- Critical fields (10 points each) = 30 points
|
||||
(CASE WHEN rm.manufacturer_id IS NOT NULL THEN 10 ELSE 0 END) +
|
||||
(CASE WHEN rm.category IS NOT NULL THEN 10 ELSE 0 END) +
|
||||
(CASE WHEN rm.ride_type IS NOT NULL THEN 10 ELSE 0 END) +
|
||||
|
||||
-- Important fields (7 points each) = 21 points
|
||||
(CASE WHEN rm.description IS NOT NULL AND length(rm.description) > 50 THEN 7 ELSE 0 END) +
|
||||
(CASE WHEN rm.banner_image_id IS NOT NULL THEN 7 ELSE 0 END) +
|
||||
(CASE WHEN rm.card_image_id IS NOT NULL THEN 7 ELSE 0 END) +
|
||||
|
||||
-- Valuable fields (5 points each) = 5 points
|
||||
-- REMOVED: introduction_year check (was 5 points)
|
||||
(CASE WHEN EXISTS(SELECT 1 FROM rides WHERE ride_model_id = rm.id LIMIT 1) THEN 5 ELSE 0 END)
|
||||
)::NUMERIC / 100.0 * 100 as completeness_score,
|
||||
|
||||
-- Missing fields tracking (using helper function)
|
||||
jsonb_build_object(
|
||||
'critical', filter_jsonb_array_nulls(jsonb_build_array(
|
||||
CASE WHEN rm.manufacturer_id IS NULL THEN 'manufacturer_id' END,
|
||||
CASE WHEN rm.category IS NULL THEN 'category' END,
|
||||
CASE WHEN rm.ride_type IS NULL THEN 'ride_type' END
|
||||
)),
|
||||
'important', filter_jsonb_array_nulls(jsonb_build_array(
|
||||
CASE WHEN rm.description IS NULL OR length(rm.description) <= 50 THEN 'description' END,
|
||||
CASE WHEN rm.banner_image_id IS NULL THEN 'banner_image' END,
|
||||
CASE WHEN rm.card_image_id IS NULL THEN 'card_image' END
|
||||
)),
|
||||
'valuable', filter_jsonb_array_nulls(jsonb_build_array(
|
||||
-- REMOVED: introduction_year from missing fields tracking
|
||||
))
|
||||
) as missing_fields
|
||||
FROM ride_models rm
|
||||
WHERE (p_entity_type IS NULL OR p_entity_type = 'ride_model')
|
||||
)
|
||||
SELECT jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'id', id,
|
||||
'name', name,
|
||||
'slug', slug,
|
||||
'entity_type', entity_type,
|
||||
'updated_at', updated_at,
|
||||
'completeness_score', completeness_score,
|
||||
'missing_fields', missing_fields
|
||||
) ORDER BY completeness_score ASC, name ASC
|
||||
)
|
||||
INTO v_ride_models
|
||||
FROM model_analysis
|
||||
WHERE (p_min_score IS NULL OR completeness_score >= p_min_score)
|
||||
AND (p_max_score IS NULL OR completeness_score <= p_max_score)
|
||||
LIMIT p_limit OFFSET p_offset;
|
||||
|
||||
-- Generate Summary
|
||||
v_summary := jsonb_build_object(
|
||||
'total_entities', (
|
||||
SELECT COUNT(*)::INTEGER FROM (
|
||||
SELECT id FROM parks WHERE (p_entity_type IS NULL OR p_entity_type = 'park')
|
||||
UNION ALL
|
||||
SELECT id FROM rides WHERE (p_entity_type IS NULL OR p_entity_type = 'ride')
|
||||
UNION ALL
|
||||
SELECT id FROM companies WHERE (p_entity_type IS NULL OR p_entity_type = 'company')
|
||||
UNION ALL
|
||||
SELECT id FROM ride_models WHERE (p_entity_type IS NULL OR p_entity_type = 'ride_model')
|
||||
) all_entities
|
||||
),
|
||||
'avg_completeness_score', (
|
||||
SELECT ROUND(AVG(score)::NUMERIC, 2) FROM (
|
||||
SELECT ((10 + 10 + 10)::NUMERIC / 100.0 * 100) as score FROM parks WHERE park_type IS NOT NULL AND status IS NOT NULL AND location_id IS NOT NULL
|
||||
UNION ALL
|
||||
SELECT ((10 + 10 + 10)::NUMERIC / 100.0 * 100) as score FROM rides WHERE park_id IS NOT NULL AND category IS NOT NULL AND status IS NOT NULL
|
||||
UNION ALL
|
||||
SELECT ((10 + 10)::NUMERIC / 100.0 * 100) as score FROM companies WHERE company_type IS NOT NULL AND person_type IS NOT NULL
|
||||
UNION ALL
|
||||
SELECT ((10 + 10 + 10)::NUMERIC / 100.0 * 100) as score FROM ride_models WHERE manufacturer_id IS NOT NULL AND category IS NOT NULL AND ride_type IS NOT NULL
|
||||
) scores
|
||||
),
|
||||
'entities_below_50', (
|
||||
SELECT COUNT(*)::INTEGER FROM (
|
||||
SELECT id FROM parks WHERE (p_entity_type IS NULL OR p_entity_type = 'park')
|
||||
UNION ALL
|
||||
SELECT id FROM rides WHERE (p_entity_type IS NULL OR p_entity_type = 'ride')
|
||||
UNION ALL
|
||||
SELECT id FROM companies WHERE (p_entity_type IS NULL OR p_entity_type = 'company')
|
||||
UNION ALL
|
||||
SELECT id FROM ride_models WHERE (p_entity_type IS NULL OR p_entity_type = 'ride_model')
|
||||
) all_entities
|
||||
WHERE id IN (
|
||||
SELECT id FROM parks WHERE description IS NULL OR manufacturer_id IS NULL
|
||||
UNION
|
||||
SELECT id FROM rides WHERE description IS NULL OR manufacturer_id IS NULL
|
||||
UNION
|
||||
SELECT id FROM companies WHERE description IS NULL
|
||||
UNION
|
||||
SELECT id FROM ride_models WHERE description IS NULL
|
||||
)
|
||||
),
|
||||
'entities_100_complete', 0,
|
||||
'by_entity_type', jsonb_build_object(
|
||||
'parks', (SELECT COUNT(*)::INTEGER FROM parks WHERE (p_entity_type IS NULL OR p_entity_type = 'park')),
|
||||
'rides', (SELECT COUNT(*)::INTEGER FROM rides WHERE (p_entity_type IS NULL OR p_entity_type = 'ride')),
|
||||
'companies', (SELECT COUNT(*)::INTEGER FROM companies WHERE (p_entity_type IS NULL OR p_entity_type = 'company')),
|
||||
'ride_models', (SELECT COUNT(*)::INTEGER FROM ride_models WHERE (p_entity_type IS NULL OR p_entity_type = 'ride_model'))
|
||||
)
|
||||
);
|
||||
|
||||
-- Build final result
|
||||
v_result := jsonb_build_object(
|
||||
'summary', v_summary,
|
||||
'entities', jsonb_build_object(
|
||||
'parks', COALESCE(v_parks, '[]'::jsonb),
|
||||
'rides', COALESCE(v_rides, '[]'::jsonb),
|
||||
'companies', COALESCE(v_companies, '[]'::jsonb),
|
||||
'ride_models', COALESCE(v_ride_models, '[]'::jsonb)
|
||||
),
|
||||
'generated_at', now()
|
||||
);
|
||||
|
||||
RETURN v_result;
|
||||
END;
|
||||
$$;
|
||||
@@ -0,0 +1,129 @@
|
||||
-- Fix get_recent_additions: Remove created_by joins for tables without created_by column
|
||||
-- Only entity_timeline_events has created_by column, not parks/rides/companies/ride_models/locations
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.get_recent_additions(limit_count integer DEFAULT 50)
|
||||
RETURNS TABLE(entity_id uuid, entity_type text, entity_name text, entity_slug text, park_slug text, image_url text, created_at timestamp with time zone, created_by_id uuid, created_by_username text, created_by_avatar text)
|
||||
LANGUAGE plpgsql
|
||||
STABLE SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $function$
|
||||
BEGIN
|
||||
RETURN QUERY
|
||||
SELECT * FROM (
|
||||
-- Parks - FIXED: Removed created_by join (parks table doesn't have created_by column)
|
||||
SELECT
|
||||
p.id as entity_id,
|
||||
'park'::text as entity_type,
|
||||
p.name as entity_name,
|
||||
p.slug as entity_slug,
|
||||
NULL::text as park_slug,
|
||||
p.card_image_url as image_url,
|
||||
p.created_at,
|
||||
NULL::uuid as created_by_id,
|
||||
NULL::text as created_by_username,
|
||||
NULL::text as created_by_avatar
|
||||
FROM parks p
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- Rides - FIXED: Removed created_by join (rides table doesn't have created_by column)
|
||||
SELECT
|
||||
r.id as entity_id,
|
||||
'ride'::text as entity_type,
|
||||
r.name as entity_name,
|
||||
r.slug as entity_slug,
|
||||
pk.slug as park_slug,
|
||||
r.card_image_url as image_url,
|
||||
r.created_at,
|
||||
NULL::uuid as created_by_id,
|
||||
NULL::text as created_by_username,
|
||||
NULL::text as created_by_avatar
|
||||
FROM rides r
|
||||
LEFT JOIN parks pk ON pk.id = r.park_id
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- Companies - FIXED: Removed created_by join (companies table doesn't have created_by column)
|
||||
SELECT
|
||||
c.id as entity_id,
|
||||
'company'::text as entity_type,
|
||||
c.name as entity_name,
|
||||
c.slug as entity_slug,
|
||||
NULL::text as park_slug,
|
||||
c.card_image_url as image_url,
|
||||
c.created_at,
|
||||
NULL::uuid as created_by_id,
|
||||
NULL::text as created_by_username,
|
||||
NULL::text as created_by_avatar
|
||||
FROM companies c
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- Ride Models - FIXED: Removed created_by join (ride_models table doesn't have created_by column)
|
||||
SELECT
|
||||
rm.id as entity_id,
|
||||
'ride_model'::text as entity_type,
|
||||
rm.name as entity_name,
|
||||
rm.slug as entity_slug,
|
||||
NULL::text as park_slug,
|
||||
rm.card_image_url as image_url,
|
||||
rm.created_at,
|
||||
NULL::uuid as created_by_id,
|
||||
NULL::text as created_by_username,
|
||||
NULL::text as created_by_avatar
|
||||
FROM ride_models rm
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- Locations - FIXED: Removed created_by join (locations table doesn't have created_by column)
|
||||
SELECT
|
||||
l.id as entity_id,
|
||||
'location'::text as entity_type,
|
||||
COALESCE(l.city || ', ' || l.country, l.country, 'Location') as entity_name,
|
||||
NULL::text as entity_slug,
|
||||
NULL::text as park_slug,
|
||||
NULL::text as image_url,
|
||||
l.created_at,
|
||||
NULL::uuid as created_by_id,
|
||||
NULL::text as created_by_username,
|
||||
NULL::text as created_by_avatar
|
||||
FROM locations l
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- Timeline Events - KEPT: This table has created_by column
|
||||
SELECT
|
||||
te.id as entity_id,
|
||||
'timeline_event'::text as entity_type,
|
||||
te.event_title as entity_name,
|
||||
NULL::text as entity_slug,
|
||||
NULL::text as park_slug,
|
||||
NULL::text as image_url,
|
||||
te.created_at,
|
||||
te.created_by as created_by_id,
|
||||
prof.username as created_by_username,
|
||||
prof.avatar_url as created_by_avatar
|
||||
FROM entity_timeline_events te
|
||||
LEFT JOIN profiles prof ON prof.user_id = te.created_by
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- Photos - KEPT: This table has submitted_by column
|
||||
SELECT
|
||||
p.id as entity_id,
|
||||
'photo'::text as entity_type,
|
||||
COALESCE(p.title, 'Photo') as entity_name,
|
||||
NULL::text as entity_slug,
|
||||
NULL::text as park_slug,
|
||||
p.cloudflare_image_url as image_url,
|
||||
p.created_at as created_at,
|
||||
p.submitted_by as created_by_id,
|
||||
prof.username as created_by_username,
|
||||
prof.avatar_url as created_by_avatar
|
||||
FROM photos p
|
||||
LEFT JOIN profiles prof ON prof.user_id = p.submitted_by
|
||||
) combined
|
||||
ORDER BY created_at DESC
|
||||
LIMIT limit_count;
|
||||
END;
|
||||
$function$;
|
||||
@@ -0,0 +1,307 @@
|
||||
-- Fix analyze_data_completeness function to check appropriate fields per entity type
|
||||
-- Parks don't have manufacturer_id, so we check relevant fields like description, park_type, status, location_id
|
||||
-- Rides and ride_models DO have manufacturer_id, so we check that
|
||||
-- Companies don't have manufacturer_id, so we check description and company_type
|
||||
|
||||
CREATE OR REPLACE FUNCTION analyze_data_completeness()
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
AS $$
|
||||
DECLARE
|
||||
result jsonb;
|
||||
park_data jsonb;
|
||||
ride_data jsonb;
|
||||
company_data jsonb;
|
||||
ride_model_data jsonb;
|
||||
total_entities integer;
|
||||
avg_score numeric;
|
||||
below_50_count integer;
|
||||
complete_count integer;
|
||||
BEGIN
|
||||
-- Analyze Parks
|
||||
WITH park_completeness AS (
|
||||
SELECT
|
||||
id,
|
||||
name,
|
||||
slug,
|
||||
'park'::text as entity_type,
|
||||
updated_at,
|
||||
CASE
|
||||
WHEN description IS NULL OR length(description) <= 50 THEN 0 ELSE 20
|
||||
END +
|
||||
CASE WHEN park_type IS NULL THEN 0 ELSE 15 END +
|
||||
CASE WHEN status IS NULL THEN 0 ELSE 10 END +
|
||||
CASE WHEN location_id IS NULL THEN 0 ELSE 15 END +
|
||||
CASE WHEN opening_date IS NULL THEN 0 ELSE 10 END +
|
||||
CASE WHEN website_url IS NULL THEN 0 ELSE 5 END +
|
||||
CASE WHEN card_image_url IS NULL THEN 0 ELSE 10 END +
|
||||
CASE WHEN banner_image_url IS NULL THEN 0 ELSE 5 END +
|
||||
CASE WHEN phone IS NULL THEN 0 ELSE 5 END +
|
||||
CASE WHEN email IS NULL THEN 0 ELSE 5 END as completeness_score
|
||||
FROM parks
|
||||
),
|
||||
park_missing AS (
|
||||
SELECT
|
||||
id,
|
||||
jsonb_build_object(
|
||||
'critical', ARRAY_REMOVE(ARRAY[
|
||||
CASE WHEN description IS NULL OR length(description) <= 50 THEN 'description' END,
|
||||
CASE WHEN park_type IS NULL THEN 'park_type' END,
|
||||
CASE WHEN status IS NULL THEN 'status' END
|
||||
], NULL),
|
||||
'important', ARRAY_REMOVE(ARRAY[
|
||||
CASE WHEN location_id IS NULL THEN 'location_id' END,
|
||||
CASE WHEN opening_date IS NULL THEN 'opening_date' END
|
||||
], NULL),
|
||||
'valuable', ARRAY_REMOVE(ARRAY[
|
||||
CASE WHEN website_url IS NULL THEN 'website_url' END,
|
||||
CASE WHEN card_image_url IS NULL THEN 'card_image_url' END
|
||||
], NULL),
|
||||
'supplementary', ARRAY_REMOVE(ARRAY[
|
||||
CASE WHEN banner_image_url IS NULL THEN 'banner_image_url' END,
|
||||
CASE WHEN phone IS NULL THEN 'phone' END,
|
||||
CASE WHEN email IS NULL THEN 'email' END
|
||||
], NULL)
|
||||
) as missing_fields
|
||||
FROM parks
|
||||
)
|
||||
SELECT jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'id', pc.id,
|
||||
'name', pc.name,
|
||||
'slug', pc.slug,
|
||||
'entity_type', pc.entity_type,
|
||||
'updated_at', pc.updated_at,
|
||||
'completeness_score', pc.completeness_score,
|
||||
'missing_fields', pm.missing_fields
|
||||
)
|
||||
)
|
||||
INTO park_data
|
||||
FROM park_completeness pc
|
||||
JOIN park_missing pm ON pc.id = pm.id;
|
||||
|
||||
-- Analyze Rides
|
||||
WITH ride_completeness AS (
|
||||
SELECT
|
||||
id,
|
||||
name,
|
||||
slug,
|
||||
'ride'::text as entity_type,
|
||||
updated_at,
|
||||
CASE
|
||||
WHEN description IS NULL OR length(description) <= 50 THEN 0 ELSE 20
|
||||
END +
|
||||
CASE WHEN manufacturer_id IS NULL THEN 0 ELSE 15 END +
|
||||
CASE WHEN category IS NULL THEN 0 ELSE 15 END +
|
||||
CASE WHEN status IS NULL THEN 0 ELSE 10 END +
|
||||
CASE WHEN opening_date IS NULL THEN 0 ELSE 10 END +
|
||||
CASE WHEN ride_model_id IS NULL THEN 0 ELSE 10 END +
|
||||
CASE WHEN card_image_url IS NULL THEN 0 ELSE 10 END +
|
||||
CASE WHEN banner_image_url IS NULL THEN 0 ELSE 5 END +
|
||||
CASE WHEN height_restriction IS NULL THEN 0 ELSE 3 END +
|
||||
CASE WHEN max_speed IS NULL THEN 0 ELSE 2 END as completeness_score
|
||||
FROM rides
|
||||
),
|
||||
ride_missing AS (
|
||||
SELECT
|
||||
id,
|
||||
jsonb_build_object(
|
||||
'critical', ARRAY_REMOVE(ARRAY[
|
||||
CASE WHEN description IS NULL OR length(description) <= 50 THEN 'description' END,
|
||||
CASE WHEN manufacturer_id IS NULL THEN 'manufacturer_id' END,
|
||||
CASE WHEN category IS NULL THEN 'category' END,
|
||||
CASE WHEN status IS NULL THEN 'status' END
|
||||
], NULL),
|
||||
'important', ARRAY_REMOVE(ARRAY[
|
||||
CASE WHEN opening_date IS NULL THEN 'opening_date' END,
|
||||
CASE WHEN ride_model_id IS NULL THEN 'ride_model_id' END
|
||||
], NULL),
|
||||
'valuable', ARRAY_REMOVE(ARRAY[
|
||||
CASE WHEN card_image_url IS NULL THEN 'card_image_url' END,
|
||||
CASE WHEN banner_image_url IS NULL THEN 'banner_image_url' END
|
||||
], NULL),
|
||||
'supplementary', ARRAY_REMOVE(ARRAY[
|
||||
CASE WHEN height_restriction IS NULL THEN 'height_restriction' END,
|
||||
CASE WHEN max_speed IS NULL THEN 'max_speed' END
|
||||
], NULL)
|
||||
) as missing_fields
|
||||
FROM rides
|
||||
)
|
||||
SELECT jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'id', rc.id,
|
||||
'name', rc.name,
|
||||
'slug', rc.slug,
|
||||
'entity_type', rc.entity_type,
|
||||
'updated_at', rc.updated_at,
|
||||
'completeness_score', rc.completeness_score,
|
||||
'missing_fields', rm.missing_fields
|
||||
)
|
||||
)
|
||||
INTO ride_data
|
||||
FROM ride_completeness rc
|
||||
JOIN ride_missing rm ON rc.id = rm.id;
|
||||
|
||||
-- Analyze Companies
|
||||
WITH company_completeness AS (
|
||||
SELECT
|
||||
id,
|
||||
name,
|
||||
slug,
|
||||
'company'::text as entity_type,
|
||||
updated_at,
|
||||
CASE
|
||||
WHEN description IS NULL OR length(description) <= 50 THEN 0 ELSE 25
|
||||
END +
|
||||
CASE WHEN company_type IS NULL THEN 0 ELSE 20 END +
|
||||
CASE WHEN headquarters_location IS NULL THEN 0 ELSE 15 END +
|
||||
CASE WHEN founded_year IS NULL THEN 0 ELSE 10 END +
|
||||
CASE WHEN website_url IS NULL THEN 0 ELSE 10 END +
|
||||
CASE WHEN logo_url IS NULL THEN 0 ELSE 10 END +
|
||||
CASE WHEN card_image_url IS NULL THEN 0 ELSE 5 END +
|
||||
CASE WHEN banner_image_url IS NULL THEN 0 ELSE 5 END as completeness_score
|
||||
FROM companies
|
||||
),
|
||||
company_missing AS (
|
||||
SELECT
|
||||
id,
|
||||
jsonb_build_object(
|
||||
'critical', ARRAY_REMOVE(ARRAY[
|
||||
CASE WHEN description IS NULL OR length(description) <= 50 THEN 'description' END,
|
||||
CASE WHEN company_type IS NULL THEN 'company_type' END
|
||||
], NULL),
|
||||
'important', ARRAY_REMOVE(ARRAY[
|
||||
CASE WHEN headquarters_location IS NULL THEN 'headquarters_location' END,
|
||||
CASE WHEN founded_year IS NULL THEN 'founded_year' END
|
||||
], NULL),
|
||||
'valuable', ARRAY_REMOVE(ARRAY[
|
||||
CASE WHEN website_url IS NULL THEN 'website_url' END,
|
||||
CASE WHEN logo_url IS NULL THEN 'logo_url' END
|
||||
], NULL),
|
||||
'supplementary', ARRAY_REMOVE(ARRAY[
|
||||
CASE WHEN card_image_url IS NULL THEN 'card_image_url' END,
|
||||
CASE WHEN banner_image_url IS NULL THEN 'banner_image_url' END
|
||||
], NULL)
|
||||
) as missing_fields
|
||||
FROM companies
|
||||
)
|
||||
SELECT jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'id', cc.id,
|
||||
'name', cc.name,
|
||||
'slug', cc.slug,
|
||||
'entity_type', cc.entity_type,
|
||||
'updated_at', cc.updated_at,
|
||||
'completeness_score', cc.completeness_score,
|
||||
'missing_fields', cm.missing_fields
|
||||
)
|
||||
)
|
||||
INTO company_data
|
||||
FROM company_completeness cc
|
||||
JOIN company_missing cm ON cc.id = cm.id;
|
||||
|
||||
-- Analyze Ride Models
|
||||
WITH ride_model_completeness AS (
|
||||
SELECT
|
||||
id,
|
||||
name,
|
||||
slug,
|
||||
'ride_model'::text as entity_type,
|
||||
updated_at,
|
||||
CASE
|
||||
WHEN description IS NULL OR length(description) <= 50 THEN 0 ELSE 25
|
||||
END +
|
||||
CASE WHEN manufacturer_id IS NULL THEN 0 ELSE 20 END +
|
||||
CASE WHEN category IS NULL THEN 0 ELSE 20 END +
|
||||
CASE WHEN card_image_url IS NULL THEN 0 ELSE 15 END +
|
||||
CASE WHEN banner_image_url IS NULL THEN 0 ELSE 10 END +
|
||||
CASE WHEN max_speed IS NULL THEN 0 ELSE 5 END +
|
||||
CASE WHEN height IS NULL THEN 0 ELSE 5 END as completeness_score
|
||||
FROM ride_models
|
||||
),
|
||||
ride_model_missing AS (
|
||||
SELECT
|
||||
id,
|
||||
jsonb_build_object(
|
||||
'critical', ARRAY_REMOVE(ARRAY[
|
||||
CASE WHEN description IS NULL OR length(description) <= 50 THEN 'description' END,
|
||||
CASE WHEN manufacturer_id IS NULL THEN 'manufacturer_id' END,
|
||||
CASE WHEN category IS NULL THEN 'category' END
|
||||
], NULL),
|
||||
'important', ARRAY_REMOVE(ARRAY[
|
||||
CASE WHEN card_image_url IS NULL THEN 'card_image_url' END
|
||||
], NULL),
|
||||
'valuable', ARRAY_REMOVE(ARRAY[
|
||||
CASE WHEN banner_image_url IS NULL THEN 'banner_image_url' END
|
||||
], NULL),
|
||||
'supplementary', ARRAY_REMOVE(ARRAY[
|
||||
CASE WHEN max_speed IS NULL THEN 'max_speed' END,
|
||||
CASE WHEN height IS NULL THEN 'height' END
|
||||
], NULL)
|
||||
) as missing_fields
|
||||
FROM ride_models
|
||||
)
|
||||
SELECT jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'id', rmc.id,
|
||||
'name', rmc.name,
|
||||
'slug', rmc.slug,
|
||||
'entity_type', rmc.entity_type,
|
||||
'updated_at', rmc.updated_at,
|
||||
'completeness_score', rmc.completeness_score,
|
||||
'missing_fields', rmm.missing_fields
|
||||
)
|
||||
)
|
||||
INTO ride_model_data
|
||||
FROM ride_model_completeness rmc
|
||||
JOIN ride_model_missing rmm ON rmc.id = rmm.id;
|
||||
|
||||
-- Calculate summary statistics
|
||||
WITH all_scores AS (
|
||||
SELECT (value->>'completeness_score')::numeric as score
|
||||
FROM jsonb_array_elements(COALESCE(park_data, '[]'::jsonb))
|
||||
UNION ALL
|
||||
SELECT (value->>'completeness_score')::numeric
|
||||
FROM jsonb_array_elements(COALESCE(ride_data, '[]'::jsonb))
|
||||
UNION ALL
|
||||
SELECT (value->>'completeness_score')::numeric
|
||||
FROM jsonb_array_elements(COALESCE(company_data, '[]'::jsonb))
|
||||
UNION ALL
|
||||
SELECT (value->>'completeness_score')::numeric
|
||||
FROM jsonb_array_elements(COALESCE(ride_model_data, '[]'::jsonb))
|
||||
)
|
||||
SELECT
|
||||
COUNT(*)::integer,
|
||||
ROUND(AVG(score), 2),
|
||||
COUNT(CASE WHEN score < 50 THEN 1 END)::integer,
|
||||
COUNT(CASE WHEN score = 100 THEN 1 END)::integer
|
||||
INTO total_entities, avg_score, below_50_count, complete_count
|
||||
FROM all_scores;
|
||||
|
||||
-- Build final result
|
||||
result := jsonb_build_object(
|
||||
'summary', jsonb_build_object(
|
||||
'total_entities', COALESCE(total_entities, 0),
|
||||
'avg_completeness_score', COALESCE(avg_score, 0),
|
||||
'entities_below_50', COALESCE(below_50_count, 0),
|
||||
'entities_100_complete', COALESCE(complete_count, 0),
|
||||
'by_entity_type', jsonb_build_object(
|
||||
'parks', COALESCE(jsonb_array_length(park_data), 0),
|
||||
'rides', COALESCE(jsonb_array_length(ride_data), 0),
|
||||
'companies', COALESCE(jsonb_array_length(company_data), 0),
|
||||
'ride_models', COALESCE(jsonb_array_length(ride_model_data), 0)
|
||||
)
|
||||
),
|
||||
'entities', jsonb_build_object(
|
||||
'parks', COALESCE(park_data, '[]'::jsonb),
|
||||
'rides', COALESCE(ride_data, '[]'::jsonb),
|
||||
'companies', COALESCE(company_data, '[]'::jsonb),
|
||||
'ride_models', COALESCE(ride_model_data, '[]'::jsonb)
|
||||
),
|
||||
'generated_at', to_jsonb(now())
|
||||
);
|
||||
|
||||
RETURN result;
|
||||
END;
|
||||
$$;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user