375 lines
14 KiB
TypeScript
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 }; |