import ExcelJS from 'exceljs'; import type { DashboardReportPayload } from '../types/dashboardReport.ts'; import { countingTotals, feedInTotals, feedOutTotals, feedUseTotals, } from './reportTotals.ts'; import { formatDateId, formatDateRange, formatNumber, formatRatio } from './format.ts'; const COLORS = { headerFill: 'FFF8FAFC', headerFont: 'FF475569', accentFill: 'FF6D28D0', accentFont: 'FFFFFFFF', totalFill: 'FFF1F5F9', zebraFill: 'FFFAFAFA', border: 'FFE2E8F0', titleFont: 'FF111827', metaFont: 'FF374151', }; const thinBorder: Partial = { top: { style: 'thin', color: { argb: COLORS.border } }, left: { style: 'thin', color: { argb: COLORS.border } }, bottom: { style: 'thin', color: { argb: COLORS.border } }, right: { style: 'thin', color: { argb: COLORS.border } }, }; type CellValue = string | number | null; const isPresent = (value: number | null | undefined): value is number => typeof value === 'number' && Number.isFinite(value); const dash = (value: number | null | undefined): CellValue => isPresent(value) ? value : '-'; const na = (value: number | null | undefined): CellValue => isPresent(value) ? value : 'N/A'; const pct = (value: number | null | undefined): CellValue => isPresent(value) ? `${value.toFixed(1)}%` : 'N/A'; const qty = (value: number | null | undefined, maxDecimals = 0, suffix = ''): CellValue => { if (!isPresent(value)) return 'N/A'; return `${formatNumber(value, maxDecimals, 0, true)}${suffix}`; }; const ratio = (value: number | null | undefined): string => isPresent(value) ? formatRatio(value, 3) : 'N/A'; const harvestKg = (grams: number | null | undefined): CellValue => { if (!isPresent(grams)) return 'N/A'; return `${formatNumber(grams / 1000, 1)} kg`; }; const latestValidRatio = ( rows: Array<{ iotFcr?: number | null; iotEef?: number | null }>, key: 'iotFcr' | 'iotEef' ): number | null => { for (let i = rows.length - 1; i >= 0; i -= 1) { const value = rows[i]?.[key]; if (isPresent(value)) return value; } return null; }; function styleHeaderRow(row: ExcelJS.Row, colCount: number): void { row.height = 22; for (let c = 1; c <= colCount; c += 1) { const cell = row.getCell(c); cell.font = { bold: true, color: { argb: COLORS.headerFont }, size: 11 }; cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: COLORS.headerFill } }; cell.alignment = { vertical: 'middle', horizontal: 'center', wrapText: true }; cell.border = thinBorder; } } function styleDataRow(row: ExcelJS.Row, colCount: number, stripe: boolean): void { for (let c = 1; c <= colCount; c += 1) { const cell = row.getCell(c); cell.font = { size: 11, color: { argb: COLORS.titleFont } }; cell.alignment = { vertical: 'middle', horizontal: c === 1 ? 'left' : 'right', }; if (stripe) { cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: COLORS.zebraFill } }; } cell.border = thinBorder; } } function styleTotalRow(row: ExcelJS.Row, colCount: number): void { row.height = 22; for (let c = 1; c <= colCount; c += 1) { const cell = row.getCell(c); cell.font = { bold: true, size: 11, color: { argb: COLORS.titleFont } }; cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: COLORS.totalFill } }; cell.alignment = { vertical: 'middle', horizontal: c === 1 ? 'left' : 'right', }; cell.border = { ...thinBorder, top: { style: 'medium', color: { argb: COLORS.border } }, }; } } function autoFitColumns(sheet: ExcelJS.Worksheet, min = 10, max = 42): void { sheet.columns.forEach((column) => { let maxLen = min; column.eachCell?.({ includeEmpty: false }, (cell) => { const text = cell.value instanceof Date ? cell.value.toLocaleDateString('id-ID') : String(cell.value ?? ''); maxLen = Math.max(maxLen, text.length); }); column.width = Math.min(max, Math.max(min, maxLen + 2)); }); } function addSheetTitle(sheet: ExcelJS.Worksheet, title: string, colCount: number): void { sheet.mergeCells(1, 1, 1, colCount); const cell = sheet.getCell(1, 1); cell.value = title; cell.font = { bold: true, size: 13, color: { argb: COLORS.titleFont } }; cell.alignment = { vertical: 'middle', horizontal: 'left' }; cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: COLORS.headerFill } }; cell.border = { ...thinBorder, bottom: { style: 'medium', color: { argb: COLORS.accentFill } }, }; sheet.getRow(1).height = 26; } function addTable( sheet: ExcelJS.Worksheet, title: string, headers: string[], rows: CellValue[][], totalRow?: CellValue[] | null ): void { const colCount = headers.length; addSheetTitle(sheet, title, colCount); const headerRow = sheet.addRow(headers); styleHeaderRow(headerRow, colCount); rows.forEach((values, index) => { const row = sheet.addRow(values); styleDataRow(row, colCount, index % 2 === 1); }); if (totalRow) { const row = sheet.addRow(totalRow); styleTotalRow(row, colCount); } sheet.views = [{ state: 'frozen', ySplit: 2, activeCell: 'A3' }]; autoFitColumns(sheet); } function addSummarySheet(workbook: ExcelJS.Workbook, payload: DashboardReportPayload): void { const isEndCycle = payload.reportType === 'end_cycle'; const title = isEndCycle ? 'Laporan Akhir Siklus' : 'Laporan Harian'; const lastDate = payload.counting[payload.counting.length - 1]?.date ?? payload.cycleStart; const printedDate = new Date(payload.generatedAt).toLocaleDateString('id-ID', { day: 'numeric', month: 'long', year: 'numeric', }); const sheet = workbook.addWorksheet('Ringkasan'); sheet.getColumn(1).width = 22; sheet.getColumn(2).width = 48; const addMeta = (label: string, value: string | number, bold = false): void => { const row = sheet.addRow([label, value]); row.getCell(1).font = { bold: true, size: 11, color: { argb: COLORS.metaFont } }; row.getCell(2).font = { bold, size: bold ? 14 : 11, color: { argb: COLORS.titleFont }, }; row.height = bold ? 24 : 20; }; sheet.getCell('A1').value = 'Dashboard CPSP'; sheet.getCell('A1').font = { bold: true, size: 10, color: { argb: COLORS.accentFill } }; sheet.addRow([]); addMeta('Judul', title, true); addMeta('Lokasi', `${payload.area} — ${payload.kandangName}`); addMeta( 'Periode', `Hari ke-0 s/d ke-${payload.upToDay} (${formatDateId(payload.cycleStart)} – ${formatDateId(lastDate)})` ); addMeta('Siklus', formatDateRange(payload.cycleStart, payload.cycleEnd)); addMeta('Dicetak', printedDate); if (isEndCycle) { const finalFcr = latestValidRatio(payload.fcr, 'iotFcr'); const finalEef = latestValidRatio(payload.eef, 'iotEef'); sheet.addRow([]); addMeta('FCR akhir', ratio(finalFcr), true); addMeta('EEF akhir', ratio(finalEef), true); } sheet.addRow([]); const navHeader = sheet.addRow(['Sheet', 'Isi']); styleHeaderRow(navHeader, 2); const navRows: Array<[string, string]> = [ ['Hitung Ayam', 'Stok, mortalitas, panen, dan akurasi hitung harian'], ['Berat Ayam', 'Rata-rata berat manual vs IoT dan akurasi'], ['Karung Masuk', 'Pakan masuk manual vs IoT'], ['Karung Dituang', 'Pakan dituang manual vs IoT'], ['Karung Keluar', 'Pakan keluar manual vs IoT'], ]; if (isEndCycle) { navRows.push(['FCR', 'Data pendukung FCR per umur']); navRows.push(['EEF', 'Data pendukung EEF per umur']); } navRows.forEach(([name, desc], index) => { const row = sheet.addRow([name, desc]); styleDataRow(row, 2, index % 2 === 1); }); } export async function downloadReportExcel(payload: DashboardReportPayload): Promise { const workbook = new ExcelJS.Workbook(); workbook.creator = 'Dashboard CPSP'; workbook.created = new Date(); addSummarySheet(workbook, payload); const countingTotal = countingTotals(payload.counting); addTable( workbook.addWorksheet('Hitung Ayam'), 'Hitung Ayam harian', [ 'Tanggal', 'Umur (hari)', 'Stok awal', 'Mortalitas', 'Panen', 'Berat panen', 'Stok akhir', 'IoT', 'Akurasi %', ], payload.counting.map((row) => [ formatDateId(row.date), row.day, na(row.countBefore), na(row.mortality), na(row.harvest), na(row.harvestWeight), na(row.countAfter), na(row.iotCount), pct(row.accuracy), ]), countingTotal ? [ countingTotal.label, '—', dash(countingTotal.countBefore), na(countingTotal.mortality), na(countingTotal.harvest), na(countingTotal.harvestWeight), dash(countingTotal.countAfter), dash(countingTotal.iotCount), countingTotal.accuracy == null ? '-' : pct(countingTotal.accuracy), ] : null ); addTable( workbook.addWorksheet('Berat Ayam'), 'Berat Ayam harian', [ 'Tanggal', 'Umur (hari)', 'Rata-rata manual', 'Rata-rata IoT', 'Keseragaman', 'Selisih', 'Akurasi %', ], payload.weight.map((row) => [ formatDateId(row.date), row.day, na(row.avgManual), na(row.avgIot), pct(row.uniformity), na(row.difference), pct(row.accuracy), ]) ); const feedInTotal = feedInTotals(payload.feedIn); addTable( workbook.addWorksheet('Karung Masuk'), 'Karung masuk', ['Tanggal', 'Manual masuk', 'IoT masuk', 'Akurasi %'], payload.feedIn.map((row) => [ formatDateId(row.date), na(row.manualIn), na(row.iotIn), pct(row.accuracy), ]), feedInTotal ? [ feedInTotal.label, na(feedInTotal.manualIn), na(feedInTotal.iotIn), pct(feedInTotal.accuracy), ] : null ); const feedUseTotal = feedUseTotals(payload.feedUse); addTable( workbook.addWorksheet('Karung Dituang'), 'Karung dituang', ['Tanggal', 'Umur (hari)', 'Manual dituang', 'IoT dituang', 'Akurasi %'], payload.feedUse.map((row) => [ formatDateId(row.date), row.day, na(row.manualUse), na(row.iotUse), pct(row.accuracy), ]), feedUseTotal ? [ feedUseTotal.label, '—', na(feedUseTotal.manualUse), na(feedUseTotal.iotUse), pct(feedUseTotal.accuracy), ] : null ); const feedOutTotal = feedOutTotals(payload.feedOut); addTable( workbook.addWorksheet('Karung Keluar'), 'Karung keluar', ['Tanggal', 'Manual keluar', 'IoT keluar', 'Akurasi %'], payload.feedOut.map((row) => [ formatDateId(row.date), na(row.manualOut), na(row.iotOut), pct(row.accuracy), ]), feedOutTotal ? [ feedOutTotal.label, na(feedOutTotal.manualOut), na(feedOutTotal.iotOut), pct(feedOutTotal.accuracy), ] : null ); if (payload.reportType === 'end_cycle') { addTable( workbook.addWorksheet('FCR'), 'Data Pendukung FCR', ['Umur', 'Total Pakan', 'Bobot IoT', 'Ayam Hidup', 'Total Bobot Panen', 'FCR'], payload.fcr.map((row) => [ `Hari ${row.day}`, qty(row.feedTotal, 0, ' karung'), qty(row.iotWeight, 2, row.iotWeight != null ? ' g' : ''), qty(row.chickenLife, 0, ' ekor'), harvestKg(row.harvestWeightTotal), ratio(row.iotFcr), ]) ); addTable( workbook.addWorksheet('EEF'), 'Data Pendukung EEF', [ 'Umur', 'Persentase Ayam Hidup', 'BW Rata-rata Panen', 'Umur Rata-rata Panen', 'FCR', 'EEF', ], payload.eef.map((row) => [ `Hari ${row.day}`, qty(row.chickenLifePercentage, 3, '%'), qty(row.iotAverageHarvestBw, 2, row.iotAverageHarvestBw != null ? ' g' : ''), qty(row.iotAverageDayWeight, 2), ratio(row.iotFcr), ratio(row.iotEef), ]) ); } const buffer = await workbook.xlsx.writeBuffer(); const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', }); const period = payload.reportType === 'end_cycle' ? 'Akhir-Siklus' : `Harian-Hari-${payload.upToDay}`; const url = URL.createObjectURL(blob); const anchor = document.createElement('a'); anchor.href = url; anchor.download = `Laporan-${period}-${payload.area}-${payload.kandangName}-${payload.cycleStart}_sd_${payload.cycleEnd}.xlsx`; anchor.click(); URL.revokeObjectURL(url); }