daily tab design with hourly data and last week quick entry with self-efficiency on top
This commit is contained in:
parent
0ac22ecbe9
commit
6cf14e3483
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue