Compare commits
90 Commits
038009433f
...
lavoce
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2163cec5e | ||
|
|
d5866ceae4 | ||
| 6fdb259246 | |||
| 0aad43c751 | |||
| d988d47e30 | |||
|
|
e08eaaa43e | ||
|
|
f6ca54f5b4 | ||
|
|
a9b8053bd8 | ||
| 600c101c68 | |||
|
|
14a6884f43 | ||
|
|
8e151ffe0b | ||
| 8f64843613 | |||
|
|
fe8f6d1002 | ||
|
|
5281e288a9 | ||
|
|
4ed05cc640 | ||
|
|
14e97fead2 | ||
|
|
0935d7c9f5 | ||
|
|
3266641f81 | ||
|
|
739c55c0bc | ||
|
|
5b4485d20d | ||
| 98057beb0f | |||
|
|
b342289888 | ||
| d03bbf2a41 | |||
|
|
ec094b8f55 | ||
| b6d941ba2d | |||
| 167abcaa43 | |||
| beb8ccbaee | |||
| 797f6c2383 | |||
| 016c77a586 | |||
| 36ebab7f9a | |||
| a5b1fbef74 | |||
|
|
cb0c53daea | ||
| 978e020305 | |||
| 4508738958 | |||
| eb23612444 | |||
| bee196e299 | |||
| d19f555c7c | |||
| 1d7253f9a1 | |||
| d8a1878ab1 | |||
| e4af2d6e18 | |||
| 8cf21643ea | |||
|
|
6b75f6f4b9 | ||
|
|
dc78add71d | ||
| 1ce922ff4c | |||
| 3a4b0f0748 | |||
| 4bffbb3798 | |||
| b9cdfcb1e9 | |||
| 49ba00d886 | |||
| cf1ccb0fd0 | |||
| fb790e5e37 | |||
| ea3adf40cc | |||
| 2ff50342e8 | |||
| 1f8ee62721 | |||
| 96d6367dbd | |||
| 8afff23ffe | |||
| 512282f367 | |||
| 4fab5df300 | |||
| 9e8191f8f8 | |||
| 13255f9713 | |||
| e23215b6c1 | |||
| a014d6b370 | |||
| 3225a0865e | |||
| 5703ff0e8d | |||
| 03be3a6a99 | |||
| fe5f081b92 | |||
| acaf1b3946 | |||
| 147171373c | |||
| f22e120204 | |||
| 1bc98de564 | |||
| 991a3eaa66 | |||
| 7a5a9aafd1 | |||
| 0694497f8d | |||
| c82d6d39c1 | |||
| edf20050db | |||
| 2e98dc168a | |||
| 1797058526 | |||
| 1c2ddca9d4 | |||
| 61ca7249cd | |||
| a98edbe658 | |||
| fbc5473f2b | |||
| 55a47c3a25 | |||
| 94e011e5c7 | |||
| db9b40f2fc | |||
| 5fdfb47f9e | |||
| 55c50f6f7f | |||
| ed4570e8dd | |||
| 572042ab53 | |||
| afcb85a323 | |||
| 14f8a5d472 | |||
| 309d191bce |
@@ -22,7 +22,8 @@
|
||||
"exceljs": "^4.4.0",
|
||||
"file-saver": "^2.0.5",
|
||||
"html2canvas": "^1.4.1",
|
||||
"jspdf": "^3.0.1",
|
||||
"jspdf": "^3.0.4",
|
||||
"jspdf-autotable": "^5.0.2",
|
||||
"mqtt": "^5.14.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^18.2.0",
|
||||
@@ -30,6 +31,7 @@
|
||||
"react-icons": "^4.11.0",
|
||||
"react-router-dom": "^6.22.3",
|
||||
"react-svg": "^16.3.0",
|
||||
"recharts": "^3.6.0",
|
||||
"sweetalert2": "^11.17.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
BIN
public/assets/defaultSparepartImg.jpg
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
public/assets/pupuk-indonesia-1.png
Normal file
|
After Width: | Height: | Size: 309 KiB |
BIN
public/assets/pupuk-indonesia-2.jpg
Normal file
|
After Width: | Height: | Size: 78 KiB |
21
src/App.jsx
@@ -36,7 +36,7 @@ import IndexNotification from './pages/notification/IndexNotification';
|
||||
import IndexRole from './pages/role/IndexRole';
|
||||
import IndexUser from './pages/user/IndexUser';
|
||||
import IndexContact from './pages/contact/IndexContact';
|
||||
import DetailNotificationTab from './pages/detailNotification/IndexDetailNotification';
|
||||
import DetailNotificationTab from './pages/notificationDetail/IndexNotificationDetail';
|
||||
import IndexVerificationSparepart from './pages/verificationSparepart/IndexVerificationSparepart';
|
||||
|
||||
import SvgTest from './pages/home/SvgTest';
|
||||
@@ -51,6 +51,10 @@ import SvgAirDryerC from './pages/home/SvgAirDryerC';
|
||||
import IndexHistoryAlarm from './pages/history/alarm/IndexHistoryAlarm';
|
||||
import IndexHistoryEvent from './pages/history/event/IndexHistoryEvent';
|
||||
|
||||
// Image Viewer
|
||||
import ImageViewer from './Utils/ImageViewer';
|
||||
import RedirectWa from './pages/blank/RedirectWa';
|
||||
|
||||
const App = () => {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
@@ -61,7 +65,7 @@ const App = () => {
|
||||
<Route path="/signup" element={<SignUp />} />
|
||||
<Route path="/svg" element={<SvgTest />} />
|
||||
<Route
|
||||
path="/detail-notification/:notificationId"
|
||||
path="/notification-detail/:notificationId"
|
||||
element={<DetailNotificationTab />}
|
||||
/>
|
||||
<Route
|
||||
@@ -69,12 +73,16 @@ const App = () => {
|
||||
element={<IndexVerificationSparepart />}
|
||||
/>
|
||||
|
||||
<Route path="/redirect" element={<RedirectWa />} />
|
||||
|
||||
{/* Protected Routes */}
|
||||
<Route path="/dashboard" element={<ProtectedRoute />}>
|
||||
<Route path="home" element={<Home />} />
|
||||
<Route path="blank" element={<Blank />} />
|
||||
</Route>
|
||||
|
||||
<Route path="/image-viewer/:fileName" element={<ImageViewer />} />
|
||||
|
||||
<Route path="/dashboard-svg" element={<ProtectedRoute />}>
|
||||
<Route path="overview-compressor" element={<SvgOverviewCompressor />} />
|
||||
<Route path="compressor-a" element={<SvgCompressorA />} />
|
||||
@@ -91,6 +99,11 @@ const App = () => {
|
||||
<Route path="tag" element={<IndexTag />} />
|
||||
<Route path="unit" element={<IndexUnit />} />
|
||||
<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/add" element={<AddBrandDevice />} />
|
||||
<Route path="brand-device/edit/:id" element={<EditBrandDevice />} />
|
||||
@@ -107,9 +120,6 @@ const App = () => {
|
||||
path="brand-device/view/temp/files/:fileName"
|
||||
element={<ViewFilePage />}
|
||||
/>
|
||||
<Route path="plant-sub-section" element={<IndexPlantSubSection />} />
|
||||
<Route path="shift" element={<IndexShift />} />
|
||||
<Route path="status" element={<IndexStatus />} />
|
||||
</Route>
|
||||
|
||||
<Route path="/report" element={<ProtectedRoute />}>
|
||||
@@ -142,7 +152,6 @@ const App = () => {
|
||||
<Route index element={<IndexJadwalShift />} />
|
||||
</Route>
|
||||
|
||||
{/* Catch-all */}
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
|
||||
248
src/Utils/ImageViewer.jsx
Normal file
@@ -0,0 +1,248 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { getFileUrl, getFolderFromFileType } from '../api/file-uploads';
|
||||
|
||||
const ImageViewer = () => {
|
||||
const { fileName } = useParams();
|
||||
const [fileUrl, setFileUrl] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [isImage, setIsImage] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!fileName) {
|
||||
setError('No file specified');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const decodedFileName = decodeURIComponent(fileName);
|
||||
const fileExtension = decodedFileName.split('.').pop()?.toLowerCase();
|
||||
const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'];
|
||||
|
||||
setIsImage(imageExtensions.includes(fileExtension));
|
||||
|
||||
const folder = getFolderFromFileType(fileExtension);
|
||||
|
||||
const url = getFileUrl(folder, decodedFileName);
|
||||
setFileUrl(url);
|
||||
|
||||
document.title = `File Viewer - ${decodedFileName}`;
|
||||
} catch (error) {
|
||||
|
||||
setError('Failed to load file');
|
||||
}
|
||||
}, [fileName]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e) => {
|
||||
if (!isImage) return;
|
||||
|
||||
if (e.key === '+' || e.key === '=') {
|
||||
setZoom(prev => Math.min(prev + 0.1, 3));
|
||||
} else if (e.key === '-' || e.key === '_') {
|
||||
setZoom(prev => Math.max(prev - 0.1, 0.1));
|
||||
} else if (e.key === '0') {
|
||||
setZoom(1);
|
||||
} else if (e.key === 'Escape') {
|
||||
window.close();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isImage]);
|
||||
|
||||
|
||||
const handleWheel = (e) => {
|
||||
if (!isImage || !e.ctrlKey) return;
|
||||
|
||||
e.preventDefault();
|
||||
const delta = e.deltaY > 0 ? -0.1 : 0.1;
|
||||
setZoom(prev => Math.min(Math.max(prev + delta, 0.1), 3));
|
||||
};
|
||||
|
||||
const handleZoomIn = () => setZoom(prev => Math.min(prev + 0.1, 3));
|
||||
const handleZoomOut = () => setZoom(prev => Math.max(prev - 0.1, 0.1));
|
||||
const handleResetZoom = () => setZoom(1);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100vh',
|
||||
fontFamily: 'Arial, sans-serif',
|
||||
backgroundColor: '#f5f5f5'
|
||||
}}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<h1>Error</h1>
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
if (!isImage) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100vh',
|
||||
fontFamily: 'Arial, sans-serif',
|
||||
backgroundColor: '#f5f5f5'
|
||||
}}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<h1>File Type Not Supported</h1>
|
||||
<p>Image viewer only supports image files.</p>
|
||||
<p>Please use direct file preview for PDFs and other documents.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
height: '100vh',
|
||||
width: '100vw',
|
||||
backgroundColor: '#000',
|
||||
overflow: 'hidden',
|
||||
position: 'relative'
|
||||
}}
|
||||
onWheel={handleWheel}
|
||||
>
|
||||
|
||||
{isImage && (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: '20px',
|
||||
right: '20px',
|
||||
display: 'flex',
|
||||
gap: '10px',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.7)',
|
||||
padding: '10px',
|
||||
borderRadius: '8px',
|
||||
zIndex: 1000
|
||||
}}>
|
||||
<button
|
||||
onClick={handleZoomOut}
|
||||
style={{
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||
color: '#fff',
|
||||
border: '1px solid rgba(255, 255, 255, 0.3)',
|
||||
padding: '8px 12px',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '16px'
|
||||
}}
|
||||
title="Zoom Out (-)"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<span style={{
|
||||
color: '#fff',
|
||||
padding: '8px 12px',
|
||||
minWidth: '60px',
|
||||
textAlign: 'center',
|
||||
fontSize: '14px'
|
||||
}}>
|
||||
{Math.round(zoom * 100)}%
|
||||
</span>
|
||||
<button
|
||||
onClick={handleZoomIn}
|
||||
style={{
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||
color: '#fff',
|
||||
border: '1px solid rgba(255, 255, 255, 0.3)',
|
||||
padding: '8px 12px',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '16px'
|
||||
}}
|
||||
title="Zoom In (+)"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<button
|
||||
onClick={handleResetZoom}
|
||||
style={{
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||
color: '#fff',
|
||||
border: '1px solid rgba(255, 255, 255, 0.3)',
|
||||
padding: '8px 12px',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
title="Reset Zoom (0)"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{isImage && fileUrl ? (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100%',
|
||||
overflow: 'auto'
|
||||
}}>
|
||||
<img
|
||||
src={fileUrl}
|
||||
alt={decodeURIComponent(fileName)}
|
||||
style={{
|
||||
maxWidth: 'none',
|
||||
maxHeight: 'none',
|
||||
transform: `scale(${zoom})`,
|
||||
transformOrigin: 'center',
|
||||
transition: 'transform 0.1s ease-out',
|
||||
cursor: zoom > 1 ? 'move' : 'default'
|
||||
}}
|
||||
onError={() => setError('Failed to load image')}
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
) : isImage ? (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100vh',
|
||||
color: '#fff',
|
||||
fontFamily: 'Arial, sans-serif'
|
||||
}}>
|
||||
<p>Loading image...</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
||||
{isImage && (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
bottom: '20px',
|
||||
left: '20px',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.7)',
|
||||
color: '#fff',
|
||||
padding: '10px 15px',
|
||||
borderRadius: '8px',
|
||||
fontSize: '12px',
|
||||
zIndex: 1000
|
||||
}}>
|
||||
<div>Mouse wheel + Ctrl: Zoom</div>
|
||||
<div>Keyboard: +/− Zoom, 0: Reset, ESC: Close</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImageViewer;
|
||||
@@ -47,4 +47,63 @@ const deleteBrand = async (id) => {
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export { getAllBrands, getBrandById, createBrand, updateBrand, deleteBrand };
|
||||
const getErrorCodesByBrandId = async (brandId, queryParams) => {
|
||||
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
|
||||
};
|
||||
|
||||
@@ -1,9 +1,105 @@
|
||||
import { SendRequest } from '../components/Global/ApiRequest';
|
||||
|
||||
export const getAllNotification = async () => {
|
||||
const getAllNotification = async (queryParams) => {
|
||||
const response = await SendRequest({
|
||||
method: 'get',
|
||||
prefix: 'notification',
|
||||
prefix: `notification?${queryParams.toString()}`,
|
||||
});
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
const getNotificationById = async (id) => {
|
||||
const response = await SendRequest({
|
||||
method: 'get',
|
||||
prefix: `notification/${id}`,
|
||||
});
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
const getNotificationDetail = async (id) => {
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -26,27 +26,29 @@
|
||||
<path style="fill: none; stroke: rgb(76, 76, 76); stroke-width: 1.222; transform-origin: 490.992px 230.229px;" d="M 646.097 240.002 L 676.271 240.002"/>
|
||||
</g>
|
||||
<path style="fill: none; stroke: rgb(0, 0, 0); stroke-width: 1.525; transform-origin: 443.701px 171.141px;" d="M 443.542 155.983 L 443.859 186.298"/>
|
||||
<g transform="matrix(0.826913, 0, 0, 0.698383, 0.443817, 3.138935)">
|
||||
<rect x="752" y="355.455" width="42.438" height="3.527" style="stroke: rgb(0, 0, 0); stroke-width: 1; fill: rgb(243, 243, 243);"/>
|
||||
<rect x="756.328" y="359.271" width="34.034" height="53.968" style="stroke: rgb(0, 0, 0); stroke-width: 1; fill: rgb(243, 243, 243);"/>
|
||||
<rect x="756.146" y="352.019" width="34.034" height="3.38" style="stroke: rgb(0, 0, 0); stroke-width: 1; fill: rgb(243, 243, 243);"/>
|
||||
<ellipse style="stroke: rgb(0, 0, 0); stroke-width: 1; fill: rgb(243, 243, 243);" cx="773.446" cy="384.7" rx="11.751" ry="11.009"/>
|
||||
<g>
|
||||
<rect x="622.282" y="251.383" width="35.093" height="2.463" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(243, 243, 243);"/>
|
||||
<g>
|
||||
<rect x="625.861" y="254.048" width="28.143" height="37.69" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(243, 243, 243);"/>
|
||||
<rect x="625.711" y="248.983" width="28.143" height="2.361" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(243, 243, 243);"/>
|
||||
<ellipse style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(243, 243, 243);" cx="640.016" cy="271.807" rx="9.717" ry="7.689"/>
|
||||
</g>
|
||||
</g>
|
||||
<rect x="461.861" y="211.956" width="62.018" height="9.968" style="stroke: rgb(0, 0, 0); fill: rgb(120, 231, 228); stroke-width: 0.763;"/>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 9px;" x="561" y="309.954" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">Heater Temp SP</text>
|
||||
<rect x="461.861" y="221.924" width="62.018" height="17.46" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(244, 248, 248);"/>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="564.279" y="330.561" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">####.##</text>
|
||||
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="609.476" y="330.521" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">°F</text>
|
||||
<rect x="461.424" y="242.149" width="62.018" height="9.968" style="stroke: rgb(0, 0, 0); fill: rgb(120, 231, 228); stroke-width: 0.763;"/>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 9px; stroke-width: 1;" x="567.471" y="352.188" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">Heater Temp</text>
|
||||
<rect x="461.424" y="252.117" width="62.018" height="17.46" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(244, 248, 248);"/>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="563.75" y="373.795" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)" id="Dry1_HeatTempCelsius">####.##</text>
|
||||
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="608.947" y="373.755" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">°F</text>
|
||||
<rect x="535.456" y="242.272" width="62.018" height="9.968" style="stroke: rgb(0, 0, 0); fill: rgb(120, 231, 228); stroke-width: 0.763;"/>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 9px; stroke-width: 1;" x="659" y="352.363" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">Heater Temp</text>
|
||||
<rect x="535.456" y="252.24" width="62.018" height="17.46" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(244, 248, 248);"/>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="653.279" y="373.97" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">####.##</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="698.476" y="373.93" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">°F</text>
|
||||
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="698.476" y="373.93" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">°C</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1; font-weight: bold;" x="748" y="347.676" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">HEATER</text>
|
||||
<path style="fill: none; stroke: rgb(0, 0, 0); stroke-width: 1.525; transform-origin: 678.512px 258.693px;" d="M 678.467 229.321 L 678.558 288.066" transform="matrix(0, 1.184039, -0.844567, 0, -0.000022, -0.000005)"/>
|
||||
<path style="fill: none; stroke: rgb(0, 0, 0); stroke-width: 1.525; transform-origin: 703.162px 309.166px;" d="M 703.004 258.049 L 703.32 360.282"/>
|
||||
@@ -108,12 +110,12 @@
|
||||
<rect x="427.269" y="377.282" width="62.018" height="9.968" style="stroke: rgb(0, 0, 0); fill: rgb(120, 231, 228); stroke-width: 0.763;"/>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 9px; stroke-width: 1;" x="532.167" y="545.681" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">Dew Temp</text>
|
||||
<rect x="427.269" y="387.25" width="62.018" height="17.46" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(244, 248, 248);"/>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="522.446" y="567.288" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">####.##</text>
|
||||
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="567.643" y="567.248" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">°C</text>
|
||||
<rect x="427.27" y="412.201" width="62.018" height="9.968" style="stroke: rgb(0, 0, 0); fill: rgb(120, 231, 228); stroke-width: 0.763;"/>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 9px; stroke-width: 1;" x="532.168" y="595.681" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">Dew Temp</text>
|
||||
<rect x="427.27" y="422.169" width="62.018" height="17.46" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(244, 248, 248);"/>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="522.447" y="617.288" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">####.##</text>
|
||||
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="567.644" y="617.248" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">°F</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 9px; stroke-width: 1; text-anchor: middle; font-weight: bolder;" x="602.463" y="573.003" transform="matrix(0.826913, 0, 0, 0.698383, 24.207672, -7.192523)">AIR<tspan x="602.4630126953125" dy="1em"></tspan>OUTLET</text>
|
||||
<g transform="matrix(-0.387768, 0, 0, -0.200385, 743.634644, -199.991287)" style="transform-origin: 72.2405px 412.5px;">
|
||||
@@ -177,17 +179,17 @@
|
||||
<rect x="43.443" y="280.75" width="82.691" height="27.103" style="stroke: rgb(0, 0, 0); fill: rgb(120, 231, 228); stroke-width: 0.763;"/>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Bahnschrift; font-size: 14px; stroke-width: 1; font-weight: bold;" x="53.987" y="423.091" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">RUN HOUR</text>
|
||||
<rect x="126.135" y="280.75" width="82.691" height="27.103" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(244, 248, 248);"/>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="159.05" y="424.106" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">####.##</text>
|
||||
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="225" y="424.066" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">H</text>
|
||||
<rect x="43.443" y="308.191" width="82.691" height="27.103" style="stroke: rgb(0, 0, 0); fill: rgb(120, 231, 228); stroke-width: 0.763;"/>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Bahnschrift; font-size: 13px; stroke-width: 1; font-weight: bold;" x="53.987" y="461.382" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">PURGE HOUR</text>
|
||||
<rect x="126.135" y="308.191" width="82.691" height="27.103" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(244, 248, 248);"/>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="159.05" y="463.397" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">####.##</text>
|
||||
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="225" y="463.357" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">H</text>
|
||||
<rect x="43.443" y="333.129" width="82.691" height="27.103" style="stroke: rgb(0, 0, 0); fill: rgb(120, 231, 228); stroke-width: 0.763;"/>
|
||||
<text style="fill: rgb(51, 51, 51); font-family: Bahnschrift; font-size: 12px; font-weight: 700; white-space: pre;" x="53.987" y="498.091" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">HEATER HOUR</text>
|
||||
<rect x="126.135" y="333.129" width="82.691" height="27.103" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(244, 248, 248);"/>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="159.05" y="499.106" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">####.##</text>
|
||||
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="225" y="499.066" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">H</text>
|
||||
<rect x="43.65" y="360.147" width="82.691" height="27.103" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(248, 213, 14);"/>
|
||||
<text style="fill: rgb(51, 51, 51); font-family: Bahnschrift; font-size: 14px; font-weight: 700; white-space: pre; stroke-width: 1;" x="54.237" y="536.777" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">Alarm Info</text>
|
||||
@@ -205,15 +207,15 @@
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="183.349" y="288.807" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">HTD</text>
|
||||
<rect x="43.443" y="214.051" width="165.383" height="27.103" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(244, 248, 248);"/>
|
||||
<text style="fill: rgb(51, 51, 51); font-family: Bahnschrift; font-size: 13px; font-weight: 700; white-space: pre; stroke-width: 1;" x="53.987" y="322.585" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">Step</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="177.05" y="323.6" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">####.##</text>
|
||||
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="233" y="323.56" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">s</text>
|
||||
<rect x="43.443" y="241.422" width="82.691" height="27.103" style="stroke: rgb(0, 0, 0); fill: rgb(120, 231, 228); stroke-width: 0.763;"/>
|
||||
<text style="fill: rgb(51, 51, 51); font-family: Bahnschrift; font-size: 14px; font-weight: 700; white-space: pre; stroke-width: 1;" x="54.237" y="364.271" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">Cycle Timer</text>
|
||||
<rect x="126.341" y="241.068" width="82.691" height="27.103" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(244, 248, 248);"/>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="168.775" y="365.807" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">####.##</text>
|
||||
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="225.25" y="365.246" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">s</text>
|
||||
<text style="fill: rgb(51, 51, 51); font-family: Bahnschrift; font-size: 13px; font-weight: 700; white-space: pre; stroke-width: 1;" x="141.894" y="324.069" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">Time</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="92.151" y="325.554" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">##</text>
|
||||
|
||||
<rect x="870.356" y="142.816" width="82.691" height="27.103" style="stroke: rgb(0, 0, 0); fill: rgb(120, 231, 228); stroke-width: 0.763;"/>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Bahnschrift; font-size: 14px; stroke-width: 1; font-weight: bold;" x="1060.06" y="224.103" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">Dryer Status</text>
|
||||
<rect x="870.356" y="170.304" width="82.691" height="42.35" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(244, 248, 248);"/>
|
||||
@@ -227,12 +229,33 @@
|
||||
<rect x="870.356" y="344.9" width="82.691" height="42.35" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(244, 248, 248);"/>
|
||||
<ellipse style="fill: rgb(216, 216, 216); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="911.702" cy="366.997" rx="20.673" ry="17.46"/>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Bahnschrift; font-size: 14px; stroke-width: 1; font-weight: bold;" x="380.451" y="296.591" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">REGEN</text>
|
||||
<ellipse style="fill: rgb(216, 216, 216); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="334.165" cy="232.104" rx="13.582" ry="12.517"/>
|
||||
<ellipse style="fill: rgb(216, 216, 216); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="336.418" cy="237.483" rx="13.582" ry="12.517"/>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Bahnschrift; font-size: 14px; stroke-width: 1; font-weight: bold;" x="379.214" y="423.395" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">DRYING</text>
|
||||
<ellipse style="fill: rgb(216, 216, 216); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="335.623" cy="320.662" rx="13.582" ry="12.517"/>
|
||||
<ellipse style="fill: rgb(255, 204, 63); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="335.418" cy="321.016" rx="13.582" ry="12.517"/>
|
||||
<ellipse style="fill: rgb(216, 216, 216); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="335.623" cy="320.662" rx="13.582" ry="12.517" id="c_4021"/>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Bahnschrift; font-size: 14px; stroke-width: 1; font-weight: bold;" x="897.237" y="299.014" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">REGEN</text>
|
||||
<ellipse style="fill: rgb(216, 216, 216); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="761.502" cy="233.796" rx="13.582" ry="12.517"/>
|
||||
<ellipse style="fill: rgb(255, 204, 63); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="761.772" cy="233.876" rx="13.582" ry="12.517"/>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Bahnschrift; font-size: 14px; stroke-width: 1; font-weight: bold;" x="896" y="425.818" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">DRYING</text>
|
||||
<ellipse style="fill: rgb(216, 216, 216); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="762.96" cy="322.354" rx="13.582" ry="12.517"/>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Bahnschrift; font-size: 35px; stroke-width: 1; font-weight: bold;" x="348.875" y="78.242" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">AIR DRYER UNIT A (01-CL-10532-A)</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="183.349" y="288.807" transform="matrix(0.826913, 0, 0, 0.698383, 1.386371, 4.000207)">HTLS</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="183.349" y="288.807" transform="matrix(0.826913, 0, 0, 0.698383, -1.613663, 3.937793)">BLWR</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="522.447" y="617.288" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)" id="c_4005">####.##</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="522.446" y="567.288" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)" id="c_4004">####.##</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="653.279" y="373.97" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)" id="c_4001">####.##</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="563.75" y="373.795" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)" id="c_4002">####.##</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="564.279" y="330.561" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)" id="c_4003">####.##</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="159.05" y="424.106" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)" id="c_4009">####.##</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="159.05" y="463.397" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)" id="c_4010">####.##</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="159.05" y="499.106" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)" id="c_4011">####.##</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="177.05" y="323.6" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)" id="c_4008">####.##</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="168.775" y="365.807" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)" id="c_4007">####.##</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="92.151" y="325.554" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)" id="c_4006">##</text>
|
||||
<ellipse style="fill: rgb(255, 204, 63); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="336.418" cy="237.483" rx="13.582" ry="12.517" id="c_4018"/>
|
||||
<ellipse style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(255, 172, 63);" cx="640.283" cy="271.689" rx="9.717" ry="7.689" id="c_4019"/>
|
||||
<ellipse style="fill: rgb(63, 255, 69); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="911.254" cy="192.696" rx="20.673" ry="17.46" id="c_4016"/>
|
||||
<ellipse style="fill: rgb(255, 159, 63); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="911.352" cy="279.12" rx="20.673" ry="17.46" id="c_4017"/>
|
||||
<ellipse style="fill: rgb(255, 63, 63); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="911.352" cy="366.862" rx="20.673" ry="17.46" id="c_4020"/>
|
||||
<ellipse style="fill: rgb(255, 204, 63); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="762.685" cy="322.259" rx="13.582" ry="12.517" id="c_4018"/>
|
||||
<ellipse style="fill: rgb(216, 216, 216); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="762.031" cy="234.094" rx="13.582" ry="12.517" id="c_4021"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 41 KiB |
@@ -3,7 +3,7 @@
|
||||
<defs>
|
||||
<bx:grid x="0" y="0" width="25" height="25"/>
|
||||
</defs>
|
||||
<rect y="10.407" width="972.648" height="439.023" style="fill: rgb(216, 216, 216); stroke: rgb(0, 0, 0); stroke-width: 0.763;" x="12.119"/>
|
||||
<rect y="10.407" width="972.648" height="440.159" style="fill: rgb(216, 216, 216); stroke: rgb(0, 0, 0); stroke-width: 0.763;" x="12.119"/>
|
||||
<g transform="matrix(0.826913, 0, 0, 0.698383, 74.03907, 53.375034)">
|
||||
<ellipse style="stroke: rgb(0, 0, 0); fill: rgb(243, 243, 243);" cx="315" cy="183.068" rx="45" ry="45"/>
|
||||
<ellipse style="stroke: rgb(0, 0, 0); stroke-width: 1; fill: rgb(243, 243, 243);" cx="315" cy="449.112" rx="45" ry="45"/>
|
||||
@@ -26,27 +26,26 @@
|
||||
<path style="fill: none; stroke: rgb(76, 76, 76); stroke-width: 1.222; transform-origin: 490.992px 230.229px;" d="M 646.097 240.002 L 676.271 240.002"/>
|
||||
</g>
|
||||
<path style="fill: none; stroke: rgb(0, 0, 0); stroke-width: 1.525; transform-origin: 443.701px 171.141px;" d="M 443.542 155.983 L 443.859 186.298"/>
|
||||
<g transform="matrix(0.826913, 0, 0, 0.698383, 0.443817, 3.138935)">
|
||||
<rect x="752" y="355.455" width="42.438" height="3.527" style="stroke: rgb(0, 0, 0); stroke-width: 1; fill: rgb(243, 243, 243);"/>
|
||||
<rect x="756.328" y="359.271" width="34.034" height="53.968" style="stroke: rgb(0, 0, 0); stroke-width: 1; fill: rgb(243, 243, 243);"/>
|
||||
<rect x="756.146" y="352.019" width="34.034" height="3.38" style="stroke: rgb(0, 0, 0); stroke-width: 1; fill: rgb(243, 243, 243);"/>
|
||||
<ellipse style="stroke: rgb(0, 0, 0); stroke-width: 1; fill: rgb(243, 243, 243);" cx="773.446" cy="384.7" rx="11.751" ry="11.009"/>
|
||||
<g>
|
||||
<rect x="622.282" y="251.383" width="35.093" height="2.463" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(243, 243, 243);"/>
|
||||
<g>
|
||||
<rect x="625.861" y="254.048" width="28.143" height="37.69" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(243, 243, 243);"/>
|
||||
<rect x="625.711" y="248.983" width="28.143" height="2.361" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(243, 243, 243);"/>
|
||||
<ellipse style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(243, 243, 243);" cx="640.016" cy="271.807" rx="9.717" ry="7.689"/>
|
||||
</g>
|
||||
</g>
|
||||
<rect x="461.861" y="211.956" width="62.018" height="9.968" style="stroke: rgb(0, 0, 0); fill: rgb(120, 231, 228); stroke-width: 0.763;"/>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 9px;" x="561" y="309.954" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">Heater Temp SP</text>
|
||||
<rect x="461.861" y="221.924" width="62.018" height="17.46" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(244, 248, 248);"/>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="564.279" y="330.561" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">####.##</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="609.476" y="330.521" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">°F</text>
|
||||
<rect x="461.424" y="242.149" width="62.018" height="9.968" style="stroke: rgb(0, 0, 0); fill: rgb(120, 231, 228); stroke-width: 0.763;"/>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 9px; stroke-width: 1;" x="567.471" y="352.188" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">Heater Temp</text>
|
||||
<rect x="461.424" y="252.117" width="62.018" height="17.46" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(244, 248, 248);"/>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="563.75" y="373.795" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)" id="Dry1_HeatTempCelsius">####.##</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="608.947" y="373.755" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">°F</text>
|
||||
<rect x="535.456" y="242.272" width="62.018" height="9.968" style="stroke: rgb(0, 0, 0); fill: rgb(120, 231, 228); stroke-width: 0.763;"/>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 9px; stroke-width: 1;" x="659" y="352.363" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">Heater Temp</text>
|
||||
<rect x="535.456" y="252.24" width="62.018" height="17.46" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(244, 248, 248);"/>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="653.279" y="373.97" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">####.##</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="698.476" y="373.93" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">°F</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="698.476" y="373.93" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">°C</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1; font-weight: bold;" x="748" y="347.676" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">HEATER</text>
|
||||
<path style="fill: none; stroke: rgb(0, 0, 0); stroke-width: 1.525; transform-origin: 678.512px 258.693px;" d="M 678.467 229.321 L 678.558 288.066" transform="matrix(0, 1.184039, -0.844567, 0, -0.000022, -0.000005)"/>
|
||||
<path style="fill: none; stroke: rgb(0, 0, 0); stroke-width: 1.525; transform-origin: 703.162px 309.166px;" d="M 703.004 258.049 L 703.32 360.282"/>
|
||||
@@ -108,12 +107,10 @@
|
||||
<rect x="427.269" y="377.282" width="62.018" height="9.968" style="stroke: rgb(0, 0, 0); fill: rgb(120, 231, 228); stroke-width: 0.763;"/>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 9px; stroke-width: 1;" x="532.167" y="545.681" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">Dew Temp</text>
|
||||
<rect x="427.269" y="387.25" width="62.018" height="17.46" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(244, 248, 248);"/>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="522.446" y="567.288" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">####.##</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="567.643" y="567.248" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">°C</text>
|
||||
<rect x="427.27" y="412.201" width="62.018" height="9.968" style="stroke: rgb(0, 0, 0); fill: rgb(120, 231, 228); stroke-width: 0.763;"/>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 9px; stroke-width: 1;" x="532.168" y="595.681" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">Dew Temp</text>
|
||||
<rect x="427.27" y="422.169" width="62.018" height="17.46" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(244, 248, 248);"/>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="522.447" y="617.288" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">####.##</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="567.644" y="617.248" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">°F</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 9px; stroke-width: 1; text-anchor: middle; font-weight: bolder;" x="602.463" y="573.003" transform="matrix(0.826913, 0, 0, 0.698383, 24.207672, -7.192523)">AIR<tspan x="602.4630126953125" dy="1em"></tspan>OUTLET</text>
|
||||
<g transform="matrix(-0.387768, 0, 0, -0.200385, 743.634644, -199.991287)" style="transform-origin: 72.2405px 412.5px;">
|
||||
@@ -177,17 +174,14 @@
|
||||
<rect x="43.443" y="280.75" width="82.691" height="27.103" style="stroke: rgb(0, 0, 0); fill: rgb(120, 231, 228); stroke-width: 0.763;"/>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Bahnschrift; font-size: 14px; stroke-width: 1; font-weight: bold;" x="53.987" y="423.091" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">RUN HOUR</text>
|
||||
<rect x="126.135" y="280.75" width="82.691" height="27.103" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(244, 248, 248);"/>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="159.05" y="424.106" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">####.##</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="225" y="424.066" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">H</text>
|
||||
<rect x="43.443" y="308.191" width="82.691" height="27.103" style="stroke: rgb(0, 0, 0); fill: rgb(120, 231, 228); stroke-width: 0.763;"/>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Bahnschrift; font-size: 13px; stroke-width: 1; font-weight: bold;" x="53.987" y="461.382" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">PURGE HOUR</text>
|
||||
<rect x="126.135" y="308.191" width="82.691" height="27.103" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(244, 248, 248);"/>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="159.05" y="463.397" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">####.##</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="225" y="463.357" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">H</text>
|
||||
<rect x="43.443" y="333.129" width="82.691" height="27.103" style="stroke: rgb(0, 0, 0); fill: rgb(120, 231, 228); stroke-width: 0.763;"/>
|
||||
<text style="fill: rgb(51, 51, 51); font-family: Bahnschrift; font-size: 12px; font-weight: 700; white-space: pre;" x="53.987" y="498.091" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">HEATER HOUR</text>
|
||||
<rect x="126.135" y="333.129" width="82.691" height="27.103" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(244, 248, 248);"/>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="159.05" y="499.106" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">####.##</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="225" y="499.066" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">H</text>
|
||||
<rect x="43.65" y="360.147" width="82.691" height="27.103" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(248, 213, 14);"/>
|
||||
<text style="fill: rgb(51, 51, 51); font-family: Bahnschrift; font-size: 14px; font-weight: 700; white-space: pre; stroke-width: 1;" x="54.237" y="536.777" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">Alarm Info</text>
|
||||
@@ -205,15 +199,12 @@
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="183.349" y="288.807" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">HTD</text>
|
||||
<rect x="43.443" y="214.051" width="165.383" height="27.103" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(244, 248, 248);"/>
|
||||
<text style="fill: rgb(51, 51, 51); font-family: Bahnschrift; font-size: 13px; font-weight: 700; white-space: pre; stroke-width: 1;" x="53.987" y="322.585" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">Step</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="177.05" y="323.6" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">####.##</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="233" y="323.56" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">s</text>
|
||||
<rect x="43.443" y="241.422" width="82.691" height="27.103" style="stroke: rgb(0, 0, 0); fill: rgb(120, 231, 228); stroke-width: 0.763;"/>
|
||||
<text style="fill: rgb(51, 51, 51); font-family: Bahnschrift; font-size: 14px; font-weight: 700; white-space: pre; stroke-width: 1;" x="54.237" y="364.271" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">Cycle Timer</text>
|
||||
<rect x="126.341" y="241.068" width="82.691" height="27.103" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(244, 248, 248);"/>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="168.775" y="365.807" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">####.##</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="225.25" y="365.246" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">s</text>
|
||||
<text style="fill: rgb(51, 51, 51); font-family: Bahnschrift; font-size: 13px; font-weight: 700; white-space: pre; stroke-width: 1;" x="141.894" y="324.069" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">Time</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="92.151" y="325.554" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">##</text>
|
||||
<rect x="870.356" y="142.816" width="82.691" height="27.103" style="stroke: rgb(0, 0, 0); fill: rgb(120, 231, 228); stroke-width: 0.763;"/>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Bahnschrift; font-size: 14px; stroke-width: 1; font-weight: bold;" x="1060.06" y="224.103" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">Dryer Status</text>
|
||||
<rect x="870.356" y="170.304" width="82.691" height="42.35" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(244, 248, 248);"/>
|
||||
@@ -227,12 +218,33 @@
|
||||
<rect x="870.356" y="344.9" width="82.691" height="42.35" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(244, 248, 248);"/>
|
||||
<ellipse style="fill: rgb(216, 216, 216); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="911.702" cy="366.997" rx="20.673" ry="17.46"/>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Bahnschrift; font-size: 14px; stroke-width: 1; font-weight: bold;" x="380.451" y="296.591" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">REGEN</text>
|
||||
<ellipse style="fill: rgb(216, 216, 216); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="334.165" cy="232.104" rx="13.582" ry="12.517"/>
|
||||
<ellipse style="fill: rgb(216, 216, 216); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="336.418" cy="237.483" rx="13.582" ry="12.517"/>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Bahnschrift; font-size: 14px; stroke-width: 1; font-weight: bold;" x="379.214" y="423.395" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">DRYING</text>
|
||||
<ellipse style="fill: rgb(216, 216, 216); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="335.623" cy="320.662" rx="13.582" ry="12.517"/>
|
||||
<ellipse style="fill: rgb(255, 204, 63); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="335.418" cy="321.016" rx="13.582" ry="12.517"/>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Bahnschrift; font-size: 14px; stroke-width: 1; font-weight: bold;" x="897.237" y="299.014" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">REGEN</text>
|
||||
<ellipse style="fill: rgb(216, 216, 216); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="761.502" cy="233.796" rx="13.582" ry="12.517"/>
|
||||
<ellipse style="fill: rgb(255, 204, 63); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="761.772" cy="233.876" rx="13.582" ry="12.517"/>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Bahnschrift; font-size: 14px; stroke-width: 1; font-weight: bold;" x="896" y="425.818" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">DRYING</text>
|
||||
<ellipse style="fill: rgb(216, 216, 216); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="762.96" cy="322.354" rx="13.582" ry="12.517"/>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Bahnschrift; font-size: 35px; stroke-width: 1; font-weight: bold;" x="348.875" y="78.242" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">AIR DRYER UNIT B (01-CL-10535-B)</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="183.349" y="288.807" transform="matrix(0.826913, 0, 0, 0.698383, 1.386371, 4.000207)">HTLS</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="183.349" y="288.807" transform="matrix(0.826913, 0, 0, 0.698383, -1.613663, 3.937793)">BLWR</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="522.447" y="617.288" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)" id="c_5005">####.##</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="522.446" y="567.288" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)" id="c_5004">####.##</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="653.279" y="373.97" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)" id="c_5001">####.##</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="563.75" y="373.795" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)" id="c_5002">####.##</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="564.279" y="330.561" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)" id="c_5003">####.##</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="159.05" y="424.106" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)" id="c_5009">####.##</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="159.05" y="463.397" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)" id="c_5010">####.##</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="159.05" y="499.106" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)" id="c_5011">####.##</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="177.05" y="323.6" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)" id="c_5008">####.##</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="168.775" y="365.807" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)" id="c_5007">####.##</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="92.151" y="325.554" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)" id="c_5006">##</text>
|
||||
<ellipse style="fill: rgb(255, 204, 63); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="336.418" cy="237.483" rx="13.582" ry="12.517" id="c_5018"/>
|
||||
<ellipse style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(255, 172, 63);" cx="640.283" cy="271.689" rx="9.717" ry="7.689" id="c_5019"/>
|
||||
<ellipse style="fill: rgb(63, 255, 69); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="911.254" cy="192.696" rx="20.673" ry="17.46" id="c_5016"/>
|
||||
<ellipse style="fill: rgb(255, 159, 63); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="911.352" cy="279.12" rx="20.673" ry="17.46" id="c_5017"/>
|
||||
<ellipse style="fill: rgb(255, 63, 63); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="911.352" cy="366.862" rx="20.673" ry="17.46" id="c_5020"/>
|
||||
<ellipse style="fill: rgb(216, 216, 216); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="335.623" cy="320.662" rx="13.582" ry="12.517" id="c_5021"/>
|
||||
<ellipse style="fill: rgb(255, 204, 63); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="762.685" cy="322.259" rx="13.582" ry="12.517" id="c_5018"/>
|
||||
<ellipse style="fill: rgb(216, 216, 216); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="762.031" cy="234.094" rx="13.582" ry="12.517" id="c_5021"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 41 KiB |
@@ -3,7 +3,7 @@
|
||||
<defs>
|
||||
<bx:grid x="0" y="0" width="25" height="25"/>
|
||||
</defs>
|
||||
<rect y="10.407" width="972.648" height="439.023" style="fill: rgb(216, 216, 216); stroke: rgb(0, 0, 0); stroke-width: 0.763;" x="12.119"/>
|
||||
<rect y="10.407" width="972.648" height="440.159" style="fill: rgb(216, 216, 216); stroke: rgb(0, 0, 0); stroke-width: 0.763;" x="12.119"/>
|
||||
<g transform="matrix(0.826913, 0, 0, 0.698383, 74.03907, 53.375034)">
|
||||
<ellipse style="stroke: rgb(0, 0, 0); fill: rgb(243, 243, 243);" cx="315" cy="183.068" rx="45" ry="45"/>
|
||||
<ellipse style="stroke: rgb(0, 0, 0); stroke-width: 1; fill: rgb(243, 243, 243);" cx="315" cy="449.112" rx="45" ry="45"/>
|
||||
@@ -26,27 +26,26 @@
|
||||
<path style="fill: none; stroke: rgb(76, 76, 76); stroke-width: 1.222; transform-origin: 490.992px 230.229px;" d="M 646.097 240.002 L 676.271 240.002"/>
|
||||
</g>
|
||||
<path style="fill: none; stroke: rgb(0, 0, 0); stroke-width: 1.525; transform-origin: 443.701px 171.141px;" d="M 443.542 155.983 L 443.859 186.298"/>
|
||||
<g transform="matrix(0.826913, 0, 0, 0.698383, 0.443817, 3.138935)">
|
||||
<rect x="752" y="355.455" width="42.438" height="3.527" style="stroke: rgb(0, 0, 0); stroke-width: 1; fill: rgb(243, 243, 243);"/>
|
||||
<rect x="756.328" y="359.271" width="34.034" height="53.968" style="stroke: rgb(0, 0, 0); stroke-width: 1; fill: rgb(243, 243, 243);"/>
|
||||
<rect x="756.146" y="352.019" width="34.034" height="3.38" style="stroke: rgb(0, 0, 0); stroke-width: 1; fill: rgb(243, 243, 243);"/>
|
||||
<ellipse style="stroke: rgb(0, 0, 0); stroke-width: 1; fill: rgb(243, 243, 243);" cx="773.446" cy="384.7" rx="11.751" ry="11.009"/>
|
||||
<g>
|
||||
<rect x="622.282" y="251.383" width="35.093" height="2.463" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(243, 243, 243);"/>
|
||||
<g>
|
||||
<rect x="625.861" y="254.048" width="28.143" height="37.69" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(243, 243, 243);"/>
|
||||
<rect x="625.711" y="248.983" width="28.143" height="2.361" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(243, 243, 243);"/>
|
||||
<ellipse style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(243, 243, 243);" cx="640.016" cy="271.807" rx="9.717" ry="7.689"/>
|
||||
</g>
|
||||
</g>
|
||||
<rect x="461.861" y="211.956" width="62.018" height="9.968" style="stroke: rgb(0, 0, 0); fill: rgb(120, 231, 228); stroke-width: 0.763;"/>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 9px;" x="561" y="309.954" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">Heater Temp SP</text>
|
||||
<rect x="461.861" y="221.924" width="62.018" height="17.46" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(244, 248, 248);"/>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="564.279" y="330.561" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">####.##</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="609.476" y="330.521" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">°F</text>
|
||||
<rect x="461.424" y="242.149" width="62.018" height="9.968" style="stroke: rgb(0, 0, 0); fill: rgb(120, 231, 228); stroke-width: 0.763;"/>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 9px; stroke-width: 1;" x="567.471" y="352.188" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">Heater Temp</text>
|
||||
<rect x="461.424" y="252.117" width="62.018" height="17.46" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(244, 248, 248);"/>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="563.75" y="373.795" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)" id="Dry1_HeatTempCelsius">####.##</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="608.947" y="373.755" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">°F</text>
|
||||
<rect x="535.456" y="242.272" width="62.018" height="9.968" style="stroke: rgb(0, 0, 0); fill: rgb(120, 231, 228); stroke-width: 0.763;"/>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 9px; stroke-width: 1;" x="659" y="352.363" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">Heater Temp</text>
|
||||
<rect x="535.456" y="252.24" width="62.018" height="17.46" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(244, 248, 248);"/>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="653.279" y="373.97" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">####.##</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="698.476" y="373.93" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">°F</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="698.476" y="373.93" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">°C</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1; font-weight: bold;" x="748" y="347.676" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">HEATER</text>
|
||||
<path style="fill: none; stroke: rgb(0, 0, 0); stroke-width: 1.525; transform-origin: 678.512px 258.693px;" d="M 678.467 229.321 L 678.558 288.066" transform="matrix(0, 1.184039, -0.844567, 0, -0.000022, -0.000005)"/>
|
||||
<path style="fill: none; stroke: rgb(0, 0, 0); stroke-width: 1.525; transform-origin: 703.162px 309.166px;" d="M 703.004 258.049 L 703.32 360.282"/>
|
||||
@@ -108,12 +107,10 @@
|
||||
<rect x="427.269" y="377.282" width="62.018" height="9.968" style="stroke: rgb(0, 0, 0); fill: rgb(120, 231, 228); stroke-width: 0.763;"/>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 9px; stroke-width: 1;" x="532.167" y="545.681" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">Dew Temp</text>
|
||||
<rect x="427.269" y="387.25" width="62.018" height="17.46" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(244, 248, 248);"/>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="522.446" y="567.288" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">####.##</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="567.643" y="567.248" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">°C</text>
|
||||
<rect x="427.27" y="412.201" width="62.018" height="9.968" style="stroke: rgb(0, 0, 0); fill: rgb(120, 231, 228); stroke-width: 0.763;"/>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 9px; stroke-width: 1;" x="532.168" y="595.681" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">Dew Temp</text>
|
||||
<rect x="427.27" y="422.169" width="62.018" height="17.46" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(244, 248, 248);"/>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="522.447" y="617.288" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">####.##</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="567.644" y="617.248" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">°F</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 9px; stroke-width: 1; text-anchor: middle; font-weight: bolder;" x="602.463" y="573.003" transform="matrix(0.826913, 0, 0, 0.698383, 24.207672, -7.192523)">AIR<tspan x="602.4630126953125" dy="1em"></tspan>OUTLET</text>
|
||||
<g transform="matrix(-0.387768, 0, 0, -0.200385, 743.634644, -199.991287)" style="transform-origin: 72.2405px 412.5px;">
|
||||
@@ -177,17 +174,14 @@
|
||||
<rect x="43.443" y="280.75" width="82.691" height="27.103" style="stroke: rgb(0, 0, 0); fill: rgb(120, 231, 228); stroke-width: 0.763;"/>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Bahnschrift; font-size: 14px; stroke-width: 1; font-weight: bold;" x="53.987" y="423.091" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">RUN HOUR</text>
|
||||
<rect x="126.135" y="280.75" width="82.691" height="27.103" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(244, 248, 248);"/>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="159.05" y="424.106" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">####.##</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="225" y="424.066" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">H</text>
|
||||
<rect x="43.443" y="308.191" width="82.691" height="27.103" style="stroke: rgb(0, 0, 0); fill: rgb(120, 231, 228); stroke-width: 0.763;"/>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Bahnschrift; font-size: 13px; stroke-width: 1; font-weight: bold;" x="53.987" y="461.382" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">PURGE HOUR</text>
|
||||
<rect x="126.135" y="308.191" width="82.691" height="27.103" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(244, 248, 248);"/>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="159.05" y="463.397" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">####.##</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="225" y="463.357" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">H</text>
|
||||
<rect x="43.443" y="333.129" width="82.691" height="27.103" style="stroke: rgb(0, 0, 0); fill: rgb(120, 231, 228); stroke-width: 0.763;"/>
|
||||
<text style="fill: rgb(51, 51, 51); font-family: Bahnschrift; font-size: 12px; font-weight: 700; white-space: pre;" x="53.987" y="498.091" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">HEATER HOUR</text>
|
||||
<rect x="126.135" y="333.129" width="82.691" height="27.103" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(244, 248, 248);"/>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="159.05" y="499.106" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">####.##</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="225" y="499.066" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">H</text>
|
||||
<rect x="43.65" y="360.147" width="82.691" height="27.103" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(248, 213, 14);"/>
|
||||
<text style="fill: rgb(51, 51, 51); font-family: Bahnschrift; font-size: 14px; font-weight: 700; white-space: pre; stroke-width: 1;" x="54.237" y="536.777" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">Alarm Info</text>
|
||||
@@ -205,15 +199,12 @@
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="183.349" y="288.807" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">HTD</text>
|
||||
<rect x="43.443" y="214.051" width="165.383" height="27.103" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(244, 248, 248);"/>
|
||||
<text style="fill: rgb(51, 51, 51); font-family: Bahnschrift; font-size: 13px; font-weight: 700; white-space: pre; stroke-width: 1;" x="53.987" y="322.585" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">Step</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="177.05" y="323.6" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">####.##</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="233" y="323.56" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">s</text>
|
||||
<rect x="43.443" y="241.422" width="82.691" height="27.103" style="stroke: rgb(0, 0, 0); fill: rgb(120, 231, 228); stroke-width: 0.763;"/>
|
||||
<text style="fill: rgb(51, 51, 51); font-family: Bahnschrift; font-size: 14px; font-weight: 700; white-space: pre; stroke-width: 1;" x="54.237" y="364.271" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">Cycle Timer</text>
|
||||
<rect x="126.341" y="241.068" width="82.691" height="27.103" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(244, 248, 248);"/>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="168.775" y="365.807" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">####.##</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="225.25" y="365.246" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">s</text>
|
||||
<text style="fill: rgb(51, 51, 51); font-family: Bahnschrift; font-size: 13px; font-weight: 700; white-space: pre; stroke-width: 1;" x="141.894" y="324.069" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">Time</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="92.151" y="325.554" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">##</text>
|
||||
<rect x="870.356" y="142.816" width="82.691" height="27.103" style="stroke: rgb(0, 0, 0); fill: rgb(120, 231, 228); stroke-width: 0.763;"/>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Bahnschrift; font-size: 14px; stroke-width: 1; font-weight: bold;" x="1060.06" y="224.103" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">Dryer Status</text>
|
||||
<rect x="870.356" y="170.304" width="82.691" height="42.35" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(244, 248, 248);"/>
|
||||
@@ -227,12 +218,34 @@
|
||||
<rect x="870.356" y="344.9" width="82.691" height="42.35" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(244, 248, 248);"/>
|
||||
<ellipse style="fill: rgb(216, 216, 216); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="911.702" cy="366.997" rx="20.673" ry="17.46"/>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Bahnschrift; font-size: 14px; stroke-width: 1; font-weight: bold;" x="380.451" y="296.591" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">REGEN</text>
|
||||
<ellipse style="fill: rgb(216, 216, 216); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="334.165" cy="232.104" rx="13.582" ry="12.517"/>
|
||||
<ellipse style="fill: rgb(216, 216, 216); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="336.418" cy="237.483" rx="13.582" ry="12.517"/>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Bahnschrift; font-size: 14px; stroke-width: 1; font-weight: bold;" x="379.214" y="423.395" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">DRYING</text>
|
||||
<ellipse style="fill: rgb(216, 216, 216); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="335.623" cy="320.662" rx="13.582" ry="12.517"/>
|
||||
<ellipse style="fill: rgb(255, 204, 63); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="335.418" cy="321.016" rx="13.582" ry="12.517"/>
|
||||
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Bahnschrift; font-size: 14px; stroke-width: 1; font-weight: bold;" x="897.237" y="299.014" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">REGEN</text>
|
||||
<ellipse style="fill: rgb(216, 216, 216); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="761.502" cy="233.796" rx="13.582" ry="12.517"/>
|
||||
<ellipse style="fill: rgb(255, 204, 63); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="761.772" cy="233.876" rx="13.582" ry="12.517"/>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Bahnschrift; font-size: 14px; stroke-width: 1; font-weight: bold;" x="896" y="425.818" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">DRYING</text>
|
||||
<ellipse style="fill: rgb(216, 216, 216); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="762.96" cy="322.354" rx="13.582" ry="12.517"/>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Bahnschrift; font-size: 35px; stroke-width: 1; font-weight: bold;" x="348.875" y="78.242" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">AIR DRYER UNIT C (01-CL-10539-C)</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="183.349" y="288.807" transform="matrix(0.826913, 0, 0, 0.698383, 1.386371, 4.000207)">HTLS</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="183.349" y="288.807" transform="matrix(0.826913, 0, 0, 0.698383, -1.613663, 3.937793)">BLWR</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="522.447" y="617.288" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)" id="c_6005">####.##</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="522.446" y="567.288" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)" id="c_6004">####.##</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="653.279" y="373.97" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)" id="c_6001">####.##</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="563.75" y="373.795" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)" id="c_6002">####.##</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="564.279" y="330.561" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)" id="c_6003">####.##</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="159.05" y="424.106" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)" id="c_6009">####.##</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="159.05" y="463.397" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)" id="c_6010">####.##</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="159.05" y="499.106" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)" id="c_6011">####.##</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="177.05" y="323.6" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)" id="c_6008">####.##</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="168.775" y="365.807" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)" id="c_6007">####.##</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="92.151" y="325.554" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)" id="c_6006">##</text>
|
||||
<ellipse style="fill: rgb(255, 204, 63); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="336.418" cy="237.483" rx="13.582" ry="12.517" id="c_6018"/>
|
||||
<ellipse style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(255, 172, 63);" cx="640.283" cy="271.689" rx="9.717" ry="7.689" id="c_6019"/>
|
||||
<ellipse style="fill: rgb(63, 255, 69); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="911.254" cy="192.696" rx="20.673" ry="17.46" id="c_6016"/>
|
||||
<ellipse style="fill: rgb(255, 159, 63); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="911.352" cy="279.12" rx="20.673" ry="17.46" id="c_6017"/>
|
||||
<ellipse style="fill: rgb(255, 63, 63); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="911.352" cy="366.862" rx="20.673" ry="17.46" id="c_6020"/>
|
||||
<ellipse style="fill: rgb(255, 204, 63); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="762.685" cy="322.259" rx="13.582" ry="12.517" id="c_6018"/>
|
||||
<ellipse style="fill: rgb(216, 216, 216); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="335.623" cy="320.662" rx="13.582" ry="12.517" id="c_6021"/>
|
||||
<ellipse style="fill: rgb(216, 216, 216); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="762.031" cy="234.094" rx="13.582" ry="12.517" id="c_6021"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 41 KiB |
@@ -1971,12 +1971,12 @@
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 9px; stroke-width: 1;" x="718.035" y="174.17">MPa</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 9px; stroke-width: 1;" x="229.981" y="200.126">####</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 9px; stroke-width: 1;" x="229.217" y="233.522">####</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 9px; stroke-width: 1;" x="229.599" y="265.154">####</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 9px; stroke-width: 1;" x="229.599" y="265.154" id="c_1003">####</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 9px; stroke-width: 1;" x="189.546" y="326.378">####</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 9px; stroke-width: 1;" x="361.232" y="371.05">####</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 9px; stroke-width: 1;" x="361.232" y="371.05" id="c_1004">####</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 9px; stroke-width: 1;" x="622.21" y="304.496">####</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 9px; stroke-width: 1;" x="684.474" y="141.612">####</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 9px; stroke-width: 1;" x="686.145" y="174.534">####</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 9px; stroke-width: 1;" x="684.474" y="141.612" id="c_1001">####</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 9px; stroke-width: 1;" x="686.145" y="174.534" id="c_1002">####</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 9px; stroke-width: 1;" x="893.7" y="201.982">####</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 9px; stroke-width: 1;" x="893.661" y="239.324">####</text>
|
||||
<text style="fill: rgb(51, 51, 51); font-family: Bahnschrift; font-size: 9px; white-space: pre; font-weight: bolder;" transform="matrix(0.705508, 0, 0, 0.49184, 796.826824, 48.14839)" x="38.471" y="128.844">Plant Air Reciever</text>
|
||||
|
||||
|
Before Width: | Height: | Size: 177 KiB After Width: | Height: | Size: 177 KiB |
@@ -30,18 +30,18 @@ instance.interceptors.response.use(
|
||||
originalRequest._retry = true;
|
||||
|
||||
try {
|
||||
console.log('🔄 Refresh token dipanggil...');
|
||||
// console.log('🔄 Refresh token dipanggil...');
|
||||
const refreshRes = await refreshApi.post('/auth/refresh-token');
|
||||
|
||||
const newAccessToken = refreshRes.data.data.accessToken;
|
||||
localStorage.setItem('token', newAccessToken);
|
||||
console.log('✅ Token refreshed successfully');
|
||||
// console.log('✅ Token refreshed successfully');
|
||||
|
||||
// update token di header
|
||||
instance.defaults.headers.common['Authorization'] = `Bearer ${newAccessToken}`;
|
||||
originalRequest.headers['Authorization'] = `Bearer ${newAccessToken}`;
|
||||
|
||||
console.log('🔁 Retrying original request...');
|
||||
// console.log('🔁 Retrying original request...');
|
||||
return instance(originalRequest);
|
||||
} catch (refreshError) {
|
||||
console.error(
|
||||
@@ -70,24 +70,35 @@ async function ApiRequest({ method = 'GET', params = {}, prefix = '/', token = t
|
||||
},
|
||||
};
|
||||
|
||||
const rawToken = localStorage.getItem('token');
|
||||
const tokenRedirect = sessionStorage.getItem('token_redirect');
|
||||
|
||||
let rawToken = '';
|
||||
|
||||
if (tokenRedirect !== null) {
|
||||
rawToken = tokenRedirect;
|
||||
// console.log(`sessionStorage: ${tokenRedirect}`);
|
||||
} else {
|
||||
rawToken = localStorage.getItem('token');
|
||||
// console.log(`localStorage: ${rawToken}`);
|
||||
}
|
||||
|
||||
if (token && rawToken) {
|
||||
const cleanToken = rawToken.replace(/"/g, '');
|
||||
request.headers['Authorization'] = `Bearer ${cleanToken}`;
|
||||
console.log('🔐 Sending request with token:', cleanToken.substring(0, 20) + '...');
|
||||
// console.log('🔐 Sending request with token:', cleanToken.substring(0, 20) + '...');
|
||||
} else {
|
||||
console.warn('⚠️ No token found in localStorage');
|
||||
}
|
||||
|
||||
console.log('📤 API Request:', { method, url: prefix, hasToken: !!rawToken });
|
||||
// console.log('📤 API Request:', { method, url: prefix, hasToken: !!rawToken });
|
||||
|
||||
try {
|
||||
const response = await instance(request);
|
||||
console.log('✅ API Response:', {
|
||||
url: prefix,
|
||||
status: response.status,
|
||||
statusCode: response.data?.statusCode,
|
||||
});
|
||||
// console.log('✅ API Response:', {
|
||||
// url: prefix,
|
||||
// status: response.status,
|
||||
// statusCode: response.data?.statusCode,
|
||||
// });
|
||||
return { ...response, error: false };
|
||||
} catch (error) {
|
||||
const status = error?.response?.status || 500;
|
||||
@@ -132,17 +143,10 @@ async function cekError(status, message = '') {
|
||||
const SendRequest = async (queryParams) => {
|
||||
try {
|
||||
const response = await ApiRequest(queryParams);
|
||||
console.log('📦 SendRequest response:', {
|
||||
hasError: response.error,
|
||||
status: response.status,
|
||||
statusCode: response.data?.statusCode,
|
||||
data: response.data,
|
||||
});
|
||||
|
||||
// If ApiRequest returned error flag, return error structure
|
||||
if (response.error) {
|
||||
const errorMsg = response.data?.message || response.statusText || 'Request failed';
|
||||
console.error('❌ SendRequest error response:', errorMsg);
|
||||
|
||||
// Return consistent error structure instead of empty array
|
||||
return {
|
||||
|
||||
@@ -2,7 +2,16 @@
|
||||
import mqtt from 'mqtt';
|
||||
|
||||
const mqttUrl = `${import.meta.env.VITE_MQTT_SERVER ?? 'ws://localhost:1884'}`;
|
||||
const topics = ['PIU_GGCP/Devices/PB'];
|
||||
const topics = [
|
||||
'PIU_COD/AIR_DRYER/OVERVIEW',
|
||||
'PIU_COD/AIR_DRYER/AIR_DRYER_A',
|
||||
'PIU_COD/AIR_DRYER/AIR_DRYER_B',
|
||||
'PIU_COD/AIR_DRYER/AIR_DRYER_C',
|
||||
'PIU_COD/COMPRESSOR/OVERVIEW',
|
||||
'PIU_COD/COMPRESSOR/COMPRESSOR_A',
|
||||
'PIU_COD/COMPRESSOR/COMPRESSOR_B',
|
||||
'PIU_COD/COMPRESSOR/COMPRESSOR_C'
|
||||
];
|
||||
const options = {
|
||||
keepalive: 30,
|
||||
clientId: 'react_mqtt_' + Math.random().toString(16).substr(2, 8),
|
||||
@@ -66,7 +75,8 @@ const listenMessage = (callback) => {
|
||||
|
||||
const setValSvg = (listenTopic, svg) => {
|
||||
client.on('message', (topic, message) => {
|
||||
if (topic == listenTopic) {
|
||||
// console.log(topic ,' = ', listenTopic);
|
||||
if (topic === listenTopic) {
|
||||
const objChanel = JSON.parse(message);
|
||||
|
||||
Object.entries(objChanel).forEach(([key, value]) => {
|
||||
@@ -78,7 +88,7 @@ const setValSvg = (listenTopic, svg) => {
|
||||
} else if (value === false) {
|
||||
el.style.display = 'none';
|
||||
} else if (!isNaN(value)) {
|
||||
el.textContent = Number(value ?? 0.0);
|
||||
el.textContent = Number(value ?? 0.0).toFixed(2);
|
||||
} else {
|
||||
el.textContent = value;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,9 @@ const TableList = memo(function TableList({
|
||||
fieldColor,
|
||||
firstLoad = true,
|
||||
columnDynamic = false,
|
||||
cardComponent, // New prop for custom card component
|
||||
onStockUpdate, // Prop to pass to card component
|
||||
onGetData, // Callback to execute when data is received
|
||||
}) {
|
||||
const [gridLoading, setGridLoading] = useState(false);
|
||||
|
||||
@@ -103,7 +106,14 @@ const TableList = memo(function TableList({
|
||||
setColumnsDynamic([...defaultColumns, ...numericColumns]);
|
||||
}
|
||||
|
||||
setData(resData?.data ?? []);
|
||||
const fetchedData = resData?.data ?? [];
|
||||
|
||||
// Panggil callback jika disediakan
|
||||
if (onGetData && typeof onGetData === 'function') {
|
||||
onGetData(fetchedData);
|
||||
}
|
||||
|
||||
setData(fetchedData);
|
||||
|
||||
const pagingData = resData?.paging;
|
||||
|
||||
@@ -142,6 +152,9 @@ const TableList = memo(function TableList({
|
||||
|
||||
const isMobile = !screens.md; // kalau kurang dari md (768px) dianggap mobile
|
||||
|
||||
// Use the custom card component if provided, otherwise default to CardList
|
||||
const CardViewComponent = cardComponent || CardList;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Segmented
|
||||
@@ -153,7 +166,7 @@ const TableList = memo(function TableList({
|
||||
onChange={setViewMode}
|
||||
/>
|
||||
{(isMobile && mobile) || viewMode === 'card' ? (
|
||||
<CardList
|
||||
<CardViewComponent
|
||||
cardColor={cardColor}
|
||||
fieldColor={fieldColor}
|
||||
data={data}
|
||||
@@ -162,6 +175,7 @@ const TableList = memo(function TableList({
|
||||
showPreviewModal={showPreviewModal}
|
||||
showEditModal={showEditModal}
|
||||
showDeleteDialog={showDeleteDialog}
|
||||
onStockUpdate={onStockUpdate}
|
||||
/>
|
||||
) : (
|
||||
<Row gutter={24} style={{ marginTop: '16px' }}>
|
||||
@@ -200,3 +214,4 @@ const TableList = memo(function TableList({
|
||||
});
|
||||
|
||||
export default TableList;
|
||||
|
||||
|
||||
@@ -20,36 +20,65 @@ html body {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
/* Custom Orange Sidebar Menu Styles */
|
||||
.custom-orange-menu.ant-menu-dark .ant-menu-item-selected {
|
||||
/* Custom green Sidebar Menu Styles */
|
||||
.custom-green-menu.ant-menu-dark .ant-menu-item-selected {
|
||||
background-color: rgba(255, 255, 255, 0.2) !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.custom-orange-menu.ant-menu-dark .ant-menu-item-selected::after {
|
||||
.custom-green-menu.ant-menu-dark .ant-menu-item-selected::after {
|
||||
border-right-color: white !important;
|
||||
}
|
||||
|
||||
.custom-orange-menu.ant-menu-dark .ant-menu-item:hover,
|
||||
.custom-orange-menu.ant-menu-dark .ant-menu-submenu-title:hover {
|
||||
.custom-green-menu.ant-menu-dark .ant-menu-item:hover,
|
||||
.custom-green-menu.ant-menu-dark .ant-menu-submenu-title:hover {
|
||||
background-color: rgba(255, 255, 255, 0.15) !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.custom-orange-menu.ant-menu-dark .ant-menu-submenu-selected > .ant-menu-submenu-title {
|
||||
.custom-green-menu.ant-menu-dark .ant-menu-submenu-selected > .ant-menu-submenu-title {
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.custom-orange-menu.ant-menu-dark.ant-menu-inline .ant-menu-sub {
|
||||
.custom-green-menu.ant-menu-dark.ant-menu-inline .ant-menu-sub {
|
||||
background: rgba(0, 0, 0, 0.2) !important;
|
||||
}
|
||||
|
||||
.custom-orange-menu.ant-menu-dark .ant-menu-item,
|
||||
.custom-orange-menu.ant-menu-dark .ant-menu-submenu-title {
|
||||
.custom-green-menu.ant-menu-dark .ant-menu-item,
|
||||
.custom-green-menu.ant-menu-dark .ant-menu-submenu-title {
|
||||
color: rgba(255, 255, 255, 0.9) !important;
|
||||
}
|
||||
|
||||
.custom-orange-menu.ant-menu-dark .ant-menu-item-active,
|
||||
.custom-orange-menu.ant-menu-dark .ant-menu-submenu-active > .ant-menu-submenu-title {
|
||||
.custom-green-menu.ant-menu-dark .ant-menu-item-active,
|
||||
.custom-green-menu.ant-menu-dark .ant-menu-submenu-active > .ant-menu-submenu-title {
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
/*start styling for scrollbar menu */
|
||||
.custom-menu-scrollbar::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
.custom-menu-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
border-radius: 10px;
|
||||
margin: 5px 0;
|
||||
}
|
||||
.custom-menu-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: linear-gradient(180deg, #1BAA56 0%, rgb(5, 75, 34) 100%);
|
||||
border-radius: 10px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
.custom-menu-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: linear-gradient(180deg, #2bc56d 0%, rgb(8, 94, 43) 100%);
|
||||
}
|
||||
.custom-menu-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #1BAA56 transparent;
|
||||
}
|
||||
/* Hilangkan panah atas/bawah dengan important */
|
||||
.custom-menu-scrollbar::-webkit-scrollbar-button {
|
||||
display: none !important;
|
||||
width: 0 !important;
|
||||
height: 0 !important;
|
||||
}
|
||||
/*end styling for scrollbar menu */
|
||||
@@ -146,7 +146,7 @@ const allItems = [
|
||||
{
|
||||
key: 'master-sparepart',
|
||||
icon: <ToolOutlined style={{ fontSize: '19px' }} />,
|
||||
label: <Link to="/master/sparepart">sparepart</Link>,
|
||||
label: <Link to="/master/sparepart">Sparepart</Link>,
|
||||
},
|
||||
// {
|
||||
// key: 'master-shift',
|
||||
@@ -422,7 +422,7 @@ const LayoutMenu = () => {
|
||||
border: 'none',
|
||||
}}
|
||||
theme="dark"
|
||||
className="custom-orange-menu"
|
||||
className="custom-green-menu"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -30,8 +30,24 @@ const LayoutSidebar = () => {
|
||||
zIndex: 9999
|
||||
}}
|
||||
>
|
||||
<LayoutLogo />
|
||||
<LayoutMenu />
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100vh',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
{/* Logo section - fixed height */}
|
||||
<div style={{flexShrink: 0,minHeight: '64px'}}>
|
||||
<LayoutLogo />
|
||||
</div>
|
||||
|
||||
{/* Menu section - scrollable */}
|
||||
<div style={{flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column'}}>
|
||||
<div className="custom-menu-scrollbar" style={{flex: 1, overflowY: 'auto', overflowX: 'hidden', backgroundColor: 'transparent'}}>
|
||||
<LayoutMenu />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Sider>
|
||||
);
|
||||
};
|
||||
|
||||
49
src/pages/blank/RedirectWa.jsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { verifyRedirect } from '../../api/auth';
|
||||
import { encryptData } from '../../components/Global/Formatter';
|
||||
import NotFound from './NotFound';
|
||||
import Waiting from './Waiting';
|
||||
import NotificationDetailTab from '../notificationDetail/IndexNotificationDetail';
|
||||
|
||||
export default function RedirectWa() {
|
||||
const [idData, setIdData] = useState(0);
|
||||
const [ready, setReady] = useState(0);
|
||||
|
||||
const location = useLocation();
|
||||
|
||||
// URLSearchParams untuk ambil query
|
||||
const queryParams = new URLSearchParams(location.search);
|
||||
const token = queryParams.get('token');
|
||||
|
||||
const handleInitForm = async (encodedToken) => {
|
||||
const originalToken = decodeURIComponent(encodedToken);
|
||||
// console.log(originalToken);
|
||||
|
||||
const response = await verifyRedirect({
|
||||
tokenRedirect: originalToken,
|
||||
});
|
||||
|
||||
console.log('tes', response);
|
||||
|
||||
const tokenResult = JSON.stringify(response.data?.data?.accessToken);
|
||||
|
||||
sessionStorage.setItem('token_redirect', tokenResult);
|
||||
response.data.auth = true;
|
||||
sessionStorage.setItem('session', encryptData(response?.data));
|
||||
|
||||
setIdData(response.data.data.idData);
|
||||
|
||||
setReady(1);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
handleInitForm(token);
|
||||
}, [idData]);
|
||||
|
||||
if (ready == 0) return <Waiting />;
|
||||
|
||||
if (idData === 0) return <NotFound />;
|
||||
|
||||
return <NotificationDetailTab id={idData} />;
|
||||
}
|
||||
@@ -14,7 +14,6 @@ const DetailContact = memo(function DetailContact(props) {
|
||||
name: '',
|
||||
phone: '',
|
||||
is_active: true,
|
||||
contact_type: '',
|
||||
};
|
||||
|
||||
const [formData, setFormData] = useState(defaultData);
|
||||
@@ -37,12 +36,6 @@ const DetailContact = memo(function DetailContact(props) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleContactTypeChange = (value) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
contact_type: value,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleStatusToggle = (checked) => {
|
||||
setFormData({
|
||||
@@ -58,7 +51,6 @@ const DetailContact = memo(function DetailContact(props) {
|
||||
const validationRules = [
|
||||
{ field: 'name', label: 'Contact Name', required: true },
|
||||
{ field: 'phone', label: 'Phone', required: true },
|
||||
{ field: 'contact_type', label: 'Contact Type', required: true },
|
||||
];
|
||||
|
||||
if (
|
||||
@@ -97,7 +89,6 @@ const DetailContact = memo(function DetailContact(props) {
|
||||
contact_name: formData.name,
|
||||
contact_phone: formData.phone.replace(/[\s\-\(\)]/g, ''), // Clean phone number
|
||||
is_active: formData.is_active,
|
||||
contact_type: formData.contact_type,
|
||||
};
|
||||
|
||||
let response;
|
||||
@@ -145,18 +136,16 @@ const DetailContact = memo(function DetailContact(props) {
|
||||
phone: props.selectedData.contact_phone || props.selectedData.phone,
|
||||
is_active:
|
||||
props.selectedData.is_active || props.selectedData.status === 'active',
|
||||
contact_type: props.selectedData.contact_type || props.contactType || '',
|
||||
});
|
||||
} else if (props.actionMode === 'add') {
|
||||
setFormData({
|
||||
name: '',
|
||||
phone: '',
|
||||
is_active: true,
|
||||
contact_type: props.contactType === 'all' ? '' : props.contactType || '',
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [props.showModal, props.actionMode, props.selectedData, props.contactType]);
|
||||
}, [props.showModal, props.actionMode, props.selectedData]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -205,27 +194,36 @@ const DetailContact = memo(function DetailContact(props) {
|
||||
]}
|
||||
>
|
||||
<div style={{ padding: '8px 0' }}>
|
||||
<div>
|
||||
<div>
|
||||
<Text strong>Status</Text>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', marginTop: '8px' }}>
|
||||
<div style={{ marginRight: '8px' }}>
|
||||
<Switch
|
||||
disabled={props.readOnly}
|
||||
style={{
|
||||
backgroundColor: formData.is_active ? '#23A55A' : '#bfbfbf',
|
||||
}}
|
||||
checked={formData.is_active}
|
||||
onChange={handleStatusToggle}
|
||||
/>
|
||||
</div>
|
||||
{/* Status field only show in add mode*/}
|
||||
{props.actionMode === 'add' && (
|
||||
<>
|
||||
<div>
|
||||
<Text>{formData.is_active ? 'Active' : 'Inactive'}</Text>
|
||||
<div>
|
||||
<Text strong>Status</Text>
|
||||
</div>
|
||||
<div
|
||||
style={{ display: 'flex', alignItems: 'center', marginTop: '8px' }}
|
||||
>
|
||||
<div style={{ marginRight: '8px' }}>
|
||||
<Switch
|
||||
disabled={props.readOnly}
|
||||
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>
|
||||
</div>
|
||||
<Divider style={{ margin: '12px 0' }} />
|
||||
<Divider style={{ margin: '12px 0' }} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<Text strong>Name</Text>
|
||||
@@ -251,7 +249,8 @@ const DetailContact = memo(function DetailContact(props) {
|
||||
style={{ color: formData.is_active ? '#000000' : '#ff4d4f' }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
{/* Contact Type */}
|
||||
{/* <div style={{ marginBottom: 12 }}>
|
||||
<Text strong>Contact Type</Text>
|
||||
<Text style={{ color: 'red' }}> *</Text>
|
||||
<Select
|
||||
@@ -264,7 +263,7 @@ const DetailContact = memo(function DetailContact(props) {
|
||||
<Select.Option value="operator">Operator</Select.Option>
|
||||
<Select.Option value="gudang">Gudang</Select.Option>
|
||||
</Select>
|
||||
</div>
|
||||
</div> */}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { memo, useState, useEffect } from 'react';
|
||||
import { Button, Row, Col, Input, Tabs, Space, ConfigProvider, Card, Tag } from 'antd';
|
||||
import { Button, Row, Col, Input, Tabs, Space, ConfigProvider, Card, Tag, Switch } from 'antd';
|
||||
import {
|
||||
PlusOutlined,
|
||||
EditOutlined,
|
||||
@@ -10,9 +10,43 @@ import {
|
||||
} from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { NotifAlert, NotifConfirmDialog } from '../../../components/Global/ToastNotif';
|
||||
import { getAllContact, deleteContact } from '../../../api/contact';
|
||||
import { getAllContact, deleteContact, updateContact } 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 (
|
||||
<Col xs={24} sm={12} md={8} lg={6}>
|
||||
<div
|
||||
@@ -44,7 +78,7 @@ const ContactCard = memo(function ContactCard({ contact, showEditModal, showDele
|
||||
}}
|
||||
>
|
||||
{/* 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
|
||||
color={
|
||||
contact.contact_type === 'operator'
|
||||
@@ -57,19 +91,37 @@ const ContactCard = memo(function ContactCard({ contact, showEditModal, showDele
|
||||
>
|
||||
{contact.contact_type === 'operator' ? 'Operator' : contact.contact_type === 'gudang' ? 'Gudang' : 'Unknown'}
|
||||
</Tag>
|
||||
</div>
|
||||
</div> */}
|
||||
|
||||
{/* Status Badge - Top Right */}
|
||||
<div style={{ position: 'absolute', top: 0, right: 0, zIndex: 1 }}>
|
||||
{contact.status === 'active' ? (
|
||||
<Tag color={'green'} style={{ fontSize: '11px' }}>
|
||||
Active
|
||||
</Tag>
|
||||
) : (
|
||||
<Tag color={'red'} style={{ fontSize: '11px' }}>
|
||||
InActive
|
||||
</Tag>
|
||||
)}
|
||||
{/* Status Slider - Top Right */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 0,
|
||||
zIndex: 1,
|
||||
padding: '4px 8px',
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
|
||||
{/* Main Content */}
|
||||
@@ -215,9 +267,6 @@ const ListContact = memo(function ListContact(props) {
|
||||
}
|
||||
}
|
||||
|
||||
// Backend doesn't support is_active filter or order parameter
|
||||
// Contact hanya supports: criteria, name, code, limit, page
|
||||
|
||||
const queryParams = new URLSearchParams();
|
||||
Object.entries(searchParams).forEach(([key, value]) => {
|
||||
if (value !== '' && value !== null && value !== undefined) {
|
||||
@@ -257,11 +306,10 @@ const ListContact = memo(function ListContact(props) {
|
||||
// Listen for saved contact data
|
||||
useEffect(() => {
|
||||
if (props.lastSavedContact) {
|
||||
fetchContacts(); // Refetch all contacts when data is saved
|
||||
fetchContacts();
|
||||
}
|
||||
}, [props.lastSavedContact]);
|
||||
|
||||
// Get contacts (already filtered by backend)
|
||||
const getFilteredContacts = () => {
|
||||
return filteredContacts;
|
||||
};
|
||||
@@ -274,7 +322,7 @@ const ListContact = memo(function ListContact(props) {
|
||||
const showAddModal = () => {
|
||||
props.setSelectedData(null);
|
||||
props.setActionMode('add');
|
||||
// Pass the current active tab to determine contact type
|
||||
|
||||
props.setContactType?.(activeTab);
|
||||
};
|
||||
|
||||
@@ -316,7 +364,7 @@ const ListContact = memo(function ListContact(props) {
|
||||
<Row justify="space-between" align="middle" gutter={[8, 8]}>
|
||||
<Col xs={24} sm={24} md={12} lg={12}>
|
||||
<Input.Search
|
||||
placeholder="Search by name or type..."
|
||||
placeholder="Search by name..."
|
||||
value={formDataFilter.criteria}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
@@ -382,7 +430,8 @@ const ListContact = memo(function ListContact(props) {
|
||||
marginBottom: '16px',
|
||||
}}
|
||||
>
|
||||
<Tabs
|
||||
{/* Tabs */}
|
||||
{/* <Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={setActiveTab}
|
||||
size="large"
|
||||
@@ -400,7 +449,7 @@ const ListContact = memo(function ListContact(props) {
|
||||
label: 'Gudang',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
/> */}
|
||||
</div>
|
||||
|
||||
{getFilteredContacts().length === 0 ? (
|
||||
@@ -423,6 +472,7 @@ const ListContact = memo(function ListContact(props) {
|
||||
}}
|
||||
showEditModal={showEditModal}
|
||||
showDeleteModal={showDeleteModal}
|
||||
onStatusToggle={fetchContacts}
|
||||
/>
|
||||
))}
|
||||
</Row>
|
||||
|
||||
@@ -1,357 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Layout,
|
||||
Card,
|
||||
Row,
|
||||
Col,
|
||||
Typography,
|
||||
Space,
|
||||
Button,
|
||||
Spin,
|
||||
Result,
|
||||
Input,
|
||||
} from 'antd';
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
CloseCircleFilled,
|
||||
WarningFilled,
|
||||
CheckCircleFilled,
|
||||
InfoCircleFilled,
|
||||
CloseOutlined,
|
||||
BookOutlined,
|
||||
ToolOutlined,
|
||||
HistoryOutlined,
|
||||
FilePdfOutlined,
|
||||
PlusOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons';
|
||||
// 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;
|
||||
@@ -8,7 +8,7 @@ import filePathSvg from '../../assets/svg/air_dryer_A_rev.svg';
|
||||
const { Text } = Typography;
|
||||
|
||||
// const filePathSvg = '/src/assets/svg/air_dryer_A_rev.svg';
|
||||
const topicMqtt = 'PIU_GGCP/Devices/PB';
|
||||
const topicMqtt = 'PIU_COD/AIR_DRYER/AIR_DRYER_A';
|
||||
|
||||
const SvgAirDryerA = () => {
|
||||
return (
|
||||
|
||||
@@ -8,7 +8,7 @@ import filePathSvg from '../../assets/svg/air_dryer_B_rev.svg';
|
||||
const { Text } = Typography;
|
||||
|
||||
// const filePathSvg = '/src/assets/svg/air_dryer_B_rev.svg';
|
||||
const topicMqtt = 'PIU_GGCP/Devices/PB';
|
||||
const topicMqtt = 'PIU_COD/AIR_DRYER/AIR_DRYER_B';
|
||||
|
||||
const SvgAirDryerB = () => {
|
||||
return (
|
||||
|
||||
@@ -8,7 +8,7 @@ import filePathSvg from '../../assets/svg/air_dryer_C_rev.svg';
|
||||
const { Text } = Typography;
|
||||
|
||||
// const filePathSvg = '/src/assets/svg/air_dryer_C_rev.svg';
|
||||
const topicMqtt = 'PIU_GGCP/Devices/PB';
|
||||
const topicMqtt = 'PIU_COD/AIR_DRYER/AIR_DRYER_C';
|
||||
|
||||
const SvgAirDryerC = () => {
|
||||
return (
|
||||
|
||||
@@ -8,7 +8,7 @@ import filePathSvg from '../../assets/svg/compressorA_rev.svg';
|
||||
const { Text } = Typography;
|
||||
|
||||
// const filePathSvg = '/src/assets/svg/test-new.svg';
|
||||
const topicMqtt = 'PIU_GGCP/Devices/PB';
|
||||
const topicMqtt = 'PIU_COD/COMPRESSOR/COMPRESSOR_A';
|
||||
|
||||
const SvgCompressorA = () => {
|
||||
return (
|
||||
|
||||
@@ -6,9 +6,7 @@ import SvgViewer from './SvgViewer';
|
||||
import filePathSvg from '../../assets/svg/compressorB_rev.svg';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
// const filePathSvg = '/src/assets/svg/test-new.svg';
|
||||
const topicMqtt = 'PIU_GGCP/Devices/PB';
|
||||
const topicMqtt = 'PIU_COD/COMPRESSOR/COMPRESSOR_B';
|
||||
|
||||
const SvgCompressorB = () => {
|
||||
return (
|
||||
|
||||
@@ -8,7 +8,7 @@ import filePathSvg from '../../assets/svg/compressorC_rev.svg';
|
||||
const { Text } = Typography;
|
||||
|
||||
// const filePathSvg = '/src/assets/svg/test-new.svg';
|
||||
const topicMqtt = 'PIU_GGCP/Devices/PB';
|
||||
const topicMqtt = 'PIU_COD/COMPRESSOR/COMPRESSOR_C';
|
||||
|
||||
const SvgCompressorC = () => {
|
||||
return (
|
||||
|
||||
@@ -8,7 +8,7 @@ import filePathSvg from '../../assets/svg/overview-airdryer.svg';
|
||||
const { Text } = Typography;
|
||||
|
||||
// const filePathSvg = '/src/assets/svg/test-new.svg';
|
||||
const topicMqtt = 'PIU_GGCP/Devices/PB';
|
||||
const topicMqtt = 'PIU_COD/AIR_DRYER/OVERVIEW';
|
||||
|
||||
const SvgOverviewAirDryer = () => {
|
||||
return (
|
||||
|
||||
@@ -8,7 +8,7 @@ import filePathSvg from '../../assets/svg/overview-compressor.svg';
|
||||
const { Text } = Typography;
|
||||
|
||||
// const filePathSvg = '/src/assets/svg/test-new.svg';
|
||||
const topicMqtt = 'PIU_GGCP/Devices/PB';
|
||||
const topicMqtt = 'PIU_COD/COMPRESSOR/OVERVIEW';
|
||||
|
||||
const SvgOverviewCompressor = () => {
|
||||
return (
|
||||
|
||||
@@ -352,7 +352,7 @@ const ListJadwalShift = memo(function ListJadwalShift(props) {
|
||||
<Title level={3}>Jadwal Shift</Title>
|
||||
<Divider />
|
||||
|
||||
<Row>
|
||||
{/* <Row>
|
||||
<Col xs={24}>
|
||||
<Row justify="end" align="middle" gutter={[8, 8]}>
|
||||
<Col xs={24} sm={24} md={12} lg={12}>
|
||||
@@ -383,7 +383,7 @@ const ListJadwalShift = memo(function ListJadwalShift(props) {
|
||||
</Col>
|
||||
</Row>
|
||||
</Col>
|
||||
</Row>
|
||||
</Row> */}
|
||||
|
||||
<div style={{ marginTop: '24px' }}>
|
||||
{loading ? (
|
||||
|
||||
@@ -6,10 +6,10 @@ import { ArrowLeftOutlined, FilePdfOutlined, FileImageOutlined, DownloadOutlined
|
||||
import { useBreadcrumb } from '../../../layout/LayoutBreadcrumb';
|
||||
import { getBrandById } from '../../../api/master-brand';
|
||||
import {
|
||||
downloadFile,
|
||||
getFile,
|
||||
getFileUrl,
|
||||
getFolderFromFileType,
|
||||
downloadFile,
|
||||
getFile,
|
||||
getFileUrl,
|
||||
getFolderFromFileType,
|
||||
} from '../../../api/file-uploads';
|
||||
|
||||
const { Title } = Typography;
|
||||
@@ -26,17 +26,7 @@ const ViewFilePage = () => {
|
||||
const [pdfBlobUrl, setPdfBlobUrl] = useState(null);
|
||||
const [pdfLoading, setPdfLoading] = useState(false);
|
||||
|
||||
// Debug: Log URL parameters and location
|
||||
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 fallbackFileType = fileType;
|
||||
@@ -45,7 +35,6 @@ const ViewFilePage = () => {
|
||||
if (!fileName || !fileType || !id) {
|
||||
|
||||
const urlParts = window.location.pathname.split('/');
|
||||
// console.log('URL Parts from pathname:', urlParts);
|
||||
|
||||
const viewIndex = urlParts.indexOf('view');
|
||||
const editIndex = urlParts.indexOf('edit');
|
||||
@@ -55,13 +44,6 @@ const ViewFilePage = () => {
|
||||
fallbackId = urlParts[actionIndex + 1];
|
||||
fallbackFileType = urlParts[actionIndex + 3];
|
||||
fallbackFileName = decodeURIComponent(urlParts[actionIndex + 4]);
|
||||
|
||||
console.log('Fallback extraction:', {
|
||||
fallbackId,
|
||||
fallbackFileType,
|
||||
fallbackFileName,
|
||||
actionType: viewIndex !== -1 ? 'view' : 'edit'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,12 +77,9 @@ const ViewFilePage = () => {
|
||||
const folder = getFolderFromFileType('pdf');
|
||||
try {
|
||||
const blobData = await getFile(folder, decodedFileName);
|
||||
console.log('PDF blob data received:', blobData);
|
||||
const blobUrl = window.URL.createObjectURL(blobData);
|
||||
setPdfBlobUrl(blobUrl);
|
||||
console.log('PDF blob URL created successfully:', blobUrl);
|
||||
} catch (pdfError) {
|
||||
console.error('Error loading PDF:', pdfError);
|
||||
setError('Failed to load PDF file: ' + (pdfError.message || pdfError));
|
||||
setPdfBlobUrl(null);
|
||||
} finally {
|
||||
@@ -110,7 +89,6 @@ const ViewFilePage = () => {
|
||||
|
||||
setLoading(false);
|
||||
} catch (error) {
|
||||
console.error('Error fetching data:', error);
|
||||
setError('Failed to load data');
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -160,12 +138,6 @@ const ViewFilePage = () => {
|
||||
|
||||
const targetPhase = savedPhase ? parseInt(savedPhase) : 1;
|
||||
|
||||
console.log('ViewFilePage handleBack - Edit mode:', {
|
||||
savedPhase,
|
||||
targetPhase,
|
||||
id: fallbackId || id
|
||||
});
|
||||
|
||||
navigate(`/master/brand-device/edit/${fallbackId || id}`, {
|
||||
state: { phase: targetPhase, fromFileViewer: true },
|
||||
replace: true
|
||||
@@ -196,9 +168,7 @@ const ViewFilePage = () => {
|
||||
const isImage = ['jpg', 'jpeg', 'png', 'gif'].includes(fileExtension);
|
||||
const isPdf = fileExtension === 'pdf';
|
||||
|
||||
// const fileUrl = loading ? null : getFileUrl(getFolderFromFileType(fallbackFileType || fileType), actualFileName);
|
||||
|
||||
// Show placeholder when loading
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ textAlign: 'center', padding: '50px' }}>
|
||||
@@ -340,17 +310,14 @@ const ViewFilePage = () => {
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
// Retry loading PDF
|
||||
setPdfLoading(true);
|
||||
const folder = getFolderFromFileType('pdf');
|
||||
getFile(folder, actualFileName)
|
||||
.then(blobData => {
|
||||
console.log('Retry PDF blob data:', blobData);
|
||||
const blobUrl = window.URL.createObjectURL(blobData);
|
||||
setPdfBlobUrl(blobUrl);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error retrying PDF load:', error);
|
||||
setError('Failed to load PDF file: ' + (error.message || error));
|
||||
setPdfBlobUrl(null);
|
||||
})
|
||||
@@ -445,7 +412,7 @@ const ViewFilePage = () => {
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{/* File type indicator */}
|
||||
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<div style={{
|
||||
display: 'inline-block',
|
||||
@@ -462,7 +429,7 @@ const ViewFilePage = () => {
|
||||
</div>
|
||||
|
||||
<div style={{ position: 'relative' }}>
|
||||
{/* Overlay with blur effect during loading */}
|
||||
|
||||
{loading && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
|
||||
@@ -3,74 +3,96 @@ import { Form, Input, Row, Col, Typography, Switch } from 'antd';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const BrandForm = ({ form, formData, onValuesChange, isEdit = false }) => {
|
||||
const isActive = Form.useWatch('is_active', form) ?? formData.is_active ?? true;
|
||||
const BrandForm = ({
|
||||
form,
|
||||
onValuesChange,
|
||||
isEdit = false,
|
||||
brandInfo = null,
|
||||
readOnly = false,
|
||||
}) => {
|
||||
const isActive = Form.useWatch('is_active', form) ?? true;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (brandInfo && brandInfo.brand_code) {
|
||||
form.setFieldsValue({
|
||||
brand_code: brandInfo.brand_code
|
||||
});
|
||||
}
|
||||
}, [brandInfo, form]);
|
||||
|
||||
return (
|
||||
<Form
|
||||
layout="vertical"
|
||||
form={form}
|
||||
onValuesChange={onValuesChange}
|
||||
initialValues={formData}
|
||||
>
|
||||
<Form.Item label="Status">
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Form.Item name="is_active" valuePropName="checked" noStyle>
|
||||
<Switch
|
||||
style={{ backgroundColor: isActive ? '#23A55A' : '#bfbfbf' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Text style={{ marginLeft: 8 }}>
|
||||
{isActive ? 'Running' : 'Offline'}
|
||||
</Text>
|
||||
</div>
|
||||
</Form.Item>
|
||||
<div>
|
||||
<Form
|
||||
layout="vertical"
|
||||
form={form}
|
||||
onValuesChange={onValuesChange}
|
||||
initialValues={{
|
||||
brand_name: '',
|
||||
brand_type: '',
|
||||
brand_model: '',
|
||||
brand_manufacture: '',
|
||||
is_active: true,
|
||||
}}
|
||||
>
|
||||
<Form.Item label="Status">
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Form.Item name="is_active" valuePropName="checked" noStyle>
|
||||
<Switch
|
||||
style={{ backgroundColor: isActive ? '#23A55A' : '#bfbfbf' }}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Text style={{ marginLeft: 8 }}>
|
||||
{isActive ? 'Running' : 'Offline'}
|
||||
</Text>
|
||||
</div>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="Brand Code" name="brand_code">
|
||||
<Input
|
||||
placeholder={'Auto Fill Brand Code'}
|
||||
disabled={true}
|
||||
style={{
|
||||
backgroundColor: '#f5f5f5',
|
||||
cursor: 'not-allowed'
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="Brand Code" name="brand_code">
|
||||
<Input
|
||||
disabled={true}
|
||||
style={{
|
||||
backgroundColor: '#f5f5f5',
|
||||
cursor: 'not-allowed'
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
label="Brand Name"
|
||||
name="brand_name"
|
||||
rules={[{ required: true, message: 'Brand Name wajib diisi!' }]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
label="Manufacturer"
|
||||
name="brand_manufacture"
|
||||
rules={[{ required: true, message: 'Manufacturer wajib diisi!' }]}
|
||||
>
|
||||
<Input placeholder="Enter Manufacturer" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
label="Brand Name"
|
||||
name="brand_name"
|
||||
rules={[{ required: !readOnly, message: 'Brand Name wajib diisi!' }]}
|
||||
>
|
||||
<Input disabled={readOnly} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
label="Manufacturer"
|
||||
name="brand_manufacture"
|
||||
rules={[{ required: !readOnly, message: 'Manufacturer wajib diisi!' }]}
|
||||
>
|
||||
<Input placeholder="Enter Manufacturer" disabled={readOnly} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item label="Brand Type" name="brand_type">
|
||||
<Input placeholder="Enter Brand Type (Optional)" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="Model" name="brand_model">
|
||||
<Input placeholder="Enter Model (Optional)" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item label="Brand Type" name="brand_type">
|
||||
<Input placeholder="Enter Brand Type (Optional)" disabled={readOnly} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="Model" name="brand_model">
|
||||
<Input placeholder="Enter Model (Optional)" disabled={readOnly} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
397
src/pages/master/brandDevice/component/CustomSparepartCard.jsx
Normal file
@@ -0,0 +1,397 @@
|
||||
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;
|
||||
288
src/pages/master/brandDevice/component/ErrorCodeForm.jsx
Normal file
@@ -0,0 +1,288 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Form, Input, Switch, Typography, ConfigProvider, Card, Button } from 'antd';
|
||||
import { FileOutlined, EyeOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||
import FileUploadHandler from './FileUploadHandler';
|
||||
import { NotifAlert, NotifOk } from '../../../../components/Global/ToastNotif';
|
||||
import { getFileUrl, getFolderFromFileType } from '../../../../api/file-uploads';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const ErrorCodeForm = ({
|
||||
errorCodeForm,
|
||||
isErrorCodeFormReadOnly = false,
|
||||
errorCodeIcon,
|
||||
onErrorCodeIconUpload,
|
||||
onErrorCodeIconRemove,
|
||||
isEdit = false,
|
||||
}) => {
|
||||
const [currentIcon, setCurrentIcon] = useState(null);
|
||||
const statusWatch = Form.useWatch('status', errorCodeForm) ?? true;
|
||||
|
||||
useEffect(() => {
|
||||
if (errorCodeIcon && typeof errorCodeIcon === 'object' && Object.keys(errorCodeIcon).length > 0) {
|
||||
setCurrentIcon(errorCodeIcon);
|
||||
} else {
|
||||
setCurrentIcon(null);
|
||||
}
|
||||
}, [errorCodeIcon]);
|
||||
|
||||
const handleIconRemove = () => {
|
||||
setCurrentIcon(null);
|
||||
onErrorCodeIconRemove();
|
||||
};
|
||||
|
||||
const renderIconUpload = () => {
|
||||
if (currentIcon) {
|
||||
const displayFileName = currentIcon.name || currentIcon.uploadPath?.split('/').pop() || currentIcon.url?.split('/').pop() || 'Icon File';
|
||||
|
||||
return (
|
||||
<Card
|
||||
style={{
|
||||
marginTop: 8,
|
||||
borderRadius: 8,
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
|
||||
border: '1px solid #e8e8e8'
|
||||
}}
|
||||
styles={{ body: { padding: '16px' } }}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 8,
|
||||
backgroundColor: '#f0f5ff',
|
||||
flexShrink: 0
|
||||
}}>
|
||||
<FileOutlined style={{ fontSize: 24, color: '#1890ff' }} />
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: '#262626',
|
||||
marginBottom: 4,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
}}>
|
||||
{displayFileName}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: '#8c8c8c' }}>
|
||||
{currentIcon.size ? `${(currentIcon.size / 1024).toFixed(1)} KB` : 'Icon uploaded'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
|
||||
<Button
|
||||
type="primary"
|
||||
size="middle"
|
||||
icon={<EyeOutlined />}
|
||||
style={{
|
||||
fontSize: 12,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4
|
||||
}}
|
||||
onClick={() => {
|
||||
try {
|
||||
let iconUrl = '';
|
||||
let actualFileName = '';
|
||||
|
||||
const filePath = currentIcon.uploadPath || currentIcon.url || currentIcon.path || '';
|
||||
const iconDisplayName = currentIcon.name || '';
|
||||
|
||||
if (iconDisplayName) {
|
||||
actualFileName = iconDisplayName;
|
||||
} else if (filePath) {
|
||||
actualFileName = filePath.split('/').pop();
|
||||
}
|
||||
|
||||
if (actualFileName) {
|
||||
const fileExtension = actualFileName.split('.').pop()?.toLowerCase();
|
||||
const folder = getFolderFromFileType(fileExtension);
|
||||
iconUrl = getFileUrl(folder, actualFileName);
|
||||
}
|
||||
|
||||
if (!iconUrl && filePath) {
|
||||
iconUrl = filePath.startsWith('http') ? filePath : `${import.meta.env.VITE_API_SERVER}/${filePath}`;
|
||||
}
|
||||
|
||||
if (iconUrl && actualFileName) {
|
||||
const fileExtension = actualFileName.split('.').pop()?.toLowerCase();
|
||||
const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'];
|
||||
const pdfExtensions = ['pdf'];
|
||||
|
||||
if (imageExtensions.includes(fileExtension) || pdfExtensions.includes(fileExtension)) {
|
||||
const viewerUrl = `/image-viewer/${encodeURIComponent(actualFileName)}`;
|
||||
window.open(viewerUrl, '_blank', 'noopener,noreferrer');
|
||||
} else {
|
||||
window.open(iconUrl, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
} else {
|
||||
NotifAlert({
|
||||
icon: 'error',
|
||||
title: 'Error',
|
||||
message: `File URL not found. FileName: ${actualFileName}, FilePath: ${filePath}`
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
NotifAlert({
|
||||
icon: 'error',
|
||||
title: 'Error',
|
||||
message: `Failed to open file preview: ${error.message}`
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
danger
|
||||
size="middle"
|
||||
icon={<DeleteOutlined />}
|
||||
style={{
|
||||
fontSize: 12,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
onClick={handleIconRemove}
|
||||
disabled={isErrorCodeFormReadOnly}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<FileUploadHandler
|
||||
type="error_code"
|
||||
existingFile={null}
|
||||
accept="image/*"
|
||||
onFileUpload={(fileData) => {
|
||||
setCurrentIcon(fileData);
|
||||
onErrorCodeIconUpload(fileData);
|
||||
}}
|
||||
onFileRemove={handleIconRemove}
|
||||
buttonText="Upload Icon"
|
||||
buttonStyle={{
|
||||
width: '100%',
|
||||
borderColor: '#23A55A',
|
||||
color: '#23A55A',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '8px'
|
||||
}}
|
||||
uploadText="Upload error code icon"
|
||||
disabled={isErrorCodeFormReadOnly}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
components: {
|
||||
Switch: {
|
||||
colorPrimary: '#23A55A',
|
||||
colorPrimaryHover: '#23A55A',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Form
|
||||
form={errorCodeForm}
|
||||
layout="vertical"
|
||||
initialValues={{
|
||||
status: true,
|
||||
error_code_color: '#000000'
|
||||
}}
|
||||
>
|
||||
{/* Header bar with color picker, icon upload, and status toggle */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: '16px',
|
||||
gap: '16px'
|
||||
}}>
|
||||
{/* Color picker on left */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<Form.Item
|
||||
name="error_code_color"
|
||||
noStyle
|
||||
getValueFromEvent={(e) => e.target.value}
|
||||
getValueProps={(value) => ({ value: value || '#000000' })}
|
||||
>
|
||||
<input
|
||||
type="color"
|
||||
style={{
|
||||
width: '120px',
|
||||
height: '40px',
|
||||
border: '1px solid #d9d9d9',
|
||||
borderRadius: 4,
|
||||
cursor: isErrorCodeFormReadOnly ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
disabled={isErrorCodeFormReadOnly}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{/* Icon upload beside color picker */}
|
||||
<div style={{ flex: 1, maxWidth: '300px' }}>
|
||||
{renderIconUpload()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status toggle on right */}
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Form.Item name="status" valuePropName="checked" noStyle>
|
||||
<Switch
|
||||
disabled={isErrorCodeFormReadOnly}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Text style={{ marginLeft: 8 }}>
|
||||
{statusWatch ? 'Active' : 'Inactive'}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Code and Error Name in one row with 1/3 and 2/3 ratio */}
|
||||
<div style={{ display: 'flex', gap: '12px', marginBottom: '16px' }}>
|
||||
<Form.Item
|
||||
label="Error Code"
|
||||
name="error_code"
|
||||
rules={[{ required: true, message: 'Error code wajib diisi!' }]}
|
||||
style={{ flex: 1, marginBottom: 0, maxWidth: '33.33%' }}
|
||||
>
|
||||
<Input
|
||||
placeholder="Enter error code"
|
||||
disabled={isErrorCodeFormReadOnly}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="Error Name"
|
||||
name="error_code_name"
|
||||
rules={[{ required: !isErrorCodeFormReadOnly, message: 'Error name wajib diisi!' }]}
|
||||
style={{ flex: 2, marginBottom: 0, maxWidth: '66.67%' }}
|
||||
>
|
||||
<Input placeholder="Enter error name" disabled={isErrorCodeFormReadOnly} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<Form.Item label="Description" name="error_code_description">
|
||||
<Input.TextArea
|
||||
placeholder="Enter error description"
|
||||
rows={3}
|
||||
disabled={isErrorCodeFormReadOnly}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</ConfigProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default ErrorCodeForm;
|
||||
@@ -1,200 +0,0 @@
|
||||
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;
|
||||
@@ -1,217 +0,0 @@
|
||||
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.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;
|
||||
@@ -1,18 +1,45 @@
|
||||
import { useState } from 'react';
|
||||
import { Upload, Modal } from 'antd';
|
||||
import { UploadOutlined } from '@ant-design/icons';
|
||||
import React, { useState } from 'react';
|
||||
import { Upload, Modal, Button, Typography, Space, Image } from 'antd';
|
||||
import { UploadOutlined, EyeOutlined, DeleteOutlined, FileOutlined } from '@ant-design/icons';
|
||||
import { NotifOk, NotifAlert } from '../../../../components/Global/ToastNotif';
|
||||
import { uploadFile, getFolderFromFileType } from '../../../../api/file-uploads';
|
||||
import { uploadFile, getFolderFromFileType, getFileUrl, getFileType } from '../../../../api/file-uploads';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const FileUploadHandler = ({
|
||||
solutionFields,
|
||||
fileList,
|
||||
type = 'solution',
|
||||
maxCount = 1,
|
||||
accept = '.pdf,.jpg,.jpeg,.png,.gif',
|
||||
disabled = false,
|
||||
|
||||
fileList = [],
|
||||
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 [previewImage, setPreviewImage] = 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) =>
|
||||
new Promise((resolve, reject) => {
|
||||
@@ -22,99 +49,372 @@ const FileUploadHandler = ({
|
||||
reader.onerror = (error) => reject(error);
|
||||
});
|
||||
|
||||
const handleUploadPreview = async (file) => {
|
||||
const preview = await getBase64(file);
|
||||
setPreviewImage(preview);
|
||||
setPreviewTitle(file.name || file.url.substring(file.url.lastIndexOf('/') + 1));
|
||||
const handlePreview = async (file) => {
|
||||
if (!file.url && !file.preview) {
|
||||
file.preview = await getBase64(file.originFileObj);
|
||||
}
|
||||
setPreviewImage(file.url || file.preview);
|
||||
setPreviewOpen(true);
|
||||
setPreviewTitle(file.name || file.url.substring(file.url.lastIndexOf('/') + 1));
|
||||
};
|
||||
|
||||
const handleFileUpload = async (file) => {
|
||||
const isAllowedType = ['application/pdf', 'image/jpeg', 'image/png', 'image/gif'].includes(file.type);
|
||||
const validateFile = (file) => {
|
||||
const isAllowedType = [
|
||||
'application/pdf',
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
].includes(file.type);
|
||||
|
||||
if (!isAllowedType) {
|
||||
NotifAlert({
|
||||
icon: 'error',
|
||||
title: 'Error',
|
||||
message: `${file.name} bukan file PDF atau gambar yang diizinkan.`
|
||||
message: `${file.name} bukan file PDF atau gambar yang diizinkan.`,
|
||||
});
|
||||
return Upload.LIST_IGNORE;
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleFileUpload = async (file) => {
|
||||
if (isUploading) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!validateFile(file)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsUploading(true);
|
||||
|
||||
const fileExtension = file.name.split('.').pop().toLowerCase();
|
||||
const isImage = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'].includes(fileExtension);
|
||||
const fileType = isImage ? 'image' : 'pdf';
|
||||
const folder = getFolderFromFileType(fileType);
|
||||
|
||||
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) {
|
||||
file.uploadPath = actualPath;
|
||||
file.solution_name = file.name;
|
||||
file.solutionId = solutionFields[0];
|
||||
file.type_solution = fileType;
|
||||
onFileUpload(file);
|
||||
let fileObject;
|
||||
|
||||
if (type === 'error_code') {
|
||||
fileObject = {
|
||||
name: file.name,
|
||||
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({
|
||||
icon: 'success',
|
||||
title: 'Berhasil',
|
||||
message: `${file.name} berhasil diupload!`
|
||||
});
|
||||
|
||||
setIsUploading(false);
|
||||
return false;
|
||||
} else {
|
||||
NotifAlert({
|
||||
icon: 'error',
|
||||
title: 'Gagal',
|
||||
message: `Gagal mengupload ${file.name}`
|
||||
message: `Gagal mengupload ${file.name}. Tidak dapat menemukan path file dalam response.`,
|
||||
});
|
||||
setIsUploading(false);
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error uploading file:', error);
|
||||
NotifAlert({
|
||||
icon: '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;
|
||||
}
|
||||
|
||||
return false;
|
||||
const filePath = fileToShow.uploadPath || fileToShow.url || fileToShow.path_icon || fileToShow.path_solution;
|
||||
const fileName = fileToShow.name || filePath?.split('/').pop() || 'Unknown file';
|
||||
const fileType = getFileType(fileName);
|
||||
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 = {
|
||||
multiple: true,
|
||||
accept: '.pdf,.jpg,.jpeg,.png,.gif',
|
||||
onRemove: onFileRemove,
|
||||
beforeUpload: handleFileUpload,
|
||||
fileList,
|
||||
onPreview: handleUploadPreview,
|
||||
name: 'file',
|
||||
multiple: false,
|
||||
accept,
|
||||
disabled: disabled || isUploading,
|
||||
fileList: [],
|
||||
beforeUpload: () => false,
|
||||
onChange: handleFileChange,
|
||||
onPreview: handlePreview,
|
||||
maxCount,
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Upload.Dragger {...uploadProps}>
|
||||
<p className="ant-upload-drag-icon">
|
||||
<UploadOutlined />
|
||||
</p>
|
||||
<p className="ant-upload-text">Click or drag file to this area to upload</p>
|
||||
<p className="ant-upload-hint">Support for PDF and image files only</p>
|
||||
</Upload.Dragger>
|
||||
<div style={{ ...containerStyle }}>
|
||||
{!existingFile && (
|
||||
<Upload {...uploadProps}>
|
||||
{type === 'drag' ? (
|
||||
<Upload.Dragger>
|
||||
<p className="ant-upload-drag-icon">
|
||||
<UploadOutlined />
|
||||
</p>
|
||||
<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}
|
||||
title={previewTitle}
|
||||
footer={null}
|
||||
onCancel={() => setPreviewOpen(false)}
|
||||
width="80%"
|
||||
style={{ top: 20 }}
|
||||
>
|
||||
{previewImage && (
|
||||
<img
|
||||
alt={previewTitle}
|
||||
style={{ width: '100%' }}
|
||||
src={previewImage}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
|
||||
|
||||
{showPreview && (
|
||||
<Modal
|
||||
open={previewOpen}
|
||||
title={previewTitle}
|
||||
footer={null}
|
||||
onCancel={() => setPreviewOpen(false)}
|
||||
width={600}
|
||||
style={{ top: 100 }}
|
||||
>
|
||||
{previewImage && (
|
||||
<img
|
||||
alt={previewTitle}
|
||||
style={{ width: '100%' }}
|
||||
src={previewImage}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Button, ConfigProvider } from 'antd';
|
||||
import { ArrowLeftOutlined } from '@ant-design/icons';
|
||||
|
||||
const FormActions = ({
|
||||
currentStep,
|
||||
onPreviousStep,
|
||||
onNextStep,
|
||||
onSave,
|
||||
onCancel,
|
||||
confirmLoading,
|
||||
isEditMode = false,
|
||||
showCancelButton = true
|
||||
}) => {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
token: { colorBgContainer: '#E9F6EF' },
|
||||
components: {
|
||||
Button: {
|
||||
defaultBg: 'white',
|
||||
defaultColor: '#23A55A',
|
||||
defaultBorderColor: '#23A55A',
|
||||
defaultHoverColor: '#23A55A',
|
||||
defaultHoverBorderColor: '#23A55A',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
{showCancelButton && (
|
||||
<Button onClick={onCancel}>Batal</Button>
|
||||
)}
|
||||
{currentStep > 0 && (
|
||||
<Button onClick={onPreviousStep} style={{ marginRight: 8 }}>
|
||||
Kembali
|
||||
</Button>
|
||||
)}
|
||||
</ConfigProvider>
|
||||
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
components: {
|
||||
Button: {
|
||||
defaultBg: '#23a55a',
|
||||
defaultColor: '#FFFFFF',
|
||||
defaultBorderColor: '#23a55a',
|
||||
defaultHoverBg: '#209652',
|
||||
defaultHoverColor: '#FFFFFF',
|
||||
defaultHoverBorderColor: '#23a55a',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
{currentStep < 1 && (
|
||||
<Button loading={confirmLoading} onClick={onNextStep}>
|
||||
Lanjut
|
||||
</Button>
|
||||
)}
|
||||
{currentStep === 1 && (
|
||||
<Button loading={confirmLoading} onClick={onSave}>
|
||||
{isEditMode ? 'Update' : 'Simpan'}
|
||||
</Button>
|
||||
)}
|
||||
</ConfigProvider>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FormActions;
|
||||
@@ -26,26 +26,12 @@ const columns = (showPreviewModal, showEditModal, showDeleteDialog) => [
|
||||
key: 'brand_name',
|
||||
width: '20%',
|
||||
},
|
||||
{
|
||||
title: 'Type',
|
||||
dataIndex: 'brand_type',
|
||||
key: 'brand_type',
|
||||
width: '15%',
|
||||
render: (text) => text || '-',
|
||||
},
|
||||
{
|
||||
title: 'Manufacturer',
|
||||
dataIndex: 'brand_manufacture',
|
||||
key: 'brand_manufacture',
|
||||
width: '20%',
|
||||
},
|
||||
{
|
||||
title: 'Model',
|
||||
dataIndex: 'brand_model',
|
||||
key: 'brand_model',
|
||||
width: '15%',
|
||||
render: (text) => text || '-',
|
||||
},
|
||||
{
|
||||
title: 'Status',
|
||||
dataIndex: 'is_active',
|
||||
@@ -105,9 +91,9 @@ const columns = (showPreviewModal, showEditModal, showDeleteDialog) => [
|
||||
const ListBrandDevice = memo(function ListBrandDevice(props) {
|
||||
const [trigerFilter, setTrigerFilter] = useState(false);
|
||||
|
||||
const defaultFilter = { search: '' };
|
||||
const defaultFilter = { criteria: '' };
|
||||
const [formDataFilter, setFormDataFilter] = useState(defaultFilter);
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const [searchText, setSearchText] = useState('');
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -128,23 +114,21 @@ const ListBrandDevice = memo(function ListBrandDevice(props) {
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
setFormDataFilter({ search: searchValue });
|
||||
setFormDataFilter({ criteria: searchText });
|
||||
setTrigerFilter((prev) => !prev);
|
||||
};
|
||||
|
||||
const handleSearchClear = () => {
|
||||
setSearchValue('');
|
||||
setFormDataFilter({ search: '' });
|
||||
setSearchText('');
|
||||
setFormDataFilter({ criteria: '' });
|
||||
setTrigerFilter((prev) => !prev);
|
||||
};
|
||||
|
||||
const showPreviewModal = (param) => {
|
||||
// Direct navigation without loading, page will handle its own loading
|
||||
navigate(`/master/brand-device/view/${param.brand_id}`);
|
||||
};
|
||||
|
||||
const showEditModal = (param = null) => {
|
||||
// Direct navigation without loading, page will handle its own loading
|
||||
if (param) {
|
||||
navigate(`/master/brand-device/edit/${param.brand_id}`);
|
||||
} else {
|
||||
@@ -158,7 +142,7 @@ const ListBrandDevice = memo(function ListBrandDevice(props) {
|
||||
title: 'Konfirmasi',
|
||||
message: 'Apakah anda yakin hapus data "' + param.brand_name + '" ?',
|
||||
onConfirm: () => handleDelete(param.brand_id, param.brand_name),
|
||||
onCancel: () => {},
|
||||
onCancel: () => { },
|
||||
});
|
||||
};
|
||||
|
||||
@@ -172,7 +156,7 @@ const ListBrandDevice = memo(function ListBrandDevice(props) {
|
||||
title: 'Berhasil',
|
||||
message: `Brand ${brand_name} deleted successfully.`,
|
||||
});
|
||||
doFilter(); // Refresh data
|
||||
doFilter();
|
||||
} else {
|
||||
NotifAlert({
|
||||
icon: 'error',
|
||||
@@ -181,7 +165,6 @@ const ListBrandDevice = memo(function ListBrandDevice(props) {
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Delete Brand Device Error:', error);
|
||||
NotifAlert({
|
||||
icon: 'error',
|
||||
title: 'Error',
|
||||
@@ -199,13 +182,12 @@ const ListBrandDevice = memo(function ListBrandDevice(props) {
|
||||
<Col xs={24} sm={24} md={12} lg={12}>
|
||||
<Input.Search
|
||||
placeholder="Search brand device..."
|
||||
value={searchValue}
|
||||
value={searchText}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setSearchValue(value);
|
||||
// Auto search when clearing by backspace/delete
|
||||
setSearchText(value);
|
||||
if (value === '') {
|
||||
setFormDataFilter({ search: '' });
|
||||
setFormDataFilter({ criteria: '' });
|
||||
setTrigerFilter((prev) => !prev);
|
||||
}
|
||||
}}
|
||||
@@ -251,7 +233,7 @@ const ListBrandDevice = memo(function ListBrandDevice(props) {
|
||||
}}
|
||||
size="large"
|
||||
>
|
||||
Add Brand Device
|
||||
Add data
|
||||
</Button>
|
||||
</ConfigProvider>
|
||||
</Space>
|
||||
|
||||
@@ -1,84 +1,315 @@
|
||||
import React from 'react';
|
||||
import { Table, Button, Space } from 'antd';
|
||||
import { EyeOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { Card, Input, Button, Row, Col, Empty } from 'antd';
|
||||
import { PlusOutlined, SearchOutlined, DeleteOutlined, LeftOutlined, RightOutlined } from '@ant-design/icons';
|
||||
import { getErrorCodesByBrandId, deleteErrorCode } from '../../../../api/master-brand';
|
||||
import { NotifAlert, NotifOk, NotifConfirmDialog } from '../../../../components/Global/ToastNotif';
|
||||
|
||||
const ErrorCodeTable = ({
|
||||
errorCodes,
|
||||
loading,
|
||||
onPreview,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onFileView
|
||||
const ListErrorCode = ({
|
||||
brandId,
|
||||
selectedErrorCode,
|
||||
onErrorCodeSelect,
|
||||
onAddNew,
|
||||
tempErrorCodes = [],
|
||||
trigerFilter,
|
||||
searchText,
|
||||
onSearchChange,
|
||||
onSearch,
|
||||
onSearchClear,
|
||||
isReadOnly = false,
|
||||
errorCodes: propErrorCodes = null
|
||||
}) => {
|
||||
const errorCodeColumns = [
|
||||
{ title: 'Error Code', dataIndex: 'error_code', key: 'error_code' },
|
||||
{ title: 'Error Code Name', dataIndex: 'error_code_name', key: 'error_code_name' },
|
||||
{
|
||||
title: 'Solutions',
|
||||
dataIndex: 'solution',
|
||||
key: 'solution',
|
||||
render: (solutions) => (
|
||||
<div>
|
||||
{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 [errorCodes, setErrorCodes] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [pagination, setPagination] = useState({
|
||||
current_page: 1,
|
||||
current_limit: 15,
|
||||
total_limit: 0,
|
||||
total_page: 0,
|
||||
});
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const pageSize = 15;
|
||||
|
||||
const dataSource = loading
|
||||
? Array.from({ length: 3 }, (_, index) => ({
|
||||
key: `loading-${index}`,
|
||||
error_code: 'Loading...',
|
||||
error_code_name: 'Loading...',
|
||||
solution: []
|
||||
}))
|
||||
: errorCodes;
|
||||
const queryParams = useMemo(() => {
|
||||
const params = new URLSearchParams();
|
||||
params.set('page', currentPage.toString());
|
||||
params.set('limit', pageSize.toString());
|
||||
if (searchText) {
|
||||
params.set('criteria', searchText);
|
||||
}
|
||||
return params;
|
||||
}, [searchText, currentPage, pageSize]);
|
||||
|
||||
const fetchErrorCodes = async () => {
|
||||
if (!brandId) {
|
||||
setErrorCodes([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await getErrorCodesByBrandId(brandId, queryParams);
|
||||
|
||||
if (response && response.statusCode === 200) {
|
||||
const apiErrorData = response.data || [];
|
||||
const allErrorCodes = [
|
||||
...apiErrorData.map(ec => ({
|
||||
...ec,
|
||||
tempId: `existing_${ec.error_code_id}`,
|
||||
status: 'existing'
|
||||
})),
|
||||
...tempErrorCodes.filter(ec => ec.status !== 'deleted')
|
||||
];
|
||||
|
||||
setErrorCodes(allErrorCodes);
|
||||
|
||||
if (response.paging) {
|
||||
setPagination({
|
||||
current_page: response.paging.current_page || 1,
|
||||
current_limit: response.paging.current_limit || 15,
|
||||
total_limit: response.paging.total_limit || 0,
|
||||
total_page: response.paging.total_page || 0,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setErrorCodes([]);
|
||||
}
|
||||
} catch (error) {
|
||||
setErrorCodes([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isReadOnly && propErrorCodes) {
|
||||
|
||||
setErrorCodes(propErrorCodes);
|
||||
setLoading(false);
|
||||
} else {
|
||||
|
||||
fetchErrorCodes();
|
||||
}
|
||||
}, [brandId, queryParams, tempErrorCodes, trigerFilter, isReadOnly, propErrorCodes]);
|
||||
|
||||
const handlePrevious = () => {
|
||||
if (pagination.current_page > 1) {
|
||||
setCurrentPage(pagination.current_page - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
if (pagination.current_page < pagination.total_page) {
|
||||
setCurrentPage(pagination.current_page + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
setCurrentPage(1);
|
||||
if (onSearch) {
|
||||
onSearch();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearchClear = () => {
|
||||
setCurrentPage(1);
|
||||
if (onSearchClear) {
|
||||
onSearchClear();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (item, e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (item.status === 'existing' && item.error_code_id) {
|
||||
NotifConfirmDialog({
|
||||
icon: 'warning',
|
||||
title: 'Hapus Error Code',
|
||||
message: `Apakah Anda yakin ingin menghapus error code ${item.error_code}?`,
|
||||
onConfirm: () => performDelete(item),
|
||||
onCancel: () => { },
|
||||
confirmButtonText: 'Hapus'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const performDelete = async (item) => {
|
||||
try {
|
||||
|
||||
if (!item.error_code_id || item.error_code_id === 'undefined') {
|
||||
NotifAlert({
|
||||
icon: 'error',
|
||||
title: 'Error',
|
||||
message: 'Error code ID tidak valid'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!item.brand_id || item.brand_id === 'undefined') {
|
||||
NotifAlert({
|
||||
icon: 'error',
|
||||
title: 'Error',
|
||||
message: 'Brand ID tidak valid'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await deleteErrorCode(item.brand_id, item.error_code_id);
|
||||
|
||||
if (response && response.statusCode === 200) {
|
||||
NotifOk({
|
||||
icon: 'success',
|
||||
title: 'Berhasil',
|
||||
message: 'Error code berhasil dihapus'
|
||||
});
|
||||
fetchErrorCodes();
|
||||
} else {
|
||||
NotifAlert({
|
||||
icon: 'error',
|
||||
title: 'Gagal',
|
||||
message: 'Gagal menghapus error code'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
NotifAlert({
|
||||
icon: 'error',
|
||||
title: 'Error',
|
||||
message: 'Terjadi kesalahan saat menghapus error code'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Table
|
||||
columns={errorCodeColumns}
|
||||
dataSource={dataSource}
|
||||
rowKey="key"
|
||||
pagination={false}
|
||||
/>
|
||||
<Card
|
||||
title="Daftar Error Code"
|
||||
style={{ width: '100%', minWidth: '472px' }}
|
||||
styles={{ body: { padding: '12px' } }}
|
||||
>
|
||||
<Input.Search
|
||||
placeholder="Cari error code..."
|
||||
value={searchText}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (onSearchChange) {
|
||||
onSearchChange(value);
|
||||
}
|
||||
}}
|
||||
onSearch={handleSearch}
|
||||
allowClear
|
||||
enterButton={
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SearchOutlined />}
|
||||
onClick={handleSearch}
|
||||
style={{
|
||||
backgroundColor: '#23A55A',
|
||||
borderColor: '#23A55A',
|
||||
height: '32px'
|
||||
}}
|
||||
>
|
||||
Search
|
||||
</Button>
|
||||
}
|
||||
size="default"
|
||||
style={{
|
||||
marginBottom: 12,
|
||||
height: '32px',
|
||||
width: '100%',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div style={{
|
||||
height: '90vh',
|
||||
border: '1px solid #d9d9d9',
|
||||
borderRadius: '6px',
|
||||
overflow: 'auto',
|
||||
marginBottom: 12,
|
||||
backgroundColor: '#fafafa'
|
||||
}}>
|
||||
{errorCodes.length === 0 ? (
|
||||
<Empty
|
||||
description="Belum ada error code"
|
||||
style={{ marginTop: 50 }}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ padding: '8px' }}>
|
||||
{errorCodes.map((item) => (
|
||||
<div
|
||||
key={item.tempId || item.error_code_id}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
padding: '8px 12px',
|
||||
borderRadius: '6px',
|
||||
marginBottom: '4px',
|
||||
border: selectedErrorCode?.tempId === item.tempId ? '2px solid #23A55A' : '1px solid #d9d9d9',
|
||||
backgroundColor: selectedErrorCode?.tempId === item.tempId ? '#f6ffed' : '#fff',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
onClick={() => onErrorCodeSelect(item)}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontWeight: 'bold', fontSize: '12px' }}>
|
||||
{item.error_code}
|
||||
</div>
|
||||
<div style={{ fontSize: '11px', color: '#666' }}>
|
||||
{item.error_code_name}
|
||||
</div>
|
||||
</div>
|
||||
{item.status === 'existing' && (
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
size="small"
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={(e) => handleDelete(item, e)}
|
||||
style={{
|
||||
padding: '2px 6px',
|
||||
height: '24px',
|
||||
fontSize: '11px',
|
||||
border: '1px solid #ff4d4f'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{pagination.total_limit > 0 && (
|
||||
<Row justify="space-between" align="middle" gutter={16}>
|
||||
<Col flex="auto">
|
||||
<span style={{ fontSize: '12px', color: '#666' }}>
|
||||
Menampilkan {pagination.current_limit} data halaman{' '}
|
||||
{pagination.current_page} dari total {pagination.total_limit} data
|
||||
</span>
|
||||
</Col>
|
||||
<Col flex="none">
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||
<Button
|
||||
icon={<LeftOutlined />}
|
||||
onClick={handlePrevious}
|
||||
disabled={pagination.current_page <= 1}
|
||||
size="small"
|
||||
>
|
||||
</Button>
|
||||
<span style={{ fontSize: '12px', color: '#666', minWidth: '60px', textAlign: 'center' }}>
|
||||
{pagination.current_page} / {pagination.total_page}
|
||||
</span>
|
||||
<Button
|
||||
icon={<RightOutlined />}
|
||||
onClick={handleNext}
|
||||
disabled={pagination.current_page >= pagination.total_page}
|
||||
size="small"
|
||||
>
|
||||
</Button>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ErrorCodeTable;
|
||||
export default ListErrorCode;
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Form, Input, Button, Switch, Radio, Upload, Typography, Space } from 'antd';
|
||||
import { DeleteOutlined, UploadOutlined, EyeOutlined } from '@ant-design/icons';
|
||||
import { uploadFile, getFolderFromFileType } from '../../../../api/file-uploads';
|
||||
import React, { useState } from 'react';
|
||||
import { Form, Input, Button, Switch, Radio, Typography, Space, Card, ConfigProvider } from 'antd';
|
||||
import { DeleteOutlined, EyeOutlined, FileOutlined } from '@ant-design/icons';
|
||||
import FileUploadHandler from './FileUploadHandler';
|
||||
import { NotifAlert } from '../../../../components/Global/ToastNotif';
|
||||
import { getFileUrl, getFolderFromFileType } from '../../../../api/file-uploads';
|
||||
|
||||
const { Text } = Typography;
|
||||
const { TextArea } = Input;
|
||||
@@ -20,223 +21,475 @@ const SolutionFieldNew = ({
|
||||
onRemove,
|
||||
onFileUpload,
|
||||
onFileView,
|
||||
fileList = []
|
||||
fileList = [],
|
||||
originalSolutionData = null
|
||||
}) => {
|
||||
const [currentStatus, setCurrentStatus] = useState(solutionStatus ?? true);
|
||||
const form = Form.useFormInstance();
|
||||
const [currentFile, setCurrentFile] = useState(null);
|
||||
const [isDeleted, setIsDeleted] = useState(false);
|
||||
|
||||
// Watch form values
|
||||
const getFieldValue = () => {
|
||||
try {
|
||||
const form = document.querySelector(`[data-field="${fieldName}"]`)?.form;
|
||||
if (form) {
|
||||
const formData = new FormData(form);
|
||||
return formData.get(`${fieldName}.status`) === 'on';
|
||||
}
|
||||
return currentStatus;
|
||||
} catch {
|
||||
return currentStatus;
|
||||
const fileUpload = Form.useWatch(['solution_items', fieldKey, 'fileUpload'], form);
|
||||
const file = Form.useWatch(['solution_items', fieldKey, 'file'], form);
|
||||
const nameValue = Form.useWatch(['solution_items', fieldKey, 'name'], form);
|
||||
const fileNameValue = Form.useWatch(['solution_items', fieldKey, 'fileName'], form);
|
||||
const statusValue = Form.useWatch(['solution_items', fieldKey, 'status'], form) ?? true;
|
||||
|
||||
const pathSolution = Form.useWatch(['solution_items', fieldKey, 'path_solution'], form);
|
||||
|
||||
const [deleteCounter, setDeleteCounter] = useState(0);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!nameValue || nameValue === '') {
|
||||
setCurrentFile(null);
|
||||
setIsDeleted(false);
|
||||
setDeleteCounter(prev => prev + 1);
|
||||
}
|
||||
};
|
||||
}, [nameValue]);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentStatus(solutionStatus ?? true);
|
||||
}, [solutionStatus]);
|
||||
const handleFileUpload = async (file) => {
|
||||
try {
|
||||
const isAllowedType = [
|
||||
'application/pdf',
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
].includes(file.type);
|
||||
React.useEffect(() => {
|
||||
const getFileFromFormValues = () => {
|
||||
const hasValidFileUpload = fileUpload && typeof fileUpload === 'object' && Object.keys(fileUpload).length > 0;
|
||||
const hasValidFile = file && typeof file === 'object' && Object.keys(file).length > 0;
|
||||
const hasValidPath = pathSolution && pathSolution.trim() !== '';
|
||||
|
||||
if (!isAllowedType) {
|
||||
NotifAlert({
|
||||
icon: 'error',
|
||||
title: 'Error',
|
||||
message: `${file.name} bukan file PDF atau gambar yang diizinkan.`,
|
||||
});
|
||||
return;
|
||||
const wasExplicitlyDeleted =
|
||||
(fileUpload === null || file === null || pathSolution === null) &&
|
||||
!hasValidFileUpload &&
|
||||
!hasValidFile &&
|
||||
!hasValidPath;
|
||||
|
||||
if (wasExplicitlyDeleted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fileExtension = file.name.split('.').pop().toLowerCase();
|
||||
const isImage = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'].includes(fileExtension);
|
||||
const fileType = isImage ? 'image' : 'pdf';
|
||||
const folder = getFolderFromFileType(fileType);
|
||||
|
||||
const uploadResponse = await uploadFile(file, folder);
|
||||
const actualPath = uploadResponse.data?.path_solution || '';
|
||||
|
||||
if (actualPath) {
|
||||
// Store the file info with the solution field
|
||||
file.uploadPath = actualPath;
|
||||
file.solutionId = fieldKey;
|
||||
file.type_solution = fileType;
|
||||
onFileUpload(file);
|
||||
NotifAlert({
|
||||
icon: 'success',
|
||||
title: 'Berhasil',
|
||||
message: `${file.name} berhasil diupload!`,
|
||||
});
|
||||
} else {
|
||||
NotifAlert({
|
||||
icon: 'error',
|
||||
title: 'Gagal',
|
||||
message: `Gagal mengupload ${file.name}`,
|
||||
});
|
||||
if (solutionType === 'text') {
|
||||
return null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error uploading file:', error);
|
||||
NotifAlert({
|
||||
icon: 'error',
|
||||
title: 'Error',
|
||||
message: `Gagal mengupload ${file.name}. Silakan coba lagi.`,
|
||||
});
|
||||
|
||||
if (hasValidFileUpload) {
|
||||
return fileUpload;
|
||||
}
|
||||
if (hasValidFile) {
|
||||
return file;
|
||||
}
|
||||
if (hasValidPath) {
|
||||
return {
|
||||
name: fileNameValue || pathSolution.split('/').pop() || 'File',
|
||||
uploadPath: pathSolution,
|
||||
url: pathSolution,
|
||||
path: pathSolution
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const fileFromForm = getFileFromFormValues();
|
||||
|
||||
if (JSON.stringify(currentFile) !== JSON.stringify(fileFromForm)) {
|
||||
setCurrentFile(fileFromForm);
|
||||
}
|
||||
};
|
||||
}, [fileUpload, file, pathSolution, solutionType, deleteCounter, fileNameValue, fieldKey]);
|
||||
|
||||
|
||||
const renderSolutionContent = () => {
|
||||
if (solutionType === 'text') {
|
||||
return (
|
||||
<Form.Item
|
||||
name={[fieldName, 'text']}
|
||||
name={['solution_items', fieldKey, 'text']}
|
||||
rules={[{ required: true, message: 'Text solution wajib diisi!' }]}
|
||||
>
|
||||
<TextArea
|
||||
placeholder="Enter solution text"
|
||||
rows={3}
|
||||
disabled={isReadOnly}
|
||||
style={{ fontSize: 12 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
);
|
||||
}
|
||||
|
||||
if (solutionType === 'file') {
|
||||
const currentFiles = fileList.filter(file => file.solutionId === fieldKey);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Form.Item
|
||||
name={[fieldName, 'file']}
|
||||
rules={[{ required: true, message: 'File solution wajib diupload!' }]}
|
||||
>
|
||||
<Upload
|
||||
beforeUpload={handleFileUpload}
|
||||
showUploadList={false}
|
||||
accept=".pdf,.jpg,.jpeg,.png,.gif"
|
||||
disabled={isReadOnly}
|
||||
>
|
||||
<Button
|
||||
icon={<UploadOutlined />}
|
||||
disabled={isReadOnly}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
Upload File (PDF/Image)
|
||||
</Button>
|
||||
</Upload>
|
||||
</Form.Item>
|
||||
|
||||
{currentFiles.length > 0 && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
{currentFiles.map((file, index) => (
|
||||
<div key={index} style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '4px 8px',
|
||||
border: '1px solid #d9d9d9',
|
||||
borderRadius: 4,
|
||||
marginBottom: 4
|
||||
}}>
|
||||
<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>
|
||||
const hasOriginalFile = originalSolutionData && (
|
||||
originalSolutionData.path_solution ||
|
||||
originalSolutionData.path_document
|
||||
);
|
||||
|
||||
let displayFile = null;
|
||||
|
||||
if (currentFile && Object.keys(currentFile).length > 0) {
|
||||
displayFile = currentFile;
|
||||
}
|
||||
else if (hasOriginalFile && !isDeleted) {
|
||||
displayFile = {
|
||||
name: originalSolutionData.file_upload_name ||
|
||||
(originalSolutionData.path_solution || originalSolutionData.path_document)?.split('/').pop() ||
|
||||
'File',
|
||||
uploadPath: originalSolutionData.path_solution || originalSolutionData.path_document,
|
||||
url: originalSolutionData.path_solution || originalSolutionData.path_document,
|
||||
path: originalSolutionData.path_solution || originalSolutionData.path_document,
|
||||
isExisting: true
|
||||
};
|
||||
}
|
||||
else if (fileUpload && typeof fileUpload === 'object' && Object.keys(fileUpload).length > 0) {
|
||||
displayFile = fileUpload;
|
||||
}
|
||||
else if (file && typeof file === 'object' && Object.keys(file).length > 0) {
|
||||
displayFile = file;
|
||||
}
|
||||
else if (pathSolution && pathSolution.trim() !== '') {
|
||||
displayFile = {
|
||||
name: pathSolution.split('/').pop() || 'File',
|
||||
uploadPath: pathSolution,
|
||||
url: pathSolution,
|
||||
path: pathSolution
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
if (displayFile) {
|
||||
const getFileNameFromPath = () => {
|
||||
const filePath = displayFile.uploadPath || displayFile.url || displayFile.path || '';
|
||||
if (filePath) {
|
||||
const fileName = filePath.split('/').pop();
|
||||
return fileName || 'Uploaded File';
|
||||
}
|
||||
return displayFile.name || 'Uploaded File';
|
||||
};
|
||||
|
||||
const displayFileName = getFileNameFromPath();
|
||||
|
||||
return (
|
||||
<Card
|
||||
style={{
|
||||
marginBottom: 8,
|
||||
borderRadius: 8,
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
|
||||
border: '1px solid #e8e8e8'
|
||||
}}
|
||||
styles={{ body: { padding: '16px' } }}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 8,
|
||||
backgroundColor: '#f0f5ff',
|
||||
flexShrink: 0
|
||||
}}>
|
||||
<FileOutlined style={{ fontSize: 24, color: '#1890ff' }} />
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: '#262626',
|
||||
marginBottom: 4,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
}}>
|
||||
{displayFileName}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: '#8c8c8c' }}>
|
||||
{displayFile.size ? `${(displayFile.size / 1024).toFixed(1)} KB` : 'File uploaded'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
|
||||
<Button
|
||||
type="primary"
|
||||
size="middle"
|
||||
icon={<EyeOutlined />}
|
||||
style={{
|
||||
fontSize: 12,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4
|
||||
}}
|
||||
onClick={() => {
|
||||
try {
|
||||
let fileUrl = '';
|
||||
let actualFileName = '';
|
||||
|
||||
const filePath = displayFile.uploadPath || displayFile.url || displayFile.path || '';
|
||||
|
||||
if (filePath) {
|
||||
actualFileName = filePath.split('/').pop();
|
||||
|
||||
if (actualFileName) {
|
||||
const fileExtension = actualFileName.split('.').pop()?.toLowerCase();
|
||||
const folder = getFolderFromFileType(fileExtension);
|
||||
|
||||
fileUrl = getFileUrl(folder, actualFileName);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
if (!fileUrl && filePath) {
|
||||
fileUrl = filePath.startsWith('http') ? filePath : `${import.meta.env.VITE_API_SERVER}/${filePath}`;
|
||||
}
|
||||
|
||||
if (fileUrl && actualFileName) {
|
||||
const fileExtension = actualFileName.split('.').pop()?.toLowerCase();
|
||||
const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'];
|
||||
|
||||
if (imageExtensions.includes(fileExtension)) {
|
||||
const viewerUrl = `/image-viewer/${encodeURIComponent(actualFileName)}`;
|
||||
window.open(viewerUrl, '_blank', 'noopener,noreferrer');
|
||||
} else {
|
||||
window.open(fileUrl, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
} else {
|
||||
NotifAlert({
|
||||
icon: 'error',
|
||||
title: 'Error',
|
||||
message: 'File URL not found'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
NotifAlert({
|
||||
icon: 'error',
|
||||
title: 'Error',
|
||||
message: 'Failed to open file preview'
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
danger
|
||||
size="middle"
|
||||
icon={<DeleteOutlined />}
|
||||
style={{
|
||||
fontSize: 12,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
onClick={() => {
|
||||
setIsDeleted(true);
|
||||
|
||||
form.setFieldValue(['solution_items', fieldKey, 'fileUpload'], null);
|
||||
form.setFieldValue(['solution_items', fieldKey, 'file'], null);
|
||||
form.setFieldValue(['solution_items', fieldKey, 'path_solution'], null);
|
||||
form.setFieldValue(['solution_items', fieldKey, 'fileName'], null);
|
||||
|
||||
setCurrentFile(null);
|
||||
|
||||
if (onFileUpload && typeof onFileUpload === 'function') {
|
||||
onFileUpload(null);
|
||||
}
|
||||
|
||||
setDeleteCounter(prev => prev + 1);
|
||||
|
||||
setTimeout(() => {
|
||||
form.validateFields(['solution_items', fieldKey]);
|
||||
}, 50);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<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 (
|
||||
<div style={{
|
||||
border: '1px solid #d9d9d9',
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
marginBottom: 16,
|
||||
backgroundColor: isReadOnly ? '#f5f5f5' : 'white'
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
||||
<Text strong>Solution #{index + 1}</Text>
|
||||
<Space>
|
||||
<Form.Item
|
||||
name={[fieldName, 'name']}
|
||||
rules={[{ required: true, message: 'Solution name wajib diisi!' }]}
|
||||
style={{ margin: 0, width: 200 }}
|
||||
>
|
||||
<Input
|
||||
placeholder="Solution name"
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
</Form.Item>
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
components: {
|
||||
Switch: {
|
||||
colorPrimary: '#23A55A',
|
||||
colorPrimaryHover: '#23A55A',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
border: '1px solid #d9d9d9',
|
||||
borderRadius: 6,
|
||||
padding: 12,
|
||||
marginBottom: 12,
|
||||
backgroundColor: isReadOnly ? '#f5f5f5' : 'white'
|
||||
}}>
|
||||
<div style={{
|
||||
marginBottom: 8,
|
||||
gap: 8
|
||||
}}>
|
||||
<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: 4 }}>
|
||||
<Form.Item name={[fieldName, 'status']} valuePropName="checked" noStyle>
|
||||
<Switch
|
||||
disabled={isReadOnly}
|
||||
onChange={(checked) => {
|
||||
onStatusChange(fieldKey, checked);
|
||||
setCurrentStatus(checked);
|
||||
}}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<Form.Item name={['solution_items', fieldKey, 'status']} valuePropName="checked" noStyle>
|
||||
<Switch
|
||||
size="small"
|
||||
disabled={isReadOnly}
|
||||
onChange={(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={{
|
||||
backgroundColor: currentStatus ? '#23A55A' : '#bfbfbf'
|
||||
fontSize: 12,
|
||||
padding: '2px 4px',
|
||||
height: '24px'
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Text style={{ fontSize: 12, color: '#666' }}>
|
||||
{currentStatus ? 'Active' : 'Inactive'}
|
||||
</Text>
|
||||
)}
|
||||
</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>
|
||||
|
||||
<Form.Item
|
||||
name={[fieldName, 'type']}
|
||||
name={['solution_items', fieldKey, 'type']}
|
||||
rules={[{ required: true, message: 'Solution type wajib diisi!' }]}
|
||||
style={{ marginBottom: 8 }}
|
||||
initialValue={solutionType || 'text'}
|
||||
>
|
||||
<Radio.Group
|
||||
onChange={(e) => onTypeChange(fieldKey, e.target.value)}
|
||||
onChange={(e) => {
|
||||
const newType = e.target.value;
|
||||
|
||||
if (newType === 'text') {
|
||||
form.setFieldValue(['solution_items', fieldKey, 'fileUpload'], null);
|
||||
form.setFieldValue(['solution_items', fieldKey, 'file'], null);
|
||||
form.setFieldValue(['solution_items', fieldKey, 'path_solution'], null);
|
||||
form.setFieldValue(['solution_items', fieldKey, 'fileName'], null);
|
||||
setCurrentFile(null);
|
||||
setIsDeleted(true);
|
||||
|
||||
if (onFileUpload && typeof onFileUpload === 'function') {
|
||||
onFileUpload(null);
|
||||
}
|
||||
} else if (newType === 'file') {
|
||||
form.setFieldValue(['solution_items', fieldKey, 'text'], null);
|
||||
setIsDeleted(false);
|
||||
}
|
||||
|
||||
onTypeChange(fieldKey, newType);
|
||||
}}
|
||||
disabled={isReadOnly}
|
||||
size="small"
|
||||
>
|
||||
<Radio value="text">Text Solution</Radio>
|
||||
<Radio value="file">File Solution</Radio>
|
||||
<Radio value="text" style={{ fontSize: 12 }}>Text</Radio>
|
||||
<Radio value="file" style={{ fontSize: 12 }}>File</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name={['solution_items', fieldKey, 'status']}
|
||||
initialValue={solutionStatus !== false ? true : false}
|
||||
noStyle
|
||||
>
|
||||
<input type="hidden" />
|
||||
</Form.Item>
|
||||
|
||||
{renderSolutionContent()}
|
||||
</div>
|
||||
</div>
|
||||
</ConfigProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Form, Card, Typography, Divider, Button } from 'antd';
|
||||
import { Typography, Divider, Button, Form } from 'antd';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import SolutionFieldNew from './SolutionField';
|
||||
|
||||
@@ -10,67 +10,64 @@ const SolutionForm = ({
|
||||
solutionFields,
|
||||
solutionTypes,
|
||||
solutionStatuses,
|
||||
fileList,
|
||||
solutionsToDelete,
|
||||
firstSolutionValid,
|
||||
onAddSolutionField,
|
||||
onRemoveSolutionField,
|
||||
onSolutionTypeChange,
|
||||
onSolutionStatusChange,
|
||||
onSolutionFileUpload,
|
||||
onFileView,
|
||||
fileList,
|
||||
isReadOnly = false,
|
||||
onAddSolution,
|
||||
solutionData = [],
|
||||
}) => {
|
||||
return (
|
||||
<div>
|
||||
<Form
|
||||
form={solutionForm}
|
||||
layout="vertical"
|
||||
initialValues={{
|
||||
solution_status_0: true,
|
||||
solution_type_0: 'text',
|
||||
}}
|
||||
>
|
||||
<Divider orientation="left">Solution Items</Divider>
|
||||
|
||||
{solutionFields.map((field, index) => (
|
||||
<SolutionFieldNew
|
||||
key={field.key}
|
||||
fieldKey={field.key}
|
||||
fieldName={field.name}
|
||||
index={index}
|
||||
solutionType={solutionTypes[field.key]}
|
||||
solutionStatus={solutionStatuses[field.key]}
|
||||
onTypeChange={onSolutionTypeChange}
|
||||
onStatusChange={onSolutionStatusChange}
|
||||
onRemove={() => onRemoveSolutionField(field.key)}
|
||||
onFileUpload={onSolutionFileUpload}
|
||||
onFileView={onFileView}
|
||||
fileList={fileList}
|
||||
isReadOnly={isReadOnly}
|
||||
canRemove={solutionFields.length > 1}
|
||||
/>
|
||||
))}
|
||||
return (
|
||||
<div style={{ marginBottom: 0 }}>
|
||||
|
||||
<Form form={solutionForm} layout="vertical">
|
||||
<div style={{
|
||||
maxHeight: '400px',
|
||||
overflowY: 'auto',
|
||||
paddingRight: '8px'
|
||||
}}>
|
||||
{solutionFields.map((field, displayIndex) => (
|
||||
<SolutionFieldNew
|
||||
key={field}
|
||||
fieldKey={field}
|
||||
fieldName={['solution_items', field]}
|
||||
index={displayIndex}
|
||||
solutionType={solutionTypes[field]}
|
||||
solutionStatus={solutionStatuses[field]}
|
||||
onTypeChange={onSolutionTypeChange}
|
||||
onStatusChange={onSolutionStatusChange}
|
||||
onRemove={() => onRemoveSolutionField(field)}
|
||||
onFileUpload={onSolutionFileUpload}
|
||||
onFileView={onFileView}
|
||||
fileList={fileList}
|
||||
isReadOnly={isReadOnly}
|
||||
canRemove={solutionFields.length > 1 && displayIndex > 0}
|
||||
originalSolutionData={solutionData[displayIndex]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!isReadOnly && (
|
||||
<>
|
||||
<Form.Item>
|
||||
<Button
|
||||
type="dashed"
|
||||
onClick={onAddSolutionField}
|
||||
icon={<PlusOutlined />}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
+ Add Solution
|
||||
</Button>
|
||||
</Form.Item>
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Text type="secondary">
|
||||
* At least one solution is required for each error code.
|
||||
</Text>
|
||||
</div>
|
||||
</>
|
||||
<div style={{ marginBottom: 8, marginTop: 12 }}>
|
||||
<Button
|
||||
type="dashed"
|
||||
onClick={onAddSolutionField}
|
||||
icon={<PlusOutlined />}
|
||||
style={{
|
||||
width: '100%',
|
||||
borderColor: '#23A55A',
|
||||
color: '#23A55A',
|
||||
height: '32px',
|
||||
fontSize: '12px'
|
||||
}}
|
||||
>
|
||||
Add sollution
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Form, Select, Button, Switch, Typography, Space, Input, message } from 'antd';
|
||||
import { DeleteOutlined } from '@ant-design/icons';
|
||||
import { getAllSparepart } from '../../../../api/sparepart';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const SparepartField = ({
|
||||
fieldKey,
|
||||
fieldName,
|
||||
index,
|
||||
sparepartType,
|
||||
sparepartStatus,
|
||||
isReadOnly = false,
|
||||
canRemove = true,
|
||||
onRemove,
|
||||
spareparts = [],
|
||||
onSparepartChange
|
||||
}) => {
|
||||
const [currentStatus, setCurrentStatus] = useState(sparepartStatus ?? true);
|
||||
const [sparepartList, setSparepartList] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentStatus(sparepartStatus ?? true);
|
||||
loadSpareparts();
|
||||
}, [sparepartStatus]);
|
||||
|
||||
const loadSpareparts = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Get all spareparts from the API
|
||||
const params = new URLSearchParams();
|
||||
params.set('limit', '100'); // Get all spareparts
|
||||
|
||||
const response = await getAllSparepart(params);
|
||||
// Response structure should have { data: [...], statusCode: 200 }
|
||||
if (response && (response.statusCode === 200 || response.data)) {
|
||||
// If response has data array directly
|
||||
const sparepartData = response.data?.data || response.data || [];
|
||||
setSparepartList(sparepartData);
|
||||
if (onSparepartChange) {
|
||||
onSparepartChange(sparepartData);
|
||||
}
|
||||
} else {
|
||||
// For demo purposes, use mock data if API fails
|
||||
setSparepartList([
|
||||
{ brand_sparepart_id: 1, sparepart_name: 'Compressor Oil Filter', brand_sparepart_description: 'Oil filter for compressor' },
|
||||
{ brand_sparepart_id: 2, sparepart_name: 'Air Intake Filter', brand_sparepart_description: 'Air intake filter' },
|
||||
{ brand_sparepart_id: 3, sparepart_name: 'Cooling Fan Motor', brand_sparepart_description: 'Motor for cooling fan' },
|
||||
]);
|
||||
if (onSparepartChange) {
|
||||
onSparepartChange([
|
||||
{ brand_sparepart_id: 1, sparepart_name: 'Compressor Oil Filter', brand_sparepart_description: 'Oil filter for compressor' },
|
||||
{ brand_sparepart_id: 2, sparepart_name: 'Air Intake Filter', brand_sparepart_description: 'Air intake filter' },
|
||||
{ brand_sparepart_id: 3, sparepart_name: 'Cooling Fan Motor', brand_sparepart_description: 'Motor for cooling fan' },
|
||||
]);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading spareparts:', error);
|
||||
// Default mock data
|
||||
const mockSpareparts = [
|
||||
{ brand_sparepart_id: 1, sparepart_name: 'Compressor Oil Filter', brand_sparepart_description: 'Oil filter for compressor' },
|
||||
{ brand_sparepart_id: 2, sparepart_name: 'Air Intake Filter', brand_sparepart_description: 'Air intake filter' },
|
||||
{ brand_sparepart_id: 3, sparepart_name: 'Cooling Fan Motor', brand_sparepart_description: 'Motor for cooling fan' },
|
||||
];
|
||||
setSparepartList(mockSpareparts);
|
||||
if (onSparepartChange) {
|
||||
onSparepartChange(mockSpareparts);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const sparepartOptions = sparepartList.map(sparepart => ({
|
||||
label: sparepart.sparepart_name || sparepart.sparepart_name || `Sparepart ${sparepart.sparepart_id || sparepart.brand_sparepart_id}`,
|
||||
value: sparepart.sparepart_id || sparepart.brand_sparepart_id,
|
||||
description: sparepart.sparepart_description
|
||||
}));
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
border: '1px solid #d9d9d9',
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
marginBottom: 16,
|
||||
backgroundColor: isReadOnly ? '#f5f5f5' : 'white'
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
||||
<Text strong>Sparepart #{index + 1}</Text>
|
||||
<Space>
|
||||
<Form.Item
|
||||
name={[fieldName, 'sparepart_id']}
|
||||
rules={[{ required: false, message: 'Sparepart wajib dipilih!' }]} /* Making it optional since sparepart is optional */
|
||||
style={{ margin: 0, width: 200 }}
|
||||
>
|
||||
<Select
|
||||
placeholder="Pilih sparepart"
|
||||
loading={loading}
|
||||
disabled={isReadOnly}
|
||||
options={sparepartOptions}
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<Form.Item name={[fieldName, 'status']} valuePropName="checked" noStyle>
|
||||
<Switch
|
||||
disabled={isReadOnly}
|
||||
onChange={(checked) => {
|
||||
setCurrentStatus(checked);
|
||||
}}
|
||||
style={{
|
||||
backgroundColor: currentStatus ? '#23A55A' : '#bfbfbf'
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Text style={{ fontSize: 12, color: '#666' }}>
|
||||
{currentStatus ? 'Active' : 'Inactive'}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{canRemove && !isReadOnly && (
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={onRemove}
|
||||
/>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{/* Sparepart Description */}
|
||||
<Form.Item
|
||||
name={[fieldName, 'description']}
|
||||
label="Deskripsi"
|
||||
>
|
||||
<Input.TextArea
|
||||
placeholder="Deskripsi sparepart"
|
||||
rows={2}
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SparepartField;
|
||||
@@ -1,76 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Form, Card, Typography, Divider, Button } from 'antd';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import SparepartField from './SparepartField';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const SparepartForm = ({
|
||||
sparepartForm,
|
||||
sparepartFields,
|
||||
onAddSparepartField,
|
||||
onRemoveSparepartField,
|
||||
isReadOnly = false,
|
||||
spareparts = [],
|
||||
onSparepartChange
|
||||
}) => {
|
||||
const [sparepartList, setSparepartList] = useState([]);
|
||||
|
||||
const handleSparepartChange = (list) => {
|
||||
setSparepartList(list);
|
||||
if (onSparepartChange) {
|
||||
onSparepartChange(list);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Form
|
||||
form={sparepartForm}
|
||||
layout="vertical"
|
||||
initialValues={{
|
||||
sparepart_status_0: true,
|
||||
}}
|
||||
>
|
||||
<Divider orientation="left">Sparepart Items</Divider>
|
||||
|
||||
{sparepartFields.map((field, index) => (
|
||||
<SparepartField
|
||||
key={field.key}
|
||||
fieldKey={field.key}
|
||||
fieldName={field.name}
|
||||
index={index}
|
||||
sparepartStatus={field.status}
|
||||
onRemove={() => onRemoveSparepartField(field.key)}
|
||||
isReadOnly={isReadOnly}
|
||||
canRemove={sparepartFields.length > 1}
|
||||
spareparts={sparepartList}
|
||||
onSparepartChange={handleSparepartChange}
|
||||
/>
|
||||
))}
|
||||
|
||||
{!isReadOnly && (
|
||||
<>
|
||||
<Form.Item>
|
||||
<Button
|
||||
type="dashed"
|
||||
onClick={onAddSparepartField}
|
||||
icon={<PlusOutlined />}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
+ Add Sparepart
|
||||
</Button>
|
||||
</Form.Item>
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Text type="secondary">
|
||||
* Sparepart is optional and can be added for each error code if needed.
|
||||
</Text>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SparepartForm;
|
||||
178
src/pages/master/brandDevice/component/SparepartSelect.jsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Select, Typography, Tag, Spin, Empty, Button } from 'antd';
|
||||
import { PlusOutlined, DeleteOutlined, CheckOutlined, EyeOutlined, InfoCircleOutlined } from '@ant-design/icons';
|
||||
import { getAllSparepart } from '../../../../api/sparepart';
|
||||
import CustomSparepartCard from './CustomSparepartCard';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
const { Option } = Select;
|
||||
|
||||
const SparepartSelect = ({
|
||||
selectedSparepartIds = [],
|
||||
onSparepartChange,
|
||||
isReadOnly = false
|
||||
}) => {
|
||||
const [spareparts, setSpareparts] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedSpareparts, setSelectedSpareparts] = useState([]);
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSpareparts();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedSparepartIds && selectedSparepartIds.length > 0) {
|
||||
const fullSelectedSpareparts = spareparts.filter(sp =>
|
||||
selectedSparepartIds.includes(sp.sparepart_id)
|
||||
);
|
||||
setSelectedSpareparts(fullSelectedSpareparts);
|
||||
} else {
|
||||
setSelectedSpareparts([]);
|
||||
}
|
||||
}, [selectedSparepartIds, spareparts]);
|
||||
|
||||
const fetchSpareparts = async (searchQuery = '') => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
params.set('limit', '10');
|
||||
|
||||
if (searchQuery && searchQuery.trim() !== '') {
|
||||
params.set('criteria', searchQuery.trim());
|
||||
}
|
||||
|
||||
const response = await getAllSparepart(params);
|
||||
if (response && (response.statusCode === 200 || response.data)) {
|
||||
const sparepartData = response.data?.data || response.data || [];
|
||||
setSpareparts(sparepartData);
|
||||
} else {
|
||||
setSpareparts([]);
|
||||
}
|
||||
} catch (error) {
|
||||
setSpareparts([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSparepartSelect = (sparepartId) => {
|
||||
const selectedSparepart = spareparts.find(sp => sp.sparepart_id === sparepartId);
|
||||
|
||||
if (selectedSparepart) {
|
||||
const isAlreadySelected = selectedSpareparts.some(sp => sp.sparepart_id === sparepartId);
|
||||
|
||||
if (!isAlreadySelected) {
|
||||
const newSelectedSpareparts = [...selectedSpareparts, selectedSparepart];
|
||||
setSelectedSpareparts(newSelectedSpareparts);
|
||||
|
||||
const newSelectedIds = newSelectedSpareparts.map(sp => sp.sparepart_id);
|
||||
onSparepartChange(newSelectedIds);
|
||||
}
|
||||
}
|
||||
setDropdownOpen(false);
|
||||
};
|
||||
|
||||
const handleSearch = (value) => {
|
||||
fetchSpareparts(value);
|
||||
};
|
||||
|
||||
const onDropdownOpenChange = (open) => {
|
||||
setDropdownOpen(open);
|
||||
if (open) {
|
||||
fetchSpareparts();
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveSparepart = (sparepartId) => {
|
||||
const newSelectedSpareparts = selectedSpareparts.filter(sp => sp.sparepart_id !== sparepartId);
|
||||
setSelectedSpareparts(newSelectedSpareparts);
|
||||
|
||||
const newSelectedIds = newSelectedSpareparts.map(sp => sp.sparepart_id);
|
||||
onSparepartChange(newSelectedIds);
|
||||
};
|
||||
|
||||
const renderSparepartCard = (sparepart, isSelected = false) => {
|
||||
const isAlreadySelected = selectedSpareparts.some(sp => sp.sparepart_id === sparepart.sparepart_id);
|
||||
|
||||
return (
|
||||
<CustomSparepartCard
|
||||
key={sparepart.sparepart_id}
|
||||
sparepart={sparepart}
|
||||
isSelected={isSelected}
|
||||
isReadOnly={isReadOnly}
|
||||
showPreview={true}
|
||||
showDelete={isAlreadySelected && !isReadOnly}
|
||||
onCardClick={!isAlreadySelected && !isReadOnly ? () => handleSparepartSelect(sparepart.sparepart_id) : undefined}
|
||||
onDelete={() => handleRemoveSparepart(sparepart.sparepart_id)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
{!isReadOnly && (
|
||||
<div style={{
|
||||
marginBottom: 16,
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 10,
|
||||
backgroundColor: 'white',
|
||||
padding: '8px 0',
|
||||
borderBottom: '1px solid #f0f0f0'
|
||||
}}>
|
||||
<Select
|
||||
placeholder="search and select sparepart"
|
||||
style={{ width: '100%' }}
|
||||
loading={loading}
|
||||
onSelect={handleSparepartSelect}
|
||||
value={null}
|
||||
showSearch
|
||||
onSearch={handleSearch}
|
||||
filterOption={false}
|
||||
open={dropdownOpen}
|
||||
onOpenChange={onDropdownOpenChange}
|
||||
suffixIcon={<PlusOutlined />}
|
||||
>
|
||||
{spareparts
|
||||
.filter(sparepart => !selectedSpareparts.some(sp => sp.sparepart_id === sparepart.sparepart_id))
|
||||
.slice(0, 10)
|
||||
.map((sparepart) => (
|
||||
<Option key={sparepart.sparepart_id} value={sparepart.sparepart_id}>
|
||||
<div>
|
||||
<Text strong>{sparepart.sparepart_name || sparepart.name || 'Unnamed'}</Text>
|
||||
<Text type="secondary" style={{ marginLeft: 8 }}>
|
||||
({sparepart.sparepart_code || 'No code'})
|
||||
</Text>
|
||||
</div>
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
<div>
|
||||
{selectedSpareparts.length > 0 ? (
|
||||
<div>
|
||||
<Title level={5} style={{ marginBottom: 16 }}>
|
||||
Selected Spareparts ({selectedSpareparts.length})
|
||||
</Title>
|
||||
<div>
|
||||
{selectedSpareparts.map(sparepart => renderSparepartCard(sparepart, true))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description="No spareparts selected"
|
||||
style={{ margin: '20px 0' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SparepartSelect;
|
||||
@@ -1,265 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { NotifAlert, NotifOk } from '../../../../components/Global/ToastNotif';
|
||||
|
||||
export const useErrorCodeLogic = (errorCodeForm, fileList) => {
|
||||
const [solutionFields, setSolutionFields] = useState([0]);
|
||||
const [solutionTypes, setSolutionTypes] = useState({ 0: 'text' });
|
||||
const [solutionStatuses, setSolutionStatuses] = useState({ 0: true });
|
||||
const [firstSolutionValid, setFirstSolutionValid] = useState(false);
|
||||
const [solutionsToDelete, setSolutionsToDelete] = useState(new Set());
|
||||
|
||||
const checkPreviousSolutionValid = (currentSolutionIndex) => {
|
||||
for (let i = 0; i < currentSolutionIndex; i++) {
|
||||
const fieldId = solutionFields[i];
|
||||
const solutionType = solutionTypes[fieldId];
|
||||
|
||||
const solutionName = errorCodeForm.getFieldValue(`solution_name_${fieldId}`);
|
||||
if (!solutionName || solutionName.trim() === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (solutionType === 'text') {
|
||||
const textSolution = errorCodeForm.getFieldValue(`text_solution_${fieldId}`);
|
||||
if (!textSolution || textSolution.trim() === '') {
|
||||
return false;
|
||||
}
|
||||
} else if (solutionType === 'file') {
|
||||
const filesForSolution = fileList.filter(file => file.solutionId === fieldId);
|
||||
if (filesForSolution.length === 0) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const checkFirstSolutionValid = () => {
|
||||
if (solutionFields.length === 0) {
|
||||
setFirstSolutionValid(false);
|
||||
return false;
|
||||
}
|
||||
const isValid = checkPreviousSolutionValid(1);
|
||||
setFirstSolutionValid(isValid);
|
||||
return isValid;
|
||||
};
|
||||
|
||||
const handleAddSolutionField = () => {
|
||||
const currentSolutionCount = solutionFields.length;
|
||||
const nextSolutionNumber = currentSolutionCount + 1;
|
||||
|
||||
if (!checkPreviousSolutionValid(currentSolutionCount)) {
|
||||
let incompleteSolutionIndex = -1;
|
||||
for (let i = 0; i < currentSolutionCount; i++) {
|
||||
const fieldId = solutionFields[i];
|
||||
const solutionType = solutionTypes[fieldId];
|
||||
const solutionName = errorCodeForm.getFieldValue(`solution_name_${fieldId}`);
|
||||
let hasContent = false;
|
||||
|
||||
if (solutionType === 'text') {
|
||||
const textSolution = errorCodeForm.getFieldValue(`text_solution_${fieldId}`);
|
||||
hasContent = textSolution && textSolution.trim();
|
||||
} else if (solutionType === 'file') {
|
||||
const filesForSolution = fileList.filter(file => file.solutionId === fieldId);
|
||||
hasContent = filesForSolution.length > 0;
|
||||
}
|
||||
|
||||
if (!solutionName?.trim() || !hasContent) {
|
||||
incompleteSolutionIndex = i + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
NotifAlert({
|
||||
icon: 'warning',
|
||||
title: 'Perhatian',
|
||||
message: `Harap lengkapi Solution ${incompleteSolutionIndex} terlebih dahulu sebelum menambah Solution ${nextSolutionNumber}!`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const newId = `new-${Date.now()}`;
|
||||
setSolutionFields(prev => [...prev, newId]);
|
||||
setSolutionTypes(prev => ({ ...prev, [newId]: 'text' }));
|
||||
setSolutionStatuses(prev => ({ ...prev, [newId]: true }));
|
||||
errorCodeForm.setFieldValue(`solution_status_${newId}`, true);
|
||||
errorCodeForm.setFieldValue(`solution_type_${newId}`, 'text');
|
||||
};
|
||||
|
||||
const handleRemoveSolutionField = (id) => {
|
||||
const isNewSolution = !id.toString().startsWith('existing-');
|
||||
|
||||
if (isNewSolution) {
|
||||
if (solutionFields.length > 1) {
|
||||
setSolutionFields(solutionFields.filter(fieldId => fieldId !== id));
|
||||
setSolutionTypes(prev => {
|
||||
const newTypes = { ...prev };
|
||||
delete newTypes[id];
|
||||
return newTypes;
|
||||
});
|
||||
setSolutionStatuses(prev => {
|
||||
const newStatuses = { ...prev };
|
||||
delete newStatuses[id];
|
||||
return newStatuses;
|
||||
});
|
||||
setSolutionsToDelete(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(id);
|
||||
return newSet;
|
||||
});
|
||||
} else {
|
||||
NotifAlert({
|
||||
icon: 'warning',
|
||||
title: 'Perhatian',
|
||||
message: 'Setiap error code harus memiliki minimal 1 solution!'
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const solutionName = errorCodeForm.getFieldValue(`solution_name_${id}`);
|
||||
const solutionType = solutionTypes[id];
|
||||
let isEmpty = true;
|
||||
|
||||
const existingSolution = window.currentSolutionData?.[id];
|
||||
const hasExistingData = existingSolution && (
|
||||
(existingSolution.solution_name && existingSolution.solution_name.trim()) ||
|
||||
(existingSolution.text_solution && existingSolution.text_solution.trim()) ||
|
||||
(existingSolution.path_solution && existingSolution.path_solution.trim())
|
||||
);
|
||||
|
||||
if (solutionType === 'text') {
|
||||
const textSolution = errorCodeForm.getFieldValue(`text_solution_${id}`);
|
||||
isEmpty = !solutionName?.trim() && !textSolution?.trim() && !hasExistingData;
|
||||
} else if (solutionType === 'file') {
|
||||
const filesForSolution = fileList.filter(file => file.solutionId === id);
|
||||
isEmpty = !solutionName?.trim() && filesForSolution.length === 0 && !hasExistingData;
|
||||
}
|
||||
|
||||
if (isEmpty) {
|
||||
if (solutionFields.length > 1) {
|
||||
setSolutionFields(solutionFields.filter(fieldId => fieldId !== id));
|
||||
setSolutionTypes(prev => {
|
||||
const newTypes = { ...prev };
|
||||
delete newTypes[id];
|
||||
return newTypes;
|
||||
});
|
||||
setSolutionStatuses(prev => {
|
||||
const newStatuses = { ...prev };
|
||||
delete newStatuses[id];
|
||||
return newStatuses;
|
||||
});
|
||||
|
||||
if (window.currentSolutionData) {
|
||||
delete window.currentSolutionData[id];
|
||||
}
|
||||
|
||||
setSolutionsToDelete(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(id);
|
||||
return newSet;
|
||||
});
|
||||
} else {
|
||||
NotifAlert({
|
||||
icon: 'warning',
|
||||
title: 'Perhatian',
|
||||
message: 'Setiap error code harus memiliki minimal 1 solution!'
|
||||
});
|
||||
}
|
||||
} else {
|
||||
if (solutionFields.length > 1) {
|
||||
setSolutionsToDelete(prev => new Set(prev).add(id));
|
||||
|
||||
const solutionElement = document.querySelector(`[data-solution-id="${id}"]`);
|
||||
if (solutionElement) {
|
||||
solutionElement.style.opacity = '0.5';
|
||||
solutionElement.style.border = '2px dashed #ff4d4f';
|
||||
}
|
||||
|
||||
NotifOk({
|
||||
icon: 'success',
|
||||
title: 'Berhasil',
|
||||
message: 'Solution ditandai untuk dihapus. Klik "Update Error Code" untuk menyimpan perubahan.'
|
||||
});
|
||||
} else {
|
||||
NotifAlert({
|
||||
icon: 'warning',
|
||||
title: 'Perhatian',
|
||||
message: 'Setiap error code harus memiliki minimal 1 solution!'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSolutionTypeChange = (fieldId, type) => {
|
||||
setSolutionTypes(prev => ({ ...prev, [fieldId]: type }));
|
||||
};
|
||||
|
||||
const handleSolutionStatusChange = (fieldId, status) => {
|
||||
// Only update local state - form is already updated by Form.Item
|
||||
setSolutionStatuses(prev => ({
|
||||
...prev,
|
||||
[fieldId]: status
|
||||
}));
|
||||
};
|
||||
|
||||
const setSolutionsForExistingRecord = (solutions, errorCodeForm) => {
|
||||
const newSolutionFields = [];
|
||||
const newSolutionTypes = {};
|
||||
const newSolutionStatuses = {};
|
||||
const newSolutionData = {};
|
||||
|
||||
solutions.forEach((solution, index) => {
|
||||
const fieldId = `existing-${index}`;
|
||||
newSolutionFields.push(fieldId);
|
||||
newSolutionTypes[fieldId] = solution.type_solution || 'text';
|
||||
newSolutionStatuses[fieldId] = solution.is_active !== false;
|
||||
newSolutionData[fieldId] = {
|
||||
...solution,
|
||||
brand_code_solution_id: solution.brand_code_solution_id
|
||||
};
|
||||
|
||||
setTimeout(() => {
|
||||
errorCodeForm.setFieldsValue({
|
||||
[`solution_name_${fieldId}`]: solution.solution_name,
|
||||
[`text_solution_${fieldId}`]: solution.text_solution || '',
|
||||
[`solution_status_${fieldId}`]: solution.is_active !== false,
|
||||
[`solution_type_${fieldId}`]: solution.type_solution === 'image' || solution.type_solution === 'pdf' ? 'file' : solution.type_solution
|
||||
});
|
||||
}, 100);
|
||||
});
|
||||
|
||||
setSolutionFields(newSolutionFields);
|
||||
setSolutionTypes(newSolutionTypes);
|
||||
setSolutionStatuses(newSolutionStatuses);
|
||||
window.currentSolutionData = newSolutionData;
|
||||
};
|
||||
|
||||
const resetSolutionFields = () => {
|
||||
setSolutionFields([0]);
|
||||
setSolutionTypes({ 0: 'text' });
|
||||
setSolutionStatuses({ 0: true });
|
||||
setFirstSolutionValid(false);
|
||||
setSolutionsToDelete(new Set());
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
checkFirstSolutionValid();
|
||||
}, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}, [solutionFields, solutionTypes, fileList, errorCodeForm]);
|
||||
|
||||
return {
|
||||
solutionFields,
|
||||
solutionTypes,
|
||||
solutionStatuses,
|
||||
firstSolutionValid,
|
||||
solutionsToDelete,
|
||||
handleAddSolutionField,
|
||||
handleRemoveSolutionField,
|
||||
handleSolutionTypeChange,
|
||||
handleSolutionStatusChange,
|
||||
resetSolutionFields,
|
||||
checkFirstSolutionValid,
|
||||
setSolutionsForExistingRecord
|
||||
};
|
||||
};
|
||||
@@ -1,166 +0,0 @@
|
||||
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,
|
||||
};
|
||||
};
|
||||
@@ -1,141 +0,0 @@
|
||||
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,
|
||||
};
|
||||
};
|
||||
@@ -23,6 +23,7 @@ const DetailDevice = (props) => {
|
||||
device_location: '',
|
||||
device_description: '',
|
||||
ip_address: '',
|
||||
listen_channel: '',
|
||||
};
|
||||
|
||||
const [formData, setFormData] = useState(defaultData);
|
||||
@@ -59,9 +60,13 @@ const DetailDevice = (props) => {
|
||||
device_name: formData.device_name,
|
||||
is_active: formData.is_active,
|
||||
device_location: formData.device_location,
|
||||
device_description: formData.device_description,
|
||||
device_description:
|
||||
formData.device_description && formData.device_description.trim() !== ''
|
||||
? formData.device_description
|
||||
: ' ',
|
||||
ip_address: formData.ip_address,
|
||||
brand_id: formData.brand_id,
|
||||
listen_channel: formData.listen_channel,
|
||||
};
|
||||
|
||||
const response = formData.device_id
|
||||
@@ -182,7 +187,6 @@ const DetailDevice = (props) => {
|
||||
defaultBorderColor: '#23A55A',
|
||||
defaultHoverColor: '#23A55A',
|
||||
defaultHoverBorderColor: '#23A55A',
|
||||
defaultHoverColor: '#23A55A',
|
||||
},
|
||||
},
|
||||
}}
|
||||
@@ -326,6 +330,16 @@ const DetailDevice = (props) => {
|
||||
readOnly={props.readOnly}
|
||||
/>
|
||||
</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 }}>
|
||||
<Text strong>Device Description</Text>
|
||||
<TextArea
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, {useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Modal, Button, ConfigProvider } from 'antd';
|
||||
import { jsPDF } from 'jspdf';
|
||||
import logoPiEnergi from '../../../../assets/images/logo/pi-energi.png';
|
||||
@@ -22,12 +22,12 @@ const GeneratePdf = (props) => {
|
||||
};
|
||||
|
||||
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({
|
||||
orientation: "portrait",
|
||||
unit: "mm",
|
||||
format: "a4"
|
||||
orientation: 'portrait',
|
||||
unit: 'mm',
|
||||
format: 'a4',
|
||||
});
|
||||
|
||||
const width = 45;
|
||||
@@ -50,27 +50,27 @@ const GeneratePdf = (props) => {
|
||||
doc.setLineWidth(0.6);
|
||||
doc.line(10, 32.8, 200, 32.8);
|
||||
|
||||
doc.text("Tanggal Pengajuan", 10, 42);
|
||||
doc.text(":", 59, 42);
|
||||
doc.text('Tanggal Pengajuan', 10, 42);
|
||||
doc.text(':', 59, 42);
|
||||
|
||||
doc.text("Deskripsi Pekerjaan", 10, 48);
|
||||
doc.text(":", 59, 48);
|
||||
doc.text('Deskripsi Pekerjaan', 10, 48);
|
||||
doc.text(':', 59, 48);
|
||||
|
||||
doc.text("No. Permit", 10, 54);
|
||||
doc.text(":", 59, 54);
|
||||
doc.text("Spesifik Lokasi", 120, 54);
|
||||
doc.text(":", 160, 54);
|
||||
doc.text('No. Permit', 10, 54);
|
||||
doc.text(':', 59, 54);
|
||||
doc.text('Spesifik Lokasi', 120, 54);
|
||||
doc.text(':', 160, 54);
|
||||
|
||||
doc.text("No. Order", 10, 60);
|
||||
doc.text(":", 59, 60);
|
||||
doc.text("Jum. Personil Terlihat", 120, 60);
|
||||
doc.text(":", 160, 60);
|
||||
doc.text('No. Order', 10, 60);
|
||||
doc.text(':', 59, 60);
|
||||
doc.text('Jum. Personil Terlihat', 120, 60);
|
||||
doc.text(':', 160, 60);
|
||||
|
||||
doc.text("Peralatan yang digunakan", 10, 66);
|
||||
doc.text(":", 59, 66);
|
||||
doc.text('Peralatan yang digunakan', 10, 66);
|
||||
doc.text(':', 59, 66);
|
||||
|
||||
doc.text("Jenis APD yang digunakan", 10, 72);
|
||||
doc.text(":", 59, 72);
|
||||
doc.text('Jenis APD yang digunakan', 10, 72);
|
||||
doc.text(':', 59, 72);
|
||||
|
||||
const blob = doc.output('blob');
|
||||
const url = URL.createObjectURL(blob);
|
||||
@@ -84,7 +84,7 @@ const GeneratePdf = (props) => {
|
||||
|
||||
return (
|
||||
<Modal
|
||||
width='60%'
|
||||
width="60%"
|
||||
title="Preview PDF"
|
||||
open={props.showPdf}
|
||||
// open={true}
|
||||
@@ -101,7 +101,6 @@ const GeneratePdf = (props) => {
|
||||
defaultBorderColor: '#23A55A',
|
||||
defaultHoverColor: '#23A55A',
|
||||
defaultHoverBorderColor: '#23A55A',
|
||||
defaultHoverColor: '#23A55A',
|
||||
},
|
||||
},
|
||||
}}
|
||||
|
||||
@@ -62,6 +62,13 @@ const columns = (showPreviewModal, showEditModal, showDeleteDialog) => [
|
||||
key: 'ip_address',
|
||||
width: '10%',
|
||||
},
|
||||
{
|
||||
title: 'Listen Channel',
|
||||
dataIndex: 'listen_channel',
|
||||
key: 'listen_channel',
|
||||
width: '10%',
|
||||
render: (listen_channel) => listen_channel || '-'
|
||||
},
|
||||
{
|
||||
title: 'Status',
|
||||
dataIndex: 'is_active',
|
||||
|
||||
@@ -38,7 +38,7 @@ const DetailPlantSubSection = (props) => {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`📝 Input change: ${name} = ${value}`);
|
||||
// console.log(`📝 Input change: ${name} = ${value}`);
|
||||
|
||||
if (name) {
|
||||
setFormData((prev) => ({
|
||||
@@ -74,16 +74,20 @@ const DetailPlantSubSection = (props) => {
|
||||
return;
|
||||
|
||||
try {
|
||||
console.log('💾 Current formData before save:', formData);
|
||||
// console.log('💾 Current formData before save:', formData);
|
||||
|
||||
const payload = {
|
||||
plant_sub_section_name: formData.plant_sub_section_name,
|
||||
plant_sub_section_description: formData.plant_sub_section_description,
|
||||
plant_sub_section_description:
|
||||
formData.plant_sub_section_description &&
|
||||
formData.plant_sub_section_description.trim() !== ''
|
||||
? formData.plant_sub_section_description
|
||||
: ' ',
|
||||
table_name_value: formData.table_name_value, // Fix field name
|
||||
is_active: formData.is_active,
|
||||
};
|
||||
|
||||
console.log('📤 Payload to be sent:', payload);
|
||||
// console.log('📤 Payload to be sent:', payload);
|
||||
|
||||
const response =
|
||||
props.actionMode === 'edit'
|
||||
@@ -126,17 +130,17 @@ const DetailPlantSubSection = (props) => {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
console.log('🔄 Modal state changed:', {
|
||||
showModal: props.showModal,
|
||||
actionMode: props.actionMode,
|
||||
selectedData: props.selectedData,
|
||||
});
|
||||
// console.log('🔄 Modal state changed:', {
|
||||
// showModal: props.showModal,
|
||||
// actionMode: props.actionMode,
|
||||
// selectedData: props.selectedData,
|
||||
// });
|
||||
|
||||
if (props.selectedData) {
|
||||
console.log('📋 Setting form data from selectedData:', props.selectedData);
|
||||
// console.log('📋 Setting form data from selectedData:', props.selectedData);
|
||||
setFormData(props.selectedData);
|
||||
} else {
|
||||
console.log('📋 Resetting to default data');
|
||||
// console.log('📋 Resetting to default data');
|
||||
setFormData(defaultData);
|
||||
}
|
||||
}, [props.showModal, props.selectedData, props.actionMode]);
|
||||
|
||||
@@ -112,9 +112,9 @@ const DetailShift = (props) => {
|
||||
is_active: formData.is_active,
|
||||
};
|
||||
|
||||
console.log('Payload yang dikirim:', payload);
|
||||
console.log('Type start_time:', typeof payload.start_time, payload.start_time);
|
||||
console.log('Type end_time:', typeof payload.end_time, payload.end_time);
|
||||
// console.log('Payload yang dikirim:', payload);
|
||||
// console.log('Type start_time:', typeof payload.start_time, payload.start_time);
|
||||
// console.log('Type end_time:', typeof payload.end_time, payload.end_time);
|
||||
|
||||
const response =
|
||||
props.actionMode === 'edit'
|
||||
|
||||
@@ -1,24 +1,53 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Modal, Input, Divider, Typography, Switch, Button, ConfigProvider, message } from 'antd';
|
||||
import {
|
||||
Modal,
|
||||
Input,
|
||||
Select,
|
||||
Divider,
|
||||
Typography,
|
||||
Button,
|
||||
ConfigProvider,
|
||||
Upload,
|
||||
Row,
|
||||
Col,
|
||||
Image,
|
||||
} from 'antd';
|
||||
import { PlusOutlined, EyeOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||
import { NotifAlert, NotifOk } from '../../../../components/Global/ToastNotif';
|
||||
import { createSparepart, updateSparepart } from '../../../../api/sparepart';
|
||||
import { uploadFile } from '../../../../api/file-uploads';
|
||||
import { validateRun } from '../../../../Utils/validate';
|
||||
|
||||
const { Text } = Typography;
|
||||
const { TextArea } = Input;
|
||||
|
||||
const getBase64 = (file) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(file);
|
||||
reader.onload = () => resolve(reader.result);
|
||||
reader.onerror = (error) => reject(error);
|
||||
});
|
||||
|
||||
const DetailSparepart = (props) => {
|
||||
const [confirmLoading, setConfirmLoading] = useState(false);
|
||||
const [fileList, setFileList] = useState([]);
|
||||
const [previewOpen, setPreviewOpen] = useState(false);
|
||||
const [previewImage, setPreviewImage] = useState('');
|
||||
const [previewTitle, setPreviewTitle] = useState('');
|
||||
const [isHovering, setIsHovering] = useState(false);
|
||||
|
||||
const defaultData = {
|
||||
sparepart_id: '',
|
||||
sparepart_name: '',
|
||||
sparepart_description: '',
|
||||
sparepart_model: '',
|
||||
sparepart_item_type: '',
|
||||
sparepart_item_type: null,
|
||||
sparepart_qty: 0,
|
||||
sparepart_unit: '',
|
||||
sparepart_merk: '',
|
||||
sparepart_stok: '',
|
||||
sparepart_stok: 'Not Available',
|
||||
sparepart_foto: '',
|
||||
};
|
||||
|
||||
const [formData, setFormData] = useState(defaultData);
|
||||
@@ -26,60 +55,205 @@ const DetailSparepart = (props) => {
|
||||
const handleCancel = () => {
|
||||
props.setSelectedData(null);
|
||||
props.setActionMode('list');
|
||||
setFileList([]);
|
||||
};
|
||||
|
||||
const handlePreviewCancel = () => setPreviewOpen(false);
|
||||
|
||||
const handlePreview = async (file) => {
|
||||
if (!file.url && !file.preview) {
|
||||
file.preview = await getBase64(file.originFileObj);
|
||||
}
|
||||
setPreviewImage(file.url || file.preview);
|
||||
setPreviewOpen(true);
|
||||
setPreviewTitle(file.name || file.url.substring(file.url.lastIndexOf('/') + 1));
|
||||
};
|
||||
|
||||
const handleChange = ({ fileList: newFileList }) => setFileList(newFileList);
|
||||
|
||||
const handleRemove = () => {
|
||||
setFileList([]);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setConfirmLoading(true);
|
||||
|
||||
// Daftar aturan validasi
|
||||
const validationRules = [
|
||||
{ field: 'sparepart_name', label: 'Sparepart Name', required: true },
|
||||
{ field: 'sparepart_model', label: 'Sparepart Model', required: true },
|
||||
{ field: 'sparepart_unit', label: 'Sparepart Unit', required: true },
|
||||
{ field: 'sparepart_merk', label: 'Sparepart Merk', required: true },
|
||||
{ field: 'sparepart_stok', label: 'Sparepart Stok', required: true },
|
||||
];
|
||||
|
||||
if (
|
||||
validateRun(formData, validationRules, (errorMessages) => {
|
||||
NotifOk({
|
||||
icon: 'warning',
|
||||
title: 'Peringatan',
|
||||
message: errorMessages,
|
||||
});
|
||||
NotifOk({ icon: 'warning', title: 'Peringatan', message: errorMessages });
|
||||
setConfirmLoading(false);
|
||||
})
|
||||
)
|
||||
return;
|
||||
|
||||
try {
|
||||
let imageUrl = formData.sparepart_foto;
|
||||
const newFile = fileList.length > 0 ? fileList[0] : null;
|
||||
|
||||
if (newFile && newFile.originFileObj) {
|
||||
// console.log('Uploading file:', newFile.originFileObj);
|
||||
const uploadResponse = await uploadFile(newFile.originFileObj, 'images');
|
||||
|
||||
// Log untuk debugging
|
||||
// console.log('Upload response:', uploadResponse);
|
||||
|
||||
// Cek berbagai kemungkinan struktur respons dari API
|
||||
let uploadedUrl = null;
|
||||
|
||||
// Cek berbagai kemungkinan struktur respons dari API
|
||||
// Cek langsung properti file_url atau url
|
||||
if (uploadResponse && typeof uploadResponse === 'object') {
|
||||
// Cek jika uploadResponse langsung memiliki file_url
|
||||
if (uploadResponse.file_url) {
|
||||
uploadedUrl = uploadResponse.file_url;
|
||||
}
|
||||
// Cek jika uploadResponse memiliki data yang berisi file_url
|
||||
else if (uploadResponse.data && uploadResponse.data.file_url) {
|
||||
uploadedUrl = uploadResponse.data.file_url;
|
||||
}
|
||||
// Cek jika uploadResponse memiliki data yang berisi url
|
||||
else if (uploadResponse.data && uploadResponse.data.url) {
|
||||
uploadedUrl = uploadResponse.data.url;
|
||||
}
|
||||
// Cek jika uploadResponse langsung memiliki url
|
||||
else if (uploadResponse.url) {
|
||||
uploadedUrl = uploadResponse.url;
|
||||
}
|
||||
// Cek jika uploadResponse.data adalah string URL
|
||||
else if (uploadResponse.data && typeof uploadResponse.data === 'string') {
|
||||
uploadedUrl = uploadResponse.data;
|
||||
}
|
||||
// Cek jika uploadResponse.data adalah objek yang berisi file URL dalam format berbeda
|
||||
else if (uploadResponse.data && typeof uploadResponse.data === 'object') {
|
||||
// Cek kemungkinan nama field lain
|
||||
if (uploadResponse.data.file) {
|
||||
uploadedUrl = uploadResponse.data.file;
|
||||
} else if (uploadResponse.data.filename) {
|
||||
// Jika hanya nama file dikembalikan, bangun URL
|
||||
const baseUrl = import.meta.env.VITE_API_SERVER || '';
|
||||
uploadedUrl = `${baseUrl}/uploads/images/${uploadResponse.data.filename}`;
|
||||
} else if (uploadResponse.data.path) {
|
||||
uploadedUrl = uploadResponse.data.path;
|
||||
} else if (uploadResponse.data.location) {
|
||||
uploadedUrl = uploadResponse.data.location;
|
||||
}
|
||||
// Tambahkan kemungkinan lain berdasarkan struktur respons umum
|
||||
else if (uploadResponse.data.filePath) {
|
||||
uploadedUrl = uploadResponse.data.filePath;
|
||||
} else if (uploadResponse.data.file_path) {
|
||||
uploadedUrl = uploadResponse.data.file_path;
|
||||
} else if (uploadResponse.data.publicUrl) {
|
||||
uploadedUrl = uploadResponse.data.publicUrl;
|
||||
} else if (uploadResponse.data.public_url) {
|
||||
uploadedUrl = uploadResponse.data.public_url;
|
||||
}
|
||||
// Berdasarkan log yang ditampilkan, API mengembalikan path_document atau path_solution
|
||||
else if (uploadResponse.data.path_document) {
|
||||
uploadedUrl = uploadResponse.data.path_document;
|
||||
} else if (uploadResponse.data.path_solution) {
|
||||
uploadedUrl = uploadResponse.data.path_solution;
|
||||
} else if (uploadResponse.data.file_upload_name) {
|
||||
// Jika hanya nama file dikembalikan, bangun URL
|
||||
const baseUrl = import.meta.env.VITE_API_SERVER || '';
|
||||
uploadedUrl = `${baseUrl}/uploads/images/${uploadResponse.data.file_upload_name}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Jika respons adalah string, mungkin itu adalah URL
|
||||
else if (uploadResponse && typeof uploadResponse === 'string') {
|
||||
uploadedUrl = uploadResponse;
|
||||
}
|
||||
|
||||
if (uploadedUrl) {
|
||||
// console.log('Successfully extracted image URL:', uploadedUrl);
|
||||
imageUrl = uploadedUrl;
|
||||
} else {
|
||||
console.error('Upload response structure:', uploadResponse);
|
||||
console.error('Available properties:', Object.keys(uploadResponse || {}));
|
||||
console.error('Response type:', typeof uploadResponse);
|
||||
console.error(
|
||||
'Is response an object?',
|
||||
uploadResponse && typeof uploadResponse === 'object'
|
||||
);
|
||||
if (uploadResponse && typeof uploadResponse === 'object') {
|
||||
console.error('Response keys:', Object.keys(uploadResponse));
|
||||
console.error(
|
||||
'Response data keys:',
|
||||
uploadResponse.data
|
||||
? Object.keys(uploadResponse.data)
|
||||
: 'No data property'
|
||||
);
|
||||
}
|
||||
|
||||
// Tampilkan notifikasi bahwa upload gagal tapi lanjutkan penyimpanan
|
||||
NotifOk({
|
||||
icon: 'warning',
|
||||
title: 'Peringatan',
|
||||
message: 'Upload gambar gagal. Data akan disimpan tanpa gambar.',
|
||||
});
|
||||
|
||||
// Gunakan URL gambar yang sebelumnya jika ada, atau kosongkan
|
||||
imageUrl = formData.sparepart_foto || '';
|
||||
}
|
||||
} else if (fileList.length === 0) {
|
||||
// Jika tidak ada file di fileList (termasuk saat user menghapus file), gunakan gambar default
|
||||
imageUrl = '/assets/defaultSparepartImg.jpg';
|
||||
}
|
||||
|
||||
// Payload hanya berisi field yang tidak kosong untuk menghindari error validasi
|
||||
const payload = {
|
||||
sparepart_name: formData.sparepart_name,
|
||||
sparepart_description: formData.sparepart_description,
|
||||
sparepart_model: formData.sparepart_model,
|
||||
sparepart_item_type: formData.sparepart_item_type,
|
||||
sparepart_unit: formData.sparepart_unit,
|
||||
sparepart_merk: formData.sparepart_merk,
|
||||
sparepart_stok: formData.sparepart_stok,
|
||||
sparepart_name: formData.sparepart_name, // Wajib
|
||||
};
|
||||
|
||||
payload.sparepart_description =
|
||||
formData.sparepart_description && formData.sparepart_description.trim() !== ''
|
||||
? formData.sparepart_description
|
||||
: ' ';
|
||||
if (formData.sparepart_model && formData.sparepart_model.trim() !== '') {
|
||||
payload.sparepart_model = formData.sparepart_model;
|
||||
}
|
||||
if (formData.sparepart_item_type && formData.sparepart_item_type.trim() !== '') {
|
||||
payload.sparepart_item_type = formData.sparepart_item_type;
|
||||
}
|
||||
if (formData.sparepart_unit && formData.sparepart_unit.trim() !== '') {
|
||||
payload.sparepart_unit = formData.sparepart_unit;
|
||||
}
|
||||
if (formData.sparepart_merk && formData.sparepart_merk.trim() !== '') {
|
||||
payload.sparepart_merk = formData.sparepart_merk;
|
||||
}
|
||||
// sparepart_qty disimpan sebagai angka kuantitas
|
||||
const qty = parseInt(formData.sparepart_qty) || 0;
|
||||
payload.sparepart_qty = qty;
|
||||
|
||||
// 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
|
||||
if (imageUrl && imageUrl.trim() !== '') {
|
||||
payload.sparepart_foto = imageUrl;
|
||||
}
|
||||
|
||||
// console.log('Sending payload:', payload);
|
||||
|
||||
const response = formData.sparepart_id
|
||||
? await updateSparepart(formData.sparepart_id, payload)
|
||||
: await createSparepart(payload);
|
||||
|
||||
// Check if response is successful
|
||||
if (response && (response.statusCode === 200 || response.statusCode === 201)) {
|
||||
const sparepartName = response.data?.sparepart_name || formData.sparepart_name;
|
||||
// console.log('API response:', response);
|
||||
|
||||
if (response && (response.statusCode === 200 || response.statusCode === 201)) {
|
||||
NotifOk({
|
||||
icon: 'success',
|
||||
title: 'Berhasil',
|
||||
message: `Data Sparepart "${sparepartName}" berhasil ${
|
||||
message: `Data Sparepart berhasil ${
|
||||
formData.sparepart_id ? 'diubah' : 'ditambahkan'
|
||||
}.`,
|
||||
});
|
||||
|
||||
props.setActionMode('list');
|
||||
setFileList([]);
|
||||
} else {
|
||||
NotifAlert({
|
||||
icon: 'error',
|
||||
@@ -101,35 +275,62 @@ const DetailSparepart = (props) => {
|
||||
|
||||
const handleInputChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData({
|
||||
...formData,
|
||||
[name]: value,
|
||||
});
|
||||
setFormData({ ...formData, [name]: value });
|
||||
};
|
||||
|
||||
const handleFieldChange = (name, value) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
[name]: value,
|
||||
});
|
||||
};
|
||||
|
||||
const handleStatusToggle = (event) => {
|
||||
const isChecked = event;
|
||||
setFormData({
|
||||
...formData,
|
||||
is_active: isChecked ? true : false,
|
||||
});
|
||||
const handleSelectChange = (name, value) => {
|
||||
setFormData({ ...formData, [name]: value });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (props.selectedData) {
|
||||
setFormData(props.selectedData);
|
||||
if (props.selectedData.sparepart_foto) {
|
||||
let displayUrl = props.selectedData.sparepart_foto;
|
||||
|
||||
// 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();
|
||||
|
||||
setFileList([
|
||||
{
|
||||
uid: '-1',
|
||||
name: fileName,
|
||||
status: 'done',
|
||||
url: displayUrl,
|
||||
},
|
||||
]);
|
||||
} else {
|
||||
setFileList([]);
|
||||
}
|
||||
} else {
|
||||
setFormData(defaultData);
|
||||
setFileList([]);
|
||||
}
|
||||
}, [props.showModal, props.selectedData, props.actionMode]);
|
||||
|
||||
const uploadButton = (
|
||||
<div>
|
||||
<PlusOutlined />
|
||||
<div style={{ marginTop: 8 }}>Upload</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={`${
|
||||
@@ -153,7 +354,6 @@ const DetailSparepart = (props) => {
|
||||
defaultBorderColor: '#23A55A',
|
||||
defaultHoverColor: '#23A55A',
|
||||
defaultHoverBorderColor: '#23A55A',
|
||||
defaultHoverColor: '#23A55A',
|
||||
},
|
||||
},
|
||||
}}
|
||||
@@ -162,9 +362,7 @@ const DetailSparepart = (props) => {
|
||||
</ConfigProvider>
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
token: {
|
||||
colorBgContainer: '#209652',
|
||||
},
|
||||
token: { colorBgContainer: '#209652' },
|
||||
components: {
|
||||
Button: {
|
||||
defaultBg: '#23a55a',
|
||||
@@ -187,101 +385,205 @@ const DetailSparepart = (props) => {
|
||||
>
|
||||
{formData && (
|
||||
<div>
|
||||
<div hidden>
|
||||
<Text strong>Sparepart ID</Text>
|
||||
<Input
|
||||
name="sparepart_id"
|
||||
value={formData.sparepart_id}
|
||||
onChange={handleInputChange}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
<Row gutter={[16, 16]}>
|
||||
{/* Kolom untuk foto */}
|
||||
<Col span={10} style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<Text strong>Foto</Text>
|
||||
<div
|
||||
style={{
|
||||
flexGrow: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
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>
|
||||
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<Text strong>Sparepart Name</Text>
|
||||
<Text style={{ color: 'red' }}> *</Text>
|
||||
<Input
|
||||
name="sparepart_name"
|
||||
value={formData.sparepart_name}
|
||||
onChange={handleInputChange}
|
||||
placeholder="Enter Sparepart Name"
|
||||
readOnly={props.readOnly}
|
||||
/>
|
||||
</div>
|
||||
{/* Kolom untuk field lainnya */}
|
||||
<Col span={14}>
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col span={24}>
|
||||
<Text strong>Sparepart Name</Text>
|
||||
<Text style={{ color: 'red' }}> *</Text>
|
||||
<Input
|
||||
name="sparepart_name"
|
||||
value={formData.sparepart_name}
|
||||
onChange={handleInputChange}
|
||||
placeholder="Enter Sparepart Name"
|
||||
readOnly={props.readOnly}
|
||||
/>
|
||||
</Col>
|
||||
<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>
|
||||
</Row>
|
||||
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<Text strong>Sparepart Description</Text>
|
||||
<TextArea
|
||||
name="sparepart_description"
|
||||
value={formData.sparepart_description}
|
||||
onChange={handleInputChange}
|
||||
placeholder="Enter Sparepart Description (Optional)"
|
||||
readOnly={props.readOnly}
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
|
||||
<Col span={12}>
|
||||
<Text strong>Brand</Text>
|
||||
<Input
|
||||
name="sparepart_merk"
|
||||
value={formData.sparepart_merk}
|
||||
onChange={handleInputChange}
|
||||
placeholder="Enter Brand (Optional)"
|
||||
readOnly={props.readOnly}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Text strong>Model</Text>
|
||||
<Input
|
||||
name="sparepart_model"
|
||||
value={formData.sparepart_model}
|
||||
onChange={handleInputChange}
|
||||
placeholder="Enter Model (Optional)"
|
||||
readOnly={props.readOnly}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<Text strong>Sparepart Model</Text>
|
||||
<Text style={{ color: 'red' }}> *</Text>
|
||||
<Input
|
||||
name="sparepart_model"
|
||||
value={formData.sparepart_model}
|
||||
onChange={handleInputChange}
|
||||
placeholder="Enter Sparepart Model"
|
||||
readOnly={props.readOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<Text strong>Sparepart Item Type</Text>
|
||||
<Input
|
||||
name="sparepart_item_type"
|
||||
value={formData.sparepart_item_type}
|
||||
onChange={handleInputChange}
|
||||
placeholder="Enter Sparepart Item Type"
|
||||
readOnly={props.readOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<Text strong>Sparepart Unit</Text>
|
||||
<Text style={{ color: 'red' }}> *</Text>
|
||||
<Input
|
||||
name="sparepart_unit"
|
||||
value={formData.sparepart_unit}
|
||||
onChange={handleInputChange}
|
||||
placeholder="Enter Sparepart Unit"
|
||||
readOnly={props.readOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<Text strong>Sparepart Merk</Text>
|
||||
<Text style={{ color: 'red' }}> *</Text>
|
||||
<Input
|
||||
name="sparepart_merk"
|
||||
value={formData.sparepart_merk}
|
||||
onChange={handleInputChange}
|
||||
placeholder="Enter Sparepart Merk"
|
||||
readOnly={props.readOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<Text strong>Sparepart Stok</Text>
|
||||
<Text style={{ color: 'red' }}> *</Text>
|
||||
<Input
|
||||
name="sparepart_stok"
|
||||
value={formData.sparepart_stok}
|
||||
onChange={handleInputChange}
|
||||
placeholder="Enter Sparepart Stok"
|
||||
readOnly={props.readOnly}
|
||||
type="number"
|
||||
/>
|
||||
</div>
|
||||
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
|
||||
<Col span={24}>
|
||||
<Text strong>Description</Text>
|
||||
<TextArea
|
||||
name="sparepart_description"
|
||||
value={formData.sparepart_description}
|
||||
onChange={handleInputChange}
|
||||
placeholder="Enter Description (Optional)"
|
||||
readOnly={props.readOnly}
|
||||
rows={3}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
)}
|
||||
<Modal
|
||||
open={previewOpen}
|
||||
title={previewTitle}
|
||||
footer={null}
|
||||
onCancel={handlePreviewCancel}
|
||||
>
|
||||
<img alt="preview" style={{ width: '100%' }} src={previewImage} />
|
||||
</Modal>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ import { NotifAlert, NotifOk, NotifConfirmDialog } from '../../../../components/
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { deleteSparepart, getAllSparepart } from '../../../../api/sparepart';
|
||||
import TableList from '../../../../components/Global/TableList';
|
||||
import SparepartCardList from './SparepartCardList'; // Import the new custom card component
|
||||
|
||||
const columns = (showPreviewModal, showEditModal, showDeleteDialog) => [
|
||||
{
|
||||
@@ -71,11 +72,18 @@ const columns = (showPreviewModal, showEditModal, showDeleteDialog) => [
|
||||
render: (sparepart_merk) => sparepart_merk || '-'
|
||||
},
|
||||
{
|
||||
title: 'Stock',
|
||||
title: 'Qty',
|
||||
dataIndex: 'sparepart_qty',
|
||||
key: 'sparepart_qty',
|
||||
width: '8%',
|
||||
render: (sparepart_qty) => sparepart_qty || '0'
|
||||
},
|
||||
{
|
||||
title: 'Status',
|
||||
dataIndex: 'sparepart_stok',
|
||||
key: 'sparepart_stok',
|
||||
width: '8%',
|
||||
render: (sparepart_stok) => sparepart_stok || '0'
|
||||
render: (sparepart_stok) => sparepart_stok || 'Not Available'
|
||||
},
|
||||
{
|
||||
title: 'Action',
|
||||
@@ -265,6 +273,14 @@ const ListSparepart = memo(function ListSparepart(props) {
|
||||
queryParams={formDataFilter}
|
||||
columns={columns(showPreviewModal, showEditModal, showDeleteDialog)}
|
||||
triger={trigerFilter}
|
||||
cardComponent={SparepartCardList} // Pass the custom component here
|
||||
onStockUpdate={doFilter}
|
||||
onGetData={(data) => {
|
||||
if(data && data.length > 0) {
|
||||
console.log('Sample sparepart data from API:', data[0]);
|
||||
console.log('Available fields:', Object.keys(data[0] || {}));
|
||||
}
|
||||
}} // Log untuk debugging field-field yang tersedia
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
395
src/pages/master/sparepart/component/SparepartCardList.jsx
Normal file
@@ -0,0 +1,395 @@
|
||||
import React, { useState } from 'react';
|
||||
import dayjs from 'dayjs';
|
||||
import { Card, Button, Row, Col, Typography, Divider, Tag, Space, InputNumber, Input } from 'antd';
|
||||
import { EditOutlined, DeleteOutlined, PlusOutlined, MinusOutlined } from '@ant-design/icons';
|
||||
import { updateSparepart } from '../../../../api/sparepart';
|
||||
import { NotifAlert, NotifOk } from '../../../../components/Global/ToastNotif';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
const SparepartCardList = ({
|
||||
data,
|
||||
header,
|
||||
showPreviewModal,
|
||||
showEditModal,
|
||||
showDeleteDialog,
|
||||
fieldColor,
|
||||
cardColor,
|
||||
onStockUpdate, // Prop to refresh the list
|
||||
}) => {
|
||||
const [updateQuantities, setUpdateQuantities] = useState({});
|
||||
const [loadingQuantities, setLoadingQuantities] = useState({});
|
||||
|
||||
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 };
|
||||
newQuantities[id] = clampedValue;
|
||||
setUpdateQuantities(newQuantities);
|
||||
};
|
||||
|
||||
const handleUpdateStock = async (item) => {
|
||||
const quantityToAdd = updateQuantities[item.sparepart_id] || 0;
|
||||
if (quantityToAdd === 0) {
|
||||
NotifAlert({
|
||||
icon: 'info',
|
||||
title: 'Info',
|
||||
message: 'Please change the quantity first.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const currentQty = Number(item.sparepart_qty) || 0;
|
||||
const newQty = currentQty + quantityToAdd;
|
||||
if (newQty < 0) {
|
||||
NotifAlert({ icon: 'error', title: 'Error', message: 'Quantity cannot be negative.' });
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingQuantities((prev) => ({ ...prev, [item.sparepart_id]: true }));
|
||||
|
||||
// sparepart_qty disimpan sebagai angka kuantitas (update boleh 0 sesuai validasi update schema)
|
||||
const payload = {
|
||||
sparepart_qty: newQty,
|
||||
sparepart_stok: newQty > 0 ? 'Available' : 'Not Available', // Otomatis tentukan status
|
||||
};
|
||||
|
||||
// Hanya tambahkan field jika nilainya tidak kosong untuk menghindari validasi error
|
||||
if (item.sparepart_unit && item.sparepart_unit.trim() !== '') {
|
||||
payload.sparepart_unit = item.sparepart_unit;
|
||||
}
|
||||
if (item.sparepart_merk && item.sparepart_merk.trim() !== '') {
|
||||
payload.sparepart_merk = item.sparepart_merk;
|
||||
}
|
||||
if (item.sparepart_model && item.sparepart_model.trim() !== '') {
|
||||
payload.sparepart_model = item.sparepart_model;
|
||||
}
|
||||
if (item.sparepart_description && item.sparepart_description.trim() !== '') {
|
||||
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 {
|
||||
const response = await updateSparepart(item.sparepart_id, payload);
|
||||
|
||||
// Periksa apakah response valid sebelum mengakses propertinya
|
||||
if (response && response.statusCode === 200) {
|
||||
NotifOk({
|
||||
icon: 'success',
|
||||
title: 'Success',
|
||||
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) {
|
||||
onStockUpdate();
|
||||
}
|
||||
handleQuantityChange(item.sparepart_id, 0); // Reset quantity
|
||||
} else {
|
||||
NotifAlert({
|
||||
icon: 'error',
|
||||
title: 'Failed',
|
||||
message: response?.message || 'Failed to update stock.',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
NotifAlert({
|
||||
icon: 'error',
|
||||
title: 'Error',
|
||||
message: error.message || 'An error occurred.',
|
||||
});
|
||||
} finally {
|
||||
setLoadingQuantities((prev) => ({ ...prev, [item.sparepart_id]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Row gutter={[16, 16]} style={{ marginTop: '16px' }}>
|
||||
{data.map((item) => {
|
||||
const quantity = updateQuantities[item.sparepart_id] || 0;
|
||||
const isLoading = loadingQuantities[item.sparepart_id] || false;
|
||||
|
||||
return (
|
||||
<Col xs={24} sm={12} md={8} lg={6} key={item.sparepart_id || item.key}>
|
||||
<Card
|
||||
style={{
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
border: `1px solid ${
|
||||
fieldColor ? item[fieldColor] : cardColor || '#E0E0E0'
|
||||
}`,
|
||||
}}
|
||||
bodyStyle={{ padding: 0 }}
|
||||
>
|
||||
<Row>
|
||||
<Col span={8}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-start',
|
||||
padding: '16px 8px',
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
{item.sparepart_item_type && (
|
||||
<Tag
|
||||
color="blue"
|
||||
style={{
|
||||
marginBottom: '8px',
|
||||
}}
|
||||
>
|
||||
{item.sparepart_item_type}
|
||||
</Tag>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: '#f0f0f0',
|
||||
width: '100%',
|
||||
paddingTop:
|
||||
'100%' /* Ini membuat tinggi sama dengan lebar (aspect ratio 1:1) */,
|
||||
position: 'relative',
|
||||
borderRadius: '4px',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{(() => {
|
||||
// Debug: log the image path construction
|
||||
let imgSrc;
|
||||
if (item.sparepart_foto) {
|
||||
if (item.sparepart_foto.startsWith('http')) {
|
||||
imgSrc = item.sparepart_foto;
|
||||
} else {
|
||||
// Gunakan format file URL seperti di brandDevice
|
||||
const fileName = item.sparepart_foto
|
||||
.split('/')
|
||||
.pop();
|
||||
|
||||
// Jika filename adalah default file, gunakan dari public assets
|
||||
if (
|
||||
fileName === 'defaultSparepartImg.jpg'
|
||||
) {
|
||||
imgSrc = `/assets/defaultSparepartImg.jpg`;
|
||||
} else {
|
||||
// Gunakan API getFileUrl untuk mendapatkan URL yang benar untuk file upload
|
||||
const token =
|
||||
localStorage.getItem('token');
|
||||
const baseURL =
|
||||
import.meta.env.VITE_API_SERVER ||
|
||||
'';
|
||||
imgSrc = `${baseURL}/file-uploads/images/${encodeURIComponent(
|
||||
fileName
|
||||
)}${
|
||||
token
|
||||
? `?token=${encodeURIComponent(
|
||||
token
|
||||
)}`
|
||||
: ''
|
||||
}`;
|
||||
}
|
||||
}
|
||||
console.log(
|
||||
'Image path being constructed:',
|
||||
imgSrc
|
||||
);
|
||||
} else {
|
||||
imgSrc = 'https://via.placeholder.com/150';
|
||||
}
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={imgSrc}
|
||||
alt={item[header]}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover', // Mengisi container dan crop sisi berlebih
|
||||
}}
|
||||
onError={(e) => {
|
||||
console.error(
|
||||
'Image failed to load:',
|
||||
imgSrc
|
||||
);
|
||||
e.target.src =
|
||||
'https://via.placeholder.com/150';
|
||||
}}
|
||||
onLoad={() =>
|
||||
console.log(
|
||||
'Image loaded successfully:',
|
||||
imgSrc
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
<Col span={16}>
|
||||
<div
|
||||
style={{
|
||||
padding: '16px',
|
||||
position: 'relative',
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
}}
|
||||
>
|
||||
{showEditModal && (
|
||||
<Button
|
||||
style={{
|
||||
color: '#faad14',
|
||||
borderColor: '#faad14',
|
||||
}}
|
||||
icon={<EditOutlined />}
|
||||
key="edit"
|
||||
onClick={() => showEditModal(item)}
|
||||
size="small"
|
||||
/>
|
||||
)}
|
||||
{showDeleteDialog && (
|
||||
<Button
|
||||
icon={<DeleteOutlined />}
|
||||
key="delete"
|
||||
onClick={() => showDeleteDialog(item)}
|
||||
size="small"
|
||||
danger
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Title
|
||||
level={5}
|
||||
style={{
|
||||
margin: 0,
|
||||
marginBottom: '8px',
|
||||
paddingRight: '60px',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{item[header]}
|
||||
</Title>
|
||||
<Text type="secondary" style={{ display: 'block' }}>
|
||||
Stok: {item.sparepart_stok || 'Not Available'}
|
||||
</Text>
|
||||
<Divider style={{ margin: '8px 0' }} />
|
||||
|
||||
<Space
|
||||
align="center"
|
||||
style={{
|
||||
marginBottom: '8px',
|
||||
display: 'flex',
|
||||
}}
|
||||
>
|
||||
<Text type="secondary">Qty</Text>
|
||||
<Button
|
||||
icon={<MinusOutlined />}
|
||||
onClick={() =>
|
||||
handleQuantityChange(
|
||||
item.sparepart_id,
|
||||
quantity - 1
|
||||
)
|
||||
}
|
||||
disabled={
|
||||
isLoading || item.sparepart_qty + quantity <= 0
|
||||
}
|
||||
style={{ width: 28, height: 28 }}
|
||||
/>
|
||||
<Text
|
||||
strong
|
||||
style={{ padding: '0 8px', fontSize: '16px' }}
|
||||
>
|
||||
{item.sparepart_qty + (quantity || 0)}
|
||||
</Text>
|
||||
<Button
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() =>
|
||||
handleQuantityChange(
|
||||
item.sparepart_id,
|
||||
quantity + 1
|
||||
)
|
||||
}
|
||||
disabled={isLoading}
|
||||
style={{ width: 28, height: 28 }}
|
||||
/>
|
||||
<Text type="secondary">
|
||||
{item.sparepart_unit
|
||||
? ` / ${item.sparepart_unit}`
|
||||
: ' / pcs'}
|
||||
</Text>
|
||||
</Space>
|
||||
|
||||
{quantity !== 0 && (
|
||||
<Button
|
||||
type={quantity === 0 ? 'default' : 'primary'}
|
||||
size="small"
|
||||
style={{ width: '100%' }}
|
||||
onClick={() => handleUpdateStock(item)}
|
||||
loading={isLoading}
|
||||
>
|
||||
Update Stock
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<br />
|
||||
<Text
|
||||
type="secondary"
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
marginTop: '8px',
|
||||
display: 'inline-block',
|
||||
}}
|
||||
>
|
||||
Last updated:{' '}
|
||||
{item.updated_at
|
||||
? dayjs(item.updated_at).format('DD MMM YYYY')
|
||||
: 'N/A'}
|
||||
</Text>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
</Col>
|
||||
);
|
||||
})}
|
||||
</Row>
|
||||
);
|
||||
};
|
||||
|
||||
export default SparepartCardList;
|
||||
@@ -81,7 +81,7 @@ const DetailStatus = (props) => {
|
||||
status_number: formData.status_number,
|
||||
status_name: formData.status_name,
|
||||
status_color: formData.status_color,
|
||||
status_description: formData.status_description,
|
||||
status_description: (formData.status_description && formData.status_description.trim() !== '') ? formData.status_description : ' ',
|
||||
is_active: formData.is_active,
|
||||
};
|
||||
|
||||
|
||||
@@ -168,10 +168,7 @@ const DetailTag = (props) => {
|
||||
payload.unit = formData.unit.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();
|
||||
}
|
||||
payload.tag_description = (formData.tag_description && formData.tag_description.trim() !== '') ? formData.tag_description.trim() : ' ';
|
||||
|
||||
// Add device_id only if it has a value
|
||||
if (formData.device_id) {
|
||||
|
||||
@@ -164,7 +164,7 @@ const ListUnit = memo(function ListUnit(props) {
|
||||
const handleDelete = async (param) => {
|
||||
try {
|
||||
const response = await deleteUnit(param.unit_id);
|
||||
console.log('deleteUnit response:', response);
|
||||
// console.log('deleteUnit response:', response);
|
||||
|
||||
if (response.statusCode === 200) {
|
||||
NotifAlert({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { memo, useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useBreadcrumb } from '../../layout/LayoutBreadcrumb';
|
||||
import { Typography } from 'antd';
|
||||
import { Typography, Row, Col } from 'antd';
|
||||
import ListNotification from './component/ListNotification';
|
||||
import DetailNotification from './component/DetailNotification';
|
||||
|
||||
@@ -10,10 +10,7 @@ const { Text } = Typography;
|
||||
const IndexNotification = memo(function IndexNotification() {
|
||||
const navigate = useNavigate();
|
||||
const { setBreadcrumbItems } = useBreadcrumb();
|
||||
|
||||
const [actionMode, setActionMode] = useState('list');
|
||||
const [selectedData, setSelectedData] = useState(null);
|
||||
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('token');
|
||||
@@ -32,33 +29,34 @@ const IndexNotification = memo(function IndexNotification() {
|
||||
}
|
||||
}, [navigate, setBreadcrumbItems]);
|
||||
|
||||
useEffect(() => {
|
||||
if (actionMode === 'preview') {
|
||||
setIsModalVisible(true);
|
||||
} else {
|
||||
setIsModalVisible(false);
|
||||
}
|
||||
}, [actionMode]);
|
||||
|
||||
const handleCancel = () => {
|
||||
setActionMode('list');
|
||||
const handleCloseDetail = () => {
|
||||
setSelectedData(null);
|
||||
};
|
||||
|
||||
// This handler will be passed to ListNotification to update the selected item
|
||||
const handleSelectNotification = (data) => {
|
||||
setSelectedData(data);
|
||||
};
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<ListNotification
|
||||
actionMode={actionMode}
|
||||
setActionMode={setActionMode}
|
||||
selectedData={selectedData}
|
||||
setSelectedData={setSelectedData}
|
||||
/>
|
||||
<DetailNotification
|
||||
visible={isModalVisible}
|
||||
onCancel={handleCancel}
|
||||
selectedData={selectedData}
|
||||
/>
|
||||
</React.Fragment>
|
||||
<Row gutter={16}>
|
||||
<Col span={selectedData ? 16 : 24}>
|
||||
<ListNotification
|
||||
// The setActionMode is likely not needed anymore,
|
||||
// but we pass the selection handler
|
||||
setActionMode={() => {}} // Keep prop for safety, but can be empty
|
||||
setSelectedData={handleSelectNotification}
|
||||
/>
|
||||
</Col>
|
||||
{selectedData && (
|
||||
<Col span={8}>
|
||||
<DetailNotification
|
||||
selectedData={selectedData}
|
||||
onClose={handleCloseDetail}
|
||||
/>
|
||||
</Col>
|
||||
)}
|
||||
</Row>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,8 +1,30 @@
|
||||
import React, { memo } from 'react';
|
||||
import { Modal, Row, Col, Tag, Divider } from 'antd';
|
||||
import { CloseCircleFilled, WarningFilled, CheckCircleFilled, InfoCircleFilled } from '@ant-design/icons';
|
||||
import { Row, Col, Tag, Card, Button } from 'antd';
|
||||
import {
|
||||
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) => {
|
||||
switch (type) {
|
||||
case 'critical':
|
||||
@@ -36,133 +58,194 @@ const DetailNotification = memo(function DetailNotification({ visible, onCancel,
|
||||
}
|
||||
};
|
||||
|
||||
const { IconComponent, color, bgColor, tagColor } = selectedData ? getIconAndColor(selectedData.type) : {};
|
||||
const notificationType = getTypeFromStatus();
|
||||
const { IconComponent, color, bgColor, tagColor } = getIconAndColor(notificationType);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
<Card
|
||||
title="Detail Notifikasi"
|
||||
open={visible}
|
||||
onCancel={onCancel}
|
||||
onOk={onCancel}
|
||||
okText="Tutup"
|
||||
cancelButtonProps={{ style: { display: 'none' } }}
|
||||
width={700}
|
||||
extra={<Button onClick={onClose}>Tutup</Button>}
|
||||
style={{ height: '100%' }}
|
||||
bodyStyle={{ padding: '0 24px' }}
|
||||
>
|
||||
{selectedData && (
|
||||
<div>
|
||||
{/* Header with Icon and Status */}
|
||||
<div>
|
||||
{/* Header with Icon and Status */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
marginBottom: '0',
|
||||
padding: '2px 0',
|
||||
backgroundColor: '#fafafa',
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: bgColor,
|
||||
color: color,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '16px',
|
||||
marginBottom: '24px',
|
||||
padding: '16px',
|
||||
backgroundColor: '#fafafa',
|
||||
borderRadius: '8px',
|
||||
justifyContent: 'center',
|
||||
fontSize: '18px',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
{IconComponent && <IconComponent style={{ fontSize: '18px' }} />}
|
||||
</div>
|
||||
|
||||
<Divider style={{ margin: '16px 0' }} />
|
||||
|
||||
{/* 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'}
|
||||
<div style={{ flex: 1 }}>
|
||||
<Tag color={tagColor} style={{ marginBottom: '2px', fontSize: '11px' }}>
|
||||
{notificationType.toUpperCase()}
|
||||
</Tag>
|
||||
</div>
|
||||
|
||||
{/* Additional Info */}
|
||||
<div
|
||||
style={{
|
||||
marginTop: '16px',
|
||||
padding: '12px',
|
||||
backgroundColor: '#f6f9ff',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid #d6e4ff',
|
||||
}}
|
||||
>
|
||||
<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 style={{ fontSize: '14px', fontWeight: 600, color: '#262626' }}>
|
||||
{errorCodeData?.error_code_name || 'N/A'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* Information Grid */}
|
||||
<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
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
color: '#262626',
|
||||
fontWeight: 500,
|
||||
padding: '8px',
|
||||
backgroundColor: '#fafafa',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid #f0f0f0',
|
||||
}}
|
||||
>
|
||||
{selectedData.message_error_issue || 'N/A'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Spareparts Information */}
|
||||
{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>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
95
src/pages/notification/component/LogHistoryCard.jsx
Normal file
@@ -0,0 +1,95 @@
|
||||
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;
|
||||
@@ -1,6 +1,12 @@
|
||||
import React from 'react';
|
||||
import { Modal, Typography, Card, Row, Col, Avatar, Tag, Button, Space } from 'antd';
|
||||
import { UserOutlined, PhoneOutlined, CheckCircleOutlined, SyncOutlined, SendOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
UserOutlined,
|
||||
PhoneOutlined,
|
||||
CheckCircleOutlined,
|
||||
SyncOutlined,
|
||||
SendOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
@@ -41,9 +47,17 @@ const UserHistoryModal = ({ visible, onCancel, notificationData }) => {
|
||||
const getStatusTag = (status) => {
|
||||
switch (status) {
|
||||
case 'delivered':
|
||||
return <Tag icon={<CheckCircleOutlined />} color="success">Delivered</Tag>;
|
||||
return (
|
||||
<Tag icon={<CheckCircleOutlined />} color="success">
|
||||
Delivered
|
||||
</Tag>
|
||||
);
|
||||
case 'sent':
|
||||
return <Tag icon={<SyncOutlined spin />} color="processing">Sent</Tag>;
|
||||
return (
|
||||
<Tag icon={<SyncOutlined spin />} color="processing">
|
||||
Sent
|
||||
</Tag>
|
||||
);
|
||||
case 'failed':
|
||||
return <Tag color="error">Failed</Tag>;
|
||||
default:
|
||||
@@ -55,7 +69,7 @@ const UserHistoryModal = ({ visible, onCancel, notificationData }) => {
|
||||
<Modal
|
||||
title={
|
||||
<Text strong style={{ fontSize: '18px' }}>
|
||||
User History Notification
|
||||
History User Notification
|
||||
</Text>
|
||||
}
|
||||
open={visible}
|
||||
@@ -78,7 +92,13 @@ const UserHistoryModal = ({ visible, onCancel, notificationData }) => {
|
||||
<Avatar size="large" icon={<UserOutlined />} />
|
||||
<div>
|
||||
<Text strong>{user.name}</Text>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
}}
|
||||
>
|
||||
<PhoneOutlined style={{ color: '#8c8c8c' }} />
|
||||
<Text type="secondary">{user.phone}</Text>
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,37 @@
|
||||
import React from 'react';
|
||||
import { Button, Row, Col, Card, Badge, Typography, Space, Divider } from 'antd';
|
||||
import { SendOutlined, MobileOutlined, CheckCircleFilled, ArrowLeftOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
SendOutlined,
|
||||
MobileOutlined,
|
||||
CheckCircleFilled,
|
||||
ArrowLeftOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
// Dummy data for user history
|
||||
const userHistoryData = [
|
||||
{ id: 1, name: 'John Doe', phone: '081234567890', status: 'Delivered', timestamp: '04-11-2025 11:40 WIB' },
|
||||
{ id: 2, name: 'Jane Smith', phone: '087654321098', status: 'Delivered', timestamp: '04-11-2025 11:41 WIB' },
|
||||
{ id: 3, name: 'Peter Jones', phone: '082345678901', status: 'Delivered', timestamp: '04-11-2025 11:42 WIB' },
|
||||
{
|
||||
id: 1,
|
||||
name: 'John Doe',
|
||||
phone: '081234567890',
|
||||
status: 'Delivered',
|
||||
timestamp: '04-11-2025 11:40 WIB',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Jane Smith',
|
||||
phone: '087654321098',
|
||||
status: 'Delivered',
|
||||
timestamp: '04-11-2025 11:41 WIB',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Peter Jones',
|
||||
phone: '082345678901',
|
||||
status: 'Delivered',
|
||||
timestamp: '04-11-2025 11:42 WIB',
|
||||
},
|
||||
];
|
||||
|
||||
const UserHistory = ({ notification, onBack }) => {
|
||||
@@ -18,7 +41,9 @@ const UserHistory = ({ notification, onBack }) => {
|
||||
<Col>
|
||||
<Space align="center">
|
||||
<Button type="text" icon={<ArrowLeftOutlined />} onClick={onBack} />
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>User History Notification</Typography.Title>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
History User Notification
|
||||
</Typography.Title>
|
||||
</Space>
|
||||
<Text type="secondary" style={{ marginLeft: '40px' }}>
|
||||
{notification.title} - {notification.issue}
|
||||
@@ -27,25 +52,34 @@ const UserHistory = ({ notification, onBack }) => {
|
||||
</Row>
|
||||
|
||||
<Space direction="vertical" size="middle" style={{ display: 'flex' }}>
|
||||
{userHistoryData.map(user => (
|
||||
<Card key={user.id} style={{ backgroundColor: '#e6f7ff', borderColor: '#91d5ff' }}>
|
||||
{userHistoryData.map((user) => (
|
||||
<Card
|
||||
key={user.id}
|
||||
style={{ backgroundColor: '#e6f7ff', borderColor: '#91d5ff' }}
|
||||
>
|
||||
<Row align="middle" justify="space-between">
|
||||
<Col>
|
||||
<Space align="center">
|
||||
<Text strong>{user.name}</Text>
|
||||
<Text>|</Text>
|
||||
<Text><MobileOutlined /> {user.phone}</Text>
|
||||
<Text>
|
||||
<MobileOutlined /> {user.phone}
|
||||
</Text>
|
||||
<Text>|</Text>
|
||||
<Badge status="success" text={user.status} />
|
||||
</Space>
|
||||
<Divider style={{ margin: '8px 0' }} />
|
||||
<Space align="center">
|
||||
<CheckCircleFilled style={{ color: '#52c41a' }} />
|
||||
<Text type="secondary">Success Delivered at {user.timestamp}</Text>
|
||||
<Text type="secondary">
|
||||
Success Delivered at {user.timestamp}
|
||||
</Text>
|
||||
</Space>
|
||||
</Col>
|
||||
<Col>
|
||||
<Button type="primary" ghost icon={<SendOutlined />}>Resend</Button>
|
||||
<Button type="primary" ghost icon={<SendOutlined />}>
|
||||
Resend
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
955
src/pages/notificationDetail/IndexNotificationDetail.jsx
Normal file
@@ -0,0 +1,955 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Layout,
|
||||
Card,
|
||||
Row,
|
||||
Col,
|
||||
Typography,
|
||||
Space,
|
||||
Button,
|
||||
Spin,
|
||||
Result,
|
||||
Input,
|
||||
message,
|
||||
Avatar,
|
||||
Tag,
|
||||
Badge,
|
||||
Divider,
|
||||
} from 'antd';
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
CloseCircleFilled,
|
||||
WarningFilled,
|
||||
CheckCircleFilled,
|
||||
InfoCircleFilled,
|
||||
CloseOutlined,
|
||||
BookOutlined,
|
||||
ToolOutlined,
|
||||
HistoryOutlined,
|
||||
FilePdfOutlined,
|
||||
PlusOutlined,
|
||||
UserOutlined,
|
||||
LoadingOutlined,
|
||||
PhoneOutlined,
|
||||
CheckCircleOutlined,
|
||||
SyncOutlined,
|
||||
SendOutlined,
|
||||
MobileOutlined,
|
||||
ClockCircleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import {
|
||||
getNotificationDetail,
|
||||
createNotificationLog,
|
||||
getNotificationLogByNotificationId,
|
||||
updateIsRead,
|
||||
resendNotificationToUser,
|
||||
resendChatByUser,
|
||||
} from '../../api/notification';
|
||||
|
||||
const { Content } = Layout;
|
||||
const { Text, Paragraph, Link } = Typography;
|
||||
|
||||
// Transform API response to component format
|
||||
const transformNotificationData = (apiData) => {
|
||||
// Extract nested data
|
||||
const errorCodeData = apiData.error_code;
|
||||
// Get active solution (is_active: true)
|
||||
const activeSolution =
|
||||
errorCodeData?.solution?.find((sol) => sol.is_active) || errorCodeData?.solution?.[0] || {};
|
||||
|
||||
return {
|
||||
id: `notification-${apiData.notification_error_id}-0`,
|
||||
type: apiData.is_read ? 'resolved' : apiData.is_delivered ? 'warning' : 'critical',
|
||||
title: errorCodeData?.error_code_name || 'Unknown Error',
|
||||
issue: errorCodeData?.error_code || 'Unknown Error',
|
||||
description: apiData.message_error_issue || 'No details available',
|
||||
timestamp: apiData.created_at
|
||||
? new Date(apiData.created_at).toLocaleString('id-ID', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}) + ' WIB'
|
||||
: 'N/A',
|
||||
location: apiData.plant_sub_section_name || 'Location not specified',
|
||||
details: apiData.message_error_issue || 'No details available',
|
||||
isRead: apiData.is_read || false,
|
||||
isDelivered: apiData.is_delivered || false,
|
||||
isSend: apiData.is_send || false,
|
||||
status: apiData.is_read ? 'Resolved' : apiData.is_delivered ? 'Delivered' : 'Pending',
|
||||
tag: errorCodeData?.error_code,
|
||||
plc: 'N/A', // PLC not available in API response
|
||||
notification_error_id: apiData.notification_error_id,
|
||||
error_code_id: apiData.error_code_id,
|
||||
error_chanel: apiData.error_chanel,
|
||||
spareparts: errorCodeData?.spareparts || [],
|
||||
solution: {
|
||||
...activeSolution,
|
||||
path_document: activeSolution.path_document
|
||||
? activeSolution.path_document.replace(
|
||||
'/detail-notification/pdf/',
|
||||
'/notification-detail/pdf/'
|
||||
)
|
||||
: activeSolution.path_document,
|
||||
}, // Include the active solution data with fixed URL
|
||||
error_code: errorCodeData,
|
||||
device_info: {
|
||||
device_code: apiData.device_code,
|
||||
device_name: apiData.device_name,
|
||||
device_location: apiData.device_location,
|
||||
brand_name: apiData.brand_name,
|
||||
},
|
||||
users: apiData.users || [],
|
||||
};
|
||||
};
|
||||
|
||||
// Function to get actual users from notification data
|
||||
const getUsersFromNotification = (notification) => {
|
||||
if (!notification || !notification.users) return [];
|
||||
|
||||
return notification.users.map((user) => ({
|
||||
id: user.notification_error_user_id.toString(),
|
||||
name: user.contact_name,
|
||||
phone: user.contact_phone,
|
||||
status: user.is_send ? 'Delivered' : 'Pending',
|
||||
loading: user.loading || false,
|
||||
timestamp: user.updated_at
|
||||
? new Date(user.updated_at)
|
||||
.toLocaleString('id-ID', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
.replace('.', ':') + ' WIB'
|
||||
: 'N/A',
|
||||
}));
|
||||
};
|
||||
|
||||
const getStatusTag = (status) => {
|
||||
switch (status) {
|
||||
case 'delivered':
|
||||
return (
|
||||
<Tag icon={<CheckCircleOutlined />} color="success">
|
||||
Delivered
|
||||
</Tag>
|
||||
);
|
||||
case 'sent':
|
||||
return (
|
||||
<Tag icon={<SyncOutlined spin />} color="processing">
|
||||
Sent
|
||||
</Tag>
|
||||
);
|
||||
case 'failed':
|
||||
return <Tag color="error">Failed</Tag>;
|
||||
default:
|
||||
return <Tag color="default">{status}</Tag>;
|
||||
}
|
||||
};
|
||||
|
||||
const getIconAndColor = (type) => {
|
||||
switch (type) {
|
||||
case 'critical':
|
||||
return { IconComponent: CloseCircleFilled, color: '#ff4d4f', bgColor: '#fff1f0' };
|
||||
case 'warning':
|
||||
return { IconComponent: WarningFilled, color: '#faad14', bgColor: '#fffbe6' };
|
||||
case 'resolved':
|
||||
return { IconComponent: CheckCircleFilled, color: '#52c41a', bgColor: '#f6ffed' };
|
||||
default:
|
||||
return { IconComponent: InfoCircleFilled, color: '#1890ff', bgColor: '#e6f7ff' };
|
||||
}
|
||||
};
|
||||
|
||||
const NotificationDetailTab = (props) => {
|
||||
const params = useParams(); // Mungkin perlu disesuaikan jika route berbeda
|
||||
const notificationId = props.id ?? params.notificationId;
|
||||
const navigate = useNavigate();
|
||||
const [notification, setNotification] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [isAddingLog, setIsAddingLog] = useState(false);
|
||||
|
||||
// Log history states
|
||||
const [logHistoryData, setLogHistoryData] = useState([]);
|
||||
const [logLoading, setLogLoading] = useState(false);
|
||||
const [newLogDescription, setNewLogDescription] = useState('');
|
||||
const [submitLoading, setSubmitLoading] = useState(false);
|
||||
|
||||
// Fetch log history from API
|
||||
const fetchLogHistory = async (notifId) => {
|
||||
try {
|
||||
setLogLoading(true);
|
||||
const response = await getNotificationLogByNotificationId(notifId);
|
||||
if (response && response.data) {
|
||||
// Transform API data to component format
|
||||
const transformedLogs = response.data.map((log) => ({
|
||||
id: log.notification_error_log_id,
|
||||
timestamp: log.created_at
|
||||
? new Date(log.created_at).toLocaleString('id-ID', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}) + ' WIB'
|
||||
: 'N/A',
|
||||
addedBy: {
|
||||
name: log.contact_name || 'Unknown',
|
||||
phone: log.contact_phone || '',
|
||||
},
|
||||
description: log.notification_error_log_description || '',
|
||||
}));
|
||||
setLogHistoryData(transformedLogs);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching log history:', err);
|
||||
} finally {
|
||||
setLogLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle submit new log
|
||||
const handleSubmitLog = async () => {
|
||||
if (!newLogDescription.trim()) {
|
||||
message.warning('Mohon isi deskripsi log terlebih dahulu');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setSubmitLoading(true);
|
||||
const payload = {
|
||||
notification_error_id: parseInt(notificationId),
|
||||
notification_error_log_description: newLogDescription.trim(),
|
||||
};
|
||||
|
||||
const response = await createNotificationLog(payload);
|
||||
|
||||
if (response && response.statusCode === 200) {
|
||||
message.success('Log berhasil ditambahkan');
|
||||
setNewLogDescription('');
|
||||
setIsAddingLog(false);
|
||||
// Refresh log history
|
||||
fetchLogHistory(notificationId);
|
||||
} else {
|
||||
throw new Error(response?.message || 'Gagal menambahkan log');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error submitting log:', err);
|
||||
message.error(err.message || 'Gagal menambahkan log');
|
||||
} finally {
|
||||
setSubmitLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchDetail = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Fetch using the actual API
|
||||
const response = await getNotificationDetail(notificationId);
|
||||
|
||||
if (response && response.data) {
|
||||
const transformedData = transformNotificationData(response.data);
|
||||
setNotification(transformedData);
|
||||
|
||||
// Fetch log history
|
||||
fetchLogHistory(notificationId);
|
||||
|
||||
// Fetch using the actual API
|
||||
const resUpdate = await updateIsRead(notificationId);
|
||||
} else {
|
||||
throw new Error('Notification not found');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
console.error('Error fetching notification detail:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchDetail();
|
||||
}, [notificationId]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Layout
|
||||
style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Spin size="large" />
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !notification) {
|
||||
return (
|
||||
<Layout
|
||||
style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Result
|
||||
status="404"
|
||||
title="404"
|
||||
subTitle="Sorry, the notification you visited does not exist."
|
||||
extra={
|
||||
<Button type="primary" onClick={() => navigate('/notification')}>
|
||||
Back to List
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
const { color } = getIconAndColor(notification.type);
|
||||
|
||||
return (
|
||||
<Layout style={{ padding: '24px', backgroundColor: '#f0f2f5' }}>
|
||||
<Content>
|
||||
<Card>
|
||||
<div
|
||||
style={{
|
||||
borderBottom: '1px solid #f0f0f0',
|
||||
paddingBottom: '16px',
|
||||
marginBottom: '24px',
|
||||
}}
|
||||
>
|
||||
{!props.id && (
|
||||
<Row justify="space-between" align="middle">
|
||||
<Col>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={() => navigate('/notification')}
|
||||
style={{ paddingLeft: 0 }}
|
||||
>
|
||||
Back to notification list
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: '#f6ffed',
|
||||
border: '1px solid #b7eb8f',
|
||||
borderRadius: '4px',
|
||||
padding: '8px 16px',
|
||||
textAlign: 'center',
|
||||
marginTop: '16px',
|
||||
}}
|
||||
>
|
||||
<Typography.Title level={4} style={{ margin: 0, color: '#262626' }}>
|
||||
Error Notification Detail
|
||||
</Typography.Title>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Space direction="vertical" size="small" style={{ width: '100%' }}>
|
||||
<Row gutter={[8, 8]}>
|
||||
{/* Kolom Kiri: Data Kompresor */}
|
||||
<Col xs={24} lg={8}>
|
||||
<Card
|
||||
size="small"
|
||||
style={{ height: '100%', borderColor: '#d4380d' }}
|
||||
bodyStyle={{ padding: '16px' }}
|
||||
>
|
||||
<Space
|
||||
direction="vertical"
|
||||
size="large"
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<Row gutter={16} align="middle">
|
||||
<Col>
|
||||
<div
|
||||
style={{
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: '#d4380d',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#ffffff',
|
||||
fontSize: '18px',
|
||||
}}
|
||||
>
|
||||
<CloseOutlined />
|
||||
</div>
|
||||
</Col>
|
||||
<Col>
|
||||
<Text>{notification.title}</Text>
|
||||
<div style={{ marginTop: '2px' }}>
|
||||
<Text strong style={{ fontSize: '16px' }}>
|
||||
Error Code {notification.issue}
|
||||
</Text>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
<div>
|
||||
<Text strong>Plant Subsection</Text>
|
||||
<div>{notification.location}</div>
|
||||
<Text
|
||||
strong
|
||||
style={{ display: 'block', marginTop: '8px' }}
|
||||
>
|
||||
Date & Time
|
||||
</Text>
|
||||
<div>{notification.timestamp}</div>
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
{/* Kolom Tengah: Informasi Teknis */}
|
||||
<Col xs={24} lg={8}>
|
||||
<Card
|
||||
title="Device Information"
|
||||
size="small"
|
||||
style={{ height: '100%' }}
|
||||
>
|
||||
<Space
|
||||
direction="vertical"
|
||||
size="middle"
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<div>
|
||||
<Text strong>Error Channel</Text>
|
||||
<div>{notification.error_chanel || 'N/A'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<Text strong>Device Code</Text>
|
||||
<div>
|
||||
{notification.device_info?.device_code || 'N/A'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Text strong>Device Name</Text>
|
||||
<div>
|
||||
{notification.device_info?.device_name || 'N/A'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Text strong>Device Location</Text>
|
||||
<div>
|
||||
{notification.device_info?.device_location || 'N/A'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Text strong>Brand</Text>
|
||||
<div>
|
||||
{notification.device_info?.brand_name || 'N/A'}
|
||||
</div>
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
{/* Kolom Kanan: User History */}
|
||||
<Col xs={24} lg={8}>
|
||||
<Card title="User History" size="small" style={{ height: '100%' }}>
|
||||
<div
|
||||
style={{
|
||||
maxHeight: '400px',
|
||||
overflowY: 'auto',
|
||||
padding: '2px',
|
||||
}}
|
||||
>
|
||||
<Space
|
||||
direction="vertical"
|
||||
size={2}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
{getUsersFromNotification(notification).map((user) => (
|
||||
<Card
|
||||
key={user.id}
|
||||
size="small"
|
||||
style={{ width: '100%', margin: 0 }}
|
||||
>
|
||||
<Row align="middle" justify="space-between">
|
||||
<Col>
|
||||
<Space align="center">
|
||||
<Text strong>{user.name}</Text>
|
||||
<Text>|</Text>
|
||||
<Text>
|
||||
<MobileOutlined /> {user.phone}
|
||||
</Text>
|
||||
<Text>|</Text>
|
||||
<Badge
|
||||
status={
|
||||
user.status === 'Delivered'
|
||||
? 'success'
|
||||
: 'default'
|
||||
}
|
||||
text={user.status}
|
||||
/>
|
||||
</Space>
|
||||
<Divider style={{ margin: '8px 0' }} />
|
||||
<Space align="center">
|
||||
{user.status === 'Delivered' ? (
|
||||
<CheckCircleFilled
|
||||
style={{ color: '#52c41a' }}
|
||||
/>
|
||||
) : (
|
||||
<ClockCircleOutlined
|
||||
style={{ color: '#faad14' }}
|
||||
/>
|
||||
)}
|
||||
<Text type="secondary">
|
||||
{user.status === 'Delivered'
|
||||
? 'Success Delivered at'
|
||||
: 'Status '}{' '}
|
||||
{user.timestamp}
|
||||
</Text>
|
||||
</Space>
|
||||
</Col>
|
||||
<Col>
|
||||
<Col>
|
||||
<Button
|
||||
type="primary"
|
||||
ghost
|
||||
icon={<SendOutlined />}
|
||||
onClick={async () => {
|
||||
await resendChatByUser(
|
||||
user.id,
|
||||
user.phone
|
||||
);
|
||||
}}
|
||||
>
|
||||
Resend
|
||||
</Button>
|
||||
</Col>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
))}
|
||||
</Space>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={[8, 8]}>
|
||||
<Col xs={24} md={8}>
|
||||
<div>
|
||||
<Card
|
||||
hoverable
|
||||
bodyStyle={{ padding: '12px'}}
|
||||
>
|
||||
<Space>
|
||||
<BookOutlined
|
||||
style={{ fontSize: '16px', color: '#1890ff' }}
|
||||
/>
|
||||
<Text
|
||||
strong
|
||||
style={{ fontSize: '16px', color: '#262626' }}
|
||||
>
|
||||
Handling Guideline
|
||||
</Text>
|
||||
</Space>
|
||||
|
||||
<Space
|
||||
direction="vertical"
|
||||
size="small"
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
{notification.error_code?.solution &&
|
||||
notification.error_code.solution.length > 0 ? (
|
||||
<>
|
||||
{notification.error_code.solution
|
||||
.filter((sol) => sol.is_active) // Hanya tampilkan solusi yang aktif
|
||||
.map((sol, index) => (
|
||||
<div
|
||||
key={
|
||||
sol.brand_code_solution_id ||
|
||||
index
|
||||
}
|
||||
>
|
||||
{sol.path_document ? (
|
||||
<Card
|
||||
size="small"
|
||||
bodyStyle={{
|
||||
padding: '8px 12px',
|
||||
marginBottom: '4px',
|
||||
}}
|
||||
hoverable
|
||||
extra={
|
||||
<Text
|
||||
type="secondary"
|
||||
style={{
|
||||
fontSize:
|
||||
'10px',
|
||||
}}
|
||||
>
|
||||
PDF
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent:
|
||||
'space-between',
|
||||
alignItems:
|
||||
'center',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<Text
|
||||
style={{
|
||||
fontSize:
|
||||
'12px',
|
||||
color: '#262626',
|
||||
}}
|
||||
>
|
||||
<FilePdfOutlined
|
||||
style={{
|
||||
marginRight:
|
||||
'8px',
|
||||
}}
|
||||
/>{' '}
|
||||
{sol.file_upload_name ||
|
||||
'Solution Document.pdf'}
|
||||
</Text>
|
||||
<Link
|
||||
href={sol.path_document.replace(
|
||||
'/detail-notification/pdf/',
|
||||
'/notification-detail/pdf/'
|
||||
)}
|
||||
target="_blank"
|
||||
style={{
|
||||
fontSize:
|
||||
'12px',
|
||||
display:
|
||||
'block',
|
||||
}}
|
||||
>
|
||||
lihat disini
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
) : null}
|
||||
{sol.type_solution === 'text' &&
|
||||
sol.text_solution ? (
|
||||
<Card
|
||||
size="small"
|
||||
title={
|
||||
<Text strong>
|
||||
{sol.solution_name}:
|
||||
</Text>
|
||||
}
|
||||
bodyStyle={{
|
||||
padding: '8px 12px',
|
||||
marginBottom: '4px',
|
||||
}}
|
||||
extra={
|
||||
<Text
|
||||
type="secondary"
|
||||
style={{
|
||||
fontSize:
|
||||
'10px',
|
||||
}}
|
||||
>
|
||||
{sol.type_solution.toUpperCase()}
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
marginTop:
|
||||
'4px',
|
||||
}}
|
||||
>
|
||||
{sol.text_solution}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
padding: '20px',
|
||||
color: '#8c8c8c',
|
||||
}}
|
||||
>
|
||||
Tidak ada dokumen solusi tersedia
|
||||
</div>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
</div>
|
||||
</Col>
|
||||
<Col xs={24} md={8}>
|
||||
<div>
|
||||
<Card
|
||||
hoverable
|
||||
bodyStyle={{ padding: '12px'}}
|
||||
>
|
||||
<Space>
|
||||
<ToolOutlined
|
||||
style={{ fontSize: '16px', color: '#1890ff' }}
|
||||
/>
|
||||
<Text
|
||||
strong
|
||||
style={{ fontSize: '16px', color: '#262626' }}
|
||||
>
|
||||
Spare Part
|
||||
</Text>
|
||||
</Space>
|
||||
|
||||
<Space
|
||||
direction="vertical"
|
||||
size="small"
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
{notification.spareparts &&
|
||||
notification.spareparts.length > 0 ? (
|
||||
notification.spareparts.map((sparepart, index) => (
|
||||
<Card
|
||||
size="small"
|
||||
key={index}
|
||||
bodyStyle={{ padding: '12px' }}
|
||||
hoverable
|
||||
>
|
||||
<Row gutter={16} align="top">
|
||||
<Col
|
||||
span={7}
|
||||
style={{ textAlign: 'center' }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '60px',
|
||||
backgroundColor: '#f0f0f0',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: '4px',
|
||||
marginBottom: '8px',
|
||||
}}
|
||||
>
|
||||
<ToolOutlined
|
||||
style={{
|
||||
fontSize: '24px',
|
||||
color: '#bfbfbf',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color:
|
||||
sparepart.sparepart_stok ===
|
||||
'Available' ||
|
||||
sparepart.sparepart_stok ===
|
||||
'available'
|
||||
? '#52c41a'
|
||||
: '#ff4d4f',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{sparepart.sparepart_stok}
|
||||
</Text>
|
||||
</Col>
|
||||
<Col span={17}>
|
||||
<Space
|
||||
direction="vertical"
|
||||
size={4}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<Text strong>
|
||||
{sparepart.sparepart_name}
|
||||
</Text>
|
||||
<Paragraph
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
margin: 0,
|
||||
color: '#595959',
|
||||
}}
|
||||
>
|
||||
{sparepart.sparepart_description ||
|
||||
'Deskripsi tidak tersedia'}
|
||||
</Paragraph>
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid #d9d9d9',
|
||||
borderRadius: '4px',
|
||||
padding: '4px 8px',
|
||||
fontSize: '11px',
|
||||
color: '#8c8c8c',
|
||||
marginTop: '8px',
|
||||
}}
|
||||
>
|
||||
Kode:{' '}
|
||||
{sparepart.sparepart_code} |
|
||||
Qty:{' '}
|
||||
{sparepart.sparepart_qty} |
|
||||
Unit:{' '}
|
||||
{sparepart.sparepart_unit}
|
||||
</div>
|
||||
</Space>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
))
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
padding: '20px',
|
||||
color: '#8c8c8c',
|
||||
}}
|
||||
>
|
||||
Tidak ada spare parts terkait
|
||||
</div>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
</div>
|
||||
</Col>
|
||||
<Col xs={24} md={8}>
|
||||
<div>
|
||||
<Card bodyStyle={{ padding: '12px'}}>
|
||||
<Space>
|
||||
<HistoryOutlined
|
||||
style={{ fontSize: '16px', color: '#1890ff' }}
|
||||
/>
|
||||
<Text
|
||||
strong
|
||||
style={{ fontSize: '16px', color: '#262626' }}
|
||||
>
|
||||
Log Activity
|
||||
</Text>
|
||||
</Space>
|
||||
|
||||
<Space
|
||||
direction="vertical"
|
||||
size="small"
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<Card
|
||||
size="small"
|
||||
bodyStyle={{
|
||||
padding: '8px 12px',
|
||||
backgroundColor: isAddingLog
|
||||
? '#fafafa'
|
||||
: '#fff',
|
||||
}}
|
||||
>
|
||||
<Space
|
||||
direction="vertical"
|
||||
style={{ width: '100%' }}
|
||||
size="small"
|
||||
>
|
||||
{isAddingLog && (
|
||||
<>
|
||||
<Text
|
||||
strong
|
||||
style={{ fontSize: '12px' }}
|
||||
>
|
||||
Add New Log / Update Progress
|
||||
</Text>
|
||||
<Input.TextArea
|
||||
rows={2}
|
||||
placeholder="Tuliskan update penanganan di sini..."
|
||||
value={newLogDescription}
|
||||
onChange={(e) =>
|
||||
setNewLogDescription(
|
||||
e.target.value
|
||||
)
|
||||
}
|
||||
disabled={submitLoading}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
type={isAddingLog ? 'primary' : 'dashed'}
|
||||
size="small"
|
||||
block
|
||||
icon={
|
||||
submitLoading ? (
|
||||
<LoadingOutlined />
|
||||
) : (
|
||||
!isAddingLog && <PlusOutlined />
|
||||
)
|
||||
}
|
||||
onClick={
|
||||
isAddingLog
|
||||
? handleSubmitLog
|
||||
: () => setIsAddingLog(true)
|
||||
}
|
||||
loading={submitLoading}
|
||||
disabled={submitLoading}
|
||||
>
|
||||
{isAddingLog ? 'Submit Log' : 'Add Log'}
|
||||
</Button>
|
||||
{isAddingLog && (
|
||||
<Button
|
||||
size="small"
|
||||
block
|
||||
onClick={() => {
|
||||
setIsAddingLog(false);
|
||||
setNewLogDescription('');
|
||||
}}
|
||||
disabled={submitLoading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
{logHistoryData.map((log) => (
|
||||
<Card
|
||||
key={log.id}
|
||||
size="small"
|
||||
bodyStyle={{
|
||||
padding: '8px 12px',
|
||||
}}
|
||||
>
|
||||
<Paragraph
|
||||
style={{ fontSize: '12px', margin: 0 }}
|
||||
// ellipsis={{ rows: 2 }}
|
||||
>
|
||||
<Text strong>{log.addedBy.name}:</Text>{' '}
|
||||
{log.description}
|
||||
</Paragraph>
|
||||
<Text
|
||||
type="secondary"
|
||||
style={{ fontSize: '11px' }}
|
||||
>
|
||||
{log.timestamp}
|
||||
</Text>
|
||||
</Card>
|
||||
))}
|
||||
</Space>
|
||||
</Card>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</Space>
|
||||
</Card>
|
||||
</Content>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationDetailTab;
|
||||
@@ -1,91 +1,246 @@
|
||||
import React, { memo, useState, useEffect } from 'react';
|
||||
import { Button, Row, Col, Card, Input, DatePicker, Select, Typography } from 'antd';
|
||||
import TableList from '../../../../components/Global/TableList';
|
||||
import { Button, Row, Col, Card, DatePicker, Select, Typography, Table, Spin, Modal } from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
import { FileTextOutlined } from '@ant-design/icons';
|
||||
import { FileTextOutlined, DownloadOutlined, LoadingOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
getAllHistoryValueReport,
|
||||
getAllHistoryValueReportPivot,
|
||||
getAllHistoryValueReport,
|
||||
} from '../../../../api/history-value';
|
||||
import { getAllPlantSection } from '../../../../api/master-plant-section';
|
||||
import jsPDF from 'jspdf';
|
||||
import autoTable from 'jspdf-autotable';
|
||||
import ExcelJS from 'exceljs';
|
||||
import { saveAs } from 'file-saver';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const ListReport = memo(function ListReport(props) {
|
||||
const columns = [
|
||||
{
|
||||
title: 'No',
|
||||
key: 'no',
|
||||
width: '5%',
|
||||
align: 'center',
|
||||
render: (_, __, index) => index + 1,
|
||||
},
|
||||
{
|
||||
title: 'Datetime',
|
||||
dataIndex: 'datetime',
|
||||
key: 'datetime',
|
||||
width: '15%',
|
||||
},
|
||||
{
|
||||
title: 'Tag Name',
|
||||
dataIndex: 'tag_name',
|
||||
key: 'tag_name',
|
||||
width: '70%',
|
||||
},
|
||||
// {
|
||||
// title: 'Value',
|
||||
// dataIndex: 'val',
|
||||
// key: 'val',
|
||||
// width: '10%',
|
||||
// render: (_, record) => Number(record.val).toFixed(4),
|
||||
// },
|
||||
// {
|
||||
// title: 'Stat',
|
||||
// dataIndex: 'status',
|
||||
// key: 'status',
|
||||
// width: '10%',
|
||||
// },
|
||||
];
|
||||
|
||||
const dateNow = dayjs();
|
||||
const dateNowFormated = dateNow.format('YYYY-MM-DD');
|
||||
|
||||
const [trigerFilter, setTrigerFilter] = useState(false);
|
||||
const [isLoadingModal, setIsLoadingModal] = useState(false);
|
||||
const [isLoadingTable, setIsLoadingTable] = useState(false);
|
||||
const [tableData, setTableData] = useState([]);
|
||||
const [columns, setColumns] = useState([]);
|
||||
const [pivotData, setPivotData] = useState([]);
|
||||
const [valueReportData, setValueReportData] = useState([]);
|
||||
const [pagination, setPagination] = useState({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
});
|
||||
|
||||
const [plantSubSection, setPlantSubSection] = useState(0);
|
||||
const [plantSubSectionList, setPlantSubSectionList] = useState([]);
|
||||
const [startDate, setStartDate] = useState(dateNow);
|
||||
const [endDate, setEndDate] = useState(dateNow);
|
||||
const [periode, setPeriode] = useState(10);
|
||||
const [periode, setPeriode] = useState(30);
|
||||
|
||||
const defaultFilter = {
|
||||
criteria: '',
|
||||
plant_sub_section_id: 0,
|
||||
from: dateNowFormated,
|
||||
to: dateNowFormated,
|
||||
interval: periode,
|
||||
const generateFullDayTimes = (dateString, intervalMinutes) => {
|
||||
const times = [];
|
||||
const startOfDay = dayjs(dateString).startOf('day');
|
||||
const endOfDay = dayjs(dateString).endOf('day');
|
||||
|
||||
let currentTime = startOfDay;
|
||||
|
||||
while (currentTime.isBefore(endOfDay) || currentTime.isSame(endOfDay)) {
|
||||
times.push(currentTime.format('YYYY-MM-DD HH:mm:ss'));
|
||||
currentTime = currentTime.add(intervalMinutes, 'minute');
|
||||
|
||||
if (currentTime.isAfter(endOfDay)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return times;
|
||||
};
|
||||
const [formDataFilter, setFormDataFilter] = useState(defaultFilter);
|
||||
|
||||
const handleSearch = () => {
|
||||
const formattedDateStart = startDate.format('YYYY-MM-DD');
|
||||
const formattedDateEnd = endDate.format('YYYY-MM-DD');
|
||||
const fetchData = async (page = 1, pageSize = 10, showModal = false) => {
|
||||
// if (!plantSubSection) {
|
||||
// return;
|
||||
// }
|
||||
|
||||
setFormDataFilter({
|
||||
criteria: '',
|
||||
plant_sub_section_id: plantSubSection,
|
||||
from: formattedDateStart,
|
||||
to: formattedDateEnd,
|
||||
interval: periode,
|
||||
});
|
||||
setTrigerFilter((prev) => !prev);
|
||||
if (showModal) {
|
||||
setIsLoadingModal(true);
|
||||
} else {
|
||||
setIsLoadingTable(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);
|
||||
const valueReportResponse = await getAllHistoryValueReportPivot(params);
|
||||
|
||||
if (pivotResponse && pivotResponse.data) {
|
||||
console.log('API Pivot Response:', pivotResponse);
|
||||
setPivotData(pivotResponse.data);
|
||||
|
||||
if (valueReportResponse && valueReportResponse.data) {
|
||||
console.log('API Value Report Response:', valueReportResponse);
|
||||
setValueReportData(valueReportResponse.data);
|
||||
}
|
||||
|
||||
// Buat struktur pivot: waktu sebagai baris, tag sebagai kolom
|
||||
const timeMap = new Map();
|
||||
const tagSet = new Set();
|
||||
|
||||
// Kumpulkan semua waktu unik dan tag unik
|
||||
pivotResponse.data.forEach((row) => {
|
||||
const tagName = row.id;
|
||||
tagSet.add(tagName);
|
||||
|
||||
const dataPoints = row.data || [];
|
||||
dataPoints.forEach((item) => {
|
||||
if (item && typeof item === 'object' && 'x' in item && 'y' in item) {
|
||||
const datetime = item.x;
|
||||
if (!timeMap.has(datetime)) {
|
||||
timeMap.set(datetime, {});
|
||||
}
|
||||
timeMap.get(datetime)[tagName] = item.y;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Konversi ke array dan sort berdasarkan waktu
|
||||
const sortedTimes = Array.from(timeMap.keys()).sort();
|
||||
const sortedTags = Array.from(tagSet).sort();
|
||||
|
||||
// Buat data untuk table
|
||||
const pivotTableData = sortedTimes.map((datetime, index) => {
|
||||
const rowData = {
|
||||
key: index,
|
||||
datetime: datetime,
|
||||
};
|
||||
|
||||
sortedTags.forEach((tagName) => {
|
||||
rowData[tagName] = timeMap.get(datetime)[tagName];
|
||||
});
|
||||
|
||||
return rowData;
|
||||
});
|
||||
|
||||
console.log('Pivot table data sample:', pivotTableData.slice(0, 5));
|
||||
console.log('Total pivot rows:', pivotTableData.length);
|
||||
|
||||
// Buat kolom dinamis
|
||||
const dynamicColumns = [
|
||||
{
|
||||
title: 'No',
|
||||
key: 'no',
|
||||
width: 60,
|
||||
align: 'center',
|
||||
fixed: 'left',
|
||||
render: (_, __, index) => {
|
||||
return (page - 1) * pageSize + index + 1;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Datetime',
|
||||
dataIndex: 'datetime',
|
||||
key: 'datetime',
|
||||
width: 180,
|
||||
fixed: 'left',
|
||||
sorter: (a, b) => new Date(a.datetime) - new Date(b.datetime),
|
||||
},
|
||||
...sortedTags.map((tagName) => ({
|
||||
title: tagName,
|
||||
dataIndex: tagName,
|
||||
key: tagName,
|
||||
width: 120,
|
||||
align: 'center',
|
||||
render: (value) => {
|
||||
if (value === null || value === undefined) {
|
||||
return '-';
|
||||
}
|
||||
return Number(value).toFixed(2);
|
||||
},
|
||||
})),
|
||||
];
|
||||
|
||||
setColumns(dynamicColumns);
|
||||
|
||||
// Pagination
|
||||
const total = pivotTableData.length;
|
||||
const startIndex = (page - 1) * pageSize;
|
||||
const endIndex = startIndex + pageSize;
|
||||
const paginatedData = pivotTableData.slice(startIndex, endIndex);
|
||||
|
||||
setTableData(paginatedData);
|
||||
setPagination({
|
||||
current: page,
|
||||
pageSize: pageSize,
|
||||
total: total,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching data:', error);
|
||||
} finally {
|
||||
if (showModal) {
|
||||
setIsLoadingModal(false);
|
||||
} else {
|
||||
setIsLoadingTable(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleTableChange = (pagination, filters, sorter) => {
|
||||
fetchData(pagination.current, pagination.pageSize, false);
|
||||
};
|
||||
|
||||
const handleSearch = async () => {
|
||||
setIsLoadingModal(true);
|
||||
|
||||
try {
|
||||
const formattedDateStart = startDate.format('YYYY-MM-DD');
|
||||
const formattedDateEnd = endDate.format('YYYY-MM-DD');
|
||||
|
||||
const params = new URLSearchParams({
|
||||
plant_sub_section_id: plantSubSection,
|
||||
from: formattedDateStart,
|
||||
to: formattedDateEnd,
|
||||
interval: periode,
|
||||
page: 1,
|
||||
limit: 1000,
|
||||
});
|
||||
|
||||
const pivotResponse = await getAllHistoryValueReportPivot(params);
|
||||
|
||||
// Jika response sukses, proses data
|
||||
if (pivotResponse && pivotResponse.data) {
|
||||
await fetchData(1, pagination.pageSize, false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching data:', error);
|
||||
// Error akan ditangkap oleh api-request.js dan muncul Swal otomatis
|
||||
} finally {
|
||||
setIsLoadingModal(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setPlantSubSection(0);
|
||||
setStartDate(dateNow);
|
||||
setEndDate(dateNow);
|
||||
setPeriode(5);
|
||||
setPeriode(30);
|
||||
setTableData([]);
|
||||
setColumns([]);
|
||||
setPivotData([]);
|
||||
setValueReportData([]);
|
||||
setPagination({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
});
|
||||
};
|
||||
|
||||
const getPlantSubSection = async () => {
|
||||
@@ -104,8 +259,548 @@ const ListReport = memo(function ListReport(props) {
|
||||
getPlantSubSection();
|
||||
}, []);
|
||||
|
||||
const isWithinOneDay = startDate.isSame(endDate, 'day');
|
||||
|
||||
useEffect(() => {
|
||||
if (!isWithinOneDay && periode < 60) {
|
||||
setPeriode(60);
|
||||
}
|
||||
}, [startDate, endDate, periode, isWithinOneDay]);
|
||||
|
||||
const periodeOptions = [
|
||||
{ value: 5, label: '5 Minute', disabled: !isWithinOneDay },
|
||||
{ value: 10, label: '10 Minute', disabled: !isWithinOneDay },
|
||||
{ value: 30, label: '30 Minute', disabled: !isWithinOneDay },
|
||||
{ value: 60, label: '1 Hour', disabled: false },
|
||||
{ value: 120, label: '2 Hour', disabled: false },
|
||||
];
|
||||
|
||||
const exportToExcel = async () => {
|
||||
if (pivotData.length === 0) {
|
||||
alert('No data to export');
|
||||
return;
|
||||
}
|
||||
|
||||
const tagMapping = {};
|
||||
valueReportData.forEach(item => {
|
||||
if (item.tag_name && item.tag_number) {
|
||||
tagMapping[item.tag_name] = item.tag_number;
|
||||
}
|
||||
});
|
||||
|
||||
const selectedSection = plantSubSectionList.find(
|
||||
item => item.plant_sub_section_id === plantSubSection
|
||||
);
|
||||
const sectionName = selectedSection ? selectedSection.plant_sub_section_name : 'Unknown';
|
||||
|
||||
// Buat struktur pivot yang sama seperti di tabel
|
||||
const timeMap = new Map();
|
||||
const tagSet = new Set();
|
||||
|
||||
pivotData.forEach((row) => {
|
||||
const tagName = row.id;
|
||||
tagSet.add(tagName);
|
||||
|
||||
const dataPoints = row.data || [];
|
||||
dataPoints.forEach((item) => {
|
||||
if (item && typeof item === 'object' && 'x' in item && 'y' in item) {
|
||||
const datetime = item.x;
|
||||
if (!timeMap.has(datetime)) {
|
||||
timeMap.set(datetime, {});
|
||||
}
|
||||
timeMap.get(datetime)[tagName] = item.y;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const sortedTimes = Array.from(timeMap.keys()).sort();
|
||||
const sortedTags = Array.from(tagSet).sort();
|
||||
|
||||
const pivotTableData = sortedTimes.map((datetime) => {
|
||||
const rowData = {
|
||||
datetime: datetime,
|
||||
};
|
||||
|
||||
sortedTags.forEach((tagName) => {
|
||||
rowData[tagName] = timeMap.get(datetime)[tagName];
|
||||
});
|
||||
|
||||
return rowData;
|
||||
});
|
||||
|
||||
console.log('Excel Pivot data:', pivotTableData.slice(0, 5));
|
||||
console.log('Total rows for Excel:', pivotTableData.length);
|
||||
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const ws = workbook.addWorksheet('Pivot Report');
|
||||
|
||||
// Buat header info (3 baris pertama)
|
||||
ws.addRow(['PT. PUPUK INDONESIA UTILITAS']);
|
||||
ws.addRow(['GRESIK GAS COGENERATION PLANT']);
|
||||
ws.addRow([`${sectionName}`]);
|
||||
ws.addRow([]); // Baris kosong sebagai pemisah
|
||||
|
||||
// Buat header kolom dengan tag number
|
||||
const headerRow = [
|
||||
'Datetime',
|
||||
...sortedTags.map(tag => tagMapping[tag] || tag)
|
||||
];
|
||||
ws.addRow(headerRow);
|
||||
|
||||
// Buat data rows - PERBAIKAN: Simpan sebagai number murni
|
||||
pivotTableData.forEach((rowData) => {
|
||||
const row = [dayjs(rowData.datetime).format('DD-MM-YYYY HH:mm')];
|
||||
sortedTags.forEach((tagName) => {
|
||||
const value = rowData[tagName];
|
||||
// Simpan sebagai number, bukan string
|
||||
if (value !== undefined && value !== null) {
|
||||
row.push(Number(value));
|
||||
} else {
|
||||
row.push('-');
|
||||
}
|
||||
});
|
||||
ws.addRow(row);
|
||||
});
|
||||
|
||||
// Set column widths
|
||||
ws.getColumn(1).width = 18; // Datetime column
|
||||
for (let i = 2; i <= sortedTags.length + 1; i++) {
|
||||
ws.getColumn(i).width = 12; // Tag columns
|
||||
}
|
||||
|
||||
// Merge cells untuk header info
|
||||
const totalCols = sortedTags.length + 1;
|
||||
ws.mergeCells(1, 1, 1, totalCols); // Baris 1
|
||||
ws.mergeCells(2, 1, 2, totalCols); // Baris 2
|
||||
ws.mergeCells(3, 1, 3, totalCols); // Baris 3
|
||||
|
||||
// Style untuk header info (3 baris pertama - bold dan center)
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
const cell = ws.getCell(i, 1);
|
||||
cell.font = { bold: true, size: 12 };
|
||||
cell.alignment = { horizontal: 'center', vertical: 'middle', wrapText: true };
|
||||
}
|
||||
|
||||
// Style untuk header kolom (bold, background color, center, border)
|
||||
const headerRowIndex = 5; // Baris header
|
||||
for (let col = 1; col <= totalCols; col++) {
|
||||
const cell = ws.getCell(headerRowIndex, col);
|
||||
cell.font = { bold: true, size: 11 };
|
||||
cell.fill = {
|
||||
type: 'pattern',
|
||||
pattern: 'solid',
|
||||
fgColor: { argb: 'FFDCDCDC' }
|
||||
};
|
||||
cell.alignment = { horizontal: 'center', vertical: 'middle', wrapText: true };
|
||||
cell.border = {
|
||||
top: { style: 'thin', color: { argb: 'FF000000' } },
|
||||
bottom: { style: 'thin', color: { argb: 'FF000000' } },
|
||||
left: { style: 'thin', color: { argb: 'FF000000' } },
|
||||
right: { style: 'thin', color: { argb: 'FF000000' } }
|
||||
};
|
||||
}
|
||||
|
||||
// Style untuk data cells (border dan alignment) - PERBAIKAN: Format number dengan 2 desimal
|
||||
for (let row = headerRowIndex + 1; row <= ws.rowCount; row++) {
|
||||
for (let col = 1; col <= totalCols; col++) {
|
||||
const cell = ws.getCell(row, col);
|
||||
|
||||
cell.alignment = {
|
||||
horizontal: 'center',
|
||||
vertical: 'middle',
|
||||
wrapText: true
|
||||
};
|
||||
cell.border = {
|
||||
top: { style: 'thin', color: { argb: 'FF000000' } },
|
||||
bottom: { style: 'thin', color: { argb: 'FF000000' } },
|
||||
left: { style: 'thin', color: { argb: 'FF000000' } },
|
||||
right: { style: 'thin', color: { argb: 'FF000000' } }
|
||||
};
|
||||
|
||||
// Format number dengan 2 desimal untuk kolom value (kolom 2 dst)
|
||||
if (col > 1) {
|
||||
const cellValue = cell.value;
|
||||
// Hanya set format number jika cell berisi angka
|
||||
if (typeof cellValue === 'number') {
|
||||
cell.numFmt = '0.00';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Generate file name
|
||||
const fileName = `Report_Pivot_${startDate.format('DD-MM-YYYY')}_to_${endDate.format('DD-MM-YYYY')}.xlsx`;
|
||||
|
||||
// Save file
|
||||
const buffer = await workbook.xlsx.writeBuffer();
|
||||
const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
|
||||
saveAs(blob, fileName);
|
||||
};
|
||||
|
||||
const exportToPDF = async () => {
|
||||
if (pivotData.length === 0) {
|
||||
alert('No data to export');
|
||||
return;
|
||||
}
|
||||
|
||||
const tagMapping = {};
|
||||
valueReportData.forEach(item => {
|
||||
if (item.tag_name && item.tag_number) {
|
||||
tagMapping[item.tag_name] = item.tag_number;
|
||||
}
|
||||
});
|
||||
|
||||
const selectedSection = plantSubSectionList.find(item => item.plant_sub_section_id === plantSubSection);
|
||||
const sectionName = selectedSection ? selectedSection.plant_sub_section_name : 'Unknown';
|
||||
|
||||
// Buat struktur pivot yang sama seperti di tabel
|
||||
const timeMap = new Map();
|
||||
const tagSet = new Set();
|
||||
|
||||
pivotData.forEach((row) => {
|
||||
const tagName = row.id;
|
||||
tagSet.add(tagName);
|
||||
|
||||
const dataPoints = row.data || [];
|
||||
dataPoints.forEach((item) => {
|
||||
if (item && typeof item === 'object' && 'x' in item && 'y' in item) {
|
||||
const datetime = item.x;
|
||||
if (!timeMap.has(datetime)) {
|
||||
timeMap.set(datetime, {});
|
||||
}
|
||||
timeMap.get(datetime)[tagName] = item.y;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const sortedTimes = Array.from(timeMap.keys()).sort();
|
||||
const sortedTags = Array.from(tagSet).sort();
|
||||
|
||||
const pivotTableData = sortedTimes.map((datetime) => {
|
||||
const rowData = {
|
||||
datetime: datetime,
|
||||
};
|
||||
|
||||
sortedTags.forEach((tagName) => {
|
||||
rowData[tagName] = timeMap.get(datetime)[tagName];
|
||||
});
|
||||
|
||||
return rowData;
|
||||
});
|
||||
|
||||
console.log('PDF Pivot data:', pivotTableData.slice(0, 5));
|
||||
console.log('Total rows for PDF:', pivotTableData.length);
|
||||
|
||||
const loadImage = (src) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = reject;
|
||||
img.src = src;
|
||||
});
|
||||
};
|
||||
|
||||
let logo1, logo2;
|
||||
try {
|
||||
logo1 = await loadImage('/assets/pupuk-indonesia-2.jpg');
|
||||
logo2 = await loadImage('/assets/pupuk-indonesia-1.png');
|
||||
} catch (error) {
|
||||
console.error('Error loading logos:', error);
|
||||
}
|
||||
|
||||
const doc = new jsPDF({ orientation: 'landscape' });
|
||||
const pageWidth = doc.internal.pageSize.width;
|
||||
const pageHeight = doc.internal.pageSize.height;
|
||||
const marginLeft = 10;
|
||||
const marginRight = 10;
|
||||
const tableWidth = pageWidth - marginLeft - marginRight;
|
||||
|
||||
const DATETIME_COLUMN_WIDTH = 25;
|
||||
const HEADER_LEFT_COLUMN_WIDTH = 40;
|
||||
const MAX_TAG_COLUMNS_PER_PAGE = 15;
|
||||
|
||||
const drawFullHeader = (doc) => {
|
||||
doc.setLineWidth(0.5);
|
||||
doc.line(marginLeft, 10, marginLeft + tableWidth, 10);
|
||||
doc.line(marginLeft, 10, marginLeft, 50);
|
||||
doc.line(marginLeft + tableWidth, 10, marginLeft + tableWidth, 50);
|
||||
|
||||
const col1Width = HEADER_LEFT_COLUMN_WIDTH;
|
||||
const col3Width = tableWidth * 0.20;
|
||||
const col2Width = tableWidth - col1Width - col3Width;
|
||||
|
||||
doc.line(marginLeft + col1Width, 10, marginLeft + col1Width, 30);
|
||||
doc.line(marginLeft + tableWidth - col3Width, 10, marginLeft + tableWidth - col3Width, 30);
|
||||
doc.line(marginLeft, 30, marginLeft + tableWidth, 30);
|
||||
|
||||
if (logo1) {
|
||||
const maxLogoHeight = 18;
|
||||
const maxLogoWidth = col1Width - 4;
|
||||
const logoAspectRatio = logo1.width / logo1.height;
|
||||
let logoWidth, logoHeight;
|
||||
|
||||
if (logoAspectRatio > (maxLogoWidth / maxLogoHeight)) {
|
||||
logoWidth = maxLogoWidth;
|
||||
logoHeight = logoWidth / logoAspectRatio;
|
||||
} else {
|
||||
logoHeight = maxLogoHeight;
|
||||
logoWidth = logoHeight * logoAspectRatio;
|
||||
}
|
||||
|
||||
const logoX = marginLeft + (col1Width - logoWidth) / 2;
|
||||
const logoY = 10 + (20 - logoHeight) / 2;
|
||||
|
||||
doc.addImage(logo1, 'JPEG', logoX, logoY, logoWidth, logoHeight);
|
||||
}
|
||||
|
||||
doc.setFontSize(12);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text('PT. PUPUK INDONESIA UTILITAS', marginLeft + col1Width + col2Width / 2, 17, { align: 'center' });
|
||||
doc.line(marginLeft + col1Width, 21, marginLeft + tableWidth - col3Width, 21);
|
||||
doc.setFontSize(11);
|
||||
doc.text('GRESIK GAS COGENERATION PLANT', marginLeft + col1Width + col2Width / 2, 27, { align: 'center' });
|
||||
|
||||
if (logo2) {
|
||||
const maxLogoHeight = 18;
|
||||
const maxLogoWidth = col3Width - 4;
|
||||
const logoAspectRatio = logo2.width / logo2.height;
|
||||
let logoWidth, logoHeight;
|
||||
|
||||
if (logoAspectRatio > (maxLogoWidth / maxLogoHeight)) {
|
||||
logoWidth = maxLogoWidth;
|
||||
logoHeight = logoWidth / logoAspectRatio;
|
||||
} else {
|
||||
logoHeight = maxLogoHeight;
|
||||
logoWidth = logoHeight * logoAspectRatio;
|
||||
}
|
||||
|
||||
const logoX = marginLeft + tableWidth - col3Width + (col3Width - logoWidth) / 2;
|
||||
const logoY = 10 + (20 - logoHeight) / 2;
|
||||
|
||||
doc.addImage(logo2, 'PNG', logoX, logoY, logoWidth, logoHeight);
|
||||
}
|
||||
|
||||
doc.setFontSize(9);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setFontSize(10);
|
||||
doc.text(`${sectionName}`, marginLeft + col1Width + col2Width / 2, 38, { align: 'center' });
|
||||
};
|
||||
|
||||
// Hitung total kolom tag chunks
|
||||
const totalTagColumns = sortedTags.length;
|
||||
const totalTagChunks = Math.ceil(totalTagColumns / MAX_TAG_COLUMNS_PER_PAGE);
|
||||
|
||||
// PERBAIKAN: Variabel untuk tracking total halaman yang sebenarnya
|
||||
let actualTotalPages = 0;
|
||||
const pageInfoArray = []; // Array untuk menyimpan info setiap page
|
||||
|
||||
// Loop pertama: hitung dulu total halaman yang akan dibuat
|
||||
for (let pageChunk = 0; pageChunk < totalTagChunks; pageChunk++) {
|
||||
const startTagIndex = pageChunk * MAX_TAG_COLUMNS_PER_PAGE;
|
||||
const endTagIndex = Math.min(startTagIndex + MAX_TAG_COLUMNS_PER_PAGE, totalTagColumns);
|
||||
const pageTagColumns = sortedTags.slice(startTagIndex, endTagIndex);
|
||||
const isFirstPage = (pageChunk === 0);
|
||||
|
||||
// Simulasi autoTable untuk menghitung jumlah halaman
|
||||
const tempDoc = new jsPDF({ orientation: 'landscape' });
|
||||
const headerRow = ['Datetime', ...pageTagColumns.map(tag => tagMapping[tag] || tag)];
|
||||
|
||||
const pdfRows = pivotTableData.map((rowData) => {
|
||||
const row = [dayjs(rowData.datetime).format('DD-MM-YYYY HH:mm')];
|
||||
pageTagColumns.forEach((tagName) => {
|
||||
const value = rowData[tagName];
|
||||
row.push(value !== undefined && value !== null ? Number(value).toFixed(2) : '-');
|
||||
});
|
||||
return row;
|
||||
});
|
||||
|
||||
const availableWidthForTags = tableWidth - DATETIME_COLUMN_WIDTH;
|
||||
const TAG_COLUMN_WIDTH = availableWidthForTags / pageTagColumns.length;
|
||||
|
||||
const tagColumnStyles = {};
|
||||
for (let i = 0; i < pageTagColumns.length; i++) {
|
||||
tagColumnStyles[i + 1] = {
|
||||
cellWidth: TAG_COLUMN_WIDTH,
|
||||
halign: 'center'
|
||||
};
|
||||
}
|
||||
|
||||
let pagesForThisChunk = 0;
|
||||
|
||||
autoTable(tempDoc, {
|
||||
head: [headerRow],
|
||||
body: pdfRows,
|
||||
startY: isFirstPage ? 50 : 15,
|
||||
theme: 'grid',
|
||||
rowPageBreak: 'avoid',
|
||||
styles: {
|
||||
fontSize: 7,
|
||||
cellPadding: 1.5,
|
||||
minCellHeight: 8,
|
||||
lineColor: [0, 0, 0],
|
||||
lineWidth: 0.1,
|
||||
halign: 'center',
|
||||
valign: 'middle',
|
||||
overflow: 'linebreak',
|
||||
},
|
||||
headStyles: {
|
||||
fillColor: [220, 220, 220],
|
||||
textColor: [0, 0, 0],
|
||||
fontStyle: 'bold',
|
||||
halign: 'center',
|
||||
valign: 'middle',
|
||||
lineColor: [0, 0, 0],
|
||||
lineWidth: 0.3,
|
||||
},
|
||||
columnStyles: {
|
||||
0: {
|
||||
cellWidth: DATETIME_COLUMN_WIDTH,
|
||||
fontStyle: 'bold',
|
||||
halign: 'center',
|
||||
valign: 'middle'
|
||||
},
|
||||
...tagColumnStyles
|
||||
},
|
||||
margin: { left: marginLeft, right: marginRight, top: 15 },
|
||||
tableWidth: tableWidth,
|
||||
pageBreak: 'auto',
|
||||
didDrawPage: () => {
|
||||
pagesForThisChunk++;
|
||||
}
|
||||
});
|
||||
|
||||
pageInfoArray.push({
|
||||
chunkIndex: pageChunk,
|
||||
pagesCount: pagesForThisChunk,
|
||||
startPage: actualTotalPages + 1
|
||||
});
|
||||
|
||||
actualTotalPages += pagesForThisChunk;
|
||||
}
|
||||
|
||||
console.log('Total pages akan dibuat:', actualTotalPages);
|
||||
|
||||
// Loop kedua: buat PDF yang sebenarnya dengan nomor halaman yang benar
|
||||
let globalPageNumber = 1;
|
||||
|
||||
for (let pageChunk = 0; pageChunk < totalTagChunks; pageChunk++) {
|
||||
if (pageChunk > 0) {
|
||||
doc.addPage();
|
||||
}
|
||||
|
||||
const startTagIndex = pageChunk * MAX_TAG_COLUMNS_PER_PAGE;
|
||||
const endTagIndex = Math.min(startTagIndex + MAX_TAG_COLUMNS_PER_PAGE, totalTagColumns);
|
||||
const pageTagColumns = sortedTags.slice(startTagIndex, endTagIndex);
|
||||
const isFirstPage = (pageChunk === 0);
|
||||
|
||||
if (isFirstPage) {
|
||||
drawFullHeader(doc);
|
||||
}
|
||||
|
||||
const headerRow = ['Datetime', ...pageTagColumns.map(tag => tagMapping[tag] || tag)];
|
||||
|
||||
const pdfRows = pivotTableData.map((rowData) => {
|
||||
const row = [dayjs(rowData.datetime).format('DD-MM-YYYY HH:mm')];
|
||||
|
||||
pageTagColumns.forEach((tagName) => {
|
||||
const value = rowData[tagName];
|
||||
row.push(value !== undefined && value !== null ? Number(value).toFixed(2) : '-');
|
||||
});
|
||||
|
||||
return row;
|
||||
});
|
||||
|
||||
const availableWidthForTags = tableWidth - DATETIME_COLUMN_WIDTH;
|
||||
const TAG_COLUMN_WIDTH = availableWidthForTags / pageTagColumns.length;
|
||||
|
||||
const tagColumnStyles = {};
|
||||
for (let i = 0; i < pageTagColumns.length; i++) {
|
||||
tagColumnStyles[i + 1] = {
|
||||
cellWidth: TAG_COLUMN_WIDTH,
|
||||
halign: 'center'
|
||||
};
|
||||
}
|
||||
|
||||
autoTable(doc, {
|
||||
head: [headerRow],
|
||||
body: pdfRows,
|
||||
startY: isFirstPage ? 43 : 15,
|
||||
theme: 'grid',
|
||||
rowPageBreak: 'avoid',
|
||||
styles: {
|
||||
fontSize: 7,
|
||||
cellPadding: 1.5,
|
||||
minCellHeight: 8,
|
||||
lineColor: [0, 0, 0],
|
||||
lineWidth: 0.5,
|
||||
halign: 'center',
|
||||
valign: 'middle',
|
||||
overflow: 'linebreak',
|
||||
},
|
||||
headStyles: {
|
||||
fillColor: [220, 220, 220],
|
||||
textColor: [0, 0, 0],
|
||||
fontStyle: 'bold',
|
||||
halign: 'center',
|
||||
valign: 'middle',
|
||||
lineColor: [0, 0, 0],
|
||||
lineWidth: 0.5,
|
||||
},
|
||||
columnStyles: {
|
||||
0: {
|
||||
cellWidth: DATETIME_COLUMN_WIDTH,
|
||||
fontStyle: 'bold',
|
||||
halign: 'center',
|
||||
valign: 'middle'
|
||||
},
|
||||
...tagColumnStyles
|
||||
},
|
||||
margin: { left: marginLeft, right: marginRight, top: 15 },
|
||||
tableWidth: tableWidth,
|
||||
pageBreak: 'auto',
|
||||
didDrawPage: (data) => {
|
||||
doc.setFontSize(8);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.text(
|
||||
`Page ${globalPageNumber} of ${actualTotalPages}`,
|
||||
doc.internal.pageSize.width / 2,
|
||||
doc.internal.pageSize.height - 10,
|
||||
{ align: 'center' }
|
||||
);
|
||||
globalPageNumber++;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
doc.save(`Report_Pivot_${startDate.format('DD-MM-YYYY')}_to_${endDate.format('DD-MM-YYYY')}.pdf`);
|
||||
};
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<Modal
|
||||
open={isLoadingModal}
|
||||
footer={null}
|
||||
closable={false}
|
||||
centered
|
||||
width={400}
|
||||
bodyStyle={{
|
||||
textAlign: 'center',
|
||||
padding: '40px 20px'
|
||||
}}
|
||||
>
|
||||
<Spin
|
||||
indicator={<LoadingOutlined style={{ fontSize: 48, color: '#1890ff' }} spin />}
|
||||
/>
|
||||
<div style={{ marginTop: '24px' }}>
|
||||
<Typography.Title level={4} style={{ marginBottom: '8px' }}>
|
||||
Please Wait
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary">
|
||||
System is generating report data...
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Card>
|
||||
<Row>
|
||||
<Col xs={24}>
|
||||
@@ -167,14 +862,8 @@ const ListReport = memo(function ListReport(props) {
|
||||
value={periode}
|
||||
onChange={setPeriode}
|
||||
style={{ width: '100%', marginTop: '4px' }}
|
||||
options={[
|
||||
{ value: 5, label: '5 Minute' },
|
||||
{ value: 10, label: '10 Minute' },
|
||||
{ value: 30, label: '30 Minute' },
|
||||
{ value: 60, label: '1 Hour' },
|
||||
{ value: 120, label: '2 Hour' },
|
||||
]}
|
||||
></Select>
|
||||
options={periodeOptions}
|
||||
/>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
@@ -185,10 +874,33 @@ const ListReport = memo(function ListReport(props) {
|
||||
danger
|
||||
icon={<FileTextOutlined />}
|
||||
onClick={handleSearch}
|
||||
disabled={false}
|
||||
>
|
||||
Show
|
||||
</Button>
|
||||
</Col>
|
||||
<Col>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={exportToPDF}
|
||||
disabled={pivotData.length === 0}
|
||||
style={{ backgroundColor: '#1890ff', borderColor: '#1890ff' }}
|
||||
>
|
||||
Export PDF
|
||||
</Button>
|
||||
</Col>
|
||||
<Col>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={exportToExcel}
|
||||
disabled={pivotData.length === 0}
|
||||
style={{ backgroundColor: '#28a745', borderColor: '#28a745' }}
|
||||
>
|
||||
Export Excel
|
||||
</Button>
|
||||
</Col>
|
||||
<Col>
|
||||
<Button
|
||||
onClick={handleReset}
|
||||
@@ -199,18 +911,26 @@ const ListReport = memo(function ListReport(props) {
|
||||
</Col>
|
||||
</Row>
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={24} lg={24} xl={24} style={{ marginTop: '16px' }}>
|
||||
<TableList
|
||||
firstLoad={false}
|
||||
mobile
|
||||
cardColor={'#d38943ff'}
|
||||
header={'datetime'}
|
||||
getData={getAllHistoryValueReportPivot}
|
||||
queryParams={formDataFilter}
|
||||
columns={columns}
|
||||
columnDynamic={'columns'}
|
||||
triger={trigerFilter}
|
||||
/>
|
||||
<Col xs={24} style={{ marginTop: '16px' }}>
|
||||
<Spin spinning={isLoadingTable}>
|
||||
<div style={{ overflowX: 'auto', width: '100%' }}>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={tableData}
|
||||
pagination={{
|
||||
...pagination,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total) => `Total ${total} data`,
|
||||
pageSizeOptions: ['10', '20', '50', '100'],
|
||||
}}
|
||||
onChange={handleTableChange}
|
||||
scroll={{ x: 'max-content', y: 500 }}
|
||||
bordered
|
||||
size="small"
|
||||
sticky
|
||||
/>
|
||||
</div>
|
||||
</Spin>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import React, { memo, useState, useEffect } from 'react';
|
||||
import { Button, Row, Col, Card, Input, DatePicker, Select, Typography } from 'antd';
|
||||
import { Button, Row, Col, Card, DatePicker, Select, Typography, Modal, Spin } from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
import { FileTextOutlined } from '@ant-design/icons';
|
||||
import { ResponsiveLine } from '@nivo/line';
|
||||
import { FileTextOutlined, LoadingOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer
|
||||
} from 'recharts';
|
||||
import './trending.css';
|
||||
import { getAllPlantSection } from '../../../api/master-plant-section';
|
||||
import { getAllHistoryValueTrendingPivot } from '../../../api/history-value';
|
||||
@@ -18,6 +27,7 @@ const ReportTrending = memo(function ReportTrending(props) {
|
||||
const [startDate, setStartDate] = useState(dateNow);
|
||||
const [endDate, setEndDate] = useState(dateNow);
|
||||
const [periode, setPeriode] = useState(60);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const defaultFilter = {
|
||||
criteria: '',
|
||||
@@ -29,51 +39,83 @@ const ReportTrending = memo(function ReportTrending(props) {
|
||||
const [formDataFilter, setFormDataFilter] = useState(defaultFilter);
|
||||
|
||||
const [trendingValue, setTrendingValue] = useState([]);
|
||||
const [chartData, setChartData] = useState([]);
|
||||
const [metrics, setMetrics] = useState([]);
|
||||
|
||||
// Palet warna
|
||||
const colorPalette = [
|
||||
'#3b82f6', '#ef4444', '#10b981', '#f59e0b', '#8b5cf6',
|
||||
'#ec4899', '#14b8a6', '#f97316', '#6366f1', '#84cc16'
|
||||
];
|
||||
|
||||
const handleSearch = async () => {
|
||||
const formattedDateStart = startDate.format('YYYY-MM-DD');
|
||||
const formattedDateEnd = endDate.format('YYYY-MM-DD');
|
||||
setIsLoading(true);
|
||||
|
||||
const newFilter = {
|
||||
criteria: '',
|
||||
plant_sub_section_id: plantSubSection,
|
||||
from: formattedDateStart,
|
||||
to: formattedDateEnd,
|
||||
interval: periode,
|
||||
};
|
||||
try {
|
||||
const formattedDateStart = startDate.format('YYYY-MM-DD');
|
||||
const formattedDateEnd = endDate.format('YYYY-MM-DD');
|
||||
|
||||
setFormDataFilter(newFilter);
|
||||
const newFilter = {
|
||||
criteria: '',
|
||||
plant_sub_section_id: plantSubSection,
|
||||
from: formattedDateStart,
|
||||
to: formattedDateEnd,
|
||||
interval: periode,
|
||||
};
|
||||
|
||||
const param = new URLSearchParams(newFilter);
|
||||
const response = await getAllHistoryValueTrendingPivot(param);
|
||||
setFormDataFilter(newFilter);
|
||||
|
||||
if (response?.data?.length > 0) {
|
||||
// 🔹 Bersihkan dan format data agar aman untuk Nivo
|
||||
const cleanedData = response.data.map((serie) => ({
|
||||
id: serie.id ?? 'Unknown',
|
||||
data: Array.isArray(serie.data)
|
||||
? serie.data.map((d) => ({
|
||||
x: d?.x ?? null,
|
||||
y:
|
||||
d?.y !== null && d?.y !== undefined
|
||||
? Number(d.y).toFixed(4) // format 4 angka di belakang koma
|
||||
: null,
|
||||
}))
|
||||
: [],
|
||||
}));
|
||||
const param = new URLSearchParams(newFilter);
|
||||
const response = await getAllHistoryValueTrendingPivot(param);
|
||||
|
||||
setTrendingValue(cleanedData);
|
||||
} else {
|
||||
// 🔹 Jika tidak ada data dari API
|
||||
setTrendingValue([]);
|
||||
if (response?.data?.length > 0) {
|
||||
transformDataForRecharts(response.data);
|
||||
} else {
|
||||
setTrendingValue([]);
|
||||
setChartData([]);
|
||||
setMetrics([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching trending data:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const transformDataForRecharts = (nivoData) => {
|
||||
setTrendingValue(nivoData);
|
||||
|
||||
const metricNames = nivoData.map(serie => serie.id);
|
||||
setMetrics(metricNames);
|
||||
|
||||
const timeMap = new Map();
|
||||
|
||||
nivoData.forEach(serie => {
|
||||
serie.data.forEach(point => {
|
||||
if (!timeMap.has(point.x)) {
|
||||
timeMap.set(point.x, { time: point.x });
|
||||
}
|
||||
const entry = timeMap.get(point.x);
|
||||
entry[serie.id] = point.y !== null && point.y !== undefined
|
||||
? parseFloat(point.y)
|
||||
: null;
|
||||
});
|
||||
});
|
||||
|
||||
const transformedData = Array.from(timeMap.values()).sort((a, b) =>
|
||||
new Date(a.time) - new Date(b.time)
|
||||
);
|
||||
|
||||
setChartData(transformedData);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setPlantSubSection(0);
|
||||
setStartDate(dateNow);
|
||||
setEndDate(dateNow);
|
||||
setPeriode(5);
|
||||
setPeriode(60);
|
||||
setChartData([]);
|
||||
setMetrics([]);
|
||||
};
|
||||
|
||||
const getPlantSubSection = async () => {
|
||||
@@ -88,12 +130,171 @@ const ReportTrending = memo(function ReportTrending(props) {
|
||||
}
|
||||
};
|
||||
|
||||
// Fungsi untuk menentukan apakah rentang tanggal lebih dari 1 hari
|
||||
const isMultipleDays = () => {
|
||||
return !startDate.isSame(endDate, 'day');
|
||||
};
|
||||
|
||||
// Format sumbu X yang otomatis menyesuaikan
|
||||
const formatXAxis = (tickItem) => {
|
||||
const date = new Date(tickItem);
|
||||
const hours = date.getHours().toString().padStart(2, '0');
|
||||
const minutes = date.getMinutes().toString().padStart(2, '0');
|
||||
|
||||
// Jika rentang lebih dari 1 hari, tampilkan tanggal + waktu
|
||||
if (isMultipleDays()) {
|
||||
const day = date.getDate().toString().padStart(2, '0');
|
||||
const month = (date.getMonth() + 1).toString().padStart(2, '0');
|
||||
return `${day}/${month} ${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
// Jika hanya 1 hari, tampilkan waktu saja
|
||||
return `${hours}:${minutes}`;
|
||||
};
|
||||
|
||||
const CustomTooltip = ({ active, payload, label }) => {
|
||||
if (active && payload && payload.length) {
|
||||
return (
|
||||
<div style={{
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.98)',
|
||||
padding: '12px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)'
|
||||
}}>
|
||||
<p style={{ margin: 0, fontWeight: 'bold', marginBottom: '8px' }}>
|
||||
{new Date(label).toLocaleString('id-ID')}
|
||||
</p>
|
||||
{payload.map((entry, index) => (
|
||||
<p key={index} style={{
|
||||
margin: '4px 0',
|
||||
color: entry.color,
|
||||
fontSize: '13px'
|
||||
}}>
|
||||
<strong>{entry.name}:</strong> {Number(entry.value).toFixed(4)}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const renderChart = () => {
|
||||
if (!chartData || chartData.length === 0) {
|
||||
return (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
marginTop: '100px',
|
||||
color: '#999',
|
||||
fontSize: '16px'
|
||||
}}>
|
||||
Tidak ada data untuk ditampilkan
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={500}>
|
||||
<LineChart
|
||||
data={chartData}
|
||||
margin={{ top: 20, right: 200, left: 80, bottom: 40 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e0e0e0" />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
angle={-45}
|
||||
textAnchor="end"
|
||||
height={100}
|
||||
tick={{ fontSize: 11 }}
|
||||
tickFormatter={formatXAxis}
|
||||
label={{
|
||||
value: 'Waktu',
|
||||
position: 'bottom',
|
||||
offset: -50,
|
||||
style: { fontSize: 14, fontWeight: 'bold' }
|
||||
}}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 11 }}
|
||||
label={{
|
||||
value: 'Nilai',
|
||||
angle: -90,
|
||||
position: 'right',
|
||||
offset: -70,
|
||||
dy: 0,
|
||||
style: {
|
||||
fontSize: 12,
|
||||
fontWeight: 'bold',
|
||||
fill: '#059669',
|
||||
textAnchor: 'middle'
|
||||
}
|
||||
}}
|
||||
tickFormatter={(value) => Number(value).toFixed(2)}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Legend
|
||||
layout="vertical"
|
||||
align="right"
|
||||
verticalAlign="middle"
|
||||
wrapperStyle={{
|
||||
position: 'absolute',
|
||||
right: 150,
|
||||
top: '35%',
|
||||
transform: 'translateY(-50%)'
|
||||
}}
|
||||
/>
|
||||
{metrics.map((metric, index) => {
|
||||
const color = colorPalette[index % colorPalette.length];
|
||||
return (
|
||||
<Line
|
||||
key={metric}
|
||||
type="monotone"
|
||||
dataKey={metric}
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
dot={chartData.length < 50}
|
||||
name={metric}
|
||||
connectNulls={true}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getPlantSubSection();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
{/* Loading Modal */}
|
||||
<Modal
|
||||
open={isLoading}
|
||||
footer={null}
|
||||
closable={false}
|
||||
centered
|
||||
width={400}
|
||||
bodyStyle={{
|
||||
textAlign: 'center',
|
||||
padding: '40px 20px'
|
||||
}}
|
||||
>
|
||||
<Spin
|
||||
indicator={<LoadingOutlined style={{ fontSize: 48, color: '#1890ff' }} spin />}
|
||||
/>
|
||||
<div style={{ marginTop: '24px' }}>
|
||||
<Typography.Title level={4} style={{ marginBottom: '8px' }}>
|
||||
Please Wait
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary">
|
||||
System is generating trending data...
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Card>
|
||||
<Row>
|
||||
<Col xs={24}>
|
||||
@@ -162,10 +363,11 @@ const ReportTrending = memo(function ReportTrending(props) {
|
||||
{ value: 60, label: '1 Hour' },
|
||||
{ value: 120, label: '2 Hour' },
|
||||
]}
|
||||
></Select>
|
||||
/>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={8} style={{ marginTop: '16px' }}>
|
||||
<Col>
|
||||
<Button
|
||||
@@ -187,108 +389,9 @@ const ReportTrending = memo(function ReportTrending(props) {
|
||||
</Col>
|
||||
</Row>
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={24} lg={24} xl={24} style={{ marginTop: '16px' }}>
|
||||
<div style={{ height: '500px', marginTop: '16px' }}>
|
||||
{trendingValue && trendingValue.length > 0 ? (
|
||||
<ResponsiveLine
|
||||
data={trendingValue} // [{ id, data: [{x, y}] }]
|
||||
// data={
|
||||
// trendingValue && trendingValue.length
|
||||
// ? trendingValue
|
||||
// : [{ id, data: [{ x, y }] }]
|
||||
// }
|
||||
margin={{ top: 40, right: 100, bottom: 70, left: 70 }}
|
||||
xScale={{
|
||||
type: 'time',
|
||||
format: '%Y-%m-%d %H:%M',
|
||||
useUTC: false,
|
||||
precision: 'minute',
|
||||
}}
|
||||
xFormat="time:%Y-%m-%d %H:%M"
|
||||
yScale={{
|
||||
type: 'linear',
|
||||
min: 'auto',
|
||||
max: 'auto',
|
||||
stacked: false,
|
||||
reverse: false,
|
||||
}}
|
||||
yFormat={(value) => Number(value).toFixed(4)} // ✅ format 4 angka di belakang koma
|
||||
axisBottom={{
|
||||
format: '%Y-%m-%d %H:%M', // ✅ tampilkan tanggal + jam
|
||||
tickValues: 'every 2 hours', // tampilkan setiap 2 jam (bisa ubah ke every 30 minutes)
|
||||
tickSize: 5,
|
||||
tickPadding: 5,
|
||||
tickRotation: -45,
|
||||
legend: 'Tanggal & Waktu',
|
||||
legendOffset: 60,
|
||||
legendPosition: 'middle',
|
||||
}}
|
||||
axisLeft={{
|
||||
tickSize: 5,
|
||||
tickPadding: 5,
|
||||
tickRotation: 0,
|
||||
legend: 'Nilai (Avg)',
|
||||
legendOffset: -60,
|
||||
legendPosition: 'middle',
|
||||
format: (value) => Number(value).toFixed(4), // ✅ tampilkan 4 angka di sumbu Y
|
||||
}}
|
||||
curve="monotoneX"
|
||||
colors={{ scheme: 'category10' }}
|
||||
pointSize={6}
|
||||
pointColor={{ theme: 'background' }}
|
||||
pointBorderWidth={2}
|
||||
pointBorderColor={{ from: 'serieColor' }}
|
||||
enablePointLabel={false}
|
||||
enableGridX={true}
|
||||
enableGridY={true}
|
||||
useMesh={true}
|
||||
tooltip={({ point }) => (
|
||||
<div
|
||||
style={{
|
||||
background: 'white',
|
||||
padding: '6px 9px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '6px',
|
||||
}}
|
||||
>
|
||||
<strong>{point.serieId}</strong>
|
||||
<br />
|
||||
{point.data.xFormatted}
|
||||
<br />
|
||||
<span style={{ color: point.serieColor }}>
|
||||
{Number(point.data.y).toFixed(4)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
legends={[
|
||||
{
|
||||
anchor: 'bottom-right',
|
||||
direction: 'column',
|
||||
justify: false,
|
||||
translateX: 100,
|
||||
translateY: 0,
|
||||
itemsSpacing: 2,
|
||||
itemDirection: 'left-to-right',
|
||||
itemWidth: 120,
|
||||
itemHeight: 20,
|
||||
itemOpacity: 0.85,
|
||||
symbolSize: 12,
|
||||
symbolShape: 'circle',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
marginTop: '40px',
|
||||
color: '#999',
|
||||
}}
|
||||
>
|
||||
Tidak ada data untuk ditampilkan
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Col xs={24} sm={24} md={24} lg={24} xl={24} style={{ marginTop: '24px' }}>
|
||||
{renderChart()}
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
@@ -115,7 +115,7 @@ const ChangePasswordModal = (props) => {
|
||||
try {
|
||||
const response = await changePassword(props.selectedUser.user_id, formData.newPassword);
|
||||
|
||||
console.log('Change Password Response:', response);
|
||||
// console.log('Change Password Response:', response);
|
||||
|
||||
if (response && response.statusCode === 200) {
|
||||
NotifOk({
|
||||
|
||||
@@ -220,35 +220,27 @@ const DetailUser = (props) => {
|
||||
|
||||
// For update mode: only send email if it has changed
|
||||
if (FormData.user_id) {
|
||||
// Only include email if it has changed from original
|
||||
if (FormData.user_email !== originalEmail) {
|
||||
payload.user_email = FormData.user_email;
|
||||
}
|
||||
// Add is_active for update mode
|
||||
|
||||
payload.is_active = FormData.is_active;
|
||||
} else {
|
||||
// For create mode: always send email
|
||||
payload.user_email = FormData.user_email;
|
||||
}
|
||||
|
||||
// Only add role_id if it exists (backend requires number >= 1, no null)
|
||||
if (FormData.role_id) {
|
||||
payload.role_id = FormData.role_id;
|
||||
}
|
||||
|
||||
// Add password and name for new user (create mode)
|
||||
if (!FormData.user_id) {
|
||||
payload.user_name = FormData.user_name; // Username only for create
|
||||
payload.user_password = FormData.password; // Backend expects 'user_password'
|
||||
// Don't send confirmPassword, is_sa for create
|
||||
payload.user_name = FormData.user_name;
|
||||
payload.user_password = FormData.password;
|
||||
}
|
||||
// For update mode:
|
||||
// - Don't send 'user_name' (username is immutable)
|
||||
// - is_active is now sent for update mode
|
||||
// - Only send email if it has changed
|
||||
|
||||
try {
|
||||
console.log('Payload being sent:', payload);
|
||||
// console.log('Payload being sent:', payload);
|
||||
|
||||
let response;
|
||||
if (!FormData.user_id) {
|
||||
@@ -257,11 +249,10 @@ const DetailUser = (props) => {
|
||||
response = await updateUser(FormData.user_id, payload);
|
||||
}
|
||||
|
||||
console.log('Save User Response:', response);
|
||||
// console.log('Save User Response:', response);
|
||||
|
||||
// Check if response is successful
|
||||
if (response && (response.statusCode === 200 || response.statusCode === 201)) {
|
||||
// If in edit mode and newPassword is provided, change password
|
||||
if (FormData.user_id && FormData.newPassword) {
|
||||
try {
|
||||
const passwordResponse = await changePassword(
|
||||
@@ -385,9 +376,9 @@ const DetailUser = (props) => {
|
||||
search: '',
|
||||
});
|
||||
|
||||
console.log('Fetching roles with params:', queryParams.toString());
|
||||
// console.log('Fetching roles with params:', queryParams.toString());
|
||||
const response = await getAllRole(queryParams);
|
||||
console.log('Fetched roles response:', response);
|
||||
// console.log('Fetched roles response:', response);
|
||||
|
||||
// Handle different response structures
|
||||
if (response && response.data) {
|
||||
@@ -408,7 +399,7 @@ const DetailUser = (props) => {
|
||||
}
|
||||
|
||||
setRoleList(roles);
|
||||
console.log('Setting role list:', roles);
|
||||
// console.log('Setting role list:', roles);
|
||||
} else {
|
||||
// Add mock data as fallback
|
||||
console.warn('No response data, using mock data');
|
||||
@@ -418,7 +409,7 @@ const DetailUser = (props) => {
|
||||
{ role_id: 3, role_name: 'User', role_level: 3 },
|
||||
];
|
||||
setRoleList(mockRoles);
|
||||
console.log('Setting mock role list:', mockRoles);
|
||||
// console.log('Setting mock role list:', mockRoles);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching roles:', error);
|
||||
@@ -429,7 +420,7 @@ const DetailUser = (props) => {
|
||||
{ role_id: 3, role_name: 'User', role_level: 3 },
|
||||
];
|
||||
setRoleList(mockRoles);
|
||||
console.log('Setting mock role list due to error:', mockRoles);
|
||||
// console.log('Setting mock role list due to error:', mockRoles);
|
||||
|
||||
// Only show error notification if we don't have fallback data
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
@@ -1146,9 +1137,7 @@ const DetailUser = (props) => {
|
||||
))}
|
||||
</Select>
|
||||
{errors.role_id && (
|
||||
<Text style={{ color: 'red', fontSize: '12px' }}>
|
||||
{errors.role_id}
|
||||
</Text>
|
||||
<Text style={{ color: 'red', fontSize: '12px' }}>{errors.role_id}</Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -192,7 +192,7 @@ const columns = (showPreviewModal, showEditModal, showDeleteDialog, showApproval
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Aksi',
|
||||
title: 'Action',
|
||||
key: 'aksi',
|
||||
align: 'center',
|
||||
width: '12%',
|
||||
|
||||