375 lines
14 KiB
TypeScript

import React from 'react';
import { useTranslation } from 'react-i18next';
import { FaExternalLinkAlt } from 'react-icons/fa';
import { DEFAULT_REPORT_HOSPITAL, DEFAULT_CLUSTER_NAME, DEFAULT_HOSPITAL_LOGO, DEFAULT_CLUSTER_LOGO } from '../config/defaults';
// ── Props ─────────────────────────────────────────────────────────────────────
interface PpmKpiReportCardProps {
ppmKpiChartData: any;
dashFromDate: string;
/** Pre-rendered chart node from ModernDashboard (which owns GroupedBarChart) */
chartElement: React.ReactNode;
onOpenDetail: (exportExcelFn: () => void) => void;
}
// ── Component ─────────────────────────────────────────────────────────────────
const PpmKpiReportCard: React.FC<PpmKpiReportCardProps> = ({
ppmKpiChartData,
dashFromDate,
chartElement,
onOpenDetail,
}) => {
const { t } = useTranslation();
const handleExportExcel = async () => {
if (!ppmKpiChartData?.labels?.length) return;
const MONTH_NAMES = [
'JANUARY','FEBRUARY','MARCH','APRIL','MAY','JUNE',
'JULY','AUGUST','SEPTEMBER','OCTOBER','NOVEMBER','DECEMBER',
];
const now = dashFromDate ? new Date(dashFromDate + 'T00:00:00') : new Date();
const monthLabel = `${MONTH_NAMES[now.getMonth()]}-${now.getFullYear()}`;
const labels: string[] = ppmKpiChartData.labels;
const datasets: any[] = ppmKpiChartData.datasets || [];
const getVal = (fieldName: string, idx: number): number => {
const ds = datasets.find((d: any) => d.name === fieldName);
return Number(ds?.values?.[idx]) || 0;
};
// ── Draw chart ────────────────────────────────────────────────────────────
const chartBase64 = await new Promise<string>((resolve) => {
const canvas = document.createElement('canvas');
canvas.width = 900;
canvas.height = 420;
const ctx = canvas.getContext('2d')!;
ctx.fillStyle = '#FCE8D8';
ctx.fillRect(0, 0, canvas.width, canvas.height);
const DEPT_COLORS = [
'#4472C4','#ED7D31','#A9D18E','#7030A0',
'#FF0000','#00B0F0','#92D050','#FFC000',
];
const xGroups = ['Total WOs', 'Pending', 'Completed'];
const deptData = labels.map((lbl: string, i: number) => ({
label: (lbl || '').toUpperCase(),
color: DEPT_COLORS[i % DEPT_COLORS.length],
values: xGroups.map(g => getVal(g, i)),
}));
const paddingLeft = 60;
const paddingRight = 20;
const paddingTop = 50;
const paddingBottom = 90;
const chartW = canvas.width - paddingLeft - paddingRight;
const chartH = canvas.height - paddingTop - paddingBottom;
const allVals = deptData.flatMap(d => d.values);
const maxVal = Math.max(...allVals, 1);
const numGroups = xGroups.length;
const groupWidth = chartW / numGroups;
const numDepts = deptData.length;
const barWidth = Math.min((groupWidth * 0.8) / numDepts, 30);
const groupPad = (groupWidth - barWidth * numDepts) / 2;
ctx.strokeStyle = '#D0D0D0';
ctx.lineWidth = 1;
for (let i = 0; i <= 5; i++) {
const y = paddingTop + chartH - (i / 5) * chartH;
ctx.beginPath();
ctx.moveTo(paddingLeft, y);
ctx.lineTo(paddingLeft + chartW, y);
ctx.stroke();
ctx.fillStyle = '#555555';
ctx.font = '11px Arial';
ctx.textAlign = 'right';
ctx.fillText(Math.round((maxVal * i) / 5).toString(), paddingLeft - 6, y + 4);
}
xGroups.forEach((group, grpIdx) => {
deptData.forEach((dept, deptIdx) => {
const val = dept.values[grpIdx];
const barH = maxVal > 0 ? (val / maxVal) * chartH : 0;
const x = paddingLeft + grpIdx * groupWidth + groupPad + deptIdx * barWidth;
const y = paddingTop + chartH - barH;
ctx.fillStyle = dept.color;
ctx.fillRect(x, y, barWidth - 1, barH);
if (val > 0) {
ctx.fillStyle = '#222222';
ctx.font = '8px Arial';
ctx.textAlign = 'center';
ctx.fillText(val.toString(), x + (barWidth - 1) / 2, y - 3);
}
});
});
ctx.fillStyle = '#333333';
ctx.font = '11px Arial';
ctx.textAlign = 'center';
xGroups.forEach((group, i) => {
const x = paddingLeft + i * groupWidth + groupWidth / 2;
const y = paddingTop + chartH + 16;
ctx.fillText(group, x, y);
});
ctx.strokeStyle = '#999999';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(paddingLeft, paddingTop + chartH);
ctx.lineTo(paddingLeft + chartW, paddingTop + chartH);
ctx.stroke();
const legendY = paddingTop + chartH + 36;
const itemW = 110;
const perRow = Math.floor(chartW / itemW);
deptData.forEach((dept, i) => {
const row = Math.floor(i / perRow);
const col = i % perRow;
const lx = paddingLeft + col * itemW;
const ly = legendY + row * 16;
ctx.fillStyle = dept.color;
ctx.fillRect(lx, ly - 9, 12, 12);
ctx.fillStyle = '#333333';
ctx.font = '10px Arial';
ctx.textAlign = 'left';
const short = dept.label.length > 10 ? dept.label.substring(0, 9) + '…' : dept.label;
ctx.fillText(short, lx + 16, ly);
});
ctx.fillStyle = '#7030A0';
ctx.font = 'bold 14px Arial';
ctx.textAlign = 'center';
ctx.fillText(`PPM FOR ${monthLabel}`, canvas.width / 2, 28);
resolve(canvas.toDataURL('image/png').split(',')[1]);
});
// ── Load logos ────────────────────────────────────────────────────────────
const loadLogo = async (path: string): Promise<string | null> => {
try {
const baseUrl = import.meta.env.BASE_URL || '/';
const url = `${baseUrl}${baseUrl.endsWith('/') ? '' : '/'}${path}`;
const res = await fetch(url);
if (!res.ok) return null;
const buf = await res.arrayBuffer();
return btoa(String.fromCharCode(...new Uint8Array(buf)));
} catch { return null; }
};
const [logoLeft, logoRight] = await Promise.all([
loadLogo(DEFAULT_HOSPITAL_LOGO),
loadLogo(DEFAULT_CLUSTER_LOGO),
]);
// ── Build workbook ────────────────────────────────────────────────────────
const ExcelJS = (await import('exceljs')).default;
const wb = new ExcelJS.Workbook();
const ws = wb.addWorksheet('PPM KPI Report');
ws.columns = [
{ width: 20 }, { width: 12 }, { width: 12 },
{ width: 12 }, { width: 14 },
];
const mkFill = (hex: string): ExcelJS.Fill => ({
type: 'pattern', pattern: 'solid',
fgColor: { argb: 'FF' + hex.replace('#', '') },
});
const mkFont = (bold = false, size = 10, color = '000000'): Partial<ExcelJS.Font> => ({
name: 'Arial', bold, size, color: { argb: 'FF' + color },
});
const mkBorder = (style: ExcelJS.BorderStyle = 'thin'): Partial<ExcelJS.Borders> => ({
top: { style, color: { argb: 'FF000000' } },
bottom: { style, color: { argb: 'FF000000' } },
left: { style, color: { argb: 'FF000000' } },
right: { style, color: { argb: 'FF000000' } },
});
const mkAlign = (
h: ExcelJS.Alignment['horizontal'] = 'center', wrap = true
): Partial<ExcelJS.Alignment> => ({ horizontal: h, vertical: 'middle', wrapText: wrap });
const styleCell = (
cell: ExcelJS.Cell,
opts: {
bold?: boolean; size?: number; color?: string; bg?: string;
h?: ExcelJS.Alignment['horizontal']; wrap?: boolean;
brd?: boolean; brdStyle?: ExcelJS.BorderStyle;
}
) => {
cell.font = mkFont(opts.bold, opts.size ?? 10, opts.color ?? '000000');
cell.alignment = mkAlign(opts.h ?? 'center', opts.wrap ?? true);
if (opts.bg) cell.fill = mkFill(opts.bg);
if (opts.brd !== false) cell.border = mkBorder(opts.brdStyle ?? 'thin');
};
const TITLE_BG = '70AD47';
const HDR_BG = 'F2F2F2';
const ROW_YELLOW = 'FFFFC7';
const RATE_BG = 'D9B3FF';
const TOTAL_BG = 'D9B3FF';
ws.getRow(1).height = 30;
ws.getRow(2).height = 20;
ws.getRow(3).height = 16;
ws.mergeCells('A1:B3');
ws.mergeCells('C1:C3');
ws.mergeCells('D1:E3');
if (logoLeft) {
const id = wb.addImage({ base64: logoLeft, extension: 'png' });
ws.addImage(id, { tl: { col: 0, row: 0 }, br: { col: 2, row: 3 }, editAs: 'oneCell' });
} else {
const c = ws.getCell('A1');
c.value = DEFAULT_REPORT_HOSPITAL;
styleCell(c, { bold: true, size: 10, h: 'center', brd: false });
}
if (logoRight) {
const id = wb.addImage({ base64: logoRight, extension: 'png' });
ws.addImage(id, { tl: { col: 3, row: 0 }, br: { col: 5, row: 3 }, editAs: 'oneCell' });
} else {
const c = ws.getCell('D1');
c.value = DEFAULT_CLUSTER_NAME;
styleCell(c, { bold: true, size: 10, h: 'center', brd: false });
}
ws.getRow(4).height = 6;
ws.mergeCells('A5:E5');
const titleCell = ws.getCell('A5');
titleCell.value = `PPM JOB ORDERS KPI REPORT FOR THE MONTH OF ${monthLabel}`;
styleCell(titleCell, { bold: true, size: 11, color: 'FFFFFF', bg: TITLE_BG, h: 'center' });
ws.getRow(5).height = 24;
ws.getRow(6).height = 6;
const HDR_LABELS = ['DEPARTMENT', 'Totl Wos', 'PENDING', 'COMPLETED', 'Completion\nRate'];
HDR_LABELS.forEach((lbl, i) => {
const cell = ws.getRow(7).getCell(i + 1);
cell.value = lbl;
if (i === 4) {
cell.fill = mkFill(RATE_BG);
cell.font = mkFont(true, 10, '000000');
cell.alignment = mkAlign('center', true);
cell.border = mkBorder();
} else {
styleCell(cell, { bold: true, size: 10, bg: HDR_BG, h: 'center' });
}
});
ws.getRow(7).height = 28;
const DATA_START = 8;
labels.forEach((label: string, idx: number) => {
const rowNum = DATA_START + idx;
const altRow = idx % 2 === 0;
const bg = altRow ? ROW_YELLOW : 'FFFFFF';
const row = ws.getRow(rowNum);
row.height = 18;
const totalWOs_ = getVal('Total WOs', idx);
const completed_ = getVal('Completed', idx);
const pending_ = getVal('Pending', idx);
const rate_ = totalWOs_ > 0
? parseFloat(((completed_ / totalWOs_) * 100).toFixed(0))
: 0;
const rowData = [
(label || '').toUpperCase(),
totalWOs_,
pending_,
completed_,
rate_ > 0 ? `${rate_}%` : '0%',
];
rowData.forEach((val, colIdx) => {
const cell = row.getCell(colIdx + 1);
cell.value = val;
const cellBg = colIdx === 4 ? RATE_BG : bg;
styleCell(cell, { bold: colIdx === 4, size: 10, bg: cellBg, h: 'center' });
});
});
const totalRowNum = DATA_START + labels.length;
const totalRow = ws.getRow(totalRowNum);
totalRow.height = 20;
const grandTotal = labels.reduce((s: number, _: string, i: number) => s + getVal('Total WOs', i), 0);
const grandComp = labels.reduce((s: number, _: string, i: number) => s + getVal('Completed', i), 0);
const grandPend = labels.reduce((s: number, _: string, i: number) => s + getVal('Pending', i), 0);
const grandRate = grandTotal > 0 ? `${((grandComp / grandTotal) * 100).toFixed(0)}%` : '0%';
['TOTAL', grandTotal, grandPend, grandComp, grandRate].forEach((val, colIdx) => {
const cell = totalRow.getCell(colIdx + 1);
cell.value = val;
styleCell(cell, { bold: true, size: 10, bg: TOTAL_BG, h: 'center', brdStyle: 'medium' });
});
const sepRow = totalRowNum + 1;
ws.getRow(sepRow).height = 8;
const chartTitleRow = sepRow + 1;
ws.mergeCells(`A${chartTitleRow}:E${chartTitleRow}`);
const ctCell = ws.getCell(`A${chartTitleRow}`);
ctCell.value = `PPM FOR ${monthLabel}`;
styleCell(ctCell, { bold: true, size: 12, color: '7030A0', bg: 'F8E4FF', h: 'center', brd: false });
ws.getRow(chartTitleRow).height = 22;
const imageId = wb.addImage({ base64: chartBase64, extension: 'png' });
ws.addImage(imageId, {
tl: { col: 0, row: chartTitleRow },
br: { col: 5, row: chartTitleRow + 22 },
editAs: 'oneCell',
});
ws.views = [{ state: 'frozen', xSplit: 1, ySplit: 7 }];
ws.pageSetup = { orientation: 'landscape', fitToPage: true, fitToWidth: 1 };
const buffer = await wb.xlsx.writeBuffer();
const blob = new Blob([buffer], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
});
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `PPM_KPI_Report_${monthLabel}.xlsx`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
return (
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm hover:shadow-md transition-all p-4 border border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-gray-800 dark:text-white">PPM KPI Report</h3>
<div className="flex items-center gap-1">
<button
onClick={() => onOpenDetail(handleExportExcel)}
className="p-1.5 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
title={t('dashboard.viewFullReport')}
>
<FaExternalLinkAlt className="w-3.5 h-3.5 text-gray-400" />
</button>
</div>
</div>
{!ppmKpiChartData || !ppmKpiChartData.datasets?.length ? (
<div className="h-48 flex items-center justify-center text-gray-400">
<div className="text-xs">No chart data available</div>
</div>
) : (
chartElement
)}
</div>
);
};
export default PpmKpiReportCard;
export { PpmKpiReportCard };
export type { PpmKpiReportCardProps };