daily tab design with hourly data and last week quick entry with self-efficiency on top

This commit is contained in:
Yinyin Liu 2026-03-10 12:32:01 +01:00
parent 0ac22ecbe9
commit 6cf14e3483
9 changed files with 764 additions and 173 deletions

View File

@ -1235,6 +1235,119 @@ public class Controller : ControllerBase
return Ok(new { count = records.Count, records });
}
[HttpGet(nameof(GetHourlyRecords))]
public ActionResult<List<HourlyEnergyRecord>> GetHourlyRecords(
Int64 installationId, String from, String to, Token authToken)
{
var user = Db.GetSession(authToken)?.User;
if (user == null)
return Unauthorized();
var installation = Db.GetInstallationById(installationId);
if (installation is null || !user.HasAccessTo(installation))
return Unauthorized();
if (!DateOnly.TryParseExact(from, "yyyy-MM-dd", out var fromDate) ||
!DateOnly.TryParseExact(to, "yyyy-MM-dd", out var toDate))
return BadRequest("from and to must be in yyyy-MM-dd format.");
var records = Db.GetHourlyRecords(installationId, fromDate, toDate);
return Ok(new { count = records.Count, records });
}
/// <summary>
/// Returns daily + hourly records for a date range.
/// DB first; if empty, falls back to xlsx parsing and caches results for future calls.
/// </summary>
[HttpGet(nameof(GetDailyDetailRecords))]
public ActionResult GetDailyDetailRecords(
Int64 installationId, String from, String to, Token authToken)
{
var user = Db.GetSession(authToken)?.User;
if (user == null)
return Unauthorized();
var installation = Db.GetInstallationById(installationId);
if (installation is null || !user.HasAccessTo(installation))
return Unauthorized();
if (!DateOnly.TryParseExact(from, "yyyy-MM-dd", out var fromDate) ||
!DateOnly.TryParseExact(to, "yyyy-MM-dd", out var toDate))
return BadRequest("from and to must be in yyyy-MM-dd format.");
// 1. Try DB
var dailyRecords = Db.GetDailyRecords(installationId, fromDate, toDate);
var hourlyRecords = Db.GetHourlyRecords(installationId, fromDate, toDate);
// 2. Fallback: parse xlsx + cache to DB
if (dailyRecords.Count == 0 || hourlyRecords.Count == 0)
{
var xlsxFiles = WeeklyReportService.GetRelevantXlsxFiles(installationId, fromDate, toDate);
if (xlsxFiles.Count > 0)
{
foreach (var xlsxPath in xlsxFiles)
{
if (dailyRecords.Count == 0)
{
foreach (var day in ExcelDataParser.Parse(xlsxPath))
{
if (Db.DailyRecordExists(installationId, day.Date))
continue;
Db.Create(new DailyEnergyRecord
{
InstallationId = installationId,
Date = day.Date,
PvProduction = day.PvProduction,
LoadConsumption = day.LoadConsumption,
GridImport = day.GridImport,
GridExport = day.GridExport,
BatteryCharged = day.BatteryCharged,
BatteryDischarged = day.BatteryDischarged,
CreatedAt = DateTime.UtcNow.ToString("o"),
});
}
}
if (hourlyRecords.Count == 0)
{
foreach (var hour in ExcelDataParser.ParseHourly(xlsxPath))
{
var dateHour = $"{hour.DateTime:yyyy-MM-dd HH}";
if (Db.HourlyRecordExists(installationId, dateHour))
continue;
Db.Create(new HourlyEnergyRecord
{
InstallationId = installationId,
Date = hour.DateTime.ToString("yyyy-MM-dd"),
Hour = hour.Hour,
DateHour = dateHour,
DayOfWeek = hour.DayOfWeek,
IsWeekend = hour.IsWeekend,
PvKwh = hour.PvKwh,
LoadKwh = hour.LoadKwh,
GridImportKwh = hour.GridImportKwh,
BatteryChargedKwh = hour.BatteryChargedKwh,
BatteryDischargedKwh = hour.BatteryDischargedKwh,
BattSoC = hour.BattSoC,
CreatedAt = DateTime.UtcNow.ToString("o"),
});
}
}
}
// Re-read from DB (now cached)
dailyRecords = Db.GetDailyRecords(installationId, fromDate, toDate);
hourlyRecords = Db.GetHourlyRecords(installationId, fromDate, toDate);
}
}
return Ok(new
{
dailyRecords = new { count = dailyRecords.Count, records = dailyRecords },
hourlyRecords = new { count = hourlyRecords.Count, records = hourlyRecords },
});
}
/// <summary>
/// Deletes DailyEnergyRecord rows for an installation in the given date range.
/// Safe to use during testing — only removes daily records, not report summaries.

