244 lines
9.1 KiB
JavaScript
244 lines
9.1 KiB
JavaScript
import { COLOR_ID, COLOR_META, COLOR_METRIC, COLOR_SKU, excelNumber, metricsByAttribute } from './helpers';
|
|
|
|
function excelCol(index) {
|
|
let letter = '';
|
|
let value = index;
|
|
while (value > 0) {
|
|
value -= 1;
|
|
letter = String.fromCharCode(65 + (value % 26)) + letter;
|
|
value = Math.floor(value / 26);
|
|
}
|
|
return letter;
|
|
}
|
|
|
|
function colIndex(letters) {
|
|
return letters.split('').reduce((sum, char) => sum * 26 + (char.charCodeAt(0) - 64), 0);
|
|
}
|
|
|
|
function parseCell(ref) {
|
|
const match = /^([A-Z]+)(\d+)$/.exec(ref);
|
|
return { col: colIndex(match[1]), row: Number(match[2]) };
|
|
}
|
|
|
|
function parseRange(range) {
|
|
const [from, to = from] = range.split(':');
|
|
const start = parseCell(from);
|
|
const end = parseCell(to);
|
|
return { top: start.row, left: start.col, bottom: end.row, right: end.col };
|
|
}
|
|
|
|
// Laravel Excel (strict_null_comparison false) skips values loosely equal to null, so 0 and '' stay blank.
|
|
// PhpSpreadsheet's default binder stores numeric text as numbers, except values with a leading zero.
|
|
function cellValue(value) {
|
|
if (value === null || value === undefined || value === '' || value === 0 || value === false) return null;
|
|
if (typeof value !== 'string' || !/^[+-]?(\d+\.?\d*|\d*\.?\d+)([Ee][-+]?[0-2]?\d{1,3})?$/.test(value)) return value;
|
|
const digits = value.replace(/^[+-]+/, '');
|
|
if (digits.length > 1 && digits[0] === '0' && digits[1] !== '.') return value;
|
|
if (!value.includes('.') && !Number.isSafeInteger(Number(value))) return value;
|
|
return Number(value);
|
|
}
|
|
|
|
function plannerExport(table) {
|
|
const { brandGroups, skuColumns, rows } = table;
|
|
const row1 = ['Territory', 'KAM Emp Code', 'KAM Name'];
|
|
const row2 = ['', '', ''];
|
|
const merges = ['A1:A2', 'B1:B2', 'C1:C2'];
|
|
const fills = [['A1:C2', COLOR_ID]];
|
|
let col = 4;
|
|
brandGroups.forEach((group) => {
|
|
const skus = skuColumns[group.name] ?? [];
|
|
const span = 3 + skus.length * 3;
|
|
const start = excelCol(col);
|
|
const end = excelCol(col + span - 1);
|
|
merges.push(`${start}1:${end}1`);
|
|
fills.push([`${start}1:${end}1`, group.style]);
|
|
fills.push([`${start}2:${end}2`, COLOR_METRIC]);
|
|
row1.push(group.name, ...Array(span - 1).fill(''));
|
|
row2.push('POB Plan (Val)', 'POB Done (Val)', '% POB');
|
|
skus.forEach((sku) => row2.push(sku.planAttr, sku.doneAttr, sku.pctLabel));
|
|
col += span;
|
|
});
|
|
|
|
const data = [row1, row2];
|
|
rows.forEach((row) => {
|
|
const line = [row.territory, row.kamEmpCode, row.kamName];
|
|
brandGroups.forEach((group) => {
|
|
const brand = row.brands[group.name] ?? {};
|
|
line.push(excelNumber(brand.plan ?? 0), excelNumber(brand.done ?? 0), excelNumber(brand.pct ?? 0, true));
|
|
const planMap = metricsByAttribute(brand.planMetrics);
|
|
const doneMap = metricsByAttribute(brand.doneMetrics);
|
|
(skuColumns[group.name] ?? []).forEach((sku) => {
|
|
const planVal = planMap[sku.planAttr] ?? 0;
|
|
const doneVal = doneMap[sku.doneAttr] ?? 0;
|
|
const pct = Number(planVal) > 0 ? Math.round((Number(doneVal) / Number(planVal)) * 100 * 100) / 100 : 0;
|
|
line.push(excelNumber(planVal), excelNumber(doneVal), excelNumber(pct, true));
|
|
});
|
|
});
|
|
data.push(line);
|
|
});
|
|
return { data, merges, fills, headerRows: 2, freeze: 'D3' };
|
|
}
|
|
|
|
function kamExport(table) {
|
|
const { brandColumns, skuColumns, rows } = table;
|
|
const headers = ['Territory', 'KAM Emp Code', 'KAM Name', 'Total Brand POB Val'];
|
|
const fills = [['A1:C1', COLOR_ID], ['D1:D1', COLOR_METRIC]];
|
|
let col = 5;
|
|
brandColumns.forEach(({ key }) => {
|
|
headers.push(key);
|
|
fills.push([`${excelCol(col)}1`, COLOR_METRIC]);
|
|
col += 1;
|
|
(skuColumns[key] ?? []).forEach((attr) => {
|
|
headers.push(attr);
|
|
fills.push([`${excelCol(col)}1`, COLOR_SKU]);
|
|
col += 1;
|
|
});
|
|
});
|
|
|
|
const data = [headers];
|
|
rows.forEach((row) => {
|
|
const line = [row.territory, row.kamEmpCode, row.kamName, excelNumber(row.total ?? 0)];
|
|
brandColumns.forEach(({ key }) => {
|
|
line.push(excelNumber(row.values[key] ?? 0));
|
|
const metricMap = metricsByAttribute(row.metrics[key]);
|
|
(skuColumns[key] ?? []).forEach((attr) => line.push(excelNumber(metricMap[attr] ?? 0)));
|
|
});
|
|
data.push(line);
|
|
});
|
|
return { data, merges: [], fills, headerRows: 1, freeze: 'D2' };
|
|
}
|
|
|
|
function drBrandExport(table) {
|
|
const { brandColumns, rows } = table;
|
|
const headers = ['Dr Name', 'Dr UID', 'Dr SBU', 'Total Brand POB Val', ...brandColumns.map((col) => col.key)];
|
|
const fills = [['A1:A1', COLOR_ID], ['B1:C1', COLOR_META], [`D1:${excelCol(headers.length)}1`, COLOR_METRIC]];
|
|
const data = [headers];
|
|
rows.forEach((row) => {
|
|
data.push([
|
|
row.drName,
|
|
row.drUid,
|
|
row.drSbu,
|
|
excelNumber(row.total ?? 0),
|
|
...brandColumns.map((col) => excelNumber(row.values[col.key] ?? 0)),
|
|
]);
|
|
});
|
|
return { data, merges: [], fills, headerRows: 1, freeze: 'B2' };
|
|
}
|
|
|
|
function skuExport(table) {
|
|
const { skuGroups, rows } = table;
|
|
const row1 = ['Dr Name'];
|
|
const row2 = [''];
|
|
const merges = ['A1:A2'];
|
|
const fills = [['A1:A2', COLOR_ID]];
|
|
let col = 2;
|
|
skuGroups.forEach((group) => {
|
|
const span = Math.max(1, group.columns.length);
|
|
const start = excelCol(col);
|
|
const end = excelCol(col + span - 1);
|
|
merges.push(`${start}1:${end}1`);
|
|
fills.push([`${start}1:${end}1`, group.style ?? COLOR_METRIC]);
|
|
fills.push([`${start}2:${end}2`, COLOR_METRIC]);
|
|
row1.push(group.name, ...Array(span - 1).fill(''));
|
|
group.columns.forEach((sku) => row2.push(sku.key));
|
|
col += span;
|
|
});
|
|
|
|
const data = [row1, row2];
|
|
rows.forEach((row) => {
|
|
const line = [row.drName];
|
|
skuGroups.forEach((group) => group.columns.forEach((sku) => line.push(excelNumber(row.values[sku.key] ?? 0, true))));
|
|
data.push(line);
|
|
});
|
|
return { data, merges, fills, headerRows: 2, freeze: 'B3' };
|
|
}
|
|
|
|
const BUILDERS = {
|
|
planner: plannerExport,
|
|
kamwise: kamExport,
|
|
drbrandwise: drBrandExport,
|
|
skuwise: skuExport,
|
|
};
|
|
|
|
function eachInRange(sheet, range, visit) {
|
|
for (let row = range.top; row <= range.bottom; row += 1) {
|
|
for (let col = range.left; col <= range.right; col += 1) visit(sheet.getCell(row, col));
|
|
}
|
|
}
|
|
|
|
// Approximates Maatwebsite ShouldAutoSize; cells merged across columns are skipped like PhpSpreadsheet.
|
|
function autoSize(sheet, data, merges, colCount) {
|
|
const merged = new Set();
|
|
merges.map(parseRange).forEach((range) => {
|
|
if (range.right === range.left) return;
|
|
for (let row = range.top; row <= range.bottom; row += 1) {
|
|
for (let col = range.left; col <= range.right; col += 1) merged.add(`${row}:${col}`);
|
|
}
|
|
});
|
|
for (let col = 1; col <= colCount; col += 1) {
|
|
let longest = 0;
|
|
data.forEach((line, index) => {
|
|
if (merged.has(`${index + 1}:${col}`)) return;
|
|
const value = line[col - 1];
|
|
if (value === null || value === undefined || value === '') return;
|
|
const length = Math.max(...String(value).split('\n').map((part) => part.length));
|
|
if (length > longest) longest = length;
|
|
});
|
|
sheet.getColumn(col).width = Math.max(Math.ceil(longest * 1.1) + 2, 8);
|
|
}
|
|
}
|
|
|
|
function freezeView(ref) {
|
|
const { col, row } = parseCell(ref);
|
|
return [{ state: 'frozen', xSplit: col - 1, ySplit: row - 1 }];
|
|
}
|
|
|
|
export async function exportIvaReport(tab, table, filename) {
|
|
const { data, merges, fills, headerRows, freeze } = BUILDERS[tab](table);
|
|
const { default: ExcelJS } = await import('exceljs');
|
|
const workbook = new ExcelJS.Workbook();
|
|
const sheet = workbook.addWorksheet('Worksheet');
|
|
const values = data.map((line) => line.map(cellValue));
|
|
values.forEach((line) => sheet.addRow(line));
|
|
|
|
const rowCount = Math.max(1, data.length);
|
|
const colCount = Math.max(1, ...data.map((line) => line.length));
|
|
autoSize(sheet, values, merges, colCount);
|
|
merges.forEach((range) => sheet.mergeCells(range));
|
|
|
|
const thin = { style: 'thin', color: { argb: 'FFB0B0B0' } };
|
|
eachInRange(sheet, { top: 1, left: 1, bottom: rowCount, right: colCount }, (cell) => {
|
|
cell.alignment = { horizontal: 'center', vertical: 'middle', wrapText: true };
|
|
cell.border = { top: thin, left: thin, bottom: thin, right: thin };
|
|
cell.font = { name: 'Calibri', size: 11 };
|
|
});
|
|
|
|
if (headerRows > 0) {
|
|
eachInRange(sheet, { top: 1, left: 1, bottom: headerRows, right: colCount }, (cell) => {
|
|
cell.font = { ...cell.font, bold: true };
|
|
});
|
|
sheet.getRow(1).height = 22;
|
|
if (headerRows > 1) sheet.getRow(2).height = 30;
|
|
}
|
|
|
|
fills.forEach(([range, color]) => {
|
|
eachInRange(sheet, parseRange(range), (cell) => {
|
|
cell.font = { ...cell.font, bold: true, color: { argb: `FF${color.fg ?? '000000'}` } };
|
|
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: `FF${color.bg ?? 'FFFFFF'}` } };
|
|
cell.alignment = { horizontal: 'center', vertical: 'middle', wrapText: true };
|
|
});
|
|
});
|
|
|
|
sheet.views = freezeView(freeze);
|
|
|
|
const buffer = await workbook.xlsx.writeBuffer();
|
|
const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
|
|
const anchor = document.createElement('a');
|
|
anchor.href = URL.createObjectURL(blob);
|
|
anchor.download = filename;
|
|
document.body.appendChild(anchor);
|
|
anchor.click();
|
|
document.body.removeChild(anchor);
|
|
URL.revokeObjectURL(anchor.href);
|
|
}
|