Perbaikan Menu Report dan Trending
This commit is contained in:
@@ -22,7 +22,8 @@
|
||||
"exceljs": "^4.4.0",
|
||||
"file-saver": "^2.0.5",
|
||||
"html2canvas": "^1.4.1",
|
||||
"jspdf": "^3.0.1",
|
||||
"jspdf": "^3.0.4",
|
||||
"jspdf-autotable": "^5.0.2",
|
||||
"mqtt": "^5.14.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^18.2.0",
|
||||
@@ -30,6 +31,7 @@
|
||||
"react-icons": "^4.11.0",
|
||||
"react-router-dom": "^6.22.3",
|
||||
"react-svg": "^16.3.0",
|
||||
"recharts": "^3.6.0",
|
||||
"sweetalert2": "^11.17.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
BIN
public/assets/pupuk-indonesia-1.png
Normal file
BIN
public/assets/pupuk-indonesia-1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 309 KiB |
BIN
public/assets/pupuk-indonesia-2.jpg
Normal file
BIN
public/assets/pupuk-indonesia-2.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 78 KiB |
@@ -1,91 +1,211 @@
|
||||
import React, { memo, useState, useEffect } from 'react';
|
||||
import { Button, Row, Col, Card, Input, DatePicker, Select, Typography } from 'antd';
|
||||
import TableList from '../../../../components/Global/TableList';
|
||||
import { Button, Row, Col, Card, DatePicker, Select, Typography, Table, Spin, Modal } from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
import { FileTextOutlined } from '@ant-design/icons';
|
||||
import { FileTextOutlined, DownloadOutlined, LoadingOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
getAllHistoryValueReport,
|
||||
getAllHistoryValueReportPivot,
|
||||
getAllHistoryValueReport,
|
||||
} from '../../../../api/history-value';
|
||||
import { getAllPlantSection } from '../../../../api/master-plant-section';
|
||||
import jsPDF from 'jspdf';
|
||||
import autoTable from 'jspdf-autotable';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
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 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 [plantSubSectionList, setPlantSubSectionList] = useState([]);
|
||||
const [startDate, setStartDate] = useState(dateNow);
|
||||
const [endDate, setEndDate] = useState(dateNow);
|
||||
const [periode, setPeriode] = useState(10);
|
||||
const [periode, setPeriode] = useState(30);
|
||||
|
||||
const defaultFilter = {
|
||||
criteria: '',
|
||||
plant_sub_section_id: 0,
|
||||
from: dateNowFormated,
|
||||
to: dateNowFormated,
|
||||
interval: periode,
|
||||
const columns = [
|
||||
{
|
||||
title: 'No',
|
||||
key: 'no',
|
||||
width: 60,
|
||||
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 [formDataFilter, setFormDataFilter] = useState(defaultFilter);
|
||||
|
||||
const handleSearch = () => {
|
||||
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');
|
||||
|
||||
setFormDataFilter({
|
||||
criteria: '',
|
||||
const params = new URLSearchParams({
|
||||
plant_sub_section_id: plantSubSection,
|
||||
from: formattedDateStart,
|
||||
to: formattedDateEnd,
|
||||
interval: periode,
|
||||
page: 1,
|
||||
limit: 1000,
|
||||
});
|
||||
setTrigerFilter((prev) => !prev);
|
||||
|
||||
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 handleSearch = () => {
|
||||
fetchData(1, pagination.pageSize, true);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setPlantSubSection(0);
|
||||
setStartDate(dateNow);
|
||||
setEndDate(dateNow);
|
||||
setPeriode(5);
|
||||
setPeriode(30);
|
||||
setTableData([]);
|
||||
setPivotData([]);
|
||||
setValueReportData([]);
|
||||
setPagination({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
});
|
||||
};
|
||||
|
||||
const getPlantSubSection = async () => {
|
||||
@@ -104,8 +224,389 @@ const ListReport = memo(function ListReport(props) {
|
||||
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 (
|
||||
<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>
|
||||
<Row>
|
||||
<Col xs={24}>
|
||||
@@ -167,14 +668,8 @@ const ListReport = memo(function ListReport(props) {
|
||||
value={periode}
|
||||
onChange={setPeriode}
|
||||
style={{ width: '100%', marginTop: '4px' }}
|
||||
options={[
|
||||
{ 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>
|
||||
options={periodeOptions}
|
||||
/>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
@@ -185,10 +680,21 @@ const ListReport = memo(function ListReport(props) {
|
||||
danger
|
||||
icon={<FileTextOutlined />}
|
||||
onClick={handleSearch}
|
||||
disabled={false}
|
||||
>
|
||||
Show
|
||||
</Button>
|
||||
</Col>
|
||||
<Col>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={exportToPDF}
|
||||
disabled={false}
|
||||
>
|
||||
Export PDF
|
||||
</Button>
|
||||
</Col>
|
||||
<Col>
|
||||
<Button
|
||||
onClick={handleReset}
|
||||
@@ -199,18 +705,23 @@ const ListReport = memo(function ListReport(props) {
|
||||
</Col>
|
||||
</Row>
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={24} lg={24} xl={24} style={{ marginTop: '16px' }}>
|
||||
<TableList
|
||||
firstLoad={false}
|
||||
mobile
|
||||
cardColor={'#d38943ff'}
|
||||
header={'datetime'}
|
||||
getData={getAllHistoryValueReportPivot}
|
||||
queryParams={formDataFilter}
|
||||
<Col xs={24} style={{ marginTop: '16px' }}>
|
||||
<Spin spinning={isLoadingTable}>
|
||||
<Table
|
||||
columns={columns}
|
||||
columnDynamic={'columns'}
|
||||
triger={trigerFilter}
|
||||
dataSource={tableData}
|
||||
pagination={{
|
||||
...pagination,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total) => `Total ${total} data`,
|
||||
pageSizeOptions: ['10', '20', '50', '100'],
|
||||
}}
|
||||
onChange={handleTableChange}
|
||||
scroll={{ x: 'max-content', y: 500 }}
|
||||
bordered
|
||||
size="small"
|
||||
/>
|
||||
</Spin>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
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 { FileTextOutlined } from '@ant-design/icons';
|
||||
import { ResponsiveLine } from '@nivo/line';
|
||||
import { FileTextOutlined, LoadingOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer
|
||||
} from 'recharts';
|
||||
import './trending.css';
|
||||
import { getAllPlantSection } from '../../../api/master-plant-section';
|
||||
import { getAllHistoryValueTrendingPivot } from '../../../api/history-value';
|
||||
@@ -18,6 +27,7 @@ const ReportTrending = memo(function ReportTrending(props) {
|
||||
const [startDate, setStartDate] = useState(dateNow);
|
||||
const [endDate, setEndDate] = useState(dateNow);
|
||||
const [periode, setPeriode] = useState(60);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const defaultFilter = {
|
||||
criteria: '',
|
||||
@@ -29,8 +39,19 @@ const ReportTrending = memo(function ReportTrending(props) {
|
||||
const [formDataFilter, setFormDataFilter] = useState(defaultFilter);
|
||||
|
||||
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 () => {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const formattedDateStart = startDate.format('YYYY-MM-DD');
|
||||
const formattedDateEnd = endDate.format('YYYY-MM-DD');
|
||||
|
||||
@@ -48,32 +69,53 @@ const ReportTrending = memo(function ReportTrending(props) {
|
||||
const response = await getAllHistoryValueTrendingPivot(param);
|
||||
|
||||
if (response?.data?.length > 0) {
|
||||
// 🔹 Bersihkan dan format data agar aman untuk Nivo
|
||||
const cleanedData = response.data.map((serie) => ({
|
||||
id: serie.id ?? 'Unknown',
|
||||
data: Array.isArray(serie.data)
|
||||
? serie.data.map((d) => ({
|
||||
x: d?.x ?? null,
|
||||
y:
|
||||
d?.y !== null && d?.y !== undefined
|
||||
? Number(d.y).toFixed(4) // format 4 angka di belakang koma
|
||||
: null,
|
||||
}))
|
||||
: [],
|
||||
}));
|
||||
|
||||
setTrendingValue(cleanedData);
|
||||
transformDataForRecharts(response.data);
|
||||
} else {
|
||||
// 🔹 Jika tidak ada data dari API
|
||||
setTrendingValue([]);
|
||||
setChartData([]);
|
||||
setMetrics([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching trending data:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
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 = () => {
|
||||
setPlantSubSection(0);
|
||||
setStartDate(dateNow);
|
||||
setEndDate(dateNow);
|
||||
setPeriode(5);
|
||||
setPeriode(60);
|
||||
setChartData([]);
|
||||
setMetrics([]);
|
||||
};
|
||||
|
||||
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(() => {
|
||||
getPlantSubSection();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<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>
|
||||
<Row>
|
||||
<Col xs={24}>
|
||||
@@ -162,10 +346,11 @@ const ReportTrending = memo(function ReportTrending(props) {
|
||||
{ value: 60, label: '1 Hour' },
|
||||
{ value: 120, label: '2 Hour' },
|
||||
]}
|
||||
></Select>
|
||||
/>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={8} style={{ marginTop: '16px' }}>
|
||||
<Col>
|
||||
<Button
|
||||
@@ -187,108 +372,9 @@ const ReportTrending = memo(function ReportTrending(props) {
|
||||
</Col>
|
||||
</Row>
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={24} lg={24} xl={24} style={{ marginTop: '16px' }}>
|
||||
<div style={{ height: '500px', marginTop: '16px' }}>
|
||||
{trendingValue && trendingValue.length > 0 ? (
|
||||
<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 xs={24} sm={24} md={24} lg={24} xl={24} style={{ marginTop: '24px' }}>
|
||||
{renderChart()}
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
Reference in New Issue
Block a user