420 lines
12 KiB
TypeScript
420 lines
12 KiB
TypeScript
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<ExcelJS.Borders> = {
|
||
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<void> {
|
||
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);
|
||
}
|