View File

@ -116,13 +116,9 @@ public static partial class Db
{
var fromStr = from.ToString("yyyy-MM-dd");
var toStr = to.ToString("yyyy-MM-dd");
return DailyRecords
.Where(r => r.InstallationId == installationId)
.ToList()
.Where(r => String.Compare(r.Date, fromStr, StringComparison.Ordinal) >= 0
&& String.Compare(r.Date, toStr, StringComparison.Ordinal) <= 0)
.OrderBy(r => r.Date)
.ToList();
return Connection.Query<DailyEnergyRecord>(
"SELECT * FROM DailyEnergyRecord WHERE InstallationId = ? AND Date >= ? AND Date <= ? ORDER BY Date",
installationId, fromStr, toStr);
}
/// <summary>
@ -141,13 +137,9 @@ public static partial class Db
{
var fromStr = from.ToString("yyyy-MM-dd");
var toStr = to.ToString("yyyy-MM-dd");
return HourlyRecords
.Where(r => r.InstallationId == installationId)
.ToList()
.Where(r => String.Compare(r.Date, fromStr, StringComparison.Ordinal) >= 0
&& String.Compare(r.Date, toStr, StringComparison.Ordinal) <= 0)
.OrderBy(r => r.Date).ThenBy(r => r.Hour)
.ToList();
return Connection.Query<HourlyEnergyRecord>(
"SELECT * FROM HourlyEnergyRecord WHERE InstallationId = ? AND Date >= ? AND Date <= ? ORDER BY Date, Hour",
installationId, fromStr, toStr);
}
/// <summary>

View File

