Compare commits

..

10 Commits

Author SHA1 Message Date
Eric Gullickson
812823f2f1 chore: integrate AddReceiptDialog into MaintenanceRecordForm (refs #184)
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 3m31s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 22s
Deploy to Staging / Verify Staging (pull_request) Successful in 9s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 8s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
Replace ReceiptCameraButton with "Add Receipt" button that opens
AddReceiptDialog. Upload path feeds handleCaptureImage, camera path
calls startCapture. Tier gating preserved.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 20:57:37 -06:00
Eric Gullickson
6751766b0a chore: create AddReceiptDialog component with upload and camera options (refs #183)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 20:55:21 -06:00
Eric Gullickson
bc72f09557 feat: add desktop sidebar collapse to icon-only mode (refs #176)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 20:07:00 -06:00
Eric Gullickson
f987e94fed chore: verify notification bell functionality and improve empty state (refs #180)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 20:02:56 -06:00
Eric Gullickson
da4cd858fa chore: use display name instead of email in header greeting (refs #177)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 20:01:56 -06:00
Eric Gullickson
553877bfc6 chore: add upload date and file type icon to document cards (refs #172)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 20:00:49 -06:00
Eric Gullickson
daa0cd072e chore: remove Insurance default bias from Add Document modal (refs #175)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 19:56:34 -06:00
Eric Gullickson
afd4583450 chore: show service type in maintenance schedule names for differentiation (refs #174)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 19:55:25 -06:00
Eric Gullickson
f03cd420ef chore: add Maintenance page title and remove duplicate vehicle dropdown (refs #169)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 19:53:13 -06:00
Eric Gullickson
e4be744643 chore: restructure Fuel Logs to list-first with add dialog (refs #168)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 19:49:46 -06:00
14 changed files with 646 additions and 176 deletions

View File

@@ -6,7 +6,7 @@ import React from 'react';
import { useAuth0 } from '@auth0/auth0-react';
import { Link, useLocation } from 'react-router-dom';
import { useLogout } from '../core/auth/useLogout';
import { Container, Paper, Typography, Box, IconButton, Avatar } from '@mui/material';
import { Container, Paper, Typography, Box, IconButton, Avatar, Tooltip } from '@mui/material';
import HomeRoundedIcon from '@mui/icons-material/HomeRounded';
import DirectionsCarRoundedIcon from '@mui/icons-material/DirectionsCarRounded';
import LocalGasStationRoundedIcon from '@mui/icons-material/LocalGasStationRounded';
@@ -15,7 +15,8 @@ import PlaceRoundedIcon from '@mui/icons-material/PlaceRounded';
import SettingsRoundedIcon from '@mui/icons-material/SettingsRounded';
import DescriptionRoundedIcon from '@mui/icons-material/DescriptionRounded';
import MenuIcon from '@mui/icons-material/Menu';
import CloseIcon from '@mui/icons-material/Close';
import ChevronLeftRoundedIcon from '@mui/icons-material/ChevronLeftRounded';
import ChevronRightRoundedIcon from '@mui/icons-material/ChevronRightRounded';
import { useAppStore } from '../core/store';
import { Button } from '../shared-minimal/components/Button';
import { NotificationBell } from '../features/notifications';
@@ -29,7 +30,7 @@ interface LayoutProps {
export const Layout: React.FC<LayoutProps> = ({ children, mobileMode = false }) => {
const { user } = useAuth0();
const { logout } = useLogout();
const { sidebarOpen, toggleSidebar, setSidebarOpen } = useAppStore();
const { sidebarOpen, sidebarCollapsed, toggleSidebar, setSidebarOpen, toggleSidebarCollapse } = useAppStore();
const location = useLocation();
// Sync theme preference with backend
@@ -52,6 +53,8 @@ export const Layout: React.FC<LayoutProps> = ({ children, mobileMode = false })
{ name: 'Settings', href: '/garage/settings', icon: <SettingsRoundedIcon sx={{ fontSize: 20 }} /> },
];
const sidebarWidth = sidebarCollapsed ? 64 : 256;
// Mobile layout
if (mobileMode) {
return (
@@ -107,61 +110,65 @@ export const Layout: React.FC<LayoutProps> = ({ children, mobileMode = false })
top: 0,
left: 0,
height: '100vh',
width: 256,
width: sidebarWidth,
zIndex: 1000,
borderRadius: 0,
borderRight: 1,
borderColor: 'divider',
transform: sidebarOpen ? 'translateX(0)' : 'translateX(-100%)',
transition: 'transform 0.2s ease-in-out',
transition: 'transform 0.2s ease-in-out, width 0.2s ease-in-out',
display: 'flex',
flexDirection: 'column'
flexDirection: 'column',
overflow: 'hidden',
}}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
justifyContent: sidebarCollapsed ? 'center' : 'space-between',
height: 64,
px: 2,
px: sidebarCollapsed ? 1 : 2,
borderBottom: 1,
borderColor: 'divider',
gap: 1
}}
>
<Box
sx={(theme) => ({
backgroundColor: 'primary.main',
...theme.applyStyles('dark', {
backgroundColor: 'transparent',
}),
borderRadius: 0.5,
px: 1,
py: 0.5,
display: 'inline-flex',
alignItems: 'center'
})}
>
<img
src="/images/logos/motovaultpro-title-slogan.png"
alt="MotoVaultPro"
style={{ height: 24, width: 'auto', maxWidth: 180 }}
/>
</Box>
{!sidebarCollapsed && (
<Box
sx={(theme) => ({
backgroundColor: 'primary.main',
...theme.applyStyles('dark', {
backgroundColor: 'transparent',
}),
borderRadius: 0.5,
px: 1,
py: 0.5,
display: 'inline-flex',
alignItems: 'center',
overflow: 'hidden',
})}
>
<img
src="/images/logos/motovaultpro-title-slogan.png"
alt="MotoVaultPro"
style={{ height: 24, width: 'auto', maxWidth: 180 }}
/>
</Box>
)}
<IconButton
onClick={toggleSidebar}
onClick={toggleSidebarCollapse}
size="small"
sx={{ color: 'text.secondary', flexShrink: 0 }}
>
<CloseIcon />
{sidebarCollapsed ? <ChevronRightRoundedIcon /> : <ChevronLeftRoundedIcon />}
</IconButton>
</Box>
<Box sx={{ mt: 3, px: 2, flex: 1 }}>
<Box sx={{ mt: 3, px: sidebarCollapsed ? 1 : 2, flex: 1 }}>
{navigation.map((item) => {
const isActive = location.pathname.startsWith(item.href);
return (
const navItem = (
<Link
key={item.name}
to={item.href}
@@ -171,7 +178,8 @@ export const Layout: React.FC<LayoutProps> = ({ children, mobileMode = false })
sx={{
display: 'flex',
alignItems: 'center',
px: 2,
justifyContent: sidebarCollapsed ? 'center' : 'flex-start',
px: sidebarCollapsed ? 1 : 2,
py: 1.5,
mb: 0.5,
borderRadius: 2,
@@ -189,52 +197,82 @@ export const Layout: React.FC<LayoutProps> = ({ children, mobileMode = false })
}
}}
>
<Box sx={{ mr: 2, display: 'flex', alignItems: 'center' }}>
<Box sx={{ mr: sidebarCollapsed ? 0 : 2, display: 'flex', alignItems: 'center' }}>
{item.icon}
</Box>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{item.name}
</Typography>
{!sidebarCollapsed && (
<Typography variant="body2" sx={{ fontWeight: 500, whiteSpace: 'nowrap' }}>
{item.name}
</Typography>
)}
</Box>
</Link>
);
return sidebarCollapsed ? (
<Tooltip key={item.name} title={item.name} placement="right" arrow>
{navItem}
</Tooltip>
) : (
navItem
);
})}
</Box>
<Box sx={{ p: 2, borderTop: 1, borderColor: 'divider', mt: 'auto' }}>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<Avatar
sx={{
width: 32,
height: 32,
bgcolor: 'primary.main',
fontSize: '0.875rem',
fontWeight: 600
}}
>
{user?.name?.charAt(0) || user?.email?.charAt(0)}
</Avatar>
<Box sx={{ ml: 1.5, flex: 1, minWidth: 0 }}>
<Typography variant="body2" sx={{ fontWeight: 500 }} noWrap>
{user?.name || user?.email}
</Typography>
</Box>
</Box>
<Button
variant="secondary"
size="sm"
className="w-full"
onClick={() => logout()}
>
Sign Out
</Button>
<Box sx={{ p: sidebarCollapsed ? 1 : 2, borderTop: 1, borderColor: 'divider', mt: 'auto' }}>
{sidebarCollapsed ? (
<Tooltip title={user?.name || user?.email || 'User'} placement="right" arrow>
<Avatar
sx={{
width: 32,
height: 32,
bgcolor: 'primary.main',
fontSize: '0.875rem',
fontWeight: 600,
mx: 'auto',
cursor: 'pointer',
}}
onClick={() => logout()}
>
{user?.name?.charAt(0) || user?.email?.charAt(0)}
</Avatar>
</Tooltip>
) : (
<>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<Avatar
sx={{
width: 32,
height: 32,
bgcolor: 'primary.main',
fontSize: '0.875rem',
fontWeight: 600
}}
>
{user?.name?.charAt(0) || user?.email?.charAt(0)}
</Avatar>
<Box sx={{ ml: 1.5, flex: 1, minWidth: 0 }}>
<Typography variant="body2" sx={{ fontWeight: 500 }} noWrap>
{user?.name || user?.email}
</Typography>
</Box>
</Box>
<Button
variant="secondary"
size="sm"
className="w-full"
onClick={() => logout()}
>
Sign Out
</Button>
</>
)}
</Box>
</Paper>
{/* Main content */}
<Box
sx={{
ml: sidebarOpen ? '256px' : '0',
ml: sidebarOpen ? `${sidebarWidth}px` : '0',
transition: 'margin-left 0.2s ease-in-out',
}}
>
@@ -255,7 +293,7 @@ export const Layout: React.FC<LayoutProps> = ({ children, mobileMode = false })
px: 3
}}>
<IconButton
onClick={toggleSidebar}
onClick={toggleSidebarCollapse}
sx={{ color: 'text.secondary' }}
>
<MenuIcon />
@@ -263,7 +301,7 @@ export const Layout: React.FC<LayoutProps> = ({ children, mobileMode = false })
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<NotificationBell />
<Typography variant="body2" color="text.secondary">
Welcome back, {user?.name || user?.email}
Welcome back, {user?.given_name || user?.name?.split(' ')[0] || user?.nickname || user?.email}
</Typography>
</Box>
</Box>

View File

@@ -4,21 +4,31 @@ import { Vehicle } from '../../features/vehicles/types/vehicles.types';
interface AppState {
// UI state
sidebarOpen: boolean;
sidebarCollapsed: boolean;
selectedVehicle: Vehicle | null;
// Actions
toggleSidebar: () => void;
setSidebarOpen: (open: boolean) => void;
toggleSidebarCollapse: () => void;
setSelectedVehicle: (vehicle: Vehicle | null) => void;
}
const savedCollapsed = localStorage.getItem('sidebarCollapsed') === 'true';
export const useAppStore = create<AppState>((set) => ({
// Initial state
sidebarOpen: false,
sidebarCollapsed: savedCollapsed,
selectedVehicle: null,
// Actions
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
setSidebarOpen: (open: boolean) => set({ sidebarOpen: open }),
toggleSidebarCollapse: () => set((state) => {
const next = !state.sidebarCollapsed;
localStorage.setItem('sidebarCollapsed', String(next));
return { sidebarCollapsed: next };
}),
setSelectedVehicle: (vehicle: Vehicle | null) => set({ selectedVehicle: vehicle }),
}));

View File

@@ -31,8 +31,8 @@ export const DocumentForm: React.FC<DocumentFormProps> = ({
onSuccess,
onCancel
}) => {
const [documentType, setDocumentType] = React.useState<DocumentType>(
initialValues?.documentType || 'insurance'
const [documentType, setDocumentType] = React.useState<DocumentType | ''>(
initialValues?.documentType || ''
);
const [vehicleID, setVehicleID] = React.useState<string>(initialValues?.vehicleId || '');
const [title, setTitle] = React.useState<string>(initialValues?.title || '');
@@ -152,6 +152,10 @@ export const DocumentForm: React.FC<DocumentFormProps> = ({
setError('Please select a vehicle.');
return;
}
if (!documentType) {
setError('Please select a document type.');
return;
}
if (!title.trim()) {
setError('Please enter a title.');
return;
@@ -337,7 +341,9 @@ export const DocumentForm: React.FC<DocumentFormProps> = ({
value={documentType}
onChange={(e) => setDocumentType(e.target.value as DocumentType)}
disabled={mode === 'edit'}
required
>
<option value="" disabled>Select a document type...</option>
<option value="insurance">Insurance</option>
<option value="registration">Registration</option>
<option value="manual">Manual</option>

View File

@@ -11,6 +11,13 @@ import { ExpirationBadge } from '../components/ExpirationBadge';
import { DocumentCardMetadata } from '../components/DocumentCardMetadata';
import { useVehicles } from '../../vehicles/hooks/useVehicles';
import { getVehicleLabel } from '@/core/utils/vehicleDisplay';
import PictureAsPdfRoundedIcon from '@mui/icons-material/PictureAsPdfRounded';
import ImageRoundedIcon from '@mui/icons-material/ImageRounded';
import InsertDriveFileRoundedIcon from '@mui/icons-material/InsertDriveFileRounded';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
dayjs.extend(relativeTime);
export const DocumentsMobileScreen: React.FC = () => {
console.log('[DocumentsMobileScreen] Component initializing');
@@ -30,6 +37,13 @@ export const DocumentsMobileScreen: React.FC = () => {
const vehiclesMap = useMemo(() => new Map(vehicles?.map(v => [v.id, v]) || []), [vehicles]);
const getFileTypeIcon = (contentType: string | null | undefined) => {
if (!contentType) return <InsertDriveFileRoundedIcon sx={{ fontSize: 14, color: 'text.secondary' }} />;
if (contentType === 'application/pdf') return <PictureAsPdfRoundedIcon sx={{ fontSize: 14, color: 'error.main' }} />;
if (contentType.startsWith('image/')) return <ImageRoundedIcon sx={{ fontSize: 14, color: 'info.main' }} />;
return <InsertDriveFileRoundedIcon sx={{ fontSize: 14, color: 'text.secondary' }} />;
};
const triggerUpload = (docId: string) => {
try {
setCurrentId(docId);
@@ -187,9 +201,13 @@ export const DocumentsMobileScreen: React.FC = () => {
<span className="font-medium text-slate-800 dark:text-avus">{doc.title}</span>
<ExpirationBadge expirationDate={doc.expirationDate} />
</div>
<div className="text-xs text-slate-500 dark:text-titanio">
{doc.documentType}
{isShared && ' • Shared'}
<div className="text-xs text-slate-500 dark:text-titanio flex items-center gap-1">
{getFileTypeIcon(doc.contentType)}
<span>
{doc.documentType}
{doc.createdAt && ` \u00B7 ${dayjs(doc.createdAt).fromNow()}`}
{isShared && ' \u00B7 Shared'}
</span>
</div>
<DocumentCardMetadata doc={doc} variant="mobile" />
<button

View File

@@ -14,7 +14,14 @@ import {
import VisibilityIcon from '@mui/icons-material/Visibility';
import EditIcon from '@mui/icons-material/Edit';
import DeleteIcon from '@mui/icons-material/Delete';
import PictureAsPdfRoundedIcon from '@mui/icons-material/PictureAsPdfRounded';
import ImageRoundedIcon from '@mui/icons-material/ImageRounded';
import InsertDriveFileRoundedIcon from '@mui/icons-material/InsertDriveFileRounded';
import { useNavigate } from 'react-router-dom';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
dayjs.extend(relativeTime);
import { AddDocumentDialog } from '../components/AddDocumentDialog';
import { EditDocumentDialog } from '../components/EditDocumentDialog';
import { ExpirationBadge } from '../components/ExpirationBadge';
@@ -36,6 +43,13 @@ export const DocumentsPage: React.FC = () => {
const vehiclesMap = useMemo(() => new Map(vehicles?.map(v => [v.id, v]) || []), [vehicles]);
const getFileTypeIcon = (contentType: string | null | undefined) => {
if (!contentType) return <InsertDriveFileRoundedIcon fontSize="small" sx={{ color: 'text.secondary' }} />;
if (contentType === 'application/pdf') return <PictureAsPdfRoundedIcon fontSize="small" sx={{ color: 'error.main' }} />;
if (contentType.startsWith('image/')) return <ImageRoundedIcon fontSize="small" sx={{ color: 'info.main' }} />;
return <InsertDriveFileRoundedIcon fontSize="small" sx={{ color: 'text.secondary' }} />;
};
// Show loading while auth is initializing
if (authLoading) {
return (
@@ -186,7 +200,12 @@ export const DocumentsPage: React.FC = () => {
<Typography variant="subtitle1" fontWeight={500}>{doc.title}</Typography>
<ExpirationBadge expirationDate={doc.expirationDate} />
</Box>
<Typography variant="body2" color="text.secondary">Type: {doc.documentType}</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
{getFileTypeIcon(doc.contentType)}
<Typography variant="body2" color="text.secondary">
{doc.documentType} {doc.createdAt && `\u00B7 Uploaded ${dayjs(doc.createdAt).fromNow()}`}
</Typography>
</Box>
<DocumentCardMetadata doc={doc} variant="card" />
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Typography variant="body2" color="text.secondary">Vehicle:</Typography>

View File

@@ -0,0 +1,44 @@
/**
* @ai-summary Dialog wrapper for FuelLogForm to create new fuel logs
*/
import React from 'react';
import { Dialog, DialogTitle, DialogContent, IconButton, useMediaQuery } from '@mui/material';
import CloseIcon from '@mui/icons-material/Close';
import { FuelLogForm } from './FuelLogForm';
interface AddFuelLogDialogProps {
open: boolean;
onClose: () => void;
}
export const AddFuelLogDialog: React.FC<AddFuelLogDialogProps> = ({ open, onClose }) => {
const isSmallScreen = useMediaQuery('(max-width:600px)');
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="sm"
fullWidth
fullScreen={isSmallScreen}
PaperProps={{
sx: { maxHeight: isSmallScreen ? '100%' : '90vh' },
}}
>
<DialogTitle sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
Log Fuel
<IconButton
aria-label="close"
onClick={onClose}
sx={{ minWidth: 44, minHeight: 44 }}
>
<CloseIcon />
</IconButton>
</DialogTitle>
<DialogContent sx={{ p: { xs: 1, sm: 2 } }}>
<FuelLogForm onSuccess={onClose} />
</DialogContent>
</Dialog>
);
};

View File

@@ -1,12 +1,12 @@
import React, { useState } from 'react';
import { Grid, Typography, Box } from '@mui/material';
import { Typography, Box, Button as MuiButton } from '@mui/material';
import AddIcon from '@mui/icons-material/Add';
import { useQueryClient } from '@tanstack/react-query';
import { FuelLogForm } from '../components/FuelLogForm';
import { FuelLogsList } from '../components/FuelLogsList';
import { FuelLogEditDialog } from '../components/FuelLogEditDialog';
import { AddFuelLogDialog } from '../components/AddFuelLogDialog';
import { useFuelLogs } from '../hooks/useFuelLogs';
import { FuelStatsCard } from '../components/FuelStatsCard';
import { FormSuspense } from '../../../components/SuspenseWrappers';
import { FuelLogResponse, UpdateFuelLogRequest } from '../types/fuel-logs.types';
import { fuelLogsApi } from '../api/fuel-logs.api';
@@ -14,9 +14,7 @@ export const FuelLogsPage: React.FC = () => {
const { fuelLogs, isLoading, error } = useFuelLogs();
const queryClient = useQueryClient();
const [editingLog, setEditingLog] = useState<FuelLogResponse | null>(null);
// DEBUG: Log page renders
console.log('[FuelLogsPage] Render - fuel logs count:', fuelLogs?.length, 'isLoading:', isLoading, 'error:', !!error);
const [showAddDialog, setShowAddDialog] = useState(false);
const handleEdit = (log: FuelLogResponse) => {
setEditingLog(log);
@@ -24,9 +22,6 @@ export const FuelLogsPage: React.FC = () => {
const handleDelete = async (_logId: string) => {
try {
console.log('[FuelLogsPage] handleDelete called - using targeted query updates');
// Use targeted invalidation instead of broad invalidation
// This prevents unnecessary re-renders of the form
queryClient.refetchQueries({ queryKey: ['fuelLogs', 'all'] });
} catch (error) {
console.error('Failed to refresh fuel logs after delete:', error);
@@ -35,15 +30,12 @@ export const FuelLogsPage: React.FC = () => {
const handleSaveEdit = async (id: string, data: UpdateFuelLogRequest) => {
try {
console.log('[FuelLogsPage] handleSaveEdit called - using targeted query updates');
await fuelLogsApi.update(id, data);
// Use targeted refetch instead of broad invalidation
// This prevents unnecessary re-renders of the form
queryClient.refetchQueries({ queryKey: ['fuelLogs', 'all'] });
setEditingLog(null);
} catch (error) {
console.error('Failed to update fuel log:', error);
throw error; // Re-throw to let the dialog handle the error
throw error;
}
};
@@ -78,22 +70,36 @@ export const FuelLogsPage: React.FC = () => {
}
return (
<FormSuspense>
<Grid container spacing={2}>
<Grid item xs={12} md={6}>
<FuelLogForm />
</Grid>
<Grid item xs={12} md={6}>
<Typography variant="h6" gutterBottom>Summary</Typography>
<FuelStatsCard logs={fuelLogs} />
<Typography variant="h6" sx={{ mt: 3 }} gutterBottom>Recent Fuel Logs</Typography>
<FuelLogsList
logs={fuelLogs}
onEdit={handleEdit}
onDelete={handleDelete}
/>
</Grid>
</Grid>
<Box>
{/* Header with Add button */}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
<Typography variant="h5" sx={{ fontWeight: 600 }}>Fuel Logs</Typography>
<MuiButton
variant="contained"
startIcon={<AddIcon />}
onClick={() => setShowAddDialog(true)}
>
Add Fuel Log
</MuiButton>
</Box>
{/* Summary Stats */}
<Box sx={{ mb: 3 }}>
<FuelStatsCard logs={fuelLogs} />
</Box>
{/* Fuel Logs List */}
<FuelLogsList
logs={fuelLogs}
onEdit={handleEdit}
onDelete={handleDelete}
/>
{/* Add Dialog */}
<AddFuelLogDialog
open={showAddDialog}
onClose={() => setShowAddDialog(false)}
/>
{/* Edit Dialog */}
<FuelLogEditDialog
@@ -102,6 +108,6 @@ export const FuelLogsPage: React.FC = () => {
onClose={handleCloseEdit}
onSave={handleSaveEdit}
/>
</FormSuspense>
</Box>
);
};

View File

@@ -0,0 +1,272 @@
/**
* @ai-summary Full-screen dialog with upload and camera options for receipt input
* @ai-context Replaces direct camera launch with upload-first pattern; both paths feed OCR pipeline
*/
import React, { useRef, useState, useCallback } from 'react';
import {
Dialog,
Box,
Typography,
Button,
IconButton,
Alert,
} from '@mui/material';
import CloseIcon from '@mui/icons-material/Close';
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
import CameraAltIcon from '@mui/icons-material/CameraAlt';
import {
DEFAULT_ACCEPTED_FORMATS,
DEFAULT_MAX_FILE_SIZE,
} from '../../../shared/components/CameraCapture/types';
interface AddReceiptDialogProps {
open: boolean;
onClose: () => void;
onFileSelect: (file: File) => void;
onStartCamera: () => void;
}
export const AddReceiptDialog: React.FC<AddReceiptDialogProps> = ({
open,
onClose,
onFileSelect,
onStartCamera,
}) => {
const inputRef = useRef<HTMLInputElement>(null);
const [isDragging, setIsDragging] = useState(false);
const [error, setError] = useState<string | null>(null);
const validateFile = useCallback((file: File): string | null => {
const isValidType = DEFAULT_ACCEPTED_FORMATS.some((format) => {
if (format === 'image/heic' || format === 'image/heif') {
return (
file.type === 'image/heic' ||
file.type === 'image/heif' ||
file.name.toLowerCase().endsWith('.heic') ||
file.name.toLowerCase().endsWith('.heif')
);
}
return file.type === format;
});
if (!isValidType) {
return 'Invalid file type. Accepted formats: JPEG, PNG, HEIC';
}
if (file.size > DEFAULT_MAX_FILE_SIZE) {
return `File too large. Maximum size: ${(DEFAULT_MAX_FILE_SIZE / (1024 * 1024)).toFixed(0)}MB`;
}
return null;
}, []);
const handleFile = useCallback(
(file: File) => {
setError(null);
const validationError = validateFile(file);
if (validationError) {
setError(validationError);
return;
}
onFileSelect(file);
},
[validateFile, onFileSelect]
);
const handleInputChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
handleFile(file);
}
if (inputRef.current) {
inputRef.current.value = '';
}
},
[handleFile]
);
const handleDragOver = useCallback((event: React.DragEvent) => {
event.preventDefault();
event.stopPropagation();
setIsDragging(true);
}, []);
const handleDragLeave = useCallback((event: React.DragEvent) => {
event.preventDefault();
event.stopPropagation();
setIsDragging(false);
}, []);
const handleDrop = useCallback(
(event: React.DragEvent) => {
event.preventDefault();
event.stopPropagation();
setIsDragging(false);
const file = event.dataTransfer.files[0];
if (file) {
handleFile(file);
}
},
[handleFile]
);
const handleClickUpload = useCallback(() => {
inputRef.current?.click();
}, []);
// Reset error state when dialog closes
const handleClose = useCallback(() => {
setError(null);
setIsDragging(false);
onClose();
}, [onClose]);
return (
<Dialog
open={open}
onClose={handleClose}
fullScreen
PaperProps={{
sx: { backgroundColor: 'background.default' },
}}
>
{/* Header */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
px: 2,
py: 1.5,
borderBottom: 1,
borderColor: 'divider',
backgroundColor: 'background.paper',
}}
>
<Typography variant="h6">Add Receipt</Typography>
<IconButton
onClick={handleClose}
aria-label="Close"
sx={{ minWidth: 44, minHeight: 44 }}
>
<CloseIcon />
</IconButton>
</Box>
{/* Content */}
<Box
sx={{
flex: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
p: 3,
gap: 3,
}}
>
{/* Drag-and-drop upload zone */}
<Box
onClick={handleClickUpload}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
sx={{
width: '100%',
maxWidth: 480,
py: 5,
px: 3,
border: 2,
borderStyle: 'dashed',
borderColor: isDragging ? 'primary.main' : 'divider',
borderRadius: 2,
backgroundColor: isDragging ? 'action.hover' : 'background.paper',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 1.5,
cursor: 'pointer',
transition: 'all 0.2s ease',
'&:hover': {
borderColor: 'primary.main',
backgroundColor: 'action.hover',
},
}}
>
<CloudUploadIcon
sx={{
fontSize: 56,
color: isDragging ? 'primary.main' : 'text.secondary',
}}
/>
<Typography
variant="body1"
color={isDragging ? 'primary.main' : 'text.primary'}
textAlign="center"
fontWeight={500}
>
{isDragging ? 'Drop image here' : 'Drag and drop an image, or tap to browse'}
</Typography>
<Typography variant="caption" color="text.secondary" textAlign="center">
JPEG, PNG, HEIC -- up to 10MB
</Typography>
</Box>
{error && (
<Alert severity="error" sx={{ maxWidth: 480, width: '100%' }}>
{error}
</Alert>
)}
{/* Divider with "or" */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
width: '100%',
maxWidth: 480,
gap: 2,
}}
>
<Box sx={{ flex: 1, height: '1px', backgroundColor: 'divider' }} />
<Typography variant="body2" color="text.secondary">
or
</Typography>
<Box sx={{ flex: 1, height: '1px', backgroundColor: 'divider' }} />
</Box>
{/* Take Photo button */}
<Button
variant="outlined"
startIcon={<CameraAltIcon />}
onClick={onStartCamera}
sx={{
minHeight: 56,
minWidth: 200,
maxWidth: 480,
width: '100%',
borderRadius: 2,
borderWidth: 2,
'&:hover': {
borderWidth: 2,
},
}}
>
Take Photo of Receipt
</Button>
</Box>
{/* Hidden file input */}
<input
ref={inputRef}
type="file"
accept={DEFAULT_ACCEPTED_FORMATS.join(',')}
onChange={handleInputChange}
style={{ display: 'none' }}
aria-label="Select receipt image"
/>
</Dialog>
);
};

View File

@@ -40,7 +40,7 @@ import {
} from '../types/maintenance.types';
import { useMaintenanceReceiptOcr } from '../hooks/useMaintenanceReceiptOcr';
import { MaintenanceReceiptReviewModal } from './MaintenanceReceiptReviewModal';
import { ReceiptCameraButton } from '../../fuel-logs/components/ReceiptCameraButton';
import { AddReceiptDialog } from './AddReceiptDialog';
import { CameraCapture } from '../../../shared/components/CameraCapture';
import { useTierAccess } from '../../../core/hooks/useTierAccess';
import { UpgradeRequiredDialog } from '../../../shared-minimal/components/UpgradeRequiredDialog';
@@ -63,7 +63,11 @@ const schema = z.object({
type FormData = z.infer<typeof schema>;
export const MaintenanceRecordForm: React.FC = () => {
interface MaintenanceRecordFormProps {
vehicleId?: string;
}
export const MaintenanceRecordForm: React.FC<MaintenanceRecordFormProps> = ({ vehicleId }) => {
const { data: vehicles, isLoading: isLoadingVehicles } = useVehicles();
const { createRecord, isRecordMutating } = useMaintenanceRecords();
const [selectedCategory, setSelectedCategory] = useState<MaintenanceCategory | null>(null);
@@ -88,6 +92,9 @@ export const MaintenanceRecordForm: React.FC = () => {
updateField,
} = useMaintenanceReceiptOcr();
// AddReceiptDialog visibility state
const [showAddReceiptDialog, setShowAddReceiptDialog] = useState(false);
// Store captured file for document upload on submit
const [capturedReceiptFile, setCapturedReceiptFile] = useState<File | null>(null);
@@ -102,7 +109,7 @@ export const MaintenanceRecordForm: React.FC = () => {
resolver: zodResolver(schema),
mode: 'onChange',
defaultValues: {
vehicle_id: '',
vehicle_id: vehicleId || '',
category: undefined as any,
subtypes: [],
date: new Date().toISOString().split('T')[0],
@@ -113,6 +120,11 @@ export const MaintenanceRecordForm: React.FC = () => {
},
});
// Sync vehicle_id when parent prop changes
useEffect(() => {
if (vehicleId) setValue('vehicle_id', vehicleId);
}, [vehicleId, setValue]);
// Watch category changes to reset subtypes
const watchedCategory = watch('category');
useEffect(() => {
@@ -236,7 +248,7 @@ export const MaintenanceRecordForm: React.FC = () => {
<Card>
<CardHeader title="Add Maintenance Record" />
<CardContent>
{/* Receipt Scan Button */}
{/* Add Receipt Button */}
<Box
sx={{
display: 'flex',
@@ -247,53 +259,62 @@ export const MaintenanceRecordForm: React.FC = () => {
borderColor: 'divider',
}}
>
<ReceiptCameraButton
<Button
variant="outlined"
onClick={() => {
if (!hasReceiptScanAccess) {
setShowUpgradeDialog(true);
return;
}
startCapture();
setShowAddReceiptDialog(true);
}}
disabled={isProcessing || isRecordMutating}
variant="button"
locked={!hasReceiptScanAccess}
/>
sx={{
minHeight: 44,
borderStyle: 'dashed',
borderWidth: 2,
'&:hover': { borderWidth: 2 },
}}
>
Add Receipt
</Button>
</Box>
<form onSubmit={handleSubmit(onSubmit)}>
<Grid container spacing={2}>
{/* Vehicle Selection */}
<Grid item xs={12}>
<Controller
name="vehicle_id"
control={control}
render={({ field }) => (
<FormControl fullWidth error={!!errors.vehicle_id}>
<InputLabel id="vehicle-select-label">Vehicle *</InputLabel>
<Select
{...field}
labelId="vehicle-select-label"
label="Vehicle *"
sx={{ minHeight: 56 }}
>
{vehicles && vehicles.length > 0 ? (
vehicles.map((vehicle) => (
<MenuItem key={vehicle.id} value={vehicle.id}>
{getVehicleSubtitle(vehicle) || 'Unknown Vehicle'}
</MenuItem>
))
) : (
<MenuItem disabled>No vehicles available</MenuItem>
{/* Vehicle Selection (hidden when vehicleId prop is provided) */}
{!vehicleId && (
<Grid item xs={12}>
<Controller
name="vehicle_id"
control={control}
render={({ field }) => (
<FormControl fullWidth error={!!errors.vehicle_id}>
<InputLabel id="vehicle-select-label">Vehicle *</InputLabel>
<Select
{...field}
labelId="vehicle-select-label"
label="Vehicle *"
sx={{ minHeight: 56 }}
>
{vehicles && vehicles.length > 0 ? (
vehicles.map((vehicle) => (
<MenuItem key={vehicle.id} value={vehicle.id}>
{getVehicleSubtitle(vehicle) || 'Unknown Vehicle'}
</MenuItem>
))
) : (
<MenuItem disabled>No vehicles available</MenuItem>
)}
</Select>
{errors.vehicle_id && (
<FormHelperText>{errors.vehicle_id.message}</FormHelperText>
)}
</Select>
{errors.vehicle_id && (
<FormHelperText>{errors.vehicle_id.message}</FormHelperText>
)}
</FormControl>
)}
/>
</Grid>
</FormControl>
)}
/>
</Grid>
)}
{/* Category Selection */}
<Grid item xs={12}>
@@ -496,6 +517,20 @@ export const MaintenanceRecordForm: React.FC = () => {
</CardContent>
</Card>
{/* Add Receipt Dialog */}
<AddReceiptDialog
open={showAddReceiptDialog}
onClose={() => setShowAddReceiptDialog(false)}
onFileSelect={(file) => {
setShowAddReceiptDialog(false);
handleCaptureImage(file);
}}
onStartCamera={() => {
setShowAddReceiptDialog(false);
startCapture();
}}
/>
{/* Camera Capture Modal */}
<Dialog
open={isCapturing}

View File

@@ -98,7 +98,11 @@ const REMINDER_OPTIONS = [
{ value: '60', label: '60 days' },
];
export const MaintenanceScheduleForm: React.FC = () => {
interface MaintenanceScheduleFormProps {
vehicleId?: string;
}
export const MaintenanceScheduleForm: React.FC<MaintenanceScheduleFormProps> = ({ vehicleId }) => {
const { data: vehicles, isLoading: isLoadingVehicles } = useVehicles();
const { createSchedule, isScheduleMutating } = useMaintenanceRecords();
const [selectedCategory, setSelectedCategory] = useState<MaintenanceCategory | null>(null);
@@ -114,7 +118,7 @@ export const MaintenanceScheduleForm: React.FC = () => {
resolver: zodResolver(schema),
mode: 'onChange',
defaultValues: {
vehicle_id: '',
vehicle_id: vehicleId || '',
category: undefined as any,
subtypes: [],
schedule_type: 'interval' as ScheduleType,
@@ -128,6 +132,11 @@ export const MaintenanceScheduleForm: React.FC = () => {
},
});
// Sync vehicle_id when parent prop changes
useEffect(() => {
if (vehicleId) setValue('vehicle_id', vehicleId);
}, [vehicleId, setValue]);
// Watch category and schedule type changes
const watchedCategory = watch('category');
const watchedScheduleType = watch('schedule_type');
@@ -198,30 +207,31 @@ export const MaintenanceScheduleForm: React.FC = () => {
<CardContent>
<form onSubmit={handleSubmit(onSubmit)}>
<Grid container spacing={2}>
{/* Vehicle Selection */}
<Grid item xs={12}>
<Controller
name="vehicle_id"
control={control}
render={({ field }) => (
<FormControl fullWidth error={!!errors.vehicle_id}>
<InputLabel id="vehicle-select-label">Vehicle *</InputLabel>
<Select
{...field}
labelId="vehicle-select-label"
label="Vehicle *"
sx={{ minHeight: 56 }}
>
{vehicles && vehicles.length > 0 ? (
vehicles.map((vehicle) => (
<MenuItem key={vehicle.id} value={vehicle.id}>
{getVehicleSubtitle(vehicle) || 'Unknown Vehicle'}
</MenuItem>
))
) : (
<MenuItem disabled>No vehicles available</MenuItem>
)}
</Select>
{/* Vehicle Selection (hidden when vehicleId prop is provided) */}
{!vehicleId && (
<Grid item xs={12}>
<Controller
name="vehicle_id"
control={control}
render={({ field }) => (
<FormControl fullWidth error={!!errors.vehicle_id}>
<InputLabel id="vehicle-select-label">Vehicle *</InputLabel>
<Select
{...field}
labelId="vehicle-select-label"
label="Vehicle *"
sx={{ minHeight: 56 }}
>
{vehicles && vehicles.length > 0 ? (
vehicles.map((vehicle) => (
<MenuItem key={vehicle.id} value={vehicle.id}>
{getVehicleSubtitle(vehicle) || 'Unknown Vehicle'}
</MenuItem>
))
) : (
<MenuItem disabled>No vehicles available</MenuItem>
)}
</Select>
{errors.vehicle_id && (
<FormHelperText>{errors.vehicle_id.message}</FormHelperText>
)}
@@ -229,6 +239,7 @@ export const MaintenanceScheduleForm: React.FC = () => {
)}
/>
</Grid>
)}
{/* Category Selection */}
<Grid item xs={12}>

View File

@@ -191,9 +191,11 @@ export const MaintenanceSchedulesList: React.FC<MaintenanceSchedulesListProps> =
}}
>
<Box sx={{ flex: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1, flexWrap: 'wrap' }}>
<Typography variant="h6">
{categoryDisplay}
{schedule.subtypes && schedule.subtypes.length > 0
? `${schedule.subtypes.join(', ')} \u2014 ${categoryDisplay}`
: categoryDisplay}
</Typography>
<Chip
label={status.label}
@@ -295,7 +297,9 @@ export const MaintenanceSchedulesList: React.FC<MaintenanceSchedulesListProps> =
</Typography>
{scheduleToDelete && (
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
{getCategoryDisplayName(scheduleToDelete.category)} - {getScheduleTypeDisplay(scheduleToDelete)}
{scheduleToDelete.subtypes && scheduleToDelete.subtypes.length > 0
? `${scheduleToDelete.subtypes.join(', ')} \u2014 ${getCategoryDisplayName(scheduleToDelete.category)}`
: getCategoryDisplayName(scheduleToDelete.category)} - {getScheduleTypeDisplay(scheduleToDelete)}
</Typography>
)}
</DialogContent>

View File

@@ -199,9 +199,9 @@ export const MaintenanceMobileScreen: React.FC = () => {
{activeTab === 'records' ? 'New Maintenance Record' : 'New Maintenance Schedule'}
</h3>
{activeTab === 'records' ? (
<MaintenanceRecordForm />
<MaintenanceRecordForm vehicleId={selectedVehicleId} />
) : (
<MaintenanceScheduleForm />
<MaintenanceScheduleForm vehicleId={selectedVehicleId} />
)}
</div>
</GlassCard>

View File

@@ -142,6 +142,9 @@ export const MaintenancePage: React.FC = () => {
return (
<FormSuspense>
{/* Page Title */}
<Typography variant="h5" sx={{ fontWeight: 600, mb: 3 }}>Maintenance</Typography>
{/* Vehicle Selector */}
<Box sx={{ mb: 3 }}>
<FormControl fullWidth>
@@ -182,7 +185,7 @@ export const MaintenancePage: React.FC = () => {
<Grid container spacing={3}>
{/* Top: Form */}
<Grid item xs={12}>
<MaintenanceRecordForm />
<MaintenanceRecordForm vehicleId={selectedVehicleId} />
</Grid>
{/* Bottom: Records List */}
@@ -203,7 +206,7 @@ export const MaintenancePage: React.FC = () => {
<Grid container spacing={3}>
{/* Top: Form */}
<Grid item xs={12}>
<MaintenanceScheduleForm />
<MaintenanceScheduleForm vehicleId={selectedVehicleId} />
</Grid>
{/* Bottom: Schedules List */}

View File

@@ -130,8 +130,12 @@ export const NotificationBell: React.FC = () => {
<CircularProgress size={24} />
</Box>
) : notifications.length === 0 ? (
<Box sx={{ p: 3, textAlign: 'center' }}>
<Typography color="text.secondary">No notifications</Typography>
<Box sx={{ p: 4, textAlign: 'center' }}>
<NotificationsIcon sx={{ fontSize: 40, color: 'text.disabled', mb: 1 }} />
<Typography color="text.secondary" fontWeight={500}>No notifications</Typography>
<Typography variant="caption" color="text.disabled">
You're all caught up
</Typography>
</Box>
) : (
<List sx={{ py: 0, maxHeight: 360, overflow: 'auto' }}>