Community 93 Premium feature complete

This commit is contained in:
Eric Gullickson
2025-12-21 11:31:10 -06:00
parent 1bde31247f
commit 95f5e89e48
60 changed files with 8061 additions and 350 deletions

View File

@@ -0,0 +1,211 @@
/**
* @ai-summary Community station review card for admin approval workflow
*/
import React, { useState } from 'react';
import {
Card,
CardContent,
CardActions,
Typography,
Box,
Button,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
Chip,
Grid,
} from '@mui/material';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import CancelIcon from '@mui/icons-material/Cancel';
import { CommunityStation } from '../../stations/types/community-stations.types';
interface CommunityStationReviewCardProps {
station: CommunityStation;
onApprove: (id: string) => void;
onReject: (id: string, reason: string) => void;
isLoading?: boolean;
}
/**
* Card showing full station details for admin review
* Responsive design with 44px minimum touch targets
*/
export const CommunityStationReviewCard: React.FC<CommunityStationReviewCardProps> = ({
station,
onApprove,
onReject,
isLoading = false,
}) => {
const [openRejectDialog, setOpenRejectDialog] = useState(false);
const [rejectionReason, setRejectionReason] = useState('');
const handleApprove = () => {
onApprove(station.id);
};
const handleRejectClick = () => {
setOpenRejectDialog(true);
};
const handleRejectConfirm = () => {
if (rejectionReason.trim()) {
onReject(station.id, rejectionReason);
setOpenRejectDialog(false);
setRejectionReason('');
}
};
const octaneLabel = station.has93Octane
? station.has93OctaneEthanolFree
? '93 Octane · Ethanol Free'
: '93 Octane · w/ Ethanol'
: 'No 93 Octane';
return (
<>
<Card
sx={{
transition: 'transform 0.2s, box-shadow 0.2s',
'&:hover': {
transform: 'translateY(-2px)',
boxShadow: 3,
},
}}
>
<CardContent>
{/* Station name and location */}
<Typography variant="h6" sx={{ mb: 1 }}>
{station.name}
</Typography>
<Box sx={{ mb: 2, display: 'flex', flexDirection: 'column', gap: 0.5 }}>
<Typography variant="body2" color="textSecondary">
{station.address}
</Typography>
{(station.city || station.state || station.zipCode) && (
<Typography variant="body2" color="textSecondary">
{[station.city, station.state, station.zipCode].filter(Boolean).join(', ')}
</Typography>
)}
</Box>
{/* Details grid */}
<Grid container spacing={1} sx={{ mb: 2 }}>
{station.brand && (
<Grid item xs={6}>
<Typography variant="caption" color="textSecondary" display="block">
Brand
</Typography>
<Typography variant="body2">{station.brand}</Typography>
</Grid>
)}
<Grid item xs={station.brand ? 6 : 12}>
<Typography variant="caption" color="textSecondary" display="block">
93 Octane Status
</Typography>
<Chip label={octaneLabel} size="small" color="success" />
</Grid>
{station.price93 && (
<Grid item xs={6}>
<Typography variant="caption" color="textSecondary" display="block">
Price
</Typography>
<Typography variant="body2">${station.price93.toFixed(3)}</Typography>
</Grid>
)}
</Grid>
{/* Coordinates */}
<Box sx={{ mb: 2, p: 1, bgcolor: 'grey.100', borderRadius: 1 }}>
<Typography variant="caption" color="textSecondary">
Latitude: {station.latitude.toFixed(8)}
</Typography>
<Typography variant="caption" color="textSecondary" display="block">
Longitude: {station.longitude.toFixed(8)}
</Typography>
</Box>
{/* Notes */}
{station.notes && (
<Box sx={{ mb: 2 }}>
<Typography variant="caption" color="textSecondary">
Notes:
</Typography>
<Typography variant="body2" sx={{ p: 1, bgcolor: 'grey.50', borderRadius: 1, mt: 0.5 }}>
{station.notes}
</Typography>
</Box>
)}
{/* Submission info */}
<Box sx={{ pt: 1, borderTop: '1px solid #e0e0e0' }}>
<Typography variant="caption" color="textSecondary" display="block">
Submitted by: {station.submittedBy}
</Typography>
<Typography variant="caption" color="textSecondary" display="block">
Date: {new Date(station.createdAt).toLocaleDateString()}
</Typography>
</Box>
</CardContent>
{/* Action buttons */}
<CardActions sx={{ gap: 1, minHeight: '44px', justifyContent: 'flex-end' }}>
<Button
variant="outlined"
color="error"
onClick={handleRejectClick}
disabled={isLoading}
startIcon={<CancelIcon />}
sx={{ minHeight: '44px' }}
>
Reject
</Button>
<Button
variant="contained"
color="success"
onClick={handleApprove}
disabled={isLoading}
startIcon={<CheckCircleIcon />}
sx={{ minHeight: '44px' }}
>
Approve
</Button>
</CardActions>
</Card>
{/* Rejection dialog */}
<Dialog open={openRejectDialog} onClose={() => setOpenRejectDialog(false)} maxWidth="sm" fullWidth>
<DialogTitle>Reject Station Submission</DialogTitle>
<DialogContent sx={{ pt: 2 }}>
<TextField
autoFocus
fullWidth
multiline
rows={4}
label="Rejection Reason"
placeholder="e.g., Incorrect location, duplicate entry, invalid address..."
value={rejectionReason}
onChange={(e) => setRejectionReason(e.target.value)}
variant="outlined"
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setOpenRejectDialog(false)}>Cancel</Button>
<Button
onClick={handleRejectConfirm}
variant="contained"
color="error"
disabled={!rejectionReason.trim() || isLoading}
>
Reject
</Button>
</DialogActions>
</Dialog>
</>
);
};
export default CommunityStationReviewCard;

