Merge branch 'report_pdf_generation'
This commit is contained in:
commit
758ad30890
|
|
@ -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 |
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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 }}>
|
||||
|
|
|
|||
Loading…
Reference in New Issue