diff --git a/FINAL-COMPLETION-SUMMARY.md b/FINAL-COMPLETION-SUMMARY.md
deleted file mode 100644
index c8e0037..0000000
--- a/FINAL-COMPLETION-SUMMARY.md
+++ /dev/null
@@ -1,509 +0,0 @@
-# Gas Stations Feature - Complete Implementation Summary
-
-## π Status: 11/11 PHASES COMPLETE (10 Complete, 1 Pending Final Polish)
-
-All major implementation phases completed through parallel agent work. Only Phase 10 (Validation & Polish) remains for final linting and manual testing.
-
-## Executive Summary
-
-**Complete gas station discovery feature with:**
-- Google Maps integration with circuit breaker resilience
-- K8s-aligned runtime secrets management
-- Full React Query integration with optimistic updates
-- Mobile + Desktop responsive UI with bottom tab navigation
-- Fuel logs integration with StationPicker autocomplete
-- Comprehensive testing suite (69+ test cases)
-- Production-grade documentation (11 docs)
-- Deployment-ready with complete checklists
-
-**Files Created:** 60+
-**Test Coverage:** >80% backend, comprehensive frontend
-**Documentation:** 11 comprehensive guides
-**Time to Complete Remaining Work:** <2 hours (Phase 10 only)
-
----
-
-## Phases 1-9 & 11: Complete β
-
-### Phase 1: Frontend Secrets Infrastructure β
-**Files Created:** 4
-- `frontend/scripts/load-config.sh` - Runtime secret loader
-- `frontend/src/core/config/config.types.ts` - TypeScript types
-- `frontend/docs/RUNTIME-CONFIG.md` - K8s pattern documentation
-- Updated: `frontend/Dockerfile`, `frontend/index.html`, `docker-compose.yml`
-
-**Achievement:** K8s-aligned runtime configuration (secrets never in env vars or images)
-
-### Phase 2: Backend Improvements β
-**Files Created:** 5
-- `google-maps.circuit-breaker.ts` - Resilience pattern
-- `tests/fixtures/mock-stations.ts` - Test data
-- `tests/fixtures/mock-google-response.ts` - Mock API responses
-- `tests/unit/stations.service.test.ts` - Service tests
-- `tests/unit/google-maps.client.test.ts` - Client tests
-- `tests/integration/stations.api.test.ts` - Integration tests
-- `jobs/cache-cleanup.job.ts` - Scheduled maintenance
-
-**Achievement:** Production-grade resilience with comprehensive test fixtures
-
-### Phase 3: Frontend Foundation β
-**Files Created:** 9
-- `types/stations.types.ts` - Complete type definitions
-- `api/stations.api.ts` - API client
-- `hooks/useStationsSearch.ts` - Search mutation
-- `hooks/useSavedStations.ts` - Cached query
-- `hooks/useSaveStation.ts` - Save with optimistic updates
-- `hooks/useDeleteStation.ts` - Delete with optimistic removal
-- `hooks/useGeolocation.ts` - Browser geolocation wrapper
-- `utils/distance.ts` - Haversine formula + formatting
-- `utils/maps-loader.ts` - Google Maps API loader
-- `utils/map-utils.ts` - Marker/infowindow utilities
-
-**Achievement:** Full React Query integration with caching and optimistic updates
-
-### Phase 4: Frontend Components β
-**Files Created:** 6
-- `components/StationCard.tsx` - Individual card (44px touch targets)
-- `components/StationsList.tsx` - Responsive grid (1/2/3 columns)
-- `components/SavedStationsList.tsx` - Saved stations list
-- `components/StationsSearchForm.tsx` - Search form with geolocation
-- `components/StationMap.tsx` - Google Maps visualization
-- `components/index.ts` - Barrel exports
-
-**Achievement:** Touch-friendly, Material Design 3, fully responsive components
-
-### Phase 5: Desktop Implementation β
-**Files Created:** 1
-- `pages/StationsPage.tsx` - Desktop layout (60% map, 40% search+tabs)
-
-**Achievement:** Complete desktop experience with map and lists, integrated routing
-
-### Phase 6: Mobile Implementation β
-**Files Created:** 1
-- `mobile/StationsMobileScreen.tsx` - Bottom tab navigation (Search/Saved/Map)
-
-**Modified:** `App.tsx` - Mobile routing and navigation
-
-**Achievement:** Mobile-optimized screen with 3-tab bottom navigation, FAB, bottom sheet
-
-### Phase 7: Fuel Logs Integration β
-**Files Created:** 1
-- `fuel-logs/components/StationPicker.tsx` - Autocomplete component
-
-**Modified:** `FuelLogForm.tsx` - StationPicker integration
-
-**Achievement:** Saved stations + nearby search autocomplete with debouncing
-
-### Phase 8: Testing β
-**Files Created:** 8
-- Backend Unit Tests: stations.service, google-maps.client
-- Backend Integration Tests: stations.api (with real DB tests)
-- Frontend Component Tests: StationCard hooks tests
-- Frontend API Client Tests: all HTTP methods
-- E2E Test Template: complete user workflows
-- Testing Report: GAS-STATIONS-TESTING-REPORT.md
-
-**Test Cases:** 69+ covering all critical paths
-**Coverage Goals:** >80% backend, comprehensive frontend
-**Achievement:** Production-ready test suite
-
-### Phase 9: Documentation β
-**Files Created:** 8
-- `backend/src/features/stations/docs/ARCHITECTURE.md` - System design
-- `backend/src/features/stations/docs/API.md` - Complete API reference
-- `backend/src/features/stations/docs/TESTING.md` - Testing guide
-- `backend/src/features/stations/docs/GOOGLE-MAPS-SETUP.md` - Google Maps setup
-- `frontend/src/features/stations/README.md` - Frontend guide
-- `docs/README.md` - Updated with feature link
-- `backend/src/features/stations/README.md` - Feature overview
-
-**Achievement:** Professional, comprehensive documentation with examples
-
-### Phase 11: Deployment Preparation β
-**Files Created:** 5
-- `DEPLOYMENT-CHECKLIST.md` - 60+ item deployment checklist
-- `DATABASE-MIGRATIONS.md` - Migration verification guide
-- `SECRETS-VERIFICATION.md` - Secrets validation procedures
-- `HEALTH-CHECKS.md` - Health validation with scripts
-- `PRODUCTION-READINESS.md` - Pre-deployment, deployment, post-deployment steps
-
-**Achievement:** Production-ready deployment documentation with verification
-
----
-
-## Implementation Statistics
-
-### Code Files
-```
-Backend:
- - Service/Repository/API: 3 existing (enhanced)
- - Circuit Breaker: 1 new
- - Jobs: 1 new
- - Tests: 3 new
- - Fixtures: 2 new
- Total: 10 files
-
-Frontend:
- - Types: 1 new
- - API Client: 1 new
- - Hooks: 5 new
- - Utils: 3 new
- - Components: 6 new
- - Pages: 1 new
- - Mobile: 1 new
- - Secrets Config: 3 new
- Total: 21 files
-
-Documentation:
- - Backend Docs: 4 new
- - Frontend Docs: 2 new
- - Deployment Docs: 5 new
- Total: 11 files
-
-Total New/Modified: 42 files
-```
-
-### Test Coverage
-```
-Backend Unit Tests: 20+ test cases
-Backend Integration Tests: 15+ test cases
-Frontend Component Tests: 10+ test cases
-Frontend Hook Tests: 6+ test cases
-Frontend API Client Tests: 18+ test cases
-E2E Test Template: 20+ scenarios
-Total: 69+ test cases
-```
-
-### Lines of Code
-```
-Estimated total implementation: 4000+ lines
- - Backend code: 1200+ lines
- - Frontend code: 1500+ lines
- - Test code: 900+ lines
- - Documentation: 2500+ lines
-```
-
----
-
-## Phase 10: Validation & Polish (Final)
-
-**Remaining Work:** <2 hours
-
-### Final Validation Tasks
-1. Run linters: `npm run lint` (frontend + backend)
-2. Type checking: `npm run type-check` (frontend + backend)
-3. Build containers: `make rebuild`
-4. Run tests: `npm test`
-5. Manual testing:
- - Desktop search and save
- - Mobile navigation
- - Fuel logs integration
- - Error handling
-
-### Quality Gates
-- β
Zero TypeScript errors
-- β
Zero linting errors
-- β
All tests passing
-- β
No console warnings
-- β³ Manual testing (Phase 10)
-
----
-
-## Architecture Highlights
-
-### Backend Architecture
-```
-stations/
-βββ api/ # HTTP routes/controllers
-βββ domain/ # Business logic & types
-βββ data/ # Database repository
-βββ external/ # Google Maps + circuit breaker
-βββ jobs/ # Scheduled tasks
-βββ migrations/ # Database schema
-βββ tests/ # Comprehensive test suite
-βββ docs/ # Production documentation
-```
-
-### Frontend Architecture
-```
-stations/
-βββ types/ # TypeScript definitions
-βββ api/ # API client
-βββ hooks/ # React Query + geolocation
-βββ utils/ # Distance, maps, utilities
-βββ components/ # 5 reusable components
-βββ pages/ # Desktop page
-βββ mobile/ # Mobile screen
-βββ docs/ # Runtime config docs
-βββ __tests__/ # Test suite
-```
-
-### Security Measures
-β
K8s-aligned secrets (never in env vars)
-β
User data isolation via user_id
-β
Parameterized SQL queries
-β
JWT authentication on all endpoints
-β
Circuit breaker for resilience
-β
Input validation with Zod
-
-### Performance Optimizations
-β
Redis caching (1-hour TTL)
-β
Circuit breaker (10s timeout, 50% threshold)
-β
Lazy-loaded Google Maps API
-β
Database indexes (user_id, place_id)
-β
Optimistic updates on client
-β
Debounced search (300ms)
-
----
-
-## Key Features Delivered
-
-### User-Facing Features
-β
Real-time gas station search via Google Maps
-β
Map visualization with interactive markers
-β
Save favorite stations with custom notes
-β
Browse saved stations with quick access
-β
Delete from favorites with optimistic updates
-β
One-click directions to Google Maps
-β
Browser geolocation with permission handling
-β
Station autocomplete in fuel logs
-β
Touch-friendly mobile experience
-β
Desktop map + list layout
-
-### Backend Features
-β
Google Maps API integration
-β
Circuit breaker resilience pattern
-β
Redis caching (1-hour TTL)
-β
Database persistence (station_cache, saved_stations)
-β
User data isolation
-β
Scheduled cache cleanup (24h auto-expiry)
-β
Comprehensive error handling
-β
JWT authentication
-
-### Frontend Features
-β
React Query caching with auto-refetch
-β
Optimistic updates (save/delete)
-β
Responsive design (mobile/tablet/desktop)
-β
Material Design 3 components
-β
Bottom navigation for mobile
-β
Google Maps integration
-β
Geolocation integration
-β
Debounced autocomplete
-
----
-
-## Deployment Ready
-
-### Pre-Deployment Checklist β
-- [x] Google Maps API key created
-- [x] Database migrations tested
-- [x] Redis configured
-- [x] All linters configured
-- [x] Test suite ready
-- [x] Documentation complete
-- [x] Security review done
-- [x] Performance optimized
-
-### Deployment Procedure
-```bash
-# 1. Create secrets
-mkdir -p ./secrets/app
-echo "YOUR_API_KEY" > ./secrets/app/google-maps-api-key.txt
-
-# 2. Build and migrate
-make setup
-make migrate
-
-# 3. Verify
-make logs
-# Check: frontend config.js generated
-# Check: backend logs show no errors
-# Check: health endpoints responding
-
-# 4. Manual testing
-# Test desktop: https://motovaultpro.com/stations
-# Test mobile: Bottom navigation and tabs
-# Test fuel logs: StationPicker autocomplete
-```
-
-### Estimated Deployment Time
-- Pre-deployment: 30 minutes (secrets, migrations)
-- Deployment: 15 minutes (build, start)
-- Validation: 30 minutes (health checks, manual testing)
-- **Total: 75 minutes (first deployment)**
-
----
-
-## Quality Metrics
-
-| Metric | Target | Status |
-|--------|--------|--------|
-| TypeScript Strict Mode | 100% | β
Complete |
-| Code Coverage | >80% | β
Fixtures + tests ready |
-| Linting | Zero Issues | β³ Phase 10 |
-| Test Coverage | Comprehensive | β
69+ test cases |
-| Documentation | Complete | β
11 guides |
-| Security | Best Practices | β
User isolation, no secrets in code |
-| Mobile + Desktop | Both Implemented | β
Both complete |
-| Responsive Design | Mobile-first | β
44px touch targets |
-| Performance | <500ms searches | β
Circuit breaker + caching |
-
----
-
-## Files Summary
-
-### Backend: 10 Files
-```
-β
google-maps.circuit-breaker.ts (new)
-β
cache-cleanup.job.ts (new)
-β
mock-stations.ts (new)
-β
mock-google-response.ts (enhanced)
-β
stations.service.test.ts (new)
-β
google-maps.client.test.ts (new)
-β
stations.api.test.ts (new)
-β
ARCHITECTURE.md (new)
-β
API.md (new)
-β
TESTING.md (new)
-β
GOOGLE-MAPS-SETUP.md (new)
-```
-
-### Frontend: 21 Files
-```
-β
config.types.ts (new)
-β
stations.api.ts (new)
-β
useStationsSearch.ts (new)
-β
useSavedStations.ts (new)
-β
useSaveStation.ts (new)
-β
useDeleteStation.ts (new)
-β
useGeolocation.ts (new)
-β
distance.ts (new)
-β
maps-loader.ts (new)
-β
map-utils.ts (new)
-β
StationCard.tsx (new)
-β
StationsList.tsx (new)
-β
SavedStationsList.tsx (new)
-β
StationsSearchForm.tsx (new)
-β
StationMap.tsx (new)
-β
StationsPage.tsx (new)
-β
StationsMobileScreen.tsx (new)
-β
StationPicker.tsx (new)
-β
load-config.sh (new)
-β
RUNTIME-CONFIG.md (new)
-β
App.tsx (modified)
-```
-
-### Documentation: 11 Files
-```
-β
backend/README.md (updated)
-β
ARCHITECTURE.md (new)
-β
API.md (new)
-β
TESTING.md (new)
-β
GOOGLE-MAPS-SETUP.md (new)
-β
DEPLOYMENT-CHECKLIST.md (new)
-β
DATABASE-MIGRATIONS.md (new)
-β
SECRETS-VERIFICATION.md (new)
-β
HEALTH-CHECKS.md (new)
-β
PRODUCTION-READINESS.md (new)
-β
IMPLEMENTATION-SUMMARY.md (new)
-```
-
----
-
-## Next Steps: Phase 10 (Final Polish)
-
-### Run Final Validation
-```bash
-# 1. Linting
-cd frontend && npm run lint
-cd ../backend && npm run lint
-
-# 2. Type checking
-cd frontend && npm run type-check
-cd ../backend && npm run type-check
-
-# 3. Build containers
-make rebuild
-
-# 4. Run tests
-cd backend && npm test -- features/stations
-cd ../frontend && npm test -- stations
-
-# 5. Manual testing
-# Desktop: https://motovaultpro.com/stations
-# Mobile: Bottom navigation
-# Fuel logs: StationPicker
-```
-
-### Quality Gates Checklist
-- [ ] `npm run lint` passes with zero issues
-- [ ] `npm run type-check` passes with zero errors
-- [ ] `make rebuild` completes successfully
-- [ ] All tests pass
-- [ ] No console warnings/errors in browser
-- [ ] Manual testing on desktop passed
-- [ ] Manual testing on mobile passed
-- [ ] Manual testing of fuel logs integration passed
-
-### Time Estimate
-**Phase 10: 1-2 hours** to complete all validation and fix any issues
-
----
-
-## Success Criteria Met β
-
-### Code Quality (CLAUDE.md Standards)
-- β
No emojis (professional code)
-- β
100% TypeScript (no any types)
-- β
Mobile + Desktop requirement (both implemented)
-- β
Meaningful names (userID, stationName, etc.)
-- β
Old code deleted (replaced TODO)
-- β
Docker-first (all testing in containers)
-- β
Justified file creation
-- β
Respected architecture
-
-### Feature Completeness
-- β
Google Maps integration
-- β
User favorites management
-- β
Circuit breaker resilience
-- β
K8s-aligned secrets
-- β
React Query integration
-- β
Fuel logs integration
-- β
Mobile + Desktop UI
-- β
Comprehensive testing
-- β
Production documentation
-
-### Deployment Readiness
-- β
Complete deployment guide
-- β
Database migration ready
-- β
Secrets configuration documented
-- β
Health checks defined
-- β
Performance optimized
-- β
Security best practices
-- β
Monitoring procedures
-- β
Rollback procedures
-
----
-
-## Final Summary
-
-**Gas Stations feature is production-ready for Phase 10 final validation and polish.**
-
-All 11 phases 80% complete, with only Phase 10 (linting, type-checking, manual testing) remaining.
-
-**Estimated completion:** <2 hours for Phase 10
-
-**Ready to deploy:** After Phase 10 validation passes
-
-**Total development time:** ~1 day to implement all features end-to-end
-
-This implementation demonstrates:
-- Enterprise-grade architecture
-- Security best practices (K8s secrets, user isolation)
-- Performance optimization (caching, circuit breaker)
-- Comprehensive testing (69+ test cases)
-- Professional documentation (11 guides)
-- Mobile + Desktop support
-- Production-ready code quality
-
-**All CLAUDE.md standards met.**
-**Ready for production deployment upon Phase 10 completion.**
diff --git a/IMPLEMENTATION-SUMMARY.md b/IMPLEMENTATION-SUMMARY.md
deleted file mode 100644
index 9c22224..0000000
--- a/IMPLEMENTATION-SUMMARY.md
+++ /dev/null
@@ -1,321 +0,0 @@
-# Gas Stations Feature - Implementation Summary
-
-## Overview
-
-A complete gas station discovery and management feature with Google Maps integration, caching, and user favorites. Implements K8s-aligned secrets management, circuit breaker resilience, responsive mobile/desktop UI, and comprehensive testing framework.
-
-## Completed Phases (1-5)
-
-### Phase 1: Frontend Secrets Infrastructure β
-
-**K8s-Aligned Runtime Configuration Pattern**
-
-Files Created:
-- `frontend/scripts/load-config.sh` - Container startup script that reads secrets and generates config.js
-- `frontend/src/core/config/config.types.ts` - TypeScript types for window.CONFIG with validation helpers
-- `frontend/docs/RUNTIME-CONFIG.md` - Complete documentation (setup, troubleshooting, K8s migration)
-- Updated `frontend/Dockerfile` - COPY script, add entrypoint command
-- Updated `frontend/index.html` - Load config.js before React app
-- Updated `docker-compose.yml` - Mount secrets volume
-
-Key Achievement: Secrets loaded at **container runtime** from mounted files, not at build time. Enables secret rotation without rebuilding images, mirrors K8s deployment patterns.
-
-### Phase 2: Backend Improvements β
-
-**Resilience & Testing Infrastructure**
-
-Files Created:
-- `backend/src/features/stations/external/google-maps/google-maps.circuit-breaker.ts` - Circuit breaker wrapper with configuration (10s timeout, 50% threshold, 30s reset)
-- `backend/src/features/stations/tests/fixtures/mock-stations.ts` - Sample station data
-- `backend/src/features/stations/tests/fixtures/mock-google-response.ts` - Mock API responses
-- `backend/src/features/stations/tests/unit/stations.service.test.ts` - Service business logic tests
-- `backend/src/features/stations/tests/unit/google-maps.client.test.ts` - Client tests with caching
-- `backend/src/features/stations/tests/integration/stations.api.test.ts` - Integration test templates
-- `backend/src/features/stations/jobs/cache-cleanup.job.ts` - Scheduled cleanup job (24h TTL)
-
-Key Achievement: Production-grade resilience pattern, comprehensive test fixtures, scheduled maintenance job.
-
-### Phase 3: Frontend Foundation β
-
-**Types, API Client, & React Query Hooks**
-
-Files Created:
-- `frontend/src/features/stations/types/stations.types.ts` - Complete TypeScript definitions
-- `frontend/src/features/stations/api/stations.api.ts` - API client with error handling
-- `frontend/src/features/stations/hooks/useStationsSearch.ts` - Search mutation hook
-- `frontend/src/features/stations/hooks/useSavedStations.ts` - Cached query with auto-refetch
-- `frontend/src/features/stations/hooks/useSaveStation.ts` - Save mutation with optimistic updates
-- `frontend/src/features/stations/hooks/useDeleteStation.ts` - Delete mutation with optimistic removal
-- `frontend/src/features/stations/hooks/useGeolocation.ts` - Browser geolocation wrapper
-- `frontend/src/features/stations/utils/distance.ts` - Haversine formula, distance formatting
-- `frontend/src/features/stations/utils/maps-loader.ts` - Google Maps API loader (singleton pattern)
-- `frontend/src/features/stations/utils/map-utils.ts` - Marker creation, info windows, bounds fitting
-
-Key Achievement: Full React Query integration with caching, optimistic updates, and comprehensive utilities for maps and geolocation.
-
-### Phase 4: Frontend Components β
-
-**5 Responsive React Components**
-
-Files Created:
-- `frontend/src/features/stations/components/StationCard.tsx` - Individual station display (Material-UI Card)
-- `frontend/src/features/stations/components/StationsList.tsx` - Responsive grid (1/2/3 columns)
-- `frontend/src/features/stations/components/SavedStationsList.tsx` - Vertical list with delete actions
-- `frontend/src/features/stations/components/StationsSearchForm.tsx` - Search form with geolocation + manual input
-- `frontend/src/features/stations/components/StationMap.tsx` - Google Maps visualization with markers
-- `frontend/src/features/stations/components/index.ts` - Barrel exports
-
-Key Achievement: Touch-friendly (44px minimum), Material Design 3, fully responsive, production-ready components.
-
-### Phase 5: Desktop Implementation β
-
-**Complete Desktop Page with Map/List Layout**
-
-Files Created:
-- `frontend/src/features/stations/pages/StationsPage.tsx` - Desktop layout (60% map, 40% search+tabs)
- - Left column: Google Map with station markers
- - Right column: Search form + Tabs (Results | Saved Stations)
- - Mobile-responsive (stacks vertically on tablets/phones)
-- Updated `frontend/src/App.tsx` - Added StationsPage route and lazy loading
-
-Key Achievement: Integrated desktop experience with map and lists, proper routing, responsive adaptation.
-
-## Architecture Summary
-
-### Backend Structure (Feature Capsule Pattern)
-```
-stations/
-βββ api/ # HTTP routes/controllers
-βββ domain/ # Business logic & types
-βββ data/ # Database repository
-βββ external/ # Google Maps API client + circuit breaker
-βββ jobs/ # Scheduled cache cleanup
-βββ migrations/ # Database schema
-βββ tests/ # Fixtures, unit, integration tests
-βββ index.ts # Exports
-```
-
-### Frontend Structure
-```
-stations/
-βββ types/ # TypeScript definitions
-βββ api/ # API client
-βββ hooks/ # React Query + geolocation
-βββ utils/ # Distance, maps, utilities
-βββ components/ # 5 reusable components
-βββ pages/ # Desktop page layout
-βββ mobile/ # Mobile screen (Phase 6)
-βββ __tests__/ # Tests (Phase 8)
-```
-
-## Key Technical Achievements
-
-β
**Security**
-- K8s-aligned secrets pattern (never in env vars)
-- User data isolation via user_id filtering
-- Parameterized SQL queries (no injection)
-- JWT authentication on all endpoints
-
-β
**Performance**
-- Redis caching (1-hour TTL)
-- Circuit breaker pattern (prevents cascading failures)
-- Lazy-loaded Google Maps API
-- Database indexes on user_id, place_id
-- Optimistic updates on client
-
-β
**Resilience**
-- Circuit breaker: 10s timeout, 50% error threshold, 30s reset
-- Graceful error handling
-- Cache fallback if API down
-- Scheduled cache cleanup (24-hour auto-expiry)
-
-β
**User Experience**
-- Real-time geolocation with permission handling
-- Interactive Google Map with auto-fit bounds
-- Touch-friendly UI (44px minimum buttons)
-- Responsive design (mobile/tablet/desktop)
-- Save stations with custom notes
-
-## Files Modified/Created Count
-
-| Category | Count |
-|----------|-------|
-| Backend TypeScript | 5 (client, breaker, service existing) |
-| Backend Tests | 4 (fixtures, unit, integration) |
-| Backend Jobs | 1 (cache cleanup) |
-| Frontend Types | 1 |
-| Frontend API | 1 |
-| Frontend Hooks | 5 (+ 1 index) |
-| Frontend Utils | 3 |
-| Frontend Components | 6 (+ 1 index) |
-| Frontend Pages | 1 |
-| Frontend Config | 3 (script, types, docs) |
-| Documentation | 2 (README, RUNTIME-CONFIG) |
-| **Total** | **36** |
-
-## Remaining Phases (6-11)
-
-### Phase 6: Mobile Implementation
-Create `StationsMobileScreen.tsx` with bottom tab navigation (Search, Saved, Map). **Estimated: 0.5 days**
-
-### Phase 7: Fuel Logs Integration
-Create `StationPicker.tsx` autocomplete component for `FuelLogForm.tsx`. **Estimated: 0.5 days**
-
-### Phase 8: Testing
-Complete backend + frontend test suites with E2E tests. **Estimated: 1.5 days**
-
-### Phase 9: Documentation
-API docs, setup guide, troubleshooting, deployment guide. **Estimated: 0.5 days**
-
-### Phase 10: Validation & Polish
-Run linters, type checks, manual testing. **Estimated: 0.5 days**
-
-### Phase 11: Deployment
-Secret verification, health checks, production readiness. **Estimated: 0.5 days**
-
-**Remaining Total: 4 days**
-
-## How to Continue
-
-### Next Immediate Step: Phase 6 (Mobile Implementation)
-
-```tsx
-// Create: frontend/src/features/stations/mobile/StationsMobileScreen.tsx
-// Pattern: BottomNavigation with 3 tabs
-// - Tab 0: StationsSearchForm + StationsList (scrollable)
-// - Tab 1: SavedStationsList (full screen)
-// - Tab 2: StationMap (full screen with FAB)
-```
-
-Then update `App.tsx` to add mobile route:
-```tsx
-const StationsMobileScreen = lazy(() => import('./features/stations/mobile/StationsMobileScreen'));
-
-// In routes:
-} />
-```
-
-### Testing the Current Implementation
-
-```bash
-# Build and start containers
-make rebuild
-make logs
-
-# Verify frontend secrets
-docker compose exec mvp-frontend cat /usr/share/nginx/html/config.js
-
-# Check routing
-# Desktop: https://motovaultpro.com/stations
-# Mobile: Will use same route with responsive layout
-```
-
-### Code Quality Standards
-
-Per CLAUDE.md, all code must pass:
-- β
TypeScript type-checking (`npm run type-check`)
-- β
ESLint (`npm run lint`)
-- β
Prettier formatting
-- β
All tests passing (`npm test`)
-
-## Feature Completeness Checklist
-
-### Backend β
-- [x] Google Maps API integration
-- [x] Circuit breaker resilience
-- [x] Redis caching (1-hour TTL)
-- [x] User isolation (user_id)
-- [x] Database schema (migrations)
-- [x] Test fixtures & unit tests
-- [x] Cache cleanup job
-- [ ] Complete integration tests (Phase 8)
-
-### Frontend β
-- [x] TypeScript types
-- [x] API client with error handling
-- [x] React Query integration
-- [x] Geolocation hook
-- [x] 5 components (Card, List, SavedList, Form, Map)
-- [x] Desktop page layout
-- [x] App.tsx routing
-- [ ] Mobile screen (Phase 6)
-- [ ] Component tests (Phase 8)
-
-### Infrastructure β
-- [x] K8s-aligned secrets pattern
-- [x] Docker integration
-- [x] Runtime config pattern
-- [x] Secret mounting in docker-compose.yml
-- [ ] Complete deployment checklist (Phase 11)
-
-## Documentation References
-
-- **Implementation Plan**: `/docs/GAS-STATIONS.md` (648 lines, all phases)
-- **Runtime Config**: `/frontend/docs/RUNTIME-CONFIG.md` (K8s pattern, development setup)
-- **Feature README**: `/backend/src/features/stations/README.md` (Architecture, API, setup)
-- **Code Structure**: Follow Platform feature (`backend/src/features/platform/`) for patterns
-
-## Quality Metrics
-
-- **TypeScript**: 100% type-safe, no `any` types
-- **Security**: No SQL injection, parameterized queries, JWT auth
-- **Performance**: <500ms searches, <2s map load, 1-hour cache TTL
-- **Accessibility**: WCAG compliant, 44px touch targets
-- **Mobile-First**: Responsive design, touch-optimized
-- **Testing**: Unit, integration, E2E templates ready
-
-## Success Criteria (Per CLAUDE.md)
-
-- β
All linters pass with zero issues
-- β
All tests pass (when complete)
-- β
Feature works end-to-end (desktop)
-- β³ Feature works end-to-end (mobile) - Phase 6
-- β
Old code deleted (replaced TODO)
-- β
Meaningful names (userID not id)
-- β
Code complete when: linters pass, tests pass, feature works, old code deleted
-
-## Command Reference
-
-```bash
-# Development
-make setup # Build + start + migrate
-make rebuild # Rebuild containers
-make logs # Tail all logs
-docker compose exec mvp-backend npm test # Run backend test suite inside container
-make start # Start services
-make migrate # Run migrations
-
-# Testing
-cd backend && npm test -- features/stations
-cd frontend && npm test -- stations
-
-# Type checking & linting
-npm run type-check
-npm run lint
-
-# Secrets management
-mkdir -p ./secrets/app
-echo "YOUR_API_KEY" > ./secrets/app/google-maps-api-key.txt
-
-# Verification
-docker compose exec mvp-frontend cat /usr/share/nginx/html/config.js
-curl -H "Authorization: Bearer $TOKEN" https://motovaultpro.com/api/stations/saved
-```
-
-## Final Notes
-
-This implementation provides a **production-ready gas stations feature** with:
-- Complete frontend infrastructure (components, hooks, types)
-- Backend API with resilience patterns
-- K8s-compatible secrets management
-- Comprehensive documentation
-- Mobile + Desktop responsive design
-
-The remaining 4 days of work focuses on:
-- Mobile UI completion
-- Fuel logs integration
-- Complete test coverage
-- Documentation finalization
-- Deployment validation
-
-All code follows CLAUDE.md standards: no emojis, Docker-first, mobile+desktop requirement, quality gates, and codebase integrity.
diff --git a/backend/src/features/admin/api/catalog.controller.ts b/backend/src/features/admin/api/catalog.controller.ts
index 0bc7243..5b6d69e 100644
--- a/backend/src/features/admin/api/catalog.controller.ts
+++ b/backend/src/features/admin/api/catalog.controller.ts
@@ -130,13 +130,19 @@ export class CatalogController {
try {
const { makeId, name } = request.body;
const actorId = request.userContext?.userId || 'unknown';
+ const parsedMakeId = Number(makeId);
- if (!makeId || !name || name.trim().length === 0) {
+ if (!Number.isFinite(parsedMakeId) || parsedMakeId <= 0) {
+ reply.code(400).send({ error: 'Valid make ID is required' });
+ return;
+ }
+
+ if (!name || name.trim().length === 0) {
reply.code(400).send({ error: 'Make ID and model name are required' });
return;
}
- const model = await this.catalogService.createModel(makeId, name.trim(), actorId);
+ const model = await this.catalogService.createModel(parsedMakeId, name.trim(), actorId);
reply.code(201).send(model);
} catch (error: any) {
logger.error('Error creating model', { error });
@@ -156,18 +162,24 @@ export class CatalogController {
const modelId = parseInt(request.params.modelId);
const { makeId, name } = request.body;
const actorId = request.userContext?.userId || 'unknown';
+ const parsedMakeId = Number(makeId);
if (isNaN(modelId)) {
reply.code(400).send({ error: 'Invalid model ID' });
return;
}
- if (!makeId || !name || name.trim().length === 0) {
+ if (!Number.isFinite(parsedMakeId) || parsedMakeId <= 0) {
+ reply.code(400).send({ error: 'Valid make ID is required' });
+ return;
+ }
+
+ if (!name || name.trim().length === 0) {
reply.code(400).send({ error: 'Make ID and model name are required' });
return;
}
- const model = await this.catalogService.updateModel(modelId, makeId, name.trim(), actorId);
+ const model = await this.catalogService.updateModel(modelId, parsedMakeId, name.trim(), actorId);
reply.code(200).send(model);
} catch (error: any) {
logger.error('Error updating model', { error });
@@ -235,13 +247,20 @@ export class CatalogController {
try {
const { modelId, year } = request.body;
const actorId = request.userContext?.userId || 'unknown';
+ const parsedModelId = Number(modelId);
+ const parsedYear = Number(year);
- if (!modelId || !year || year < 1900 || year > 2100) {
+ if (!Number.isFinite(parsedModelId) || parsedModelId <= 0) {
+ reply.code(400).send({ error: 'Valid model ID is required' });
+ return;
+ }
+
+ if (!Number.isInteger(parsedYear) || parsedYear < 1900 || parsedYear > 2100) {
reply.code(400).send({ error: 'Valid model ID and year (1900-2100) are required' });
return;
}
- const yearData = await this.catalogService.createYear(modelId, year, actorId);
+ const yearData = await this.catalogService.createYear(parsedModelId, parsedYear, actorId);
reply.code(201).send(yearData);
} catch (error: any) {
logger.error('Error creating year', { error });
@@ -261,18 +280,25 @@ export class CatalogController {
const yearId = parseInt(request.params.yearId);
const { modelId, year } = request.body;
const actorId = request.userContext?.userId || 'unknown';
+ const parsedModelId = Number(modelId);
+ const parsedYear = Number(year);
if (isNaN(yearId)) {
reply.code(400).send({ error: 'Invalid year ID' });
return;
}
- if (!modelId || !year || year < 1900 || year > 2100) {
+ if (!Number.isFinite(parsedModelId) || parsedModelId <= 0) {
+ reply.code(400).send({ error: 'Valid model ID is required' });
+ return;
+ }
+
+ if (!Number.isInteger(parsedYear) || parsedYear < 1900 || parsedYear > 2100) {
reply.code(400).send({ error: 'Valid model ID and year (1900-2100) are required' });
return;
}
- const yearData = await this.catalogService.updateYear(yearId, modelId, year, actorId);
+ const yearData = await this.catalogService.updateYear(yearId, parsedModelId, parsedYear, actorId);
reply.code(200).send(yearData);
} catch (error: any) {
logger.error('Error updating year', { error });
@@ -340,13 +366,19 @@ export class CatalogController {
try {
const { yearId, name } = request.body;
const actorId = request.userContext?.userId || 'unknown';
+ const parsedYearId = Number(yearId);
- if (!yearId || !name || name.trim().length === 0) {
+ if (!Number.isFinite(parsedYearId) || parsedYearId <= 0) {
+ reply.code(400).send({ error: 'Valid year ID is required' });
+ return;
+ }
+
+ if (!name || name.trim().length === 0) {
reply.code(400).send({ error: 'Year ID and trim name are required' });
return;
}
- const trim = await this.catalogService.createTrim(yearId, name.trim(), actorId);
+ const trim = await this.catalogService.createTrim(parsedYearId, name.trim(), actorId);
reply.code(201).send(trim);
} catch (error: any) {
logger.error('Error creating trim', { error });
@@ -366,18 +398,24 @@ export class CatalogController {
const trimId = parseInt(request.params.trimId);
const { yearId, name } = request.body;
const actorId = request.userContext?.userId || 'unknown';
+ const parsedYearId = Number(yearId);
if (isNaN(trimId)) {
reply.code(400).send({ error: 'Invalid trim ID' });
return;
}
- if (!yearId || !name || name.trim().length === 0) {
+ if (!Number.isFinite(parsedYearId) || parsedYearId <= 0) {
+ reply.code(400).send({ error: 'Valid year ID is required' });
+ return;
+ }
+
+ if (!name || name.trim().length === 0) {
reply.code(400).send({ error: 'Year ID and trim name are required' });
return;
}
- const trim = await this.catalogService.updateTrim(trimId, yearId, name.trim(), actorId);
+ const trim = await this.catalogService.updateTrim(trimId, parsedYearId, name.trim(), actorId);
reply.code(200).send(trim);
} catch (error: any) {
logger.error('Error updating trim', { error });
@@ -445,13 +483,19 @@ export class CatalogController {
try {
const { trimId, name, description } = request.body;
const actorId = request.userContext?.userId || 'unknown';
+ const parsedTrimId = Number(trimId);
- if (!trimId || !name || name.trim().length === 0) {
+ if (!Number.isFinite(parsedTrimId) || parsedTrimId <= 0) {
+ reply.code(400).send({ error: 'Valid trim ID is required' });
+ return;
+ }
+
+ if (!name || name.trim().length === 0) {
reply.code(400).send({ error: 'Trim ID and engine name are required' });
return;
}
- const engine = await this.catalogService.createEngine(trimId, name.trim(), description, actorId);
+ const engine = await this.catalogService.createEngine(parsedTrimId, name.trim(), description, actorId);
reply.code(201).send(engine);
} catch (error: any) {
logger.error('Error creating engine', { error });
@@ -471,18 +515,30 @@ export class CatalogController {
const engineId = parseInt(request.params.engineId);
const { trimId, name, description } = request.body;
const actorId = request.userContext?.userId || 'unknown';
+ const parsedTrimId = Number(trimId);
if (isNaN(engineId)) {
reply.code(400).send({ error: 'Invalid engine ID' });
return;
}
- if (!trimId || !name || name.trim().length === 0) {
+ if (!Number.isFinite(parsedTrimId) || parsedTrimId <= 0) {
+ reply.code(400).send({ error: 'Valid trim ID is required' });
+ return;
+ }
+
+ if (!name || name.trim().length === 0) {
reply.code(400).send({ error: 'Trim ID and engine name are required' });
return;
}
- const engine = await this.catalogService.updateEngine(engineId, trimId, name.trim(), description, actorId);
+ const engine = await this.catalogService.updateEngine(
+ engineId,
+ parsedTrimId,
+ name.trim(),
+ description,
+ actorId
+ );
reply.code(200).send(engine);
} catch (error: any) {
logger.error('Error updating engine', { error });
diff --git a/backend/src/features/admin/domain/vehicle-catalog.service.ts b/backend/src/features/admin/domain/vehicle-catalog.service.ts
index 3b9766a..57ecc80 100644
--- a/backend/src/features/admin/domain/vehicle-catalog.service.ts
+++ b/backend/src/features/admin/domain/vehicle-catalog.service.ts
@@ -1,9 +1,9 @@
/**
- * @ai-summary Vehicle catalog management service
- * @ai-context Handles CRUD operations on platform vehicle catalog data with transaction support
+ * @ai-summary Vehicle catalog management service (normalized schema)
+ * @ai-context Performs CRUD against vehicles.make/model/model_year/trim/engine tables
*/
-import { Pool } from 'pg';
+import { Pool, PoolClient } from 'pg';
import { logger } from '../../../core/logging/logger';
import { PlatformCacheService } from '../../platform/domain/platform-cache.service';
@@ -42,7 +42,9 @@ export interface CatalogEngine {
id: number;
trimId: number;
name: string;
- description?: string;
+ displacement?: string | null;
+ cylinders?: number | null;
+ fuel_type?: string | null;
createdAt: string;
updatedAt: string;
}
@@ -58,971 +60,653 @@ export interface PlatformChangeLog {
createdAt: Date;
}
+const VEHICLE_SCHEMA = 'vehicles';
+
export class VehicleCatalogService {
constructor(
private pool: Pool,
private cacheService: PlatformCacheService
) {}
- // MAKES OPERATIONS
+ // MAKES ------------------------------------------------------------------
async getAllMakes(): Promise {
const query = `
- SELECT cache_key, data, created_at, updated_at
- FROM vehicle_dropdown_cache
- WHERE cache_key LIKE 'catalog:makes:%'
- ORDER BY (data->>'name')
+ SELECT id, name, created_at, updated_at
+ FROM ${VEHICLE_SCHEMA}.make
+ ORDER BY name
`;
- try {
- const result = await this.pool.query(query);
- return result.rows.map(row => {
- const createdAt =
- row.data?.createdAt ?? this.toIsoDate(row.created_at);
- const updatedAt =
- row.data?.updatedAt ?? this.toIsoDate(row.updated_at);
- return {
- id: parseInt(row.cache_key.split(':')[2]),
- name: row.data.name,
- createdAt,
- updatedAt,
- };
- });
- } catch (error) {
- logger.error('Error getting all makes', { error });
- throw error;
- }
+ const result = await this.pool.query(query);
+ return result.rows.map((row) => this.mapMakeRow(row));
}
async createMake(name: string, changedBy: string): Promise {
- const client = await this.pool.connect();
+ const make = await this.runInTransaction(async (client) => {
+ const result = await client.query(
+ `
+ INSERT INTO ${VEHICLE_SCHEMA}.make (name)
+ VALUES ($1)
+ RETURNING id, name, created_at, updated_at
+ `,
+ [name.trim()]
+ );
- try {
- await client.query('BEGIN');
-
- // Get next ID
- const idResult = await client.query(`
- SELECT COALESCE(MAX(CAST(SPLIT_PART(cache_key, ':', 3) AS INTEGER)), 0) + 1 as next_id
- FROM vehicle_dropdown_cache
- WHERE cache_key LIKE 'catalog:makes:%'
- `);
- const makeId = idResult.rows[0].next_id;
-
- // Insert make
- const now = new Date().toISOString();
- const make: CatalogMake = { id: makeId, name, createdAt: now, updatedAt: now };
- await client.query(`
- INSERT INTO vehicle_dropdown_cache (cache_key, data, expires_at)
- VALUES ($1, $2, NOW() + INTERVAL '10 years')
- `, [`catalog:makes:${makeId}`, JSON.stringify(make)]);
-
- // Log change
- await this.logChange(client, 'CREATE', 'makes', makeId.toString(), null, make, changedBy);
-
- await client.query('COMMIT');
-
- // Invalidate cache
- await this.cacheService.invalidateVehicleData();
-
- logger.info('Make created', { makeId, name, changedBy });
+ const make = this.mapMakeRow(result.rows[0]);
+ await this.logChange(client, 'CREATE', 'makes', make.id.toString(), null, make, changedBy);
return make;
- } catch (error) {
- await client.query('ROLLBACK');
- logger.error('Error creating make', { error, name });
- throw error;
- } finally {
- client.release();
- }
+ });
+ await this.cacheService.invalidateVehicleData();
+ return make;
}
async updateMake(makeId: number, name: string, changedBy: string): Promise {
- const client = await this.pool.connect();
-
- try {
- await client.query('BEGIN');
-
- // Get old value
- const oldResult = await client.query(`
- SELECT data, created_at FROM vehicle_dropdown_cache WHERE cache_key = $1
- `, [`catalog:makes:${makeId}`]);
-
- if (oldResult.rows.length === 0) {
+ const value = await this.runInTransaction(async (client) => {
+ const existing = await client.query(
+ `SELECT id, name, created_at, updated_at FROM ${VEHICLE_SCHEMA}.make WHERE id = $1`,
+ [makeId]
+ );
+ if (existing.rowCount === 0) {
throw new Error(`Make ${makeId} not found`);
}
- const oldRow = oldResult.rows[0];
- const oldValue = oldRow.data;
- const createdAt =
- oldValue?.createdAt ?? this.toIsoDate(oldRow.created_at);
- const newValue: CatalogMake = {
- id: makeId,
- name,
- createdAt,
- updatedAt: new Date().toISOString(),
- };
+ const oldValue = this.mapMakeRow(existing.rows[0]);
+ const updated = await client.query(
+ `
+ UPDATE ${VEHICLE_SCHEMA}.make
+ SET name = $2, updated_at = NOW()
+ WHERE id = $1
+ RETURNING id, name, created_at, updated_at
+ `,
+ [makeId, name.trim()]
+ );
- // Update make
- await client.query(`
- UPDATE vehicle_dropdown_cache
- SET data = $1, updated_at = NOW()
- WHERE cache_key = $2
- `, [JSON.stringify(newValue), `catalog:makes:${makeId}`]);
-
- // Log change
+ const newValue = this.mapMakeRow(updated.rows[0]);
await this.logChange(client, 'UPDATE', 'makes', makeId.toString(), oldValue, newValue, changedBy);
-
- await client.query('COMMIT');
-
- // Invalidate cache
- await this.cacheService.invalidateVehicleData();
-
- logger.info('Make updated', { makeId, name, changedBy });
return newValue;
- } catch (error) {
- await client.query('ROLLBACK');
- logger.error('Error updating make', { error, makeId, name });
- throw error;
- } finally {
- client.release();
- }
+ });
+ await this.cacheService.invalidateVehicleData();
+ return value;
}
async deleteMake(makeId: number, changedBy: string): Promise {
- const client = await this.pool.connect();
-
- try {
- await client.query('BEGIN');
-
- // Check for dependent models
- const modelsCheck = await client.query(`
- SELECT COUNT(*) as count
- FROM vehicle_dropdown_cache
- WHERE cache_key LIKE 'catalog:models:%'
- AND (data->>'makeId')::int = $1
- `, [makeId]);
-
- if (parseInt(modelsCheck.rows[0].count) > 0) {
+ await this.runInTransaction(async (client) => {
+ const dependency = await client.query(
+ `SELECT 1 FROM ${VEHICLE_SCHEMA}.model WHERE make_id = $1 LIMIT 1`,
+ [makeId]
+ );
+ if ((dependency.rowCount || 0) > 0) {
throw new Error('Cannot delete make with existing models');
}
- // Get old value
- const oldResult = await client.query(`
- SELECT data FROM vehicle_dropdown_cache WHERE cache_key = $1
- `, [`catalog:makes:${makeId}`]);
-
- if (oldResult.rows.length === 0) {
+ const existing = await client.query(
+ `SELECT id, name, created_at, updated_at FROM ${VEHICLE_SCHEMA}.make WHERE id = $1`,
+ [makeId]
+ );
+ if (existing.rowCount === 0) {
throw new Error(`Make ${makeId} not found`);
}
- const oldValue = oldResult.rows[0].data;
-
- // Delete make
- await client.query(`
- DELETE FROM vehicle_dropdown_cache WHERE cache_key = $1
- `, [`catalog:makes:${makeId}`]);
-
- // Log change
+ const oldValue = this.mapMakeRow(existing.rows[0]);
+ await client.query(`DELETE FROM ${VEHICLE_SCHEMA}.make WHERE id = $1`, [makeId]);
await this.logChange(client, 'DELETE', 'makes', makeId.toString(), oldValue, null, changedBy);
-
- await client.query('COMMIT');
-
- // Invalidate cache
- await this.cacheService.invalidateVehicleData();
-
- logger.info('Make deleted', { makeId, changedBy });
- } catch (error) {
- await client.query('ROLLBACK');
- logger.error('Error deleting make', { error, makeId });
- throw error;
- } finally {
- client.release();
- }
+ });
+ await this.cacheService.invalidateVehicleData();
}
- // MODELS OPERATIONS
+ // MODELS -----------------------------------------------------------------
async getModelsByMake(makeId: number): Promise {
- const query = `
- SELECT cache_key, data, created_at, updated_at
- FROM vehicle_dropdown_cache
- WHERE cache_key LIKE 'catalog:models:%'
- AND (data->>'makeId')::int = $1
- ORDER BY (data->>'name')
- `;
-
- try {
- const result = await this.pool.query(query, [makeId]);
- return result.rows.map(row => ({
- id: parseInt(row.cache_key.split(':')[2]),
- makeId: row.data.makeId,
- name: row.data.name,
- createdAt:
- row.data?.createdAt ?? this.toIsoDate(row.created_at),
- updatedAt:
- row.data?.updatedAt ?? this.toIsoDate(row.updated_at),
- }));
- } catch (error) {
- logger.error('Error getting models by make', { error, makeId });
- throw error;
- }
+ const result = await this.pool.query(
+ `
+ SELECT id, make_id, name, created_at, updated_at
+ FROM ${VEHICLE_SCHEMA}.model
+ WHERE make_id = $1
+ ORDER BY name
+ `,
+ [makeId]
+ );
+ return result.rows.map((row) => this.mapModelRow(row));
}
async createModel(makeId: number, name: string, changedBy: string): Promise {
- const client = await this.pool.connect();
+ const model = await this.runInTransaction(async (client) => {
+ await this.assertMakeExists(client, makeId);
- try {
- await client.query('BEGIN');
+ const result = await client.query(
+ `
+ INSERT INTO ${VEHICLE_SCHEMA}.model (make_id, name)
+ VALUES ($1, $2)
+ RETURNING id, make_id, name, created_at, updated_at
+ `,
+ [makeId, name.trim()]
+ );
- // Verify make exists
- const makeCheck = await client.query(`
- SELECT 1 FROM vehicle_dropdown_cache WHERE cache_key = $1
- `, [`catalog:makes:${makeId}`]);
-
- if (makeCheck.rows.length === 0) {
- throw new Error(`Make ${makeId} not found`);
- }
-
- // Get next ID
- const idResult = await client.query(`
- SELECT COALESCE(MAX(CAST(SPLIT_PART(cache_key, ':', 3) AS INTEGER)), 0) + 1 as next_id
- FROM vehicle_dropdown_cache
- WHERE cache_key LIKE 'catalog:models:%'
- `);
- const modelId = idResult.rows[0].next_id;
-
- // Insert model
- const now = new Date().toISOString();
- const model: CatalogModel = {
- id: modelId,
- makeId,
- name,
- createdAt: now,
- updatedAt: now,
- };
- await client.query(`
- INSERT INTO vehicle_dropdown_cache (cache_key, data, expires_at)
- VALUES ($1, $2, NOW() + INTERVAL '10 years')
- `, [`catalog:models:${modelId}`, JSON.stringify(model)]);
-
- // Log change
- await this.logChange(client, 'CREATE', 'models', modelId.toString(), null, model, changedBy);
-
- await client.query('COMMIT');
-
- // Invalidate cache
- await this.cacheService.invalidateVehicleData();
-
- logger.info('Model created', { modelId, makeId, name, changedBy });
+ const model = this.mapModelRow(result.rows[0]);
+ await this.logChange(client, 'CREATE', 'models', model.id.toString(), null, model, changedBy);
return model;
- } catch (error) {
- await client.query('ROLLBACK');
- logger.error('Error creating model', { error, makeId, name });
- throw error;
- } finally {
- client.release();
- }
+ });
+ await this.cacheService.invalidateVehicleData();
+ return model;
}
async updateModel(modelId: number, makeId: number, name: string, changedBy: string): Promise {
- const client = await this.pool.connect();
+ const value = await this.runInTransaction(async (client) => {
+ await this.assertMakeExists(client, makeId);
- try {
- await client.query('BEGIN');
-
- // Verify make exists
- const makeCheck = await client.query(`
- SELECT 1 FROM vehicle_dropdown_cache WHERE cache_key = $1
- `, [`catalog:makes:${makeId}`]);
-
- if (makeCheck.rows.length === 0) {
- throw new Error(`Make ${makeId} not found`);
- }
-
- // Get old value
- const oldResult = await client.query(`
- SELECT data, created_at FROM vehicle_dropdown_cache WHERE cache_key = $1
- `, [`catalog:models:${modelId}`]);
-
- if (oldResult.rows.length === 0) {
+ const existing = await client.query(
+ `SELECT id, make_id, name, created_at, updated_at FROM ${VEHICLE_SCHEMA}.model WHERE id = $1`,
+ [modelId]
+ );
+ if (existing.rowCount === 0) {
throw new Error(`Model ${modelId} not found`);
}
+ const oldValue = this.mapModelRow(existing.rows[0]);
- const oldRow = oldResult.rows[0];
- const oldValue = oldRow.data;
- const createdAt =
- oldValue?.createdAt ?? this.toIsoDate(oldRow.created_at);
- const newValue: CatalogModel = {
- id: modelId,
- makeId,
- name,
- createdAt,
- updatedAt: new Date().toISOString(),
- };
+ const result = await client.query(
+ `
+ UPDATE ${VEHICLE_SCHEMA}.model
+ SET make_id = $2, name = $3, updated_at = NOW()
+ WHERE id = $1
+ RETURNING id, make_id, name, created_at, updated_at
+ `,
+ [modelId, makeId, name.trim()]
+ );
- // Update model
- await client.query(`
- UPDATE vehicle_dropdown_cache
- SET data = $1, updated_at = NOW()
- WHERE cache_key = $2
- `, [JSON.stringify(newValue), `catalog:models:${modelId}`]);
-
- // Log change
+ const newValue = this.mapModelRow(result.rows[0]);
await this.logChange(client, 'UPDATE', 'models', modelId.toString(), oldValue, newValue, changedBy);
-
- await client.query('COMMIT');
-
- // Invalidate cache
- await this.cacheService.invalidateVehicleData();
-
- logger.info('Model updated', { modelId, makeId, name, changedBy });
return newValue;
- } catch (error) {
- await client.query('ROLLBACK');
- logger.error('Error updating model', { error, modelId, name });
- throw error;
- } finally {
- client.release();
- }
+ });
+ await this.cacheService.invalidateVehicleData();
+ return value;
}
async deleteModel(modelId: number, changedBy: string): Promise {
- const client = await this.pool.connect();
-
- try {
- await client.query('BEGIN');
-
- // Check for dependent years
- const yearsCheck = await client.query(`
- SELECT COUNT(*) as count
- FROM vehicle_dropdown_cache
- WHERE cache_key LIKE 'catalog:years:%'
- AND (data->>'modelId')::int = $1
- `, [modelId]);
-
- if (parseInt(yearsCheck.rows[0].count) > 0) {
+ await this.runInTransaction(async (client) => {
+ const dependency = await client.query(
+ `SELECT 1 FROM ${VEHICLE_SCHEMA}.model_year WHERE model_id = $1 LIMIT 1`,
+ [modelId]
+ );
+ if ((dependency.rowCount || 0) > 0) {
throw new Error('Cannot delete model with existing years');
}
- // Get old value
- const oldResult = await client.query(`
- SELECT data FROM vehicle_dropdown_cache WHERE cache_key = $1
- `, [`catalog:models:${modelId}`]);
-
- if (oldResult.rows.length === 0) {
+ const existing = await client.query(
+ `SELECT id, make_id, name, created_at, updated_at FROM ${VEHICLE_SCHEMA}.model WHERE id = $1`,
+ [modelId]
+ );
+ if (existing.rowCount === 0) {
throw new Error(`Model ${modelId} not found`);
}
- const oldValue = oldResult.rows[0].data;
-
- // Delete model
- await client.query(`
- DELETE FROM vehicle_dropdown_cache WHERE cache_key = $1
- `, [`catalog:models:${modelId}`]);
-
- // Log change
+ const oldValue = this.mapModelRow(existing.rows[0]);
+ await client.query(`DELETE FROM ${VEHICLE_SCHEMA}.model WHERE id = $1`, [modelId]);
await this.logChange(client, 'DELETE', 'models', modelId.toString(), oldValue, null, changedBy);
-
- await client.query('COMMIT');
-
- // Invalidate cache
- await this.cacheService.invalidateVehicleData();
-
- logger.info('Model deleted', { modelId, changedBy });
- } catch (error) {
- await client.query('ROLLBACK');
- logger.error('Error deleting model', { error, modelId });
- throw error;
- } finally {
- client.release();
- }
+ });
+ await this.cacheService.invalidateVehicleData();
}
- // YEARS OPERATIONS
+ // YEARS ------------------------------------------------------------------
async getYearsByModel(modelId: number): Promise {
- const query = `
- SELECT cache_key, data, created_at, updated_at
- FROM vehicle_dropdown_cache
- WHERE cache_key LIKE 'catalog:years:%'
- AND (data->>'modelId')::int = $1
- ORDER BY (data->>'year')::int DESC
- `;
-
- try {
- const result = await this.pool.query(query, [modelId]);
- return result.rows.map(row => ({
- id: parseInt(row.cache_key.split(':')[2]),
- modelId: row.data.modelId,
- year: row.data.year,
- createdAt:
- row.data?.createdAt ?? this.toIsoDate(row.created_at),
- updatedAt:
- row.data?.updatedAt ?? this.toIsoDate(row.updated_at),
- }));
- } catch (error) {
- logger.error('Error getting years by model', { error, modelId });
- throw error;
- }
+ const result = await this.pool.query(
+ `
+ SELECT id, model_id, year, created_at, updated_at
+ FROM ${VEHICLE_SCHEMA}.model_year
+ WHERE model_id = $1
+ ORDER BY year
+ `,
+ [modelId]
+ );
+ return result.rows.map((row) => this.mapYearRow(row));
}
async createYear(modelId: number, year: number, changedBy: string): Promise {
- const client = await this.pool.connect();
+ const value = await this.runInTransaction(async (client) => {
+ await this.assertModelExists(client, modelId);
- try {
- await client.query('BEGIN');
+ const result = await client.query(
+ `
+ INSERT INTO ${VEHICLE_SCHEMA}.model_year (model_id, year)
+ VALUES ($1, $2)
+ RETURNING id, model_id, year, created_at, updated_at
+ `,
+ [modelId, year]
+ );
- // Verify model exists
- const modelCheck = await client.query(`
- SELECT 1 FROM vehicle_dropdown_cache WHERE cache_key = $1
- `, [`catalog:models:${modelId}`]);
-
- if (modelCheck.rows.length === 0) {
- throw new Error(`Model ${modelId} not found`);
- }
-
- // Get next ID
- const idResult = await client.query(`
- SELECT COALESCE(MAX(CAST(SPLIT_PART(cache_key, ':', 3) AS INTEGER)), 0) + 1 as next_id
- FROM vehicle_dropdown_cache
- WHERE cache_key LIKE 'catalog:years:%'
- `);
- const yearId = idResult.rows[0].next_id;
-
- // Insert year
- const now = new Date().toISOString();
- const yearData: CatalogYear = {
- id: yearId,
- modelId,
- year,
- createdAt: now,
- updatedAt: now,
- };
- await client.query(`
- INSERT INTO vehicle_dropdown_cache (cache_key, data, expires_at)
- VALUES ($1, $2, NOW() + INTERVAL '10 years')
- `, [`catalog:years:${yearId}`, JSON.stringify(yearData)]);
-
- // Log change
- await this.logChange(client, 'CREATE', 'years', yearId.toString(), null, yearData, changedBy);
-
- await client.query('COMMIT');
-
- // Invalidate cache
- await this.cacheService.invalidateVehicleData();
-
- logger.info('Year created', { yearId, modelId, year, changedBy });
- return yearData;
- } catch (error) {
- await client.query('ROLLBACK');
- logger.error('Error creating year', { error, modelId, year });
- throw error;
- } finally {
- client.release();
- }
+ const value = this.mapYearRow(result.rows[0]);
+ await this.logChange(client, 'CREATE', 'years', value.id.toString(), null, value, changedBy);
+ return value;
+ });
+ await this.cacheService.invalidateVehicleData();
+ return value;
}
async updateYear(yearId: number, modelId: number, year: number, changedBy: string): Promise {
- const client = await this.pool.connect();
+ const value = await this.runInTransaction(async (client) => {
+ await this.assertModelExists(client, modelId);
- try {
- await client.query('BEGIN');
-
- // Verify model exists
- const modelCheck = await client.query(`
- SELECT 1 FROM vehicle_dropdown_cache WHERE cache_key = $1
- `, [`catalog:models:${modelId}`]);
-
- if (modelCheck.rows.length === 0) {
- throw new Error(`Model ${modelId} not found`);
- }
-
- // Get old value
- const oldResult = await client.query(`
- SELECT data, created_at FROM vehicle_dropdown_cache WHERE cache_key = $1
- `, [`catalog:years:${yearId}`]);
-
- if (oldResult.rows.length === 0) {
+ const existing = await client.query(
+ `SELECT id, model_id, year, created_at, updated_at FROM ${VEHICLE_SCHEMA}.model_year WHERE id = $1`,
+ [yearId]
+ );
+ if (existing.rowCount === 0) {
throw new Error(`Year ${yearId} not found`);
}
+ const oldValue = this.mapYearRow(existing.rows[0]);
- const oldRow = oldResult.rows[0];
- const oldValue = oldRow.data;
- const createdAt =
- oldValue?.createdAt ?? this.toIsoDate(oldRow.created_at);
- const newValue: CatalogYear = {
- id: yearId,
- modelId,
- year,
- createdAt,
- updatedAt: new Date().toISOString(),
- };
+ const result = await client.query(
+ `
+ UPDATE ${VEHICLE_SCHEMA}.model_year
+ SET model_id = $2, year = $3, updated_at = NOW()
+ WHERE id = $1
+ RETURNING id, model_id, year, created_at, updated_at
+ `,
+ [yearId, modelId, year]
+ );
- // Update year
- await client.query(`
- UPDATE vehicle_dropdown_cache
- SET data = $1, updated_at = NOW()
- WHERE cache_key = $2
- `, [JSON.stringify(newValue), `catalog:years:${yearId}`]);
-
- // Log change
+ const newValue = this.mapYearRow(result.rows[0]);
await this.logChange(client, 'UPDATE', 'years', yearId.toString(), oldValue, newValue, changedBy);
-
- await client.query('COMMIT');
-
- // Invalidate cache
- await this.cacheService.invalidateVehicleData();
-
- logger.info('Year updated', { yearId, modelId, year, changedBy });
return newValue;
- } catch (error) {
- await client.query('ROLLBACK');
- logger.error('Error updating year', { error, yearId, year });
- throw error;
- } finally {
- client.release();
- }
+ });
+ await this.cacheService.invalidateVehicleData();
+ return value;
}
async deleteYear(yearId: number, changedBy: string): Promise {
- const client = await this.pool.connect();
-
- try {
- await client.query('BEGIN');
-
- // Check for dependent trims
- const trimsCheck = await client.query(`
- SELECT COUNT(*) as count
- FROM vehicle_dropdown_cache
- WHERE cache_key LIKE 'catalog:trims:%'
- AND (data->>'yearId')::int = $1
- `, [yearId]);
-
- if (parseInt(trimsCheck.rows[0].count) > 0) {
+ await this.runInTransaction(async (client) => {
+ const dependency = await client.query(
+ `SELECT 1 FROM ${VEHICLE_SCHEMA}.trim WHERE model_year_id = $1 LIMIT 1`,
+ [yearId]
+ );
+ if ((dependency.rowCount || 0) > 0) {
throw new Error('Cannot delete year with existing trims');
}
- // Get old value
- const oldResult = await client.query(`
- SELECT data FROM vehicle_dropdown_cache WHERE cache_key = $1
- `, [`catalog:years:${yearId}`]);
-
- if (oldResult.rows.length === 0) {
+ const existing = await client.query(
+ `SELECT id, model_id, year, created_at, updated_at FROM ${VEHICLE_SCHEMA}.model_year WHERE id = $1`,
+ [yearId]
+ );
+ if (existing.rowCount === 0) {
throw new Error(`Year ${yearId} not found`);
}
- const oldValue = oldResult.rows[0].data;
-
- // Delete year
- await client.query(`
- DELETE FROM vehicle_dropdown_cache WHERE cache_key = $1
- `, [`catalog:years:${yearId}`]);
-
- // Log change
+ const oldValue = this.mapYearRow(existing.rows[0]);
+ await client.query(`DELETE FROM ${VEHICLE_SCHEMA}.model_year WHERE id = $1`, [yearId]);
await this.logChange(client, 'DELETE', 'years', yearId.toString(), oldValue, null, changedBy);
-
- await client.query('COMMIT');
-
- // Invalidate cache
- await this.cacheService.invalidateVehicleData();
-
- logger.info('Year deleted', { yearId, changedBy });
- } catch (error) {
- await client.query('ROLLBACK');
- logger.error('Error deleting year', { error, yearId });
- throw error;
- } finally {
- client.release();
- }
+ });
+ await this.cacheService.invalidateVehicleData();
}
- // TRIMS OPERATIONS
+ // TRIMS ------------------------------------------------------------------
async getTrimsByYear(yearId: number): Promise {
- const query = `
- SELECT cache_key, data, created_at, updated_at
- FROM vehicle_dropdown_cache
- WHERE cache_key LIKE 'catalog:trims:%'
- AND (data->>'yearId')::int = $1
- ORDER BY (data->>'name')
- `;
-
- try {
- const result = await this.pool.query(query, [yearId]);
- return result.rows.map(row => ({
- id: parseInt(row.cache_key.split(':')[2]),
- yearId: row.data.yearId,
- name: row.data.name,
- createdAt:
- row.data?.createdAt ?? this.toIsoDate(row.created_at),
- updatedAt:
- row.data?.updatedAt ?? this.toIsoDate(row.updated_at),
- }));
- } catch (error) {
- logger.error('Error getting trims by year', { error, yearId });
- throw error;
- }
+ const result = await this.pool.query(
+ `
+ SELECT id, model_year_id, name, created_at, updated_at
+ FROM ${VEHICLE_SCHEMA}.trim
+ WHERE model_year_id = $1
+ ORDER BY name
+ `,
+ [yearId]
+ );
+ return result.rows.map((row) => this.mapTrimRow(row));
}
async createTrim(yearId: number, name: string, changedBy: string): Promise {
- const client = await this.pool.connect();
+ const value = await this.runInTransaction(async (client) => {
+ await this.assertYearExists(client, yearId);
- try {
- await client.query('BEGIN');
+ const result = await client.query(
+ `
+ INSERT INTO ${VEHICLE_SCHEMA}.trim (model_year_id, name)
+ VALUES ($1, $2)
+ RETURNING id, model_year_id, name, created_at, updated_at
+ `,
+ [yearId, name.trim()]
+ );
- // Verify year exists
- const yearCheck = await client.query(`
- SELECT 1 FROM vehicle_dropdown_cache WHERE cache_key = $1
- `, [`catalog:years:${yearId}`]);
-
- if (yearCheck.rows.length === 0) {
- throw new Error(`Year ${yearId} not found`);
- }
-
- // Get next ID
- const idResult = await client.query(`
- SELECT COALESCE(MAX(CAST(SPLIT_PART(cache_key, ':', 3) AS INTEGER)), 0) + 1 as next_id
- FROM vehicle_dropdown_cache
- WHERE cache_key LIKE 'catalog:trims:%'
- `);
- const trimId = idResult.rows[0].next_id;
-
- // Insert trim
- const now = new Date().toISOString();
- const trim: CatalogTrim = {
- id: trimId,
- yearId,
- name,
- createdAt: now,
- updatedAt: now,
- };
- await client.query(`
- INSERT INTO vehicle_dropdown_cache (cache_key, data, expires_at)
- VALUES ($1, $2, NOW() + INTERVAL '10 years')
- `, [`catalog:trims:${trimId}`, JSON.stringify(trim)]);
-
- // Log change
- await this.logChange(client, 'CREATE', 'trims', trimId.toString(), null, trim, changedBy);
-
- await client.query('COMMIT');
-
- // Invalidate cache
- await this.cacheService.invalidateVehicleData();
-
- logger.info('Trim created', { trimId, yearId, name, changedBy });
- return trim;
- } catch (error) {
- await client.query('ROLLBACK');
- logger.error('Error creating trim', { error, yearId, name });
- throw error;
- } finally {
- client.release();
- }
+ const value = this.mapTrimRow(result.rows[0]);
+ await this.logChange(client, 'CREATE', 'trims', value.id.toString(), null, value, changedBy);
+ return value;
+ });
+ await this.cacheService.invalidateVehicleData();
+ return value;
}
async updateTrim(trimId: number, yearId: number, name: string, changedBy: string): Promise {
- const client = await this.pool.connect();
+ const value = await this.runInTransaction(async (client) => {
+ await this.assertYearExists(client, yearId);
- try {
- await client.query('BEGIN');
-
- // Verify year exists
- const yearCheck = await client.query(`
- SELECT 1 FROM vehicle_dropdown_cache WHERE cache_key = $1
- `, [`catalog:years:${yearId}`]);
-
- if (yearCheck.rows.length === 0) {
- throw new Error(`Year ${yearId} not found`);
- }
-
- // Get old value
- const oldResult = await client.query(`
- SELECT data, created_at FROM vehicle_dropdown_cache WHERE cache_key = $1
- `, [`catalog:trims:${trimId}`]);
-
- if (oldResult.rows.length === 0) {
+ const existing = await client.query(
+ `SELECT id, model_year_id, name, created_at, updated_at FROM ${VEHICLE_SCHEMA}.trim WHERE id = $1`,
+ [trimId]
+ );
+ if (existing.rowCount === 0) {
throw new Error(`Trim ${trimId} not found`);
}
+ const oldValue = this.mapTrimRow(existing.rows[0]);
- const oldRow = oldResult.rows[0];
- const oldValue = oldRow.data;
- const createdAt =
- oldValue?.createdAt ?? this.toIsoDate(oldRow.created_at);
- const newValue: CatalogTrim = {
- id: trimId,
- yearId,
- name,
- createdAt,
- updatedAt: new Date().toISOString(),
- };
+ const result = await client.query(
+ `
+ UPDATE ${VEHICLE_SCHEMA}.trim
+ SET model_year_id = $2, name = $3, updated_at = NOW()
+ WHERE id = $1
+ RETURNING id, model_year_id, name, created_at, updated_at
+ `,
+ [trimId, yearId, name.trim()]
+ );
- // Update trim
- await client.query(`
- UPDATE vehicle_dropdown_cache
- SET data = $1, updated_at = NOW()
- WHERE cache_key = $2
- `, [JSON.stringify(newValue), `catalog:trims:${trimId}`]);
-
- // Log change
+ const newValue = this.mapTrimRow(result.rows[0]);
await this.logChange(client, 'UPDATE', 'trims', trimId.toString(), oldValue, newValue, changedBy);
-
- await client.query('COMMIT');
-
- // Invalidate cache
- await this.cacheService.invalidateVehicleData();
-
- logger.info('Trim updated', { trimId, yearId, name, changedBy });
return newValue;
- } catch (error) {
- await client.query('ROLLBACK');
- logger.error('Error updating trim', { error, trimId, name });
- throw error;
- } finally {
- client.release();
- }
+ });
+ await this.cacheService.invalidateVehicleData();
+ return value;
}
async deleteTrim(trimId: number, changedBy: string): Promise {
- const client = await this.pool.connect();
-
- try {
- await client.query('BEGIN');
-
- // Check for dependent engines
- const enginesCheck = await client.query(`
- SELECT COUNT(*) as count
- FROM vehicle_dropdown_cache
- WHERE cache_key LIKE 'catalog:engines:%'
- AND (data->>'trimId')::int = $1
- `, [trimId]);
-
- if (parseInt(enginesCheck.rows[0].count) > 0) {
+ await this.runInTransaction(async (client) => {
+ const dependency = await client.query(
+ `SELECT 1 FROM ${VEHICLE_SCHEMA}.trim_engine WHERE trim_id = $1 LIMIT 1`,
+ [trimId]
+ );
+ if ((dependency.rowCount || 0) > 0) {
throw new Error('Cannot delete trim with existing engines');
}
- // Get old value
- const oldResult = await client.query(`
- SELECT data FROM vehicle_dropdown_cache WHERE cache_key = $1
- `, [`catalog:trims:${trimId}`]);
-
- if (oldResult.rows.length === 0) {
+ const existing = await client.query(
+ `SELECT id, model_year_id, name, created_at, updated_at FROM ${VEHICLE_SCHEMA}.trim WHERE id = $1`,
+ [trimId]
+ );
+ if (existing.rowCount === 0) {
throw new Error(`Trim ${trimId} not found`);
}
- const oldValue = oldResult.rows[0].data;
-
- // Delete trim
- await client.query(`
- DELETE FROM vehicle_dropdown_cache WHERE cache_key = $1
- `, [`catalog:trims:${trimId}`]);
-
- // Log change
+ const oldValue = this.mapTrimRow(existing.rows[0]);
+ await client.query(`DELETE FROM ${VEHICLE_SCHEMA}.trim WHERE id = $1`, [trimId]);
await this.logChange(client, 'DELETE', 'trims', trimId.toString(), oldValue, null, changedBy);
-
- await client.query('COMMIT');
-
- // Invalidate cache
- await this.cacheService.invalidateVehicleData();
-
- logger.info('Trim deleted', { trimId, changedBy });
- } catch (error) {
- await client.query('ROLLBACK');
- logger.error('Error deleting trim', { error, trimId });
- throw error;
- } finally {
- client.release();
- }
+ });
+ await this.cacheService.invalidateVehicleData();
}
- // ENGINES OPERATIONS
+ // ENGINES ----------------------------------------------------------------
async getEnginesByTrim(trimId: number): Promise {
- const query = `
- SELECT cache_key, data, created_at, updated_at
- FROM vehicle_dropdown_cache
- WHERE cache_key LIKE 'catalog:engines:%'
- AND (data->>'trimId')::int = $1
- ORDER BY (data->>'name')
- `;
+ const result = await this.pool.query(
+ `
+ SELECT e.id,
+ e.name,
+ e.displacement_l,
+ e.cylinders,
+ e.fuel_type,
+ e.created_at,
+ e.updated_at,
+ te.trim_id
+ FROM ${VEHICLE_SCHEMA}.engine e
+ JOIN ${VEHICLE_SCHEMA}.trim_engine te ON te.engine_id = e.id
+ WHERE te.trim_id = $1
+ ORDER BY e.name
+ `,
+ [trimId]
+ );
- try {
- const result = await this.pool.query(query, [trimId]);
- return result.rows.map(row => ({
- id: parseInt(row.cache_key.split(':')[2]),
- trimId: row.data.trimId,
- name: row.data.name,
- description: row.data.description,
- createdAt:
- row.data?.createdAt ?? this.toIsoDate(row.created_at),
- updatedAt:
- row.data?.updatedAt ?? this.toIsoDate(row.updated_at),
- }));
- } catch (error) {
- logger.error('Error getting engines by trim', { error, trimId });
- throw error;
- }
+ return result.rows.map((row) => this.mapEngineRow(row));
}
- async createEngine(trimId: number, name: string, description: string | undefined, changedBy: string): Promise {
- const client = await this.pool.connect();
+ async createEngine(
+ trimId: number,
+ name: string,
+ _description: string | undefined,
+ changedBy: string
+ ): Promise {
+ const value = await this.runInTransaction(async (client) => {
+ await this.assertTrimExists(client, trimId);
- try {
- await client.query('BEGIN');
+ const engineResult = await client.query(
+ `
+ INSERT INTO ${VEHICLE_SCHEMA}.engine (name)
+ VALUES ($1)
+ RETURNING id, name, displacement_l, cylinders, fuel_type, created_at, updated_at
+ `,
+ [name.trim()]
+ );
- // Verify trim exists
- const trimCheck = await client.query(`
- SELECT 1 FROM vehicle_dropdown_cache WHERE cache_key = $1
- `, [`catalog:trims:${trimId}`]);
+ const engineId = engineResult.rows[0].id;
+ await client.query(
+ `
+ INSERT INTO ${VEHICLE_SCHEMA}.trim_engine (trim_id, engine_id)
+ VALUES ($1, $2)
+ ON CONFLICT DO NOTHING
+ `,
+ [trimId, engineId]
+ );
- if (trimCheck.rows.length === 0) {
- throw new Error(`Trim ${trimId} not found`);
- }
-
- // Get next ID
- const idResult = await client.query(`
- SELECT COALESCE(MAX(CAST(SPLIT_PART(cache_key, ':', 3) AS INTEGER)), 0) + 1 as next_id
- FROM vehicle_dropdown_cache
- WHERE cache_key LIKE 'catalog:engines:%'
- `);
- const engineId = idResult.rows[0].next_id;
-
- // Insert engine
- const now = new Date().toISOString();
- const engine: CatalogEngine = {
- id: engineId,
- trimId,
- name,
- description,
- createdAt: now,
- updatedAt: now,
- };
- await client.query(`
- INSERT INTO vehicle_dropdown_cache (cache_key, data, expires_at)
- VALUES ($1, $2, NOW() + INTERVAL '10 years')
- `, [`catalog:engines:${engineId}`, JSON.stringify(engine)]);
-
- // Log change
- await this.logChange(client, 'CREATE', 'engines', engineId.toString(), null, engine, changedBy);
-
- await client.query('COMMIT');
-
- // Invalidate cache
- await this.cacheService.invalidateVehicleData();
-
- logger.info('Engine created', { engineId, trimId, name, changedBy });
- return engine;
- } catch (error) {
- await client.query('ROLLBACK');
- logger.error('Error creating engine', { error, trimId, name });
- throw error;
- } finally {
- client.release();
- }
+ const value = this.mapEngineRow({ ...engineResult.rows[0], trim_id: trimId });
+ await this.logChange(client, 'CREATE', 'engines', engineId.toString(), null, value, changedBy);
+ return value;
+ });
+ await this.cacheService.invalidateVehicleData();
+ return value;
}
- async updateEngine(engineId: number, trimId: number, name: string, description: string | undefined, changedBy: string): Promise {
- const client = await this.pool.connect();
+ async updateEngine(
+ engineId: number,
+ trimId: number,
+ name: string,
+ _description: string | undefined,
+ changedBy: string
+ ): Promise {
+ const value = await this.runInTransaction(async (client) => {
+ await this.assertTrimExists(client, trimId);
- try {
- await client.query('BEGIN');
+ const existing = await client.query(
+ `
+ SELECT e.id,
+ e.name,
+ e.displacement_l,
+ e.cylinders,
+ e.fuel_type,
+ e.created_at,
+ e.updated_at,
+ te.trim_id
+ FROM ${VEHICLE_SCHEMA}.engine e
+ JOIN ${VEHICLE_SCHEMA}.trim_engine te ON te.engine_id = e.id
+ WHERE e.id = $1 AND te.trim_id = $2
+ `,
+ [engineId, trimId]
+ );
- // Verify trim exists
- const trimCheck = await client.query(`
- SELECT 1 FROM vehicle_dropdown_cache WHERE cache_key = $1
- `, [`catalog:trims:${trimId}`]);
-
- if (trimCheck.rows.length === 0) {
- throw new Error(`Trim ${trimId} not found`);
+ if (existing.rowCount === 0) {
+ throw new Error(`Engine ${engineId} not found for trim ${trimId}`);
}
- // Get old value
- const oldResult = await client.query(`
- SELECT data, created_at FROM vehicle_dropdown_cache WHERE cache_key = $1
- `, [`catalog:engines:${engineId}`]);
+ const oldValue = this.mapEngineRow(existing.rows[0]);
+ const updated = await client.query(
+ `
+ UPDATE ${VEHICLE_SCHEMA}.engine
+ SET name = $2, updated_at = NOW()
+ WHERE id = $1
+ RETURNING id, name, displacement_l, cylinders, fuel_type, created_at, updated_at
+ `,
+ [engineId, name.trim()]
+ );
- if (oldResult.rows.length === 0) {
- throw new Error(`Engine ${engineId} not found`);
- }
-
- const oldRow = oldResult.rows[0];
- const oldValue = oldRow.data;
- const createdAt =
- oldValue?.createdAt ?? this.toIsoDate(oldRow.created_at);
- const newValue: CatalogEngine = {
- id: engineId,
- trimId,
- name,
- description,
- createdAt,
- updatedAt: new Date().toISOString(),
- };
-
- // Update engine
- await client.query(`
- UPDATE vehicle_dropdown_cache
- SET data = $1, updated_at = NOW()
- WHERE cache_key = $2
- `, [JSON.stringify(newValue), `catalog:engines:${engineId}`]);
-
- // Log change
+ const newValue = this.mapEngineRow({ ...updated.rows[0], trim_id: trimId });
await this.logChange(client, 'UPDATE', 'engines', engineId.toString(), oldValue, newValue, changedBy);
-
- await client.query('COMMIT');
-
- // Invalidate cache
- await this.cacheService.invalidateVehicleData();
-
- logger.info('Engine updated', { engineId, trimId, name, changedBy });
return newValue;
- } catch (error) {
- await client.query('ROLLBACK');
- logger.error('Error updating engine', { error, engineId, name });
- throw error;
- } finally {
- client.release();
- }
+ });
+ await this.cacheService.invalidateVehicleData();
+ return value;
}
async deleteEngine(engineId: number, changedBy: string): Promise {
- const client = await this.pool.connect();
+ await this.runInTransaction(async (client) => {
+ const existing = await client.query(
+ `
+ SELECT e.id,
+ e.name,
+ e.displacement_l,
+ e.cylinders,
+ e.fuel_type,
+ e.created_at,
+ e.updated_at,
+ te.trim_id
+ FROM ${VEHICLE_SCHEMA}.engine e
+ JOIN ${VEHICLE_SCHEMA}.trim_engine te ON te.engine_id = e.id
+ WHERE e.id = $1
+ `,
+ [engineId]
+ );
- try {
- await client.query('BEGIN');
-
- // Get old value
- const oldResult = await client.query(`
- SELECT data FROM vehicle_dropdown_cache WHERE cache_key = $1
- `, [`catalog:engines:${engineId}`]);
-
- if (oldResult.rows.length === 0) {
+ if (existing.rowCount === 0) {
throw new Error(`Engine ${engineId} not found`);
}
- const oldValue = oldResult.rows[0].data;
+ const oldValue = this.mapEngineRow(existing.rows[0]);
+ await client.query(
+ `DELETE FROM ${VEHICLE_SCHEMA}.trim_engine WHERE engine_id = $1`,
+ [engineId]
+ );
+ await client.query(`DELETE FROM ${VEHICLE_SCHEMA}.engine WHERE id = $1`, [engineId]);
- // Delete engine
- await client.query(`
- DELETE FROM vehicle_dropdown_cache WHERE cache_key = $1
- `, [`catalog:engines:${engineId}`]);
-
- // Log change
await this.logChange(client, 'DELETE', 'engines', engineId.toString(), oldValue, null, changedBy);
+ });
+ await this.cacheService.invalidateVehicleData();
+ }
+ // CHANGE LOGS ------------------------------------------------------------
+
+ async getChangeLogs(limit: number = 100, offset: number = 0): Promise<{ logs: PlatformChangeLog[]; total: number }> {
+ const countQuery = 'SELECT COUNT(*) as total FROM platform_change_log';
+ const query = `
+ SELECT id, change_type, resource_type, resource_id, old_value, new_value, changed_by, created_at
+ FROM platform_change_log
+ ORDER BY created_at DESC
+ LIMIT $1 OFFSET $2
+ `;
+
+ try {
+ const [countResult, dataResult] = await Promise.all([
+ this.pool.query(countQuery),
+ this.pool.query(query, [limit, offset])
+ ]);
+
+ const total = parseInt(countResult.rows[0].total, 10);
+ const logs = dataResult.rows.map((row) => ({
+ id: row.id,
+ changeType: row.change_type,
+ resourceType: row.resource_type,
+ resourceId: row.resource_id,
+ oldValue: row.old_value,
+ newValue: row.new_value,
+ changedBy: row.changed_by,
+ createdAt: new Date(row.created_at),
+ }));
+
+ return { logs, total };
+ } catch (error) {
+ logger.error('Error fetching change logs', { error });
+ throw error;
+ }
+ }
+
+ // HELPERS ----------------------------------------------------------------
+
+ private async runInTransaction(handler: (client: PoolClient) => Promise): Promise {
+ const client = await this.pool.connect();
+ try {
+ await client.query('BEGIN');
+ const result = await handler(client);
await client.query('COMMIT');
-
- // Invalidate cache
- await this.cacheService.invalidateVehicleData();
-
- logger.info('Engine deleted', { engineId, changedBy });
+ return result;
} catch (error) {
await client.query('ROLLBACK');
- logger.error('Error deleting engine', { error, engineId });
throw error;
} finally {
client.release();
}
}
- // HELPER METHODS
+ private mapMakeRow(row: any): CatalogMake {
+ return {
+ id: Number(row.id),
+ name: row.name,
+ createdAt: this.toIsoDate(row.created_at),
+ updatedAt: this.toIsoDate(row.updated_at),
+ };
+ }
+
+ private mapModelRow(row: any): CatalogModel {
+ return {
+ id: Number(row.id),
+ makeId: Number(row.make_id),
+ name: row.name,
+ createdAt: this.toIsoDate(row.created_at),
+ updatedAt: this.toIsoDate(row.updated_at),
+ };
+ }
+
+ private mapYearRow(row: any): CatalogYear {
+ return {
+ id: Number(row.id),
+ modelId: Number(row.model_id),
+ year: Number(row.year),
+ createdAt: this.toIsoDate(row.created_at),
+ updatedAt: this.toIsoDate(row.updated_at),
+ };
+ }
+
+ private mapTrimRow(row: any): CatalogTrim {
+ return {
+ id: Number(row.id),
+ yearId: Number(row.model_year_id),
+ name: row.name,
+ createdAt: this.toIsoDate(row.created_at),
+ updatedAt: this.toIsoDate(row.updated_at),
+ };
+ }
+
+ private mapEngineRow(row: any): CatalogEngine {
+ return {
+ id: Number(row.id),
+ trimId: Number(row.trim_id),
+ name: row.name,
+ displacement: row.displacement_l?.toString() ?? null,
+ cylinders: row.cylinders !== null ? Number(row.cylinders) : null,
+ fuel_type: row.fuel_type ?? null,
+ createdAt: this.toIsoDate(row.created_at),
+ updatedAt: this.toIsoDate(row.updated_at),
+ };
+ }
+
+ private async assertMakeExists(client: PoolClient, makeId: number): Promise {
+ const result = await client.query(`SELECT 1 FROM ${VEHICLE_SCHEMA}.make WHERE id = $1`, [makeId]);
+ if (result.rowCount === 0) {
+ throw new Error(`Make ${makeId} not found`);
+ }
+ }
+
+ private async assertModelExists(client: PoolClient, modelId: number): Promise {
+ const result = await client.query(`SELECT 1 FROM ${VEHICLE_SCHEMA}.model WHERE id = $1`, [modelId]);
+ if (result.rowCount === 0) {
+ throw new Error(`Model ${modelId} not found`);
+ }
+ }
+
+ private async assertYearExists(client: PoolClient, yearId: number): Promise {
+ const result = await client.query(`SELECT 1 FROM ${VEHICLE_SCHEMA}.model_year WHERE id = $1`, [yearId]);
+ if (result.rowCount === 0) {
+ throw new Error(`Year ${yearId} not found`);
+ }
+ }
+
+ private async assertTrimExists(client: PoolClient, trimId: number): Promise {
+ const result = await client.query(`SELECT 1 FROM ${VEHICLE_SCHEMA}.trim WHERE id = $1`, [trimId]);
+ if (result.rowCount === 0) {
+ throw new Error(`Trim ${trimId} not found`);
+ }
+ }
private toIsoDate(value: Date | string | null | undefined): string {
if (!value) {
@@ -1036,7 +720,7 @@ export class VehicleCatalogService {
}
private async logChange(
- client: any,
+ client: PoolClient,
changeType: 'CREATE' | 'UPDATE' | 'DELETE',
resourceType: 'makes' | 'models' | 'years' | 'trims' | 'engines',
resourceId: string,
@@ -1055,41 +739,7 @@ export class VehicleCatalogService {
resourceId,
oldValue ? JSON.stringify(oldValue) : null,
newValue ? JSON.stringify(newValue) : null,
- changedBy
+ changedBy,
]);
}
-
- async getChangeLogs(limit: number = 100, offset: number = 0): Promise<{ logs: PlatformChangeLog[]; total: number }> {
- const countQuery = 'SELECT COUNT(*) as total FROM platform_change_log';
- const query = `
- SELECT id, change_type, resource_type, resource_id, old_value, new_value, changed_by, created_at
- FROM platform_change_log
- ORDER BY created_at DESC
- LIMIT $1 OFFSET $2
- `;
-
- try {
- const [countResult, dataResult] = await Promise.all([
- this.pool.query(countQuery),
- this.pool.query(query, [limit, offset])
- ]);
-
- const total = parseInt(countResult.rows[0].total, 10);
- const logs = dataResult.rows.map(row => ({
- id: row.id,
- changeType: row.change_type,
- resourceType: row.resource_type,
- resourceId: row.resource_id,
- oldValue: row.old_value,
- newValue: row.new_value,
- changedBy: row.changed_by,
- createdAt: new Date(row.created_at)
- }));
-
- return { logs, total };
- } catch (error) {
- logger.error('Error fetching change logs', { error });
- throw error;
- }
- }
}
diff --git a/docs/changes/platform-vehicle-data-loader.md b/docs/changes/platform-vehicle-data-loader.md
index fbd1b0d..43b0959 100644
--- a/docs/changes/platform-vehicle-data-loader.md
+++ b/docs/changes/platform-vehicle-data-loader.md
@@ -9,7 +9,7 @@
1. **Wire dropdown API to refreshed data**
- Run `make migrate` (or `npm run migrate:all` inside backend container) to ensure the new schema exists.
- Execute the loader (see command below) so Postgres has the latest lookup entries.
- - Verify `VehicleDataRepository` queries and Redis caching logic continue to function against the reinstated tables.
+ - Verify both the platform dropdown queries and the admin catalog APIs surface the same data set (admin APIs now read directly from `vehicles.*` tables).
2. **Add Makefile wrapper**
- Create a `make load-vehicle-data` task that shells into the backend container, installs `psycopg` if needed, and invokes `python3 scripts/load_vehicle_data.py` with the correct DB credentials and data directory.
@@ -25,3 +25,7 @@ python3 scripts/load_vehicle_data.py \
```
> Run the command from the repository root (outside of containers) while `mvp-postgres` is up. Adjust host/port if executing inside a container.
+
+## 2025-11-07 Update
+- Admin catalog CRUD endpoints have been refactored to read/write the normalized `vehicles.*` tables instead of the legacy `vehicle_dropdown_cache`.
+- Platform cache invalidation now happens immediately after each catalog mutation so both the admin UI and the user dropdown APIs stay in sync.
diff --git a/frontend/package.json b/frontend/package.json
index 2812401..6339b8f 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -30,6 +30,7 @@
"framer-motion": "^11.0.0",
"@mui/material": "^5.15.0",
"@mui/x-date-pickers": "^6.19.0",
+ "@mui/x-data-grid": "^6.19.1",
"@emotion/react": "^11.11.4",
"@emotion/styled": "^11.11.0",
"@emotion/cache": "^11.11.0",
@@ -58,6 +59,7 @@
"jest-environment-jsdom": "^29.7.0",
"@testing-library/react": "^16.0.0",
"@testing-library/jest-dom": "^6.1.5",
- "@testing-library/user-event": "^14.5.1"
+ "@testing-library/user-event": "^14.5.1",
+ "patch-package": "^6.5.1"
}
}
diff --git a/frontend/src/features/admin/api/admin.api.ts b/frontend/src/features/admin/api/admin.api.ts
index c9d4922..5b1b94e 100644
--- a/frontend/src/features/admin/api/admin.api.ts
+++ b/frontend/src/features/admin/api/admin.api.ts
@@ -19,6 +19,7 @@ import {
CreateCatalogModelRequest,
UpdateCatalogModelRequest,
CreateCatalogYearRequest,
+ UpdateCatalogYearRequest,
CreateCatalogTrimRequest,
UpdateCatalogTrimRequest,
CreateCatalogEngineRequest,
@@ -121,6 +122,11 @@ export const adminApi = {
return response.data;
},
+ updateYear: async (id: string, data: UpdateCatalogYearRequest): Promise => {
+ const response = await apiClient.put(`/admin/catalog/years/${id}`, data);
+ return response.data;
+ },
+
deleteYear: async (id: string): Promise => {
await apiClient.delete(`/admin/catalog/years/${id}`);
},
diff --git a/frontend/src/features/admin/types/admin.types.ts b/frontend/src/features/admin/types/admin.types.ts
index 2fb6db8..9faa60b 100644
--- a/frontend/src/features/admin/types/admin.types.ts
+++ b/frontend/src/features/admin/types/admin.types.ts
@@ -96,6 +96,11 @@ export interface CreateCatalogYearRequest {
year: number;
}
+export interface UpdateCatalogYearRequest {
+ modelId: string;
+ year: number;
+}
+
export interface CreateCatalogTrimRequest {
yearId: string;
name: string;
diff --git a/frontend/src/pages/admin/AdminCatalogPage.tsx b/frontend/src/pages/admin/AdminCatalogPage.tsx
index 32b7789..e597208 100644
--- a/frontend/src/pages/admin/AdminCatalogPage.tsx
+++ b/frontend/src/pages/admin/AdminCatalogPage.tsx
@@ -6,20 +6,15 @@ import React, {
} from 'react';
import { Navigate } from 'react-router-dom';
import {
+ Autocomplete,
Box,
- Breadcrumbs,
Button,
CircularProgress,
- Collapse,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
- IconButton,
- Link,
- List,
- ListItemButton,
- ListItemText,
+ Grid,
MenuItem,
Paper,
TextField,
@@ -27,61 +22,26 @@ import {
} from '@mui/material';
import {
Add,
- ChevronRight,
Delete,
Edit,
- ExpandLess,
- ExpandMore,
} from '@mui/icons-material';
-import { useForm, Controller } from 'react-hook-form';
-import { zodResolver } from '@hookform/resolvers/zod';
-import { useQueryClient } from '@tanstack/react-query';
+import {
+ DataGrid,
+ GridColDef,
+ GridPaginationModel,
+ GridRowSelectionModel,
+} from '@mui/x-data-grid';
+import { Controller, useForm } from 'react-hook-form';
import toast from 'react-hot-toast';
import { useAdminAccess } from '../../core/auth/useAdminAccess';
import {
- AdminDataGrid,
- GridColumn,
+ adminApi,
+} from '../../features/admin/api/admin.api';
+import {
AdminSectionHeader,
- SelectionToolbar,
- BulkActionDialog,
AuditLogPanel,
+ BulkActionDialog,
} from '../../features/admin/components';
-import { useBulkSelection } from '../../features/admin/hooks/useBulkSelection';
-import {
- useMakes,
- useCreateMake,
- useUpdateMake,
- useDeleteMake,
- useModels,
- useCreateModel,
- useUpdateModel,
- useDeleteModel,
- useYears,
- useCreateYear,
- useDeleteYear,
- useTrims,
- useCreateTrim,
- useUpdateTrim,
- useDeleteTrim,
- useEngines,
- useCreateEngine,
- useUpdateEngine,
- useDeleteEngine,
-} from '../../features/admin/hooks/useCatalog';
-import {
- CatalogLevel,
- CatalogRow,
- CatalogSelectionContext,
- LEVEL_LABEL,
- LEVEL_SINGULAR_LABEL,
- NEXT_LEVEL,
- getCascadeSummary,
-} from '../../features/admin/catalog/catalogShared';
-import {
- CatalogFormValues,
- buildDefaultValues,
- getSchemaForLevel,
-} from '../../features/admin/catalog/catalogSchemas';
import {
CatalogEngine,
CatalogMake,
@@ -90,1029 +50,548 @@ import {
CatalogYear,
} from '../../features/admin/types/admin.types';
-interface TreeNode {
+type TransmissionOption = 'Automatic' | 'Manual';
+
+interface CatalogGridRow {
id: string;
+ makeId: string;
+ makeName: string;
+ modelId: string;
+ modelName: string;
+ yearId: string;
+ yearValue: number;
+ trimId: string;
+ trimName: string;
+ engineId: string;
+ engineName: string;
+ transmission: TransmissionOption;
+ createdAt: string;
+ updatedAt: string;
+}
+
+interface CatalogCombinationFormValues {
+ makeName: string;
+ modelName: string;
+ year: number | '';
+ trimName: string;
+ engineName: string;
+ transmission: TransmissionOption;
+}
+
+const transmissionOptions: TransmissionOption[] = ['Automatic', 'Manual'];
+
+const normalizeName = (value: string): string => value.trim().toLowerCase();
+
+const formatRowLabel = (row: CatalogGridRow): string =>
+ `${row.yearValue} ${row.makeName} ${row.modelName} ${row.trimName} ${row.engineName}`;
+
+const normalizeId = (value: string | number): string => value.toString();
+
+const ColumnHeader: React.FC<{
label: string;
- level?: CatalogLevel;
- children?: TreeNode[];
-}
-
-interface BreadcrumbItem {
- key: string;
- label: string;
- target: CatalogSelectionContext;
-}
-
-interface DialogState {
- open: boolean;
- mode: 'create' | 'edit';
- level: CatalogLevel;
- entity?: CatalogRow;
- context: CatalogSelectionContext;
-}
-
-const normalizeCollection = (value: unknown, key: string): T[] => {
- if (Array.isArray(value)) {
- return value as T[];
- }
- if (
- value &&
- typeof value === 'object' &&
- Array.isArray((value as Record)[key])
- ) {
- return (value as Record)[key];
- }
- return [];
-};
+ value: string;
+ onChange: (next: string) => void;
+}> = ({ label, value, onChange }) => (
+
+
+ {label}
+
+ onChange(event.target.value)}
+ placeholder="Filter"
+ fullWidth
+ InputProps={{
+ sx: { fontSize: 13 },
+ }}
+ />
+
+);
export const AdminCatalogPage: React.FC = () => {
- const { isAdmin, loading: authLoading } = useAdminAccess();
- const queryClient = useQueryClient();
+ const { loading: authLoading, isAdmin } = useAdminAccess();
- const [selection, setSelection] = useState({
- level: 'makes',
+ const [makes, setMakes] = useState([]);
+ const [models, setModels] = useState([]);
+ const [years, setYears] = useState([]);
+ const [trims, setTrims] = useState([]);
+ const [engines, setEngines] = useState([]);
+ const [rows, setRows] = useState([]);
+ const [loadingMessage, setLoadingMessage] = useState('Loading catalogβ¦');
+ const [loadingCatalog, setLoadingCatalog] = useState(true);
+ const [catalogError, setCatalogError] = useState(null);
+
+ const [filters, setFilters] = useState({
+ year: '',
+ make: '',
+ model: '',
+ trim: '',
+ engine: '',
+ transmission: '',
});
+ const [paginationModel, setPaginationModel] = useState({
+ page: 0,
+ pageSize: 25,
+ });
+ const [rowSelectionModel, setRowSelectionModel] = useState([]);
- const makesQuery = useMakes();
- const modelsQuery = useModels(selection.make?.id);
- const yearsQuery = useYears(selection.model?.id);
- const trimsQuery = useTrims(selection.year?.id);
- const enginesQuery = useEngines(selection.trim?.id);
-
- const makes = normalizeCollection(makesQuery.data, 'makes');
- const models =
- selection.make !== undefined
- ? normalizeCollection(modelsQuery.data, 'models')
- : [];
- const years =
- selection.model !== undefined
- ? normalizeCollection(yearsQuery.data, 'years')
- : [];
- const trims =
- selection.year !== undefined
- ? normalizeCollection(trimsQuery.data, 'trims')
- : [];
- const engines =
- selection.trim !== undefined
- ? normalizeCollection(enginesQuery.data, 'engines')
- : [];
-
- const createMake = useCreateMake();
- const updateMake = useUpdateMake();
- const deleteMake = useDeleteMake();
-
- const createModel = useCreateModel();
- const updateModel = useUpdateModel();
- const deleteModel = useDeleteModel();
-
- const createYear = useCreateYear();
- const deleteYear = useDeleteYear();
-
- const createTrim = useCreateTrim();
- const updateTrim = useUpdateTrim();
- const deleteTrim = useDeleteTrim();
-
- const createEngine = useCreateEngine();
- const updateEngine = useUpdateEngine();
- const deleteEngine = useDeleteEngine();
- const [expandedNodes, setExpandedNodes] = useState>(
- () => new Set(['node-makes', 'node-models', 'node-years', 'node-trims'])
- );
- const [dialogState, setDialogState] = useState(null);
+ const [dialogState, setDialogState] = useState<{
+ mode: 'create' | 'edit';
+ row?: CatalogGridRow;
+ } | null>(null);
+ const [dialogSubmitting, setDialogSubmitting] = useState(false);
const [bulkDialogOpen, setBulkDialogOpen] = useState(false);
const [bulkDeleting, setBulkDeleting] = useState(false);
- const makesById = useMemo(() => {
- const map = new Map();
- makes.forEach((make) => map.set(make.id, make));
- return map;
- }, [makes]);
+ const filteredRows = useMemo(() => {
+ const normalized = {
+ year: filters.year.trim(),
+ make: filters.make.toLowerCase().trim(),
+ model: filters.model.toLowerCase().trim(),
+ trim: filters.trim.toLowerCase().trim(),
+ engine: filters.engine.toLowerCase().trim(),
+ transmission: filters.transmission.toLowerCase().trim(),
+ };
- const modelsById = useMemo(() => {
- const map = new Map();
- models.forEach((model) => map.set(model.id, model));
- return map;
- }, [models]);
+ return rows.filter((row) => {
+ const matchesYear = normalized.year
+ ? row.yearValue.toString().includes(normalized.year)
+ : true;
+ const matchesMake = normalized.make
+ ? row.makeName.toLowerCase().includes(normalized.make)
+ : true;
+ const matchesModel = normalized.model
+ ? row.modelName.toLowerCase().includes(normalized.model)
+ : true;
+ const matchesTrim = normalized.trim
+ ? row.trimName.toLowerCase().includes(normalized.trim)
+ : true;
+ const matchesEngine = normalized.engine
+ ? row.engineName.toLowerCase().includes(normalized.engine)
+ : true;
+ const matchesTransmission = normalized.transmission
+ ? row.transmission.toLowerCase().includes(normalized.transmission)
+ : true;
- const yearsById = useMemo(() => {
- const map = new Map();
- years.forEach((year) => map.set(year.id, year));
- return map;
- }, [years]);
-
- const trimsById = useMemo(() => {
- const map = new Map();
- trims.forEach((trim) => map.set(trim.id, trim));
- return map;
- }, [trims]);
-
- const modelsByMake = useMemo(() => {
- const map = new Map();
- models.forEach((model) => {
- const list = map.get(model.makeId) ?? [];
- list.push(model);
- map.set(model.makeId, list);
+ return (
+ matchesYear &&
+ matchesMake &&
+ matchesModel &&
+ matchesTrim &&
+ matchesEngine &&
+ matchesTransmission
+ );
});
- return map;
- }, [models]);
+ }, [filters, rows]);
- const yearsByModel = useMemo(() => {
- const map = new Map();
- years.forEach((year) => {
- const list = map.get(year.modelId) ?? [];
- list.push(year);
- map.set(year.modelId, list);
- });
- return map;
- }, [years]);
+ const selectedRows = useMemo(
+ () => filteredRows.filter((row) => rowSelectionModel.includes(row.id)),
+ [filteredRows, rowSelectionModel]
+ );
- const trimsByYear = useMemo(() => {
- const map = new Map();
- trims.forEach((trim) => {
- const list = map.get(trim.yearId) ?? [];
- list.push(trim);
- map.set(trim.yearId, list);
- });
- return map;
- }, [trims]);
+ const setFilterValue = (field: keyof typeof filters, value: string) => {
+ setFilters((prev) => ({ ...prev, [field]: value }));
+ setPaginationModel((prev) => ({ ...prev, page: 0 }));
+ };
- const enginesByTrim = useMemo(() => {
- const map = new Map();
- engines.forEach((engine) => {
- const list = map.get(engine.trimId) ?? [];
- list.push(engine);
- map.set(engine.trimId, list);
- });
- return map;
- }, [engines]);
+ const buildGridRows = useCallback(
+ (
+ makeList: CatalogMake[],
+ modelList: CatalogModel[],
+ yearList: CatalogYear[],
+ trimList: CatalogTrim[],
+ engineList: CatalogEngine[]
+ ): CatalogGridRow[] => {
+ const rowsAccumulator: CatalogGridRow[] = [];
- const levelRequirements = useMemo<
- Record
- >(() => ({
- makes: { valid: true },
- models: selection.make
- ? { valid: true }
- : { valid: false, message: 'Select a make to view models.' },
- years: selection.model
- ? { valid: true }
- : { valid: false, message: 'Select a model to view years.' },
- trims: selection.year
- ? { valid: true }
- : { valid: false, message: 'Select a year to view trims.' },
- engines: selection.trim
- ? { valid: true }
- : { valid: false, message: 'Select a trim to view engines.' },
- }), [selection]);
+ makeList.forEach((make) => {
+ const makeModels = modelList.filter((model) => model.makeId === make.id);
- const currentLevelRequirement = levelRequirements[selection.level];
- const canViewLevel = currentLevelRequirement.valid;
+ makeModels.forEach((model) => {
+ const modelYears = yearList.filter((year) => year.modelId === model.id);
- const currentRows = useMemo(() => {
- if (!canViewLevel) {
- return [];
+ modelYears.forEach((year) => {
+ const yearTrims = trimList.filter((trim) => trim.yearId === year.id);
+
+ yearTrims.forEach((trim) => {
+ const trimEngines = engineList.filter((engine) => engine.trimId === trim.id);
+
+ trimEngines.forEach((engine) => {
+ rowsAccumulator.push({
+ id: engine.id,
+ makeId: make.id,
+ makeName: make.name,
+ modelId: model.id,
+ modelName: model.name,
+ yearId: year.id,
+ yearValue: year.year,
+ trimId: trim.id,
+ trimName: trim.name,
+ engineId: engine.id,
+ engineName: engine.name,
+ transmission: 'Automatic',
+ createdAt: engine.createdAt,
+ updatedAt: engine.updatedAt,
+ });
+ });
+ });
+ });
+ });
+ });
+
+ return rowsAccumulator;
+ },
+ []
+ );
+
+ const fetchCatalogData = useCallback(async () => {
+ setLoadingCatalog(true);
+ setCatalogError(null);
+ setLoadingMessage('Loading makesβ¦');
+
+ try {
+ const makesData = await adminApi.listMakes();
+
+ const modelsData: CatalogModel[] = [];
+ for (let index = 0; index < makesData.length; index += 1) {
+ const make = makesData[index];
+ setLoadingMessage(`Loading models (${index + 1}/${makesData.length})β¦`);
+ const makeModels = await adminApi.listModels(make.id);
+ modelsData.push(...makeModels);
+ }
+
+ const yearsData: CatalogYear[] = [];
+ for (let index = 0; index < modelsData.length; index += 1) {
+ const model = modelsData[index];
+ setLoadingMessage(`Loading years (${index + 1}/${modelsData.length})β¦`);
+ const modelYears = await adminApi.listYears(model.id);
+ yearsData.push(...modelYears);
+ }
+
+ const trimsData: CatalogTrim[] = [];
+ for (let index = 0; index < yearsData.length; index += 1) {
+ const year = yearsData[index];
+ setLoadingMessage(`Loading trims (${index + 1}/${yearsData.length})β¦`);
+ const yearTrims = await adminApi.listTrims(year.id);
+ trimsData.push(...yearTrims);
+ }
+
+ const enginesData: CatalogEngine[] = [];
+ for (let index = 0; index < trimsData.length; index += 1) {
+ const trim = trimsData[index];
+ setLoadingMessage(`Loading engines (${index + 1}/${trimsData.length})β¦`);
+ const trimEngines = await adminApi.listEngines(trim.id);
+ enginesData.push(...trimEngines);
+ }
+
+ const normalizedMakes = makesData.map((make) => ({
+ ...make,
+ id: normalizeId(make.id),
+ }));
+ const normalizedModels = modelsData.map((model) => ({
+ ...model,
+ id: normalizeId(model.id),
+ makeId: normalizeId(model.makeId),
+ }));
+ const normalizedYears = yearsData.map((year) => ({
+ ...year,
+ id: normalizeId(year.id),
+ modelId: normalizeId(year.modelId),
+ }));
+ const normalizedTrims = trimsData.map((trim) => ({
+ ...trim,
+ id: normalizeId(trim.id),
+ yearId: normalizeId(trim.yearId),
+ }));
+ const normalizedEngines = enginesData.map((engine) => ({
+ ...engine,
+ id: normalizeId(engine.id),
+ trimId: normalizeId(engine.trimId),
+ }));
+
+ setMakes(normalizedMakes);
+ setModels(normalizedModels);
+ setYears(normalizedYears);
+ setTrims(normalizedTrims);
+ setEngines(normalizedEngines);
+
+ setRows(
+ buildGridRows(
+ normalizedMakes,
+ normalizedModels,
+ normalizedYears,
+ normalizedTrims,
+ normalizedEngines
+ )
+ );
+ } catch (error) {
+ setCatalogError(error as Error);
+ } finally {
+ setLoadingCatalog(false);
+ setLoadingMessage('Loading catalogβ¦');
}
-
- switch (selection.level) {
- case 'makes':
- return makes;
- case 'models':
- return models;
- case 'years':
- return years;
- case 'trims':
- return trims;
- case 'engines':
- return engines;
- default:
- return makes;
- }
- }, [canViewLevel, selection.level, makes, models, years, trims, engines]);
-
- const {
- selected,
- toggleItem,
- toggleAll,
- reset: resetBulkSelection,
- count: selectedCount,
- selectedItems,
- } = useBulkSelection({
- items: currentRows,
- keyExtractor: (item) => item.id,
- });
+ }, [buildGridRows]);
useEffect(() => {
- resetBulkSelection();
- }, [
- resetBulkSelection,
- selection.level,
- selection.make?.id,
- selection.model?.id,
- selection.year?.id,
- selection.trim?.id,
- ]);
+ if (isAdmin) {
+ fetchCatalogData();
+ }
+ }, [fetchCatalogData, isAdmin]);
- const queryByLevel = useMemo(
- () => ({
- makes: makesQuery,
- models: modelsQuery,
- years: yearsQuery,
- trims: trimsQuery,
- engines: enginesQuery,
- }),
- [makesQuery, modelsQuery, yearsQuery, trimsQuery, enginesQuery]
- );
-
- const activeQuery = queryByLevel[selection.level];
- const activeError = activeQuery.error
- ? activeQuery.error instanceof Error
- ? activeQuery.error
- : new Error('Failed to load data')
- : null;
-
- const toggleTreeNode = useCallback((nodeId: string) => {
- setExpandedNodes((prev) => {
- const next = new Set(prev);
- if (next.has(nodeId)) {
- next.delete(nodeId);
- } else {
- next.add(nodeId);
- }
- return next;
- });
+ const handleRowUpdateError = useCallback(() => {
+ toast.error('Unable to update catalog row.');
}, []);
- const handleLevelSelect = useCallback((level: CatalogLevel) => {
- setSelection((prev) => {
- switch (level) {
- case 'makes':
- return { level: 'makes' };
- case 'models':
- if (!prev.make) {
- toast.error('Select a make to view models.');
- return prev;
- }
- return {
- level: 'models',
- make: prev.make,
- };
- case 'years':
- if (!prev.model) {
- toast.error('Select a model to view years.');
- return prev;
- }
- return {
- level: 'years',
- make: prev.make,
- model: prev.model,
- };
- case 'trims':
- if (!prev.year) {
- toast.error('Select a year to view trims.');
- return prev;
- }
- return {
- level: 'trims',
- make: prev.make,
- model: prev.model,
- year: prev.year,
- };
- case 'engines':
- default:
- if (!prev.trim) {
- toast.error('Select a trim to view engines.');
- return prev;
- }
- return {
- level: 'engines',
- make: prev.make,
- model: prev.model,
- year: prev.year,
- trim: prev.trim,
- };
- }
- });
- }, []);
-
- const deriveContextFromRow = useCallback(
- (row: CatalogRow, level: CatalogLevel): CatalogSelectionContext => {
- switch (level) {
- case 'makes':
- return { level, make: row as CatalogMake };
- case 'models': {
- const model = row as CatalogModel;
- const make = makesById.get(model.makeId);
- return {
- level,
- make,
- model,
- };
- }
- case 'years': {
- const year = row as CatalogYear;
- const model = modelsById.get(year.modelId);
- const make = model ? makesById.get(model.makeId) : undefined;
- return {
- level,
- make,
- model,
- year,
- };
- }
- case 'trims': {
- const trim = row as CatalogTrim;
- const year = yearsById.get(trim.yearId);
- const model = year ? modelsById.get(year.modelId) : undefined;
- const make = model ? makesById.get(model.makeId) : undefined;
- return {
- level,
- make,
- model,
- year,
- trim,
- };
- }
- case 'engines': {
- const engine = row as CatalogEngine;
- const trim = trimsById.get(engine.trimId);
- const year = trim ? yearsById.get(trim.yearId) : undefined;
- const model = year ? modelsById.get(year.modelId) : undefined;
- const make = model ? makesById.get(model.makeId) : undefined;
- return {
- level,
- make,
- model,
- year,
- trim,
- };
- }
- default:
- return { level };
- }
- },
- [makesById, modelsById, yearsById, trimsById]
- );
-
- const handleDrillDown = useCallback(
- (row: CatalogRow) => {
- const nextLevel = NEXT_LEVEL[selection.level];
- if (!nextLevel) {
- return;
- }
-
- if (selection.level === 'makes') {
- const make = row as CatalogMake;
- setSelection({
- level: 'models',
- make,
- });
- return;
- }
-
- if (selection.level === 'models') {
- const model = row as CatalogModel;
- const make =
- selection.make ?? makesById.get(model.makeId);
- setSelection({
- level: 'years',
- make,
- model,
- });
- return;
- }
-
- if (selection.level === 'years') {
- const year = row as CatalogYear;
- const model =
- selection.model ?? modelsById.get(year.modelId);
- const make =
- selection.make ?? (model ? makesById.get(model.makeId) : undefined);
- setSelection({
- level: 'trims',
- make,
- model,
- year,
- });
- return;
- }
-
- if (selection.level === 'trims') {
- const trim = row as CatalogTrim;
- const year =
- selection.year ?? yearsById.get(trim.yearId);
- const model =
- selection.model ??
- (year ? modelsById.get(year.modelId) : undefined);
- const make =
- selection.make ?? (model ? makesById.get(model.makeId) : undefined);
- setSelection({
- level: 'engines',
- make,
- model,
- year,
- trim,
- });
- }
- },
- [
- selection,
- makesById,
- modelsById,
- yearsById,
- trimsById,
- ]
- );
-
- const openCreateDialog = useCallback(() => {
- if (!currentLevelRequirement.valid) {
- if (currentLevelRequirement.message) {
- toast.error(currentLevelRequirement.message);
- }
- return;
- }
-
- setDialogState({
- open: true,
- mode: 'create',
- level: selection.level,
- context: { ...selection },
- });
- }, [currentLevelRequirement, selection]);
-
- const openEditDialog = useCallback(
- (row: CatalogRow) => {
- const context = deriveContextFromRow(row, selection.level);
- setDialogState({
- open: true,
- mode: 'edit',
- level: selection.level,
- entity: row,
- context,
- });
- },
- [deriveContextFromRow, selection.level]
- );
-
- const deleteMutationMap = useMemo(
- () => ({
- makes: deleteMake,
- models: deleteModel,
- years: deleteYear,
- trims: deleteTrim,
- engines: deleteEngine,
- }),
- [deleteMake, deleteModel, deleteYear, deleteTrim, deleteEngine]
- );
-
- const invalidateCatalogQueries = useCallback(() => {
- queryClient.invalidateQueries({ queryKey: ['catalogMakes'] });
- queryClient.invalidateQueries({ queryKey: ['catalogModels'] });
- queryClient.invalidateQueries({ queryKey: ['catalogYears'] });
- queryClient.invalidateQueries({ queryKey: ['catalogTrims'] });
- queryClient.invalidateQueries({ queryKey: ['catalogEngines'] });
- }, [queryClient]);
-
- const handleDeleteSingle = useCallback(
- async (row: CatalogRow) => {
- const confirmed = window.confirm(
- `Delete this ${LEVEL_SINGULAR_LABEL[selection.level]}? This action cannot be undone.`
- );
- if (!confirmed) {
- return;
- }
-
+ const processRowUpdate = useCallback(
+ async (newRow: CatalogGridRow, oldRow: CatalogGridRow) => {
try {
- await deleteMutationMap[selection.level].mutateAsync(row.id);
- invalidateCatalogQueries();
- resetBulkSelection();
- } catch {
- // Mutation hooks handle error messaging.
- }
- },
- [deleteMutationMap, selection.level, invalidateCatalogQueries, resetBulkSelection]
- );
-
- const handleBulkDelete = useCallback(async () => {
- if (selectedCount === 0) {
- return;
- }
-
- setBulkDeleting(true);
- const ids = Array.from(selected);
- let deleted = 0;
- let failed = 0;
-
- for (const id of ids) {
- try {
- await deleteMutationMap[selection.level].mutateAsync(id);
- deleted += 1;
- } catch {
- failed += 1;
- }
- }
-
- invalidateCatalogQueries();
- resetBulkSelection();
- setBulkDeleting(false);
- setBulkDialogOpen(false);
-
- if (failed > 0) {
- toast.error(
- `Deleted ${deleted} ${LEVEL_LABEL[selection.level].toLowerCase()}, failed ${failed}`
- );
- } else {
- toast.success(
- `Deleted ${deleted} ${LEVEL_LABEL[selection.level].toLowerCase()}`
- );
- }
- }, [
- deleteMutationMap,
- invalidateCatalogQueries,
- resetBulkSelection,
- selected,
- selectedCount,
- selection.level,
- ]);
-
- const handleDialogSubmit = useCallback(
- async (values: CatalogFormValues) => {
- if (!dialogState) {
- return;
- }
-
- try {
- if (dialogState.mode === 'create') {
- switch (dialogState.level) {
- case 'makes':
- await createMake.mutateAsync({
- name: values.name?.trim() ?? '',
- });
- break;
- case 'models':
- await createModel.mutateAsync({
- name: values.name?.trim() ?? '',
- makeId:
- values.makeId ??
- dialogState.context.make?.id ??
- '',
- });
- break;
- case 'years':
- await createYear.mutateAsync({
- modelId:
- values.modelId ??
- dialogState.context.model?.id ??
- '',
- year: values.year ?? new Date().getFullYear(),
- });
- break;
- case 'trims':
- await createTrim.mutateAsync({
- name: values.name?.trim() ?? '',
- yearId:
- values.yearId ??
- dialogState.context.year?.id ??
- '',
- });
- break;
- case 'engines':
- await createEngine.mutateAsync({
- name: values.name?.trim() ?? '',
- trimId:
- values.trimId ??
- dialogState.context.trim?.id ??
- '',
- displacement: values.displacement,
- cylinders: values.cylinders,
- fuel_type: values.fuel_type,
- });
- break;
- default:
- break;
- }
+ if (newRow.makeName !== oldRow.makeName) {
+ await adminApi.updateMake(oldRow.makeId, { name: newRow.makeName });
+ } else if (newRow.modelName !== oldRow.modelName) {
+ await adminApi.updateModel(oldRow.modelId, { name: newRow.modelName });
+ } else if (newRow.yearValue !== oldRow.yearValue) {
+ await adminApi.updateYear(oldRow.yearId, {
+ modelId: oldRow.modelId,
+ year: Number(newRow.yearValue),
+ });
+ } else if (newRow.trimName !== oldRow.trimName) {
+ await adminApi.updateTrim(oldRow.trimId, { name: newRow.trimName });
+ } else if (newRow.engineName !== oldRow.engineName) {
+ await adminApi.updateEngine(oldRow.engineId, { name: newRow.engineName });
+ } else if (newRow.transmission !== oldRow.transmission) {
+ return newRow;
} else {
- const entityId = dialogState.entity?.id ?? '';
- switch (dialogState.level) {
- case 'makes':
- await updateMake.mutateAsync({
- id: entityId,
- data: { name: values.name?.trim() ?? '' },
- });
- break;
- case 'models':
- await updateModel.mutateAsync({
- id: entityId,
- data: { name: values.name?.trim() ?? '' },
- });
- break;
- case 'trims':
- await updateTrim.mutateAsync({
- id: entityId,
- data: { name: values.name?.trim() ?? '' },
- });
- break;
- case 'engines':
- await updateEngine.mutateAsync({
- id: entityId,
- data: {
- name: values.name?.trim(),
- displacement: values.displacement,
- cylinders: values.cylinders,
- fuel_type: values.fuel_type,
- },
- });
- break;
- default:
- break;
- }
+ return oldRow;
}
- setDialogState(null);
- resetBulkSelection();
- } catch {
- // Mutation hooks handle error presentation; keep dialog open for corrections.
+ toast.success('Catalog updated');
+ await fetchCatalogData();
+ return newRow;
+ } catch (error) {
+ handleRowUpdateError();
+ throw error;
}
},
- [
- createEngine,
- createMake,
- createModel,
- createTrim,
- createYear,
- dialogState,
- resetBulkSelection,
- updateEngine,
- updateMake,
- updateModel,
- updateTrim,
- ]
+ [fetchCatalogData, handleRowUpdateError]
);
- const formatRowLabel = useCallback(
- (row: CatalogRow): string => {
- switch (selection.level) {
- case 'makes':
- return (row as CatalogMake).name;
- case 'models': {
- const model = row as CatalogModel;
- const make = makesById.get(model.makeId);
- return make ? `${model.name} (${make.name})` : model.name;
- }
- case 'years': {
- const year = row as CatalogYear;
- const model = modelsById.get(year.modelId);
- return model ? `${year.year} (${model.name})` : String(year.year);
- }
- case 'trims': {
- const trim = row as CatalogTrim;
- const year = yearsById.get(trim.yearId);
- return year ? `${trim.name} (${year.year})` : trim.name;
- }
- case 'engines': {
- const engine = row as CatalogEngine;
- const trim = trimsById.get(engine.trimId);
- return trim
- ? `${engine.name} (${trim.name})`
- : engine.name;
- }
- default:
- return row.id;
+ const ensureMake = useCallback(
+ async (name: string): Promise => {
+ const existing = makes.find((make) => normalizeName(make.name) === normalizeName(name));
+ if (existing) {
+ return existing;
}
+ const created = await adminApi.createMake({ name });
+ return created;
},
- [
- makesById,
- modelsById,
- selection.level,
- trimsById,
- yearsById,
- ]
+ [makes]
);
- const cascadeSummary = useMemo(
- () =>
- getCascadeSummary(
- selection.level,
- selectedItems,
- modelsByMake,
- yearsByModel,
- trimsByYear,
- enginesByTrim
+ const ensureModel = useCallback(
+ async (makeId: string, name: string): Promise => {
+ const existing = models.find(
+ (model) =>
+ model.makeId === makeId && normalizeName(model.name) === normalizeName(name)
+ );
+ if (existing) {
+ return existing;
+ }
+ const created = await adminApi.createModel({ makeId, name });
+ return created;
+ },
+ [models]
+ );
+
+ const ensureYear = useCallback(
+ async (modelId: string, yearValue: number): Promise => {
+ const existing = years.find(
+ (year) => year.modelId === modelId && year.year === yearValue
+ );
+ if (existing) {
+ return existing;
+ }
+ const created = await adminApi.createYear({
+ modelId,
+ year: yearValue,
+ });
+ return created;
+ },
+ [years]
+ );
+
+ const ensureTrim = useCallback(
+ async (yearId: string, name: string): Promise => {
+ const existing = trims.find(
+ (trim) =>
+ trim.yearId === yearId && normalizeName(trim.name) === normalizeName(name)
+ );
+ if (existing) {
+ return existing;
+ }
+ const created = await adminApi.createTrim({ yearId, name });
+ return created;
+ },
+ [trims]
+ );
+
+ const handleDialogSubmit = async (values: CatalogCombinationFormValues) => {
+ if (!dialogState) {
+ return;
+ }
+
+ setDialogSubmitting(true);
+ try {
+ if (values.year === '' || Number.isNaN(Number(values.year))) {
+ throw new Error('Year is required');
+ }
+ const numericYear = Number(values.year);
+
+ if (dialogState.mode === 'create') {
+ const make = await ensureMake(values.makeName);
+ const model = await ensureModel(make.id, values.modelName);
+ const year = await ensureYear(model.id, numericYear);
+ const trim = await ensureTrim(year.id, values.trimName);
+ await adminApi.createEngine({
+ trimId: trim.id,
+ name: values.engineName,
+ });
+ toast.success('Vehicle configuration added');
+ } else if (dialogState.mode === 'edit' && dialogState.row) {
+ const row = dialogState.row;
+ if (values.makeName !== row.makeName) {
+ await adminApi.updateMake(row.makeId, { name: values.makeName });
+ }
+ if (values.modelName !== row.modelName) {
+ await adminApi.updateModel(row.modelId, { name: values.modelName });
+ }
+ if (numericYear !== row.yearValue) {
+ await adminApi.updateYear(row.yearId, { modelId: row.modelId, year: numericYear });
+ }
+ if (values.trimName !== row.trimName) {
+ await adminApi.updateTrim(row.trimId, { name: values.trimName });
+ }
+ if (values.engineName !== row.engineName) {
+ await adminApi.updateEngine(row.engineId, { name: values.engineName });
+ }
+ toast.success('Vehicle configuration updated');
+ }
+
+ setDialogState(null);
+ await fetchCatalogData();
+ } catch (error) {
+ const message =
+ (error as Error)?.message || 'Unable to process vehicle configuration.';
+ toast.error(message);
+ } finally {
+ setDialogSubmitting(false);
+ }
+ };
+
+ const handleDeleteRows = async () => {
+ if (selectedRows.length === 0) {
+ return;
+ }
+ setBulkDeleting(true);
+ try {
+ await Promise.all(
+ selectedRows.map((row) => adminApi.deleteEngine(row.engineId))
+ );
+ toast.success('Selected configurations deleted');
+ setBulkDialogOpen(false);
+ setRowSelectionModel([]);
+ await fetchCatalogData();
+ } catch (error) {
+ toast.error('Failed to delete selected configurations');
+ } finally {
+ setBulkDeleting(false);
+ }
+ };
+
+ const columns: GridColDef[] = [
+ {
+ field: 'yearValue',
+ headerName: 'Year',
+ flex: 0.8,
+ editable: true,
+ renderHeader: () => (
+ setFilterValue('year', value)}
+ />
),
- [
- selection.level,
- selectedItems,
- modelsByMake,
- yearsByModel,
- trimsByYear,
- enginesByTrim,
- ]
- );
-
- const bulkDialogItems = useMemo(
- () => selectedItems.map((item) => formatRowLabel(item)),
- [formatRowLabel, selectedItems]
- );
-
- const breadcrumbs = useMemo(() => {
- const items: BreadcrumbItem[] = [
- {
- key: 'catalog',
- label: 'Catalog',
- target: { level: 'makes' },
- },
- ];
-
- if (selection.make) {
- items.push({
- key: 'makes',
- label: 'Makes',
- target: { level: 'makes' },
- });
- items.push({
- key: `make-${selection.make.id}`,
- label: selection.make.name,
- target: {
- level: 'models',
- make: selection.make,
- },
- });
- }
-
- if (selection.model) {
- items.push({
- key: 'models',
- label: 'Models',
- target: {
- level: 'models',
- make: selection.make,
- },
- });
- items.push({
- key: `model-${selection.model.id}`,
- label: selection.model.name,
- target: {
- level: 'years',
- make: selection.make,
- model: selection.model,
- },
- });
- }
-
- if (selection.year) {
- items.push({
- key: 'years',
- label: 'Years',
- target: {
- level: 'years',
- make: selection.make,
- model: selection.model,
- year: selection.year,
- },
- });
- items.push({
- key: `year-${selection.year.id}`,
- label: String(selection.year.year),
- target: {
- level: 'trims',
- make: selection.make,
- model: selection.model,
- year: selection.year,
- },
- });
- }
-
- if (selection.trim) {
- items.push({
- key: 'trims',
- label: 'Trims',
- target: {
- level: 'trims',
- make: selection.make,
- model: selection.model,
- year: selection.year,
- trim: selection.trim,
- },
- });
- items.push({
- key: `trim-${selection.trim.id}`,
- label: selection.trim.name,
- target: {
- level: 'engines',
- make: selection.make,
- model: selection.model,
- year: selection.year,
- trim: selection.trim,
- },
- });
- }
-
- const currentLabel = LEVEL_LABEL[selection.level];
- const last = items[items.length - 1];
-
- if (!last || last.label !== currentLabel) {
- items.push({
- key: `level-${selection.level}`,
- label: currentLabel,
- target: { ...selection },
- });
- }
-
- return items;
- }, [selection]);
-
- const contextDescription = useMemo(() => {
- if (!canViewLevel) {
- return currentLevelRequirement.message;
- }
-
- switch (selection.level) {
- case 'models':
- return selection.make
- ? `Showing models for ${selection.make.name}`
- : undefined;
- case 'years':
- if (selection.model) {
- return `Showing years for ${selection.model.name}`;
- }
- if (selection.make) {
- return `Showing years for ${selection.make.name}`;
- }
- return undefined;
- case 'trims':
- if (selection.year) {
- return `Showing trims for ${selection.year.year}`;
- }
- if (selection.model) {
- return `Showing trims for ${selection.model.name}`;
- }
- return undefined;
- case 'engines':
- if (selection.trim) {
- return `Showing engines for ${selection.trim.name}`;
- }
- if (selection.year) {
- return `Showing engines for ${selection.year.year}`;
- }
- return undefined;
- default:
- return undefined;
- }
- }, [canViewLevel, currentLevelRequirement.message, selection]);
-
- const formatDateValue = useCallback((value?: string) => {
- if (!value) {
- return 'β';
- }
- const date = new Date(value);
- return Number.isNaN(date.valueOf())
- ? 'β'
- : date.toLocaleDateString();
- }, []);
-
- const columns = useMemo[]>(() => {
- const baseColumns: GridColumn[] = [
- {
- field: selection.level === 'years' ? 'year' : 'name',
- headerName: selection.level === 'years' ? 'Year' : 'Name',
- sortable: true,
- renderCell: (row) =>
- selection.level === 'years'
- ? String((row as CatalogYear).year)
- : (row as CatalogMake | CatalogModel | CatalogTrim | CatalogEngine).name,
- },
- {
- field: 'createdAt',
- headerName: 'Created',
- sortable: true,
- renderCell: (row) =>
- formatDateValue(row.createdAt),
- },
- {
- field: 'updatedAt',
- headerName: 'Updated',
- sortable: true,
- renderCell: (row) =>
- formatDateValue(row.updatedAt),
- },
- {
- field: 'actions',
- headerName: 'Actions',
- renderCell: (row) => (
-
- {selection.level !== 'years' && selection.level !== 'engines' && (
- {
- event.stopPropagation();
- openEditDialog(row);
- }}
- sx={{ minWidth: 44, minHeight: 44 }}
- >
-
-
- )}
- {selection.level === 'engines' && (
- {
- event.stopPropagation();
- openEditDialog(row);
- }}
- sx={{ minWidth: 44, minHeight: 44 }}
- >
-
-
- )}
- {
- event.stopPropagation();
- handleDeleteSingle(row);
- }}
- sx={{ minWidth: 44, minHeight: 44 }}
- >
-
-
- {NEXT_LEVEL[selection.level] && (
- {
- event.stopPropagation();
- handleDrillDown(row);
- }}
- sx={{ minWidth: 44, minHeight: 44 }}
- >
-
-
- )}
-
- ),
- },
- ];
-
- return baseColumns;
- }, [formatDateValue, handleDeleteSingle, handleDrillDown, openEditDialog, selection.level]);
-
- const renderTree = useCallback(
- (nodes: TreeNode[], depth = 0): React.ReactNode =>
- nodes.map((node) => {
- const isExpanded = expandedNodes.has(node.id);
- const isSelected =
- node.level !== undefined && selection.level === node.level;
-
- return (
-
- {
- if (node.level) {
- handleLevelSelect(node.level);
- }
- }}
- sx={{
- pl: 2 + depth * 2,
- }}
- >
-
- {node.label}
-
- }
- />
- {node.children && (
- {
- event.stopPropagation();
- toggleTreeNode(node.id);
- }}
- sx={{ minWidth: 36, minHeight: 36 }}
- >
- {isExpanded ? (
-
- ) : (
-
- )}
-
- )}
-
- {node.children && (
-
-
- {renderTree(node.children, depth + 1)}
-
-
- )}
-
- );
- }),
- [expandedNodes, handleLevelSelect, selection.level, toggleTreeNode]
- );
+ valueFormatter: (params) => params.value?.toString() ?? '',
+ type: 'number',
+ },
+ {
+ field: 'makeName',
+ headerName: 'Make',
+ flex: 1,
+ editable: true,
+ renderHeader: () => (
+ setFilterValue('make', value)}
+ />
+ ),
+ },
+ {
+ field: 'modelName',
+ headerName: 'Model',
+ flex: 1,
+ editable: true,
+ renderHeader: () => (
+ setFilterValue('model', value)}
+ />
+ ),
+ },
+ {
+ field: 'trimName',
+ headerName: 'Trim',
+ flex: 1,
+ editable: true,
+ renderHeader: () => (
+ setFilterValue('trim', value)}
+ />
+ ),
+ },
+ {
+ field: 'engineName',
+ headerName: 'Engine',
+ flex: 1.2,
+ editable: true,
+ renderHeader: () => (
+ setFilterValue('engine', value)}
+ />
+ ),
+ },
+ {
+ field: 'transmission',
+ headerName: 'Transmission',
+ flex: 0.8,
+ editable: true,
+ type: 'singleSelect',
+ valueOptions: transmissionOptions,
+ renderHeader: () => (
+ setFilterValue('transmission', value)}
+ />
+ ),
+ },
+ ];
if (authLoading) {
return (
@@ -1121,7 +600,7 @@ export const AdminCatalogPage: React.FC = () => {
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
- minHeight: '50vh',
+ minHeight: '60vh',
}}
>
@@ -1141,56 +620,6 @@ export const AdminCatalogPage: React.FC = () => {
{ label: 'Engines', value: engines.length },
];
- const modelsCountLabel = selection.make ? models.length.toString() : 'β';
- const yearsCountLabel = selection.model ? years.length.toString() : 'β';
- const trimsCountLabel = selection.year ? trims.length.toString() : 'β';
- const enginesCountLabel = selection.trim ? engines.length.toString() : 'β';
-
- const treeNodes: TreeNode[] = [
- {
- id: 'node-makes',
- label: `Makes (${makes.length})`,
- level: 'makes',
- children: [
- {
- id: 'node-models',
- label: `Models (${modelsCountLabel})`,
- level: 'models',
- children: [
- {
- id: 'node-years',
- label: `Years (${yearsCountLabel})`,
- level: 'years',
- children: [
- {
- id: 'node-trims',
- label: `Trims (${trimsCountLabel})`,
- level: 'trims',
- children: [
- {
- id: 'node-engines',
- label: `Engines (${enginesCountLabel})`,
- level: 'engines',
- },
- ],
- },
- ],
- },
- ],
- },
- ],
- },
- ];
-
- const emptyStateMessage = canViewLevel
- ? `No ${LEVEL_LABEL[selection.level].toLowerCase()} found`
- : currentLevelRequirement.message ?? 'Select a parent item to continue.';
-
- const bulkDialogTitle =
- selectedCount === 1
- ? `Delete 1 ${LEVEL_SINGULAR_LABEL[selection.level]}?`
- : `Delete ${selectedCount} ${LEVEL_LABEL[selection.level]}?`;
-
return (
{
gap: 3,
}}
>
-
-
-
- {breadcrumbs.map((crumb, index) => {
- const isLast = index === breadcrumbs.length - 1;
- if (isLast) {
- return (
-
- {crumb.label}
-
- );
- }
- return (
- setSelection({ ...crumb.target })}
- sx={{ cursor: 'pointer' }}
- >
- {crumb.label}
-
- );
- })}
-
-
-
-
-
- Catalog Tree
-
-
- {renderTree(treeNodes)}
-
-
+
+ {loadingCatalog ? (
- toggleAll(currentRows)}
- loading={activeQuery.isLoading}
- error={activeError}
- onRetry={() => activeQuery.refetch()}
- emptyMessage={emptyStateMessage}
- toolbar={
+
+
+ {loadingMessage}
+
+
+ ) : catalogError ? (
+
+
+ Failed to load catalog
+
+
+ {catalogError.message || 'An unexpected error occurred.'}
+
+
+
+ ) : (
+ <>
+
+
+
+ Vehicle Configurations
+
+
- toggleAll(currentRows)}
+ }
+ variant="contained"
+ onClick={() => setDialogState({ mode: 'create' })}
+ sx={{ textTransform: 'none' }}
>
-
-
-
+ }
+ variant="outlined"
+ color="primary"
+ disabled={selectedRows.length !== 1}
+ onClick={() =>
+ setDialogState({
+ mode: 'edit',
+ row: selectedRows[0],
+ })
+ }
+ sx={{ textTransform: 'none' }}
>
-
- {LEVEL_LABEL[selection.level]}
-
- }
- onClick={openCreateDialog}
- disabled={!currentLevelRequirement.valid}
- sx={{ minHeight: 44, textTransform: 'none' }}
- >
- Add {LEVEL_SINGULAR_LABEL[selection.level]}
-
-
- {contextDescription && (
-
- {contextDescription}
-
- )}
+ Edit
+
+ }
+ variant="outlined"
+ color="error"
+ disabled={selectedRows.length === 0}
+ onClick={() => setBulkDialogOpen(true)}
+ sx={{ textTransform: 'none' }}
+ >
+ {selectedRows.length > 1 ? 'Bulk Delete' : 'Delete'}
+
- }
- />
-
+
-
-
-
-
+
+
+
+
+
+
+
+
+ >
+ )}
{
if (!bulkDeleting) {
setBulkDialogOpen(false);
@@ -1346,15 +790,18 @@ export const AdminCatalogPage: React.FC = () => {
/>
{dialogState && (
- setDialogState(null)}
+ row={dialogState.row}
+ onClose={() => {
+ if (!dialogSubmitting) {
+ setDialogState(null);
+ }
+ }}
onSubmit={handleDialogSubmit}
- context={dialogState.context}
- options={{
+ submitting={dialogSubmitting}
+ existingOptions={{
makes,
models,
years,
@@ -1366,15 +813,14 @@ export const AdminCatalogPage: React.FC = () => {
);
};
-interface CatalogFormDialogProps {
+interface CatalogCombinationDialogProps {
open: boolean;
- level: CatalogLevel;
mode: 'create' | 'edit';
- entity?: CatalogRow;
+ row?: CatalogGridRow;
+ submitting: boolean;
+ onSubmit: (values: CatalogCombinationFormValues) => Promise;
onClose: () => void;
- onSubmit: (values: CatalogFormValues) => Promise;
- context: CatalogSelectionContext;
- options: {
+ existingOptions: {
makes: CatalogMake[];
models: CatalogModel[];
years: CatalogYear[];
@@ -1382,374 +828,248 @@ interface CatalogFormDialogProps {
};
}
-const CatalogFormDialog: React.FC = ({
+const CatalogCombinationDialog: React.FC = ({
open,
- level,
mode,
- entity,
- onClose,
+ row,
+ submitting,
onSubmit,
- context,
- options,
+ onClose,
+ existingOptions,
}) => {
- const schema = useMemo(() => getSchemaForLevel(level), [level]);
-
- const defaultValues = useMemo(
- () => buildDefaultValues(level, mode, entity, context),
- [context, entity, level, mode]
- );
-
const {
control,
handleSubmit,
- formState: { errors, isSubmitting },
- reset,
watch,
- } = useForm({
- resolver: zodResolver(schema),
- defaultValues,
+ reset,
+ formState: { errors },
+ } = useForm({
+ defaultValues: {
+ makeName: row?.makeName ?? '',
+ modelName: row?.modelName ?? '',
+ year: row?.yearValue ?? new Date().getFullYear(),
+ trimName: row?.trimName ?? '',
+ engineName: row?.engineName ?? '',
+ transmission: row?.transmission ?? 'Automatic',
+ },
});
useEffect(() => {
- if (open) {
- reset(defaultValues);
- }
- }, [defaultValues, open, reset]);
+ reset({
+ makeName: row?.makeName ?? '',
+ modelName: row?.modelName ?? '',
+ year: row?.yearValue ?? new Date().getFullYear(),
+ trimName: row?.trimName ?? '',
+ engineName: row?.engineName ?? '',
+ transmission: row?.transmission ?? 'Automatic',
+ });
+ }, [reset, row]);
- const selectedModelId = watch('modelId');
- const selectedYearId = watch('yearId');
+ const selectedMakeName = watch('makeName');
+ const selectedModelName = watch('modelName');
+ const selectedYearValue = watch('year');
- const sanitizedOptions = useMemo(() => ({
- makes: normalizeCollection(options.makes, 'makes'),
- models: normalizeCollection(options.models, 'models'),
- years: normalizeCollection(options.years, 'years'),
- trims: normalizeCollection(options.trims, 'trims'),
- }), [options]);
+ const filteredModels = useMemo(() => {
+ if (!selectedMakeName) {
+ return existingOptions.models;
+ }
+ const make = existingOptions.makes.find(
+ (item) => normalizeName(item.name) === normalizeName(selectedMakeName)
+ );
+ if (!make) {
+ return [];
+ }
+ return existingOptions.models.filter((model) => model.makeId === make.id);
+ }, [existingOptions.models, existingOptions.makes, selectedMakeName]);
- const availableModels = useMemo(() => {
- if (context.make) {
- return sanitizedOptions.models.filter(
- (model) => model.makeId === context.make?.id
- );
+ const filteredTrims = useMemo(() => {
+ if (!selectedYearValue) {
+ return existingOptions.trims;
}
- return sanitizedOptions.models;
- }, [context.make, sanitizedOptions.models]);
- const availableYears = useMemo(() => {
- if (context.model) {
- return sanitizedOptions.years.filter(
- (year) => year.modelId === context.model?.id
- );
- }
- if (context.make) {
- const modelIds = sanitizedOptions.models
- .filter((model) => model.makeId === context.make?.id)
- .map((model) => model.id);
- return sanitizedOptions.years.filter((year) =>
- modelIds.includes(year.modelId)
- );
- }
- if (selectedModelId) {
- return sanitizedOptions.years.filter(
- (year) => year.modelId === selectedModelId
- );
- }
- return sanitizedOptions.years;
- }, [
- context.make,
- context.model,
- sanitizedOptions.models,
- sanitizedOptions.years,
- selectedModelId,
- ]);
+ const matchingYears = existingOptions.years.filter(
+ (item) => item.year === selectedYearValue
+ );
- const availableTrims = useMemo(() => {
- if (context.year) {
- return sanitizedOptions.trims.filter(
- (trim) => trim.yearId === context.year?.id
- );
+ if (matchingYears.length === 0) {
+ return [];
}
- if (selectedYearId) {
- return sanitizedOptions.trims.filter(
- (trim) => trim.yearId === selectedYearId
- );
- }
- if (context.model) {
- const yearIds = sanitizedOptions.years
- .filter((year) => year.modelId === context.model?.id)
- .map((year) => year.id);
- return sanitizedOptions.trims.filter((trim) =>
- yearIds.includes(trim.yearId)
- );
- }
- return sanitizedOptions.trims;
- }, [
- context.model,
- context.year,
- sanitizedOptions.trims,
- sanitizedOptions.years,
- selectedYearId,
- ]);
- const handleDialogClose = () => {
- if (!isSubmitting) {
- onClose();
+ if (selectedModelName) {
+ const model = existingOptions.models.find(
+ (item) => normalizeName(item.name) === normalizeName(selectedModelName)
+ );
+ if (model) {
+ const yearForModel = matchingYears.find((year) => year.modelId === model.id);
+ if (yearForModel) {
+ return existingOptions.trims.filter((trim) => trim.yearId === yearForModel.id);
+ }
+ }
}
- };
+
+ if (matchingYears.length === 1) {
+ return existingOptions.trims.filter((trim) => trim.yearId === matchingYears[0].id);
+ }
+
+ return existingOptions.trims;
+ }, [existingOptions.trims, existingOptions.years, existingOptions.models, selectedYearValue, selectedModelName]);
+
+ const submitHandler = handleSubmit((values) => onSubmit(values));
return (
-