Merge branch 'report_pdf_generation'

This commit is contained in:
Yinyin Liu 2026-03-19 12:57:02 +01:00
commit 758ad30890
5 changed files with 281 additions and 24 deletions

View File

@ -1471,6 +1471,91 @@ public class Controller : ControllerBase
}
}
// ── Report HTML (for PDF download) ─────────────────────────────
[HttpGet(nameof(GetWeeklyReportHtml))]
public async Task<ActionResult> GetWeeklyReportHtml(Int64 installationId, Token authToken, String? language = null)
{
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();
var lang = language ?? user.Language ?? "en";
var report = await WeeklyReportService.GenerateReportAsync(installationId, installation.Name, lang);
var html = ReportEmailService.BuildHtmlEmail(report, lang);
return Content(html, "text/html");
}
[HttpGet(nameof(GetMonthlyReportHtml))]
public async Task<ActionResult> GetMonthlyReportHtml(Int64 installationId, Int32 year, Int32 month, Token authToken, String? language = null)
{
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();
var lang = language ?? user.Language ?? "en";
var report = Db.GetMonthlyReports(installationId).FirstOrDefault(r => r.Year == year && r.Month == month);
if (report == null) return BadRequest($"No monthly report found for {year}-{month:D2}.");
report.AiInsight = await ReportAggregationService.GetOrGenerateMonthlyInsightAsync(report, lang);
var s = ReportEmailService.GetAggregatedStrings(lang, "monthly");
var html = ReportEmailService.BuildAggregatedHtmlEmail(
report.PeriodStart, report.PeriodEnd, installation.Name,
report.TotalPvProduction, report.TotalConsumption, report.TotalGridImport, report.TotalGridExport,
report.TotalBatteryCharged, report.TotalBatteryDischarged, report.TotalEnergySaved, report.TotalSavingsCHF,
report.SelfSufficiencyPercent, report.BatteryEfficiencyPercent, report.AiInsight,
$"{report.WeekCount} {s.CountLabel}", s);
return Content(html, "text/html");
}
[HttpGet(nameof(GetYearlyReportHtml))]
public async Task<ActionResult> GetYearlyReportHtml(Int64 installationId, Int32 year, Token authToken, String? language = null)
{
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();
var lang = language ?? user.Language ?? "en";
var report = Db.GetYearlyReports(installationId).FirstOrDefault(r => r.Year == year);
if (report == null) return BadRequest($"No yearly report found for {year}.");
report.AiInsight = await ReportAggregationService.GetOrGenerateYearlyInsightAsync(report, lang);
var s = ReportEmailService.GetAggregatedStrings(lang, "yearly");
var html = ReportEmailService.BuildAggregatedHtmlEmail(
report.PeriodStart, report.PeriodEnd, installation.Name,
report.TotalPvProduction, report.TotalConsumption, report.TotalGridImport, report.TotalGridExport,
report.TotalBatteryCharged, report.TotalBatteryDischarged, report.TotalEnergySaved, report.TotalSavingsCHF,
report.SelfSufficiencyPercent, report.BatteryEfficiencyPercent, report.AiInsight,
$"{report.MonthCount} {s.CountLabel}", s);
return Content(html, "text/html");
}
[HttpGet(nameof(GetDailyReportHtml))]
public ActionResult GetDailyReportHtml(Int64 installationId, String date, Token authToken, String? language = null)
{
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(date, "yyyy-MM-dd", out var parsedDate))
return BadRequest("date must be in yyyy-MM-dd format.");
var records = Db.GetDailyRecords(installationId, parsedDate, parsedDate);
if (records.Count == 0) return BadRequest($"No daily record found for {date}.");
var lang = language ?? user.Language ?? "en";
var html = ReportEmailService.BuildDailyHtmlEmail(records[0], installation.Name, lang);
return Content(html, "text/html");
}
[HttpGet(nameof(GetWeeklyReportSummaries))]
public async Task<ActionResult<List<WeeklyReportSummary>>> GetWeeklyReportSummaries(
Int64 installationId, Int32 year, Int32 month, Token authToken, String? language = null)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.2 KiB

View File

