mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-25 13:31:12 -05:00
Compare commits
97 Commits
08926610b9
...
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 | ||
|
|
9ee84b31ff | ||
|
|
96b7594738 | ||
|
|
8ee548fd27 | ||
|
|
de921a5fcf | ||
|
|
4040fd783e | ||
|
|
afe7a93f69 | ||
|
|
fa57d497af | ||
|
|
3797e34e0b | ||
|
|
0e9ea18be8 | ||
|
|
10df39c7d4 | ||
|
|
d56bb3cd15 | ||
|
|
9b1c2415b0 | ||
|
|
947964482f | ||
|
|
f036776dce | ||
|
|
69db3c7743 | ||
|
|
901d25807d | ||
|
|
664c894bb1 | ||
|
|
314db65591 | ||
|
|
d48e95ee7c | ||
|
|
054348b9c4 | ||
|
|
a2663b392a | ||
|
|
2aebe6a041 | ||
|
|
18f1e6b8b5 | ||
|
|
8a73dd0166 | ||
|
|
46ed097a81 | ||
|
|
82b85e3284 | ||
|
|
466c549e4a | ||
|
|
a5fed1e26a | ||
|
|
8581950a6e | ||
|
|
53b576ecc1 | ||
|
|
eac8c7a77f | ||
|
|
21cd547c27 | ||
|
|
da32935d63 | ||
|
|
9cabd20e43 | ||
|
|
2093560f64 | ||
|
|
0dfc5ff724 | ||
|
|
177eb540a8 | ||
|
|
ca6e95f4f8 |
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
|
||||
-- ============================================================================
|
||||
38
src/App.tsx
38
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";
|
||||
@@ -69,6 +70,8 @@ const AdminSystemLog = lazy(() => import("./pages/AdminSystemLog"));
|
||||
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"));
|
||||
@@ -76,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"));
|
||||
@@ -162,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 />} />
|
||||
@@ -383,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 />
|
||||
@@ -414,6 +427,22 @@ function AppContent(): React.JSX.Element {
|
||||
</AdminErrorBoundary>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/database-stats"
|
||||
element={
|
||||
<AdminErrorBoundary section="Database Statistics">
|
||||
<AdminDatabaseStats />
|
||||
</AdminErrorBoundary>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/database-maintenance"
|
||||
element={
|
||||
<AdminErrorBoundary section="Database Maintenance">
|
||||
<DatabaseMaintenance />
|
||||
</AdminErrorBoundary>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Utility routes - lazy loaded */}
|
||||
<Route path="/force-logout" element={<ForceLogout />} />
|
||||
@@ -425,7 +454,8 @@ function AppContent(): React.JSX.Element {
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</RouteErrorBoundary>
|
||||
</Suspense>
|
||||
</PageTransition>
|
||||
</Suspense>
|
||||
</div>
|
||||
<Footer />
|
||||
</div>
|
||||
|
||||
116
src/components/admin/CompanyDataBackfill.tsx
Normal file
116
src/components/admin/CompanyDataBackfill.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { supabase } from '@/lib/supabaseClient';
|
||||
import { Building2, AlertCircle, CheckCircle2 } from 'lucide-react';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
|
||||
export function CompanyDataBackfill() {
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const [result, setResult] = useState<{
|
||||
success: boolean;
|
||||
companies_updated: number;
|
||||
headquarters_added: number;
|
||||
website_added: number;
|
||||
founded_year_added: number;
|
||||
description_added: number;
|
||||
logo_added: number;
|
||||
} | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { toast } = useToast();
|
||||
|
||||
const handleBackfill = async () => {
|
||||
setIsRunning(true);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
|
||||
try {
|
||||
const { data, error: invokeError } = await supabase.functions.invoke(
|
||||
'backfill-company-data'
|
||||
);
|
||||
|
||||
if (invokeError) throw invokeError;
|
||||
|
||||
setResult(data);
|
||||
|
||||
const updates: string[] = [];
|
||||
if (data.headquarters_added > 0) updates.push(`${data.headquarters_added} headquarters`);
|
||||
if (data.website_added > 0) updates.push(`${data.website_added} websites`);
|
||||
if (data.founded_year_added > 0) updates.push(`${data.founded_year_added} founding years`);
|
||||
if (data.description_added > 0) updates.push(`${data.description_added} descriptions`);
|
||||
if (data.logo_added > 0) updates.push(`${data.logo_added} logos`);
|
||||
|
||||
toast({
|
||||
title: 'Backfill Complete',
|
||||
description: `Updated ${data.companies_updated} companies: ${updates.join(', ')}`,
|
||||
});
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.message || 'Failed to run backfill';
|
||||
setError(errorMessage);
|
||||
toast({
|
||||
title: 'Backfill Failed',
|
||||
description: errorMessage,
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setIsRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Building2 className="w-5 h-5" />
|
||||
Company Data Backfill
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Backfill missing headquarters, website, founding year, description, and logo data for companies from their submission data
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
This tool will find companies (operators, manufacturers, designers) missing basic information and populate them using data from their approved submissions. Useful for fixing companies that were approved before all fields were properly handled.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{result && (
|
||||
<Alert className="border-green-200 bg-green-50 dark:bg-green-950 dark:border-green-800">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-600 dark:text-green-400" />
|
||||
<AlertDescription className="text-green-900 dark:text-green-100">
|
||||
<div className="font-medium">Backfill completed successfully!</div>
|
||||
<div className="mt-2 space-y-1">
|
||||
<div>Companies updated: {result.companies_updated}</div>
|
||||
<div>Headquarters added: {result.headquarters_added}</div>
|
||||
<div>Websites added: {result.website_added}</div>
|
||||
<div>Founding years added: {result.founded_year_added}</div>
|
||||
<div>Descriptions added: {result.description_added}</div>
|
||||
<div>Logos added: {result.logo_added}</div>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={handleBackfill}
|
||||
disabled={isRunning}
|
||||
className="w-full"
|
||||
trackingLabel="run-company-data-backfill"
|
||||
>
|
||||
<Building2 className="w-4 h-4 mr-2" />
|
||||
{isRunning ? 'Running Backfill...' : 'Run Company Data Backfill'}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { AlertCircle, AlertTriangle, Info, ChevronDown, ChevronUp, Clock, Zap, RefreshCw } from 'lucide-react';
|
||||
import { AlertCircle, AlertTriangle, Info, ChevronDown, ChevronUp, Clock, Zap, RefreshCw, Loader2 } from 'lucide-react';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import type { GroupedAlert } from '@/hooks/admin/useGroupedAlerts';
|
||||
import { useResolveAlertGroup, useSnoozeAlertGroup } from '@/hooks/admin/useAlertGroupActions';
|
||||
@@ -37,6 +37,11 @@ export function GroupedAlertsPanel({ alerts, isLoading }: GroupedAlertsPanelProp
|
||||
});
|
||||
|
||||
const handleResolveGroup = (alert: GroupedAlert) => {
|
||||
console.log('🔴 Resolve button clicked', {
|
||||
alertIds: alert.alert_ids,
|
||||
source: alert.source,
|
||||
alert,
|
||||
});
|
||||
resolveGroup.mutate({
|
||||
alertIds: alert.alert_ids,
|
||||
source: alert.source,
|
||||
@@ -211,7 +216,14 @@ export function GroupedAlertsPanel({ alerts, isLoading }: GroupedAlertsPanelProp
|
||||
onClick={() => handleResolveGroup(alert)}
|
||||
disabled={resolveGroup.isPending}
|
||||
>
|
||||
Resolve All
|
||||
{resolveGroup.isPending ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Resolving...
|
||||
</>
|
||||
) : (
|
||||
'Resolve All'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
100
src/components/admin/ParkLocationBackfill.tsx
Normal file
100
src/components/admin/ParkLocationBackfill.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { supabase } from '@/lib/supabaseClient';
|
||||
import { MapPin, AlertCircle, CheckCircle2 } from 'lucide-react';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
|
||||
export function ParkLocationBackfill() {
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const [result, setResult] = useState<{
|
||||
success: boolean;
|
||||
parks_updated: number;
|
||||
locations_created: number;
|
||||
} | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { toast } = useToast();
|
||||
|
||||
const handleBackfill = async () => {
|
||||
setIsRunning(true);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
|
||||
try {
|
||||
const { data, error: invokeError } = await supabase.functions.invoke(
|
||||
'backfill-park-locations'
|
||||
);
|
||||
|
||||
if (invokeError) throw invokeError;
|
||||
|
||||
setResult(data);
|
||||
toast({
|
||||
title: 'Backfill Complete',
|
||||
description: `Updated ${data.parks_updated} parks with ${data.locations_created} new locations`,
|
||||
});
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.message || 'Failed to run backfill';
|
||||
setError(errorMessage);
|
||||
toast({
|
||||
title: 'Backfill Failed',
|
||||
description: errorMessage,
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setIsRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<MapPin className="w-5 h-5" />
|
||||
Park Location Backfill
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Backfill missing location data for approved parks from their submission data
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
This tool will find parks without location data and populate them using the location information from their approved submissions. This is useful for fixing parks that were approved before the location creation fix was implemented.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{result && (
|
||||
<Alert className="border-green-200 bg-green-50 dark:bg-green-950 dark:border-green-800">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-600 dark:text-green-400" />
|
||||
<AlertDescription className="text-green-900 dark:text-green-100">
|
||||
<div className="font-medium">Backfill completed successfully!</div>
|
||||
<div className="mt-2 space-y-1">
|
||||
<div>Parks updated: {result.parks_updated}</div>
|
||||
<div>Locations created: {result.locations_created}</div>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={handleBackfill}
|
||||
disabled={isRunning}
|
||||
className="w-full"
|
||||
trackingLabel="run-park-location-backfill"
|
||||
>
|
||||
<MapPin className="w-4 h-4 mr-2" />
|
||||
{isRunning ? 'Running Backfill...' : 'Run Location Backfill'}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -5,14 +5,18 @@
|
||||
* Shows top 10 active alerts with severity-based styling and resolution actions.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { useSystemAlerts } from '@/hooks/useSystemHealth';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { AlertTriangle, CheckCircle, XCircle, AlertCircle } from 'lucide-react';
|
||||
import { AlertTriangle, CheckCircle, XCircle, AlertCircle, Loader2 } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { supabase } from '@/lib/supabaseClient';
|
||||
import { toast } from 'sonner';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { queryKeys } from '@/lib/queryKeys';
|
||||
import { logAdminAction } from '@/lib/adminActionAuditHelpers';
|
||||
|
||||
const SEVERITY_CONFIG = {
|
||||
critical: { color: 'destructive', icon: XCircle },
|
||||
@@ -38,6 +42,8 @@ const ALERT_TYPE_LABELS: Record<string, string> = {
|
||||
};
|
||||
|
||||
export function PipelineHealthAlerts() {
|
||||
const queryClient = useQueryClient();
|
||||
const [resolvingAlertId, setResolvingAlertId] = useState<string | null>(null);
|
||||
const { data: criticalAlerts } = useSystemAlerts('critical');
|
||||
const { data: highAlerts } = useSystemAlerts('high');
|
||||
const { data: mediumAlerts } = useSystemAlerts('medium');
|
||||
@@ -49,15 +55,48 @@ export function PipelineHealthAlerts() {
|
||||
].slice(0, 10);
|
||||
|
||||
const resolveAlert = async (alertId: string) => {
|
||||
const { error } = await supabase
|
||||
.from('system_alerts')
|
||||
.update({ resolved_at: new Date().toISOString() })
|
||||
.eq('id', alertId);
|
||||
console.log('🔴 Resolve button clicked in PipelineHealthAlerts', { alertId });
|
||||
setResolvingAlertId(alertId);
|
||||
|
||||
try {
|
||||
// Fetch alert details before resolving
|
||||
const alertToResolve = allAlerts.find(a => a.id === alertId);
|
||||
|
||||
const { error } = await supabase
|
||||
.from('system_alerts')
|
||||
.update({ resolved_at: new Date().toISOString() })
|
||||
.eq('id', alertId);
|
||||
|
||||
if (error) {
|
||||
toast.error('Failed to resolve alert');
|
||||
} else {
|
||||
if (error) {
|
||||
console.error('❌ Error resolving alert:', error);
|
||||
toast.error('Failed to resolve alert');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('✅ Alert resolved successfully');
|
||||
toast.success('Alert resolved');
|
||||
|
||||
// Log to audit trail
|
||||
if (alertToResolve) {
|
||||
await logAdminAction('system_alert_resolved', {
|
||||
alert_id: alertToResolve.id,
|
||||
alert_type: alertToResolve.alert_type,
|
||||
severity: alertToResolve.severity,
|
||||
message: alertToResolve.message,
|
||||
metadata: alertToResolve.metadata,
|
||||
});
|
||||
}
|
||||
|
||||
// Invalidate all system-alerts queries (critical, high, medium, etc.)
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['system-alerts'] }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.monitoring.systemHealth() })
|
||||
]);
|
||||
} catch (err) {
|
||||
console.error('❌ Unexpected error resolving alert:', err);
|
||||
toast.error('An unexpected error occurred');
|
||||
} finally {
|
||||
setResolvingAlertId(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -113,8 +152,16 @@ export function PipelineHealthAlerts() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => resolveAlert(alert.id)}
|
||||
disabled={resolvingAlertId === alert.id}
|
||||
>
|
||||
Resolve
|
||||
{resolvingAlertId === alert.id ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Resolving...
|
||||
</>
|
||||
) : (
|
||||
'Resolve'
|
||||
)}
|
||||
</Button>
|
||||
</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 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 {
|
||||
|
||||
110
src/components/admin/RideDataBackfill.tsx
Normal file
110
src/components/admin/RideDataBackfill.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { supabase } from '@/lib/supabaseClient';
|
||||
import { Hammer, AlertCircle, CheckCircle2 } from 'lucide-react';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
|
||||
export function RideDataBackfill() {
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const [result, setResult] = useState<{
|
||||
success: boolean;
|
||||
rides_updated: number;
|
||||
manufacturer_added: number;
|
||||
designer_added: number;
|
||||
ride_model_added: number;
|
||||
} | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { toast } = useToast();
|
||||
|
||||
const handleBackfill = async () => {
|
||||
setIsRunning(true);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
|
||||
try {
|
||||
const { data, error: invokeError } = await supabase.functions.invoke(
|
||||
'backfill-ride-data'
|
||||
);
|
||||
|
||||
if (invokeError) throw invokeError;
|
||||
|
||||
setResult(data);
|
||||
|
||||
const updates: string[] = [];
|
||||
if (data.manufacturer_added > 0) updates.push(`${data.manufacturer_added} manufacturers`);
|
||||
if (data.designer_added > 0) updates.push(`${data.designer_added} designers`);
|
||||
if (data.ride_model_added > 0) updates.push(`${data.ride_model_added} ride models`);
|
||||
|
||||
toast({
|
||||
title: 'Backfill Complete',
|
||||
description: `Updated ${data.rides_updated} rides: ${updates.join(', ')}`,
|
||||
});
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.message || 'Failed to run backfill';
|
||||
setError(errorMessage);
|
||||
toast({
|
||||
title: 'Backfill Failed',
|
||||
description: errorMessage,
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setIsRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Hammer className="w-5 h-5" />
|
||||
Ride Data Backfill
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Backfill missing manufacturer, designer, and ride model data for approved rides from their submission data
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
This tool will find rides missing manufacturer, designer, or ride model information and populate them using data from their approved submissions. Useful for fixing rides that were approved before relationship data was properly handled.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{result && (
|
||||
<Alert className="border-green-200 bg-green-50 dark:bg-green-950 dark:border-green-800">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-600 dark:text-green-400" />
|
||||
<AlertDescription className="text-green-900 dark:text-green-100">
|
||||
<div className="font-medium">Backfill completed successfully!</div>
|
||||
<div className="mt-2 space-y-1">
|
||||
<div>Rides updated: {result.rides_updated}</div>
|
||||
<div>Manufacturers added: {result.manufacturer_added}</div>
|
||||
<div>Designers added: {result.designer_added}</div>
|
||||
<div>Ride models added: {result.ride_model_added}</div>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={handleBackfill}
|
||||
disabled={isRunning}
|
||||
className="w-full"
|
||||
trackingLabel="run-ride-data-backfill"
|
||||
>
|
||||
<Hammer className="w-4 h-4 mr-2" />
|
||||
{isRunning ? 'Running Backfill...' : 'Run Ride Data Backfill'}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -68,7 +68,15 @@ export function VersionCleanupSettings() {
|
||||
|
||||
const handleSaveRetention = async () => {
|
||||
setIsSaving(true);
|
||||
const oldRetentionDays = retentionDays;
|
||||
try {
|
||||
// Get current value for audit log
|
||||
const { data: currentSetting } = await supabase
|
||||
.from('admin_settings')
|
||||
.select('setting_value')
|
||||
.eq('setting_key', 'version_retention_days')
|
||||
.single();
|
||||
|
||||
const { error } = await supabase
|
||||
.from('admin_settings')
|
||||
.update({ setting_value: retentionDays.toString() })
|
||||
@@ -76,6 +84,14 @@ export function VersionCleanupSettings() {
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
// Log to audit trail
|
||||
const { logAdminAction } = await import('@/lib/adminActionAuditHelpers');
|
||||
await logAdminAction('version_cleanup_config_changed', {
|
||||
setting_key: 'version_retention_days',
|
||||
old_value: currentSetting?.setting_value,
|
||||
new_value: retentionDays,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: 'Settings Saved',
|
||||
description: 'Retention period updated successfully'
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Data Completeness Summary Component
|
||||
*
|
||||
* Displays high-level overview cards for data completeness metrics
|
||||
*/
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Database, AlertCircle, CheckCircle2, TrendingUp } from 'lucide-react';
|
||||
import type { CompletenessSummary } from '@/types/data-completeness';
|
||||
|
||||
interface CompletenessSummaryProps {
|
||||
summary: CompletenessSummary;
|
||||
}
|
||||
|
||||
export function CompletenessSummary({ summary }: CompletenessSummaryProps) {
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Entities</CardTitle>
|
||||
<Database className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{summary.total_entities.toLocaleString()}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Parks: {summary.by_entity_type.parks} | Rides: {summary.by_entity_type.rides}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Avg Completeness</CardTitle>
|
||||
<TrendingUp className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{summary.avg_completeness_score?.toFixed(1) || 0}%</div>
|
||||
<Progress value={summary.avg_completeness_score || 0} className="mt-2" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Below 50%</CardTitle>
|
||||
<AlertCircle className="h-4 w-4 text-destructive" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-destructive">
|
||||
{summary.entities_below_50}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{((summary.entities_below_50 / summary.total_entities) * 100).toFixed(1)}% of total
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">100% Complete</CardTitle>
|
||||
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{summary.entities_100_complete}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{((summary.entities_100_complete / summary.total_entities) * 100).toFixed(1)}% of total
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
110
src/components/admin/data-completeness/CompletenessFilters.tsx
Normal file
110
src/components/admin/data-completeness/CompletenessFilters.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Data Completeness Filters Component
|
||||
*
|
||||
* Filter controls for entity type, score range, and missing field categories
|
||||
*/
|
||||
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import type { CompletenessFilters, EntityType, MissingFieldCategory } from '@/types/data-completeness';
|
||||
|
||||
interface CompletenessFiltersProps {
|
||||
filters: CompletenessFilters;
|
||||
onFiltersChange: (filters: CompletenessFilters) => void;
|
||||
}
|
||||
|
||||
export function CompletenessFilters({ filters, onFiltersChange }: CompletenessFiltersProps) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="entity-type">Entity Type</Label>
|
||||
<Select
|
||||
value={filters.entityType || 'all'}
|
||||
onValueChange={(value) =>
|
||||
onFiltersChange({
|
||||
...filters,
|
||||
entityType: value === 'all' ? undefined : (value as EntityType),
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="entity-type">
|
||||
<SelectValue placeholder="All entities" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Entities</SelectItem>
|
||||
<SelectItem value="park">Parks</SelectItem>
|
||||
<SelectItem value="ride">Rides</SelectItem>
|
||||
<SelectItem value="company">Companies</SelectItem>
|
||||
<SelectItem value="ride_model">Ride Models</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="missing-category">Missing Category</Label>
|
||||
<Select
|
||||
value={filters.missingCategory || 'all'}
|
||||
onValueChange={(value) =>
|
||||
onFiltersChange({
|
||||
...filters,
|
||||
missingCategory: value === 'all' ? undefined : (value as MissingFieldCategory),
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="missing-category">
|
||||
<SelectValue placeholder="All categories" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Categories</SelectItem>
|
||||
<SelectItem value="critical">Missing Critical</SelectItem>
|
||||
<SelectItem value="important">Missing Important</SelectItem>
|
||||
<SelectItem value="valuable">Missing Valuable</SelectItem>
|
||||
<SelectItem value="supplementary">Missing Supplementary</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="search">Search</Label>
|
||||
<Input
|
||||
id="search"
|
||||
placeholder="Search entities..."
|
||||
value={filters.searchQuery || ''}
|
||||
onChange={(e) =>
|
||||
onFiltersChange({
|
||||
...filters,
|
||||
searchQuery: e.target.value || undefined,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Completeness Score Range</Label>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{filters.minScore || 0}% - {filters.maxScore || 100}%
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
value={[filters.minScore || 0, filters.maxScore || 100]}
|
||||
onValueChange={([min, max]) =>
|
||||
onFiltersChange({
|
||||
...filters,
|
||||
minScore: min === 0 ? undefined : min,
|
||||
maxScore: max === 100 ? undefined : max,
|
||||
})
|
||||
}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
146
src/components/admin/data-completeness/CompletenessTable.tsx
Normal file
146
src/components/admin/data-completeness/CompletenessTable.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Data Completeness Table Component
|
||||
*
|
||||
* Virtualized table displaying entity completeness data with sorting and actions
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ExternalLink, AlertCircle } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { EntityCompleteness, CompletenessFilters } from '@/types/data-completeness';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
|
||||
interface CompletenessTableProps {
|
||||
entities: EntityCompleteness[];
|
||||
filters: CompletenessFilters;
|
||||
}
|
||||
|
||||
export function CompletenessTable({ entities, filters }: CompletenessTableProps) {
|
||||
// Filter and sort entities
|
||||
const filteredEntities = useMemo(() => {
|
||||
let filtered = entities;
|
||||
|
||||
// Apply search filter
|
||||
if (filters.searchQuery) {
|
||||
const query = filters.searchQuery.toLowerCase();
|
||||
filtered = filtered.filter((entity) =>
|
||||
entity.name.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
|
||||
// Sort by completeness score (ascending - most incomplete first)
|
||||
return filtered.sort((a, b) => a.completeness_score - b.completeness_score);
|
||||
}, [entities, filters]);
|
||||
|
||||
const getEntityUrl = (entity: EntityCompleteness) => {
|
||||
switch (entity.entity_type) {
|
||||
case 'park':
|
||||
return `/parks/${entity.slug}`;
|
||||
case 'ride':
|
||||
return `/rides/${entity.slug}`;
|
||||
case 'company':
|
||||
return `/companies/${entity.slug}`;
|
||||
case 'ride_model':
|
||||
return `/ride-models/${entity.slug}`;
|
||||
default:
|
||||
return '#';
|
||||
}
|
||||
};
|
||||
|
||||
const getScoreColor = (score: number) => {
|
||||
if (score >= 80) return 'text-green-600';
|
||||
if (score >= 50) return 'text-yellow-600';
|
||||
return 'text-destructive';
|
||||
};
|
||||
|
||||
const getMissingFieldsCount = (entity: EntityCompleteness) => {
|
||||
return (
|
||||
entity.missing_fields.critical.length +
|
||||
entity.missing_fields.important.length +
|
||||
entity.missing_fields.valuable.length +
|
||||
entity.missing_fields.supplementary.length
|
||||
);
|
||||
};
|
||||
|
||||
if (filteredEntities.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<AlertCircle className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<p className="text-lg font-medium">No entities found</p>
|
||||
<p className="text-sm text-muted-foreground">Try adjusting your filters</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border rounded-lg">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Entity</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Completeness</TableHead>
|
||||
<TableHead>Missing Fields</TableHead>
|
||||
<TableHead>Last Updated</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredEntities.map((entity) => (
|
||||
<TableRow key={entity.id}>
|
||||
<TableCell className="font-medium">{entity.name}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">
|
||||
{entity.entity_type.replace('_', ' ')}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-sm font-medium ${getScoreColor(entity.completeness_score)}`}>
|
||||
{entity.completeness_score.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={entity.completeness_score} className="h-2" />
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{entity.missing_fields.critical.length > 0 && (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
{entity.missing_fields.critical.length} Critical
|
||||
</Badge>
|
||||
)}
|
||||
{entity.missing_fields.important.length > 0 && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{entity.missing_fields.important.length} Important
|
||||
</Badge>
|
||||
)}
|
||||
{getMissingFieldsCount(entity) === 0 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Complete
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{formatDistanceToNow(new Date(entity.updated_at), { addSuffix: true })}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link to={getEntityUrl(entity)}>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Data Completeness Dashboard
|
||||
*
|
||||
* Main dashboard component combining summary, filters, and table
|
||||
* Provides comprehensive view of data quality across all entity types
|
||||
*/
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Loader2, AlertCircle, RefreshCw } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useDataCompleteness } from '@/hooks/useDataCompleteness';
|
||||
import { CompletenessSummary } from './CompletenesSummary';
|
||||
import { CompletenessFilters } from './CompletenessFilters';
|
||||
import { CompletenessTable } from './CompletenessTable';
|
||||
import type { CompletenessFilters as Filters, EntityType } from '@/types/data-completeness';
|
||||
|
||||
export function DataCompletenessDashboard() {
|
||||
const [filters, setFilters] = useState<Filters>({});
|
||||
const { data, isLoading, error, refetch, isRefetching } = useDataCompleteness(filters);
|
||||
|
||||
// Combine all entities for the "All" tab
|
||||
const allEntities = useMemo(() => {
|
||||
if (!data) return [];
|
||||
return [
|
||||
...data.entities.parks,
|
||||
...data.entities.rides,
|
||||
...data.entities.companies,
|
||||
...data.entities.ride_models,
|
||||
];
|
||||
}, [data]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
<span className="ml-2 text-muted-foreground">Analyzing data completeness...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
Failed to load data completeness analysis. Please try again.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Data Completeness Dashboard</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Monitor and improve data quality across all entities
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => refetch()}
|
||||
disabled={isRefetching}
|
||||
variant="outline"
|
||||
>
|
||||
{isRefetching ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<CompletenessSummary summary={data.summary} />
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Filter Entities</CardTitle>
|
||||
<CardDescription>
|
||||
Filter by entity type, completeness score, and missing field categories
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CompletenessFilters filters={filters} onFiltersChange={setFilters} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Entity Details</CardTitle>
|
||||
<CardDescription>
|
||||
Entities sorted by completeness (most incomplete first)
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Tabs defaultValue="all" className="space-y-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="all">
|
||||
All ({allEntities.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="parks">
|
||||
Parks ({data.entities.parks.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="rides">
|
||||
Rides ({data.entities.rides.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="companies">
|
||||
Companies ({data.entities.companies.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="ride_models">
|
||||
Ride Models ({data.entities.ride_models.length})
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="all">
|
||||
<CompletenessTable entities={allEntities} filters={filters} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="parks">
|
||||
<CompletenessTable entities={data.entities.parks} filters={filters} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="rides">
|
||||
<CompletenessTable entities={data.entities.rides} filters={filters} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="companies">
|
||||
<CompletenessTable entities={data.entities.companies} filters={filters} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="ride_models">
|
||||
<CompletenessTable entities={data.entities.ride_models} filters={filters} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
107
src/components/admin/database-stats/ComparisonTable.tsx
Normal file
107
src/components/admin/database-stats/ComparisonTable.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ExternalLink } from 'lucide-react';
|
||||
|
||||
interface Column {
|
||||
key: string;
|
||||
label: string;
|
||||
numeric?: boolean;
|
||||
linkBase?: string;
|
||||
}
|
||||
|
||||
interface ComparisonTableProps {
|
||||
title: string;
|
||||
data: any[];
|
||||
columns: Column[];
|
||||
slugKey: string;
|
||||
parkSlugKey?: string;
|
||||
}
|
||||
|
||||
export function ComparisonTable({ title, data, columns, slugKey, parkSlugKey }: ComparisonTableProps) {
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
No data available
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Find the max value for each numeric column (for progress bars)
|
||||
const maxValues: Record<string, number> = {};
|
||||
columns.forEach(col => {
|
||||
if (col.numeric) {
|
||||
maxValues[col.key] = Math.max(...data.map(row => row[col.key] || 0));
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-semibold">{title}</h3>
|
||||
<div className="border rounded-lg">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-12">Rank</TableHead>
|
||||
{columns.map(col => (
|
||||
<TableHead key={col.key} className={col.numeric ? 'text-right' : ''}>
|
||||
{col.label}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.map((row, index) => {
|
||||
const slug = row[slugKey];
|
||||
const parkSlug = parkSlugKey ? row[parkSlugKey] : null;
|
||||
|
||||
return (
|
||||
<TableRow key={index}>
|
||||
<TableCell className="font-medium text-muted-foreground">
|
||||
#{index + 1}
|
||||
</TableCell>
|
||||
{columns.map(col => {
|
||||
const value = row[col.key];
|
||||
const isFirst = col === columns[0];
|
||||
|
||||
if (isFirst && col.linkBase && slug) {
|
||||
const linkPath = parkSlug
|
||||
? `${col.linkBase}/${parkSlug}/rides/${slug}`
|
||||
: `${col.linkBase}/${slug}`;
|
||||
|
||||
return (
|
||||
<TableCell key={col.key}>
|
||||
<Link
|
||||
to={linkPath}
|
||||
className="flex items-center gap-2 hover:text-primary transition-colors"
|
||||
>
|
||||
{value}
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</Link>
|
||||
</TableCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (col.numeric) {
|
||||
const percentage = (value / maxValues[col.key]) * 100;
|
||||
return (
|
||||
<TableCell key={col.key} className="text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<span className="font-semibold min-w-12">{value}</span>
|
||||
<Progress value={percentage} className="h-2 w-24" />
|
||||
</div>
|
||||
</TableCell>
|
||||
);
|
||||
}
|
||||
|
||||
return <TableCell key={col.key}>{value}</TableCell>;
|
||||
})}
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
124
src/components/admin/database-stats/DataQualityOverview.tsx
Normal file
124
src/components/admin/database-stats/DataQualityOverview.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ArrowRight, CheckCircle2, AlertCircle } from 'lucide-react';
|
||||
import { useDataCompleteness } from '@/hooks/useDataCompleteness';
|
||||
|
||||
export function DataQualityOverview() {
|
||||
const { data, isLoading } = useDataCompleteness();
|
||||
|
||||
if (isLoading || !data) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Data Quality</CardTitle>
|
||||
<CardDescription>Loading completeness metrics...</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-20 bg-muted rounded" />
|
||||
<div className="h-20 bg-muted rounded" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const { summary } = data;
|
||||
const avgScore = Math.round(summary.avg_completeness_score);
|
||||
|
||||
const getScoreColor = (score: number) => {
|
||||
if (score >= 80) return 'text-green-600';
|
||||
if (score >= 60) return 'text-blue-600';
|
||||
if (score >= 40) return 'text-yellow-600';
|
||||
return 'text-red-600';
|
||||
};
|
||||
|
||||
const getProgressColor = (score: number) => {
|
||||
if (score >= 80) return 'bg-green-600';
|
||||
if (score >= 60) return 'bg-blue-600';
|
||||
if (score >= 40) return 'bg-yellow-600';
|
||||
return 'bg-red-600';
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Data Quality</CardTitle>
|
||||
<CardDescription>Overall completeness metrics across all entities</CardDescription>
|
||||
</div>
|
||||
<Link
|
||||
to="/admin/data-completeness"
|
||||
className="text-sm text-primary hover:text-primary/80 flex items-center gap-1"
|
||||
>
|
||||
View Details <ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Average Score */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium">Average Completeness</span>
|
||||
<span className={`text-3xl font-bold ${getScoreColor(avgScore)}`}>
|
||||
{avgScore}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Progress value={avgScore} className="h-3" />
|
||||
<div
|
||||
className={`absolute inset-0 rounded-full ${getProgressColor(avgScore)} transition-all`}
|
||||
style={{ width: `${avgScore}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Stats Grid */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
||||
<span className="text-sm font-medium">100% Complete</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold">{summary.entities_100_complete}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{((summary.entities_100_complete / summary.total_entities) * 100).toFixed(1)}% of total
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="h-4 w-4 text-yellow-600" />
|
||||
<span className="text-sm font-medium">Below 50%</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold">{summary.entities_below_50}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{((summary.entities_below_50 / summary.total_entities) * 100).toFixed(1)}% need attention
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* By Entity Type */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">By Entity Type</h4>
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
{ label: 'Parks', value: summary.by_entity_type.parks, total: summary.total_entities },
|
||||
{ label: 'Rides', value: summary.by_entity_type.rides, total: summary.total_entities },
|
||||
{ label: 'Companies', value: summary.by_entity_type.companies, total: summary.total_entities },
|
||||
{ label: 'Models', value: summary.by_entity_type.ride_models, total: summary.total_entities },
|
||||
].map((item) => (
|
||||
<div key={item.label} className="flex items-center gap-2">
|
||||
<span className="text-xs w-20">{item.label}</span>
|
||||
<Progress value={(item.value / item.total) * 100} className="h-2 flex-1" />
|
||||
<span className="text-xs text-muted-foreground w-12 text-right">{item.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
159
src/components/admin/database-stats/DatabaseHealthDashboard.tsx
Normal file
159
src/components/admin/database-stats/DatabaseHealthDashboard.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { useDatabaseHealthCheck } from '@/hooks/useDatabaseHealthCheck';
|
||||
import { AlertCircle, AlertTriangle, Info, CheckCircle2 } from 'lucide-react';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { HealthIssueCard } from './HealthIssueCard';
|
||||
import { Accordion } from '@/components/ui/accordion';
|
||||
|
||||
export function DatabaseHealthDashboard() {
|
||||
const { data, isLoading } = useDatabaseHealthCheck();
|
||||
|
||||
if (isLoading || !data) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Database Health</CardTitle>
|
||||
<CardDescription>Loading health checks...</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-32 bg-muted rounded" />
|
||||
<div className="h-64 bg-muted rounded" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const { overall_score, critical_issues, warning_issues, info_issues, issues } = data;
|
||||
|
||||
const getScoreColor = (score: number) => {
|
||||
if (score >= 80) return 'text-green-600';
|
||||
if (score >= 60) return 'text-yellow-600';
|
||||
if (score >= 40) return 'text-orange-600';
|
||||
return 'text-red-600';
|
||||
};
|
||||
|
||||
const getScoreBackground = (score: number) => {
|
||||
if (score >= 80) return 'bg-green-600';
|
||||
if (score >= 60) return 'bg-yellow-600';
|
||||
if (score >= 40) return 'bg-orange-600';
|
||||
return 'bg-red-600';
|
||||
};
|
||||
|
||||
const criticalIssues = issues.filter(i => i.severity === 'critical');
|
||||
const warningIssues = issues.filter(i => i.severity === 'warning');
|
||||
const infoIssues = issues.filter(i => i.severity === 'info');
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Database Health</CardTitle>
|
||||
<CardDescription>Automated health checks and data quality issues</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Overall Health Score */}
|
||||
<div className="flex items-center justify-between p-6 border rounded-lg bg-card">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">Overall Health Score</h3>
|
||||
<div className={`text-6xl font-bold ${getScoreColor(overall_score)}`}>
|
||||
{overall_score}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">Out of 100</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<AlertCircle className="h-5 w-5 text-red-600" />
|
||||
<span className="text-sm font-medium">Critical Issues:</span>
|
||||
<span className="text-lg font-bold">{critical_issues}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<AlertTriangle className="h-5 w-5 text-yellow-600" />
|
||||
<span className="text-sm font-medium">Warnings:</span>
|
||||
<span className="text-lg font-bold">{warning_issues}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Info className="h-5 w-5 text-blue-600" />
|
||||
<span className="text-sm font-medium">Info:</span>
|
||||
<span className="text-lg font-bold">{info_issues}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>Database Health</span>
|
||||
<span className={getScoreColor(overall_score)}>{overall_score}%</span>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Progress value={overall_score} className="h-3" />
|
||||
<div
|
||||
className={`absolute inset-0 rounded-full ${getScoreBackground(overall_score)} transition-all`}
|
||||
style={{ width: `${overall_score}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Issues List */}
|
||||
{issues.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<CheckCircle2 className="h-16 w-16 text-green-600 mx-auto mb-4" />
|
||||
<h3 className="text-xl font-semibold mb-2">All Systems Healthy!</h3>
|
||||
<p className="text-muted-foreground">
|
||||
No database health issues detected at this time.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* Critical Issues */}
|
||||
{criticalIssues.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-semibold text-red-600 flex items-center gap-2">
|
||||
<AlertCircle className="h-5 w-5" />
|
||||
Critical Issues ({criticalIssues.length})
|
||||
</h3>
|
||||
<Accordion type="multiple" className="space-y-2">
|
||||
{criticalIssues.map((issue, index) => (
|
||||
<HealthIssueCard key={index} issue={issue} />
|
||||
))}
|
||||
</Accordion>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Warnings */}
|
||||
{warningIssues.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-semibold text-yellow-600 flex items-center gap-2">
|
||||
<AlertTriangle className="h-5 w-5" />
|
||||
Warnings ({warningIssues.length})
|
||||
</h3>
|
||||
<Accordion type="multiple" className="space-y-2">
|
||||
{warningIssues.map((issue, index) => (
|
||||
<HealthIssueCard key={index} issue={issue} />
|
||||
))}
|
||||
</Accordion>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info */}
|
||||
{infoIssues.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-semibold text-blue-600 flex items-center gap-2">
|
||||
<Info className="h-5 w-5" />
|
||||
Information ({infoIssues.length})
|
||||
</h3>
|
||||
<Accordion type="multiple" className="space-y-2">
|
||||
{infoIssues.map((issue, index) => (
|
||||
<HealthIssueCard key={index} issue={issue} />
|
||||
))}
|
||||
</Accordion>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
45
src/components/admin/database-stats/DatabaseStatsCard.tsx
Normal file
45
src/components/admin/database-stats/DatabaseStatsCard.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface DatabaseStatsCardProps {
|
||||
title: string;
|
||||
icon: LucideIcon;
|
||||
stats: Array<{
|
||||
label: string;
|
||||
value: number | string;
|
||||
trend?: {
|
||||
value: number;
|
||||
period: string;
|
||||
};
|
||||
}>;
|
||||
iconClassName?: string;
|
||||
}
|
||||
|
||||
export function DatabaseStatsCard({ title, icon: Icon, stats, iconClassName }: DatabaseStatsCardProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">{title}</CardTitle>
|
||||
<Icon className={cn("h-4 w-4 text-muted-foreground", iconClassName)} />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{stats.map((stat, index) => (
|
||||
<div key={index} className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">{stat.label}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold">{stat.value.toLocaleString()}</span>
|
||||
{stat.trend && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
+{stat.trend.value} ({stat.trend.period})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { useEntityComparisons } from '@/hooks/useEntityComparisons';
|
||||
import { ComparisonTable } from './ComparisonTable';
|
||||
import { Building2, Factory, Users, Pencil, Image as ImageIcon } from 'lucide-react';
|
||||
|
||||
export function EntityComparisonDashboard() {
|
||||
const { data, isLoading } = useEntityComparisons();
|
||||
|
||||
if (isLoading || !data) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Entity Comparisons</CardTitle>
|
||||
<CardDescription>Loading comparison data...</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-64 bg-muted rounded" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Entity Comparisons</CardTitle>
|
||||
<CardDescription>Top entities by content volume</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Tabs defaultValue="parks-rides" className="space-y-4">
|
||||
<TabsList className="grid w-full grid-cols-5">
|
||||
<TabsTrigger value="parks-rides">
|
||||
<Building2 className="h-4 w-4 mr-2" />
|
||||
Parks
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="manufacturers">
|
||||
<Factory className="h-4 w-4 mr-2" />
|
||||
Manufacturers
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="operators">
|
||||
<Users className="h-4 w-4 mr-2" />
|
||||
Operators
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="designers">
|
||||
<Pencil className="h-4 w-4 mr-2" />
|
||||
Designers
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="photos">
|
||||
<ImageIcon className="h-4 w-4 mr-2" />
|
||||
Photos
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="parks-rides" className="space-y-4">
|
||||
<ComparisonTable
|
||||
title="Top Parks by Ride Count"
|
||||
data={data.top_parks_by_rides}
|
||||
columns={[
|
||||
{ key: 'park_name', label: 'Park Name', linkBase: '/parks' },
|
||||
{ key: 'ride_count', label: 'Rides', numeric: true },
|
||||
{ key: 'photo_count', label: 'Photos', numeric: true },
|
||||
]}
|
||||
slugKey="park_slug"
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="manufacturers" className="space-y-4">
|
||||
<ComparisonTable
|
||||
title="Top Manufacturers"
|
||||
data={data.top_manufacturers}
|
||||
columns={[
|
||||
{ key: 'manufacturer_name', label: 'Manufacturer', linkBase: '/manufacturers' },
|
||||
{ key: 'ride_count', label: 'Rides', numeric: true },
|
||||
{ key: 'model_count', label: 'Models', numeric: true },
|
||||
]}
|
||||
slugKey="slug"
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="operators" className="space-y-4">
|
||||
<ComparisonTable
|
||||
title="Top Operators"
|
||||
data={data.top_operators}
|
||||
columns={[
|
||||
{ key: 'operator_name', label: 'Operator', linkBase: '/operators' },
|
||||
{ key: 'park_count', label: 'Parks', numeric: true },
|
||||
{ key: 'ride_count', label: 'Total Rides', numeric: true },
|
||||
]}
|
||||
slugKey="slug"
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="designers" className="space-y-4">
|
||||
<ComparisonTable
|
||||
title="Top Designers"
|
||||
data={data.top_designers}
|
||||
columns={[
|
||||
{ key: 'designer_name', label: 'Designer', linkBase: '/designers' },
|
||||
{ key: 'ride_count', label: 'Rides', numeric: true },
|
||||
]}
|
||||
slugKey="slug"
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="photos" className="space-y-4">
|
||||
<div className="space-y-6">
|
||||
<ComparisonTable
|
||||
title="Top Parks by Photo Count"
|
||||
data={data.top_parks_by_photos}
|
||||
columns={[
|
||||
{ key: 'park_name', label: 'Park Name', linkBase: '/parks' },
|
||||
{ key: 'photo_count', label: 'Photos', numeric: true },
|
||||
]}
|
||||
slugKey="park_slug"
|
||||
/>
|
||||
|
||||
<ComparisonTable
|
||||
title="Top Rides by Photo Count"
|
||||
data={data.top_rides_by_photos}
|
||||
columns={[
|
||||
{ key: 'ride_name', label: 'Ride Name', linkBase: '/parks' },
|
||||
{ key: 'photo_count', label: 'Photos', numeric: true },
|
||||
]}
|
||||
slugKey="ride_slug"
|
||||
parkSlugKey="park_slug"
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
204
src/components/admin/database-stats/GrowthTrendsChart.tsx
Normal file
204
src/components/admin/database-stats/GrowthTrendsChart.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
import { useState } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useGrowthTrends } from '@/hooks/useGrowthTrends';
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
|
||||
import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart';
|
||||
import type { GranularityType } from '@/types/database-analytics';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
const chartConfig = {
|
||||
parks_added: {
|
||||
label: "Parks",
|
||||
color: "hsl(var(--chart-1))",
|
||||
},
|
||||
rides_added: {
|
||||
label: "Rides",
|
||||
color: "hsl(var(--chart-2))",
|
||||
},
|
||||
companies_added: {
|
||||
label: "Companies",
|
||||
color: "hsl(var(--chart-3))",
|
||||
},
|
||||
ride_models_added: {
|
||||
label: "Models",
|
||||
color: "hsl(var(--chart-4))",
|
||||
},
|
||||
photos_added: {
|
||||
label: "Photos",
|
||||
color: "hsl(var(--chart-5))",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export function GrowthTrendsChart() {
|
||||
const [timeRange, setTimeRange] = useState<number>(90);
|
||||
const [granularity, setGranularity] = useState<GranularityType>('daily');
|
||||
const [activeLines, setActiveLines] = useState({
|
||||
parks_added: true,
|
||||
rides_added: true,
|
||||
companies_added: true,
|
||||
ride_models_added: true,
|
||||
photos_added: true,
|
||||
});
|
||||
|
||||
const { data, isLoading } = useGrowthTrends(timeRange, granularity);
|
||||
|
||||
const toggleLine = (key: keyof typeof activeLines) => {
|
||||
setActiveLines(prev => ({ ...prev, [key]: !prev[key] }));
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Growth Trends</CardTitle>
|
||||
<CardDescription>Loading growth data...</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-80 bg-muted rounded animate-pulse" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const formattedData = data?.map(point => ({
|
||||
...point,
|
||||
date: format(new Date(point.period), granularity === 'daily' ? 'MMM dd' : granularity === 'weekly' ? 'MMM dd' : 'MMM yyyy'),
|
||||
})) || [];
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||
<div>
|
||||
<CardTitle>Growth Trends</CardTitle>
|
||||
<CardDescription>Entity additions over time</CardDescription>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{/* Time Range Controls */}
|
||||
<div className="flex gap-1">
|
||||
{[
|
||||
{ label: '7D', days: 7 },
|
||||
{ label: '30D', days: 30 },
|
||||
{ label: '90D', days: 90 },
|
||||
{ label: '1Y', days: 365 },
|
||||
].map(({ label, days }) => (
|
||||
<Button
|
||||
key={label}
|
||||
variant={timeRange === days ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setTimeRange(days)}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Granularity Controls */}
|
||||
<div className="flex gap-1">
|
||||
{(['daily', 'weekly', 'monthly'] as GranularityType[]).map((g) => (
|
||||
<Button
|
||||
key={g}
|
||||
variant={granularity === g ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setGranularity(g)}
|
||||
className="capitalize"
|
||||
>
|
||||
{g}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
{/* Entity Type Toggles */}
|
||||
<div className="flex gap-2 mb-4 flex-wrap">
|
||||
{Object.entries(chartConfig).map(([key, config]) => (
|
||||
<Button
|
||||
key={key}
|
||||
variant={activeLines[key as keyof typeof activeLines] ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => toggleLine(key as keyof typeof activeLines)}
|
||||
>
|
||||
{config.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Chart */}
|
||||
<ChartContainer config={chartConfig} className="h-80">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={formattedData}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
className="text-xs"
|
||||
tick={{ fill: 'hsl(var(--muted-foreground))' }}
|
||||
/>
|
||||
<YAxis
|
||||
className="text-xs"
|
||||
tick={{ fill: 'hsl(var(--muted-foreground))' }}
|
||||
/>
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
<Legend />
|
||||
|
||||
{activeLines.parks_added && (
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="parks_added"
|
||||
stroke={chartConfig.parks_added.color}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
name={chartConfig.parks_added.label}
|
||||
/>
|
||||
)}
|
||||
{activeLines.rides_added && (
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="rides_added"
|
||||
stroke={chartConfig.rides_added.color}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
name={chartConfig.rides_added.label}
|
||||
/>
|
||||
)}
|
||||
{activeLines.companies_added && (
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="companies_added"
|
||||
stroke={chartConfig.companies_added.color}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
name={chartConfig.companies_added.label}
|
||||
/>
|
||||
)}
|
||||
{activeLines.ride_models_added && (
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="ride_models_added"
|
||||
stroke={chartConfig.ride_models_added.color}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
name={chartConfig.ride_models_added.label}
|
||||
/>
|
||||
)}
|
||||
{activeLines.photos_added && (
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="photos_added"
|
||||
stroke={chartConfig.photos_added.color}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
name={chartConfig.photos_added.label}
|
||||
/>
|
||||
)}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
110
src/components/admin/database-stats/HealthIssueCard.tsx
Normal file
110
src/components/admin/database-stats/HealthIssueCard.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { HealthIssue } from '@/types/database-analytics';
|
||||
import { AlertCircle, AlertTriangle, Info, Lightbulb } from 'lucide-react';
|
||||
|
||||
interface HealthIssueCardProps {
|
||||
issue: HealthIssue;
|
||||
}
|
||||
|
||||
export function HealthIssueCard({ issue }: HealthIssueCardProps) {
|
||||
const getSeverityIcon = () => {
|
||||
switch (issue.severity) {
|
||||
case 'critical':
|
||||
return <AlertCircle className="h-4 w-4 text-red-600" />;
|
||||
case 'warning':
|
||||
return <AlertTriangle className="h-4 w-4 text-yellow-600" />;
|
||||
case 'info':
|
||||
return <Info className="h-4 w-4 text-blue-600" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getSeverityColor = () => {
|
||||
switch (issue.severity) {
|
||||
case 'critical':
|
||||
return 'border-red-600 bg-red-50 dark:bg-red-950/20';
|
||||
case 'warning':
|
||||
return 'border-yellow-600 bg-yellow-50 dark:bg-yellow-950/20';
|
||||
case 'info':
|
||||
return 'border-blue-600 bg-blue-50 dark:bg-blue-950/20';
|
||||
}
|
||||
};
|
||||
|
||||
const getSeverityBadgeVariant = () => {
|
||||
switch (issue.severity) {
|
||||
case 'critical':
|
||||
return 'destructive';
|
||||
case 'warning':
|
||||
return 'default';
|
||||
case 'info':
|
||||
return 'secondary';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AccordionItem
|
||||
value={`issue-${issue.category}-${issue.count}`}
|
||||
className={`border rounded-lg ${getSeverityColor()}`}
|
||||
>
|
||||
<AccordionTrigger className="px-4 hover:no-underline">
|
||||
<div className="flex items-center justify-between w-full pr-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{getSeverityIcon()}
|
||||
<div className="text-left">
|
||||
<div className="font-semibold">{issue.description}</div>
|
||||
<div className="text-sm text-muted-foreground capitalize">
|
||||
{issue.category.replace(/_/g, ' ')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={getSeverityBadgeVariant()}>
|
||||
{issue.count} {issue.count === 1 ? 'entity' : 'entities'}
|
||||
</Badge>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
|
||||
<AccordionContent className="px-4 pb-4 space-y-4">
|
||||
{/* Suggested Action */}
|
||||
<div className="flex items-start gap-2 p-3 bg-background rounded border">
|
||||
<Lightbulb className="h-4 w-4 text-yellow-600 mt-0.5 flex-shrink-0" />
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-medium">Suggested Action</div>
|
||||
<div className="text-sm text-muted-foreground">{issue.suggested_action}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Entity IDs (first 10) */}
|
||||
{issue.entity_ids && issue.entity_ids.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">
|
||||
Affected Entities ({issue.entity_ids.length})
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{issue.entity_ids.slice(0, 10).map((id) => (
|
||||
<Badge key={id} variant="outline" className="font-mono text-xs">
|
||||
{id.substring(0, 8)}...
|
||||
</Badge>
|
||||
))}
|
||||
{issue.entity_ids.length > 10 && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
+{issue.entity_ids.length - 10} more
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="default">
|
||||
View Entities
|
||||
</Button>
|
||||
<Button size="sm" variant="outline">
|
||||
Export List
|
||||
</Button>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
);
|
||||
}
|
||||
221
src/components/admin/database-stats/RecentAdditionsTable.tsx
Normal file
221
src/components/admin/database-stats/RecentAdditionsTable.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import {
|
||||
Building2,
|
||||
Bike,
|
||||
Factory,
|
||||
Box,
|
||||
MapPin,
|
||||
Calendar,
|
||||
Image,
|
||||
Download,
|
||||
Search
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import type { RecentAddition } from '@/types/database-stats';
|
||||
|
||||
interface RecentAdditionsTableProps {
|
||||
additions: RecentAddition[];
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
const entityTypeConfig = {
|
||||
park: { icon: Building2, label: 'Park', color: 'bg-blue-500' },
|
||||
ride: { icon: Bike, label: 'Ride', color: 'bg-purple-500' },
|
||||
company: { icon: Factory, label: 'Company', color: 'bg-orange-500' },
|
||||
ride_model: { icon: Box, label: 'Model', color: 'bg-green-500' },
|
||||
location: { icon: MapPin, label: 'Location', color: 'bg-yellow-500' },
|
||||
timeline_event: { icon: Calendar, label: 'Event', color: 'bg-pink-500' },
|
||||
photo: { icon: Image, label: 'Photo', color: 'bg-teal-500' },
|
||||
};
|
||||
|
||||
export function RecentAdditionsTable({ additions, isLoading }: RecentAdditionsTableProps) {
|
||||
const [entityTypeFilter, setEntityTypeFilter] = useState<string>('all');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
const filteredAdditions = useMemo(() => {
|
||||
let filtered = additions;
|
||||
|
||||
if (entityTypeFilter !== 'all') {
|
||||
filtered = filtered.filter(item => item.entity_type === entityTypeFilter);
|
||||
}
|
||||
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
filtered = filtered.filter(item =>
|
||||
item.entity_name.toLowerCase().includes(query) ||
|
||||
item.created_by_username?.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}, [additions, entityTypeFilter, searchQuery]);
|
||||
|
||||
const exportToCSV = () => {
|
||||
const headers = ['Type', 'Name', 'Added By', 'Added At'];
|
||||
const rows = filteredAdditions.map(item => [
|
||||
entityTypeConfig[item.entity_type].label,
|
||||
item.entity_name,
|
||||
item.created_by_username || 'System',
|
||||
new Date(item.created_at).toISOString(),
|
||||
]);
|
||||
|
||||
const csv = [headers, ...rows].map(row => row.join(',')).join('\n');
|
||||
const blob = new Blob([csv], { type: 'text/csv' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `recent-additions-${new Date().toISOString()}.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const getEntityLink = (item: RecentAddition) => {
|
||||
if (item.entity_type === 'park' && item.entity_slug) {
|
||||
return `/parks/${item.entity_slug}`;
|
||||
}
|
||||
if (item.entity_type === 'ride' && item.park_slug && item.entity_slug) {
|
||||
return `/parks/${item.park_slug}/rides/${item.entity_slug}`;
|
||||
}
|
||||
if (item.entity_type === 'company' && item.entity_slug) {
|
||||
return `/manufacturers/${item.entity_slug}`;
|
||||
}
|
||||
if (item.entity_type === 'ride_model' && item.entity_slug) {
|
||||
return `/models/${item.entity_slug}`;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Latest Additions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<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>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>Latest Additions (Newest First)</CardTitle>
|
||||
<Button onClick={exportToCSV} variant="outline" size="sm">
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Export CSV
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col sm:flex-row gap-4 mb-6">
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search by name or creator..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Select value={entityTypeFilter} onValueChange={setEntityTypeFilter}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Filter by type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Types</SelectItem>
|
||||
<SelectItem value="park">Parks</SelectItem>
|
||||
<SelectItem value="ride">Rides</SelectItem>
|
||||
<SelectItem value="company">Companies</SelectItem>
|
||||
<SelectItem value="ride_model">Ride Models</SelectItem>
|
||||
<SelectItem value="location">Locations</SelectItem>
|
||||
<SelectItem value="timeline_event">Timeline Events</SelectItem>
|
||||
<SelectItem value="photo">Photos</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{filteredAdditions.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
No additions found matching your filters.
|
||||
</div>
|
||||
) : (
|
||||
filteredAdditions.map((item) => {
|
||||
const config = entityTypeConfig[item.entity_type];
|
||||
const Icon = config.icon;
|
||||
const link = getEntityLink(item);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${item.entity_type}-${item.entity_id}`}
|
||||
className="flex items-center gap-4 p-4 rounded-lg border bg-card hover:bg-accent/50 transition-colors"
|
||||
>
|
||||
<div className={`p-2 rounded-lg ${config.color} bg-opacity-10`}>
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
|
||||
{item.image_url && (
|
||||
<img
|
||||
src={item.image_url}
|
||||
alt={item.entity_name}
|
||||
className="h-12 w-12 rounded object-cover"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{config.label}
|
||||
</Badge>
|
||||
{link ? (
|
||||
<Link
|
||||
to={link}
|
||||
className="font-medium text-sm hover:underline truncate"
|
||||
>
|
||||
{item.entity_name}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="font-medium text-sm truncate">
|
||||
{item.entity_name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
{item.created_by_username ? (
|
||||
<>
|
||||
<Avatar className="h-4 w-4">
|
||||
<AvatarImage src={item.created_by_avatar || undefined} />
|
||||
<AvatarFallback className="text-[8px]">
|
||||
{item.created_by_username[0].toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span>@{item.created_by_username}</span>
|
||||
</>
|
||||
) : (
|
||||
<span>System</span>
|
||||
)}
|
||||
<span>•</span>
|
||||
<span>{formatDistanceToNow(new Date(item.created_at), { addSuffix: true })}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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 />
|
||||
|
||||
@@ -115,6 +115,21 @@ export function TOTPSetup() {
|
||||
|
||||
if (verifyError) throw verifyError;
|
||||
|
||||
// Log MFA enrollment to audit trail
|
||||
try {
|
||||
const { logAdminAction } = await import('@/lib/adminActionAuditHelpers');
|
||||
await logAdminAction(
|
||||
'mfa_enabled',
|
||||
{
|
||||
factor_id: factorId,
|
||||
factor_type: 'totp',
|
||||
friendly_name: 'Authenticator App',
|
||||
}
|
||||
);
|
||||
} catch (auditError) {
|
||||
// Non-critical - don't fail enrollment if audit logging fails
|
||||
}
|
||||
|
||||
// Check if user signed in via OAuth and trigger step-up flow
|
||||
const authMethod = getAuthMethod();
|
||||
const isOAuthUser = authMethod === 'oauth';
|
||||
|
||||
173
src/components/contributors/AchievementBadge.tsx
Normal file
173
src/components/contributors/AchievementBadge.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import {
|
||||
Award,
|
||||
Camera,
|
||||
Edit,
|
||||
MapPin,
|
||||
MessageSquare,
|
||||
Sparkles,
|
||||
Trophy,
|
||||
Crown,
|
||||
Shield
|
||||
} from 'lucide-react';
|
||||
import type { AchievementLevel, SpecialBadge } from '@/types/contributor';
|
||||
|
||||
interface AchievementBadgeProps {
|
||||
level: AchievementLevel;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
interface SpecialBadgeProps {
|
||||
badge: SpecialBadge;
|
||||
size?: 'sm' | 'md';
|
||||
}
|
||||
|
||||
const achievementConfig: Record<AchievementLevel, {
|
||||
label: string;
|
||||
color: string;
|
||||
icon: React.ReactNode;
|
||||
description: string;
|
||||
}> = {
|
||||
legend: {
|
||||
label: 'Legend',
|
||||
color: 'bg-gradient-to-r from-purple-500 to-pink-500 text-white border-0',
|
||||
icon: <Crown className="w-3 h-3" />,
|
||||
description: '5000+ contribution points - An absolute legend!',
|
||||
},
|
||||
platinum: {
|
||||
label: 'Platinum',
|
||||
color: 'bg-gradient-to-r from-slate-300 to-slate-400 text-slate-900 border-0',
|
||||
icon: <Trophy className="w-3 h-3" />,
|
||||
description: '1000+ contribution points - Elite contributor',
|
||||
},
|
||||
gold: {
|
||||
label: 'Gold',
|
||||
color: 'bg-gradient-to-r from-yellow-400 to-yellow-500 text-yellow-900 border-0',
|
||||
icon: <Award className="w-3 h-3" />,
|
||||
description: '500+ contribution points - Outstanding work!',
|
||||
},
|
||||
silver: {
|
||||
label: 'Silver',
|
||||
color: 'bg-gradient-to-r from-gray-300 to-gray-400 text-gray-800 border-0',
|
||||
icon: <Award className="w-3 h-3" />,
|
||||
description: '100+ contribution points - Great contributor',
|
||||
},
|
||||
bronze: {
|
||||
label: 'Bronze',
|
||||
color: 'bg-gradient-to-r from-orange-400 to-orange-500 text-orange-900 border-0',
|
||||
icon: <Award className="w-3 h-3" />,
|
||||
description: '10+ contribution points - Getting started!',
|
||||
},
|
||||
newcomer: {
|
||||
label: 'Newcomer',
|
||||
color: 'bg-muted text-muted-foreground',
|
||||
icon: <Sparkles className="w-3 h-3" />,
|
||||
description: 'Just getting started',
|
||||
},
|
||||
};
|
||||
|
||||
const specialBadgeConfig: Record<SpecialBadge, {
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
description: string;
|
||||
color: string;
|
||||
}> = {
|
||||
park_explorer: {
|
||||
label: 'Park Explorer',
|
||||
icon: <MapPin className="w-3 h-3" />,
|
||||
description: 'Added 100+ parks to the database',
|
||||
color: 'bg-green-500/10 text-green-700 dark:text-green-400 border-green-500/20',
|
||||
},
|
||||
ride_master: {
|
||||
label: 'Ride Master',
|
||||
icon: <Sparkles className="w-3 h-3" />,
|
||||
description: 'Added 200+ rides to the database',
|
||||
color: 'bg-blue-500/10 text-blue-700 dark:text-blue-400 border-blue-500/20',
|
||||
},
|
||||
photographer: {
|
||||
label: 'Photographer',
|
||||
icon: <Camera className="w-3 h-3" />,
|
||||
description: 'Uploaded 500+ photos',
|
||||
color: 'bg-purple-500/10 text-purple-700 dark:text-purple-400 border-purple-500/20',
|
||||
},
|
||||
critic: {
|
||||
label: 'Critic',
|
||||
icon: <MessageSquare className="w-3 h-3" />,
|
||||
description: 'Wrote 100+ reviews',
|
||||
color: 'bg-orange-500/10 text-orange-700 dark:text-orange-400 border-orange-500/20',
|
||||
},
|
||||
editor: {
|
||||
label: 'Editor',
|
||||
icon: <Edit className="w-3 h-3" />,
|
||||
description: 'Made 500+ edits to existing entries',
|
||||
color: 'bg-cyan-500/10 text-cyan-700 dark:text-cyan-400 border-cyan-500/20',
|
||||
},
|
||||
completionist: {
|
||||
label: 'Completionist',
|
||||
icon: <Shield className="w-3 h-3" />,
|
||||
description: 'Contributed across all content types',
|
||||
color: 'bg-indigo-500/10 text-indigo-700 dark:text-indigo-400 border-indigo-500/20',
|
||||
},
|
||||
veteran: {
|
||||
label: 'Veteran',
|
||||
icon: <Award className="w-3 h-3" />,
|
||||
description: 'Member for over 1 year',
|
||||
color: 'bg-amber-500/10 text-amber-700 dark:text-amber-400 border-amber-500/20',
|
||||
},
|
||||
top_contributor: {
|
||||
label: 'Top Contributor',
|
||||
icon: <Crown className="w-3 h-3" />,
|
||||
description: 'Ranked #1 contributor',
|
||||
color: 'bg-pink-500/10 text-pink-700 dark:text-pink-400 border-pink-500/20',
|
||||
},
|
||||
};
|
||||
|
||||
export function AchievementBadge({ level, size = 'md' }: AchievementBadgeProps) {
|
||||
const config = achievementConfig[level];
|
||||
const sizeClasses = {
|
||||
sm: 'text-xs px-2 py-0.5',
|
||||
md: 'text-sm px-2.5 py-0.5',
|
||||
lg: 'text-base px-3 py-1',
|
||||
};
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge className={`${config.color} ${sizeClasses[size]} gap-1`}>
|
||||
{config.icon}
|
||||
{config.label}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{config.description}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export function SpecialBadge({ badge, size = 'sm' }: SpecialBadgeProps) {
|
||||
const config = specialBadgeConfig[badge];
|
||||
const sizeClasses = {
|
||||
sm: 'text-xs px-2 py-0.5',
|
||||
md: 'text-sm px-2.5 py-0.5',
|
||||
};
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge variant="outline" className={`${config.color} ${sizeClasses[size]} gap-1`}>
|
||||
{config.icon}
|
||||
{config.label}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{config.description}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
172
src/components/contributors/ContributorLeaderboard.tsx
Normal file
172
src/components/contributors/ContributorLeaderboard.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
import { useState } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { useContributorLeaderboard } from '@/hooks/useContributorLeaderboard';
|
||||
import { LeaderboardEntry } from './LeaderboardEntry';
|
||||
import { TimePeriod } from '@/types/contributor';
|
||||
import { Trophy, TrendingUp, Users, AlertCircle } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
export function ContributorLeaderboard() {
|
||||
const [timePeriod, setTimePeriod] = useState<TimePeriod>('all_time');
|
||||
const [limit, setLimit] = useState(50);
|
||||
|
||||
const { data, isLoading, error } = useContributorLeaderboard(limit, timePeriod);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
Failed to load contributor leaderboard. Please try again later.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2 text-2xl">
|
||||
<Trophy className="w-6 h-6 text-yellow-500" />
|
||||
Contributor Leaderboard
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Celebrating our amazing contributors who make ThrillWiki possible
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant="secondary" className="text-lg px-4 py-2">
|
||||
<Users className="w-4 h-4 mr-2" />
|
||||
{data?.total_contributors.toLocaleString() || 0} Contributors
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
{/* Time Period Filter */}
|
||||
<div className="flex-1">
|
||||
<label className="text-sm font-medium mb-2 block">Time Period</label>
|
||||
<Select value={timePeriod} onValueChange={(value) => setTimePeriod(value as TimePeriod)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all_time">
|
||||
<div className="flex items-center gap-2">
|
||||
<Trophy className="w-4 h-4" />
|
||||
All Time
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="month">
|
||||
<div className="flex items-center gap-2">
|
||||
<TrendingUp className="w-4 h-4" />
|
||||
This Month
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="week">
|
||||
<div className="flex items-center gap-2">
|
||||
<TrendingUp className="w-4 h-4" />
|
||||
This Week
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Limit Filter */}
|
||||
<div className="flex-1">
|
||||
<label className="text-sm font-medium mb-2 block">Show Top</label>
|
||||
<Select value={limit.toString()} onValueChange={(value) => setLimit(parseInt(value))}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="10">Top 10</SelectItem>
|
||||
<SelectItem value="25">Top 25</SelectItem>
|
||||
<SelectItem value="50">Top 50</SelectItem>
|
||||
<SelectItem value="100">Top 100</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Achievement Legend */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Achievement Levels</CardTitle>
|
||||
<CardDescription>
|
||||
Contribution points are calculated based on approved submissions: Parks (10 pts), Rides (8 pts), Companies (5 pts), Models (5 pts), Reviews (3 pts), Photos (2 pts), Edits (1 pt)
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
|
||||
<AchievementInfo level="Legend" points="5000+" color="bg-gradient-to-r from-purple-500 to-pink-500" />
|
||||
<AchievementInfo level="Platinum" points="1000+" color="bg-gradient-to-r from-slate-300 to-slate-400" />
|
||||
<AchievementInfo level="Gold" points="500+" color="bg-gradient-to-r from-yellow-400 to-yellow-500" />
|
||||
<AchievementInfo level="Silver" points="100+" color="bg-gradient-to-r from-gray-300 to-gray-400" />
|
||||
<AchievementInfo level="Bronze" points="10+" color="bg-gradient-to-r from-orange-400 to-orange-500" />
|
||||
<AchievementInfo level="Newcomer" points="0-9" color="bg-muted" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Leaderboard */}
|
||||
{isLoading ? (
|
||||
<div className="space-y-4">
|
||||
{[...Array(10)].map((_, i) => (
|
||||
<Card key={i} className="p-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<Skeleton className="w-[60px] h-[60px] rounded-lg" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-6 w-1/3" />
|
||||
<Skeleton className="h-4 w-1/4" />
|
||||
<Skeleton className="h-20 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : data?.contributors && data.contributors.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{data.contributors.map((contributor) => (
|
||||
<LeaderboardEntry
|
||||
key={contributor.user_id}
|
||||
contributor={contributor}
|
||||
showPeriodStats={timePeriod !== 'all_time'}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center">
|
||||
<Trophy className="w-12 h-12 mx-auto mb-4 text-muted-foreground" />
|
||||
<h3 className="text-lg font-semibold mb-2">No Contributors Yet</h3>
|
||||
<p className="text-muted-foreground">
|
||||
Be the first to contribute to ThrillWiki!
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AchievementInfo({ level, points, color }: { level: string; points: string; color: string }) {
|
||||
return (
|
||||
<div className="text-center">
|
||||
<div className={`${color} rounded-lg p-3 mb-2`}>
|
||||
<Trophy className="w-6 h-6 mx-auto" />
|
||||
</div>
|
||||
<div className="text-sm font-semibold">{level}</div>
|
||||
<div className="text-xs text-muted-foreground">{points} pts</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
146
src/components/contributors/LeaderboardEntry.tsx
Normal file
146
src/components/contributors/LeaderboardEntry.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { LeaderboardContributor } from '@/types/contributor';
|
||||
import { AchievementBadge, SpecialBadge } from './AchievementBadge';
|
||||
import { Trophy, TrendingUp, Calendar } from 'lucide-react';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
|
||||
interface LeaderboardEntryProps {
|
||||
contributor: LeaderboardContributor;
|
||||
showPeriodStats?: boolean;
|
||||
}
|
||||
|
||||
export function LeaderboardEntry({ contributor, showPeriodStats = false }: LeaderboardEntryProps) {
|
||||
const periodStats = contributor.stats;
|
||||
const allTimeStats = contributor.total_stats;
|
||||
const totalContributions = showPeriodStats
|
||||
? contributor.contribution_score
|
||||
: contributor.total_score;
|
||||
|
||||
const getRankColor = (rank: number) => {
|
||||
if (rank === 1) return 'text-yellow-500';
|
||||
if (rank === 2) return 'text-gray-400';
|
||||
if (rank === 3) return 'text-orange-600';
|
||||
return 'text-muted-foreground';
|
||||
};
|
||||
|
||||
const getRankIcon = (rank: number) => {
|
||||
if (rank <= 3) {
|
||||
return <Trophy className={`w-6 h-6 ${getRankColor(rank)}`} />;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="p-4 hover:shadow-lg transition-shadow">
|
||||
<div className="flex items-start gap-4">
|
||||
{/* Rank */}
|
||||
<div className="flex flex-col items-center justify-center min-w-[60px]">
|
||||
{getRankIcon(contributor.rank)}
|
||||
<span className={`text-2xl font-bold ${getRankColor(contributor.rank)}`}>
|
||||
#{contributor.rank}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Avatar & Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start gap-3 mb-3">
|
||||
<Avatar className="w-12 h-12">
|
||||
<AvatarImage src={contributor.avatar_url || undefined} />
|
||||
<AvatarFallback>
|
||||
{(contributor.display_name || contributor.username).slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="font-semibold text-lg truncate">
|
||||
{contributor.display_name || contributor.username}
|
||||
</h3>
|
||||
<AchievementBadge level={contributor.achievement_level} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 text-sm text-muted-foreground mb-2">
|
||||
<Calendar className="w-3 h-3" />
|
||||
<span>
|
||||
Joined {formatDistanceToNow(new Date(contributor.join_date), { addSuffix: true })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Special Badges */}
|
||||
{contributor.special_badges.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mb-3">
|
||||
{contributor.special_badges.map((badge) => (
|
||||
<SpecialBadge key={badge} badge={badge} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
{showPeriodStats ? (
|
||||
<>
|
||||
{periodStats.parks_added > 0 && (
|
||||
<StatCard label="Parks" value={periodStats.parks_added} />
|
||||
)}
|
||||
{periodStats.rides_added > 0 && (
|
||||
<StatCard label="Rides" value={periodStats.rides_added} />
|
||||
)}
|
||||
{periodStats.photos_added > 0 && (
|
||||
<StatCard label="Photos" value={periodStats.photos_added} />
|
||||
)}
|
||||
{periodStats.reviews_added > 0 && (
|
||||
<StatCard label="Reviews" value={periodStats.reviews_added} />
|
||||
)}
|
||||
{periodStats.edits_made > 0 && (
|
||||
<StatCard label="Edits" value={periodStats.edits_made} />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{allTimeStats.total_parks > 0 && (
|
||||
<StatCard label="Parks" value={allTimeStats.total_parks} />
|
||||
)}
|
||||
{allTimeStats.total_rides > 0 && (
|
||||
<StatCard label="Rides" value={allTimeStats.total_rides} />
|
||||
)}
|
||||
{allTimeStats.total_photos > 0 && (
|
||||
<StatCard label="Photos" value={allTimeStats.total_photos} />
|
||||
)}
|
||||
{allTimeStats.total_reviews > 0 && (
|
||||
<StatCard label="Reviews" value={allTimeStats.total_reviews} />
|
||||
)}
|
||||
{allTimeStats.total_edits > 0 && (
|
||||
<StatCard label="Edits" value={allTimeStats.total_edits} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Total Score */}
|
||||
<div className="mt-3 pt-3 border-t flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<TrendingUp className="w-4 h-4" />
|
||||
<span>Contribution Score</span>
|
||||
</div>
|
||||
<Badge variant="secondary" className="text-base font-bold">
|
||||
{totalContributions.toLocaleString()} pts
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({ label, value }: { label: string; value: number }) {
|
||||
return (
|
||||
<div className="bg-muted/50 rounded-lg p-2 text-center">
|
||||
<div className="text-xs text-muted-foreground mb-1">{label}</div>
|
||||
<div className="text-lg font-bold">{value.toLocaleString()}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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 } 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';
|
||||
@@ -68,6 +68,17 @@ export function AdminSidebar() {
|
||||
url: '/admin/rate-limit-metrics',
|
||||
icon: Shield,
|
||||
},
|
||||
{
|
||||
title: 'Database Stats',
|
||||
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',
|
||||
@@ -129,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';
|
||||
@@ -262,7 +262,23 @@ export const ModerationQueue = forwardRef<ModerationQueueRef, ModerationQueuePro
|
||||
|
||||
// Superuser force release lock
|
||||
const handleSuperuserReleaseLock = useCallback(async (submissionId: string) => {
|
||||
// Fetch lock details before releasing
|
||||
const { data: submission } = await supabase
|
||||
.from('content_submissions')
|
||||
.select('assigned_to, locked_until')
|
||||
.eq('id', submissionId)
|
||||
.single();
|
||||
|
||||
await queueManager.queue.superuserReleaseLock(submissionId);
|
||||
|
||||
// Log to audit trail
|
||||
const { logAdminAction } = await import('@/lib/adminActionAuditHelpers');
|
||||
await logAdminAction('moderation_lock_force_released', {
|
||||
submission_id: submissionId,
|
||||
original_moderator_id: submission?.assigned_to,
|
||||
original_locked_until: submission?.locked_until,
|
||||
});
|
||||
|
||||
// Refresh locks count and queue
|
||||
setActiveLocksCount(prev => Math.max(0, prev - 1));
|
||||
queueManager.refresh();
|
||||
@@ -485,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}
|
||||
@@ -501,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>
|
||||
))}
|
||||
|
||||
|
||||
@@ -189,6 +189,15 @@ export function UserRoleManager() {
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
// Log to audit trail
|
||||
const { logAdminAction } = await import('@/lib/adminActionAuditHelpers');
|
||||
const targetUsername = searchResults.find(p => p.user_id === userId)?.username;
|
||||
await logAdminAction('role_granted', {
|
||||
target_user_id: userId,
|
||||
target_username: targetUsername,
|
||||
role: role,
|
||||
}, userId);
|
||||
|
||||
handleSuccess('Role Granted', `User has been granted ${getRoleLabel(role)} role`);
|
||||
setNewUserSearch('');
|
||||
setNewRole('');
|
||||
@@ -208,10 +217,23 @@ export function UserRoleManager() {
|
||||
if (!isAdmin()) return;
|
||||
setActionLoading(roleId);
|
||||
try {
|
||||
// Fetch role details before revoking
|
||||
const roleToRevoke = userRoles.find(r => r.id === roleId);
|
||||
|
||||
const {
|
||||
error
|
||||
} = await supabase.from('user_roles').delete().eq('id', roleId);
|
||||
if (error) throw error;
|
||||
|
||||
// Log to audit trail
|
||||
const { logAdminAction } = await import('@/lib/adminActionAuditHelpers');
|
||||
await logAdminAction('role_revoked', {
|
||||
role_id: roleId,
|
||||
target_user_id: roleToRevoke?.user_id,
|
||||
target_username: roleToRevoke?.profiles?.username,
|
||||
role: roleToRevoke?.role,
|
||||
}, roleToRevoke?.user_id);
|
||||
|
||||
handleSuccess('Role Revoked', 'User role has been revoked');
|
||||
fetchUserRoles();
|
||||
} catch (error: unknown) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { supabase } from '@/lib/supabaseClient';
|
||||
import { queryKeys } from '@/lib/queryKeys';
|
||||
import { toast } from 'sonner';
|
||||
import { breadcrumb } from '@/lib/errorBreadcrumbs';
|
||||
import type { GroupedAlert } from './useGroupedAlerts';
|
||||
|
||||
export function useResolveAlertGroup() {
|
||||
@@ -15,14 +16,43 @@ export function useResolveAlertGroup() {
|
||||
alertIds: string[];
|
||||
source: 'system' | 'rate_limit';
|
||||
}) => {
|
||||
console.log('🟢 Mutation function called', { alertIds, source });
|
||||
const table = source === 'system' ? 'system_alerts' : 'rate_limit_alerts';
|
||||
const { error } = await supabase
|
||||
|
||||
// Log breadcrumb for debugging
|
||||
breadcrumb.userAction(`resolve-alerts`, 'AlertGroupActions', {
|
||||
alertIds,
|
||||
source,
|
||||
count: alertIds.length,
|
||||
});
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from(table)
|
||||
.update({ resolved_at: new Date().toISOString() })
|
||||
.in('id', alertIds);
|
||||
.in('id', alertIds)
|
||||
.select();
|
||||
|
||||
if (error) throw error;
|
||||
return { count: alertIds.length };
|
||||
if (error) {
|
||||
// Enhanced error handling with specific messages
|
||||
if (error.code === '42501') {
|
||||
throw new Error('Permission denied. You do not have access to resolve these alerts.');
|
||||
} else if (error.code === 'PGRST116') {
|
||||
throw new Error('No alerts found to resolve. They may have already been resolved.');
|
||||
} else {
|
||||
console.error('Supabase error details:', error);
|
||||
throw new Error(`Failed to resolve alerts: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Log to audit trail
|
||||
const { logAdminAction } = await import('@/lib/adminActionAuditHelpers');
|
||||
await logAdminAction('alert_group_resolved', {
|
||||
alert_source: source,
|
||||
alert_count: alertIds.length,
|
||||
alert_ids: alertIds,
|
||||
});
|
||||
|
||||
return { count: alertIds.length, updatedAlerts: data };
|
||||
},
|
||||
onMutate: async ({ alertIds }) => {
|
||||
// Cancel any outgoing refetches
|
||||
@@ -60,7 +90,7 @@ export function useResolveAlertGroup() {
|
||||
onSuccess: (data) => {
|
||||
toast.success(`Resolved ${data.count} alert${data.count > 1 ? 's' : ''}`);
|
||||
},
|
||||
onError: (error, variables, context) => {
|
||||
onError: (error: Error, variables, context) => {
|
||||
// Rollback on error
|
||||
if (context?.previousData) {
|
||||
queryClient.setQueryData(
|
||||
@@ -68,8 +98,19 @@ export function useResolveAlertGroup() {
|
||||
context.previousData
|
||||
);
|
||||
}
|
||||
toast.error('Failed to resolve alerts');
|
||||
console.error('Error resolving alert group:', error);
|
||||
|
||||
// Show detailed error message
|
||||
toast.error(error.message || 'Failed to resolve alerts', {
|
||||
description: 'Please try again or contact support if the issue persists.',
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
// Log to error tracking system
|
||||
breadcrumb.apiCall('resolve-alerts', 'POST', 500);
|
||||
console.error('Error resolving alert group:', error, {
|
||||
alertIds: variables.alertIds,
|
||||
source: variables.source,
|
||||
});
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({
|
||||
|
||||
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,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -90,6 +90,17 @@ export function useCreateIncident() {
|
||||
.insert(incidentAlerts);
|
||||
|
||||
if (linkError) throw linkError;
|
||||
|
||||
// Log to audit trail
|
||||
const { logAdminAction } = await import('@/lib/adminActionAuditHelpers');
|
||||
await logAdminAction('incident_created', {
|
||||
incident_id: incident.id,
|
||||
incident_number: incident.incident_number,
|
||||
title: title,
|
||||
severity: severity,
|
||||
alert_count: alertIds.length,
|
||||
correlation_rule_id: ruleId,
|
||||
});
|
||||
|
||||
return incident as Incident;
|
||||
},
|
||||
@@ -122,6 +133,16 @@ export function useAcknowledgeIncident() {
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
// Log to audit trail
|
||||
const { logAdminAction } = await import('@/lib/adminActionAuditHelpers');
|
||||
await logAdminAction('incident_acknowledged', {
|
||||
incident_id: incidentId,
|
||||
incident_number: data.incident_number,
|
||||
severity: data.severity,
|
||||
status_change: 'open -> investigating',
|
||||
});
|
||||
|
||||
return data as Incident;
|
||||
},
|
||||
onSuccess: () => {
|
||||
@@ -149,6 +170,13 @@ export function useResolveIncident() {
|
||||
resolveAlerts?: boolean;
|
||||
}) => {
|
||||
const userId = (await supabase.auth.getUser()).data.user?.id;
|
||||
|
||||
// Fetch incident details before resolving
|
||||
const { data: incident } = await supabase
|
||||
.from('incidents')
|
||||
.select('incident_number, severity, alert_count')
|
||||
.eq('id', incidentId)
|
||||
.single();
|
||||
|
||||
// Update incident
|
||||
const { error: incidentError } = await supabase
|
||||
@@ -162,6 +190,17 @@ export function useResolveIncident() {
|
||||
.eq('id', incidentId);
|
||||
|
||||
if (incidentError) throw incidentError;
|
||||
|
||||
// Log to audit trail
|
||||
const { logAdminAction } = await import('@/lib/adminActionAuditHelpers');
|
||||
await logAdminAction('incident_resolved', {
|
||||
incident_id: incidentId,
|
||||
incident_number: incident?.incident_number,
|
||||
severity: incident?.severity,
|
||||
alert_count: incident?.alert_count,
|
||||
resolution_notes: resolutionNotes,
|
||||
resolved_linked_alerts: resolveAlerts,
|
||||
});
|
||||
|
||||
// Optionally resolve all linked alerts
|
||||
if (resolveAlerts) {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -311,6 +312,19 @@ export function useModerationQueueManager(config: ModerationQueueManagerConfig):
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
// Log manual submission deletion
|
||||
const { logAdminAction } = await import('@/lib/adminActionAuditHelpers');
|
||||
await logAdminAction(
|
||||
'submission_force_deleted',
|
||||
{
|
||||
submission_id: item.id,
|
||||
submission_type: item.content?.action || 'unknown',
|
||||
entity_type: item.content?.entity_type,
|
||||
reason: 'Manual deletion by moderator',
|
||||
},
|
||||
item.user_id
|
||||
);
|
||||
|
||||
toast({
|
||||
title: "Submission deleted",
|
||||
description: "The submission has been permanently deleted",
|
||||
@@ -336,7 +350,7 @@ export function useModerationQueueManager(config: ModerationQueueManagerConfig):
|
||||
setActionLoading(null);
|
||||
}
|
||||
},
|
||||
[actionLoading, toast],
|
||||
[actionLoading, toast, queue],
|
||||
);
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
26
src/hooks/useAdminDatabaseStats.ts
Normal file
26
src/hooks/useAdminDatabaseStats.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
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 () => {
|
||||
const { data, error } = await supabase.rpc('get_database_statistics');
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data as unknown as DatabaseStatistics;
|
||||
},
|
||||
enabled: isAdminPage, // Only run query on admin pages
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
refetchInterval: isAdminPage ? 60 * 1000 : false, // Only refetch on admin pages
|
||||
});
|
||||
}
|
||||
@@ -49,6 +49,10 @@ export function useAdminSettings() {
|
||||
|
||||
const updateSettingMutation = useMutation({
|
||||
mutationFn: async ({ key, value }: { key: string; value: unknown }) => {
|
||||
// Get old value for audit log
|
||||
const oldSetting = settings?.find(s => s.setting_key === key);
|
||||
const oldValue = oldSetting?.setting_value;
|
||||
|
||||
const { error } = await supabase
|
||||
.from('admin_settings')
|
||||
.update({
|
||||
@@ -59,10 +63,19 @@ export function useAdminSettings() {
|
||||
.eq('setting_key', key);
|
||||
|
||||
if (error) throw error;
|
||||
return { key, value };
|
||||
return { key, value, oldValue };
|
||||
},
|
||||
onSuccess: () => {
|
||||
onSuccess: async (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
|
||||
|
||||
// Log to audit trail
|
||||
const { logAdminAction } = await import('@/lib/adminActionAuditHelpers');
|
||||
await logAdminAction('admin_setting_updated', {
|
||||
setting_key: data.key,
|
||||
old_value: data.oldValue,
|
||||
new_value: data.value,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: "Setting Updated",
|
||||
description: "The setting has been saved successfully.",
|
||||
|
||||
25
src/hooks/useContributorLeaderboard.ts
Normal file
25
src/hooks/useContributorLeaderboard.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { supabase } from '@/lib/supabaseClient';
|
||||
import { LeaderboardData, TimePeriod } from '@/types/contributor';
|
||||
import { queryKeys } from '@/lib/queryKeys';
|
||||
|
||||
export function useContributorLeaderboard(
|
||||
limit: number = 50,
|
||||
timePeriod: TimePeriod = 'all_time'
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.analytics.contributorLeaderboard(limit, timePeriod),
|
||||
queryFn: async () => {
|
||||
const { data, error } = await supabase.rpc('get_contributor_leaderboard', {
|
||||
limit_count: limit,
|
||||
time_period: timePeriod,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return data as unknown as LeaderboardData;
|
||||
},
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
refetchInterval: 5 * 60 * 1000, // Refresh every 5 minutes
|
||||
});
|
||||
}
|
||||
110
src/hooks/useDataCompleteness.ts
Normal file
110
src/hooks/useDataCompleteness.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Data Completeness Hook
|
||||
*
|
||||
* React Query hook for fetching and caching data completeness analysis
|
||||
* with real-time updates via Supabase subscriptions
|
||||
*/
|
||||
|
||||
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({
|
||||
queryKey: ['data-completeness', filters],
|
||||
queryFn: async (): Promise<CompletenessAnalysis> => {
|
||||
try {
|
||||
const { data, error } = await supabase.rpc('analyze_data_completeness', {
|
||||
p_entity_type: filters.entityType ?? undefined,
|
||||
p_min_score: filters.minScore ?? undefined,
|
||||
p_max_score: filters.maxScore ?? undefined,
|
||||
p_missing_category: filters.missingCategory ?? undefined,
|
||||
p_limit: 1000,
|
||||
p_offset: 0,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return data as unknown as CompletenessAnalysis;
|
||||
} catch (error) {
|
||||
handleError(error, {
|
||||
action: 'fetch_data_completeness',
|
||||
metadata: {
|
||||
filters,
|
||||
},
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
enabled: isAdminPage, // Only run on admin pages
|
||||
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
// Real-time subscriptions for data updates
|
||||
useEffect(() => {
|
||||
// Subscribe to parks changes
|
||||
const parksChannel = supabase
|
||||
.channel('parks-completeness-updates')
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{ event: '*', schema: 'public', table: 'parks' },
|
||||
() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['data-completeness'] });
|
||||
}
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
// Subscribe to rides changes
|
||||
const ridesChannel = supabase
|
||||
.channel('rides-completeness-updates')
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{ event: '*', schema: 'public', table: 'rides' },
|
||||
() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['data-completeness'] });
|
||||
}
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
// Subscribe to companies changes
|
||||
const companiesChannel = supabase
|
||||
.channel('companies-completeness-updates')
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{ event: '*', schema: 'public', table: 'companies' },
|
||||
() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['data-completeness'] });
|
||||
}
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
// Subscribe to ride_models changes
|
||||
const modelsChannel = supabase
|
||||
.channel('ride-models-completeness-updates')
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{ event: '*', schema: 'public', table: 'ride_models' },
|
||||
() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['data-completeness'] });
|
||||
}
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
return () => {
|
||||
supabase.removeChannel(parksChannel);
|
||||
supabase.removeChannel(ridesChannel);
|
||||
supabase.removeChannel(companiesChannel);
|
||||
supabase.removeChannel(modelsChannel);
|
||||
};
|
||||
}, [queryClient]);
|
||||
|
||||
return query;
|
||||
}
|
||||
21
src/hooks/useDatabaseHealthCheck.ts
Normal file
21
src/hooks/useDatabaseHealthCheck.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { queryKeys } from '@/lib/queryKeys';
|
||||
import type { DatabaseHealthData } from '@/types/database-analytics';
|
||||
|
||||
export function useDatabaseHealthCheck() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.analytics.databaseHealth(),
|
||||
queryFn: async () => {
|
||||
const { data, error } = await supabase.rpc('check_database_health');
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data as unknown as DatabaseHealthData;
|
||||
},
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
refetchInterval: 2 * 60 * 1000, // Auto-refetch every 2 minutes (health is important!)
|
||||
});
|
||||
}
|
||||
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,
|
||||
};
|
||||
}
|
||||
21
src/hooks/useEntityComparisons.ts
Normal file
21
src/hooks/useEntityComparisons.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { queryKeys } from '@/lib/queryKeys';
|
||||
import type { EntityComparisons } from '@/types/database-analytics';
|
||||
|
||||
export function useEntityComparisons() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.analytics.entityComparisons(),
|
||||
queryFn: async () => {
|
||||
const { data, error } = await supabase.rpc('get_entity_comparisons');
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data as unknown as EntityComparisons;
|
||||
},
|
||||
staleTime: 15 * 60 * 1000, // 15 minutes
|
||||
refetchInterval: 10 * 60 * 1000, // Auto-refetch every 10 minutes
|
||||
});
|
||||
}
|
||||
24
src/hooks/useGrowthTrends.ts
Normal file
24
src/hooks/useGrowthTrends.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { queryKeys } from '@/lib/queryKeys';
|
||||
import type { GrowthTrendDataPoint, GranularityType } from '@/types/database-analytics';
|
||||
|
||||
export function useGrowthTrends(daysBack: number = 90, granularity: GranularityType = 'daily') {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.analytics.growthTrends(daysBack, granularity),
|
||||
queryFn: async () => {
|
||||
const { data, error } = await supabase.rpc('get_entity_growth_trends', {
|
||||
days_back: daysBack,
|
||||
granularity: granularity,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data as GrowthTrendDataPoint[];
|
||||
},
|
||||
staleTime: 10 * 60 * 1000, // 10 minutes
|
||||
refetchInterval: 5 * 60 * 1000, // Auto-refetch every 5 minutes
|
||||
});
|
||||
}
|
||||
@@ -80,6 +80,13 @@ export function useUpdateAlertConfig() {
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ id, updates }: { id: string; updates: Partial<AlertConfig> }) => {
|
||||
// Fetch old config for audit log
|
||||
const { data: oldConfig } = await supabase
|
||||
.from('rate_limit_alert_config')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.single();
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('rate_limit_alert_config')
|
||||
.update(updates)
|
||||
@@ -88,10 +95,23 @@ export function useUpdateAlertConfig() {
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
return { data, oldConfig };
|
||||
},
|
||||
onSuccess: () => {
|
||||
onSuccess: async ({ data, oldConfig }) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['rateLimitAlertConfigs'] });
|
||||
|
||||
// Log to audit trail
|
||||
const { logAdminAction } = await import('@/lib/adminActionAuditHelpers');
|
||||
await logAdminAction('rate_limit_config_updated', {
|
||||
config_id: data.id,
|
||||
metric_type: data.metric_type,
|
||||
old_threshold: oldConfig?.threshold_value,
|
||||
new_threshold: data.threshold_value,
|
||||
old_enabled: oldConfig?.enabled,
|
||||
new_enabled: data.enabled,
|
||||
function_name: data.function_name,
|
||||
});
|
||||
|
||||
toast.success('Alert configuration updated');
|
||||
},
|
||||
onError: (error) => {
|
||||
@@ -114,8 +134,20 @@ export function useCreateAlertConfig() {
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
onSuccess: async (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['rateLimitAlertConfigs'] });
|
||||
|
||||
// Log to audit trail
|
||||
const { logAdminAction } = await import('@/lib/adminActionAuditHelpers');
|
||||
await logAdminAction('rate_limit_config_created', {
|
||||
config_id: data.id,
|
||||
metric_type: data.metric_type,
|
||||
threshold_value: data.threshold_value,
|
||||
time_window_ms: data.time_window_ms,
|
||||
function_name: data.function_name,
|
||||
enabled: data.enabled,
|
||||
});
|
||||
|
||||
toast.success('Alert configuration created');
|
||||
},
|
||||
onError: (error) => {
|
||||
@@ -129,15 +161,36 @@ export function useDeleteAlertConfig() {
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
// Fetch config details before deletion for audit log
|
||||
const { data: config } = await supabase
|
||||
.from('rate_limit_alert_config')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.single();
|
||||
|
||||
const { error } = await supabase
|
||||
.from('rate_limit_alert_config')
|
||||
.delete()
|
||||
.eq('id', id);
|
||||
|
||||
if (error) throw error;
|
||||
return config;
|
||||
},
|
||||
onSuccess: () => {
|
||||
onSuccess: async (config) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['rateLimitAlertConfigs'] });
|
||||
|
||||
// Log to audit trail
|
||||
if (config) {
|
||||
const { logAdminAction } = await import('@/lib/adminActionAuditHelpers');
|
||||
await logAdminAction('rate_limit_config_deleted', {
|
||||
config_id: config.id,
|
||||
metric_type: config.metric_type,
|
||||
threshold_value: config.threshold_value,
|
||||
time_window_ms: config.time_window_ms,
|
||||
function_name: config.function_name,
|
||||
});
|
||||
}
|
||||
|
||||
toast.success('Alert configuration deleted');
|
||||
},
|
||||
onError: (error) => {
|
||||
@@ -151,6 +204,16 @@ export function useResolveAlert() {
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
// Fetch full alert details before resolving
|
||||
const { data: alert, error: fetchError } = await supabase
|
||||
.from('rate_limit_alerts')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.single();
|
||||
|
||||
if (fetchError) throw fetchError;
|
||||
|
||||
// Resolve the alert
|
||||
const { data, error } = await supabase
|
||||
.from('rate_limit_alerts')
|
||||
.update({ resolved_at: new Date().toISOString() })
|
||||
@@ -159,6 +222,18 @@ export function useResolveAlert() {
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
// Log to audit trail
|
||||
const { logAdminAction } = await import('@/lib/adminActionAuditHelpers');
|
||||
await logAdminAction('rate_limit_alert_resolved', {
|
||||
alert_id: id,
|
||||
metric_type: alert.metric_type,
|
||||
metric_value: alert.metric_value,
|
||||
threshold_value: alert.threshold_value,
|
||||
function_name: alert.function_name,
|
||||
time_window_ms: alert.time_window_ms,
|
||||
});
|
||||
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
|
||||
79
src/hooks/useRecentAdditions.ts
Normal file
79
src/hooks/useRecentAdditions.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
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 () => {
|
||||
const { data, error } = await supabase.rpc('get_recent_additions', {
|
||||
limit_count: limit
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data as unknown as RecentAddition[];
|
||||
},
|
||||
enabled: isAdminPage, // Only run query on admin pages
|
||||
staleTime: 2 * 60 * 1000, // 2 minutes
|
||||
refetchInterval: isAdminPage ? 30 * 1000 : false, // Only refetch on admin pages
|
||||
});
|
||||
|
||||
// Set up real-time subscriptions
|
||||
useEffect(() => {
|
||||
const channels = [
|
||||
supabase
|
||||
.channel('recent_additions_parks')
|
||||
.on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'parks' }, () => {
|
||||
query.refetch();
|
||||
})
|
||||
.subscribe(),
|
||||
supabase
|
||||
.channel('recent_additions_rides')
|
||||
.on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'rides' }, () => {
|
||||
query.refetch();
|
||||
})
|
||||
.subscribe(),
|
||||
supabase
|
||||
.channel('recent_additions_companies')
|
||||
.on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'companies' }, () => {
|
||||
query.refetch();
|
||||
})
|
||||
.subscribe(),
|
||||
supabase
|
||||
.channel('recent_additions_ride_models')
|
||||
.on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'ride_models' }, () => {
|
||||
query.refetch();
|
||||
})
|
||||
.subscribe(),
|
||||
supabase
|
||||
.channel('recent_additions_photos')
|
||||
.on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'photos' }, () => {
|
||||
query.refetch();
|
||||
})
|
||||
.subscribe(),
|
||||
];
|
||||
|
||||
return () => {
|
||||
channels.forEach(channel => channel.unsubscribe());
|
||||
};
|
||||
}, [query]);
|
||||
|
||||
// Filter by entity type on client side
|
||||
const filteredData = entityTypeFilter && query.data
|
||||
? query.data.filter(item => item.entity_type === entityTypeFilter)
|
||||
: query.data;
|
||||
|
||||
return {
|
||||
...query,
|
||||
data: filteredData,
|
||||
};
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -3520,6 +3527,60 @@ export type Database = {
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
request_spans: {
|
||||
Row: {
|
||||
created_at: string
|
||||
duration_ms: number | null
|
||||
end_time: string | null
|
||||
error_message: string | null
|
||||
error_stack: string | null
|
||||
error_type: string | null
|
||||
id: string
|
||||
kind: string
|
||||
name: string
|
||||
parent_span_id: string | null
|
||||
request_id: string | null
|
||||
span_id: string
|
||||
start_time: string
|
||||
status: string
|
||||
trace_id: string
|
||||
}
|
||||
Insert: {
|
||||
created_at?: string
|
||||
duration_ms?: number | null
|
||||
end_time?: string | null
|
||||
error_message?: string | null
|
||||
error_stack?: string | null
|
||||
error_type?: string | null
|
||||
id?: string
|
||||
kind: string
|
||||
name: string
|
||||
parent_span_id?: string | null
|
||||
request_id?: string | null
|
||||
span_id: string
|
||||
start_time: string
|
||||
status?: string
|
||||
trace_id: string
|
||||
}
|
||||
Update: {
|
||||
created_at?: string
|
||||
duration_ms?: number | null
|
||||
end_time?: string | null
|
||||
error_message?: string | null
|
||||
error_stack?: string | null
|
||||
error_type?: string | null
|
||||
id?: string
|
||||
kind?: string
|
||||
name?: string
|
||||
parent_span_id?: string | null
|
||||
request_id?: string | null
|
||||
span_id?: string
|
||||
start_time?: string
|
||||
status?: string
|
||||
trace_id?: string
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
review_deletions: {
|
||||
Row: {
|
||||
content: string | null
|
||||
@@ -5404,6 +5465,111 @@ export type Database = {
|
||||
},
|
||||
]
|
||||
}
|
||||
span_attributes: {
|
||||
Row: {
|
||||
created_at: string
|
||||
id: string
|
||||
key: string
|
||||
span_id: string
|
||||
value: string
|
||||
value_type: string
|
||||
}
|
||||
Insert: {
|
||||
created_at?: string
|
||||
id?: string
|
||||
key: string
|
||||
span_id: string
|
||||
value: string
|
||||
value_type?: string
|
||||
}
|
||||
Update: {
|
||||
created_at?: string
|
||||
id?: string
|
||||
key?: string
|
||||
span_id?: string
|
||||
value?: string
|
||||
value_type?: string
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "span_attributes_span_id_fkey"
|
||||
columns: ["span_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "request_spans"
|
||||
referencedColumns: ["span_id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
span_event_attributes: {
|
||||
Row: {
|
||||
created_at: string
|
||||
id: string
|
||||
key: string
|
||||
span_event_id: string
|
||||
value: string
|
||||
value_type: string
|
||||
}
|
||||
Insert: {
|
||||
created_at?: string
|
||||
id?: string
|
||||
key: string
|
||||
span_event_id: string
|
||||
value: string
|
||||
value_type?: string
|
||||
}
|
||||
Update: {
|
||||
created_at?: string
|
||||
id?: string
|
||||
key?: string
|
||||
span_event_id?: string
|
||||
value?: string
|
||||
value_type?: string
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "span_event_attributes_span_event_id_fkey"
|
||||
columns: ["span_event_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "span_events"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
span_events: {
|
||||
Row: {
|
||||
created_at: string
|
||||
id: string
|
||||
name: string
|
||||
sequence_order: number
|
||||
span_id: string
|
||||
timestamp: string
|
||||
}
|
||||
Insert: {
|
||||
created_at?: string
|
||||
id?: string
|
||||
name: string
|
||||
sequence_order: number
|
||||
span_id: string
|
||||
timestamp: string
|
||||
}
|
||||
Update: {
|
||||
created_at?: string
|
||||
id?: string
|
||||
name?: string
|
||||
sequence_order?: number
|
||||
span_id?: string
|
||||
timestamp?: string
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "span_events_span_id_fkey"
|
||||
columns: ["span_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "request_spans"
|
||||
referencedColumns: ["span_id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
submission_dependencies: {
|
||||
Row: {
|
||||
child_entity_type: string
|
||||
@@ -5523,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"]
|
||||
@@ -5535,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
|
||||
@@ -5555,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
|
||||
@@ -5575,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
|
||||
@@ -5601,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"]
|
||||
@@ -5772,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"]
|
||||
@@ -6147,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
|
||||
@@ -6439,12 +6699,56 @@ export type Database = {
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
span_hierarchy: {
|
||||
Row: {
|
||||
depth: number | null
|
||||
duration_ms: number | null
|
||||
kind: string | null
|
||||
name: string | null
|
||||
parent_span_id: string | null
|
||||
path: string[] | null
|
||||
span_id: string | null
|
||||
start_time: string | null
|
||||
status: string | null
|
||||
trace_id: string | null
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
trace_summary: {
|
||||
Row: {
|
||||
error_count: number | null
|
||||
span_count: number | null
|
||||
span_ids: string[] | null
|
||||
span_names: string[] | null
|
||||
total_duration_ms: number | null
|
||||
trace_end: string | null
|
||||
trace_id: string | null
|
||||
trace_start: string | null
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
| { Args: never; Returns: Json }
|
||||
anonymize_user_submissions: {
|
||||
Args: { target_user_id: string }
|
||||
Returns: undefined
|
||||
}
|
||||
backfill_company_data: { Args: never; Returns: Json }
|
||||
backfill_park_locations: { Args: never; Returns: Json }
|
||||
backfill_ride_data: { Args: never; Returns: Json }
|
||||
backfill_sort_orders: { Args: never; Returns: undefined }
|
||||
block_aal1_with_mfa: { Args: never; Returns: boolean }
|
||||
can_approve_submission_item: {
|
||||
@@ -6472,6 +6776,7 @@ export type Database = {
|
||||
Returns: boolean
|
||||
}
|
||||
cancel_user_email_change: { Args: { _user_id: string }; Returns: boolean }
|
||||
check_database_health: { Args: never; Returns: Json }
|
||||
check_rate_limit: {
|
||||
Args: {
|
||||
p_action: string
|
||||
@@ -6540,6 +6845,7 @@ export type Database = {
|
||||
}
|
||||
cleanup_old_page_views: { Args: never; Returns: undefined }
|
||||
cleanup_old_request_metadata: { Args: never; Returns: undefined }
|
||||
cleanup_old_spans: { Args: never; Returns: number }
|
||||
cleanup_old_submissions: {
|
||||
Args: { p_retention_days?: number }
|
||||
Returns: {
|
||||
@@ -6613,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: {
|
||||
@@ -6625,13 +6932,75 @@ 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 }
|
||||
Returns: Json
|
||||
}
|
||||
get_current_user_id: { Args: never; Returns: string }
|
||||
get_database_statistics: { Args: never; Returns: Json }
|
||||
get_email_change_status: { Args: never; Returns: Json }
|
||||
get_entity_comparisons: { Args: never; Returns: Json }
|
||||
get_entity_growth_trends: {
|
||||
Args: { days_back?: number; granularity?: string }
|
||||
Returns: {
|
||||
companies_added: number
|
||||
parks_added: number
|
||||
period: string
|
||||
photos_added: number
|
||||
ride_models_added: number
|
||||
rides_added: number
|
||||
total_added: number
|
||||
}[]
|
||||
}
|
||||
get_filtered_profile: {
|
||||
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: {
|
||||
@@ -6651,6 +7020,21 @@ export type Database = {
|
||||
id: string
|
||||
}[]
|
||||
}
|
||||
get_recent_additions: {
|
||||
Args: { limit_count?: number }
|
||||
Returns: {
|
||||
created_at: string
|
||||
created_by_avatar: string
|
||||
created_by_id: string
|
||||
created_by_username: string
|
||||
entity_id: string
|
||||
entity_name: string
|
||||
entity_slug: string
|
||||
entity_type: string
|
||||
image_url: string
|
||||
park_slug: string
|
||||
}[]
|
||||
}
|
||||
get_recent_changes: {
|
||||
Args: { limit_count?: number }
|
||||
Returns: {
|
||||
@@ -6815,29 +7199,18 @@ export type Database = {
|
||||
monitor_ban_attempts: { Args: never; Returns: undefined }
|
||||
monitor_failed_submissions: { Args: never; Returns: undefined }
|
||||
monitor_slow_approvals: { Args: never; Returns: undefined }
|
||||
process_approval_transaction:
|
||||
| {
|
||||
Args: {
|
||||
p_item_ids: string[]
|
||||
p_moderator_id: string
|
||||
p_request_id?: string
|
||||
p_submission_id: string
|
||||
p_submitter_id: string
|
||||
}
|
||||
Returns: Json
|
||||
}
|
||||
| {
|
||||
Args: {
|
||||
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
|
||||
}
|
||||
process_approval_transaction: {
|
||||
Args: {
|
||||
p_approval_mode?: string
|
||||
p_idempotency_key?: string
|
||||
p_item_ids: string[]
|
||||
p_moderator_id: string
|
||||
p_request_id?: string
|
||||
p_submission_id: string
|
||||
p_submitter_id: string
|
||||
}
|
||||
Returns: Json
|
||||
}
|
||||
process_rejection_transaction:
|
||||
| {
|
||||
Args: {
|
||||
@@ -6861,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 }
|
||||
@@ -6890,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
|
||||
@@ -6899,6 +7274,7 @@ export type Database = {
|
||||
status: string
|
||||
}[]
|
||||
}
|
||||
run_reindex_table: { Args: { table_name: string }; Returns: Json }
|
||||
run_system_maintenance: {
|
||||
Args: never
|
||||
Returns: {
|
||||
@@ -6907,6 +7283,7 @@ export type Database = {
|
||||
task: string
|
||||
}[]
|
||||
}
|
||||
run_vacuum_table: { Args: { table_name: string }; Returns: Json }
|
||||
set_config_value: {
|
||||
Args: {
|
||||
is_local?: boolean
|
||||
|
||||
45
src/lib/adminActionAuditHelpers.ts
Normal file
45
src/lib/adminActionAuditHelpers.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Centralized audit logging for all admin/moderator/superuser actions
|
||||
*
|
||||
* This ensures consistent logging across the application and provides
|
||||
* a single point of maintenance for audit trail functionality.
|
||||
*/
|
||||
|
||||
import { supabase } from '@/lib/supabaseClient';
|
||||
import { handleNonCriticalError } from '@/lib/errorHandler';
|
||||
|
||||
/**
|
||||
* Log any admin/moderator/superuser action to the audit trail
|
||||
*
|
||||
* @param action - The action being performed (e.g., 'system_alert_resolved', 'role_granted')
|
||||
* @param details - Key-value pairs with action-specific details
|
||||
* @param targetUserId - The user affected by this action (optional, defaults to admin user)
|
||||
*/
|
||||
export async function logAdminAction(
|
||||
action: string,
|
||||
details: Record<string, any>,
|
||||
targetUserId?: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
console.warn('Cannot log admin action: No authenticated user', { action, details });
|
||||
return;
|
||||
}
|
||||
|
||||
await supabase.rpc('log_admin_action', {
|
||||
_admin_user_id: user.id,
|
||||
_target_user_id: targetUserId || user.id,
|
||||
_action: action,
|
||||
_details: details
|
||||
});
|
||||
|
||||
console.log('✅ Admin action logged:', { action, targetUserId, hasDetails: Object.keys(details).length > 0 });
|
||||
} catch (error) {
|
||||
// Log error but don't throw - audit logging shouldn't block operations
|
||||
handleNonCriticalError(error, {
|
||||
action: 'Log admin action',
|
||||
metadata: { adminAction: action, details }
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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')}`;
|
||||
}
|
||||
|
||||
@@ -34,7 +34,19 @@ export async function invokeWithTracking<T = any>(
|
||||
timeout: number = 30000,
|
||||
retryOptions?: Partial<RetryOptions>,
|
||||
customHeaders?: Record<string, string>
|
||||
): Promise<{ data: T | null; error: any; requestId: string; duration: number; attempts?: number; status?: number; traceId?: string }> {
|
||||
): Promise<{
|
||||
data: T | null;
|
||||
error: any;
|
||||
requestId: string;
|
||||
duration: number;
|
||||
attempts?: number;
|
||||
status?: number;
|
||||
traceId?: string;
|
||||
backendRequestId?: string;
|
||||
backendSpanId?: string;
|
||||
backendTraceId?: string;
|
||||
backendDuration?: number;
|
||||
}> {
|
||||
// Configure retry options with defaults
|
||||
const effectiveRetryOptions: RetryOptions = {
|
||||
maxAttempts: retryOptions?.maxAttempts ?? 3,
|
||||
@@ -123,6 +135,16 @@ export async function invokeWithTracking<T = any>(
|
||||
}
|
||||
);
|
||||
|
||||
// Extract backend metadata from successful response
|
||||
let backendRequestId: string | undefined;
|
||||
let backendSpanId: string | undefined;
|
||||
let backendTraceId: string | undefined;
|
||||
let backendDuration: number | undefined;
|
||||
|
||||
// Note: Response headers from edge functions are not currently accessible via the client
|
||||
// Backend metadata extraction will be enhanced when Supabase client supports response headers
|
||||
// For now, backend can include metadata in response body if needed
|
||||
|
||||
return {
|
||||
data: result,
|
||||
error: null,
|
||||
@@ -131,6 +153,10 @@ export async function invokeWithTracking<T = any>(
|
||||
attempts: attemptCount,
|
||||
status: 200,
|
||||
traceId,
|
||||
backendRequestId,
|
||||
backendSpanId,
|
||||
backendTraceId,
|
||||
backendDuration,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
// Handle AbortError specifically
|
||||
@@ -151,6 +177,22 @@ export async function invokeWithTracking<T = any>(
|
||||
|
||||
const errorMessage = getErrorMessage(error);
|
||||
|
||||
// Extract backend metadata from error context
|
||||
let backendRequestId: string | undefined;
|
||||
let backendSpanId: string | undefined;
|
||||
let backendTraceId: string | undefined;
|
||||
let backendDuration: number | undefined;
|
||||
|
||||
if (error && typeof error === 'object') {
|
||||
const context = (error as any).context;
|
||||
if (context) {
|
||||
backendRequestId = context['x-request-id'];
|
||||
backendSpanId = context['x-span-id'];
|
||||
backendTraceId = context['x-trace-id'];
|
||||
backendDuration = context['x-duration-ms'] ? parseInt(context['x-duration-ms']) : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// Detect CORS errors specifically
|
||||
const isCorsError = errorMessage.toLowerCase().includes('cors') ||
|
||||
errorMessage.toLowerCase().includes('cross-origin') ||
|
||||
@@ -166,6 +208,12 @@ export async function invokeWithTracking<T = any>(
|
||||
isCorsError,
|
||||
debugHint: isCorsError ? 'Browser blocked request - verify CORS headers allow X-Idempotency-Key or check network connectivity' : undefined,
|
||||
status: (error as any)?.status,
|
||||
backendMetadata: backendRequestId ? {
|
||||
requestId: backendRequestId,
|
||||
spanId: backendSpanId,
|
||||
traceId: backendTraceId,
|
||||
duration: backendDuration,
|
||||
} : undefined,
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -180,6 +228,10 @@ export async function invokeWithTracking<T = any>(
|
||||
attempts: attemptCount,
|
||||
status: (error as any)?.status,
|
||||
traceId: undefined,
|
||||
backendRequestId,
|
||||
backendSpanId,
|
||||
backendTraceId,
|
||||
backendDuration,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
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(),
|
||||
|
||||
@@ -71,6 +71,30 @@ export const handleError = (
|
||||
const errorId = (context.metadata?.requestId as string) || crypto.randomUUID();
|
||||
const shortErrorId = errorId.slice(0, 8);
|
||||
|
||||
// Extract backend metadata if available
|
||||
let backendRequestId: string | undefined;
|
||||
let backendSpanId: string | undefined;
|
||||
let backendTraceId: string | undefined;
|
||||
let backendDuration: number | undefined;
|
||||
|
||||
if (error && typeof error === 'object') {
|
||||
const errorObj = error as any;
|
||||
// Check for backend metadata in error context
|
||||
if (errorObj.context) {
|
||||
backendRequestId = errorObj.context['x-request-id'];
|
||||
backendSpanId = errorObj.context['x-span-id'];
|
||||
backendTraceId = errorObj.context['x-trace-id'];
|
||||
backendDuration = errorObj.context['x-duration-ms'] ? parseInt(errorObj.context['x-duration-ms']) : undefined;
|
||||
}
|
||||
// Also check metadata property
|
||||
if (context.metadata) {
|
||||
backendRequestId = backendRequestId || (context.metadata.backendRequestId as string);
|
||||
backendSpanId = backendSpanId || (context.metadata.backendSpanId as string);
|
||||
backendTraceId = backendTraceId || (context.metadata.backendTraceId as string);
|
||||
backendDuration = backendDuration || (context.metadata.backendDuration as number);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this is a connection error and dispatch event
|
||||
if (isSupabaseConnectionError(error)) {
|
||||
const errorMsg = getErrorMessage(error).toLowerCase();
|
||||
@@ -169,6 +193,12 @@ export const handleError = (
|
||||
supabaseError: supabaseErrorDetails,
|
||||
isCorsError,
|
||||
debugHint: isCorsError ? 'Browser blocked request - check CORS headers or network connectivity' : undefined,
|
||||
backendMetadata: backendRequestId ? {
|
||||
requestId: backendRequestId,
|
||||
spanId: backendSpanId,
|
||||
traceId: backendTraceId,
|
||||
duration: backendDuration,
|
||||
} : undefined,
|
||||
});
|
||||
|
||||
// Additional debug logging when stack is missing
|
||||
@@ -204,7 +234,13 @@ export const handleError = (
|
||||
attempt: context.metadata?.attempt,
|
||||
retriesExhausted: context.metadata?.retriesExhausted || false,
|
||||
supabaseError: supabaseErrorDetails,
|
||||
metadata: context.metadata
|
||||
metadata: context.metadata,
|
||||
backendMetadata: backendRequestId ? {
|
||||
requestId: backendRequestId,
|
||||
spanId: backendSpanId,
|
||||
traceId: backendTraceId,
|
||||
duration: backendDuration,
|
||||
} : undefined,
|
||||
}),
|
||||
p_timezone: envContext.timezone,
|
||||
p_referrer: document.referrer || undefined,
|
||||
@@ -221,8 +257,9 @@ export const handleError = (
|
||||
// Show user-friendly toast with error ID (skip for retry attempts)
|
||||
const isRetry = context.metadata?.isRetry === true || context.metadata?.attempt;
|
||||
if (!isRetry) {
|
||||
const refId = backendRequestId ? backendRequestId.slice(0, 8) : shortErrorId;
|
||||
toast.error(context.action, {
|
||||
description: `${errorMessage}\n\nReference ID: ${shortErrorId}`,
|
||||
description: `${errorMessage}\n\nReference ID: ${refId}`,
|
||||
duration: 5000,
|
||||
});
|
||||
}
|
||||
|
||||
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',
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user