Compare commits

..

31 Commits

Author SHA1 Message Date
5079f8d316 Merge pull request 'lavoce' (#25) from lavoce into main
Reviewed-on: #25
2025-11-28 07:12:41 +00:00
7073390de7 Merge pull request 'lavoce' (#24) from lavoce into main
Reviewed-on: #24
2025-11-28 05:50:36 +00:00
4226a24e79 Merge pull request 'lavoce' (#23) from lavoce into main
Reviewed-on: #23
2025-11-28 05:10:26 +00:00
038009433f Merge pull request 'lavoce' (#22) from lavoce into main
Reviewed-on: #22
2025-11-25 03:50:54 +00:00
64ba51b17c Merge pull request 'lavoce' (#21) from lavoce into main
Reviewed-on: #21
2025-11-20 03:44:05 +00:00
47d0638a42 Merge pull request 'lavoce' (#20) from lavoce into main
Reviewed-on: #20
2025-11-19 01:05:19 +00:00
d8c5f3ed44 Merge pull request 'lavoce' (#19) from lavoce into main
Reviewed-on: #19
2025-11-18 00:26:36 +00:00
affd9146bb Merge pull request 'lavoce' (#18) from lavoce into main
Reviewed-on: #18
2025-11-17 08:27:39 +00:00
4022b3f8f4 Merge pull request 'lavoce' (#17) from lavoce into main
Reviewed-on: #17
2025-11-10 07:57:17 +00:00
446a4e2b95 Merge pull request 'lavoce' (#16) from lavoce into main
Reviewed-on: #16
2025-11-10 06:33:35 +00:00
83a475c708 Merge pull request 'Repair beautiful apps' (#15) from lavoce into main
Reviewed-on: #15
2025-11-04 10:01:47 +00:00
ab1c510a77 Merge pull request 'Repair bg-signin' (#14) from lavoce into main
Reviewed-on: #14
2025-11-04 08:48:59 +00:00
59859c6d18 Update public/web.config 2025-11-04 07:03:57 +00:00
2bd27937dc Update public/web.config 2025-11-04 07:02:55 +00:00
1058c660d6 Update public/web.config 2025-11-04 07:01:10 +00:00
35b2167791 Update public/web.config 2025-11-04 06:53:05 +00:00
ec676983d0 Update .gitignore 2025-11-04 06:51:39 +00:00
c07c5f8235 Update .gitignore 2025-11-04 06:47:31 +00:00
b32ad97034 Merge pull request 'Add side bar menus mobile mode' (#13) from lavoce into main
Reviewed-on: #13
2025-11-04 06:42:09 +00:00
76244f6f6e Merge pull request 'lavoce' (#12) from lavoce into main
Reviewed-on: #12
2025-11-04 06:23:15 +00:00
0a128cbb3c Merge pull request 'lavoce' (#11) from lavoce into main
Reviewed-on: #11
2025-10-28 09:47:36 +00:00
bd4ab26680 Merge pull request 'lavoce' (#10) from lavoce into main
Reviewed-on: #10
2025-10-28 04:48:54 +00:00
3e728a1ff5 Merge pull request 'lavoce' (#9) from lavoce into main
Reviewed-on: #9
2025-10-27 03:48:58 +00:00
9db143972e Merge pull request 'lavoce' (#8) from lavoce into main
Reviewed-on: #8
2025-10-25 09:19:36 +00:00
029ea269a7 Merge pull request 'lavoce' (#7) from lavoce into main
Reviewed-on: #7
2025-10-24 05:43:32 +00:00
4cdaa042da Merge pull request 'lavoce' (#6) from lavoce into main
Reviewed-on: #6
2025-10-23 07:28:11 +00:00
56af2a16c0 Merge pull request 'update file svg to src assets' (#5) from lavoce into main
Reviewed-on: #5
2025-10-23 04:52:13 +00:00
deadf2ffb4 Merge pull request 'lavoce' (#4) from lavoce into main
Reviewed-on: #4
2025-10-23 04:27:57 +00:00
4da80c7089 Merge pull request 'lavoce' (#3) from lavoce into main
Reviewed-on: #3
2025-10-22 05:59:57 +00:00
56e3ce78a6 Merge pull request 'lavoce' (#2) from lavoce into main
Reviewed-on: #2
2025-10-20 04:06:02 +00:00
7c2a019dd2 Merge pull request 'lavoce' (#1) from lavoce into main
Reviewed-on: #1
2025-09-17 08:39:35 +00:00
73 changed files with 4782 additions and 8548 deletions

1
.gitignore vendored
View File

@@ -6,6 +6,7 @@ yarn-debug.log*
yarn-error.log* yarn-error.log*
pnpm-debug.log* pnpm-debug.log*
lerna-debug.log* lerna-debug.log*
*.config
node_modules node_modules
dist dist

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 309 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

View File

@@ -3,7 +3,7 @@
<system.webServer> <system.webServer>
<rewrite> <rewrite>
<rules> <rules>
<rule name="reactViteSypiu"> <rule name="CallOfDuty">
<match url=".*" /> <match url=".*" />
<conditions logicalGrouping="MatchAll"> <conditions logicalGrouping="MatchAll">
<add input="{REQUEST_FILENAME}" matchType="IsFile" negate="true" /> <add input="{REQUEST_FILENAME}" matchType="IsFile" negate="true" />

View File

@@ -36,7 +36,7 @@ import IndexNotification from './pages/notification/IndexNotification';
import IndexRole from './pages/role/IndexRole'; import IndexRole from './pages/role/IndexRole';
import IndexUser from './pages/user/IndexUser'; import IndexUser from './pages/user/IndexUser';
import IndexContact from './pages/contact/IndexContact'; import IndexContact from './pages/contact/IndexContact';
import DetailNotificationTab from './pages/notificationDetail/IndexNotificationDetail'; import DetailNotificationTab from './pages/detailNotification/IndexDetailNotification';
import IndexVerificationSparepart from './pages/verificationSparepart/IndexVerificationSparepart'; import IndexVerificationSparepart from './pages/verificationSparepart/IndexVerificationSparepart';
import SvgTest from './pages/home/SvgTest'; import SvgTest from './pages/home/SvgTest';
@@ -51,10 +51,6 @@ import SvgAirDryerC from './pages/home/SvgAirDryerC';
import IndexHistoryAlarm from './pages/history/alarm/IndexHistoryAlarm'; import IndexHistoryAlarm from './pages/history/alarm/IndexHistoryAlarm';
import IndexHistoryEvent from './pages/history/event/IndexHistoryEvent'; import IndexHistoryEvent from './pages/history/event/IndexHistoryEvent';
// Image Viewer
import ImageViewer from './Utils/ImageViewer';
import RedirectWa from './pages/blank/RedirectWa';
const App = () => { const App = () => {
return ( return (
<BrowserRouter> <BrowserRouter>
@@ -65,7 +61,7 @@ const App = () => {
<Route path="/signup" element={<SignUp />} /> <Route path="/signup" element={<SignUp />} />
<Route path="/svg" element={<SvgTest />} /> <Route path="/svg" element={<SvgTest />} />
<Route <Route
path="/notification-detail/:notificationId" path="/detail-notification/:notificationId"
element={<DetailNotificationTab />} element={<DetailNotificationTab />}
/> />
<Route <Route
@@ -73,16 +69,12 @@ const App = () => {
element={<IndexVerificationSparepart />} element={<IndexVerificationSparepart />}
/> />
<Route path="/redirect" element={<RedirectWa />} />
{/* Protected Routes */} {/* Protected Routes */}
<Route path="/dashboard" element={<ProtectedRoute />}> <Route path="/dashboard" element={<ProtectedRoute />}>
<Route path="home" element={<Home />} /> <Route path="home" element={<Home />} />
<Route path="blank" element={<Blank />} /> <Route path="blank" element={<Blank />} />
</Route> </Route>
<Route path="/image-viewer/:fileName" element={<ImageViewer />} />
<Route path="/dashboard-svg" element={<ProtectedRoute />}> <Route path="/dashboard-svg" element={<ProtectedRoute />}>
<Route path="overview-compressor" element={<SvgOverviewCompressor />} /> <Route path="overview-compressor" element={<SvgOverviewCompressor />} />
<Route path="compressor-a" element={<SvgCompressorA />} /> <Route path="compressor-a" element={<SvgCompressorA />} />
@@ -99,11 +91,6 @@ const App = () => {
<Route path="tag" element={<IndexTag />} /> <Route path="tag" element={<IndexTag />} />
<Route path="unit" element={<IndexUnit />} /> <Route path="unit" element={<IndexUnit />} />
<Route path="sparepart" element={<IndexSparepart />} /> <Route path="sparepart" element={<IndexSparepart />} />
<Route path="plant-sub-section" element={<IndexPlantSubSection />} />
<Route path="shift" element={<IndexShift />} />
<Route path="status" element={<IndexStatus />} />
{/* Brand Device Routes */}
<Route path="brand-device" element={<IndexBrandDevice />} /> <Route path="brand-device" element={<IndexBrandDevice />} />
<Route path="brand-device/add" element={<AddBrandDevice />} /> <Route path="brand-device/add" element={<AddBrandDevice />} />
<Route path="brand-device/edit/:id" element={<EditBrandDevice />} /> <Route path="brand-device/edit/:id" element={<EditBrandDevice />} />
@@ -120,6 +107,9 @@ const App = () => {
path="brand-device/view/temp/files/:fileName" path="brand-device/view/temp/files/:fileName"
element={<ViewFilePage />} element={<ViewFilePage />}
/> />
<Route path="plant-sub-section" element={<IndexPlantSubSection />} />
<Route path="shift" element={<IndexShift />} />
<Route path="status" element={<IndexStatus />} />
</Route> </Route>
<Route path="/report" element={<ProtectedRoute />}> <Route path="/report" element={<ProtectedRoute />}>
@@ -152,6 +142,7 @@ const App = () => {
<Route index element={<IndexJadwalShift />} /> <Route index element={<IndexJadwalShift />} />
</Route> </Route>
{/* Catch-all */}
<Route path="*" element={<NotFound />} /> <Route path="*" element={<NotFound />} />
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>

View File

@@ -1,248 +0,0 @@
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

@@ -47,63 +47,4 @@ const deleteBrand = async (id) => {
return response.data; return response.data;
}; };
const getErrorCodesByBrandId = async (brandId, queryParams) => { export { getAllBrands, getBrandById, createBrand, updateBrand, deleteBrand };
const query = queryParams ? `?${queryParams.toString()}` : '';
const response = await SendRequest({
method: 'get',
prefix: `error-code/brand/${brandId}${query}`,
});
return response.data;
};
const getErrorCodeById = async (id) => {
const response = await SendRequest({
method: 'get',
prefix: `error-code/${id}`,
});
return response.data;
};
const createErrorCode = async (brandId, queryParams) => {
const response = await SendRequest({
method: 'post',
prefix: `error-code/brand/${brandId}`,
params: queryParams,
});
return response.data;
};
const updateErrorCode = async (brandId, errorCodeId, queryParams) => {
const response = await SendRequest({
method: 'put',
prefix: `error-code/brand/${brandId}/${errorCodeId}`,
params: queryParams,
});
return response.data;
};
const deleteErrorCode = async (brandId, errorCode) => {
const response = await SendRequest({
method: 'delete',
prefix: `error-code/brand/${brandId}/${errorCode}`,
});
return response.data;
};
export {
getAllBrands,
getBrandById,
createBrand,
updateBrand,
deleteBrand,
getErrorCodesByBrandId,
getErrorCodeById,
createErrorCode,
updateErrorCode,
deleteErrorCode
};

View File

@@ -18,88 +18,4 @@ const getNotificationById = async (id) => {
return response.data; return response.data;
}; };
const getNotificationDetail = async (id) => { export { getAllNotification, getNotificationById };
const response = await SendRequest({
method: 'get',
prefix: `notification/${id}`,
});
return response.data;
};
// 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,29 +26,27 @@
<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"/> <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> </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"/> <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> <g transform="matrix(0.826913, 0, 0, 0.698383, 0.443817, 3.138935)">
<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);"/> <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);"/>
<g> <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="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="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);"/>
<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: 1; fill: rgb(243, 243, 243);" cx="773.446" cy="384.7" rx="11.751" ry="11.009"/>
<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> </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;"/> <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> <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);"/> <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> <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;"/> <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> <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);"/> <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> <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;"/> <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> <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);"/> <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)">°C</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; font-weight: bold;" x="748" y="347.676" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">HEATER</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: 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"/> <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"/>
@@ -110,12 +108,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;"/> <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> <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);"/> <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> <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;"/> <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> <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);"/> <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: 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> <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;"> <g transform="matrix(-0.387768, 0, 0, -0.200385, 743.634644, -199.991287)" style="transform-origin: 72.2405px 412.5px;">
@@ -179,17 +177,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;"/> <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> <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);"/> <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> <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;"/> <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> <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);"/> <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> <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;"/> <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> <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);"/> <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> <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);"/> <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> <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>
@@ -207,15 +205,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> <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);"/> <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="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> <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;"/> <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> <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);"/> <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="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="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;"/> <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> <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);"/> <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);"/>
@@ -229,33 +227,12 @@
<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);"/> <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"/> <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> <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="336.418" cy="237.483" rx="13.582" ry="12.517"/> <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"/>
<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> <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(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"/>
<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> <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(255, 204, 63); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="761.772" cy="233.876" rx="13.582" ry="12.517"/> <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"/>
<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> <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"/> <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: 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> </svg>

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 40 KiB

View File

@@ -3,7 +3,7 @@
<defs> <defs>
<bx:grid x="0" y="0" width="25" height="25"/> <bx:grid x="0" y="0" width="25" height="25"/>
</defs> </defs>
<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"/> <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"/>
<g transform="matrix(0.826913, 0, 0, 0.698383, 74.03907, 53.375034)"> <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); 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"/> <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,26 +26,27 @@
<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"/> <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> </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"/> <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> <g transform="matrix(0.826913, 0, 0, 0.698383, 0.443817, 3.138935)">
<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);"/> <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);"/>
<g> <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="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="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);"/>
<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: 1; fill: rgb(243, 243, 243);" cx="773.446" cy="384.7" rx="11.751" ry="11.009"/>
<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> </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;"/> <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> <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);"/> <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> <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;"/> <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> <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);"/> <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> <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;"/> <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> <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);"/> <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="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;" 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; font-weight: bold;" x="748" y="347.676" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">HEATER</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: 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"/> <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"/>
@@ -107,10 +108,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;"/> <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> <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);"/> <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> <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;"/> <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> <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);"/> <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: 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> <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;"> <g transform="matrix(-0.387768, 0, 0, -0.200385, 743.634644, -199.991287)" style="transform-origin: 72.2405px 412.5px;">
@@ -174,14 +177,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;"/> <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> <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);"/> <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> <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;"/> <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> <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);"/> <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> <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;"/> <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> <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);"/> <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> <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);"/> <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> <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>
@@ -199,12 +205,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> <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);"/> <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="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> <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;"/> <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> <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);"/> <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="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="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;"/> <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> <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);"/> <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);"/>
@@ -218,33 +227,12 @@
<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);"/> <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"/> <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> <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="336.418" cy="237.483" rx="13.582" ry="12.517"/> <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"/>
<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> <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(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"/>
<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> <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(255, 204, 63); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="761.772" cy="233.876" rx="13.582" ry="12.517"/> <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"/>
<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> <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"/> <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: 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> </svg>

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 40 KiB

View File

@@ -3,7 +3,7 @@
<defs> <defs>
<bx:grid x="0" y="0" width="25" height="25"/> <bx:grid x="0" y="0" width="25" height="25"/>
</defs> </defs>
<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"/> <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"/>
<g transform="matrix(0.826913, 0, 0, 0.698383, 74.03907, 53.375034)"> <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); 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"/> <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,26 +26,27 @@
<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"/> <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> </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"/> <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> <g transform="matrix(0.826913, 0, 0, 0.698383, 0.443817, 3.138935)">
<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);"/> <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);"/>
<g> <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="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="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);"/>
<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: 1; fill: rgb(243, 243, 243);" cx="773.446" cy="384.7" rx="11.751" ry="11.009"/>
<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> </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;"/> <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> <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);"/> <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> <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;"/> <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> <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);"/> <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> <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;"/> <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> <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);"/> <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="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;" 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; font-weight: bold;" x="748" y="347.676" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">HEATER</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: 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"/> <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"/>
@@ -107,10 +108,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;"/> <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> <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);"/> <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> <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;"/> <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> <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);"/> <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: 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> <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;"> <g transform="matrix(-0.387768, 0, 0, -0.200385, 743.634644, -199.991287)" style="transform-origin: 72.2405px 412.5px;">
@@ -174,14 +177,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;"/> <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> <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);"/> <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> <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;"/> <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> <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);"/> <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> <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;"/> <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> <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);"/> <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> <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);"/> <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> <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>
@@ -199,12 +205,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> <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);"/> <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="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> <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;"/> <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> <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);"/> <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="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="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;"/> <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> <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);"/> <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);"/>
@@ -218,34 +227,12 @@
<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);"/> <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"/> <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> <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="336.418" cy="237.483" rx="13.582" ry="12.517"/> <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"/>
<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> <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(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"/>
<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> <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(255, 204, 63); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="761.772" cy="233.876" rx="13.582" ry="12.517"/> <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"/>
<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> <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"/> <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: 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> </svg>

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 40 KiB

View File

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

View File

@@ -2,16 +2,7 @@
import mqtt from 'mqtt'; import mqtt from 'mqtt';
const mqttUrl = `${import.meta.env.VITE_MQTT_SERVER ?? 'ws://localhost:1884'}`; const mqttUrl = `${import.meta.env.VITE_MQTT_SERVER ?? 'ws://localhost:1884'}`;
const topics = [ const topics = ['cod/air_dryer/air_dryer1'];
'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 = { const options = {
keepalive: 30, keepalive: 30,
clientId: 'react_mqtt_' + Math.random().toString(16).substr(2, 8), clientId: 'react_mqtt_' + Math.random().toString(16).substr(2, 8),

View File

@@ -75,7 +75,7 @@ html body {
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: #1BAA56 transparent; scrollbar-color: #1BAA56 transparent;
} }
/* Hilangkan panah atas/bawah dengan important */ /* Hilangkan panah atas/bawah */
.custom-menu-scrollbar::-webkit-scrollbar-button { .custom-menu-scrollbar::-webkit-scrollbar-button {
display: none !important; display: none !important;
width: 0 !important; width: 0 !important;

View File

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

View File

@@ -1,49 +0,0 @@
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

@@ -14,6 +14,7 @@ const DetailContact = memo(function DetailContact(props) {
name: '', name: '',
phone: '', phone: '',
is_active: true, is_active: true,
contact_type: '',
}; };
const [formData, setFormData] = useState(defaultData); const [formData, setFormData] = useState(defaultData);
@@ -36,7 +37,13 @@ const DetailContact = memo(function DetailContact(props) {
} }
}; };
const handleContactTypeChange = (value) => {
setFormData((prev) => ({
...prev,
contact_type: value,
}));
};
const handleStatusToggle = (checked) => { const handleStatusToggle = (checked) => {
setFormData({ setFormData({
...formData, ...formData,
@@ -51,6 +58,7 @@ const DetailContact = memo(function DetailContact(props) {
const validationRules = [ const validationRules = [
{ field: 'name', label: 'Contact Name', required: true }, { field: 'name', label: 'Contact Name', required: true },
{ field: 'phone', label: 'Phone', required: true }, { field: 'phone', label: 'Phone', required: true },
{ field: 'contact_type', label: 'Contact Type', required: true },
]; ];
if ( if (
@@ -89,6 +97,7 @@ const DetailContact = memo(function DetailContact(props) {
contact_name: formData.name, contact_name: formData.name,
contact_phone: formData.phone.replace(/[\s\-\(\)]/g, ''), // Clean phone number contact_phone: formData.phone.replace(/[\s\-\(\)]/g, ''), // Clean phone number
is_active: formData.is_active, is_active: formData.is_active,
contact_type: formData.contact_type,
}; };
let response; let response;
@@ -136,16 +145,18 @@ const DetailContact = memo(function DetailContact(props) {
phone: props.selectedData.contact_phone || props.selectedData.phone, phone: props.selectedData.contact_phone || props.selectedData.phone,
is_active: is_active:
props.selectedData.is_active || props.selectedData.status === 'active', props.selectedData.is_active || props.selectedData.status === 'active',
contact_type: props.selectedData.contact_type || props.contactType || '',
}); });
} else if (props.actionMode === 'add') { } else if (props.actionMode === 'add') {
setFormData({ setFormData({
name: '', name: '',
phone: '', phone: '',
is_active: true, is_active: true,
contact_type: props.contactType === 'all' ? '' : props.contactType || '',
}); });
} }
} }
}, [props.showModal, props.actionMode, props.selectedData]); }, [props.showModal, props.actionMode, props.selectedData, props.contactType]);
return ( return (
<Modal <Modal
@@ -194,36 +205,27 @@ const DetailContact = memo(function DetailContact(props) {
]} ]}
> >
<div style={{ padding: '8px 0' }}> <div style={{ padding: '8px 0' }}>
{/* Status field only show in add mode*/} <div>
{props.actionMode === 'add' && ( <div>
<> <Text strong>Status</Text>
<div> </div>
<div> <div style={{ display: 'flex', alignItems: 'center', marginTop: '8px' }}>
<Text strong>Status</Text> <div style={{ marginRight: '8px' }}>
</div> <Switch
<div disabled={props.readOnly}
style={{ display: 'flex', alignItems: 'center', marginTop: '8px' }} style={{
> backgroundColor: formData.is_active ? '#23A55A' : '#bfbfbf',
<div style={{ marginRight: '8px' }}> }}
<Switch checked={formData.is_active}
disabled={props.readOnly} onChange={handleStatusToggle}
style={{ />
backgroundColor: formData.is_active
? '#23A55A'
: '#bfbfbf',
}}
checked={formData.is_active}
onChange={handleStatusToggle}
/>
</div>
<div>
<Text>{formData.is_active ? 'Active' : 'Inactive'}</Text>
</div>
</div>
</div> </div>
<Divider style={{ margin: '12px 0' }} /> <div>
</> <Text>{formData.is_active ? 'Active' : 'Inactive'}</Text>
)} </div>
</div>
</div>
<Divider style={{ margin: '12px 0' }} />
<div style={{ marginBottom: 12 }}> <div style={{ marginBottom: 12 }}>
<Text strong>Name</Text> <Text strong>Name</Text>
@@ -249,8 +251,7 @@ const DetailContact = memo(function DetailContact(props) {
style={{ color: formData.is_active ? '#000000' : '#ff4d4f' }} style={{ color: formData.is_active ? '#000000' : '#ff4d4f' }}
/> />
</div> </div>
{/* Contact Type */} <div style={{ marginBottom: 12 }}>
{/* <div style={{ marginBottom: 12 }}>
<Text strong>Contact Type</Text> <Text strong>Contact Type</Text>
<Text style={{ color: 'red' }}> *</Text> <Text style={{ color: 'red' }}> *</Text>
<Select <Select
@@ -263,7 +264,7 @@ const DetailContact = memo(function DetailContact(props) {
<Select.Option value="operator">Operator</Select.Option> <Select.Option value="operator">Operator</Select.Option>
<Select.Option value="gudang">Gudang</Select.Option> <Select.Option value="gudang">Gudang</Select.Option>
</Select> </Select>
</div> */} </div>
</div> </div>
</Modal> </Modal>
); );

View File

@@ -1,5 +1,5 @@
import React, { memo, useState, useEffect } from 'react'; import React, { memo, useState, useEffect } from 'react';
import { Button, Row, Col, Input, Tabs, Space, ConfigProvider, Card, Tag, Switch } from 'antd'; import { Button, Row, Col, Input, Tabs, Space, ConfigProvider, Card, Tag } from 'antd';
import { import {
PlusOutlined, PlusOutlined,
EditOutlined, EditOutlined,
@@ -10,43 +10,9 @@ import {
} from '@ant-design/icons'; } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { NotifAlert, NotifConfirmDialog } from '../../../components/Global/ToastNotif'; import { NotifAlert, NotifConfirmDialog } from '../../../components/Global/ToastNotif';
import { getAllContact, deleteContact, updateContact } from '../../../api/contact'; import { getAllContact, deleteContact } from '../../../api/contact';
const ContactCard = memo(function ContactCard({
contact,
showEditModal,
showDeleteModal,
onStatusToggle,
}) {
const handleStatusToggle = async (checked) => {
try {
const updatedContact = {
contact_name: contact.contact_name || contact.name,
contact_phone: contact.contact_phone || contact.phone,
is_active: checked,
contact_type: contact.contact_type,
};
await updateContact(contact.contact_id || contact.id, updatedContact);
NotifAlert({
icon: 'success',
title: 'Berhasil',
message: `Status "${contact.contact_name || contact.name}" berhasil diperbarui.`,
});
// Refresh contacts list
onStatusToggle && onStatusToggle();
} catch (error) {
console.error('Error updating contact status:', error);
NotifAlert({
icon: 'error',
title: 'Error',
message: 'Gagal memperbarui status kontak',
});
}
};
const ContactCard = memo(function ContactCard({ contact, showEditModal, showDeleteModal }) {
return ( return (
<Col xs={24} sm={12} md={8} lg={6}> <Col xs={24} sm={12} md={8} lg={6}>
<div <div
@@ -78,7 +44,7 @@ const ContactCard = memo(function ContactCard({
}} }}
> >
{/* Type Badge - Top Left */} {/* Type Badge - Top Left */}
{/* <div style={{ position: 'absolute', top: 0, left: 0, zIndex: 1 }}> <div style={{ position: 'absolute', top: 0, left: 0, zIndex: 1 }}>
<Tag <Tag
color={ color={
contact.contact_type === 'operator' contact.contact_type === 'operator'
@@ -91,37 +57,19 @@ const ContactCard = memo(function ContactCard({
> >
{contact.contact_type === 'operator' ? 'Operator' : contact.contact_type === 'gudang' ? 'Gudang' : 'Unknown'} {contact.contact_type === 'operator' ? 'Operator' : contact.contact_type === 'gudang' ? 'Gudang' : 'Unknown'}
</Tag> </Tag>
</div> */} </div>
{/* Status Slider - Top Right */} {/* Status Badge - Top Right */}
<div <div style={{ position: 'absolute', top: 0, right: 0, zIndex: 1 }}>
style={{ {contact.status === 'active' ? (
position: 'absolute', <Tag color={'green'} style={{ fontSize: '11px' }}>
top: 0, Active
right: 0, </Tag>
zIndex: 1, ) : (
padding: '4px 8px', <Tag color={'red'} style={{ fontSize: '11px' }}>
}} InActive
> </Tag>
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}> )}
<Switch
checked={contact.status === 'active'}
onChange={handleStatusToggle}
style={{
backgroundColor:
contact.status === 'active' ? '#52c41a' : '#d9d9d9',
}}
/>
<span
style={{
fontSize: '12px',
color: contact.status === 'active' ? '#52c41a' : '#ff4d4f',
fontWeight: 500,
}}
>
{contact.status === 'active' ? 'Active' : 'Inactive'}
</span>
</div>
</div> </div>
{/* Main Content */} {/* Main Content */}
@@ -267,6 +215,9 @@ 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(); const queryParams = new URLSearchParams();
Object.entries(searchParams).forEach(([key, value]) => { Object.entries(searchParams).forEach(([key, value]) => {
if (value !== '' && value !== null && value !== undefined) { if (value !== '' && value !== null && value !== undefined) {
@@ -306,10 +257,11 @@ const ListContact = memo(function ListContact(props) {
// Listen for saved contact data // Listen for saved contact data
useEffect(() => { useEffect(() => {
if (props.lastSavedContact) { if (props.lastSavedContact) {
fetchContacts(); fetchContacts(); // Refetch all contacts when data is saved
} }
}, [props.lastSavedContact]); }, [props.lastSavedContact]);
// Get contacts (already filtered by backend)
const getFilteredContacts = () => { const getFilteredContacts = () => {
return filteredContacts; return filteredContacts;
}; };
@@ -322,7 +274,7 @@ const ListContact = memo(function ListContact(props) {
const showAddModal = () => { const showAddModal = () => {
props.setSelectedData(null); props.setSelectedData(null);
props.setActionMode('add'); props.setActionMode('add');
// Pass the current active tab to determine contact type
props.setContactType?.(activeTab); props.setContactType?.(activeTab);
}; };
@@ -364,7 +316,7 @@ const ListContact = memo(function ListContact(props) {
<Row justify="space-between" align="middle" gutter={[8, 8]}> <Row justify="space-between" align="middle" gutter={[8, 8]}>
<Col xs={24} sm={24} md={12} lg={12}> <Col xs={24} sm={24} md={12} lg={12}>
<Input.Search <Input.Search
placeholder="Search by name..." placeholder="Search by name or type..."
value={formDataFilter.criteria} value={formDataFilter.criteria}
onChange={(e) => { onChange={(e) => {
const value = e.target.value; const value = e.target.value;
@@ -430,8 +382,7 @@ const ListContact = memo(function ListContact(props) {
marginBottom: '16px', marginBottom: '16px',
}} }}
> >
{/* Tabs */} <Tabs
{/* <Tabs
activeKey={activeTab} activeKey={activeTab}
onChange={setActiveTab} onChange={setActiveTab}
size="large" size="large"
@@ -449,7 +400,7 @@ const ListContact = memo(function ListContact(props) {
label: 'Gudang', label: 'Gudang',
}, },
]} ]}
/> */} />
</div> </div>
{getFilteredContacts().length === 0 ? ( {getFilteredContacts().length === 0 ? (
@@ -472,7 +423,6 @@ const ListContact = memo(function ListContact(props) {
}} }}
showEditModal={showEditModal} showEditModal={showEditModal}
showDeleteModal={showDeleteModal} showDeleteModal={showDeleteModal}
onStatusToggle={fetchContacts}
/> />
))} ))}
</Row> </Row>

View File

@@ -0,0 +1,357 @@
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';
// Path disesuaikan karena lokasi file berubah
// import { getNotificationById } from '../../api/notification'; // Dihapus karena belum ada di file API
import UserHistoryModal from '../notification/component/UserHistoryModal';
import LogHistoryModal from '../notification/component/LogHistoryModal';
const { Content } = Layout;
const { Text, Paragraph, Link } = Typography;
// Menggunakan kembali fungsi transform dari ListNotification untuk konsistensi data
const transformNotificationData = (item) => ({
id: `notification-${item.notification_error_id || 'dummy'}-0`,
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 || Date.now()).toLocaleString('id-ID'),
location: item.device_location || 'Location not specified',
details: item.message_error_issue || 'No details available',
link: '#',
subsection: item.solution_name || 'N/A',
isRead: item.is_read || false,
status: item.is_read ? 'Resolved' : item.is_delivered ? 'Delivered' : 'Pending',
tag: item.error_code,
plc: item.plc || 'N/A',
});
const getDummyNotificationById = (id) => {
console.log("Fetching dummy data for ID:", id);
// Data mentah dummy, seolah-olah dari API
const rawDummyData = { device_name: 'Compressor C-101', error_code_name: 'High Temperature', error_code: 'TEMP-H-303', device_location: 'Gudang Produksi A', message_error_issue: 'Suhu kompresor terdeteksi melebihi ambang batas aman.', is_delivered: true, plc: 'PLC-UTL-01' };
// Mengolah data mentah dummy menggunakan transform function
return transformNotificationData(rawDummyData);
};
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', 'log', 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);
// Ganti dengan fungsi API asli Anda
// const response = await getNotificationById(notificationId);
// setNotification(response.data);
// Menggunakan data dummy untuk sekarang
const dummyData = getDummyNotificationById(notificationId);
if (dummyData) {
setNotification(dummyData);
} else {
throw new Error('Notification not found');
}
} catch (err) {
setError(err.message);
} 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={12}>
<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.subsection}</div>
<Text strong style={{ display: 'block', marginTop: '8px' }}>Time</Text>
<div>{notification.timestamp}</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 Kanan: Informasi Teknis */}
<Col xs={24} lg={12}>
<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>
</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} onClick={() => setModalContent('log')} 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%' }}>
<Card size="small" hoverable>
<Text><FilePdfOutlined style={{ marginRight: '8px' }} /> Error 303.pdf</Text>
<Link href="#" target="_blank" style={{ fontSize: '12px', display: 'block', marginLeft: '24px' }}>lihat disini</Link>
</Card>
<Card size="small" hoverable>
<Text><FilePdfOutlined style={{ marginRight: '8px' }} /> SOP Kompresor.pdf</Text>
<Link href="#" target="_blank" style={{ fontSize: '12px', display: 'block', marginLeft: '24px' }}>lihat disini</Link>
</Card>
</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%' }}>
<Card size="small">
<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: '#52c41a', fontWeight: 500 }}>Available</Text>
</Col>
<Col span={17}>
<Text strong>Air Filter</Text>
<Paragraph style={{ fontSize: '12px', margin: 0, color: '#595959' }}>Filters incoming air to remove dust.</Paragraph>
</Col>
</Row>
</Card>
</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}
/>
<LogHistoryModal
visible={modalContent === 'log'}
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 { Text } = Typography;
// const filePathSvg = '/src/assets/svg/air_dryer_A_rev.svg'; // const filePathSvg = '/src/assets/svg/air_dryer_A_rev.svg';
const topicMqtt = 'PIU_COD/AIR_DRYER/AIR_DRYER_A'; const topicMqtt = 'PIU_GGCP/Devices/PB';
const SvgAirDryerA = () => { const SvgAirDryerA = () => {
return ( return (

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -6,10 +6,10 @@ import { ArrowLeftOutlined, FilePdfOutlined, FileImageOutlined, DownloadOutlined
import { useBreadcrumb } from '../../../layout/LayoutBreadcrumb'; import { useBreadcrumb } from '../../../layout/LayoutBreadcrumb';
import { getBrandById } from '../../../api/master-brand'; import { getBrandById } from '../../../api/master-brand';
import { import {
downloadFile, downloadFile,
getFile, getFile,
getFileUrl, getFileUrl,
getFolderFromFileType, getFolderFromFileType,
} from '../../../api/file-uploads'; } from '../../../api/file-uploads';
const { Title } = Typography; const { Title } = Typography;
@@ -26,7 +26,17 @@ const ViewFilePage = () => {
const [pdfBlobUrl, setPdfBlobUrl] = useState(null); const [pdfBlobUrl, setPdfBlobUrl] = useState(null);
const [pdfLoading, setPdfLoading] = useState(false); const [pdfLoading, setPdfLoading] = useState(false);
// Debug: Log URL parameters and location
const isFromEdit = window.location.pathname.includes('/edit/'); const isFromEdit = window.location.pathname.includes('/edit/');
console.log('ViewFilePage URL Parameters:', {
id,
fileType,
fileName,
allParams: params,
windowLocation: window.location.pathname,
urlParts: window.location.pathname.split('/'),
isFromEdit
});
let fallbackId = id; let fallbackId = id;
let fallbackFileType = fileType; let fallbackFileType = fileType;
@@ -35,6 +45,7 @@ const ViewFilePage = () => {
if (!fileName || !fileType || !id) { if (!fileName || !fileType || !id) {
const urlParts = window.location.pathname.split('/'); const urlParts = window.location.pathname.split('/');
// console.log('URL Parts from pathname:', urlParts);
const viewIndex = urlParts.indexOf('view'); const viewIndex = urlParts.indexOf('view');
const editIndex = urlParts.indexOf('edit'); const editIndex = urlParts.indexOf('edit');
@@ -44,6 +55,13 @@ const ViewFilePage = () => {
fallbackId = urlParts[actionIndex + 1]; fallbackId = urlParts[actionIndex + 1];
fallbackFileType = urlParts[actionIndex + 3]; fallbackFileType = urlParts[actionIndex + 3];
fallbackFileName = decodeURIComponent(urlParts[actionIndex + 4]); fallbackFileName = decodeURIComponent(urlParts[actionIndex + 4]);
console.log('Fallback extraction:', {
fallbackId,
fallbackFileType,
fallbackFileName,
actionType: viewIndex !== -1 ? 'view' : 'edit'
});
} }
} }
@@ -77,9 +95,12 @@ const ViewFilePage = () => {
const folder = getFolderFromFileType('pdf'); const folder = getFolderFromFileType('pdf');
try { try {
const blobData = await getFile(folder, decodedFileName); const blobData = await getFile(folder, decodedFileName);
console.log('PDF blob data received:', blobData);
const blobUrl = window.URL.createObjectURL(blobData); const blobUrl = window.URL.createObjectURL(blobData);
setPdfBlobUrl(blobUrl); setPdfBlobUrl(blobUrl);
console.log('PDF blob URL created successfully:', blobUrl);
} catch (pdfError) { } catch (pdfError) {
console.error('Error loading PDF:', pdfError);
setError('Failed to load PDF file: ' + (pdfError.message || pdfError)); setError('Failed to load PDF file: ' + (pdfError.message || pdfError));
setPdfBlobUrl(null); setPdfBlobUrl(null);
} finally { } finally {
@@ -89,6 +110,7 @@ const ViewFilePage = () => {
setLoading(false); setLoading(false);
} catch (error) { } catch (error) {
console.error('Error fetching data:', error);
setError('Failed to load data'); setError('Failed to load data');
setLoading(false); setLoading(false);
} }
@@ -138,6 +160,12 @@ const ViewFilePage = () => {
const targetPhase = savedPhase ? parseInt(savedPhase) : 1; const targetPhase = savedPhase ? parseInt(savedPhase) : 1;
console.log('ViewFilePage handleBack - Edit mode:', {
savedPhase,
targetPhase,
id: fallbackId || id
});
navigate(`/master/brand-device/edit/${fallbackId || id}`, { navigate(`/master/brand-device/edit/${fallbackId || id}`, {
state: { phase: targetPhase, fromFileViewer: true }, state: { phase: targetPhase, fromFileViewer: true },
replace: true replace: true
@@ -168,7 +196,9 @@ const ViewFilePage = () => {
const isImage = ['jpg', 'jpeg', 'png', 'gif'].includes(fileExtension); const isImage = ['jpg', 'jpeg', 'png', 'gif'].includes(fileExtension);
const isPdf = fileExtension === 'pdf'; const isPdf = fileExtension === 'pdf';
// const fileUrl = loading ? null : getFileUrl(getFolderFromFileType(fallbackFileType || fileType), actualFileName);
// Show placeholder when loading
if (loading) { if (loading) {
return ( return (
<div style={{ textAlign: 'center', padding: '50px' }}> <div style={{ textAlign: 'center', padding: '50px' }}>
@@ -310,14 +340,17 @@ const ViewFilePage = () => {
</Button> </Button>
<Button <Button
onClick={() => { onClick={() => {
// Retry loading PDF
setPdfLoading(true); setPdfLoading(true);
const folder = getFolderFromFileType('pdf'); const folder = getFolderFromFileType('pdf');
getFile(folder, actualFileName) getFile(folder, actualFileName)
.then(blobData => { .then(blobData => {
console.log('Retry PDF blob data:', blobData);
const blobUrl = window.URL.createObjectURL(blobData); const blobUrl = window.URL.createObjectURL(blobData);
setPdfBlobUrl(blobUrl); setPdfBlobUrl(blobUrl);
}) })
.catch(error => { .catch(error => {
console.error('Error retrying PDF load:', error);
setError('Failed to load PDF file: ' + (error.message || error)); setError('Failed to load PDF file: ' + (error.message || error));
setPdfBlobUrl(null); setPdfBlobUrl(null);
}) })
@@ -412,7 +445,7 @@ const ViewFilePage = () => {
</Space> </Space>
</div> </div>
{/* File type indicator */}
<div style={{ marginBottom: '16px' }}> <div style={{ marginBottom: '16px' }}>
<div style={{ <div style={{
display: 'inline-block', display: 'inline-block',
@@ -429,7 +462,7 @@ const ViewFilePage = () => {
</div> </div>
<div style={{ position: 'relative' }}> <div style={{ position: 'relative' }}>
{/* Overlay with blur effect during loading */}
{loading && ( {loading && (
<div style={{ <div style={{
position: 'absolute', position: 'absolute',

View File

@@ -3,96 +3,74 @@ import { Form, Input, Row, Col, Typography, Switch } from 'antd';
const { Text } = Typography; const { Text } = Typography;
const BrandForm = ({ const BrandForm = ({ form, formData, onValuesChange, isEdit = false }) => {
form, const isActive = Form.useWatch('is_active', form) ?? formData.is_active ?? true;
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 ( return (
<div> <Form
<Form layout="vertical"
layout="vertical" form={form}
form={form} onValuesChange={onValuesChange}
onValuesChange={onValuesChange} initialValues={formData}
initialValues={{ >
brand_name: '', <Form.Item label="Status">
brand_type: '', <div style={{ display: 'flex', alignItems: 'center' }}>
brand_model: '', <Form.Item name="is_active" valuePropName="checked" noStyle>
brand_manufacture: '', <Switch
is_active: true, style={{ backgroundColor: isActive ? '#23A55A' : '#bfbfbf' }}
}} />
> </Form.Item>
<Form.Item label="Status"> <Text style={{ marginLeft: 8 }}>
<div style={{ display: 'flex', alignItems: 'center' }}> {isActive ? 'Running' : 'Offline'}
<Form.Item name="is_active" valuePropName="checked" noStyle> </Text>
<Switch </div>
style={{ backgroundColor: isActive ? '#23A55A' : '#bfbfbf' }} </Form.Item>
disabled={readOnly}
/>
</Form.Item>
<Text style={{ marginLeft: 8 }}>
{isActive ? 'Running' : 'Offline'}
</Text>
</div>
</Form.Item>
<Form.Item label="Brand Code" name="brand_code"> <Form.Item label="Brand Code" name="brand_code">
<Input <Input
disabled={true} placeholder={'Auto Fill Brand Code'}
style={{ disabled={true}
backgroundColor: '#f5f5f5', style={{
cursor: 'not-allowed' backgroundColor: '#f5f5f5',
}} cursor: 'not-allowed'
/> }}
</Form.Item> />
</Form.Item>
<Row gutter={16}> <Row gutter={16}>
<Col span={12}> <Col span={12}>
<Form.Item <Form.Item
label="Brand Name" label="Brand Name"
name="brand_name" name="brand_name"
rules={[{ required: !readOnly, message: 'Brand Name wajib diisi!' }]} rules={[{ required: true, message: 'Brand Name wajib diisi!' }]}
> >
<Input disabled={readOnly} /> <Input />
</Form.Item> </Form.Item>
</Col> </Col>
<Col span={12}> <Col span={12}>
<Form.Item <Form.Item
label="Manufacturer" label="Manufacturer"
name="brand_manufacture" name="brand_manufacture"
rules={[{ required: !readOnly, message: 'Manufacturer wajib diisi!' }]} rules={[{ required: true, message: 'Manufacturer wajib diisi!' }]}
> >
<Input placeholder="Enter Manufacturer" disabled={readOnly} /> <Input placeholder="Enter Manufacturer" />
</Form.Item> </Form.Item>
</Col> </Col>
</Row> </Row>
<Row gutter={16}> <Row gutter={16}>
<Col span={12}> <Col span={12}>
<Form.Item label="Brand Type" name="brand_type"> <Form.Item label="Brand Type" name="brand_type">
<Input placeholder="Enter Brand Type (Optional)" disabled={readOnly} /> <Input placeholder="Enter Brand Type (Optional)" />
</Form.Item> </Form.Item>
</Col> </Col>
<Col span={12}> <Col span={12}>
<Form.Item label="Model" name="brand_model"> <Form.Item label="Model" name="brand_model">
<Input placeholder="Enter Model (Optional)" disabled={readOnly} /> <Input placeholder="Enter Model (Optional)" />
</Form.Item> </Form.Item>
</Col> </Col>
</Row> </Row>
</Form> </Form>
</div>
); );
}; };

View File

@@ -1,397 +0,0 @@
import React, { useState } from 'react';
import { Card, Typography, Tag, Button, Modal, Row, Col, Space } from 'antd';
import { EyeOutlined, DeleteOutlined, CheckOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
const { Text, Title } = Typography;
const CustomSparepartCard = ({
sparepart,
isSelected = false,
isReadOnly = false,
showPreview = true,
showDelete = false,
onPreview,
onDelete,
onCardClick,
loading = false,
size = 'small',
style = {},
}) => {
const [previewModalVisible, setPreviewModalVisible] = useState(false);
const getImageSrc = () => {
if (sparepart.sparepart_foto) {
if (sparepart.sparepart_foto.startsWith('http')) {
return sparepart.sparepart_foto;
} else {
const fileName = sparepart.sparepart_foto.split('/').pop();
if (fileName === 'defaultSparepartImg.jpg') {
return `/assets/defaultSparepartImg.jpg`;
} else {
const token = localStorage.getItem('token');
const baseURL = import.meta.env.VITE_API_SERVER || '';
return `${baseURL}/file-uploads/images/${encodeURIComponent(fileName)}${token ? `?token=${encodeURIComponent(token)}` : ''}`;
}
}
}
return 'https://via.placeholder.com/150';
};
const handlePreview = () => {
if (onPreview) {
onPreview(sparepart);
} else {
setPreviewModalVisible(true);
}
};
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);
}
};
const getCardActions = () => {
const actions = [];
if (showPreview) {
actions.push(
<Button
key="preview"
type="text"
icon={<EyeOutlined />}
title="Lihat Detail"
style={{ color: '#1890ff' }}
onClick={(e) => {
e.stopPropagation();
handlePreview();
}}
/>
);
}
if (showDelete && !isReadOnly) {
actions.push(
<Button
key="delete"
type="text"
icon={<DeleteOutlined />}
title="Hapus"
style={{ color: '#ff4d4f' }}
onClick={(e) => {
e.stopPropagation();
onDelete?.(sparepart);
}}
/>
);
}
return actions;
};
const getCardStyle = () => {
const baseStyle = {
borderRadius: '12px',
overflow: 'hidden',
border: isSelected ? '2px solid #1890ff' : '1px solid #E0E0E0',
cursor: isReadOnly ? 'default' : 'pointer',
position: 'relative',
boxShadow: '0 2px 8px rgba(0,0,0,0.06)',
transition: 'all 0.3s ease'
};
switch (size) {
case 'small':
return {
...baseStyle,
height: '180px',
minHeight: '180px'
};
case 'large':
return {
...baseStyle,
height: '280px',
minHeight: '280px'
};
default:
return {
...baseStyle,
height: '220px',
minHeight: '220px'
};
}
};
return (
<>
<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',
alignItems: 'center',
justifyContent: 'space-between'
}}
onClick={handleCardClick}
>
<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
style={{
fontSize: '12px',
fontWeight: 600,
color: '#262626'
}}
>
{sparepart.sparepart_qty || 0}
</Text>
</div>
</div>
</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>
<Modal
title="Sparepart Details"
open={previewModalVisible}
onCancel={() => setPreviewModalVisible(false)}
footer={[
<Button key="close" onClick={() => setPreviewModalVisible(false)}>
Close
</Button>
]}
width={800}
centered
styles={{ body: { padding: '24px' } }}
>
<Row gutter={[24, 24]}>
<Col span={10}>
<div style={{ textAlign: 'center' }}>
<div
style={{
backgroundColor: '#f0f0f0',
width: '220px',
height: '220px',
margin: '0 auto 16px',
position: 'relative',
borderRadius: '12px',
overflow: 'hidden',
border: '1px solid #E0E0E0',
boxShadow: '0 4px 12px rgba(0,0,0,0.1)'
}}
>
<img
src={getImageSrc()}
alt={sparepart.sparepart_name || 'Sparepart'}
style={{
width: '100%',
height: '100%',
objectFit: 'cover'
}}
onError={(e) => {
e.target.src = 'https://via.placeholder.com/220x220/d9d9d9/666666?text=No+Image';
}}
/>
</div>
{sparepart.sparepart_item_type && (
<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={14}>
<div>
<Title level={3} style={{ marginBottom: '20px', color: '#262626' }}>
{sparepart.sparepart_name || 'Unnamed'}
</Title>
<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'}
</div>
</div>
</Col>
<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 && (
<Col span={24}>
<div>
<Text type="secondary" style={{ fontSize: '12px' }}>Description</Text>
<div style={{ fontSize: '15px', marginTop: '2px', lineHeight: '1.5' }}>
{sparepart.sparepart_description}
</div>
</div>
</Col>
)}
</Row>
</div>
{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>
</Col>
</Row>
</Modal>
</>
);
};
export default CustomSparepartCard;

View File

@@ -1,288 +0,0 @@
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

@@ -0,0 +1,200 @@
import React, { useState } from 'react';
import { Modal, Table, Button, Space, message, Tag, ConfigProvider } from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined, EyeOutlined } from '@ant-design/icons';
import { NotifConfirmDialog, NotifOk, NotifAlert } from '../../../../components/Global/ToastNotif';
const ErrorCodeListModal = ({
visible,
onClose,
errorCodes,
loading,
onPreview,
onEdit,
onDelete,
onAddNew,
}) => {
const [confirmLoading, setConfirmLoading] = useState(false);
const columns = [
{
title: 'No',
key: 'no',
width: '5%',
align: 'center',
render: (_, __, index) => index + 1,
},
{
title: 'Error Code',
dataIndex: 'error_code',
key: 'error_code',
width: '15%',
},
{
title: 'Error Name',
dataIndex: 'error_code_name',
key: 'error_code_name',
width: '30%',
render: (text) => text || '-',
},
{
title: 'Description',
dataIndex: 'error_code_description',
key: 'error_code_description',
width: '25%',
render: (text) => text || '-',
ellipsis: true,
},
{
title: 'Solutions',
key: 'solutions',
width: '10%',
align: 'center',
render: (_, record) => {
const solutionCount = record.solution ? record.solution.length : 0;
return <Tag color={solutionCount > 0 ? 'green' : 'red'}>{solutionCount} Sol</Tag>;
},
},
{
title: 'Status',
dataIndex: 'status',
key: 'status',
width: '10%',
align: 'center',
render: (_, { status }) => (
<Tag color={status ? 'green' : 'red'}>{status ? 'Active' : 'Inactive'}</Tag>
),
},
{
title: 'Action',
key: 'action',
align: 'center',
width: '15%',
render: (_, record) => (
<Space>
<Button
type="text"
icon={<EyeOutlined />}
onClick={() => onPreview(record)}
style={{
color: '#23A55A',
borderColor: '#23A55A',
}}
size="small"
/>
<Button
type="text"
icon={<EditOutlined />}
onClick={() => onEdit(record)}
style={{
color: '#faad14',
borderColor: '#faad14',
}}
size="small"
/>
<Button
type="text"
danger
icon={<DeleteOutlined />}
onClick={() => handleDelete(record)}
style={{
borderColor: '#ff4d4f',
color: '#ff4d4f',
}}
size="small"
/>
</Space>
),
},
];
const handleDelete = (record) => {
if (errorCodes.length <= 1) {
NotifAlert({
icon: 'warning',
title: 'Perhatian',
message: 'Setiap brand harus memiliki minimal 1 error code!',
});
return;
}
NotifConfirmDialog({
icon: 'question',
title: 'Konfirmasi',
message: `Apakah anda yakin hapus error code "${
record.error_code_name || record.error_code
}" ?`,
onConfirm: () => {
setConfirmLoading(true);
onDelete(record.key);
setConfirmLoading(false);
},
onCancel: () => {},
});
};
return (
<Modal
title={
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<span>Daftar Error Codes</span>
<ConfigProvider
theme={{
token: { colorBgContainer: '#23a55ade' },
components: {
Button: {
defaultBg: '#23a55a',
defaultColor: '#FFFFFF',
defaultBorderColor: '#23a55a',
defaultHoverBg: '#209652',
defaultHoverColor: '#FFFFFF',
defaultHoverBorderColor: '#23a55a',
},
},
}}
>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={onAddNew}
>
Add New Error Code
</Button>
</ConfigProvider>
</div>
}
open={visible}
onCancel={onClose}
closable={false}
maskClosable={false}
width={1200}
footer={[
<Button key="close" onClick={onClose}>
Close
</Button>,
]}
>
<Table
columns={columns}
dataSource={errorCodes}
loading={loading || confirmLoading}
rowKey="key"
pagination={{
pageSize: 10,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total, range) => `${range[0]}-${range[1]} of ${total} items`,
}}
scroll={{ x: 1000 }}
size="small"
/>
</Modal>
);
};
export default ErrorCodeListModal;

View File

@@ -0,0 +1,217 @@
import { Form, Input, Switch, Upload, Button, Typography, message, ConfigProvider } from 'antd';
import { UploadOutlined } from '@ant-design/icons';
import { uploadFile } from '../../../../api/file-uploads';
const { Text } = Typography;
const ErrorCodeSimpleForm = ({
errorCodeForm,
isErrorCodeFormReadOnly = false,
errorCodeIcon,
onErrorCodeIconUpload,
onErrorCodeIconRemove,
onAddErrorCode,
}) => {
const statusValue = Form.useWatch('status', errorCodeForm);
const handleIconUpload = async (file) => {
// Check if file is an image
const isImage = file.type.startsWith('image/');
if (!isImage) {
message.error('You can only upload image files!');
return Upload.LIST_IGNORE;
}
// Check file size (max 2MB)
const isLt2M = file.size / 1024 / 1024 < 2;
if (!isLt2M) {
message.error('Image must be smaller than 2MB!');
return Upload.LIST_IGNORE;
}
try {
const fileExtension = file.name.split('.').pop().toLowerCase();
const isImageFile = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'].includes(
fileExtension
);
const fileType = isImageFile ? 'image' : 'pdf';
const folder = 'images';
const uploadResponse = await uploadFile(file, folder);
const iconPath =
uploadResponse.data?.path_icon || uploadResponse.data?.path_solution || '';
if (iconPath) {
onErrorCodeIconUpload({
name: file.name,
uploadPath: iconPath,
fileExtension,
isImage: isImageFile,
size: file.size,
});
message.success(`${file.name} uploaded successfully!`);
} else {
message.error(`Failed to upload ${file.name}`);
}
} catch (error) {
console.error('Error uploading icon:', error);
message.error(`Failed to upload ${file.name}`);
}
};
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
disabled={isErrorCodeFormReadOnly}
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" disabled={isErrorCodeFormReadOnly} />
</Form.Item>
{/* Error Description */}
<Form.Item label="Description" name="error_code_description">
<Input.TextArea
placeholder="Enter error description"
rows={3}
disabled={isErrorCodeFormReadOnly}
/>
</Form.Item>
{/* Color and Icon in same row */}
<Form.Item label="Color & Icon">
<Input.Group compact>
<Form.Item name="error_code_color" noStyle>
<input
type="color"
disabled={isErrorCodeFormReadOnly}
style={{
width: '30%',
height: '40px',
border: '1px solid #d9d9d9',
borderRadius: 4,
}}
defaultValue="#000000"
/>
</Form.Item>
<Form.Item noStyle style={{ width: '70%', paddingLeft: 8 }}>
{!isErrorCodeFormReadOnly ? (
<Upload
beforeUpload={handleIconUpload}
showUploadList={false}
accept="image/*"
style={{ width: '100%' }}
>
<Button icon={<UploadOutlined />} style={{ width: '100%' }}>
Upload Icon
</Button>
</Upload>
) : (
<div
style={{
padding: '8px 12px',
border: '1px solid #d9d9d9',
borderRadius: 4,
}}
>
<Text type="secondary">No upload allowed</Text>
</div>
)}
</Form.Item>
</Input.Group>
{errorCodeIcon && (
<div style={{ marginTop: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<img
src={errorCodeIcon.url || errorCodeIcon.uploadPath}
alt="Error Code Icon"
style={{
width: 50,
height: 50,
objectFit: 'cover',
border: '1px solid #d9d9d9',
borderRadius: 4,
}}
/>
<div>
<Text style={{ fontSize: 12 }}>{errorCodeIcon.name}</Text>
<br />
<Text type="secondary" style={{ fontSize: 10 }}>
Size: {(errorCodeIcon.size / 1024).toFixed(1)} KB
</Text>
</div>
{!isErrorCodeFormReadOnly && (
<Button type="text" danger size="small" onClick={handleIconRemove}>
Remove
</Button>
)}
</div>
</div>
)}
</Form.Item>
{/* Add Error Code Button */}
{!isErrorCodeFormReadOnly && (
<Form.Item>
<ConfigProvider
theme={{
token: { colorBgContainer: '#23a55ade' },
components: {
Button: {
defaultBg: '#23a55a',
defaultColor: '#FFFFFF',
defaultBorderColor: '#23a55a',
defaultHoverBg: '#209652',
defaultHoverColor: '#FFFFFF',
defaultHoverBorderColor: '#23a55a',
},
},
}}
>
<Button
htmlType="button"
onClick={() => {
// Call parent function to add error code
onAddErrorCode();
}}
style={{ width: '100%' }}
>
Simpan Error Code
</Button>
</ConfigProvider>
</Form.Item>
)}
</>
);
};
export default ErrorCodeSimpleForm;

View File

@@ -1,45 +1,18 @@
import React, { useState } from 'react'; import { useState } from 'react';
import { Upload, Modal, Button, Typography, Space, Image } from 'antd'; import { Upload, Modal } from 'antd';
import { UploadOutlined, EyeOutlined, DeleteOutlined, FileOutlined } from '@ant-design/icons'; import { UploadOutlined } from '@ant-design/icons';
import { NotifOk, NotifAlert } from '../../../../components/Global/ToastNotif'; import { NotifOk, NotifAlert } from '../../../../components/Global/ToastNotif';
import { uploadFile, getFolderFromFileType, getFileUrl, getFileType } from '../../../../api/file-uploads'; import { uploadFile, getFolderFromFileType } from '../../../../api/file-uploads';
const { Text } = Typography;
const FileUploadHandler = ({ const FileUploadHandler = ({
type = 'solution', solutionFields,
maxCount = 1, fileList,
accept = '.pdf,.jpg,.jpeg,.png,.gif',
disabled = false,
fileList = [],
onFileUpload, onFileUpload,
onFileRemove, onFileRemove
existingFile = null,
clearSignal = null,
debugProps = {},
uploadText = 'Click or drag file to this area to upload',
uploadHint = 'Support for PDF and image files only',
buttonText = 'Upload File',
buttonType = 'default',
containerStyle = {},
buttonStyle = {},
showPreview = true
}) => { }) => {
const [previewOpen, setPreviewOpen] = useState(false); const [previewOpen, setPreviewOpen] = useState(false);
const [previewImage, setPreviewImage] = useState(''); const [previewImage, setPreviewImage] = useState('');
const [previewTitle, setPreviewTitle] = useState(''); const [previewTitle, setPreviewTitle] = useState('');
const [isUploading, setIsUploading] = useState(false);
const [uploadedFile, setUploadedFile] = useState(null);
React.useEffect(() => {
if (clearSignal !== null && clearSignal > 0) {
setUploadedFile(null);
}
}, [clearSignal, debugProps]);
const getBase64 = (file) => const getBase64 = (file) =>
new Promise((resolve, reject) => { new Promise((resolve, reject) => {
@@ -49,372 +22,99 @@ const FileUploadHandler = ({
reader.onerror = (error) => reject(error); reader.onerror = (error) => reject(error);
}); });
const handlePreview = async (file) => { const handleUploadPreview = async (file) => {
if (!file.url && !file.preview) { const preview = await getBase64(file);
file.preview = await getBase64(file.originFileObj); setPreviewImage(preview);
}
setPreviewImage(file.url || file.preview);
setPreviewOpen(true);
setPreviewTitle(file.name || file.url.substring(file.url.lastIndexOf('/') + 1)); setPreviewTitle(file.name || file.url.substring(file.url.lastIndexOf('/') + 1));
setPreviewOpen(true);
}; };
const validateFile = (file) => { const handleFileUpload = async (file) => {
const isAllowedType = [ const isAllowedType = ['application/pdf', 'image/jpeg', 'image/png', 'image/gif'].includes(file.type);
'application/pdf',
'image/jpeg',
'image/png',
'image/gif',
].includes(file.type);
if (!isAllowedType) { if (!isAllowedType) {
NotifAlert({ NotifAlert({
icon: 'error', icon: 'error',
title: 'Error', title: 'Error',
message: `${file.name} bukan file PDF atau gambar yang diizinkan.`, message: `${file.name} bukan file PDF atau gambar yang diizinkan.`
}); });
return false; return Upload.LIST_IGNORE;
}
return true;
};
const handleFileUpload = async (file) => {
if (isUploading) {
return false;
}
if (!validateFile(file)) {
return false;
} }
try { try {
setIsUploading(true);
const fileExtension = file.name.split('.').pop().toLowerCase(); const fileExtension = file.name.split('.').pop().toLowerCase();
const isImage = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'].includes(fileExtension); const isImage = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'].includes(fileExtension);
const fileType = isImage ? 'image' : 'pdf'; const fileType = isImage ? 'image' : 'pdf';
const folder = getFolderFromFileType(fileType); const folder = getFolderFromFileType(fileType);
const uploadResponse = await uploadFile(file, folder); const uploadResponse = await uploadFile(file, folder);
const actualPath = uploadResponse.data?.path_solution || '';
const isSuccess = uploadResponse && (
uploadResponse.statusCode === 200 ||
uploadResponse.statusCode === 201
);
if (!isSuccess) {
NotifAlert({
icon: 'error',
title: 'Gagal',
message: uploadResponse?.message || `Gagal mengupload ${file.name}`,
});
setIsUploading(false);
return false;
}
let actualPath = '';
if (uploadResponse && typeof uploadResponse === 'object') {
if (uploadResponse.data && uploadResponse.data.path_document) {
actualPath = uploadResponse.data.path_document;
}
else if (uploadResponse.path_document) {
actualPath = uploadResponse.path_document;
}
else if (uploadResponse.data && uploadResponse.data.path_solution) {
actualPath = uploadResponse.data.path_solution;
}
else if (uploadResponse.data && typeof uploadResponse.data === 'object') {
if (uploadResponse.data.file_url) {
actualPath = uploadResponse.data.file_url;
} else if (uploadResponse.data.url) {
actualPath = uploadResponse.data.url;
} else if (uploadResponse.data.path) {
actualPath = uploadResponse.data.path;
} else if (uploadResponse.data.location) {
actualPath = uploadResponse.data.location;
} else if (uploadResponse.data.filePath) {
actualPath = uploadResponse.data.filePath;
} else if (uploadResponse.data.file_path) {
actualPath = uploadResponse.data.file_path;
} else if (uploadResponse.data.publicUrl) {
actualPath = uploadResponse.data.publicUrl;
} else if (uploadResponse.data.public_url) {
actualPath = uploadResponse.data.public_url;
}
}
else if (uploadResponse && typeof uploadResponse === 'string') {
actualPath = uploadResponse;
}
}
if (actualPath) { if (actualPath) {
let fileObject; file.uploadPath = actualPath;
file.solution_name = file.name;
if (type === 'error_code') { file.solutionId = solutionFields[0];
fileObject = { file.type_solution = fileType;
name: file.name, onFileUpload(file);
path_icon: actualPath,
uploadPath: actualPath,
url: actualPath,
size: file.size,
type: file.type,
fileExtension
};
} else {
fileObject = {
name: file.name,
path_solution: actualPath,
uploadPath: actualPath,
type_solution: fileType,
size: file.size,
type: file.type
};
}
onFileUpload(fileObject);
setUploadedFile(fileObject);
NotifOk({ NotifOk({
icon: 'success', icon: 'success',
title: 'Berhasil', title: 'Berhasil',
message: `${file.name} berhasil diupload!` message: `${file.name} berhasil diupload!`
}); });
setIsUploading(false);
return false;
} else { } else {
NotifAlert({ NotifAlert({
icon: 'error', icon: 'error',
title: 'Gagal', title: 'Gagal',
message: `Gagal mengupload ${file.name}. Tidak dapat menemukan path file dalam response.`, message: `Gagal mengupload ${file.name}`
}); });
setIsUploading(false);
return false;
} }
} catch (error) { } catch (error) {
console.error('Error uploading file:', error);
NotifAlert({ NotifAlert({
icon: 'error', icon: 'error',
title: 'Error', title: 'Error',
message: `Gagal mengupload ${file.name}. Silakan coba lagi.`, message: `Gagal mengupload ${file.name}. Silakan coba lagi.`
}); });
setIsUploading(false);
return false;
}
};
const handleFileChange = ({ fileList }) => {
if (fileList && fileList.length > 0 && fileList[0] && fileList[0].originFileObj) {
handleFileUpload(fileList[0].originFileObj);
}
};
const handleRemove = () => {
if (existingFile && onFileRemove) {
onFileRemove(existingFile);
} else if (onFileRemove) {
onFileRemove(null);
}
};
const renderExistingFile = () => {
const fileToShow = existingFile || uploadedFile;
if (!fileToShow) {
return null;
} }
const filePath = fileToShow.uploadPath || fileToShow.url || fileToShow.path_icon || fileToShow.path_solution; return false;
const fileName = fileToShow.name || filePath?.split('/').pop() || 'Unknown file';
const fileType = getFileType(fileName);
const isImage = fileType === 'image';
const handlePreview = () => {
if (!showPreview || !filePath) return;
if (isImage) {
const folder = fileToShow.type_solution === 'pdf' ? 'pdf' : 'images';
const filename = filePath.split('/').pop();
const imageUrl = getFileUrl(folder, filename);
if (imageUrl) {
setPreviewImage(imageUrl);
setPreviewOpen(true);
setPreviewTitle(fileName);
} else {
NotifAlert({
icon: 'error',
title: 'Error',
message: 'Cannot generate image preview URL',
});
}
} else {
const folder = fileToShow.type_solution === 'pdf' ? 'pdf' : 'images';
const filename = filePath.split('/').pop();
const fileUrl = getFileUrl(folder, filename);
if (fileUrl) {
window.open(fileUrl, '_blank', 'noopener,noreferrer');
} else {
NotifAlert({
icon: 'error',
title: 'Error',
message: 'Cannot generate file preview URL',
});
}
}
};
const getThumbnailUrl = () => {
if (!isImage || !filePath) return null;
const folder = fileToShow.type_solution === 'pdf' ? 'pdf' : 'images';
const filename = filePath.split('/').pop();
return getFileUrl(folder, filename);
};
const thumbnailUrl = getThumbnailUrl();
return (
<div style={{ marginTop: 12 }}>
<div style={{
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '8px',
border: '1px solid #d9d9d9',
borderRadius: 4,
backgroundColor: '#fafafa'
}}>
{isImage ? (
<img
src={thumbnailUrl || filePath}
alt={fileName}
style={{
width: 50,
height: 50,
objectFit: 'cover',
border: '1px solid #d9d9d9',
borderRadius: 4,
cursor: showPreview ? 'pointer' : 'default'
}}
onClick={handlePreview}
onError={(e) => {
e.target.src = filePath;
}}
/>
) : (
<div
style={{
width: 50,
height: 50,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
border: '1px solid #d9d9d9',
borderRadius: 4,
backgroundColor: '#f5f5f5',
cursor: showPreview ? 'pointer' : 'default'
}}
onClick={handlePreview}
>
<FileOutlined style={{ fontSize: 24, color: '#666' }} />
</div>
)}
<div style={{ flex: 1 }}>
<Text style={{ fontSize: 12, fontWeight: 500 }}>
{fileName}
</Text>
<br />
<Text type="secondary" style={{ fontSize: 10 }}>
{fileType === 'image' ? 'Image' : fileType === 'pdf' ? 'PDF' : 'File'}
{fileToShow.size && `${(fileToShow.size / 1024).toFixed(1)} KB`}
</Text>
</div>
<div style={{ display: 'flex', gap: 4 }}>
{showPreview && (
<Button
type="text"
icon={<EyeOutlined />}
size="small"
onClick={handlePreview}
title={isImage ? "Preview Image" : "Open File"}
/>
)}
<Button
type="text"
danger
icon={<DeleteOutlined />}
size="small"
onClick={handleRemove}
title="Remove File"
/>
</div>
</div>
</div>
);
}; };
const uploadProps = { const uploadProps = {
name: 'file', multiple: true,
multiple: false, accept: '.pdf,.jpg,.jpeg,.png,.gif',
accept, onRemove: onFileRemove,
disabled: disabled || isUploading, beforeUpload: handleFileUpload,
fileList: [], fileList,
beforeUpload: () => false, onPreview: handleUploadPreview,
onChange: handleFileChange,
onPreview: handlePreview,
maxCount,
}; };
return ( return (
<div style={{ ...containerStyle }}> <>
{!existingFile && ( <Upload.Dragger {...uploadProps}>
<Upload {...uploadProps}> <p className="ant-upload-drag-icon">
{type === 'drag' ? ( <UploadOutlined />
<Upload.Dragger> </p>
<p className="ant-upload-drag-icon"> <p className="ant-upload-text">Click or drag file to this area to upload</p>
<UploadOutlined /> <p className="ant-upload-hint">Support for PDF and image files only</p>
</p> </Upload.Dragger>
<p className="ant-upload-text">{uploadText}</p>
<p className="ant-upload-hint">{uploadHint}</p>
</Upload.Dragger>
) : (
<Button
type={buttonType}
icon={<UploadOutlined />}
loading={isUploading}
style={{ ...buttonStyle }}
>
{isUploading ? 'Uploading...' : buttonText}
</Button>
)}
</Upload>
)}
<Modal
open={previewOpen}
{showPreview && ( title={previewTitle}
<Modal footer={null}
open={previewOpen} onCancel={() => setPreviewOpen(false)}
title={previewTitle} width="80%"
footer={null} style={{ top: 20 }}
onCancel={() => setPreviewOpen(false)} >
width={600} {previewImage && (
style={{ top: 100 }} <img
> alt={previewTitle}
{previewImage && ( style={{ width: '100%' }}
<img src={previewImage}
alt={previewTitle} />
style={{ width: '100%' }} )}
src={previewImage} </Modal>
/> </>
)}
</Modal>
)}
</div>
); );
}; };

View File

@@ -0,0 +1,70 @@
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

@@ -26,12 +26,26 @@ const columns = (showPreviewModal, showEditModal, showDeleteDialog) => [
key: 'brand_name', key: 'brand_name',
width: '20%', width: '20%',
}, },
{
title: 'Type',
dataIndex: 'brand_type',
key: 'brand_type',
width: '15%',
render: (text) => text || '-',
},
{ {
title: 'Manufacturer', title: 'Manufacturer',
dataIndex: 'brand_manufacture', dataIndex: 'brand_manufacture',
key: 'brand_manufacture', key: 'brand_manufacture',
width: '20%', width: '20%',
}, },
{
title: 'Model',
dataIndex: 'brand_model',
key: 'brand_model',
width: '15%',
render: (text) => text || '-',
},
{ {
title: 'Status', title: 'Status',
dataIndex: 'is_active', dataIndex: 'is_active',
@@ -91,9 +105,9 @@ const columns = (showPreviewModal, showEditModal, showDeleteDialog) => [
const ListBrandDevice = memo(function ListBrandDevice(props) { const ListBrandDevice = memo(function ListBrandDevice(props) {
const [trigerFilter, setTrigerFilter] = useState(false); const [trigerFilter, setTrigerFilter] = useState(false);
const defaultFilter = { criteria: '' }; const defaultFilter = { search: '' };
const [formDataFilter, setFormDataFilter] = useState(defaultFilter); const [formDataFilter, setFormDataFilter] = useState(defaultFilter);
const [searchText, setSearchText] = useState(''); const [searchValue, setSearchValue] = useState('');
const navigate = useNavigate(); const navigate = useNavigate();
@@ -114,21 +128,23 @@ const ListBrandDevice = memo(function ListBrandDevice(props) {
}; };
const handleSearch = () => { const handleSearch = () => {
setFormDataFilter({ criteria: searchText }); setFormDataFilter({ search: searchValue });
setTrigerFilter((prev) => !prev); setTrigerFilter((prev) => !prev);
}; };
const handleSearchClear = () => { const handleSearchClear = () => {
setSearchText(''); setSearchValue('');
setFormDataFilter({ criteria: '' }); setFormDataFilter({ search: '' });
setTrigerFilter((prev) => !prev); setTrigerFilter((prev) => !prev);
}; };
const showPreviewModal = (param) => { const showPreviewModal = (param) => {
// Direct navigation without loading, page will handle its own loading
navigate(`/master/brand-device/view/${param.brand_id}`); navigate(`/master/brand-device/view/${param.brand_id}`);
}; };
const showEditModal = (param = null) => { const showEditModal = (param = null) => {
// Direct navigation without loading, page will handle its own loading
if (param) { if (param) {
navigate(`/master/brand-device/edit/${param.brand_id}`); navigate(`/master/brand-device/edit/${param.brand_id}`);
} else { } else {
@@ -142,7 +158,7 @@ const ListBrandDevice = memo(function ListBrandDevice(props) {
title: 'Konfirmasi', title: 'Konfirmasi',
message: 'Apakah anda yakin hapus data "' + param.brand_name + '" ?', message: 'Apakah anda yakin hapus data "' + param.brand_name + '" ?',
onConfirm: () => handleDelete(param.brand_id, param.brand_name), onConfirm: () => handleDelete(param.brand_id, param.brand_name),
onCancel: () => { }, onCancel: () => {},
}); });
}; };
@@ -156,7 +172,7 @@ const ListBrandDevice = memo(function ListBrandDevice(props) {
title: 'Berhasil', title: 'Berhasil',
message: `Brand ${brand_name} deleted successfully.`, message: `Brand ${brand_name} deleted successfully.`,
}); });
doFilter(); doFilter(); // Refresh data
} else { } else {
NotifAlert({ NotifAlert({
icon: 'error', icon: 'error',
@@ -165,6 +181,7 @@ const ListBrandDevice = memo(function ListBrandDevice(props) {
}); });
} }
} catch (error) { } catch (error) {
console.error('Delete Brand Device Error:', error);
NotifAlert({ NotifAlert({
icon: 'error', icon: 'error',
title: 'Error', title: 'Error',
@@ -182,12 +199,13 @@ const ListBrandDevice = memo(function ListBrandDevice(props) {
<Col xs={24} sm={24} md={12} lg={12}> <Col xs={24} sm={24} md={12} lg={12}>
<Input.Search <Input.Search
placeholder="Search brand device..." placeholder="Search brand device..."
value={searchText} value={searchValue}
onChange={(e) => { onChange={(e) => {
const value = e.target.value; const value = e.target.value;
setSearchText(value); setSearchValue(value);
// Auto search when clearing by backspace/delete
if (value === '') { if (value === '') {
setFormDataFilter({ criteria: '' }); setFormDataFilter({ search: '' });
setTrigerFilter((prev) => !prev); setTrigerFilter((prev) => !prev);
} }
}} }}
@@ -233,7 +251,7 @@ const ListBrandDevice = memo(function ListBrandDevice(props) {
}} }}
size="large" size="large"
> >
Add data Add Brand Device
</Button> </Button>
</ConfigProvider> </ConfigProvider>
</Space> </Space>

View File

@@ -1,315 +1,84 @@
import React, { useState, useEffect, useMemo } from 'react'; import React from 'react';
import { Card, Input, Button, Row, Col, Empty } from 'antd'; import { Table, Button, Space } from 'antd';
import { PlusOutlined, SearchOutlined, DeleteOutlined, LeftOutlined, RightOutlined } from '@ant-design/icons'; import { EyeOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
import { getErrorCodesByBrandId, deleteErrorCode } from '../../../../api/master-brand';
import { NotifAlert, NotifOk, NotifConfirmDialog } from '../../../../components/Global/ToastNotif';
const ListErrorCode = ({ const ErrorCodeTable = ({
brandId, errorCodes,
selectedErrorCode, loading,
onErrorCodeSelect, onPreview,
onAddNew, onEdit,
tempErrorCodes = [], onDelete,
trigerFilter, onFileView
searchText,
onSearchChange,
onSearch,
onSearchClear,
isReadOnly = false,
errorCodes: propErrorCodes = null
}) => { }) => {
const [errorCodes, setErrorCodes] = useState([]); const errorCodeColumns = [
const [loading, setLoading] = useState(false); { title: 'Error Code', dataIndex: 'error_code', key: 'error_code' },
const [pagination, setPagination] = useState({ { title: 'Error Code Name', dataIndex: 'error_code_name', key: 'error_code_name' },
current_page: 1, {
current_limit: 15, title: 'Solutions',
total_limit: 0, dataIndex: 'solution',
total_page: 0, key: 'solution',
}); render: (solutions) => (
const [currentPage, setCurrentPage] = useState(1); <div>
const pageSize = 15; {solutions && solutions.length > 0 ? (
solutions.map((sol, index) => (
<div key={index} style={{ marginBottom: 4 }}>
<span style={{ fontSize: '12px' }}>
{sol.solution_name}
</span>
</div>
))
) : (
<span style={{ color: '#999', fontSize: '12px' }}>No solutions</span>
)}
</div>
)
},
{
title: 'Action',
key: 'action',
render: (_, record) => (
<Space>
<Button
type="text"
icon={<EyeOutlined />}
onClick={() => onPreview(record)}
style={{ color: '#1890ff', borderColor: '#1890ff' }}
/>
<Button
type="text"
icon={<EditOutlined />}
onClick={() => onEdit(record)}
style={{ color: '#faad14', borderColor: '#faad14' }}
/>
<Button
danger
type="text"
icon={<DeleteOutlined />}
onClick={() => onDelete(record.key)}
style={{ borderColor: '#ff4d4f' }}
/>
</Space>
),
},
];
const queryParams = useMemo(() => { const dataSource = loading
const params = new URLSearchParams(); ? Array.from({ length: 3 }, (_, index) => ({
params.set('page', currentPage.toString()); key: `loading-${index}`,
params.set('limit', pageSize.toString()); error_code: 'Loading...',
if (searchText) { error_code_name: 'Loading...',
params.set('criteria', searchText); solution: []
} }))
return params; : errorCodes;
}, [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 ( return (
<Card <Table
title="Daftar Error Code" columns={errorCodeColumns}
style={{ width: '100%', minWidth: '472px' }} dataSource={dataSource}
styles={{ body: { padding: '12px' } }} rowKey="key"
> pagination={false}
<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; export default ErrorCodeTable;

View File

@@ -1,9 +1,8 @@
import React, { useState } from 'react'; import React, { useState, useEffect } from 'react';
import { Form, Input, Button, Switch, Radio, Typography, Space, Card, ConfigProvider } from 'antd'; import { Form, Input, Button, Switch, Radio, Upload, Typography, Space } from 'antd';
import { DeleteOutlined, EyeOutlined, FileOutlined } from '@ant-design/icons'; import { DeleteOutlined, UploadOutlined, EyeOutlined } from '@ant-design/icons';
import FileUploadHandler from './FileUploadHandler'; import { uploadFile, getFolderFromFileType } from '../../../../api/file-uploads';
import { NotifAlert } from '../../../../components/Global/ToastNotif'; import { NotifAlert } from '../../../../components/Global/ToastNotif';
import { getFileUrl, getFolderFromFileType } from '../../../../api/file-uploads';
const { Text } = Typography; const { Text } = Typography;
const { TextArea } = Input; const { TextArea } = Input;
@@ -21,475 +20,223 @@ const SolutionFieldNew = ({
onRemove, onRemove,
onFileUpload, onFileUpload,
onFileView, onFileView,
fileList = [], fileList = []
originalSolutionData = null
}) => { }) => {
const form = Form.useFormInstance(); const [currentStatus, setCurrentStatus] = useState(solutionStatus ?? true);
const [currentFile, setCurrentFile] = useState(null);
const [isDeleted, setIsDeleted] = useState(false);
const fileUpload = Form.useWatch(['solution_items', fieldKey, 'fileUpload'], form); // Watch form values
const file = Form.useWatch(['solution_items', fieldKey, 'file'], form); const getFieldValue = () => {
const nameValue = Form.useWatch(['solution_items', fieldKey, 'name'], form); try {
const fileNameValue = Form.useWatch(['solution_items', fieldKey, 'fileName'], form); const form = document.querySelector(`[data-field="${fieldName}"]`)?.form;
const statusValue = Form.useWatch(['solution_items', fieldKey, 'status'], form) ?? true; if (form) {
const formData = new FormData(form);
const pathSolution = Form.useWatch(['solution_items', fieldKey, 'path_solution'], form); return formData.get(`${fieldName}.status`) === 'on';
}
const [deleteCounter, setDeleteCounter] = useState(0); return currentStatus;
} catch {
React.useEffect(() => { return currentStatus;
if (!nameValue || nameValue === '') {
setCurrentFile(null);
setIsDeleted(false);
setDeleteCounter(prev => prev + 1);
} }
}, [nameValue]); };
React.useEffect(() => { useEffect(() => {
const getFileFromFormValues = () => { setCurrentStatus(solutionStatus ?? true);
const hasValidFileUpload = fileUpload && typeof fileUpload === 'object' && Object.keys(fileUpload).length > 0; }, [solutionStatus]);
const hasValidFile = file && typeof file === 'object' && Object.keys(file).length > 0; const handleFileUpload = async (file) => {
const hasValidPath = pathSolution && pathSolution.trim() !== ''; try {
const isAllowedType = [
'application/pdf',
'image/jpeg',
'image/png',
'image/gif',
].includes(file.type);
const wasExplicitlyDeleted = if (!isAllowedType) {
(fileUpload === null || file === null || pathSolution === null) && NotifAlert({
!hasValidFileUpload && icon: 'error',
!hasValidFile && title: 'Error',
!hasValidPath; message: `${file.name} bukan file PDF atau gambar yang diizinkan.`,
});
if (wasExplicitlyDeleted) { return;
return null;
} }
if (solutionType === 'text') { const fileExtension = file.name.split('.').pop().toLowerCase();
return null; const isImage = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'].includes(fileExtension);
} const fileType = isImage ? 'image' : 'pdf';
const folder = getFolderFromFileType(fileType);
if (hasValidFileUpload) { const uploadResponse = await uploadFile(file, folder);
return fileUpload; const actualPath = uploadResponse.data?.path_solution || '';
}
if (hasValidFile) {
return file;
}
if (hasValidPath) {
return {
name: fileNameValue || pathSolution.split('/').pop() || 'File',
uploadPath: pathSolution,
url: pathSolution,
path: pathSolution
};
}
return null; if (actualPath) {
}; // Store the file info with the solution field
file.uploadPath = actualPath;
const fileFromForm = getFileFromFormValues(); file.solutionId = fieldKey;
file.type_solution = fileType;
if (JSON.stringify(currentFile) !== JSON.stringify(fileFromForm)) { onFileUpload(file);
setCurrentFile(fileFromForm); NotifAlert({
icon: 'success',
title: 'Berhasil',
message: `${file.name} berhasil diupload!`,
});
} else {
NotifAlert({
icon: 'error',
title: 'Gagal',
message: `Gagal mengupload ${file.name}`,
});
}
} catch (error) {
console.error('Error uploading file:', error);
NotifAlert({
icon: 'error',
title: 'Error',
message: `Gagal mengupload ${file.name}. Silakan coba lagi.`,
});
} }
}, [fileUpload, file, pathSolution, solutionType, deleteCounter, fileNameValue, fieldKey]); };
const renderSolutionContent = () => { const renderSolutionContent = () => {
if (solutionType === 'text') { if (solutionType === 'text') {
return ( return (
<Form.Item <Form.Item
name={['solution_items', fieldKey, 'text']} name={[fieldName, 'text']}
rules={[{ required: true, message: 'Text solution wajib diisi!' }]} rules={[{ required: true, message: 'Text solution wajib diisi!' }]}
> >
<TextArea <TextArea
placeholder="Enter solution text" placeholder="Enter solution text"
rows={3} rows={3}
disabled={isReadOnly} disabled={isReadOnly}
style={{ fontSize: 12 }}
/> />
</Form.Item> </Form.Item>
); );
} }
if (solutionType === 'file') { if (solutionType === 'file') {
const hasOriginalFile = originalSolutionData && ( const currentFiles = fileList.filter(file => file.solutionId === fieldKey);
originalSolutionData.path_solution ||
originalSolutionData.path_document
);
let displayFile = null; return (
<div>
if (currentFile && Object.keys(currentFile).length > 0) { <Form.Item
displayFile = currentFile; name={[fieldName, 'file']}
} rules={[{ required: true, message: 'File solution wajib diupload!' }]}
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 }}> <Upload
<div style={{ beforeUpload={handleFileUpload}
display: 'flex', showUploadList={false}
alignItems: 'center', accept=".pdf,.jpg,.jpeg,.png,.gif"
justifyContent: 'center', disabled={isReadOnly}
width: 48, >
height: 48, <Button
borderRadius: 8, icon={<UploadOutlined />}
backgroundColor: '#f0f5ff', disabled={isReadOnly}
flexShrink: 0 style={{ width: '100%' }}
}}> >
<FileOutlined style={{ fontSize: 24, color: '#1890ff' }} /> Upload File (PDF/Image)
</div> </Button>
</Upload>
</Form.Item>
<div style={{ flex: 1, minWidth: 0 }}> {currentFiles.length > 0 && (
<div style={{ <div style={{ marginTop: 8 }}>
fontSize: 13, {currentFiles.map((file, index) => (
fontWeight: 600, <div key={index} style={{
color: '#262626', display: 'flex',
marginBottom: 4, justifyContent: 'space-between',
overflow: 'hidden', alignItems: 'center',
textOverflow: 'ellipsis', padding: '4px 8px',
whiteSpace: 'nowrap' border: '1px solid #d9d9d9',
borderRadius: 4,
marginBottom: 4
}}> }}>
{displayFileName} <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Text style={{ fontSize: 12 }}>{file.name}</Text>
<Text type="secondary" style={{ fontSize: 10 }}>
({(file.size / 1024).toFixed(1)} KB)
</Text>
</div>
<Button
type="text"
size="small"
icon={<EyeOutlined />}
onClick={() => onFileView(file.uploadPath, file.type_solution)}
/>
</div> </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> </div>
</Card> )}
); </div>
} else { );
return (
<FileUploadHandler
type="solution"
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,
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, '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={() => {
form.setFieldValue(['solution_items', fieldKey, 'fileUpload'], null);
form.setFieldValue(['solution_items', fieldKey, 'file'], null);
form.setFieldValue(['solution_items', fieldKey, 'path_solution'], null);
setCurrentFile(null);
if (onFileUpload && typeof onFileUpload === 'function') {
onFileUpload(null);
}
setDeleteCounter(prev => prev + 1);
}}
disabled={isReadOnly}
buttonText="Upload File"
buttonStyle={{ width: '100%', fontSize: 12 }}
uploadText="Upload solution file (includes images, PDF, documents)"
acceptFileTypes="*"
/>
);
}
} }
return null; return null;
}; };
return ( return (
<ConfigProvider <div style={{
theme={{ border: '1px solid #d9d9d9',
components: { borderRadius: 8,
Switch: { padding: 16,
colorPrimary: '#23A55A', marginBottom: 16,
colorPrimaryHover: '#23A55A', backgroundColor: isReadOnly ? '#f5f5f5' : 'white'
}, }}>
}, <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
}} <Text strong>Solution #{index + 1}</Text>
> <Space>
<div style={{ <Form.Item
border: '1px solid #d9d9d9', name={[fieldName, 'name']}
borderRadius: 6, rules={[{ required: true, message: 'Solution name wajib diisi!' }]}
padding: 12, style={{ margin: 0, width: 200 }}
marginBottom: 12, >
backgroundColor: isReadOnly ? '#f5f5f5' : 'white' <Input
}}> placeholder="Solution name"
<div style={{ disabled={isReadOnly}
marginBottom: 8, />
gap: 8 </Form.Item>
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<Text strong style={{
fontSize: 12,
color: '#262626',
display: 'block'
}}>
Solution #{index + 1}
</Text>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}> <Form.Item name={[fieldName, 'status']} valuePropName="checked" noStyle>
<Form.Item name={['solution_items', fieldKey, 'status']} valuePropName="checked" noStyle> <Switch
<Switch disabled={isReadOnly}
size="small" onChange={(checked) => {
disabled={isReadOnly} onStatusChange(fieldKey, checked);
onChange={(checked) => { setCurrentStatus(checked);
onStatusChange(fieldKey, checked); }}
}}
/>
</Form.Item>
<Text style={{
fontSize: 11,
color: '#666',
whiteSpace: 'nowrap'
}}>
{statusValue ? 'Active' : 'Inactive'}
</Text>
</div>
{canRemove && !isReadOnly && (
<Button
type="text"
danger
size="small"
icon={<DeleteOutlined />}
onClick={onRemove}
style={{ style={{
fontSize: 12, backgroundColor: currentStatus ? '#23A55A' : '#bfbfbf'
padding: '2px 4px',
height: '24px'
}} }}
/> />
)} </Form.Item>
<Text style={{ fontSize: 12, color: '#666' }}>
{currentStatus ? 'Active' : 'Inactive'}
</Text>
</div> </div>
</div>
<Form.Item
name={['solution_items', fieldKey, 'name']}
rules={[{ required: true, message: 'Solution name wajib diisi!' }]}
style={{ margin: 0 }}
>
<Input
placeholder="Solution name"
disabled={isReadOnly}
size="default"
style={{ fontSize: 13 }}
/>
</Form.Item>
{canRemove && !isReadOnly && (
<Button
type="text"
danger
icon={<DeleteOutlined />}
onClick={onRemove}
/>
)}
</Space>
</div> </div>
<Form.Item <Form.Item
name={['solution_items', fieldKey, 'type']} name={[fieldName, 'type']}
rules={[{ required: true, message: 'Solution type wajib diisi!' }]} rules={[{ required: true, message: 'Solution type wajib diisi!' }]}
style={{ marginBottom: 8 }}
initialValue={solutionType || 'text'}
> >
<Radio.Group <Radio.Group
onChange={(e) => { onChange={(e) => onTypeChange(fieldKey, e.target.value)}
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} disabled={isReadOnly}
size="small"
> >
<Radio value="text" style={{ fontSize: 12 }}>Text</Radio> <Radio value="text">Text Solution</Radio>
<Radio value="file" style={{ fontSize: 12 }}>File</Radio> <Radio value="file">File Solution</Radio>
</Radio.Group> </Radio.Group>
</Form.Item> </Form.Item>
<Form.Item
name={['solution_items', fieldKey, 'status']}
initialValue={solutionStatus !== false ? true : false}
noStyle
>
<input type="hidden" />
</Form.Item>
{renderSolutionContent()} {renderSolutionContent()}
</div> </div>
</ConfigProvider>
); );
}; };

View File

@@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { Typography, Divider, Button, Form } from 'antd'; import { Form, Card, Typography, Divider, Button } from 'antd';
import { PlusOutlined } from '@ant-design/icons'; import { PlusOutlined } from '@ant-design/icons';
import SolutionFieldNew from './SolutionField'; import SolutionFieldNew from './SolutionField';
@@ -10,64 +10,67 @@ const SolutionForm = ({
solutionFields, solutionFields,
solutionTypes, solutionTypes,
solutionStatuses, solutionStatuses,
fileList,
solutionsToDelete,
firstSolutionValid,
onAddSolutionField, onAddSolutionField,
onRemoveSolutionField, onRemoveSolutionField,
onSolutionTypeChange, onSolutionTypeChange,
onSolutionStatusChange, onSolutionStatusChange,
onSolutionFileUpload, onSolutionFileUpload,
onFileView, onFileView,
fileList,
isReadOnly = false, isReadOnly = false,
solutionData = [], onAddSolution,
}) => { }) => {
return ( return (
<div style={{ marginBottom: 0 }}> <div>
<Form
form={solutionForm}
layout="vertical"
initialValues={{
solution_status_0: true,
solution_type_0: 'text',
}}
>
<Divider orientation="left">Solution Items</Divider>
<Form form={solutionForm} layout="vertical"> {solutionFields.map((field, index) => (
<div style={{ <SolutionFieldNew
maxHeight: '400px', key={field.key}
overflowY: 'auto', fieldKey={field.key}
paddingRight: '8px' fieldName={field.name}
}}> index={index}
{solutionFields.map((field, displayIndex) => ( solutionType={solutionTypes[field.key]}
<SolutionFieldNew solutionStatus={solutionStatuses[field.key]}
key={field} onTypeChange={onSolutionTypeChange}
fieldKey={field} onStatusChange={onSolutionStatusChange}
fieldName={['solution_items', field]} onRemove={() => onRemoveSolutionField(field.key)}
index={displayIndex} onFileUpload={onSolutionFileUpload}
solutionType={solutionTypes[field]} onFileView={onFileView}
solutionStatus={solutionStatuses[field]} fileList={fileList}
onTypeChange={onSolutionTypeChange} isReadOnly={isReadOnly}
onStatusChange={onSolutionStatusChange} canRemove={solutionFields.length > 1}
onRemove={() => onRemoveSolutionField(field)} />
onFileUpload={onSolutionFileUpload} ))}
onFileView={onFileView}
fileList={fileList}
isReadOnly={isReadOnly}
canRemove={solutionFields.length > 1 && displayIndex > 0}
originalSolutionData={solutionData[displayIndex]}
/>
))}
</div>
{!isReadOnly && ( {!isReadOnly && (
<div style={{ marginBottom: 8, marginTop: 12 }}> <>
<Button <Form.Item>
type="dashed" <Button
onClick={onAddSolutionField} type="dashed"
icon={<PlusOutlined />} onClick={onAddSolutionField}
style={{ icon={<PlusOutlined />}
width: '100%', style={{ width: '100%' }}
borderColor: '#23A55A', >
color: '#23A55A', + Add Solution
height: '32px', </Button>
fontSize: '12px' </Form.Item>
}} <div style={{ marginTop: 16 }}>
> <Text type="secondary">
Add sollution * At least one solution is required for each error code.
</Button> </Text>
</div> </div>
</>
)} )}
</Form> </Form>
</div> </div>

View File

@@ -0,0 +1,310 @@
import React, { useState, useEffect } from 'react';
import { Card, Row, Col, Image, Typography, Tag, Space, Spin, Button, Empty } from 'antd';
import { CheckCircleOutlined, CloseCircleOutlined, SearchOutlined } from '@ant-design/icons';
import { getAllSparepart } from '../../../../api/sparepart';
const { Text, Title } = Typography;
const SparepartCardSelect = ({
selectedSparepartIds = [],
onSparepartChange,
isLoading: externalLoading = false,
isReadOnly = false
}) => {
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) {
console.error('Error loading spareparts:', 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 = (sparepartId) => {
if (isReadOnly) return;
const newSelectedIds = selectedSparepartIds.includes(sparepartId)
? 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 span={8} key={sparepart.sparepart_id}>
<Card
size="small"
hoverable
style={{
border: isSelected(sparepart.sparepart_id)
? '2px solid #23A55A'
: '1px solid #d9d9d9',
backgroundColor: isSelected(sparepart.sparepart_id)
? '#f6ffed'
: 'white',
cursor: isReadOnly ? 'default' : 'pointer',
position: 'relative'
}}
onClick={() => handleSparepartToggle(sparepart.sparepart_id)}
>
<div style={{ position: 'absolute', top: 8, right: 8 }}>
{isSelected(sparepart.sparepart_id) ? (
<CheckCircleOutlined
style={{
fontSize: '18px',
color: '#23A55A',
backgroundColor: 'white',
borderRadius: '50%'
}}
/>
) : (
<CloseCircleOutlined
style={{
fontSize: '18px',
color: '#d9d9d9',
backgroundColor: 'white',
borderRadius: '50%'
}}
/>
)}
</div>
<div style={{ textAlign: 'center', marginBottom: 12 }}>
<div style={{
width: '100%',
height: 120,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#f5f5f5',
borderRadius: 8,
overflow: 'hidden'
}}>
{sparepart.sparepart_foto ? (
<Image
src={sparepart.sparepart_foto}
alt={sparepart.sparepart_name}
style={{
maxWidth: '100%',
maxHeight: '100%',
objectFit: 'contain'
}}
preview={false}
fallback="/assets/defaultSparepartImg.jpg"
/>
) : (
<div style={{
color: '#bfbfbf',
fontSize: 12
}}>
No Image
</div>
)}
</div>
</div>
<div>
<Text
strong
style={{
display: 'block',
fontSize: '14px',
marginBottom: 4,
color: isSelected(sparepart.sparepart_id) ? '#23A55A' : 'inherit'
}}
>
{sparepart.sparepart_name}
</Text>
<Text
type="secondary"
style={{
fontSize: '12px',
display: 'block',
marginBottom: 4
}}
>
{sparepart.sparepart_description || 'No description'}
</Text>
<Space size="small" style={{ marginBottom: 4 }}>
<Tag color="blue" style={{ margin: 0 }}>
{sparepart.sparepart_code}
</Tag>
<Tag color="geekblue" style={{ margin: 0 }}>
{sparepart.sparepart_merk || 'N/A'}
</Tag>
</Space>
{sparepart.sparepart_model && (
<div style={{
fontSize: '12px',
color: '#666'
}}>
Model: {sparepart.sparepart_model}
</div>
)}
</div>
</Card>
</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

@@ -0,0 +1,26 @@
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>
<Card size="small" title="Spareparts">
<SparepartCardSelect
selectedSparepartIds={selectedSparepartIds}
onSparepartChange={onSparepartChange}
isReadOnly={isReadOnly}
/>
</Card>
</div>
);
};
export default SparepartForm;

View File

@@ -1,178 +0,0 @@
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

@@ -0,0 +1,265 @@
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

@@ -0,0 +1,166 @@
import { useState } from 'react';
export const useSolutionLogic = (solutionForm) => {
const [solutionFields, setSolutionFields] = useState([
{ name: ['solution_items', 0], key: 0 }
]);
const [solutionTypes, setSolutionTypes] = useState({ 0: 'text' });
const [solutionStatuses, setSolutionStatuses] = useState({ 0: true });
const [solutionsToDelete, setSolutionsToDelete] = useState([]);
const handleAddSolutionField = () => {
const newKey = Date.now(); // Use timestamp for unique key
const newField = { name: ['solution_items', newKey], key: newKey };
setSolutionFields(prev => [...prev, newField]);
setSolutionTypes(prev => ({ ...prev, [newKey]: 'text' }));
setSolutionStatuses(prev => ({ ...prev, [newKey]: true }));
// Set default values for the new field
setTimeout(() => {
solutionForm.setFieldValue(['solution_items', newKey, 'name'], '');
solutionForm.setFieldValue(['solution_items', newKey, 'type'], 'text');
solutionForm.setFieldValue(['solution_items', newKey, 'text'], '');
}, 0);
};
const handleRemoveSolutionField = (key) => {
if (solutionFields.length <= 1) {
return; // Keep at least one solution field
}
setSolutionFields(prev => prev.filter(field => field.key !== key));
// Clean up type and status
const newTypes = { ...solutionTypes };
const newStatuses = { ...solutionStatuses };
delete newTypes[key];
delete newStatuses[key];
setSolutionTypes(newTypes);
setSolutionStatuses(newStatuses);
};
const handleSolutionTypeChange = (key, value) => {
setSolutionTypes(prev => ({ ...prev, [key]: value }));
};
const handleSolutionStatusChange = (key, value) => {
setSolutionStatuses(prev => ({ ...prev, [key]: value }));
};
const resetSolutionFields = () => {
setSolutionFields([{ name: ['solution_items', 0], key: 0 }]);
setSolutionTypes({ 0: 'text' });
setSolutionStatuses({ 0: true });
// Reset form values
solutionForm.resetFields();
solutionForm.setFieldsValue({
solution_status_0: true,
solution_type_0: 'text',
});
};
const checkFirstSolutionValid = () => {
const values = solutionForm.getFieldsValue();
const firstSolution = values.solution_items?.[0];
if (!firstSolution || !firstSolution.name || firstSolution.name.trim() === '') {
return false;
}
if (solutionTypes[0] === 'text' && (!firstSolution.text || firstSolution.text.trim() === '')) {
return false;
}
return true;
};
const getSolutionData = () => {
const values = solutionForm.getFieldsValue();
const result = solutionFields.map(field => {
const key = field.key;
// Access form values using the key from field.name (AntD stores with comma)
const solutionPath = field.name.join(',');
const solution = values[solutionPath];
const validSolution = solution && solution.name && solution.name.trim() !== '';
if (validSolution) {
return {
solution_name: solution.name || 'Default Solution',
type_solution: solutionTypes[key] || 'text',
text_solution: solution.text || '',
path_solution: solution.file || '',
is_active: solution.status !== false, // Use form value directly
};
}
return null;
}).filter(Boolean);
return result;
};
const setSolutionsForExistingRecord = (solutions, form) => {
if (!solutions || solutions.length === 0) return;
const newFields = solutions.map((solution, index) => ({
name: ['solution_items', solution.id || index],
key: solution.id || index
}));
setSolutionFields(newFields);
// Set solution values
const solutionsValues = {};
const newTypes = {};
const newStatuses = {};
solutions.forEach((solution, index) => {
const key = solution.id || index;
solutionsValues[key] = {
name: solution.solution_name || '',
type: solution.type_solution || 'text',
text: solution.text_solution || '',
file: solution.path_solution || '',
};
newTypes[key] = solution.type_solution || 'text';
newStatuses[key] = solution.is_active !== false;
});
// Set all form values at once
const formValues = {};
Object.keys(solutionsValues).forEach(key => {
const solution = solutionsValues[key];
formValues[`solution_items,${key}`] = {
name: solution.name,
type: solution.type,
text: solution.text,
file: solution.file,
status: solution.is_active !== false
};
});
form.setFieldsValue(formValues);
setSolutionTypes(newTypes);
setSolutionStatuses(newStatuses);
};
return {
solutionFields,
solutionTypes,
solutionStatuses,
solutionsToDelete,
firstSolutionValid: checkFirstSolutionValid(),
handleAddSolutionField,
handleRemoveSolutionField,
handleSolutionTypeChange,
handleSolutionStatusChange,
resetSolutionFields,
checkFirstSolutionValid,
getSolutionData,
setSolutionsForExistingRecord,
};
};

View File

@@ -0,0 +1,141 @@
import { useState, useCallback } from 'react';
export const useSparepartLogic = (sparepartForm) => {
const [sparepartFields, setSparepartFields] = useState([]);
const [sparepartTypes, setSparepartTypes] = useState({});
const [sparepartStatuses, setSparepartStatuses] = useState({});
const [sparepartsToDelete, setSparepartsToDelete] = useState(new Set());
const handleAddSparepartField = useCallback(() => {
const newKey = Date.now();
const newField = {
key: newKey,
name: sparepartFields.length,
isCreated: true,
};
setSparepartFields(prev => [...prev, newField]);
setSparepartTypes(prev => ({
...prev,
[newKey]: 'required'
}));
setSparepartStatuses(prev => ({
...prev,
[newKey]: true
}));
}, [sparepartFields.length]);
const handleRemoveSparepartField = useCallback((key) => {
setSparepartFields(prev => prev.filter(field => field.key !== key));
setSparepartTypes(prev => {
const newTypes = { ...prev };
delete newTypes[key];
return newTypes;
});
setSparepartStatuses(prev => {
const newStatuses = { ...prev };
delete newStatuses[key];
return newStatuses;
});
// Add to delete list if it's not a new field
setSparepartsToDelete(prev => new Set([...prev, key]));
}, []);
const handleSparepartTypeChange = useCallback((key, type) => {
setSparepartTypes(prev => ({
...prev,
[key]: type
}));
}, []);
const handleSparepartStatusChange = useCallback((key, status) => {
setSparepartStatuses(prev => ({
...prev,
[key]: status
}));
}, []);
const resetSparepartFields = useCallback(() => {
setSparepartFields([]);
setSparepartTypes({});
setSparepartStatuses({});
setSparepartsToDelete(new Set());
}, []);
const getSparepartData = useCallback(() => {
if (!sparepartForm) return [];
const values = sparepartForm.getFieldsValue();
const data = [];
sparepartFields.forEach((field, index) => {
const fieldData = {
sparepart_id: values[`sparepart_id_${field.name}`],
sparepart_name: values[`sparepart_name_${field.name}`],
sparepart_description: values[`sparepart_description_${field.name}`],
status: values[`sparepart_status_${field.name}`],
type: sparepartTypes[field.key] || 'required',
};
// Only add if required fields are filled
if (fieldData.sparepart_id) {
data.push(fieldData);
}
});
return data;
}, [sparepartForm, sparepartFields, sparepartTypes]);
const setSparepartsForExistingRecord = useCallback((sparepartData, form) => {
resetSparepartFields();
if (!sparepartData || !Array.isArray(sparepartData)) {
return;
}
const newFields = sparepartData.map((sp, index) => ({
key: sp.brand_sparepart_id || sp.sparepart_id || `existing-${index}`,
name: index,
isCreated: false,
}));
setSparepartFields(newFields);
// Set form values for existing spareparts
setTimeout(() => {
const formValues = {};
sparepartData.forEach((sp, index) => {
const sparepartId = sp.brand_sparepart_id || sp.sparepart_id || sp.sparepart_name;
formValues[`sparepart_id_${index}`] = sparepartId;
formValues[`sparepart_status_${index}`] = sp.is_active ?? sp.status ?? true;
formValues[`sparepart_description_${index}`] = sp.brand_sparepart_description || sp.description || sp.sparepart_name;
setSparepartTypes(prev => ({
...prev,
[sp.brand_sparepart_id || sp.sparepart_id || `existing-${index}`]: sp.type || sp.sparepart_type || 'required'
}));
setSparepartStatuses(prev => ({
...prev,
[sp.brand_sparepart_id || sp.sparepart_id || `existing-${index}`]: sp.is_active ?? sp.status ?? true
}));
});
form.setFieldsValue(formValues);
}, 0);
}, [resetSparepartFields]);
return {
sparepartFields,
sparepartTypes,
sparepartStatuses,
sparepartsToDelete,
handleAddSparepartField,
handleRemoveSparepartField,
handleSparepartTypeChange,
handleSparepartStatusChange,
resetSparepartFields,
getSparepartData,
setSparepartsForExistingRecord,
};
};

View File

@@ -23,7 +23,6 @@ const DetailDevice = (props) => {
device_location: '', device_location: '',
device_description: '', device_description: '',
ip_address: '', ip_address: '',
listen_channel: '',
}; };
const [formData, setFormData] = useState(defaultData); const [formData, setFormData] = useState(defaultData);
@@ -60,13 +59,9 @@ const DetailDevice = (props) => {
device_name: formData.device_name, device_name: formData.device_name,
is_active: formData.is_active, is_active: formData.is_active,
device_location: formData.device_location, device_location: formData.device_location,
device_description: device_description: formData.device_description,
formData.device_description && formData.device_description.trim() !== ''
? formData.device_description
: ' ',
ip_address: formData.ip_address, ip_address: formData.ip_address,
brand_id: formData.brand_id, brand_id: formData.brand_id,
listen_channel: formData.listen_channel,
}; };
const response = formData.device_id const response = formData.device_id
@@ -187,6 +182,7 @@ const DetailDevice = (props) => {
defaultBorderColor: '#23A55A', defaultBorderColor: '#23A55A',
defaultHoverColor: '#23A55A', defaultHoverColor: '#23A55A',
defaultHoverBorderColor: '#23A55A', defaultHoverBorderColor: '#23A55A',
defaultHoverColor: '#23A55A',
}, },
}, },
}} }}
@@ -330,16 +326,6 @@ const DetailDevice = (props) => {
readOnly={props.readOnly} readOnly={props.readOnly}
/> />
</div> </div>
<div style={{ marginBottom: 12 }}>
<Text strong>Listen Channel</Text>
<Input
name="listen_channel"
value={formData.listen_channel}
onChange={handleInputChange}
placeholder="Enter Listen Channel"
readOnly={props.readOnly}
/>
</div>
<div style={{ marginBottom: 12 }}> <div style={{ marginBottom: 12 }}>
<Text strong>Device Description</Text> <Text strong>Device Description</Text>
<TextArea <TextArea

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react'; import React, {useEffect, useState } from 'react';
import { Modal, Button, ConfigProvider } from 'antd'; import { Modal, Button, ConfigProvider } from 'antd';
import { jsPDF } from 'jspdf'; import { jsPDF } from 'jspdf';
import logoPiEnergi from '../../../../assets/images/logo/pi-energi.png'; import logoPiEnergi from '../../../../assets/images/logo/pi-energi.png';
@@ -22,12 +22,12 @@ const GeneratePdf = (props) => {
}; };
const generatePdf = async () => { const generatePdf = async () => {
const { images, title } = await kopReportPdf(logoPiEnergi, 'COLD WORK PERMIT'); const {images, title} = await kopReportPdf(logoPiEnergi, 'COLD WORK PERMIT');
const doc = new jsPDF({ const doc = new jsPDF({
orientation: 'portrait', orientation: "portrait",
unit: 'mm', unit: "mm",
format: 'a4', format: "a4"
}); });
const width = 45; const width = 45;
@@ -45,32 +45,32 @@ const GeneratePdf = (props) => {
doc.setFontSize(11); doc.setFontSize(11);
doc.setFont('helvetica', 'normal'); doc.setFont('helvetica', 'normal');
doc.setLineWidth(0.2); doc.setLineWidth(0.2);
doc.line(10, 32, 200, 32); doc.line(10, 32, 200, 32);
doc.setLineWidth(0.6); doc.setLineWidth(0.6);
doc.line(10, 32.8, 200, 32.8); doc.line(10, 32.8, 200, 32.8);
doc.text('Tanggal Pengajuan', 10, 42); doc.text("Tanggal Pengajuan", 10, 42);
doc.text(':', 59, 42); doc.text(":", 59, 42);
doc.text('Deskripsi Pekerjaan', 10, 48); doc.text("Deskripsi Pekerjaan", 10, 48);
doc.text(':', 59, 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("No. Order", 10, 60);
doc.text(':', 59, 54); doc.text(":", 59, 60);
doc.text('Spesifik Lokasi', 120, 54); doc.text("Jum. Personil Terlihat", 120, 60);
doc.text(':', 160, 54); doc.text(":", 160, 60);
doc.text('No. Order', 10, 60); doc.text("Peralatan yang digunakan", 10, 66);
doc.text(':', 59, 60); doc.text(":", 59, 66);
doc.text('Jum. Personil Terlihat', 120, 60);
doc.text(':', 160, 60);
doc.text('Peralatan yang digunakan', 10, 66); doc.text("Jenis APD yang digunakan", 10, 72);
doc.text(':', 59, 66); doc.text(":", 59, 72);
doc.text('Jenis APD yang digunakan', 10, 72);
doc.text(':', 59, 72);
const blob = doc.output('blob'); const blob = doc.output('blob');
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
@@ -84,7 +84,7 @@ const GeneratePdf = (props) => {
return ( return (
<Modal <Modal
width="60%" width='60%'
title="Preview PDF" title="Preview PDF"
open={props.showPdf} open={props.showPdf}
// open={true} // open={true}
@@ -101,6 +101,7 @@ const GeneratePdf = (props) => {
defaultBorderColor: '#23A55A', defaultBorderColor: '#23A55A',
defaultHoverColor: '#23A55A', defaultHoverColor: '#23A55A',
defaultHoverBorderColor: '#23A55A', defaultHoverBorderColor: '#23A55A',
defaultHoverColor: '#23A55A',
}, },
}, },
}} }}

View File

@@ -62,13 +62,6 @@ const columns = (showPreviewModal, showEditModal, showDeleteDialog) => [
key: 'ip_address', key: 'ip_address',
width: '10%', width: '10%',
}, },
{
title: 'Listen Channel',
dataIndex: 'listen_channel',
key: 'listen_channel',
width: '10%',
render: (listen_channel) => listen_channel || '-'
},
{ {
title: 'Status', title: 'Status',
dataIndex: 'is_active', dataIndex: 'is_active',

View File

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

View File

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

View File

@@ -12,7 +12,7 @@ import {
Col, Col,
Image, Image,
} from 'antd'; } from 'antd';
import { PlusOutlined, EyeOutlined, DeleteOutlined } from '@ant-design/icons'; import { PlusOutlined } from '@ant-design/icons';
import { NotifAlert, NotifOk } from '../../../../components/Global/ToastNotif'; import { NotifAlert, NotifOk } from '../../../../components/Global/ToastNotif';
import { createSparepart, updateSparepart } from '../../../../api/sparepart'; import { createSparepart, updateSparepart } from '../../../../api/sparepart';
import { uploadFile } from '../../../../api/file-uploads'; import { uploadFile } from '../../../../api/file-uploads';
@@ -35,18 +35,16 @@ const DetailSparepart = (props) => {
const [previewOpen, setPreviewOpen] = useState(false); const [previewOpen, setPreviewOpen] = useState(false);
const [previewImage, setPreviewImage] = useState(''); const [previewImage, setPreviewImage] = useState('');
const [previewTitle, setPreviewTitle] = useState(''); const [previewTitle, setPreviewTitle] = useState('');
const [isHovering, setIsHovering] = useState(false);
const defaultData = { const defaultData = {
sparepart_id: '', sparepart_id: '',
sparepart_name: '', sparepart_name: '',
sparepart_description: '', sparepart_description: '',
sparepart_model: '', sparepart_model: '',
sparepart_item_type: null, sparepart_item_type: '',
sparepart_qty: 0,
sparepart_unit: '', sparepart_unit: '',
sparepart_merk: '', sparepart_merk: '',
sparepart_stok: 'Not Available', sparepart_stok: '0',
sparepart_foto: '', sparepart_foto: '',
}; };
@@ -71,10 +69,6 @@ const DetailSparepart = (props) => {
const handleChange = ({ fileList: newFileList }) => setFileList(newFileList); const handleChange = ({ fileList: newFileList }) => setFileList(newFileList);
const handleRemove = () => {
setFileList([]);
};
const handleSave = async () => { const handleSave = async () => {
setConfirmLoading(true); setConfirmLoading(true);
@@ -95,11 +89,11 @@ const DetailSparepart = (props) => {
const newFile = fileList.length > 0 ? fileList[0] : null; const newFile = fileList.length > 0 ? fileList[0] : null;
if (newFile && newFile.originFileObj) { if (newFile && newFile.originFileObj) {
// console.log('Uploading file:', newFile.originFileObj); console.log('Uploading file:', newFile.originFileObj);
const uploadResponse = await uploadFile(newFile.originFileObj, 'images'); const uploadResponse = await uploadFile(newFile.originFileObj, 'images');
// Log untuk debugging // Log untuk debugging
// console.log('Upload response:', uploadResponse); console.log('Upload response:', uploadResponse);
// Cek berbagai kemungkinan struktur respons dari API // Cek berbagai kemungkinan struktur respons dari API
let uploadedUrl = null; let uploadedUrl = null;
@@ -169,7 +163,7 @@ const DetailSparepart = (props) => {
} }
if (uploadedUrl) { if (uploadedUrl) {
// console.log('Successfully extracted image URL:', uploadedUrl); console.log('Successfully extracted image URL:', uploadedUrl);
imageUrl = uploadedUrl; imageUrl = uploadedUrl;
} else { } else {
console.error('Upload response structure:', uploadResponse); console.error('Upload response structure:', uploadResponse);
@@ -209,10 +203,10 @@ const DetailSparepart = (props) => {
sparepart_name: formData.sparepart_name, // Wajib sparepart_name: formData.sparepart_name, // Wajib
}; };
payload.sparepart_description = // Tambahkan field-field secara kondisional hanya jika nilainya tidak kosong
formData.sparepart_description && formData.sparepart_description.trim() !== '' if (formData.sparepart_description && formData.sparepart_description.trim() !== '') {
? formData.sparepart_description payload.sparepart_description = formData.sparepart_description;
: ' '; }
if (formData.sparepart_model && formData.sparepart_model.trim() !== '') { if (formData.sparepart_model && formData.sparepart_model.trim() !== '') {
payload.sparepart_model = formData.sparepart_model; payload.sparepart_model = formData.sparepart_model;
} }
@@ -225,24 +219,23 @@ const DetailSparepart = (props) => {
if (formData.sparepart_merk && formData.sparepart_merk.trim() !== '') { if (formData.sparepart_merk && formData.sparepart_merk.trim() !== '') {
payload.sparepart_merk = formData.sparepart_merk; payload.sparepart_merk = formData.sparepart_merk;
} }
// sparepart_qty disimpan sebagai angka kuantitas if (formData.sparepart_stok && formData.sparepart_stok.trim() !== '') {
const qty = parseInt(formData.sparepart_qty) || 0; payload.sparepart_stok = formData.sparepart_stok.toString();
payload.sparepart_qty = qty; } else {
payload.sparepart_stok = '0'; // Set default value jika tidak diisi
// sparepart_stok ditentukan otomatis berdasarkan qty sebenarnya }
payload.sparepart_stok = qty > 0 ? 'Available' : 'Not Available';
// Sertakan sparepart_foto hanya jika nilainya tidak kosong, agar tidak memicu validasi // Sertakan sparepart_foto hanya jika nilainya tidak kosong, agar tidak memicu validasi
if (imageUrl && imageUrl.trim() !== '') { if (imageUrl && imageUrl.trim() !== '') {
payload.sparepart_foto = imageUrl; payload.sparepart_foto = imageUrl;
} }
// console.log('Sending payload:', payload); console.log('Sending payload:', payload);
const response = formData.sparepart_id const response = formData.sparepart_id
? await updateSparepart(formData.sparepart_id, payload) ? await updateSparepart(formData.sparepart_id, payload)
: await createSparepart(payload); : await createSparepart(payload);
// console.log('API response:', response); console.log('API response:', response);
if (response && (response.statusCode === 200 || response.statusCode === 201)) { if (response && (response.statusCode === 200 || response.statusCode === 201)) {
NotifOk({ NotifOk({
@@ -286,33 +279,18 @@ const DetailSparepart = (props) => {
if (props.selectedData) { if (props.selectedData) {
setFormData(props.selectedData); setFormData(props.selectedData);
if (props.selectedData.sparepart_foto) { if (props.selectedData.sparepart_foto) {
let displayUrl = props.selectedData.sparepart_foto; // Buat URL lengkap dengan token untuk file yang sudah ada
// Jika URL bukan full URL (tidak mengandung http/https), bangun URL lokal
if (!props.selectedData.sparepart_foto.startsWith('http')) {
const fileName = props.selectedData.sparepart_foto.split('/').pop();
// Cek apakah ini file default
if (fileName === 'defaultSparepartImg.jpg') {
displayUrl = '/assets/defaultSparepartImg.jpg';
} else {
// Gunakan format file URL seperti di brandDevice
const token = localStorage.getItem('token');
const baseURL = import.meta.env.VITE_API_SERVER || '';
displayUrl = `${baseURL}/file-uploads/images/${encodeURIComponent(
fileName
)}${token ? `?token=${encodeURIComponent(token)}` : ''}`;
}
}
const fileName = props.selectedData.sparepart_foto.split('/').pop(); const fileName = props.selectedData.sparepart_foto.split('/').pop();
const token = localStorage.getItem('token');
const baseURL = import.meta.env.VITE_API_SERVER || '';
const fullUrl = `${baseURL}/file-uploads/images/${encodeURIComponent(fileName)}${token ? `?token=${encodeURIComponent(token)}` : ''}`;
setFileList([ setFileList([
{ {
uid: '-1', uid: '-1',
name: fileName, name: fileName,
status: 'done', status: 'done',
url: displayUrl, url: fullUrl,
}, },
]); ]);
} else { } else {
@@ -386,159 +364,85 @@ const DetailSparepart = (props) => {
{formData && ( {formData && (
<div> <div>
<Row gutter={[16, 16]}> <Row gutter={[16, 16]}>
{/* Kolom untuk foto */} <Col span={12}>
<Col span={10} style={{ display: 'flex', flexDirection: 'column' }}> <Text strong>Sparepart Name</Text>
<Text strong>Foto</Text> <Text style={{ color: 'red' }}> *</Text>
<div <Input
style={{ name="sparepart_name"
flexGrow: 1, value={formData.sparepart_name}
display: 'flex', onChange={handleInputChange}
alignItems: 'center', placeholder="Enter Sparepart Name"
justifyContent: 'center', readOnly={props.readOnly}
width: '100%', />
}}
>
{fileList.length > 0 ? (
<div
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
style={{
position: 'relative',
width: '180px', // Fixed width for square
height: '180px', // Fixed height
border: '1px solid #d9d9d9',
borderRadius: '8px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Image
src={fileList[0].url || fileList[0].thumbUrl}
alt="preview"
style={{
maxWidth: '100%',
maxHeight: '100%',
objectFit: 'contain',
}}
preview={false} // Disable default preview
/>
{isHovering && !props.readOnly && (
<div
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0, 0, 0, 0.5)',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
color: 'white',
gap: '16px',
fontSize: '20px',
borderRadius: '8px',
cursor: 'pointer',
}}
>
<EyeOutlined
onClick={() => handlePreview(fileList[0])}
/>
<DeleteOutlined onClick={handleRemove} />
</div>
)}
</div>
) : (
<Upload
name="file"
multiple={false}
fileList={fileList}
onChange={handleChange}
beforeUpload={() => false}
maxCount={1}
disabled={props.readOnly}
showUploadList={false}
>
<div
style={{
width: '180px', // Fixed width for square
height: '180px',
border: '1px dashed #d9d9d9',
borderRadius: '8px',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
gap: '8px',
}}
>
<PlusOutlined />
<div>Upload</div>
</div>
</Upload>
)}
</div>
</Col> </Col>
<Col span={12}>
{/* Kolom untuk field lainnya */} <Text strong>Item Type</Text>
<Col span={14}> <Select
<Row gutter={[16, 16]}> name="sparepart_item_type"
<Col span={24}> value={formData.sparepart_item_type}
<Text strong>Sparepart Name</Text> onChange={(value) =>
<Text style={{ color: 'red' }}> *</Text> handleSelectChange('sparepart_item_type', value)
<Input }
name="sparepart_name" placeholder="Select Item Type"
value={formData.sparepart_name} disabled={props.readOnly}
onChange={handleInputChange} style={{ width: '100%' }}
placeholder="Enter Sparepart Name" >
readOnly={props.readOnly} <Select.Option value="Air Dryer">Air Dryer</Select.Option>
/> <Select.Option value="Compressor">Compressor</Select.Option>
</Col> </Select>
<Col span={24}>
<Text strong>Item Type</Text>
<Select
name="sparepart_item_type"
value={formData.sparepart_item_type}
onChange={(value) =>
handleSelectChange('sparepart_item_type', value)
}
placeholder="Enter Item Type"
disabled={props.readOnly}
style={{ width: '100%' }}
>
<Select.Option value="Air Dryer">Air Dryer</Select.Option>
<Select.Option value="Compressor">Compressor</Select.Option>
</Select>
</Col>
<Col span={12}>
<Text strong>Qty</Text>
<Input
name="sparepart_qty"
value={formData.sparepart_qty}
onChange={handleInputChange}
placeholder="Enter quantity"
readOnly={props.readOnly}
type="number"
min="0"
/>
</Col>
<Col span={12}>
<Text strong>Unit</Text>
<Input
name="sparepart_unit"
value={formData.sparepart_unit}
onChange={handleInputChange}
placeholder="e.g., pcs"
readOnly={props.readOnly}
/>
</Col>
</Row>
</Col> </Col>
</Row> </Row>
<Row gutter={[16, 16]} style={{ marginTop: 16 }}> <Row gutter={[16, 16]}>
<Col span={12}>
<Text strong>Stock</Text>
<Input
name="sparepart_stok"
value={formData.sparepart_stok}
onChange={handleInputChange}
placeholder="Initial stock quantity"
readOnly={props.readOnly}
type="number"
/>
</Col>
<Col span={12}>
<Text strong>Unit</Text>
<Input
name="sparepart_unit"
value={formData.sparepart_unit}
onChange={handleInputChange}
placeholder="e.g., pcs, box, roll"
readOnly={props.readOnly}
/>
</Col>
</Row>
<Row gutter={[16, 16]}>
<Col span={24}>
<Text strong>Foto</Text>
<Upload
listType="picture-card"
fileList={fileList}
onPreview={handlePreview}
onChange={handleChange}
beforeUpload={() => false}
maxCount={1}
disabled={props.readOnly}
>
{fileList.length >= 1 ? null : uploadButton}
</Upload>
<Modal
open={previewOpen}
title={previewTitle}
footer={null}
onCancel={handlePreviewCancel}
>
<img alt="preview" style={{ width: '100%' }} src={previewImage} />
</Modal>
</Col>
</Row>
<Row gutter={[16, 16]}>
<Col span={12}> <Col span={12}>
<Text strong>Brand</Text> <Text strong>Brand</Text>
<Input <Input
@@ -561,7 +465,7 @@ const DetailSparepart = (props) => {
</Col> </Col>
</Row> </Row>
<Row gutter={[16, 16]} style={{ marginTop: 16 }}> <Row gutter={[16, 16]}>
<Col span={24}> <Col span={24}>
<Text strong>Description</Text> <Text strong>Description</Text>
<TextArea <TextArea
@@ -576,14 +480,6 @@ const DetailSparepart = (props) => {
</Row> </Row>
</div> </div>
)} )}
<Modal
open={previewOpen}
title={previewTitle}
footer={null}
onCancel={handlePreviewCancel}
>
<img alt="preview" style={{ width: '100%' }} src={previewImage} />
</Modal>
</Modal> </Modal>
); );
}; };

View File

@@ -72,18 +72,11 @@ const columns = (showPreviewModal, showEditModal, showDeleteDialog) => [
render: (sparepart_merk) => sparepart_merk || '-' render: (sparepart_merk) => sparepart_merk || '-'
}, },
{ {
title: 'Qty', title: 'Stock',
dataIndex: 'sparepart_qty',
key: 'sparepart_qty',
width: '8%',
render: (sparepart_qty) => sparepart_qty || '0'
},
{
title: 'Status',
dataIndex: 'sparepart_stok', dataIndex: 'sparepart_stok',
key: 'sparepart_stok', key: 'sparepart_stok',
width: '8%', width: '8%',
render: (sparepart_stok) => sparepart_stok || 'Not Available' render: (sparepart_stok) => sparepart_stok || '0'
}, },
{ {
title: 'Action', title: 'Action',

View File

@@ -21,15 +21,8 @@ const SparepartCardList = ({
const [loadingQuantities, setLoadingQuantities] = useState({}); const [loadingQuantities, setLoadingQuantities] = useState({});
const handleQuantityChange = (id, value) => { const handleQuantityChange = (id, value) => {
// Prevent the adjustment from going below the negative value of the original quantity
// This ensures the final quantity (original + adjustment) never goes below 0
const originalQty = data.find((item) => item.sparepart_id === id)?.sparepart_qty || 0;
const maxNegativeAdjustment = -originalQty;
const clampedValue = Math.max(value, maxNegativeAdjustment);
const newQuantities = { ...updateQuantities }; const newQuantities = { ...updateQuantities };
newQuantities[id] = clampedValue; newQuantities[id] = value;
setUpdateQuantities(newQuantities); setUpdateQuantities(newQuantities);
}; };
@@ -44,19 +37,16 @@ const SparepartCardList = ({
return; return;
} }
const currentQty = Number(item.sparepart_qty) || 0; const newStock = Number(item.sparepart_stok) + quantityToAdd;
const newQty = currentQty + quantityToAdd; if (newStock < 0) {
if (newQty < 0) { NotifAlert({ icon: 'error', title: 'Error', message: 'Stock cannot be negative.' });
NotifAlert({ icon: 'error', title: 'Error', message: 'Quantity cannot be negative.' });
return; return;
} }
setLoadingQuantities((prev) => ({ ...prev, [item.sparepart_id]: true })); setLoadingQuantities((prev) => ({ ...prev, [item.sparepart_id]: true }));
// sparepart_qty disimpan sebagai angka kuantitas (update boleh 0 sesuai validasi update schema)
const payload = { const payload = {
sparepart_qty: newQty, sparepart_stok: newStock.toString(), // Convert number to string as required by API
sparepart_stok: newQty > 0 ? 'Available' : 'Not Available', // Otomatis tentukan status
}; };
// Hanya tambahkan field jika nilainya tidak kosong untuk menghindari validasi error // Hanya tambahkan field jika nilainya tidak kosong untuk menghindari validasi error
@@ -72,12 +62,6 @@ const SparepartCardList = ({
if (item.sparepart_description && item.sparepart_description.trim() !== '') { if (item.sparepart_description && item.sparepart_description.trim() !== '') {
payload.sparepart_description = item.sparepart_description; payload.sparepart_description = item.sparepart_description;
} }
if (item.sparepart_item_type && item.sparepart_item_type !== null) {
payload.sparepart_item_type = item.sparepart_item_type;
}
if (item.sparepart_foto && item.sparepart_foto.trim() !== '') {
payload.sparepart_foto = item.sparepart_foto;
}
try { try {
const response = await updateSparepart(item.sparepart_id, payload); const response = await updateSparepart(item.sparepart_id, payload);
@@ -89,16 +73,6 @@ const SparepartCardList = ({
title: 'Success', title: 'Success',
message: 'Stock updated successfully.', message: 'Stock updated successfully.',
}); });
// Cek apakah qty baru kurang dari 1, tampilkan alert
if (newQty < 1) {
NotifAlert({
icon: 'warning',
title: 'Low Stock',
message: `Warning: Sparepart "${item.sparepart_name}" is out of stock. Please restock immediately.`,
});
}
if (onStockUpdate) { if (onStockUpdate) {
onStockUpdate(); onStockUpdate();
} }
@@ -165,8 +139,7 @@ const SparepartCardList = ({
style={{ style={{
backgroundColor: '#f0f0f0', backgroundColor: '#f0f0f0',
width: '100%', width: '100%',
paddingTop: paddingTop: '100%', /* Ini membuat tinggi sama dengan lebar (aspect ratio 1:1) */
'100%' /* Ini membuat tinggi sama dengan lebar (aspect ratio 1:1) */,
position: 'relative', position: 'relative',
borderRadius: '4px', borderRadius: '4px',
overflow: 'hidden', overflow: 'hidden',
@@ -180,50 +153,30 @@ const SparepartCardList = ({
imgSrc = item.sparepart_foto; imgSrc = item.sparepart_foto;
} else { } else {
// Gunakan format file URL seperti di brandDevice // Gunakan format file URL seperti di brandDevice
const fileName = item.sparepart_foto const fileName = item.sparepart_foto.split('/').pop();
.split('/')
.pop();
// Jika filename adalah default file, gunakan dari public assets // Jika filename adalah default file, gunakan dari public assets
if ( if (fileName === 'defaultSparepartImg.jpg') {
fileName === 'defaultSparepartImg.jpg'
) {
imgSrc = `/assets/defaultSparepartImg.jpg`; imgSrc = `/assets/defaultSparepartImg.jpg`;
} else { } else {
// Gunakan API getFileUrl untuk mendapatkan URL yang benar untuk file upload // Gunakan API getFileUrl untuk mendapatkan URL yang benar untuk file upload
const token = const token = localStorage.getItem('token');
localStorage.getItem('token'); const baseURL = import.meta.env.VITE_API_SERVER || '';
const baseURL = imgSrc = `${baseURL}/file-uploads/images/${encodeURIComponent(fileName)}${token ? `?token=${encodeURIComponent(token)}` : ''}`;
import.meta.env.VITE_API_SERVER ||
'';
imgSrc = `${baseURL}/file-uploads/images/${encodeURIComponent(
fileName
)}${
token
? `?token=${encodeURIComponent(
token
)}`
: ''
}`;
} }
} }
console.log( console.log('Image path being constructed:', imgSrc);
'Image path being constructed:',
imgSrc
);
} else { } else {
imgSrc = 'https://via.placeholder.com/150'; imgSrc = 'https://via.placeholder.com/150';
} }
return ( return (
<div <div style={{
style={{ position: 'absolute',
position: 'absolute', top: 0,
top: 0, left: 0,
left: 0, width: '100%',
width: '100%', height: '100%',
height: '100%', }}>
}}
>
<img <img
src={imgSrc} src={imgSrc}
alt={item[header]} alt={item[header]}
@@ -233,19 +186,10 @@ const SparepartCardList = ({
objectFit: 'cover', // Mengisi container dan crop sisi berlebih objectFit: 'cover', // Mengisi container dan crop sisi berlebih
}} }}
onError={(e) => { onError={(e) => {
console.error( console.error('Image failed to load:', imgSrc);
'Image failed to load:', e.target.src = 'https://via.placeholder.com/150';
imgSrc
);
e.target.src =
'https://via.placeholder.com/150';
}} }}
onLoad={() => onLoad={() => console.log('Image loaded successfully:', imgSrc)}
console.log(
'Image loaded successfully:',
imgSrc
)
}
/> />
</div> </div>
); );
@@ -305,8 +249,8 @@ const SparepartCardList = ({
> >
{item[header]} {item[header]}
</Title> </Title>
<Text type="secondary" style={{ display: 'block' }}> <Text type="secondary">
Stok: {item.sparepart_stok || 'Not Available'} Available Stock: {item.sparepart_stok || '0'}
</Text> </Text>
<Divider style={{ margin: '8px 0' }} /> <Divider style={{ margin: '8px 0' }} />
@@ -315,9 +259,9 @@ const SparepartCardList = ({
style={{ style={{
marginBottom: '8px', marginBottom: '8px',
display: 'flex', display: 'flex',
justifyContent: 'center',
}} }}
> >
<Text type="secondary">Qty</Text>
<Button <Button
icon={<MinusOutlined />} icon={<MinusOutlined />}
onClick={() => onClick={() =>
@@ -326,16 +270,14 @@ const SparepartCardList = ({
quantity - 1 quantity - 1
) )
} }
disabled={ disabled={isLoading}
isLoading || item.sparepart_qty + quantity <= 0
}
style={{ width: 28, height: 28 }} style={{ width: 28, height: 28 }}
/> />
<Text <Text
strong strong
style={{ padding: '0 8px', fontSize: '16px' }} style={{ padding: '0 8px', fontSize: '16px' }}
> >
{item.sparepart_qty + (quantity || 0)} {quantity}
</Text> </Text>
<Button <Button
icon={<PlusOutlined />} icon={<PlusOutlined />}
@@ -355,17 +297,15 @@ const SparepartCardList = ({
</Text> </Text>
</Space> </Space>
{quantity !== 0 && ( <Button
<Button type={quantity === 0 ? 'default' : 'primary'}
type={quantity === 0 ? 'default' : 'primary'} size="small"
size="small" style={{ width: '100%' }}
style={{ width: '100%' }} onClick={() => handleUpdateStock(item)}
onClick={() => handleUpdateStock(item)} loading={isLoading}
loading={isLoading} >
> Update Stock
Update Stock </Button>
</Button>
)}
<br /> <br />
<Text <Text

View File

@@ -81,7 +81,7 @@ const DetailStatus = (props) => {
status_number: formData.status_number, status_number: formData.status_number,
status_name: formData.status_name, status_name: formData.status_name,
status_color: formData.status_color, status_color: formData.status_color,
status_description: (formData.status_description && formData.status_description.trim() !== '') ? formData.status_description : ' ', status_description: formData.status_description,
is_active: formData.is_active, is_active: formData.is_active,
}; };

View File

@@ -168,7 +168,10 @@ const DetailTag = (props) => {
payload.unit = formData.unit.trim(); payload.unit = formData.unit.trim();
} }
payload.tag_description = (formData.tag_description && formData.tag_description.trim() !== '') ? formData.tag_description.trim() : ' '; // Add tag_description only if it has a value
if (formData.tag_description && formData.tag_description.trim() !== '') {
payload.tag_description = formData.tag_description.trim();
}
// Add device_id only if it has a value // Add device_id only if it has a value
if (formData.device_id) { if (formData.device_id) {

View File

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

View File

@@ -1,7 +1,7 @@
import React, { memo, useState, useEffect } from 'react'; import React, { memo, useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useBreadcrumb } from '../../layout/LayoutBreadcrumb'; import { useBreadcrumb } from '../../layout/LayoutBreadcrumb';
import { Typography, Row, Col } from 'antd'; import { Typography } from 'antd';
import ListNotification from './component/ListNotification'; import ListNotification from './component/ListNotification';
import DetailNotification from './component/DetailNotification'; import DetailNotification from './component/DetailNotification';
@@ -10,7 +10,10 @@ const { Text } = Typography;
const IndexNotification = memo(function IndexNotification() { const IndexNotification = memo(function IndexNotification() {
const navigate = useNavigate(); const navigate = useNavigate();
const { setBreadcrumbItems } = useBreadcrumb(); const { setBreadcrumbItems } = useBreadcrumb();
const [actionMode, setActionMode] = useState('list');
const [selectedData, setSelectedData] = useState(null); const [selectedData, setSelectedData] = useState(null);
const [isModalVisible, setIsModalVisible] = useState(false);
useEffect(() => { useEffect(() => {
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
@@ -29,34 +32,33 @@ const IndexNotification = memo(function IndexNotification() {
} }
}, [navigate, setBreadcrumbItems]); }, [navigate, setBreadcrumbItems]);
const handleCloseDetail = () => { useEffect(() => {
if (actionMode === 'preview') {
setIsModalVisible(true);
} else {
setIsModalVisible(false);
}
}, [actionMode]);
const handleCancel = () => {
setActionMode('list');
setSelectedData(null); setSelectedData(null);
}; };
// This handler will be passed to ListNotification to update the selected item
const handleSelectNotification = (data) => {
setSelectedData(data);
};
return ( return (
<Row gutter={16}> <React.Fragment>
<Col span={selectedData ? 16 : 24}> <ListNotification
<ListNotification actionMode={actionMode}
// The setActionMode is likely not needed anymore, setActionMode={setActionMode}
// but we pass the selection handler selectedData={selectedData}
setActionMode={() => {}} // Keep prop for safety, but can be empty setSelectedData={setSelectedData}
setSelectedData={handleSelectNotification} />
/> <DetailNotification
</Col> visible={isModalVisible}
{selectedData && ( onCancel={handleCancel}
<Col span={8}> selectedData={selectedData}
<DetailNotification />
selectedData={selectedData} </React.Fragment>
onClose={handleCloseDetail}
/>
</Col>
)}
</Row>
); );
}); });

View File

@@ -1,30 +1,8 @@
import React, { memo } from 'react'; import React, { memo } from 'react';
import { Row, Col, Tag, Card, Button } from 'antd'; import { Modal, Row, Col, Tag, Divider } from 'antd';
import { import { CloseCircleFilled, WarningFilled, CheckCircleFilled, InfoCircleFilled } from '@ant-design/icons';
CloseCircleFilled,
WarningFilled,
CheckCircleFilled,
InfoCircleFilled,
} from '@ant-design/icons';
const DetailNotification = memo(function DetailNotification({ selectedData, onClose }) {
if (!selectedData) {
return null;
}
// Get error code data from the nested structure
const errorCodeData = selectedData.error_code;
// 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 = () => {
if (selectedData.is_read === false) return 'critical'; // Not read yet
if (selectedData.is_delivered === false) return 'warning'; // Not delivered
return 'resolved'; // Read and delivered
};
const DetailNotification = memo(function DetailNotification({ visible, onCancel, form, selectedData }) {
const getIconAndColor = (type) => { const getIconAndColor = (type) => {
switch (type) { switch (type) {
case 'critical': case 'critical':
@@ -58,194 +36,133 @@ const DetailNotification = memo(function DetailNotification({ selectedData, onCl
} }
}; };
const notificationType = getTypeFromStatus(); const { IconComponent, color, bgColor, tagColor } = selectedData ? getIconAndColor(selectedData.type) : {};
const { IconComponent, color, bgColor, tagColor } = getIconAndColor(notificationType);
return ( return (
<Card <Modal
title="Detail Notifikasi" title="Detail Notifikasi"
extra={<Button onClick={onClose}>Tutup</Button>} open={visible}
style={{ height: '100%' }} onCancel={onCancel}
bodyStyle={{ padding: '0 24px' }} onOk={onCancel}
okText="Tutup"
cancelButtonProps={{ style: { display: 'none' } }}
width={700}
> >
<div> {selectedData && (
{/* Header with Icon and Status */} <div>
<div {/* Header with Icon and Status */}
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
marginBottom: '0',
padding: '2px 0',
backgroundColor: '#fafafa',
borderRadius: '8px',
}}
>
<div <div
style={{ style={{
width: '32px',
height: '32px',
borderRadius: '50%',
backgroundColor: bgColor,
color: color,
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', gap: '16px',
fontSize: '18px', marginBottom: '24px',
flexShrink: 0, padding: '16px',
backgroundColor: '#fafafa',
borderRadius: '8px',
}} }}
> >
{IconComponent && <IconComponent style={{ fontSize: '18px' }} />} <div
style={{
width: '64px',
height: '64px',
borderRadius: '50%',
backgroundColor: bgColor,
color: color,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '32px',
flexShrink: 0,
}}
>
{IconComponent && <IconComponent style={{ fontSize: '32px' }} />}
</div>
<div style={{ flex: 1 }}>
<Tag color={tagColor} style={{ marginBottom: '8px', fontSize: '12px' }}>
{selectedData.type.toUpperCase()}
</Tag>
<div style={{ fontSize: '16px', fontWeight: 600, color: '#262626' }}>
{selectedData.title}
</div>
</div>
</div> </div>
<div style={{ flex: 1 }}>
<Tag color={tagColor} style={{ marginBottom: '2px', fontSize: '11px' }}> <Divider style={{ margin: '16px 0' }} />
{notificationType.toUpperCase()}
{/* Information Grid */}
<Row gutter={[16, 16]}>
<Col span={12}>
<div style={{ marginBottom: '16px' }}>
<div style={{ fontSize: '12px', color: '#8c8c8c', marginBottom: '4px' }}>
PLC
</div>
<div style={{ fontSize: '14px', color: '#262626', fontWeight: 500 }}>
{selectedData.plc}
</div>
</div>
</Col>
<Col span={12}>
<div style={{ marginBottom: '16px' }}>
<div style={{ fontSize: '12px', color: '#8c8c8c', marginBottom: '4px' }}>Tag</div>
<div style={{ fontSize: '14px', color: '#262626', fontWeight: 500 }}>
{selectedData.tag}
</div>
</div>
</Col>
</Row>
<Row gutter={[16, 16]}>
<Col span={12}>
<div style={{ marginBottom: '16px' }}>
<div style={{ fontSize: '12px', color: '#8c8c8c', marginBottom: '4px' }}>
Engineer
</div>
<div style={{ fontSize: '14px', color: '#262626', fontWeight: 500 }}>
{selectedData.engineer}
</div>
</div>
</Col>
<Col span={12}>
<div style={{ marginBottom: '16px' }}>
<div style={{ fontSize: '12px', color: '#8c8c8c', marginBottom: '4px' }}>
Waktu
</div>
<div style={{ fontSize: '14px', color: '#262626', fontWeight: 500 }}>
{selectedData.time}
</div>
</div>
</Col>
</Row>
<Divider style={{ margin: '16px 0' }} />
{/* Status */}
<div style={{ marginBottom: '16px' }}>
<div style={{ fontSize: '12px', color: '#8c8c8c', marginBottom: '8px' }}>Status</div>
<Tag color={selectedData.isRead ? 'default' : 'blue'}>
{selectedData.isRead ? 'Sudah Dibaca' : 'Belum Dibaca'}
</Tag> </Tag>
<div style={{ fontSize: '14px', fontWeight: 600, color: '#262626' }}>
{errorCodeData?.error_code_name || 'N/A'}
</div>
</div> </div>
</div>
{/* Information Grid */} {/* Additional Info */}
<Row gutter={[16, 0]}>
<Col span={12}>
<div style={{ marginBottom: '2px' }}>
<div style={{ fontSize: '11px', color: '#8c8c8c', marginBottom: '0' }}>
Kode Error
</div>
<div style={{ fontSize: '13px', color: '#262626', fontWeight: 500 }}>
{errorCodeData?.error_code || 'N/A'}
</div>
</div>
</Col>
<Col span={12}>
<div style={{ marginBottom: '2px' }}>
<div style={{ fontSize: '11px', color: '#8c8c8c', marginBottom: '0' }}>
ID Notifikasi
</div>
<div style={{ fontSize: '13px', color: '#262626', fontWeight: 500 }}>
{selectedData.notification_error_id || 'N/A'}
</div>
</div>
</Col>
</Row>
<Row gutter={[16, 0]}>
<Col span={12}>
<div style={{ marginBottom: '2px' }}>
<div style={{ fontSize: '11px', color: '#8c8c8c', marginBottom: '0' }}>
Solusi
</div>
<div style={{ fontSize: '13px', color: '#262626', fontWeight: 500 }}>
{activeSolution?.solution_name || 'N/A'}
</div>
</div>
</Col>
<Col span={12}>
<div style={{ marginBottom: '2px' }}>
<div style={{ fontSize: '11px', color: '#8c8c8c', marginBottom: '0' }}>
Waktu Dibuat
</div>
<div style={{ fontSize: '13px', color: '#262626', fontWeight: 500 }}>
{selectedData.created_at
? new Date(selectedData.created_at).toLocaleString('id-ID', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
}) + ' WIB'
: 'N/A'}
</div>
</div>
</Col>
</Row>
{/* Status Information */}
<Row gutter={[16, 0]}>
<Col span={8}>
<div style={{ marginBottom: '2px' }}>
<div style={{ fontSize: '11px', color: '#8c8c8c', marginBottom: '0' }}>
Status Kirim
</div>
<Tag color={selectedData.is_send ? 'success' : 'error'}>
{selectedData.is_send ? 'Terkirim' : 'Belum Terkirim'}
</Tag>
</div>
</Col>
<Col span={8}>
<div style={{ marginBottom: '2px' }}>
<div style={{ fontSize: '11px', color: '#8c8c8c', marginBottom: '0' }}>
Status Terkirim
</div>
<Tag color={selectedData.is_delivered ? 'success' : 'warning'}>
{selectedData.is_delivered ? 'Terkirim' : 'Belum Terkirim'}
</Tag>
</div>
</Col>
<Col span={8}>
<div style={{ marginBottom: '2px' }}>
<div style={{ fontSize: '11px', color: '#8c8c8c', marginBottom: '0' }}>
Status Baca
</div>
<Tag color={selectedData.is_read ? 'success' : 'processing'}>
{selectedData.is_read ? 'Dibaca' : 'Belum Dibaca'}
</Tag>
</div>
</Col>
</Row>
{/* Description */}
<div style={{ marginTop: '16px', marginBottom: '8px' }}>
<div style={{ fontSize: '11px', color: '#8c8c8c', marginBottom: '4px' }}>
Deskripsi Error
</div>
<div <div
style={{ style={{
fontSize: '13px', marginTop: '16px',
color: '#262626', padding: '12px',
fontWeight: 500, backgroundColor: '#f6f9ff',
padding: '8px', borderRadius: '6px',
backgroundColor: '#fafafa', border: '1px solid #d6e4ff',
borderRadius: '4px',
border: '1px solid #f0f0f0',
}} }}
> >
{selectedData.message_error_issue || 'N/A'} <div style={{ fontSize: '12px', color: '#595959' }}>
<strong>Catatan:</strong> Notifikasi ini telah dikirim ke engineer yang bersangkutan
untuk ditindaklanjuti sesuai dengan prosedur yang berlaku.
</div>
</div> </div>
</div> </div>
)}
{/* Spareparts Information */} </Modal>
{sparepartsData.length > 0 && (
<div style={{ marginTop: '16px' }}>
<div style={{ fontSize: '11px', color: '#8c8c8c', marginBottom: '4px' }}>
Spareparts Terkait
</div>
{sparepartsData.map((sparepart, index) => (
<div
key={index}
style={{
padding: '8px',
marginBottom: '4px',
backgroundColor: '#fafafa',
borderRadius: '4px',
border: '1px solid #f0f0f0',
}}
>
<div style={{ fontWeight: 600, marginBottom: '4px' }}>
{sparepart.sparepart_name}
</div>
<div style={{ fontSize: '12px' }}>
Kode: {sparepart.sparepart_code} | Stok:{' '}
{sparepart.sparepart_stok}
</div>
</div>
))}
</div>
)}
</div>
</Card>
); );
}); });

View File

@@ -38,14 +38,7 @@ import {
SearchOutlined, SearchOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { useNavigate, Link as RouterLink } from 'react-router-dom'; import { useNavigate, Link as RouterLink } from 'react-router-dom';
import { import { getAllNotification } from '../../../api/notification';
getAllNotification,
getNotificationLogByNotificationId,
getNotificationDetail,
resendChatByUser,
resendChatAllUser,
searchData,
} from '../../../api/notification';
const { Text, Paragraph, Link: AntdLink } = Typography; const { Text, Paragraph, Link: AntdLink } = Typography;
@@ -54,37 +47,87 @@ const transformNotificationData = (apiData) => {
return apiData.map((item, index) => ({ return apiData.map((item, index) => ({
id: `notification-${item.notification_error_id}-${index}`, // Unique key prefix with array index id: `notification-${item.notification_error_id}-${index}`, // Unique key prefix with array index
type: item.is_read ? 'resolved' : item.is_delivered ? 'warning' : 'critical', type: item.is_read ? 'resolved' : item.is_delivered ? 'warning' : 'critical',
title: item.error_code_name || 'Unknown Error', title: item.device_name || 'Unknown Device',
issue: item.error_code || item.error_code_name || 'Unknown Error', issue: item.error_code_name || 'Unknown Error',
description: `${item.error_code} - ${item.error_code_name || ''}`, description: `${item.error_code} - ${item.error_code_name}`,
timestamp: item.created_at timestamp:
? new Date(item.created_at).toLocaleString('id-ID', { new Date(item.created_at).toLocaleString('id-ID', {
day: '2-digit', day: '2-digit',
month: '2-digit', month: '2-digit',
year: 'numeric', year: 'numeric',
hour: '2-digit', hour: '2-digit',
minute: '2-digit', minute: '2-digit',
}) + ' WIB' }) + ' WIB',
: 'N/A', location: item.device_location || 'Location not specified',
location: item.plant_sub_section_name || item.device_location || 'Location not specified', details: item.message_error_issue || 'No details available',
details: item.device_name || '-',
errId: item.notification_error_id || 0,
link: `/verification-sparepart/${item.notification_error_id}`, // Dummy URL untuk verifikasi spare part link: `/verification-sparepart/${item.notification_error_id}`, // Dummy URL untuk verifikasi spare part
subsection: item.plant_sub_section_name || 'N/A', subsection: item.solution_name || 'N/A',
isRead: item.is_read, isRead: item.is_read,
status: item.is_read ? 'Resolved' : item.is_delivered ? 'Delivered' : 'Pending', status: item.is_read ? 'Resolved' : item.is_delivered ? 'Delivered' : 'Pending',
tag: item.error_code, tag: item.error_code,
errorCode: item.error_code, errorCode: item.error_code,
solutionName: item.error_code?.solution?.[0]?.solution_name || 'N/A', solutionName: item.solution_name,
typeSolution: item.error_code?.solution?.[0]?.type_solution || 'N/A', typeSolution: item.type_solution,
pathSolution: pathSolution: item.path_solution,
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 ListNotification = memo(function ListNotification(props) {
const [notifications, setNotifications] = useState([]); const [notifications, setNotifications] = useState([]);
const [activeTab, setActiveTab] = useState('all'); const [activeTab, setActiveTab] = useState('all');
@@ -94,10 +137,6 @@ const ListNotification = memo(function ListNotification(props) {
const [modalContent, setModalContent] = useState(null); // 'user', 'log', 'details', or null const [modalContent, setModalContent] = useState(null); // 'user', 'log', 'details', or null
const [isAddingLog, setIsAddingLog] = useState(false); const [isAddingLog, setIsAddingLog] = useState(false);
const [selectedNotification, setSelectedNotification] = useState(null); 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({ const [pagination, setPagination] = useState({
current_page: 1, current_page: 1,
current_limit: 10, current_limit: 10,
@@ -163,7 +202,7 @@ const ListNotification = memo(function ListNotification(props) {
})); }));
// Fetch notifications with new pagination // Fetch notifications with new pagination
const isReadFilter = activeTab === 'read' ? 1 : activeTab === 'unread' ? 0 : null; const isReadFilter = activeTab === 'read' ? true : activeTab === 'unread' ? false : null;
fetchNotifications(page, pageSize, isReadFilter); fetchNotifications(page, pageSize, isReadFilter);
}; };
@@ -175,7 +214,7 @@ const ListNotification = memo(function ListNotification(props) {
} }
// Fetch notifications on component mount and when tab changes // Fetch notifications on component mount and when tab changes
const isReadFilter = activeTab === 'read' ? 1 : activeTab === 'unread' ? 0 : null; const isReadFilter = activeTab === 'read' ? true : activeTab === 'unread' ? false : null;
fetchNotifications(pagination.current_page, pagination.current_limit, isReadFilter); fetchNotifications(pagination.current_page, pagination.current_limit, isReadFilter);
}, [activeTab]); }, [activeTab]);
@@ -199,9 +238,9 @@ const ListNotification = memo(function ListNotification(props) {
content: `Are you sure you want to resend the notification for "${notification.title}"?`, content: `Are you sure you want to resend the notification for "${notification.title}"?`,
okText: 'Resend', okText: 'Resend',
cancelText: 'Cancel', cancelText: 'Cancel',
async onOk() { onOk() {
console.log('Resending notification:', notification.id); console.log('Resending notification:', notification.id);
await resendChatAllUser(notification.errId);
message.success( message.success(
`Notification for "${notification.title}" has been resent successfully.` `Notification for "${notification.title}" has been resent successfully.`
); );
@@ -220,49 +259,13 @@ 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 = () => { const handleSearch = () => {
fetchSearch(searchValue); setSearchTerm(searchValue);
}; };
const handleSearchClear = () => { const handleSearchClear = () => {
setSearchValue(''); setSearchValue('');
fetchSearch(''); setSearchTerm('');
}; };
const getUnreadCount = () => notifications.filter((n) => !n.isRead).length; const getUnreadCount = () => notifications.filter((n) => !n.isRead).length;
@@ -277,78 +280,6 @@ 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) => ({ const tabButtonStyle = (isActive) => ({
padding: '12px 16px', padding: '12px 16px',
border: 'none', border: 'none',
@@ -383,6 +314,7 @@ const ListNotification = memo(function ListNotification(props) {
borderColor: notification.isRead ? '#f0f0f0' : '#d6e4ff', borderColor: notification.isRead ? '#f0f0f0' : '#d6e4ff',
cursor: 'pointer', cursor: 'pointer',
}} }}
onClick={() => handleMarkAsRead(notification.id)}
> >
<div <div
style={{ style={{
@@ -421,7 +353,7 @@ const ListNotification = memo(function ListNotification(props) {
<Text strong>{notification.title}</Text> <Text strong>{notification.title}</Text>
<div style={{ marginTop: '4px' }}> <div style={{ marginTop: '4px' }}>
<Text style={{ color }}> <Text style={{ color }}>
Error Code {notification.issue} {notification.issue}
</Text> </Text>
</div> </div>
</div> </div>
@@ -438,7 +370,7 @@ const ListNotification = memo(function ListNotification(props) {
</div> </div>
</Col> </Col>
<Col flex="auto"> <Col flex="auto">
{/* <div <div
style={{ style={{
display: 'flex', display: 'flex',
gap: '8px', gap: '8px',
@@ -461,18 +393,12 @@ const ListNotification = memo(function ListNotification(props) {
> >
{notification.details} {notification.details}
</Paragraph> </Paragraph>
</div> */} </div>
<Space <Space
direction="vertical" direction="vertical"
size={4} size={4}
style={{ fontSize: '13px', color: '#8c8c8c' }} style={{ fontSize: '13px', color: '#8c8c8c' }}
> >
<Space>
<MobileOutlined />
<Text type="secondary">
{notification.details}
</Text>
</Space>
<Space> <Space>
<ClockCircleOutlined /> <ClockCircleOutlined />
<Text type="secondary"> <Text type="secondary">
@@ -486,10 +412,17 @@ const ListNotification = memo(function ListNotification(props) {
</Text> </Text>
</Space> </Space>
<Space> <Space>
<LinkOutlined />
<AntdLink
href={notification.link}
target="_blank"
>
{notification.link}
</AntdLink>
<Button <Button
type="link" type="link"
icon={<SendOutlined />} icon={<SendOutlined />}
style={{ paddingLeft: '0px' }} style={{ paddingLeft: '8px' }}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
handleResend(notification); handleResend(notification);
@@ -525,23 +458,13 @@ const ListNotification = memo(function ListNotification(props) {
border: '1px solid #1890ff', border: '1px solid #1890ff',
borderRadius: '4px', borderRadius: '4px',
}} }}
onClick={async (e) => { onClick={(e) => {
e.stopPropagation(); 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'); setModalContent('user');
}} }}
/> />
<RouterLink <RouterLink
to={`/notification-detail/${ to={`/detail-notification/${
notification.id.split('-')[1] notification.id.split('-')[1]
}`} }`}
target="_blank" target="_blank"
@@ -576,15 +499,6 @@ const ListNotification = memo(function ListNotification(props) {
}} }}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); 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'); setModalContent('log');
}} }}
/> />
@@ -603,191 +517,131 @@ const ListNotification = memo(function ListNotification(props) {
const renderUserHistory = () => ( const renderUserHistory = () => (
<> <>
{userLoading ? ( <Space direction="vertical" size="middle" style={{ display: 'flex' }}>
<div style={{ textAlign: 'center', padding: '24px' }}> {userHistoryData.map((user) => (
<Spin size="large" /> <Card key={user.id} style={{ borderColor: '#91d5ff' }}>
</div> <Row align="middle" justify="space-between">
) : ( <Col>
<Space direction="vertical" size="middle" style={{ display: 'flex' }}> <Space align="center">
{userHistoryData.map((user) => ( <Text strong>{user.name}</Text>
<Card key={user.id} style={{ borderColor: '#91d5ff' }}> <Text>|</Text>
<Row align="middle" justify="space-between"> <Text>
<Col> <MobileOutlined /> {user.phone}
<Space align="center"> </Text>
<Text strong>{user.name}</Text> <Text>|</Text>
<Text>|</Text> <Badge status="success" text={user.status} />
<Text> </Space>
<MobileOutlined /> {user.phone} <Divider style={{ margin: '8px 0' }} />
</Text> <Space align="center">
<Text>|</Text> <CheckCircleFilled style={{ color: '#52c41a' }} />
<Badge <Text type="secondary">
status={ Success Delivered at {user.timestamp}
user.status === 'Delivered' ? 'success' : 'default' </Text>
} </Space>
text={user.status} </Col>
/> <Col>
</Space> <Button type="primary" ghost icon={<SendOutlined />}>
<Divider style={{ margin: '8px 0' }} /> Resend
<Space align="center"> </Button>
{user.status === 'Delivered' ? ( </Col>
<CheckCircleFilled style={{ color: '#52c41a' }} /> </Row>
) : ( </Card>
<ClockCircleOutlined style={{ color: '#faad14' }} /> ))}
)} </Space>
<Text type="secondary">
{user.status === 'Delivered'
? 'Success Delivered at'
: 'Status '}{' '}
{user.timestamp}
</Text>
</Space>
</Col>
<Col>
<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 = () => ( const renderLogHistory = () => (
<> <>
{logLoading ? ( <div style={{ padding: '0 16px', position: 'relative' }}>
<div style={{ textAlign: 'center', padding: '24px' }}> {/* Garis vertikal yang menyambung */}
<Spin size="large" />
</div>
) : logHistoryData.length === 0 ? (
<div style={{ textAlign: 'center', padding: '24px', color: '#8c8c8c' }}>
Tidak ada log history
</div>
) : (
<div <div
style={{ style={{
height: '400px', position: 'absolute',
overflowY: 'auto', top: '7px',
padding: '0 16px', left: '23px',
position: 'relative', bottom: '7px',
border: '1px solid #f0f0f0', width: '2px',
borderRadius: '4px', backgroundColor: '#91d5ff',
zIndex: 0,
}} }}
> ></div>
<div style={{ position: 'relative' }}>
{/* Garis vertikal yang menyambung */} {logHistoryData.map((log, index) => (
<div <Row
key={log.id}
wrap={false}
style={{ marginBottom: '16px', position: 'relative', zIndex: 1 }}
>
{/* Kolom Kiri: Branch/Timeline */}
<Col
style={{ style={{
position: 'absolute', position: 'relative',
top: '7px', display: 'flex',
left: '23px', flexDirection: 'column',
bottom: '7px', alignItems: 'center',
width: '2px', marginRight: '16px',
backgroundColor: '#91d5ff',
zIndex: 0,
}} }}
></div> >
<div
style={{
width: '14px',
height: '14px',
backgroundColor: '#fff',
border: '3px solid #1890ff',
borderRadius: '50%',
zIndex: 1,
flexShrink: 0,
}}
></div>
</Col>
{logHistoryData.map((log, index) => ( {/* Kolom Kanan: Card */}
<Row <Col flex="auto">
key={log.id} <Card size="small" style={{ borderColor: '#91d5ff' }}>
wrap={false} <Row gutter={[16, 8]} align="middle">
style={{ marginBottom: '16px', position: 'relative', zIndex: 1 }} <Col xs={24} md={12}>
> <Space direction="vertical" size={4}>
{/* Kolom Kiri: Branch/Timeline */} <Space>
<Col <ClockCircleOutlined />
style={{ <Text type="secondary" style={{ fontSize: '12px' }}>
position: 'relative', Added at {log.timestamp}
display: 'flex', </Text>
flexDirection: 'column', </Space>
alignItems: 'center', <div>
marginRight: '16px', <Text strong>Added by: {log.addedBy.name}</Text>
}} <span
>
<div
style={{
width: '14px',
height: '14px',
backgroundColor: '#fff',
border: '3px solid #1890ff',
borderRadius: '50%',
zIndex: 1,
flexShrink: 0,
}}
></div>
</Col>
{/* Kolom Kanan: Card */}
<Col flex="auto">
<Card size="small" style={{ borderColor: '#91d5ff' }}>
<Row gutter={[16, 8]} align="top">
<Col xs={24} md={10}>
<Space direction="vertical" size={4}>
<Space>
<ClockCircleOutlined />
<Text
type="secondary"
style={{ fontSize: '12px' }}
>
Added at {log.timestamp}
</Text>
</Space>
<div>
<Text strong>
{log.addedBy.name}
</Text>
</div>
<div>
<span
style={{
border: '1px solid #52c41a',
color: '#52c41a',
padding: '2px 6px',
borderRadius: '4px',
fontSize: '12px',
}}
>
<MobileOutlined /> {log.addedBy.phone}
</span>
</div>
</Space>
</Col>
<Col xs={24} md={14}>
<Text strong>Description:</Text>
<Paragraph
style={{ style={{
color: '#595959', marginLeft: '8px',
margin: 0, border: '1px solid #52c41a',
fontSize: '13px', color: '#52c41a',
padding: '2px 6px',
borderRadius: '4px',
fontSize: '12px',
}} }}
> >
{log.description} <MobileOutlined /> {log.addedBy.phone}
</Paragraph> </span>
</Col> </div>
</Row> </Space>
</Card> </Col>
</Col> <Col xs={24} md={12}>
</Row> <Paragraph
))} style={{
</div> color: '#595959',
</div> margin: 0,
)} fontSize: '13px',
}}
>
{log.description}
</Paragraph>
</Col>
</Row>
</Card>
</Col>
</Row>
))}
</div>
</> </>
); );
@@ -797,8 +651,8 @@ const ListNotification = memo(function ListNotification(props) {
const { IconComponent, color } = getIconAndColor(selectedNotification.type); const { IconComponent, color } = getIconAndColor(selectedNotification.type);
return ( return (
<Space direction="vertical" size="middle" style={{ width: '100%' }}> <Space direction="vertical" size="large" style={{ width: '100%' }}>
<Row gutter={[16, 8]}> <Row gutter={[16, 16]}>
{/* Kolom Kiri: Data Kompresor */} {/* Kolom Kiri: Data Kompresor */}
<Col span={12}> <Col span={12}>
<Card <Card
@@ -807,7 +661,7 @@ const ListNotification = memo(function ListNotification(props) {
style={{ height: '100%', borderColor: '#d4380d' }} style={{ height: '100%', borderColor: '#d4380d' }}
bodyStyle={{ padding: '12px' }} bodyStyle={{ padding: '12px' }}
> >
<Space direction="vertical" size="middle" style={{ width: '100%' }}> <Space direction="vertical" size="large" style={{ width: '100%' }}>
<Row gutter={16} align="middle"> <Row gutter={16} align="middle">
<Col> <Col>
<div <div
@@ -839,9 +693,9 @@ const ListNotification = memo(function ListNotification(props) {
<Text strong>Plant Subsection</Text> <Text strong>Plant Subsection</Text>
<div>{selectedNotification.subsection}</div> <div>{selectedNotification.subsection}</div>
<Text strong style={{ display: 'block', marginTop: '8px' }}> <Text strong style={{ display: 'block', marginTop: '8px' }}>
Date & Time Time
</Text> </Text>
<div>{selectedNotification.timestamp}</div> <div>{selectedNotification.timestamp.split(' ')[1]} WIB</div>
</div> </div>
<div <div
@@ -912,7 +766,7 @@ const ListNotification = memo(function ListNotification(props) {
</Col> </Col>
</Row> </Row>
<div> <div>
<Row gutter={[16, 8]}> <Row gutter={[16, 16]}>
<Col span={8}> <Col span={8}>
<Card <Card
style={{ style={{
@@ -958,16 +812,7 @@ const ListNotification = memo(function ListNotification(props) {
cursor: 'pointer', cursor: 'pointer',
}} }}
bodyStyle={{ padding: '12px' }} bodyStyle={{ padding: '12px' }}
onClick={() => { onClick={() => setModalContent('log')}
// 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> <Space>
<HistoryOutlined <HistoryOutlined
@@ -980,7 +825,7 @@ const ListNotification = memo(function ListNotification(props) {
</Card> </Card>
</Col> </Col>
</Row> </Row>
<Row gutter={[16, 8]} style={{ marginTop: '0' }}> <Row gutter={[16, 16]} style={{ marginTop: '16px' }}>
<Col span={8}> <Col span={8}>
<Card size="small" style={{ height: '100%' }}> <Card size="small" style={{ height: '100%' }}>
<Space direction="vertical" size="small" style={{ width: '100%' }}> <Space direction="vertical" size="small" style={{ width: '100%' }}>
@@ -1284,40 +1129,24 @@ const ListNotification = memo(function ListNotification(props) {
</Button> </Button>
</Space> </Space>
</Card> </Card>
{logLoading ? ( {logHistoryData.map((log) => (
<div style={{ textAlign: 'center', padding: '12px' }}> <Card
<Spin size="small" /> key={log.id}
</div> size="small"
) : logHistoryData.length === 0 ? ( bodyStyle={{ padding: '8px 12px' }}
<div
style={{
textAlign: 'center',
padding: '12px',
color: '#8c8c8c',
}}
> >
Tidak ada log history <Paragraph
</div> style={{ fontSize: '12px', margin: 0 }}
) : ( ellipsis={{ rows: 2 }}
logHistoryData.map((log) => (
<Card
key={log.id}
size="small"
bodyStyle={{ padding: '8px 12px' }}
> >
<Paragraph <Text strong>{log.addedBy.name}:</Text>{' '}
style={{ fontSize: '12px', margin: 0 }} {log.description}
ellipsis={{ rows: 2 }} </Paragraph>
> <Text type="secondary" style={{ fontSize: '11px' }}>
<Text strong>{log.addedBy.name}:</Text>{' '} {log.timestamp}
{log.description} </Text>
</Paragraph> </Card>
<Text type="secondary" style={{ fontSize: '11px' }}> ))}
{log.timestamp}
</Text>
</Card>
))
)}
</Space> </Space>
</Card> </Card>
</Col> </Col>
@@ -1457,7 +1286,7 @@ const ListNotification = memo(function ListNotification(props) {
</div> </div>
) : ( ) : (
<Typography.Title level={4} style={{ margin: 0 }}> <Typography.Title level={4} style={{ margin: 0 }}>
{modalContent === 'user' && 'History User Notification'} {modalContent === 'user' && 'User History Notification'}
{modalContent === 'log' && 'Log History Notification'} {modalContent === 'log' && 'Log History Notification'}
</Typography.Title> </Typography.Title>
)} )}

View File

@@ -1,95 +0,0 @@
import React from 'react';
import { Card, Table, Tag, Typography } from 'antd';
import { ClockCircleOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
const { Text } = Typography;
const getDummyLogHistory = (notification) => {
if (!notification) return [];
return [
{
key: '1',
timestamp: dayjs().subtract(2, 'hour').format('DD-MM-YYYY HH:mm:ss'),
activity: 'Notification Created',
details: `System generated a ${notification.type} notification for: ${notification.issue}`,
},
{
key: '2',
timestamp: dayjs().subtract(1, 'hour').format('DD-MM-YYYY HH:mm:ss'),
activity: 'Notification Sent',
details: 'Sent to 2 engineers',
},
{
key: '3',
timestamp: dayjs().subtract(30, 'minute').format('DD-MM-YYYY HH:mm:ss'),
activity: 'Notification Read',
details: 'Read by Engineer A',
},
{
key: '4',
timestamp: dayjs().subtract(5, 'minute').format('DD-MM-YYYY HH:mm:ss'),
activity: 'Resend Triggered',
details: 'Notification resent by Admin',
},
];
};
const columns = [
{
title: 'Timestamp',
dataIndex: 'timestamp',
key: 'timestamp',
render: (text) => (
<span>
<ClockCircleOutlined style={{ marginRight: 8 }} />
{text}
</span>
),
},
{
title: 'Activity',
dataIndex: 'activity',
key: 'activity',
render: (text) => {
let color = 'blue';
if (text.includes('Created')) {
color = 'geekblue';
} else if (text.includes('Sent')) {
color = 'purple';
} else if (text.includes('Read')) {
color = 'green';
} else if (text.includes('Triggered')) {
color = 'orange';
}
return <Tag color={color}>{text.toUpperCase()}</Tag>;
},
},
{
title: 'Details',
dataIndex: 'details',
key: 'details',
},
];
const LogHistoryCard = ({ notificationData }) => {
const logHistoryData = getDummyLogHistory(notificationData);
return (
<Card
title="Log History"
size="small"
style={{ height: '100%' }}
>
<Table
columns={columns}
dataSource={logHistoryData}
pagination={false} // Remove pagination entirely
size="small"
scroll={{ y: 200 }} // Use scroll for overflow, adjust height as needed
/>
</Card>
);
};
export default LogHistoryCard;

View File

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

View File

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

View File

@@ -1,955 +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,
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,246 +1,91 @@
import React, { memo, useState, useEffect } from 'react'; import React, { memo, useState, useEffect } from 'react';
import { Button, Row, Col, Card, DatePicker, Select, Typography, Table, Spin, Modal } from 'antd'; import { Button, Row, Col, Card, Input, DatePicker, Select, Typography } from 'antd';
import TableList from '../../../../components/Global/TableList';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { FileTextOutlined, DownloadOutlined, LoadingOutlined } from '@ant-design/icons'; import { FileTextOutlined } from '@ant-design/icons';
import { import {
getAllHistoryValueReportPivot,
getAllHistoryValueReport, getAllHistoryValueReport,
getAllHistoryValueReportPivot,
} from '../../../../api/history-value'; } from '../../../../api/history-value';
import { getAllPlantSection } from '../../../../api/master-plant-section'; import { getAllPlantSection } from '../../../../api/master-plant-section';
import jsPDF from 'jspdf';
import autoTable from 'jspdf-autotable';
import ExcelJS from 'exceljs';
import { saveAs } from 'file-saver';
const { Text } = Typography; const { Text } = Typography;
const ListReport = memo(function ListReport(props) { const ListReport = memo(function ListReport(props) {
const columns = [
{
title: 'No',
key: 'no',
width: '5%',
align: 'center',
render: (_, __, index) => index + 1,
},
{
title: 'Datetime',
dataIndex: 'datetime',
key: 'datetime',
width: '15%',
},
{
title: 'Tag Name',
dataIndex: 'tag_name',
key: 'tag_name',
width: '70%',
},
// {
// title: 'Value',
// dataIndex: 'val',
// key: 'val',
// width: '10%',
// render: (_, record) => Number(record.val).toFixed(4),
// },
// {
// title: 'Stat',
// dataIndex: 'status',
// key: 'status',
// width: '10%',
// },
];
const dateNow = dayjs(); const dateNow = dayjs();
const dateNowFormated = dateNow.format('YYYY-MM-DD'); const dateNowFormated = dateNow.format('YYYY-MM-DD');
const [isLoadingModal, setIsLoadingModal] = useState(false); const [trigerFilter, setTrigerFilter] = 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 [plantSubSection, setPlantSubSection] = useState(0);
const [plantSubSectionList, setPlantSubSectionList] = useState([]); const [plantSubSectionList, setPlantSubSectionList] = useState([]);
const [startDate, setStartDate] = useState(dateNow); const [startDate, setStartDate] = useState(dateNow);
const [endDate, setEndDate] = useState(dateNow); const [endDate, setEndDate] = useState(dateNow);
const [periode, setPeriode] = useState(30); const [periode, setPeriode] = useState(10);
const generateFullDayTimes = (dateString, intervalMinutes) => { const defaultFilter = {
const times = []; criteria: '',
const startOfDay = dayjs(dateString).startOf('day'); plant_sub_section_id: 0,
const endOfDay = dayjs(dateString).endOf('day'); from: dateNowFormated,
to: dateNowFormated,
let currentTime = startOfDay; interval: periode,
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 fetchData = async (page = 1, pageSize = 10, showModal = false) => { const handleSearch = () => {
// if (!plantSubSection) { const formattedDateStart = startDate.format('YYYY-MM-DD');
// return; const formattedDateEnd = endDate.format('YYYY-MM-DD');
// }
if (showModal) { setFormDataFilter({
setIsLoadingModal(true); criteria: '',
} else { plant_sub_section_id: plantSubSection,
setIsLoadingTable(true); from: formattedDateStart,
} to: formattedDateEnd,
try { interval: periode,
const formattedDateStart = startDate.format('YYYY-MM-DD'); });
const formattedDateEnd = endDate.format('YYYY-MM-DD'); setTrigerFilter((prev) => !prev);
const params = new URLSearchParams({
plant_sub_section_id: plantSubSection,
from: formattedDateStart,
to: formattedDateEnd,
interval: periode,
page: 1,
limit: 1000,
});
const pivotResponse = await getAllHistoryValueReportPivot(params);
const valueReportResponse = await getAllHistoryValueReportPivot(params);
if (pivotResponse && pivotResponse.data) {
console.log('API Pivot Response:', pivotResponse);
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 = () => { const handleReset = () => {
setPlantSubSection(0); setPlantSubSection(0);
setStartDate(dateNow); setStartDate(dateNow);
setEndDate(dateNow); setEndDate(dateNow);
setPeriode(30); setPeriode(5);
setTableData([]);
setColumns([]);
setPivotData([]);
setValueReportData([]);
setPagination({
current: 1,
pageSize: 10,
total: 0,
});
}; };
const getPlantSubSection = async () => { const getPlantSubSection = async () => {
@@ -259,548 +104,8 @@ const ListReport = memo(function ListReport(props) {
getPlantSubSection(); getPlantSubSection();
}, []); }, []);
const isWithinOneDay = startDate.isSame(endDate, 'day');
useEffect(() => {
if (!isWithinOneDay && periode < 60) {
setPeriode(60);
}
}, [startDate, endDate, periode, isWithinOneDay]);
const periodeOptions = [
{ value: 5, label: '5 Minute', disabled: !isWithinOneDay },
{ value: 10, label: '10 Minute', disabled: !isWithinOneDay },
{ value: 30, label: '30 Minute', disabled: !isWithinOneDay },
{ value: 60, label: '1 Hour', disabled: false },
{ value: 120, label: '2 Hour', disabled: false },
];
const 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 ( return (
<React.Fragment> <React.Fragment>
<Modal
open={isLoadingModal}
footer={null}
closable={false}
centered
width={400}
bodyStyle={{
textAlign: 'center',
padding: '40px 20px'
}}
>
<Spin
indicator={<LoadingOutlined style={{ fontSize: 48, color: '#1890ff' }} spin />}
/>
<div style={{ marginTop: '24px' }}>
<Typography.Title level={4} style={{ marginBottom: '8px' }}>
Please Wait
</Typography.Title>
<Typography.Text type="secondary">
System is generating report data...
</Typography.Text>
</div>
</Modal>
<Card> <Card>
<Row> <Row>
<Col xs={24}> <Col xs={24}>
@@ -862,8 +167,14 @@ const ListReport = memo(function ListReport(props) {
value={periode} value={periode}
onChange={setPeriode} onChange={setPeriode}
style={{ width: '100%', marginTop: '4px' }} style={{ width: '100%', marginTop: '4px' }}
options={periodeOptions} 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>
</div> </div>
</Col> </Col>
</Row> </Row>
@@ -874,33 +185,10 @@ const ListReport = memo(function ListReport(props) {
danger danger
icon={<FileTextOutlined />} icon={<FileTextOutlined />}
onClick={handleSearch} onClick={handleSearch}
disabled={false}
> >
Show Show
</Button> </Button>
</Col> </Col>
<Col>
<Button
type="primary"
icon={<DownloadOutlined />}
onClick={exportToPDF}
disabled={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> <Col>
<Button <Button
onClick={handleReset} onClick={handleReset}
@@ -911,26 +199,18 @@ const ListReport = memo(function ListReport(props) {
</Col> </Col>
</Row> </Row>
</Col> </Col>
<Col xs={24} style={{ marginTop: '16px' }}> <Col xs={24} sm={24} md={24} lg={24} xl={24} style={{ marginTop: '16px' }}>
<Spin spinning={isLoadingTable}> <TableList
<div style={{ overflowX: 'auto', width: '100%' }}> firstLoad={false}
<Table mobile
columns={columns} cardColor={'#d38943ff'}
dataSource={tableData} header={'datetime'}
pagination={{ getData={getAllHistoryValueReportPivot}
...pagination, queryParams={formDataFilter}
showSizeChanger: true, columns={columns}
showTotal: (total) => `Total ${total} data`, columnDynamic={'columns'}
pageSizeOptions: ['10', '20', '50', '100'], triger={trigerFilter}
}} />
onChange={handleTableChange}
scroll={{ x: 'max-content', y: 500 }}
bordered
size="small"
sticky
/>
</div>
</Spin>
</Col> </Col>
</Row> </Row>
</Card> </Card>
@@ -938,4 +218,4 @@ const ListReport = memo(function ListReport(props) {
); );
}); });
export default ListReport; export default ListReport;

View File

@@ -1,17 +1,8 @@
import React, { memo, useState, useEffect } from 'react'; import React, { memo, useState, useEffect } from 'react';
import { Button, Row, Col, Card, DatePicker, Select, Typography, Modal, Spin } from 'antd'; import { Button, Row, Col, Card, Input, DatePicker, Select, Typography } from 'antd';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { FileTextOutlined, LoadingOutlined } from '@ant-design/icons'; import { FileTextOutlined } from '@ant-design/icons';
import { import { ResponsiveLine } from '@nivo/line';
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer
} from 'recharts';
import './trending.css'; import './trending.css';
import { getAllPlantSection } from '../../../api/master-plant-section'; import { getAllPlantSection } from '../../../api/master-plant-section';
import { getAllHistoryValueTrendingPivot } from '../../../api/history-value'; import { getAllHistoryValueTrendingPivot } from '../../../api/history-value';
@@ -27,7 +18,6 @@ const ReportTrending = memo(function ReportTrending(props) {
const [startDate, setStartDate] = useState(dateNow); const [startDate, setStartDate] = useState(dateNow);
const [endDate, setEndDate] = useState(dateNow); const [endDate, setEndDate] = useState(dateNow);
const [periode, setPeriode] = useState(60); const [periode, setPeriode] = useState(60);
const [isLoading, setIsLoading] = useState(false);
const defaultFilter = { const defaultFilter = {
criteria: '', criteria: '',
@@ -39,83 +29,51 @@ const ReportTrending = memo(function ReportTrending(props) {
const [formDataFilter, setFormDataFilter] = useState(defaultFilter); const [formDataFilter, setFormDataFilter] = useState(defaultFilter);
const [trendingValue, setTrendingValue] = useState([]); const [trendingValue, setTrendingValue] = useState([]);
const [chartData, setChartData] = useState([]);
const [metrics, setMetrics] = useState([]);
// Palet warna
const colorPalette = [
'#3b82f6', '#ef4444', '#10b981', '#f59e0b', '#8b5cf6',
'#ec4899', '#14b8a6', '#f97316', '#6366f1', '#84cc16'
];
const handleSearch = async () => { const handleSearch = async () => {
setIsLoading(true); const formattedDateStart = startDate.format('YYYY-MM-DD');
const formattedDateEnd = endDate.format('YYYY-MM-DD');
try {
const formattedDateStart = startDate.format('YYYY-MM-DD');
const formattedDateEnd = endDate.format('YYYY-MM-DD');
const newFilter = { const newFilter = {
criteria: '', criteria: '',
plant_sub_section_id: plantSubSection, plant_sub_section_id: plantSubSection,
from: formattedDateStart, from: formattedDateStart,
to: formattedDateEnd, to: formattedDateEnd,
interval: periode, interval: periode,
}; };
setFormDataFilter(newFilter); setFormDataFilter(newFilter);
const param = new URLSearchParams(newFilter); const param = new URLSearchParams(newFilter);
const response = await getAllHistoryValueTrendingPivot(param); const response = await getAllHistoryValueTrendingPivot(param);
if (response?.data?.length > 0) { if (response?.data?.length > 0) {
transformDataForRecharts(response.data); // 🔹 Bersihkan dan format data agar aman untuk Nivo
} else { const cleanedData = response.data.map((serie) => ({
setTrendingValue([]); id: serie.id ?? 'Unknown',
setChartData([]); data: Array.isArray(serie.data)
setMetrics([]); ? serie.data.map((d) => ({
} x: d?.x ?? null,
} catch (error) { y:
console.error('Error fetching trending data:', error); d?.y !== null && d?.y !== undefined
} finally { ? Number(d.y).toFixed(4) // format 4 angka di belakang koma
setIsLoading(false); : null,
}))
: [],
}));
setTrendingValue(cleanedData);
} else {
// 🔹 Jika tidak ada data dari API
setTrendingValue([]);
} }
}; };
const transformDataForRecharts = (nivoData) => {
setTrendingValue(nivoData);
const metricNames = nivoData.map(serie => serie.id);
setMetrics(metricNames);
const timeMap = new Map();
nivoData.forEach(serie => {
serie.data.forEach(point => {
if (!timeMap.has(point.x)) {
timeMap.set(point.x, { time: point.x });
}
const entry = timeMap.get(point.x);
entry[serie.id] = point.y !== null && point.y !== undefined
? parseFloat(point.y)
: null;
});
});
const transformedData = Array.from(timeMap.values()).sort((a, b) =>
new Date(a.time) - new Date(b.time)
);
setChartData(transformedData);
};
const handleReset = () => { const handleReset = () => {
setPlantSubSection(0); setPlantSubSection(0);
setStartDate(dateNow); setStartDate(dateNow);
setEndDate(dateNow); setEndDate(dateNow);
setPeriode(60); setPeriode(5);
setChartData([]);
setMetrics([]);
}; };
const getPlantSubSection = async () => { const getPlantSubSection = async () => {
@@ -130,171 +88,12 @@ 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(() => { useEffect(() => {
getPlantSubSection(); getPlantSubSection();
}, []); }, []);
return ( return (
<React.Fragment> <React.Fragment>
{/* Loading Modal */}
<Modal
open={isLoading}
footer={null}
closable={false}
centered
width={400}
bodyStyle={{
textAlign: 'center',
padding: '40px 20px'
}}
>
<Spin
indicator={<LoadingOutlined style={{ fontSize: 48, color: '#1890ff' }} spin />}
/>
<div style={{ marginTop: '24px' }}>
<Typography.Title level={4} style={{ marginBottom: '8px' }}>
Please Wait
</Typography.Title>
<Typography.Text type="secondary">
System is generating trending data...
</Typography.Text>
</div>
</Modal>
<Card> <Card>
<Row> <Row>
<Col xs={24}> <Col xs={24}>
@@ -363,11 +162,10 @@ const ReportTrending = memo(function ReportTrending(props) {
{ value: 60, label: '1 Hour' }, { value: 60, label: '1 Hour' },
{ value: 120, label: '2 Hour' }, { value: 120, label: '2 Hour' },
]} ]}
/> ></Select>
</div> </div>
</Col> </Col>
</Row> </Row>
<Row gutter={8} style={{ marginTop: '16px' }}> <Row gutter={8} style={{ marginTop: '16px' }}>
<Col> <Col>
<Button <Button
@@ -389,9 +187,108 @@ const ReportTrending = memo(function ReportTrending(props) {
</Col> </Col>
</Row> </Row>
</Col> </Col>
<Col xs={24} sm={24} md={24} lg={24} xl={24} style={{ marginTop: '16px' }}>
<Col xs={24} sm={24} md={24} lg={24} xl={24} style={{ marginTop: '24px' }}> <div style={{ height: '500px', marginTop: '16px' }}>
{renderChart()} {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> </Col>
</Row> </Row>
</Card> </Card>
@@ -399,4 +296,4 @@ const ReportTrending = memo(function ReportTrending(props) {
); );
}); });
export default ReportTrending; export default ReportTrending;

View File

@@ -115,7 +115,7 @@ const ChangePasswordModal = (props) => {
try { try {
const response = await changePassword(props.selectedUser.user_id, formData.newPassword); 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) { if (response && response.statusCode === 200) {
NotifOk({ NotifOk({

View File

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

View File

@@ -192,7 +192,7 @@ const columns = (showPreviewModal, showEditModal, showDeleteDialog, showApproval
}, },
}, },
{ {
title: 'Action', title: 'Aksi',
key: 'aksi', key: 'aksi',
align: 'center', align: 'center',
width: '12%', width: '12%',