@ -97,10 +97,12 @@ function getCurrentWeekDays(currentMonday: Date): Date[] {
export default function DailySection({
installationId,
onHasData
onHasData,
onPeriodChange
}: {
installationId: number;
onHasData?: (hasData: boolean) => void;
onPeriodChange?: (date: string) => void;
}) {
const intl = useIntl();
const currentMonday = useMemo(() => getCurrentMonday(), []);
@ -113,7 +115,11 @@ export default function DailySection({
const [allRecords, setAllRecords] = useState<DailyEnergyData[]>([]);
const [allHourlyRecords, setAllHourlyRecords] = useState<HourlyEnergyRecord[]>([]);
const [selectedDate, setSelectedDate] = useState(formatDateISO(yesterday));
const [selectedDate, setSelectedDate] = useState(() => {
const date = formatDateISO(yesterday);
onPeriodChange?.(date);
return date;
});
const [selectedDayRecord, setSelectedDayRecord] = useState<DailyEnergyData | null>(null);
const [hourlyRecords, setHourlyRecords] = useState<HourlyEnergyRecord[]>([]);
const [loadingWeek, setLoadingWeek] = useState(false);
@ -174,6 +180,7 @@ export default function DailySection({
const handleStripSelect = (date: string) => {
setSelectedDate(date);
setNoData(false);
onPeriodChange?.(date);
};
const dt = new Date(selectedDate + 'T00:00:00');

View File

@ -236,6 +236,8 @@ function WeeklyReport({ installationId }: WeeklyReportProps) {
const [regenerating, setRegenerating] = useState(false);
const [dailyHasData, setDailyHasData] = useState(false);
const [weeklyHasData, setWeeklyHasData] = useState(false);
const [downloadingPdf, setDownloadingPdf] = useState(false);
const [reportPeriod, setReportPeriod] = useState<{ start: string; end: string; year?: number; month?: number } | null>(null);
const weeklyRef = useRef<WeeklySectionHandle>(null);
const fetchReportData = () => {
@ -302,16 +304,56 @@ function WeeklyReport({ installationId }: WeeklyReportProps) {
return false;
})();
const handleDownloadPdf = async () => {
const reportType = tabs[safeTab]?.key ?? 'report';
let endpoint = '';
const params: Record<string, any> = { installationId, language: intl.locale };
switch (reportType) {
case 'daily':
endpoint = '/GetDailyReportHtml';
if (reportPeriod?.start) params.date = reportPeriod.start;
break;
case 'weekly':
endpoint = '/GetWeeklyReportHtml';
break;
case 'monthly':
endpoint = '/GetMonthlyReportHtml';
if (reportPeriod?.year) params.year = reportPeriod.year;
if (reportPeriod?.month) params.month = reportPeriod.month;
break;
case 'yearly':
endpoint = '/GetYearlyReportHtml';
if (reportPeriod?.year) params.year = reportPeriod.year;
break;
}
if (!endpoint) return;
setDownloadingPdf(true);
try {
const res = await axiosConfig.get(endpoint, { params, responseType: 'text' });
const printWindow = window.open('', '_blank');
if (!printWindow) return;
const dateRange = reportPeriod
? `${reportPeriod.start.replace(/-/g, '')}-${reportPeriod.end.replace(/-/g, '')}`
: new Date().toISOString().split('T')[0].replace(/-/g, '');
printWindow.document.write(res.data);
printWindow.document.close();
printWindow.document.title = `inesco-energy-${installationId}-${reportType}-${dateRange}`;
printWindow.onafterprint = () => printWindow.close();
setTimeout(() => printWindow.print(), 500);
} catch (err) {
console.error('PDF download failed', err);
} finally {
setDownloadingPdf(false);
}
};
return (
<Box sx={{ p: 2, width: '100%', maxWidth: 900, mx: 'auto' }} className="report-container">
<style>{`
@media print {
body * { visibility: hidden; }
.report-container, .report-container * { visibility: visible; }
.report-container { position: absolute; left: 0; top: 0; width: 100%; padding: 20px; }
.no-print { display: none !important; }
}
`}</style>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }} className="no-print">
<Tabs
value={safeTab}
@ -323,8 +365,9 @@ function WeeklyReport({ installationId }: WeeklyReportProps) {
{activeTabHasData && (
<Button
variant="outlined"
startIcon={<DownloadIcon />}
onClick={() => window.print()}
startIcon={downloadingPdf ? <CircularProgress size={16} /> : <DownloadIcon />}
onClick={handleDownloadPdf}
disabled={downloadingPdf}
sx={{ ml: 2, whiteSpace: 'nowrap' }}
>
<FormattedMessage id="downloadPdf" defaultMessage="Download PDF" />
@ -361,7 +404,7 @@ function WeeklyReport({ installationId }: WeeklyReportProps) {
</Box>
<Box sx={{ display: tabs[safeTab]?.key === 'daily' ? 'block' : 'none', minHeight: '50vh' }}>
<DailySection installationId={installationId} onHasData={setDailyHasData} />
<DailySection installationId={installationId} onHasData={setDailyHasData} onPeriodChange={(date: string) => setReportPeriod({ start: date, end: date })} />
</Box>
<Box sx={{ display: tabs[safeTab]?.key === 'weekly' ? 'block' : 'none', minHeight: '50vh' }}>
<WeeklySection
@ -373,6 +416,7 @@ function WeeklyReport({ installationId }: WeeklyReportProps) {
: null
}
onHasData={setWeeklyHasData}
onPeriodChange={(start, end) => setReportPeriod({ start, end })}
/>
</Box>
<Box sx={{ display: tabs[safeTab]?.key === 'monthly' ? 'block' : 'none', minHeight: '50vh' }}>
@ -384,6 +428,7 @@ function WeeklyReport({ installationId }: WeeklyReportProps) {
onGenerate={handleGenerateMonthly}
selectedIdx={selectedMonthlyIdx}
onSelectedIdxChange={setSelectedMonthlyIdx}
onPeriodChange={(r: MonthlyReport) => setReportPeriod({ start: r.periodStart, end: r.periodEnd, year: r.year, month: r.month })}
/>
</Box>
<Box sx={{ display: tabs[safeTab]?.key === 'yearly' ? 'block' : 'none', minHeight: '50vh' }}>
@ -395,6 +440,7 @@ function WeeklyReport({ installationId }: WeeklyReportProps) {
onGenerate={handleGenerateYearly}
selectedIdx={selectedYearlyIdx}
onSelectedIdxChange={setSelectedYearlyIdx}
onPeriodChange={(r: YearlyReport) => setReportPeriod({ start: r.periodStart, end: r.periodEnd, year: r.year })}
/>
</Box>
</Box>
@ -407,8 +453,8 @@ interface WeeklySectionHandle {
regenerate: () => void;
}
const WeeklySection = forwardRef<WeeklySectionHandle, { installationId: number; latestMonthlyPeriodEnd: string | null; onHasData?: (hasData: boolean) => void }>(
({ installationId, latestMonthlyPeriodEnd, onHasData }, ref) => {
const WeeklySection = forwardRef<WeeklySectionHandle, { installationId: number; latestMonthlyPeriodEnd: string | null; onHasData?: (hasData: boolean) => void; onPeriodChange?: (start: string, end: string) => void }>(
({ installationId, latestMonthlyPeriodEnd, onHasData, onPeriodChange }, ref) => {
const intl = useIntl();
const [report, setReport] = useState<WeeklyReportResponse | null>(null);
const [loading, setLoading] = useState(true);
@ -427,6 +473,7 @@ const WeeklySection = forwardRef<WeeklySectionHandle, { installationId: number;
});
setReport(res.data);
onHasData?.(true);
onPeriodChange?.(res.data.periodStart, res.data.periodEnd);
} catch (err: any) {
const msg =
err.response?.data ||
@ -811,7 +858,8 @@ function MonthlySection({
generating,
onGenerate,
selectedIdx,
onSelectedIdxChange
onSelectedIdxChange,
onPeriodChange
}: {
installationId: number;
reports: MonthlyReport[];
@ -820,6 +868,7 @@ function MonthlySection({
onGenerate: (year: number, month: number) => void;
selectedIdx: number;
onSelectedIdxChange: (idx: number) => void;
onPeriodChange?: (report: MonthlyReport) => void;
}) {
const intl = useIntl();
@ -871,6 +920,7 @@ function MonthlySection({
sendParamsFn={(r: MonthlyReport) => ({ installationId, year: r.year, month: r.month })}
controlledIdx={selectedIdx}
onIdxChange={onSelectedIdxChange}
onPeriodChange={onPeriodChange}
/>
) : pendingMonths.length === 0 ? (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', py: 6 }}>
@ -892,7 +942,8 @@ function YearlySection({
generating,
onGenerate,
selectedIdx,
onSelectedIdxChange
onSelectedIdxChange,
onPeriodChange
}: {
installationId: number;
reports: YearlyReport[];
@ -901,6 +952,7 @@ function YearlySection({
onGenerate: (year: number) => void;
selectedIdx: number;
onSelectedIdxChange: (idx: number) => void;
onPeriodChange?: (report: YearlyReport) => void;
}) {
const intl = useIntl();
@ -952,6 +1004,7 @@ function YearlySection({
sendParamsFn={(r: YearlyReport) => ({ installationId, year: r.year })}
controlledIdx={selectedIdx}
onIdxChange={onSelectedIdxChange}
onPeriodChange={onPeriodChange}
/>
) : pendingYears.length === 0 ? (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', py: 6 }}>
@ -975,7 +1028,8 @@ function AggregatedSection<T extends ReportSummary>({
sendEndpoint,
sendParamsFn,
controlledIdx,
onIdxChange
onIdxChange,
onPeriodChange
}: {
reports: T[];
type: 'monthly' | 'yearly';
@ -986,6 +1040,7 @@ function AggregatedSection<T extends ReportSummary>({
sendParamsFn: (r: T) => object;
controlledIdx?: number;
onIdxChange?: (idx: number) => void;
onPeriodChange?: (report: T) => void;
}) {
const intl = useIntl();
const [internalIdx, setInternalIdx] = useState(0);
@ -993,8 +1048,16 @@ function AggregatedSection<T extends ReportSummary>({
const handleIdxChange = (idx: number) => {
setInternalIdx(idx);
onIdxChange?.(idx);
if (reports[idx]) onPeriodChange?.(reports[idx]);
};
// Report initial period on mount
useEffect(() => {
if (reports.length > 0 && reports[selectedIdx]) {
onPeriodChange?.(reports[selectedIdx]);
}
}, [reports.length]);
if (reports.length === 0) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', py: 6 }}>