View File

@@ -0,0 +1,119 @@
/**
* @ai-summary Queue of pending community station submissions for admin review
*/
import React from 'react';
import {
Box,
CircularProgress,
Alert,
Grid,
Pagination,
Typography,
} from '@mui/material';
import { CommunityStation } from '../../stations/types/community-stations.types';
import { CommunityStationReviewCard } from './CommunityStationReviewCard';
interface CommunityStationReviewQueueProps {
stations: CommunityStation[];
loading?: boolean;
error?: string | null;
onApprove: (id: string) => void;
onReject: (id: string, reason: string) => void;
page?: number;
onPageChange?: (page: number) => void;
totalPages?: number;
isReviewLoading?: boolean;
}
/**
* Review queue for pending community station submissions
* Responsive grid layout with pagination
*/
export const CommunityStationReviewQueue: React.FC<CommunityStationReviewQueueProps> = ({
stations,
loading = false,
error = null,
onApprove,
onReject,
page = 0,
onPageChange,
totalPages = 1,
isReviewLoading = false,
}) => {
if (loading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '300px' }}>
<CircularProgress />
</Box>
);
}
if (error) {
return (
<Alert severity="error">
{error}
</Alert>
);
}
if (stations.length === 0) {
return (
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minHeight: '300px',
flexDirection: 'column',
gap: 1,
}}
>
<Typography variant="h6" color="textSecondary">
No pending submissions
</Typography>
<Typography variant="body2" color="textSecondary">
All community stations have been reviewed
</Typography>
</Box>
);
}
return (
<Box>
{/* Stats header */}
<Box sx={{ mb: 2 }}>
<Typography variant="body2" color="textSecondary">
Pending review: {stations.length} submission{stations.length !== 1 ? 's' : ''}
</Typography>
</Box>
{/* Grid of cards */}
<Grid container spacing={2}>
{stations.map((station) => (
<Grid item xs={12} sm={6} md={4} key={station.id}>
<CommunityStationReviewCard
station={station}
onApprove={onApprove}
onReject={onReject}
isLoading={isReviewLoading}
/>
</Grid>
))}
</Grid>
{/* Pagination */}
{onPageChange && totalPages > 1 && (
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 3 }}>
<Pagination
count={totalPages}
page={page + 1}
onChange={(_, newPage) => onPageChange(newPage - 1)}
/>
</Box>
)}
</Box>
);
};
export default CommunityStationReviewQueue;