@ -15,7 +15,7 @@ public static class WeeklyReportService
/// Filename pattern: {installationId}_MMDD_MMDD.xlsx (e.g. "848_0302_0308.xlsx")
/// Falls back to all files if filenames can't be parsed.
/// </summary>
private static List<string> GetRelevantXlsxFiles(long installationId, DateOnly rangeStart, DateOnly rangeEnd)
public static List<string> GetRelevantXlsxFiles(long installationId, DateOnly rangeStart, DateOnly rangeEnd)
{
if (!Directory.Exists(TmpReportDir))
return new List<string>();

View File

@ -0,0 +1,614 @@
import { useEffect, useState, useMemo } from 'react';
import { useIntl, FormattedMessage } from 'react-intl';
import {
Alert,
Box,
CircularProgress,
Container,
Grid,
Paper,
TextField,
Typography
} from '@mui/material';
import { Line } from 'react-chartjs-2';
import {
Chart,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Filler,
Tooltip,
Legend
} from 'chart.js';
import axiosConfig from 'src/Resources/axiosConfig';
import { SavingsCards } from './WeeklyReport';
Chart.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
Filler,
Tooltip,
Legend
);
// ── Interfaces ───────────────────────────────────────────────
interface DailyEnergyData {
date: string;
pvProduction: number;
loadConsumption: number;
gridImport: number;
gridExport: number;
batteryCharged: number;
batteryDischarged: number;
}
interface HourlyEnergyRecord {
date: string;
hour: number;
pvKwh: number;
loadKwh: number;
gridImportKwh: number;
batteryChargedKwh: number;
batteryDischargedKwh: number;
battSoC: number;
}
// ── Date Helpers ─────────────────────────────────────────────
/**
* Anchor date for the 7-day strip. Returns last completed Sunday.
* To switch to live-data mode later, change to: () => new Date()
*/
function getDataAnchorDate(): Date {
const today = new Date();
const dow = today.getDay(); // 0=Sun
const lastSunday = new Date(today);
lastSunday.setDate(today.getDate() - (dow === 0 ? 7 : dow));
lastSunday.setHours(0, 0, 0, 0);
return lastSunday;
}
function getWeekRange(anchor: Date): { monday: Date; sunday: Date } {
const sunday = new Date(anchor);
const monday = new Date(sunday);
monday.setDate(sunday.getDate() - 6);
return { monday, sunday };
}
function formatDateISO(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
function getWeekDays(monday: Date): Date[] {
return Array.from({ length: 7 }, (_, i) => {
const d = new Date(monday);
d.setDate(monday.getDate() + i);
return d;
});
}
// ── Main Component ───────────────────────────────────────────
export default function DailySection({
installationId
}: {
installationId: number;
}) {
const intl = useIntl();
const anchor = useMemo(() => getDataAnchorDate(), []);
const { monday, sunday } = useMemo(() => getWeekRange(anchor), [anchor]);
const weekDays = useMemo(() => getWeekDays(monday), [monday]);
const [weekRecords, setWeekRecords] = useState<DailyEnergyData[]>([]);
const [weekHourlyRecords, setWeekHourlyRecords] = useState<HourlyEnergyRecord[]>([]);
const [selectedDate, setSelectedDate] = useState(formatDateISO(sunday));
const [selectedDayRecord, setSelectedDayRecord] = useState<DailyEnergyData | null>(null);
const [hourlyRecords, setHourlyRecords] = useState<HourlyEnergyRecord[]>([]);
const [loadingWeek, setLoadingWeek] = useState(false);
const [noData, setNoData] = useState(false);
// Fetch week data (daily + hourly) via combined endpoint with xlsx fallback + cache
useEffect(() => {
setLoadingWeek(true);
axiosConfig
.get('/GetDailyDetailRecords', {
params: {
installationId,
from: formatDateISO(monday),
to: formatDateISO(sunday)
}
})
.then((res) => {
const daily = res.data?.dailyRecords?.records ?? [];
const hourly = res.data?.hourlyRecords?.records ?? [];
setWeekRecords(Array.isArray(daily) ? daily : []);
setWeekHourlyRecords(Array.isArray(hourly) ? hourly : []);
})
.catch(() => {
setWeekRecords([]);
setWeekHourlyRecords([]);
})
.finally(() => setLoadingWeek(false));
}, [installationId, monday, sunday]);
// When selected date changes, extract data from week cache or fetch
useEffect(() => {
setNoData(false);
setSelectedDayRecord(null);
// Try week cache first
const cachedDay = weekRecords.find((r) => r.date === selectedDate);
const cachedHours = weekHourlyRecords.filter((r) => r.date === selectedDate);
if (cachedDay) {
setSelectedDayRecord(cachedDay);
setHourlyRecords(cachedHours);
return;
}
// Not in cache (date picker outside strip) — fetch via combined endpoint
axiosConfig
.get('/GetDailyDetailRecords', {
params: { installationId, from: selectedDate, to: selectedDate }
})
.then((res) => {
const daily = res.data?.dailyRecords?.records ?? [];
const hourly = res.data?.hourlyRecords?.records ?? [];
setHourlyRecords(Array.isArray(hourly) ? hourly : []);
if (Array.isArray(daily) && daily.length > 0) {
setSelectedDayRecord(daily[0]);
} else {
setNoData(true);
}
})
.catch(() => {
setHourlyRecords([]);
setNoData(true);
});
}, [installationId, selectedDate, weekRecords, weekHourlyRecords]);
const record = selectedDayRecord;
const kpis = useMemo(() => computeKPIs(record), [record]);
const handleDatePicker = (e: React.ChangeEvent<HTMLInputElement>) => {
setSelectedDate(e.target.value);
};
const handleStripSelect = (date: string) => {
setSelectedDate(date);
setNoData(false);
};
const dt = new Date(selectedDate + 'T00:00:00');
const dateLabel = dt.toLocaleDateString(intl.locale, {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
});
return (
<>
{/* Date Picker */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
<Typography variant="body1" fontWeight="bold">
<FormattedMessage id="selectDate" defaultMessage="Select Date" />
</Typography>
<TextField
type="date"
size="small"
value={selectedDate}
onChange={handleDatePicker}
inputProps={{ max: formatDateISO(new Date()) }}
sx={{ width: 200 }}
/>
</Box>
{/* 7-Day Strip */}
<DayStrip
weekDays={weekDays}
weekRecords={weekRecords}
selectedDate={selectedDate}
onSelect={handleStripSelect}
sunday={sunday}
loading={loadingWeek}
/>
{/* Loading state */}
{loadingWeek && !record && (
<Container
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '20vh'
}}
>
<CircularProgress size={40} style={{ color: '#ffc04d' }} />
</Container>
)}
{/* No data state */}
{!loadingWeek && noData && !record && (
<Alert severity="info" sx={{ mb: 3 }}>
<FormattedMessage
id="noDataForDate"
defaultMessage="No data available for the selected date."
/>
</Alert>
)}
{/* Day detail */}
{record && (
<>
{/* Header */}
<Paper
sx={{ bgcolor: '#2c3e50', color: '#fff', p: 3, mb: 3, borderRadius: 2 }}
>
<Typography variant="h5" fontWeight="bold">
<FormattedMessage
id="dailyReportTitle"
defaultMessage="Daily Energy Summary"
/>
</Typography>
<Typography variant="body2" sx={{ opacity: 0.8, mt: 0.5 }}>
{dateLabel}
</Typography>
</Paper>
{/* KPI Cards */}
<Paper sx={{ p: 3, mb: 3 }}>
<SavingsCards
intl={intl}
energySaved={+kpis.energySaved.toFixed(1)}
savingsCHF={kpis.savingsCHF}
selfSufficiency={kpis.selfSufficiency}
batteryEfficiency={kpis.batteryEfficiency}
/>
</Paper>
{/* Intraday Chart */}
<IntradayChart
hourlyData={hourlyRecords}
loading={loadingWeek}
/>
{/* Summary Table */}
<DailySummaryTable record={record} />
</>
)}
</>
);
}
// ── KPI Computation ──────────────────────────────────────────
function computeKPIs(record: DailyEnergyData | null) {
if (!record) {
return { energySaved: 0, savingsCHF: 0, selfSufficiency: 0, batteryEfficiency: 0 };
}
const energySaved = Math.max(0, record.loadConsumption - record.gridImport);
const savingsCHF = +(energySaved * 0.39).toFixed(2);
const selfSufficiency =
record.loadConsumption > 0
? Math.min(100, (1 - record.gridImport / record.loadConsumption) * 100)
: 0;
const batteryEfficiency =
record.batteryCharged > 0
? Math.min(100, Math.floor((record.batteryDischarged / record.batteryCharged) * 100))
: 0;
return { energySaved, savingsCHF, selfSufficiency, batteryEfficiency };
}
// ── DayStrip ─────────────────────────────────────────────────
function DayStrip({
weekDays,
weekRecords,
selectedDate,
onSelect,
sunday,
loading
}: {
weekDays: Date[];
weekRecords: DailyEnergyData[];
selectedDate: string;
onSelect: (date: string) => void;
sunday: Date;
loading: boolean;
}) {
const intl = useIntl();
const sundayLabel = sunday.toLocaleDateString(intl.locale, {
year: 'numeric',
month: 'short',
day: 'numeric'
});
return (
<Box sx={{ mb: 3 }}>
<Box
sx={{
display: 'flex',
gap: 1,
overflowX: 'auto',
pb: 1,
mb: 1
}}
>
{weekDays.map((day) => {
const dateStr = formatDateISO(day);
const isSelected = dateStr === selectedDate;
const record = weekRecords.find((r) => r.date === dateStr);
const selfSuff =
record && record.loadConsumption > 0
? Math.min(100, (1 - record.gridImport / record.loadConsumption) * 100)
: null;
return (
<Paper
key={dateStr}
onClick={() => onSelect(dateStr)}
elevation={isSelected ? 4 : 1}
sx={{
flex: '1 1 0',
minWidth: 80,
p: 1.5,
textAlign: 'center',
cursor: 'pointer',
border: isSelected ? '2px solid #2980b9' : '2px solid transparent',
bgcolor: isSelected ? '#e3f2fd' : '#fff',
transition: 'all 0.15s',
'&:hover': { bgcolor: isSelected ? '#e3f2fd' : '#f5f5f5' }
}}
>
<Typography variant="caption" fontWeight="bold" sx={{ color: '#666' }}>
{day.toLocaleDateString(intl.locale, { weekday: 'short' })}
</Typography>
<Typography variant="h6" fontWeight="bold" sx={{ lineHeight: 1.2 }}>
{day.getDate()}
</Typography>
<Typography
variant="caption"
sx={{ color: selfSuff != null ? '#8e44ad' : '#ccc' }}
>
{loading
? '...'
: selfSuff != null
? `${selfSuff.toFixed(0)}%`
: '—'}
</Typography>
</Paper>
);
})}
</Box>
<Typography variant="caption" sx={{ color: '#888' }}>
<FormattedMessage
id="dataUpTo"
defaultMessage="Data up to {date}"
values={{ date: sundayLabel }}
/>
</Typography>
</Box>
);
}
// ── IntradayChart ────────────────────────────────────────────
const HOUR_LABELS = Array.from({ length: 24 }, (_, i) =>
`${String(i).padStart(2, '0')}:00`
);
function IntradayChart({
hourlyData,
loading
}: {
hourlyData: HourlyEnergyRecord[];
loading: boolean;
}) {
const intl = useIntl();
if (loading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4, mb: 3 }}>
<CircularProgress size={30} style={{ color: '#ffc04d' }} />
</Box>
);
}
if (hourlyData.length === 0) {
return (
<Alert severity="info" sx={{ mb: 3 }}>
<FormattedMessage
id="noHourlyData"
defaultMessage="Hourly data not available for this day."
/>
</Alert>
);
}
const hourMap = new Map(hourlyData.map((h) => [h.hour, h]));
const pvData = HOUR_LABELS.map((_, i) => hourMap.get(i)?.pvKwh ?? null);
const loadData = HOUR_LABELS.map((_, i) => hourMap.get(i)?.loadKwh ?? null);
const batteryData = HOUR_LABELS.map((_, i) => {
const h = hourMap.get(i);
return h ? h.batteryDischargedKwh - h.batteryChargedKwh : null;
});
const socData = HOUR_LABELS.map((_, i) => hourMap.get(i)?.battSoC ?? null);
const chartData = {
labels: HOUR_LABELS,
datasets: [
{
label: intl.formatMessage({ id: 'pvProduction', defaultMessage: 'PV Production' }),
data: pvData,
borderColor: '#f1c40f',
backgroundColor: 'rgba(241,196,15,0.1)',
fill: true,
tension: 0.3,
pointRadius: 2,
yAxisID: 'y'
},
{
label: intl.formatMessage({ id: 'consumption', defaultMessage: 'Consumption' }),
data: loadData,
borderColor: '#e74c3c',
backgroundColor: 'rgba(231,76,60,0.1)',
fill: true,
tension: 0.3,
pointRadius: 2,
yAxisID: 'y'
},
{
label: intl.formatMessage({ id: 'batteryPower', defaultMessage: 'Battery Power' }),
data: batteryData,
borderColor: '#3498db',
backgroundColor: 'rgba(52,152,219,0.1)',
fill: true,
tension: 0.3,
pointRadius: 2,
yAxisID: 'y'
},
{
label: intl.formatMessage({ id: 'batterySoCLabel', defaultMessage: 'Battery SoC' }),
data: socData,
borderColor: '#27ae60',
borderDash: [6, 3],
backgroundColor: 'transparent',
tension: 0.3,
pointRadius: 2,
yAxisID: 'soc'
}
]
};
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index' as const, intersect: false },
plugins: {
legend: { position: 'top' as const }
},
scales: {
y: {
position: 'left' as const,
title: {
display: true,
text: intl.formatMessage({ id: 'powerKw', defaultMessage: 'Power (kW)' })
}
},
soc: {
position: 'right' as const,
min: 0,
max: 100,
title: {
display: true,
text: intl.formatMessage({ id: 'socPercent', defaultMessage: 'SoC (%)' })
},
grid: { drawOnChartArea: false }
}
}
};
return (
<Paper sx={{ p: 3, mb: 3 }}>
<Typography variant="h6" fontWeight="bold" sx={{ mb: 2, color: '#2c3e50' }}>
<FormattedMessage
id="intradayChart"
defaultMessage="Intraday Power Flow"
/>
</Typography>
<Box sx={{ height: 350 }}>
<Line data={chartData} options={chartOptions} />
</Box>
</Paper>
);
}
// ── DailySummaryTable ────────────────────────────────────────
function DailySummaryTable({ record }: { record: DailyEnergyData }) {
return (
<Paper sx={{ p: 3, mb: 3 }}>
<Typography variant="h6" fontWeight="bold" sx={{ mb: 2, color: '#2c3e50' }}>
<FormattedMessage id="dailySummary" defaultMessage="Daily Summary" />
</Typography>
<Box
component="table"
sx={{
width: '100%',
borderCollapse: 'collapse',
'& td, & th': { p: 1.5, borderBottom: '1px solid #eee' }
}}
>
<thead>
<tr style={{ background: '#f8f9fa' }}>
<th style={{ textAlign: 'left' }}>
<FormattedMessage id="metric" defaultMessage="Metric" />
</th>
<th style={{ textAlign: 'right' }}>
<FormattedMessage id="total" defaultMessage="Total" />
</th>
</tr>
</thead>
<tbody>
<tr>
<td><FormattedMessage id="pvProduction" defaultMessage="PV Production" /></td>
<td style={{ textAlign: 'right', fontWeight: 'bold' }}>
{record.pvProduction.toFixed(1)} kWh
</td>
</tr>
<tr>
<td><FormattedMessage id="consumption" defaultMessage="Consumption" /></td>
<td style={{ textAlign: 'right', fontWeight: 'bold' }}>
{record.loadConsumption.toFixed(1)} kWh
</td>
</tr>
<tr>
<td><FormattedMessage id="gridImport" defaultMessage="Grid Import" /></td>
<td style={{ textAlign: 'right', fontWeight: 'bold' }}>
{record.gridImport.toFixed(1)} kWh
</td>
</tr>
<tr>
<td><FormattedMessage id="gridExport" defaultMessage="Grid Export" /></td>
<td style={{ textAlign: 'right', fontWeight: 'bold' }}>
{record.gridExport.toFixed(1)} kWh
</td>
</tr>
<tr style={{ background: '#f0f7ff' }}>
<td>
<strong>
<FormattedMessage id="batteryCharged" defaultMessage="Battery Charged" />
</strong>
</td>
<td style={{ textAlign: 'right', fontWeight: 'bold', color: '#2980b9' }}>
{record.batteryCharged.toFixed(1)} kWh
</td>
</tr>
<tr style={{ background: '#f0f7ff' }}>
<td>
<strong>
<FormattedMessage
id="batteryDischarged"
defaultMessage="Battery Discharged"
/>
</strong>
</td>
<td style={{ textAlign: 'right', fontWeight: 'bold', color: '#e67e22' }}>
{record.batteryDischarged.toFixed(1)} kWh
</td>
</tr>
</tbody>
</Box>
</Paper>
);
}

