Compare commits
26 Commits
lavoce
...
47d0638a42
| Author | SHA1 | Date | |
|---|---|---|---|
| 47d0638a42 | |||
| d8c5f3ed44 | |||
| affd9146bb | |||
| 4022b3f8f4 | |||
| 446a4e2b95 | |||
| 83a475c708 | |||
| ab1c510a77 | |||
| 59859c6d18 | |||
| 2bd27937dc | |||
| 1058c660d6 | |||
| 35b2167791 | |||
| ec676983d0 | |||
| c07c5f8235 | |||
| b32ad97034 | |||
| 76244f6f6e | |||
| 0a128cbb3c | |||
| bd4ab26680 | |||
| 3e728a1ff5 | |||
| 9db143972e | |||
| 029ea269a7 | |||
| 4cdaa042da | |||
| 56af2a16c0 | |||
| deadf2ffb4 | |||
| 4da80c7089 | |||
| 56e3ce78a6 | |||
| 7c2a019dd2 |
1
.gitignore
vendored
@@ -6,6 +6,7 @@ yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
*.config
|
||||
|
||||
node_modules
|
||||
dist
|
||||
|
||||
@@ -22,8 +22,7 @@
|
||||
"exceljs": "^4.4.0",
|
||||
"file-saver": "^2.0.5",
|
||||
"html2canvas": "^1.4.1",
|
||||
"jspdf": "^3.0.4",
|
||||
"jspdf-autotable": "^5.0.2",
|
||||
"jspdf": "^3.0.1",
|
||||
"mqtt": "^5.14.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^18.2.0",
|
||||
@@ -31,7 +30,6 @@
|
||||
"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": {
|
||||
|
||||
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 309 KiB |
|
Before Width: | Height: | Size: 78 KiB |
@@ -3,7 +3,7 @@
|
||||
<system.webServer>
|
||||
<rewrite>
|
||||
<rules>
|
||||
<rule name="reactViteSypiu">
|
||||
<rule name="CallOfDuty">
|
||||
<match url=".*" />
|
||||
<conditions logicalGrouping="MatchAll">
|
||||
<add input="{REQUEST_FILENAME}" matchType="IsFile" negate="true" />
|
||||
|
||||
50
src/App.jsx
@@ -10,13 +10,11 @@ import Home from './pages/home/Home';
|
||||
import Blank from './pages/blank/Blank';
|
||||
|
||||
// Master
|
||||
import IndexPlantSubSection from './pages/master/plantSubSection/IndexPlantSubSection';
|
||||
import IndexBrandDevice from './pages/master/brandDevice/IndexBrandDevice';
|
||||
import IndexDevice from './pages/master/device/IndexDevice';
|
||||
import IndexUnit from './pages/master/unit/IndexUnit';
|
||||
import IndexTag from './pages/master/tag/IndexTag';
|
||||
import IndexUnit from './pages/master/unit/IndexUnit';
|
||||
import IndexBrandDevice from './pages/master/brandDevice/IndexBrandDevice';
|
||||
import IndexStatus from './pages/master/status/IndexStatus';
|
||||
import IndexSparepart from './pages/master/sparepart/IndexSparepart';
|
||||
import IndexShift from './pages/master/shift/IndexShift';
|
||||
// Brand device
|
||||
import AddBrandDevice from './pages/master/brandDevice/AddBrandDevice';
|
||||
@@ -36,8 +34,6 @@ 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/notificationDetail/IndexNotificationDetail';
|
||||
import IndexVerificationSparepart from './pages/verificationSparepart/IndexVerificationSparepart';
|
||||
|
||||
import SvgTest from './pages/home/SvgTest';
|
||||
import SvgOverviewCompressor from './pages/home/SvgOverviewCompressor';
|
||||
@@ -50,10 +46,7 @@ import SvgAirDryerB from './pages/home/SvgAirDryerB';
|
||||
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';
|
||||
import IndexPlantSubSection from './pages/master/plantSubSection/IndexPlantSubSection';
|
||||
|
||||
const App = () => {
|
||||
return (
|
||||
@@ -64,16 +57,6 @@ const App = () => {
|
||||
<Route path="/signin" element={<SignIn />} />
|
||||
<Route path="/signup" element={<SignUp />} />
|
||||
<Route path="/svg" element={<SvgTest />} />
|
||||
<Route
|
||||
path="/notification-detail/:notificationId"
|
||||
element={<DetailNotificationTab />}
|
||||
/>
|
||||
<Route
|
||||
path="/verification-sparepart/:notificationId"
|
||||
element={<IndexVerificationSparepart />}
|
||||
/>
|
||||
|
||||
<Route path="/redirect" element={<RedirectWa />} />
|
||||
|
||||
{/* Protected Routes */}
|
||||
<Route path="/dashboard" element={<ProtectedRoute />}>
|
||||
@@ -81,8 +64,6 @@ const App = () => {
|
||||
<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 />} />
|
||||
@@ -98,28 +79,16 @@ const App = () => {
|
||||
<Route path="device" element={<IndexDevice />} />
|
||||
<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 />} />
|
||||
<Route path="brand-device/view/:id" element={<ViewBrandDevice />} />
|
||||
<Route
|
||||
path="brand-device/edit/:id/files/:fileType/:fileName"
|
||||
element={<ViewFilePage />}
|
||||
/>
|
||||
<Route
|
||||
path="brand-device/view/:id/files/:fileType/:fileName"
|
||||
element={<ViewFilePage />}
|
||||
/>
|
||||
<Route
|
||||
path="brand-device/view/temp/files/:fileName"
|
||||
element={<ViewFilePage />}
|
||||
/>
|
||||
<Route path="brand-device/edit/:id/files/:fileType/:fileName" element={<ViewFilePage />} />
|
||||
<Route path="brand-device/view/:id/files/:fileType/:fileName" element={<ViewFilePage />} />
|
||||
<Route 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 />}>
|
||||
@@ -152,6 +121,7 @@ const App = () => {
|
||||
<Route index element={<IndexJadwalShift />} />
|
||||
</Route>
|
||||
|
||||
{/* Catch-all */}
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
|
||||
@@ -1,248 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { getFileUrl, getFolderFromFileType } from '../api/file-uploads';
|
||||
|
||||
const ImageViewer = () => {
|
||||
const { fileName } = useParams();
|
||||
const [fileUrl, setFileUrl] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [isImage, setIsImage] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!fileName) {
|
||||
setError('No file specified');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const decodedFileName = decodeURIComponent(fileName);
|
||||
const fileExtension = decodedFileName.split('.').pop()?.toLowerCase();
|
||||
const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'];
|
||||
|
||||
setIsImage(imageExtensions.includes(fileExtension));
|
||||
|
||||
const folder = getFolderFromFileType(fileExtension);
|
||||
|
||||
const url = getFileUrl(folder, decodedFileName);
|
||||
setFileUrl(url);
|
||||
|
||||
document.title = `File Viewer - ${decodedFileName}`;
|
||||
} catch (error) {
|
||||
|
||||
setError('Failed to load file');
|
||||
}
|
||||
}, [fileName]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e) => {
|
||||
if (!isImage) return;
|
||||
|
||||
if (e.key === '+' || e.key === '=') {
|
||||
setZoom(prev => Math.min(prev + 0.1, 3));
|
||||
} else if (e.key === '-' || e.key === '_') {
|
||||
setZoom(prev => Math.max(prev - 0.1, 0.1));
|
||||
} else if (e.key === '0') {
|
||||
setZoom(1);
|
||||
} else if (e.key === 'Escape') {
|
||||
window.close();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isImage]);
|
||||
|
||||
|
||||
const handleWheel = (e) => {
|
||||
if (!isImage || !e.ctrlKey) return;
|
||||
|
||||
e.preventDefault();
|
||||
const delta = e.deltaY > 0 ? -0.1 : 0.1;
|
||||
setZoom(prev => Math.min(Math.max(prev + delta, 0.1), 3));
|
||||
};
|
||||
|
||||
const handleZoomIn = () => setZoom(prev => Math.min(prev + 0.1, 3));
|
||||
const handleZoomOut = () => setZoom(prev => Math.max(prev - 0.1, 0.1));
|
||||
const handleResetZoom = () => setZoom(1);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100vh',
|
||||
fontFamily: 'Arial, sans-serif',
|
||||
backgroundColor: '#f5f5f5'
|
||||
}}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<h1>Error</h1>
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
if (!isImage) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100vh',
|
||||
fontFamily: 'Arial, sans-serif',
|
||||
backgroundColor: '#f5f5f5'
|
||||
}}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<h1>File Type Not Supported</h1>
|
||||
<p>Image viewer only supports image files.</p>
|
||||
<p>Please use direct file preview for PDFs and other documents.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
height: '100vh',
|
||||
width: '100vw',
|
||||
backgroundColor: '#000',
|
||||
overflow: 'hidden',
|
||||
position: 'relative'
|
||||
}}
|
||||
onWheel={handleWheel}
|
||||
>
|
||||
|
||||
{isImage && (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: '20px',
|
||||
right: '20px',
|
||||
display: 'flex',
|
||||
gap: '10px',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.7)',
|
||||
padding: '10px',
|
||||
borderRadius: '8px',
|
||||
zIndex: 1000
|
||||
}}>
|
||||
<button
|
||||
onClick={handleZoomOut}
|
||||
style={{
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||
color: '#fff',
|
||||
border: '1px solid rgba(255, 255, 255, 0.3)',
|
||||
padding: '8px 12px',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '16px'
|
||||
}}
|
||||
title="Zoom Out (-)"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<span style={{
|
||||
color: '#fff',
|
||||
padding: '8px 12px',
|
||||
minWidth: '60px',
|
||||
textAlign: 'center',
|
||||
fontSize: '14px'
|
||||
}}>
|
||||
{Math.round(zoom * 100)}%
|
||||
</span>
|
||||
<button
|
||||
onClick={handleZoomIn}
|
||||
style={{
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||
color: '#fff',
|
||||
border: '1px solid rgba(255, 255, 255, 0.3)',
|
||||
padding: '8px 12px',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '16px'
|
||||
}}
|
||||
title="Zoom In (+)"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<button
|
||||
onClick={handleResetZoom}
|
||||
style={{
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||
color: '#fff',
|
||||
border: '1px solid rgba(255, 255, 255, 0.3)',
|
||||
padding: '8px 12px',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
title="Reset Zoom (0)"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{isImage && fileUrl ? (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100%',
|
||||
overflow: 'auto'
|
||||
}}>
|
||||
<img
|
||||
src={fileUrl}
|
||||
alt={decodeURIComponent(fileName)}
|
||||
style={{
|
||||
maxWidth: 'none',
|
||||
maxHeight: 'none',
|
||||
transform: `scale(${zoom})`,
|
||||
transformOrigin: 'center',
|
||||
transition: 'transform 0.1s ease-out',
|
||||
cursor: zoom > 1 ? 'move' : 'default'
|
||||
}}
|
||||
onError={() => setError('Failed to load image')}
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
) : isImage ? (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100vh',
|
||||
color: '#fff',
|
||||
fontFamily: 'Arial, sans-serif'
|
||||
}}>
|
||||
<p>Loading image...</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
||||
{isImage && (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
bottom: '20px',
|
||||
left: '20px',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.7)',
|
||||
color: '#fff',
|
||||
padding: '10px 15px',
|
||||
borderRadius: '8px',
|
||||
fontSize: '12px',
|
||||
zIndex: 1000
|
||||
}}>
|
||||
<div>Mouse wheel + Ctrl: Zoom</div>
|
||||
<div>Keyboard: +/− Zoom, 0: Reset, ESC: Close</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImageViewer;
|
||||
@@ -47,63 +47,4 @@ const deleteBrand = async (id) => {
|
||||
return response.data;
|
||||
};
|
||||
|
||||
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
|
||||
};
|
||||
export { getAllBrands, getBrandById, createBrand, updateBrand, deleteBrand };
|
||||
|
||||
@@ -1,105 +1,9 @@
|
||||
import { SendRequest } from '../components/Global/ApiRequest';
|
||||
|
||||
const getAllNotification = async (queryParams) => {
|
||||
export const getAllNotification = async () => {
|
||||
const response = await SendRequest({
|
||||
method: 'get',
|
||||
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,
|
||||
prefix: 'notification',
|
||||
});
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
import { SendRequest } from '../components/Global/ApiRequest';
|
||||
|
||||
const getAllSparepart = async (queryParams) => {
|
||||
const response = await SendRequest({
|
||||
method: 'get',
|
||||
prefix: `sparepart?${queryParams.toString()}`,
|
||||
});
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
const getSparepartById = async (id) => {
|
||||
const response = await SendRequest({
|
||||
method: 'get',
|
||||
prefix: `sparepart/${id}`,
|
||||
});
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
const createSparepart = async (queryParams) => {
|
||||
const response = await SendRequest({
|
||||
method: 'post',
|
||||
prefix: `sparepart`,
|
||||
params: queryParams,
|
||||
});
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
const updateSparepart = async (id, queryParams) => {
|
||||
const response = await SendRequest({
|
||||
method: 'put',
|
||||
prefix: `sparepart/${id}`,
|
||||
params: queryParams,
|
||||
});
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
const deleteSparepart = async (id) => {
|
||||
const response = await SendRequest({
|
||||
method: 'delete',
|
||||
prefix: `sparepart/${id}`,
|
||||
});
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export { getAllSparepart, getSparepartById, createSparepart, updateSparepart, deleteSparepart };
|
||||
@@ -26,29 +26,27 @@
|
||||
<path style="fill: none; stroke: rgb(76, 76, 76); stroke-width: 1.222; transform-origin: 490.992px 230.229px;" d="M 646.097 240.002 L 676.271 240.002"/>
|
||||
</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>
|
||||
<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 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="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="698.476" y="373.93" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">°C</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="653.279" y="373.97" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">####.##</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="698.476" y="373.93" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">°F</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1; font-weight: bold;" x="748" y="347.676" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">HEATER</text>
|
||||
<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"/>
|
||||
@@ -110,12 +108,12 @@
|
||||
<rect x="427.269" y="377.282" width="62.018" height="9.968" style="stroke: rgb(0, 0, 0); fill: rgb(120, 231, 228); stroke-width: 0.763;"/>
|
||||
<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;">
|
||||
@@ -179,17 +177,17 @@
|
||||
<rect x="43.443" y="280.75" width="82.691" height="27.103" style="stroke: rgb(0, 0, 0); fill: rgb(120, 231, 228); stroke-width: 0.763;"/>
|
||||
<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>
|
||||
@@ -207,15 +205,15 @@
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="183.349" y="288.807" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">HTD</text>
|
||||
<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);"/>
|
||||
@@ -229,33 +227,12 @@
|
||||
<rect x="870.356" y="344.9" width="82.691" height="42.35" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(244, 248, 248);"/>
|
||||
<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="336.418" cy="237.483" rx="13.582" ry="12.517"/>
|
||||
<ellipse style="fill: rgb(216, 216, 216); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="334.165" cy="232.104" rx="13.582" ry="12.517"/>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Bahnschrift; font-size: 14px; stroke-width: 1; font-weight: bold;" x="379.214" y="423.395" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">DRYING</text>
|
||||
<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"/>
|
||||
<ellipse style="fill: rgb(216, 216, 216); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="335.623" cy="320.662" rx="13.582" ry="12.517"/>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Bahnschrift; font-size: 14px; stroke-width: 1; font-weight: bold;" x="897.237" y="299.014" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">REGEN</text>
|
||||
<ellipse style="fill: rgb(255, 204, 63); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="761.772" cy="233.876" rx="13.582" ry="12.517"/>
|
||||
<ellipse style="fill: rgb(216, 216, 216); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="761.502" cy="233.796" rx="13.582" ry="12.517"/>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Bahnschrift; font-size: 14px; stroke-width: 1; font-weight: bold;" x="896" y="425.818" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">DRYING</text>
|
||||
<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: 41 KiB After Width: | Height: | Size: 40 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="440.159" style="fill: rgb(216, 216, 216); stroke: rgb(0, 0, 0); stroke-width: 0.763;" x="12.119"/>
|
||||
<rect y="10.407" width="972.648" height="439.023" style="fill: rgb(216, 216, 216); stroke: rgb(0, 0, 0); stroke-width: 0.763;" x="12.119"/>
|
||||
<g transform="matrix(0.826913, 0, 0, 0.698383, 74.03907, 53.375034)">
|
||||
<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,26 +26,27 @@
|
||||
<path style="fill: none; stroke: rgb(76, 76, 76); stroke-width: 1.222; transform-origin: 490.992px 230.229px;" d="M 646.097 240.002 L 676.271 240.002"/>
|
||||
</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>
|
||||
<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 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="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="698.476" y="373.93" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">°C</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="653.279" y="373.97" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">####.##</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="698.476" y="373.93" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">°F</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1; font-weight: bold;" x="748" y="347.676" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">HEATER</text>
|
||||
<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"/>
|
||||
@@ -107,10 +108,12 @@
|
||||
<rect x="427.269" y="377.282" width="62.018" height="9.968" style="stroke: rgb(0, 0, 0); fill: rgb(120, 231, 228); stroke-width: 0.763;"/>
|
||||
<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;">
|
||||
@@ -174,14 +177,17 @@
|
||||
<rect x="43.443" y="280.75" width="82.691" height="27.103" style="stroke: rgb(0, 0, 0); fill: rgb(120, 231, 228); stroke-width: 0.763;"/>
|
||||
<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>
|
||||
@@ -199,12 +205,15 @@
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="183.349" y="288.807" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">HTD</text>
|
||||
<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);"/>
|
||||
@@ -218,33 +227,12 @@
|
||||
<rect x="870.356" y="344.9" width="82.691" height="42.35" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(244, 248, 248);"/>
|
||||
<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="336.418" cy="237.483" rx="13.582" ry="12.517"/>
|
||||
<ellipse style="fill: rgb(216, 216, 216); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="334.165" cy="232.104" rx="13.582" ry="12.517"/>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Bahnschrift; font-size: 14px; stroke-width: 1; font-weight: bold;" x="379.214" y="423.395" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">DRYING</text>
|
||||
<ellipse style="fill: rgb(255, 204, 63); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="335.418" cy="321.016" rx="13.582" ry="12.517"/>
|
||||
<ellipse style="fill: rgb(216, 216, 216); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="335.623" cy="320.662" rx="13.582" ry="12.517"/>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Bahnschrift; font-size: 14px; stroke-width: 1; font-weight: bold;" x="897.237" y="299.014" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">REGEN</text>
|
||||
<ellipse style="fill: rgb(255, 204, 63); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="761.772" cy="233.876" rx="13.582" ry="12.517"/>
|
||||
<ellipse style="fill: rgb(216, 216, 216); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="761.502" cy="233.796" rx="13.582" ry="12.517"/>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Bahnschrift; font-size: 14px; stroke-width: 1; font-weight: bold;" x="896" y="425.818" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">DRYING</text>
|
||||
<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: 41 KiB After Width: | Height: | Size: 40 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="440.159" style="fill: rgb(216, 216, 216); stroke: rgb(0, 0, 0); stroke-width: 0.763;" x="12.119"/>
|
||||
<rect y="10.407" width="972.648" height="439.023" style="fill: rgb(216, 216, 216); stroke: rgb(0, 0, 0); stroke-width: 0.763;" x="12.119"/>
|
||||
<g transform="matrix(0.826913, 0, 0, 0.698383, 74.03907, 53.375034)">
|
||||
<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,26 +26,27 @@
|
||||
<path style="fill: none; stroke: rgb(76, 76, 76); stroke-width: 1.222; transform-origin: 490.992px 230.229px;" d="M 646.097 240.002 L 676.271 240.002"/>
|
||||
</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>
|
||||
<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 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="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="698.476" y="373.93" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">°C</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="653.279" y="373.97" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">####.##</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1;" x="698.476" y="373.93" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">°F</text>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 12px; stroke-width: 1; font-weight: bold;" x="748" y="347.676" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">HEATER</text>
|
||||
<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"/>
|
||||
@@ -107,10 +108,12 @@
|
||||
<rect x="427.269" y="377.282" width="62.018" height="9.968" style="stroke: rgb(0, 0, 0); fill: rgb(120, 231, 228); stroke-width: 0.763;"/>
|
||||
<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;">
|
||||
@@ -174,14 +177,17 @@
|
||||
<rect x="43.443" y="280.75" width="82.691" height="27.103" style="stroke: rgb(0, 0, 0); fill: rgb(120, 231, 228); stroke-width: 0.763;"/>
|
||||
<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>
|
||||
@@ -199,12 +205,15 @@
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 15px; stroke-width: 1;" x="183.349" y="288.807" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">HTD</text>
|
||||
<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);"/>
|
||||
@@ -218,34 +227,12 @@
|
||||
<rect x="870.356" y="344.9" width="82.691" height="42.35" style="stroke: rgb(0, 0, 0); stroke-width: 0.763; fill: rgb(244, 248, 248);"/>
|
||||
<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="336.418" cy="237.483" rx="13.582" ry="12.517"/>
|
||||
<ellipse style="fill: rgb(216, 216, 216); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="334.165" cy="232.104" rx="13.582" ry="12.517"/>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Bahnschrift; font-size: 14px; stroke-width: 1; font-weight: bold;" x="379.214" y="423.395" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">DRYING</text>
|
||||
<ellipse style="fill: rgb(255, 204, 63); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="335.418" cy="321.016" rx="13.582" ry="12.517"/>
|
||||
|
||||
<ellipse style="fill: rgb(216, 216, 216); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="335.623" cy="320.662" rx="13.582" ry="12.517"/>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Bahnschrift; font-size: 14px; stroke-width: 1; font-weight: bold;" x="897.237" y="299.014" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">REGEN</text>
|
||||
<ellipse style="fill: rgb(255, 204, 63); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="761.772" cy="233.876" rx="13.582" ry="12.517"/>
|
||||
<ellipse style="fill: rgb(216, 216, 216); stroke: rgb(0, 0, 0); stroke-width: 0.763;" cx="761.502" cy="233.796" rx="13.582" ry="12.517"/>
|
||||
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Bahnschrift; font-size: 14px; stroke-width: 1; font-weight: bold;" x="896" y="425.818" transform="matrix(0.826913, 0, 0, 0.698383, 2.097643, 3.138935)">DRYING</text>
|
||||
<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: 41 KiB After Width: | Height: | Size: 40 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" 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="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="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" 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="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="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" 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="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="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,35 +70,24 @@ async function ApiRequest({ method = 'GET', params = {}, prefix = '/', token = t
|
||||
},
|
||||
};
|
||||
|
||||
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}`);
|
||||
}
|
||||
|
||||
const rawToken = localStorage.getItem('token');
|
||||
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;
|
||||
@@ -143,10 +132,17 @@ 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 {
|
||||
|
||||
@@ -85,7 +85,7 @@ const CardList = ({
|
||||
<React.Fragment key={index}>
|
||||
{!itemCard.hidden &&
|
||||
itemCard.title !== 'No' &&
|
||||
itemCard.title !== 'Action' && (
|
||||
itemCard.title !== 'Aksi' && (
|
||||
<p style={{ margin: '8px 0' }}>
|
||||
<Text strong>{itemCard.title}:</Text>{' '}
|
||||
{itemCard.render
|
||||
|
||||
@@ -2,16 +2,7 @@
|
||||
import mqtt from 'mqtt';
|
||||
|
||||
const mqttUrl = `${import.meta.env.VITE_MQTT_SERVER ?? 'ws://localhost:1884'}`;
|
||||
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 topics = ['PIU_GGCP/Devices/PB'];
|
||||
const options = {
|
||||
keepalive: 30,
|
||||
clientId: 'react_mqtt_' + Math.random().toString(16).substr(2, 8),
|
||||
@@ -75,8 +66,7 @@ const listenMessage = (callback) => {
|
||||
|
||||
const setValSvg = (listenTopic, svg) => {
|
||||
client.on('message', (topic, message) => {
|
||||
// console.log(topic ,' = ', listenTopic);
|
||||
if (topic === listenTopic) {
|
||||
if (topic == listenTopic) {
|
||||
const objChanel = JSON.parse(message);
|
||||
|
||||
Object.entries(objChanel).forEach(([key, value]) => {
|
||||
@@ -88,7 +78,7 @@ const setValSvg = (listenTopic, svg) => {
|
||||
} else if (value === false) {
|
||||
el.style.display = 'none';
|
||||
} else if (!isNaN(value)) {
|
||||
el.textContent = Number(value ?? 0.0).toFixed(2);
|
||||
el.textContent = Number(value ?? 0.0);
|
||||
} else {
|
||||
el.textContent = value;
|
||||
}
|
||||
|
||||
@@ -20,9 +20,6 @@ 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);
|
||||
|
||||
@@ -106,14 +103,7 @@ const TableList = memo(function TableList({
|
||||
setColumnsDynamic([...defaultColumns, ...numericColumns]);
|
||||
}
|
||||
|
||||
const fetchedData = resData?.data ?? [];
|
||||
|
||||
// Panggil callback jika disediakan
|
||||
if (onGetData && typeof onGetData === 'function') {
|
||||
onGetData(fetchedData);
|
||||
}
|
||||
|
||||
setData(fetchedData);
|
||||
setData(resData?.data ?? []);
|
||||
|
||||
const pagingData = resData?.paging;
|
||||
|
||||
@@ -152,9 +142,6 @@ 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
|
||||
@@ -166,7 +153,7 @@ const TableList = memo(function TableList({
|
||||
onChange={setViewMode}
|
||||
/>
|
||||
{(isMobile && mobile) || viewMode === 'card' ? (
|
||||
<CardViewComponent
|
||||
<CardList
|
||||
cardColor={cardColor}
|
||||
fieldColor={fieldColor}
|
||||
data={data}
|
||||
@@ -175,7 +162,6 @@ const TableList = memo(function TableList({
|
||||
showPreviewModal={showPreviewModal}
|
||||
showEditModal={showEditModal}
|
||||
showDeleteDialog={showDeleteDialog}
|
||||
onStockUpdate={onStockUpdate}
|
||||
/>
|
||||
) : (
|
||||
<Row gutter={24} style={{ marginTop: '16px' }}>
|
||||
@@ -214,4 +200,3 @@ const TableList = memo(function TableList({
|
||||
});
|
||||
|
||||
export default TableList;
|
||||
|
||||
|
||||
@@ -20,65 +20,36 @@ html body {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
/* Custom green Sidebar Menu Styles */
|
||||
.custom-green-menu.ant-menu-dark .ant-menu-item-selected {
|
||||
/* Custom Orange Sidebar Menu Styles */
|
||||
.custom-orange-menu.ant-menu-dark .ant-menu-item-selected {
|
||||
background-color: rgba(255, 255, 255, 0.2) !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.custom-green-menu.ant-menu-dark .ant-menu-item-selected::after {
|
||||
.custom-orange-menu.ant-menu-dark .ant-menu-item-selected::after {
|
||||
border-right-color: white !important;
|
||||
}
|
||||
|
||||
.custom-green-menu.ant-menu-dark .ant-menu-item:hover,
|
||||
.custom-green-menu.ant-menu-dark .ant-menu-submenu-title:hover {
|
||||
.custom-orange-menu.ant-menu-dark .ant-menu-item:hover,
|
||||
.custom-orange-menu.ant-menu-dark .ant-menu-submenu-title:hover {
|
||||
background-color: rgba(255, 255, 255, 0.15) !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.custom-green-menu.ant-menu-dark .ant-menu-submenu-selected > .ant-menu-submenu-title {
|
||||
.custom-orange-menu.ant-menu-dark .ant-menu-submenu-selected > .ant-menu-submenu-title {
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.custom-green-menu.ant-menu-dark.ant-menu-inline .ant-menu-sub {
|
||||
.custom-orange-menu.ant-menu-dark.ant-menu-inline .ant-menu-sub {
|
||||
background: rgba(0, 0, 0, 0.2) !important;
|
||||
}
|
||||
|
||||
.custom-green-menu.ant-menu-dark .ant-menu-item,
|
||||
.custom-green-menu.ant-menu-dark .ant-menu-submenu-title {
|
||||
.custom-orange-menu.ant-menu-dark .ant-menu-item,
|
||||
.custom-orange-menu.ant-menu-dark .ant-menu-submenu-title {
|
||||
color: rgba(255, 255, 255, 0.9) !important;
|
||||
}
|
||||
|
||||
.custom-green-menu.ant-menu-dark .ant-menu-item-active,
|
||||
.custom-green-menu.ant-menu-dark .ant-menu-submenu-active > .ant-menu-submenu-title {
|
||||
.custom-orange-menu.ant-menu-dark .ant-menu-item-active,
|
||||
.custom-orange-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 */
|
||||
@@ -32,7 +32,6 @@ import {
|
||||
SlidersOutlined,
|
||||
SnippetsOutlined,
|
||||
ContactsOutlined,
|
||||
ToolOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
const { Text } = Typography;
|
||||
@@ -143,11 +142,6 @@ const allItems = [
|
||||
icon: <SafetyOutlined style={{ fontSize: '19px' }} />,
|
||||
label: <Link to="/master/status">Status</Link>,
|
||||
},
|
||||
{
|
||||
key: 'master-sparepart',
|
||||
icon: <ToolOutlined style={{ fontSize: '19px' }} />,
|
||||
label: <Link to="/master/sparepart">Sparepart</Link>,
|
||||
},
|
||||
// {
|
||||
// key: 'master-shift',
|
||||
// icon: <ClockCircleOutlined style={{ fontSize: '19px' }} />,
|
||||
@@ -265,7 +259,6 @@ const LayoutMenu = () => {
|
||||
unit: 'master-unit',
|
||||
tag: 'master-tag',
|
||||
status: 'master-status',
|
||||
sparepart: 'master-sparepart',
|
||||
shift: 'master-shift',
|
||||
};
|
||||
return masterKeyMap[subPath] || `master-${subPath}`;
|
||||
@@ -422,7 +415,7 @@ const LayoutMenu = () => {
|
||||
border: 'none',
|
||||
}}
|
||||
theme="dark"
|
||||
className="custom-green-menu"
|
||||
className="custom-orange-menu"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -30,24 +30,8 @@ const LayoutSidebar = () => {
|
||||
zIndex: 9999
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import { useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { verifyRedirect } from '../../api/auth';
|
||||
import { encryptData } from '../../components/Global/Formatter';
|
||||
import NotFound from './NotFound';
|
||||
import Waiting from './Waiting';
|
||||
import NotificationDetailTab from '../notificationDetail/IndexNotificationDetail';
|
||||
|
||||
export default function RedirectWa() {
|
||||
const [idData, setIdData] = useState(0);
|
||||
const [ready, setReady] = useState(0);
|
||||
|
||||
const location = useLocation();
|
||||
|
||||
// URLSearchParams untuk ambil query
|
||||
const queryParams = new URLSearchParams(location.search);
|
||||
const token = queryParams.get('token');
|
||||
|
||||
const handleInitForm = async (encodedToken) => {
|
||||
const originalToken = decodeURIComponent(encodedToken);
|
||||
// console.log(originalToken);
|
||||
|
||||
const response = await verifyRedirect({
|
||||
tokenRedirect: originalToken,
|
||||
});
|
||||
|
||||
console.log('tes', response);
|
||||
|
||||
const tokenResult = JSON.stringify(response.data?.data?.accessToken);
|
||||
|
||||
sessionStorage.setItem('token_redirect', tokenResult);
|
||||
response.data.auth = true;
|
||||
sessionStorage.setItem('session', encryptData(response?.data));
|
||||
|
||||
setIdData(response.data.data.idData);
|
||||
|
||||
setReady(1);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
handleInitForm(token);
|
||||
}, [idData]);
|
||||
|
||||
if (ready == 0) return <Waiting />;
|
||||
|
||||
if (idData === 0) return <NotFound />;
|
||||
|
||||
return <NotificationDetailTab id={idData} />;
|
||||
}
|
||||
@@ -14,21 +14,34 @@ const DetailContact = memo(function DetailContact(props) {
|
||||
name: '',
|
||||
phone: '',
|
||||
is_active: true,
|
||||
contact_type: 'operator',
|
||||
};
|
||||
|
||||
const [formData, setFormData] = useState(defaultData);
|
||||
|
||||
const handleInputChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
let name, value;
|
||||
|
||||
if (e && e.target) {
|
||||
name = e.target.name;
|
||||
value = e.target.value;
|
||||
} else if (e && e.type === 'change') {
|
||||
name = e.name || e.target?.name;
|
||||
value = e.value !== undefined ? e.value : e.checked;
|
||||
} else if (typeof e === 'string' || typeof e === 'number') {
|
||||
// Handle Select onChange
|
||||
value = e;
|
||||
name = 'contact_type';
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
// Validasi untuk field phone - hanya angka yang diperbolehkan
|
||||
if (name === 'phone') {
|
||||
const cleanedValue = value.replace(/[^0-9+\-\s()]/g, '');
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[name]: cleanedValue,
|
||||
}));
|
||||
} else {
|
||||
value = value.replace(/[^0-9+\-\s()]/g, '');
|
||||
}
|
||||
|
||||
if (name) {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[name]: value,
|
||||
@@ -36,7 +49,6 @@ const DetailContact = memo(function DetailContact(props) {
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const handleStatusToggle = (checked) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
@@ -47,20 +59,6 @@ const DetailContact = memo(function DetailContact(props) {
|
||||
const handleSave = async () => {
|
||||
setConfirmLoading(true);
|
||||
|
||||
// Validation rules
|
||||
const validationRules = [
|
||||
{ field: 'name', label: 'Contact Name', required: true },
|
||||
{ field: 'phone', label: 'Phone', required: true },
|
||||
];
|
||||
|
||||
if (
|
||||
validateRun(formData, validationRules, (errorMessages) => {
|
||||
NotifOk({ icon: 'warning', title: 'Peringatan', message: errorMessages });
|
||||
setConfirmLoading(false);
|
||||
})
|
||||
)
|
||||
return;
|
||||
|
||||
// Custom validation untuk name - minimal 3 karakter
|
||||
if (formData.name && formData.name.length < 3) {
|
||||
NotifOk({
|
||||
@@ -84,19 +82,32 @@ const DetailContact = memo(function DetailContact(props) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Validation rules
|
||||
const validationRules = [
|
||||
{ field: 'name', label: 'Contact Name', required: true },
|
||||
{ field: 'phone', label: 'Phone', required: true },
|
||||
{ field: 'contact_type', label: 'Contact Type', required: true },
|
||||
];
|
||||
|
||||
if (
|
||||
validateRun(formData, validationRules, (errorMessages) => {
|
||||
NotifOk({ icon: 'warning', title: 'Peringatan', message: errorMessages });
|
||||
setConfirmLoading(false);
|
||||
})
|
||||
)
|
||||
return;
|
||||
|
||||
try {
|
||||
const contactData = {
|
||||
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;
|
||||
if (props.actionMode === 'edit') {
|
||||
response = await updateContact(
|
||||
props.selectedData.contact_id || props.selectedData.id,
|
||||
contactData
|
||||
);
|
||||
response = await updateContact(props.selectedData.contact_id || props.selectedData.id, contactData);
|
||||
} else {
|
||||
response = await createContact(contactData);
|
||||
}
|
||||
@@ -104,7 +115,7 @@ const DetailContact = memo(function DetailContact(props) {
|
||||
NotifAlert({
|
||||
icon: 'success',
|
||||
title: 'Berhasil',
|
||||
message: `Data Contact "${formData.name}" berhasil ${
|
||||
message: `Data Contact berhasil ${
|
||||
props.actionMode === 'add' ? 'ditambahkan' : 'diperbarui'
|
||||
}.`,
|
||||
});
|
||||
@@ -134,18 +145,19 @@ const DetailContact = memo(function DetailContact(props) {
|
||||
setFormData({
|
||||
name: props.selectedData.contact_name || props.selectedData.name,
|
||||
phone: props.selectedData.contact_phone || props.selectedData.phone,
|
||||
is_active:
|
||||
props.selectedData.is_active || props.selectedData.status === 'active',
|
||||
is_active: props.selectedData.is_active || props.selectedData.status === 'active',
|
||||
contact_type: props.selectedData.contact_type || props.contactType || 'operator',
|
||||
});
|
||||
} else if (props.actionMode === 'add') {
|
||||
setFormData({
|
||||
name: '',
|
||||
phone: '',
|
||||
is_active: true,
|
||||
contact_type: props.contactType === 'all' ? 'operator' : props.contactType || 'operator',
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [props.showModal, props.actionMode, props.selectedData]);
|
||||
}, [props.showModal, props.actionMode, props.selectedData, props.contactType]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -194,23 +206,16 @@ const DetailContact = memo(function DetailContact(props) {
|
||||
]}
|
||||
>
|
||||
<div style={{ padding: '8px 0' }}>
|
||||
{/* Status field only show in add mode*/}
|
||||
{props.actionMode === 'add' && (
|
||||
<>
|
||||
<div>
|
||||
<div>
|
||||
<Text strong>Status</Text>
|
||||
</div>
|
||||
<div
|
||||
style={{ display: 'flex', alignItems: 'center', marginTop: '8px' }}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', marginTop: '8px' }}>
|
||||
<div style={{ marginRight: '8px' }}>
|
||||
<Switch
|
||||
disabled={props.readOnly}
|
||||
style={{
|
||||
backgroundColor: formData.is_active
|
||||
? '#23A55A'
|
||||
: '#bfbfbf',
|
||||
backgroundColor: formData.is_active ? '#23A55A' : '#bfbfbf',
|
||||
}}
|
||||
checked={formData.is_active}
|
||||
onChange={handleStatusToggle}
|
||||
@@ -222,8 +227,6 @@ const DetailContact = memo(function DetailContact(props) {
|
||||
</div>
|
||||
</div>
|
||||
<Divider style={{ margin: '12px 0' }} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<Text strong>Name</Text>
|
||||
@@ -249,13 +252,12 @@ const DetailContact = memo(function DetailContact(props) {
|
||||
style={{ color: formData.is_active ? '#000000' : '#ff4d4f' }}
|
||||
/>
|
||||
</div>
|
||||
{/* Contact Type */}
|
||||
{/* <div style={{ marginBottom: 12 }}>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<Text strong>Contact Type</Text>
|
||||
<Text style={{ color: 'red' }}> *</Text>
|
||||
<Select
|
||||
value={formData.contact_type || undefined}
|
||||
onChange={handleContactTypeChange}
|
||||
value={formData.contact_type}
|
||||
onChange={handleInputChange}
|
||||
placeholder="Select Contact Type"
|
||||
disabled={props.readOnly}
|
||||
style={{ width: '100%' }}
|
||||
@@ -263,7 +265,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, Switch } from 'antd';
|
||||
import { Button, Row, Col, Input, Tabs, Space, ConfigProvider, Card, Tag } from 'antd';
|
||||
import {
|
||||
PlusOutlined,
|
||||
EditOutlined,
|
||||
@@ -10,43 +10,9 @@ import {
|
||||
} from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { NotifAlert, NotifConfirmDialog } from '../../../components/Global/ToastNotif';
|
||||
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',
|
||||
});
|
||||
}
|
||||
};
|
||||
import { getAllContact, deleteContact } from '../../../api/contact';
|
||||
|
||||
const ContactCard = memo(function ContactCard({ contact, showEditModal, showDeleteModal }) {
|
||||
return (
|
||||
<Col xs={24} sm={12} md={8} lg={6}>
|
||||
<div
|
||||
@@ -78,7 +44,7 @@ const ContactCard = memo(function ContactCard({
|
||||
}}
|
||||
>
|
||||
{/* 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'
|
||||
@@ -91,37 +57,19 @@ const ContactCard = memo(function ContactCard({
|
||||
>
|
||||
{contact.contact_type === 'operator' ? 'Operator' : contact.contact_type === 'gudang' ? 'Gudang' : 'Unknown'}
|
||||
</Tag>
|
||||
</div> */}
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
@@ -174,7 +122,7 @@ const ContactCard = memo(function ContactCard({
|
||||
<PhoneOutlined style={{ marginRight: 6, color: '#1890ff' }} />
|
||||
<span
|
||||
style={{
|
||||
color: contact.status === 'active' ? '#262626' : '#262626',
|
||||
color: contact.status === 'active' ? '#52c41a' : '#ff4d4f',
|
||||
}}
|
||||
>
|
||||
{contact.contact_phone || contact.phone}
|
||||
@@ -194,12 +142,10 @@ const ContactCard = memo(function ContactCard({
|
||||
>
|
||||
<Space>
|
||||
<Button
|
||||
type="default"
|
||||
type="text"
|
||||
size="small"
|
||||
style={{
|
||||
backgroundColor: '#fff7e6',
|
||||
borderColor: '#faad14',
|
||||
color: '#faad14',
|
||||
padding: '2px 6px',
|
||||
fontSize: '11px',
|
||||
height: '24px',
|
||||
@@ -215,11 +161,10 @@ const ContactCard = memo(function ContactCard({
|
||||
Edit info
|
||||
</Button>
|
||||
<Button
|
||||
type="default"
|
||||
type="text"
|
||||
danger
|
||||
size="small"
|
||||
style={{
|
||||
backgroundColor: '#fff1f0',
|
||||
borderColor: 'red',
|
||||
padding: '2px 6px',
|
||||
fontSize: '11px',
|
||||
@@ -267,6 +212,9 @@ const ListContact = memo(function ListContact(props) {
|
||||
}
|
||||
}
|
||||
|
||||
// Backend doesn't support is_active filter or order parameter
|
||||
// Contact hanya supports: criteria, name, code, limit, page
|
||||
|
||||
const queryParams = new URLSearchParams();
|
||||
Object.entries(searchParams).forEach(([key, value]) => {
|
||||
if (value !== '' && value !== null && value !== undefined) {
|
||||
@@ -306,10 +254,11 @@ const ListContact = memo(function ListContact(props) {
|
||||
// Listen for saved contact data
|
||||
useEffect(() => {
|
||||
if (props.lastSavedContact) {
|
||||
fetchContacts();
|
||||
fetchContacts(); // Refetch all contacts when data is saved
|
||||
}
|
||||
}, [props.lastSavedContact]);
|
||||
|
||||
// Get contacts (already filtered by backend)
|
||||
const getFilteredContacts = () => {
|
||||
return filteredContacts;
|
||||
};
|
||||
@@ -322,7 +271,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);
|
||||
};
|
||||
|
||||
@@ -364,7 +313,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..."
|
||||
placeholder="Search by name or type..."
|
||||
value={formDataFilter.criteria}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
@@ -430,8 +379,7 @@ const ListContact = memo(function ListContact(props) {
|
||||
marginBottom: '16px',
|
||||
}}
|
||||
>
|
||||
{/* Tabs */}
|
||||
{/* <Tabs
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={setActiveTab}
|
||||
size="large"
|
||||
@@ -449,7 +397,7 @@ const ListContact = memo(function ListContact(props) {
|
||||
label: 'Gudang',
|
||||
},
|
||||
]}
|
||||
/> */}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{getFilteredContacts().length === 0 ? (
|
||||
@@ -472,7 +420,6 @@ const ListContact = memo(function ListContact(props) {
|
||||
}}
|
||||
showEditModal={showEditModal}
|
||||
showDeleteModal={showDeleteModal}
|
||||
onStatusToggle={fetchContacts}
|
||||
/>
|
||||
))}
|
||||
</Row>
|
||||
|
||||
@@ -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_COD/AIR_DRYER/AIR_DRYER_A';
|
||||
const topicMqtt = 'PIU_GGCP/Devices/PB';
|
||||
|
||||
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_COD/AIR_DRYER/AIR_DRYER_B';
|
||||
const topicMqtt = 'PIU_GGCP/Devices/PB';
|
||||
|
||||
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_COD/AIR_DRYER/AIR_DRYER_C';
|
||||
const topicMqtt = 'PIU_GGCP/Devices/PB';
|
||||
|
||||
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_COD/COMPRESSOR/COMPRESSOR_A';
|
||||
const topicMqtt = 'PIU_GGCP/Devices/PB';
|
||||
|
||||
const SvgCompressorA = () => {
|
||||
return (
|
||||
|
||||
@@ -6,7 +6,9 @@ import SvgViewer from './SvgViewer';
|
||||
import filePathSvg from '../../assets/svg/compressorB_rev.svg';
|
||||
|
||||
const { Text } = Typography;
|
||||
const topicMqtt = 'PIU_COD/COMPRESSOR/COMPRESSOR_B';
|
||||
|
||||
// const filePathSvg = '/src/assets/svg/test-new.svg';
|
||||
const topicMqtt = 'PIU_GGCP/Devices/PB';
|
||||
|
||||
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_COD/COMPRESSOR/COMPRESSOR_C';
|
||||
const topicMqtt = 'PIU_GGCP/Devices/PB';
|
||||
|
||||
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_COD/AIR_DRYER/OVERVIEW';
|
||||
const topicMqtt = 'PIU_GGCP/Devices/PB';
|
||||
|
||||
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_COD/COMPRESSOR/OVERVIEW';
|
||||
const topicMqtt = 'PIU_GGCP/Devices/PB';
|
||||
|
||||
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 ? (
|
||||
|
||||
@@ -26,7 +26,17 @@ 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;
|
||||
@@ -35,6 +45,7 @@ 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');
|
||||
@@ -44,6 +55,13 @@ 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'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,9 +95,12 @@ 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 {
|
||||
@@ -89,6 +110,7 @@ const ViewFilePage = () => {
|
||||
|
||||
setLoading(false);
|
||||
} catch (error) {
|
||||
console.error('Error fetching data:', error);
|
||||
setError('Failed to load data');
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -138,6 +160,12 @@ 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
|
||||
@@ -168,7 +196,9 @@ 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' }}>
|
||||
@@ -310,14 +340,17 @@ 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);
|
||||
})
|
||||
@@ -412,7 +445,7 @@ const ViewFilePage = () => {
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
|
||||
{/* File type indicator */}
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<div style={{
|
||||
display: 'inline-block',
|
||||
@@ -429,7 +462,7 @@ const ViewFilePage = () => {
|
||||
</div>
|
||||
|
||||
<div style={{ position: 'relative' }}>
|
||||
|
||||
{/* Overlay with blur effect during loading */}
|
||||
{loading && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
|
||||
@@ -3,43 +3,21 @@ import { Form, Input, Row, Col, Typography, Switch } from 'antd';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
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]);
|
||||
const BrandForm = ({ form, formData, onValuesChange, isEdit = false }) => {
|
||||
const isActive = Form.useWatch('is_active', form) ?? formData.is_active ?? true;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Form
|
||||
layout="vertical"
|
||||
form={form}
|
||||
onValuesChange={onValuesChange}
|
||||
initialValues={{
|
||||
brand_name: '',
|
||||
brand_type: '',
|
||||
brand_model: '',
|
||||
brand_manufacture: '',
|
||||
is_active: true,
|
||||
}}
|
||||
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' }}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Text style={{ marginLeft: 8 }}>
|
||||
@@ -50,6 +28,7 @@ const BrandForm = ({
|
||||
|
||||
<Form.Item label="Brand Code" name="brand_code">
|
||||
<Input
|
||||
placeholder={'Auto Fill Brand Code'}
|
||||
disabled={true}
|
||||
style={{
|
||||
backgroundColor: '#f5f5f5',
|
||||
@@ -63,18 +42,18 @@ const BrandForm = ({
|
||||
<Form.Item
|
||||
label="Brand Name"
|
||||
name="brand_name"
|
||||
rules={[{ required: !readOnly, message: 'Brand Name wajib diisi!' }]}
|
||||
rules={[{ required: true, message: 'Brand Name wajib diisi!' }]}
|
||||
>
|
||||
<Input disabled={readOnly} />
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
label="Manufacturer"
|
||||
name="brand_manufacture"
|
||||
rules={[{ required: !readOnly, message: 'Manufacturer wajib diisi!' }]}
|
||||
rules={[{ required: true, message: 'Manufacturer wajib diisi!' }]}
|
||||
>
|
||||
<Input placeholder="Enter Manufacturer" disabled={readOnly} />
|
||||
<Input placeholder="Enter Manufacturer" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
@@ -82,17 +61,16 @@ const BrandForm = ({
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item label="Brand Type" name="brand_type">
|
||||
<Input placeholder="Enter Brand Type (Optional)" disabled={readOnly} />
|
||||
<Input placeholder="Enter Brand Type (Optional)" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="Model" name="brand_model">
|
||||
<Input placeholder="Enter Model (Optional)" disabled={readOnly} />
|
||||
<Input placeholder="Enter Model (Optional)" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,397 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Card, Typography, Tag, Button, Modal, Row, Col, Space } from 'antd';
|
||||
import { EyeOutlined, DeleteOutlined, CheckOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
const CustomSparepartCard = ({
|
||||
sparepart,
|
||||
isSelected = false,
|
||||
isReadOnly = false,
|
||||
showPreview = true,
|
||||
showDelete = false,
|
||||
onPreview,
|
||||
onDelete,
|
||||
onCardClick,
|
||||
loading = false,
|
||||
size = 'small',
|
||||
style = {},
|
||||
}) => {
|
||||
const [previewModalVisible, setPreviewModalVisible] = useState(false);
|
||||
|
||||
const getImageSrc = () => {
|
||||
if (sparepart.sparepart_foto) {
|
||||
if (sparepart.sparepart_foto.startsWith('http')) {
|
||||
return sparepart.sparepart_foto;
|
||||
} else {
|
||||
const fileName = sparepart.sparepart_foto.split('/').pop();
|
||||
if (fileName === 'defaultSparepartImg.jpg') {
|
||||
return `/assets/defaultSparepartImg.jpg`;
|
||||
} else {
|
||||
const token = localStorage.getItem('token');
|
||||
const baseURL = import.meta.env.VITE_API_SERVER || '';
|
||||
return `${baseURL}/file-uploads/images/${encodeURIComponent(fileName)}${token ? `?token=${encodeURIComponent(token)}` : ''}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
return 'https://via.placeholder.com/150';
|
||||
};
|
||||
|
||||
const handlePreview = () => {
|
||||
if (onPreview) {
|
||||
onPreview(sparepart);
|
||||
} else {
|
||||
setPreviewModalVisible(true);
|
||||
}
|
||||
};
|
||||
|
||||
const truncateText = (text, maxLength = 15) => {
|
||||
if (!text) return 'Unnamed';
|
||||
return text.length > maxLength ? `${text.substring(0, maxLength)}...` : text;
|
||||
};
|
||||
|
||||
const handleCardClick = () => {
|
||||
if (!isReadOnly && onCardClick) {
|
||||
onCardClick(sparepart);
|
||||
}
|
||||
};
|
||||
|
||||
const getCardActions = () => {
|
||||
const actions = [];
|
||||
|
||||
if (showPreview) {
|
||||
actions.push(
|
||||
<Button
|
||||
key="preview"
|
||||
type="text"
|
||||
icon={<EyeOutlined />}
|
||||
title="Lihat Detail"
|
||||
style={{ color: '#1890ff' }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handlePreview();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (showDelete && !isReadOnly) {
|
||||
actions.push(
|
||||
<Button
|
||||
key="delete"
|
||||
type="text"
|
||||
icon={<DeleteOutlined />}
|
||||
title="Hapus"
|
||||
style={{ color: '#ff4d4f' }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete?.(sparepart);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return actions;
|
||||
};
|
||||
|
||||
const getCardStyle = () => {
|
||||
const baseStyle = {
|
||||
borderRadius: '12px',
|
||||
overflow: 'hidden',
|
||||
border: isSelected ? '2px solid #1890ff' : '1px solid #E0E0E0',
|
||||
cursor: isReadOnly ? 'default' : 'pointer',
|
||||
position: 'relative',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.06)',
|
||||
transition: 'all 0.3s ease'
|
||||
};
|
||||
|
||||
switch (size) {
|
||||
case 'small':
|
||||
return {
|
||||
...baseStyle,
|
||||
height: '180px',
|
||||
minHeight: '180px'
|
||||
};
|
||||
case 'large':
|
||||
return {
|
||||
...baseStyle,
|
||||
height: '280px',
|
||||
minHeight: '280px'
|
||||
};
|
||||
default:
|
||||
return {
|
||||
...baseStyle,
|
||||
height: '220px',
|
||||
minHeight: '220px'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid #f0f0f0',
|
||||
borderRadius: '6px',
|
||||
padding: '12px 16px',
|
||||
marginBottom: '8px',
|
||||
backgroundColor: 'white',
|
||||
cursor: onCardClick && !isReadOnly ? 'pointer' : 'default',
|
||||
transition: 'all 0.2s ease',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between'
|
||||
}}
|
||||
onClick={handleCardClick}
|
||||
>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', marginBottom: '4px' }}>
|
||||
<Text
|
||||
strong
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
color: '#262626',
|
||||
marginRight: '12px'
|
||||
}}
|
||||
title={sparepart.sparepart_name || sparepart.name || 'Unnamed'}
|
||||
>
|
||||
{truncateText(sparepart.sparepart_name || sparepart.name || 'Unnamed')}
|
||||
</Text>
|
||||
<Tag
|
||||
color={sparepart.sparepart_stok === 'Available' ? 'green' : 'red'}
|
||||
style={{ fontSize: '11px', margin: 0 }}
|
||||
>
|
||||
{sparepart.sparepart_stok || 'Not Available'}
|
||||
</Tag>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Text style={{ fontSize: '12px', color: '#666', marginRight: '4px' }}>
|
||||
qty:
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
color: '#262626'
|
||||
}}
|
||||
>
|
||||
{sparepart.sparepart_qty || 0}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Space size="small">
|
||||
{showPreview && (
|
||||
<Button
|
||||
type="text"
|
||||
icon={<EyeOutlined />}
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handlePreview();
|
||||
}}
|
||||
title="Preview"
|
||||
/>
|
||||
)}
|
||||
{showDelete && !isReadOnly && (
|
||||
<Button
|
||||
type="text"
|
||||
icon={<DeleteOutlined />}
|
||||
size="small"
|
||||
danger
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete?.(sparepart);
|
||||
}}
|
||||
title="Remove"
|
||||
/>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
|
||||
<Modal
|
||||
title="Sparepart Details"
|
||||
open={previewModalVisible}
|
||||
onCancel={() => setPreviewModalVisible(false)}
|
||||
footer={[
|
||||
<Button key="close" onClick={() => setPreviewModalVisible(false)}>
|
||||
Close
|
||||
</Button>
|
||||
]}
|
||||
width={800}
|
||||
centered
|
||||
styles={{ body: { padding: '24px' } }}
|
||||
>
|
||||
<Row gutter={[24, 24]}>
|
||||
<Col span={10}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: '#f0f0f0',
|
||||
width: '220px',
|
||||
height: '220px',
|
||||
margin: '0 auto 16px',
|
||||
position: 'relative',
|
||||
borderRadius: '12px',
|
||||
overflow: 'hidden',
|
||||
border: '1px solid #E0E0E0',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.1)'
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={getImageSrc()}
|
||||
alt={sparepart.sparepart_name || 'Sparepart'}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover'
|
||||
}}
|
||||
onError={(e) => {
|
||||
e.target.src = 'https://via.placeholder.com/220x220/d9d9d9/666666?text=No+Image';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
{sparepart.sparepart_item_type && (
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<Tag color="blue" style={{ fontSize: '14px', padding: '4px 12px' }}>
|
||||
{sparepart.sparepart_item_type}
|
||||
</Tag>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
<div style={{
|
||||
textAlign: 'left',
|
||||
background: '#f8f9fa',
|
||||
padding: '12px',
|
||||
borderRadius: '8px',
|
||||
marginTop: '25px'
|
||||
}}>
|
||||
<div style={{ marginBottom: '8px' }}>
|
||||
<Text strong style={{ fontSize: '14px', color: '#262626' }}>Stock Status:</Text>
|
||||
<Tag
|
||||
color={sparepart.sparepart_stok === 'Available' ? 'green' : 'red'}
|
||||
style={{ marginLeft: '8px', fontSize: '12px' }}
|
||||
>
|
||||
{sparepart.sparepart_stok || 'Not Available'}
|
||||
</Tag>
|
||||
</div>
|
||||
<div>
|
||||
<Text strong style={{ fontSize: '14px', color: '#262626' }}>Quantity:</Text>
|
||||
<Text style={{ fontSize: '14px', marginLeft: '8px', fontWeight: 600 }}>
|
||||
{sparepart.sparepart_qty || 0} {sparepart.sparepart_unit || ''}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
|
||||
<Col span={14}>
|
||||
<div>
|
||||
|
||||
<Title level={3} style={{ marginBottom: '20px', color: '#262626' }}>
|
||||
{sparepart.sparepart_name || 'Unnamed'}
|
||||
</Title>
|
||||
|
||||
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<Row gutter={[16, 12]}>
|
||||
<Col span={24}>
|
||||
<div style={{
|
||||
padding: '12px',
|
||||
backgroundColor: '#fafafa',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #f0f0f0'
|
||||
}}>
|
||||
<Row gutter={16}>
|
||||
<Col span={8}>
|
||||
<div>
|
||||
<Text type="secondary" style={{ fontSize: '12px' }}>Code</Text>
|
||||
<div style={{ fontSize: '15px', fontWeight: 500, marginTop: '2px' }}>
|
||||
{sparepart.sparepart_code || 'N/A'}
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<div>
|
||||
<Text type="secondary" style={{ fontSize: '12px' }}>Brand</Text>
|
||||
<div style={{ fontSize: '15px', fontWeight: 500, marginTop: '2px' }}>
|
||||
{sparepart.sparepart_merk || '-'}
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<div>
|
||||
<Text type="secondary" style={{ fontSize: '12px' }}>Unit</Text>
|
||||
<div style={{ fontSize: '15px', fontWeight: 500, marginTop: '2px' }}>
|
||||
{sparepart.sparepart_unit || '-'}
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
</Col>
|
||||
|
||||
{sparepart.sparepart_model && (
|
||||
<Col span={24}>
|
||||
<div>
|
||||
<Text type="secondary" style={{ fontSize: '12px' }}>Model</Text>
|
||||
<div style={{ fontSize: '15px', fontWeight: 500, marginTop: '2px' }}>
|
||||
{sparepart.sparepart_model}
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
)}
|
||||
|
||||
{sparepart.sparepart_description && (
|
||||
<Col span={24}>
|
||||
<div>
|
||||
<Text type="secondary" style={{ fontSize: '12px' }}>Description</Text>
|
||||
<div style={{ fontSize: '15px', marginTop: '2px', lineHeight: '1.5' }}>
|
||||
{sparepart.sparepart_description}
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
)}
|
||||
</Row>
|
||||
</div>
|
||||
|
||||
|
||||
{sparepart.created_at && (
|
||||
<div>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<div>
|
||||
<Text type="secondary" style={{ fontSize: '12px' }}>Created</Text>
|
||||
<div style={{ fontSize: '13px', marginTop: '2px' }}>
|
||||
{dayjs(sparepart.created_at).format('DD MMM YYYY, HH:mm')}
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<div>
|
||||
<Text type="secondary" style={{ fontSize: '12px' }}>Last Updated</Text>
|
||||
<div style={{ fontSize: '13px', marginTop: '2px' }}>
|
||||
{dayjs(sparepart.updated_at).format('DD MMM YYYY, HH:mm')}
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomSparepartCard;
|
||||
@@ -1,287 +1,393 @@
|
||||
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';
|
||||
import {
|
||||
Form,
|
||||
Divider,
|
||||
Button,
|
||||
Switch,
|
||||
Input,
|
||||
ConfigProvider,
|
||||
Typography,
|
||||
Upload,
|
||||
message,
|
||||
} from 'antd';
|
||||
import { PlusOutlined, UploadOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||
import { NotifAlert } from '../../../../components/Global/ToastNotif';
|
||||
import SolutionField from './SolutionField';
|
||||
import { uploadFile, getFileUrl } from '../../../../api/file-uploads';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const ErrorCodeForm = ({
|
||||
errorCodeForm,
|
||||
isErrorCodeFormReadOnly = false,
|
||||
isErrorCodeFormReadOnly,
|
||||
editingErrorCodeKey,
|
||||
solutionFields,
|
||||
solutionTypes,
|
||||
solutionStatuses,
|
||||
fileList,
|
||||
solutionsToDelete,
|
||||
firstSolutionValid,
|
||||
onAddErrorCode,
|
||||
onAddSolutionField,
|
||||
onRemoveSolutionField,
|
||||
onSolutionTypeChange,
|
||||
onSolutionStatusChange,
|
||||
onSolutionFileUpload,
|
||||
onFileView,
|
||||
onCreateNewErrorCode,
|
||||
onResetForm,
|
||||
errorCodes,
|
||||
errorCodeIcon,
|
||||
onErrorCodeIconUpload,
|
||||
onErrorCodeIconRemove,
|
||||
isEdit = false,
|
||||
}) => {
|
||||
const [currentIcon, setCurrentIcon] = useState(null);
|
||||
const statusWatch = Form.useWatch('status', errorCodeForm) ?? true;
|
||||
const statusValue = Form.useWatch('status', errorCodeForm);
|
||||
|
||||
useEffect(() => {
|
||||
if (errorCodeIcon && typeof errorCodeIcon === 'object' && Object.keys(errorCodeIcon).length > 0) {
|
||||
setCurrentIcon(errorCodeIcon);
|
||||
} else {
|
||||
setCurrentIcon(null);
|
||||
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;
|
||||
}
|
||||
}, [errorCodeIcon]);
|
||||
|
||||
const handleIconRemove = () => {
|
||||
setCurrentIcon(null);
|
||||
onErrorCodeIconRemove();
|
||||
};
|
||||
// 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;
|
||||
}
|
||||
|
||||
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 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 filePath = currentIcon.uploadPath || currentIcon.url || currentIcon.path || '';
|
||||
const iconDisplayName = currentIcon.name || '';
|
||||
const uploadResponse = await uploadFile(file, folder);
|
||||
const iconPath =
|
||||
uploadResponse.data?.path_icon || uploadResponse.data?.path_solution || '';
|
||||
|
||||
if (iconDisplayName) {
|
||||
actualFileName = iconDisplayName;
|
||||
} else if (filePath) {
|
||||
actualFileName = filePath.split('/').pop();
|
||||
}
|
||||
if (iconPath) {
|
||||
// Extract folder and filename from the path
|
||||
const pathParts = iconPath.split('/');
|
||||
const folder = pathParts[0];
|
||||
const filename = pathParts.slice(1).join('/');
|
||||
|
||||
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}`
|
||||
onErrorCodeIconUpload({
|
||||
name: file.name,
|
||||
uploadPath: iconPath,
|
||||
url: getFileUrl(folder, filename), // Use the same endpoint as file uploads
|
||||
type_solution: fileType,
|
||||
solutionId: 'icon',
|
||||
});
|
||||
message.success(`${file.name} uploaded successfully!`);
|
||||
} else {
|
||||
message.error('Failed to upload icon');
|
||||
}
|
||||
} catch (error) {
|
||||
NotifAlert({
|
||||
icon: 'error',
|
||||
title: 'Error',
|
||||
message: `Failed to open file preview: ${error.message}`
|
||||
console.error('Error uploading icon:', error);
|
||||
message.error('Failed to upload icon');
|
||||
}
|
||||
|
||||
return false; // Prevent default upload behavior
|
||||
};
|
||||
|
||||
const handleRemoveIcon = () => {
|
||||
onErrorCodeIconRemove();
|
||||
message.success('Icon removed');
|
||||
};
|
||||
|
||||
const handleAddErrorCode = async () => {
|
||||
try {
|
||||
const values = await errorCodeForm.validateFields();
|
||||
|
||||
const solutions = [];
|
||||
|
||||
solutionFields.forEach((fieldId) => {
|
||||
if (solutionsToDelete && solutionsToDelete.has(fieldId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const solutionName = values[`solution_name_${fieldId}`];
|
||||
const textSolution = values[`text_solution_${fieldId}`];
|
||||
const solutionStatus = values[`solution_status_${fieldId}`];
|
||||
const filesForSolution = fileList.filter((file) => file.solutionId === fieldId);
|
||||
const solutionType = values[`solution_type_${fieldId}`] || solutionTypes[fieldId];
|
||||
|
||||
if (solutionType === 'text') {
|
||||
if (textSolution && textSolution.trim()) {
|
||||
const solutionData = {
|
||||
solution_name: solutionName || `Solution ${fieldId}`,
|
||||
type_solution: 'text',
|
||||
text_solution: textSolution.trim(),
|
||||
path_solution: '',
|
||||
is_active: solutionStatus !== undefined ? solutionStatus : true,
|
||||
};
|
||||
|
||||
if (window.currentSolutionData && window.currentSolutionData[fieldId]) {
|
||||
solutionData.brand_code_solution_id =
|
||||
window.currentSolutionData[fieldId].brand_code_solution_id;
|
||||
}
|
||||
|
||||
solutions.push(solutionData);
|
||||
}
|
||||
} else if (solutionType === 'file') {
|
||||
filesForSolution.forEach((file) => {
|
||||
const solutionData = {
|
||||
solution_name:
|
||||
solutionName ||
|
||||
file.solution_name ||
|
||||
file.name ||
|
||||
`Solution ${fieldId}`,
|
||||
type_solution:
|
||||
file.type_solution ||
|
||||
(file.type.startsWith('image/') ? 'image' : 'pdf'),
|
||||
text_solution: '',
|
||||
path_solution: file.uploadPath,
|
||||
is_active: solutionStatus !== undefined ? solutionStatus : true,
|
||||
};
|
||||
|
||||
if (window.currentSolutionData && window.currentSolutionData[fieldId]) {
|
||||
solutionData.brand_code_solution_id =
|
||||
window.currentSolutionData[fieldId].brand_code_solution_id;
|
||||
}
|
||||
|
||||
solutions.push(solutionData);
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<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}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
if (solutions.length === 0) {
|
||||
NotifAlert({
|
||||
icon: 'warning',
|
||||
title: 'Perhatian',
|
||||
message:
|
||||
'Setiap error code harus memiliki minimal 1 solution (text atau file)!',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const newErrorCode = {
|
||||
error_code: values.error_code,
|
||||
error_code_name: values.error_code_name,
|
||||
error_code_description: values.error_code_description,
|
||||
error_code_color: values.error_code_color || '#000000',
|
||||
path_icon: errorCodeIcon?.uploadPath || '',
|
||||
status: values.status === undefined ? true : values.status,
|
||||
solution: solutions,
|
||||
key: editingErrorCodeKey || `temp-${Date.now()}`,
|
||||
};
|
||||
|
||||
onAddErrorCode(newErrorCode);
|
||||
} catch (error) {
|
||||
NotifAlert({
|
||||
icon: 'warning',
|
||||
title: 'Perhatian',
|
||||
message: 'Harap isi semua kolom wajib (error code + minimal 1 solution)!',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
components: {
|
||||
Switch: {
|
||||
colorPrimary: '#23A55A',
|
||||
colorPrimaryHover: '#23A55A',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Form
|
||||
form={errorCodeForm}
|
||||
layout="vertical"
|
||||
initialValues={{
|
||||
const handleResetForm = () => {
|
||||
errorCodeForm.resetFields();
|
||||
errorCodeForm.setFieldsValue({
|
||||
status: true,
|
||||
error_code_color: '#000000'
|
||||
}}
|
||||
>
|
||||
{/* Header bar with color picker, icon upload, and status toggle */}
|
||||
<div style={{
|
||||
solution_status_0: true,
|
||||
solution_type_0: 'text',
|
||||
});
|
||||
onResetForm();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<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',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
}}
|
||||
disabled={isErrorCodeFormReadOnly}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{/* Icon upload beside color picker */}
|
||||
<div style={{ flex: 1, maxWidth: '300px' }}>
|
||||
{renderIconUpload()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status toggle on right */}
|
||||
>
|
||||
<Form.Item label="Status" style={{ margin: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Form.Item name="status" valuePropName="checked" noStyle>
|
||||
<Switch
|
||||
style={{ backgroundColor: statusValue ? '#23A55A' : '#bfbfbf' }}
|
||||
disabled={isErrorCodeFormReadOnly}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Text style={{ marginLeft: 8 }}>
|
||||
{statusWatch ? 'Active' : 'Inactive'}
|
||||
</Text>
|
||||
<Text style={{ marginLeft: 8 }}>{statusValue ? 'Running' : 'Offline'}</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%' }}
|
||||
</Form.Item>
|
||||
{!isErrorCodeFormReadOnly && (
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
components: {
|
||||
Button: {
|
||||
defaultBg: '#23a55a',
|
||||
defaultColor: '#FFFFFF',
|
||||
defaultBorderColor: '#23a55a',
|
||||
defaultHoverBg: '#209652',
|
||||
defaultHoverColor: '#FFFFFF',
|
||||
defaultHoverBorderColor: '#23a55a',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
<Button icon={<PlusOutlined />} onClick={handleAddErrorCode}>
|
||||
{editingErrorCodeKey ? 'Update Error Code' : 'Tambah Error Code'}
|
||||
</Button>
|
||||
</ConfigProvider>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Form.Item
|
||||
name="error_code"
|
||||
label="Error Code"
|
||||
rules={[{ required: true, message: 'Error Code wajib diisi' }]}
|
||||
>
|
||||
<Input disabled={isErrorCodeFormReadOnly} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="error_code_name"
|
||||
label="Error Code Name"
|
||||
rules={[{ required: true, message: 'Error Code Name wajib diisi' }]}
|
||||
>
|
||||
<Input disabled={isErrorCodeFormReadOnly} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="Color & Icon">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 24 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<Text style={{ fontSize: 14, minWidth: 40 }}>Icon:</Text>
|
||||
{errorCodeIcon ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<img
|
||||
src={errorCodeIcon.url}
|
||||
alt="Error Code Icon"
|
||||
style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
objectFit: 'cover',
|
||||
border: '1px solid #d9d9d9',
|
||||
borderRadius: 4,
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<div style={{ fontSize: 11, color: '#666', marginBottom: 2 }}>
|
||||
{errorCodeIcon.name.length > 15
|
||||
? errorCodeIcon.name.substring(0, 15) + '...'
|
||||
: errorCodeIcon.name}
|
||||
</div>
|
||||
{!isErrorCodeFormReadOnly && (
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={handleRemoveIcon}
|
||||
style={{ height: 20, padding: '0 4px', fontSize: 10 }}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Upload
|
||||
accept="image/*"
|
||||
beforeUpload={handleIconUpload}
|
||||
showUploadList={false}
|
||||
disabled={isErrorCodeFormReadOnly}
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<UploadOutlined />}
|
||||
disabled={isErrorCodeFormReadOnly}
|
||||
style={{ height: 32 }}
|
||||
>
|
||||
Upload
|
||||
</Button>
|
||||
</Upload>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<Text style={{ fontSize: 14, minWidth: 40 }}>Color:</Text>
|
||||
<Form.Item name="error_code_color" noStyle>
|
||||
<Input
|
||||
type="color"
|
||||
disabled={isErrorCodeFormReadOnly}
|
||||
style={{
|
||||
width: 50,
|
||||
height: 32,
|
||||
border: '1px solid #d9d9d9',
|
||||
borderRadius: 4,
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: '#999', marginTop: 4 }}>
|
||||
Choose color and upload icon (max 2MB, JPG/PNG/GIF)
|
||||
</div>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="error_code_description"
|
||||
label="Error Code Description"
|
||||
rules={[{ required: true, message: 'Error Code Description wajib diisi' }]}
|
||||
>
|
||||
<Input.TextArea disabled={isErrorCodeFormReadOnly} />
|
||||
</Form.Item>
|
||||
|
||||
<Divider>Solutions</Divider>
|
||||
|
||||
{solutionFields.map((fieldId, index) => (
|
||||
<SolutionField
|
||||
key={fieldId}
|
||||
fieldId={fieldId}
|
||||
index={index}
|
||||
solutionType={solutionTypes[fieldId]}
|
||||
solutionStatus={solutionStatuses[fieldId]}
|
||||
isReadOnly={isErrorCodeFormReadOnly}
|
||||
fileList={fileList.filter((file) => file.solutionId === fieldId)}
|
||||
onRemove={() => onRemoveSolutionField(fieldId)}
|
||||
onSolutionTypeChange={(type) => onSolutionTypeChange(fieldId, type)}
|
||||
onSolutionStatusChange={(status) => onSolutionStatusChange(fieldId, status)}
|
||||
onFileUpload={onSolutionFileUpload}
|
||||
currentSolutionData={window.currentSolutionData?.[fieldId] || null}
|
||||
onFileView={onFileView}
|
||||
errorCodeForm={errorCodeForm}
|
||||
/>
|
||||
))}
|
||||
|
||||
{!isErrorCodeFormReadOnly && (
|
||||
<Form.Item style={{ textAlign: 'center' }}>
|
||||
<Button
|
||||
icon={<PlusOutlined />}
|
||||
onClick={onAddSolutionField}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
Add More Solution
|
||||
</Button>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{!isErrorCodeFormReadOnly && editingErrorCodeKey && (
|
||||
<Form.Item style={{ textAlign: 'right', marginTop: 16 }}>
|
||||
<Button onClick={handleResetForm}>Kembali</Button>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{isErrorCodeFormReadOnly && editingErrorCodeKey && (
|
||||
<Form.Item style={{ textAlign: 'right', marginTop: 16 }}>
|
||||
<Button onClick={handleResetForm}>Kembali</Button>
|
||||
</Form.Item>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
212
src/pages/master/brandDevice/component/ErrorCodeListModal.jsx
Normal file
@@ -0,0 +1,212 @@
|
||||
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: '25%',
|
||||
render: (text) => text || '-',
|
||||
},
|
||||
{
|
||||
title: 'Description',
|
||||
dataIndex: 'error_code_description',
|
||||
key: 'error_code_description',
|
||||
width: '30%',
|
||||
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: 'Spareparts',
|
||||
key: 'spareparts',
|
||||
width: '10%',
|
||||
align: 'center',
|
||||
render: (_, record) => {
|
||||
const sparepartCount = record.sparepart ? record.sparepart.length : 0;
|
||||
return (
|
||||
<Tag color={sparepartCount > 0 ? 'blue' : 'default'}>{sparepartCount} SP</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;
|
||||
233
src/pages/master/brandDevice/component/ErrorCodeSimpleForm.jsx
Normal file
@@ -0,0 +1,233 @@
|
||||
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%' }}
|
||||
>
|
||||
+ Add Error Code
|
||||
</Button>
|
||||
</ConfigProvider>
|
||||
</Form.Item>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ErrorCodeSimpleForm;
|
||||
@@ -1,45 +1,18 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Upload, Modal, Button, Typography, Space, Image } from 'antd';
|
||||
import { UploadOutlined, EyeOutlined, DeleteOutlined, FileOutlined } from '@ant-design/icons';
|
||||
import { useState } from 'react';
|
||||
import { Upload, Modal } from 'antd';
|
||||
import { UploadOutlined } from '@ant-design/icons';
|
||||
import { NotifOk, NotifAlert } from '../../../../components/Global/ToastNotif';
|
||||
import { uploadFile, getFolderFromFileType, getFileUrl, getFileType } from '../../../../api/file-uploads';
|
||||
|
||||
const { Text } = Typography;
|
||||
import { uploadFile, getFolderFromFileType } from '../../../../api/file-uploads';
|
||||
|
||||
const FileUploadHandler = ({
|
||||
type = 'solution',
|
||||
maxCount = 1,
|
||||
accept = '.pdf,.jpg,.jpeg,.png,.gif',
|
||||
disabled = false,
|
||||
|
||||
fileList = [],
|
||||
solutionFields,
|
||||
fileList,
|
||||
onFileUpload,
|
||||
onFileRemove,
|
||||
|
||||
existingFile = null,
|
||||
clearSignal = null,
|
||||
debugProps = {},
|
||||
|
||||
uploadText = 'Click or drag file to this area to upload',
|
||||
uploadHint = 'Support for PDF and image files only',
|
||||
buttonText = 'Upload File',
|
||||
buttonType = 'default',
|
||||
|
||||
containerStyle = {},
|
||||
buttonStyle = {},
|
||||
showPreview = true
|
||||
onFileRemove
|
||||
}) => {
|
||||
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) => {
|
||||
@@ -49,361 +22,89 @@ const FileUploadHandler = ({
|
||||
reader.onerror = (error) => reject(error);
|
||||
});
|
||||
|
||||
const handlePreview = async (file) => {
|
||||
if (!file.url && !file.preview) {
|
||||
file.preview = await getBase64(file.originFileObj);
|
||||
}
|
||||
setPreviewImage(file.url || file.preview);
|
||||
setPreviewOpen(true);
|
||||
const handleUploadPreview = async (file) => {
|
||||
const preview = await getBase64(file);
|
||||
setPreviewImage(preview);
|
||||
setPreviewTitle(file.name || file.url.substring(file.url.lastIndexOf('/') + 1));
|
||||
setPreviewOpen(true);
|
||||
};
|
||||
|
||||
const validateFile = (file) => {
|
||||
const isAllowedType = [
|
||||
'application/pdf',
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
].includes(file.type);
|
||||
|
||||
const handleFileUpload = async (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 false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleFileUpload = async (file) => {
|
||||
if (isUploading) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!validateFile(file)) {
|
||||
return false;
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
|
||||
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 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;
|
||||
}
|
||||
}
|
||||
|
||||
const actualPath = uploadResponse.data?.path_solution || '';
|
||||
|
||||
if (actualPath) {
|
||||
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);
|
||||
|
||||
file.uploadPath = actualPath;
|
||||
file.solution_name = file.name;
|
||||
file.solutionId = solutionFields[0];
|
||||
file.type_solution = fileType;
|
||||
onFileUpload(file);
|
||||
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}. Tidak dapat menemukan path file dalam response.`,
|
||||
message: `Gagal mengupload ${file.name}`
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
||||
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 = {
|
||||
name: 'file',
|
||||
multiple: false,
|
||||
accept,
|
||||
disabled: disabled || isUploading,
|
||||
fileList: [],
|
||||
beforeUpload: () => false,
|
||||
onChange: handleFileChange,
|
||||
onPreview: handlePreview,
|
||||
maxCount,
|
||||
multiple: true,
|
||||
accept: '.pdf,.jpg,.jpeg,.png,.gif',
|
||||
onRemove: onFileRemove,
|
||||
beforeUpload: handleFileUpload,
|
||||
fileList,
|
||||
onPreview: handleUploadPreview,
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ ...containerStyle }}>
|
||||
{!existingFile && (
|
||||
<Upload {...uploadProps}>
|
||||
{type === 'drag' ? (
|
||||
<Upload.Dragger>
|
||||
<>
|
||||
<Upload.Dragger {...uploadProps}>
|
||||
<p className="ant-upload-drag-icon">
|
||||
<UploadOutlined />
|
||||
</p>
|
||||
<p className="ant-upload-text">{uploadText}</p>
|
||||
<p className="ant-upload-hint">{uploadHint}</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>
|
||||
) : (
|
||||
<Button
|
||||
type={buttonType}
|
||||
icon={<UploadOutlined />}
|
||||
loading={isUploading}
|
||||
style={{ ...buttonStyle }}
|
||||
>
|
||||
{isUploading ? 'Uploading...' : buttonText}
|
||||
</Button>
|
||||
)}
|
||||
</Upload>
|
||||
)}
|
||||
|
||||
|
||||
|
||||
{showPreview && (
|
||||
<Modal
|
||||
open={previewOpen}
|
||||
title={previewTitle}
|
||||
footer={null}
|
||||
onCancel={() => setPreviewOpen(false)}
|
||||
width={600}
|
||||
style={{ top: 100 }}
|
||||
width="80%"
|
||||
style={{ top: 20 }}
|
||||
>
|
||||
{previewImage && (
|
||||
<img
|
||||
@@ -413,8 +114,7 @@ const FileUploadHandler = ({
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
70
src/pages/master/brandDevice/component/FormActions.jsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import React from 'react';
|
||||
import { Button, ConfigProvider } from 'antd';
|
||||
import { ArrowLeftOutlined } from '@ant-design/icons';
|
||||
|
||||
const FormActions = ({
|
||||
currentStep,
|
||||
onPreviousStep,
|
||||
onNextStep,
|
||||
onSave,
|
||||
onCancel,
|
||||
confirmLoading,
|
||||
isEditMode = false,
|
||||
showCancelButton = true
|
||||
}) => {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
token: { colorBgContainer: '#E9F6EF' },
|
||||
components: {
|
||||
Button: {
|
||||
defaultBg: 'white',
|
||||
defaultColor: '#23A55A',
|
||||
defaultBorderColor: '#23A55A',
|
||||
defaultHoverColor: '#23A55A',
|
||||
defaultHoverBorderColor: '#23A55A',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
{showCancelButton && (
|
||||
<Button onClick={onCancel}>Batal</Button>
|
||||
)}
|
||||
{currentStep > 0 && (
|
||||
<Button onClick={onPreviousStep} style={{ marginRight: 8 }}>
|
||||
Kembali
|
||||
</Button>
|
||||
)}
|
||||
</ConfigProvider>
|
||||
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
components: {
|
||||
Button: {
|
||||
defaultBg: '#23a55a',
|
||||
defaultColor: '#FFFFFF',
|
||||
defaultBorderColor: '#23a55a',
|
||||
defaultHoverBg: '#209652',
|
||||
defaultHoverColor: '#FFFFFF',
|
||||
defaultHoverBorderColor: '#23a55a',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
{currentStep < 1 && (
|
||||
<Button loading={confirmLoading} onClick={onNextStep}>
|
||||
Lanjut
|
||||
</Button>
|
||||
)}
|
||||
{currentStep === 1 && (
|
||||
<Button loading={confirmLoading} onClick={onSave}>
|
||||
{isEditMode ? 'Update' : 'Simpan'}
|
||||
</Button>
|
||||
)}
|
||||
</ConfigProvider>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FormActions;
|
||||
@@ -26,12 +26,26 @@ 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',
|
||||
@@ -91,9 +105,9 @@ const columns = (showPreviewModal, showEditModal, showDeleteDialog) => [
|
||||
const ListBrandDevice = memo(function ListBrandDevice(props) {
|
||||
const [trigerFilter, setTrigerFilter] = useState(false);
|
||||
|
||||
const defaultFilter = { criteria: '' };
|
||||
const defaultFilter = { search: '' };
|
||||
const [formDataFilter, setFormDataFilter] = useState(defaultFilter);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -114,21 +128,23 @@ const ListBrandDevice = memo(function ListBrandDevice(props) {
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
setFormDataFilter({ criteria: searchText });
|
||||
setFormDataFilter({ search: searchValue });
|
||||
setTrigerFilter((prev) => !prev);
|
||||
};
|
||||
|
||||
const handleSearchClear = () => {
|
||||
setSearchText('');
|
||||
setFormDataFilter({ criteria: '' });
|
||||
setSearchValue('');
|
||||
setFormDataFilter({ search: '' });
|
||||
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 {
|
||||
@@ -142,7 +158,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: () => {},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -156,7 +172,7 @@ const ListBrandDevice = memo(function ListBrandDevice(props) {
|
||||
title: 'Berhasil',
|
||||
message: `Brand ${brand_name} deleted successfully.`,
|
||||
});
|
||||
doFilter();
|
||||
doFilter(); // Refresh data
|
||||
} else {
|
||||
NotifAlert({
|
||||
icon: 'error',
|
||||
@@ -165,6 +181,7 @@ const ListBrandDevice = memo(function ListBrandDevice(props) {
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Delete Brand Device Error:', error);
|
||||
NotifAlert({
|
||||
icon: 'error',
|
||||
title: 'Error',
|
||||
@@ -182,12 +199,13 @@ const ListBrandDevice = memo(function ListBrandDevice(props) {
|
||||
<Col xs={24} sm={24} md={12} lg={12}>
|
||||
<Input.Search
|
||||
placeholder="Search brand device..."
|
||||
value={searchText}
|
||||
value={searchValue}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setSearchText(value);
|
||||
setSearchValue(value);
|
||||
// Auto search when clearing by backspace/delete
|
||||
if (value === '') {
|
||||
setFormDataFilter({ criteria: '' });
|
||||
setFormDataFilter({ search: '' });
|
||||
setTrigerFilter((prev) => !prev);
|
||||
}
|
||||
}}
|
||||
@@ -233,7 +251,7 @@ const ListBrandDevice = memo(function ListBrandDevice(props) {
|
||||
}}
|
||||
size="large"
|
||||
>
|
||||
Add data
|
||||
Add Brand Device
|
||||
</Button>
|
||||
</ConfigProvider>
|
||||
</Space>
|
||||
@@ -244,7 +262,7 @@ const ListBrandDevice = memo(function ListBrandDevice(props) {
|
||||
<TableList
|
||||
mobile
|
||||
cardColor={'#42AAFF'}
|
||||
header={'brand_name'}
|
||||
header={'tag_name'}
|
||||
showPreviewModal={showPreviewModal}
|
||||
showEditModal={showEditModal}
|
||||
showDeleteDialog={showDeleteDialog}
|
||||
|
||||
@@ -1,315 +1,84 @@
|
||||
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';
|
||||
import React from 'react';
|
||||
import { Table, Button, Space } from 'antd';
|
||||
import { EyeOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||
|
||||
const ListErrorCode = ({
|
||||
brandId,
|
||||
selectedErrorCode,
|
||||
onErrorCodeSelect,
|
||||
onAddNew,
|
||||
tempErrorCodes = [],
|
||||
trigerFilter,
|
||||
searchText,
|
||||
onSearchChange,
|
||||
onSearch,
|
||||
onSearchClear,
|
||||
isReadOnly = false,
|
||||
errorCodes: propErrorCodes = null
|
||||
const ErrorCodeTable = ({
|
||||
errorCodes,
|
||||
loading,
|
||||
onPreview,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onFileView
|
||||
}) => {
|
||||
const [errorCodes, setErrorCodes] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [pagination, setPagination] = useState({
|
||||
current_page: 1,
|
||||
current_limit: 15,
|
||||
total_limit: 0,
|
||||
total_page: 0,
|
||||
});
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const pageSize = 15;
|
||||
|
||||
const queryParams = useMemo(() => {
|
||||
const params = new URLSearchParams();
|
||||
params.set('page', currentPage.toString());
|
||||
params.set('limit', pageSize.toString());
|
||||
if (searchText) {
|
||||
params.set('criteria', searchText);
|
||||
}
|
||||
return params;
|
||||
}, [searchText, currentPage, pageSize]);
|
||||
|
||||
const fetchErrorCodes = async () => {
|
||||
if (!brandId) {
|
||||
setErrorCodes([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await getErrorCodesByBrandId(brandId, queryParams);
|
||||
|
||||
if (response && response.statusCode === 200) {
|
||||
const apiErrorData = response.data || [];
|
||||
const allErrorCodes = [
|
||||
...apiErrorData.map(ec => ({
|
||||
...ec,
|
||||
tempId: `existing_${ec.error_code_id}`,
|
||||
status: 'existing'
|
||||
})),
|
||||
...tempErrorCodes.filter(ec => ec.status !== 'deleted')
|
||||
];
|
||||
|
||||
setErrorCodes(allErrorCodes);
|
||||
|
||||
if (response.paging) {
|
||||
setPagination({
|
||||
current_page: response.paging.current_page || 1,
|
||||
current_limit: response.paging.current_limit || 15,
|
||||
total_limit: response.paging.total_limit || 0,
|
||||
total_page: response.paging.total_page || 0,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setErrorCodes([]);
|
||||
}
|
||||
} catch (error) {
|
||||
setErrorCodes([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isReadOnly && propErrorCodes) {
|
||||
|
||||
setErrorCodes(propErrorCodes);
|
||||
setLoading(false);
|
||||
} else {
|
||||
|
||||
fetchErrorCodes();
|
||||
}
|
||||
}, [brandId, queryParams, tempErrorCodes, trigerFilter, isReadOnly, propErrorCodes]);
|
||||
|
||||
const handlePrevious = () => {
|
||||
if (pagination.current_page > 1) {
|
||||
setCurrentPage(pagination.current_page - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
if (pagination.current_page < pagination.total_page) {
|
||||
setCurrentPage(pagination.current_page + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
setCurrentPage(1);
|
||||
if (onSearch) {
|
||||
onSearch();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearchClear = () => {
|
||||
setCurrentPage(1);
|
||||
if (onSearchClear) {
|
||||
onSearchClear();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (item, e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (item.status === 'existing' && item.error_code_id) {
|
||||
NotifConfirmDialog({
|
||||
icon: 'warning',
|
||||
title: 'Hapus Error Code',
|
||||
message: `Apakah Anda yakin ingin menghapus error code ${item.error_code}?`,
|
||||
onConfirm: () => performDelete(item),
|
||||
onCancel: () => { },
|
||||
confirmButtonText: 'Hapus'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const performDelete = async (item) => {
|
||||
try {
|
||||
|
||||
if (!item.error_code_id || item.error_code_id === 'undefined') {
|
||||
NotifAlert({
|
||||
icon: 'error',
|
||||
title: 'Error',
|
||||
message: 'Error code ID tidak valid'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!item.brand_id || item.brand_id === 'undefined') {
|
||||
NotifAlert({
|
||||
icon: 'error',
|
||||
title: 'Error',
|
||||
message: 'Brand ID tidak valid'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await deleteErrorCode(item.brand_id, item.error_code_id);
|
||||
|
||||
if (response && response.statusCode === 200) {
|
||||
NotifOk({
|
||||
icon: 'success',
|
||||
title: 'Berhasil',
|
||||
message: 'Error code berhasil dihapus'
|
||||
});
|
||||
fetchErrorCodes();
|
||||
} else {
|
||||
NotifAlert({
|
||||
icon: 'error',
|
||||
title: 'Gagal',
|
||||
message: 'Gagal menghapus error code'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
NotifAlert({
|
||||
icon: 'error',
|
||||
title: 'Error',
|
||||
message: 'Terjadi kesalahan saat menghapus error code'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
title="Daftar Error Code"
|
||||
style={{ width: '100%', minWidth: '472px' }}
|
||||
styles={{ body: { padding: '12px' } }}
|
||||
>
|
||||
<Input.Search
|
||||
placeholder="Cari error code..."
|
||||
value={searchText}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (onSearchChange) {
|
||||
onSearchChange(value);
|
||||
}
|
||||
}}
|
||||
onSearch={handleSearch}
|
||||
allowClear
|
||||
enterButton={
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SearchOutlined />}
|
||||
onClick={handleSearch}
|
||||
style={{
|
||||
backgroundColor: '#23A55A',
|
||||
borderColor: '#23A55A',
|
||||
height: '32px'
|
||||
}}
|
||||
>
|
||||
Search
|
||||
</Button>
|
||||
}
|
||||
size="default"
|
||||
style={{
|
||||
marginBottom: 12,
|
||||
height: '32px',
|
||||
width: '100%',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div style={{
|
||||
height: '90vh',
|
||||
border: '1px solid #d9d9d9',
|
||||
borderRadius: '6px',
|
||||
overflow: 'auto',
|
||||
marginBottom: 12,
|
||||
backgroundColor: '#fafafa'
|
||||
}}>
|
||||
{errorCodes.length === 0 ? (
|
||||
<Empty
|
||||
description="Belum ada error code"
|
||||
style={{ marginTop: 50 }}
|
||||
/>
|
||||
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>
|
||||
))
|
||||
) : (
|
||||
<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}
|
||||
<span style={{ color: '#999', fontSize: '12px' }}>No solutions</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: '11px', color: '#666' }}>
|
||||
{item.error_code_name}
|
||||
</div>
|
||||
</div>
|
||||
{item.status === 'existing' && (
|
||||
)
|
||||
},
|
||||
{
|
||||
title: 'Action',
|
||||
key: 'action',
|
||||
render: (_, record) => (
|
||||
<Space>
|
||||
<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'
|
||||
}}
|
||||
icon={<EyeOutlined />}
|
||||
onClick={() => onPreview(record)}
|
||||
style={{ color: '#1890ff', borderColor: '#1890ff' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<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>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
{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>
|
||||
const dataSource = loading
|
||||
? Array.from({ length: 3 }, (_, index) => ({
|
||||
key: `loading-${index}`,
|
||||
error_code: 'Loading...',
|
||||
error_code_name: 'Loading...',
|
||||
solution: []
|
||||
}))
|
||||
: errorCodes;
|
||||
|
||||
return (
|
||||
<Table
|
||||
columns={errorCodeColumns}
|
||||
dataSource={dataSource}
|
||||
rowKey="key"
|
||||
pagination={false}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ListErrorCode;
|
||||
export default ErrorCodeTable;
|
||||
@@ -1,496 +1,315 @@
|
||||
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';
|
||||
import React, { useEffect } from 'react';
|
||||
import { Form, Input, Button, Switch, Radio, Upload, Typography } from 'antd';
|
||||
import { DeleteOutlined, UploadOutlined } from '@ant-design/icons';
|
||||
import { uploadFile, getFolderFromFileType } from '../../../../api/file-uploads';
|
||||
import { NotifAlert, NotifOk } from '../../../../components/Global/ToastNotif';
|
||||
|
||||
const { Text } = Typography;
|
||||
const { TextArea } = Input;
|
||||
|
||||
const SolutionFieldNew = ({
|
||||
fieldKey,
|
||||
fieldName,
|
||||
const SolutionField = ({
|
||||
fieldId,
|
||||
index,
|
||||
solutionType,
|
||||
solutionStatus,
|
||||
isReadOnly = false,
|
||||
canRemove = true,
|
||||
onTypeChange,
|
||||
onStatusChange,
|
||||
isReadOnly,
|
||||
fileList,
|
||||
onRemove,
|
||||
onSolutionTypeChange,
|
||||
onSolutionStatusChange,
|
||||
onFileUpload,
|
||||
currentSolutionData,
|
||||
onFileView,
|
||||
fileList = [],
|
||||
originalSolutionData = null
|
||||
errorCodeForm,
|
||||
}) => {
|
||||
const form = Form.useFormInstance();
|
||||
const [currentFile, setCurrentFile] = useState(null);
|
||||
const [isDeleted, setIsDeleted] = useState(false);
|
||||
|
||||
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]);
|
||||
|
||||
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() !== '';
|
||||
|
||||
const wasExplicitlyDeleted =
|
||||
(fileUpload === null || file === null || pathSolution === null) &&
|
||||
!hasValidFileUpload &&
|
||||
!hasValidFile &&
|
||||
!hasValidPath;
|
||||
|
||||
if (wasExplicitlyDeleted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (solutionType === 'text') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (hasValidFileUpload) {
|
||||
return fileUpload;
|
||||
}
|
||||
if (hasValidFile) {
|
||||
return file;
|
||||
}
|
||||
if (hasValidPath) {
|
||||
return {
|
||||
name: fileNameValue || pathSolution.split('/').pop() || 'File',
|
||||
uploadPath: pathSolution,
|
||||
url: pathSolution,
|
||||
path: pathSolution
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const fileFromForm = getFileFromFormValues();
|
||||
|
||||
if (JSON.stringify(currentFile) !== JSON.stringify(fileFromForm)) {
|
||||
setCurrentFile(fileFromForm);
|
||||
}
|
||||
}, [fileUpload, file, pathSolution, solutionType, deleteCounter, fileNameValue, fieldKey]);
|
||||
|
||||
|
||||
const renderSolutionContent = () => {
|
||||
if (solutionType === 'text') {
|
||||
return (
|
||||
<Form.Item
|
||||
name={['solution_items', fieldKey, 'text']}
|
||||
rules={[{ required: true, message: 'Text solution wajib diisi!' }]}
|
||||
>
|
||||
<TextArea
|
||||
placeholder="Enter solution text"
|
||||
rows={3}
|
||||
disabled={isReadOnly}
|
||||
style={{ fontSize: 12 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
// Watch the solution status from the form
|
||||
const watchedStatus = Form.useWatch(`solution_status_${fieldId}`, errorCodeForm);
|
||||
useEffect(() => {
|
||||
if (currentSolutionData && errorCodeForm) {
|
||||
if (currentSolutionData.solution_name) {
|
||||
errorCodeForm.setFieldValue(
|
||||
`solution_name_${fieldId}`,
|
||||
currentSolutionData.solution_name
|
||||
);
|
||||
}
|
||||
|
||||
if (solutionType === 'file') {
|
||||
const hasOriginalFile = originalSolutionData && (
|
||||
originalSolutionData.path_solution ||
|
||||
originalSolutionData.path_document
|
||||
if (currentSolutionData.type_solution === 'text' && currentSolutionData.text_solution) {
|
||||
errorCodeForm.setFieldValue(
|
||||
`text_solution_${fieldId}`,
|
||||
currentSolutionData.text_solution
|
||||
);
|
||||
|
||||
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 (currentSolutionData.type_solution) {
|
||||
const formValue =
|
||||
currentSolutionData.type_solution === 'image' ||
|
||||
currentSolutionData.type_solution === 'pdf'
|
||||
? 'file'
|
||||
: currentSolutionData.type_solution;
|
||||
errorCodeForm.setFieldValue(`solution_type_${fieldId}`, formValue);
|
||||
}
|
||||
|
||||
if (!fileUrl && filePath) {
|
||||
fileUrl = filePath.startsWith('http') ? filePath : `${import.meta.env.VITE_API_SERVER}/${filePath}`;
|
||||
// Only set status if it's not already set to prevent overwriting user changes
|
||||
const currentStatus = errorCodeForm.getFieldValue(`solution_status_${fieldId}`);
|
||||
if (currentSolutionData.is_active !== undefined && currentStatus === undefined) {
|
||||
errorCodeForm.setFieldValue(
|
||||
`solution_status_${fieldId}`,
|
||||
currentSolutionData.is_active
|
||||
);
|
||||
}
|
||||
|
||||
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 {
|
||||
}, [currentSolutionData, fieldId, errorCodeForm]);
|
||||
|
||||
const handleBeforeUpload = async (file) => {
|
||||
const isAllowedType = ['application/pdf', 'image/jpeg', 'image/png', 'image/gif'].includes(
|
||||
file.type
|
||||
);
|
||||
if (!isAllowedType) {
|
||||
NotifAlert({
|
||||
icon: 'error',
|
||||
title: 'Error',
|
||||
message: 'File URL not found'
|
||||
message: `${file.name} bukan file PDF atau gambar yang diizinkan.`,
|
||||
});
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
|
||||
try {
|
||||
// Upload file immediately to get path
|
||||
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) {
|
||||
file.uploadPath = actualPath;
|
||||
file.solution_name = file.name;
|
||||
file.solutionId = fieldId;
|
||||
file.type_solution = fileType;
|
||||
onFileUpload(file);
|
||||
NotifOk({
|
||||
icon: 'success',
|
||||
title: 'Berhasil',
|
||||
message: `${file.name} berhasil diupload!`,
|
||||
});
|
||||
} else {
|
||||
NotifAlert({
|
||||
icon: 'error',
|
||||
title: 'Gagal',
|
||||
message: `Gagal mengupload ${file.name}`,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error uploading file:', error);
|
||||
NotifAlert({
|
||||
icon: 'error',
|
||||
title: 'Error',
|
||||
message: 'Failed to open file preview'
|
||||
message: `Gagal mengupload ${file.name}. Silakan coba lagi.`,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
danger
|
||||
size="middle"
|
||||
icon={<DeleteOutlined />}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
data-solution-id={fieldId}
|
||||
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 (
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
components: {
|
||||
Switch: {
|
||||
colorPrimary: '#23A55A',
|
||||
colorPrimaryHover: '#23A55A',
|
||||
},
|
||||
},
|
||||
marginBottom: 24,
|
||||
padding: 16,
|
||||
border: '1px solid #d9d9d9',
|
||||
borderRadius: 8,
|
||||
transition: 'all 0.3s ease',
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
border: '1px solid #d9d9d9',
|
||||
borderRadius: 6,
|
||||
padding: 12,
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
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: 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 && (
|
||||
>
|
||||
<Text strong>Solution {index + 1}</Text>
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
size="small"
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={onRemove}
|
||||
onClick={() => onRemove(fieldId)}
|
||||
disabled={isReadOnly}
|
||||
style={{
|
||||
fontSize: 12,
|
||||
padding: '2px 4px',
|
||||
height: '24px'
|
||||
borderColor: '#ff4d4f',
|
||||
color: '#ff4d4f'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</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 name={`solution_name_${fieldId}`} label="Solution Name">
|
||||
<Input placeholder="Enter solution name" disabled={isReadOnly} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="Status">
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Form.Item name={`solution_status_${fieldId}`} valuePropName="checked" noStyle>
|
||||
<Switch
|
||||
disabled={isReadOnly}
|
||||
onChange={(checked) => {
|
||||
onSolutionStatusChange(fieldId, checked);
|
||||
}}
|
||||
style={{
|
||||
backgroundColor: (watchedStatus ?? true) ? '#23A55A' : '#bfbfbf',
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Text style={{ marginLeft: 8 }}>
|
||||
{(watchedStatus ?? true) ? 'Active' : 'Inactive'}
|
||||
</Text>
|
||||
</div>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name={['solution_items', fieldKey, 'type']}
|
||||
rules={[{ required: true, message: 'Solution type wajib diisi!' }]}
|
||||
style={{ marginBottom: 8 }}
|
||||
initialValue={solutionType || 'text'}
|
||||
>
|
||||
<Form.Item label="Solution Type">
|
||||
<Form.Item name={`solution_type_${fieldId}`} noStyle>
|
||||
<Radio.Group
|
||||
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);
|
||||
onSolutionTypeChange(fieldId, e.target.value);
|
||||
}}
|
||||
disabled={isReadOnly}
|
||||
size="small"
|
||||
>
|
||||
<Radio value="text" style={{ fontSize: 12 }}>Text</Radio>
|
||||
<Radio value="file" style={{ fontSize: 12 }}>File</Radio>
|
||||
<Radio value="text">Text Solution</Radio>
|
||||
<Radio value="file">File Upload</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name={['solution_items', fieldKey, 'status']}
|
||||
initialValue={solutionStatus !== false ? true : false}
|
||||
shouldUpdate={(prevValues, currentValues) =>
|
||||
prevValues[`solution_type_${fieldId}`] !==
|
||||
currentValues[`solution_type_${fieldId}`]
|
||||
}
|
||||
noStyle
|
||||
>
|
||||
<input type="hidden" />
|
||||
</Form.Item>
|
||||
{({ getFieldValue }) => {
|
||||
const currentType = getFieldValue(`solution_type_${fieldId}`) || 'text';
|
||||
const displayType =
|
||||
currentType === 'file' && currentSolutionData
|
||||
? currentSolutionData.type_solution === 'image'
|
||||
? 'image'
|
||||
: currentSolutionData.type_solution === 'pdf'
|
||||
? 'pdf'
|
||||
: 'file'
|
||||
: currentType;
|
||||
|
||||
{renderSolutionContent()}
|
||||
return displayType === 'text' ? (
|
||||
<Form.Item name={`text_solution_${fieldId}`} label="Text Solution">
|
||||
<Input.TextArea
|
||||
placeholder="Enter text solution"
|
||||
disabled={isReadOnly}
|
||||
rows={4}
|
||||
/>
|
||||
</Form.Item>
|
||||
) : (
|
||||
<>
|
||||
{/* Show existing file info for both preview and edit mode */}
|
||||
{currentSolutionData &&
|
||||
currentSolutionData.type_solution !== 'text' &&
|
||||
currentSolutionData.path_solution && (
|
||||
<Form.Item label="Current Document">
|
||||
{(() => {
|
||||
const solution = currentSolutionData;
|
||||
const fileName =
|
||||
solution.file_upload_name ||
|
||||
solution.path_solution?.split('/')[1] ||
|
||||
'File';
|
||||
const fileType = solution.type_solution;
|
||||
|
||||
if (fileType !== 'text' && solution.path_solution) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
marginBottom: '8px',
|
||||
}}
|
||||
>
|
||||
<Text>
|
||||
{fileType === 'image'
|
||||
? '[Image]'
|
||||
: '[Document]'}{' '}
|
||||
{fileName}
|
||||
</Text>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() =>
|
||||
onFileView(
|
||||
solution.path_solution,
|
||||
solution.type_solution
|
||||
)
|
||||
}
|
||||
style={{
|
||||
padding: 0,
|
||||
height: 'auto',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
>
|
||||
View Document
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
<Form.Item label="Upload File">
|
||||
<Upload
|
||||
multiple={true}
|
||||
accept=".pdf,.jpg,.jpeg,.png,.gif"
|
||||
disabled={isReadOnly}
|
||||
fileList={[
|
||||
...fileList.filter((file) => file.solutionId === fieldId),
|
||||
// Add existing file to fileList if it exists
|
||||
...(currentSolutionData &&
|
||||
currentSolutionData.type_solution !== 'text' &&
|
||||
currentSolutionData.path_solution
|
||||
? [
|
||||
{
|
||||
uid: `existing-${fieldId}`,
|
||||
name:
|
||||
currentSolutionData.file_upload_name ||
|
||||
currentSolutionData.path_solution?.split(
|
||||
'/'
|
||||
)[1] ||
|
||||
'File',
|
||||
status: 'done',
|
||||
url: null, // We'll use the path_solution for viewing
|
||||
solutionId: fieldId,
|
||||
type_solution:
|
||||
currentSolutionData.type_solution,
|
||||
uploadPath: currentSolutionData.path_solution,
|
||||
existingFile: true,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
onRemove={(file) => {}}
|
||||
beforeUpload={handleBeforeUpload}
|
||||
>
|
||||
<Button icon={<UploadOutlined />} disabled={isReadOnly}>
|
||||
Click to Upload (File or Image)
|
||||
</Button>
|
||||
</Upload>
|
||||
</Form.Item>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
</div>
|
||||
</ConfigProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default SolutionFieldNew;
|
||||
export default SolutionField;
|
||||
|
||||
243
src/pages/master/brandDevice/component/SolutionFieldNew.jsx
Normal file
@@ -0,0 +1,243 @@
|
||||
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 { NotifAlert } from '../../../../components/Global/ToastNotif';
|
||||
|
||||
const { Text } = Typography;
|
||||
const { TextArea } = Input;
|
||||
|
||||
const SolutionFieldNew = ({
|
||||
fieldKey,
|
||||
fieldName,
|
||||
index,
|
||||
solutionType,
|
||||
solutionStatus,
|
||||
isReadOnly = false,
|
||||
canRemove = true,
|
||||
onTypeChange,
|
||||
onStatusChange,
|
||||
onRemove,
|
||||
onFileUpload,
|
||||
onFileView,
|
||||
fileList = []
|
||||
}) => {
|
||||
const [currentStatus, setCurrentStatus] = useState(solutionStatus ?? true);
|
||||
|
||||
// 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;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentStatus(solutionStatus ?? true);
|
||||
}, [solutionStatus]);
|
||||
const handleFileUpload = async (file) => {
|
||||
try {
|
||||
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.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
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}`,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error uploading file:', error);
|
||||
NotifAlert({
|
||||
icon: 'error',
|
||||
title: 'Error',
|
||||
message: `Gagal mengupload ${file.name}. Silakan coba lagi.`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const renderSolutionContent = () => {
|
||||
if (solutionType === 'text') {
|
||||
return (
|
||||
<Form.Item
|
||||
name={[fieldName, 'text']}
|
||||
rules={[{ required: true, message: 'Text solution wajib diisi!' }]}
|
||||
>
|
||||
<TextArea
|
||||
placeholder="Enter solution text"
|
||||
rows={3}
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
<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);
|
||||
}}
|
||||
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>
|
||||
|
||||
<Form.Item
|
||||
name={[fieldName, 'type']}
|
||||
rules={[{ required: true, message: 'Solution type wajib diisi!' }]}
|
||||
>
|
||||
<Radio.Group
|
||||
onChange={(e) => onTypeChange(fieldKey, e.target.value)}
|
||||
disabled={isReadOnly}
|
||||
>
|
||||
<Radio value="text">Text Solution</Radio>
|
||||
<Radio value="file">File Solution</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
|
||||
{renderSolutionContent()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SolutionFieldNew;
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Typography, Divider, Button, Form } from 'antd';
|
||||
import { Form, Card, Typography, Divider, Button } from 'antd';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import SolutionFieldNew from './SolutionField';
|
||||
import SolutionFieldNew from './SolutionFieldNew';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
@@ -10,64 +10,67 @@ const SolutionForm = ({
|
||||
solutionFields,
|
||||
solutionTypes,
|
||||
solutionStatuses,
|
||||
fileList,
|
||||
solutionsToDelete,
|
||||
firstSolutionValid,
|
||||
onAddSolutionField,
|
||||
onRemoveSolutionField,
|
||||
onSolutionTypeChange,
|
||||
onSolutionStatusChange,
|
||||
onSolutionFileUpload,
|
||||
onFileView,
|
||||
fileList,
|
||||
isReadOnly = false,
|
||||
solutionData = [],
|
||||
onAddSolution
|
||||
}) => {
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: 0 }}>
|
||||
<div>
|
||||
<Form
|
||||
form={solutionForm}
|
||||
layout="vertical"
|
||||
initialValues={{
|
||||
solution_status_0: true,
|
||||
solution_type_0: 'text',
|
||||
}}
|
||||
>
|
||||
<Divider orientation="left">Solution Items</Divider>
|
||||
|
||||
<Form form={solutionForm} layout="vertical">
|
||||
<div style={{
|
||||
maxHeight: '400px',
|
||||
overflowY: 'auto',
|
||||
paddingRight: '8px'
|
||||
}}>
|
||||
{solutionFields.map((field, displayIndex) => (
|
||||
{solutionFields.map((field, index) => (
|
||||
<SolutionFieldNew
|
||||
key={field}
|
||||
fieldKey={field}
|
||||
fieldName={['solution_items', field]}
|
||||
index={displayIndex}
|
||||
solutionType={solutionTypes[field]}
|
||||
solutionStatus={solutionStatuses[field]}
|
||||
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)}
|
||||
onRemove={() => onRemoveSolutionField(field.key)}
|
||||
onFileUpload={onSolutionFileUpload}
|
||||
onFileView={onFileView}
|
||||
fileList={fileList}
|
||||
isReadOnly={isReadOnly}
|
||||
canRemove={solutionFields.length > 1 && displayIndex > 0}
|
||||
originalSolutionData={solutionData[displayIndex]}
|
||||
canRemove={solutionFields.length > 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!isReadOnly && (
|
||||
<div style={{ marginBottom: 8, marginTop: 12 }}>
|
||||
<>
|
||||
<Form.Item>
|
||||
<Button
|
||||
type="dashed"
|
||||
onClick={onAddSolutionField}
|
||||
icon={<PlusOutlined />}
|
||||
style={{
|
||||
width: '100%',
|
||||
borderColor: '#23A55A',
|
||||
color: '#23A55A',
|
||||
height: '32px',
|
||||
fontSize: '12px'
|
||||
}}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
Add sollution
|
||||
+ Add Solution
|
||||
</Button>
|
||||
</Form.Item>
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Text type="secondary">
|
||||
* At least one solution is required for each error code.
|
||||
</Text>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
258
src/pages/master/brandDevice/component/SparepartForm.jsx
Normal file
@@ -0,0 +1,258 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Form,
|
||||
Input,
|
||||
Button,
|
||||
Divider,
|
||||
Typography,
|
||||
Switch,
|
||||
Space,
|
||||
Card,
|
||||
Upload,
|
||||
message,
|
||||
} from 'antd';
|
||||
import { PlusOutlined, DeleteOutlined, UploadOutlined } from '@ant-design/icons';
|
||||
import { uploadFile } from '../../../../api/file-uploads';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const SparepartForm = ({
|
||||
sparepartForm,
|
||||
sparepartFields,
|
||||
onAddSparepartField,
|
||||
onRemoveSparepartField,
|
||||
onSparepartTypeChange,
|
||||
onSparepartStatusChange,
|
||||
onSparepartImageUpload,
|
||||
onSparepartImageRemove,
|
||||
sparepartImages = {},
|
||||
isReadOnly = false,
|
||||
}) => {
|
||||
const [fieldStatuses, setFieldStatuses] = useState({});
|
||||
|
||||
// Watch form values for each field
|
||||
const getFieldValue = (fieldName) => {
|
||||
try {
|
||||
const values = sparepartForm?.getFieldsValue();
|
||||
return values?.sparepart_items?.[fieldName]?.status ?? true;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Update field statuses when form changes
|
||||
const newStatuses = {};
|
||||
sparepartFields.forEach((field) => {
|
||||
newStatuses[field.key] = getFieldValue(field.key);
|
||||
});
|
||||
setFieldStatuses(newStatuses);
|
||||
}, [sparepartFields, sparepartForm]);
|
||||
|
||||
const handleImageUpload = async (fieldKey, 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 imagePath =
|
||||
uploadResponse.data?.path_icon || uploadResponse.data?.path_solution || '';
|
||||
|
||||
if (imagePath) {
|
||||
onSparepartImageUpload &&
|
||||
onSparepartImageUpload(fieldKey, {
|
||||
name: file.name,
|
||||
uploadPath: imagePath,
|
||||
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 image:', error);
|
||||
message.error(`Failed to upload ${file.name}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImageRemove = (fieldKey) => {
|
||||
onSparepartImageRemove && onSparepartImageRemove(fieldKey);
|
||||
};
|
||||
return (
|
||||
<div>
|
||||
<Text strong style={{ marginBottom: 16, display: 'block' }}>
|
||||
{isReadOnly ? 'Sparepart Details' : 'Tambah Sparepart'}
|
||||
</Text>
|
||||
|
||||
<Form
|
||||
form={sparepartForm}
|
||||
layout="vertical"
|
||||
initialValues={{
|
||||
sparepart_status_0: true,
|
||||
sparepart_type_0: 'required',
|
||||
}}
|
||||
>
|
||||
{/* Dynamic Sparepart Fields */}
|
||||
<Divider orientation="left">Sparepart Items</Divider>
|
||||
|
||||
{sparepartFields.map((field, index) => (
|
||||
<Card
|
||||
key={field.key}
|
||||
size="small"
|
||||
style={{ marginBottom: 16 }}
|
||||
title={
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Text strong>Sparepart {index + 1}</Text>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
{!isReadOnly && sparepartFields.length > 1 && (
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => onRemoveSparepartField(field.key)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Form layout="vertical" style={{ border: 'none' }}>
|
||||
{/* Sparepart Name */}
|
||||
<Form.Item
|
||||
name={[field.name, 'name']}
|
||||
rules={[{ required: true, message: 'Sparepart name wajib diisi!' }]}
|
||||
>
|
||||
<Input placeholder="Enter sparepart name" disabled={isReadOnly} />
|
||||
</Form.Item>
|
||||
|
||||
{/* Description */}
|
||||
<Form.Item name={[field.name, 'description']}>
|
||||
<Input.TextArea
|
||||
placeholder="Enter sparepart description (optional)"
|
||||
rows={2}
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{/* Image Upload */}
|
||||
<Form.Item label="Sparepart Image">
|
||||
{!isReadOnly ? (
|
||||
<Upload
|
||||
beforeUpload={(file) => handleImageUpload(field.key, file)}
|
||||
showUploadList={false}
|
||||
accept="image/*"
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<Button icon={<UploadOutlined />} style={{ width: '100%' }}>
|
||||
Upload Sparepart Image
|
||||
</Button>
|
||||
</Upload>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
border: '1px solid #d9d9d9',
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
<Text type="secondary">No upload allowed</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sparepartImages[field.key] && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={sparepartImages[field.key].uploadPath}
|
||||
alt="Sparepart Image"
|
||||
style={{
|
||||
width: 50,
|
||||
height: 50,
|
||||
objectFit: 'cover',
|
||||
border: '1px solid #d9d9d9',
|
||||
borderRadius: 4,
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<Text style={{ fontSize: 12 }}>
|
||||
{sparepartImages[field.key].name}
|
||||
</Text>
|
||||
<br />
|
||||
<Text type="secondary" style={{ fontSize: 10 }}>
|
||||
Size:{' '}
|
||||
{(
|
||||
sparepartImages[field.key].size / 1024
|
||||
).toFixed(1)}{' '}
|
||||
KB
|
||||
</Text>
|
||||
</div>
|
||||
{!isReadOnly && (
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
size="small"
|
||||
onClick={() => handleImageRemove(field.key)}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{!isReadOnly && (
|
||||
<Form.Item>
|
||||
<Button
|
||||
type="dashed"
|
||||
onClick={() => onAddSparepartField()}
|
||||
icon={<PlusOutlined />}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
+ Add Sparepart
|
||||
</Button>
|
||||
</Form.Item>
|
||||
)}
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SparepartForm;
|
||||
@@ -1,178 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Select, Typography, Tag, Spin, Empty, Button } from 'antd';
|
||||
import { PlusOutlined, DeleteOutlined, CheckOutlined, EyeOutlined, InfoCircleOutlined } from '@ant-design/icons';
|
||||
import { getAllSparepart } from '../../../../api/sparepart';
|
||||
import CustomSparepartCard from './CustomSparepartCard';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
const { Option } = Select;
|
||||
|
||||
const SparepartSelect = ({
|
||||
selectedSparepartIds = [],
|
||||
onSparepartChange,
|
||||
isReadOnly = false
|
||||
}) => {
|
||||
const [spareparts, setSpareparts] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedSpareparts, setSelectedSpareparts] = useState([]);
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSpareparts();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedSparepartIds && selectedSparepartIds.length > 0) {
|
||||
const fullSelectedSpareparts = spareparts.filter(sp =>
|
||||
selectedSparepartIds.includes(sp.sparepart_id)
|
||||
);
|
||||
setSelectedSpareparts(fullSelectedSpareparts);
|
||||
} else {
|
||||
setSelectedSpareparts([]);
|
||||
}
|
||||
}, [selectedSparepartIds, spareparts]);
|
||||
|
||||
const fetchSpareparts = async (searchQuery = '') => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
params.set('limit', '10');
|
||||
|
||||
if (searchQuery && searchQuery.trim() !== '') {
|
||||
params.set('criteria', searchQuery.trim());
|
||||
}
|
||||
|
||||
const response = await getAllSparepart(params);
|
||||
if (response && (response.statusCode === 200 || response.data)) {
|
||||
const sparepartData = response.data?.data || response.data || [];
|
||||
setSpareparts(sparepartData);
|
||||
} else {
|
||||
setSpareparts([]);
|
||||
}
|
||||
} catch (error) {
|
||||
setSpareparts([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSparepartSelect = (sparepartId) => {
|
||||
const selectedSparepart = spareparts.find(sp => sp.sparepart_id === sparepartId);
|
||||
|
||||
if (selectedSparepart) {
|
||||
const isAlreadySelected = selectedSpareparts.some(sp => sp.sparepart_id === sparepartId);
|
||||
|
||||
if (!isAlreadySelected) {
|
||||
const newSelectedSpareparts = [...selectedSpareparts, selectedSparepart];
|
||||
setSelectedSpareparts(newSelectedSpareparts);
|
||||
|
||||
const newSelectedIds = newSelectedSpareparts.map(sp => sp.sparepart_id);
|
||||
onSparepartChange(newSelectedIds);
|
||||
}
|
||||
}
|
||||
setDropdownOpen(false);
|
||||
};
|
||||
|
||||
const handleSearch = (value) => {
|
||||
fetchSpareparts(value);
|
||||
};
|
||||
|
||||
const onDropdownOpenChange = (open) => {
|
||||
setDropdownOpen(open);
|
||||
if (open) {
|
||||
fetchSpareparts();
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveSparepart = (sparepartId) => {
|
||||
const newSelectedSpareparts = selectedSpareparts.filter(sp => sp.sparepart_id !== sparepartId);
|
||||
setSelectedSpareparts(newSelectedSpareparts);
|
||||
|
||||
const newSelectedIds = newSelectedSpareparts.map(sp => sp.sparepart_id);
|
||||
onSparepartChange(newSelectedIds);
|
||||
};
|
||||
|
||||
const renderSparepartCard = (sparepart, isSelected = false) => {
|
||||
const isAlreadySelected = selectedSpareparts.some(sp => sp.sparepart_id === sparepart.sparepart_id);
|
||||
|
||||
return (
|
||||
<CustomSparepartCard
|
||||
key={sparepart.sparepart_id}
|
||||
sparepart={sparepart}
|
||||
isSelected={isSelected}
|
||||
isReadOnly={isReadOnly}
|
||||
showPreview={true}
|
||||
showDelete={isAlreadySelected && !isReadOnly}
|
||||
onCardClick={!isAlreadySelected && !isReadOnly ? () => handleSparepartSelect(sparepart.sparepart_id) : undefined}
|
||||
onDelete={() => handleRemoveSparepart(sparepart.sparepart_id)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
{!isReadOnly && (
|
||||
<div style={{
|
||||
marginBottom: 16,
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 10,
|
||||
backgroundColor: 'white',
|
||||
padding: '8px 0',
|
||||
borderBottom: '1px solid #f0f0f0'
|
||||
}}>
|
||||
<Select
|
||||
placeholder="search and select sparepart"
|
||||
style={{ width: '100%' }}
|
||||
loading={loading}
|
||||
onSelect={handleSparepartSelect}
|
||||
value={null}
|
||||
showSearch
|
||||
onSearch={handleSearch}
|
||||
filterOption={false}
|
||||
open={dropdownOpen}
|
||||
onOpenChange={onDropdownOpenChange}
|
||||
suffixIcon={<PlusOutlined />}
|
||||
>
|
||||
{spareparts
|
||||
.filter(sparepart => !selectedSpareparts.some(sp => sp.sparepart_id === sparepart.sparepart_id))
|
||||
.slice(0, 10)
|
||||
.map((sparepart) => (
|
||||
<Option key={sparepart.sparepart_id} value={sparepart.sparepart_id}>
|
||||
<div>
|
||||
<Text strong>{sparepart.sparepart_name || sparepart.name || 'Unnamed'}</Text>
|
||||
<Text type="secondary" style={{ marginLeft: 8 }}>
|
||||
({sparepart.sparepart_code || 'No code'})
|
||||
</Text>
|
||||
</div>
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
<div>
|
||||
{selectedSpareparts.length > 0 ? (
|
||||
<div>
|
||||
<Title level={5} style={{ marginBottom: 16 }}>
|
||||
Selected Spareparts ({selectedSpareparts.length})
|
||||
</Title>
|
||||
<div>
|
||||
{selectedSpareparts.map(sparepart => renderSparepartCard(sparepart, true))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description="No spareparts selected"
|
||||
style={{ margin: '20px 0' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SparepartSelect;
|
||||
265
src/pages/master/brandDevice/hooks/errorCode.js
Normal file
@@ -0,0 +1,265 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { NotifAlert, NotifOk } from '../../../../components/Global/ToastNotif';
|
||||
|
||||
export const useErrorCodeLogic = (errorCodeForm, fileList) => {
|
||||
const [solutionFields, setSolutionFields] = useState([0]);
|
||||
const [solutionTypes, setSolutionTypes] = useState({ 0: 'text' });
|
||||
const [solutionStatuses, setSolutionStatuses] = useState({ 0: true });
|
||||
const [firstSolutionValid, setFirstSolutionValid] = useState(false);
|
||||
const [solutionsToDelete, setSolutionsToDelete] = useState(new Set());
|
||||
|
||||
const checkPreviousSolutionValid = (currentSolutionIndex) => {
|
||||
for (let i = 0; i < currentSolutionIndex; i++) {
|
||||
const fieldId = solutionFields[i];
|
||||
const solutionType = solutionTypes[fieldId];
|
||||
|
||||
const solutionName = errorCodeForm.getFieldValue(`solution_name_${fieldId}`);
|
||||
if (!solutionName || solutionName.trim() === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (solutionType === 'text') {
|
||||
const textSolution = errorCodeForm.getFieldValue(`text_solution_${fieldId}`);
|
||||
if (!textSolution || textSolution.trim() === '') {
|
||||
return false;
|
||||
}
|
||||
} else if (solutionType === 'file') {
|
||||
const filesForSolution = fileList.filter(file => file.solutionId === fieldId);
|
||||
if (filesForSolution.length === 0) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const checkFirstSolutionValid = () => {
|
||||
if (solutionFields.length === 0) {
|
||||
setFirstSolutionValid(false);
|
||||
return false;
|
||||
}
|
||||
const isValid = checkPreviousSolutionValid(1);
|
||||
setFirstSolutionValid(isValid);
|
||||
return isValid;
|
||||
};
|
||||
|
||||
const handleAddSolutionField = () => {
|
||||
const currentSolutionCount = solutionFields.length;
|
||||
const nextSolutionNumber = currentSolutionCount + 1;
|
||||
|
||||
if (!checkPreviousSolutionValid(currentSolutionCount)) {
|
||||
let incompleteSolutionIndex = -1;
|
||||
for (let i = 0; i < currentSolutionCount; i++) {
|
||||
const fieldId = solutionFields[i];
|
||||
const solutionType = solutionTypes[fieldId];
|
||||
const solutionName = errorCodeForm.getFieldValue(`solution_name_${fieldId}`);
|
||||
let hasContent = false;
|
||||
|
||||
if (solutionType === 'text') {
|
||||
const textSolution = errorCodeForm.getFieldValue(`text_solution_${fieldId}`);
|
||||
hasContent = textSolution && textSolution.trim();
|
||||
} else if (solutionType === 'file') {
|
||||
const filesForSolution = fileList.filter(file => file.solutionId === fieldId);
|
||||
hasContent = filesForSolution.length > 0;
|
||||
}
|
||||
|
||||
if (!solutionName?.trim() || !hasContent) {
|
||||
incompleteSolutionIndex = i + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
NotifAlert({
|
||||
icon: 'warning',
|
||||
title: 'Perhatian',
|
||||
message: `Harap lengkapi Solution ${incompleteSolutionIndex} terlebih dahulu sebelum menambah Solution ${nextSolutionNumber}!`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const newId = `new-${Date.now()}`;
|
||||
setSolutionFields(prev => [...prev, newId]);
|
||||
setSolutionTypes(prev => ({ ...prev, [newId]: 'text' }));
|
||||
setSolutionStatuses(prev => ({ ...prev, [newId]: true }));
|
||||
errorCodeForm.setFieldValue(`solution_status_${newId}`, true);
|
||||
errorCodeForm.setFieldValue(`solution_type_${newId}`, 'text');
|
||||
};
|
||||
|
||||
const handleRemoveSolutionField = (id) => {
|
||||
const isNewSolution = !id.toString().startsWith('existing-');
|
||||
|
||||
if (isNewSolution) {
|
||||
if (solutionFields.length > 1) {
|
||||
setSolutionFields(solutionFields.filter(fieldId => fieldId !== id));
|
||||
setSolutionTypes(prev => {
|
||||
const newTypes = { ...prev };
|
||||
delete newTypes[id];
|
||||
return newTypes;
|
||||
});
|
||||
setSolutionStatuses(prev => {
|
||||
const newStatuses = { ...prev };
|
||||
delete newStatuses[id];
|
||||
return newStatuses;
|
||||
});
|
||||
setSolutionsToDelete(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(id);
|
||||
return newSet;
|
||||
});
|
||||
} else {
|
||||
NotifAlert({
|
||||
icon: 'warning',
|
||||
title: 'Perhatian',
|
||||
message: 'Setiap error code harus memiliki minimal 1 solution!'
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const solutionName = errorCodeForm.getFieldValue(`solution_name_${id}`);
|
||||
const solutionType = solutionTypes[id];
|
||||
let isEmpty = true;
|
||||
|
||||
const existingSolution = window.currentSolutionData?.[id];
|
||||
const hasExistingData = existingSolution && (
|
||||
(existingSolution.solution_name && existingSolution.solution_name.trim()) ||
|
||||
(existingSolution.text_solution && existingSolution.text_solution.trim()) ||
|
||||
(existingSolution.path_solution && existingSolution.path_solution.trim())
|
||||
);
|
||||
|
||||
if (solutionType === 'text') {
|
||||
const textSolution = errorCodeForm.getFieldValue(`text_solution_${id}`);
|
||||
isEmpty = !solutionName?.trim() && !textSolution?.trim() && !hasExistingData;
|
||||
} else if (solutionType === 'file') {
|
||||
const filesForSolution = fileList.filter(file => file.solutionId === id);
|
||||
isEmpty = !solutionName?.trim() && filesForSolution.length === 0 && !hasExistingData;
|
||||
}
|
||||
|
||||
if (isEmpty) {
|
||||
if (solutionFields.length > 1) {
|
||||
setSolutionFields(solutionFields.filter(fieldId => fieldId !== id));
|
||||
setSolutionTypes(prev => {
|
||||
const newTypes = { ...prev };
|
||||
delete newTypes[id];
|
||||
return newTypes;
|
||||
});
|
||||
setSolutionStatuses(prev => {
|
||||
const newStatuses = { ...prev };
|
||||
delete newStatuses[id];
|
||||
return newStatuses;
|
||||
});
|
||||
|
||||
if (window.currentSolutionData) {
|
||||
delete window.currentSolutionData[id];
|
||||
}
|
||||
|
||||
setSolutionsToDelete(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(id);
|
||||
return newSet;
|
||||
});
|
||||
} else {
|
||||
NotifAlert({
|
||||
icon: 'warning',
|
||||
title: 'Perhatian',
|
||||
message: 'Setiap error code harus memiliki minimal 1 solution!'
|
||||
});
|
||||
}
|
||||
} else {
|
||||
if (solutionFields.length > 1) {
|
||||
setSolutionsToDelete(prev => new Set(prev).add(id));
|
||||
|
||||
const solutionElement = document.querySelector(`[data-solution-id="${id}"]`);
|
||||
if (solutionElement) {
|
||||
solutionElement.style.opacity = '0.5';
|
||||
solutionElement.style.border = '2px dashed #ff4d4f';
|
||||
}
|
||||
|
||||
NotifOk({
|
||||
icon: 'success',
|
||||
title: 'Berhasil',
|
||||
message: 'Solution ditandai untuk dihapus. Klik "Update Error Code" untuk menyimpan perubahan.'
|
||||
});
|
||||
} else {
|
||||
NotifAlert({
|
||||
icon: 'warning',
|
||||
title: 'Perhatian',
|
||||
message: 'Setiap error code harus memiliki minimal 1 solution!'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSolutionTypeChange = (fieldId, type) => {
|
||||
setSolutionTypes(prev => ({ ...prev, [fieldId]: type }));
|
||||
};
|
||||
|
||||
const handleSolutionStatusChange = (fieldId, status) => {
|
||||
// Only update local state - form is already updated by Form.Item
|
||||
setSolutionStatuses(prev => ({
|
||||
...prev,
|
||||
[fieldId]: status
|
||||
}));
|
||||
};
|
||||
|
||||
const setSolutionsForExistingRecord = (solutions, errorCodeForm) => {
|
||||
const newSolutionFields = [];
|
||||
const newSolutionTypes = {};
|
||||
const newSolutionStatuses = {};
|
||||
const newSolutionData = {};
|
||||
|
||||
solutions.forEach((solution, index) => {
|
||||
const fieldId = `existing-${index}`;
|
||||
newSolutionFields.push(fieldId);
|
||||
newSolutionTypes[fieldId] = solution.type_solution || 'text';
|
||||
newSolutionStatuses[fieldId] = solution.is_active !== false;
|
||||
newSolutionData[fieldId] = {
|
||||
...solution,
|
||||
brand_code_solution_id: solution.brand_code_solution_id
|
||||
};
|
||||
|
||||
setTimeout(() => {
|
||||
errorCodeForm.setFieldsValue({
|
||||
[`solution_name_${fieldId}`]: solution.solution_name,
|
||||
[`text_solution_${fieldId}`]: solution.text_solution || '',
|
||||
[`solution_status_${fieldId}`]: solution.is_active !== false,
|
||||
[`solution_type_${fieldId}`]: solution.type_solution === 'image' || solution.type_solution === 'pdf' ? 'file' : solution.type_solution
|
||||
});
|
||||
}, 100);
|
||||
});
|
||||
|
||||
setSolutionFields(newSolutionFields);
|
||||
setSolutionTypes(newSolutionTypes);
|
||||
setSolutionStatuses(newSolutionStatuses);
|
||||
window.currentSolutionData = newSolutionData;
|
||||
};
|
||||
|
||||
const resetSolutionFields = () => {
|
||||
setSolutionFields([0]);
|
||||
setSolutionTypes({ 0: 'text' });
|
||||
setSolutionStatuses({ 0: true });
|
||||
setFirstSolutionValid(false);
|
||||
setSolutionsToDelete(new Set());
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
checkFirstSolutionValid();
|
||||
}, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}, [solutionFields, solutionTypes, fileList, errorCodeForm]);
|
||||
|
||||
return {
|
||||
solutionFields,
|
||||
solutionTypes,
|
||||
solutionStatuses,
|
||||
firstSolutionValid,
|
||||
solutionsToDelete,
|
||||
handleAddSolutionField,
|
||||
handleRemoveSolutionField,
|
||||
handleSolutionTypeChange,
|
||||
handleSolutionStatusChange,
|
||||
resetSolutionFields,
|
||||
checkFirstSolutionValid,
|
||||
setSolutionsForExistingRecord
|
||||
};
|
||||
};
|
||||
166
src/pages/master/brandDevice/hooks/solution.js
Normal file
@@ -0,0 +1,166 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
export const useSolutionLogic = (solutionForm) => {
|
||||
const [solutionFields, setSolutionFields] = useState([
|
||||
{ name: ['solution_items', 0], key: 0 }
|
||||
]);
|
||||
const [solutionTypes, setSolutionTypes] = useState({ 0: 'text' });
|
||||
const [solutionStatuses, setSolutionStatuses] = useState({ 0: true });
|
||||
const [solutionsToDelete, setSolutionsToDelete] = useState([]);
|
||||
|
||||
const handleAddSolutionField = () => {
|
||||
const newKey = Date.now(); // Use timestamp for unique key
|
||||
const newField = { name: ['solution_items', newKey], key: newKey };
|
||||
|
||||
setSolutionFields(prev => [...prev, newField]);
|
||||
setSolutionTypes(prev => ({ ...prev, [newKey]: 'text' }));
|
||||
setSolutionStatuses(prev => ({ ...prev, [newKey]: true }));
|
||||
|
||||
// Set default values for the new field
|
||||
setTimeout(() => {
|
||||
solutionForm.setFieldValue(['solution_items', newKey, 'name'], '');
|
||||
solutionForm.setFieldValue(['solution_items', newKey, 'type'], 'text');
|
||||
solutionForm.setFieldValue(['solution_items', newKey, 'text'], '');
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const handleRemoveSolutionField = (key) => {
|
||||
if (solutionFields.length <= 1) {
|
||||
return; // Keep at least one solution field
|
||||
}
|
||||
|
||||
setSolutionFields(prev => prev.filter(field => field.key !== key));
|
||||
|
||||
// Clean up type and status
|
||||
const newTypes = { ...solutionTypes };
|
||||
const newStatuses = { ...solutionStatuses };
|
||||
delete newTypes[key];
|
||||
delete newStatuses[key];
|
||||
|
||||
setSolutionTypes(newTypes);
|
||||
setSolutionStatuses(newStatuses);
|
||||
};
|
||||
|
||||
const handleSolutionTypeChange = (key, value) => {
|
||||
setSolutionTypes(prev => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const handleSolutionStatusChange = (key, value) => {
|
||||
setSolutionStatuses(prev => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const resetSolutionFields = () => {
|
||||
setSolutionFields([{ name: ['solution_items', 0], key: 0 }]);
|
||||
setSolutionTypes({ 0: 'text' });
|
||||
setSolutionStatuses({ 0: true });
|
||||
|
||||
// Reset form values
|
||||
solutionForm.resetFields();
|
||||
solutionForm.setFieldsValue({
|
||||
solution_status_0: true,
|
||||
solution_type_0: 'text',
|
||||
});
|
||||
};
|
||||
|
||||
const checkFirstSolutionValid = () => {
|
||||
const values = solutionForm.getFieldsValue();
|
||||
const firstSolution = values.solution_items?.[0];
|
||||
|
||||
if (!firstSolution || !firstSolution.name || firstSolution.name.trim() === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (solutionTypes[0] === 'text' && (!firstSolution.text || firstSolution.text.trim() === '')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const getSolutionData = () => {
|
||||
const values = solutionForm.getFieldsValue();
|
||||
|
||||
const result = solutionFields.map(field => {
|
||||
const key = field.key;
|
||||
// Access form values using the key from field.name (AntD stores with comma)
|
||||
const solutionPath = field.name.join(',');
|
||||
const solution = values[solutionPath];
|
||||
|
||||
const validSolution = solution && solution.name && solution.name.trim() !== '';
|
||||
|
||||
if (validSolution) {
|
||||
return {
|
||||
solution_name: solution.name || 'Default Solution',
|
||||
type_solution: solutionTypes[key] || 'text',
|
||||
text_solution: solution.text || '',
|
||||
path_solution: solution.file || '',
|
||||
is_active: solution.status !== false, // Use form value directly
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}).filter(Boolean);
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const setSolutionsForExistingRecord = (solutions, form) => {
|
||||
if (!solutions || solutions.length === 0) return;
|
||||
|
||||
const newFields = solutions.map((solution, index) => ({
|
||||
name: ['solution_items', solution.id || index],
|
||||
key: solution.id || index
|
||||
}));
|
||||
|
||||
setSolutionFields(newFields);
|
||||
|
||||
// Set solution values
|
||||
const solutionsValues = {};
|
||||
const newTypes = {};
|
||||
const newStatuses = {};
|
||||
|
||||
solutions.forEach((solution, index) => {
|
||||
const key = solution.id || index;
|
||||
solutionsValues[key] = {
|
||||
name: solution.solution_name || '',
|
||||
type: solution.type_solution || 'text',
|
||||
text: solution.text_solution || '',
|
||||
file: solution.path_solution || '',
|
||||
};
|
||||
newTypes[key] = solution.type_solution || 'text';
|
||||
newStatuses[key] = solution.is_active !== false;
|
||||
});
|
||||
|
||||
// Set all form values at once
|
||||
const formValues = {};
|
||||
Object.keys(solutionsValues).forEach(key => {
|
||||
const solution = solutionsValues[key];
|
||||
formValues[`solution_items,${key}`] = {
|
||||
name: solution.name,
|
||||
type: solution.type,
|
||||
text: solution.text,
|
||||
file: solution.file,
|
||||
status: solution.is_active !== false
|
||||
};
|
||||
});
|
||||
|
||||
form.setFieldsValue(formValues);
|
||||
setSolutionTypes(newTypes);
|
||||
setSolutionStatuses(newStatuses);
|
||||
};
|
||||
|
||||
return {
|
||||
solutionFields,
|
||||
solutionTypes,
|
||||
solutionStatuses,
|
||||
solutionsToDelete,
|
||||
firstSolutionValid: checkFirstSolutionValid(),
|
||||
handleAddSolutionField,
|
||||
handleRemoveSolutionField,
|
||||
handleSolutionTypeChange,
|
||||
handleSolutionStatusChange,
|
||||
resetSolutionFields,
|
||||
checkFirstSolutionValid,
|
||||
getSolutionData,
|
||||
setSolutionsForExistingRecord,
|
||||
};
|
||||
};
|
||||
115
src/pages/master/brandDevice/hooks/sparepart.js
Normal file
@@ -0,0 +1,115 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
export const useSparepartLogic = (sparepartForm) => {
|
||||
const [sparepartFields, setSparepartFields] = useState([
|
||||
{ name: ['sparepart_items', 0], key: 0 }
|
||||
]);
|
||||
const [sparepartTypes, setSparepartTypes] = useState({ 0: 'required' });
|
||||
const [sparepartStatuses, setSparepartStatuses] = useState({ 0: true });
|
||||
|
||||
const handleAddSparepartField = () => {
|
||||
const newKey = Date.now(); // Use timestamp for unique key
|
||||
const newField = { name: ['sparepart_items', newKey], key: newKey };
|
||||
|
||||
setSparepartFields(prev => [...prev, newField]);
|
||||
setSparepartTypes(prev => ({ ...prev, [newKey]: 'required' }));
|
||||
setSparepartStatuses(prev => ({ ...prev, [newKey]: true }));
|
||||
|
||||
// Set default values for the new field
|
||||
setTimeout(() => {
|
||||
sparepartForm.setFieldValue(['sparepart_items', newKey, 'type'], 'required');
|
||||
sparepartForm.setFieldValue(['sparepart_items', newKey, 'quantity'], 1);
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const handleRemoveSparepartField = (key) => {
|
||||
if (sparepartFields.length <= 1) {
|
||||
return; // Keep at least one sparepart field
|
||||
}
|
||||
|
||||
setSparepartFields(prev => prev.filter(field => field.key !== key));
|
||||
|
||||
// Clean up type and status
|
||||
const newTypes = { ...sparepartTypes };
|
||||
const newStatuses = { ...sparepartStatuses };
|
||||
delete newTypes[key];
|
||||
delete newStatuses[key];
|
||||
|
||||
setSparepartTypes(newTypes);
|
||||
setSparepartStatuses(newStatuses);
|
||||
};
|
||||
|
||||
const handleSparepartTypeChange = (key, value) => {
|
||||
setSparepartTypes(prev => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const handleSparepartStatusChange = (key, value) => {
|
||||
setSparepartStatuses(prev => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const resetSparepartFields = () => {
|
||||
setSparepartFields([{ name: ['sparepart_items', 0], key: 0 }]);
|
||||
setSparepartTypes({ 0: 'required' });
|
||||
setSparepartStatuses({ 0: true });
|
||||
|
||||
// Reset form values
|
||||
sparepartForm.resetFields();
|
||||
sparepartForm.setFieldsValue({
|
||||
sparepart_status_0: true,
|
||||
sparepart_type_0: 'required',
|
||||
});
|
||||
};
|
||||
|
||||
const getSparepartData = () => {
|
||||
const values = sparepartForm.getFieldsValue();
|
||||
return sparepartFields.map(field => {
|
||||
const key = field.key;
|
||||
const sparepartPath = field.name.join(',');
|
||||
const sparepart = values[sparepartPath];
|
||||
|
||||
return sparepart && sparepart.name && sparepart.name.trim() !== '' ? {
|
||||
name: sparepart.name || '',
|
||||
description: sparepart.description || '',
|
||||
is_active: sparepart.status !== false,
|
||||
} : null;
|
||||
}).filter(Boolean);
|
||||
};
|
||||
|
||||
const setSparepartForExistingRecord = (spareparts, form) => {
|
||||
if (!spareparts || spareparts.length === 0) return;
|
||||
|
||||
const newFields = spareparts.map((sparepart, index) => ({
|
||||
name: ['sparepart_items', sparepart.id || index],
|
||||
key: sparepart.id || index
|
||||
}));
|
||||
|
||||
setSparepartFields(newFields);
|
||||
|
||||
// Set sparepart values
|
||||
const formValues = {};
|
||||
Object.keys(spareparts).forEach(index => {
|
||||
const key = spareparts[index].id || index;
|
||||
const sparepart = spareparts[index];
|
||||
formValues[`sparepart_items,${key}`] = {
|
||||
name: sparepart.name || '',
|
||||
description: sparepart.description || '',
|
||||
status: sparepart.is_active !== false,
|
||||
};
|
||||
});
|
||||
|
||||
form.setFieldsValue(formValues);
|
||||
};
|
||||
|
||||
return {
|
||||
sparepartFields,
|
||||
sparepartTypes,
|
||||
sparepartStatuses,
|
||||
handleAddSparepartField,
|
||||
handleRemoveSparepartField,
|
||||
handleSparepartTypeChange,
|
||||
handleSparepartStatusChange,
|
||||
resetSparepartFields,
|
||||
getSparepartData,
|
||||
setSparepartForExistingRecord,
|
||||
};
|
||||
};
|
||||
@@ -1,8 +1,7 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Modal, Input, Divider, Typography, Switch, Button, ConfigProvider, Select } from 'antd';
|
||||
import { Modal, Input, Divider, Typography, Switch, Button, ConfigProvider } from 'antd';
|
||||
import { NotifAlert, NotifOk } from '../../../../components/Global/ToastNotif';
|
||||
import { createDevice, updateDevice } from '../../../../api/master-device';
|
||||
import { getAllBrands } from '../../../../api/master-brand';
|
||||
import { validateRun } from '../../../../Utils/validate';
|
||||
|
||||
const { Text } = Typography;
|
||||
@@ -10,20 +9,16 @@ const { TextArea } = Input;
|
||||
|
||||
const DetailDevice = (props) => {
|
||||
const [confirmLoading, setConfirmLoading] = useState(false);
|
||||
const [brands, setBrands] = useState([]);
|
||||
const [loadingBrands, setLoadingBrands] = useState(false);
|
||||
|
||||
const defaultData = {
|
||||
device_id: '',
|
||||
device_code: '',
|
||||
device_name: '',
|
||||
brand_id: '',
|
||||
brand_code: '',
|
||||
brand_device: '',
|
||||
is_active: true,
|
||||
device_location: '',
|
||||
device_description: '',
|
||||
ip_address: '',
|
||||
listen_channel: '',
|
||||
};
|
||||
|
||||
const [formData, setFormData] = useState(defaultData);
|
||||
@@ -40,7 +35,6 @@ const DetailDevice = (props) => {
|
||||
const validationRules = [
|
||||
{ field: 'device_name', label: 'Device Name', required: true },
|
||||
{ field: 'ip_address', label: 'Ip Address', required: true, ip: true },
|
||||
{ field: 'brand_id', label: 'Brand Device', required: true },
|
||||
];
|
||||
|
||||
if (
|
||||
@@ -60,13 +54,8 @@ const DetailDevice = (props) => {
|
||||
device_name: formData.device_name,
|
||||
is_active: formData.is_active,
|
||||
device_location: formData.device_location,
|
||||
device_description:
|
||||
formData.device_description && formData.device_description.trim() !== ''
|
||||
? formData.device_description
|
||||
: ' ',
|
||||
device_description: formData.device_description,
|
||||
ip_address: formData.ip_address,
|
||||
brand_id: formData.brand_id,
|
||||
listen_channel: formData.listen_channel,
|
||||
};
|
||||
|
||||
const response = formData.device_id
|
||||
@@ -113,13 +102,6 @@ const DetailDevice = (props) => {
|
||||
});
|
||||
};
|
||||
|
||||
const handleSelectChange = (name, value) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
[name]: value,
|
||||
});
|
||||
};
|
||||
|
||||
const handleStatusToggle = (event) => {
|
||||
const isChecked = event;
|
||||
setFormData({
|
||||
@@ -128,32 +110,6 @@ const DetailDevice = (props) => {
|
||||
});
|
||||
};
|
||||
|
||||
// Fungsi untuk mengambil daftar brand
|
||||
const fetchBrands = async () => {
|
||||
setLoadingBrands(true);
|
||||
try {
|
||||
const response = await getAllBrands(new URLSearchParams());
|
||||
if (response && response.data) {
|
||||
setBrands(response.data || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching brands:', error);
|
||||
NotifAlert({
|
||||
icon: 'error',
|
||||
title: 'Error',
|
||||
message: error.message || 'Gagal mengambil data brand',
|
||||
});
|
||||
} finally {
|
||||
setLoadingBrands(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (props.showModal && (props.actionMode === 'add' || props.actionMode === 'edit')) {
|
||||
fetchBrands();
|
||||
}
|
||||
}, [props.showModal, props.actionMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (props.selectedData) {
|
||||
setFormData(props.selectedData);
|
||||
@@ -187,6 +143,7 @@ const DetailDevice = (props) => {
|
||||
defaultBorderColor: '#23A55A',
|
||||
defaultHoverColor: '#23A55A',
|
||||
defaultHoverBorderColor: '#23A55A',
|
||||
defaultHoverColor: '#23A55A',
|
||||
},
|
||||
},
|
||||
}}
|
||||
@@ -287,26 +244,19 @@ const DetailDevice = (props) => {
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<Text strong>Brand Device</Text>
|
||||
<Text style={{ color: 'red' }}> *</Text>
|
||||
<Select
|
||||
name="brand_id"
|
||||
value={formData.brand_id}
|
||||
onChange={(value) => handleSelectChange('brand_id', value)}
|
||||
placeholder="Select Brand Device"
|
||||
disabled={props.readOnly}
|
||||
loading={loadingBrands}
|
||||
style={{ width: '100%' }}
|
||||
allowClear
|
||||
showSearch
|
||||
filterOption={(input, option) =>
|
||||
option.children.toLowerCase().includes(input.toLowerCase())
|
||||
}
|
||||
>
|
||||
{brands.map((brand) => (
|
||||
<Select.Option key={brand.brand_id} value={brand.brand_id}>
|
||||
{`${brand.brand_code} - ${brand.brand_name} `}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<Input
|
||||
name="brand_device"
|
||||
value={formData.brand_device}
|
||||
onChange={handleInputChange}
|
||||
placeholder="Enter Brand Device"
|
||||
readOnly={props.readOnly}
|
||||
disabled
|
||||
style={{
|
||||
backgroundColor: '#f5f5f5',
|
||||
cursor: 'not-allowed',
|
||||
color: formData.brand_device ? '#000000' : '#bfbfbf',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<Text strong>Device Location</Text>
|
||||
@@ -330,16 +280,6 @@ 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,6 +101,7 @@ const GeneratePdf = (props) => {
|
||||
defaultBorderColor: '#23A55A',
|
||||
defaultHoverColor: '#23A55A',
|
||||
defaultHoverBorderColor: '#23A55A',
|
||||
defaultHoverColor: '#23A55A',
|
||||
},
|
||||
},
|
||||
}}
|
||||
|
||||
@@ -13,7 +13,6 @@ import { NotifAlert, NotifOk, NotifConfirmDialog } from '../../../../components/
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { deleteDevice, getAllDevice } from '../../../../api/master-device';
|
||||
import TableList from '../../../../components/Global/TableList';
|
||||
import { getAllBrands } from '../../../../api/master-brand';
|
||||
|
||||
const columns = (showPreviewModal, showEditModal, showDeleteDialog) => [
|
||||
{
|
||||
@@ -45,10 +44,9 @@ const columns = (showPreviewModal, showEditModal, showDeleteDialog) => [
|
||||
},
|
||||
{
|
||||
title: 'Brand Device',
|
||||
dataIndex: 'brand_name',
|
||||
key: 'brand_name',
|
||||
dataIndex: 'brand_device',
|
||||
key: 'brand_device',
|
||||
width: '20%',
|
||||
render: (brand_name) => brand_name || '-'
|
||||
},
|
||||
{
|
||||
title: 'Location',
|
||||
@@ -62,13 +60,6 @@ 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,20 +74,16 @@ const DetailPlantSubSection = (props) => {
|
||||
return;
|
||||
|
||||
try {
|
||||
// console.log('💾 Current formData before save:', formData);
|
||||
console.log('💾 Current formData before save:', formData);
|
||||
|
||||
const payload = {
|
||||
plant_sub_section_name: formData.plant_sub_section_name,
|
||||
plant_sub_section_description:
|
||||
formData.plant_sub_section_description &&
|
||||
formData.plant_sub_section_description.trim() !== ''
|
||||
? formData.plant_sub_section_description
|
||||
: ' ',
|
||||
plant_sub_section_description: formData.plant_sub_section_description,
|
||||
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'
|
||||
@@ -130,17 +126,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]);
|
||||
|
||||
@@ -237,7 +237,7 @@ const ListPlantSubSection = memo(function ListPlantSubSection(props) {
|
||||
<TableList
|
||||
mobile
|
||||
cardColor={'#42AAFF'}
|
||||
header={'plant_sub_section_name'}
|
||||
header={'sub_section_name'}
|
||||
showPreviewModal={showPreviewModal}
|
||||
showEditModal={showEditModal}
|
||||
showDeleteDialog={showDeleteDialog}
|
||||
|
||||
@@ -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,75 +0,0 @@
|
||||
import React, { memo, useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useBreadcrumb } from '../../../layout/LayoutBreadcrumb';
|
||||
import { Typography } from 'antd';
|
||||
import ListSparepart from './component/ListSparepart';
|
||||
import DetailSparepart from './component/DetailSparepart';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const IndexSparepart = memo(function IndexSparepart() {
|
||||
const navigate = useNavigate();
|
||||
const { setBreadcrumbItems } = useBreadcrumb();
|
||||
|
||||
const [actionMode, setActionMode] = useState('list');
|
||||
const [selectedData, setSelectedData] = useState(null);
|
||||
const [readOnly, setReadOnly] = useState(false);
|
||||
const [showModal, setShowmodal] = useState(false);
|
||||
|
||||
const setMode = (param) => {
|
||||
setShowmodal(true);
|
||||
switch (param) {
|
||||
case 'add':
|
||||
setReadOnly(false);
|
||||
break;
|
||||
|
||||
case 'edit':
|
||||
setReadOnly(false);
|
||||
break;
|
||||
|
||||
case 'preview':
|
||||
setReadOnly(true);
|
||||
break;
|
||||
|
||||
default:
|
||||
setShowmodal(false);
|
||||
break;
|
||||
}
|
||||
setActionMode(param);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
setBreadcrumbItems([
|
||||
{ title: <Text strong style={{ fontSize: '14px' }}>• Master</Text> },
|
||||
{ title: <Text strong style={{ fontSize: '14px' }}>Sparepart</Text> }
|
||||
]);
|
||||
} else {
|
||||
navigate('/signin');
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<ListSparepart
|
||||
actionMode={actionMode}
|
||||
setActionMode={setMode}
|
||||
selectedData={selectedData}
|
||||
setSelectedData={setSelectedData}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
<DetailSparepart
|
||||
setActionMode={setMode}
|
||||
selectedData={selectedData}
|
||||
setSelectedData={setSelectedData}
|
||||
readOnly={readOnly}
|
||||
showModal={showModal}
|
||||
permitDefault={false}
|
||||
actionMode={actionMode}
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
});
|
||||
|
||||
export default IndexSparepart;
|
||||
@@ -1,591 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
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: null,
|
||||
sparepart_qty: 0,
|
||||
sparepart_unit: '',
|
||||
sparepart_merk: '',
|
||||
sparepart_stok: 'Not Available',
|
||||
sparepart_foto: '',
|
||||
};
|
||||
|
||||
const [formData, setFormData] = useState(defaultData);
|
||||
|
||||
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);
|
||||
|
||||
const validationRules = [
|
||||
{ field: 'sparepart_name', label: 'Sparepart Name', required: true },
|
||||
];
|
||||
|
||||
if (
|
||||
validateRun(formData, validationRules, (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, // 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);
|
||||
|
||||
// console.log('API response:', response);
|
||||
|
||||
if (response && (response.statusCode === 200 || response.statusCode === 201)) {
|
||||
NotifOk({
|
||||
icon: 'success',
|
||||
title: 'Berhasil',
|
||||
message: `Data Sparepart berhasil ${
|
||||
formData.sparepart_id ? 'diubah' : 'ditambahkan'
|
||||
}.`,
|
||||
});
|
||||
props.setActionMode('list');
|
||||
setFileList([]);
|
||||
} else {
|
||||
NotifAlert({
|
||||
icon: 'error',
|
||||
title: 'Gagal',
|
||||
message: response?.message || 'Terjadi kesalahan saat menyimpan data.',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Save Sparepart Error:', error);
|
||||
NotifAlert({
|
||||
icon: 'error',
|
||||
title: 'Error',
|
||||
message: error.message || 'Terjadi kesalahan pada server. Coba lagi nanti.',
|
||||
});
|
||||
}
|
||||
|
||||
setConfirmLoading(false);
|
||||
};
|
||||
|
||||
const handleInputChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData({ ...formData, [name]: value });
|
||||
};
|
||||
|
||||
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={`${
|
||||
props.actionMode === 'add'
|
||||
? 'Tambah'
|
||||
: props.actionMode === 'preview'
|
||||
? 'Preview'
|
||||
: 'Edit'
|
||||
} Sparepart`}
|
||||
open={props.showModal}
|
||||
onCancel={handleCancel}
|
||||
footer={[
|
||||
<React.Fragment key="modal-footer">
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
token: { colorBgContainer: '#E9F6EF' },
|
||||
components: {
|
||||
Button: {
|
||||
defaultBg: 'white',
|
||||
defaultColor: '#23A55A',
|
||||
defaultBorderColor: '#23A55A',
|
||||
defaultHoverColor: '#23A55A',
|
||||
defaultHoverBorderColor: '#23A55A',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Button onClick={handleCancel}>Batal</Button>
|
||||
</ConfigProvider>
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
token: { colorBgContainer: '#209652' },
|
||||
components: {
|
||||
Button: {
|
||||
defaultBg: '#23a55a',
|
||||
defaultColor: '#FFFFFF',
|
||||
defaultBorderColor: '#23a55a',
|
||||
defaultHoverColor: '#FFFFFF',
|
||||
defaultHoverBorderColor: '#23a55a',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
{!props.readOnly && (
|
||||
<Button loading={confirmLoading} onClick={handleSave}>
|
||||
Simpan
|
||||
</Button>
|
||||
)}
|
||||
</ConfigProvider>
|
||||
</React.Fragment>,
|
||||
]}
|
||||
>
|
||||
{formData && (
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
export default DetailSparepart;
|
||||
@@ -1,292 +0,0 @@
|
||||
import React, { memo, useState, useEffect } from 'react';
|
||||
import { Space, Tag, ConfigProvider, Button, Row, Col, Card, Input, Segmented } from 'antd';
|
||||
import {
|
||||
PlusOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
EyeOutlined,
|
||||
SearchOutlined,
|
||||
AppstoreOutlined,
|
||||
TableOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { NotifAlert, NotifOk, NotifConfirmDialog } from '../../../../components/Global/ToastNotif';
|
||||
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) => [
|
||||
{
|
||||
title: 'No',
|
||||
key: 'no',
|
||||
width: '5%',
|
||||
align: 'center',
|
||||
render: (_, __, index) => index + 1,
|
||||
},
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'sparepart_id',
|
||||
key: 'sparepart_id',
|
||||
width: '5%',
|
||||
hidden: 'true',
|
||||
},
|
||||
{
|
||||
title: 'Sparepart Name',
|
||||
dataIndex: 'sparepart_name',
|
||||
key: 'sparepart_name',
|
||||
width: '20%',
|
||||
},
|
||||
{
|
||||
title: 'Description',
|
||||
dataIndex: 'sparepart_description',
|
||||
key: 'sparepart_description',
|
||||
width: '20%',
|
||||
render: (sparepart_description) => sparepart_description || '-'
|
||||
},
|
||||
{
|
||||
title: 'Model',
|
||||
dataIndex: 'sparepart_model',
|
||||
key: 'sparepart_model',
|
||||
width: '10%',
|
||||
render: (sparepart_model) => sparepart_model || '-'
|
||||
},
|
||||
{
|
||||
title: 'Item Type',
|
||||
dataIndex: 'sparepart_item_type',
|
||||
key: 'sparepart_item_type',
|
||||
width: '10%',
|
||||
render: (sparepart_item_type) => sparepart_item_type || '-'
|
||||
},
|
||||
{
|
||||
title: 'Unit',
|
||||
dataIndex: 'sparepart_unit',
|
||||
key: 'sparepart_unit',
|
||||
width: '8%',
|
||||
render: (sparepart_unit) => sparepart_unit || '-'
|
||||
},
|
||||
{
|
||||
title: 'Merk',
|
||||
dataIndex: 'sparepart_merk',
|
||||
key: 'sparepart_merk',
|
||||
width: '12%',
|
||||
render: (sparepart_merk) => sparepart_merk || '-'
|
||||
},
|
||||
{
|
||||
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 || 'Not Available'
|
||||
},
|
||||
{
|
||||
title: 'Action',
|
||||
key: 'aksi',
|
||||
align: 'center',
|
||||
width: '12%',
|
||||
render: (_, record) => (
|
||||
<Space>
|
||||
<Button
|
||||
type="text"
|
||||
style={{ borderColor: '#1890ff' }}
|
||||
icon={<EyeOutlined style={{ color: '#1890ff' }} />}
|
||||
onClick={() => showPreviewModal(record)}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
style={{ borderColor: '#faad14' }}
|
||||
icon={<EditOutlined style={{ color: '#faad14' }} />}
|
||||
onClick={() => showEditModal(record)}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
style={{ borderColor: 'red' }}
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => showDeleteDialog(record)}
|
||||
/>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const ListSparepart = memo(function ListSparepart(props) {
|
||||
const [trigerFilter, setTrigerFilter] = useState(false);
|
||||
|
||||
const defaultFilter = { criteria: '' };
|
||||
const [formDataFilter, setFormDataFilter] = useState(defaultFilter);
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
if (props.actionMode === 'list') {
|
||||
setFormDataFilter(defaultFilter);
|
||||
doFilter();
|
||||
}
|
||||
} else {
|
||||
navigate('/signin');
|
||||
}
|
||||
}, [props.actionMode]);
|
||||
|
||||
const doFilter = () => {
|
||||
setTrigerFilter((prev) => !prev);
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
setFormDataFilter({ criteria: searchValue });
|
||||
setTrigerFilter((prev) => !prev);
|
||||
};
|
||||
|
||||
const handleSearchClear = () => {
|
||||
setSearchValue('');
|
||||
setFormDataFilter({ criteria: '' });
|
||||
setTrigerFilter((prev) => !prev);
|
||||
};
|
||||
|
||||
const showPreviewModal = (param) => {
|
||||
props.setSelectedData(param);
|
||||
props.setActionMode('preview');
|
||||
};
|
||||
|
||||
const showEditModal = (param = null) => {
|
||||
props.setSelectedData(param);
|
||||
props.setActionMode('edit');
|
||||
};
|
||||
|
||||
const showAddModal = (param = null) => {
|
||||
props.setSelectedData(param);
|
||||
props.setActionMode('add');
|
||||
};
|
||||
|
||||
const showDeleteDialog = (param) => {
|
||||
NotifConfirmDialog({
|
||||
icon: 'question',
|
||||
title: 'Konfirmasi Hapus',
|
||||
message: 'Sparepart "' + param.sparepart_name + '" akan dihapus?',
|
||||
onConfirm: () => handleDelete(param.sparepart_id),
|
||||
onCancel: () => props.setSelectedData(null),
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = async (sparepart_id) => {
|
||||
const response = await deleteSparepart(sparepart_id);
|
||||
|
||||
if (response.statusCode === 200 && response.data === true) {
|
||||
NotifAlert({
|
||||
icon: 'success',
|
||||
title: 'Berhasil',
|
||||
message: response.message || 'Data Sparepart berhasil dihapus.',
|
||||
});
|
||||
doFilter();
|
||||
} else {
|
||||
NotifOk({
|
||||
icon: 'error',
|
||||
title: 'Gagal',
|
||||
message: response?.message || 'Gagal Menghapus Data Sparepart',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<Card>
|
||||
<Row>
|
||||
<Col xs={24}>
|
||||
<Row justify="space-between" align="middle" gutter={[8, 8]}>
|
||||
<Col xs={24} sm={24} md={12} lg={12}>
|
||||
<Input.Search
|
||||
placeholder="Search sparepart by name, model, or merk..."
|
||||
value={searchValue}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setSearchValue(value);
|
||||
if (value === '') {
|
||||
setFormDataFilter({ criteria: '' });
|
||||
setTrigerFilter((prev) => !prev);
|
||||
}
|
||||
}}
|
||||
onSearch={handleSearch}
|
||||
allowClear={{
|
||||
clearIcon: <span onClick={handleSearchClear}>✕</span>,
|
||||
}}
|
||||
enterButton={
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SearchOutlined />}
|
||||
style={{
|
||||
backgroundColor: '#23A55A',
|
||||
borderColor: '#23A55A',
|
||||
}}
|
||||
>
|
||||
Search
|
||||
</Button>
|
||||
}
|
||||
size="large"
|
||||
/>
|
||||
</Col>
|
||||
<Col>
|
||||
<Space wrap size="small">
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
token: { colorBgContainer: '#E9F6EF' },
|
||||
components: {
|
||||
Button: {
|
||||
defaultBg: 'white',
|
||||
defaultColor: '#23A55A',
|
||||
defaultBorderColor: '#23A55A',
|
||||
defaultHoverColor: '#23A55A',
|
||||
defaultHoverBorderColor: '#23A55A',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => showAddModal()}
|
||||
size="large"
|
||||
>
|
||||
Add data
|
||||
</Button>
|
||||
</ConfigProvider>
|
||||
</Space>
|
||||
</Col>
|
||||
</Row>
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={24} lg={24} xl={24} style={{ marginTop: '16px' }}>
|
||||
<TableList
|
||||
mobile
|
||||
cardColor={'#42AAFF'}
|
||||
header={'sparepart_name'}
|
||||
showPreviewModal={showPreviewModal}
|
||||
showEditModal={showEditModal}
|
||||
showDeleteDialog={showDeleteDialog}
|
||||
getData={getAllSparepart}
|
||||
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>
|
||||
</Card>
|
||||
</React.Fragment>
|
||||
);
|
||||
});
|
||||
|
||||
export default ListSparepart;
|
||||
@@ -1,395 +0,0 @@
|
||||
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 && formData.status_description.trim() !== '') ? formData.status_description : ' ',
|
||||
status_description: formData.status_description,
|
||||
is_active: formData.is_active,
|
||||
};
|
||||
|
||||
|
||||
@@ -84,32 +84,12 @@ const DetailTag = (props) => {
|
||||
const params = new URLSearchParams({ limit: 10000 });
|
||||
const response = await getAllTag(params);
|
||||
|
||||
// Handle different response structures
|
||||
let existingTags = [];
|
||||
if (response) {
|
||||
if (Array.isArray(response)) {
|
||||
existingTags = response;
|
||||
} else if (response.data && Array.isArray(response.data)) {
|
||||
existingTags = response.data;
|
||||
} else if (response.data && response.data.data && Array.isArray(response.data.data)) {
|
||||
existingTags = response.data.data;
|
||||
}
|
||||
}
|
||||
if (response && response.data && response.data.data) {
|
||||
const existingTags = response.data.data;
|
||||
|
||||
if (existingTags.length > 0) {
|
||||
const isDuplicate = existingTags.some((tag) => {
|
||||
// Handle both string and number tag_number
|
||||
const existingTagNumber = Number(tag.tag_number);
|
||||
const currentTagNumber = Number(formData.tag_number);
|
||||
|
||||
// Check if numbers are valid and equal
|
||||
const isSameNumber = !isNaN(existingTagNumber) && !isNaN(currentTagNumber) &&
|
||||
existingTagNumber === currentTagNumber;
|
||||
|
||||
// For edit mode, exclude the current tag from duplicate check
|
||||
const isDifferentTag = formData.tag_id ?
|
||||
String(tag.tag_id) !== String(formData.tag_id) : true;
|
||||
|
||||
const isSameNumber = Number(tag.tag_number) === tagNumberInt;
|
||||
const isDifferentTag = formData.tag_id ? tag.tag_id !== formData.tag_id : true;
|
||||
return isSameNumber && isDifferentTag;
|
||||
});
|
||||
|
||||
@@ -117,7 +97,7 @@ const DetailTag = (props) => {
|
||||
NotifOk({
|
||||
icon: 'warning',
|
||||
title: 'Peringatan',
|
||||
message: `Tag Number ${formData.tag_number} sudah digunakan. Silakan gunakan nomor yang berbeda.`,
|
||||
message: `Tag Number ${tagNumberInt} sudah digunakan. Silakan gunakan nomor yang berbeda.`,
|
||||
});
|
||||
setConfirmLoading(false);
|
||||
return;
|
||||
@@ -168,7 +148,10 @@ const DetailTag = (props) => {
|
||||
payload.unit = formData.unit.trim();
|
||||
}
|
||||
|
||||
payload.tag_description = (formData.tag_description && formData.tag_description.trim() !== '') ? formData.tag_description.trim() : ' ';
|
||||
// Add tag_description only if it has a value
|
||||
if (formData.tag_description && formData.tag_description.trim() !== '') {
|
||||
payload.tag_description = formData.tag_description.trim();
|
||||
}
|
||||
|
||||
// Add device_id only if it has a value
|
||||
if (formData.device_id) {
|
||||
|
||||
@@ -63,7 +63,7 @@ const columns = (showPreviewModal, showEditModal, showDeleteDialog) => [
|
||||
render: (text) => text || '-',
|
||||
},
|
||||
{
|
||||
title: 'Plant Sub Section',
|
||||
title: 'Sub Section',
|
||||
dataIndex: 'plant_sub_section_name',
|
||||
key: 'plant_sub_section_name',
|
||||
width: '10%',
|
||||
|
||||
@@ -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, Row, Col } from 'antd';
|
||||
import { Typography } from 'antd';
|
||||
import ListNotification from './component/ListNotification';
|
||||
import DetailNotification from './component/DetailNotification';
|
||||
|
||||
@@ -10,7 +10,10 @@ 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');
|
||||
@@ -29,34 +32,33 @@ const IndexNotification = memo(function IndexNotification() {
|
||||
}
|
||||
}, [navigate, setBreadcrumbItems]);
|
||||
|
||||
const handleCloseDetail = () => {
|
||||
useEffect(() => {
|
||||
if (actionMode === 'preview') {
|
||||
setIsModalVisible(true);
|
||||
} else {
|
||||
setIsModalVisible(false);
|
||||
}
|
||||
}, [actionMode]);
|
||||
|
||||
const handleCancel = () => {
|
||||
setActionMode('list');
|
||||
setSelectedData(null);
|
||||
};
|
||||
|
||||
// This handler will be passed to ListNotification to update the selected item
|
||||
const handleSelectNotification = (data) => {
|
||||
setSelectedData(data);
|
||||
};
|
||||
|
||||
return (
|
||||
<Row gutter={16}>
|
||||
<Col span={selectedData ? 16 : 24}>
|
||||
<React.Fragment>
|
||||
<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
|
||||
actionMode={actionMode}
|
||||
setActionMode={setActionMode}
|
||||
selectedData={selectedData}
|
||||
onClose={handleCloseDetail}
|
||||
setSelectedData={setSelectedData}
|
||||
/>
|
||||
</Col>
|
||||
)}
|
||||
</Row>
|
||||
<DetailNotification
|
||||
visible={isModalVisible}
|
||||
onCancel={handleCancel}
|
||||
selectedData={selectedData}
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,30 +1,8 @@
|
||||
import React, { memo } from 'react';
|
||||
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
|
||||
};
|
||||
import { Modal, Row, Col, Tag, Divider } from 'antd';
|
||||
import { CloseCircleFilled, WarningFilled, CheckCircleFilled, InfoCircleFilled } from '@ant-design/icons';
|
||||
|
||||
const DetailNotification = memo(function DetailNotification({ visible, onCancel, form, selectedData }) {
|
||||
const getIconAndColor = (type) => {
|
||||
switch (type) {
|
||||
case 'critical':
|
||||
@@ -58,194 +36,133 @@ const DetailNotification = memo(function DetailNotification({ selectedData, onCl
|
||||
}
|
||||
};
|
||||
|
||||
const notificationType = getTypeFromStatus();
|
||||
const { IconComponent, color, bgColor, tagColor } = getIconAndColor(notificationType);
|
||||
const { IconComponent, color, bgColor, tagColor } = selectedData ? getIconAndColor(selectedData.type) : {};
|
||||
|
||||
return (
|
||||
<Card
|
||||
<Modal
|
||||
title="Detail Notifikasi"
|
||||
extra={<Button onClick={onClose}>Tutup</Button>}
|
||||
style={{ height: '100%' }}
|
||||
bodyStyle={{ padding: '0 24px' }}
|
||||
open={visible}
|
||||
onCancel={onCancel}
|
||||
onOk={onCancel}
|
||||
okText="Tutup"
|
||||
cancelButtonProps={{ style: { display: 'none' } }}
|
||||
width={700}
|
||||
>
|
||||
{selectedData && (
|
||||
<div>
|
||||
{/* Header with Icon and Status */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
marginBottom: '0',
|
||||
padding: '2px 0',
|
||||
gap: '16px',
|
||||
marginBottom: '24px',
|
||||
padding: '16px',
|
||||
backgroundColor: '#fafafa',
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
width: '64px',
|
||||
height: '64px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: bgColor,
|
||||
color: color,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '18px',
|
||||
fontSize: '32px',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{IconComponent && <IconComponent style={{ fontSize: '18px' }} />}
|
||||
{IconComponent && <IconComponent style={{ fontSize: '32px' }} />}
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<Tag color={tagColor} style={{ marginBottom: '2px', fontSize: '11px' }}>
|
||||
{notificationType.toUpperCase()}
|
||||
<Tag color={tagColor} style={{ marginBottom: '8px', fontSize: '12px' }}>
|
||||
{selectedData.type.toUpperCase()}
|
||||
</Tag>
|
||||
<div style={{ fontSize: '14px', fontWeight: 600, color: '#262626' }}>
|
||||
{errorCodeData?.error_code_name || 'N/A'}
|
||||
<div style={{ fontSize: '16px', fontWeight: 600, color: '#262626' }}>
|
||||
{selectedData.title}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider style={{ margin: '16px 0' }} />
|
||||
|
||||
{/* Information Grid */}
|
||||
<Row gutter={[16, 0]}>
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col span={12}>
|
||||
<div style={{ marginBottom: '2px' }}>
|
||||
<div style={{ fontSize: '11px', color: '#8c8c8c', marginBottom: '0' }}>
|
||||
Kode Error
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<div style={{ fontSize: '12px', color: '#8c8c8c', marginBottom: '4px' }}>
|
||||
PLC
|
||||
</div>
|
||||
<div style={{ fontSize: '13px', color: '#262626', fontWeight: 500 }}>
|
||||
{errorCodeData?.error_code || 'N/A'}
|
||||
<div style={{ fontSize: '14px', color: '#262626', fontWeight: 500 }}>
|
||||
{selectedData.plc}
|
||||
</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 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, 0]}>
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col span={12}>
|
||||
<div style={{ marginBottom: '2px' }}>
|
||||
<div style={{ fontSize: '11px', color: '#8c8c8c', marginBottom: '0' }}>
|
||||
Solusi
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<div style={{ fontSize: '12px', color: '#8c8c8c', marginBottom: '4px' }}>
|
||||
Engineer
|
||||
</div>
|
||||
<div style={{ fontSize: '13px', color: '#262626', fontWeight: 500 }}>
|
||||
{activeSolution?.solution_name || 'N/A'}
|
||||
<div style={{ fontSize: '14px', color: '#262626', fontWeight: 500 }}>
|
||||
{selectedData.engineer}
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<div style={{ marginBottom: '2px' }}>
|
||||
<div style={{ fontSize: '11px', color: '#8c8c8c', marginBottom: '0' }}>
|
||||
Waktu Dibuat
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<div style={{ fontSize: '12px', color: '#8c8c8c', marginBottom: '4px' }}>
|
||||
Waktu
|
||||
</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 style={{ fontSize: '14px', color: '#262626', fontWeight: 500 }}>
|
||||
{selectedData.time}
|
||||
</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>
|
||||
<Divider style={{ margin: '16px 0' }} />
|
||||
|
||||
{/* Description */}
|
||||
<div style={{ marginTop: '16px', marginBottom: '8px' }}>
|
||||
<div style={{ fontSize: '11px', color: '#8c8c8c', marginBottom: '4px' }}>
|
||||
Deskripsi Error
|
||||
{/* Status */}
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<div style={{ fontSize: '12px', color: '#8c8c8c', marginBottom: '8px' }}>Status</div>
|
||||
<Tag color={selectedData.isRead ? 'default' : 'blue'}>
|
||||
{selectedData.isRead ? 'Sudah Dibaca' : 'Belum Dibaca'}
|
||||
</Tag>
|
||||
</div>
|
||||
|
||||
{/* Additional Info */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
color: '#262626',
|
||||
fontWeight: 500,
|
||||
padding: '8px',
|
||||
backgroundColor: '#fafafa',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid #f0f0f0',
|
||||
marginTop: '16px',
|
||||
padding: '12px',
|
||||
backgroundColor: '#f6f9ff',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid #d6e4ff',
|
||||
}}
|
||||
>
|
||||
{selectedData.message_error_issue || 'N/A'}
|
||||
<div style={{ fontSize: '12px', color: '#595959' }}>
|
||||
<strong>Catatan:</strong> Notifikasi ini telah dikirim ke engineer yang bersangkutan
|
||||
untuk ditindaklanjuti sesuai dengan prosedur yang berlaku.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
</Modal>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -12,8 +12,6 @@ import {
|
||||
Modal,
|
||||
Tag,
|
||||
message,
|
||||
Spin,
|
||||
Pagination,
|
||||
} from 'antd';
|
||||
import {
|
||||
CloseCircleFilled,
|
||||
@@ -35,149 +33,121 @@ import {
|
||||
FilePdfOutlined,
|
||||
PlusOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
SearchOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useNavigate, Link as RouterLink } from 'react-router-dom';
|
||||
import {
|
||||
getAllNotification,
|
||||
getNotificationLogByNotificationId,
|
||||
getNotificationDetail,
|
||||
resendChatByUser,
|
||||
resendChatAllUser,
|
||||
searchData,
|
||||
} from '../../../api/notification';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { getAllNotification } from '../../../api/notification';
|
||||
|
||||
const { Text, Paragraph, Link } = Typography;
|
||||
|
||||
const { Text, Paragraph, Link: AntdLink } = Typography;
|
||||
const OpenMail = ({ size = 22, color = 'black' }) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 640 640"
|
||||
width={size}
|
||||
height={size}
|
||||
fill={color}
|
||||
>
|
||||
<path d="M576 480C576 515.3 547.5 544 512.1 544L128 544C92.6 544 64 515.3 64 480L64 228C64.1 212.5 71.8 198 84.5 189.2L270 61.3C300.1 40.6 339.8 40.6 369.9 61.3L555.5 189.2C568.3 198 575.9 212.5 576 228L576 480zM128 496L512.1 496C520.9 496 528 488.9 528 480L528 288.3L373.2 405.7C341.8 429.6 298.3 429.6 266.8 405.7L112 288.3L112 480C112 488.9 119.2 496 128 496zM527.6 228.4L342.7 100.8C329 91.4 311 91.4 297.3 100.8L112.4 228.4L295.8 367.5C310.1 378.3 329.9 378.3 344.2 367.5L527.6 228.4z" />
|
||||
</svg>
|
||||
);
|
||||
// Transform API response to component format
|
||||
const transformNotificationData = (apiData) => {
|
||||
return apiData.map((item, index) => ({
|
||||
id: `notification-${item.notification_error_id}-${index}`, // Unique key prefix with array index
|
||||
type: item.is_read ? 'resolved' : item.is_delivered ? 'warning' : 'critical',
|
||||
title: item.error_code_name || 'Unknown Error',
|
||||
color: item.error_code_color || 'Black',
|
||||
issue: item.error_code || item.error_code_name || 'Unknown Error',
|
||||
description: `${item.error_code} - ${item.error_code_name || ''}`,
|
||||
timestamp: item.created_at
|
||||
? new Date(item.created_at).toLocaleString('id-ID', {
|
||||
title: item.device_name || 'Unknown Device',
|
||||
issue: item.error_code_name || 'Unknown Error',
|
||||
description: `${item.error_code} - ${item.error_code_name}`,
|
||||
timestamp:
|
||||
new Date(item.created_at).toLocaleString('id-ID', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}) + ' WIB'
|
||||
: 'N/A',
|
||||
location: item.plant_sub_section_name || item.device_location || 'Location not specified',
|
||||
details: item.device_name || '-',
|
||||
errId: item.notification_error_id || 0,
|
||||
link: `/verification-sparepart/${item.notification_error_id}`, // Dummy URL untuk verifikasi spare part
|
||||
subsection: item.plant_sub_section_name || 'N/A',
|
||||
}) + ' WIB',
|
||||
location: item.device_location || 'Location not specified',
|
||||
details: item.message_error_issue || 'No details available',
|
||||
link: '#', // Will be updated when API provides link
|
||||
subsection: item.solution_name || 'N/A',
|
||||
isRead: item.is_read,
|
||||
status: item.is_read ? 'Resolved' : item.is_delivered ? 'Delivered' : 'Pending',
|
||||
tag: item.error_code,
|
||||
errorCode: item.error_code,
|
||||
solutionName: item.error_code?.solution?.[0]?.solution_name || 'N/A',
|
||||
typeSolution: item.error_code?.solution?.[0]?.type_solution || 'N/A',
|
||||
pathSolution:
|
||||
item.error_code?.solution?.[0]?.path_document ||
|
||||
item.error_code?.solution?.[0]?.path_solution ||
|
||||
'N/A',
|
||||
error_code: item.error_code,
|
||||
solutionName: item.solution_name,
|
||||
typeSolution: item.type_solution,
|
||||
pathSolution: item.path_solution,
|
||||
}));
|
||||
};
|
||||
|
||||
// Dummy data untuk user history
|
||||
const userHistoryData = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'John Doe',
|
||||
phone: '081234567890',
|
||||
status: 'Delivered',
|
||||
timestamp: '04-11-2025 11:40 WIB',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Jane Smith',
|
||||
phone: '087654321098',
|
||||
status: 'Delivered',
|
||||
timestamp: '04-11-2025 11:41 WIB',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Peter Jones',
|
||||
phone: '082345678901',
|
||||
status: 'Delivered',
|
||||
timestamp: '04-11-2025 11:42 WIB',
|
||||
},
|
||||
];
|
||||
|
||||
// Dummy data untuk log history
|
||||
const logHistoryData = [
|
||||
{
|
||||
id: 1,
|
||||
timestamp: '04-11-2025 11:55 WIB',
|
||||
addedBy: {
|
||||
name: 'Budi Santoso',
|
||||
phone: '081122334455',
|
||||
},
|
||||
description: 'Suhu sudah coba diturunkan, namun masih belum mencapai treshold aman.',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
timestamp: '04-11-2025 11:45 WIB',
|
||||
addedBy: {
|
||||
name: 'John Doe',
|
||||
phone: '081234567890',
|
||||
},
|
||||
description: 'Suhu sudah coba diturunkan, namun masih belum mencapai treshold aman.',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
timestamp: '04-11-2025 11:40 WIB',
|
||||
addedBy: {
|
||||
name: 'Jane Smith',
|
||||
phone: '087654321098',
|
||||
},
|
||||
description: 'Suhu sudah coba diturunkan, namun masih belum mencapai treshold aman.',
|
||||
},
|
||||
];
|
||||
|
||||
const ListNotification = memo(function ListNotification(props) {
|
||||
const [notifications, setNotifications] = useState([]);
|
||||
const [activeTab, setActiveTab] = useState('all');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modalContent, setModalContent] = useState(null); // 'user', 'log', 'details', or null
|
||||
const [isAddingLog, setIsAddingLog] = useState(false);
|
||||
const [selectedNotification, setSelectedNotification] = useState(null);
|
||||
const [logHistoryData, setLogHistoryData] = useState([]);
|
||||
const [logLoading, setLogLoading] = useState(false);
|
||||
const [userHistoryData, setUserHistoryData] = useState([]);
|
||||
const [userLoading, setUserLoading] = useState(false);
|
||||
const [pagination, setPagination] = useState({
|
||||
current_page: 1,
|
||||
current_limit: 10,
|
||||
total_limit: 0,
|
||||
total_page: 1,
|
||||
});
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Fetch notifications from API
|
||||
const fetchNotifications = async (page = 1, limit = 10, isRead = null) => {
|
||||
setLoading(true);
|
||||
const fetchNotifications = async () => {
|
||||
try {
|
||||
const queryParams = new URLSearchParams({
|
||||
page: page.toString(),
|
||||
limit: limit.toString(),
|
||||
});
|
||||
|
||||
if (isRead !== null) {
|
||||
queryParams.append('is_read', isRead.toString());
|
||||
}
|
||||
|
||||
const response = await getAllNotification(queryParams);
|
||||
const response = await getAllNotification();
|
||||
if (response && response.data) {
|
||||
const transformedData = transformNotificationData(response.data);
|
||||
setNotifications(transformedData);
|
||||
|
||||
// Update pagination with API response or calculate from data
|
||||
if (response.paging) {
|
||||
setPagination({
|
||||
current_page: response.paging.current_page || page,
|
||||
current_limit: response.paging.current_limit || limit,
|
||||
total_limit: response.paging.total_limit || transformedData.length,
|
||||
total_page:
|
||||
response.paging.total_page || Math.ceil(transformedData.length / limit),
|
||||
});
|
||||
} else {
|
||||
// Fallback: calculate pagination from data
|
||||
const totalItems = transformedData.length;
|
||||
setPagination((prev) => ({
|
||||
...prev,
|
||||
current_page: page,
|
||||
current_limit: limit,
|
||||
total_limit: totalItems,
|
||||
total_page: Math.ceil(totalItems / limit),
|
||||
}));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching notifications:', error);
|
||||
setNotifications([]);
|
||||
} finally {
|
||||
setTimeout(() => {
|
||||
setLoading(false);
|
||||
}, 500);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePaginationChange = (page, pageSize) => {
|
||||
setPagination((prev) => ({
|
||||
...prev,
|
||||
current_page: page,
|
||||
current_limit: pageSize,
|
||||
}));
|
||||
|
||||
// Fetch notifications with new pagination
|
||||
const isReadFilter = activeTab === 'read' ? 1 : activeTab === 'unread' ? 0 : null;
|
||||
fetchNotifications(page, pageSize, isReadFilter);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
@@ -185,21 +155,20 @@ const ListNotification = memo(function ListNotification(props) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch notifications on component mount and when tab changes
|
||||
const isReadFilter = activeTab === 'read' ? 1 : activeTab === 'unread' ? 0 : null;
|
||||
fetchNotifications(pagination.current_page, pagination.current_limit, isReadFilter);
|
||||
}, [activeTab]);
|
||||
// Fetch notifications on component mount
|
||||
fetchNotifications();
|
||||
}, []);
|
||||
|
||||
const getIconAndColor = (type) => {
|
||||
switch (type) {
|
||||
case 'critical':
|
||||
return { IconComponent: MailOutlined, color: '#faad14', bgColor: '#fff1f0' };
|
||||
return { IconComponent: CloseCircleFilled, color: '#ff4d4f', bgColor: '#fff1f0' };
|
||||
case 'warning':
|
||||
return { IconComponent: MailOutlined, color: '#1890ff', bgColor: '#fffbe6' };
|
||||
return { IconComponent: WarningFilled, color: '#faad14', bgColor: '#fffbe6' };
|
||||
case 'resolved':
|
||||
return { IconComponent: MailOutlined, color: '#52c41a', bgColor: '#f6ffed' };
|
||||
return { IconComponent: CheckCircleFilled, color: '#52c41a', bgColor: '#f6ffed' };
|
||||
default:
|
||||
return { IconComponent: MailOutlined, color: '#1890ff', bgColor: '#e6f7ff' };
|
||||
return { IconComponent: InfoCircleFilled, color: '#1890ff', bgColor: '#e6f7ff' };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -210,9 +179,9 @@ const ListNotification = memo(function ListNotification(props) {
|
||||
content: `Are you sure you want to resend the notification for "${notification.title}"?`,
|
||||
okText: 'Resend',
|
||||
cancelText: 'Cancel',
|
||||
async onOk() {
|
||||
onOk() {
|
||||
console.log('Resending notification:', notification.id);
|
||||
await resendChatAllUser(notification.errId);
|
||||
|
||||
message.success(
|
||||
`Notification for "${notification.title}" has been resent successfully.`
|
||||
);
|
||||
@@ -231,134 +200,22 @@ const ListNotification = memo(function ListNotification(props) {
|
||||
);
|
||||
};
|
||||
|
||||
const fetchSearch = async (data) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await searchData(data);
|
||||
if (response && response.data) {
|
||||
const transformedData = transformNotificationData(response.data);
|
||||
setNotifications(transformedData);
|
||||
|
||||
// Update pagination with API response or calculate from data
|
||||
if (response.paging) {
|
||||
setPagination({
|
||||
current_page: response.paging.current_page || page,
|
||||
current_limit: response.paging.current_limit || limit,
|
||||
total_limit: response.paging.total_limit || transformedData.length,
|
||||
total_page:
|
||||
response.paging.total_page || Math.ceil(transformedData.length / limit),
|
||||
});
|
||||
} else {
|
||||
// Fallback: calculate pagination from data
|
||||
const totalItems = transformedData.length;
|
||||
setPagination((prev) => ({
|
||||
...prev,
|
||||
current_page: page,
|
||||
current_limit: limit,
|
||||
total_limit: totalItems,
|
||||
total_page: Math.ceil(totalItems / limit),
|
||||
}));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
fetchSearch(searchValue);
|
||||
};
|
||||
|
||||
const handleSearchClear = () => {
|
||||
setSearchValue('');
|
||||
fetchSearch('');
|
||||
};
|
||||
|
||||
const getUnreadCount = () => notifications.filter((n) => !n.isRead).length;
|
||||
|
||||
// Filter notifications based on search term
|
||||
const getFilteredNotifications = () => {
|
||||
if (!searchTerm) return notifications;
|
||||
// Search by title and error code name
|
||||
return notifications.filter((n) => {
|
||||
const searchableText = `${n.title} ${n.issue}`.toLowerCase();
|
||||
const filteredNotifications = notifications
|
||||
.filter((n) => {
|
||||
const matchesTab =
|
||||
activeTab === 'all' ||
|
||||
(activeTab === 'unread' && !n.isRead) ||
|
||||
(activeTab === 'read' && n.isRead);
|
||||
return matchesTab;
|
||||
})
|
||||
.filter((n) => {
|
||||
if (!searchTerm) return true;
|
||||
const searchableText =
|
||||
`${n.title} ${n.issue} ${n.description} ${n.location} ${n.details}`.toLowerCase();
|
||||
return searchableText.includes(searchTerm.toLowerCase());
|
||||
});
|
||||
};
|
||||
|
||||
// Fetch log history from API
|
||||
const fetchLogHistory = async (notificationId) => {
|
||||
try {
|
||||
setLogLoading(true);
|
||||
const response = await getNotificationLogByNotificationId(notificationId);
|
||||
if (response && response.data) {
|
||||
// Transform API data to component format
|
||||
const transformedLogs = response.data.map((log) => ({
|
||||
id: log.notification_error_log_id,
|
||||
timestamp: log.created_at
|
||||
? new Date(log.created_at).toLocaleString('id-ID', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}) + ' WIB'
|
||||
: 'N/A',
|
||||
addedBy: {
|
||||
name: log.contact_name || 'Unknown',
|
||||
phone: log.contact_phone || 'N/A',
|
||||
},
|
||||
description: log.notification_error_log_description || '',
|
||||
}));
|
||||
setLogHistoryData(transformedLogs);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching log history:', err);
|
||||
setLogHistoryData([]); // Set empty array on error
|
||||
} finally {
|
||||
setLogLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch user history from API
|
||||
const fetchUserHistory = async (notificationId) => {
|
||||
try {
|
||||
setUserLoading(true);
|
||||
|
||||
const response = await getNotificationDetail(notificationId);
|
||||
|
||||
if (response && response.data && response.data.users) {
|
||||
// Transform API data to component format
|
||||
const transformedUsers = response.data.users.map((user) => ({
|
||||
id: user.notification_error_user_id.toString(),
|
||||
name: user.contact_name,
|
||||
phone: user.contact_phone,
|
||||
status: user.is_send ? 'Delivered' : 'Pending',
|
||||
timestamp: user.updated_at
|
||||
? new Date(user.updated_at)
|
||||
.toLocaleString('id-ID', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
.replace('.', ':') + ' WIB'
|
||||
: 'N/A',
|
||||
}));
|
||||
setUserHistoryData(transformedUsers);
|
||||
} else {
|
||||
setUserHistoryData([]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching user history:', err);
|
||||
setUserHistoryData([]); // Set empty array on error
|
||||
} finally {
|
||||
setUserLoading(false);
|
||||
}
|
||||
};
|
||||
const getUnreadCount = () => notifications.filter((n) => !n.isRead).length;
|
||||
|
||||
const tabButtonStyle = (isActive) => ({
|
||||
padding: '12px 16px',
|
||||
@@ -373,9 +230,7 @@ const ListNotification = memo(function ListNotification(props) {
|
||||
transition: 'all 0.3s',
|
||||
});
|
||||
|
||||
const renderDeviceNotifications = () => {
|
||||
const filteredNotifications = getFilteredNotifications();
|
||||
return (
|
||||
const renderDeviceNotifications = () => (
|
||||
<Space direction="vertical" size="middle" style={{ display: 'flex' }}>
|
||||
{filteredNotifications.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px 0', color: '#8c8c8c' }}>
|
||||
@@ -383,9 +238,7 @@ const ListNotification = memo(function ListNotification(props) {
|
||||
</div>
|
||||
) : (
|
||||
filteredNotifications.map((notification) => {
|
||||
const { IconComponent, color, bgColor } = getIconAndColor(
|
||||
notification.type
|
||||
);
|
||||
const { IconComponent, color, bgColor } = getIconAndColor(notification.type);
|
||||
return (
|
||||
<Card
|
||||
key={notification.id}
|
||||
@@ -394,14 +247,9 @@ const ListNotification = memo(function ListNotification(props) {
|
||||
borderColor: notification.isRead ? '#f0f0f0' : '#d6e4ff',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={() => handleMarkAsRead(notification.id)}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '16px',
|
||||
alignItems: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', gap: '16px', alignItems: 'flex-start' }}>
|
||||
<div
|
||||
style={{
|
||||
width: '40px',
|
||||
@@ -416,11 +264,7 @@ const ListNotification = memo(function ListNotification(props) {
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{notification.type === 'resolved' ? (
|
||||
<OpenMail size={28.5} color={color} />
|
||||
) : (
|
||||
<IconComponent style={{ fontSize: '22px' }} />
|
||||
)}
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<Row align="top">
|
||||
@@ -435,12 +279,8 @@ const ListNotification = memo(function ListNotification(props) {
|
||||
<div>
|
||||
<Text strong>{notification.title}</Text>
|
||||
<div style={{ marginTop: '4px' }}>
|
||||
<Text
|
||||
style={{
|
||||
color: notification.color,
|
||||
}}
|
||||
>
|
||||
Error Code {notification.issue}
|
||||
<Text style={{ color }}>
|
||||
{notification.issue}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
@@ -457,7 +297,7 @@ const ListNotification = memo(function ListNotification(props) {
|
||||
</div>
|
||||
</Col>
|
||||
<Col flex="auto">
|
||||
{/* <div
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
@@ -466,32 +306,19 @@ const ListNotification = memo(function ListNotification(props) {
|
||||
}}
|
||||
>
|
||||
<MailOutlined
|
||||
style={{
|
||||
marginTop: '4px',
|
||||
color: '#1890ff',
|
||||
}}
|
||||
style={{ marginTop: '4px', color: '#1890ff' }}
|
||||
/>
|
||||
<Paragraph
|
||||
style={{
|
||||
color: '#595959',
|
||||
margin: 0,
|
||||
flex: 1,
|
||||
}}
|
||||
style={{ color: '#595959', margin: 0, flex: 1 }}
|
||||
>
|
||||
{notification.details}
|
||||
</Paragraph>
|
||||
</div> */}
|
||||
</div>
|
||||
<Space
|
||||
direction="vertical"
|
||||
size={4}
|
||||
style={{ fontSize: '13px', color: '#8c8c8c' }}
|
||||
>
|
||||
<Space>
|
||||
<MobileOutlined />
|
||||
<Text type="secondary">
|
||||
{notification.details}
|
||||
</Text>
|
||||
</Space>
|
||||
<Space>
|
||||
<ClockCircleOutlined />
|
||||
<Text type="secondary">
|
||||
@@ -505,10 +332,14 @@ const ListNotification = memo(function ListNotification(props) {
|
||||
</Text>
|
||||
</Space>
|
||||
<Space>
|
||||
<LinkOutlined />
|
||||
<Link href={notification.link} target="_blank">
|
||||
{notification.link}
|
||||
</Link>
|
||||
<Button
|
||||
type="link"
|
||||
icon={<SendOutlined />}
|
||||
style={{ paddingLeft: '0px' }}
|
||||
style={{ paddingLeft: '8px' }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleResend(notification);
|
||||
@@ -544,43 +375,27 @@ const ListNotification = memo(function ListNotification(props) {
|
||||
border: '1px solid #1890ff',
|
||||
borderRadius: '4px',
|
||||
}}
|
||||
onClick={async (e) => {
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
setSelectedNotification(notification);
|
||||
|
||||
// Extract notification ID from the notification object
|
||||
const notificationId =
|
||||
notification.id.split('-')[1];
|
||||
|
||||
// Fetch user history for the selected notification
|
||||
await fetchUserHistory(notificationId);
|
||||
|
||||
setModalContent('user');
|
||||
}}
|
||||
/>
|
||||
<RouterLink
|
||||
to={`/notification-detail/${
|
||||
notification.id.split('-')[1]
|
||||
}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
icon={
|
||||
<EyeOutlined
|
||||
style={{ color: '#1890ff' }}
|
||||
/>
|
||||
<EyeOutlined style={{ color: '#1890ff' }} />
|
||||
}
|
||||
title="Details"
|
||||
style={{
|
||||
border: '1px solid #1890ff',
|
||||
borderRadius: '4px',
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setModalContent('details');
|
||||
setSelectedNotification(notification);
|
||||
}}
|
||||
/>
|
||||
</RouterLink>
|
||||
<Button
|
||||
type="text"
|
||||
icon={
|
||||
@@ -595,15 +410,6 @@ const ListNotification = memo(function ListNotification(props) {
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
// Set the selected notification for the log history
|
||||
const notificationId =
|
||||
notification.id.split('-')[1];
|
||||
setSelectedNotification(notification);
|
||||
|
||||
// Fetch log history for the selected notification
|
||||
fetchLogHistory(notificationId);
|
||||
|
||||
setModalContent('log');
|
||||
}}
|
||||
/>
|
||||
@@ -618,15 +424,9 @@ const ListNotification = memo(function ListNotification(props) {
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
};
|
||||
|
||||
const renderUserHistory = () => (
|
||||
<>
|
||||
{userLoading ? (
|
||||
<div style={{ textAlign: 'center', padding: '24px' }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
) : (
|
||||
<Space direction="vertical" size="middle" style={{ display: 'flex' }}>
|
||||
{userHistoryData.map((user) => (
|
||||
<Card key={user.id} style={{ borderColor: '#91d5ff' }}>
|
||||
@@ -639,75 +439,31 @@ const ListNotification = memo(function ListNotification(props) {
|
||||
<MobileOutlined /> {user.phone}
|
||||
</Text>
|
||||
<Text>|</Text>
|
||||
<Badge
|
||||
status={
|
||||
user.status === 'Delivered' ? 'success' : 'default'
|
||||
}
|
||||
text={user.status}
|
||||
/>
|
||||
<Badge status="success" 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}
|
||||
Success Delivered at {user.timestamp}
|
||||
</Text>
|
||||
</Space>
|
||||
</Col>
|
||||
<Col>
|
||||
<Button
|
||||
type="primary"
|
||||
ghost
|
||||
icon={<SendOutlined />}
|
||||
onClick={async () => {
|
||||
await resendChatByUser(user.id, user.phone);
|
||||
}}
|
||||
>
|
||||
<Button type="primary" ghost icon={<SendOutlined />}>
|
||||
Resend
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
))}
|
||||
{userHistoryData.length === 0 && (
|
||||
<div style={{ textAlign: 'center', padding: '24px', color: '#8c8c8c' }}>
|
||||
No user history available
|
||||
</div>
|
||||
)}
|
||||
</Space>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const renderLogHistory = () => (
|
||||
<>
|
||||
{logLoading ? (
|
||||
<div style={{ textAlign: 'center', padding: '24px' }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
) : logHistoryData.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: '24px', color: '#8c8c8c' }}>
|
||||
Tidak ada log history
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
height: '400px',
|
||||
overflowY: 'auto',
|
||||
padding: '0 16px',
|
||||
position: 'relative',
|
||||
border: '1px solid #f0f0f0',
|
||||
borderRadius: '4px',
|
||||
}}
|
||||
>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<div style={{ padding: '0 16px', position: 'relative' }}>
|
||||
{/* Garis vertikal yang menyambung */}
|
||||
<div
|
||||
style={{
|
||||
@@ -753,26 +509,20 @@ const ListNotification = memo(function ListNotification(props) {
|
||||
{/* Kolom Kanan: Card */}
|
||||
<Col flex="auto">
|
||||
<Card size="small" style={{ borderColor: '#91d5ff' }}>
|
||||
<Row gutter={[16, 8]} align="top">
|
||||
<Col xs={24} md={10}>
|
||||
<Row gutter={[16, 8]} align="middle">
|
||||
<Col xs={24} md={12}>
|
||||
<Space direction="vertical" size={4}>
|
||||
<Space>
|
||||
<ClockCircleOutlined />
|
||||
<Text
|
||||
type="secondary"
|
||||
style={{ fontSize: '12px' }}
|
||||
>
|
||||
<Text type="secondary" style={{ fontSize: '12px' }}>
|
||||
Added at {log.timestamp}
|
||||
</Text>
|
||||
</Space>
|
||||
|
||||
<div>
|
||||
<Text strong>{log.addedBy.name}</Text>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text strong>Added by: {log.addedBy.name}</Text>
|
||||
<span
|
||||
style={{
|
||||
marginLeft: '8px',
|
||||
border: '1px solid #52c41a',
|
||||
color: '#52c41a',
|
||||
padding: '2px 6px',
|
||||
@@ -785,8 +535,7 @@ const ListNotification = memo(function ListNotification(props) {
|
||||
</div>
|
||||
</Space>
|
||||
</Col>
|
||||
<Col xs={24} md={14}>
|
||||
<Text strong>Description:</Text>
|
||||
<Col xs={24} md={12}>
|
||||
<Paragraph
|
||||
style={{
|
||||
color: '#595959',
|
||||
@@ -803,8 +552,6 @@ const ListNotification = memo(function ListNotification(props) {
|
||||
</Row>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -814,8 +561,8 @@ const ListNotification = memo(function ListNotification(props) {
|
||||
const { IconComponent, color } = getIconAndColor(selectedNotification.type);
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||
<Row gutter={[16, 8]}>
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
<Row gutter={[16, 16]}>
|
||||
{/* Kolom Kiri: Data Kompresor */}
|
||||
<Col span={12}>
|
||||
<Card
|
||||
@@ -824,7 +571,7 @@ const ListNotification = memo(function ListNotification(props) {
|
||||
style={{ height: '100%', borderColor: '#d4380d' }}
|
||||
bodyStyle={{ padding: '12px' }}
|
||||
>
|
||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
<Row gutter={16} align="middle">
|
||||
<Col>
|
||||
<div
|
||||
@@ -856,9 +603,9 @@ const ListNotification = memo(function ListNotification(props) {
|
||||
<Text strong>Plant Subsection</Text>
|
||||
<div>{selectedNotification.subsection}</div>
|
||||
<Text strong style={{ display: 'block', marginTop: '8px' }}>
|
||||
Date & Time
|
||||
Time
|
||||
</Text>
|
||||
<div>{selectedNotification.timestamp}</div>
|
||||
<div>{selectedNotification.timestamp.split(' ')[1]} WIB</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -888,7 +635,9 @@ const ListNotification = memo(function ListNotification(props) {
|
||||
<Text type="secondary" style={{ fontSize: '12px' }}>
|
||||
Treshold
|
||||
</Text>
|
||||
<div style={{ fontWeight: 500 }}>N/A</div>
|
||||
<div style={{ fontWeight: 500 }}>
|
||||
N/A
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
@@ -929,7 +678,7 @@ const ListNotification = memo(function ListNotification(props) {
|
||||
</Col>
|
||||
</Row>
|
||||
<div>
|
||||
<Row gutter={[16, 8]}>
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col span={8}>
|
||||
<Card
|
||||
style={{
|
||||
@@ -975,16 +724,7 @@ const ListNotification = memo(function ListNotification(props) {
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
bodyStyle={{ padding: '12px' }}
|
||||
onClick={() => {
|
||||
// Set the selected notification for the log history if not already set
|
||||
if (selectedNotification) {
|
||||
const notificationId =
|
||||
selectedNotification.id.split('-')[1];
|
||||
// Fetch log history for the selected notification
|
||||
fetchLogHistory(notificationId);
|
||||
}
|
||||
setModalContent('log');
|
||||
}}
|
||||
onClick={() => setModalContent('log')}
|
||||
>
|
||||
<Space>
|
||||
<HistoryOutlined
|
||||
@@ -997,7 +737,7 @@ const ListNotification = memo(function ListNotification(props) {
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={[16, 8]} style={{ marginTop: '0' }}>
|
||||
<Row gutter={[16, 16]} style={{ marginTop: '16px' }}>
|
||||
<Col span={8}>
|
||||
<Card size="small" style={{ height: '100%' }}>
|
||||
<Space direction="vertical" size="small" style={{ width: '100%' }}>
|
||||
@@ -1301,22 +1041,10 @@ const ListNotification = memo(function ListNotification(props) {
|
||||
</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
{logLoading ? (
|
||||
<div style={{ textAlign: 'center', padding: '12px' }}>
|
||||
<Spin size="small" />
|
||||
</div>
|
||||
) : logHistoryData.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
padding: '12px',
|
||||
color: '#8c8c8c',
|
||||
}}
|
||||
>
|
||||
Tidak ada log history
|
||||
</div>
|
||||
) : (
|
||||
logHistoryData.map((log) => (
|
||||
{logHistoryData.slice(0, 2).map(
|
||||
(
|
||||
log // Menampilkan 2 log terbaru sebagai pratinjau
|
||||
) => (
|
||||
<Card
|
||||
key={log.id}
|
||||
size="small"
|
||||
@@ -1333,8 +1061,17 @@ const ListNotification = memo(function ListNotification(props) {
|
||||
{log.timestamp}
|
||||
</Text>
|
||||
</Card>
|
||||
))
|
||||
)
|
||||
)}
|
||||
<div style={{ textAlign: 'center', paddingTop: '8px' }}>
|
||||
<Button
|
||||
type="link"
|
||||
style={{ padding: 0 }}
|
||||
onClick={() => setModalContent('log')}
|
||||
>
|
||||
View All Log History
|
||||
</Button>
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
@@ -1363,35 +1100,17 @@ const ListNotification = memo(function ListNotification(props) {
|
||||
Riwayat notifikasi yang dikirim ke engineer
|
||||
</p>
|
||||
|
||||
<Row justify="space-between" align="middle" gutter={[8, 8]}>
|
||||
<Col xs={24} sm={24} md={12} lg={12}>
|
||||
<Input.Search
|
||||
placeholder="Search by notification name or error code name..."
|
||||
value={searchValue}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setSearchValue(value);
|
||||
if (value === '') {
|
||||
handleSearchClear();
|
||||
}
|
||||
}}
|
||||
onSearch={handleSearch}
|
||||
allowClear={{
|
||||
clearIcon: <span onClick={handleSearchClear}>✕</span>,
|
||||
}}
|
||||
enterButton={
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SearchOutlined />}
|
||||
style={{
|
||||
backgroundColor: '#23A55A',
|
||||
borderColor: '#23A55A',
|
||||
}}
|
||||
<Row
|
||||
justify="space-between"
|
||||
align="middle"
|
||||
style={{ marginBottom: '24px' }}
|
||||
>
|
||||
Search
|
||||
</Button>
|
||||
}
|
||||
size="large"
|
||||
<Col>
|
||||
<Input.Search
|
||||
placeholder="Search notifications..."
|
||||
onSearch={setSearchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
style={{ width: 300 }}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
@@ -1429,28 +1148,7 @@ const ListNotification = memo(function ListNotification(props) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Spin spinning={loading}>{renderDeviceNotifications()}</Spin>
|
||||
|
||||
{/* PAGINATION */}
|
||||
<Row justify="space-between" align="middle" style={{ marginTop: '16px' }}>
|
||||
<Col>
|
||||
<div>
|
||||
Menampilkan {pagination.current_limit} data halaman{' '}
|
||||
{pagination.current_page} dari total {pagination.total_limit}{' '}
|
||||
data
|
||||
</div>
|
||||
</Col>
|
||||
<Col>
|
||||
<Pagination
|
||||
showSizeChanger
|
||||
onChange={handlePaginationChange}
|
||||
onShowSizeChange={handlePaginationChange}
|
||||
current={pagination.current_page}
|
||||
pageSize={pagination.current_limit}
|
||||
total={pagination.total_limit}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
{renderDeviceNotifications()}
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
@@ -1474,7 +1172,7 @@ const ListNotification = memo(function ListNotification(props) {
|
||||
</div>
|
||||
) : (
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
{modalContent === 'user' && 'History User Notification'}
|
||||
{modalContent === 'user' && 'User History Notification'}
|
||||
{modalContent === 'log' && 'Log History Notification'}
|
||||
</Typography.Title>
|
||||
)}
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Card, Table, Tag, Typography } from 'antd';
|
||||
import { ClockCircleOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const getDummyLogHistory = (notification) => {
|
||||
if (!notification) return [];
|
||||
return [
|
||||
{
|
||||
key: '1',
|
||||
timestamp: dayjs().subtract(2, 'hour').format('DD-MM-YYYY HH:mm:ss'),
|
||||
activity: 'Notification Created',
|
||||
details: `System generated a ${notification.type} notification for: ${notification.issue}`,
|
||||
},
|
||||
{
|
||||
key: '2',
|
||||
timestamp: dayjs().subtract(1, 'hour').format('DD-MM-YYYY HH:mm:ss'),
|
||||
activity: 'Notification Sent',
|
||||
details: 'Sent to 2 engineers',
|
||||
},
|
||||
{
|
||||
key: '3',
|
||||
timestamp: dayjs().subtract(30, 'minute').format('DD-MM-YYYY HH:mm:ss'),
|
||||
activity: 'Notification Read',
|
||||
details: 'Read by Engineer A',
|
||||
},
|
||||
{
|
||||
key: '4',
|
||||
timestamp: dayjs().subtract(5, 'minute').format('DD-MM-YYYY HH:mm:ss'),
|
||||
activity: 'Resend Triggered',
|
||||
details: 'Notification resent by Admin',
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: 'Timestamp',
|
||||
dataIndex: 'timestamp',
|
||||
key: 'timestamp',
|
||||
render: (text) => (
|
||||
<span>
|
||||
<ClockCircleOutlined style={{ marginRight: 8 }} />
|
||||
{text}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Activity',
|
||||
dataIndex: 'activity',
|
||||
key: 'activity',
|
||||
render: (text) => {
|
||||
let color = 'blue';
|
||||
if (text.includes('Created')) {
|
||||
color = 'geekblue';
|
||||
} else if (text.includes('Sent')) {
|
||||
color = 'purple';
|
||||
} else if (text.includes('Read')) {
|
||||
color = 'green';
|
||||
} else if (text.includes('Triggered')) {
|
||||
color = 'orange';
|
||||
}
|
||||
return <Tag color={color}>{text.toUpperCase()}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Details',
|
||||
dataIndex: 'details',
|
||||
key: 'details',
|
||||
},
|
||||
];
|
||||
|
||||
const LogHistoryCard = ({ notificationData }) => {
|
||||
const logHistoryData = getDummyLogHistory(notificationData);
|
||||
|
||||
return (
|
||||
<Card
|
||||
title="Log History"
|
||||
size="small"
|
||||
style={{ height: '100%' }}
|
||||
>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={logHistoryData}
|
||||
pagination={false} // Remove pagination entirely
|
||||
size="small"
|
||||
scroll={{ y: 200 }} // Use scroll for overflow, adjust height as needed
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default LogHistoryCard;
|
||||
@@ -1,12 +1,6 @@
|
||||
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;
|
||||
|
||||
@@ -47,17 +41,9 @@ 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:
|
||||
@@ -69,7 +55,7 @@ const UserHistoryModal = ({ visible, onCancel, notificationData }) => {
|
||||
<Modal
|
||||
title={
|
||||
<Text strong style={{ fontSize: '18px' }}>
|
||||
History User Notification
|
||||
User History Notification
|
||||
</Text>
|
||||
}
|
||||
open={visible}
|
||||
@@ -92,13 +78,7 @@ 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,37 +1,14 @@
|
||||
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 }) => {
|
||||
@@ -41,9 +18,7 @@ const UserHistory = ({ notification, onBack }) => {
|
||||
<Col>
|
||||
<Space align="center">
|
||||
<Button type="text" icon={<ArrowLeftOutlined />} onClick={onBack} />
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
History User Notification
|
||||
</Typography.Title>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>User History Notification</Typography.Title>
|
||||
</Space>
|
||||
<Text type="secondary" style={{ marginLeft: '40px' }}>
|
||||
{notification.title} - {notification.issue}
|
||||
@@ -52,34 +27,25 @@ 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>
|
||||
|
||||
@@ -1,955 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Layout,
|
||||
Card,
|
||||
Row,
|
||||
Col,
|
||||
Typography,
|
||||
Space,
|
||||
Button,
|
||||
Spin,
|
||||
Result,
|
||||
Input,
|
||||
message,
|
||||
Avatar,
|
||||
Tag,
|
||||
Badge,
|
||||
Divider,
|
||||
} from 'antd';
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
CloseCircleFilled,
|
||||
WarningFilled,
|
||||
CheckCircleFilled,
|
||||
InfoCircleFilled,
|
||||
CloseOutlined,
|
||||
BookOutlined,
|
||||
ToolOutlined,
|
||||
HistoryOutlined,
|
||||
FilePdfOutlined,
|
||||
PlusOutlined,
|
||||
UserOutlined,
|
||||
LoadingOutlined,
|
||||
PhoneOutlined,
|
||||
CheckCircleOutlined,
|
||||
SyncOutlined,
|
||||
SendOutlined,
|
||||
MobileOutlined,
|
||||
ClockCircleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import {
|
||||
getNotificationDetail,
|
||||
createNotificationLog,
|
||||
getNotificationLogByNotificationId,
|
||||
updateIsRead,
|
||||
resendNotificationToUser,
|
||||
resendChatByUser,
|
||||
} from '../../api/notification';
|
||||
|
||||
const { Content } = Layout;
|
||||
const { Text, Paragraph, Link } = Typography;
|
||||
|
||||
// Transform API response to component format
|
||||
const transformNotificationData = (apiData) => {
|
||||
// Extract nested data
|
||||
const errorCodeData = apiData.error_code;
|
||||
// Get active solution (is_active: true)
|
||||
const activeSolution =
|
||||
errorCodeData?.solution?.find((sol) => sol.is_active) || errorCodeData?.solution?.[0] || {};
|
||||
|
||||
return {
|
||||
id: `notification-${apiData.notification_error_id}-0`,
|
||||
type: apiData.is_read ? 'resolved' : apiData.is_delivered ? 'warning' : 'critical',
|
||||
title: errorCodeData?.error_code_name || 'Unknown Error',
|
||||
issue: errorCodeData?.error_code || 'Unknown Error',
|
||||
description: apiData.message_error_issue || 'No details available',
|
||||
timestamp: apiData.created_at
|
||||
? new Date(apiData.created_at).toLocaleString('id-ID', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}) + ' WIB'
|
||||
: 'N/A',
|
||||
location: apiData.plant_sub_section_name || 'Location not specified',
|
||||
details: apiData.message_error_issue || 'No details available',
|
||||
isRead: apiData.is_read || false,
|
||||
isDelivered: apiData.is_delivered || false,
|
||||
isSend: apiData.is_send || false,
|
||||
status: apiData.is_read ? 'Resolved' : apiData.is_delivered ? 'Delivered' : 'Pending',
|
||||
tag: errorCodeData?.error_code,
|
||||
plc: 'N/A', // PLC not available in API response
|
||||
notification_error_id: apiData.notification_error_id,
|
||||
error_code_id: apiData.error_code_id,
|
||||
error_chanel: apiData.error_chanel,
|
||||
spareparts: errorCodeData?.spareparts || [],
|
||||
solution: {
|
||||
...activeSolution,
|
||||
path_document: activeSolution.path_document
|
||||
? activeSolution.path_document.replace(
|
||||
'/detail-notification/pdf/',
|
||||
'/notification-detail/pdf/'
|
||||
)
|
||||
: activeSolution.path_document,
|
||||
}, // Include the active solution data with fixed URL
|
||||
error_code: errorCodeData,
|
||||
device_info: {
|
||||
device_code: apiData.device_code,
|
||||
device_name: apiData.device_name,
|
||||
device_location: apiData.device_location,
|
||||
brand_name: apiData.brand_name,
|
||||
},
|
||||
users: apiData.users || [],
|
||||
};
|
||||
};
|
||||
|
||||
// Function to get actual users from notification data
|
||||
const getUsersFromNotification = (notification) => {
|
||||
if (!notification || !notification.users) return [];
|
||||
|
||||
return notification.users.map((user) => ({
|
||||
id: user.notification_error_user_id.toString(),
|
||||
name: user.contact_name,
|
||||
phone: user.contact_phone,
|
||||
status: user.is_send ? 'Delivered' : 'Pending',
|
||||
loading: user.loading || false,
|
||||
timestamp: user.updated_at
|
||||
? new Date(user.updated_at)
|
||||
.toLocaleString('id-ID', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
.replace('.', ':') + ' WIB'
|
||||
: 'N/A',
|
||||
}));
|
||||
};
|
||||
|
||||
const getStatusTag = (status) => {
|
||||
switch (status) {
|
||||
case 'delivered':
|
||||
return (
|
||||
<Tag icon={<CheckCircleOutlined />} color="success">
|
||||
Delivered
|
||||
</Tag>
|
||||
);
|
||||
case 'sent':
|
||||
return (
|
||||
<Tag icon={<SyncOutlined spin />} color="processing">
|
||||
Sent
|
||||
</Tag>
|
||||
);
|
||||
case 'failed':
|
||||
return <Tag color="error">Failed</Tag>;
|
||||
default:
|
||||
return <Tag color="default">{status}</Tag>;
|
||||
}
|
||||
};
|
||||
|
||||
const getIconAndColor = (type) => {
|
||||
switch (type) {
|
||||
case 'critical':
|
||||
return { IconComponent: CloseCircleFilled, color: '#ff4d4f', bgColor: '#fff1f0' };
|
||||
case 'warning':
|
||||
return { IconComponent: WarningFilled, color: '#faad14', bgColor: '#fffbe6' };
|
||||
case 'resolved':
|
||||
return { IconComponent: CheckCircleFilled, color: '#52c41a', bgColor: '#f6ffed' };
|
||||
default:
|
||||
return { IconComponent: InfoCircleFilled, color: '#1890ff', bgColor: '#e6f7ff' };
|
||||
}
|
||||
};
|
||||
|
||||
const NotificationDetailTab = (props) => {
|
||||
const params = useParams(); // Mungkin perlu disesuaikan jika route berbeda
|
||||
const notificationId = props.id ?? params.notificationId;
|
||||
const navigate = useNavigate();
|
||||
const [notification, setNotification] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [isAddingLog, setIsAddingLog] = useState(false);
|
||||
|
||||
// Log history states
|
||||
const [logHistoryData, setLogHistoryData] = useState([]);
|
||||
const [logLoading, setLogLoading] = useState(false);
|
||||
const [newLogDescription, setNewLogDescription] = useState('');
|
||||
const [submitLoading, setSubmitLoading] = useState(false);
|
||||
|
||||
// Fetch log history from API
|
||||
const fetchLogHistory = async (notifId) => {
|
||||
try {
|
||||
setLogLoading(true);
|
||||
const response = await getNotificationLogByNotificationId(notifId);
|
||||
if (response && response.data) {
|
||||
// Transform API data to component format
|
||||
const transformedLogs = response.data.map((log) => ({
|
||||
id: log.notification_error_log_id,
|
||||
timestamp: log.created_at
|
||||
? new Date(log.created_at).toLocaleString('id-ID', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}) + ' WIB'
|
||||
: 'N/A',
|
||||
addedBy: {
|
||||
name: log.contact_name || 'Unknown',
|
||||
phone: log.contact_phone || '',
|
||||
},
|
||||
description: log.notification_error_log_description || '',
|
||||
}));
|
||||
setLogHistoryData(transformedLogs);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching log history:', err);
|
||||
} finally {
|
||||
setLogLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle submit new log
|
||||
const handleSubmitLog = async () => {
|
||||
if (!newLogDescription.trim()) {
|
||||
message.warning('Mohon isi deskripsi log terlebih dahulu');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setSubmitLoading(true);
|
||||
const payload = {
|
||||
notification_error_id: parseInt(notificationId),
|
||||
notification_error_log_description: newLogDescription.trim(),
|
||||
};
|
||||
|
||||
const response = await createNotificationLog(payload);
|
||||
|
||||
if (response && response.statusCode === 200) {
|
||||
message.success('Log berhasil ditambahkan');
|
||||
setNewLogDescription('');
|
||||
setIsAddingLog(false);
|
||||
// Refresh log history
|
||||
fetchLogHistory(notificationId);
|
||||
} else {
|
||||
throw new Error(response?.message || 'Gagal menambahkan log');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error submitting log:', err);
|
||||
message.error(err.message || 'Gagal menambahkan log');
|
||||
} finally {
|
||||
setSubmitLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchDetail = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Fetch using the actual API
|
||||
const response = await getNotificationDetail(notificationId);
|
||||
|
||||
if (response && response.data) {
|
||||
const transformedData = transformNotificationData(response.data);
|
||||
setNotification(transformedData);
|
||||
|
||||
// Fetch log history
|
||||
fetchLogHistory(notificationId);
|
||||
|
||||
// Fetch using the actual API
|
||||
const resUpdate = await updateIsRead(notificationId);
|
||||
} else {
|
||||
throw new Error('Notification not found');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
console.error('Error fetching notification detail:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchDetail();
|
||||
}, [notificationId]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Layout
|
||||
style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Spin size="large" />
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !notification) {
|
||||
return (
|
||||
<Layout
|
||||
style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Result
|
||||
status="404"
|
||||
title="404"
|
||||
subTitle="Sorry, the notification you visited does not exist."
|
||||
extra={
|
||||
<Button type="primary" onClick={() => navigate('/notification')}>
|
||||
Back to List
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
const { color } = getIconAndColor(notification.type);
|
||||
|
||||
return (
|
||||
<Layout style={{ padding: '24px', backgroundColor: '#f0f2f5' }}>
|
||||
<Content>
|
||||
<Card>
|
||||
<div
|
||||
style={{
|
||||
borderBottom: '1px solid #f0f0f0',
|
||||
paddingBottom: '16px',
|
||||
marginBottom: '24px',
|
||||
}}
|
||||
>
|
||||
{!props.id && (
|
||||
<Row justify="space-between" align="middle">
|
||||
<Col>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={() => navigate('/notification')}
|
||||
style={{ paddingLeft: 0 }}
|
||||
>
|
||||
Back to notification list
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: '#f6ffed',
|
||||
border: '1px solid #b7eb8f',
|
||||
borderRadius: '4px',
|
||||
padding: '8px 16px',
|
||||
textAlign: 'center',
|
||||
marginTop: '16px',
|
||||
}}
|
||||
>
|
||||
<Typography.Title level={4} style={{ margin: 0, color: '#262626' }}>
|
||||
Error Notification Detail
|
||||
</Typography.Title>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Space direction="vertical" size="small" style={{ width: '100%' }}>
|
||||
<Row gutter={[8, 8]}>
|
||||
{/* Kolom Kiri: Data Kompresor */}
|
||||
<Col xs={24} lg={8}>
|
||||
<Card
|
||||
size="small"
|
||||
style={{ height: '100%', borderColor: '#d4380d' }}
|
||||
bodyStyle={{ padding: '16px' }}
|
||||
>
|
||||
<Space
|
||||
direction="vertical"
|
||||
size="large"
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<Row gutter={16} align="middle">
|
||||
<Col>
|
||||
<div
|
||||
style={{
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: '#d4380d',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#ffffff',
|
||||
fontSize: '18px',
|
||||
}}
|
||||
>
|
||||
<CloseOutlined />
|
||||
</div>
|
||||
</Col>
|
||||
<Col>
|
||||
<Text>{notification.title}</Text>
|
||||
<div style={{ marginTop: '2px' }}>
|
||||
<Text strong style={{ fontSize: '16px' }}>
|
||||
Error Code {notification.issue}
|
||||
</Text>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
<div>
|
||||
<Text strong>Plant Subsection</Text>
|
||||
<div>{notification.location}</div>
|
||||
<Text
|
||||
strong
|
||||
style={{ display: 'block', marginTop: '8px' }}
|
||||
>
|
||||
Date & Time
|
||||
</Text>
|
||||
<div>{notification.timestamp}</div>
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
{/* Kolom Tengah: Informasi Teknis */}
|
||||
<Col xs={24} lg={8}>
|
||||
<Card
|
||||
title="Device Information"
|
||||
size="small"
|
||||
style={{ height: '100%' }}
|
||||
>
|
||||
<Space
|
||||
direction="vertical"
|
||||
size="middle"
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<div>
|
||||
<Text strong>Error Channel</Text>
|
||||
<div>{notification.error_chanel || 'N/A'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<Text strong>Device Code</Text>
|
||||
<div>
|
||||
{notification.device_info?.device_code || 'N/A'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Text strong>Device Name</Text>
|
||||
<div>
|
||||
{notification.device_info?.device_name || 'N/A'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Text strong>Device Location</Text>
|
||||
<div>
|
||||
{notification.device_info?.device_location || 'N/A'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Text strong>Brand</Text>
|
||||
<div>
|
||||
{notification.device_info?.brand_name || 'N/A'}
|
||||
</div>
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
{/* Kolom Kanan: User History */}
|
||||
<Col xs={24} lg={8}>
|
||||
<Card title="User History" size="small" style={{ height: '100%' }}>
|
||||
<div
|
||||
style={{
|
||||
maxHeight: '400px',
|
||||
overflowY: 'auto',
|
||||
padding: '2px',
|
||||
}}
|
||||
>
|
||||
<Space
|
||||
direction="vertical"
|
||||
size={2}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
{getUsersFromNotification(notification).map((user) => (
|
||||
<Card
|
||||
key={user.id}
|
||||
size="small"
|
||||
style={{ width: '100%', margin: 0 }}
|
||||
>
|
||||
<Row align="middle" justify="space-between">
|
||||
<Col>
|
||||
<Space align="center">
|
||||
<Text strong>{user.name}</Text>
|
||||
<Text>|</Text>
|
||||
<Text>
|
||||
<MobileOutlined /> {user.phone}
|
||||
</Text>
|
||||
<Text>|</Text>
|
||||
<Badge
|
||||
status={
|
||||
user.status === 'Delivered'
|
||||
? 'success'
|
||||
: 'default'
|
||||
}
|
||||
text={user.status}
|
||||
/>
|
||||
</Space>
|
||||
<Divider style={{ margin: '8px 0' }} />
|
||||
<Space align="center">
|
||||
{user.status === 'Delivered' ? (
|
||||
<CheckCircleFilled
|
||||
style={{ color: '#52c41a' }}
|
||||
/>
|
||||
) : (
|
||||
<ClockCircleOutlined
|
||||
style={{ color: '#faad14' }}
|
||||
/>
|
||||
)}
|
||||
<Text type="secondary">
|
||||
{user.status === 'Delivered'
|
||||
? 'Success Delivered at'
|
||||
: 'Status '}{' '}
|
||||
{user.timestamp}
|
||||
</Text>
|
||||
</Space>
|
||||
</Col>
|
||||
<Col>
|
||||
<Col>
|
||||
<Button
|
||||
type="primary"
|
||||
ghost
|
||||
icon={<SendOutlined />}
|
||||
onClick={async () => {
|
||||
await resendChatByUser(
|
||||
user.id,
|
||||
user.phone
|
||||
);
|
||||
}}
|
||||
>
|
||||
Resend
|
||||
</Button>
|
||||
</Col>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
))}
|
||||
</Space>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={[8, 8]}>
|
||||
<Col xs={24} md={8}>
|
||||
<div>
|
||||
<Card
|
||||
hoverable
|
||||
bodyStyle={{ padding: '12px'}}
|
||||
>
|
||||
<Space>
|
||||
<BookOutlined
|
||||
style={{ fontSize: '16px', color: '#1890ff' }}
|
||||
/>
|
||||
<Text
|
||||
strong
|
||||
style={{ fontSize: '16px', color: '#262626' }}
|
||||
>
|
||||
Handling Guideline
|
||||
</Text>
|
||||
</Space>
|
||||
|
||||
<Space
|
||||
direction="vertical"
|
||||
size="small"
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
{notification.error_code?.solution &&
|
||||
notification.error_code.solution.length > 0 ? (
|
||||
<>
|
||||
{notification.error_code.solution
|
||||
.filter((sol) => sol.is_active) // Hanya tampilkan solusi yang aktif
|
||||
.map((sol, index) => (
|
||||
<div
|
||||
key={
|
||||
sol.brand_code_solution_id ||
|
||||
index
|
||||
}
|
||||
>
|
||||
{sol.path_document ? (
|
||||
<Card
|
||||
size="small"
|
||||
bodyStyle={{
|
||||
padding: '8px 12px',
|
||||
marginBottom: '4px',
|
||||
}}
|
||||
hoverable
|
||||
extra={
|
||||
<Text
|
||||
type="secondary"
|
||||
style={{
|
||||
fontSize:
|
||||
'10px',
|
||||
}}
|
||||
>
|
||||
PDF
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent:
|
||||
'space-between',
|
||||
alignItems:
|
||||
'center',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<Text
|
||||
style={{
|
||||
fontSize:
|
||||
'12px',
|
||||
color: '#262626',
|
||||
}}
|
||||
>
|
||||
<FilePdfOutlined
|
||||
style={{
|
||||
marginRight:
|
||||
'8px',
|
||||
}}
|
||||
/>{' '}
|
||||
{sol.file_upload_name ||
|
||||
'Solution Document.pdf'}
|
||||
</Text>
|
||||
<Link
|
||||
href={sol.path_document.replace(
|
||||
'/detail-notification/pdf/',
|
||||
'/notification-detail/pdf/'
|
||||
)}
|
||||
target="_blank"
|
||||
style={{
|
||||
fontSize:
|
||||
'12px',
|
||||
display:
|
||||
'block',
|
||||
}}
|
||||
>
|
||||
lihat disini
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
) : null}
|
||||
{sol.type_solution === 'text' &&
|
||||
sol.text_solution ? (
|
||||
<Card
|
||||
size="small"
|
||||
title={
|
||||
<Text strong>
|
||||
{sol.solution_name}:
|
||||
</Text>
|
||||
}
|
||||
bodyStyle={{
|
||||
padding: '8px 12px',
|
||||
marginBottom: '4px',
|
||||
}}
|
||||
extra={
|
||||
<Text
|
||||
type="secondary"
|
||||
style={{
|
||||
fontSize:
|
||||
'10px',
|
||||
}}
|
||||
>
|
||||
{sol.type_solution.toUpperCase()}
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
marginTop:
|
||||
'4px',
|
||||
}}
|
||||
>
|
||||
{sol.text_solution}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
padding: '20px',
|
||||
color: '#8c8c8c',
|
||||
}}
|
||||
>
|
||||
Tidak ada dokumen solusi tersedia
|
||||
</div>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
</div>
|
||||
</Col>
|
||||
<Col xs={24} md={8}>
|
||||
<div>
|
||||
<Card
|
||||
hoverable
|
||||
bodyStyle={{ padding: '12px'}}
|
||||
>
|
||||
<Space>
|
||||
<ToolOutlined
|
||||
style={{ fontSize: '16px', color: '#1890ff' }}
|
||||
/>
|
||||
<Text
|
||||
strong
|
||||
style={{ fontSize: '16px', color: '#262626' }}
|
||||
>
|
||||
Spare Part
|
||||
</Text>
|
||||
</Space>
|
||||
|
||||
<Space
|
||||
direction="vertical"
|
||||
size="small"
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
{notification.spareparts &&
|
||||
notification.spareparts.length > 0 ? (
|
||||
notification.spareparts.map((sparepart, index) => (
|
||||
<Card
|
||||
size="small"
|
||||
key={index}
|
||||
bodyStyle={{ padding: '12px' }}
|
||||
hoverable
|
||||
>
|
||||
<Row gutter={16} align="top">
|
||||
<Col
|
||||
span={7}
|
||||
style={{ textAlign: 'center' }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '60px',
|
||||
backgroundColor: '#f0f0f0',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: '4px',
|
||||
marginBottom: '8px',
|
||||
}}
|
||||
>
|
||||
<ToolOutlined
|
||||
style={{
|
||||
fontSize: '24px',
|
||||
color: '#bfbfbf',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color:
|
||||
sparepart.sparepart_stok ===
|
||||
'Available' ||
|
||||
sparepart.sparepart_stok ===
|
||||
'available'
|
||||
? '#52c41a'
|
||||
: '#ff4d4f',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{sparepart.sparepart_stok}
|
||||
</Text>
|
||||
</Col>
|
||||
<Col span={17}>
|
||||
<Space
|
||||
direction="vertical"
|
||||
size={4}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<Text strong>
|
||||
{sparepart.sparepart_name}
|
||||
</Text>
|
||||
<Paragraph
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
margin: 0,
|
||||
color: '#595959',
|
||||
}}
|
||||
>
|
||||
{sparepart.sparepart_description ||
|
||||
'Deskripsi tidak tersedia'}
|
||||
</Paragraph>
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid #d9d9d9',
|
||||
borderRadius: '4px',
|
||||
padding: '4px 8px',
|
||||
fontSize: '11px',
|
||||
color: '#8c8c8c',
|
||||
marginTop: '8px',
|
||||
}}
|
||||
>
|
||||
Kode:{' '}
|
||||
{sparepart.sparepart_code} |
|
||||
Qty:{' '}
|
||||
{sparepart.sparepart_qty} |
|
||||
Unit:{' '}
|
||||
{sparepart.sparepart_unit}
|
||||
</div>
|
||||
</Space>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
))
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
padding: '20px',
|
||||
color: '#8c8c8c',
|
||||
}}
|
||||
>
|
||||
Tidak ada spare parts terkait
|
||||
</div>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
</div>
|
||||
</Col>
|
||||
<Col xs={24} md={8}>
|
||||
<div>
|
||||
<Card bodyStyle={{ padding: '12px'}}>
|
||||
<Space>
|
||||
<HistoryOutlined
|
||||
style={{ fontSize: '16px', color: '#1890ff' }}
|
||||
/>
|
||||
<Text
|
||||
strong
|
||||
style={{ fontSize: '16px', color: '#262626' }}
|
||||
>
|
||||
Log Activity
|
||||
</Text>
|
||||
</Space>
|
||||
|
||||
<Space
|
||||
direction="vertical"
|
||||
size="small"
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<Card
|
||||
size="small"
|
||||
bodyStyle={{
|
||||
padding: '8px 12px',
|
||||
backgroundColor: isAddingLog
|
||||
? '#fafafa'
|
||||
: '#fff',
|
||||
}}
|
||||
>
|
||||
<Space
|
||||
direction="vertical"
|
||||
style={{ width: '100%' }}
|
||||
size="small"
|
||||
>
|
||||
{isAddingLog && (
|
||||
<>
|
||||
<Text
|
||||
strong
|
||||
style={{ fontSize: '12px' }}
|
||||
>
|
||||
Add New Log / Update Progress
|
||||
</Text>
|
||||
<Input.TextArea
|
||||
rows={2}
|
||||
placeholder="Tuliskan update penanganan di sini..."
|
||||
value={newLogDescription}
|
||||
onChange={(e) =>
|
||||
setNewLogDescription(
|
||||
e.target.value
|
||||
)
|
||||
}
|
||||
disabled={submitLoading}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
type={isAddingLog ? 'primary' : 'dashed'}
|
||||
size="small"
|
||||
block
|
||||
icon={
|
||||
submitLoading ? (
|
||||
<LoadingOutlined />
|
||||
) : (
|
||||
!isAddingLog && <PlusOutlined />
|
||||
)
|
||||
}
|
||||
onClick={
|
||||
isAddingLog
|
||||
? handleSubmitLog
|
||||
: () => setIsAddingLog(true)
|
||||
}
|
||||
loading={submitLoading}
|
||||
disabled={submitLoading}
|
||||
>
|
||||
{isAddingLog ? 'Submit Log' : 'Add Log'}
|
||||
</Button>
|
||||
{isAddingLog && (
|
||||
<Button
|
||||
size="small"
|
||||
block
|
||||
onClick={() => {
|
||||
setIsAddingLog(false);
|
||||
setNewLogDescription('');
|
||||
}}
|
||||
disabled={submitLoading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
{logHistoryData.map((log) => (
|
||||
<Card
|
||||
key={log.id}
|
||||
size="small"
|
||||
bodyStyle={{
|
||||
padding: '8px 12px',
|
||||
}}
|
||||
>
|
||||
<Paragraph
|
||||
style={{ fontSize: '12px', margin: 0 }}
|
||||
// ellipsis={{ rows: 2 }}
|
||||
>
|
||||
<Text strong>{log.addedBy.name}:</Text>{' '}
|
||||
{log.description}
|
||||
</Paragraph>
|
||||
<Text
|
||||
type="secondary"
|
||||
style={{ fontSize: '11px' }}
|
||||
>
|
||||
{log.timestamp}
|
||||
</Text>
|
||||
</Card>
|
||||
))}
|
||||
</Space>
|
||||
</Card>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</Space>
|
||||
</Card>
|
||||
</Content>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationDetailTab;
|
||||
@@ -1,246 +1,91 @@
|
||||
import React, { memo, useState, useEffect } from 'react';
|
||||
import { Button, Row, Col, Card, DatePicker, Select, Typography, Table, Spin, Modal } from 'antd';
|
||||
import { Button, Row, Col, Card, Input, DatePicker, Select, Typography } from 'antd';
|
||||
import TableList from '../../../../components/Global/TableList';
|
||||
import dayjs from 'dayjs';
|
||||
import { FileTextOutlined, DownloadOutlined, LoadingOutlined } from '@ant-design/icons';
|
||||
import { FileTextOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
getAllHistoryValueReportPivot,
|
||||
getAllHistoryValueReport,
|
||||
getAllHistoryValueReportPivot,
|
||||
} 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 dateNow = dayjs();
|
||||
const dateNowFormated = dateNow.format('YYYY-MM-DD');
|
||||
|
||||
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(30);
|
||||
|
||||
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 fetchData = async (page = 1, pageSize = 10, showModal = false) => {
|
||||
// if (!plantSubSection) {
|
||||
// return;
|
||||
// }
|
||||
|
||||
if (showModal) {
|
||||
setIsLoadingModal(true);
|
||||
} else {
|
||||
setIsLoadingTable(true);
|
||||
}
|
||||
try {
|
||||
const formattedDateStart = startDate.format('YYYY-MM-DD');
|
||||
const formattedDateEnd = endDate.format('YYYY-MM-DD');
|
||||
|
||||
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 = [
|
||||
const columns = [
|
||||
{
|
||||
title: 'No',
|
||||
key: 'no',
|
||||
width: 60,
|
||||
width: '5%',
|
||||
align: 'center',
|
||||
fixed: 'left',
|
||||
render: (_, __, index) => {
|
||||
return (page - 1) * pageSize + index + 1;
|
||||
},
|
||||
render: (_, __, index) => index + 1,
|
||||
},
|
||||
{
|
||||
title: 'Datetime',
|
||||
dataIndex: 'datetime',
|
||||
key: 'datetime',
|
||||
width: 180,
|
||||
fixed: 'left',
|
||||
sorter: (a, b) => new Date(a.datetime) - new Date(b.datetime),
|
||||
width: '15%',
|
||||
},
|
||||
...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);
|
||||
{
|
||||
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%',
|
||||
// },
|
||||
];
|
||||
|
||||
setColumns(dynamicColumns);
|
||||
const dateNow = dayjs();
|
||||
const dateNowFormated = dateNow.format('YYYY-MM-DD');
|
||||
|
||||
// Pagination
|
||||
const total = pivotTableData.length;
|
||||
const startIndex = (page - 1) * pageSize;
|
||||
const endIndex = startIndex + pageSize;
|
||||
const paginatedData = pivotTableData.slice(startIndex, endIndex);
|
||||
const [trigerFilter, setTrigerFilter] = useState(false);
|
||||
|
||||
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 [plantSubSection, setPlantSubSection] = useState(0);
|
||||
const [plantSubSectionList, setPlantSubSectionList] = useState([]);
|
||||
const [startDate, setStartDate] = useState(dateNow);
|
||||
const [endDate, setEndDate] = useState(dateNow);
|
||||
const [periode, setPeriode] = useState(10);
|
||||
|
||||
const defaultFilter = {
|
||||
criteria: '',
|
||||
plant_sub_section_id: 0,
|
||||
from: dateNowFormated,
|
||||
to: dateNowFormated,
|
||||
interval: periode,
|
||||
};
|
||||
const [formDataFilter, setFormDataFilter] = useState(defaultFilter);
|
||||
|
||||
const handleTableChange = (pagination, filters, sorter) => {
|
||||
fetchData(pagination.current, pagination.pageSize, false);
|
||||
};
|
||||
|
||||
const handleSearch = async () => {
|
||||
setIsLoadingModal(true);
|
||||
|
||||
try {
|
||||
const handleSearch = () => {
|
||||
const formattedDateStart = startDate.format('YYYY-MM-DD');
|
||||
const formattedDateEnd = endDate.format('YYYY-MM-DD');
|
||||
|
||||
const params = new URLSearchParams({
|
||||
setFormDataFilter({
|
||||
criteria: '',
|
||||
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);
|
||||
}
|
||||
setTrigerFilter((prev) => !prev);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setPlantSubSection(0);
|
||||
setStartDate(dateNow);
|
||||
setEndDate(dateNow);
|
||||
setPeriode(30);
|
||||
setTableData([]);
|
||||
setColumns([]);
|
||||
setPivotData([]);
|
||||
setValueReportData([]);
|
||||
setPagination({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
});
|
||||
setPeriode(5);
|
||||
};
|
||||
|
||||
const getPlantSubSection = async () => {
|
||||
@@ -259,548 +104,8 @@ 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}>
|
||||
@@ -862,8 +167,14 @@ const ListReport = memo(function ListReport(props) {
|
||||
value={periode}
|
||||
onChange={setPeriode}
|
||||
style={{ width: '100%', marginTop: '4px' }}
|
||||
options={periodeOptions}
|
||||
/>
|
||||
options={[
|
||||
{ value: 5, label: '5 Minute' },
|
||||
{ value: 10, label: '10 Minute' },
|
||||
{ value: 30, label: '30 Minute' },
|
||||
{ value: 60, label: '1 Hour' },
|
||||
{ value: 120, label: '2 Hour' },
|
||||
]}
|
||||
></Select>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
@@ -874,33 +185,10 @@ 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}
|
||||
@@ -911,26 +199,18 @@ const ListReport = memo(function ListReport(props) {
|
||||
</Col>
|
||||
</Row>
|
||||
</Col>
|
||||
<Col xs={24} style={{ marginTop: '16px' }}>
|
||||
<Spin spinning={isLoadingTable}>
|
||||
<div style={{ overflowX: 'auto', width: '100%' }}>
|
||||
<Table
|
||||
<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}
|
||||
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
|
||||
columnDynamic={'columns'}
|
||||
triger={trigerFilter}
|
||||
/>
|
||||
</div>
|
||||
</Spin>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
@@ -1,17 +1,8 @@
|
||||
import React, { memo, useState, useEffect } from 'react';
|
||||
import { Button, Row, Col, Card, DatePicker, Select, Typography, Modal, Spin } from 'antd';
|
||||
import { Button, Row, Col, Card, Input, DatePicker, Select, Typography } from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
import { FileTextOutlined, LoadingOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer
|
||||
} from 'recharts';
|
||||
import { FileTextOutlined } from '@ant-design/icons';
|
||||
import { ResponsiveLine } from '@nivo/line';
|
||||
import './trending.css';
|
||||
import { getAllPlantSection } from '../../../api/master-plant-section';
|
||||
import { getAllHistoryValueTrendingPivot } from '../../../api/history-value';
|
||||
@@ -27,7 +18,6 @@ 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: '',
|
||||
@@ -39,19 +29,8 @@ const ReportTrending = memo(function ReportTrending(props) {
|
||||
const [formDataFilter, setFormDataFilter] = useState(defaultFilter);
|
||||
|
||||
const [trendingValue, setTrendingValue] = useState([]);
|
||||
const [chartData, setChartData] = useState([]);
|
||||
const [metrics, setMetrics] = useState([]);
|
||||
|
||||
// Palet warna
|
||||
const colorPalette = [
|
||||
'#3b82f6', '#ef4444', '#10b981', '#f59e0b', '#8b5cf6',
|
||||
'#ec4899', '#14b8a6', '#f97316', '#6366f1', '#84cc16'
|
||||
];
|
||||
|
||||
const handleSearch = async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const formattedDateStart = startDate.format('YYYY-MM-DD');
|
||||
const formattedDateEnd = endDate.format('YYYY-MM-DD');
|
||||
|
||||
@@ -69,53 +48,32 @@ const ReportTrending = memo(function ReportTrending(props) {
|
||||
const response = await getAllHistoryValueTrendingPivot(param);
|
||||
|
||||
if (response?.data?.length > 0) {
|
||||
transformDataForRecharts(response.data);
|
||||
// 🔹 Bersihkan dan format data agar aman untuk Nivo
|
||||
const cleanedData = response.data.map((serie) => ({
|
||||
id: serie.id ?? 'Unknown',
|
||||
data: Array.isArray(serie.data)
|
||||
? serie.data.map((d) => ({
|
||||
x: d?.x ?? null,
|
||||
y:
|
||||
d?.y !== null && d?.y !== undefined
|
||||
? Number(d.y).toFixed(4) // format 4 angka di belakang koma
|
||||
: null,
|
||||
}))
|
||||
: [],
|
||||
}));
|
||||
|
||||
setTrendingValue(cleanedData);
|
||||
} else {
|
||||
// 🔹 Jika tidak ada data dari API
|
||||
setTrendingValue([]);
|
||||
setChartData([]);
|
||||
setMetrics([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching trending data:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const transformDataForRecharts = (nivoData) => {
|
||||
setTrendingValue(nivoData);
|
||||
|
||||
const metricNames = nivoData.map(serie => serie.id);
|
||||
setMetrics(metricNames);
|
||||
|
||||
const timeMap = new Map();
|
||||
|
||||
nivoData.forEach(serie => {
|
||||
serie.data.forEach(point => {
|
||||
if (!timeMap.has(point.x)) {
|
||||
timeMap.set(point.x, { time: point.x });
|
||||
}
|
||||
const entry = timeMap.get(point.x);
|
||||
entry[serie.id] = point.y !== null && point.y !== undefined
|
||||
? parseFloat(point.y)
|
||||
: null;
|
||||
});
|
||||
});
|
||||
|
||||
const transformedData = Array.from(timeMap.values()).sort((a, b) =>
|
||||
new Date(a.time) - new Date(b.time)
|
||||
);
|
||||
|
||||
setChartData(transformedData);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setPlantSubSection(0);
|
||||
setStartDate(dateNow);
|
||||
setEndDate(dateNow);
|
||||
setPeriode(60);
|
||||
setChartData([]);
|
||||
setMetrics([]);
|
||||
setPeriode(5);
|
||||
};
|
||||
|
||||
const getPlantSubSection = async () => {
|
||||
@@ -130,171 +88,12 @@ const ReportTrending = memo(function ReportTrending(props) {
|
||||
}
|
||||
};
|
||||
|
||||
// Fungsi untuk menentukan apakah rentang tanggal lebih dari 1 hari
|
||||
const isMultipleDays = () => {
|
||||
return !startDate.isSame(endDate, 'day');
|
||||
};
|
||||
|
||||
// Format sumbu X yang otomatis menyesuaikan
|
||||
const formatXAxis = (tickItem) => {
|
||||
const date = new Date(tickItem);
|
||||
const hours = date.getHours().toString().padStart(2, '0');
|
||||
const minutes = date.getMinutes().toString().padStart(2, '0');
|
||||
|
||||
// Jika rentang lebih dari 1 hari, tampilkan tanggal + waktu
|
||||
if (isMultipleDays()) {
|
||||
const day = date.getDate().toString().padStart(2, '0');
|
||||
const month = (date.getMonth() + 1).toString().padStart(2, '0');
|
||||
return `${day}/${month} ${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
// Jika hanya 1 hari, tampilkan waktu saja
|
||||
return `${hours}:${minutes}`;
|
||||
};
|
||||
|
||||
const CustomTooltip = ({ active, payload, label }) => {
|
||||
if (active && payload && payload.length) {
|
||||
return (
|
||||
<div style={{
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.98)',
|
||||
padding: '12px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)'
|
||||
}}>
|
||||
<p style={{ margin: 0, fontWeight: 'bold', marginBottom: '8px' }}>
|
||||
{new Date(label).toLocaleString('id-ID')}
|
||||
</p>
|
||||
{payload.map((entry, index) => (
|
||||
<p key={index} style={{
|
||||
margin: '4px 0',
|
||||
color: entry.color,
|
||||
fontSize: '13px'
|
||||
}}>
|
||||
<strong>{entry.name}:</strong> {Number(entry.value).toFixed(4)}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const renderChart = () => {
|
||||
if (!chartData || chartData.length === 0) {
|
||||
return (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
marginTop: '100px',
|
||||
color: '#999',
|
||||
fontSize: '16px'
|
||||
}}>
|
||||
Tidak ada data untuk ditampilkan
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={500}>
|
||||
<LineChart
|
||||
data={chartData}
|
||||
margin={{ top: 20, right: 200, left: 80, bottom: 40 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e0e0e0" />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
angle={-45}
|
||||
textAnchor="end"
|
||||
height={100}
|
||||
tick={{ fontSize: 11 }}
|
||||
tickFormatter={formatXAxis}
|
||||
label={{
|
||||
value: 'Waktu',
|
||||
position: 'bottom',
|
||||
offset: -50,
|
||||
style: { fontSize: 14, fontWeight: 'bold' }
|
||||
}}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 11 }}
|
||||
label={{
|
||||
value: 'Nilai',
|
||||
angle: -90,
|
||||
position: 'right',
|
||||
offset: -70,
|
||||
dy: 0,
|
||||
style: {
|
||||
fontSize: 12,
|
||||
fontWeight: 'bold',
|
||||
fill: '#059669',
|
||||
textAnchor: 'middle'
|
||||
}
|
||||
}}
|
||||
tickFormatter={(value) => Number(value).toFixed(2)}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Legend
|
||||
layout="vertical"
|
||||
align="right"
|
||||
verticalAlign="middle"
|
||||
wrapperStyle={{
|
||||
position: 'absolute',
|
||||
right: 150,
|
||||
top: '35%',
|
||||
transform: 'translateY(-50%)'
|
||||
}}
|
||||
/>
|
||||
{metrics.map((metric, index) => {
|
||||
const color = colorPalette[index % colorPalette.length];
|
||||
return (
|
||||
<Line
|
||||
key={metric}
|
||||
type="monotone"
|
||||
dataKey={metric}
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
dot={chartData.length < 50}
|
||||
name={metric}
|
||||
connectNulls={true}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
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}>
|
||||
@@ -363,11 +162,10 @@ 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
|
||||
@@ -389,9 +187,108 @@ const ReportTrending = memo(function ReportTrending(props) {
|
||||
</Col>
|
||||
</Row>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} sm={24} md={24} lg={24} xl={24} style={{ marginTop: '24px' }}>
|
||||
{renderChart()}
|
||||
<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>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
@@ -229,7 +229,7 @@ const ListRole = memo(function ListRole(props) {
|
||||
onClick={() => showAddModal()}
|
||||
size="large"
|
||||
>
|
||||
Add Role
|
||||
Add Data
|
||||
</Button>
|
||||
</ConfigProvider>
|
||||
</Space>
|
||||
|
||||
@@ -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,27 +220,35 @@ 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;
|
||||
payload.user_password = FormData.password;
|
||||
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
|
||||
}
|
||||
// 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) {
|
||||
@@ -249,10 +257,11 @@ 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(
|
||||
@@ -376,9 +385,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) {
|
||||
@@ -399,7 +408,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');
|
||||
@@ -409,7 +418,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);
|
||||
@@ -420,7 +429,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') {
|
||||
@@ -1137,7 +1146,9 @@ 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: 'Action',
|
||||
title: 'Aksi',
|
||||
key: 'aksi',
|
||||
align: 'center',
|
||||
width: '12%',
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Layout, Card, Row, Col, Typography, Button, Input } from 'antd';
|
||||
import { ArrowLeftOutlined, ExclamationCircleOutlined } from '@ant-design/icons';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
|
||||
const { Content } = Layout;
|
||||
const { Title, Paragraph } = Typography;
|
||||
const { Search } = Input;
|
||||
|
||||
const IndexVerificationSparepart = () => {
|
||||
const navigate = useNavigate();
|
||||
const { notification_error_id } = useParams();
|
||||
|
||||
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 }}
|
||||
>
|
||||
Kembali ke daftar notifikasi
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
<div style={{ backgroundColor: '#f6ffed', border: '1px solid #b7eb8f', borderRadius: '4px 4px 0 0', padding: '8px 16px', marginTop: '16px' }}>
|
||||
<Row justify="center" align="middle">
|
||||
<Col>
|
||||
<Title level={4} style={{ margin: 0, color: '#262626' }}>
|
||||
List Available Sparepart
|
||||
</Title>
|
||||
</Col>
|
||||
</Row>
|
||||
<Paragraph style={{ margin: '4px 0 0', color: '#595959', textAlign: 'center' }}>
|
||||
Select items from inventory to save changes
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div style={{ border: '1px solid #91d5ff', borderTop: 'none', backgroundColor: '#e6f7ff', padding: '12px 16px', borderRadius: '0 0 4px 4px', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<ExclamationCircleOutlined style={{ color: '#1890ff', fontSize: '20px', marginRight: '12px' }} />
|
||||
<Paragraph style={{ margin: 0 }}>
|
||||
<strong>Important Notice:</strong> All items listed are currently in stock and available for immediate use. Please verify part numbers before installation. Selected items will be marked for inventory tracking.
|
||||
</Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Row justify="space-between" align="middle" style={{ marginBottom: '24px' }}>
|
||||
<Col>
|
||||
<Title level={5} style={{ margin: 0 }}>• Inventory</Title>
|
||||
</Col>
|
||||
<Col>
|
||||
<Search
|
||||
placeholder="Search in inventory"
|
||||
onSearch={value => console.log(value)}
|
||||
style={{ width: 200 }}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* Konten untuk verifikasi spare part akan ditambahkan di sini */}
|
||||
<div style={{ textAlign: 'center', padding: '40px 0' }}>
|
||||
<Title level={5}>ID Notifikasi: {notification_error_id}</Title>
|
||||
<p>Halaman ini dalam pengembangan. Di sini akan ditampilkan detail spare part yang perlu diverifikasi.</p>
|
||||
</div>
|
||||
</Card>
|
||||
</Content>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default IndexVerificationSparepart;
|
||||