Perbaikan Menu Report dan Trending

This commit is contained in:
Athif
2025-12-18 13:02:52 +07:00
parent 1ce922ff4c
commit dc78add71d
5 changed files with 820 additions and 221 deletions

View File

@@ -22,7 +22,8 @@
"exceljs": "^4.4.0", "exceljs": "^4.4.0",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"html2canvas": "^1.4.1", "html2canvas": "^1.4.1",
"jspdf": "^3.0.1", "jspdf": "^3.0.4",
"jspdf-autotable": "^5.0.2",
"mqtt": "^5.14.0", "mqtt": "^5.14.0",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"react": "^18.2.0", "react": "^18.2.0",
@@ -30,6 +31,7 @@
"react-icons": "^4.11.0", "react-icons": "^4.11.0",
"react-router-dom": "^6.22.3", "react-router-dom": "^6.22.3",
"react-svg": "^16.3.0", "react-svg": "^16.3.0",
"recharts": "^3.6.0",
"sweetalert2": "^11.17.2" "sweetalert2": "^11.17.2"
}, },
"devDependencies": { "devDependencies": {

Binary file not shown.

After

Width:  |  Height:  |  Size: 309 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

View File

@@ -1,91 +1,211 @@
import React, { memo, useState, useEffect } from 'react'; import React, { memo, useState, useEffect } from 'react';
import { Button, Row, Col, Card, Input, DatePicker, Select, Typography } from 'antd'; import { Button, Row, Col, Card, DatePicker, Select, Typography, Table, Spin, Modal } from 'antd';
import TableList from '../../../../components/Global/TableList';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { FileTextOutlined } from '@ant-design/icons'; import { FileTextOutlined, DownloadOutlined, LoadingOutlined } from '@ant-design/icons';
import { import {
getAllHistoryValueReport,
getAllHistoryValueReportPivot, getAllHistoryValueReportPivot,
getAllHistoryValueReport,
} from '../../../../api/history-value'; } from '../../../../api/history-value';
import { getAllPlantSection } from '../../../../api/master-plant-section'; import { getAllPlantSection } from '../../../../api/master-plant-section';
import jsPDF from 'jspdf';
import autoTable from 'jspdf-autotable';
const { Text } = Typography; const { Text } = Typography;
const ListReport = memo(function ListReport(props) { const ListReport = memo(function ListReport(props) {
const columns = [
{
title: 'No',
key: 'no',
width: '5%',
align: 'center',
render: (_, __, index) => index + 1,
},
{
title: 'Datetime',
dataIndex: 'datetime',
key: 'datetime',
width: '15%',
},
{
title: 'Tag Name',
dataIndex: 'tag_name',
key: 'tag_name',
width: '70%',
},
// {
// title: 'Value',
// dataIndex: 'val',
// key: 'val',
// width: '10%',
// render: (_, record) => Number(record.val).toFixed(4),
// },
// {
// title: 'Stat',
// dataIndex: 'status',
// key: 'status',
// width: '10%',
// },
];
const dateNow = dayjs(); const dateNow = dayjs();
const dateNowFormated = dateNow.format('YYYY-MM-DD'); const dateNowFormated = dateNow.format('YYYY-MM-DD');
const [trigerFilter, setTrigerFilter] = useState(false); const [isLoadingModal, setIsLoadingModal] = useState(false); // Modal loading
const [isLoadingTable, setIsLoadingTable] = useState(false); // Table loading
const [tableData, setTableData] = useState([]);
const [pivotData, setPivotData] = useState([]);
const [valueReportData, setValueReportData] = useState([]);
const [pagination, setPagination] = useState({
current: 1,
pageSize: 10,
total: 0,
});
const [plantSubSection, setPlantSubSection] = useState(0); const [plantSubSection, setPlantSubSection] = useState(0);
const [plantSubSectionList, setPlantSubSectionList] = useState([]); const [plantSubSectionList, setPlantSubSectionList] = useState([]);
const [startDate, setStartDate] = useState(dateNow); const [startDate, setStartDate] = useState(dateNow);
const [endDate, setEndDate] = useState(dateNow); const [endDate, setEndDate] = useState(dateNow);
const [periode, setPeriode] = useState(10); const [periode, setPeriode] = useState(30);
const defaultFilter = { const columns = [
criteria: '', {
plant_sub_section_id: 0, title: 'No',
from: dateNowFormated, key: 'no',
to: dateNowFormated, width: 60,
interval: periode, align: 'center',
fixed: 'left',
render: (_, __, index) => {
return (pagination.current - 1) * pagination.pageSize + index + 1;
},
},
{
title: 'Datetime',
dataIndex: 'datetime',
key: 'datetime',
width: 180,
sorter: (a, b) => new Date(a.datetime) - new Date(b.datetime),
},
{
title: 'Tag Name',
dataIndex: 'tagName',
key: 'tagName',
width: 200,
},
{
title: 'Value',
dataIndex: 'value',
key: 'value',
width: 120,
align: 'left', // Aligned kanan (seperti angka pada umumnya)
render: (value) => {
if (value === null || value === undefined) {
return '-';
}
return Number(value).toFixed(2); // Format 2 desimal
},
}
];
// Fungsi helper untuk generate semua waktu dalam sehari berdasarkan periode
const generateFullDayTimes = (dateString, intervalMinutes) => {
const times = [];
const startOfDay = dayjs(dateString).startOf('day');
const endOfDay = dayjs(dateString).endOf('day');
let currentTime = startOfDay;
while (currentTime.isBefore(endOfDay) || currentTime.isSame(endOfDay)) {
times.push(currentTime.format('YYYY-MM-DD HH:mm:ss'));
currentTime = currentTime.add(intervalMinutes, 'minute');
// Jika waktu berikutnya melebihi akhir hari, break
if (currentTime.isAfter(endOfDay)) {
break;
}
}
return times;
};
const fetchData = async (page = 1, pageSize = 10, showModal = false) => {
if (!plantSubSection) {
return;
}
if (showModal) {
setIsLoadingModal(true); // Modal untuk fetch pertama
} else {
setIsLoadingTable(true); // Spin untuk pagination
}
try {
const formattedDateStart = startDate.format('YYYY-MM-DD');
const formattedDateEnd = endDate.format('YYYY-MM-DD');
const params = new URLSearchParams({
plant_sub_section_id: plantSubSection,
from: formattedDateStart,
to: formattedDateEnd,
interval: periode,
page: 1,
limit: 1000,
});
const pivotResponse = await getAllHistoryValueReportPivot(params);
const valueReportResponse = await getAllHistoryValueReportPivot(params);
if (pivotResponse && pivotResponse.data) {
console.log('API Pivot Response:', pivotResponse);
console.log('First row pivot data:', pivotResponse.data[0]);
setPivotData(pivotResponse.data);
if (valueReportResponse && valueReportResponse.data) {
console.log('API Value Report Response:', valueReportResponse);
setValueReportData(valueReportResponse.data);
}
const unpivotedData = [];
pivotResponse.data.forEach((row) => {
const tagName = row.id;
const dataPoints = row.data || [];
dataPoints.forEach((item) => {
if (item && typeof item === 'object' && 'x' in item && 'y' in item) {
unpivotedData.push({
datetime: item.x,
tagName: tagName,
value: item.y,
});
}
});
});
console.log('Unpivoted data sample:', unpivotedData.slice(0, 10));
console.log('Total unpivoted rows:', unpivotedData.length);
unpivotedData.sort((a, b) => {
if (a.tagName !== b.tagName) {
return a.tagName.localeCompare(b.tagName);
}
return new Date(a.datetime) - new Date(b.datetime);
});
const transformedData = unpivotedData.map((item, index) => ({
key: index,
...item,
}));
const total = transformedData.length;
const startIndex = (page - 1) * pageSize;
const endIndex = startIndex + pageSize;
const paginatedData = transformedData.slice(startIndex, endIndex);
setTableData(paginatedData);
setPagination({
current: page,
pageSize: pageSize,
total: total,
});
}
} catch (error) {
console.error('Error fetching data:', error);
} finally {
if (showModal) {
setIsLoadingModal(false);
} else {
setIsLoadingTable(false);
}
}
};
const handleTableChange = (pagination, filters, sorter) => {
fetchData(pagination.current, pagination.pageSize, false);
}; };
const [formDataFilter, setFormDataFilter] = useState(defaultFilter);
const handleSearch = () => { const handleSearch = () => {
const formattedDateStart = startDate.format('YYYY-MM-DD'); fetchData(1, pagination.pageSize, true);
const formattedDateEnd = endDate.format('YYYY-MM-DD');
setFormDataFilter({
criteria: '',
plant_sub_section_id: plantSubSection,
from: formattedDateStart,
to: formattedDateEnd,
interval: periode,
});
setTrigerFilter((prev) => !prev);
}; };
const handleReset = () => { const handleReset = () => {
setPlantSubSection(0); setPlantSubSection(0);
setStartDate(dateNow); setStartDate(dateNow);
setEndDate(dateNow); setEndDate(dateNow);
setPeriode(5); setPeriode(30);
setTableData([]);
setPivotData([]);
setValueReportData([]);
setPagination({
current: 1,
pageSize: 10,
total: 0,
});
}; };
const getPlantSubSection = async () => { const getPlantSubSection = async () => {
@@ -104,8 +224,389 @@ const ListReport = memo(function ListReport(props) {
getPlantSubSection(); getPlantSubSection();
}, []); }, []);
const isWithinOneDay = startDate.isSame(endDate, 'day');
useEffect(() => {
if (!isWithinOneDay && periode < 60) {
setPeriode(60);
}
}, [startDate, endDate, periode, isWithinOneDay]);
const periodeOptions = [
{ value: 5, label: '5 Minute', disabled: !isWithinOneDay },
{ value: 10, label: '10 Minute', disabled: !isWithinOneDay },
{ value: 30, label: '30 Minute', disabled: !isWithinOneDay },
{ value: 60, label: '1 Hour', disabled: false },
{ value: 120, label: '2 Hour', disabled: false },
];
const exportToPDF = async () => {
if (pivotData.length === 0) {
alert('No data to export');
return;
}
const tagMapping = {};
valueReportData.forEach(item => {
if (item.tag_name && item.tag_number) {
tagMapping[item.tag_name] = item.tag_number;
}
});
const selectedSection = plantSubSectionList.find(item => item.plant_sub_section_id === plantSubSection);
const sectionName = selectedSection ? selectedSection.plant_sub_section_name : 'Unknown';
// Generate data lengkap untuk setiap tanggal dengan semua interval waktu
const dataByDate = {};
// Pertama, kumpulkan semua tanggal yang ada
const allDates = new Set();
pivotData.forEach(series => {
series.data.forEach(item => {
const date = dayjs(item.x).format('YYYY-MM-DD');
allDates.add(date);
});
});
// Untuk setiap tanggal, generate semua waktu dan isi dengan data yang ada
Array.from(allDates).forEach(date => {
const fullDayTimes = generateFullDayTimes(date, periode);
dataByDate[date] = {};
// Initialize dengan semua waktu
pivotData.forEach(series => {
dataByDate[date][series.id] = fullDayTimes.map(time => ({
x: time,
y: null // Default null, akan diisi jika ada data
}));
});
// Isi dengan data aktual yang ada
pivotData.forEach(series => {
series.data.forEach(item => {
const itemDate = dayjs(item.x).format('YYYY-MM-DD');
if (itemDate === date) {
const itemTime = dayjs(item.x).format('YYYY-MM-DD HH:mm:ss');
const timeIndex = fullDayTimes.indexOf(itemTime);
if (timeIndex !== -1 && dataByDate[date][series.id][timeIndex]) {
dataByDate[date][series.id][timeIndex].y = item.y;
}
}
});
});
});
const sortedDates = Object.keys(dataByDate).sort();
const loadImage = (src) => {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = reject;
img.src = src;
});
};
let logo1, logo2;
try {
logo1 = await loadImage('/assets/pupuk-indonesia-2.jpg');
logo2 = await loadImage('/assets/pupuk-indonesia-1.png');
} catch (error) {
console.error('Error loading logos:', error);
}
const doc = new jsPDF({ orientation: 'landscape' });
const pageWidth = doc.internal.pageSize.width;
const pageHeight = doc.internal.pageSize.height;
const marginLeft = 10;
const marginRight = 10;
const tableWidth = pageWidth - marginLeft - marginRight;
// Konstanta untuk mengatur lebar kolom
const IO_TAG_COLUMN_WIDTH = 17; // Lebar kolom IO Soft Tag di TABEL (mm)
const HEADER_LEFT_COLUMN_WIDTH = 40; // Lebar kolom KIRI di HEADER (untuk logo1) (mm)
const MAX_TIME_COLUMNS_PER_PAGE = 24;
let globalPageNumber = 1;
// FUNGSI HEADER LENGKAP - Untuk Halaman Pertama
const drawFullHeader = (doc, date) => {
doc.setLineWidth(0.5);
doc.line(marginLeft, 10, marginLeft + tableWidth, 10);
doc.line(marginLeft, 10, marginLeft, 50);
doc.line(marginLeft + tableWidth, 10, marginLeft + tableWidth, 50);
const col1Width = HEADER_LEFT_COLUMN_WIDTH; // Gunakan konstanta HEADER
const col3Width = tableWidth * 0.20;
const col2Width = tableWidth - col1Width - col3Width;
doc.line(marginLeft + col1Width, 10, marginLeft + col1Width, 30);
doc.line(marginLeft + tableWidth - col3Width, 10, marginLeft + tableWidth - col3Width, 30);
doc.line(marginLeft, 30, marginLeft + tableWidth, 30);
if (logo1) {
const maxLogoHeight = 18;
const maxLogoWidth = col1Width - 4;
const logoAspectRatio = logo1.width / logo1.height;
let logoWidth, logoHeight;
if (logoAspectRatio > (maxLogoWidth / maxLogoHeight)) {
logoWidth = maxLogoWidth;
logoHeight = logoWidth / logoAspectRatio;
} else {
logoHeight = maxLogoHeight;
logoWidth = logoHeight * logoAspectRatio;
}
const logoX = marginLeft + (col1Width - logoWidth) / 2;
const logoY = 10 + (20 - logoHeight) / 2;
doc.addImage(logo1, 'JPEG', logoX, logoY, logoWidth, logoHeight);
}
doc.setFontSize(12);
doc.setFont('helvetica', 'bold');
doc.text('PT. PUPUK INDONESIA UTILITAS', marginLeft + col1Width + col2Width / 2, 17, { align: 'center' });
doc.line(marginLeft + col1Width, 21, marginLeft + tableWidth - col3Width, 21);
doc.setFontSize(11);
doc.text('GRESIK GAS COGENERATION PLANT', marginLeft + col1Width + col2Width / 2, 27, { align: 'center' });
if (logo2) {
const maxLogoHeight = 18;
const maxLogoWidth = col3Width - 4;
const logoAspectRatio = logo2.width / logo2.height;
let logoWidth, logoHeight;
if (logoAspectRatio > (maxLogoWidth / maxLogoHeight)) {
logoWidth = maxLogoWidth;
logoHeight = logoWidth / logoAspectRatio;
} else {
logoHeight = maxLogoHeight;
logoWidth = logoHeight * logoAspectRatio;
}
const logoX = marginLeft + tableWidth - col3Width + (col3Width - logoWidth) / 2;
const logoY = 10 + (20 - logoHeight) / 2;
doc.addImage(logo2, 'PNG', logoX, logoY, logoWidth, logoHeight);
}
doc.line(marginLeft + tableWidth - col3Width, 30, marginLeft + tableWidth - col3Width, 50);
const formattedDate = dayjs(date).format('DD-MM-YYYY');
doc.setFontSize(9);
doc.setFont('helvetica', 'bold');
doc.text('Laporan Periode :', marginLeft + tableWidth - col3Width / 2, 35, { align: 'center' });
doc.setFont('helvetica', 'normal');
doc.text(formattedDate, marginLeft + tableWidth - col3Width / 2, 40, { align: 'center' });
// Hitung waktu akhir berdasarkan periode
const minutesInDay = 24 * 60;
const lastTimeMinutes = Math.floor((minutesInDay - periode) / periode) * periode;
const lastHour = Math.floor(lastTimeMinutes / 60);
const lastMinute = lastTimeMinutes % 60;
const endTime = `${String(lastHour).padStart(2, '0')}:${String(lastMinute).padStart(2, '0')}`;
doc.setFontSize(9);
doc.text(`00:00 - ${endTime}`, marginLeft + tableWidth - col3Width / 2, 45, { align: 'center' });
doc.setFont('helvetica', 'bold');
doc.setFontSize(10);
doc.text(`Plant Section : ${sectionName}`, marginLeft + col1Width + col2Width / 2, 41, { align: 'center' });
};
// FUNGSI HEADER SEDERHANA - Untuk Halaman Selanjutnya
const drawSimpleHeader = (doc, date) => {
const formattedDate = dayjs(date).format('DD-MM-YYYY');
// Gunakan struktur kolom yang SAMA seperti header lengkap
const col1Width = HEADER_LEFT_COLUMN_WIDTH; // Gunakan konstanta HEADER
const col3Width = tableWidth * 0.20;
const col2Width = tableWidth - col1Width - col3Width;
// Border luar
doc.setLineWidth(0.5);
doc.line(marginLeft, 10, marginLeft + tableWidth, 10);
doc.line(marginLeft, 10, marginLeft, 30);
doc.line(marginLeft + tableWidth, 10, marginLeft + tableWidth, 30);
doc.line(marginLeft, 30, marginLeft + tableWidth, 30);
// Garis vertikal pemisah kolom
doc.line(marginLeft + tableWidth - col3Width, 10, marginLeft + tableWidth - col3Width, 30);
// Kolom Kiri (KOSONG - tidak ada isi)
// Kolom Tengah - Plant Section
doc.setFontSize(10);
doc.setFont('helvetica', 'bold');
doc.text(`Plant Section : ${sectionName}`, marginLeft + col1Width + col2Width / 2, 20, { align: 'center' });
// Kolom Kanan - Laporan Periode + Time Range
doc.setFontSize(9);
doc.setFont('helvetica', 'bold');
doc.text('Laporan Periode :', marginLeft + tableWidth - col3Width / 2, 14, { align: 'center' });
doc.setFont('helvetica', 'normal');
doc.text(formattedDate, marginLeft + tableWidth - col3Width / 2, 19, { align: 'center' });
// Hitung waktu akhir berdasarkan periode
const minutesInDay = 24 * 60;
const lastTimeMinutes = Math.floor((minutesInDay - periode) / periode) * periode;
const lastHour = Math.floor(lastTimeMinutes / 60);
const lastMinute = lastTimeMinutes % 60;
const endTime = `${String(lastHour).padStart(2, '0')}:${String(lastMinute).padStart(2, '0')}`;
doc.text(`00:00 - ${endTime}`, marginLeft + tableWidth - col3Width / 2, 24, { align: 'center' });
};
sortedDates.forEach((date, dateIndex) => {
const dateData = dataByDate[date];
// Ambil semua waktu yang sudah di-generate lengkap
const allTimes = dateData[Object.keys(dateData)[0]].map(item => item.x);
const formattedTimes = allTimes.map(time => dayjs(time).format('HH:mm'));
const sortedTags = Object.keys(dateData).sort();
const totalTimeColumns = formattedTimes.length;
const totalPages = Math.ceil(totalTimeColumns / MAX_TIME_COLUMNS_PER_PAGE);
let totalPagesCount = 0;
sortedDates.forEach((date) => {
const dateData = dataByDate[date];
const totalTimeColumns = dateData[Object.keys(dateData)[0]].length;
const pagesForThisDate = Math.ceil(totalTimeColumns / MAX_TIME_COLUMNS_PER_PAGE);
totalPagesCount += pagesForThisDate;
});
for (let pageChunk = 0; pageChunk < totalPages; pageChunk++) {
if (dateIndex > 0 || pageChunk > 0) {
doc.addPage();
}
const startColIndex = pageChunk * MAX_TIME_COLUMNS_PER_PAGE;
const endColIndex = Math.min(startColIndex + MAX_TIME_COLUMNS_PER_PAGE, totalTimeColumns);
const pageTimeColumns = formattedTimes.slice(startColIndex, endColIndex);
const pageRawTimes = allTimes.slice(startColIndex, endColIndex);
const isFirstPage = (dateIndex === 0 && pageChunk === 0);
// Gunakan header yang sesuai
if (isFirstPage) {
drawFullHeader(doc, date);
} else {
drawSimpleHeader(doc, date);
}
const availableWidthForTime = tableWidth - IO_TAG_COLUMN_WIDTH;
const TIME_COLUMN_WIDTH = availableWidthForTime / pageTimeColumns.length;
const headerRow = ['IO Soft Tag', ...pageTimeColumns];
const pdfRows = sortedTags.map(tagName => {
const tagIdentifier = tagMapping[tagName] || tagName;
const row = [tagIdentifier];
const tagData = dateData[tagName];
for (let i = startColIndex; i < endColIndex; i++) {
const dataPoint = tagData[i];
const val = dataPoint && dataPoint.y;
row.push(val !== undefined && val !== null ? Number(val).toFixed(2) : '-');
}
return row;
});
const timeColumnStyles = {};
for (let i = 0; i < pageTimeColumns.length; i++) {
timeColumnStyles[i + 1] = {
cellWidth: TIME_COLUMN_WIDTH,
minCellWidht: 10,
halign: 'center'
};
}
autoTable(doc, {
head: [headerRow],
body: pdfRows,
startY: isFirstPage ? 50 : 30, // Sesuaikan startY untuk header sederhana (sama tinggi dengan header lengkap)
theme: 'grid',
styles: {
fontSize: 5,
cellPadding: 1,
minCellHeight: 10,
lineColor: [0, 0, 0],
lineWidth: 0.1,
halign: 'center',
valign: 'middle',
overflow: 'linebreak',
},
headStyles: {
fillColor: [255, 255, 255],
textColor: [0, 0, 0],
fontStyle: 'bold',
halign: 'center',
valign: 'middle',
lineColor: [0, 0, 0],
lineWidth: 0.3,
},
columnStyles: {
0: {
cellWidth: IO_TAG_COLUMN_WIDTH,
fontStyle: 'bold',
halign: 'center',
valign: 'middle'
},
...timeColumnStyles
},
margin: { left: marginLeft, right: marginRight },
tableWidth: tableWidth,
pageBreak: 'auto',
didDrawPage: (data) => {
doc.setFontSize(8);
doc.setFont('helvetica', 'normal');
doc.text(
`Page ${globalPageNumber} of ${totalPagesCount}`,
doc.internal.pageSize.width / 2,
doc.internal.pageSize.height - 10,
{ align: 'center' }
);
globalPageNumber++;
},
});
}
});
doc.save(`Report_${startDate.format('DD-MM-YYYY')}_to_${endDate.format('DD-MM-YYYY')}_ByDate.pdf`);
};
return ( return (
<React.Fragment> <React.Fragment>
<Modal
open={isLoadingModal}
footer={null}
closable={false}
centered
width={400}
bodyStyle={{
textAlign: 'center',
padding: '40px 20px'
}}
>
<Spin
indicator={<LoadingOutlined style={{ fontSize: 48, color: '#1890ff' }} spin />}
/>
<div style={{ marginTop: '24px' }}>
<Typography.Title level={4} style={{ marginBottom: '8px' }}>
Please Wait
</Typography.Title>
<Typography.Text type="secondary">
System is generating report data...
</Typography.Text>
</div>
</Modal>
<Card> <Card>
<Row> <Row>
<Col xs={24}> <Col xs={24}>
@@ -167,14 +668,8 @@ const ListReport = memo(function ListReport(props) {
value={periode} value={periode}
onChange={setPeriode} onChange={setPeriode}
style={{ width: '100%', marginTop: '4px' }} style={{ width: '100%', marginTop: '4px' }}
options={[ options={periodeOptions}
{ value: 5, label: '5 Minute' }, />
{ value: 10, label: '10 Minute' },
{ value: 30, label: '30 Minute' },
{ value: 60, label: '1 Hour' },
{ value: 120, label: '2 Hour' },
]}
></Select>
</div> </div>
</Col> </Col>
</Row> </Row>
@@ -185,10 +680,21 @@ const ListReport = memo(function ListReport(props) {
danger danger
icon={<FileTextOutlined />} icon={<FileTextOutlined />}
onClick={handleSearch} onClick={handleSearch}
disabled={false}
> >
Show Show
</Button> </Button>
</Col> </Col>
<Col>
<Button
type="primary"
icon={<DownloadOutlined />}
onClick={exportToPDF}
disabled={false}
>
Export PDF
</Button>
</Col>
<Col> <Col>
<Button <Button
onClick={handleReset} onClick={handleReset}
@@ -199,18 +705,23 @@ const ListReport = memo(function ListReport(props) {
</Col> </Col>
</Row> </Row>
</Col> </Col>
<Col xs={24} sm={24} md={24} lg={24} xl={24} style={{ marginTop: '16px' }}> <Col xs={24} style={{ marginTop: '16px' }}>
<TableList <Spin spinning={isLoadingTable}>
firstLoad={false} <Table
mobile columns={columns}
cardColor={'#d38943ff'} dataSource={tableData}
header={'datetime'} pagination={{
getData={getAllHistoryValueReportPivot} ...pagination,
queryParams={formDataFilter} showSizeChanger: true,
columns={columns} showTotal: (total) => `Total ${total} data`,
columnDynamic={'columns'} pageSizeOptions: ['10', '20', '50', '100'],
triger={trigerFilter} }}
/> onChange={handleTableChange}
scroll={{ x: 'max-content', y: 500 }}
bordered
size="small"
/>
</Spin>
</Col> </Col>
</Row> </Row>
</Card> </Card>
@@ -218,4 +729,4 @@ const ListReport = memo(function ListReport(props) {
); );
}); });
export default ListReport; export default ListReport;

View File

@@ -1,8 +1,17 @@
import React, { memo, useState, useEffect } from 'react'; import React, { memo, useState, useEffect } from 'react';
import { Button, Row, Col, Card, Input, DatePicker, Select, Typography } from 'antd'; import { Button, Row, Col, Card, DatePicker, Select, Typography, Modal, Spin } from 'antd';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { FileTextOutlined } from '@ant-design/icons'; import { FileTextOutlined, LoadingOutlined } from '@ant-design/icons';
import { ResponsiveLine } from '@nivo/line'; import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer
} from 'recharts';
import './trending.css'; import './trending.css';
import { getAllPlantSection } from '../../../api/master-plant-section'; import { getAllPlantSection } from '../../../api/master-plant-section';
import { getAllHistoryValueTrendingPivot } from '../../../api/history-value'; import { getAllHistoryValueTrendingPivot } from '../../../api/history-value';
@@ -18,6 +27,7 @@ const ReportTrending = memo(function ReportTrending(props) {
const [startDate, setStartDate] = useState(dateNow); const [startDate, setStartDate] = useState(dateNow);
const [endDate, setEndDate] = useState(dateNow); const [endDate, setEndDate] = useState(dateNow);
const [periode, setPeriode] = useState(60); const [periode, setPeriode] = useState(60);
const [isLoading, setIsLoading] = useState(false);
const defaultFilter = { const defaultFilter = {
criteria: '', criteria: '',
@@ -29,51 +39,83 @@ const ReportTrending = memo(function ReportTrending(props) {
const [formDataFilter, setFormDataFilter] = useState(defaultFilter); const [formDataFilter, setFormDataFilter] = useState(defaultFilter);
const [trendingValue, setTrendingValue] = useState([]); const [trendingValue, setTrendingValue] = useState([]);
const [chartData, setChartData] = useState([]);
const [metrics, setMetrics] = useState([]);
// Palet warna
const colorPalette = [
'#3b82f6', '#ef4444', '#10b981', '#f59e0b', '#8b5cf6',
'#ec4899', '#14b8a6', '#f97316', '#6366f1', '#84cc16'
];
const handleSearch = async () => { const handleSearch = async () => {
const formattedDateStart = startDate.format('YYYY-MM-DD'); setIsLoading(true);
const formattedDateEnd = endDate.format('YYYY-MM-DD');
try {
const formattedDateStart = startDate.format('YYYY-MM-DD');
const formattedDateEnd = endDate.format('YYYY-MM-DD');
const newFilter = { const newFilter = {
criteria: '', criteria: '',
plant_sub_section_id: plantSubSection, plant_sub_section_id: plantSubSection,
from: formattedDateStart, from: formattedDateStart,
to: formattedDateEnd, to: formattedDateEnd,
interval: periode, interval: periode,
}; };
setFormDataFilter(newFilter); setFormDataFilter(newFilter);
const param = new URLSearchParams(newFilter); const param = new URLSearchParams(newFilter);
const response = await getAllHistoryValueTrendingPivot(param); const response = await getAllHistoryValueTrendingPivot(param);
if (response?.data?.length > 0) { if (response?.data?.length > 0) {
// 🔹 Bersihkan dan format data agar aman untuk Nivo transformDataForRecharts(response.data);
const cleanedData = response.data.map((serie) => ({ } else {
id: serie.id ?? 'Unknown', setTrendingValue([]);
data: Array.isArray(serie.data) setChartData([]);
? serie.data.map((d) => ({ setMetrics([]);
x: d?.x ?? null, }
y: } catch (error) {
d?.y !== null && d?.y !== undefined console.error('Error fetching trending data:', error);
? Number(d.y).toFixed(4) // format 4 angka di belakang koma } finally {
: null, setIsLoading(false);
}))
: [],
}));
setTrendingValue(cleanedData);
} else {
// 🔹 Jika tidak ada data dari API
setTrendingValue([]);
} }
}; };
const transformDataForRecharts = (nivoData) => {
setTrendingValue(nivoData);
const metricNames = nivoData.map(serie => serie.id);
setMetrics(metricNames);
const timeMap = new Map();
nivoData.forEach(serie => {
serie.data.forEach(point => {
if (!timeMap.has(point.x)) {
timeMap.set(point.x, { time: point.x });
}
const entry = timeMap.get(point.x);
entry[serie.id] = point.y !== null && point.y !== undefined
? parseFloat(point.y)
: null;
});
});
const transformedData = Array.from(timeMap.values()).sort((a, b) =>
new Date(a.time) - new Date(b.time)
);
setChartData(transformedData);
};
const handleReset = () => { const handleReset = () => {
setPlantSubSection(0); setPlantSubSection(0);
setStartDate(dateNow); setStartDate(dateNow);
setEndDate(dateNow); setEndDate(dateNow);
setPeriode(5); setPeriode(60);
setChartData([]);
setMetrics([]);
}; };
const getPlantSubSection = async () => { const getPlantSubSection = async () => {
@@ -88,12 +130,154 @@ const ReportTrending = memo(function ReportTrending(props) {
} }
}; };
const formatXAxis = (tickItem) => {
const date = new Date(tickItem);
return `${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`;
};
const CustomTooltip = ({ active, payload, label }) => {
if (active && payload && payload.length) {
return (
<div style={{
backgroundColor: 'rgba(255, 255, 255, 0.98)',
padding: '12px',
border: '1px solid #ccc',
borderRadius: '8px',
boxShadow: '0 2px 8px rgba(0,0,0,0.15)'
}}>
<p style={{ margin: 0, fontWeight: 'bold', marginBottom: '8px' }}>
{new Date(label).toLocaleString('id-ID')}
</p>
{payload.map((entry, index) => (
<p key={index} style={{
margin: '4px 0',
color: entry.color,
fontSize: '13px'
}}>
<strong>{entry.name}:</strong> {Number(entry.value).toFixed(4)}
</p>
))}
</div>
);
}
return null;
};
const renderChart = () => {
if (!chartData || chartData.length === 0) {
return (
<div style={{
textAlign: 'center',
marginTop: '100px',
color: '#999',
fontSize: '16px'
}}>
Tidak ada data untuk ditampilkan
</div>
);
}
return (
<ResponsiveContainer width="100%" height={500}>
<LineChart
data={chartData}
margin={{ top: 20, right: 200, left: 80, bottom: 40 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="#e0e0e0" />
<XAxis
dataKey="time"
angle={-45}
textAnchor="end"
height={100}
tick={{ fontSize: 11 }}
tickFormatter={formatXAxis}
label={{
value: 'Waktu',
position: 'bottom',
offset: -50,
style: { fontSize: 14, fontWeight: 'bold' }
}}
/>
<YAxis
tick={{ fontSize: 11 }}
label={{
value: 'Nilai',
angle: -90,
position: 'right',
offset: -70,
dy: 0,
style: {
fontSize: 12,
fontWeight: 'bold',
fill: '#059669',
textAnchor: 'middle'
}
}}
tickFormatter={(value) => Number(value).toFixed(2)}
/>
<Tooltip content={<CustomTooltip />} />
<Legend
layout="vertical"
align="right"
verticalAlign="middle"
wrapperStyle={{
position: 'absolute',
right: 150,
top: '35%',
transform: 'translateY(-50%)'
}}
/>
{metrics.map((metric, index) => {
const color = colorPalette[index % colorPalette.length];
return (
<Line
key={metric}
type="monotone"
dataKey={metric}
stroke={color}
strokeWidth={2}
dot={chartData.length < 50}
name={metric}
connectNulls={true}
/>
);
})}
</LineChart>
</ResponsiveContainer>
);
};
useEffect(() => { useEffect(() => {
getPlantSubSection(); getPlantSubSection();
}, []); }, []);
return ( return (
<React.Fragment> <React.Fragment>
{/* Loading Modal */}
<Modal
open={isLoading}
footer={null}
closable={false}
centered
width={400}
bodyStyle={{
textAlign: 'center',
padding: '40px 20px'
}}
>
<Spin
indicator={<LoadingOutlined style={{ fontSize: 48, color: '#1890ff' }} spin />}
/>
<div style={{ marginTop: '24px' }}>
<Typography.Title level={4} style={{ marginBottom: '8px' }}>
Please Wait
</Typography.Title>
<Typography.Text type="secondary">
System is generating trending data...
</Typography.Text>
</div>
</Modal>
<Card> <Card>
<Row> <Row>
<Col xs={24}> <Col xs={24}>
@@ -162,10 +346,11 @@ const ReportTrending = memo(function ReportTrending(props) {
{ value: 60, label: '1 Hour' }, { value: 60, label: '1 Hour' },
{ value: 120, label: '2 Hour' }, { value: 120, label: '2 Hour' },
]} ]}
></Select> />
</div> </div>
</Col> </Col>
</Row> </Row>
<Row gutter={8} style={{ marginTop: '16px' }}> <Row gutter={8} style={{ marginTop: '16px' }}>
<Col> <Col>
<Button <Button
@@ -187,108 +372,9 @@ const ReportTrending = memo(function ReportTrending(props) {
</Col> </Col>
</Row> </Row>
</Col> </Col>
<Col xs={24} sm={24} md={24} lg={24} xl={24} style={{ marginTop: '16px' }}>
<div style={{ height: '500px', marginTop: '16px' }}> <Col xs={24} sm={24} md={24} lg={24} xl={24} style={{ marginTop: '24px' }}>
{trendingValue && trendingValue.length > 0 ? ( {renderChart()}
<ResponsiveLine
data={trendingValue} // [{ id, data: [{x, y}] }]
// data={
// trendingValue && trendingValue.length
// ? trendingValue
// : [{ id, data: [{ x, y }] }]
// }
margin={{ top: 40, right: 100, bottom: 70, left: 70 }}
xScale={{
type: 'time',
format: '%Y-%m-%d %H:%M',
useUTC: false,
precision: 'minute',
}}
xFormat="time:%Y-%m-%d %H:%M"
yScale={{
type: 'linear',
min: 'auto',
max: 'auto',
stacked: false,
reverse: false,
}}
yFormat={(value) => Number(value).toFixed(4)} // ✅ format 4 angka di belakang koma
axisBottom={{
format: '%Y-%m-%d %H:%M', // ✅ tampilkan tanggal + jam
tickValues: 'every 2 hours', // tampilkan setiap 2 jam (bisa ubah ke every 30 minutes)
tickSize: 5,
tickPadding: 5,
tickRotation: -45,
legend: 'Tanggal & Waktu',
legendOffset: 60,
legendPosition: 'middle',
}}
axisLeft={{
tickSize: 5,
tickPadding: 5,
tickRotation: 0,
legend: 'Nilai (Avg)',
legendOffset: -60,
legendPosition: 'middle',
format: (value) => Number(value).toFixed(4), // ✅ tampilkan 4 angka di sumbu Y
}}
curve="monotoneX"
colors={{ scheme: 'category10' }}
pointSize={6}
pointColor={{ theme: 'background' }}
pointBorderWidth={2}
pointBorderColor={{ from: 'serieColor' }}
enablePointLabel={false}
enableGridX={true}
enableGridY={true}
useMesh={true}
tooltip={({ point }) => (
<div
style={{
background: 'white',
padding: '6px 9px',
border: '1px solid #ccc',
borderRadius: '6px',
}}
>
<strong>{point.serieId}</strong>
<br />
{point.data.xFormatted}
<br />
<span style={{ color: point.serieColor }}>
{Number(point.data.y).toFixed(4)}
</span>
</div>
)}
legends={[
{
anchor: 'bottom-right',
direction: 'column',
justify: false,
translateX: 100,
translateY: 0,
itemsSpacing: 2,
itemDirection: 'left-to-right',
itemWidth: 120,
itemHeight: 20,
itemOpacity: 0.85,
symbolSize: 12,
symbolShape: 'circle',
},
]}
/>
) : (
<div
style={{
textAlign: 'center',
marginTop: '40px',
color: '#999',
}}
>
Tidak ada data untuk ditampilkan
</div>
)}
</div>
</Col> </Col>
</Row> </Row>
</Card> </Card>
@@ -296,4 +382,4 @@ const ReportTrending = memo(function ReportTrending(props) {
); );
}); });
export default ReportTrending; export default ReportTrending;