View File

@ -23,6 +23,7 @@ import DownloadIcon from '@mui/icons-material/Download';
import RefreshIcon from '@mui/icons-material/Refresh';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import axiosConfig from 'src/Resources/axiosConfig';
import DailySection from './DailySection';
interface WeeklyReportProps {
installationId: number;
@ -346,163 +347,6 @@ function WeeklyReport({ installationId }: WeeklyReportProps) {
);
}
// ── Daily Section ──────────────────────────────────────────────
function DailySection({ installationId }: { installationId: number }) {
const intl = useIntl();
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
const formatDate = (d: Date) => d.toISOString().split('T')[0];
const [selectedDate, setSelectedDate] = useState(formatDate(yesterday));
const [dailyRecords, setDailyRecords] = useState<DailyEnergyData[]>([]);
const [loading, setLoading] = useState(false);
const [noData, setNoData] = useState(false);
const fetchDailyData = async (date: string) => {
setLoading(true);
setNoData(false);
try {
const res = await axiosConfig.get('/GetDailyRecords', {
params: { installationId, from: date, to: date }
});
const records = res.data?.records ?? res.data ?? [];
if (Array.isArray(records) && records.length > 0) {
setDailyRecords(records);
} else {
setDailyRecords([]);
setNoData(true);
}
} catch {
setDailyRecords([]);
setNoData(true);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchDailyData(selectedDate);
}, [installationId]);
const handleDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newDate = e.target.value;
setSelectedDate(newDate);
fetchDailyData(newDate);
};
const record = dailyRecords.length > 0 ? dailyRecords[0] : null;
const energySaved = record ? Math.max(0, record.loadConsumption - record.gridImport) : 0;
const savingsCHF = +(energySaved * 0.39).toFixed(2);
const selfSufficiency = record && record.loadConsumption > 0
? Math.min(100, ((1 - record.gridImport / record.loadConsumption) * 100))
: 0;
const batteryEfficiency = record && record.batteryCharged > 0
? Math.min(100, (record.batteryDischarged / record.batteryCharged) * 100)
: 0;
const dt = new Date(selectedDate);
const dateLabel = dt.toLocaleDateString(intl.locale, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
return (
<>
{/* Date Picker */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
<Typography variant="body1" fontWeight="bold">
<FormattedMessage id="selectDate" defaultMessage="Select Date" />
</Typography>
<TextField
type="date"
size="small"
value={selectedDate}
onChange={handleDateChange}
inputProps={{ max: formatDate(new Date()) }}
sx={{ width: 200 }}
/>
</Box>
{loading && (
<Container sx={{ display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', height: '30vh' }}>
<CircularProgress size={50} style={{ color: '#ffc04d' }} />
</Container>
)}
{!loading && noData && (
<Alert severity="info" sx={{ mb: 3 }}>
<FormattedMessage id="noDataForDate" defaultMessage="No data available for the selected date." />
</Alert>
)}
{!loading && record && (
<>
{/* Header */}
<Paper sx={{ bgcolor: '#2c3e50', color: '#fff', p: 3, mb: 3, borderRadius: 2 }}>
<Typography variant="h5" fontWeight="bold">
<FormattedMessage id="dailyReportTitle" defaultMessage="Daily Energy Summary" />
</Typography>
<Typography variant="body2" sx={{ opacity: 0.8, mt: 0.5 }}>
{dateLabel}
</Typography>
</Paper>
{/* Savings Cards */}
<Paper sx={{ p: 3, mb: 3 }}>
<SavingsCards
intl={intl}
energySaved={+energySaved.toFixed(1)}
savingsCHF={savingsCHF}
selfSufficiency={selfSufficiency}
batteryEfficiency={batteryEfficiency}
/>
</Paper>
{/* Daily Summary Table */}
<Paper sx={{ p: 3, mb: 3 }}>
<Typography variant="h6" fontWeight="bold" sx={{ mb: 2, color: '#2c3e50' }}>
<FormattedMessage id="dailySummary" defaultMessage="Daily Summary" />
</Typography>
<Box component="table" sx={{ width: '100%', borderCollapse: 'collapse', '& td, & th': { p: 1.5, borderBottom: '1px solid #eee' } }}>
<thead>
<tr style={{ background: '#f8f9fa' }}>
<th style={{ textAlign: 'left' }}><FormattedMessage id="metric" defaultMessage="Metric" /></th>
<th style={{ textAlign: 'right' }}><FormattedMessage id="total" defaultMessage="Total" /></th>
</tr>
</thead>
<tbody>
<tr>
<td><FormattedMessage id="pvProduction" defaultMessage="PV Production" /></td>
<td style={{ textAlign: 'right', fontWeight: 'bold' }}>{record.pvProduction.toFixed(1)} kWh</td>
</tr>
<tr>
<td><FormattedMessage id="consumption" defaultMessage="Consumption" /></td>
<td style={{ textAlign: 'right', fontWeight: 'bold' }}>{record.loadConsumption.toFixed(1)} kWh</td>
</tr>
<tr>
<td><FormattedMessage id="gridImport" defaultMessage="Grid Import" /></td>
<td style={{ textAlign: 'right', fontWeight: 'bold' }}>{record.gridImport.toFixed(1)} kWh</td>
</tr>
<tr>
<td><FormattedMessage id="gridExport" defaultMessage="Grid Export" /></td>
<td style={{ textAlign: 'right', fontWeight: 'bold' }}>{record.gridExport.toFixed(1)} kWh</td>
</tr>
<tr style={{ background: '#f0f7ff' }}>
<td><strong><FormattedMessage id="batteryCharged" defaultMessage="Battery Charged" /></strong></td>
<td style={{ textAlign: 'right', fontWeight: 'bold', color: '#2980b9' }}>{record.batteryCharged.toFixed(1)} kWh</td>
</tr>
<tr style={{ background: '#f0f7ff' }}>
<td><strong><FormattedMessage id="batteryDischarged" defaultMessage="Battery Discharged" /></strong></td>
<td style={{ textAlign: 'right', fontWeight: 'bold', color: '#e67e22' }}>{record.batteryDischarged.toFixed(1)} kWh</td>
</tr>
</tbody>
</Box>
</Paper>
</>
)}
</>
);
}
// ── Weekly Section (existing weekly report content) ────────────
function WeeklySection({ installationId, latestMonthlyPeriodEnd }: { installationId: number; latestMonthlyPeriodEnd: string | null }) {
@ -1210,7 +1054,7 @@ function InsightBox({ text, bullets }: { text: string; bullets: string[] }) {
);
}
function SavingsCards({ intl, energySaved, savingsCHF, selfSufficiency, batteryEfficiency, hint }: {
export function SavingsCards({ intl, energySaved, savingsCHF, selfSufficiency, batteryEfficiency, hint }: {
intl: any;
energySaved: number;
savingsCHF: number;

View File

@ -145,6 +145,13 @@
"dailySummary": "Tagesübersicht",
"selectDate": "Datum wählen",
"noDataForDate": "Keine Daten für das gewählte Datum verfügbar.",
"noHourlyData": "Stündliche Daten für diesen Tag nicht verfügbar.",
"dataUpTo": "Daten bis {date}",
"intradayChart": "Tagesverlauf Energiefluss",
"batteryPower": "Batterieleistung",
"batterySoCLabel": "Batterie SoC",
"powerKw": "Leistung (kW)",
"socPercent": "SoC (%)",
"batteryActivity": "Batterieaktivität",
"batteryCharged": "Batterie geladen",
"batteryDischarged": "Batterie entladen",

View File

@ -127,6 +127,13 @@
"dailySummary": "Daily Summary",
"selectDate": "Select Date",
"noDataForDate": "No data available for the selected date.",
"noHourlyData": "Hourly data not available for this day.",
"dataUpTo": "Data up to {date}",
"intradayChart": "Intraday Power Flow",
"batteryPower": "Battery Power",
"batterySoCLabel": "Battery SoC",
"powerKw": "Power (kW)",
"socPercent": "SoC (%)",
"batteryActivity": "Battery Activity",
"batteryCharged": "Battery Charged",
"batteryDischarged": "Battery Discharged",

View File

@ -139,6 +139,13 @@
"dailySummary": "Résumé du jour",
"selectDate": "Sélectionner la date",
"noDataForDate": "Aucune donnée disponible pour la date sélectionnée.",
"noHourlyData": "Données horaires non disponibles pour ce jour.",
"dataUpTo": "Données jusqu'au {date}",
"intradayChart": "Flux d'énergie journalier",
"batteryPower": "Puissance batterie",
"batterySoCLabel": "SoC batterie",
"powerKw": "Puissance (kW)",
"socPercent": "SoC (%)",
"batteryActivity": "Activité de la batterie",
"batteryCharged": "Batterie chargée",
"batteryDischarged": "Batterie déchargée",

View File

@ -150,6 +150,13 @@
"dailySummary": "Riepilogo del giorno",
"selectDate": "Seleziona data",
"noDataForDate": "Nessun dato disponibile per la data selezionata.",
"noHourlyData": "Dati orari non disponibili per questo giorno.",
"dataUpTo": "Dati fino al {date}",
"intradayChart": "Flusso energetico giornaliero",
"batteryPower": "Potenza batteria",
"batterySoCLabel": "SoC batteria",
"powerKw": "Potenza (kW)",
"socPercent": "SoC (%)",
"batteryActivity": "Attività della batteria",
"batteryCharged": "Batteria caricata",
"batteryDischarged": "Batteria scaricata",