Compare commits

...

9 Commits

Author SHA1 Message Date
e729d425fd Merge pull request 'feat: Reorder Log Fuel fields by usage and add decimal keypad (#246)' (#247) from issue-246-reorder-log-fuel-fields into main
All checks were successful
Deploy to Staging / Build Images (push) Successful in 1m2s
Deploy to Staging / Deploy to Staging (push) Successful in 42s
Deploy to Staging / Verify Staging (push) Successful in 4s
Deploy to Staging / Notify Staging Ready (push) Successful in 3s
Deploy to Staging / Notify Staging Failure (push) Has been skipped
Mirror Base Images / Mirror Base Images (push) Successful in 28s
Reviewed-on: #247
2026-06-20 03:44:26 +00:00
Eric Gullickson
2221819b4a feat: reorder Log Fuel fields by usage and add decimal keypad (refs #246)
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 4m10s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 44s
Deploy to Staging / Verify Staging (pull_request) Successful in 4s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 4s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
Reorder the Add Fuel Log form so the most-used inputs come first and
calculated/auto-filled fields are de-emphasized:
Vehicle, Mileage, Cost Per Gallon, Fuel Amount, Fuel Type/Grade,
Location, MPG (read-only), Notes, Date & Time.

Mileage is the existing Trip Distance / Odometer Reading field,
repositioned only; toggle and logic unchanged. Fuel Type stays before
Fuel Grade to preserve the type-filters-grade dependency.

Add inputMode=decimal to the numeric fields (Mileage via DistanceInput,
Fuel Amount, Cost Per Gallon) so mobile shows the decimal keypad. MPG
and Date & Time become full-width since they are no longer paired.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 22:18:46 -05:00
77aeba0102 Merge pull request 'fix: coerce decimals in fuel-logs enhanced repository methods (#244)' (#245) from issue-244-fuel-logs-enhanced-mapper into main
All checks were successful
Deploy to Staging / Build Images (push) Successful in 10s
Deploy to Staging / Deploy to Staging (push) Successful in 14s
Deploy to Staging / Verify Staging (push) Successful in 3s
Deploy to Staging / Notify Staging Ready (push) Successful in 3s
Deploy to Staging / Notify Staging Failure (push) Has been skipped
Mirror Base Images / Mirror Base Images (push) Successful in 26s
Reviewed-on: #245
2026-05-16 02:58:52 +00:00
Eric Gullickson
0d90829d31 fix: coerce decimals in fuel-logs enhanced repository methods (refs #244)
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 35s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 43s
Deploy to Staging / Verify Staging (pull_request) Successful in 3s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 3s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
The enhanced API path on FuelLogsRepository returned raw pg rows
straight to the service layer, so DECIMAL columns (fuel_units,
cost_per_unit, total_cost, gallons, price_per_gallon) arrived as
strings instead of numbers. The service layer compensated with
scattered Number() coercion in toEnhancedResponse and getVehicleStats,
and EfficiencyCalculationService silently leaned on JS string-to-
number coercion in division. Type signatures across the stack
declared number and the runtime delivered string.

Add a private mapEnhancedRow that coerces all DECIMAL columns with
parseFloat while preserving snake_case keys (the convention the
service layer already uses to access these rows). Apply it in every
enhanced read/write path: createEnhanced, findByVehicleIdEnhanced,
findByUserIdEnhanced, findByIdEnhanced, getPreviousLogByOdometer,
getLatestLogForVehicle, updateEnhanced. Drop the now-redundant
Number() wrappers in toEnhancedResponse and getVehicleStats.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 21:46:48 -05:00
0dd5746f60 Merge pull request 'fix: coerce numeric/decimal columns in repository mappers (#241)' (#242) from issue-241-numeric-mapper-coercion into main
All checks were successful
Deploy to Staging / Build Images (push) Successful in 10s
Deploy to Staging / Deploy to Staging (push) Successful in 41s
Deploy to Staging / Verify Staging (push) Successful in 3s
Deploy to Staging / Notify Staging Ready (push) Successful in 4s
Deploy to Staging / Notify Staging Failure (push) Has been skipped
Reviewed-on: #242
2026-05-16 02:35:02 +00:00
Eric Gullickson
fdc34aee2f fix: coerce numeric/decimal columns in repository mappers (refs #241)
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 1m49s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 43s
Deploy to Staging / Verify Staging (pull_request) Successful in 4s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 3s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
node-postgres returns numeric/decimal columns as JavaScript strings,
but the TypeScript interfaces for MaintenanceRecord and OwnershipCost
declare numeric fields as number. The mappers were passing values
through raw, breaking type-safe arithmetic and display (e.g. the
amount column on the vehicle summary screen was empty until the
recent frontend workaround in PR #240, and OwnershipCostsList silently
no-ops toLocaleString on the string).

Backend
- mapMaintenanceRecord: coerce cost via Number() when non-null.
- ownership-costs mapRow: coerce amount via Number().

Frontend (remove now-redundant workarounds)
- MaintenanceRecordsList: drop Number() coercion on cost and
  odometerReading; use the number values directly.
- VehicleDetailPage / VehicleDetailMobile: revert the PR #240 cost
  coercion to the simple typeof number guard now that the backend
  honors the type.

Scope notes
- Other repositories with the same pattern (stations, community-stations,
  fuel-logs enhanced methods) are tracked separately because they have
  unclear downstream consumers and warrant their own investigation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 21:06:24 -05:00
9f5c81a14e Merge pull request 'fix: Vehicle summary screen does not display maintenance records (#239)' (#240) from issue-239-vehicle-summary-maintenance into main
All checks were successful
Deploy to Staging / Build Images (push) Successful in 10s
Deploy to Staging / Deploy to Staging (push) Successful in 42s
Deploy to Staging / Verify Staging (push) Successful in 3s
Deploy to Staging / Notify Staging Ready (push) Successful in 3s
Deploy to Staging / Notify Staging Failure (push) Has been skipped
Reviewed-on: #240
2026-05-16 01:56:10 +00:00
Eric Gullickson
55b8b67a6e fix: coerce maintenance cost to number for amount column (refs #239)
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 1m11s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 42s
Deploy to Staging / Verify Staging (pull_request) Successful in 4s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 3s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
Postgres numeric columns come back as strings via node-postgres, so
typeof rec.cost === 'number' was false and the amount column rendered
as '—'. Coerce with Number() (matching the pattern in
MaintenanceRecordsList) so the cost displays as a dollar amount.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 20:51:58 -05:00
Eric Gullickson
a49f419eab fix: show maintenance records on vehicle summary screen (refs #239)
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 1m14s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 42s
Deploy to Staging / Verify Staging (pull_request) Successful in 3s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 4s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
The Vehicle Records section on /garage/vehicles/:id never called
useMaintenanceRecords, so maintenance rows always rendered empty even
when records existed for the vehicle. Wire the existing hook into both
the desktop VehicleDetailPage and mobile VehicleDetailMobile, merge
records into the unified list with category + subtypes + shop name,
and include the maintenance loading state in the section's loading
and empty-state guards.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 20:45:42 -05:00
9 changed files with 178 additions and 102 deletions

View File

@@ -247,6 +247,20 @@ export class FuelLogsRepository {
}; };
} }
// Coerces DECIMAL columns from node-postgres strings to numbers while
// preserving snake_case row keys used by the enhanced API service callers.
private mapEnhancedRow(row: any): any {
if (!row) return null;
return {
...row,
fuel_units: row.fuel_units != null ? parseFloat(row.fuel_units) : row.fuel_units,
cost_per_unit: row.cost_per_unit != null ? parseFloat(row.cost_per_unit) : row.cost_per_unit,
total_cost: row.total_cost != null ? parseFloat(row.total_cost) : row.total_cost,
gallons: row.gallons != null ? parseFloat(row.gallons) : row.gallons,
price_per_gallon: row.price_per_gallon != null ? parseFloat(row.price_per_gallon) : row.price_per_gallon,
};
}
// Enhanced API support (new schema) // Enhanced API support (new schema)
async createEnhanced(data: { async createEnhanced(data: {
userId: string; userId: string;
@@ -293,7 +307,7 @@ export class FuelLogsRepository {
data.notes ?? null data.notes ?? null
]; ];
const res = await this.pool.query(query, values); const res = await this.pool.query(query, values);
return res.rows[0] ?? null; return this.mapEnhancedRow(res.rows[0] ?? null);
} }
async findByVehicleIdEnhanced(vehicleId: string): Promise<any[]> { async findByVehicleIdEnhanced(vehicleId: string): Promise<any[]> {
@@ -301,7 +315,7 @@ export class FuelLogsRepository {
`SELECT * FROM fuel_logs WHERE vehicle_id = $1 ORDER BY date_time DESC NULLS LAST, date DESC NULLS LAST, created_at DESC`, `SELECT * FROM fuel_logs WHERE vehicle_id = $1 ORDER BY date_time DESC NULLS LAST, date DESC NULLS LAST, created_at DESC`,
[vehicleId] [vehicleId]
); );
return res.rows; return res.rows.map(r => this.mapEnhancedRow(r));
} }
async findByUserIdEnhanced(userId: string): Promise<any[]> { async findByUserIdEnhanced(userId: string): Promise<any[]> {
@@ -309,12 +323,12 @@ export class FuelLogsRepository {
`SELECT * FROM fuel_logs WHERE user_id = $1 ORDER BY date_time DESC NULLS LAST, date DESC NULLS LAST, created_at DESC`, `SELECT * FROM fuel_logs WHERE user_id = $1 ORDER BY date_time DESC NULLS LAST, date DESC NULLS LAST, created_at DESC`,
[userId] [userId]
); );
return res.rows; return res.rows.map(r => this.mapEnhancedRow(r));
} }
async findByIdEnhanced(id: string): Promise<any | null> { async findByIdEnhanced(id: string): Promise<any | null> {
const res = await this.pool.query(`SELECT * FROM fuel_logs WHERE id = $1`, [id]); const res = await this.pool.query(`SELECT * FROM fuel_logs WHERE id = $1`, [id]);
return res.rows[0] ?? null; return this.mapEnhancedRow(res.rows[0] ?? null);
} }
async getPreviousLogByOdometer(vehicleId: string, odometerReading: number): Promise<any | null> { async getPreviousLogByOdometer(vehicleId: string, odometerReading: number): Promise<any | null> {
@@ -322,7 +336,7 @@ export class FuelLogsRepository {
`SELECT * FROM fuel_logs WHERE vehicle_id = $1 AND odometer IS NOT NULL AND odometer < $2 ORDER BY odometer DESC LIMIT 1`, `SELECT * FROM fuel_logs WHERE vehicle_id = $1 AND odometer IS NOT NULL AND odometer < $2 ORDER BY odometer DESC LIMIT 1`,
[vehicleId, odometerReading] [vehicleId, odometerReading]
); );
return res.rows[0] ?? null; return this.mapEnhancedRow(res.rows[0] ?? null);
} }
async getLatestLogForVehicle(vehicleId: string): Promise<any | null> { async getLatestLogForVehicle(vehicleId: string): Promise<any | null> {
@@ -330,7 +344,7 @@ export class FuelLogsRepository {
`SELECT * FROM fuel_logs WHERE vehicle_id = $1 ORDER BY date_time DESC NULLS LAST, date DESC NULLS LAST, created_at DESC LIMIT 1`, `SELECT * FROM fuel_logs WHERE vehicle_id = $1 ORDER BY date_time DESC NULLS LAST, date DESC NULLS LAST, created_at DESC LIMIT 1`,
[vehicleId] [vehicleId]
); );
return res.rows[0] ?? null; return this.mapEnhancedRow(res.rows[0] ?? null);
} }
async updateEnhanced(id: string, data: { async updateEnhanced(id: string, data: {
@@ -416,6 +430,6 @@ export class FuelLogsRepository {
return null; return null;
} }
return result.rows[0]; return this.mapEnhancedRow(result.rows[0]);
} }
} }

View File

@@ -234,8 +234,8 @@ export class FuelLogsService {
return { logCount: 0, totalFuelUnits: 0, totalCost: 0, averageCostPerUnit: 0, totalDistance: 0, averageEfficiency: 0, unitLabels: labels }; return { logCount: 0, totalFuelUnits: 0, totalCost: 0, averageCostPerUnit: 0, totalDistance: 0, averageEfficiency: 0, unitLabels: labels };
} }
const totalFuelUnits = rows.reduce((s, r) => s + (Number(r.fuel_units) || 0), 0); const totalFuelUnits = rows.reduce((s, r) => s + (r.fuel_units || 0), 0);
const totalCost = rows.reduce((s, r) => s + (Number(r.total_cost) || 0), 0); const totalCost = rows.reduce((s, r) => s + (r.total_cost || 0), 0);
const averageCostPerUnit = totalFuelUnits > 0 ? totalCost / totalFuelUnits : 0; const averageCostPerUnit = totalFuelUnits > 0 ? totalCost / totalFuelUnits : 0;
const sorted = [...rows].sort((a, b) => (new Date(b.date_time || b.date)).getTime() - (new Date(a.date_time || a.date)).getTime()); const sorted = [...rows].sort((a, b) => (new Date(b.date_time || b.date)).getTime() - (new Date(a.date_time || a.date)).getTime());
@@ -282,9 +282,9 @@ export class FuelLogsService {
tripDistance: row.trip_distance ?? undefined, tripDistance: row.trip_distance ?? undefined,
fuelType: row.fuel_type as FuelType, fuelType: row.fuel_type as FuelType,
fuelGrade: row.fuel_grade ?? undefined, fuelGrade: row.fuel_grade ?? undefined,
fuelUnits: Number(row.fuel_units), fuelUnits: row.fuel_units ?? 0,
costPerUnit: Number(row.cost_per_unit), costPerUnit: row.cost_per_unit ?? 0,
totalCost: Number(row.total_cost), totalCost: row.total_cost ?? 0,
locationData: row.location_data ?? undefined, locationData: row.location_data ?? undefined,
notes: row.notes ?? undefined, notes: row.notes ?? undefined,
efficiency: efficiency, efficiency: efficiency,

View File

@@ -18,7 +18,8 @@ export class MaintenanceRepository {
subtypes: row.subtypes, subtypes: row.subtypes,
date: row.date, date: row.date,
odometerReading: row.odometer_reading, odometerReading: row.odometer_reading,
cost: row.cost, // node-postgres returns numeric/decimal columns as strings; coerce to honor the number type.
cost: row.cost != null ? Number(row.cost) : undefined,
shopName: row.shop_name, shopName: row.shop_name,
notes: row.notes, notes: row.notes,
receiptDocumentId: row.receipt_document_id, receiptDocumentId: row.receipt_document_id,

View File

@@ -16,7 +16,8 @@ export class OwnershipCostsRepository {
vehicleId: row.vehicle_id, vehicleId: row.vehicle_id,
documentId: row.document_id, documentId: row.document_id,
costType: row.cost_type, costType: row.cost_type,
amount: row.amount, // node-postgres returns numeric/decimal columns as strings; coerce to honor the number type.
amount: Number(row.amount),
description: row.description, description: row.description,
periodStart: row.period_start, periodStart: row.period_start,
periodEnd: row.period_end, periodEnd: row.period_end,

View File

@@ -24,7 +24,7 @@ export const DistanceInput: React.FC<Props> = ({ type, value, onChange, unitSyst
fullWidth fullWidth
error={!!error} error={!!error}
disabled={disabled} disabled={disabled}
inputProps={{ step: type === 'trip' ? 0.1 : 1, min: 0 }} inputProps={{ step: type === 'trip' ? 0.1 : 1, min: 0, inputMode: 'decimal' }}
InputProps={{ endAdornment: <InputAdornment position="end">{units}</InputAdornment> }} InputProps={{ endAdornment: <InputAdornment position="end">{units}</InputAdornment> }}
/> />
{error && <FormHelperText error>{error}</FormHelperText>} {error && <FormHelperText error>{error}</FormHelperText>}

View File

@@ -247,60 +247,7 @@ const FuelLogFormComponent: React.FC<{ onSuccess?: () => void; initial?: Partial
)} /> )} />
</Grid> </Grid>
{/* Row 2: Date/Time | MPG/L/100km */} {/* Row 2: Mileage (Trip Distance / Odometer) | Input Method Toggle */}
<Grid item xs={12} sm={6}>
<Controller name="dateTime" control={control} render={({ field }) => (
<DateTimePicker
label="Date & Time"
value={field.value ? dayjs(field.value) : null}
onChange={(newValue) => field.onChange(newValue?.toISOString() || '')}
format="MM/DD/YYYY hh:mm a"
slotProps={{
textField: {
fullWidth: true,
error: !!errors.dateTime,
helperText: errors.dateTime?.message,
sx: {
'& .MuiOutlinedInput-root': {
minHeight: '56px',
}
}
}
}}
/>
)} />
</Grid>
<Grid item xs={12} sm={6}>
<TextField
label={`${userSettings?.unitSystem === 'metric' ? 'L/100km' : 'MPG'}`}
value={calculatedEfficiency > 0 ? calculatedEfficiency.toFixed(3) : ''}
fullWidth
InputProps={{
readOnly: true,
sx: (theme) => ({
backgroundColor: 'grey.50',
...theme.applyStyles('dark', {
backgroundColor: '#4C4E4D',
}),
'& .MuiOutlinedInput-input': {
cursor: 'default',
color: 'inherit',
...theme.applyStyles('dark', {
color: '#F2F3F6',
}),
},
}),
}}
helperText="Calculated from distance ÷ fuel amount"
sx={{
'& .MuiOutlinedInput-root': {
minHeight: '56px',
}
}}
/>
</Grid>
{/* Row 3: Odometer | Distance Input Method */}
<Grid item xs={12} sm={6}> <Grid item xs={12} sm={6}>
<Controller name={useOdometer ? 'odometerReading' : 'tripDistance'} control={control} render={({ field }) => ( <Controller name={useOdometer ? 'odometerReading' : 'tripDistance'} control={control} render={({ field }) => (
<DistanceInput type={useOdometer ? 'odometer' : 'trip'} value={field.value as any} onChange={field.onChange as any} unitSystem={userSettings?.unitSystem} error={useOdometer ? (errors.odometerReading?.message as any) : (errors.tripDistance?.message as any)} /> <DistanceInput type={useOdometer ? 'odometer' : 'trip'} value={field.value as any} onChange={field.onChange as any} unitSystem={userSettings?.unitSystem} error={useOdometer ? (errors.odometerReading?.message as any) : (errors.tripDistance?.message as any)} />
@@ -342,11 +289,21 @@ const FuelLogFormComponent: React.FC<{ onSuccess?: () => void; initial?: Partial
</ToggleButton> </ToggleButton>
</ToggleButtonGroup> </ToggleButtonGroup>
</Grid> </Grid>
<Grid item xs={12}>
<Controller name="fuelType" control={control} render={({ field: fuelTypeField }) => ( {/* Row 3: Cost Per Gallon | Fuel Amount */}
<Controller name="fuelGrade" control={control} render={({ field: fuelGradeField }) => ( <Grid item xs={12} sm={6}>
<FuelTypeSelector fuelType={fuelTypeField.value} fuelGrade={fuelGradeField.value as any} onFuelTypeChange={fuelTypeField.onChange} onFuelGradeChange={fuelGradeField.onChange as any} error={(errors.fuelType?.message as any) || (errors.fuelGrade?.message as any)} /> <Controller name="costPerUnit" control={control} render={({ field }) => (
)} /> <TextField
{...field}
value={field.value ?? ''}
onChange={(e) => field.onChange(e.target.value)}
label={`Cost Per ${userSettings?.unitSystem === 'imperial' ? 'Gallon' : 'Liter'}`}
type="number"
inputProps={{ step: 0.001, min: 0.001, inputMode: 'decimal' }}
fullWidth
error={!!errors.costPerUnit}
helperText={errors.costPerUnit?.message}
/>
)} /> )} />
</Grid> </Grid>
<Grid item xs={12} sm={6}> <Grid item xs={12} sm={6}>
@@ -357,31 +314,27 @@ const FuelLogFormComponent: React.FC<{ onSuccess?: () => void; initial?: Partial
onChange={(e) => field.onChange(e.target.value)} onChange={(e) => field.onChange(e.target.value)}
label={`Fuel Amount (${userSettings?.unitSystem === 'imperial' ? 'gallons' : 'liters'})`} label={`Fuel Amount (${userSettings?.unitSystem === 'imperial' ? 'gallons' : 'liters'})`}
type="number" type="number"
inputProps={{ step: 0.001, min: 0.001 }} inputProps={{ step: 0.001, min: 0.001, inputMode: 'decimal' }}
fullWidth fullWidth
error={!!errors.fuelUnits} error={!!errors.fuelUnits}
helperText={errors.fuelUnits?.message} helperText={errors.fuelUnits?.message}
/> />
)} /> )} />
</Grid> </Grid>
<Grid item xs={12} sm={6}>
<Controller name="costPerUnit" control={control} render={({ field }) => (
<TextField
{...field}
value={field.value ?? ''}
onChange={(e) => field.onChange(e.target.value)}
label={`Cost Per ${userSettings?.unitSystem === 'imperial' ? 'Gallon' : 'Liter'}`}
type="number"
inputProps={{ step: 0.001, min: 0.001 }}
fullWidth
error={!!errors.costPerUnit}
helperText={errors.costPerUnit?.message}
/>
)} />
</Grid>
<Grid item xs={12}> <Grid item xs={12}>
<CostCalculator fuelUnits={fuelUnits} costPerUnit={costPerUnit} calculatedCost={calculatedCost} /> <CostCalculator fuelUnits={fuelUnits} costPerUnit={costPerUnit} calculatedCost={calculatedCost} />
</Grid> </Grid>
{/* Row 4: Fuel Type / Fuel Grade (grade options filtered by type) */}
<Grid item xs={12}>
<Controller name="fuelType" control={control} render={({ field: fuelTypeField }) => (
<Controller name="fuelGrade" control={control} render={({ field: fuelGradeField }) => (
<FuelTypeSelector fuelType={fuelTypeField.value} fuelGrade={fuelGradeField.value as any} onFuelTypeChange={fuelTypeField.onChange} onFuelGradeChange={fuelGradeField.onChange as any} error={(errors.fuelType?.message as any) || (errors.fuelGrade?.message as any)} />
)} />
)} />
</Grid>
{/* Row 5: Location */}
<Grid item xs={12}> <Grid item xs={12}>
<Controller name="locationData" control={control} render={({ field }) => ( <Controller name="locationData" control={control} render={({ field }) => (
<StationPicker <StationPicker
@@ -392,11 +345,68 @@ const FuelLogFormComponent: React.FC<{ onSuccess?: () => void; initial?: Partial
/> />
)} /> )} />
</Grid> </Grid>
{/* Row 6: MPG/L/100km (calculated, read-only) */}
<Grid item xs={12}>
<TextField
label={`${userSettings?.unitSystem === 'metric' ? 'L/100km' : 'MPG'}`}
value={calculatedEfficiency > 0 ? calculatedEfficiency.toFixed(3) : ''}
fullWidth
InputProps={{
readOnly: true,
sx: (theme) => ({
backgroundColor: 'grey.50',
...theme.applyStyles('dark', {
backgroundColor: '#4C4E4D',
}),
'& .MuiOutlinedInput-input': {
cursor: 'default',
color: 'inherit',
...theme.applyStyles('dark', {
color: '#F2F3F6',
}),
},
}),
}}
helperText="Calculated from distance ÷ fuel amount"
sx={{
'& .MuiOutlinedInput-root': {
minHeight: '56px',
}
}}
/>
</Grid>
{/* Row 7: Notes */}
<Grid item xs={12}> <Grid item xs={12}>
<Controller name="notes" control={control} render={({ field }) => ( <Controller name="notes" control={control} render={({ field }) => (
<TextField {...field} label="Notes (optional)" multiline rows={3} fullWidth error={!!errors.notes} helperText={errors.notes?.message} /> <TextField {...field} label="Notes (optional)" multiline rows={3} fullWidth error={!!errors.notes} helperText={errors.notes?.message} />
)} /> )} />
</Grid> </Grid>
{/* Row 8: Date & Time (defaults to now, least-edited field) */}
<Grid item xs={12}>
<Controller name="dateTime" control={control} render={({ field }) => (
<DateTimePicker
label="Date & Time"
value={field.value ? dayjs(field.value) : null}
onChange={(newValue) => field.onChange(newValue?.toISOString() || '')}
format="MM/DD/YYYY hh:mm a"
slotProps={{
textField: {
fullWidth: true,
error: !!errors.dateTime,
helperText: errors.dateTime?.message,
sx: {
'& .MuiOutlinedInput-root': {
minHeight: '56px',
}
}
}
}}
/>
)} />
</Grid>
<Grid item xs={12}> <Grid item xs={12}>
<Box display="flex" gap={2} justifyContent="flex-end"> <Box display="flex" gap={2} justifyContent="flex-end">
<Button type="submit" variant="contained" disabled={!isValid || isLoading} startIcon={isLoading ? <CircularProgress size={18} /> : undefined}>Add Fuel Log</Button> <Button type="submit" variant="contained" disabled={!isValid || isLoading} startIcon={isLoading ? <CircularProgress size={18} /> : undefined}>Add Fuel Log</Button>

View File

@@ -115,16 +115,16 @@ export const MaintenanceRecordsList: React.FC<MaintenanceRecordsListProps> = ({
{categoryDisplay} ({subtypeCount}) {categoryDisplay} ({subtypeCount})
</Typography> </Typography>
<Stack direction="row" spacing={1} flexWrap="wrap" sx={{ mt: 1 }}> <Stack direction="row" spacing={1} flexWrap="wrap" sx={{ mt: 1 }}>
{record.odometerReading && ( {record.odometerReading != null && (
<Chip <Chip
label={`${Number(record.odometerReading).toLocaleString()} miles`} label={`${record.odometerReading.toLocaleString()} miles`}
size="small" size="small"
variant="outlined" variant="outlined"
/> />
)} )}
{record.cost && ( {record.cost != null && (
<Chip <Chip
label={`$${Number(record.cost).toFixed(2)}`} label={`$${record.cost.toFixed(2)}`}
size="small" size="small"
color="primary" color="primary"
variant="outlined" variant="outlined"

View File

@@ -12,6 +12,9 @@ import { FuelLogResponse, UpdateFuelLogRequest } from '../../fuel-logs/types/fue
import { FuelLogEditDialog } from '../../fuel-logs/components/FuelLogEditDialog'; import { FuelLogEditDialog } from '../../fuel-logs/components/FuelLogEditDialog';
import { fuelLogsApi } from '../../fuel-logs/api/fuel-logs.api'; import { fuelLogsApi } from '../../fuel-logs/api/fuel-logs.api';
import { MaintenanceRecordForm } from '../../maintenance/components/MaintenanceRecordForm'; import { MaintenanceRecordForm } from '../../maintenance/components/MaintenanceRecordForm';
import { useMaintenanceRecords } from '../../maintenance/hooks/useMaintenanceRecords';
import { getCategoryDisplayName } from '../../maintenance/types/maintenance.types';
import type { MaintenanceRecordResponse } from '../../maintenance/types/maintenance.types';
import { VehicleImage } from '../components/VehicleImage'; import { VehicleImage } from '../components/VehicleImage';
import { OwnershipCostsList } from '../../ownership-costs'; import { OwnershipCostsList } from '../../ownership-costs';
import { getVehicleLabel } from '@/core/utils/vehicleDisplay'; import { getVehicleLabel } from '@/core/utils/vehicleDisplay';
@@ -46,6 +49,7 @@ export const VehicleDetailMobile: React.FC<VehicleDetailMobileProps> = ({
const [recordFilter, setRecordFilter] = useState<'All' | 'Fuel Logs' | 'Maintenance' | 'Documents'>('All'); const [recordFilter, setRecordFilter] = useState<'All' | 'Fuel Logs' | 'Maintenance' | 'Documents'>('All');
const { fuelLogs, isLoading: isFuelLoading } = useFuelLogs(vehicle.id); const { fuelLogs, isLoading: isFuelLoading } = useFuelLogs(vehicle.id);
const { records: maintenanceRecords, isRecordsLoading: isMaintenanceLoading } = useMaintenanceRecords(vehicle.id);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [editingLog, setEditingLog] = useState<FuelLogResponse | null>(null); const [editingLog, setEditingLog] = useState<FuelLogResponse | null>(null);
const [showMaintenanceDialog, setShowMaintenanceDialog] = useState(false); const [showMaintenanceDialog, setShowMaintenanceDialog] = useState(false);
@@ -124,8 +128,34 @@ export const VehicleDetailMobile: React.FC<VehicleDetailMobileProps> = ({
}); });
} }
} }
if (maintenanceRecords && Array.isArray(maintenanceRecords)) {
for (const rec of maintenanceRecords as MaintenanceRecordResponse[]) {
const catLabel = getCategoryDisplayName(rec.category);
const subtypes = Array.isArray(rec.subtypes) ? rec.subtypes : [];
const subtypeText = subtypes.length
? `${subtypes.slice(0, 3).join(', ')}${subtypes.length > 3 ? ` +${subtypes.length - 3}` : ''}`
: '';
const summary = subtypeText ? `${catLabel}${subtypeText}` : catLabel;
const secondaryParts: string[] = [];
if (rec.shopName) secondaryParts.push(rec.shopName);
secondaryParts.push(new Date(rec.date).toLocaleDateString());
secondaryParts.push('Maintenance');
const secondary = secondaryParts.join(' • ');
const amount = typeof rec.cost === 'number' ? `$${rec.cost.toFixed(2)}` : undefined;
list.push({
id: rec.id,
type: 'Maintenance',
date: rec.date,
summary,
amount,
secondary
});
}
}
return list.sort((a, b) => b.date.localeCompare(a.date)); return list.sort((a, b) => b.date.localeCompare(a.date));
}, [fuelLogs]); }, [fuelLogs, maintenanceRecords]);
const filteredRecords = useMemo(() => { const filteredRecords = useMemo(() => {
if (recordFilter === 'All') return records; if (recordFilter === 'All') return records;
@@ -243,7 +273,7 @@ export const VehicleDetailMobile: React.FC<VehicleDetailMobileProps> = ({
<Section title="Vehicle Records"> <Section title="Vehicle Records">
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}> <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
{isFuelLoading ? 'Loading…' : `${filteredRecords.length} record${filteredRecords.length === 1 ? '' : 's'}`} {(isFuelLoading || isMaintenanceLoading) ? 'Loading…' : `${filteredRecords.length} record${filteredRecords.length === 1 ? '' : 's'}`}
</Typography> </Typography>
<FormControl size="small" sx={{ minWidth: 180 }}> <FormControl size="small" sx={{ minWidth: 180 }}>
<InputLabel id="vehicle-records-filter">Filter</InputLabel> <InputLabel id="vehicle-records-filter">Filter</InputLabel>
@@ -262,7 +292,7 @@ export const VehicleDetailMobile: React.FC<VehicleDetailMobileProps> = ({
</Box> </Box>
<Card> <Card>
<CardContent sx={{ p: 0 }}> <CardContent sx={{ p: 0 }}>
{isFuelLoading ? ( {(isFuelLoading || isMaintenanceLoading) ? (
<Box sx={{ p: 2 }}> <Box sx={{ p: 2 }}>
<Typography color="text.secondary" variant="body2">Loading records</Typography> <Typography color="text.secondary" variant="body2">Loading records</Typography>
</Box> </Box>

View File

@@ -23,6 +23,9 @@ import { FuelLogResponse, UpdateFuelLogRequest } from '../../fuel-logs/types/fue
import { FuelLogEditDialog } from '../../fuel-logs/components/FuelLogEditDialog'; import { FuelLogEditDialog } from '../../fuel-logs/components/FuelLogEditDialog';
import { FuelLogForm } from '../../fuel-logs/components/FuelLogForm'; import { FuelLogForm } from '../../fuel-logs/components/FuelLogForm';
import { MaintenanceRecordForm } from '../../maintenance/components/MaintenanceRecordForm'; import { MaintenanceRecordForm } from '../../maintenance/components/MaintenanceRecordForm';
import { useMaintenanceRecords } from '../../maintenance/hooks/useMaintenanceRecords';
import { getCategoryDisplayName } from '../../maintenance/types/maintenance.types';
import type { MaintenanceRecordResponse } from '../../maintenance/types/maintenance.types';
// Unit conversions now handled by backend // Unit conversions now handled by backend
import { fuelLogsApi } from '../../fuel-logs/api/fuel-logs.api'; import { fuelLogsApi } from '../../fuel-logs/api/fuel-logs.api';
import { OwnershipCostsList } from '../../ownership-costs'; import { OwnershipCostsList } from '../../ownership-costs';
@@ -60,6 +63,7 @@ export const VehicleDetailPage: React.FC = () => {
const { fuelLogs, isLoading: isFuelLoading } = useFuelLogs(id); const { fuelLogs, isLoading: isFuelLoading } = useFuelLogs(id);
const { data: documents, isLoading: isDocumentsLoading } = useDocumentsByVehicle(id); const { data: documents, isLoading: isDocumentsLoading } = useDocumentsByVehicle(id);
const { records: maintenanceRecords, isRecordsLoading: isMaintenanceLoading } = useMaintenanceRecords(id);
const { mutateAsync: deleteDocument } = useDeleteDocument(); const { mutateAsync: deleteDocument } = useDeleteDocument();
const { mutateAsync: removeVehicleFromDocument } = useRemoveVehicleFromDocument(); const { mutateAsync: removeVehicleFromDocument } = useRemoveVehicleFromDocument();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -133,8 +137,24 @@ export const VehicleDetailPage: React.FC = () => {
} }
} }
if (maintenanceRecords && Array.isArray(maintenanceRecords)) {
for (const rec of maintenanceRecords as MaintenanceRecordResponse[]) {
const catLabel = getCategoryDisplayName(rec.category);
const subtypes = Array.isArray(rec.subtypes) ? rec.subtypes : [];
const subtypeText = subtypes.length
? `${subtypes.slice(0, 3).join(', ')}${subtypes.length > 3 ? ` +${subtypes.length - 3}` : ''}`
: '';
const parts: string[] = [catLabel];
if (subtypeText) parts.push(subtypeText);
if (rec.shopName) parts.push(rec.shopName);
const summary = parts.join(' • ');
const amount = typeof rec.cost === 'number' ? `$${rec.cost.toFixed(2)}` : undefined;
list.push({ id: rec.id, type: 'Maintenance', date: rec.date, summary, amount });
}
}
return list.sort((a, b) => b.date.localeCompare(a.date)); return list.sort((a, b) => b.date.localeCompare(a.date));
}, [fuelLogs, documents]); }, [fuelLogs, documents, maintenanceRecords]);
const filteredRecords = useMemo(() => { const filteredRecords = useMemo(() => {
if (recordFilter === 'All') return records; if (recordFilter === 'All') return records;
@@ -475,22 +495,22 @@ export const VehicleDetailPage: React.FC = () => {
</TableRow> </TableRow>
</TableHead> </TableHead>
<TableBody> <TableBody>
{(isFuelLoading || isDocumentsLoading) && ( {(isFuelLoading || isDocumentsLoading || isMaintenanceLoading) && (
<TableRow> <TableRow>
<TableCell colSpan={5}> <TableCell colSpan={5}>
<Typography color="text.secondary">Loading records</Typography> <Typography color="text.secondary">Loading records</Typography>
</TableCell> </TableCell>
</TableRow> </TableRow>
)} )}
{!isFuelLoading && !isDocumentsLoading && filteredRecords.length === 0 && ( {!isFuelLoading && !isDocumentsLoading && !isMaintenanceLoading && filteredRecords.length === 0 && (
<TableRow> <TableRow>
<TableCell colSpan={5}> <TableCell colSpan={5}>
<Typography color="text.secondary">No records found for this filter.</Typography> <Typography color="text.secondary">No records found for this filter.</Typography>
</TableCell> </TableCell>
</TableRow> </TableRow>
)} )}
{!isFuelLoading && !isDocumentsLoading && filteredRecords.map((rec) => ( {!isFuelLoading && !isDocumentsLoading && !isMaintenanceLoading && filteredRecords.map((rec) => (
<TableRow key={rec.id} hover sx={{ cursor: rec.type === 'Documents' ? 'default' : 'pointer' }} onClick={() => handleRowClick(rec.id, rec.type)}> <TableRow key={rec.id} hover sx={{ cursor: rec.type === 'Fuel Logs' ? 'pointer' : 'default' }} onClick={() => handleRowClick(rec.id, rec.type)}>
<TableCell>{dayjs(rec.date).format('M/D/YYYY')}</TableCell> <TableCell>{dayjs(rec.date).format('M/D/YYYY')}</TableCell>
<TableCell>{rec.type}</TableCell> <TableCell>{rec.type}</TableCell>
<TableCell>{rec.summary}</TableCell> <TableCell>{rec.summary}</TableCell>