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)
|
windowStart.setDate(windowStart.getDate() - windowDays)
|
||||||
const windowStartStr = windowStart.toISOString().slice(0, 10)
|
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) {
|
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)
|
const days = daysBetween(before.reading_date, latest.reading_date)
|
||||||
if (days <= 0) return { daily_rate: null, latest }
|
if (days <= 0) return { daily_rate: null, latest }
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import Layout from './components/Layout'
|
||||||
import Meters from './pages/Meters'
|
import Meters from './pages/Meters'
|
||||||
import MeterDetail from './pages/MeterDetail'
|
import MeterDetail from './pages/MeterDetail'
|
||||||
import Tariffs from './pages/Tariffs'
|
import Tariffs from './pages/Tariffs'
|
||||||
|
import AddReading from './pages/AddReading'
|
||||||
import Readings from './pages/Readings'
|
import Readings from './pages/Readings'
|
||||||
import Reports from './pages/Reports'
|
import Reports from './pages/Reports'
|
||||||
import Estimates from './pages/Estimates'
|
import Estimates from './pages/Estimates'
|
||||||
|
|
@ -20,7 +21,7 @@ export default function App() {
|
||||||
<AuthGate>
|
<AuthGate>
|
||||||
<Layout>
|
<Layout>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Navigate to="/meters" replace />} />
|
<Route path="/" element={<AddReading />} />
|
||||||
<Route path="/meters" element={<Meters />} />
|
<Route path="/meters" element={<Meters />} />
|
||||||
<Route path="/meters/:id" element={<MeterDetail />} />
|
<Route path="/meters/:id" element={<MeterDetail />} />
|
||||||
<Route path="/readings" element={<Readings />} />
|
<Route path="/readings" element={<Readings />} />
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { NavLink, useLocation } from 'react-router-dom'
|
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 { useAuth } from './AuthGate'
|
||||||
import { can } from '../types'
|
import { can } from '../types'
|
||||||
|
|
||||||
const ICON_PROPS = { size: 16, strokeWidth: 1.75 }
|
const ICON_PROPS = { size: 16, strokeWidth: 1.75 }
|
||||||
|
|
||||||
const NAV = [
|
const NAV = [
|
||||||
|
{ to: '/', label: 'Add reading', icon: PlusCircle, cap: 'readings', end: true },
|
||||||
{ to: '/meters', label: 'Meters', icon: Gauge, cap: 'meters' },
|
{ to: '/meters', label: 'Meters', icon: Gauge, cap: 'meters' },
|
||||||
{ to: '/readings', label: 'Readings', icon: Zap, cap: 'readings' },
|
{ to: '/readings', label: 'Readings', icon: Zap, cap: 'readings' },
|
||||||
{ to: '/tariffs', label: 'Tariffs', icon: Receipt, cap: 'tariffs' },
|
{ to: '/tariffs', label: 'Tariffs', icon: Receipt, cap: 'tariffs' },
|
||||||
|
|
@ -37,7 +38,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
||||||
</div>
|
</div>
|
||||||
<nav className="sidebar-nav">
|
<nav className="sidebar-nav">
|
||||||
{items.map(({ to, label, icon: Icon }) => (
|
{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} />
|
<Icon {...ICON_PROPS} />
|
||||||
{label}
|
{label}
|
||||||
</NavLink>
|
</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 { useAuth } from '../components/AuthGate'
|
||||||
import { can } from '../types'
|
import { can } from '../types'
|
||||||
import type { Meter, Reading } from '../types'
|
import type { Meter, Reading } from '../types'
|
||||||
|
import PhotoPicker from '../components/PhotoPicker'
|
||||||
import * as api from '../api'
|
import * as api from '../api'
|
||||||
|
|
||||||
export default function Readings() {
|
export default function Readings() {
|
||||||
|
|
@ -106,7 +107,7 @@ export default function Readings() {
|
||||||
</div>
|
</div>
|
||||||
<div className="field" style={{ marginBottom: 0 }}>
|
<div className="field" style={{ marginBottom: 0 }}>
|
||||||
<label>Photo</label>
|
<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>
|
</div>
|
||||||
<button className="btn btn-primary" onClick={submit} disabled={saving} style={{ marginBottom: 1 }}>
|
<button className="btn btn-primary" onClick={submit} disabled={saving} style={{ marginBottom: 1 }}>
|
||||||
{saving ? 'Saving…' : 'Add reading'}
|
{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>
|
<a href={api.uploadUrl(r.photo_path)} target="_blank" rel="noreferrer">View</a>
|
||||||
) : canEnter ? (
|
) : canEnter ? (
|
||||||
photoTargetId === r.id ? (
|
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>
|
<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