View File

@@ -0,0 +1,179 @@
/**
* @ai-summary Mobile admin screen for community station reviews
*/
import React, { useState } from 'react';
import { Navigate } from 'react-router-dom';
import {
Box,
Select,
MenuItem,
FormControl,
InputLabel,
} from '@mui/material';
import { MobileContainer } from '../../../shared-minimal/components/mobile/MobileContainer';
import { useAdminAccess } from '../../../core/auth/useAdminAccess';
import { CommunityStationReviewQueue } from '../components/CommunityStationReviewQueue';
import { CommunityStationsList } from '../../stations/components/CommunityStationsList';
import {
usePendingSubmissions,
useAllCommunitySubmissions,
useReviewStation,
} from '../../stations/hooks/useCommunityStations';
import toast from 'react-hot-toast';
type TabType = 'pending' | 'all';
/**
* Mobile admin screen for reviewing community station submissions
* Touch-friendly layout with tab navigation
*/
export const AdminCommunityStationsMobileScreen: React.FC = () => {
const { isAdmin, loading } = useAdminAccess();
const [activeTab, setActiveTab] = useState<TabType>('pending');
const [statusFilter, setStatusFilter] = useState<string>('');
const [page, setPage] = useState(0);
// Hooks
const pendingSubmissions = usePendingSubmissions(page, 20);
const allSubmissions = useAllCommunitySubmissions(statusFilter || undefined, page, 20);
const reviewMutation = useReviewStation();
if (loading) {
return (
<MobileContainer>
<div className="flex items-center justify-center min-h-[400px]">
<div className="text-center">
<div className="text-slate-500 mb-2">Loading admin access...</div>
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
</div>
</div>
</MobileContainer>
);
}
if (!isAdmin) {
return <Navigate to="/garage/settings" replace />;
}
// Handle approval
const handleApprove = async (id: string) => {
try {
await reviewMutation.mutateAsync({
id,
decision: { status: 'approved' },
});
toast.success('Station approved');
} catch (error: any) {
toast.error(error?.response?.data?.message || 'Failed to approve station');
}
};
// Handle rejection
const handleReject = async (id: string, reason: string) => {
try {
await reviewMutation.mutateAsync({
id,
decision: { status: 'rejected', rejectionReason: reason },
});
toast.success('Station rejected');
} catch (error: any) {
toast.error(error?.response?.data?.message || 'Failed to reject station');
}
};
const displayData = activeTab === 'pending' ? pendingSubmissions : allSubmissions;
const stations = displayData.data?.stations || [];
const totalPages = displayData.data?.total ? Math.ceil(displayData.data.total / 20) : 1;
return (
<MobileContainer>
<div className="space-y-4 pb-20 p-4">
{/* Header */}
<div className="text-center mb-6">
<h1 className="text-2xl font-bold text-slate-800">Community Station Reviews</h1>
<p className="text-slate-500 mt-1">Review user-submitted gas stations</p>
</div>
{/* Tab navigation */}
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
<button
onClick={() => {
setActiveTab('pending');
setPage(0);
}}
className={`flex-1 py-2 px-3 rounded-lg font-medium transition ${
activeTab === 'pending'
? 'bg-blue-600 text-white'
: 'bg-slate-100 text-slate-600'
}`}
>
Pending ({pendingSubmissions.data?.total || 0})
</button>
<button
onClick={() => {
setActiveTab('all');
setPage(0);
}}
className={`flex-1 py-2 px-3 rounded-lg font-medium transition ${
activeTab === 'all'
? 'bg-blue-600 text-white'
: 'bg-slate-100 text-slate-600'
}`}
>
All
</button>
</Box>
{/* Status filter for all tab */}
{activeTab === 'all' && (
<FormControl fullWidth size="small" sx={{ mb: 2 }}>
<InputLabel>Filter by Status</InputLabel>
<Select
value={statusFilter}
label="Filter by Status"
onChange={(e) => {
setStatusFilter(e.target.value);
setPage(0);
}}
>
<MenuItem value="">All</MenuItem>
<MenuItem value="pending">Pending</MenuItem>
<MenuItem value="approved">Approved</MenuItem>
<MenuItem value="rejected">Rejected</MenuItem>
</Select>
</FormControl>
)}
{/* Content */}
{activeTab === 'pending' ? (
<CommunityStationReviewQueue
stations={stations}
loading={displayData.isLoading}
error={displayData.error ? 'Failed to load submissions' : null}
onApprove={handleApprove}
onReject={handleReject}
page={page}
onPageChange={setPage}
totalPages={totalPages}
isReviewLoading={reviewMutation.isPending}
/>
) : (
<CommunityStationsList
stations={stations}
loading={displayData.isLoading}
error={displayData.error ? 'Failed to load submissions' : null}
isAdmin={true}
onApprove={handleApprove}
onReject={handleReject}
page={page}
onPageChange={setPage}
totalPages={totalPages}
/>
)}
</div>
</MobileContainer>
);
};
export default AdminCommunityStationsMobileScreen;

