Compare commits

..

58 Commits

Author SHA1 Message Date
zain94rif
c2163cec5e Merge branch 'lavoce' of https://gitea.idetama.id/yogiedigital/cod-fe into lavoce 2026-01-08 17:25:23 +07:00
zain94rif
d5866ceae4 fix(api): search use api 2026-01-08 17:25:17 +07:00
6fdb259246 fixing validate solution optional in brand error code 2026-01-08 14:32:27 +07:00
0aad43c751 add label error code in list notification 2026-01-08 14:24:32 +07:00
d988d47e30 fixing layout mobile detail notification 2026-01-08 14:17:38 +07:00
zain94rif
e08eaaa43e fix(text): add 'error code' & move solution name 2026-01-08 13:55:01 +07:00
zain94rif
f6ca54f5b4 Merge branch 'lavoce' of https://gitea.idetama.id/yogiedigital/cod-fe into lavoce 2026-01-08 13:07:27 +07:00
zain94rif
a9b8053bd8 fix: change message 'Setiap error code harus memiliki minimal 1 solution!' disabled 2026-01-08 13:07:21 +07:00
600c101c68 fixing typo detail notification user 2026-01-08 12:17:13 +07:00
zain94rif
14a6884f43 Merge branch 'lavoce' of https://gitea.idetama.id/yogiedigital/cod-fe into lavoce 2026-01-07 17:07:46 +07:00
zain94rif
8e151ffe0b fix(comp): modified the card in notification detail 2026-01-07 17:07:42 +07:00
8f64843613 fixing redirect wa session token 2026-01-07 16:10:23 +07:00
zain94rif
fe8f6d1002 fix: move update is read's api after fetchLogHistory 2026-01-07 14:40:36 +07:00
zain94rif
5281e288a9 feat(var): add update at from create at 2026-01-07 11:00:35 +07:00
zain94rif
4ed05cc640 fix: resize add log card 2026-01-07 10:30:03 +07:00
zain94rif
14e97fead2 feat(api): add update is_read for detail 2026-01-06 16:12:58 +07:00
zain94rif
0935d7c9f5 fix(var): use notification_error_id from item, not from users 2026-01-06 09:53:15 +07:00
zain94rif
3266641f81 fix(api): fixing put to post 2026-01-05 14:48:46 +07:00
zain94rif
739c55c0bc fix(api): fixing endpoint notification 2026-01-05 14:26:12 +07:00
zain94rif
5b4485d20d feat(api): add API for resend chat user 2026-01-05 14:08:02 +07:00
98057beb0f Merge pull request 'fix-lav-notification' (#31) from fix-lav-notification into lavoce
Reviewed-on: #31
2026-01-05 03:40:24 +00:00
zain94rif
b342289888 fix(view): adjustment view page notification 2026-01-05 10:39:01 +07:00
d03bbf2a41 Repair topic mqtt 2026-01-05 10:38:24 +07:00
zain94rif
ec094b8f55 fix(text): change 'User History' to 'History User' 2026-01-05 09:38:27 +07:00
b6d941ba2d refactor: comment out console logs for cleaner production code 2025-12-29 10:58:03 +07:00
167abcaa43 refactor: enhance notification log layout and styling for better readability 2025-12-24 11:51:39 +07:00
beb8ccbaee feat: integration notification functionality and user history fetching 2025-12-23 22:10:11 +07:00
797f6c2383 refactor: clean up comments and streamline payload handling in user detail form 2025-12-23 20:10:33 +07:00
016c77a586 fixing redirect detail notification tab 2025-12-23 12:17:17 +07:00
36ebab7f9a refactor: remove unused UserHistoryModal and related state management 2025-12-23 10:30:44 +07:00
a5b1fbef74 repair: layout & sparepart brand-device 2025-12-23 10:26:30 +07:00
Athif
cb0c53daea update Menu Report 2025-12-23 02:20:16 +07:00
978e020305 feat: update notification data transformation and enhance user history display 2025-12-22 20:49:48 +07:00
4508738958 feat: implement log history fetching and display in ListNotification component 2025-12-22 18:47:42 +07:00
eb23612444 Repair and replace svg 2025-12-22 16:37:11 +07:00
bee196e299 feat: add notification log creation and retrieval functionality 2025-12-22 14:34:14 +07:00
d19f555c7c repair: layout error code form brand-device 2025-12-19 12:43:56 +07:00
1d7253f9a1 repair: sparepart select brand-device 2025-12-19 12:43:19 +07:00
d8a1878ab1 refactor: adjust spacing and layout in NotificationDetailTab for improved readability 2025-12-18 21:11:49 +07:00
e4af2d6e18 refactor: improve timestamp display and code formatting in notification detail 2025-12-18 19:34:20 +07:00
8cf21643ea refactor: update notification handling to prioritize active solutions and improve data extraction 2025-12-18 17:57:25 +07:00
Athif
6b75f6f4b9 update menu report 2025-12-18 15:03:39 +07:00
Athif
dc78add71d Perbaikan Menu Report dan Trending 2025-12-18 13:02:52 +07:00
1ce922ff4c minor fix notif 2025-12-18 12:36:46 +07:00
3a4b0f0748 repair: ErrorCode brand-device 2025-12-18 10:51:35 +07:00
4bffbb3798 repair: add clear selected error code 2025-12-13 14:25:30 +07:00
b9cdfcb1e9 repair: sollution field, handle clear form 2025-12-13 14:15:35 +07:00
49ba00d886 repair: view brand device, add: read only 2025-12-12 23:02:09 +07:00
cf1ccb0fd0 repair: sollution brand-device 2025-12-12 16:55:05 +07:00
fb790e5e37 repair: error code brand-device 2025-12-12 16:54:52 +07:00
ea3adf40cc repair: brandDevice add edit page 2025-12-12 16:54:30 +07:00
2ff50342e8 repair: sollution brand-device 2025-12-12 15:58:11 +07:00
1f8ee62721 fix: improve formatting and consistency in DetailDevice and GeneratePdf components 2025-12-12 15:53:29 +07:00
96d6367dbd add: image viewer 2025-12-12 12:46:05 +07:00
8afff23ffe update: brand device 2025-12-12 12:45:46 +07:00
512282f367 repair: sollution brand-device 2025-12-12 12:45:00 +07:00
4fab5df300 repair: error code brand-device 2025-12-12 12:44:16 +07:00
9e8191f8f8 repair: sparepart brand-device 2025-12-12 12:43:14 +07:00
60 changed files with 6926 additions and 4808 deletions

View File

@@ -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": {

Binary file not shown.

After

Width:  |  Height:  |  Size: 309 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

View File

@@ -21,7 +21,6 @@ import IndexShift from './pages/master/shift/IndexShift';
// Brand device
import AddBrandDevice from './pages/master/brandDevice/AddBrandDevice';
import EditBrandDevice from './pages/master/brandDevice/EditBrandDevice';
import AddEditErrorCode from './pages/master/brandDevice/AddEditErrorCode';
import ViewBrandDevice from './pages/master/brandDevice/ViewBrandDevice';
import ViewFilePage from './pages/master/brandDevice/ViewFilePage';
@@ -37,7 +36,7 @@ import IndexNotification from './pages/notification/IndexNotification';
import IndexRole from './pages/role/IndexRole';
import IndexUser from './pages/user/IndexUser';
import IndexContact from './pages/contact/IndexContact';
import DetailNotificationTab from './pages/detailNotification/IndexDetailNotification';
import DetailNotificationTab from './pages/notificationDetail/IndexNotificationDetail';
import IndexVerificationSparepart from './pages/verificationSparepart/IndexVerificationSparepart';
import SvgTest from './pages/home/SvgTest';
@@ -52,6 +51,10 @@ import SvgAirDryerC from './pages/home/SvgAirDryerC';
import IndexHistoryAlarm from './pages/history/alarm/IndexHistoryAlarm';
import IndexHistoryEvent from './pages/history/event/IndexHistoryEvent';
// Image Viewer
import ImageViewer from './Utils/ImageViewer';
import RedirectWa from './pages/blank/RedirectWa';
const App = () => {
return (
<BrowserRouter>
@@ -62,7 +65,7 @@ const App = () => {
<Route path="/signup" element={<SignUp />} />
<Route path="/svg" element={<SvgTest />} />
<Route
path="/detail-notification/:notificationId"
path="/notification-detail/:notificationId"
element={<DetailNotificationTab />}
/>
<Route
@@ -70,12 +73,16 @@ const App = () => {
element={<IndexVerificationSparepart />}
/>
<Route path="/redirect" element={<RedirectWa />} />
{/* Protected Routes */}
<Route path="/dashboard" element={<ProtectedRoute />}>
<Route path="home" element={<Home />} />
<Route path="blank" element={<Blank />} />
</Route>
<Route path="/image-viewer/:fileName" element={<ImageViewer />} />
<Route path="/dashboard-svg" element={<ProtectedRoute />}>
<Route path="overview-compressor" element={<SvgOverviewCompressor />} />
<Route path="compressor-a" element={<SvgCompressorA />} />
@@ -113,9 +120,6 @@ const App = () => {
path="brand-device/view/temp/files/:fileName"
element={<ViewFilePage />}
/>
<Route path="brand-device/:brandId/error-code/add" element={<AddEditErrorCode />} />
<Route path="brand-device/:brandId/error-code/edit/:errorCodeId" element={<AddEditErrorCode />} />
<Route path="brand-device/add/error-code/edit/:errorCodeId" element={<AddEditErrorCode />} />
</Route>
<Route path="/report" element={<ProtectedRoute />}>
@@ -148,7 +152,6 @@ const App = () => {
<Route index element={<IndexJadwalShift />} />
</Route>
{/* Catch-all */}
<Route path="*" element={<NotFound />} />
</Routes>
</BrowserRouter>

248
src/Utils/ImageViewer.jsx Normal file
View File

@@ -0,0 +1,248 @@
import React, { useState, useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { getFileUrl, getFolderFromFileType } from '../api/file-uploads';
const ImageViewer = () => {
const { fileName } = useParams();
const [fileUrl, setFileUrl] = useState('');
const [error, setError] = useState('');
const [zoom, setZoom] = useState(1);
const [isImage, setIsImage] = useState(false);
useEffect(() => {
if (!fileName) {
setError('No file specified');
return;
}
try {
const decodedFileName = decodeURIComponent(fileName);
const fileExtension = decodedFileName.split('.').pop()?.toLowerCase();
const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'];
setIsImage(imageExtensions.includes(fileExtension));
const folder = getFolderFromFileType(fileExtension);
const url = getFileUrl(folder, decodedFileName);
setFileUrl(url);
document.title = `File Viewer - ${decodedFileName}`;
} catch (error) {
setError('Failed to load file');
}
}, [fileName]);
useEffect(() => {
const handleKeyDown = (e) => {
if (!isImage) return;
if (e.key === '+' || e.key === '=') {
setZoom(prev => Math.min(prev + 0.1, 3));
} else if (e.key === '-' || e.key === '_') {
setZoom(prev => Math.max(prev - 0.1, 0.1));
} else if (e.key === '0') {
setZoom(1);
} else if (e.key === 'Escape') {
window.close();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isImage]);
const handleWheel = (e) => {
if (!isImage || !e.ctrlKey) return;
e.preventDefault();
const delta = e.deltaY > 0 ? -0.1 : 0.1;
setZoom(prev => Math.min(Math.max(prev + delta, 0.1), 3));
};
const handleZoomIn = () => setZoom(prev => Math.min(prev + 0.1, 3));
const handleZoomOut = () => setZoom(prev => Math.max(prev - 0.1, 0.1));
const handleResetZoom = () => setZoom(1);
if (error) {
return (
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100vh',
fontFamily: 'Arial, sans-serif',
backgroundColor: '#f5f5f5'
}}>
<div style={{ textAlign: 'center' }}>
<h1>Error</h1>
<p>{error}</p>
</div>
</div>
);
}
if (!isImage) {
return (
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100vh',
fontFamily: 'Arial, sans-serif',
backgroundColor: '#f5f5f5'
}}>
<div style={{ textAlign: 'center' }}>
<h1>File Type Not Supported</h1>
<p>Image viewer only supports image files.</p>
<p>Please use direct file preview for PDFs and other documents.</p>
</div>
</div>
);
}
return (
<div
style={{
margin: 0,
padding: 0,
height: '100vh',
width: '100vw',
backgroundColor: '#000',
overflow: 'hidden',
position: 'relative'
}}
onWheel={handleWheel}
>
{isImage && (
<div style={{
position: 'fixed',
top: '20px',
right: '20px',
display: 'flex',
gap: '10px',
backgroundColor: 'rgba(0, 0, 0, 0.7)',
padding: '10px',
borderRadius: '8px',
zIndex: 1000
}}>
<button
onClick={handleZoomOut}
style={{
backgroundColor: 'rgba(255, 255, 255, 0.2)',
color: '#fff',
border: '1px solid rgba(255, 255, 255, 0.3)',
padding: '8px 12px',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '16px'
}}
title="Zoom Out (-)"
>
</button>
<span style={{
color: '#fff',
padding: '8px 12px',
minWidth: '60px',
textAlign: 'center',
fontSize: '14px'
}}>
{Math.round(zoom * 100)}%
</span>
<button
onClick={handleZoomIn}
style={{
backgroundColor: 'rgba(255, 255, 255, 0.2)',
color: '#fff',
border: '1px solid rgba(255, 255, 255, 0.3)',
padding: '8px 12px',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '16px'
}}
title="Zoom In (+)"
>
+
</button>
<button
onClick={handleResetZoom}
style={{
backgroundColor: 'rgba(255, 255, 255, 0.2)',
color: '#fff',
border: '1px solid rgba(255, 255, 255, 0.3)',
padding: '8px 12px',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '14px'
}}
title="Reset Zoom (0)"
>
Reset
</button>
</div>
)}
{isImage && fileUrl ? (
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100%',
overflow: 'auto'
}}>
<img
src={fileUrl}
alt={decodeURIComponent(fileName)}
style={{
maxWidth: 'none',
maxHeight: 'none',
transform: `scale(${zoom})`,
transformOrigin: 'center',
transition: 'transform 0.1s ease-out',
cursor: zoom > 1 ? 'move' : 'default'
}}
onError={() => setError('Failed to load image')}
draggable={false}
/>
</div>
) : isImage ? (
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100vh',
color: '#fff',
fontFamily: 'Arial, sans-serif'
}}>
<p>Loading image...</p>
</div>
) : null}
{isImage && (
<div style={{
position: 'fixed',
bottom: '20px',
left: '20px',
backgroundColor: 'rgba(0, 0, 0, 0.7)',
color: '#fff',
padding: '10px 15px',
borderRadius: '8px',
fontSize: '12px',
zIndex: 1000
}}>
<div>Mouse wheel + Ctrl: Zoom</div>
<div>Keyboard: +/ Zoom, 0: Reset, ESC: Close</div>
</div>
)}
</div>
);
};
export default ImageViewer;

View File

@@ -27,4 +27,79 @@ const getNotificationDetail = async (id) => {
return response.data;
};
export { getAllNotification, getNotificationById, getNotificationDetail };
// Create new notification log
const createNotificationLog = async (data) => {
const response = await SendRequest({
method: 'post',
prefix: 'notification-log',
params: data,
});
return response.data;
};
// Get notification logs by notification_error_id
const getNotificationLogByNotificationId = async (notificationId) => {
const response = await SendRequest({
method: 'get',
prefix: `notification-log/notification_error/${notificationId}`,
});
return response.data;
};
// update is_read status
const updateIsRead = async (notificationId) => {
const response = await SendRequest({
method: 'put',
prefix: `notification/${notificationId}`,
});
return response.data;
};
// Resend notification to specific user
const resendNotificationToUser = async (notificationId, userId) => {
const response = await SendRequest({
method: 'post',
prefix: `notification/${notificationId}/resend/${userId}`,
});
return response.data;
};
// Resend Chat by User
const resendChatByUser = async (notificationId, userPhone) => {
const response = await SendRequest({
method: 'post',
prefix: `notification-user/resend/${notificationId}/${userPhone}`,
});
return response.data;
};
// Resend Chat All User
const resendChatAllUser = async (notificationId) => {
const response = await SendRequest({
method: 'post',
prefix: `notification/resend/${notificationId}`,
});
return response.data;
};
// Searching
const searchData = async (queryParams) => {
const response = await SendRequest({
method: 'get',
prefix: `notification?criteria=${queryParams}`,
});
return response.data;
};
export {
getAllNotification,
getNotificationById,
getNotificationDetail,
createNotificationLog,
getNotificationLogByNotificationId,
updateIsRead,
resendNotificationToUser,
resendChatByUser,
resendChatAllUser,
searchData,
};

View File

@@ -26,27 +26,29 @@
<path style="fill: none; stroke: rgb(76, 76, 76); stroke-width: 1.222; transform-origin: 490.992px 230.229px;" d="M 646.097 240.002 L 676.271 240.002"/>
</g>
<path style="fill: none; stroke: rgb(0, 0, 0); stroke-width: 1.525; transform-origin: 443.701px 171.141px;" d="M 443.542 155.983 L 443.859 186.298"/>
<g transform="matrix(0.826913, 0, 0, 0.698383, 0.443817, 3.138935)">
<rect x="752" y="355.455" width="42.438" height="3.527" style="stroke: rgb(0, 0, 0); stroke-width: 1; fill: rgb(243, 243, 243);"/>
<rect x="756.328" y="359.271" width="34.034" height="53.968" style="stroke: rgb(0, 0, 0); stroke-width: 1; fill: rgb(243, 243, 243);"/>
<rect x="756.146" y="352.019" width="34.034" height="3.38" style="stroke: rgb(0, 0, 0); stroke-width: 1; fill: rgb(243, 243, 243);"/>
<ellipse style="stroke: rgb(0, 0, 0); stroke-width: 1; fill: rgb(243, 243, 243);" cx="773.446" cy="384.7" rx="11.751" ry="11.009"/>
<g>
<rect x="622.282" y="251.383" width="35.093" height="2.463" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(243, 243, 243);"/>
<g>
<rect x="625.861" y="254.048" width="28.143" height="37.69" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(243, 243, 243);"/>
<rect x="625.711" y="248.983" width="28.143" height="2.361" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(243, 243, 243);"/>
<ellipse style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(243, 243, 243);" cx="640.016" cy="271.807" rx="9.717" ry="7.689"/>
</g>
</g>
<rect x="461.861" y="211.956" width="62.018" height="9.968" style="stroke: rgb(0, 0, 0); fill: rgb(120, 231, 228); stroke-width: 0.763;"/>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 9px;" x="561" y="309.954" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">Heater Temp SP</text>
<rect x="461.861" y="221.924" width="62.018" height="17.46" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(244, 248, 248);"/>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="564.279" y="330.561" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">####.##</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="609.476" y="330.521" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">°F</text>
<rect x="461.424" y="242.149" width="62.018" height="9.968" style="stroke: rgb(0, 0, 0); fill: rgb(120, 231, 228); stroke-width: 0.763;"/>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 9px; stroke-width: 1;" x="567.471" y="352.188" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">Heater Temp</text>
<rect x="461.424" y="252.117" width="62.018" height="17.46" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(244, 248, 248);"/>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="563.75" y="373.795" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)" id="Dry1_HeatTempCelsius">####.##</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="608.947" y="373.755" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">°F</text>
<rect x="535.456" y="242.272" width="62.018" height="9.968" style="stroke: rgb(0, 0, 0); fill: rgb(120, 231, 228); stroke-width: 0.763;"/>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 9px; stroke-width: 1;" x="659" y="352.363" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">Heater Temp</text>
<rect x="535.456" y="252.24" width="62.018" height="17.46" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(244, 248, 248);"/>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="653.279" y="373.97" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">####.##</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="698.476" y="373.93" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">°F</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="698.476" y="373.93" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">°C</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1; font-weight: bold;" x="748" y="347.676" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">HEATER</text>
<path style="fill: none; stroke: rgb(0, 0, 0); stroke-width: 1.525; transform-origin: 678.512px 258.693px;" d="M 678.467 229.321 L 678.558 288.066" transform="matrix(0, 1.184039, -0.844567, 0, -0.000022, -0.000005)"/>
<path style="fill: none; stroke: rgb(0, 0, 0); stroke-width: 1.525; transform-origin: 703.162px 309.166px;" d="M 703.004 258.049 L 703.32 360.282"/>
@@ -108,12 +110,12 @@
<rect x="427.269" y="377.282" width="62.018" height="9.968" style="stroke: rgb(0, 0, 0); fill: rgb(120, 231, 228); stroke-width: 0.763;"/>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 9px; stroke-width: 1;" x="532.167" y="545.681" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">Dew Temp</text>
<rect x="427.269" y="387.25" width="62.018" height="17.46" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(244, 248, 248);"/>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="522.446" y="567.288" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">####.##</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="567.643" y="567.248" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">°C</text>
<rect x="427.27" y="412.201" width="62.018" height="9.968" style="stroke: rgb(0, 0, 0); fill: rgb(120, 231, 228); stroke-width: 0.763;"/>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 9px; stroke-width: 1;" x="532.168" y="595.681" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">Dew Temp</text>
<rect x="427.27" y="422.169" width="62.018" height="17.46" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(244, 248, 248);"/>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="522.447" y="617.288" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">####.##</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="567.644" y="617.248" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">°F</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 9px; stroke-width: 1; text-anchor: middle; font-weight: bolder;" x="602.463" y="573.003" transform="matrix(0.826913, 0, 0, 0.698383, 24.207672, -7.192523)">AIR<tspan x="602.4630126953125" dy="1em"></tspan>OUTLET</text>
<g transform="matrix(-0.387768, 0, 0, -0.200385, 743.634644, -199.991287)" style="transform-origin: 72.2405px 412.5px;">
@@ -177,17 +179,17 @@
<rect x="43.443" y="280.75" width="82.691" height="27.103" style="stroke: rgb(0, 0, 0); fill: rgb(120, 231, 228); stroke-width: 0.763;"/>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Bahnschrift; font-size: 14px; stroke-width: 1; font-weight: bold;" x="53.987" y="423.091" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">RUN HOUR</text>
<rect x="126.135" y="280.75" width="82.691" height="27.103" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(244, 248, 248);"/>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="159.05" y="424.106" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">####.##</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="225" y="424.066" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">H</text>
<rect x="43.443" y="308.191" width="82.691" height="27.103" style="stroke: rgb(0, 0, 0); fill: rgb(120, 231, 228); stroke-width: 0.763;"/>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Bahnschrift; font-size: 13px; stroke-width: 1; font-weight: bold;" x="53.987" y="461.382" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">PURGE HOUR</text>
<rect x="126.135" y="308.191" width="82.691" height="27.103" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(244, 248, 248);"/>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="159.05" y="463.397" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">####.##</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="225" y="463.357" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">H</text>
<rect x="43.443" y="333.129" width="82.691" height="27.103" style="stroke: rgb(0, 0, 0); fill: rgb(120, 231, 228); stroke-width: 0.763;"/>
<text style="fill: rgb(51, 51, 51); font-family: Bahnschrift; font-size: 12px; font-weight: 700; white-space: pre;" x="53.987" y="498.091" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">HEATER HOUR</text>
<rect x="126.135" y="333.129" width="82.691" height="27.103" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(244, 248, 248);"/>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="159.05" y="499.106" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">####.##</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="225" y="499.066" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">H</text>
<rect x="43.65" y="360.147" width="82.691" height="27.103" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(248, 213, 14);"/>
<text style="fill: rgb(51, 51, 51); font-family: Bahnschrift; font-size: 14px; font-weight: 700; white-space: pre; stroke-width: 1;" x="54.237" y="536.777" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">Alarm Info</text>
@@ -205,15 +207,15 @@
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="183.349" y="288.807" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">HTD</text>
<rect x="43.443" y="214.051" width="165.383" height="27.103" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(244, 248, 248);"/>
<text style="fill: rgb(51, 51, 51); font-family: Bahnschrift; font-size: 13px; font-weight: 700; white-space: pre; stroke-width: 1;" x="53.987" y="322.585" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">Step</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="177.05" y="323.6" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">####.##</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="233" y="323.56" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">s</text>
<rect x="43.443" y="241.422" width="82.691" height="27.103" style="stroke: rgb(0, 0, 0); fill: rgb(120, 231, 228); stroke-width: 0.763;"/>
<text style="fill: rgb(51, 51, 51); font-family: Bahnschrift; font-size: 14px; font-weight: 700; white-space: pre; stroke-width: 1;" x="54.237" y="364.271" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">Cycle Timer</text>
<rect x="126.341" y="241.068" width="82.691" height="27.103" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(244, 248, 248);"/>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="168.775" y="365.807" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">####.##</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="225.25" y="365.246" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">s</text>
<text style="fill: rgb(51, 51, 51); font-family: Bahnschrift; font-size: 13px; font-weight: 700; white-space: pre; stroke-width: 1;" x="141.894" y="324.069" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">Time</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="92.151" y="325.554" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">##</text>
<rect x="870.356" y="142.816" width="82.691" height="27.103" style="stroke: rgb(0, 0, 0); fill: rgb(120, 231, 228); stroke-width: 0.763;"/>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Bahnschrift; font-size: 14px; stroke-width: 1; font-weight: bold;" x="1060.06" y="224.103" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">Dryer Status</text>
<rect x="870.356" y="170.304" width="82.691" height="42.35" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(244, 248, 248);"/>
@@ -227,12 +229,33 @@
<rect x="870.356" y="344.9" width="82.691" height="42.35" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(244, 248, 248);"/>
<ellipse style="fill: rgb(216, 216, 216); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="911.702" cy="366.997" rx="20.673" ry="17.46"/>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Bahnschrift; font-size: 14px; stroke-width: 1; font-weight: bold;" x="380.451" y="296.591" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">REGEN</text>
<ellipse style="fill: rgb(216, 216, 216); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="334.165" cy="232.104" rx="13.582" ry="12.517"/>
<ellipse style="fill: rgb(216, 216, 216); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="336.418" cy="237.483" rx="13.582" ry="12.517"/>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Bahnschrift; font-size: 14px; stroke-width: 1; font-weight: bold;" x="379.214" y="423.395" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">DRYING</text>
<ellipse style="fill: rgb(216, 216, 216); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="335.623" cy="320.662" rx="13.582" ry="12.517"/>
<ellipse style="fill: rgb(255, 204, 63); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="335.418" cy="321.016" rx="13.582" ry="12.517"/>
<ellipse style="fill: rgb(216, 216, 216); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="335.623" cy="320.662" rx="13.582" ry="12.517" id="c_4021"/>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Bahnschrift; font-size: 14px; stroke-width: 1; font-weight: bold;" x="897.237" y="299.014" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">REGEN</text>
<ellipse style="fill: rgb(216, 216, 216); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="761.502" cy="233.796" rx="13.582" ry="12.517"/>
<ellipse style="fill: rgb(255, 204, 63); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="761.772" cy="233.876" rx="13.582" ry="12.517"/>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Bahnschrift; font-size: 14px; stroke-width: 1; font-weight: bold;" x="896" y="425.818" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">DRYING</text>
<ellipse style="fill: rgb(216, 216, 216); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="762.96" cy="322.354" rx="13.582" ry="12.517"/>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Bahnschrift; font-size: 35px; stroke-width: 1; font-weight: bold;" x="348.875" y="78.242" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">AIR DRYER UNIT A (01-CL-10532-A)</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="183.349" y="288.807" transform="matrix(0.826913, 0, 0, 0.698383, 1.386371, 4.000207)">HTLS</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="183.349" y="288.807" transform="matrix(0.826913, 0, 0, 0.698383, -1.613663, 3.937793)">BLWR</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="522.447" y="617.288" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)" id="c_4005">####.##</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="522.446" y="567.288" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)" id="c_4004">####.##</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="653.279" y="373.97" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)" id="c_4001">####.##</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="563.75" y="373.795" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)" id="c_4002">####.##</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="564.279" y="330.561" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)" id="c_4003">####.##</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="159.05" y="424.106" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)" id="c_4009">####.##</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="159.05" y="463.397" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)" id="c_4010">####.##</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="159.05" y="499.106" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)" id="c_4011">####.##</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="177.05" y="323.6" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)" id="c_4008">####.##</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="168.775" y="365.807" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)" id="c_4007">####.##</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="92.151" y="325.554" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)" id="c_4006">##</text>
<ellipse style="fill: rgb(255, 204, 63); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="336.418" cy="237.483" rx="13.582" ry="12.517" id="c_4018"/>
<ellipse style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(255, 172, 63);" cx="640.283" cy="271.689" rx="9.717" ry="7.689" id="c_4019"/>
<ellipse style="fill: rgb(63, 255, 69); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="911.254" cy="192.696" rx="20.673" ry="17.46" id="c_4016"/>
<ellipse style="fill: rgb(255, 159, 63); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="911.352" cy="279.12" rx="20.673" ry="17.46" id="c_4017"/>
<ellipse style="fill: rgb(255, 63, 63); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="911.352" cy="366.862" rx="20.673" ry="17.46" id="c_4020"/>
<ellipse style="fill: rgb(255, 204, 63); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="762.685" cy="322.259" rx="13.582" ry="12.517" id="c_4018"/>
<ellipse style="fill: rgb(216, 216, 216); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="762.031" cy="234.094" rx="13.582" ry="12.517" id="c_4021"/>
</svg>

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 41 KiB

View File

@@ -3,7 +3,7 @@
<defs>
<bx:grid x="0" y="0" width="25" height="25"/>
</defs>
<rect y="10.407" width="972.648" height="439.023" style="fill: rgb(216, 216, 216); stroke: rgb(0, 0, 0); stroke-width: 0.763;" x="12.119"/>
<rect y="10.407" width="972.648" height="440.159" style="fill: rgb(216, 216, 216); stroke: rgb(0, 0, 0); stroke-width: 0.763;" x="12.119"/>
<g transform="matrix(0.826913, 0, 0, 0.698383, 74.03907, 53.375034)">
<ellipse style="stroke: rgb(0, 0, 0); fill: rgb(243, 243, 243);" cx="315" cy="183.068" rx="45" ry="45"/>
<ellipse style="stroke: rgb(0, 0, 0); stroke-width: 1; fill: rgb(243, 243, 243);" cx="315" cy="449.112" rx="45" ry="45"/>
@@ -26,27 +26,26 @@
<path style="fill: none; stroke: rgb(76, 76, 76); stroke-width: 1.222; transform-origin: 490.992px 230.229px;" d="M 646.097 240.002 L 676.271 240.002"/>
</g>
<path style="fill: none; stroke: rgb(0, 0, 0); stroke-width: 1.525; transform-origin: 443.701px 171.141px;" d="M 443.542 155.983 L 443.859 186.298"/>
<g transform="matrix(0.826913, 0, 0, 0.698383, 0.443817, 3.138935)">
<rect x="752" y="355.455" width="42.438" height="3.527" style="stroke: rgb(0, 0, 0); stroke-width: 1; fill: rgb(243, 243, 243);"/>
<rect x="756.328" y="359.271" width="34.034" height="53.968" style="stroke: rgb(0, 0, 0); stroke-width: 1; fill: rgb(243, 243, 243);"/>
<rect x="756.146" y="352.019" width="34.034" height="3.38" style="stroke: rgb(0, 0, 0); stroke-width: 1; fill: rgb(243, 243, 243);"/>
<ellipse style="stroke: rgb(0, 0, 0); stroke-width: 1; fill: rgb(243, 243, 243);" cx="773.446" cy="384.7" rx="11.751" ry="11.009"/>
<g>
<rect x="622.282" y="251.383" width="35.093" height="2.463" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(243, 243, 243);"/>
<g>
<rect x="625.861" y="254.048" width="28.143" height="37.69" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(243, 243, 243);"/>
<rect x="625.711" y="248.983" width="28.143" height="2.361" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(243, 243, 243);"/>
<ellipse style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(243, 243, 243);" cx="640.016" cy="271.807" rx="9.717" ry="7.689"/>
</g>
</g>
<rect x="461.861" y="211.956" width="62.018" height="9.968" style="stroke: rgb(0, 0, 0); fill: rgb(120, 231, 228); stroke-width: 0.763;"/>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 9px;" x="561" y="309.954" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">Heater Temp SP</text>
<rect x="461.861" y="221.924" width="62.018" height="17.46" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(244, 248, 248);"/>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="564.279" y="330.561" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">####.##</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="609.476" y="330.521" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">°F</text>
<rect x="461.424" y="242.149" width="62.018" height="9.968" style="stroke: rgb(0, 0, 0); fill: rgb(120, 231, 228); stroke-width: 0.763;"/>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 9px; stroke-width: 1;" x="567.471" y="352.188" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">Heater Temp</text>
<rect x="461.424" y="252.117" width="62.018" height="17.46" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(244, 248, 248);"/>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="563.75" y="373.795" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)" id="Dry1_HeatTempCelsius">####.##</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="608.947" y="373.755" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">°F</text>
<rect x="535.456" y="242.272" width="62.018" height="9.968" style="stroke: rgb(0, 0, 0); fill: rgb(120, 231, 228); stroke-width: 0.763;"/>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 9px; stroke-width: 1;" x="659" y="352.363" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">Heater Temp</text>
<rect x="535.456" y="252.24" width="62.018" height="17.46" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(244, 248, 248);"/>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="653.279" y="373.97" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">####.##</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="698.476" y="373.93" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">°F</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="698.476" y="373.93" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">°C</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1; font-weight: bold;" x="748" y="347.676" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">HEATER</text>
<path style="fill: none; stroke: rgb(0, 0, 0); stroke-width: 1.525; transform-origin: 678.512px 258.693px;" d="M 678.467 229.321 L 678.558 288.066" transform="matrix(0, 1.184039, -0.844567, 0, -0.000022, -0.000005)"/>
<path style="fill: none; stroke: rgb(0, 0, 0); stroke-width: 1.525; transform-origin: 703.162px 309.166px;" d="M 703.004 258.049 L 703.32 360.282"/>
@@ -108,12 +107,10 @@
<rect x="427.269" y="377.282" width="62.018" height="9.968" style="stroke: rgb(0, 0, 0); fill: rgb(120, 231, 228); stroke-width: 0.763;"/>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 9px; stroke-width: 1;" x="532.167" y="545.681" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">Dew Temp</text>
<rect x="427.269" y="387.25" width="62.018" height="17.46" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(244, 248, 248);"/>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="522.446" y="567.288" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">####.##</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="567.643" y="567.248" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">°C</text>
<rect x="427.27" y="412.201" width="62.018" height="9.968" style="stroke: rgb(0, 0, 0); fill: rgb(120, 231, 228); stroke-width: 0.763;"/>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 9px; stroke-width: 1;" x="532.168" y="595.681" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">Dew Temp</text>
<rect x="427.27" y="422.169" width="62.018" height="17.46" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(244, 248, 248);"/>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="522.447" y="617.288" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">####.##</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="567.644" y="617.248" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">°F</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 9px; stroke-width: 1; text-anchor: middle; font-weight: bolder;" x="602.463" y="573.003" transform="matrix(0.826913, 0, 0, 0.698383, 24.207672, -7.192523)">AIR<tspan x="602.4630126953125" dy="1em"></tspan>OUTLET</text>
<g transform="matrix(-0.387768, 0, 0, -0.200385, 743.634644, -199.991287)" style="transform-origin: 72.2405px 412.5px;">
@@ -177,17 +174,14 @@
<rect x="43.443" y="280.75" width="82.691" height="27.103" style="stroke: rgb(0, 0, 0); fill: rgb(120, 231, 228); stroke-width: 0.763;"/>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Bahnschrift; font-size: 14px; stroke-width: 1; font-weight: bold;" x="53.987" y="423.091" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">RUN HOUR</text>
<rect x="126.135" y="280.75" width="82.691" height="27.103" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(244, 248, 248);"/>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="159.05" y="424.106" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">####.##</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="225" y="424.066" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">H</text>
<rect x="43.443" y="308.191" width="82.691" height="27.103" style="stroke: rgb(0, 0, 0); fill: rgb(120, 231, 228); stroke-width: 0.763;"/>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Bahnschrift; font-size: 13px; stroke-width: 1; font-weight: bold;" x="53.987" y="461.382" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">PURGE HOUR</text>
<rect x="126.135" y="308.191" width="82.691" height="27.103" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(244, 248, 248);"/>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="159.05" y="463.397" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">####.##</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="225" y="463.357" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">H</text>
<rect x="43.443" y="333.129" width="82.691" height="27.103" style="stroke: rgb(0, 0, 0); fill: rgb(120, 231, 228); stroke-width: 0.763;"/>
<text style="fill: rgb(51, 51, 51); font-family: Bahnschrift; font-size: 12px; font-weight: 700; white-space: pre;" x="53.987" y="498.091" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">HEATER HOUR</text>
<rect x="126.135" y="333.129" width="82.691" height="27.103" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(244, 248, 248);"/>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="159.05" y="499.106" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">####.##</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="225" y="499.066" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">H</text>
<rect x="43.65" y="360.147" width="82.691" height="27.103" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(248, 213, 14);"/>
<text style="fill: rgb(51, 51, 51); font-family: Bahnschrift; font-size: 14px; font-weight: 700; white-space: pre; stroke-width: 1;" x="54.237" y="536.777" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">Alarm Info</text>
@@ -205,15 +199,12 @@
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="183.349" y="288.807" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">HTD</text>
<rect x="43.443" y="214.051" width="165.383" height="27.103" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(244, 248, 248);"/>
<text style="fill: rgb(51, 51, 51); font-family: Bahnschrift; font-size: 13px; font-weight: 700; white-space: pre; stroke-width: 1;" x="53.987" y="322.585" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">Step</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="177.05" y="323.6" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">####.##</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="233" y="323.56" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">s</text>
<rect x="43.443" y="241.422" width="82.691" height="27.103" style="stroke: rgb(0, 0, 0); fill: rgb(120, 231, 228); stroke-width: 0.763;"/>
<text style="fill: rgb(51, 51, 51); font-family: Bahnschrift; font-size: 14px; font-weight: 700; white-space: pre; stroke-width: 1;" x="54.237" y="364.271" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">Cycle Timer</text>
<rect x="126.341" y="241.068" width="82.691" height="27.103" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(244, 248, 248);"/>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="168.775" y="365.807" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">####.##</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="225.25" y="365.246" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">s</text>
<text style="fill: rgb(51, 51, 51); font-family: Bahnschrift; font-size: 13px; font-weight: 700; white-space: pre; stroke-width: 1;" x="141.894" y="324.069" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">Time</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="92.151" y="325.554" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">##</text>
<rect x="870.356" y="142.816" width="82.691" height="27.103" style="stroke: rgb(0, 0, 0); fill: rgb(120, 231, 228); stroke-width: 0.763;"/>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Bahnschrift; font-size: 14px; stroke-width: 1; font-weight: bold;" x="1060.06" y="224.103" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">Dryer Status</text>
<rect x="870.356" y="170.304" width="82.691" height="42.35" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(244, 248, 248);"/>
@@ -227,12 +218,33 @@
<rect x="870.356" y="344.9" width="82.691" height="42.35" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(244, 248, 248);"/>
<ellipse style="fill: rgb(216, 216, 216); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="911.702" cy="366.997" rx="20.673" ry="17.46"/>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Bahnschrift; font-size: 14px; stroke-width: 1; font-weight: bold;" x="380.451" y="296.591" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">REGEN</text>
<ellipse style="fill: rgb(216, 216, 216); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="334.165" cy="232.104" rx="13.582" ry="12.517"/>
<ellipse style="fill: rgb(216, 216, 216); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="336.418" cy="237.483" rx="13.582" ry="12.517"/>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Bahnschrift; font-size: 14px; stroke-width: 1; font-weight: bold;" x="379.214" y="423.395" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">DRYING</text>
<ellipse style="fill: rgb(216, 216, 216); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="335.623" cy="320.662" rx="13.582" ry="12.517"/>
<ellipse style="fill: rgb(255, 204, 63); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="335.418" cy="321.016" rx="13.582" ry="12.517"/>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Bahnschrift; font-size: 14px; stroke-width: 1; font-weight: bold;" x="897.237" y="299.014" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">REGEN</text>
<ellipse style="fill: rgb(216, 216, 216); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="761.502" cy="233.796" rx="13.582" ry="12.517"/>
<ellipse style="fill: rgb(255, 204, 63); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="761.772" cy="233.876" rx="13.582" ry="12.517"/>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Bahnschrift; font-size: 14px; stroke-width: 1; font-weight: bold;" x="896" y="425.818" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">DRYING</text>
<ellipse style="fill: rgb(216, 216, 216); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="762.96" cy="322.354" rx="13.582" ry="12.517"/>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Bahnschrift; font-size: 35px; stroke-width: 1; font-weight: bold;" x="348.875" y="78.242" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">AIR DRYER UNIT B (01-CL-10535-B)</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="183.349" y="288.807" transform="matrix(0.826913, 0, 0, 0.698383, 1.386371, 4.000207)">HTLS</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="183.349" y="288.807" transform="matrix(0.826913, 0, 0, 0.698383, -1.613663, 3.937793)">BLWR</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="522.447" y="617.288" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)" id="c_5005">####.##</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="522.446" y="567.288" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)" id="c_5004">####.##</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="653.279" y="373.97" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)" id="c_5001">####.##</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="563.75" y="373.795" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)" id="c_5002">####.##</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="564.279" y="330.561" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)" id="c_5003">####.##</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="159.05" y="424.106" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)" id="c_5009">####.##</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="159.05" y="463.397" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)" id="c_5010">####.##</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="159.05" y="499.106" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)" id="c_5011">####.##</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="177.05" y="323.6" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)" id="c_5008">####.##</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="168.775" y="365.807" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)" id="c_5007">####.##</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="92.151" y="325.554" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)" id="c_5006">##</text>
<ellipse style="fill: rgb(255, 204, 63); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="336.418" cy="237.483" rx="13.582" ry="12.517" id="c_5018"/>
<ellipse style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(255, 172, 63);" cx="640.283" cy="271.689" rx="9.717" ry="7.689" id="c_5019"/>
<ellipse style="fill: rgb(63, 255, 69); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="911.254" cy="192.696" rx="20.673" ry="17.46" id="c_5016"/>
<ellipse style="fill: rgb(255, 159, 63); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="911.352" cy="279.12" rx="20.673" ry="17.46" id="c_5017"/>
<ellipse style="fill: rgb(255, 63, 63); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="911.352" cy="366.862" rx="20.673" ry="17.46" id="c_5020"/>
<ellipse style="fill: rgb(216, 216, 216); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="335.623" cy="320.662" rx="13.582" ry="12.517" id="c_5021"/>
<ellipse style="fill: rgb(255, 204, 63); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="762.685" cy="322.259" rx="13.582" ry="12.517" id="c_5018"/>
<ellipse style="fill: rgb(216, 216, 216); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="762.031" cy="234.094" rx="13.582" ry="12.517" id="c_5021"/>
</svg>

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 41 KiB

View File

@@ -3,7 +3,7 @@
<defs>
<bx:grid x="0" y="0" width="25" height="25"/>
</defs>
<rect y="10.407" width="972.648" height="439.023" style="fill: rgb(216, 216, 216); stroke: rgb(0, 0, 0); stroke-width: 0.763;" x="12.119"/>
<rect y="10.407" width="972.648" height="440.159" style="fill: rgb(216, 216, 216); stroke: rgb(0, 0, 0); stroke-width: 0.763;" x="12.119"/>
<g transform="matrix(0.826913, 0, 0, 0.698383, 74.03907, 53.375034)">
<ellipse style="stroke: rgb(0, 0, 0); fill: rgb(243, 243, 243);" cx="315" cy="183.068" rx="45" ry="45"/>
<ellipse style="stroke: rgb(0, 0, 0); stroke-width: 1; fill: rgb(243, 243, 243);" cx="315" cy="449.112" rx="45" ry="45"/>
@@ -26,27 +26,26 @@
<path style="fill: none; stroke: rgb(76, 76, 76); stroke-width: 1.222; transform-origin: 490.992px 230.229px;" d="M 646.097 240.002 L 676.271 240.002"/>
</g>
<path style="fill: none; stroke: rgb(0, 0, 0); stroke-width: 1.525; transform-origin: 443.701px 171.141px;" d="M 443.542 155.983 L 443.859 186.298"/>
<g transform="matrix(0.826913, 0, 0, 0.698383, 0.443817, 3.138935)">
<rect x="752" y="355.455" width="42.438" height="3.527" style="stroke: rgb(0, 0, 0); stroke-width: 1; fill: rgb(243, 243, 243);"/>
<rect x="756.328" y="359.271" width="34.034" height="53.968" style="stroke: rgb(0, 0, 0); stroke-width: 1; fill: rgb(243, 243, 243);"/>
<rect x="756.146" y="352.019" width="34.034" height="3.38" style="stroke: rgb(0, 0, 0); stroke-width: 1; fill: rgb(243, 243, 243);"/>
<ellipse style="stroke: rgb(0, 0, 0); stroke-width: 1; fill: rgb(243, 243, 243);" cx="773.446" cy="384.7" rx="11.751" ry="11.009"/>
<g>
<rect x="622.282" y="251.383" width="35.093" height="2.463" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(243, 243, 243);"/>
<g>
<rect x="625.861" y="254.048" width="28.143" height="37.69" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(243, 243, 243);"/>
<rect x="625.711" y="248.983" width="28.143" height="2.361" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(243, 243, 243);"/>
<ellipse style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(243, 243, 243);" cx="640.016" cy="271.807" rx="9.717" ry="7.689"/>
</g>
</g>
<rect x="461.861" y="211.956" width="62.018" height="9.968" style="stroke: rgb(0, 0, 0); fill: rgb(120, 231, 228); stroke-width: 0.763;"/>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 9px;" x="561" y="309.954" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">Heater Temp SP</text>
<rect x="461.861" y="221.924" width="62.018" height="17.46" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(244, 248, 248);"/>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="564.279" y="330.561" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">####.##</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="609.476" y="330.521" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">°F</text>
<rect x="461.424" y="242.149" width="62.018" height="9.968" style="stroke: rgb(0, 0, 0); fill: rgb(120, 231, 228); stroke-width: 0.763;"/>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 9px; stroke-width: 1;" x="567.471" y="352.188" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">Heater Temp</text>
<rect x="461.424" y="252.117" width="62.018" height="17.46" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(244, 248, 248);"/>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="563.75" y="373.795" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)" id="Dry1_HeatTempCelsius">####.##</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="608.947" y="373.755" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">°F</text>
<rect x="535.456" y="242.272" width="62.018" height="9.968" style="stroke: rgb(0, 0, 0); fill: rgb(120, 231, 228); stroke-width: 0.763;"/>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 9px; stroke-width: 1;" x="659" y="352.363" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">Heater Temp</text>
<rect x="535.456" y="252.24" width="62.018" height="17.46" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(244, 248, 248);"/>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="653.279" y="373.97" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">####.##</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="698.476" y="373.93" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">°F</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="698.476" y="373.93" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">°C</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1; font-weight: bold;" x="748" y="347.676" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">HEATER</text>
<path style="fill: none; stroke: rgb(0, 0, 0); stroke-width: 1.525; transform-origin: 678.512px 258.693px;" d="M 678.467 229.321 L 678.558 288.066" transform="matrix(0, 1.184039, -0.844567, 0, -0.000022, -0.000005)"/>
<path style="fill: none; stroke: rgb(0, 0, 0); stroke-width: 1.525; transform-origin: 703.162px 309.166px;" d="M 703.004 258.049 L 703.32 360.282"/>
@@ -108,12 +107,10 @@
<rect x="427.269" y="377.282" width="62.018" height="9.968" style="stroke: rgb(0, 0, 0); fill: rgb(120, 231, 228); stroke-width: 0.763;"/>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 9px; stroke-width: 1;" x="532.167" y="545.681" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">Dew Temp</text>
<rect x="427.269" y="387.25" width="62.018" height="17.46" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(244, 248, 248);"/>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="522.446" y="567.288" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">####.##</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="567.643" y="567.248" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">°C</text>
<rect x="427.27" y="412.201" width="62.018" height="9.968" style="stroke: rgb(0, 0, 0); fill: rgb(120, 231, 228); stroke-width: 0.763;"/>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 9px; stroke-width: 1;" x="532.168" y="595.681" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">Dew Temp</text>
<rect x="427.27" y="422.169" width="62.018" height="17.46" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(244, 248, 248);"/>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="522.447" y="617.288" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">####.##</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="567.644" y="617.248" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">°F</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 9px; stroke-width: 1; text-anchor: middle; font-weight: bolder;" x="602.463" y="573.003" transform="matrix(0.826913, 0, 0, 0.698383, 24.207672, -7.192523)">AIR<tspan x="602.4630126953125" dy="1em"></tspan>OUTLET</text>
<g transform="matrix(-0.387768, 0, 0, -0.200385, 743.634644, -199.991287)" style="transform-origin: 72.2405px 412.5px;">
@@ -177,17 +174,14 @@
<rect x="43.443" y="280.75" width="82.691" height="27.103" style="stroke: rgb(0, 0, 0); fill: rgb(120, 231, 228); stroke-width: 0.763;"/>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Bahnschrift; font-size: 14px; stroke-width: 1; font-weight: bold;" x="53.987" y="423.091" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">RUN HOUR</text>
<rect x="126.135" y="280.75" width="82.691" height="27.103" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(244, 248, 248);"/>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="159.05" y="424.106" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">####.##</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="225" y="424.066" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">H</text>
<rect x="43.443" y="308.191" width="82.691" height="27.103" style="stroke: rgb(0, 0, 0); fill: rgb(120, 231, 228); stroke-width: 0.763;"/>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Bahnschrift; font-size: 13px; stroke-width: 1; font-weight: bold;" x="53.987" y="461.382" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">PURGE HOUR</text>
<rect x="126.135" y="308.191" width="82.691" height="27.103" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(244, 248, 248);"/>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="159.05" y="463.397" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">####.##</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="225" y="463.357" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">H</text>
<rect x="43.443" y="333.129" width="82.691" height="27.103" style="stroke: rgb(0, 0, 0); fill: rgb(120, 231, 228); stroke-width: 0.763;"/>
<text style="fill: rgb(51, 51, 51); font-family: Bahnschrift; font-size: 12px; font-weight: 700; white-space: pre;" x="53.987" y="498.091" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">HEATER HOUR</text>
<rect x="126.135" y="333.129" width="82.691" height="27.103" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(244, 248, 248);"/>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="159.05" y="499.106" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">####.##</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="225" y="499.066" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">H</text>
<rect x="43.65" y="360.147" width="82.691" height="27.103" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(248, 213, 14);"/>
<text style="fill: rgb(51, 51, 51); font-family: Bahnschrift; font-size: 14px; font-weight: 700; white-space: pre; stroke-width: 1;" x="54.237" y="536.777" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">Alarm Info</text>
@@ -205,15 +199,12 @@
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="183.349" y="288.807" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">HTD</text>
<rect x="43.443" y="214.051" width="165.383" height="27.103" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(244, 248, 248);"/>
<text style="fill: rgb(51, 51, 51); font-family: Bahnschrift; font-size: 13px; font-weight: 700; white-space: pre; stroke-width: 1;" x="53.987" y="322.585" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">Step</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="177.05" y="323.6" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">####.##</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="233" y="323.56" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">s</text>
<rect x="43.443" y="241.422" width="82.691" height="27.103" style="stroke: rgb(0, 0, 0); fill: rgb(120, 231, 228); stroke-width: 0.763;"/>
<text style="fill: rgb(51, 51, 51); font-family: Bahnschrift; font-size: 14px; font-weight: 700; white-space: pre; stroke-width: 1;" x="54.237" y="364.271" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">Cycle Timer</text>
<rect x="126.341" y="241.068" width="82.691" height="27.103" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(244, 248, 248);"/>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="168.775" y="365.807" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">####.##</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="225.25" y="365.246" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">s</text>
<text style="fill: rgb(51, 51, 51); font-family: Bahnschrift; font-size: 13px; font-weight: 700; white-space: pre; stroke-width: 1;" x="141.894" y="324.069" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">Time</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="92.151" y="325.554" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">##</text>
<rect x="870.356" y="142.816" width="82.691" height="27.103" style="stroke: rgb(0, 0, 0); fill: rgb(120, 231, 228); stroke-width: 0.763;"/>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Bahnschrift; font-size: 14px; stroke-width: 1; font-weight: bold;" x="1060.06" y="224.103" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">Dryer Status</text>
<rect x="870.356" y="170.304" width="82.691" height="42.35" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(244, 248, 248);"/>
@@ -227,12 +218,34 @@
<rect x="870.356" y="344.9" width="82.691" height="42.35" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(244, 248, 248);"/>
<ellipse style="fill: rgb(216, 216, 216); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="911.702" cy="366.997" rx="20.673" ry="17.46"/>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Bahnschrift; font-size: 14px; stroke-width: 1; font-weight: bold;" x="380.451" y="296.591" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">REGEN</text>
<ellipse style="fill: rgb(216, 216, 216); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="334.165" cy="232.104" rx="13.582" ry="12.517"/>
<ellipse style="fill: rgb(216, 216, 216); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="336.418" cy="237.483" rx="13.582" ry="12.517"/>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Bahnschrift; font-size: 14px; stroke-width: 1; font-weight: bold;" x="379.214" y="423.395" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">DRYING</text>
<ellipse style="fill: rgb(216, 216, 216); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="335.623" cy="320.662" rx="13.582" ry="12.517"/>
<ellipse style="fill: rgb(255, 204, 63); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="335.418" cy="321.016" rx="13.582" ry="12.517"/>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Bahnschrift; font-size: 14px; stroke-width: 1; font-weight: bold;" x="897.237" y="299.014" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">REGEN</text>
<ellipse style="fill: rgb(216, 216, 216); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="761.502" cy="233.796" rx="13.582" ry="12.517"/>
<ellipse style="fill: rgb(255, 204, 63); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="761.772" cy="233.876" rx="13.582" ry="12.517"/>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Bahnschrift; font-size: 14px; stroke-width: 1; font-weight: bold;" x="896" y="425.818" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">DRYING</text>
<ellipse style="fill: rgb(216, 216, 216); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="762.96" cy="322.354" rx="13.582" ry="12.517"/>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Bahnschrift; font-size: 35px; stroke-width: 1; font-weight: bold;" x="348.875" y="78.242" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">AIR DRYER UNIT C (01-CL-10539-C)</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="183.349" y="288.807" transform="matrix(0.826913, 0, 0, 0.698383, 1.386371, 4.000207)">HTLS</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="183.349" y="288.807" transform="matrix(0.826913, 0, 0, 0.698383, -1.613663, 3.937793)">BLWR</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="522.447" y="617.288" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)" id="c_6005">####.##</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="522.446" y="567.288" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)" id="c_6004">####.##</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="653.279" y="373.97" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)" id="c_6001">####.##</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="563.75" y="373.795" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)" id="c_6002">####.##</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="564.279" y="330.561" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)" id="c_6003">####.##</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="159.05" y="424.106" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)" id="c_6009">####.##</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="159.05" y="463.397" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)" id="c_6010">####.##</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="159.05" y="499.106" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)" id="c_6011">####.##</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="177.05" y="323.6" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)" id="c_6008">####.##</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="168.775" y="365.807" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)" id="c_6007">####.##</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="92.151" y="325.554" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)" id="c_6006">##</text>
<ellipse style="fill: rgb(255, 204, 63); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="336.418" cy="237.483" rx="13.582" ry="12.517" id="c_6018"/>
<ellipse style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(255, 172, 63);" cx="640.283" cy="271.689" rx="9.717" ry="7.689" id="c_6019"/>
<ellipse style="fill: rgb(63, 255, 69); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="911.254" cy="192.696" rx="20.673" ry="17.46" id="c_6016"/>
<ellipse style="fill: rgb(255, 159, 63); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="911.352" cy="279.12" rx="20.673" ry="17.46" id="c_6017"/>
<ellipse style="fill: rgb(255, 63, 63); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="911.352" cy="366.862" rx="20.673" ry="17.46" id="c_6020"/>
<ellipse style="fill: rgb(255, 204, 63); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="762.685" cy="322.259" rx="13.582" ry="12.517" id="c_6018"/>
<ellipse style="fill: rgb(216, 216, 216); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="335.623" cy="320.662" rx="13.582" ry="12.517" id="c_6021"/>
<ellipse style="fill: rgb(216, 216, 216); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="762.031" cy="234.094" rx="13.582" ry="12.517" id="c_6021"/>
</svg>

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 41 KiB

View File

@@ -30,18 +30,18 @@ instance.interceptors.response.use(
originalRequest._retry = true;
try {
console.log('🔄 Refresh token dipanggil...');
// console.log('🔄 Refresh token dipanggil...');
const refreshRes = await refreshApi.post('/auth/refresh-token');
const newAccessToken = refreshRes.data.data.accessToken;
localStorage.setItem('token', newAccessToken);
console.log('✅ Token refreshed successfully');
// console.log('✅ Token refreshed successfully');
// update token di header
instance.defaults.headers.common['Authorization'] = `Bearer ${newAccessToken}`;
originalRequest.headers['Authorization'] = `Bearer ${newAccessToken}`;
console.log('🔁 Retrying original request...');
// console.log('🔁 Retrying original request...');
return instance(originalRequest);
} catch (refreshError) {
console.error(
@@ -70,24 +70,35 @@ async function ApiRequest({ method = 'GET', params = {}, prefix = '/', token = t
},
};
const rawToken = localStorage.getItem('token');
const tokenRedirect = sessionStorage.getItem('token_redirect');
let rawToken = '';
if (tokenRedirect !== null) {
rawToken = tokenRedirect;
// console.log(`sessionStorage: ${tokenRedirect}`);
} else {
rawToken = localStorage.getItem('token');
// console.log(`localStorage: ${rawToken}`);
}
if (token && rawToken) {
const cleanToken = rawToken.replace(/"/g, '');
request.headers['Authorization'] = `Bearer ${cleanToken}`;
console.log('🔐 Sending request with token:', cleanToken.substring(0, 20) + '...');
// console.log('🔐 Sending request with token:', cleanToken.substring(0, 20) + '...');
} else {
console.warn('⚠️ No token found in localStorage');
}
console.log('📤 API Request:', { method, url: prefix, hasToken: !!rawToken });
// console.log('📤 API Request:', { method, url: prefix, hasToken: !!rawToken });
try {
const response = await instance(request);
console.log('✅ API Response:', {
url: prefix,
status: response.status,
statusCode: response.data?.statusCode,
});
// console.log('✅ API Response:', {
// url: prefix,
// status: response.status,
// statusCode: response.data?.statusCode,
// });
return { ...response, error: false };
} catch (error) {
const status = error?.response?.status || 500;
@@ -132,17 +143,10 @@ async function cekError(status, message = '') {
const SendRequest = async (queryParams) => {
try {
const response = await ApiRequest(queryParams);
console.log('📦 SendRequest response:', {
hasError: response.error,
status: response.status,
statusCode: response.data?.statusCode,
data: response.data,
});
// If ApiRequest returned error flag, return error structure
if (response.error) {
const errorMsg = response.data?.message || response.statusText || 'Request failed';
console.error('❌ SendRequest error response:', errorMsg);
// Return consistent error structure instead of empty array
return {

View File

@@ -2,7 +2,16 @@
import mqtt from 'mqtt';
const mqttUrl = `${import.meta.env.VITE_MQTT_SERVER ?? 'ws://localhost:1884'}`;
const topics = ['cod/air_dryer/air_dryer1'];
const topics = [
'PIU_COD/AIR_DRYER/OVERVIEW',
'PIU_COD/AIR_DRYER/AIR_DRYER_A',
'PIU_COD/AIR_DRYER/AIR_DRYER_B',
'PIU_COD/AIR_DRYER/AIR_DRYER_C',
'PIU_COD/COMPRESSOR/OVERVIEW',
'PIU_COD/COMPRESSOR/COMPRESSOR_A',
'PIU_COD/COMPRESSOR/COMPRESSOR_B',
'PIU_COD/COMPRESSOR/COMPRESSOR_C'
];
const options = {
keepalive: 30,
clientId: 'react_mqtt_' + Math.random().toString(16).substr(2, 8),

View File

@@ -146,7 +146,7 @@ const allItems = [
{
key: 'master-sparepart',
icon: <ToolOutlined style={{ fontSize: '19px' }} />,
label: <Link to="/master/sparepart">sparepart</Link>,
label: <Link to="/master/sparepart">Sparepart</Link>,
},
// {
// key: 'master-shift',

View File

@@ -0,0 +1,49 @@
import { useLocation, useNavigate, useParams } from 'react-router-dom';
import { useEffect, useState } from 'react';
import { verifyRedirect } from '../../api/auth';
import { encryptData } from '../../components/Global/Formatter';
import NotFound from './NotFound';
import Waiting from './Waiting';
import NotificationDetailTab from '../notificationDetail/IndexNotificationDetail';
export default function RedirectWa() {
const [idData, setIdData] = useState(0);
const [ready, setReady] = useState(0);
const location = useLocation();
// URLSearchParams untuk ambil query
const queryParams = new URLSearchParams(location.search);
const token = queryParams.get('token');
const handleInitForm = async (encodedToken) => {
const originalToken = decodeURIComponent(encodedToken);
// console.log(originalToken);
const response = await verifyRedirect({
tokenRedirect: originalToken,
});
console.log('tes', response);
const tokenResult = JSON.stringify(response.data?.data?.accessToken);
sessionStorage.setItem('token_redirect', tokenResult);
response.data.auth = true;
sessionStorage.setItem('session', encryptData(response?.data));
setIdData(response.data.data.idData);
setReady(1);
};
useEffect(() => {
handleInitForm(token);
}, [idData]);
if (ready == 0) return <Waiting />;
if (idData === 0) return <NotFound />;
return <NotificationDetailTab id={idData} />;
}

View File

@@ -267,9 +267,6 @@ const ListContact = memo(function ListContact(props) {
}
}
// Backend doesn't support is_active filter or order parameter
// Contact hanya supports: criteria, name, code, limit, page
const queryParams = new URLSearchParams();
Object.entries(searchParams).forEach(([key, value]) => {
if (value !== '' && value !== null && value !== undefined) {
@@ -309,11 +306,10 @@ const ListContact = memo(function ListContact(props) {
// Listen for saved contact data
useEffect(() => {
if (props.lastSavedContact) {
fetchContacts(); // Refetch all contacts when data is saved
fetchContacts();
}
}, [props.lastSavedContact]);
// Get contacts (already filtered by backend)
const getFilteredContacts = () => {
return filteredContacts;
};
@@ -326,7 +322,7 @@ const ListContact = memo(function ListContact(props) {
const showAddModal = () => {
props.setSelectedData(null);
props.setActionMode('add');
// Pass the current active tab to determine contact type
props.setContactType?.(activeTab);
};

View File

@@ -1,411 +0,0 @@
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import {
Layout,
Card,
Row,
Col,
Typography,
Space,
Button,
Spin,
Result,
Input,
} from 'antd';
import {
ArrowLeftOutlined,
CloseCircleFilled,
WarningFilled,
CheckCircleFilled,
InfoCircleFilled,
CloseOutlined,
BookOutlined,
ToolOutlined,
HistoryOutlined,
FilePdfOutlined,
PlusOutlined,
UserOutlined,
} from '@ant-design/icons';
import { getNotificationDetail } from '../../api/notification';
import UserHistoryModal from '../notification/component/UserHistoryModal';
import LogHistoryCard from '../notification/component/LogHistoryCard';
const { Content } = Layout;
const { Text, Paragraph, Link } = Typography;
// Transform API response to component format
const transformNotificationData = (apiData) => {
// Extract nested data
const errorCodeData = apiData.error_code;
const solutionData = errorCodeData?.solution?.[0] || {};
return {
id: `notification-${apiData.notification_error_id}-0`,
type: apiData.is_read ? 'resolved' : apiData.is_delivered ? 'warning' : 'critical',
title: errorCodeData?.error_code_name || 'Unknown Device',
issue: errorCodeData?.error_code || 'Unknown Error',
description: apiData.message_error_issue || 'No details available',
timestamp: apiData.created_at ? new Date(apiData.created_at).toLocaleString('id-ID', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
}) + ' WIB' : 'N/A',
location: solutionData?.solution_name || 'Location not specified',
details: apiData.message_error_issue || 'No details available',
isRead: apiData.is_read || false,
isDelivered: apiData.is_delivered || false,
isSend: apiData.is_send || false,
status: apiData.is_read ? 'Resolved' : apiData.is_delivered ? 'Delivered' : 'Pending',
tag: errorCodeData?.error_code,
plc: 'N/A', // PLC not available in API response
notification_error_id: apiData.notification_error_id,
error_code_id: apiData.error_code_id,
spareparts: errorCodeData?.spareparts || [],
solution: solutionData, // Include the solution data
error_code: errorCodeData,
};
};
const getIconAndColor = (type) => {
switch (type) {
case 'critical':
return { IconComponent: CloseCircleFilled, color: '#ff4d4f', bgColor: '#fff1f0' };
case 'warning':
return { IconComponent: WarningFilled, color: '#faad14', bgColor: '#fffbe6' };
case 'resolved':
return { IconComponent: CheckCircleFilled, color: '#52c41a', bgColor: '#f6ffed' };
default:
return { IconComponent: InfoCircleFilled, color: '#1890ff', bgColor: '#e6f7ff' };
}
};
const DetailNotificationTab = () => {
const { notificationId } = useParams(); // Mungkin perlu disesuaikan jika route berbeda
const navigate = useNavigate();
const [notification, setNotification] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [modalContent, setModalContent] = useState(null); // 'user', atau null
const [isAddingLog, setIsAddingLog] = useState(false);
const logHistoryData = [
{
id: 1,
timestamp: '04-11-2025 11:55 WIB',
addedBy: {
name: 'Budi Santoso',
phone: '081122334455',
},
description: 'Suhu sudah coba diturunkan, namun masih belum mencapai treshold aman.',
},
{
id: 2,
timestamp: '04-11-2025 11:45 WIB',
addedBy: {
name: 'John Doe',
phone: '081234567890',
},
description: 'Suhu sudah coba diturunkan, namun masih belum mencapai treshold aman.',
},
{
id: 3,
timestamp: '04-11-2025 11:40 WIB',
addedBy: {
name: 'Jane Smith',
phone: '087654321098',
},
description: 'Suhu sudah coba diturunkan, namun masih belum mencapai treshold aman.',
},
];
useEffect(() => {
const fetchDetail = async () => {
try {
setLoading(true);
// Fetch using the actual API
const response = await getNotificationDetail(notificationId);
if (response && response.data) {
const transformedData = transformNotificationData(response.data);
setNotification(transformedData);
} else {
throw new Error('Notification not found');
}
} catch (err) {
setError(err.message);
console.error('Error fetching notification detail:', err);
} finally {
setLoading(false);
}
};
fetchDetail();
}, [notificationId]);
if (loading) {
return (
<Layout style={{ minHeight: '100vh', display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
<Spin size="large" />
</Layout>
);
}
if (error || !notification) {
return (
<Layout style={{ minHeight: '100vh', display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
<Result
status="404"
title="404"
subTitle="Sorry, the notification you visited does not exist."
extra={<Button type="primary" onClick={() => navigate('/notification')}>Back to List</Button>}
/>
</Layout>
);
}
const { color } = getIconAndColor(notification.type);
return (
<Layout style={{ padding: '24px', backgroundColor: '#f0f2f5' }}>
<Content>
<Card>
<div style={{ borderBottom: '1px solid #f0f0f0', paddingBottom: '16px', marginBottom: '24px' }}>
<Row justify="space-between" align="middle">
<Col>
<Button
type="text"
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/notification')}
style={{ paddingLeft: 0 }}
>
Back to notification list
</Button>
</Col>
<Col>
<Button
icon={<UserOutlined />}
onClick={() => setModalContent('user')}
>
User History
</Button>
</Col>
</Row>
<div style={{ backgroundColor: '#f6ffed', border: '1px solid #b7eb8f', borderRadius: '4px', padding: '8px 16px', textAlign: 'center', marginTop: '16px' }}>
<Typography.Title level={4} style={{ margin: 0, color: '#262626' }}>
Error Notification Detail
</Typography.Title>
</div>
</div>
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<Row gutter={[24, 24]}>
{/* Kolom Kiri: Data Kompresor */}
<Col xs={24} lg={8}>
<Card size="small" style={{ height: '100%', borderColor: '#d4380d' }} bodyStyle={{ padding: '16px' }}>
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<Row gutter={16} align="middle">
<Col>
<div style={{ width: '32px', height: '32px', borderRadius: '50%', backgroundColor: '#d4380d', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#ffffff', fontSize: '18px' }}><CloseOutlined /></div>
</Col>
<Col>
<Text>{notification.title}</Text>
<div style={{ marginTop: '2px' }}><Text strong style={{ fontSize: '16px' }}>{notification.issue}</Text></div>
</Col>
</Row>
<div>
<Text strong>Plant Subsection</Text>
<div>{notification.location}</div>
<Text strong style={{ display: 'block', marginTop: '8px' }}>Time</Text>
<div>{notification.timestamp.split(' ')[1]} WIB</div>
</div>
<div style={{ border: '1px solid #d4380d', borderRadius: '4px', padding: '8px', background: 'linear-gradient(to right, #ffe7e6, #ffffff)' }}>
<Row justify="space-around" align="middle">
<Col><Text style={{ fontSize: '12px', color: color }}>Value</Text><div style={{ fontWeight: 'bold', fontSize: '16px', color: color }}>N/A</div></Col>
<Col><Text type="secondary" style={{ fontSize: '12px' }}>Treshold</Text><div style={{ fontWeight: 500 }}>N/A</div></Col>
</Row>
</div>
</Space>
</Card>
</Col>
{/* Kolom Tengah: Informasi Teknis */}
<Col xs={24} lg={8}>
<Card title="Informasi Teknis" size="small" style={{ height: '100%' }}>
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<div><Text strong>PLC</Text><div>{notification.plc || 'N/A'}</div></div>
<div><Text strong>Status</Text><div style={{ color: '#faad14', fontWeight: 500 }}>{notification.status}</div></div>
<div><Text strong>Tag</Text><div style={{ fontFamily: 'monospace', backgroundColor: '#f0f0f0', padding: '2px 6px', borderRadius: '4px', display: 'inline-block' }}>{notification.tag}</div></div>
</Space>
</Card>
</Col>
{/* Kolom Kanan: Log History */}
<Col xs={24} lg={8}>
<LogHistoryCard notificationData={notification} />
</Col>
</Row>
<Row gutter={[16, 16]}>
<Col xs={24} md={8}><Card hoverable bodyStyle={{ padding: '12px', textAlign: 'center' }}><Space><BookOutlined style={{ fontSize: '16px', color: '#1890ff' }} /><Text strong style={{ fontSize: '16px', color: '#262626' }}>Handling Guideline</Text></Space></Card></Col>
<Col xs={24} md={8}><Card hoverable bodyStyle={{ padding: '12px', textAlign: 'center' }}><Space><ToolOutlined style={{ fontSize: '16px', color: '#1890ff' }} /><Text strong style={{ fontSize: '16px', color: '#262626' }}>Spare Part</Text></Space></Card></Col>
<Col xs={24} md={8} style={{ cursor: 'pointer' }}><Card hoverable bodyStyle={{ padding: '12px', textAlign: 'center' }}><Space><HistoryOutlined style={{ fontSize: '16px', color: '#1890ff' }} /><Text strong style={{ fontSize: '16px', color: '#262626' }}>Log Activity</Text></Space></Card></Col>
</Row>
<Row gutter={[16, 16]}>
<Col xs={24} md={8}>
<Card size="small" title="Guideline Documents" style={{ height: '100%' }}>
<Space direction="vertical" size="small" style={{ width: '100%' }}>
{notification.solution && (
<>
{notification.solution.path_document ? (
<Card size="small" bodyStyle={{ padding: '8px 12px' }} hoverable extra={<Text type="secondary" style={{ fontSize: '10px' }}>PDF</Text>}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<Text style={{ fontSize: '12px', color: '#262626' }}><FilePdfOutlined style={{ marginRight: '8px' }} /> {notification.solution.file_upload_name || 'Solution Document.pdf'}</Text>
<Link href={notification.solution.path_document} target="_blank" style={{ fontSize: '12px', display: 'block' }}>lihat disini</Link>
</div>
</div>
</Card>
) : null}
{notification.solution.type_solution === 'text' && notification.solution.text_solution ? (
<Card size="small" bodyStyle={{ padding: '8px 12px' }} extra={<Text type="secondary" style={{ fontSize: '10px' }}>{notification.solution.type_solution.toUpperCase()}</Text>}>
<Paragraph style={{ fontSize: '12px', margin: 0 }}>
<Text strong>{notification.issue}:</Text> {notification.solution.text_solution}
</Paragraph>
</Card>
) : null}
</>
)}
{!notification.solution && (
<div style={{ textAlign: 'center', padding: '20px', color: '#8c8c8c' }}>
Tidak ada dokumen solusi tersedia
</div>
)}
</Space>
</Card>
</Col>
<Col xs={24} md={8}>
<Card size="small" title="Required Spare Parts" style={{ height: '100%' }}>
<Space direction="vertical" size="small" style={{ width: '100%' }}>
{notification.spareparts && notification.spareparts.length > 0 ? (
notification.spareparts.map((sparepart, index) => (
<Card size="small" key={index} bodyStyle={{ padding: '12px' }} hoverable>
<Row gutter={16} align="top">
<Col span={7} style={{ textAlign: 'center' }}>
<div style={{ width: '100%', height: '60px', backgroundColor: '#f0f0f0', display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: '4px', marginBottom: '8px' }}>
<ToolOutlined style={{ fontSize: '24px', color: '#bfbfbf' }} />
</div>
<Text style={{
fontSize: '12px',
color: sparepart.sparepart_stok === 'Available' || sparepart.sparepart_stok === 'available' ? '#52c41a' : '#ff4d4f',
fontWeight: 500
}}>
{sparepart.sparepart_stok}
</Text>
</Col>
<Col span={17}>
<Space direction="vertical" size={4} style={{ width: '100%' }}>
<Text strong>{sparepart.sparepart_name}</Text>
<Paragraph style={{ fontSize: '12px', margin: 0, color: '#595959' }}>
{sparepart.sparepart_description || 'Deskripsi tidak tersedia'}
</Paragraph>
<div style={{
border: '1px solid #d9d9d9',
borderRadius: '4px',
padding: '4px 8px',
fontSize: '11px',
color: '#8c8c8c',
marginTop: '8px'
}}>
Kode: {sparepart.sparepart_code} | Qty: {sparepart.sparepart_qty} | Unit: {sparepart.sparepart_unit}
</div>
</Space>
</Col>
</Row>
</Card>
))
) : (
<div style={{ textAlign: 'center', padding: '20px', color: '#8c8c8c' }}>
Tidak ada spare parts terkait
</div>
)}
</Space>
</Card>
</Col>
<Col span={8}>
<Card size="small" style={{ height: '100%' }}>
<Space direction="vertical" size="small" style={{ width: '100%' }}>
<Card
size="small"
bodyStyle={{
padding: '8px 12px',
backgroundColor: isAddingLog ? '#fafafa' : '#fff',
}}
>
<Space
direction="vertical"
style={{ width: '100%' }}
size="small"
>
{isAddingLog && (
<>
<Text strong style={{ fontSize: '12px' }}>
Add New Log / Update Progress
</Text>
<Input.TextArea
rows={2}
placeholder="Tuliskan update penanganan di sini..."
/>
</>
)}
<Button
type={isAddingLog ? 'primary' : 'dashed'}
size="small"
block
icon={!isAddingLog && <PlusOutlined />}
onClick={() => setIsAddingLog(!isAddingLog)}
>
{isAddingLog ? 'Submit Log' : 'Add Log'}
</Button>
</Space>
</Card>
{logHistoryData.map((log) => (
<Card
key={log.id}
size="small"
bodyStyle={{ padding: '8px 12px' }}
>
<Paragraph
style={{ fontSize: '12px', margin: 0 }}
ellipsis={{ rows: 2 }}
>
<Text strong>{log.addedBy.name}:</Text>{' '}
{log.description}
</Paragraph>
<Text type="secondary" style={{ fontSize: '11px' }}>
{log.timestamp}
</Text>
</Card>
))}
</Space>
</Card>
</Col>
</Row>
</Space>
</Card>
</Content>
<UserHistoryModal
visible={modalContent === 'user'}
onCancel={() => setModalContent(null)}
notificationData={notification}
/>
</Layout>
);
};
export default DetailNotificationTab;

View File

@@ -8,7 +8,7 @@ import filePathSvg from '../../assets/svg/air_dryer_A_rev.svg';
const { Text } = Typography;
// const filePathSvg = '/src/assets/svg/air_dryer_A_rev.svg';
const topicMqtt = 'PIU_GGCP/Devices/PB';
const topicMqtt = 'PIU_COD/AIR_DRYER/AIR_DRYER_A';
const SvgAirDryerA = () => {
return (

View File

@@ -8,7 +8,7 @@ import filePathSvg from '../../assets/svg/air_dryer_B_rev.svg';
const { Text } = Typography;
// const filePathSvg = '/src/assets/svg/air_dryer_B_rev.svg';
const topicMqtt = 'PIU_GGCP/Devices/PB';
const topicMqtt = 'PIU_COD/AIR_DRYER/AIR_DRYER_B';
const SvgAirDryerB = () => {
return (

View File

@@ -8,7 +8,7 @@ import filePathSvg from '../../assets/svg/air_dryer_C_rev.svg';
const { Text } = Typography;
// const filePathSvg = '/src/assets/svg/air_dryer_C_rev.svg';
const topicMqtt = 'PIU_GGCP/Devices/PB';
const topicMqtt = 'PIU_COD/AIR_DRYER/AIR_DRYER_C';
const SvgAirDryerC = () => {
return (

View File

@@ -8,7 +8,7 @@ import filePathSvg from '../../assets/svg/compressorA_rev.svg';
const { Text } = Typography;
// const filePathSvg = '/src/assets/svg/test-new.svg';
const topicMqtt = 'PIU_GGCP/Devices/PB';
const topicMqtt = 'PIU_COD/COMPRESSOR/COMPRESSOR_A';
const SvgCompressorA = () => {
return (

View File

@@ -6,7 +6,7 @@ import SvgViewer from './SvgViewer';
import filePathSvg from '../../assets/svg/compressorB_rev.svg';
const { Text } = Typography;
const topicMqtt = 'cod/air_dryer/air_dryer1';
const topicMqtt = 'PIU_COD/COMPRESSOR/COMPRESSOR_B';
const SvgCompressorB = () => {
return (

View File

@@ -8,7 +8,7 @@ import filePathSvg from '../../assets/svg/compressorC_rev.svg';
const { Text } = Typography;
// const filePathSvg = '/src/assets/svg/test-new.svg';
const topicMqtt = 'PIU_GGCP/Devices/PB';
const topicMqtt = 'PIU_COD/COMPRESSOR/COMPRESSOR_C';
const SvgCompressorC = () => {
return (

View File

@@ -8,7 +8,7 @@ import filePathSvg from '../../assets/svg/overview-airdryer.svg';
const { Text } = Typography;
// const filePathSvg = '/src/assets/svg/test-new.svg';
const topicMqtt = 'PIU_GGCP/Devices/PB';
const topicMqtt = 'PIU_COD/AIR_DRYER/OVERVIEW';
const SvgOverviewAirDryer = () => {
return (

View File

@@ -8,7 +8,7 @@ import filePathSvg from '../../assets/svg/overview-compressor.svg';
const { Text } = Typography;
// const filePathSvg = '/src/assets/svg/test-new.svg';
const topicMqtt = 'PIU_GGCP/Devices/PB';
const topicMqtt = 'PIU_COD/COMPRESSOR/OVERVIEW';
const SvgOverviewCompressor = () => {
return (

View File

@@ -352,7 +352,7 @@ const ListJadwalShift = memo(function ListJadwalShift(props) {
<Title level={3}>Jadwal Shift</Title>
<Divider />
<Row>
{/* <Row>
<Col xs={24}>
<Row justify="end" align="middle" gutter={[8, 8]}>
<Col xs={24} sm={24} md={12} lg={12}>
@@ -383,7 +383,7 @@ const ListJadwalShift = memo(function ListJadwalShift(props) {
</Col>
</Row>
</Col>
</Row>
</Row> */}
<div style={{ marginTop: '24px' }}>
{loading ? (

File diff suppressed because it is too large Load Diff

View File

@@ -1,463 +0,0 @@
import { useEffect, useState } from 'react';
import { useNavigate, useParams, useLocation } from 'react-router-dom';
import {
Card,
Typography,
Button,
Form,
Row,
Col,
Spin,
Upload,
} from 'antd';
import { ArrowLeftOutlined, UploadOutlined } from '@ant-design/icons';
import { getBrandById, getErrorCodeById, updateBrand, getErrorCodesByBrandId, createErrorCode, updateErrorCode } from '../../../api/master-brand';
import { useBreadcrumb } from '../../../layout/LayoutBreadcrumb';
import ErrorCodeSimpleForm from './component/ErrorCodeSimpleForm';
import SolutionForm from './component/SolutionForm';
import { useSolutionLogic } from './hooks/solution';
import SingleSparepartSelect from './component/SingleSparepartSelect';
import { NotifAlert, NotifOk } from '../../../components/Global/ToastNotif';
const { Title } = Typography;
const AddEditErrorCode = () => {
const navigate = useNavigate();
const { brandId: routeBrandId, errorCodeId } = useParams();
const { setBreadcrumbItems } = useBreadcrumb();
const location = useLocation();
const currentBrandId = routeBrandId;
const isFromAddBrand = location.pathname.includes('/master/brand-device/') && location.pathname.includes('/error-code/') &&
(location.pathname.includes('/add') || (location.pathname.includes('/edit/') && !location.pathname.includes('/edit/')));
// Forms
const [errorCodeForm] = Form.useForm();
const [solutionForm] = Form.useForm();
const [loading, setLoading] = useState(false);
const [confirmLoading, setConfirmLoading] = useState(false);
const [errorCodeIcon, setErrorCodeIcon] = useState(null);
const [selectedSparepartIds, setSelectedSparepartIds] = useState([]);
const [isEdit, setIsEdit] = useState(false);
const [fileList, setFileList] = useState([]);
const {
solutionFields,
solutionTypes,
solutionStatuses,
solutionsToDelete,
firstSolutionValid,
handleAddSolutionField,
handleRemoveSolutionField,
handleSolutionTypeChange,
handleSolutionStatusChange,
resetSolutionFields,
getSolutionData,
setSolutionsForExistingRecord,
} = useSolutionLogic(solutionForm);
useEffect(() => {
const isEditMode = errorCodeId && errorCodeId !== 'add';
setIsEdit(isEditMode);
// Initialize solution form with proper structure
if (!isEditMode) {
resetSolutionFields();
}
setBreadcrumbItems([
{
title: <span style={{ fontSize: '14px', fontWeight: 'bold' }}> Master</span>
},
{
title: (
<span
style={{ fontSize: '14px', fontWeight: 'bold', cursor: 'pointer' }}
onClick={() => navigate('/master/brand-device')}
>
Brand Device
</span>
),
},
{
title: (
<span
style={{ fontSize: '14px', fontWeight: 'bold', cursor: 'pointer' }}
onClick={() => navigate(`/master/brand-device/edit/${currentBrandId}?tab=error-codes`)}
>
Edit Brand Device
</span>
),
},
{
title: (
<span style={{ fontSize: '14px', fontWeight: 'bold' }}>
{isEditMode ? 'Edit Error Code' : 'Add Error Code'}
</span>
),
},
]);
if (isEditMode && errorCodeId) {
const tempId = errorCodeId.startsWith('existing_') ? errorCodeId : `existing_${errorCodeId}`;
loadExistingErrorCode(tempId);
}
}, [currentBrandId, errorCodeId, navigate, setBreadcrumbItems]);
const loadExistingErrorCode = async (tempId) => {
try {
setLoading(true);
let errorIdToUse = tempId;
if (tempId.startsWith('existing_')) {
errorIdToUse = tempId.replace('existing_', '');
}
const errorCodeResponse = await getErrorCodeById(errorIdToUse);
if (errorCodeResponse && errorCodeResponse.statusCode === 200) {
const errorData = errorCodeResponse.data;
if (errorData) {
errorCodeForm.setFieldsValue({
error_code: errorData.error_code,
error_code_name: errorData.error_code_name || '',
error_code_description: errorData.error_code_description || '',
error_code_color: errorData.error_code_color || '#000000',
status: errorData.is_active !== false,
});
if (errorData.path_icon) {
setErrorCodeIcon({
name: errorData.path_icon.split('/').pop(),
uploadPath: errorData.path_icon,
url: errorData.path_icon,
});
}
if (errorData.solution && errorData.solution.length > 0) {
setSolutionsForExistingRecord(errorData.solution, solutionForm);
}
if (errorData.spareparts && errorData.spareparts.length > 0) {
const sparepartIds = errorData.spareparts.map(sp => sp.sparepart_id);
setSelectedSparepartIds(sparepartIds);
}
}
} else {
errorCodeForm.setFieldsValue({
error_code: '',
error_code_name: '',
error_code_description: '',
error_code_color: '#000000',
status: true,
});
NotifAlert({
icon: 'warning',
title: 'Peringatan',
message: 'Error code not found. Creating new error code.',
});
}
} catch (error) {
NotifAlert({
icon: 'error',
title: 'Error',
message: 'Failed to load error code data',
});
} finally {
setLoading(false);
}
};
const handleSave = async () => {
try {
await errorCodeForm.validateFields();
const solutionData = getSolutionData();
// Validate that at least one solution exists and is valid
if (!solutionData || solutionData.length === 0) {
NotifAlert({
icon: 'warning',
title: 'Perhatian',
message: 'Harap lengkapi minimal 1 solution',
});
return;
}
// Validate solutions based on their type
const invalidSolutions = solutionData.filter(solution => {
if (solution.type_solution === 'text') {
return !solution.text_solution || solution.text_solution.trim() === '';
} else if (solution.type_solution === 'file') {
return !solution.path_solution || solution.path_solution.trim() === '';
}
return false;
});
if (invalidSolutions.length > 0) {
const invalidNames = invalidSolutions.map(s => s.solution_name).join(', ');
NotifAlert({
icon: 'warning',
title: 'Perhatian',
message: `Harap lengkapi solution berikut:\n${invalidSolutions.map(s =>
`- ${s.solution_name}: ${s.type_solution === 'text' ? 'Text solution wajib diisi' : 'File wajib diupload'}`
).join('\n')}`,
});
return;
}
const errorCodeValues = errorCodeForm.getFieldsValue();
setConfirmLoading(true);
try {
const payload = {
error_code_name: errorCodeValues.error_code_name,
error_code_description: errorCodeValues.error_code_description || '',
error_code_color: errorCodeValues.error_code_color || '#000000',
path_icon: errorCodeIcon?.path_icon || errorCodeIcon?.uploadPath || '',
is_active: errorCodeValues.status !== undefined ? errorCodeValues.status : true,
solution: solutionData || [],
spareparts: selectedSparepartIds || []
};
if (!isEdit) {
payload.error_code = errorCodeValues.error_code;
}
let response;
if (isEdit && errorCodeId) {
response = await updateErrorCode(currentBrandId, errorCodeId, payload);
} else {
response = await createErrorCode(currentBrandId, payload);
}
if (response && (response.statusCode === 200 || response.statusCode === 201)) {
NotifOk({
icon: 'success',
title: 'Berhasil',
message: isEdit ? 'Error Code berhasil diupdate!' : 'Error Code berhasil ditambahkan!',
});
if (isFromAddBrand) {
navigate(`/master/brand-device/add`);
} else {
navigate(`/master/brand-device/edit/${currentBrandId}?tab=error-codes`);
}
} else {
NotifAlert({
icon: 'error',
title: 'Gagal',
message: response?.message || 'Gagal menyimpan error code',
});
}
} catch (error) {
NotifAlert({
icon: 'error',
title: 'Gagal',
message: error.message || 'Gagal menyimpan error code. Silakan coba lagi.',
});
} finally {
setConfirmLoading(false);
}
} catch (error) {
NotifAlert({
icon: 'error',
title: 'Error',
message: 'Gagal menyimpan error code. Silakan coba lagi.',
});
}
};
const handleCancel = () => {
if (isFromAddBrand) {
navigate(`/master/brand-device/add`);
} else {
navigate(`/master/brand-device/edit/${currentBrandId}?tab=error-codes`);
}
};
const handleErrorCodeIconUpload = (iconData) => {
if (!iconData || !iconData.uploadPath) {
return null;
}
const formattedIconData = {
name: iconData.name,
uploadPath: iconData.uploadPath,
url: iconData.uploadPath,
};
setErrorCodeIcon(formattedIconData);
return formattedIconData;
};
const handleErrorCodeIconRemove = () => {
setErrorCodeIcon(null);
};
const handleSolutionFileUpload = (fileObject) => {
// Handle solution file upload if needed
};
const resetForm = () => {
errorCodeForm.resetFields();
errorCodeForm.setFieldsValue({
status: true,
});
setErrorCodeIcon(null);
resetSolutionFields();
setSelectedSparepartIds([]);
};
return (
<Card>
{/* Header */}
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 24
}}>
<Title level={4} style={{ margin: 0 }}>
{isEdit ? 'Edit Error Code' : 'Add Error Code'}
</Title>
<Button
icon={<ArrowLeftOutlined />}
onClick={handleCancel}
>
Back to Brand Device
</Button>
</div>
{/* Content */}
<div style={{ position: 'relative', minHeight: 500 }}>
{loading && (
<div
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(255, 255, 255, 0.7)',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
zIndex: 10,
}}
>
<Spin size="large" />
</div>
)}
<Row gutter={[24, 24]}>
{/* Error Code Form */}
<Col xs={24} lg={8}>
<Card
title="Error Code Details"
size="small"
style={{ height: 'fit-content' }}
>
<Form
form={errorCodeForm}
layout="vertical"
initialValues={{
status: true,
error_code_color: '#000000',
}}
>
<ErrorCodeSimpleForm
errorCodeForm={errorCodeForm}
isErrorCodeFormReadOnly={false}
errorCodeIcon={errorCodeIcon}
onErrorCodeIconUpload={handleErrorCodeIconUpload}
onErrorCodeIconRemove={handleErrorCodeIconRemove}
isEdit={isEdit}
/>
</Form>
</Card>
</Col>
{/* Solutions Form */}
<Col xs={24} lg={8}>
<Card
title="Solutions"
size="small"
style={{ height: 'fit-content' }}
>
<Form
form={solutionForm}
layout="vertical"
initialValues={{}}
>
<SolutionForm
solutionForm={solutionForm}
solutionFields={solutionFields}
solutionTypes={solutionTypes}
solutionStatuses={solutionStatuses}
firstSolutionValid={firstSolutionValid}
checkFirstSolutionValid={() => {
// console.log('🔍 checkFirstSolutionValid function:', typeof checkFirstSolutionValid);
return checkFirstSolutionValid();
}}
onAddSolutionField={handleAddSolutionField}
onRemoveSolutionField={handleRemoveSolutionField}
onSolutionTypeChange={handleSolutionTypeChange}
onSolutionStatusChange={handleSolutionStatusChange}
onSolutionFileUpload={handleSolutionFileUpload}
onFileView={(fileData) => {
if (fileData && fileData.url) {
window.open(fileData.url, '_blank');
}
}}
isReadOnly={false}
/>
</Form>
</Card>
</Col>
{/* Sparepart Selection */}
<Col xs={24} lg={8}>
<Card
title="Spareparts"
size="small"
style={{ height: 'fit-content' }}
>
<SingleSparepartSelect
selectedSparepartIds={selectedSparepartIds}
onSparepartChange={setSelectedSparepartIds}
isReadOnly={false}
brandId={currentBrandId}
/>
</Card>
</Col>
</Row>
{/* Save Button */}
<div style={{ marginTop: 24, textAlign: 'right' }}>
<Button
type="primary"
loading={confirmLoading}
style={{
backgroundColor: '#23A55A',
borderColor: '#23A55A',
}}
onClick={handleSave}
>
{isEdit ? 'Update Error Code' : 'Save Error Code'}
</Button>
</div>
</div>
</Card>
);
};
export default AddEditErrorCode;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -138,12 +138,6 @@ const ViewFilePage = () => {
const targetPhase = savedPhase ? parseInt(savedPhase) : 1;
console.log({
savedPhase,
targetPhase,
id: fallbackId || id
});
navigate(`/master/brand-device/edit/${fallbackId || id}`, {
state: { phase: targetPhase, fromFileViewer: true },
replace: true
@@ -174,9 +168,7 @@ const ViewFilePage = () => {
const isImage = ['jpg', 'jpeg', 'png', 'gif'].includes(fileExtension);
const isPdf = fileExtension === 'pdf';
// const fileUrl = loading ? null : getFileUrl(getFolderFromFileType(fallbackFileType || fileType), actualFileName);
// Show placeholder when loading
if (loading) {
return (
<div style={{ textAlign: 'center', padding: '50px' }}>
@@ -318,7 +310,6 @@ const ViewFilePage = () => {
</Button>
<Button
onClick={() => {
// Retry loading PDF
setPdfLoading(true);
const folder = getFolderFromFileType('pdf');
getFile(folder, actualFileName)
@@ -421,7 +412,7 @@ const ViewFilePage = () => {
</Space>
</div>
{/* File type indicator */}
<div style={{ marginBottom: '16px' }}>
<div style={{
display: 'inline-block',
@@ -438,7 +429,7 @@ const ViewFilePage = () => {
</div>
<div style={{ position: 'relative' }}>
{/* Overlay with blur effect during loading */}
{loading && (
<div style={{
position: 'absolute',

View File

@@ -7,9 +7,19 @@ const BrandForm = ({
form,
onValuesChange,
isEdit = false,
brandInfo = null,
readOnly = false,
}) => {
const isActive = Form.useWatch('is_active', form) ?? true;
React.useEffect(() => {
if (brandInfo && brandInfo.brand_code) {
form.setFieldsValue({
brand_code: brandInfo.brand_code
});
}
}, [brandInfo, form]);
return (
<div>
<Form
@@ -29,6 +39,7 @@ const BrandForm = ({
<Form.Item name="is_active" valuePropName="checked" noStyle>
<Switch
style={{ backgroundColor: isActive ? '#23A55A' : '#bfbfbf' }}
disabled={readOnly}
/>
</Form.Item>
<Text style={{ marginLeft: 8 }}>
@@ -52,18 +63,18 @@ const BrandForm = ({
<Form.Item
label="Brand Name"
name="brand_name"
rules={[{ required: true, message: 'Brand Name wajib diisi!' }]}
rules={[{ required: !readOnly, message: 'Brand Name wajib diisi!' }]}
>
<Input />
<Input disabled={readOnly} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
label="Manufacturer"
name="brand_manufacture"
rules={[{ required: true, message: 'Manufacturer wajib diisi!' }]}
rules={[{ required: !readOnly, message: 'Manufacturer wajib diisi!' }]}
>
<Input placeholder="Enter Manufacturer" />
<Input placeholder="Enter Manufacturer" disabled={readOnly} />
</Form.Item>
</Col>
</Row>
@@ -71,12 +82,12 @@ const BrandForm = ({
<Row gutter={16}>
<Col span={12}>
<Form.Item label="Brand Type" name="brand_type">
<Input placeholder="Enter Brand Type (Optional)" />
<Input placeholder="Enter Brand Type (Optional)" disabled={readOnly} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="Model" name="brand_model">
<Input placeholder="Enter Model (Optional)" />
<Input placeholder="Enter Model (Optional)" disabled={readOnly} />
</Form.Item>
</Col>
</Row>

View File

@@ -20,7 +20,6 @@ const CustomSparepartCard = ({
}) => {
const [previewModalVisible, setPreviewModalVisible] = useState(false);
// Construct image source with proper fallback
const getImageSrc = () => {
if (sparepart.sparepart_foto) {
if (sparepart.sparepart_foto.startsWith('http')) {
@@ -47,6 +46,11 @@ const CustomSparepartCard = ({
}
};
const truncateText = (text, maxLength = 15) => {
if (!text) return 'Unnamed';
return text.length > maxLength ? `${text.substring(0, maxLength)}...` : text;
};
const handleCardClick = () => {
if (!isReadOnly && onCardClick) {
onCardClick(sparepart);
@@ -56,7 +60,6 @@ const CustomSparepartCard = ({
const getCardActions = () => {
const actions = [];
// Preview button
if (showPreview) {
actions.push(
<Button
@@ -73,7 +76,6 @@ const CustomSparepartCard = ({
);
}
// Delete button without confirmation
if (showDelete && !isReadOnly) {
actions.push(
<Button
@@ -93,7 +95,6 @@ const CustomSparepartCard = ({
return actions;
};
// Get card styling based on size
const getCardStyle = () => {
const baseStyle = {
borderRadius: '12px',
@@ -129,200 +130,90 @@ const CustomSparepartCard = ({
return (
<>
<Card
hoverable={!!onCardClick && !isReadOnly}
style={getCardStyle()}
bodyStyle={{
padding: 0,
height: 'calc(100% - 48px)',
<div
style={{
border: '1px solid #f0f0f0',
borderRadius: '6px',
padding: '12px 16px',
marginBottom: '8px',
backgroundColor: 'white',
cursor: onCardClick && !isReadOnly ? 'pointer' : 'default',
transition: 'all 0.2s ease',
display: 'flex',
flexDirection: 'column'
alignItems: 'center',
justifyContent: 'space-between'
}}
actions={getCardActions()}
onClick={handleCardClick}
>
<div style={{ display: 'flex', height: '100%' }}>
{/* Image Section */}
<div style={{
width: size === 'small' ? '90px' : '110px',
flexShrink: 0,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: size === 'small' ? '12px' : '16px',
backgroundColor: '#fafafa',
borderRight: '1px solid #f0f0f0',
position: 'relative'
}}>
{sparepart.sparepart_item_type && (
<Tag
color="blue"
style={{
marginBottom: '8px',
fontSize: '10px',
fontWeight: 500
}}
>
{sparepart.sparepart_item_type}
</Tag>
)}
<div
style={{
width: size === 'small' ? '65px' : '75px',
height: size === 'small' ? '65px' : '75px',
backgroundColor: '#f0f0f0',
borderRadius: '8px',
overflow: 'hidden',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
border: '1px solid #e8e8e8'
}}
>
<img
src={getImageSrc()}
alt={sparepart.sparepart_name || 'Sparepart'}
style={{
width: '100%',
height: '100%',
objectFit: 'cover'
}}
onError={(e) => {
e.target.src = 'https://via.placeholder.com/75';
}}
/>
</div>
{/* Selection Indicator */}
{isSelected && (
<div
style={{
position: 'absolute',
top: '8px',
right: '8px',
backgroundColor: '#52c41a',
borderRadius: '50%',
width: '18px',
height: '18px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 2px 4px rgba(0,0,0,0.15)',
zIndex: 2
}}
>
<CheckOutlined style={{ color: 'white', fontSize: '10px' }} />
</div>
)}
</div>
{/* Content Section */}
<div style={{
flex: 1,
padding: size === 'small' ? '12px' : '16px',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
overflow: 'hidden'
}}>
<div style={{ flex: 1, overflow: 'hidden' }}>
<Title
level={size === 'small' ? 5 : 4}
style={{
margin: `0 0 ${size === 'small' ? '6px' : '8px'} 0`,
fontSize: size === 'small' ? '12px' : '14px',
fontWeight: 600,
lineHeight: '1.4',
overflow: 'hidden',
textOverflow: 'ellipsis',
display: '-webkit-box',
WebkitLineClamp: size === 'small' ? 2 : 3,
WebkitBoxOrient: 'vertical'
}}
>
{sparepart.sparepart_name || sparepart.name || 'Unnamed'}
</Title>
{size !== 'small' && (
<>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', marginBottom: '4px' }}>
<Text
strong
style={{
fontSize: '14px',
color: '#262626',
marginRight: '12px'
}}
title={sparepart.sparepart_name || sparepart.name || 'Unnamed'}
>
{truncateText(sparepart.sparepart_name || sparepart.name || 'Unnamed')}
</Text>
<Tag
color={sparepart.sparepart_stok === 'Available' ? 'green' : 'red'}
style={{ fontSize: '11px', margin: 0 }}
>
{sparepart.sparepart_stok || 'Not Available'}
</Tag>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
<div style={{ display: 'flex', alignItems: 'center' }}>
<Text style={{ fontSize: '12px', color: '#666', marginRight: '4px' }}>
qty:
</Text>
<Text
type="secondary"
style={{
fontSize: '12px',
display: 'block',
marginBottom: '6px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
fontWeight: 600,
color: '#262626'
}}
>
Stock: {sparepart.sparepart_stock || sparepart.sparepart_stok || '0'} {sparepart.sparepart_unit || 'pcs'}
</Text>
<div style={{ marginBottom: '6px' }}>
<Text
code
style={{
fontSize: '11px',
backgroundColor: '#f5f5f5',
padding: '2px 6px',
borderRadius: '3px'
}}
>
{sparepart.sparepart_code || 'No code'}
</Text>
</div>
</>
)}
{size === 'small' && (
<Text
code
style={{
fontSize: '10px',
backgroundColor: '#f5f5f5',
display: 'block',
marginBottom: '4px'
}}
>
{sparepart.sparepart_code || 'No code'}
</Text>
)}
{(sparepart.sparepart_merk || sparepart.sparepart_model) && (
<div style={{
fontSize: size === 'small' ? '10px' : '11px',
color: '#666',
lineHeight: '1.4',
marginBottom: '4px'
}}>
{sparepart.sparepart_merk && (
<div>Brand: {sparepart.sparepart_merk}</div>
)}
{sparepart.sparepart_model && (
<div>Model: {sparepart.sparepart_model}</div>
)}
</div>
)}
</div>
<Text
type="secondary"
style={{
fontSize: size === 'small' ? '9px' : '10px',
marginTop: 'auto',
paddingTop: '4px',
borderTop: '1px solid #f0f0f0'
}}
>
{sparepart.updated_at && dayjs(sparepart.updated_at).format('DD MMM YYYY')}
{sparepart.sparepart_qty || 0}
</Text>
</div>
</div>
</Card>
</div>
<Space size="small">
{showPreview && (
<Button
type="text"
icon={<EyeOutlined />}
size="small"
onClick={(e) => {
e.stopPropagation();
handlePreview();
}}
title="Preview"
/>
)}
{showDelete && !isReadOnly && (
<Button
type="text"
icon={<DeleteOutlined />}
size="small"
danger
onClick={(e) => {
e.stopPropagation();
onDelete?.(sparepart);
}}
title="Remove"
/>
)}
</Space>
</div>
{/* Preview Modal */}
<Modal
title="Sparepart Details"
open={previewModalVisible}
@@ -332,23 +223,24 @@ const CustomSparepartCard = ({
Close
</Button>
]}
width={700}
width={800}
centered
bodyStyle={{ padding: '24px' }}
styles={{ body: { padding: '24px' } }}
>
<Row gutter={[24, 24]}>
<Col span={8}>
<Col span={10}>
<div style={{ textAlign: 'center' }}>
<div
style={{
backgroundColor: '#f0f0f0',
width: '200px',
height: '200px',
width: '220px',
height: '220px',
margin: '0 auto 16px',
position: 'relative',
borderRadius: '8px',
borderRadius: '12px',
overflow: 'hidden',
border: '1px solid #E0E0E0',
boxShadow: '0 4px 12px rgba(0,0,0,0.1)'
}}
>
<img
@@ -360,116 +252,138 @@ const CustomSparepartCard = ({
objectFit: 'cover'
}}
onError={(e) => {
e.target.src = 'https://via.placeholder.com/200x200/d9d9d9/666666?text=No+Image';
e.target.src = 'https://via.placeholder.com/220x220/d9d9d9/666666?text=No+Image';
}}
/>
</div>
{sparepart.sparepart_item_type && (
<Tag color="blue" style={{ marginTop: '12px' }}>
<div style={{ marginBottom: '12px' }}>
<Tag color="blue" style={{ fontSize: '14px', padding: '4px 12px' }}>
{sparepart.sparepart_item_type}
</Tag>
</div>
)}
<div style={{
textAlign: 'left',
background: '#f8f9fa',
padding: '12px',
borderRadius: '8px',
marginTop: '25px'
}}>
<div style={{ marginBottom: '8px' }}>
<Text strong style={{ fontSize: '14px', color: '#262626' }}>Stock Status:</Text>
<Tag
color={sparepart.sparepart_stok === 'Available' ? 'green' : 'red'}
style={{ marginLeft: '8px', fontSize: '12px' }}
>
{sparepart.sparepart_stok || 'Not Available'}
</Tag>
</div>
<div>
<Text strong style={{ fontSize: '14px', color: '#262626' }}>Quantity:</Text>
<Text style={{ fontSize: '14px', marginLeft: '8px', fontWeight: 600 }}>
{sparepart.sparepart_qty || 0} {sparepart.sparepart_unit || ''}
</Text>
</div>
</div>
</div>
</Col>
<Col span={16}>
<Col span={14}>
<div>
<Title level={3} style={{ marginBottom: '16px' }}>
<Title level={3} style={{ marginBottom: '20px', color: '#262626' }}>
{sparepart.sparepart_name || 'Unnamed'}
</Title>
<Row gutter={[16, 16]} style={{ marginBottom: '16px' }}>
<Col span={12}>
<div style={{ marginBottom: '8px' }}>
<Text strong style={{ fontSize: '16px', color: '#262626' }}>
Code:
</Text>
<Text style={{ fontSize: '16px', marginLeft: '8px' }}>
<div style={{ marginBottom: '24px' }}>
<Row gutter={[16, 12]}>
<Col span={24}>
<div style={{
padding: '12px',
backgroundColor: '#fafafa',
borderRadius: '8px',
border: '1px solid #f0f0f0'
}}>
<Row gutter={16}>
<Col span={8}>
<div>
<Text type="secondary" style={{ fontSize: '12px' }}>Code</Text>
<div style={{ fontSize: '15px', fontWeight: 500, marginTop: '2px' }}>
{sparepart.sparepart_code || 'N/A'}
</Text>
</div>
</div>
</Col>
<Col span={12}>
<div style={{ marginBottom: '8px' }}>
<Text strong style={{ fontSize: '16px', color: '#262626' }}>
Status:
</Text>
<Tag
color={sparepart.is_active ? 'green' : 'red'}
style={{ marginLeft: '8px', fontSize: '14px' }}
>
{sparepart.is_active ? 'Active' : 'Inactive'}
</Tag>
<Col span={8}>
<div>
<Text type="secondary" style={{ fontSize: '12px' }}>Brand</Text>
<div style={{ fontSize: '15px', fontWeight: 500, marginTop: '2px' }}>
{sparepart.sparepart_merk || '-'}
</div>
</div>
</Col>
<Col span={8}>
<div>
<Text type="secondary" style={{ fontSize: '12px' }}>Unit</Text>
<div style={{ fontSize: '15px', fontWeight: 500, marginTop: '2px' }}>
{sparepart.sparepart_unit || '-'}
</div>
</div>
</Col>
</Row>
</div>
</Col>
{sparepart.sparepart_model && (
<Col span={24}>
<div>
<Text type="secondary" style={{ fontSize: '12px' }}>Model</Text>
<div style={{ fontSize: '15px', fontWeight: 500, marginTop: '2px' }}>
{sparepart.sparepart_model}
</div>
</div>
</Col>
)}
{sparepart.sparepart_description && (
<div style={{ marginBottom: '16px' }}>
<Text strong style={{ fontSize: '16px', color: '#262626' }}>
Description:
</Text>
<Text style={{ fontSize: '16px', marginLeft: '8px' }}>
<Col span={24}>
<div>
<Text type="secondary" style={{ fontSize: '12px' }}>Description</Text>
<div style={{ fontSize: '15px', marginTop: '2px', lineHeight: '1.5' }}>
{sparepart.sparepart_description}
</Text>
</div>
)}
<div style={{ marginBottom: '16px' }}>
<Text strong style={{ fontSize: '16px', color: '#262626' }}>
Stock:
</Text>
<Text style={{ fontSize: '16px', marginLeft: '8px' }}>
{sparepart.sparepart_stock || sparepart.sparepart_stok || '0'}
{sparepart.sparepart_unit ? ` ${sparepart.sparepart_unit}` : ' units'}
</Text>
</div>
<Row gutter={[16, 16]} style={{ marginBottom: '16px' }}>
{sparepart.sparepart_merk && (
<Col span={8}>
<div>
<Text strong style={{ fontSize: '14px', color: '#262626' }}>
Brand:
</Text>
<Text style={{ fontSize: '14px', marginLeft: '8px' }}>
{sparepart.sparepart_merk}
</Text>
</div>
</Col>
)}
{sparepart.sparepart_model && (
<Col span={8}>
<div>
<Text strong style={{ fontSize: '14px', color: '#262626' }}>
Model:
</Text>
<Text style={{ fontSize: '14px', marginLeft: '8px' }}>
{sparepart.sparepart_model}
</Text>
</div>
</Col>
)}
{sparepart.sparepart_unit && (
<Col span={8}>
<div>
<Text strong style={{ fontSize: '14px', color: '#262626' }}>
Unit:
</Text>
<Text style={{ fontSize: '14px', marginLeft: '8px' }}>
{sparepart.sparepart_unit}
</Text>
</div>
</Col>
)}
</Row>
</div>
{sparepart.updated_at && (
<div style={{ marginTop: '24px', paddingTop: '16px', borderTop: '1px solid #f0f0f0' }}>
<Text type="secondary" style={{ fontSize: '14px' }}>
Last updated: {dayjs(sparepart.updated_at).format('DD MMMM YYYY, HH:mm')}
</Text>
{sparepart.created_at && (
<div>
<Row gutter={16}>
<Col span={12}>
<div>
<Text type="secondary" style={{ fontSize: '12px' }}>Created</Text>
<div style={{ fontSize: '13px', marginTop: '2px' }}>
{dayjs(sparepart.created_at).format('DD MMM YYYY, HH:mm')}
</div>
</div>
</Col>
<Col span={12}>
<div>
<Text type="secondary" style={{ fontSize: '12px' }}>Last Updated</Text>
<div style={{ fontSize: '13px', marginTop: '2px' }}>
{dayjs(sparepart.updated_at).format('DD MMM YYYY, HH:mm')}
</div>
</div>
</Col>
</Row>
</div>
)}
</div>

View File

@@ -0,0 +1,288 @@
import React, { useState, useEffect } from 'react';
import { Form, Input, Switch, Typography, ConfigProvider, Card, Button } from 'antd';
import { FileOutlined, EyeOutlined, DeleteOutlined } from '@ant-design/icons';
import FileUploadHandler from './FileUploadHandler';
import { NotifAlert, NotifOk } from '../../../../components/Global/ToastNotif';
import { getFileUrl, getFolderFromFileType } from '../../../../api/file-uploads';
const { Text } = Typography;
const ErrorCodeForm = ({
errorCodeForm,
isErrorCodeFormReadOnly = false,
errorCodeIcon,
onErrorCodeIconUpload,
onErrorCodeIconRemove,
isEdit = false,
}) => {
const [currentIcon, setCurrentIcon] = useState(null);
const statusWatch = Form.useWatch('status', errorCodeForm) ?? true;
useEffect(() => {
if (errorCodeIcon && typeof errorCodeIcon === 'object' && Object.keys(errorCodeIcon).length > 0) {
setCurrentIcon(errorCodeIcon);
} else {
setCurrentIcon(null);
}
}, [errorCodeIcon]);
const handleIconRemove = () => {
setCurrentIcon(null);
onErrorCodeIconRemove();
};
const renderIconUpload = () => {
if (currentIcon) {
const displayFileName = currentIcon.name || currentIcon.uploadPath?.split('/').pop() || currentIcon.url?.split('/').pop() || 'Icon File';
return (
<Card
style={{
marginTop: 8,
borderRadius: 8,
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
border: '1px solid #e8e8e8'
}}
styles={{ body: { padding: '16px' } }}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: 48,
height: 48,
borderRadius: 8,
backgroundColor: '#f0f5ff',
flexShrink: 0
}}>
<FileOutlined style={{ fontSize: 24, color: '#1890ff' }} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontSize: 13,
fontWeight: 600,
color: '#262626',
marginBottom: 4,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}>
{displayFileName}
</div>
<div style={{ fontSize: 12, color: '#8c8c8c' }}>
{currentIcon.size ? `${(currentIcon.size / 1024).toFixed(1)} KB` : 'Icon uploaded'}
</div>
</div>
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
<Button
type="primary"
size="middle"
icon={<EyeOutlined />}
style={{
fontSize: 12,
display: 'flex',
alignItems: 'center',
gap: 4
}}
onClick={() => {
try {
let iconUrl = '';
let actualFileName = '';
const filePath = currentIcon.uploadPath || currentIcon.url || currentIcon.path || '';
const iconDisplayName = currentIcon.name || '';
if (iconDisplayName) {
actualFileName = iconDisplayName;
} else if (filePath) {
actualFileName = filePath.split('/').pop();
}
if (actualFileName) {
const fileExtension = actualFileName.split('.').pop()?.toLowerCase();
const folder = getFolderFromFileType(fileExtension);
iconUrl = getFileUrl(folder, actualFileName);
}
if (!iconUrl && filePath) {
iconUrl = filePath.startsWith('http') ? filePath : `${import.meta.env.VITE_API_SERVER}/${filePath}`;
}
if (iconUrl && actualFileName) {
const fileExtension = actualFileName.split('.').pop()?.toLowerCase();
const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'];
const pdfExtensions = ['pdf'];
if (imageExtensions.includes(fileExtension) || pdfExtensions.includes(fileExtension)) {
const viewerUrl = `/image-viewer/${encodeURIComponent(actualFileName)}`;
window.open(viewerUrl, '_blank', 'noopener,noreferrer');
} else {
window.open(iconUrl, '_blank', 'noopener,noreferrer');
}
} else {
NotifAlert({
icon: 'error',
title: 'Error',
message: `File URL not found. FileName: ${actualFileName}, FilePath: ${filePath}`
});
}
} catch (error) {
NotifAlert({
icon: 'error',
title: 'Error',
message: `Failed to open file preview: ${error.message}`
});
}
}}
/>
<Button
danger
size="middle"
icon={<DeleteOutlined />}
style={{
fontSize: 12,
display: 'flex',
alignItems: 'center',
}}
onClick={handleIconRemove}
disabled={isErrorCodeFormReadOnly}
/>
</div>
</div>
</Card>
);
} else {
return (
<FileUploadHandler
type="error_code"
existingFile={null}
accept="image/*"
onFileUpload={(fileData) => {
setCurrentIcon(fileData);
onErrorCodeIconUpload(fileData);
}}
onFileRemove={handleIconRemove}
buttonText="Upload Icon"
buttonStyle={{
width: '100%',
borderColor: '#23A55A',
color: '#23A55A',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '8px'
}}
uploadText="Upload error code icon"
disabled={isErrorCodeFormReadOnly}
/>
);
}
};
return (
<ConfigProvider
theme={{
components: {
Switch: {
colorPrimary: '#23A55A',
colorPrimaryHover: '#23A55A',
},
},
}}
>
<Form
form={errorCodeForm}
layout="vertical"
initialValues={{
status: true,
error_code_color: '#000000'
}}
>
{/* Header bar with color picker, icon upload, and status toggle */}
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
marginBottom: '16px',
gap: '16px'
}}>
{/* Color picker on left */}
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<Form.Item
name="error_code_color"
noStyle
getValueFromEvent={(e) => e.target.value}
getValueProps={(value) => ({ value: value || '#000000' })}
>
<input
type="color"
style={{
width: '120px',
height: '40px',
border: '1px solid #d9d9d9',
borderRadius: 4,
cursor: isErrorCodeFormReadOnly ? 'not-allowed' : 'pointer',
}}
disabled={isErrorCodeFormReadOnly}
/>
</Form.Item>
{/* Icon upload beside color picker */}
<div style={{ flex: 1, maxWidth: '300px' }}>
{renderIconUpload()}
</div>
</div>
{/* Status toggle on right */}
<div style={{ display: 'flex', alignItems: 'center' }}>
<Form.Item name="status" valuePropName="checked" noStyle>
<Switch
disabled={isErrorCodeFormReadOnly}
/>
</Form.Item>
<Text style={{ marginLeft: 8 }}>
{statusWatch ? 'Active' : 'Inactive'}
</Text>
</div>
</div>
{/* Error Code and Error Name in one row with 1/3 and 2/3 ratio */}
<div style={{ display: 'flex', gap: '12px', marginBottom: '16px' }}>
<Form.Item
label="Error Code"
name="error_code"
rules={[{ required: true, message: 'Error code wajib diisi!' }]}
style={{ flex: 1, marginBottom: 0, maxWidth: '33.33%' }}
>
<Input
placeholder="Enter error code"
disabled={isErrorCodeFormReadOnly}
/>
</Form.Item>
<Form.Item
label="Error Name"
name="error_code_name"
rules={[{ required: !isErrorCodeFormReadOnly, message: 'Error name wajib diisi!' }]}
style={{ flex: 2, marginBottom: 0, maxWidth: '66.67%' }}
>
<Input placeholder="Enter error name" disabled={isErrorCodeFormReadOnly} />
</Form.Item>
</div>
<Form.Item label="Description" name="error_code_description">
<Input.TextArea
placeholder="Enter error description"
rows={3}
disabled={isErrorCodeFormReadOnly}
/>
</Form.Item>
</Form>
</ConfigProvider>
);
};
export default ErrorCodeForm;

View File

@@ -1,108 +0,0 @@
import React from 'react';
import { Form, Input, Switch, Typography, ConfigProvider } from 'antd';
import FileUploadHandler from './FileUploadHandler';
import { NotifAlert, NotifOk } from '../../../../components/Global/ToastNotif';
const { Text } = Typography;
const ErrorCodeSimpleForm = ({
errorCodeForm,
isErrorCodeFormReadOnly = false,
errorCodeIcon,
onErrorCodeIconUpload,
onErrorCodeIconRemove,
onAddErrorCode,
isEdit = false, // Add isEdit prop to check if we're in edit mode
}) => {
const statusValue = Form.useWatch('status', errorCodeForm);
const handleIconRemove = () => {
onErrorCodeIconRemove();
};
return (
<>
{/* Status Switch */}
<Form.Item label="Status" name="status">
<div style={{ display: 'flex', alignItems: 'center' }}>
<Form.Item name="status" valuePropName="checked" noStyle>
<Switch
style={{ backgroundColor: statusValue ? '#23A55A' : '#bfbfbf' }}
/>
</Form.Item>
<Text style={{ marginLeft: 8 }}>{statusValue ? 'Active' : 'Inactive'}</Text>
</div>
</Form.Item>
{/* Error Code */}
<Form.Item
label="Error Code"
name="error_code"
rules={[{ required: true, message: 'Error code wajib diisi!' }]}
>
<Input
placeholder="Enter error code"
disabled={isErrorCodeFormReadOnly}
/>
</Form.Item>
{/* Error Name */}
<Form.Item
label="Error Name"
name="error_code_name"
rules={[{ required: true, message: 'Error name wajib diisi!' }]}
>
<Input placeholder="Enter error name" />
</Form.Item>
{/* Error Description */}
<Form.Item label="Description" name="error_code_description">
<Input.TextArea
placeholder="Enter error description"
rows={3}
/>
</Form.Item>
{/* Color and Icon */}
<Form.Item label="Color & Icon">
<div style={{ display: 'flex', gap: '12px', alignItems: 'flex-start' }}>
<Form.Item name="error_code_color" noStyle style={{ flex: '0 0 auto' }}>
<input
type="color"
style={{
width: '120px',
height: '40px',
border: '1px solid #d9d9d9',
borderRadius: 4,
}}
/>
</Form.Item>
<Form.Item noStyle style={{ flex: '1 1 auto' }}>
<FileUploadHandler
type="error_code"
existingFile={errorCodeIcon}
accept="image/*"
onFileUpload={onErrorCodeIconUpload}
onFileRemove={handleIconRemove}
buttonText="Upload Icon"
buttonStyle={{
width: '100%',
borderColor: '#23A55A',
color: '#23A55A',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '8px'
}}
uploadText="Upload error code icon"
/>
</Form.Item>
</div>
</Form.Item>
</>
);
};
export default ErrorCodeSimpleForm;

View File

@@ -12,12 +12,13 @@ const FileUploadHandler = ({
accept = '.pdf,.jpg,.jpeg,.png,.gif',
disabled = false,
// File management
fileList = [],
onFileUpload,
onFileRemove,
existingFile = null,
clearSignal = null,
debugProps = {},
uploadText = 'Click or drag file to this area to upload',
uploadHint = 'Support for PDF and image files only',
@@ -34,6 +35,12 @@ const FileUploadHandler = ({
const [isUploading, setIsUploading] = useState(false);
const [uploadedFile, setUploadedFile] = useState(null);
React.useEffect(() => {
if (clearSignal !== null && clearSignal > 0) {
setUploadedFile(null);
}
}, [clearSignal, debugProps]);
const getBase64 = (file) =>
new Promise((resolve, reject) => {
const reader = new FileReader();
@@ -140,6 +147,7 @@ const FileUploadHandler = ({
}
}
if (actualPath) {
let fileObject;
@@ -176,7 +184,6 @@ const FileUploadHandler = ({
setIsUploading(false);
return false;
} else {
console.error('Failed to extract file path from upload response:', uploadResponse);
NotifAlert({
icon: 'error',
title: 'Gagal',
@@ -186,7 +193,6 @@ const FileUploadHandler = ({
return false;
}
} catch (error) {
console.error('Upload error:', error);
NotifAlert({
icon: 'error',
title: 'Error',
@@ -204,16 +210,9 @@ const FileUploadHandler = ({
};
const handleRemove = () => {
console.log('🗑️ FileUploadHandler handleRemove called:', {
existingFile,
onFileRemove: typeof onFileRemove,
hasExistingFile: !!existingFile
});
if (existingFile && onFileRemove) {
onFileRemove(existingFile);
} else if (onFileRemove) {
// Call onFileRemove even without existingFile to trigger form cleanup
onFileRemove(null);
}
};
@@ -221,17 +220,9 @@ const FileUploadHandler = ({
const renderExistingFile = () => {
const fileToShow = existingFile || uploadedFile;
if (!fileToShow) {
console.log('❌ FileUploadHandler renderExistingFile: No file to render');
return null;
}
console.log('✅ FileUploadHandler renderExistingFile: File found', {
existingFile: !!existingFile,
uploadedFile: !!uploadedFile,
fileName: fileToShow.name,
shouldShowDeleteButton: true
});
const filePath = fileToShow.uploadPath || fileToShow.url || fileToShow.path_icon || fileToShow.path_solution;
const fileName = fileToShow.name || filePath?.split('/').pop() || 'Unknown file';
const fileType = getFileType(fileName);
@@ -257,7 +248,6 @@ const FileUploadHandler = ({
});
}
} else {
// For PDFs and other files, open in new tab
const folder = fileToShow.type_solution === 'pdf' ? 'pdf' : 'images';
const filename = filePath.split('/').pop();
const fileUrl = getFileUrl(folder, filename);
@@ -404,7 +394,7 @@ const FileUploadHandler = ({
</Upload>
)}
{renderExistingFile()}
{showPreview && (
<Modal

View File

@@ -1,70 +0,0 @@
import React from 'react';
import { Button, ConfigProvider } from 'antd';
import { ArrowLeftOutlined } from '@ant-design/icons';
const FormActions = ({
currentStep,
onPreviousStep,
onNextStep,
onSave,
onCancel,
confirmLoading,
isEditMode = false,
showCancelButton = true
}) => {
return (
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<ConfigProvider
theme={{
token: { colorBgContainer: '#E9F6EF' },
components: {
Button: {
defaultBg: 'white',
defaultColor: '#23A55A',
defaultBorderColor: '#23A55A',
defaultHoverColor: '#23A55A',
defaultHoverBorderColor: '#23A55A',
},
},
}}
>
{showCancelButton && (
<Button onClick={onCancel}>Batal</Button>
)}
{currentStep > 0 && (
<Button onClick={onPreviousStep} style={{ marginRight: 8 }}>
Kembali
</Button>
)}
</ConfigProvider>
<ConfigProvider
theme={{
components: {
Button: {
defaultBg: '#23a55a',
defaultColor: '#FFFFFF',
defaultBorderColor: '#23a55a',
defaultHoverBg: '#209652',
defaultHoverColor: '#FFFFFF',
defaultHoverBorderColor: '#23a55a',
},
},
}}
>
{currentStep < 1 && (
<Button loading={confirmLoading} onClick={onNextStep}>
Lanjut
</Button>
)}
{currentStep === 1 && (
<Button loading={confirmLoading} onClick={onSave}>
{isEditMode ? 'Update' : 'Simpan'}
</Button>
)}
</ConfigProvider>
</div>
);
};
export default FormActions;

View File

@@ -91,9 +91,9 @@ const columns = (showPreviewModal, showEditModal, showDeleteDialog) => [
const ListBrandDevice = memo(function ListBrandDevice(props) {
const [trigerFilter, setTrigerFilter] = useState(false);
const defaultFilter = { search: '' };
const defaultFilter = { criteria: '' };
const [formDataFilter, setFormDataFilter] = useState(defaultFilter);
const [searchValue, setSearchValue] = useState('');
const [searchText, setSearchText] = useState('');
const navigate = useNavigate();
@@ -114,13 +114,13 @@ const ListBrandDevice = memo(function ListBrandDevice(props) {
};
const handleSearch = () => {
setFormDataFilter({ search: searchValue });
setFormDataFilter({ criteria: searchText });
setTrigerFilter((prev) => !prev);
};
const handleSearchClear = () => {
setSearchValue('');
setFormDataFilter({ search: '' });
setSearchText('');
setFormDataFilter({ criteria: '' });
setTrigerFilter((prev) => !prev);
};
@@ -156,7 +156,7 @@ const ListBrandDevice = memo(function ListBrandDevice(props) {
title: 'Berhasil',
message: `Brand ${brand_name} deleted successfully.`,
});
doFilter(); // Refresh data
doFilter();
} else {
NotifAlert({
icon: 'error',
@@ -182,12 +182,12 @@ const ListBrandDevice = memo(function ListBrandDevice(props) {
<Col xs={24} sm={24} md={12} lg={12}>
<Input.Search
placeholder="Search brand device..."
value={searchValue}
value={searchText}
onChange={(e) => {
const value = e.target.value;
setSearchValue(value);
setSearchText(value);
if (value === '') {
setFormDataFilter({ search: '' });
setFormDataFilter({ criteria: '' });
setTrigerFilter((prev) => !prev);
}
}}

View File

@@ -0,0 +1,315 @@
import React, { useState, useEffect, useMemo } from 'react';
import { Card, Input, Button, Row, Col, Empty } from 'antd';
import { PlusOutlined, SearchOutlined, DeleteOutlined, LeftOutlined, RightOutlined } from '@ant-design/icons';
import { getErrorCodesByBrandId, deleteErrorCode } from '../../../../api/master-brand';
import { NotifAlert, NotifOk, NotifConfirmDialog } from '../../../../components/Global/ToastNotif';
const ListErrorCode = ({
brandId,
selectedErrorCode,
onErrorCodeSelect,
onAddNew,
tempErrorCodes = [],
trigerFilter,
searchText,
onSearchChange,
onSearch,
onSearchClear,
isReadOnly = false,
errorCodes: propErrorCodes = null
}) => {
const [errorCodes, setErrorCodes] = useState([]);
const [loading, setLoading] = useState(false);
const [pagination, setPagination] = useState({
current_page: 1,
current_limit: 15,
total_limit: 0,
total_page: 0,
});
const [currentPage, setCurrentPage] = useState(1);
const pageSize = 15;
const queryParams = useMemo(() => {
const params = new URLSearchParams();
params.set('page', currentPage.toString());
params.set('limit', pageSize.toString());
if (searchText) {
params.set('criteria', searchText);
}
return params;
}, [searchText, currentPage, pageSize]);
const fetchErrorCodes = async () => {
if (!brandId) {
setErrorCodes([]);
return;
}
setLoading(true);
try {
const response = await getErrorCodesByBrandId(brandId, queryParams);
if (response && response.statusCode === 200) {
const apiErrorData = response.data || [];
const allErrorCodes = [
...apiErrorData.map(ec => ({
...ec,
tempId: `existing_${ec.error_code_id}`,
status: 'existing'
})),
...tempErrorCodes.filter(ec => ec.status !== 'deleted')
];
setErrorCodes(allErrorCodes);
if (response.paging) {
setPagination({
current_page: response.paging.current_page || 1,
current_limit: response.paging.current_limit || 15,
total_limit: response.paging.total_limit || 0,
total_page: response.paging.total_page || 0,
});
}
} else {
setErrorCodes([]);
}
} catch (error) {
setErrorCodes([]);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (isReadOnly && propErrorCodes) {
setErrorCodes(propErrorCodes);
setLoading(false);
} else {
fetchErrorCodes();
}
}, [brandId, queryParams, tempErrorCodes, trigerFilter, isReadOnly, propErrorCodes]);
const handlePrevious = () => {
if (pagination.current_page > 1) {
setCurrentPage(pagination.current_page - 1);
}
};
const handleNext = () => {
if (pagination.current_page < pagination.total_page) {
setCurrentPage(pagination.current_page + 1);
}
};
const handleSearch = () => {
setCurrentPage(1);
if (onSearch) {
onSearch();
}
};
const handleSearchClear = () => {
setCurrentPage(1);
if (onSearchClear) {
onSearchClear();
}
};
const handleDelete = async (item, e) => {
e.stopPropagation();
if (item.status === 'existing' && item.error_code_id) {
NotifConfirmDialog({
icon: 'warning',
title: 'Hapus Error Code',
message: `Apakah Anda yakin ingin menghapus error code ${item.error_code}?`,
onConfirm: () => performDelete(item),
onCancel: () => { },
confirmButtonText: 'Hapus'
});
}
};
const performDelete = async (item) => {
try {
if (!item.error_code_id || item.error_code_id === 'undefined') {
NotifAlert({
icon: 'error',
title: 'Error',
message: 'Error code ID tidak valid'
});
return;
}
if (!item.brand_id || item.brand_id === 'undefined') {
NotifAlert({
icon: 'error',
title: 'Error',
message: 'Brand ID tidak valid'
});
return;
}
const response = await deleteErrorCode(item.brand_id, item.error_code_id);
if (response && response.statusCode === 200) {
NotifOk({
icon: 'success',
title: 'Berhasil',
message: 'Error code berhasil dihapus'
});
fetchErrorCodes();
} else {
NotifAlert({
icon: 'error',
title: 'Gagal',
message: 'Gagal menghapus error code'
});
}
} catch (error) {
NotifAlert({
icon: 'error',
title: 'Error',
message: 'Terjadi kesalahan saat menghapus error code'
});
}
};
return (
<Card
title="Daftar Error Code"
style={{ width: '100%', minWidth: '472px' }}
styles={{ body: { padding: '12px' } }}
>
<Input.Search
placeholder="Cari error code..."
value={searchText}
onChange={(e) => {
const value = e.target.value;
if (onSearchChange) {
onSearchChange(value);
}
}}
onSearch={handleSearch}
allowClear
enterButton={
<Button
type="primary"
icon={<SearchOutlined />}
onClick={handleSearch}
style={{
backgroundColor: '#23A55A',
borderColor: '#23A55A',
height: '32px'
}}
>
Search
</Button>
}
size="default"
style={{
marginBottom: 12,
height: '32px',
width: '100%',
}}
/>
<div style={{
height: '90vh',
border: '1px solid #d9d9d9',
borderRadius: '6px',
overflow: 'auto',
marginBottom: 12,
backgroundColor: '#fafafa'
}}>
{errorCodes.length === 0 ? (
<Empty
description="Belum ada error code"
style={{ marginTop: 50 }}
/>
) : (
<div style={{ padding: '8px' }}>
{errorCodes.map((item) => (
<div
key={item.tempId || item.error_code_id}
style={{
cursor: 'pointer',
padding: '8px 12px',
borderRadius: '6px',
marginBottom: '4px',
border: selectedErrorCode?.tempId === item.tempId ? '2px solid #23A55A' : '1px solid #d9d9d9',
backgroundColor: selectedErrorCode?.tempId === item.tempId ? '#f6ffed' : '#fff',
transition: 'all 0.2s ease'
}}
onClick={() => onErrorCodeSelect(item)}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 'bold', fontSize: '12px' }}>
{item.error_code}
</div>
<div style={{ fontSize: '11px', color: '#666' }}>
{item.error_code_name}
</div>
</div>
{item.status === 'existing' && (
<Button
type="text"
danger
size="small"
icon={<DeleteOutlined />}
onClick={(e) => handleDelete(item, e)}
style={{
padding: '2px 6px',
height: '24px',
fontSize: '11px',
border: '1px solid #ff4d4f'
}}
/>
)}
</div>
</div>
))}
</div>
)}
</div>
{pagination.total_limit > 0 && (
<Row justify="space-between" align="middle" gutter={16}>
<Col flex="auto">
<span style={{ fontSize: '12px', color: '#666' }}>
Menampilkan {pagination.current_limit} data halaman{' '}
{pagination.current_page} dari total {pagination.total_limit} data
</span>
</Col>
<Col flex="none">
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<Button
icon={<LeftOutlined />}
onClick={handlePrevious}
disabled={pagination.current_page <= 1}
size="small"
>
</Button>
<span style={{ fontSize: '12px', color: '#666', minWidth: '60px', textAlign: 'center' }}>
{pagination.current_page} / {pagination.total_page}
</span>
<Button
icon={<RightOutlined />}
onClick={handleNext}
disabled={pagination.current_page >= pagination.total_page}
size="small"
>
</Button>
</div>
</Col>
</Row>
)}
</Card>
);
};
export default ListErrorCode;

View File

@@ -1,183 +0,0 @@
import React, { useState, useEffect } from 'react';
import { Select, Typography, Tag, Spin, Empty, Button, Row, Col } from 'antd';
import { PlusOutlined, DeleteOutlined, CheckOutlined, EyeOutlined, InfoCircleOutlined } from '@ant-design/icons';
import { getAllSparepart } from '../../../../api/sparepart';
import CustomSparepartCard from './CustomSparepartCard';
const { Text, Title } = Typography;
const { Option } = Select;
const SingleSparepartSelect = ({
selectedSparepartIds = [],
onSparepartChange,
isReadOnly = false
}) => {
const [spareparts, setSpareparts] = useState([]);
const [loading, setLoading] = useState(false);
const [selectedSpareparts, setSelectedSpareparts] = useState([]);
const [dropdownOpen, setDropdownOpen] = useState(false);
useEffect(() => {
fetchSpareparts();
}, []);
useEffect(() => {
if (selectedSparepartIds && selectedSparepartIds.length > 0) {
const fullSelectedSpareparts = spareparts.filter(sp =>
selectedSparepartIds.includes(sp.sparepart_id)
);
setSelectedSpareparts(fullSelectedSpareparts);
} else {
setSelectedSpareparts([]);
}
}, [selectedSparepartIds, spareparts]);
const fetchSpareparts = async () => {
setLoading(true);
try {
const params = new URLSearchParams();
params.set('limit', '1000');
const response = await getAllSparepart(params);
if (response && (response.statusCode === 200 || response.data)) {
const sparepartData = response.data?.data || response.data || [];
setSpareparts(sparepartData);
} else {
setSpareparts([
{
sparepart_id: 1,
sparepart_name: 'Compressor Oil Filter',
sparepart_description: 'Oil filter for compressor',
sparepart_foto: null,
sparepart_code: 'SP-001',
sparepart_merk: 'Brand A',
sparepart_model: 'Model X',
is_active: true,
stock_quantity: 50
}
]);
}
} catch (error) {
setSpareparts([
{
sparepart_id: 1,
sparepart_name: 'Compressor Oil Filter',
sparepart_description: 'Oil filter for compressor',
sparepart_foto: null,
sparepart_code: 'SP-001',
sparepart_merk: 'Brand A',
sparepart_model: 'Model X',
is_active: true,
stock_quantity: 50
}
]);
} finally {
setLoading(false);
}
};
const handleSparepartSelect = (sparepartId) => {
const selectedSparepart = spareparts.find(sp => sp.sparepart_id === sparepartId);
if (selectedSparepart) {
const isAlreadySelected = selectedSpareparts.some(sp => sp.sparepart_id === sparepartId);
if (!isAlreadySelected) {
const newSelectedSpareparts = [...selectedSpareparts, selectedSparepart];
setSelectedSpareparts(newSelectedSpareparts);
const newSelectedIds = newSelectedSpareparts.map(sp => sp.sparepart_id);
onSparepartChange(newSelectedIds);
}
}
setDropdownOpen(false);
};
const handleRemoveSparepart = (sparepartId) => {
const newSelectedSpareparts = selectedSpareparts.filter(sp => sp.sparepart_id !== sparepartId);
setSelectedSpareparts(newSelectedSpareparts);
const newSelectedIds = newSelectedSpareparts.map(sp => sp.sparepart_id);
onSparepartChange(newSelectedIds);
};
const renderSparepartCard = (sparepart, isSelected = false) => {
const isAlreadySelected = selectedSpareparts.some(sp => sp.sparepart_id === sparepart.sparepart_id);
return (
<Col xs={24} sm={24} md={12} lg={12} key={sparepart.sparepart_id}>
<CustomSparepartCard
sparepart={sparepart}
isSelected={isSelected}
isReadOnly={isReadOnly}
showPreview={true}
showDelete={isAlreadySelected && !isReadOnly} // Show delete only for already selected items
onCardClick={!isAlreadySelected && !isReadOnly ? () => handleSparepartSelect(sparepart.sparepart_id) : undefined}
onDelete={() => handleRemoveSparepart(sparepart.sparepart_id)}
style={{
border: isAlreadySelected ? '2px solid #52c41a' : undefined,
}}
/>
</Col>
);
};
return (
<>
<div>
{!isReadOnly && (
<div style={{ marginBottom: 16 }}>
<Select
placeholder="Search and select sparepart..."
style={{ width: '100%' }}
loading={loading}
onSelect={handleSparepartSelect}
value={null}
showSearch
filterOption={(input, option) =>
option.children.toLowerCase().indexOf(input.toLowerCase()) >= 0
}
open={dropdownOpen}
onDropdownVisibleChange={setDropdownOpen}
suffixIcon={<PlusOutlined />}
>
{spareparts
.filter(sparepart => !selectedSpareparts.some(sp => sp.sparepart_id === sparepart.sparepart_id))
.map((sparepart) => (
<Option key={sparepart.sparepart_id} value={sparepart.sparepart_id}>
<div>
<Text strong>{sparepart.sparepart_name || sparepart.name || 'Unnamed'}</Text>
<Text type="secondary" style={{ marginLeft: 8 }}>
({sparepart.sparepart_code || 'No code'})
</Text>
</div>
</Option>
))}
</Select>
</div>
)}
<div>
{selectedSpareparts.length > 0 ? (
<div>
<Title level={5} style={{ marginBottom: 16 }}>
Selected Spareparts ({selectedSpareparts.length})
</Title>
<Row gutter={[16, 16]}>
{selectedSpareparts.map(sparepart => renderSparepartCard(sparepart, true))}
</Row>
</div>
) : (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="No spareparts selected"
style={{ margin: '20px 0' }}
/>
)}
</div>
</div>
</>
);
};
export default SingleSparepartSelect;

View File

@@ -1,8 +1,9 @@
import React, { useState } from 'react';
import { Form, Input, Button, Switch, Radio, Typography, Space } from 'antd';
import { DeleteOutlined } from '@ant-design/icons';
import { Form, Input, Button, Switch, Radio, Typography, Space, Card, ConfigProvider } from 'antd';
import { DeleteOutlined, EyeOutlined, FileOutlined } from '@ant-design/icons';
import FileUploadHandler from './FileUploadHandler';
import { NotifAlert } from '../../../../components/Global/ToastNotif';
import { getFileUrl, getFolderFromFileType } from '../../../../api/file-uploads';
const { Text } = Typography;
const { TextArea } = Input;
@@ -20,50 +21,87 @@ const SolutionFieldNew = ({
onRemove,
onFileUpload,
onFileView,
fileList = []
fileList = [],
originalSolutionData = null
}) => {
const form = Form.useFormInstance();
const existingFile = Form.useWatch([`solution_items,${fieldKey}`, 'fileUpload'], form) ||
Form.useWatch([`solution_items,${fieldKey}`, 'file'], form);
const [currentFile, setCurrentFile] = useState(null);
const [isDeleted, setIsDeleted] = useState(false);
// Get form values for debugging and file data extraction
const allFormValues = form.getFieldsValue(true);
const solutionData = allFormValues[`solution_items,${fieldKey}`] || {};
const fileUpload = Form.useWatch(['solution_items', fieldKey, 'fileUpload'], form);
const file = Form.useWatch(['solution_items', fieldKey, 'file'], form);
const nameValue = Form.useWatch(['solution_items', fieldKey, 'name'], form);
const fileNameValue = Form.useWatch(['solution_items', fieldKey, 'fileName'], form);
const statusValue = Form.useWatch(['solution_items', fieldKey, 'status'], form) ?? true;
// Extract file data from form values for preview
const pathSolution = Form.useWatch(['solution_items', fieldKey, 'path_solution'], form);
const [deleteCounter, setDeleteCounter] = useState(0);
React.useEffect(() => {
if (!nameValue || nameValue === '') {
setCurrentFile(null);
setIsDeleted(false);
setDeleteCounter(prev => prev + 1);
}
}, [nameValue]);
React.useEffect(() => {
const getFileFromFormValues = () => {
if (solutionData.fileUpload && typeof solutionData.fileUpload === 'object' && Object.keys(solutionData.fileUpload).length > 0) {
return solutionData.fileUpload;
const hasValidFileUpload = fileUpload && typeof fileUpload === 'object' && Object.keys(fileUpload).length > 0;
const hasValidFile = file && typeof file === 'object' && Object.keys(file).length > 0;
const hasValidPath = pathSolution && pathSolution.trim() !== '';
const wasExplicitlyDeleted =
(fileUpload === null || file === null || pathSolution === null) &&
!hasValidFileUpload &&
!hasValidFile &&
!hasValidPath;
if (wasExplicitlyDeleted) {
return null;
}
if (solutionData.file && typeof solutionData.file === 'object' && Object.keys(solutionData.file).length > 0) {
return solutionData.file;
if (solutionType === 'text') {
return null;
}
if (hasValidFileUpload) {
return fileUpload;
}
if (hasValidFile) {
return file;
}
if (hasValidPath) {
return {
name: fileNameValue || pathSolution.split('/').pop() || 'File',
uploadPath: pathSolution,
url: pathSolution,
path: pathSolution
};
}
return null;
};
const fileFromForm = getFileFromFormValues();
const displayFile = existingFile || fileFromForm;
console.log(`🔍 SolutionField ${fieldKey}:`, {
solutionType,
hasPathSolution: !!solutionData.path_solution,
pathSolution: solutionData.path_solution,
fileFromForm,
existingFile,
displayFile,
shouldRenderPreview: !!displayFile
});
if (JSON.stringify(currentFile) !== JSON.stringify(fileFromForm)) {
setCurrentFile(fileFromForm);
}
}, [fileUpload, file, pathSolution, solutionType, deleteCounter, fileNameValue, fieldKey]);
const renderSolutionContent = () => {
if (solutionType === 'text') {
return (
<Form.Item
name={[`solution_items,${fieldKey}`, 'text']}
name={['solution_items', fieldKey, 'text']}
rules={[{ required: true, message: 'Text solution wajib diisi!' }]}
>
<TextArea
placeholder="Enter solution text"
rows={2}
rows={3}
disabled={isReadOnly}
style={{ fontSize: 12 }}
/>
@@ -72,52 +110,267 @@ const SolutionFieldNew = ({
}
if (solutionType === 'file') {
const hasOriginalFile = originalSolutionData && (
originalSolutionData.path_solution ||
originalSolutionData.path_document
);
let displayFile = null;
if (currentFile && Object.keys(currentFile).length > 0) {
displayFile = currentFile;
}
else if (hasOriginalFile && !isDeleted) {
displayFile = {
name: originalSolutionData.file_upload_name ||
(originalSolutionData.path_solution || originalSolutionData.path_document)?.split('/').pop() ||
'File',
uploadPath: originalSolutionData.path_solution || originalSolutionData.path_document,
url: originalSolutionData.path_solution || originalSolutionData.path_document,
path: originalSolutionData.path_solution || originalSolutionData.path_document,
isExisting: true
};
}
else if (fileUpload && typeof fileUpload === 'object' && Object.keys(fileUpload).length > 0) {
displayFile = fileUpload;
}
else if (file && typeof file === 'object' && Object.keys(file).length > 0) {
displayFile = file;
}
else if (pathSolution && pathSolution.trim() !== '') {
displayFile = {
name: pathSolution.split('/').pop() || 'File',
uploadPath: pathSolution,
url: pathSolution,
path: pathSolution
};
}
if (displayFile) {
const getFileNameFromPath = () => {
const filePath = displayFile.uploadPath || displayFile.url || displayFile.path || '';
if (filePath) {
const fileName = filePath.split('/').pop();
return fileName || 'Uploaded File';
}
return displayFile.name || 'Uploaded File';
};
const displayFileName = getFileNameFromPath();
return (
<Card
style={{
marginBottom: 8,
borderRadius: 8,
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
border: '1px solid #e8e8e8'
}}
styles={{ body: { padding: '16px' } }}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: 48,
height: 48,
borderRadius: 8,
backgroundColor: '#f0f5ff',
flexShrink: 0
}}>
<FileOutlined style={{ fontSize: 24, color: '#1890ff' }} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontSize: 13,
fontWeight: 600,
color: '#262626',
marginBottom: 4,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}>
{displayFileName}
</div>
<div style={{ fontSize: 12, color: '#8c8c8c' }}>
{displayFile.size ? `${(displayFile.size / 1024).toFixed(1)} KB` : 'File uploaded'}
</div>
</div>
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
<Button
type="primary"
size="middle"
icon={<EyeOutlined />}
style={{
fontSize: 12,
display: 'flex',
alignItems: 'center',
gap: 4
}}
onClick={() => {
try {
let fileUrl = '';
let actualFileName = '';
const filePath = displayFile.uploadPath || displayFile.url || displayFile.path || '';
if (filePath) {
actualFileName = filePath.split('/').pop();
if (actualFileName) {
const fileExtension = actualFileName.split('.').pop()?.toLowerCase();
const folder = getFolderFromFileType(fileExtension);
fileUrl = getFileUrl(folder, actualFileName);
}
}
if (!fileUrl && filePath) {
fileUrl = filePath.startsWith('http') ? filePath : `${import.meta.env.VITE_API_SERVER}/${filePath}`;
}
if (fileUrl && actualFileName) {
const fileExtension = actualFileName.split('.').pop()?.toLowerCase();
const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'];
if (imageExtensions.includes(fileExtension)) {
const viewerUrl = `/image-viewer/${encodeURIComponent(actualFileName)}`;
window.open(viewerUrl, '_blank', 'noopener,noreferrer');
} else {
window.open(fileUrl, '_blank', 'noopener,noreferrer');
}
} else {
NotifAlert({
icon: 'error',
title: 'Error',
message: 'File URL not found'
});
}
} catch (error) {
NotifAlert({
icon: 'error',
title: 'Error',
message: 'Failed to open file preview'
});
}
}}
/>
<Button
danger
size="middle"
icon={<DeleteOutlined />}
style={{
fontSize: 12,
display: 'flex',
alignItems: 'center',
}}
onClick={() => {
setIsDeleted(true);
form.setFieldValue(['solution_items', fieldKey, 'fileUpload'], null);
form.setFieldValue(['solution_items', fieldKey, 'file'], null);
form.setFieldValue(['solution_items', fieldKey, 'path_solution'], null);
form.setFieldValue(['solution_items', fieldKey, 'fileName'], null);
setCurrentFile(null);
if (onFileUpload && typeof onFileUpload === 'function') {
onFileUpload(null);
}
setDeleteCounter(prev => prev + 1);
setTimeout(() => {
form.validateFields(['solution_items', fieldKey]);
}, 50);
}}
/>
</div>
</div>
</Card>
);
} else {
return (
<div>
<FileUploadHandler
type="solution"
existingFile={displayFile}
existingFile={null}
clearSignal={deleteCounter}
debugProps={{
currentFile: !!currentFile,
deleteCounter,
shouldClear: !currentFile && deleteCounter > 0
}}
onFileUpload={(fileObject) => {
setIsDeleted(false);
const filePath = fileObject.path_solution || fileObject.uploadPath || fileObject.path || fileObject.url;
const fileWithKey = {
...fileObject,
solutionId: fieldKey
solutionId: fieldKey,
path_solution: filePath,
uploadPath: filePath
};
if (onFileUpload && typeof onFileUpload === 'function') {
onFileUpload(fileWithKey);
}
form.setFieldValue([`solution_items,${fieldKey}`, 'fileUpload'], fileWithKey);
form.setFieldValue([`solution_items,${fieldKey}`, 'file'], fileWithKey);
form.setFieldValue([`solution_items,${fieldKey}`, 'type'], 'file');
form.setFieldValue(['solution_items', fieldKey, 'fileUpload'], fileWithKey);
form.setFieldValue(['solution_items', fieldKey, 'file'], fileWithKey);
form.setFieldValue(['solution_items', fieldKey, 'type'], 'file');
form.setFieldValue(['solution_items', fieldKey, 'path_solution'], filePath);
form.setFieldValue(['solution_items', fieldKey, 'fileName'], fileObject.name);
setTimeout(() => {
const values = form.getFieldValue(['solution_items', fieldKey]);
const pathSolutionValue = form.getFieldValue(['solution_items', fieldKey, 'path_solution']);
}, 100);
setCurrentFile(fileWithKey);
}}
onFileRemove={() => {
console.log(`🗑️ Removing file from solution ${fieldKey}`);
form.setFieldValue(['solution_items', fieldKey, 'fileUpload'], null);
form.setFieldValue(['solution_items', fieldKey, 'file'], null);
form.setFieldValue(['solution_items', fieldKey, 'path_solution'], null);
// Clear file form values only, keep type as file and status active
form.setFieldValue([`solution_items,${fieldKey}`, 'fileUpload'], null);
form.setFieldValue([`solution_items,${fieldKey}`, 'file'], null);
setCurrentFile(null);
// Call parent callback if exists
if (onFileUpload && typeof onFileUpload === 'function') {
onFileUpload(null);
}
console.log(`✅ File removed from solution ${fieldKey} - type and status preserved`);
setDeleteCounter(prev => prev + 1);
}}
disabled={isReadOnly}
buttonText={displayFile ? 'Replace File' : 'Upload File'}
buttonText="Upload File"
buttonStyle={{ width: '100%', fontSize: 12 }}
uploadText="Upload solution file"
uploadText="Upload solution file (includes images, PDF, documents)"
acceptFileTypes="*"
/>
</div>
);
}
}
return null;
};
return (
<ConfigProvider
theme={{
components: {
Switch: {
colorPrimary: '#23A55A',
colorPrimaryHover: '#23A55A',
},
},
}}
>
<div style={{
border: '1px solid #d9d9d9',
borderRadius: 6,
@@ -140,17 +393,13 @@ const SolutionFieldNew = ({
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<Form.Item name={[`solution_items,${fieldKey}`, 'status']} valuePropName="checked" noStyle>
<Form.Item name={['solution_items', fieldKey, 'status']} valuePropName="checked" noStyle>
<Switch
size="small"
disabled={isReadOnly}
onChange={(checked) => {
onStatusChange(fieldKey, checked);
}}
defaultChecked={solutionStatus !== false}
style={{
backgroundColor: solutionStatus !== false ? '#23A55A' : '#bfbfbf'
}}
/>
</Form.Item>
<Text style={{
@@ -158,7 +407,7 @@ const SolutionFieldNew = ({
color: '#666',
whiteSpace: 'nowrap'
}}>
{solutionStatus !== false ? 'Active' : 'Inactive'}
{statusValue ? 'Active' : 'Inactive'}
</Text>
</div>
@@ -180,7 +429,7 @@ const SolutionFieldNew = ({
</div>
<Form.Item
name={[`solution_items,${fieldKey}`, 'name']}
name={['solution_items', fieldKey, 'name']}
rules={[{ required: true, message: 'Solution name wajib diisi!' }]}
style={{ margin: 0 }}
>
@@ -195,13 +444,33 @@ const SolutionFieldNew = ({
</div>
<Form.Item
name={[`solution_items,${fieldKey}`, 'type']}
name={['solution_items', fieldKey, 'type']}
rules={[{ required: true, message: 'Solution type wajib diisi!' }]}
style={{ marginBottom: 8 }}
initialValue={solutionType || 'text'}
>
<Radio.Group
onChange={(e) => onTypeChange(fieldKey, e.target.value)}
onChange={(e) => {
const newType = e.target.value;
if (newType === 'text') {
form.setFieldValue(['solution_items', fieldKey, 'fileUpload'], null);
form.setFieldValue(['solution_items', fieldKey, 'file'], null);
form.setFieldValue(['solution_items', fieldKey, 'path_solution'], null);
form.setFieldValue(['solution_items', fieldKey, 'fileName'], null);
setCurrentFile(null);
setIsDeleted(true);
if (onFileUpload && typeof onFileUpload === 'function') {
onFileUpload(null);
}
} else if (newType === 'file') {
form.setFieldValue(['solution_items', fieldKey, 'text'], null);
setIsDeleted(false);
}
onTypeChange(fieldKey, newType);
}}
disabled={isReadOnly}
size="small"
>
@@ -211,7 +480,7 @@ const SolutionFieldNew = ({
</Form.Item>
<Form.Item
name={[`solution_items,${fieldKey}`, 'status']}
name={['solution_items', fieldKey, 'status']}
initialValue={solutionStatus !== false ? true : false}
noStyle
>
@@ -220,6 +489,7 @@ const SolutionFieldNew = ({
{renderSolutionContent()}
</div>
</ConfigProvider>
);
};

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { Typography, Divider, Button } from 'antd';
import { Typography, Divider, Button, Form } from 'antd';
import { PlusOutlined } from '@ant-design/icons';
import SolutionFieldNew from './SolutionField';
@@ -10,34 +10,21 @@ const SolutionForm = ({
solutionFields,
solutionTypes,
solutionStatuses,
firstSolutionValid,
onAddSolutionField,
onRemoveSolutionField,
onSolutionTypeChange,
onSolutionStatusChange,
checkFirstSolutionValid,
onSolutionFileUpload,
onFileView,
fileList,
isReadOnly = false,
solutionData = [],
}) => {
// console.log('SolutionForm props:', {
// solutionFields,
// solutionTypes,
// solutionStatuses,
// firstSolutionValid,
// onAddSolutionField: typeof onAddSolutionField,
// onRemoveSolutionField: typeof onRemoveSolutionField,
// checkFirstSolutionValid: typeof checkFirstSolutionValid,
// onSolutionFileUpload: typeof onSolutionFileUpload,
// onFileView: typeof onFileView,
// fileList: fileList ? fileList.length : 0
// });
return (
<div style={{ marginBottom: 0 }}>
<Divider orientation="left">Solution Items</Divider>
<Form form={solutionForm} layout="vertical">
<div style={{
maxHeight: '400px',
overflowY: 'auto',
@@ -59,12 +46,13 @@ const SolutionForm = ({
fileList={fileList}
isReadOnly={isReadOnly}
canRemove={solutionFields.length > 1 && displayIndex > 0}
originalSolutionData={solutionData[displayIndex]}
/>
))}
</div>
{!isReadOnly && (
<div style={{ marginBottom: 8 }}>
<div style={{ marginBottom: 8, marginTop: 12 }}>
<Button
type="dashed"
onClick={onAddSolutionField}
@@ -77,10 +65,11 @@ const SolutionForm = ({
fontSize: '12px'
}}
>
Add More Solution
Add sollution
</Button>
</div>
)}
</Form>
</div>
);
};

View File

@@ -1,249 +0,0 @@
import React, { useState, useEffect } from 'react';
import { Card, Row, Col, Typography, Tag, Space, Spin, Button, Empty, message } from 'antd';
import { SearchOutlined } from '@ant-design/icons';
import { getAllSparepart } from '../../../../api/sparepart';
import { addSparepartToBrand, removeSparepartFromBrand } from '../../../../api/master-brand';
import CustomSparepartCard from './CustomSparepartCard';
const { Text, Title } = Typography;
const SparepartCardSelect = ({
selectedSparepartIds = [],
onSparepartChange,
isLoading: externalLoading = false,
isReadOnly = false,
brandId = null
}) => {
const [spareparts, setSpareparts] = useState([]);
const [loading, setLoading] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
useEffect(() => {
loadSpareparts();
}, []);
const loadSpareparts = async () => {
setLoading(true);
try {
const params = new URLSearchParams();
params.set('limit', '1000'); // Get all spareparts
const response = await getAllSparepart(params);
if (response && (response.statusCode === 200 || response.data)) {
const sparepartData = response.data?.data || response.data || [];
setSpareparts(sparepartData);
} else {
// For demo purposes, use mock data if API fails
setSpareparts([
{
sparepart_id: 1,
sparepart_name: 'Compressor Oil Filter',
sparepart_description: 'Oil filter for compressor',
sparepart_foto: null,
sparepart_code: 'SP-001',
sparepart_merk: 'Brand A',
sparepart_model: 'Model X'
},
{
sparepart_id: 2,
sparepart_name: 'Air Intake Filter',
sparepart_description: 'Air intake filter',
sparepart_foto: null,
sparepart_code: 'SP-002',
sparepart_merk: 'Brand B',
sparepart_model: 'Model Y'
},
{
sparepart_id: 3,
sparepart_name: 'Cooling Fan Motor',
sparepart_description: 'Motor for cooling fan',
sparepart_foto: null,
sparepart_code: 'SP-003',
sparepart_merk: 'Brand C',
sparepart_model: 'Model Z'
},
]);
}
} catch (error) {
// Default mock data
setSpareparts([
{
sparepart_id: 1,
sparepart_name: 'Compressor Oil Filter',
sparepart_description: 'Oil filter for compressor',
sparepart_foto: null,
sparepart_code: 'SP-001',
sparepart_merk: 'Brand A',
sparepart_model: 'Model X'
},
{
sparepart_id: 2,
sparepart_name: 'Air Intake Filter',
sparepart_description: 'Air intake filter',
sparepart_foto: null,
sparepart_code: 'SP-002',
sparepart_merk: 'Brand B',
sparepart_model: 'Model Y'
},
{
sparepart_id: 3,
sparepart_name: 'Cooling Fan Motor',
sparepart_description: 'Motor for cooling fan',
sparepart_foto: null,
sparepart_code: 'SP-003',
sparepart_merk: 'Brand C',
sparepart_model: 'Model Z'
},
]);
} finally {
setLoading(false);
}
};
const filteredSpareparts = spareparts.filter(sp =>
sp.sparepart_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
sp.sparepart_code.toLowerCase().includes(searchTerm.toLowerCase()) ||
sp.sparepart_merk?.toLowerCase().includes(searchTerm.toLowerCase()) ||
sp.sparepart_model?.toLowerCase().includes(searchTerm.toLowerCase())
);
const handleSparepartToggle = async (sparepartId) => {
if (isReadOnly) return;
const isCurrentlySelected = selectedSparepartIds.includes(sparepartId);
// If brandId is provided, save immediately to database
if (brandId) {
try {
setLoading(true);
if (isCurrentlySelected) {
// Remove from database
await removeSparepartFromBrand(brandId, sparepartId);
message.success('Sparepart removed from brand successfully');
} else {
// Add to database
await addSparepartToBrand(brandId, sparepartId);
message.success('Sparepart added to brand successfully');
}
// Update local state
const newSelectedIds = isCurrentlySelected
? selectedSparepartIds.filter(id => id !== sparepartId)
: [...selectedSparepartIds, sparepartId];
onSparepartChange(newSelectedIds);
} catch (error) {
message.error(error.message || 'Failed to update sparepart');
} finally {
setLoading(false);
}
} else {
// If no brandId (add mode), just update local state
const newSelectedIds = isCurrentlySelected
? selectedSparepartIds.filter(id => id !== sparepartId)
: [...selectedSparepartIds, sparepartId];
onSparepartChange(newSelectedIds);
}
};
const isSelected = (sparepartId) => selectedSparepartIds.includes(sparepartId);
const combinedLoading = loading || externalLoading;
return (
<div>
<div style={{ marginBottom: 16 }}>
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
<Title level={5} style={{ margin: 0 }}>
Select Spareparts
</Title>
<div style={{ position: 'relative', width: '200px' }}>
<input
type="text"
placeholder="Search spareparts..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
style={{
padding: '8px 30px 8px 12px',
border: '1px solid #d9d9d9',
borderRadius: '6px',
width: '100%'
}}
/>
<SearchOutlined
style={{
position: 'absolute',
right: '10px',
top: '50%',
transform: 'translateY(-50%)',
color: '#bfbfbf'
}}
/>
</div>
</Space>
</div>
{combinedLoading ? (
<div style={{ textAlign: 'center', padding: '40px' }}>
<Spin size="large" />
</div>
) : filteredSpareparts.length === 0 ? (
<Empty
description="No spareparts found"
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
) : (
<Row gutter={[16, 16]}>
{filteredSpareparts.map(sparepart => (
<Col xs={24} sm={12} md={12} lg={12} key={sparepart.sparepart_id}>
<CustomSparepartCard
sparepart={sparepart}
isSelected={isSelected(sparepart.sparepart_id)}
isReadOnly={isReadOnly}
showPreview={true}
showDelete={true}
onCardClick={() => handleSparepartToggle(sparepart.sparepart_id)}
onDelete={() => {
// When delete button is clicked, remove from selection
const newSelectedIds = selectedSparepartIds.filter(id => id !== sparepart.sparepart_id);
onSparepartChange(newSelectedIds);
// Also remove from database if brandId exists
if (brandId) {
removeSparepartFromBrand(brandId, sparepart.sparepart_id)
.then(() => message.success('Sparepart removed successfully'))
.catch(error => message.error(error.message || 'Failed to remove sparepart'));
}
}}
/>
</Col>
))}
</Row>
)}
{selectedSparepartIds.length > 0 && (
<div style={{ marginTop: 16 }}>
<Text strong>Selected Spareparts: </Text>
<Space wrap>
{selectedSparepartIds.map(id => {
const sparepart = spareparts.find(sp => sp.sparepart_id === id);
return sparepart ? (
<Tag key={id} color="green">
{sparepart.sparepart_name} (ID: {id})
</Tag>
) : (
<Tag key={id} color="green">
Sparepart ID: {id}
</Tag>
);
})}
</Space>
</div>
)}
</div>
);
};
export default SparepartCardSelect;

View File

@@ -1,26 +0,0 @@
import React from 'react';
import { Card, Divider, Typography } from 'antd';
import SparepartCardSelect from './SparepartCardSelect';
const { Text } = Typography;
const SparepartForm = ({
sparepartForm,
selectedSparepartIds,
onSparepartChange,
isReadOnly = false
}) => {
return (
<div style={{ minHeight: '400px' }}>
<Card title="Spareparts" style={{ minHeight: '100%' }}>
<SparepartCardSelect
selectedSparepartIds={selectedSparepartIds}
onSparepartChange={onSparepartChange}
isReadOnly={isReadOnly}
/>
</Card>
</div>
);
};
export default SparepartForm;

View File

@@ -0,0 +1,178 @@
import React, { useState, useEffect } from 'react';
import { Select, Typography, Tag, Spin, Empty, Button } from 'antd';
import { PlusOutlined, DeleteOutlined, CheckOutlined, EyeOutlined, InfoCircleOutlined } from '@ant-design/icons';
import { getAllSparepart } from '../../../../api/sparepart';
import CustomSparepartCard from './CustomSparepartCard';
const { Text, Title } = Typography;
const { Option } = Select;
const SparepartSelect = ({
selectedSparepartIds = [],
onSparepartChange,
isReadOnly = false
}) => {
const [spareparts, setSpareparts] = useState([]);
const [loading, setLoading] = useState(false);
const [selectedSpareparts, setSelectedSpareparts] = useState([]);
const [dropdownOpen, setDropdownOpen] = useState(false);
useEffect(() => {
fetchSpareparts();
}, []);
useEffect(() => {
if (selectedSparepartIds && selectedSparepartIds.length > 0) {
const fullSelectedSpareparts = spareparts.filter(sp =>
selectedSparepartIds.includes(sp.sparepart_id)
);
setSelectedSpareparts(fullSelectedSpareparts);
} else {
setSelectedSpareparts([]);
}
}, [selectedSparepartIds, spareparts]);
const fetchSpareparts = async (searchQuery = '') => {
setLoading(true);
try {
const params = new URLSearchParams();
params.set('limit', '10');
if (searchQuery && searchQuery.trim() !== '') {
params.set('criteria', searchQuery.trim());
}
const response = await getAllSparepart(params);
if (response && (response.statusCode === 200 || response.data)) {
const sparepartData = response.data?.data || response.data || [];
setSpareparts(sparepartData);
} else {
setSpareparts([]);
}
} catch (error) {
setSpareparts([]);
} finally {
setLoading(false);
}
};
const handleSparepartSelect = (sparepartId) => {
const selectedSparepart = spareparts.find(sp => sp.sparepart_id === sparepartId);
if (selectedSparepart) {
const isAlreadySelected = selectedSpareparts.some(sp => sp.sparepart_id === sparepartId);
if (!isAlreadySelected) {
const newSelectedSpareparts = [...selectedSpareparts, selectedSparepart];
setSelectedSpareparts(newSelectedSpareparts);
const newSelectedIds = newSelectedSpareparts.map(sp => sp.sparepart_id);
onSparepartChange(newSelectedIds);
}
}
setDropdownOpen(false);
};
const handleSearch = (value) => {
fetchSpareparts(value);
};
const onDropdownOpenChange = (open) => {
setDropdownOpen(open);
if (open) {
fetchSpareparts();
}
};
const handleRemoveSparepart = (sparepartId) => {
const newSelectedSpareparts = selectedSpareparts.filter(sp => sp.sparepart_id !== sparepartId);
setSelectedSpareparts(newSelectedSpareparts);
const newSelectedIds = newSelectedSpareparts.map(sp => sp.sparepart_id);
onSparepartChange(newSelectedIds);
};
const renderSparepartCard = (sparepart, isSelected = false) => {
const isAlreadySelected = selectedSpareparts.some(sp => sp.sparepart_id === sparepart.sparepart_id);
return (
<CustomSparepartCard
key={sparepart.sparepart_id}
sparepart={sparepart}
isSelected={isSelected}
isReadOnly={isReadOnly}
showPreview={true}
showDelete={isAlreadySelected && !isReadOnly}
onCardClick={!isAlreadySelected && !isReadOnly ? () => handleSparepartSelect(sparepart.sparepart_id) : undefined}
onDelete={() => handleRemoveSparepart(sparepart.sparepart_id)}
/>
);
};
return (
<>
{!isReadOnly && (
<div style={{
marginBottom: 16,
position: 'sticky',
top: 0,
zIndex: 10,
backgroundColor: 'white',
padding: '8px 0',
borderBottom: '1px solid #f0f0f0'
}}>
<Select
placeholder="search and select sparepart"
style={{ width: '100%' }}
loading={loading}
onSelect={handleSparepartSelect}
value={null}
showSearch
onSearch={handleSearch}
filterOption={false}
open={dropdownOpen}
onOpenChange={onDropdownOpenChange}
suffixIcon={<PlusOutlined />}
>
{spareparts
.filter(sparepart => !selectedSpareparts.some(sp => sp.sparepart_id === sparepart.sparepart_id))
.slice(0, 10)
.map((sparepart) => (
<Option key={sparepart.sparepart_id} value={sparepart.sparepart_id}>
<div>
<Text strong>{sparepart.sparepart_name || sparepart.name || 'Unnamed'}</Text>
<Text type="secondary" style={{ marginLeft: 8 }}>
({sparepart.sparepart_code || 'No code'})
</Text>
</div>
</Option>
))}
</Select>
</div>
)}
<div>
{selectedSpareparts.length > 0 ? (
<div>
<Title level={5} style={{ marginBottom: 16 }}>
Selected Spareparts ({selectedSpareparts.length})
</Title>
<div>
{selectedSpareparts.map(sparepart => renderSparepartCard(sparepart, true))}
</div>
</div>
) : (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="No spareparts selected"
style={{ margin: '20px 0' }}
/>
)}
</div>
</>
);
};
export default SparepartSelect;

View File

@@ -1,265 +0,0 @@
import { useState, useEffect } from 'react';
import { NotifAlert, NotifOk } from '../../../../components/Global/ToastNotif';
export const useErrorCodeLogic = (errorCodeForm, fileList) => {
const [solutionFields, setSolutionFields] = useState([0]);
const [solutionTypes, setSolutionTypes] = useState({ 0: 'text' });
const [solutionStatuses, setSolutionStatuses] = useState({ 0: true });
const [firstSolutionValid, setFirstSolutionValid] = useState(false);
const [solutionsToDelete, setSolutionsToDelete] = useState(new Set());
const checkPreviousSolutionValid = (currentSolutionIndex) => {
for (let i = 0; i < currentSolutionIndex; i++) {
const fieldId = solutionFields[i];
const solutionType = solutionTypes[fieldId];
const solutionName = errorCodeForm.getFieldValue(`solution_name_${fieldId}`);
if (!solutionName || solutionName.trim() === '') {
return false;
}
if (solutionType === 'text') {
const textSolution = errorCodeForm.getFieldValue(`text_solution_${fieldId}`);
if (!textSolution || textSolution.trim() === '') {
return false;
}
} else if (solutionType === 'file') {
const filesForSolution = fileList.filter(file => file.solutionId === fieldId);
if (filesForSolution.length === 0) {
return false;
}
}
}
return true;
};
const checkFirstSolutionValid = () => {
if (solutionFields.length === 0) {
setFirstSolutionValid(false);
return false;
}
const isValid = checkPreviousSolutionValid(1);
setFirstSolutionValid(isValid);
return isValid;
};
const handleAddSolutionField = () => {
const currentSolutionCount = solutionFields.length;
const nextSolutionNumber = currentSolutionCount + 1;
if (!checkPreviousSolutionValid(currentSolutionCount)) {
let incompleteSolutionIndex = -1;
for (let i = 0; i < currentSolutionCount; i++) {
const fieldId = solutionFields[i];
const solutionType = solutionTypes[fieldId];
const solutionName = errorCodeForm.getFieldValue(`solution_name_${fieldId}`);
let hasContent = false;
if (solutionType === 'text') {
const textSolution = errorCodeForm.getFieldValue(`text_solution_${fieldId}`);
hasContent = textSolution && textSolution.trim();
} else if (solutionType === 'file') {
const filesForSolution = fileList.filter(file => file.solutionId === fieldId);
hasContent = filesForSolution.length > 0;
}
if (!solutionName?.trim() || !hasContent) {
incompleteSolutionIndex = i + 1;
break;
}
}
NotifAlert({
icon: 'warning',
title: 'Perhatian',
message: `Harap lengkapi Solution ${incompleteSolutionIndex} terlebih dahulu sebelum menambah Solution ${nextSolutionNumber}!`
});
return;
}
const newId = `new-${Date.now()}`;
setSolutionFields(prev => [...prev, newId]);
setSolutionTypes(prev => ({ ...prev, [newId]: 'text' }));
setSolutionStatuses(prev => ({ ...prev, [newId]: true }));
errorCodeForm.setFieldValue(`solution_status_${newId}`, true);
errorCodeForm.setFieldValue(`solution_type_${newId}`, 'text');
};
const handleRemoveSolutionField = (id) => {
const isNewSolution = !id.toString().startsWith('existing-');
if (isNewSolution) {
if (solutionFields.length > 1) {
setSolutionFields(solutionFields.filter(fieldId => fieldId !== id));
setSolutionTypes(prev => {
const newTypes = { ...prev };
delete newTypes[id];
return newTypes;
});
setSolutionStatuses(prev => {
const newStatuses = { ...prev };
delete newStatuses[id];
return newStatuses;
});
setSolutionsToDelete(prev => {
const newSet = new Set(prev);
newSet.delete(id);
return newSet;
});
} else {
NotifAlert({
icon: 'warning',
title: 'Perhatian',
message: 'Setiap error code harus memiliki minimal 1 solution!'
});
}
} else {
const solutionName = errorCodeForm.getFieldValue(`solution_name_${id}`);
const solutionType = solutionTypes[id];
let isEmpty = true;
const existingSolution = window.currentSolutionData?.[id];
const hasExistingData = existingSolution && (
(existingSolution.solution_name && existingSolution.solution_name.trim()) ||
(existingSolution.text_solution && existingSolution.text_solution.trim()) ||
(existingSolution.path_solution && existingSolution.path_solution.trim())
);
if (solutionType === 'text') {
const textSolution = errorCodeForm.getFieldValue(`text_solution_${id}`);
isEmpty = !solutionName?.trim() && !textSolution?.trim() && !hasExistingData;
} else if (solutionType === 'file') {
const filesForSolution = fileList.filter(file => file.solutionId === id);
isEmpty = !solutionName?.trim() && filesForSolution.length === 0 && !hasExistingData;
}
if (isEmpty) {
if (solutionFields.length > 1) {
setSolutionFields(solutionFields.filter(fieldId => fieldId !== id));
setSolutionTypes(prev => {
const newTypes = { ...prev };
delete newTypes[id];
return newTypes;
});
setSolutionStatuses(prev => {
const newStatuses = { ...prev };
delete newStatuses[id];
return newStatuses;
});
if (window.currentSolutionData) {
delete window.currentSolutionData[id];
}
setSolutionsToDelete(prev => {
const newSet = new Set(prev);
newSet.delete(id);
return newSet;
});
} else {
NotifAlert({
icon: 'warning',
title: 'Perhatian',
message: 'Setiap error code harus memiliki minimal 1 solution!'
});
}
} else {
if (solutionFields.length > 1) {
setSolutionsToDelete(prev => new Set(prev).add(id));
const solutionElement = document.querySelector(`[data-solution-id="${id}"]`);
if (solutionElement) {
solutionElement.style.opacity = '0.5';
solutionElement.style.border = '2px dashed #ff4d4f';
}
NotifOk({
icon: 'success',
title: 'Berhasil',
message: 'Solution ditandai untuk dihapus. Klik "Update Error Code" untuk menyimpan perubahan.'
});
} else {
NotifAlert({
icon: 'warning',
title: 'Perhatian',
message: 'Setiap error code harus memiliki minimal 1 solution!'
});
}
}
}
};
const handleSolutionTypeChange = (fieldId, type) => {
setSolutionTypes(prev => ({ ...prev, [fieldId]: type }));
};
const handleSolutionStatusChange = (fieldId, status) => {
// Only update local state - form is already updated by Form.Item
setSolutionStatuses(prev => ({
...prev,
[fieldId]: status
}));
};
const setSolutionsForExistingRecord = (solutions, errorCodeForm) => {
const newSolutionFields = [];
const newSolutionTypes = {};
const newSolutionStatuses = {};
const newSolutionData = {};
solutions.forEach((solution, index) => {
const fieldId = `existing-${index}`;
newSolutionFields.push(fieldId);
newSolutionTypes[fieldId] = solution.type_solution || 'text';
newSolutionStatuses[fieldId] = solution.is_active !== false;
newSolutionData[fieldId] = {
...solution,
brand_code_solution_id: solution.brand_code_solution_id
};
setTimeout(() => {
errorCodeForm.setFieldsValue({
[`solution_name_${fieldId}`]: solution.solution_name,
[`text_solution_${fieldId}`]: solution.text_solution || '',
[`solution_status_${fieldId}`]: solution.is_active !== false,
[`solution_type_${fieldId}`]: solution.type_solution === 'image' || solution.type_solution === 'pdf' ? 'file' : solution.type_solution
});
}, 100);
});
setSolutionFields(newSolutionFields);
setSolutionTypes(newSolutionTypes);
setSolutionStatuses(newSolutionStatuses);
window.currentSolutionData = newSolutionData;
};
const resetSolutionFields = () => {
setSolutionFields([0]);
setSolutionTypes({ 0: 'text' });
setSolutionStatuses({ 0: true });
setFirstSolutionValid(false);
setSolutionsToDelete(new Set());
};
useEffect(() => {
const timer = setTimeout(() => {
checkFirstSolutionValid();
}, 100);
return () => clearTimeout(timer);
}, [solutionFields, solutionTypes, fileList, errorCodeForm]);
return {
solutionFields,
solutionTypes,
solutionStatuses,
firstSolutionValid,
solutionsToDelete,
handleAddSolutionField,
handleRemoveSolutionField,
handleSolutionTypeChange,
handleSolutionStatusChange,
resetSolutionFields,
checkFirstSolutionValid,
setSolutionsForExistingRecord
};
};

View File

@@ -1,427 +0,0 @@
import { useState, useEffect } from 'react';
export const useSolutionLogic = (solutionForm) => {
const [solutionFields, setSolutionFields] = useState([0]);
const [solutionTypes, setSolutionTypes] = useState({ 0: 'text' });
const [solutionStatuses, setSolutionStatuses] = useState({ 0: true });
const [solutionsToDelete, setSolutionsToDelete] = useState([]);
useEffect(() => {
setTimeout(() => {
if (solutionForm) {
solutionForm.setFieldsValue({
solution_items: {
0: {
name: 'Solution 1',
status: true,
type: 'text',
text: 'Deskripsi untuk Solution 1',
file: null,
fileUpload: null
}
}
});
}
}, 100);
}, [solutionForm]);
const handleAddSolutionField = () => {
const newKey = Date.now();
setSolutionFields(prev => [...prev, newKey]);
setSolutionTypes(prev => ({ ...prev, [newKey]: 'text' }));
setSolutionStatuses(prev => ({ ...prev, [newKey]: true }));
setTimeout(() => {
const currentFormValues = solutionForm.getFieldsValue(true);
const existingNames = [];
Object.keys(currentFormValues).forEach(key => {
if (key.startsWith('solution_items,') || key.startsWith('solution_items.')) {
const solutionData = currentFormValues[key];
if (solutionData && solutionData.name) {
existingNames.push(solutionData.name);
}
}
});
if (currentFormValues.solution_items) {
Object.values(currentFormValues.solution_items).forEach(solution => {
if (solution && solution.name) {
existingNames.push(solution.name);
}
});
}
let solutionNumber = solutionFields.length + 1;
let defaultName = `Solution ${solutionNumber}`;
while (existingNames.includes(defaultName)) {
solutionNumber++;
defaultName = `Solution ${solutionNumber}`;
}
solutionForm.setFieldValue(['solution_items', newKey, 'name'], defaultName);
solutionForm.setFieldValue(['solution_items', newKey, 'type'], 'text');
solutionForm.setFieldValue(['solution_items', newKey, 'text'], `Deskripsi untuk ${defaultName}`);
solutionForm.setFieldValue(['solution_items', newKey, 'status'], true);
solutionForm.setFieldValue(['solution_items', newKey, 'file'], null);
solutionForm.setFieldValue(['solution_items', newKey, 'fileUpload'], null);
}, 100);
};
const handleRemoveSolutionField = (key) => {
if (solutionFields.length <= 1) {
return;
}
setSolutionFields(prev => prev.filter(field => field !== key));
const newTypes = { ...solutionTypes };
const newStatuses = { ...solutionStatuses };
delete newTypes[key];
delete newStatuses[key];
setSolutionTypes(newTypes);
setSolutionStatuses(newStatuses);
setTimeout(() => {
try {
solutionForm.setFieldValue(['solution_items', key], undefined);
solutionForm.setFieldValue(['solution_items', key, 'name'], undefined);
solutionForm.setFieldValue(['solution_items', key, 'type'], undefined);
solutionForm.setFieldValue(['solution_items', key, 'text'], undefined);
solutionForm.setFieldValue(['solution_items', key, 'status'], undefined);
solutionForm.setFieldValue(['solution_items', key, 'file'], undefined);
solutionForm.setFieldValue(['solution_items', key, 'fileUpload'], undefined);
} catch (error) {
}
}, 50);
};
const handleSolutionTypeChange = (key, value) => {
setSolutionTypes(prev => ({ ...prev, [key]: value }));
setTimeout(() => {
const fieldName = ['solution_items', key];
const currentSolutionData = solutionForm.getFieldsValue([fieldName]) || {};
const solutionData = currentSolutionData[`solution_items,${key}`] || currentSolutionData[`solution_items.${key}`] || {};
if (value === 'text') {
const updatedSolutionData = {
...solutionData,
fileUpload: null,
file: null,
text: solutionData.text || `Deskripsi untuk ${solutionData.name || 'Solution'}`
};
solutionForm.setFieldValue([...fieldName, 'fileUpload'], null);
solutionForm.setFieldValue([...fieldName, 'file'], null);
solutionForm.setFieldValue([...fieldName, 'text'], updatedSolutionData.text);
} else if (value === 'file') {
const updatedSolutionData = {
...solutionData,
text: '',
fileUpload: null,
file: null
};
solutionForm.setFieldValue([...fieldName, 'text'], '');
solutionForm.setFieldValue([...fieldName, 'fileUpload'], null);
solutionForm.setFieldValue([...fieldName, 'file'], null);
}
}, 0);
};
const handleSolutionStatusChange = (key, value) => {
setSolutionStatuses(prev => ({ ...prev, [key]: value }));
};
const resetSolutionFields = () => {
setSolutionFields([0]);
setSolutionTypes({ 0: 'text' });
setSolutionStatuses({ 0: true });
solutionForm.resetFields();
setTimeout(() => {
solutionForm.setFieldsValue({
solution_items: {
0: {
name: 'Solution 1',
status: true,
type: 'text',
text: '',
file: null,
fileUpload: null
}
}
});
solutionForm.setFieldValue(['solution_items', 0, 'name'], 'Solution 1');
solutionForm.setFieldValue(['solution_items', 0, 'type'], 'text');
solutionForm.setFieldValue(['solution_items', 0, 'text'], 'Deskripsi untuk Solution 1');
solutionForm.setFieldValue(['solution_items', 0, 'status'], true);
solutionForm.setFieldValue(['solution_items', 0, 'file'], null);
solutionForm.setFieldValue(['solution_items', 0, 'fileUpload'], null);
console.log('✅ Reset solution fields with proper structure');
console.log('Form values after reset:', solutionForm.getFieldsValue(true));
}, 100);
};
const checkFirstSolutionValid = () => {
const values = solutionForm.getFieldsValue();
const firstField = solutionFields[0];
if (!firstField) {
return false;
}
const solutionKey = firstField.key || firstField;
// Try both notations for compatibility
const commaPath = `solution_items,${solutionKey}`;
const dotPath = `solution_items.${solutionKey}`;
const firstSolution = values[commaPath] || values[dotPath];
if (!firstSolution || !firstSolution.name || firstSolution.name.trim() === '') {
return false;
}
if (solutionTypes[solutionKey] === 'text' && (!firstSolution.text || firstSolution.text.trim() === '')) {
return false;
}
return true;
};
const getSolutionData = () => {
try {
const values = solutionForm.getFieldsValue(true);
const result = [];
solutionFields.forEach(key => {
let solution = null;
try {
solution = solutionForm.getFieldValue(['solution_items', key]);
} catch (error) {
// Silently handle errors
}
if (!solution && values.solution_items && values.solution_items[key]) {
solution = values.solution_items[key];
}
if (!solution) {
const commaKey = `solution_items,${key}`;
solution = values[commaKey];
}
if (!solution) {
const dotKey = `solution_items.${key}`;
solution = values[dotKey];
}
if (!solution) {
const allKeys = Object.keys(values);
const foundKey = allKeys.find(k =>
k.includes(key.toString()) &&
k.includes('solution_items')
);
if (foundKey) {
solution = values[foundKey];
}
}
if (!solution) {
// Try to find the solution in the raw form structure
const rawValues = solutionForm.getFieldsValue();
if (rawValues.solution_items && rawValues.solution_items[key]) {
solution = rawValues.solution_items[key];
}
}
if (!solution) {
return;
}
const hasName = solution.name && solution.name.trim() !== '';
if (!hasName) {
return;
}
const solutionType = solutionTypes[key] || solution.type || 'text';
let isValidType = true;
if (solutionType === 'text') {
isValidType = solution.text && solution.text.trim() !== '';
if (!isValidType) {
return;
}
} else if (solutionType === 'file') {
const hasPathSolution = solution.path_solution && solution.path_solution.trim() !== '';
const hasFileUpload = (solution.fileUpload && typeof solution.fileUpload === 'object' && Object.keys(solution.fileUpload).length > 0);
const hasFile = (solution.file && typeof solution.file === 'object' && Object.keys(solution.file).length > 0);
isValidType = hasPathSolution || hasFileUpload || hasFile;
if (!isValidType) {
return;
}
}
let pathSolution = '';
let fileObject = null;
if (solution.fileUpload && typeof solution.fileUpload === 'object' && Object.keys(solution.fileUpload).length > 0) {
pathSolution = solution.fileUpload.path_solution || solution.fileUpload.uploadPath || '';
fileObject = solution.fileUpload;
} else if (solution.file && typeof solution.file === 'object' && Object.keys(solution.file).length > 0) {
pathSolution = solution.file.path_solution || solution.file.uploadPath || '';
fileObject = solution.file;
} else if (solution.file && typeof solution.file === 'string' && solution.file.trim() !== '') {
pathSolution = solution.file;
}
let typeSolution = solutionTypes[key] || solution.type || 'text';
if (typeSolution === 'file') {
if (fileObject && fileObject.type_solution) {
typeSolution = fileObject.type_solution;
} else {
typeSolution = 'image';
}
}
const finalSolution = {
solution_name: solution.name,
type_solution: typeSolution,
text_solution: solution.text || '',
path_solution: pathSolution,
is_active: solution.status !== false && solution.status !== undefined ? solution.status : (solutionStatuses[key] !== false),
};
result.push(finalSolution);
});
return result;
} catch (error) {
return [];
}
};
const setSolutionsForExistingRecord = (solutions, form) => {
if (!solutions || solutions.length === 0) return;
const newFields = solutions.map((solution, index) => solution.id || index);
setSolutionFields(newFields);
const solutionsValues = {};
const newTypes = {};
const newStatuses = {};
solutions.forEach((solution, index) => {
const key = solution.id || index;
let fileObject = null;
if (solution.path_solution && solution.path_solution.trim() !== '') {
const fileName = solution.file_upload_name || solution.path_solution.split('/').pop() || `file_${index}`;
fileObject = {
uploadPath: solution.path_solution,
path_solution: solution.path_solution,
name: fileName,
type_solution: solution.type_solution || 'image',
isExisting: true,
size: 0,
type: solution.type_solution === 'pdf' ? 'application/pdf' : 'image/jpeg',
fileExtension: solution.type_solution === 'pdf' ? 'pdf' : (fileName.split('.').pop().toLowerCase() || 'jpg')
};
}
const isFileType = solution.type_solution && solution.type_solution !== 'text' && fileObject;
solutionsValues[key] = {
name: solution.solution_name || '',
type: isFileType ? 'file' : 'text',
text: solution.text_solution || '',
file: fileObject,
fileUpload: fileObject,
status: solution.is_active !== false
};
newTypes[key] = isFileType ? 'file' : 'text';
newStatuses[key] = solution.is_active !== false;
});
const nestedFormValues = {
solution_items: {}
};
Object.keys(solutionsValues).forEach(key => {
const solution = solutionsValues[key];
nestedFormValues.solution_items[key] = {
name: solution.name,
type: solution.type,
text: solution.text,
file: solution.file,
fileUpload: solution.fileUpload,
status: solution.status
};
});
form.setFieldsValue(nestedFormValues);
const fallbackFormValues = {};
Object.keys(solutionsValues).forEach(key => {
const solution = solutionsValues[key];
fallbackFormValues[`solution_items,${key}`] = {
name: solution.name,
type: solution.type,
text: solution.text,
file: solution.file,
fileUpload: solution.fileUpload,
status: solution.status
};
});
form.setFieldsValue(fallbackFormValues);
Object.keys(solutionsValues).forEach(key => {
const solution = solutionsValues[key];
form.setFieldValue([`solution_items,${key}`, 'name'], solution.name);
form.setFieldValue([`solution_items,${key}`, 'type'], solution.type);
form.setFieldValue([`solution_items,${key}`, 'text'], solution.text);
form.setFieldValue([`solution_items,${key}`, 'file'], solution.file);
form.setFieldValue([`solution_items,${key}`, 'fileUpload'], solution.fileUpload);
form.setFieldValue([`solution_items,${key}`, 'status'], solution.status);
form.setFieldValue(['solution_items', key, 'name'], solution.name);
form.setFieldValue(['solution_items', key, 'type'], solution.type);
form.setFieldValue(['solution_items', key, 'text'], solution.text);
form.setFieldValue(['solution_items', key, 'file'], solution.file);
form.setFieldValue(['solution_items', key, 'fileUpload'], solution.fileUpload);
form.setFieldValue(['solution_items', key, 'status'], solution.status);
});
setSolutionTypes(newTypes);
setSolutionStatuses(newStatuses);
};
return {
solutionFields,
solutionTypes,
solutionStatuses,
solutionsToDelete,
firstSolutionValid: checkFirstSolutionValid(),
handleAddSolutionField,
handleRemoveSolutionField,
handleSolutionTypeChange,
handleSolutionStatusChange,
resetSolutionFields,
checkFirstSolutionValid,
getSolutionData,
setSolutionsForExistingRecord,
};
};

View File

@@ -60,7 +60,10 @@ const DetailDevice = (props) => {
device_name: formData.device_name,
is_active: formData.is_active,
device_location: formData.device_location,
device_description: (formData.device_description && formData.device_description.trim() !== '') ? formData.device_description : ' ',
device_description:
formData.device_description && formData.device_description.trim() !== ''
? formData.device_description
: ' ',
ip_address: formData.ip_address,
brand_id: formData.brand_id,
listen_channel: formData.listen_channel,
@@ -184,7 +187,6 @@ const DetailDevice = (props) => {
defaultBorderColor: '#23A55A',
defaultHoverColor: '#23A55A',
defaultHoverBorderColor: '#23A55A',
defaultHoverColor: '#23A55A',
},
},
}}

View File

@@ -25,9 +25,9 @@ const GeneratePdf = (props) => {
const { images, title } = await kopReportPdf(logoPiEnergi, 'COLD WORK PERMIT');
const doc = new jsPDF({
orientation: "portrait",
unit: "mm",
format: "a4"
orientation: 'portrait',
unit: 'mm',
format: 'a4',
});
const width = 45;
@@ -50,27 +50,27 @@ const GeneratePdf = (props) => {
doc.setLineWidth(0.6);
doc.line(10, 32.8, 200, 32.8);
doc.text("Tanggal Pengajuan", 10, 42);
doc.text(":", 59, 42);
doc.text('Tanggal Pengajuan', 10, 42);
doc.text(':', 59, 42);
doc.text("Deskripsi Pekerjaan", 10, 48);
doc.text(":", 59, 48);
doc.text('Deskripsi Pekerjaan', 10, 48);
doc.text(':', 59, 48);
doc.text("No. Permit", 10, 54);
doc.text(":", 59, 54);
doc.text("Spesifik Lokasi", 120, 54);
doc.text(":", 160, 54);
doc.text('No. Permit', 10, 54);
doc.text(':', 59, 54);
doc.text('Spesifik Lokasi', 120, 54);
doc.text(':', 160, 54);
doc.text("No. Order", 10, 60);
doc.text(":", 59, 60);
doc.text("Jum. Personil Terlihat", 120, 60);
doc.text(":", 160, 60);
doc.text('No. Order', 10, 60);
doc.text(':', 59, 60);
doc.text('Jum. Personil Terlihat', 120, 60);
doc.text(':', 160, 60);
doc.text("Peralatan yang digunakan", 10, 66);
doc.text(":", 59, 66);
doc.text('Peralatan yang digunakan', 10, 66);
doc.text(':', 59, 66);
doc.text("Jenis APD yang digunakan", 10, 72);
doc.text(":", 59, 72);
doc.text('Jenis APD yang digunakan', 10, 72);
doc.text(':', 59, 72);
const blob = doc.output('blob');
const url = URL.createObjectURL(blob);
@@ -84,7 +84,7 @@ const GeneratePdf = (props) => {
return (
<Modal
width='60%'
width="60%"
title="Preview PDF"
open={props.showPdf}
// open={true}
@@ -101,7 +101,6 @@ const GeneratePdf = (props) => {
defaultBorderColor: '#23A55A',
defaultHoverColor: '#23A55A',
defaultHoverBorderColor: '#23A55A',
defaultHoverColor: '#23A55A',
},
},
}}

View File

@@ -38,7 +38,7 @@ const DetailPlantSubSection = (props) => {
return;
}
console.log(`📝 Input change: ${name} = ${value}`);
// console.log(`📝 Input change: ${name} = ${value}`);
if (name) {
setFormData((prev) => ({
@@ -74,16 +74,20 @@ const DetailPlantSubSection = (props) => {
return;
try {
console.log('💾 Current formData before save:', formData);
// console.log('💾 Current formData before save:', formData);
const payload = {
plant_sub_section_name: formData.plant_sub_section_name,
plant_sub_section_description: (formData.plant_sub_section_description && formData.plant_sub_section_description.trim() !== '') ? formData.plant_sub_section_description : ' ',
plant_sub_section_description:
formData.plant_sub_section_description &&
formData.plant_sub_section_description.trim() !== ''
? formData.plant_sub_section_description
: ' ',
table_name_value: formData.table_name_value, // Fix field name
is_active: formData.is_active,
};
console.log('📤 Payload to be sent:', payload);
// console.log('📤 Payload to be sent:', payload);
const response =
props.actionMode === 'edit'
@@ -126,17 +130,17 @@ const DetailPlantSubSection = (props) => {
};
useEffect(() => {
console.log('🔄 Modal state changed:', {
showModal: props.showModal,
actionMode: props.actionMode,
selectedData: props.selectedData,
});
// console.log('🔄 Modal state changed:', {
// showModal: props.showModal,
// actionMode: props.actionMode,
// selectedData: props.selectedData,
// });
if (props.selectedData) {
console.log('📋 Setting form data from selectedData:', props.selectedData);
// console.log('📋 Setting form data from selectedData:', props.selectedData);
setFormData(props.selectedData);
} else {
console.log('📋 Resetting to default data');
// console.log('📋 Resetting to default data');
setFormData(defaultData);
}
}, [props.showModal, props.selectedData, props.actionMode]);

View File

@@ -112,9 +112,9 @@ const DetailShift = (props) => {
is_active: formData.is_active,
};
console.log('Payload yang dikirim:', payload);
console.log('Type start_time:', typeof payload.start_time, payload.start_time);
console.log('Type end_time:', typeof payload.end_time, payload.end_time);
// console.log('Payload yang dikirim:', payload);
// console.log('Type start_time:', typeof payload.start_time, payload.start_time);
// console.log('Type end_time:', typeof payload.end_time, payload.end_time);
const response =
props.actionMode === 'edit'

View File

@@ -95,11 +95,11 @@ const DetailSparepart = (props) => {
const newFile = fileList.length > 0 ? fileList[0] : null;
if (newFile && newFile.originFileObj) {
console.log('Uploading file:', newFile.originFileObj);
// console.log('Uploading file:', newFile.originFileObj);
const uploadResponse = await uploadFile(newFile.originFileObj, 'images');
// Log untuk debugging
console.log('Upload response:', uploadResponse);
// console.log('Upload response:', uploadResponse);
// Cek berbagai kemungkinan struktur respons dari API
let uploadedUrl = null;
@@ -169,7 +169,7 @@ const DetailSparepart = (props) => {
}
if (uploadedUrl) {
console.log('Successfully extracted image URL:', uploadedUrl);
// console.log('Successfully extracted image URL:', uploadedUrl);
imageUrl = uploadedUrl;
} else {
console.error('Upload response structure:', uploadResponse);
@@ -209,7 +209,10 @@ const DetailSparepart = (props) => {
sparepart_name: formData.sparepart_name, // Wajib
};
payload.sparepart_description = (formData.sparepart_description && formData.sparepart_description.trim() !== '') ? formData.sparepart_description : ' ';
payload.sparepart_description =
formData.sparepart_description && formData.sparepart_description.trim() !== ''
? formData.sparepart_description
: ' ';
if (formData.sparepart_model && formData.sparepart_model.trim() !== '') {
payload.sparepart_model = formData.sparepart_model;
}
@@ -233,13 +236,13 @@ const DetailSparepart = (props) => {
payload.sparepart_foto = imageUrl;
}
console.log('Sending payload:', payload);
// console.log('Sending payload:', payload);
const response = formData.sparepart_id
? await updateSparepart(formData.sparepart_id, payload)
: await createSparepart(payload);
console.log('API response:', response);
// console.log('API response:', response);
if (response && (response.statusCode === 200 || response.statusCode === 201)) {
NotifOk({

View File

@@ -164,7 +164,7 @@ const ListUnit = memo(function ListUnit(props) {
const handleDelete = async (param) => {
try {
const response = await deleteUnit(param.unit_id);
console.log('deleteUnit response:', response);
// console.log('deleteUnit response:', response);
if (response.statusCode === 200) {
NotifAlert({

View File

@@ -14,8 +14,9 @@ const DetailNotification = memo(function DetailNotification({ selectedData, onCl
// Get error code data from the nested structure
const errorCodeData = selectedData.error_code;
const solutionData = errorCodeData?.solution?.[0] || {};
const sparepartsData = errorCodeData?.spareparts || [];
// Get active solution (is_active: true) or first solution
const activeSolution = errorCodeData?.solution?.find(sol => sol.is_active) || errorCodeData?.solution?.[0] || {};
const sparepartsData = selectedData.spareparts || errorCodeData?.spareparts || [];
// Determine notification type based on is_read status
const getTypeFromStatus = () => {
@@ -137,7 +138,7 @@ const DetailNotification = memo(function DetailNotification({ selectedData, onCl
Solusi
</div>
<div style={{ fontSize: '13px', color: '#262626', fontWeight: 500 }}>
{solutionData?.solution_name || 'N/A'}
{activeSolution?.solution_name || 'N/A'}
</div>
</div>
</Col>

View File

@@ -38,7 +38,14 @@ import {
SearchOutlined,
} from '@ant-design/icons';
import { useNavigate, Link as RouterLink } from 'react-router-dom';
import { getAllNotification } from '../../../api/notification';
import {
getAllNotification,
getNotificationLogByNotificationId,
getNotificationDetail,
resendChatByUser,
resendChatAllUser,
searchData,
} from '../../../api/notification';
const { Text, Paragraph, Link: AntdLink } = Typography;
@@ -47,87 +54,37 @@ const transformNotificationData = (apiData) => {
return apiData.map((item, index) => ({
id: `notification-${item.notification_error_id}-${index}`, // Unique key prefix with array index
type: item.is_read ? 'resolved' : item.is_delivered ? 'warning' : 'critical',
title: item.device_name || 'Unknown Device',
issue: item.error_code_name || 'Unknown Error',
description: `${item.error_code} - ${item.error_code_name}`,
timestamp:
new Date(item.created_at).toLocaleString('id-ID', {
title: item.error_code_name || 'Unknown Error',
issue: item.error_code || item.error_code_name || 'Unknown Error',
description: `${item.error_code} - ${item.error_code_name || ''}`,
timestamp: item.created_at
? new Date(item.created_at).toLocaleString('id-ID', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
}) + ' WIB',
location: item.device_location || 'Location not specified',
details: item.message_error_issue || 'No details available',
}) + ' WIB'
: 'N/A',
location: item.plant_sub_section_name || item.device_location || 'Location not specified',
details: item.device_name || '-',
errId: item.notification_error_id || 0,
link: `/verification-sparepart/${item.notification_error_id}`, // Dummy URL untuk verifikasi spare part
subsection: item.solution_name || 'N/A',
subsection: item.plant_sub_section_name || 'N/A',
isRead: item.is_read,
status: item.is_read ? 'Resolved' : item.is_delivered ? 'Delivered' : 'Pending',
tag: item.error_code,
errorCode: item.error_code,
solutionName: item.solution_name,
typeSolution: item.type_solution,
pathSolution: item.path_solution,
solutionName: item.error_code?.solution?.[0]?.solution_name || 'N/A',
typeSolution: item.error_code?.solution?.[0]?.type_solution || 'N/A',
pathSolution:
item.error_code?.solution?.[0]?.path_document ||
item.error_code?.solution?.[0]?.path_solution ||
'N/A',
error_code: item.error_code,
}));
};
// Dummy data untuk user history
const userHistoryData = [
{
id: '1',
name: 'John Doe',
phone: '081234567890',
status: 'Delivered',
timestamp: '04-11-2025 11:40 WIB',
},
{
id: '2',
name: 'Jane Smith',
phone: '087654321098',
status: 'Delivered',
timestamp: '04-11-2025 11:41 WIB',
},
{
id: '3',
name: 'Peter Jones',
phone: '082345678901',
status: 'Delivered',
timestamp: '04-11-2025 11:42 WIB',
},
];
// Dummy data untuk log history
const logHistoryData = [
{
id: 1,
timestamp: '04-11-2025 11:55 WIB',
addedBy: {
name: 'Budi Santoso',
phone: '081122334455',
},
description: 'Suhu sudah coba diturunkan, namun masih belum mencapai treshold aman.',
},
{
id: 2,
timestamp: '04-11-2025 11:45 WIB',
addedBy: {
name: 'John Doe',
phone: '081234567890',
},
description: 'Suhu sudah coba diturunkan, namun masih belum mencapai treshold aman.',
},
{
id: 3,
timestamp: '04-11-2025 11:40 WIB',
addedBy: {
name: 'Jane Smith',
phone: '087654321098',
},
description: 'Suhu sudah coba diturunkan, namun masih belum mencapai treshold aman.',
},
];
const ListNotification = memo(function ListNotification(props) {
const [notifications, setNotifications] = useState([]);
const [activeTab, setActiveTab] = useState('all');
@@ -137,6 +94,10 @@ const ListNotification = memo(function ListNotification(props) {
const [modalContent, setModalContent] = useState(null); // 'user', 'log', 'details', or null
const [isAddingLog, setIsAddingLog] = useState(false);
const [selectedNotification, setSelectedNotification] = useState(null);
const [logHistoryData, setLogHistoryData] = useState([]);
const [logLoading, setLogLoading] = useState(false);
const [userHistoryData, setUserHistoryData] = useState([]);
const [userLoading, setUserLoading] = useState(false);
const [pagination, setPagination] = useState({
current_page: 1,
current_limit: 10,
@@ -238,9 +199,9 @@ const ListNotification = memo(function ListNotification(props) {
content: `Are you sure you want to resend the notification for "${notification.title}"?`,
okText: 'Resend',
cancelText: 'Cancel',
onOk() {
async onOk() {
console.log('Resending notification:', notification.id);
await resendChatAllUser(notification.errId);
message.success(
`Notification for "${notification.title}" has been resent successfully.`
);
@@ -259,13 +220,49 @@ const ListNotification = memo(function ListNotification(props) {
);
};
const fetchSearch = async (data) => {
setLoading(true);
try {
const response = await searchData(data);
if (response && response.data) {
const transformedData = transformNotificationData(response.data);
setNotifications(transformedData);
// Update pagination with API response or calculate from data
if (response.paging) {
setPagination({
current_page: response.paging.current_page || page,
current_limit: response.paging.current_limit || limit,
total_limit: response.paging.total_limit || transformedData.length,
total_page:
response.paging.total_page || Math.ceil(transformedData.length / limit),
});
} else {
// Fallback: calculate pagination from data
const totalItems = transformedData.length;
setPagination((prev) => ({
...prev,
current_page: page,
current_limit: limit,
total_limit: totalItems,
total_page: Math.ceil(totalItems / limit),
}));
}
}
} catch (error) {
console.error(error);
} finally {
setLoading(false);
}
};
const handleSearch = () => {
setSearchTerm(searchValue);
fetchSearch(searchValue);
};
const handleSearchClear = () => {
setSearchValue('');
setSearchTerm('');
fetchSearch('');
};
const getUnreadCount = () => notifications.filter((n) => !n.isRead).length;
@@ -280,6 +277,78 @@ const ListNotification = memo(function ListNotification(props) {
});
};
// Fetch log history from API
const fetchLogHistory = async (notificationId) => {
try {
setLogLoading(true);
const response = await getNotificationLogByNotificationId(notificationId);
if (response && response.data) {
// Transform API data to component format
const transformedLogs = response.data.map((log) => ({
id: log.notification_error_log_id,
timestamp: log.created_at
? new Date(log.created_at).toLocaleString('id-ID', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
}) + ' WIB'
: 'N/A',
addedBy: {
name: log.contact_name || 'Unknown',
phone: log.contact_phone || 'N/A',
},
description: log.notification_error_log_description || '',
}));
setLogHistoryData(transformedLogs);
}
} catch (err) {
console.error('Error fetching log history:', err);
setLogHistoryData([]); // Set empty array on error
} finally {
setLogLoading(false);
}
};
// Fetch user history from API
const fetchUserHistory = async (notificationId) => {
try {
setUserLoading(true);
const response = await getNotificationDetail(notificationId);
if (response && response.data && response.data.users) {
// Transform API data to component format
const transformedUsers = response.data.users.map((user) => ({
id: user.notification_error_user_id.toString(),
name: user.contact_name,
phone: user.contact_phone,
status: user.is_send ? 'Delivered' : 'Pending',
timestamp: user.updated_at
? new Date(user.updated_at)
.toLocaleString('id-ID', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
.replace('.', ':') + ' WIB'
: 'N/A',
}));
setUserHistoryData(transformedUsers);
} else {
setUserHistoryData([]);
}
} catch (err) {
console.error('Error fetching user history:', err);
setUserHistoryData([]); // Set empty array on error
} finally {
setUserLoading(false);
}
};
const tabButtonStyle = (isActive) => ({
padding: '12px 16px',
border: 'none',
@@ -314,7 +383,6 @@ const ListNotification = memo(function ListNotification(props) {
borderColor: notification.isRead ? '#f0f0f0' : '#d6e4ff',
cursor: 'pointer',
}}
onClick={() => handleMarkAsRead(notification.id)}
>
<div
style={{
@@ -353,7 +421,7 @@ const ListNotification = memo(function ListNotification(props) {
<Text strong>{notification.title}</Text>
<div style={{ marginTop: '4px' }}>
<Text style={{ color }}>
{notification.issue}
Error Code {notification.issue}
</Text>
</div>
</div>
@@ -370,7 +438,7 @@ const ListNotification = memo(function ListNotification(props) {
</div>
</Col>
<Col flex="auto">
<div
{/* <div
style={{
display: 'flex',
gap: '8px',
@@ -393,12 +461,18 @@ const ListNotification = memo(function ListNotification(props) {
>
{notification.details}
</Paragraph>
</div>
</div> */}
<Space
direction="vertical"
size={4}
style={{ fontSize: '13px', color: '#8c8c8c' }}
>
<Space>
<MobileOutlined />
<Text type="secondary">
{notification.details}
</Text>
</Space>
<Space>
<ClockCircleOutlined />
<Text type="secondary">
@@ -412,17 +486,10 @@ const ListNotification = memo(function ListNotification(props) {
</Text>
</Space>
<Space>
<LinkOutlined />
<AntdLink
href={notification.link}
target="_blank"
>
{notification.link}
</AntdLink>
<Button
type="link"
icon={<SendOutlined />}
style={{ paddingLeft: '8px' }}
style={{ paddingLeft: '0px' }}
onClick={(e) => {
e.stopPropagation();
handleResend(notification);
@@ -458,13 +525,23 @@ const ListNotification = memo(function ListNotification(props) {
border: '1px solid #1890ff',
borderRadius: '4px',
}}
onClick={(e) => {
onClick={async (e) => {
e.stopPropagation();
setSelectedNotification(notification);
// Extract notification ID from the notification object
const notificationId =
notification.id.split('-')[1];
// Fetch user history for the selected notification
await fetchUserHistory(notificationId);
setModalContent('user');
}}
/>
<RouterLink
to={`/detail-notification/${
to={`/notification-detail/${
notification.id.split('-')[1]
}`}
target="_blank"
@@ -499,6 +576,15 @@ const ListNotification = memo(function ListNotification(props) {
}}
onClick={(e) => {
e.stopPropagation();
// Set the selected notification for the log history
const notificationId =
notification.id.split('-')[1];
setSelectedNotification(notification);
// Fetch log history for the selected notification
fetchLogHistory(notificationId);
setModalContent('log');
}}
/>
@@ -517,6 +603,11 @@ const ListNotification = memo(function ListNotification(props) {
const renderUserHistory = () => (
<>
{userLoading ? (
<div style={{ textAlign: 'center', padding: '24px' }}>
<Spin size="large" />
</div>
) : (
<Space direction="vertical" size="middle" style={{ display: 'flex' }}>
{userHistoryData.map((user) => (
<Card key={user.id} style={{ borderColor: '#91d5ff' }}>
@@ -529,31 +620,75 @@ const ListNotification = memo(function ListNotification(props) {
<MobileOutlined /> {user.phone}
</Text>
<Text>|</Text>
<Badge status="success" text={user.status} />
<Badge
status={
user.status === 'Delivered' ? 'success' : 'default'
}
text={user.status}
/>
</Space>
<Divider style={{ margin: '8px 0' }} />
<Space align="center">
{user.status === 'Delivered' ? (
<CheckCircleFilled style={{ color: '#52c41a' }} />
) : (
<ClockCircleOutlined style={{ color: '#faad14' }} />
)}
<Text type="secondary">
Success Delivered at {user.timestamp}
{user.status === 'Delivered'
? 'Success Delivered at'
: 'Status '}{' '}
{user.timestamp}
</Text>
</Space>
</Col>
<Col>
<Button type="primary" ghost icon={<SendOutlined />}>
<Button
type="primary"
ghost
icon={<SendOutlined />}
onClick={async () => {
await resendChatByUser(user.id, user.phone);
}}
>
Resend
</Button>
</Col>
</Row>
</Card>
))}
{userHistoryData.length === 0 && (
<div style={{ textAlign: 'center', padding: '24px', color: '#8c8c8c' }}>
No user history available
</div>
)}
</Space>
)}
</>
);
const renderLogHistory = () => (
<>
<div style={{ padding: '0 16px', position: 'relative' }}>
{logLoading ? (
<div style={{ textAlign: 'center', padding: '24px' }}>
<Spin size="large" />
</div>
) : logHistoryData.length === 0 ? (
<div style={{ textAlign: 'center', padding: '24px', color: '#8c8c8c' }}>
Tidak ada log history
</div>
) : (
<div
style={{
height: '400px',
overflowY: 'auto',
padding: '0 16px',
position: 'relative',
border: '1px solid #f0f0f0',
borderRadius: '4px',
}}
>
<div style={{ position: 'relative' }}>
{/* Garis vertikal yang menyambung */}
<div
style={{
@@ -599,20 +734,28 @@ const ListNotification = memo(function ListNotification(props) {
{/* Kolom Kanan: Card */}
<Col flex="auto">
<Card size="small" style={{ borderColor: '#91d5ff' }}>
<Row gutter={[16, 8]} align="middle">
<Col xs={24} md={12}>
<Row gutter={[16, 8]} align="top">
<Col xs={24} md={10}>
<Space direction="vertical" size={4}>
<Space>
<ClockCircleOutlined />
<Text type="secondary" style={{ fontSize: '12px' }}>
<Text
type="secondary"
style={{ fontSize: '12px' }}
>
Added at {log.timestamp}
</Text>
</Space>
<div>
<Text strong>
{log.addedBy.name}
</Text>
</div>
<div>
<Text strong>Added by: {log.addedBy.name}</Text>
<span
style={{
marginLeft: '8px',
border: '1px solid #52c41a',
color: '#52c41a',
padding: '2px 6px',
@@ -625,7 +768,8 @@ const ListNotification = memo(function ListNotification(props) {
</div>
</Space>
</Col>
<Col xs={24} md={12}>
<Col xs={24} md={14}>
<Text strong>Description:</Text>
<Paragraph
style={{
color: '#595959',
@@ -642,6 +786,8 @@ const ListNotification = memo(function ListNotification(props) {
</Row>
))}
</div>
</div>
)}
</>
);
@@ -693,9 +839,9 @@ const ListNotification = memo(function ListNotification(props) {
<Text strong>Plant Subsection</Text>
<div>{selectedNotification.subsection}</div>
<Text strong style={{ display: 'block', marginTop: '8px' }}>
Time
Date & Time
</Text>
<div>{selectedNotification.timestamp.split(' ')[1]} WIB</div>
<div>{selectedNotification.timestamp}</div>
</div>
<div
@@ -812,7 +958,16 @@ const ListNotification = memo(function ListNotification(props) {
cursor: 'pointer',
}}
bodyStyle={{ padding: '12px' }}
onClick={() => setModalContent('log')}
onClick={() => {
// Set the selected notification for the log history if not already set
if (selectedNotification) {
const notificationId =
selectedNotification.id.split('-')[1];
// Fetch log history for the selected notification
fetchLogHistory(notificationId);
}
setModalContent('log');
}}
>
<Space>
<HistoryOutlined
@@ -837,7 +992,8 @@ const ListNotification = memo(function ListNotification(props) {
<Text type="secondary" style={{ fontSize: '10px' }}>
PDF
</Text>
} >
}
>
<div
style={{
display: 'flex',
@@ -1128,7 +1284,22 @@ const ListNotification = memo(function ListNotification(props) {
</Button>
</Space>
</Card>
{logHistoryData.map((log) => (
{logLoading ? (
<div style={{ textAlign: 'center', padding: '12px' }}>
<Spin size="small" />
</div>
) : logHistoryData.length === 0 ? (
<div
style={{
textAlign: 'center',
padding: '12px',
color: '#8c8c8c',
}}
>
Tidak ada log history
</div>
) : (
logHistoryData.map((log) => (
<Card
key={log.id}
size="small"
@@ -1145,7 +1316,8 @@ const ListNotification = memo(function ListNotification(props) {
{log.timestamp}
</Text>
</Card>
))}
))
)}
</Space>
</Card>
</Col>
@@ -1285,7 +1457,7 @@ const ListNotification = memo(function ListNotification(props) {
</div>
) : (
<Typography.Title level={4} style={{ margin: 0 }}>
{modalContent === 'user' && 'User History Notification'}
{modalContent === 'user' && 'History User Notification'}
{modalContent === 'log' && 'Log History Notification'}
</Typography.Title>
)}

View File

@@ -1,6 +1,12 @@
import React from 'react';
import { Modal, Typography, Card, Row, Col, Avatar, Tag, Button, Space } from 'antd';
import { UserOutlined, PhoneOutlined, CheckCircleOutlined, SyncOutlined, SendOutlined } from '@ant-design/icons';
import {
UserOutlined,
PhoneOutlined,
CheckCircleOutlined,
SyncOutlined,
SendOutlined,
} from '@ant-design/icons';
const { Text } = Typography;
@@ -41,9 +47,17 @@ const UserHistoryModal = ({ visible, onCancel, notificationData }) => {
const getStatusTag = (status) => {
switch (status) {
case 'delivered':
return <Tag icon={<CheckCircleOutlined />} color="success">Delivered</Tag>;
return (
<Tag icon={<CheckCircleOutlined />} color="success">
Delivered
</Tag>
);
case 'sent':
return <Tag icon={<SyncOutlined spin />} color="processing">Sent</Tag>;
return (
<Tag icon={<SyncOutlined spin />} color="processing">
Sent
</Tag>
);
case 'failed':
return <Tag color="error">Failed</Tag>;
default:
@@ -55,7 +69,7 @@ const UserHistoryModal = ({ visible, onCancel, notificationData }) => {
<Modal
title={
<Text strong style={{ fontSize: '18px' }}>
User History Notification
History User Notification
</Text>
}
open={visible}
@@ -78,7 +92,13 @@ const UserHistoryModal = ({ visible, onCancel, notificationData }) => {
<Avatar size="large" icon={<UserOutlined />} />
<div>
<Text strong>{user.name}</Text>
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
}}
>
<PhoneOutlined style={{ color: '#8c8c8c' }} />
<Text type="secondary">{user.phone}</Text>
</div>

View File

@@ -1,14 +1,37 @@
import React from 'react';
import { Button, Row, Col, Card, Badge, Typography, Space, Divider } from 'antd';
import { SendOutlined, MobileOutlined, CheckCircleFilled, ArrowLeftOutlined } from '@ant-design/icons';
import {
SendOutlined,
MobileOutlined,
CheckCircleFilled,
ArrowLeftOutlined,
} from '@ant-design/icons';
const { Text } = Typography;
// Dummy data for user history
const userHistoryData = [
{ id: 1, name: 'John Doe', phone: '081234567890', status: 'Delivered', timestamp: '04-11-2025 11:40 WIB' },
{ id: 2, name: 'Jane Smith', phone: '087654321098', status: 'Delivered', timestamp: '04-11-2025 11:41 WIB' },
{ id: 3, name: 'Peter Jones', phone: '082345678901', status: 'Delivered', timestamp: '04-11-2025 11:42 WIB' },
{
id: 1,
name: 'John Doe',
phone: '081234567890',
status: 'Delivered',
timestamp: '04-11-2025 11:40 WIB',
},
{
id: 2,
name: 'Jane Smith',
phone: '087654321098',
status: 'Delivered',
timestamp: '04-11-2025 11:41 WIB',
},
{
id: 3,
name: 'Peter Jones',
phone: '082345678901',
status: 'Delivered',
timestamp: '04-11-2025 11:42 WIB',
},
];
const UserHistory = ({ notification, onBack }) => {
@@ -18,7 +41,9 @@ const UserHistory = ({ notification, onBack }) => {
<Col>
<Space align="center">
<Button type="text" icon={<ArrowLeftOutlined />} onClick={onBack} />
<Typography.Title level={4} style={{ margin: 0 }}>User History Notification</Typography.Title>
<Typography.Title level={4} style={{ margin: 0 }}>
History User Notification
</Typography.Title>
</Space>
<Text type="secondary" style={{ marginLeft: '40px' }}>
{notification.title} - {notification.issue}
@@ -27,25 +52,34 @@ const UserHistory = ({ notification, onBack }) => {
</Row>
<Space direction="vertical" size="middle" style={{ display: 'flex' }}>
{userHistoryData.map(user => (
<Card key={user.id} style={{ backgroundColor: '#e6f7ff', borderColor: '#91d5ff' }}>
{userHistoryData.map((user) => (
<Card
key={user.id}
style={{ backgroundColor: '#e6f7ff', borderColor: '#91d5ff' }}
>
<Row align="middle" justify="space-between">
<Col>
<Space align="center">
<Text strong>{user.name}</Text>
<Text>|</Text>
<Text><MobileOutlined /> {user.phone}</Text>
<Text>
<MobileOutlined /> {user.phone}
</Text>
<Text>|</Text>
<Badge status="success" text={user.status} />
</Space>
<Divider style={{ margin: '8px 0' }} />
<Space align="center">
<CheckCircleFilled style={{ color: '#52c41a' }} />
<Text type="secondary">Success Delivered at {user.timestamp}</Text>
<Text type="secondary">
Success Delivered at {user.timestamp}
</Text>
</Space>
</Col>
<Col>
<Button type="primary" ghost icon={<SendOutlined />}>Resend</Button>
<Button type="primary" ghost icon={<SendOutlined />}>
Resend
</Button>
</Col>
</Row>
</Card>

View File

@@ -0,0 +1,955 @@
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import {
Layout,
Card,
Row,
Col,
Typography,
Space,
Button,
Spin,
Result,
Input,
message,
Avatar,
Tag,
Badge,
Divider,
} from 'antd';
import {
ArrowLeftOutlined,
CloseCircleFilled,
WarningFilled,
CheckCircleFilled,
InfoCircleFilled,
CloseOutlined,
BookOutlined,
ToolOutlined,
HistoryOutlined,
FilePdfOutlined,
PlusOutlined,
UserOutlined,
LoadingOutlined,
PhoneOutlined,
CheckCircleOutlined,
SyncOutlined,
SendOutlined,
MobileOutlined,
ClockCircleOutlined,
} from '@ant-design/icons';
import {
getNotificationDetail,
createNotificationLog,
getNotificationLogByNotificationId,
updateIsRead,
resendNotificationToUser,
resendChatByUser,
} from '../../api/notification';
const { Content } = Layout;
const { Text, Paragraph, Link } = Typography;
// Transform API response to component format
const transformNotificationData = (apiData) => {
// Extract nested data
const errorCodeData = apiData.error_code;
// Get active solution (is_active: true)
const activeSolution =
errorCodeData?.solution?.find((sol) => sol.is_active) || errorCodeData?.solution?.[0] || {};
return {
id: `notification-${apiData.notification_error_id}-0`,
type: apiData.is_read ? 'resolved' : apiData.is_delivered ? 'warning' : 'critical',
title: errorCodeData?.error_code_name || 'Unknown Error',
issue: errorCodeData?.error_code || 'Unknown Error',
description: apiData.message_error_issue || 'No details available',
timestamp: apiData.created_at
? new Date(apiData.created_at).toLocaleString('id-ID', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
}) + ' WIB'
: 'N/A',
location: apiData.plant_sub_section_name || 'Location not specified',
details: apiData.message_error_issue || 'No details available',
isRead: apiData.is_read || false,
isDelivered: apiData.is_delivered || false,
isSend: apiData.is_send || false,
status: apiData.is_read ? 'Resolved' : apiData.is_delivered ? 'Delivered' : 'Pending',
tag: errorCodeData?.error_code,
plc: 'N/A', // PLC not available in API response
notification_error_id: apiData.notification_error_id,
error_code_id: apiData.error_code_id,
error_chanel: apiData.error_chanel,
spareparts: errorCodeData?.spareparts || [],
solution: {
...activeSolution,
path_document: activeSolution.path_document
? activeSolution.path_document.replace(
'/detail-notification/pdf/',
'/notification-detail/pdf/'
)
: activeSolution.path_document,
}, // Include the active solution data with fixed URL
error_code: errorCodeData,
device_info: {
device_code: apiData.device_code,
device_name: apiData.device_name,
device_location: apiData.device_location,
brand_name: apiData.brand_name,
},
users: apiData.users || [],
};
};
// Function to get actual users from notification data
const getUsersFromNotification = (notification) => {
if (!notification || !notification.users) return [];
return notification.users.map((user) => ({
id: user.notification_error_user_id.toString(),
name: user.contact_name,
phone: user.contact_phone,
status: user.is_send ? 'Delivered' : 'Pending',
loading: user.loading || false,
timestamp: user.updated_at
? new Date(user.updated_at)
.toLocaleString('id-ID', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
.replace('.', ':') + ' WIB'
: 'N/A',
}));
};
const getStatusTag = (status) => {
switch (status) {
case 'delivered':
return (
<Tag icon={<CheckCircleOutlined />} color="success">
Delivered
</Tag>
);
case 'sent':
return (
<Tag icon={<SyncOutlined spin />} color="processing">
Sent
</Tag>
);
case 'failed':
return <Tag color="error">Failed</Tag>;
default:
return <Tag color="default">{status}</Tag>;
}
};
const getIconAndColor = (type) => {
switch (type) {
case 'critical':
return { IconComponent: CloseCircleFilled, color: '#ff4d4f', bgColor: '#fff1f0' };
case 'warning':
return { IconComponent: WarningFilled, color: '#faad14', bgColor: '#fffbe6' };
case 'resolved':
return { IconComponent: CheckCircleFilled, color: '#52c41a', bgColor: '#f6ffed' };
default:
return { IconComponent: InfoCircleFilled, color: '#1890ff', bgColor: '#e6f7ff' };
}
};
const NotificationDetailTab = (props) => {
const params = useParams(); // Mungkin perlu disesuaikan jika route berbeda
const notificationId = props.id ?? params.notificationId;
const navigate = useNavigate();
const [notification, setNotification] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [isAddingLog, setIsAddingLog] = useState(false);
// Log history states
const [logHistoryData, setLogHistoryData] = useState([]);
const [logLoading, setLogLoading] = useState(false);
const [newLogDescription, setNewLogDescription] = useState('');
const [submitLoading, setSubmitLoading] = useState(false);
// Fetch log history from API
const fetchLogHistory = async (notifId) => {
try {
setLogLoading(true);
const response = await getNotificationLogByNotificationId(notifId);
if (response && response.data) {
// Transform API data to component format
const transformedLogs = response.data.map((log) => ({
id: log.notification_error_log_id,
timestamp: log.created_at
? new Date(log.created_at).toLocaleString('id-ID', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
}) + ' WIB'
: 'N/A',
addedBy: {
name: log.contact_name || 'Unknown',
phone: log.contact_phone || '',
},
description: log.notification_error_log_description || '',
}));
setLogHistoryData(transformedLogs);
}
} catch (err) {
console.error('Error fetching log history:', err);
} finally {
setLogLoading(false);
}
};
// Handle submit new log
const handleSubmitLog = async () => {
if (!newLogDescription.trim()) {
message.warning('Mohon isi deskripsi log terlebih dahulu');
return;
}
try {
setSubmitLoading(true);
const payload = {
notification_error_id: parseInt(notificationId),
notification_error_log_description: newLogDescription.trim(),
};
const response = await createNotificationLog(payload);
if (response && response.statusCode === 200) {
message.success('Log berhasil ditambahkan');
setNewLogDescription('');
setIsAddingLog(false);
// Refresh log history
fetchLogHistory(notificationId);
} else {
throw new Error(response?.message || 'Gagal menambahkan log');
}
} catch (err) {
console.error('Error submitting log:', err);
message.error(err.message || 'Gagal menambahkan log');
} finally {
setSubmitLoading(false);
}
};
useEffect(() => {
const fetchDetail = async () => {
try {
setLoading(true);
// Fetch using the actual API
const response = await getNotificationDetail(notificationId);
if (response && response.data) {
const transformedData = transformNotificationData(response.data);
setNotification(transformedData);
// Fetch log history
fetchLogHistory(notificationId);
// Fetch using the actual API
const resUpdate = await updateIsRead(notificationId);
} else {
throw new Error('Notification not found');
}
} catch (err) {
setError(err.message);
console.error('Error fetching notification detail:', err);
} finally {
setLoading(false);
}
};
fetchDetail();
}, [notificationId]);
if (loading) {
return (
<Layout
style={{
minHeight: '100vh',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
}}
>
<Spin size="large" />
</Layout>
);
}
if (error || !notification) {
return (
<Layout
style={{
minHeight: '100vh',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
}}
>
<Result
status="404"
title="404"
subTitle="Sorry, the notification you visited does not exist."
extra={
<Button type="primary" onClick={() => navigate('/notification')}>
Back to List
</Button>
}
/>
</Layout>
);
}
const { color } = getIconAndColor(notification.type);
return (
<Layout style={{ padding: '24px', backgroundColor: '#f0f2f5' }}>
<Content>
<Card>
<div
style={{
borderBottom: '1px solid #f0f0f0',
paddingBottom: '16px',
marginBottom: '24px',
}}
>
{!props.id && (
<Row justify="space-between" align="middle">
<Col>
<Button
type="text"
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/notification')}
style={{ paddingLeft: 0 }}
>
Back to notification list
</Button>
</Col>
</Row>
)}
<div
style={{
backgroundColor: '#f6ffed',
border: '1px solid #b7eb8f',
borderRadius: '4px',
padding: '8px 16px',
textAlign: 'center',
marginTop: '16px',
}}
>
<Typography.Title level={4} style={{ margin: 0, color: '#262626' }}>
Error Notification Detail
</Typography.Title>
</div>
</div>
<Space direction="vertical" size="small" style={{ width: '100%' }}>
<Row gutter={[8, 8]}>
{/* Kolom Kiri: Data Kompresor */}
<Col xs={24} lg={8}>
<Card
size="small"
style={{ height: '100%', borderColor: '#d4380d' }}
bodyStyle={{ padding: '16px' }}
>
<Space
direction="vertical"
size="large"
style={{ width: '100%' }}
>
<Row gutter={16} align="middle">
<Col>
<div
style={{
width: '32px',
height: '32px',
borderRadius: '50%',
backgroundColor: '#d4380d',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#ffffff',
fontSize: '18px',
}}
>
<CloseOutlined />
</div>
</Col>
<Col>
<Text>{notification.title}</Text>
<div style={{ marginTop: '2px' }}>
<Text strong style={{ fontSize: '16px' }}>
Error Code {notification.issue}
</Text>
</div>
</Col>
</Row>
<div>
<Text strong>Plant Subsection</Text>
<div>{notification.location}</div>
<Text
strong
style={{ display: 'block', marginTop: '8px' }}
>
Date & Time
</Text>
<div>{notification.timestamp}</div>
</div>
</Space>
</Card>
</Col>
{/* Kolom Tengah: Informasi Teknis */}
<Col xs={24} lg={8}>
<Card
title="Device Information"
size="small"
style={{ height: '100%' }}
>
<Space
direction="vertical"
size="middle"
style={{ width: '100%' }}
>
<div>
<Text strong>Error Channel</Text>
<div>{notification.error_chanel || 'N/A'}</div>
</div>
<div>
<Text strong>Device Code</Text>
<div>
{notification.device_info?.device_code || 'N/A'}
</div>
</div>
<div>
<Text strong>Device Name</Text>
<div>
{notification.device_info?.device_name || 'N/A'}
</div>
</div>
<div>
<Text strong>Device Location</Text>
<div>
{notification.device_info?.device_location || 'N/A'}
</div>
</div>
<div>
<Text strong>Brand</Text>
<div>
{notification.device_info?.brand_name || 'N/A'}
</div>
</div>
</Space>
</Card>
</Col>
{/* Kolom Kanan: User History */}
<Col xs={24} lg={8}>
<Card title="User History" size="small" style={{ height: '100%' }}>
<div
style={{
maxHeight: '400px',
overflowY: 'auto',
padding: '2px',
}}
>
<Space
direction="vertical"
size={2}
style={{ width: '100%' }}
>
{getUsersFromNotification(notification).map((user) => (
<Card
key={user.id}
size="small"
style={{ width: '100%', margin: 0 }}
>
<Row align="middle" justify="space-between">
<Col>
<Space align="center">
<Text strong>{user.name}</Text>
<Text>|</Text>
<Text>
<MobileOutlined /> {user.phone}
</Text>
<Text>|</Text>
<Badge
status={
user.status === 'Delivered'
? 'success'
: 'default'
}
text={user.status}
/>
</Space>
<Divider style={{ margin: '8px 0' }} />
<Space align="center">
{user.status === 'Delivered' ? (
<CheckCircleFilled
style={{ color: '#52c41a' }}
/>
) : (
<ClockCircleOutlined
style={{ color: '#faad14' }}
/>
)}
<Text type="secondary">
{user.status === 'Delivered'
? 'Success Delivered at'
: 'Status '}{' '}
{user.timestamp}
</Text>
</Space>
</Col>
<Col>
<Col>
<Button
type="primary"
ghost
icon={<SendOutlined />}
onClick={async () => {
await resendChatByUser(
user.id,
user.phone
);
}}
>
Resend
</Button>
</Col>
</Col>
</Row>
</Card>
))}
</Space>
</div>
</Card>
</Col>
</Row>
<Row gutter={[8, 8]}>
<Col xs={24} md={8}>
<div>
<Card
hoverable
bodyStyle={{ padding: '12px'}}
>
<Space>
<BookOutlined
style={{ fontSize: '16px', color: '#1890ff' }}
/>
<Text
strong
style={{ fontSize: '16px', color: '#262626' }}
>
Handling Guideline
</Text>
</Space>
<Space
direction="vertical"
size="small"
style={{ width: '100%' }}
>
{notification.error_code?.solution &&
notification.error_code.solution.length > 0 ? (
<>
{notification.error_code.solution
.filter((sol) => sol.is_active) // Hanya tampilkan solusi yang aktif
.map((sol, index) => (
<div
key={
sol.brand_code_solution_id ||
index
}
>
{sol.path_document ? (
<Card
size="small"
bodyStyle={{
padding: '8px 12px',
marginBottom: '4px',
}}
hoverable
extra={
<Text
type="secondary"
style={{
fontSize:
'10px',
}}
>
PDF
</Text>
}
>
<div
style={{
display: 'flex',
justifyContent:
'space-between',
alignItems:
'center',
}}
>
<div>
<Text
style={{
fontSize:
'12px',
color: '#262626',
}}
>
<FilePdfOutlined
style={{
marginRight:
'8px',
}}
/>{' '}
{sol.file_upload_name ||
'Solution Document.pdf'}
</Text>
<Link
href={sol.path_document.replace(
'/detail-notification/pdf/',
'/notification-detail/pdf/'
)}
target="_blank"
style={{
fontSize:
'12px',
display:
'block',
}}
>
lihat disini
</Link>
</div>
</div>
</Card>
) : null}
{sol.type_solution === 'text' &&
sol.text_solution ? (
<Card
size="small"
title={
<Text strong>
{sol.solution_name}:
</Text>
}
bodyStyle={{
padding: '8px 12px',
marginBottom: '4px',
}}
extra={
<Text
type="secondary"
style={{
fontSize:
'10px',
}}
>
{sol.type_solution.toUpperCase()}
</Text>
}
>
<div>
<div
style={{
marginTop:
'4px',
}}
>
{sol.text_solution}
</div>
</div>
</Card>
) : null}
</div>
))}
</>
) : (
<div
style={{
textAlign: 'center',
padding: '20px',
color: '#8c8c8c',
}}
>
Tidak ada dokumen solusi tersedia
</div>
)}
</Space>
</Card>
</div>
</Col>
<Col xs={24} md={8}>
<div>
<Card
hoverable
bodyStyle={{ padding: '12px'}}
>
<Space>
<ToolOutlined
style={{ fontSize: '16px', color: '#1890ff' }}
/>
<Text
strong
style={{ fontSize: '16px', color: '#262626' }}
>
Spare Part
</Text>
</Space>
<Space
direction="vertical"
size="small"
style={{ width: '100%' }}
>
{notification.spareparts &&
notification.spareparts.length > 0 ? (
notification.spareparts.map((sparepart, index) => (
<Card
size="small"
key={index}
bodyStyle={{ padding: '12px' }}
hoverable
>
<Row gutter={16} align="top">
<Col
span={7}
style={{ textAlign: 'center' }}
>
<div
style={{
width: '100%',
height: '60px',
backgroundColor: '#f0f0f0',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: '4px',
marginBottom: '8px',
}}
>
<ToolOutlined
style={{
fontSize: '24px',
color: '#bfbfbf',
}}
/>
</div>
<Text
style={{
fontSize: '12px',
color:
sparepart.sparepart_stok ===
'Available' ||
sparepart.sparepart_stok ===
'available'
? '#52c41a'
: '#ff4d4f',
fontWeight: 500,
}}
>
{sparepart.sparepart_stok}
</Text>
</Col>
<Col span={17}>
<Space
direction="vertical"
size={4}
style={{ width: '100%' }}
>
<Text strong>
{sparepart.sparepart_name}
</Text>
<Paragraph
style={{
fontSize: '12px',
margin: 0,
color: '#595959',
}}
>
{sparepart.sparepart_description ||
'Deskripsi tidak tersedia'}
</Paragraph>
<div
style={{
border: '1px solid #d9d9d9',
borderRadius: '4px',
padding: '4px 8px',
fontSize: '11px',
color: '#8c8c8c',
marginTop: '8px',
}}
>
Kode:{' '}
{sparepart.sparepart_code} |
Qty:{' '}
{sparepart.sparepart_qty} |
Unit:{' '}
{sparepart.sparepart_unit}
</div>
</Space>
</Col>
</Row>
</Card>
))
) : (
<div
style={{
textAlign: 'center',
padding: '20px',
color: '#8c8c8c',
}}
>
Tidak ada spare parts terkait
</div>
)}
</Space>
</Card>
</div>
</Col>
<Col xs={24} md={8}>
<div>
<Card bodyStyle={{ padding: '12px'}}>
<Space>
<HistoryOutlined
style={{ fontSize: '16px', color: '#1890ff' }}
/>
<Text
strong
style={{ fontSize: '16px', color: '#262626' }}
>
Log Activity
</Text>
</Space>
<Space
direction="vertical"
size="small"
style={{ width: '100%' }}
>
<Card
size="small"
bodyStyle={{
padding: '8px 12px',
backgroundColor: isAddingLog
? '#fafafa'
: '#fff',
}}
>
<Space
direction="vertical"
style={{ width: '100%' }}
size="small"
>
{isAddingLog && (
<>
<Text
strong
style={{ fontSize: '12px' }}
>
Add New Log / Update Progress
</Text>
<Input.TextArea
rows={2}
placeholder="Tuliskan update penanganan di sini..."
value={newLogDescription}
onChange={(e) =>
setNewLogDescription(
e.target.value
)
}
disabled={submitLoading}
/>
</>
)}
<Button
type={isAddingLog ? 'primary' : 'dashed'}
size="small"
block
icon={
submitLoading ? (
<LoadingOutlined />
) : (
!isAddingLog && <PlusOutlined />
)
}
onClick={
isAddingLog
? handleSubmitLog
: () => setIsAddingLog(true)
}
loading={submitLoading}
disabled={submitLoading}
>
{isAddingLog ? 'Submit Log' : 'Add Log'}
</Button>
{isAddingLog && (
<Button
size="small"
block
onClick={() => {
setIsAddingLog(false);
setNewLogDescription('');
}}
disabled={submitLoading}
>
Cancel
</Button>
)}
</Space>
</Card>
{logHistoryData.map((log) => (
<Card
key={log.id}
size="small"
bodyStyle={{
padding: '8px 12px',
}}
>
<Paragraph
style={{ fontSize: '12px', margin: 0 }}
// ellipsis={{ rows: 2 }}
>
<Text strong>{log.addedBy.name}:</Text>{' '}
{log.description}
</Paragraph>
<Text
type="secondary"
style={{ fontSize: '11px' }}
>
{log.timestamp}
</Text>
</Card>
))}
</Space>
</Card>
</div>
</Col>
</Row>
</Space>
</Card>
</Content>
</Layout>
);
};
export default NotificationDetailTab;

View File

@@ -1,91 +1,246 @@
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';
import ExcelJS from 'exceljs';
import { saveAs } from 'file-saver';
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);
const [isLoadingTable, setIsLoadingTable] = useState(false);
const [tableData, setTableData] = useState([]);
const [columns, setColumns] = 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 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');
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);
} else {
setIsLoadingTable(true);
}
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);
setPivotData(pivotResponse.data);
if (valueReportResponse && valueReportResponse.data) {
console.log('API Value Report Response:', valueReportResponse);
setValueReportData(valueReportResponse.data);
}
// Buat struktur pivot: waktu sebagai baris, tag sebagai kolom
const timeMap = new Map();
const tagSet = new Set();
// Kumpulkan semua waktu unik dan tag unik
pivotResponse.data.forEach((row) => {
const tagName = row.id;
tagSet.add(tagName);
const dataPoints = row.data || [];
dataPoints.forEach((item) => {
if (item && typeof item === 'object' && 'x' in item && 'y' in item) {
const datetime = item.x;
if (!timeMap.has(datetime)) {
timeMap.set(datetime, {});
}
timeMap.get(datetime)[tagName] = item.y;
}
});
});
// Konversi ke array dan sort berdasarkan waktu
const sortedTimes = Array.from(timeMap.keys()).sort();
const sortedTags = Array.from(tagSet).sort();
// Buat data untuk table
const pivotTableData = sortedTimes.map((datetime, index) => {
const rowData = {
key: index,
datetime: datetime,
};
sortedTags.forEach((tagName) => {
rowData[tagName] = timeMap.get(datetime)[tagName];
});
return rowData;
});
console.log('Pivot table data sample:', pivotTableData.slice(0, 5));
console.log('Total pivot rows:', pivotTableData.length);
// Buat kolom dinamis
const dynamicColumns = [
{
title: 'No',
key: 'no',
width: 60,
align: 'center',
fixed: 'left',
render: (_, __, index) => {
return (page - 1) * pageSize + index + 1;
},
},
{
title: 'Datetime',
dataIndex: 'datetime',
key: 'datetime',
width: 180,
fixed: 'left',
sorter: (a, b) => new Date(a.datetime) - new Date(b.datetime),
},
...sortedTags.map((tagName) => ({
title: tagName,
dataIndex: tagName,
key: tagName,
width: 120,
align: 'center',
render: (value) => {
if (value === null || value === undefined) {
return '-';
}
return Number(value).toFixed(2);
},
})),
];
setColumns(dynamicColumns);
// Pagination
const total = pivotTableData.length;
const startIndex = (page - 1) * pageSize;
const endIndex = startIndex + pageSize;
const paginatedData = pivotTableData.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 = async () => {
setIsLoadingModal(true);
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);
// Jika response sukses, proses data
if (pivotResponse && pivotResponse.data) {
await fetchData(1, pagination.pageSize, false);
}
} catch (error) {
console.error('Error fetching data:', error);
// Error akan ditangkap oleh api-request.js dan muncul Swal otomatis
} finally {
setIsLoadingModal(false);
}
};
const handleReset = () => {
setPlantSubSection(0);
setStartDate(dateNow);
setEndDate(dateNow);
setPeriode(5);
setPeriode(30);
setTableData([]);
setColumns([]);
setPivotData([]);
setValueReportData([]);
setPagination({
current: 1,
pageSize: 10,
total: 0,
});
};
const getPlantSubSection = async () => {
@@ -104,8 +259,548 @@ 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 exportToExcel = 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';
// Buat struktur pivot yang sama seperti di tabel
const timeMap = new Map();
const tagSet = new Set();
pivotData.forEach((row) => {
const tagName = row.id;
tagSet.add(tagName);
const dataPoints = row.data || [];
dataPoints.forEach((item) => {
if (item && typeof item === 'object' && 'x' in item && 'y' in item) {
const datetime = item.x;
if (!timeMap.has(datetime)) {
timeMap.set(datetime, {});
}
timeMap.get(datetime)[tagName] = item.y;
}
});
});
const sortedTimes = Array.from(timeMap.keys()).sort();
const sortedTags = Array.from(tagSet).sort();
const pivotTableData = sortedTimes.map((datetime) => {
const rowData = {
datetime: datetime,
};
sortedTags.forEach((tagName) => {
rowData[tagName] = timeMap.get(datetime)[tagName];
});
return rowData;
});
console.log('Excel Pivot data:', pivotTableData.slice(0, 5));
console.log('Total rows for Excel:', pivotTableData.length);
const workbook = new ExcelJS.Workbook();
const ws = workbook.addWorksheet('Pivot Report');
// Buat header info (3 baris pertama)
ws.addRow(['PT. PUPUK INDONESIA UTILITAS']);
ws.addRow(['GRESIK GAS COGENERATION PLANT']);
ws.addRow([`${sectionName}`]);
ws.addRow([]); // Baris kosong sebagai pemisah
// Buat header kolom dengan tag number
const headerRow = [
'Datetime',
...sortedTags.map(tag => tagMapping[tag] || tag)
];
ws.addRow(headerRow);
// Buat data rows - PERBAIKAN: Simpan sebagai number murni
pivotTableData.forEach((rowData) => {
const row = [dayjs(rowData.datetime).format('DD-MM-YYYY HH:mm')];
sortedTags.forEach((tagName) => {
const value = rowData[tagName];
// Simpan sebagai number, bukan string
if (value !== undefined && value !== null) {
row.push(Number(value));
} else {
row.push('-');
}
});
ws.addRow(row);
});
// Set column widths
ws.getColumn(1).width = 18; // Datetime column
for (let i = 2; i <= sortedTags.length + 1; i++) {
ws.getColumn(i).width = 12; // Tag columns
}
// Merge cells untuk header info
const totalCols = sortedTags.length + 1;
ws.mergeCells(1, 1, 1, totalCols); // Baris 1
ws.mergeCells(2, 1, 2, totalCols); // Baris 2
ws.mergeCells(3, 1, 3, totalCols); // Baris 3
// Style untuk header info (3 baris pertama - bold dan center)
for (let i = 1; i <= 3; i++) {
const cell = ws.getCell(i, 1);
cell.font = { bold: true, size: 12 };
cell.alignment = { horizontal: 'center', vertical: 'middle', wrapText: true };
}
// Style untuk header kolom (bold, background color, center, border)
const headerRowIndex = 5; // Baris header
for (let col = 1; col <= totalCols; col++) {
const cell = ws.getCell(headerRowIndex, col);
cell.font = { bold: true, size: 11 };
cell.fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FFDCDCDC' }
};
cell.alignment = { horizontal: 'center', vertical: 'middle', wrapText: true };
cell.border = {
top: { style: 'thin', color: { argb: 'FF000000' } },
bottom: { style: 'thin', color: { argb: 'FF000000' } },
left: { style: 'thin', color: { argb: 'FF000000' } },
right: { style: 'thin', color: { argb: 'FF000000' } }
};
}
// Style untuk data cells (border dan alignment) - PERBAIKAN: Format number dengan 2 desimal
for (let row = headerRowIndex + 1; row <= ws.rowCount; row++) {
for (let col = 1; col <= totalCols; col++) {
const cell = ws.getCell(row, col);
cell.alignment = {
horizontal: 'center',
vertical: 'middle',
wrapText: true
};
cell.border = {
top: { style: 'thin', color: { argb: 'FF000000' } },
bottom: { style: 'thin', color: { argb: 'FF000000' } },
left: { style: 'thin', color: { argb: 'FF000000' } },
right: { style: 'thin', color: { argb: 'FF000000' } }
};
// Format number dengan 2 desimal untuk kolom value (kolom 2 dst)
if (col > 1) {
const cellValue = cell.value;
// Hanya set format number jika cell berisi angka
if (typeof cellValue === 'number') {
cell.numFmt = '0.00';
}
}
}
}
// Generate file name
const fileName = `Report_Pivot_${startDate.format('DD-MM-YYYY')}_to_${endDate.format('DD-MM-YYYY')}.xlsx`;
// Save file
const buffer = await workbook.xlsx.writeBuffer();
const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
saveAs(blob, fileName);
};
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';
// Buat struktur pivot yang sama seperti di tabel
const timeMap = new Map();
const tagSet = new Set();
pivotData.forEach((row) => {
const tagName = row.id;
tagSet.add(tagName);
const dataPoints = row.data || [];
dataPoints.forEach((item) => {
if (item && typeof item === 'object' && 'x' in item && 'y' in item) {
const datetime = item.x;
if (!timeMap.has(datetime)) {
timeMap.set(datetime, {});
}
timeMap.get(datetime)[tagName] = item.y;
}
});
});
const sortedTimes = Array.from(timeMap.keys()).sort();
const sortedTags = Array.from(tagSet).sort();
const pivotTableData = sortedTimes.map((datetime) => {
const rowData = {
datetime: datetime,
};
sortedTags.forEach((tagName) => {
rowData[tagName] = timeMap.get(datetime)[tagName];
});
return rowData;
});
console.log('PDF Pivot data:', pivotTableData.slice(0, 5));
console.log('Total rows for PDF:', pivotTableData.length);
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;
const DATETIME_COLUMN_WIDTH = 25;
const HEADER_LEFT_COLUMN_WIDTH = 40;
const MAX_TAG_COLUMNS_PER_PAGE = 15;
const drawFullHeader = (doc) => {
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;
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.setFontSize(9);
doc.setFont('helvetica', 'bold');
doc.setFontSize(10);
doc.text(`${sectionName}`, marginLeft + col1Width + col2Width / 2, 38, { align: 'center' });
};
// Hitung total kolom tag chunks
const totalTagColumns = sortedTags.length;
const totalTagChunks = Math.ceil(totalTagColumns / MAX_TAG_COLUMNS_PER_PAGE);
// PERBAIKAN: Variabel untuk tracking total halaman yang sebenarnya
let actualTotalPages = 0;
const pageInfoArray = []; // Array untuk menyimpan info setiap page
// Loop pertama: hitung dulu total halaman yang akan dibuat
for (let pageChunk = 0; pageChunk < totalTagChunks; pageChunk++) {
const startTagIndex = pageChunk * MAX_TAG_COLUMNS_PER_PAGE;
const endTagIndex = Math.min(startTagIndex + MAX_TAG_COLUMNS_PER_PAGE, totalTagColumns);
const pageTagColumns = sortedTags.slice(startTagIndex, endTagIndex);
const isFirstPage = (pageChunk === 0);
// Simulasi autoTable untuk menghitung jumlah halaman
const tempDoc = new jsPDF({ orientation: 'landscape' });
const headerRow = ['Datetime', ...pageTagColumns.map(tag => tagMapping[tag] || tag)];
const pdfRows = pivotTableData.map((rowData) => {
const row = [dayjs(rowData.datetime).format('DD-MM-YYYY HH:mm')];
pageTagColumns.forEach((tagName) => {
const value = rowData[tagName];
row.push(value !== undefined && value !== null ? Number(value).toFixed(2) : '-');
});
return row;
});
const availableWidthForTags = tableWidth - DATETIME_COLUMN_WIDTH;
const TAG_COLUMN_WIDTH = availableWidthForTags / pageTagColumns.length;
const tagColumnStyles = {};
for (let i = 0; i < pageTagColumns.length; i++) {
tagColumnStyles[i + 1] = {
cellWidth: TAG_COLUMN_WIDTH,
halign: 'center'
};
}
let pagesForThisChunk = 0;
autoTable(tempDoc, {
head: [headerRow],
body: pdfRows,
startY: isFirstPage ? 50 : 15,
theme: 'grid',
rowPageBreak: 'avoid',
styles: {
fontSize: 7,
cellPadding: 1.5,
minCellHeight: 8,
lineColor: [0, 0, 0],
lineWidth: 0.1,
halign: 'center',
valign: 'middle',
overflow: 'linebreak',
},
headStyles: {
fillColor: [220, 220, 220],
textColor: [0, 0, 0],
fontStyle: 'bold',
halign: 'center',
valign: 'middle',
lineColor: [0, 0, 0],
lineWidth: 0.3,
},
columnStyles: {
0: {
cellWidth: DATETIME_COLUMN_WIDTH,
fontStyle: 'bold',
halign: 'center',
valign: 'middle'
},
...tagColumnStyles
},
margin: { left: marginLeft, right: marginRight, top: 15 },
tableWidth: tableWidth,
pageBreak: 'auto',
didDrawPage: () => {
pagesForThisChunk++;
}
});
pageInfoArray.push({
chunkIndex: pageChunk,
pagesCount: pagesForThisChunk,
startPage: actualTotalPages + 1
});
actualTotalPages += pagesForThisChunk;
}
console.log('Total pages akan dibuat:', actualTotalPages);
// Loop kedua: buat PDF yang sebenarnya dengan nomor halaman yang benar
let globalPageNumber = 1;
for (let pageChunk = 0; pageChunk < totalTagChunks; pageChunk++) {
if (pageChunk > 0) {
doc.addPage();
}
const startTagIndex = pageChunk * MAX_TAG_COLUMNS_PER_PAGE;
const endTagIndex = Math.min(startTagIndex + MAX_TAG_COLUMNS_PER_PAGE, totalTagColumns);
const pageTagColumns = sortedTags.slice(startTagIndex, endTagIndex);
const isFirstPage = (pageChunk === 0);
if (isFirstPage) {
drawFullHeader(doc);
}
const headerRow = ['Datetime', ...pageTagColumns.map(tag => tagMapping[tag] || tag)];
const pdfRows = pivotTableData.map((rowData) => {
const row = [dayjs(rowData.datetime).format('DD-MM-YYYY HH:mm')];
pageTagColumns.forEach((tagName) => {
const value = rowData[tagName];
row.push(value !== undefined && value !== null ? Number(value).toFixed(2) : '-');
});
return row;
});
const availableWidthForTags = tableWidth - DATETIME_COLUMN_WIDTH;
const TAG_COLUMN_WIDTH = availableWidthForTags / pageTagColumns.length;
const tagColumnStyles = {};
for (let i = 0; i < pageTagColumns.length; i++) {
tagColumnStyles[i + 1] = {
cellWidth: TAG_COLUMN_WIDTH,
halign: 'center'
};
}
autoTable(doc, {
head: [headerRow],
body: pdfRows,
startY: isFirstPage ? 43 : 15,
theme: 'grid',
rowPageBreak: 'avoid',
styles: {
fontSize: 7,
cellPadding: 1.5,
minCellHeight: 8,
lineColor: [0, 0, 0],
lineWidth: 0.5,
halign: 'center',
valign: 'middle',
overflow: 'linebreak',
},
headStyles: {
fillColor: [220, 220, 220],
textColor: [0, 0, 0],
fontStyle: 'bold',
halign: 'center',
valign: 'middle',
lineColor: [0, 0, 0],
lineWidth: 0.5,
},
columnStyles: {
0: {
cellWidth: DATETIME_COLUMN_WIDTH,
fontStyle: 'bold',
halign: 'center',
valign: 'middle'
},
...tagColumnStyles
},
margin: { left: marginLeft, right: marginRight, top: 15 },
tableWidth: tableWidth,
pageBreak: 'auto',
didDrawPage: (data) => {
doc.setFontSize(8);
doc.setFont('helvetica', 'normal');
doc.text(
`Page ${globalPageNumber} of ${actualTotalPages}`,
doc.internal.pageSize.width / 2,
doc.internal.pageSize.height - 10,
{ align: 'center' }
);
globalPageNumber++;
},
});
}
doc.save(`Report_Pivot_${startDate.format('DD-MM-YYYY')}_to_${endDate.format('DD-MM-YYYY')}.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 +862,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 +874,33 @@ 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={pivotData.length === 0}
style={{ backgroundColor: '#1890ff', borderColor: '#1890ff' }}
>
Export PDF
</Button>
</Col>
<Col>
<Button
type="primary"
icon={<DownloadOutlined />}
onClick={exportToExcel}
disabled={pivotData.length === 0}
style={{ backgroundColor: '#28a745', borderColor: '#28a745' }}
>
Export Excel
</Button>
</Col>
<Col>
<Button
onClick={handleReset}
@@ -199,18 +911,26 @@ 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}>
<div style={{ overflowX: 'auto', width: '100%' }}>
<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"
sticky
/>
</div>
</Spin>
</Col>
</Row>
</Card>

View File

@@ -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,171 @@ const ReportTrending = memo(function ReportTrending(props) {
}
};
// Fungsi untuk menentukan apakah rentang tanggal lebih dari 1 hari
const isMultipleDays = () => {
return !startDate.isSame(endDate, 'day');
};
// Format sumbu X yang otomatis menyesuaikan
const formatXAxis = (tickItem) => {
const date = new Date(tickItem);
const hours = date.getHours().toString().padStart(2, '0');
const minutes = date.getMinutes().toString().padStart(2, '0');
// Jika rentang lebih dari 1 hari, tampilkan tanggal + waktu
if (isMultipleDays()) {
const day = date.getDate().toString().padStart(2, '0');
const month = (date.getMonth() + 1).toString().padStart(2, '0');
return `${day}/${month} ${hours}:${minutes}`;
}
// Jika hanya 1 hari, tampilkan waktu saja
return `${hours}:${minutes}`;
};
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 +363,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 +389,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>

View File

@@ -115,7 +115,7 @@ const ChangePasswordModal = (props) => {
try {
const response = await changePassword(props.selectedUser.user_id, formData.newPassword);
console.log('Change Password Response:', response);
// console.log('Change Password Response:', response);
if (response && response.statusCode === 200) {
NotifOk({

View File

@@ -220,35 +220,27 @@ const DetailUser = (props) => {
// For update mode: only send email if it has changed
if (FormData.user_id) {
// Only include email if it has changed from original
if (FormData.user_email !== originalEmail) {
payload.user_email = FormData.user_email;
}
// Add is_active for update mode
payload.is_active = FormData.is_active;
} else {
// For create mode: always send email
payload.user_email = FormData.user_email;
}
// Only add role_id if it exists (backend requires number >= 1, no null)
if (FormData.role_id) {
payload.role_id = FormData.role_id;
}
// Add password and name for new user (create mode)
if (!FormData.user_id) {
payload.user_name = FormData.user_name; // Username only for create
payload.user_password = FormData.password; // Backend expects 'user_password'
// Don't send confirmPassword, is_sa for create
payload.user_name = FormData.user_name;
payload.user_password = FormData.password;
}
// For update mode:
// - Don't send 'user_name' (username is immutable)
// - is_active is now sent for update mode
// - Only send email if it has changed
try {
console.log('Payload being sent:', payload);
// console.log('Payload being sent:', payload);
let response;
if (!FormData.user_id) {
@@ -257,11 +249,10 @@ const DetailUser = (props) => {
response = await updateUser(FormData.user_id, payload);
}
console.log('Save User Response:', response);
// console.log('Save User Response:', response);
// Check if response is successful
if (response && (response.statusCode === 200 || response.statusCode === 201)) {
// If in edit mode and newPassword is provided, change password
if (FormData.user_id && FormData.newPassword) {
try {
const passwordResponse = await changePassword(
@@ -385,9 +376,9 @@ const DetailUser = (props) => {
search: '',
});
console.log('Fetching roles with params:', queryParams.toString());
// console.log('Fetching roles with params:', queryParams.toString());
const response = await getAllRole(queryParams);
console.log('Fetched roles response:', response);
// console.log('Fetched roles response:', response);
// Handle different response structures
if (response && response.data) {
@@ -408,7 +399,7 @@ const DetailUser = (props) => {
}
setRoleList(roles);
console.log('Setting role list:', roles);
// console.log('Setting role list:', roles);
} else {
// Add mock data as fallback
console.warn('No response data, using mock data');
@@ -418,7 +409,7 @@ const DetailUser = (props) => {
{ role_id: 3, role_name: 'User', role_level: 3 },
];
setRoleList(mockRoles);
console.log('Setting mock role list:', mockRoles);
// console.log('Setting mock role list:', mockRoles);
}
} catch (error) {
console.error('Error fetching roles:', error);
@@ -429,7 +420,7 @@ const DetailUser = (props) => {
{ role_id: 3, role_name: 'User', role_level: 3 },
];
setRoleList(mockRoles);
console.log('Setting mock role list due to error:', mockRoles);
// console.log('Setting mock role list due to error:', mockRoles);
// Only show error notification if we don't have fallback data
if (process.env.NODE_ENV === 'development') {
@@ -1146,9 +1137,7 @@ const DetailUser = (props) => {
))}
</Select>
{errors.role_id && (
<Text style={{ color: 'red', fontSize: '12px' }}>
{errors.role_id}
</Text>
<Text style={{ color: 'red', fontSize: '12px' }}>{errors.role_id}</Text>
)}
</div>
</div>