468 lines
14 KiB
TypeScript
468 lines
14 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';
|
|
import { buildReportSummary, formatSummaryValue } from './reportSummary.ts';
|
|
|
|
const COLORS = {
|
|
headerFill: 'FFF8FAFC',
|
|
headerFont: 'FF475569',
|
|
accentFill: 'FF6D28D0',
|
|
accentDeep: 'FF5B21B6',
|
|
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`;
|
|
};
|
|
|
|
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 styleHighlightKpiRow(row: ExcelJS.Row, colCount: number): void {
|
|
row.height = 28;
|
|
const highlightBorder: Partial<ExcelJS.Borders> = {
|
|
top: { style: 'medium', color: { argb: COLORS.accentDeep } },
|
|
left: { style: 'medium', color: { argb: COLORS.accentDeep } },
|
|
bottom: { style: 'medium', color: { argb: COLORS.accentDeep } },
|
|
right: { style: 'medium', color: { argb: COLORS.accentDeep } },
|
|
};
|
|
for (let c = 1; c <= colCount; c += 1) {
|
|
const cell = row.getCell(c);
|
|
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: COLORS.accentFill } };
|
|
cell.font = {
|
|
bold: true,
|
|
size: c === 2 ? 14 : 11,
|
|
color: { argb: COLORS.accentFont },
|
|
};
|
|
cell.alignment = {
|
|
vertical: 'middle',
|
|
horizontal: c === 2 ? 'center' : 'left',
|
|
};
|
|
cell.border = highlightBorder;
|
|
}
|
|
}
|
|
|
|
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(
|
|
'Berat DOC',
|
|
payload.docWeight == null || !Number.isFinite(payload.docWeight)
|
|
? '—'
|
|
: `${formatNumber(payload.docWeight, 1, 0, true)} gram`
|
|
);
|
|
addMeta('Dicetak', printedDate);
|
|
|
|
sheet.addRow([]);
|
|
const kpiTitle = sheet.addRow(['Ringkasan KPI']);
|
|
kpiTitle.getCell(1).font = {
|
|
bold: true,
|
|
size: 12,
|
|
color: { argb: COLORS.accentFill },
|
|
};
|
|
|
|
sheet.getColumn(3).width = 12;
|
|
const kpiHeader = sheet.addRow(['Metrik', 'Nilai', 'Satuan']);
|
|
styleHeaderRow(kpiHeader, 3);
|
|
|
|
const summary = buildReportSummary(payload);
|
|
let insertedHighlightGap = false;
|
|
summary.metrics.forEach((metric, index) => {
|
|
const isHighlight = metric.key === 'fcr' || metric.key === 'eef';
|
|
if (isHighlight && !insertedHighlightGap) {
|
|
sheet.addRow([]);
|
|
insertedHighlightGap = true;
|
|
}
|
|
const row = sheet.addRow([
|
|
metric.label,
|
|
formatSummaryValue(metric),
|
|
metric.unit || '—',
|
|
]);
|
|
if (isHighlight) {
|
|
styleHighlightKpiRow(row, 3);
|
|
} else {
|
|
styleDataRow(row, 3, index % 2 === 1);
|
|
row.getCell(1).alignment = { vertical: 'middle', horizontal: 'left' };
|
|
row.getCell(2).alignment = { vertical: 'middle', horizontal: 'right' };
|
|
row.getCell(3).alignment = { vertical: 'middle', horizontal: 'left' };
|
|
}
|
|
});
|
|
|
|
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);
|
|
}
|