View File

@@ -0,0 +1,236 @@
/**
* @ai-summary Admin desktop page for managing community station submissions
*/
import React, { useState, useCallback } from 'react';
import {
Box,
Paper,
Tabs,
Tab,
Select,
MenuItem,
FormControl,
InputLabel,
useMediaQuery,
useTheme,
Grid,
Typography,
} from '@mui/material';
import { AdminSectionHeader } from '../components/AdminSectionHeader';
import { CommunityStationReviewQueue } from '../components/CommunityStationReviewQueue';
import { CommunityStationsList } from '../../stations/components/CommunityStationsList';
import {
usePendingSubmissions,
useAllCommunitySubmissions,
useReviewStation,
} from '../../stations/hooks/useCommunityStations';
import toast from 'react-hot-toast';
interface TabPanelProps {
children?: React.ReactNode;
index: number;
value: number;
}
const TabPanel: React.FC<TabPanelProps> = ({ children, value, index }) => {
if (value !== index) {
return null;
}
return (
<div role="tabpanel">
<Box sx={{ padding: 2 }}>{children}</Box>
</div>
);
};
/**
* Admin page for reviewing and managing community station submissions
* Desktop layout with tab navigation and status filtering
*/
export const AdminCommunityStationsPage: React.FC = () => {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const [tabValue, setTabValue] = useState(0);
const [statusFilter, setStatusFilter] = useState<string>('');
const [page, setPage] = useState(0);
// Hooks
const pendingSubmissions = usePendingSubmissions(page, 50);
const allSubmissions = useAllCommunitySubmissions(statusFilter || undefined, page, 50);
const reviewMutation = useReviewStation();
// Determine which data to display
const displayData = tabValue === 0 ? pendingSubmissions : allSubmissions;
const stations = displayData.data?.stations || [];
const totalPages = displayData.data?.total
? Math.ceil(displayData.data.total / 50)
: 1;
// Handle approval
const handleApprove = useCallback(
async (id: string) => {
try {
await reviewMutation.mutateAsync({
id,
decision: { status: 'approved' },
});
toast.success('Station approved');
} catch (error: any) {
toast.error(error?.response?.data?.message || 'Failed to approve station');
}
},
[reviewMutation]
);
// Handle rejection
const handleReject = useCallback(
async (id: string, reason: string) => {
try {
await reviewMutation.mutateAsync({
id,
decision: { status: 'rejected', rejectionReason: reason },
});
toast.success('Station rejected');
} catch (error: any) {
toast.error(error?.response?.data?.message || 'Failed to reject station');
}
},
[reviewMutation]
);
return (
<Box>
{/* Header */}
<AdminSectionHeader
title="Community Gas Station Reviews"
stats={[
{ label: 'Pending', value: pendingSubmissions.data?.total || 0 },
{ label: 'Total', value: allSubmissions.data?.total || 0 }
]}
/>
{/* Main content */}
<Paper sx={{ m: 2 }}>
<Tabs
value={tabValue}
onChange={(_, newValue) => setTabValue(newValue)}
indicatorColor="primary"
textColor="primary"
aria-label="admin community stations tabs"
variant={isMobile ? 'scrollable' : 'standard'}
>
<Tab label={`Pending (${pendingSubmissions.data?.total || 0})`} id="tab-0" />
<Tab label="All Submissions" id="tab-1" />
</Tabs>
{/* Pending tab */}
<TabPanel value={tabValue} index={0}>
<CommunityStationReviewQueue
stations={stations}
loading={displayData.isLoading}
error={displayData.error ? 'Failed to load submissions' : null}
onApprove={handleApprove}
onReject={handleReject}
page={page}
onPageChange={setPage}
totalPages={totalPages}
isReviewLoading={reviewMutation.isPending}
/>
</TabPanel>
{/* All submissions tab */}
<TabPanel value={tabValue} index={1}>
<Grid container spacing={2} sx={{ mb: 2 }}>
<Grid item xs={12} sm={6} md={3}>
<FormControl fullWidth size="small">
<InputLabel>Filter by Status</InputLabel>
<Select
value={statusFilter}
label="Filter by Status"
onChange={(e) => {
setStatusFilter(e.target.value);
setPage(0);
}}
>
<MenuItem value="">All</MenuItem>
<MenuItem value="pending">Pending</MenuItem>
<MenuItem value="approved">Approved</MenuItem>
<MenuItem value="rejected">Rejected</MenuItem>
</Select>
</FormControl>
</Grid>
</Grid>
<CommunityStationsList
stations={stations}
loading={displayData.isLoading}
error={displayData.error ? 'Failed to load submissions' : null}
isAdmin={true}
onApprove={handleApprove}
onReject={handleReject}
page={page}
onPageChange={setPage}
totalPages={totalPages}
/>
</TabPanel>
</Paper>
{/* Stats card */}
{pendingSubmissions.data && (
<Paper sx={{ m: 2, p: 2 }}>
<Grid container spacing={2}>
<Grid item xs={12} sm={6} md={3}>
<Box>
<Typography variant="h4" color="primary">
{pendingSubmissions.data.total}
</Typography>
<Typography variant="body2" color="textSecondary">
Pending Review
</Typography>
</Box>
</Grid>
{allSubmissions.data && (
<>
<Grid item xs={12} sm={6} md={3}>
<Box>
<Typography variant="h4" color="success.main">
{allSubmissions.data.stations.filter((s) => s.status === 'approved').length}
</Typography>
<Typography variant="body2" color="textSecondary">
Approved
</Typography>
</Box>
</Grid>
<Grid item xs={12} sm={6} md={3}>
<Box>
<Typography variant="h4" color="error.main">
{allSubmissions.data.stations.filter((s) => s.status === 'rejected').length}
</Typography>
<Typography variant="body2" color="textSecondary">
Rejected
</Typography>
</Box>
</Grid>
<Grid item xs={12} sm={6} md={3}>
<Box>
<Typography variant="h4">
{allSubmissions.data.total}
</Typography>
<Typography variant="body2" color="textSecondary">
Total Submitted
</Typography>
</Box>
</Grid>
</>
)}
</Grid>
</Paper>
)}
</Box>
);
};
export default AdminCommunityStationsPage;