Add quick add-reading homepage, photo take/choose picker, carry-forward estimate rate
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
68dab06b0a
commit
847fe49acd
6 changed files with 180 additions and 7 deletions
|
|
@ -167,9 +167,19 @@ export async function getTrailingDailyRate(meterId, windowDays) {
|
|||
windowStart.setDate(windowStart.getDate() - windowDays)
|
||||
const windowStartStr = windowStart.toISOString().slice(0, 10)
|
||||
|
||||
const before = await readingOnOrBefore(meterId, windowStartStr)
|
||||
let before = await readingOnOrBefore(meterId, windowStartStr)
|
||||
if (!before || before.reading_date >= latest.reading_date) {
|
||||
return { daily_rate: null, latest }
|
||||
// Nothing old enough to fill the window (readings are sparse) — fall back
|
||||
// to the average between the last two readings so the estimate carries
|
||||
// forward instead of dropping to zero until a new reading is added.
|
||||
const { rows } = await pool.query(
|
||||
`SELECT reading_value, reading_date FROM readings
|
||||
WHERE meter_id = $1 AND reading_date < $2
|
||||
ORDER BY reading_date DESC, id DESC LIMIT 1`,
|
||||
[meterId, latest.reading_date]
|
||||
)
|
||||
before = rows[0] || null
|
||||
if (!before) return { daily_rate: null, latest }
|
||||
}
|
||||
const days = daysBetween(before.reading_date, latest.reading_date)
|
||||
if (days <= 0) return { daily_rate: null, latest }
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import Layout from './components/Layout'
|
|||
import Meters from './pages/Meters'
|
||||
import MeterDetail from './pages/MeterDetail'
|
||||
import Tariffs from './pages/Tariffs'
|
||||
import AddReading from './pages/AddReading'
|
||||
import Readings from './pages/Readings'
|
||||
import Reports from './pages/Reports'
|
||||
import Estimates from './pages/Estimates'
|
||||
|
|
@ -20,7 +21,7 @@ export default function App() {
|
|||
<AuthGate>
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/meters" replace />} />
|
||||
<Route path="/" element={<AddReading />} />
|
||||
<Route path="/meters" element={<Meters />} />
|
||||
<Route path="/meters/:id" element={<MeterDetail />} />
|
||||
<Route path="/readings" element={<Readings />} />
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { NavLink, useLocation } from 'react-router-dom'
|
||||
import { Zap, Gauge, Receipt, BarChart3, TrendingUp, Settings, Menu, LogOut } from 'lucide-react'
|
||||
import { Zap, PlusCircle, Gauge, Receipt, BarChart3, TrendingUp, Settings, Menu, LogOut } from 'lucide-react'
|
||||
import { useAuth } from './AuthGate'
|
||||
import { can } from '../types'
|
||||
|
||||
const ICON_PROPS = { size: 16, strokeWidth: 1.75 }
|
||||
|
||||
const NAV = [
|
||||
{ to: '/', label: 'Add reading', icon: PlusCircle, cap: 'readings', end: true },
|
||||
{ to: '/meters', label: 'Meters', icon: Gauge, cap: 'meters' },
|
||||
{ to: '/readings', label: 'Readings', icon: Zap, cap: 'readings' },
|
||||
{ to: '/tariffs', label: 'Tariffs', icon: Receipt, cap: 'tariffs' },
|
||||
|
|
@ -37,7 +38,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
|||
</div>
|
||||
<nav className="sidebar-nav">
|
||||
{items.map(({ to, label, icon: Icon }) => (
|
||||
<NavLink key={to} to={to} className={({ isActive }) => isActive ? 'active' : ''}>
|
||||
<NavLink key={to} to={to} end={to === '/'} className={({ isActive }) => isActive ? 'active' : ''}>
|
||||
<Icon {...ICON_PROPS} />
|
||||
{label}
|
||||
</NavLink>
|
||||
|
|
|
|||
38
frontend/src/components/PhotoPicker.tsx
Normal file
38
frontend/src/components/PhotoPicker.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { useRef } from 'react'
|
||||
import { Camera, Image as ImageIcon } from 'lucide-react'
|
||||
|
||||
interface Props {
|
||||
file: File | null
|
||||
onChange: (file: File | null) => void
|
||||
}
|
||||
|
||||
// Two inputs: one opens the gallery/file chooser, the other opens the camera on mobile.
|
||||
export default function PhotoPicker({ file, onChange }: Props) {
|
||||
const chooseRef = useRef<HTMLInputElement>(null)
|
||||
const cameraRef = useRef<HTMLInputElement>(null)
|
||||
const pick = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const f = e.target.files?.[0]
|
||||
if (f) onChange(f)
|
||||
e.target.value = ''
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
<button type="button" className="btn btn-sm" onClick={() => cameraRef.current?.click()}>
|
||||
<Camera size={13} strokeWidth={1.75} /> Take photo
|
||||
</button>
|
||||
<button type="button" className="btn btn-sm" onClick={() => chooseRef.current?.click()}>
|
||||
<ImageIcon size={13} strokeWidth={1.75} /> Choose image
|
||||
</button>
|
||||
</div>
|
||||
<input ref={cameraRef} type="file" accept="image/*" capture="environment" onChange={pick} style={{ display: 'none' }} />
|
||||
<input ref={chooseRef} type="file" accept="image/*" onChange={pick} style={{ display: 'none' }} />
|
||||
{file && (
|
||||
<div className="field-hint" style={{ marginTop: 6 }}>
|
||||
{file.name}{' '}
|
||||
<button type="button" className="btn btn-sm" onClick={() => onChange(null)}>Remove</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
122
frontend/src/pages/AddReading.tsx
Normal file
122
frontend/src/pages/AddReading.tsx
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Navigate } from 'react-router-dom'
|
||||
import { X } from 'lucide-react'
|
||||
import { useAuth } from '../components/AuthGate'
|
||||
import PhotoPicker from '../components/PhotoPicker'
|
||||
import { can, CATEGORY_DOT_CLASS } from '../types'
|
||||
import type { Meter } from '../types'
|
||||
import * as api from '../api'
|
||||
|
||||
export default function AddReading() {
|
||||
const { user } = useAuth()
|
||||
const canEnter = can(user, 'readings')
|
||||
|
||||
const [meters, setMeters] = useState<Meter[]>([])
|
||||
const [active, setActive] = useState<Meter | null>(null)
|
||||
const [value, setValue] = useState('')
|
||||
const [date, setDate] = useState(new Date().toISOString().slice(0, 10))
|
||||
const [photo, setPhoto] = useState<File | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [info, setInfo] = useState<string | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const load = useCallback(() => {
|
||||
api.fetchMeters({ active: true }).then(setMeters).catch(err => setError(err.message))
|
||||
}, [])
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
if (!canEnter) return <Navigate to="/meters" replace />
|
||||
|
||||
function open(m: Meter) {
|
||||
setActive(m)
|
||||
setValue('')
|
||||
setPhoto(null)
|
||||
setDate(new Date().toISOString().slice(0, 10))
|
||||
setError(null)
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!active || value === '') { setError('Reading value is required'); return }
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const r = await api.createReading({ meter_id: active.id, reading_value: parseFloat(value), reading_date: date })
|
||||
if (photo) await api.uploadReadingPhoto(r.id, photo)
|
||||
setInfo(r.warning ? `${active.name}: ${r.warning}` : `Saved reading for ${active.name}`)
|
||||
setActive(null)
|
||||
load()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Save failed')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="page-header"><h1>Add reading</h1></div>
|
||||
|
||||
{error && !active && <div className="error-banner">{error}</div>}
|
||||
{info && <div className="info-banner">{info}</div>}
|
||||
|
||||
{meters.length === 0 ? (
|
||||
<div className="empty-state">No active meters.</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))', gap: 12 }}>
|
||||
{meters.map(m => (
|
||||
<button
|
||||
key={m.id}
|
||||
className="card"
|
||||
onClick={() => open(m)}
|
||||
style={{ textAlign: 'left', cursor: 'pointer', marginBottom: 0 }}
|
||||
>
|
||||
<div style={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span className={`cat-dot ${CATEGORY_DOT_CLASS[m.category_key] || ''}`} />{m.name}
|
||||
</div>
|
||||
<div className="field-hint">{m.category_name}{m.location ? ` · ${m.location}` : ''}</div>
|
||||
<div className="field-hint" style={{ marginTop: 6 }}>
|
||||
{m.latest_reading_value
|
||||
? `${Number(m.latest_reading_value).toLocaleString()} ${m.reading_unit_label} · ${new Date(m.latest_reading_date!).toLocaleDateString('en-GB')}`
|
||||
: 'No readings yet'}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{active && (
|
||||
<div className="modal-overlay" onClick={() => setActive(null)}>
|
||||
<div className="modal" onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2>{active.name}</h2>
|
||||
<button className="modal-close" onClick={() => setActive(null)}><X size={18} strokeWidth={1.75} /></button>
|
||||
</div>
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
<div className="field">
|
||||
<label>Reading ({active.reading_unit_label})</label>
|
||||
<input type="number" step="0.001" inputMode="decimal" autoFocus value={value}
|
||||
onChange={e => setValue(e.target.value)} placeholder="e.g. 45231.5" />
|
||||
{active.latest_reading_value && (
|
||||
<div className="field-hint">
|
||||
Previous: {Number(active.latest_reading_value).toLocaleString()} {active.reading_unit_label} on {new Date(active.latest_reading_date!).toLocaleDateString('en-GB')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Date</label>
|
||||
<input type="date" value={date} onChange={e => setDate(e.target.value)} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Photo</label>
|
||||
<PhotoPicker file={photo} onChange={setPhoto} />
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button className="btn" onClick={() => setActive(null)}>Cancel</button>
|
||||
<button className="btn btn-primary" onClick={submit} disabled={saving}>{saving ? 'Saving…' : 'Save reading'}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ import { Camera, Trash2 } from 'lucide-react'
|
|||
import { useAuth } from '../components/AuthGate'
|
||||
import { can } from '../types'
|
||||
import type { Meter, Reading } from '../types'
|
||||
import PhotoPicker from '../components/PhotoPicker'
|
||||
import * as api from '../api'
|
||||
|
||||
export default function Readings() {
|
||||
|
|
@ -106,7 +107,7 @@ export default function Readings() {
|
|||
</div>
|
||||
<div className="field" style={{ marginBottom: 0 }}>
|
||||
<label>Photo</label>
|
||||
<input type="file" accept="image/*" onChange={e => setPhoto(e.target.files?.[0] || null)} style={{ fontSize: 11 }} />
|
||||
<PhotoPicker file={photo} onChange={setPhoto} />
|
||||
</div>
|
||||
<button className="btn btn-primary" onClick={submit} disabled={saving} style={{ marginBottom: 1 }}>
|
||||
{saving ? 'Saving…' : 'Add reading'}
|
||||
|
|
@ -142,7 +143,7 @@ export default function Readings() {
|
|||
<a href={api.uploadUrl(r.photo_path)} target="_blank" rel="noreferrer">View</a>
|
||||
) : canEnter ? (
|
||||
photoTargetId === r.id ? (
|
||||
<input type="file" accept="image/*" autoFocus onChange={e => e.target.files?.[0] && uploadPhoto(r.id, e.target.files[0])} />
|
||||
<PhotoPicker file={null} onChange={f => f && uploadPhoto(r.id, f)} />
|
||||
) : (
|
||||
<button className="btn btn-sm" onClick={() => setPhotoTargetId(r.id)}><Camera size={13} strokeWidth={1.75} /></button>
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue