Compare commits

...

126 Commits

Author SHA1 Message Date
zain94rif
c2163cec5e Merge branch 'lavoce' of https://gitea.idetama.id/yogiedigital/cod-fe into lavoce 2026-01-08 17:25:23 +07:00
zain94rif
d5866ceae4 fix(api): search use api 2026-01-08 17:25:17 +07:00
6fdb259246 fixing validate solution optional in brand error code 2026-01-08 14:32:27 +07:00
0aad43c751 add label error code in list notification 2026-01-08 14:24:32 +07:00
d988d47e30 fixing layout mobile detail notification 2026-01-08 14:17:38 +07:00
zain94rif
e08eaaa43e fix(text): add 'error code' & move solution name 2026-01-08 13:55:01 +07:00
zain94rif
f6ca54f5b4 Merge branch 'lavoce' of https://gitea.idetama.id/yogiedigital/cod-fe into lavoce 2026-01-08 13:07:27 +07:00
zain94rif
a9b8053bd8 fix: change message 'Setiap error code harus memiliki minimal 1 solution!' disabled 2026-01-08 13:07:21 +07:00
600c101c68 fixing typo detail notification user 2026-01-08 12:17:13 +07:00
zain94rif
14a6884f43 Merge branch 'lavoce' of https://gitea.idetama.id/yogiedigital/cod-fe into lavoce 2026-01-07 17:07:46 +07:00
zain94rif
8e151ffe0b fix(comp): modified the card in notification detail 2026-01-07 17:07:42 +07:00
8f64843613 fixing redirect wa session token 2026-01-07 16:10:23 +07:00
zain94rif
fe8f6d1002 fix: move update is read's api after fetchLogHistory 2026-01-07 14:40:36 +07:00
zain94rif
5281e288a9 feat(var): add update at from create at 2026-01-07 11:00:35 +07:00
zain94rif
4ed05cc640 fix: resize add log card 2026-01-07 10:30:03 +07:00
zain94rif
14e97fead2 feat(api): add update is_read for detail 2026-01-06 16:12:58 +07:00
zain94rif
0935d7c9f5 fix(var): use notification_error_id from item, not from users 2026-01-06 09:53:15 +07:00
zain94rif
3266641f81 fix(api): fixing put to post 2026-01-05 14:48:46 +07:00
zain94rif
739c55c0bc fix(api): fixing endpoint notification 2026-01-05 14:26:12 +07:00
zain94rif
5b4485d20d feat(api): add API for resend chat user 2026-01-05 14:08:02 +07:00
98057beb0f Merge pull request 'fix-lav-notification' (#31) from fix-lav-notification into lavoce
Reviewed-on: #31
2026-01-05 03:40:24 +00:00
zain94rif
b342289888 fix(view): adjustment view page notification 2026-01-05 10:39:01 +07:00
d03bbf2a41 Repair topic mqtt 2026-01-05 10:38:24 +07:00
zain94rif
ec094b8f55 fix(text): change 'User History' to 'History User' 2026-01-05 09:38:27 +07:00
b6d941ba2d refactor: comment out console logs for cleaner production code 2025-12-29 10:58:03 +07:00
167abcaa43 refactor: enhance notification log layout and styling for better readability 2025-12-24 11:51:39 +07:00
beb8ccbaee feat: integration notification functionality and user history fetching 2025-12-23 22:10:11 +07:00
797f6c2383 refactor: clean up comments and streamline payload handling in user detail form 2025-12-23 20:10:33 +07:00
016c77a586 fixing redirect detail notification tab 2025-12-23 12:17:17 +07:00
36ebab7f9a refactor: remove unused UserHistoryModal and related state management 2025-12-23 10:30:44 +07:00
a5b1fbef74 repair: layout & sparepart brand-device 2025-12-23 10:26:30 +07:00
Athif
cb0c53daea update Menu Report 2025-12-23 02:20:16 +07:00
978e020305 feat: update notification data transformation and enhance user history display 2025-12-22 20:49:48 +07:00
4508738958 feat: implement log history fetching and display in ListNotification component 2025-12-22 18:47:42 +07:00
eb23612444 Repair and replace svg 2025-12-22 16:37:11 +07:00
bee196e299 feat: add notification log creation and retrieval functionality 2025-12-22 14:34:14 +07:00
d19f555c7c repair: layout error code form brand-device 2025-12-19 12:43:56 +07:00
1d7253f9a1 repair: sparepart select brand-device 2025-12-19 12:43:19 +07:00
d8a1878ab1 refactor: adjust spacing and layout in NotificationDetailTab for improved readability 2025-12-18 21:11:49 +07:00
e4af2d6e18 refactor: improve timestamp display and code formatting in notification detail 2025-12-18 19:34:20 +07:00
8cf21643ea refactor: update notification handling to prioritize active solutions and improve data extraction 2025-12-18 17:57:25 +07:00
Athif
6b75f6f4b9 update menu report 2025-12-18 15:03:39 +07:00
Athif
dc78add71d Perbaikan Menu Report dan Trending 2025-12-18 13:02:52 +07:00
1ce922ff4c minor fix notif 2025-12-18 12:36:46 +07:00
3a4b0f0748 repair: ErrorCode brand-device 2025-12-18 10:51:35 +07:00
4bffbb3798 repair: add clear selected error code 2025-12-13 14:25:30 +07:00
b9cdfcb1e9 repair: sollution field, handle clear form 2025-12-13 14:15:35 +07:00
49ba00d886 repair: view brand device, add: read only 2025-12-12 23:02:09 +07:00
cf1ccb0fd0 repair: sollution brand-device 2025-12-12 16:55:05 +07:00
fb790e5e37 repair: error code brand-device 2025-12-12 16:54:52 +07:00
ea3adf40cc repair: brandDevice add edit page 2025-12-12 16:54:30 +07:00
2ff50342e8 repair: sollution brand-device 2025-12-12 15:58:11 +07:00
1f8ee62721 fix: improve formatting and consistency in DetailDevice and GeneratePdf components 2025-12-12 15:53:29 +07:00
96d6367dbd add: image viewer 2025-12-12 12:46:05 +07:00
8afff23ffe update: brand device 2025-12-12 12:45:46 +07:00
512282f367 repair: sollution brand-device 2025-12-12 12:45:00 +07:00
4fab5df300 repair: error code brand-device 2025-12-12 12:44:16 +07:00
9e8191f8f8 repair: sparepart brand-device 2025-12-12 12:43:14 +07:00
13255f9713 feat: add getNotificationDetail API function and integrate it into DetailNotification component for enhanced notification handling 2025-12-11 17:07:06 +07:00
e23215b6c1 refactor: adjust layout and styling in DetailNotification and ListNotification components; remove pagination in LogHistoryCard for improved UI 2025-12-11 09:53:44 +07:00
a014d6b370 feat: replace LogHistoryModal with LogHistoryCard and update DetailNotification for improved log history display 2025-12-09 13:32:53 +07:00
3225a0865e repair: delete error code 2025-12-09 11:06:28 +07:00
5703ff0e8d repair: add edit brand device 2025-12-08 16:45:49 +07:00
03be3a6a99 fix: update column title from 'Aksi' to 'Action' in ListUser component for clarity 2025-12-08 13:48:15 +07:00
fe5f081b92 feat: update handling of description fields in multiple components to ensure non-empty values are stored 2025-12-05 13:13:30 +07:00
acaf1b3946 feat: add listen channel field to DetailDevice component and update ListDevice to display it; enhance quantity handling in SparepartCardList to prevent negative adjustments 2025-12-04 15:59:57 +07:00
147171373c feat: update quantity display and stock update button visibility in SparepartCardList component 2025-12-04 13:59:25 +07:00
f22e120204 update ui brand device 2025-12-04 01:17:25 +07:00
1bc98de564 feat: enhance image URL handling in DetailSparepart component and remove status field display 2025-12-03 16:58:58 +07:00
991a3eaa66 feat: update quantity handling in DetailSparepart and SparepartCardList components to allow zero values 2025-12-03 16:45:44 +07:00
7a5a9aafd1 feat: update sparepart quantity and stock status handling in DetailSparepart and SparepartCardList components 2025-12-03 13:26:54 +07:00
0694497f8d feat: remove contact_type from DetailContact component 2025-12-03 10:49:16 +07:00
c82d6d39c1 feat: conditionally display status field in add mode for DetailContact component 2025-12-02 15:39:20 +07:00
edf20050db feat: hide tabs and improve UI elements 2025-12-02 14:05:35 +07:00
2e98dc168a feat: enhance DetailSparepart component with image upload and preview functionality 2025-12-02 13:48:56 +07:00
1797058526 repair: brandDevice sparepart integration 2025-12-02 11:10:36 +07:00
1c2ddca9d4 fix: update isReadFilter 2025-12-01 10:51:11 +07:00
61ca7249cd repair 2025-11-28 14:16:45 +07:00
a98edbe658 Add scroll for overflow menus 2025-11-28 14:10:22 +07:00
fbc5473f2b feat: implement sparepart selection functionality and refactor related components 2025-11-28 14:00:17 +07:00
55a47c3a25 Repair svg compressor B 2025-11-28 12:50:09 +07:00
94e011e5c7 Repair topic mqtt 2025-11-28 12:49:56 +07:00
db9b40f2fc Repair compresso 2025-11-28 12:28:17 +07:00
5fdfb47f9e fix: update error code icon handling to use URL if available 2025-11-27 17:39:39 +07:00
55c50f6f7f Refactor code structure for improved readability and maintainability 2025-11-27 16:58:27 +07:00
ed4570e8dd feat: add onGetData callback to TableList and enhance DetailSparepart with improved image handling 2025-11-27 13:30:45 +07:00
572042ab53 feat: enhance DetailSparepart component with image upload and preview functionality 2025-11-27 11:36:23 +07:00
afcb85a323 feat: add image upload functionality and stock update feature in sparepart components 2025-11-26 17:16:09 +07:00
14f8a5d472 feat: enhance notification fetching with pagination and filtering options 2025-11-26 11:44:50 +07:00
309d191bce feat: add custom card component for sparepart display in ListSparepart 2025-11-25 16:49:46 +07:00
7e5105392c feat: add activity log functionality with log history display and input for new logs 2025-11-25 10:22:55 +07:00
7e16bf63aa refactor: consolidate SolutionField components and remove obsolete files 2025-11-24 21:44:44 +07:00
3e384f89b1 feat: integrate sparepart management into AddBrandDevice and EditBrandDevice components 2025-11-24 21:25:24 +07:00
b05e3fe5d9 feat: implement sparepart management with CRUD operations and UI components 2025-11-24 20:40:38 +07:00
1986368c1c feat: update button label from 'Add Data' to 'Add Role' in ListRole component 2025-11-24 15:58:23 +07:00
1cd9cf765c fix dual action 2025-11-24 15:57:12 +07:00
908788f41d feat: enhance error code management with table display and action buttons 2025-11-24 13:05:33 +07:00
899695f548 feat: add sparepart management page and update routing in App component 2025-11-24 12:17:31 +07:00
7d2b18a94d refactor: remove sparepart handling from AddBrandDevice and EditBrandDevice components 2025-11-24 11:59:26 +07:00
1eab3fe845 feat: refactor notification detail handling and add verification spare part page 2025-11-21 16:25:50 +07:00
c4f290bfcb fix: improve success message in DetailContact 2025-11-20 19:52:03 +07:00
f304a28493 enhance search and pagination functionality to ListNotification component 2025-11-20 19:45:45 +07:00
73b5cd6e97 feat: add detail notification page and update notification links 2025-11-20 16:41:21 +07:00
2d0c28bc48 fix: validation in DetailContact component 2025-11-20 15:55:03 +07:00
1413d0ef33 fix: header list tag plant sub section 2025-11-20 15:40:06 +07:00
fde71818e2 fix: enhance tag duplication check and handle varied response structures in DetailTag component 2025-11-20 15:25:07 +07:00
85017cd88c fix: update header label from 'tag_name' to 'brand_name' in ListBrandDevice component 2025-11-20 15:14:30 +07:00
f1c7ae5e20 fix: update header label for clarity in ListPlantSubSection component 2025-11-20 15:09:16 +07:00
5989948bf9 refactor: simplify log history display in ListNotification component 2025-11-20 11:00:46 +07:00
6a21b65808 konect brand device on device 2025-11-19 15:57:38 +07:00
b5fbf2f745 fix color contact 2025-11-19 15:40:15 +07:00
3b7ba28053 fix color number contact 2025-11-19 15:02:47 +07:00
f4caac55e6 feat: add API function to retrieve all notifications 2025-11-18 19:55:11 +07:00
34e38b3969 refactor: Improve code formatting and readability in SparepartForm component 2025-11-18 13:09:39 +07:00
3198b71f7e feat: Enhance delete confirmation with brand name in notification message 2025-11-18 12:43:00 +07:00
8405568e85 fix: Update Brand Code input to always be disabled with consistent placeholder and styling 2025-11-18 11:51:28 +07:00
ecf59fa9c6 refactor: Improve code readability and structure in EditBrandDevice component 2025-11-18 11:34:03 +07:00
0e8078c29f feat: Enhance notification system with device alerts, user history, and log history modals 2025-11-18 04:04:25 +07:00
1d06963f67 Load solutions and spareparts into forms with image handling in EditBrandDevice 2025-11-17 22:37:58 +07:00
8de195d961 Add custom validation for contact name and display type badge in contact list 2025-11-17 12:29:42 +07:00
da9cf0d554 hidden jadwal shift and master-shift 2025-11-15 14:57:25 +07:00
8cf5878d46 Add contact management functionality with CRUD operations and UI enhancements 2025-11-15 13:36:38 +07:00
7dd38aa50c Change inactive contact status color to red for better visibility 2025-11-13 15:22:45 +07:00
b2bcaa6b5f Add ErrorCodeListModal component for managing error codes with enhanced UI and functionality 2025-11-13 14:22:49 +07:00
5822dbbc82 Refactor Add and Edit Brand Device components to include solutions and spareparts forms, enhancing error code management and UI layout 2025-11-13 14:22:42 +07:00
de8f0ba2b6 Add forms and hooks for managing error codes and spare parts 2025-11-13 14:22:23 +07:00
78 changed files with 11584 additions and 3436 deletions

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 309 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

View File

@@ -10,13 +10,15 @@ import Home from './pages/home/Home';
import Blank from './pages/blank/Blank';
// Master
import IndexDevice from './pages/master/device/IndexDevice';
import IndexTag from './pages/master/tag/IndexTag';
import IndexUnit from './pages/master/unit/IndexUnit';
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 IndexStatus from './pages/master/status/IndexStatus';
import IndexSparepart from './pages/master/sparepart/IndexSparepart';
import IndexShift from './pages/master/shift/IndexShift';
// Brand device
// Brand device
import AddBrandDevice from './pages/master/brandDevice/AddBrandDevice';
import EditBrandDevice from './pages/master/brandDevice/EditBrandDevice';
import ViewBrandDevice from './pages/master/brandDevice/ViewBrandDevice';
@@ -34,6 +36,8 @@ 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';
@@ -46,7 +50,10 @@ 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';
import IndexPlantSubSection from './pages/master/plantSubSection/IndexPlantSubSection';
// Image Viewer
import ImageViewer from './Utils/ImageViewer';
import RedirectWa from './pages/blank/RedirectWa';
const App = () => {
return (
@@ -57,6 +64,16 @@ 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 />}>
@@ -64,6 +81,8 @@ 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 />} />
@@ -79,16 +98,28 @@ 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="plant-sub-section" element={<IndexPlantSubSection />} />
<Route path="shift" element={<IndexShift />} />
<Route path="status" element={<IndexStatus />} />
<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>
<Route path="/report" element={<ProtectedRoute />}>
@@ -121,7 +152,6 @@ const App = () => {
<Route index element={<IndexJadwalShift />} />
</Route>
{/* Catch-all */}
<Route path="*" element={<NotFound />} />
</Routes>
</BrowserRouter>

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

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

56
src/api/contact.jsx Normal file
View File

@@ -0,0 +1,56 @@
import { SendRequest } from '../components/Global/ApiRequest';
const getAllContact = async (queryParams) => {
const response = await SendRequest({
method: 'get',
prefix: `contact?${queryParams.toString()}`,
});
return response.data;
};
const getContactById = async (id) => {
const response = await SendRequest({
method: 'get',
prefix: `contact/${id}`,
});
return response.data;
};
const createContact = async (queryParams) => {
const response = await SendRequest({
method: 'post',
prefix: `contact`,
params: queryParams,
});
return response.data;
};
const updateContact = async (id, queryParams) => {
const response = await SendRequest({
method: 'put',
prefix: `contact/${id}`,
params: queryParams,
});
return response.data;
};
const deleteContact = async (id) => {
const response = await SendRequest({
method: 'delete',
prefix: `contact/${id}`,
});
return response.data;
};
export {
getAllContact,
getContactById,
createContact,
updateContact,
deleteContact,
};

View File

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

105
src/api/notification.jsx Normal file
View File

@@ -0,0 +1,105 @@
import { SendRequest } from '../components/Global/ApiRequest';
const getAllNotification = async (queryParams) => {
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,
});
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,
};

50
src/api/sparepart.jsx Normal file
View File

@@ -0,0 +1,50 @@
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 };

View File

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

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 41 KiB

View File

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

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 41 KiB

View File

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

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 41 KiB

View File

@@ -1971,12 +1971,12 @@
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 9px; stroke-width: 1;" x="718.035" y="174.17">MPa</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 9px; stroke-width: 1;" x="229.981" y="200.126">####</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 9px; stroke-width: 1;" x="229.217" y="233.522">####</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 9px; stroke-width: 1;" x="229.599" y="265.154">####</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 9px; stroke-width: 1;" x="229.599" y="265.154" id="c_1003">####</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 9px; stroke-width: 1;" x="189.546" y="326.378">####</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 9px; stroke-width: 1;" x="361.232" y="371.05">####</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 9px; stroke-width: 1;" x="361.232" y="371.05" id="c_1004">####</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 9px; stroke-width: 1;" x="622.21" y="304.496">####</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 9px; stroke-width: 1;" x="684.474" y="141.612">####</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 9px; stroke-width: 1;" x="686.145" y="174.534">####</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 9px; stroke-width: 1;" x="684.474" y="141.612" id="c_1001">####</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 9px; stroke-width: 1;" x="686.145" y="174.534" id="c_1002">####</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 9px; stroke-width: 1;" x="893.7" y="201.982">####</text>
<text style="white-space: pre; fill: rgb(51, 51, 51); font-family: Arial, sans-serif; font-size: 9px; stroke-width: 1;" x="893.661" y="239.324">####</text>
<text style="fill: rgb(51, 51, 51); font-family: Bahnschrift; font-size: 9px; white-space: pre; font-weight: bolder;" transform="matrix(0.705508, 0, 0, 0.49184, 796.826824, 48.14839)" x="38.471" y="128.844">Plant Air Reciever</text>

Before

Width:  |  Height:  |  Size: 177 KiB

After

Width:  |  Height:  |  Size: 177 KiB

View File

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

View File

@@ -58,34 +58,34 @@ const CardList = ({
style={getCardStyle(fieldColor ? item[fieldColor] : cardColor)}
actions={[
showPreviewModal && (
<EyeOutlined
style={{ color: '#1890ff' }}
key="preview"
onClick={() => showPreviewModal(item)}
/>
<EyeOutlined
style={{ color: '#1890ff' }}
key="preview"
onClick={() => showPreviewModal(item)}
/>
),
showEditModal && (
<EditOutlined
style={{ color: '#faad14' }}
key="edit"
onClick={() => showEditModal(item)}
/>
<EditOutlined
style={{ color: '#faad14' }}
key="edit"
onClick={() => showEditModal(item)}
/>
),
showDeleteDialog && (
<DeleteOutlined
style={{ color: '#ff1818' }}
key="delete"
onClick={() => showDeleteDialog(item)}
/>
<DeleteOutlined
style={{ color: '#ff1818' }}
key="delete"
onClick={() => showDeleteDialog(item)}
/>
),
].filter(Boolean)} // <== Hapus elemen yang undefined
].filter(Boolean)} // <== Hapus elemen yang undefined
>
<div style={{ textAlign: 'left' }}>
{column.map((itemCard, index) => (
<React.Fragment key={index}>
{!itemCard.hidden &&
itemCard.title !== 'No' &&
itemCard.title !== 'Aksi' && (
itemCard.title !== 'Action' && (
<p style={{ margin: '8px 0' }}>
<Text strong>{itemCard.title}:</Text>{' '}
{itemCard.render

View File

@@ -2,7 +2,16 @@
import mqtt from 'mqtt';
const mqttUrl = `${import.meta.env.VITE_MQTT_SERVER ?? 'ws://localhost:1884'}`;
const topics = ['PIU_GGCP/Devices/PB'];
const topics = [
'PIU_COD/AIR_DRYER/OVERVIEW',
'PIU_COD/AIR_DRYER/AIR_DRYER_A',
'PIU_COD/AIR_DRYER/AIR_DRYER_B',
'PIU_COD/AIR_DRYER/AIR_DRYER_C',
'PIU_COD/COMPRESSOR/OVERVIEW',
'PIU_COD/COMPRESSOR/COMPRESSOR_A',
'PIU_COD/COMPRESSOR/COMPRESSOR_B',
'PIU_COD/COMPRESSOR/COMPRESSOR_C'
];
const options = {
keepalive: 30,
clientId: 'react_mqtt_' + Math.random().toString(16).substr(2, 8),
@@ -66,7 +75,8 @@ const listenMessage = (callback) => {
const setValSvg = (listenTopic, svg) => {
client.on('message', (topic, message) => {
if (topic == listenTopic) {
// console.log(topic ,' = ', listenTopic);
if (topic === listenTopic) {
const objChanel = JSON.parse(message);
Object.entries(objChanel).forEach(([key, value]) => {
@@ -78,7 +88,7 @@ const setValSvg = (listenTopic, svg) => {
} else if (value === false) {
el.style.display = 'none';
} else if (!isNaN(value)) {
el.textContent = Number(value ?? 0.0);
el.textContent = Number(value ?? 0.0).toFixed(2);
} else {
el.textContent = value;
}

View File

@@ -20,6 +20,9 @@ const TableList = memo(function TableList({
fieldColor,
firstLoad = true,
columnDynamic = false,
cardComponent, // New prop for custom card component
onStockUpdate, // Prop to pass to card component
onGetData, // Callback to execute when data is received
}) {
const [gridLoading, setGridLoading] = useState(false);
@@ -103,7 +106,14 @@ const TableList = memo(function TableList({
setColumnsDynamic([...defaultColumns, ...numericColumns]);
}
setData(resData?.data ?? []);
const fetchedData = resData?.data ?? [];
// Panggil callback jika disediakan
if (onGetData && typeof onGetData === 'function') {
onGetData(fetchedData);
}
setData(fetchedData);
const pagingData = resData?.paging;
@@ -142,6 +152,9 @@ const TableList = memo(function TableList({
const isMobile = !screens.md; // kalau kurang dari md (768px) dianggap mobile
// Use the custom card component if provided, otherwise default to CardList
const CardViewComponent = cardComponent || CardList;
return (
<div>
<Segmented
@@ -153,7 +166,7 @@ const TableList = memo(function TableList({
onChange={setViewMode}
/>
{(isMobile && mobile) || viewMode === 'card' ? (
<CardList
<CardViewComponent
cardColor={cardColor}
fieldColor={fieldColor}
data={data}
@@ -162,6 +175,7 @@ const TableList = memo(function TableList({
showPreviewModal={showPreviewModal}
showEditModal={showEditModal}
showDeleteDialog={showDeleteDialog}
onStockUpdate={onStockUpdate}
/>
) : (
<Row gutter={24} style={{ marginTop: '16px' }}>
@@ -200,3 +214,4 @@ const TableList = memo(function TableList({
});
export default TableList;

View File

@@ -20,36 +20,65 @@ html body {
height: 100vh;
}
/* Custom Orange Sidebar Menu Styles */
.custom-orange-menu.ant-menu-dark .ant-menu-item-selected {
/* Custom green Sidebar Menu Styles */
.custom-green-menu.ant-menu-dark .ant-menu-item-selected {
background-color: rgba(255, 255, 255, 0.2) !important;
color: white !important;
}
.custom-orange-menu.ant-menu-dark .ant-menu-item-selected::after {
.custom-green-menu.ant-menu-dark .ant-menu-item-selected::after {
border-right-color: white !important;
}
.custom-orange-menu.ant-menu-dark .ant-menu-item:hover,
.custom-orange-menu.ant-menu-dark .ant-menu-submenu-title:hover {
.custom-green-menu.ant-menu-dark .ant-menu-item:hover,
.custom-green-menu.ant-menu-dark .ant-menu-submenu-title:hover {
background-color: rgba(255, 255, 255, 0.15) !important;
color: white !important;
}
.custom-orange-menu.ant-menu-dark .ant-menu-submenu-selected > .ant-menu-submenu-title {
.custom-green-menu.ant-menu-dark .ant-menu-submenu-selected > .ant-menu-submenu-title {
color: white !important;
}
.custom-orange-menu.ant-menu-dark.ant-menu-inline .ant-menu-sub {
.custom-green-menu.ant-menu-dark.ant-menu-inline .ant-menu-sub {
background: rgba(0, 0, 0, 0.2) !important;
}
.custom-orange-menu.ant-menu-dark .ant-menu-item,
.custom-orange-menu.ant-menu-dark .ant-menu-submenu-title {
.custom-green-menu.ant-menu-dark .ant-menu-item,
.custom-green-menu.ant-menu-dark .ant-menu-submenu-title {
color: rgba(255, 255, 255, 0.9) !important;
}
.custom-orange-menu.ant-menu-dark .ant-menu-item-active,
.custom-orange-menu.ant-menu-dark .ant-menu-submenu-active > .ant-menu-submenu-title {
.custom-green-menu.ant-menu-dark .ant-menu-item-active,
.custom-green-menu.ant-menu-dark .ant-menu-submenu-active > .ant-menu-submenu-title {
color: white !important;
}
}
/*start styling for scrollbar menu */
.custom-menu-scrollbar::-webkit-scrollbar {
width: 8px;
}
.custom-menu-scrollbar::-webkit-scrollbar-track {
background: transparent;
border-radius: 10px;
margin: 5px 0;
}
.custom-menu-scrollbar::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, #1BAA56 0%, rgb(5, 75, 34) 100%);
border-radius: 10px;
border: 2px solid rgba(255, 255, 255, 0.3);
}
.custom-menu-scrollbar::-webkit-scrollbar-thumb:hover {
background: linear-gradient(180deg, #2bc56d 0%, rgb(8, 94, 43) 100%);
}
.custom-menu-scrollbar {
scrollbar-width: thin;
scrollbar-color: #1BAA56 transparent;
}
/* Hilangkan panah atas/bawah dengan important */
.custom-menu-scrollbar::-webkit-scrollbar-button {
display: none !important;
width: 0 !important;
height: 0 !important;
}
/*end styling for scrollbar menu */

View File

@@ -32,6 +32,7 @@ import {
SlidersOutlined,
SnippetsOutlined,
ContactsOutlined,
ToolOutlined,
} from '@ant-design/icons';
const { Text } = Typography;
@@ -76,7 +77,7 @@ const allItems = [
icon: <NodeExpandOutlined style={{ fontSize: '19px' }} />,
label: <Link to="/dashboard-svg/compressor-c">Compressor C</Link>,
},
]
],
},
{
key: 'dashboard-svg-airdryer',
@@ -103,7 +104,7 @@ const allItems = [
icon: <NodeExpandOutlined style={{ fontSize: '19px' }} />,
label: <Link to="/dashboard-svg/airdryer-c">Air Dryer C</Link>,
},
]
],
},
],
},
@@ -143,10 +144,15 @@ const allItems = [
label: <Link to="/master/status">Status</Link>,
},
{
key: 'master-shift',
icon: <ClockCircleOutlined style={{ fontSize: '19px' }} />,
label: <Link to="/master/shift">Shift</Link>,
key: 'master-sparepart',
icon: <ToolOutlined style={{ fontSize: '19px' }} />,
label: <Link to="/master/sparepart">Sparepart</Link>,
},
// {
// key: 'master-shift',
// icon: <ClockCircleOutlined style={{ fontSize: '19px' }} />,
// label: <Link to="/master/shift">Shift</Link>,
// },
],
},
{
@@ -219,15 +225,15 @@ const allItems = [
</Link>
),
},
{
key: 'jadwal-shift',
icon: <CalendarOutlined style={{ fontSize: '19px' }} />,
label: (
<Link to="/jadwal-shift" className="fontMenus">
Jadwal Shift
</Link>
),
},
// {
// key: 'jadwal-shift',
// icon: <CalendarOutlined style={{ fontSize: '19px' }} />,
// label: (
// <Link to="/jadwal-shift" className="fontMenus">
// Jadwal Shift
// </Link>
// ),
// },
];
const LayoutMenu = () => {
@@ -255,11 +261,12 @@ const LayoutMenu = () => {
const masterKeyMap = {
'plant-sub-section': 'master-plant-sub-section',
'brand-device': 'master-brand-device',
'device': 'master-device',
'unit': 'master-unit',
'tag': 'master-tag',
'status': 'master-status',
'shift': 'master-shift'
device: 'master-device',
unit: 'master-unit',
tag: 'master-tag',
status: 'master-status',
sparepart: 'master-sparepart',
shift: 'master-shift',
};
return masterKeyMap[subPath] || `master-${subPath}`;
}
@@ -276,7 +283,7 @@ const LayoutMenu = () => {
if (subPath === 'airdryer-a') return 'dashboard-svg-airdryer-airdryer-a';
if (subPath === 'airdryer-b') return 'dashboard-svg-airdryer-airdryer-b';
if (subPath === 'airdryer-c') return 'dashboard-svg-airdryer-airdryer-c';
return `dashboard-svg-${subPath}`;
}
@@ -284,8 +291,8 @@ const LayoutMenu = () => {
if (pathname.startsWith('/report/')) {
const subPath = pathParts[1];
const reportKeyMap = {
'trending': 'report-trending',
'report': 'report-report'
trending: 'report-trending',
report: 'report-report',
};
return reportKeyMap[subPath] || `report-${subPath}`;
}
@@ -294,8 +301,8 @@ const LayoutMenu = () => {
if (pathname.startsWith('/history/')) {
const subPath = pathParts[1];
const historyKeyMap = {
'alarm': 'history-alarm',
'event': 'history-event'
alarm: 'history-alarm',
event: 'history-event',
};
return historyKeyMap[subPath] || `history-${subPath}`;
}
@@ -306,7 +313,7 @@ const LayoutMenu = () => {
// Function to get parent keys from menu key
const getParentKeys = (key) => {
const parentKeys = [];
if (key.startsWith('dashboard-svg-compressor-')) {
parentKeys.push('dashboard-svg', 'dashboard-svg-compressor');
} else if (key.startsWith('dashboard-svg-airdryer-')) {
@@ -320,7 +327,7 @@ const LayoutMenu = () => {
} else if (key.startsWith('history-')) {
parentKeys.push('history');
}
return parentKeys;
};
@@ -330,7 +337,7 @@ const LayoutMenu = () => {
setSelectedKeys([currentKey]);
const parentKeys = getParentKeys(currentKey);
// Always keep the parent menus open when a child is selected
if (parentKeys.length > 0) {
setStateOpenKeys(parentKeys);
@@ -359,13 +366,13 @@ const LayoutMenu = () => {
const onOpenChange = (openKeys) => {
const currentOpenKey = openKeys.find((key) => stateOpenKeys.indexOf(key) === -1);
// If clicking on a menu that was previously closed
if (currentOpenKey !== undefined) {
const repeatIndex = openKeys
.filter((key) => key !== currentOpenKey)
.findIndex((key) => levelKeys[key] === levelKeys[currentOpenKey]);
setStateOpenKeys(
openKeys
.filter((_, index) => index !== repeatIndex)
@@ -376,12 +383,10 @@ const LayoutMenu = () => {
// but keep other parent menus open if they have active children
const currentKey = getMenuKeyFromPath(location.pathname);
const necessaryParentKeys = getParentKeys(currentKey);
// Filter out only the menus that are necessary to keep open
const filteredOpenKeys = openKeys.filter(key =>
necessaryParentKeys.includes(key)
);
const filteredOpenKeys = openKeys.filter((key) => necessaryParentKeys.includes(key));
setStateOpenKeys(filteredOpenKeys);
}
};
@@ -391,9 +396,7 @@ const LayoutMenu = () => {
const karyawan = () => {
return allItems
.filter(
(item) => item.key !== 'setting'
)
.filter((item) => item.key !== 'setting')
.map((item) => {
if (item.key === 'master') {
return {
@@ -403,7 +406,7 @@ const LayoutMenu = () => {
return item;
});
};
const items = isAdmin === 1 ? allItems : karyawan();
return (
@@ -419,8 +422,8 @@ const LayoutMenu = () => {
border: 'none',
}}
theme="dark"
className="custom-orange-menu"
className="custom-green-menu"
/>
);
};
export default LayoutMenu;
export default LayoutMenu;

View File

@@ -30,8 +30,24 @@ const LayoutSidebar = () => {
zIndex: 9999
}}
>
<LayoutLogo />
<LayoutMenu />
<div style={{
display: 'flex',
flexDirection: 'column',
height: '100vh',
overflow: 'hidden'
}}>
{/* Logo section - fixed height */}
<div style={{flexShrink: 0,minHeight: '64px'}}>
<LayoutLogo />
</div>
{/* Menu section - scrollable */}
<div style={{flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column'}}>
<div className="custom-menu-scrollbar" style={{flex: 1, overflowY: 'auto', overflowX: 'hidden', backgroundColor: 'transparent'}}>
<LayoutMenu />
</div>
</div>
</div>
</Sider>
);
};

View File

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

View File

@@ -15,6 +15,7 @@ const IndexContact = memo(function IndexContact() {
const [selectedData, setSelectedData] = useState(null);
const [readOnly, setReadOnly] = useState(false);
const [showModal, setShowModal] = useState(false);
const [contactType, setContactType] = useState('operator');
const setMode = (param) => {
setShowModal(param !== 'list');
@@ -52,6 +53,7 @@ const IndexContact = memo(function IndexContact() {
setSelectedData={setSelectedData}
readOnly={readOnly}
lastSavedContact={lastSavedContact}
setContactType={setContactType}
/>
<DetailContact
setActionMode={setMode}
@@ -61,6 +63,7 @@ const IndexContact = memo(function IndexContact() {
showModal={showModal}
actionMode={actionMode}
onContactSaved={handleContactSaved}
contactType={contactType}
/>
</React.Fragment>
);

View File

@@ -1,7 +1,8 @@
import React, { memo, useEffect, useState } from 'react';
import { Modal, Input, Button, Switch, ConfigProvider, Typography, Divider } from 'antd';
import { Modal, Input, Button, Switch, ConfigProvider, Typography, Divider, Select } from 'antd';
import { NotifAlert, NotifOk } from '../../../components/Global/ToastNotif';
import { validateRun } from '../../../Utils/validate';
import { createContact, updateContact } from '../../../api/contact';
const { Text } = Typography;
@@ -18,24 +19,16 @@ const DetailContact = memo(function DetailContact(props) {
const [formData, setFormData] = useState(defaultData);
const handleInputChange = (e) => {
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 {
return;
}
const { name, value } = e.target;
// Validasi untuk field phone - hanya angka yang diperbolehkan
if (name === 'phone') {
value = value.replace(/[^0-9+\-\s()]/g, '');
}
if (name) {
const cleanedValue = value.replace(/[^0-9+\-\s()]/g, '');
setFormData((prev) => ({
...prev,
[name]: cleanedValue,
}));
} else {
setFormData((prev) => ({
...prev,
[name]: value,
@@ -43,6 +36,7 @@ const DetailContact = memo(function DetailContact(props) {
}
};
const handleStatusToggle = (checked) => {
setFormData({
...formData,
@@ -53,17 +47,6 @@ const DetailContact = memo(function DetailContact(props) {
const handleSave = async () => {
setConfirmLoading(true);
// Custom validation untuk phone
if (formData.phone && !/^[\d\s\+\-\(\)]+$/.test(formData.phone)) {
NotifOk({
icon: 'warning',
title: 'Peringatan',
message: 'Nomor telepon hanya boleh mengandung angka, spasi, +, -, dan ()',
});
setConfirmLoading(false);
return;
}
// Validation rules
const validationRules = [
{ field: 'name', label: 'Contact Name', required: true },
@@ -78,34 +61,62 @@ const DetailContact = memo(function DetailContact(props) {
)
return;
// Custom validation untuk name - minimal 3 karakter
if (formData.name && formData.name.length < 3) {
NotifOk({
icon: 'warning',
title: 'Peringatan',
message: 'Nama contact minimal 3 karakter',
});
setConfirmLoading(false);
return;
}
// Custom validation untuk phone - Indonesian phone format
const phoneRegex = /^(?:\+62|0)8\d{7,10}$/;
if (formData.phone && !phoneRegex.test(formData.phone.replace(/[\s\-\(\)]/g, ''))) {
NotifOk({
icon: 'warning',
title: 'Peringatan',
message: 'Nomor telepon harus format Indonesia (+628XXXXXXXXX atau 08XXXXXXXXX)',
});
setConfirmLoading(false);
return;
}
try {
const contactData = {
id: props.selectedData?.id || null,
name: formData.name,
phone: formData.phone,
contact_name: formData.name,
contact_phone: formData.phone.replace(/[\s\-\(\)]/g, ''), // Clean phone number
is_active: formData.is_active,
status: formData.is_active ? 'active' : 'inactive',
};
console.log('Saving contact data:', contactData);
await new Promise((resolve) => setTimeout(resolve, 1000));
let response;
if (props.actionMode === 'edit') {
response = await updateContact(
props.selectedData.contact_id || props.selectedData.id,
contactData
);
} else {
response = await createContact(contactData);
}
NotifAlert({
icon: 'success',
title: 'Berhasil',
message: `Data Contact berhasil ${
message: `Data Contact "${formData.name}" berhasil ${
props.actionMode === 'add' ? 'ditambahkan' : 'diperbarui'
}.`,
});
props.onContactSaved?.(contactData, props.actionMode);
props.onContactSaved?.(response.data, props.actionMode);
handleCancel();
} catch (error) {
console.error('Save failed:', error);
NotifAlert({
icon: 'error',
title: 'Error',
message: 'Terjadi kesalahan saat menyimpan data.',
message: error.response?.data?.message || 'Terjadi kesalahan saat menyimpan data.',
});
} finally {
setConfirmLoading(false);
@@ -121,9 +132,10 @@ const DetailContact = memo(function DetailContact(props) {
if (props.showModal) {
if (props.actionMode === 'edit' && props.selectedData) {
setFormData({
name: props.selectedData.name,
phone: props.selectedData.phone,
is_active: props.selectedData.status === 'active',
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',
});
} else if (props.actionMode === 'add') {
setFormData({
@@ -182,27 +194,36 @@ const DetailContact = memo(function DetailContact(props) {
]}
>
<div style={{ padding: '8px 0' }}>
<div>
<div>
<Text strong>Status</Text>
</div>
<div style={{ display: 'flex', alignItems: 'center', marginTop: '8px' }}>
<div style={{ marginRight: '8px' }}>
<Switch
disabled={props.readOnly}
style={{
backgroundColor: formData.is_active ? '#23A55A' : '#bfbfbf',
}}
checked={formData.is_active}
onChange={handleStatusToggle}
/>
</div>
{/* Status field only show in add mode*/}
{props.actionMode === 'add' && (
<>
<div>
<Text>{formData.is_active ? 'Active' : 'Inactive'}</Text>
<div>
<Text strong>Status</Text>
</div>
<div
style={{ display: 'flex', alignItems: 'center', marginTop: '8px' }}
>
<div style={{ marginRight: '8px' }}>
<Switch
disabled={props.readOnly}
style={{
backgroundColor: formData.is_active
? '#23A55A'
: '#bfbfbf',
}}
checked={formData.is_active}
onChange={handleStatusToggle}
/>
</div>
<div>
<Text>{formData.is_active ? 'Active' : 'Inactive'}</Text>
</div>
</div>
</div>
</div>
</div>
<Divider style={{ margin: '12px 0' }} />
<Divider style={{ margin: '12px 0' }} />
</>
)}
<div style={{ marginBottom: 12 }}>
<Text strong>Name</Text>
@@ -228,6 +249,21 @@ const DetailContact = memo(function DetailContact(props) {
style={{ color: formData.is_active ? '#000000' : '#ff4d4f' }}
/>
</div>
{/* Contact Type */}
{/* <div style={{ marginBottom: 12 }}>
<Text strong>Contact Type</Text>
<Text style={{ color: 'red' }}> *</Text>
<Select
value={formData.contact_type || undefined}
onChange={handleContactTypeChange}
placeholder="Select Contact Type"
disabled={props.readOnly}
style={{ width: '100%' }}
>
<Select.Option value="operator">Operator</Select.Option>
<Select.Option value="gudang">Gudang</Select.Option>
</Select>
</div> */}
</div>
</Modal>
);

View File

@@ -1,5 +1,5 @@
import React, { memo, useState, useEffect } from 'react';
import { Button, Row, Col, Input, Tabs, Space, ConfigProvider, Card, Tag, Select } from 'antd';
import { Button, Row, Col, Input, Tabs, Space, ConfigProvider, Card, Tag, Switch } from 'antd';
import {
PlusOutlined,
EditOutlined,
@@ -10,25 +10,43 @@ 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 { TabPane } = Tabs;
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,
};
// Mock data
const initialMockOperators = [
{ id: 1, name: 'Shof Watun Niswah', phone: '+62 821 9049 8383', status: 'active' },
{ id: 2, name: 'Ahmad Susanto', phone: '+62 812 3456 7890', status: 'active' },
{ id: 3, name: 'Budi Santoso', phone: '+62 813 2345 6789', status: 'active' },
{ id: 4, name: 'Rina Wijaya', phone: '+62 814 3456 7891', status: 'active' },
];
await updateContact(contact.contact_id || contact.id, updatedContact);
const initialMockGudang = [
{ id: 101, name: 'Eko Prasetyo', phone: '+62 816 5678 9012', status: 'active' },
{ id: 102, name: 'Fajar Hidayat', phone: '+62 817 6789 0123', status: 'active' },
{ id: 103, name: 'Siti Nurhaliza', phone: '+62 818 7890 1234', status: 'active' },
{ id: 104, name: 'Andi Pratama', phone: '+62 819 8901 2345', status: 'inactive' },
];
NotifAlert({
icon: 'success',
title: 'Berhasil',
message: `Status "${contact.contact_name || contact.name}" berhasil diperbarui.`,
});
// Refresh contacts list
onStatusToggle && onStatusToggle();
} catch (error) {
console.error('Error updating contact status:', error);
NotifAlert({
icon: 'error',
title: 'Error',
message: 'Gagal memperbarui status kontak',
});
}
};
const ContactCard = memo(function ContactCard({ contact, showEditModal, showDeleteModal }) {
return (
<Col xs={24} sm={12} md={8} lg={6}>
<div
@@ -59,17 +77,51 @@ const ContactCard = memo(function ContactCard({ contact, showEditModal, showDele
position: 'relative',
}}
>
{/* 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>
)}
{/* Type Badge - Top Left */}
{/* <div style={{ position: 'absolute', top: 0, left: 0, zIndex: 1 }}>
<Tag
color={
contact.contact_type === 'operator'
? 'blue'
: contact.contact_type === 'gudang'
? 'orange'
: 'default'
}
style={{ fontSize: '11px' }}
>
{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>
</div>
{/* Main Content */}
@@ -79,7 +131,7 @@ const ContactCard = memo(function ContactCard({ contact, showEditModal, showDele
alignItems: 'center',
gap: 12,
flex: 1,
paddingTop: '4px',
paddingTop: '28px',
}}
>
<div
@@ -89,7 +141,7 @@ const ContactCard = memo(function ContactCard({ contact, showEditModal, showDele
height: 55,
borderRadius: '50%',
backgroundColor:
contact.status === 'active' ? '#52c41a' : '#8c8c8c',
contact.status === 'active' ? '#52c41a' : '#ff4d4f',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
@@ -110,7 +162,7 @@ const ContactCard = memo(function ContactCard({ contact, showEditModal, showDele
whiteSpace: 'nowrap',
}}
>
{contact.name}
{contact.contact_name || contact.name}
</div>
<div
style={{
@@ -122,28 +174,39 @@ const ContactCard = memo(function ContactCard({ contact, showEditModal, showDele
<PhoneOutlined style={{ marginRight: 6, color: '#1890ff' }} />
<span
style={{
color: contact.status === 'active' ? '#52c41a' : '#ff4d4f',
color: contact.status === 'active' ? '#262626' : '#262626',
}}
>
{contact.phone}
{contact.contact_phone || contact.phone}
</span>
</div>
</div>
</div>
{/* Edit and Delete Buttons - Bottom Right */}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px', marginTop: '8px' }}>
<div
style={{
display: 'flex',
justifyContent: 'flex-end',
gap: '8px',
marginTop: '8px',
}}
>
<Space>
<Button
type="text"
type="default"
size="small"
style={{
backgroundColor: '#fff7e6',
borderColor: '#faad14',
color: '#faad14',
padding: '2px 6px',
fontSize: '11px',
height: '24px'
height: '24px',
}}
icon={<EditOutlined style={{ color: '#faad14', fontSize: '11px' }} />}
icon={
<EditOutlined style={{ color: '#faad14', fontSize: '11px' }} />
}
onClick={(e) => {
e.stopPropagation();
showEditModal(contact);
@@ -152,14 +215,15 @@ const ContactCard = memo(function ContactCard({ contact, showEditModal, showDele
Edit info
</Button>
<Button
type="text"
type="default"
danger
size="small"
style={{
backgroundColor: '#fff1f0',
borderColor: 'red',
padding: '2px 6px',
fontSize: '11px',
height: '24px'
height: '24px',
}}
icon={<DeleteOutlined style={{ fontSize: '11px' }} />}
onClick={(e) => {
@@ -178,77 +242,77 @@ const ContactCard = memo(function ContactCard({ contact, showEditModal, showDele
});
const ListContact = memo(function ListContact(props) {
const [activeTab, setActiveTab] = useState('operator');
const [searchValue, setSearchValue] = useState('');
const [operators, setOperators] = useState(initialMockOperators);
const [gudang, setGudang] = useState(initialMockGudang);
const [filteredOperators, setFilteredOperators] = useState(initialMockOperators);
const [filteredGudang, setFilteredGudang] = useState(initialMockGudang);
const [sortBy, setSortBy] = useState('name');
const [filterBy, setFilterBy] = useState('all');
const [activeTab, setActiveTab] = useState('all');
const [filteredContacts, setFilteredContacts] = useState([]);
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
// Listen for saved contact data
useEffect(() => {
if (props.lastSavedContact) {
handleContactSaved(
props.lastSavedContact.contactData,
props.lastSavedContact.actionMode
);
}
}, [props.lastSavedContact]);
// Default filter object matching plantSection pattern
const defaultFilter = { criteria: '' };
const [formDataFilter, setFormDataFilter] = useState(defaultFilter);
// Handle contact data from modal
const handleContactSaved = (contactData, actionMode) => {
const updateContacts = (contacts) => {
if (actionMode === 'add') {
const maxId = Math.max(...contacts.map((item) => item.id), 0);
return [...contacts, { ...contactData, id: maxId + 1 }];
} else if (actionMode === 'edit') {
return contacts.map((item) =>
item.id === contactData.id ? { ...item, ...contactData } : item
);
// Fetch contacts from API
const fetchContacts = async () => {
setLoading(true);
try {
// Build search parameters matching database pattern
const searchParams = { ...formDataFilter };
// Add specific filters if not "all"
if (activeTab !== 'all') {
if (activeTab === 'operator') {
searchParams.code = 'operator';
} else if (activeTab === 'gudang') {
searchParams.code = 'gudang';
}
}
return contacts;
};
if (activeTab === 'operator') {
setOperators(updateContacts(operators));
} else {
setGudang(updateContacts(gudang));
const queryParams = new URLSearchParams();
Object.entries(searchParams).forEach(([key, value]) => {
if (value !== '' && value !== null && value !== undefined) {
queryParams.append(key, value);
}
});
const response = await getAllContact(queryParams);
setFilteredContacts(response.data || []);
} catch (error) {
console.error('Error fetching contacts:', error);
NotifAlert({
icon: 'error',
title: 'Error',
message: 'Gagal memuat data kontak',
});
} finally {
setLoading(false);
}
};
// Fetch contacts on component mount
useEffect(() => {
const token = localStorage.getItem('token');
if (!token) {
navigate('/signin');
return;
}
fetchContacts();
}, []);
// Filter and sort helper function
const filterAndSort = (contacts) => {
let filtered = contacts.filter(
(contact) =>
contact.name.toLowerCase().includes(searchValue.toLowerCase()) ||
contact.phone.includes(searchValue)
);
if (filterBy !== 'all') {
filtered = filtered.filter((contact) => contact.status === filterBy);
}
return [...filtered].sort((a, b) => {
if (sortBy === 'name') return a.name.localeCompare(b.name);
if (sortBy === 'phone') return a.phone.localeCompare(b.phone);
return 0;
});
};
// Refetch when filters change
useEffect(() => {
setFilteredOperators(filterAndSort(operators));
setFilteredGudang(filterAndSort(gudang));
}, [searchValue, sortBy, filterBy, operators, gudang]);
fetchContacts();
}, [formDataFilter, activeTab]);
// Listen for saved contact data
useEffect(() => {
if (props.lastSavedContact) {
fetchContacts();
}
}, [props.lastSavedContact]);
const getFilteredContacts = () => {
return filteredContacts;
};
const showEditModal = (param) => {
props.setSelectedData(param);
@@ -258,40 +322,38 @@ const ListContact = memo(function ListContact(props) {
const showAddModal = () => {
props.setSelectedData(null);
props.setActionMode('add');
props.setContactType?.(activeTab);
};
const showDeleteModal = (contact) => {
NotifConfirmDialog({
icon: 'question',
title: 'Konfirmasi Hapus',
message: `Kontak "${contact.name}" akan dihapus?`,
message: `Kontak "${contact.contact_name || contact.name}" akan dihapus?`,
onConfirm: () => handleDelete(contact),
onCancel: () => props.setSelectedData(null),
});
};
const handleDelete = (contact) => {
if (activeTab === 'operator') {
const updatedOperators = operators.filter((op) => op.id !== contact.id);
setOperators(updatedOperators);
} else {
const updatedGudang = gudang.filter((item) => item.id !== contact.id);
setGudang(updatedGudang);
const handleDelete = async (contact) => {
try {
await deleteContact(contact.contact_id || contact.id);
NotifAlert({
icon: 'success',
title: 'Berhasil',
message: `Kontak "${contact.contact_name || contact.name}" berhasil dihapus.`,
});
// Refetch contacts after deletion
fetchContacts();
} catch (error) {
console.error('Error deleting contact:', error);
NotifAlert({
icon: 'error',
title: 'Error',
message: 'Gagal menghapus kontak',
});
}
NotifAlert({
icon: 'success',
title: 'Berhasil',
message: `Kontak "${contact.name}" berhasil dihapus.`,
});
};
const handleSearch = (value) => {
setSearchValue(value);
};
const handleSearchClear = () => {
setSearchValue('');
};
return (
@@ -302,18 +364,22 @@ const ListContact = memo(function ListContact(props) {
<Row justify="space-between" align="middle" gutter={[8, 8]}>
<Col xs={24} sm={24} md={12} lg={12}>
<Input.Search
placeholder="Search by name or phone..."
value={searchValue}
placeholder="Search by name..."
value={formDataFilter.criteria}
onChange={(e) => {
const value = e.target.value;
setSearchValue(value);
setFormDataFilter({ criteria: value });
if (value === '') {
handleSearchClear();
setFormDataFilter(defaultFilter);
}
}}
onSearch={handleSearch}
onSearch={(value) => setFormDataFilter({ criteria: value })}
allowClear={{
clearIcon: <span onClick={handleSearchClear}></span>,
clearIcon: (
<span onClick={() => setFormDataFilter(defaultFilter)}>
</span>
),
}}
enterButton={
<Button
@@ -364,76 +430,49 @@ const ListContact = memo(function ListContact(props) {
marginBottom: '16px',
}}
>
<Tabs activeKey={activeTab} onChange={setActiveTab} size="large">
<TabPane tab="Operator" key="operator" />
<TabPane tab="Gudang" key="gudang" />
</Tabs>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ fontSize: '14px', color: '#666' }}>
Filter by:
</span>
<Select
value={filterBy}
onChange={setFilterBy}
style={{ width: 100 }}
size="small"
>
<Select.Option value="all">All</Select.Option>
<Select.Option value="active">Active</Select.Option>
<Select.Option value="inactive">Inactive</Select.Option>
</Select>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ fontSize: '14px', color: '#666' }}>
Sort by:
</span>
<Select
value={sortBy}
onChange={setSortBy}
style={{ width: 100 }}
size="small"
>
<Select.Option value="name">Name</Select.Option>
<Select.Option value="phone">Phone</Select.Option>
</Select>
</div>
</div>
{/* Tabs */}
{/* <Tabs
activeKey={activeTab}
onChange={setActiveTab}
size="large"
items={[
{
key: 'all',
label: 'All',
},
{
key: 'operator',
label: 'Operator',
},
{
key: 'gudang',
label: 'Gudang',
},
]}
/> */}
</div>
{activeTab === 'operator' ? (
filteredOperators.length === 0 ? (
<div style={{ textAlign: 'center', padding: '40px' }}>
<span style={{ color: '#8c8c8c' }}>No operators found</span>
</div>
) : (
<Row gutter={[16, 16]}>
{filteredOperators.map((operator) => (
<ContactCard
key={operator.id}
contact={operator}
showEditModal={showEditModal}
showDeleteModal={showDeleteModal}
/>
))}
</Row>
)
) : filteredGudang.length === 0 ? (
{getFilteredContacts().length === 0 ? (
<div style={{ textAlign: 'center', padding: '40px' }}>
<span style={{ color: '#8c8c8c' }}>
No warehouse contacts found
{loading ? 'Loading contacts...' : 'No contacts found'}
</span>
</div>
) : (
<Row gutter={[16, 16]}>
{filteredGudang.map((item) => (
{getFilteredContacts().map((contact) => (
<ContactCard
key={item.id}
contact={item}
key={contact.contact_id || contact.id}
contact={{
...contact,
id: contact.contact_id || contact.id,
name: contact.contact_name || contact.name,
phone: contact.contact_phone || contact.phone,
status: contact.is_active ? 'active' : 'inactive',
}}
showEditModal={showEditModal}
showDeleteModal={showDeleteModal}
onStatusToggle={fetchContacts}
/>
))}
</Row>

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,9 +6,7 @@ import SvgViewer from './SvgViewer';
import filePathSvg from '../../assets/svg/compressorB_rev.svg';
const { Text } = Typography;
// const filePathSvg = '/src/assets/svg/test-new.svg';
const topicMqtt = 'PIU_GGCP/Devices/PB';
const topicMqtt = 'PIU_COD/COMPRESSOR/COMPRESSOR_B';
const SvgCompressorB = () => {
return (

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -0,0 +1,397 @@
import React, { useState } from 'react';
import { Card, Typography, Tag, Button, Modal, Row, Col, Space } from 'antd';
import { EyeOutlined, DeleteOutlined, CheckOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
const { Text, Title } = Typography;
const CustomSparepartCard = ({
sparepart,
isSelected = false,
isReadOnly = false,
showPreview = true,
showDelete = false,
onPreview,
onDelete,
onCardClick,
loading = false,
size = 'small',
style = {},
}) => {
const [previewModalVisible, setPreviewModalVisible] = useState(false);
const getImageSrc = () => {
if (sparepart.sparepart_foto) {
if (sparepart.sparepart_foto.startsWith('http')) {
return sparepart.sparepart_foto;
} else {
const fileName = sparepart.sparepart_foto.split('/').pop();
if (fileName === 'defaultSparepartImg.jpg') {
return `/assets/defaultSparepartImg.jpg`;
} else {
const token = localStorage.getItem('token');
const baseURL = import.meta.env.VITE_API_SERVER || '';
return `${baseURL}/file-uploads/images/${encodeURIComponent(fileName)}${token ? `?token=${encodeURIComponent(token)}` : ''}`;
}
}
}
return 'https://via.placeholder.com/150';
};
const handlePreview = () => {
if (onPreview) {
onPreview(sparepart);
} else {
setPreviewModalVisible(true);
}
};
const truncateText = (text, maxLength = 15) => {
if (!text) return 'Unnamed';
return text.length > maxLength ? `${text.substring(0, maxLength)}...` : text;
};
const handleCardClick = () => {
if (!isReadOnly && onCardClick) {
onCardClick(sparepart);
}
};
const getCardActions = () => {
const actions = [];
if (showPreview) {
actions.push(
<Button
key="preview"
type="text"
icon={<EyeOutlined />}
title="Lihat Detail"
style={{ color: '#1890ff' }}
onClick={(e) => {
e.stopPropagation();
handlePreview();
}}
/>
);
}
if (showDelete && !isReadOnly) {
actions.push(
<Button
key="delete"
type="text"
icon={<DeleteOutlined />}
title="Hapus"
style={{ color: '#ff4d4f' }}
onClick={(e) => {
e.stopPropagation();
onDelete?.(sparepart);
}}
/>
);
}
return actions;
};
const getCardStyle = () => {
const baseStyle = {
borderRadius: '12px',
overflow: 'hidden',
border: isSelected ? '2px solid #1890ff' : '1px solid #E0E0E0',
cursor: isReadOnly ? 'default' : 'pointer',
position: 'relative',
boxShadow: '0 2px 8px rgba(0,0,0,0.06)',
transition: 'all 0.3s ease'
};
switch (size) {
case 'small':
return {
...baseStyle,
height: '180px',
minHeight: '180px'
};
case 'large':
return {
...baseStyle,
height: '280px',
minHeight: '280px'
};
default:
return {
...baseStyle,
height: '220px',
minHeight: '220px'
};
}
};
return (
<>
<div
style={{
border: '1px solid #f0f0f0',
borderRadius: '6px',
padding: '12px 16px',
marginBottom: '8px',
backgroundColor: 'white',
cursor: onCardClick && !isReadOnly ? 'pointer' : 'default',
transition: 'all 0.2s ease',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between'
}}
onClick={handleCardClick}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', marginBottom: '4px' }}>
<Text
strong
style={{
fontSize: '14px',
color: '#262626',
marginRight: '12px'
}}
title={sparepart.sparepart_name || sparepart.name || 'Unnamed'}
>
{truncateText(sparepart.sparepart_name || sparepart.name || 'Unnamed')}
</Text>
<Tag
color={sparepart.sparepart_stok === 'Available' ? 'green' : 'red'}
style={{ fontSize: '11px', margin: 0 }}
>
{sparepart.sparepart_stok || 'Not Available'}
</Tag>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
<div style={{ display: 'flex', alignItems: 'center' }}>
<Text style={{ fontSize: '12px', color: '#666', marginRight: '4px' }}>
qty:
</Text>
<Text
style={{
fontSize: '12px',
fontWeight: 600,
color: '#262626'
}}
>
{sparepart.sparepart_qty || 0}
</Text>
</div>
</div>
</div>
<Space size="small">
{showPreview && (
<Button
type="text"
icon={<EyeOutlined />}
size="small"
onClick={(e) => {
e.stopPropagation();
handlePreview();
}}
title="Preview"
/>
)}
{showDelete && !isReadOnly && (
<Button
type="text"
icon={<DeleteOutlined />}
size="small"
danger
onClick={(e) => {
e.stopPropagation();
onDelete?.(sparepart);
}}
title="Remove"
/>
)}
</Space>
</div>
<Modal
title="Sparepart Details"
open={previewModalVisible}
onCancel={() => setPreviewModalVisible(false)}
footer={[
<Button key="close" onClick={() => setPreviewModalVisible(false)}>
Close
</Button>
]}
width={800}
centered
styles={{ body: { padding: '24px' } }}
>
<Row gutter={[24, 24]}>
<Col span={10}>
<div style={{ textAlign: 'center' }}>
<div
style={{
backgroundColor: '#f0f0f0',
width: '220px',
height: '220px',
margin: '0 auto 16px',
position: 'relative',
borderRadius: '12px',
overflow: 'hidden',
border: '1px solid #E0E0E0',
boxShadow: '0 4px 12px rgba(0,0,0,0.1)'
}}
>
<img
src={getImageSrc()}
alt={sparepart.sparepart_name || 'Sparepart'}
style={{
width: '100%',
height: '100%',
objectFit: 'cover'
}}
onError={(e) => {
e.target.src = 'https://via.placeholder.com/220x220/d9d9d9/666666?text=No+Image';
}}
/>
</div>
{sparepart.sparepart_item_type && (
<div style={{ marginBottom: '12px' }}>
<Tag color="blue" style={{ fontSize: '14px', padding: '4px 12px' }}>
{sparepart.sparepart_item_type}
</Tag>
</div>
)}
<div style={{
textAlign: 'left',
background: '#f8f9fa',
padding: '12px',
borderRadius: '8px',
marginTop: '25px'
}}>
<div style={{ marginBottom: '8px' }}>
<Text strong style={{ fontSize: '14px', color: '#262626' }}>Stock Status:</Text>
<Tag
color={sparepart.sparepart_stok === 'Available' ? 'green' : 'red'}
style={{ marginLeft: '8px', fontSize: '12px' }}
>
{sparepart.sparepart_stok || 'Not Available'}
</Tag>
</div>
<div>
<Text strong style={{ fontSize: '14px', color: '#262626' }}>Quantity:</Text>
<Text style={{ fontSize: '14px', marginLeft: '8px', fontWeight: 600 }}>
{sparepart.sparepart_qty || 0} {sparepart.sparepart_unit || ''}
</Text>
</div>
</div>
</div>
</Col>
<Col span={14}>
<div>
<Title level={3} style={{ marginBottom: '20px', color: '#262626' }}>
{sparepart.sparepart_name || 'Unnamed'}
</Title>
<div style={{ marginBottom: '24px' }}>
<Row gutter={[16, 12]}>
<Col span={24}>
<div style={{
padding: '12px',
backgroundColor: '#fafafa',
borderRadius: '8px',
border: '1px solid #f0f0f0'
}}>
<Row gutter={16}>
<Col span={8}>
<div>
<Text type="secondary" style={{ fontSize: '12px' }}>Code</Text>
<div style={{ fontSize: '15px', fontWeight: 500, marginTop: '2px' }}>
{sparepart.sparepart_code || 'N/A'}
</div>
</div>
</Col>
<Col span={8}>
<div>
<Text type="secondary" style={{ fontSize: '12px' }}>Brand</Text>
<div style={{ fontSize: '15px', fontWeight: 500, marginTop: '2px' }}>
{sparepart.sparepart_merk || '-'}
</div>
</div>
</Col>
<Col span={8}>
<div>
<Text type="secondary" style={{ fontSize: '12px' }}>Unit</Text>
<div style={{ fontSize: '15px', fontWeight: 500, marginTop: '2px' }}>
{sparepart.sparepart_unit || '-'}
</div>
</div>
</Col>
</Row>
</div>
</Col>
{sparepart.sparepart_model && (
<Col span={24}>
<div>
<Text type="secondary" style={{ fontSize: '12px' }}>Model</Text>
<div style={{ fontSize: '15px', fontWeight: 500, marginTop: '2px' }}>
{sparepart.sparepart_model}
</div>
</div>
</Col>
)}
{sparepart.sparepart_description && (
<Col span={24}>
<div>
<Text type="secondary" style={{ fontSize: '12px' }}>Description</Text>
<div style={{ fontSize: '15px', marginTop: '2px', lineHeight: '1.5' }}>
{sparepart.sparepart_description}
</div>
</div>
</Col>
)}
</Row>
</div>
{sparepart.created_at && (
<div>
<Row gutter={16}>
<Col span={12}>
<div>
<Text type="secondary" style={{ fontSize: '12px' }}>Created</Text>
<div style={{ fontSize: '13px', marginTop: '2px' }}>
{dayjs(sparepart.created_at).format('DD MMM YYYY, HH:mm')}
</div>
</div>
</Col>
<Col span={12}>
<div>
<Text type="secondary" style={{ fontSize: '12px' }}>Last Updated</Text>
<div style={{ fontSize: '13px', marginTop: '2px' }}>
{dayjs(sparepart.updated_at).format('DD MMM YYYY, HH:mm')}
</div>
</div>
</Col>
</Row>
</div>
)}
</div>
</Col>
</Row>
</Modal>
</>
);
};
export default CustomSparepartCard;

View File

@@ -1,393 +1,287 @@
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';
import React, { useState, useEffect } from 'react';
import { Form, Input, Switch, Typography, ConfigProvider, Card, Button } from 'antd';
import { FileOutlined, EyeOutlined, DeleteOutlined } from '@ant-design/icons';
import FileUploadHandler from './FileUploadHandler';
import { NotifAlert, NotifOk } from '../../../../components/Global/ToastNotif';
import { getFileUrl, getFolderFromFileType } from '../../../../api/file-uploads';
const { Text } = Typography;
const ErrorCodeForm = ({
errorCodeForm,
isErrorCodeFormReadOnly,
editingErrorCodeKey,
solutionFields,
solutionTypes,
solutionStatuses,
fileList,
solutionsToDelete,
firstSolutionValid,
onAddErrorCode,
onAddSolutionField,
onRemoveSolutionField,
onSolutionTypeChange,
onSolutionStatusChange,
onSolutionFileUpload,
onFileView,
onCreateNewErrorCode,
onResetForm,
errorCodes,
isErrorCodeFormReadOnly = false,
errorCodeIcon,
onErrorCodeIconUpload,
onErrorCodeIconRemove,
isEdit = false,
}) => {
const statusValue = Form.useWatch('status', errorCodeForm);
const [currentIcon, setCurrentIcon] = useState(null);
const statusWatch = Form.useWatch('status', errorCodeForm) ?? true;
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;
useEffect(() => {
if (errorCodeIcon && typeof errorCodeIcon === 'object' && Object.keys(errorCodeIcon).length > 0) {
setCurrentIcon(errorCodeIcon);
} else {
setCurrentIcon(null);
}
}, [errorCodeIcon]);
// 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) {
// Extract folder and filename from the path
const pathParts = iconPath.split('/');
const folder = pathParts[0];
const filename = pathParts.slice(1).join('/');
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) {
console.error('Error uploading icon:', error);
message.error('Failed to upload icon');
}
return false; // Prevent default upload behavior
};
const handleRemoveIcon = () => {
const handleIconRemove = () => {
setCurrentIcon(null);
onErrorCodeIconRemove();
message.success('Icon removed');
};
const handleAddErrorCode = async () => {
try {
const values = await errorCodeForm.validateFields();
const renderIconUpload = () => {
if (currentIcon) {
const displayFileName = currentIcon.name || currentIcon.uploadPath?.split('/').pop() || currentIcon.url?.split('/').pop() || 'Icon File';
const solutions = [];
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>
solutionFields.forEach((fieldId) => {
if (solutionsToDelete && solutionsToDelete.has(fieldId)) {
return;
}
<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>
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];
<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 = '';
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,
};
const filePath = currentIcon.uploadPath || currentIcon.url || currentIcon.path || '';
const iconDisplayName = currentIcon.name || '';
if (window.currentSolutionData && window.currentSolutionData[fieldId]) {
solutionData.brand_code_solution_id =
window.currentSolutionData[fieldId].brand_code_solution_id;
}
if (iconDisplayName) {
actualFileName = iconDisplayName;
} else if (filePath) {
actualFileName = filePath.split('/').pop();
}
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 (actualFileName) {
const fileExtension = actualFileName.split('.').pop()?.toLowerCase();
const folder = getFolderFromFileType(fileExtension);
iconUrl = getFileUrl(folder, actualFileName);
}
if (window.currentSolutionData && window.currentSolutionData[fieldId]) {
solutionData.brand_code_solution_id =
window.currentSolutionData[fieldId].brand_code_solution_id;
}
if (!iconUrl && filePath) {
iconUrl = filePath.startsWith('http') ? filePath : `${import.meta.env.VITE_API_SERVER}/${filePath}`;
}
solutions.push(solutionData);
});
}
});
if (iconUrl && actualFileName) {
const fileExtension = actualFileName.split('.').pop()?.toLowerCase();
const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'];
const pdfExtensions = ['pdf'];
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)!',
});
if (imageExtensions.includes(fileExtension) || pdfExtensions.includes(fileExtension)) {
const viewerUrl = `/image-viewer/${encodeURIComponent(actualFileName)}`;
window.open(viewerUrl, '_blank', 'noopener,noreferrer');
} else {
window.open(iconUrl, '_blank', 'noopener,noreferrer');
}
} else {
NotifAlert({
icon: 'error',
title: 'Error',
message: `File URL not found. FileName: ${actualFileName}, FilePath: ${filePath}`
});
}
} catch (error) {
NotifAlert({
icon: 'error',
title: 'Error',
message: `Failed to open file preview: ${error.message}`
});
}
}}
/>
<Button
danger
size="middle"
icon={<DeleteOutlined />}
style={{
fontSize: 12,
display: 'flex',
alignItems: 'center',
}}
onClick={handleIconRemove}
disabled={isErrorCodeFormReadOnly}
/>
</div>
</div>
</Card>
);
} else {
return (
<FileUploadHandler
type="error_code"
existingFile={null}
accept="image/*"
onFileUpload={(fileData) => {
setCurrentIcon(fileData);
onErrorCodeIconUpload(fileData);
}}
onFileRemove={handleIconRemove}
buttonText="Upload Icon"
buttonStyle={{
width: '100%',
borderColor: '#23A55A',
color: '#23A55A',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '8px'
}}
uploadText="Upload error code icon"
disabled={isErrorCodeFormReadOnly}
/>
);
}
};
const handleResetForm = () => {
errorCodeForm.resetFields();
errorCodeForm.setFieldsValue({
status: true,
solution_status_0: true,
solution_type_0: 'text',
});
onResetForm();
};
return (
<>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 16,
<ConfigProvider
theme={{
components: {
Switch: {
colorPrimary: '#23A55A',
colorPrimaryHover: '#23A55A',
},
},
}}
>
<Form
form={errorCodeForm}
layout="vertical"
initialValues={{
status: true,
error_code_color: '#000000'
}}
>
<Form.Item label="Status" style={{ margin: 0 }}>
{/* Header bar with color picker, icon upload, and status toggle */}
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
marginBottom: '16px',
gap: '16px'
}}>
{/* Color picker on left */}
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<Form.Item
name="error_code_color"
noStyle
getValueFromEvent={(e) => e.target.value}
getValueProps={(value) => ({ value: value || '#000000' })}
>
<input
type="color"
style={{
width: '120px',
height: '40px',
border: '1px solid #d9d9d9',
borderRadius: 4,
cursor: isErrorCodeFormReadOnly ? 'not-allowed' : 'pointer',
}}
disabled={isErrorCodeFormReadOnly}
/>
</Form.Item>
{/* Icon upload beside color picker */}
<div style={{ flex: 1, maxWidth: '300px' }}>
{renderIconUpload()}
</div>
</div>
{/* Status toggle on right */}
<div style={{ display: 'flex', alignItems: 'center' }}>
<Form.Item name="status" valuePropName="checked" noStyle>
<Switch
style={{ backgroundColor: statusValue ? '#23A55A' : '#bfbfbf' }}
disabled={isErrorCodeFormReadOnly}
/>
</Form.Item>
<Text style={{ marginLeft: 8 }}>{statusValue ? 'Running' : 'Offline'}</Text>
</div>
</Form.Item>
{!isErrorCodeFormReadOnly && (
<ConfigProvider
theme={{
components: {
Button: {
defaultBg: '#23a55a',
defaultColor: '#FFFFFF',
defaultBorderColor: '#23a55a',
defaultHoverBg: '#209652',
defaultHoverColor: '#FFFFFF',
defaultHoverBorderColor: '#23a55a',
},
},
}}
>
<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>
<Text style={{ marginLeft: 8 }}>
{statusWatch ? 'Active' : 'Inactive'}
</Text>
</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%' }}
{/* 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%' }}
>
Add More Solution
</Button>
</Form.Item>
)}
<Input
placeholder="Enter error code"
disabled={isErrorCodeFormReadOnly}
/>
</Form.Item>
{!isErrorCodeFormReadOnly && editingErrorCodeKey && (
<Form.Item style={{ textAlign: 'right', marginTop: 16 }}>
<Button onClick={handleResetForm}>Kembali</Button>
</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>
{isErrorCodeFormReadOnly && editingErrorCodeKey && (
<Form.Item style={{ textAlign: 'right', marginTop: 16 }}>
<Button onClick={handleResetForm}>Kembali</Button>
<Form.Item label="Description" name="error_code_description">
<Input.TextArea
placeholder="Enter error description"
rows={3}
disabled={isErrorCodeFormReadOnly}
/>
</Form.Item>
)}
</>
</Form>
</ConfigProvider>
);
};

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,312 +1,496 @@
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';
import React, { useState } from 'react';
import { Form, Input, Button, Switch, Radio, Typography, Space, Card, ConfigProvider } from 'antd';
import { DeleteOutlined, EyeOutlined, FileOutlined } from '@ant-design/icons';
import FileUploadHandler from './FileUploadHandler';
import { NotifAlert } from '../../../../components/Global/ToastNotif';
import { getFileUrl, getFolderFromFileType } from '../../../../api/file-uploads';
const { Text } = Typography;
const { TextArea } = Input;
const SolutionField = ({
fieldId,
const SolutionFieldNew = ({
fieldKey,
fieldName,
index,
solutionType,
solutionStatus,
isReadOnly,
fileList,
isReadOnly = false,
canRemove = true,
onTypeChange,
onStatusChange,
onRemove,
onSolutionTypeChange,
onSolutionStatusChange,
onFileUpload,
currentSolutionData,
onFileView,
errorCodeForm,
fileList = [],
originalSolutionData = null
}) => {
// 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
);
}
const form = Form.useFormInstance();
const [currentFile, setCurrentFile] = useState(null);
const [isDeleted, setIsDeleted] = useState(false);
if (currentSolutionData.type_solution === 'text' && currentSolutionData.text_solution) {
errorCodeForm.setFieldValue(
`text_solution_${fieldId}`,
currentSolutionData.text_solution
);
}
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;
if (currentSolutionData.type_solution) {
const formValue =
currentSolutionData.type_solution === 'image' ||
currentSolutionData.type_solution === 'pdf'
? 'file'
: currentSolutionData.type_solution;
errorCodeForm.setFieldValue(`solution_type_${fieldId}`, formValue);
}
const pathSolution = Form.useWatch(['solution_items', fieldKey, 'path_solution'], form);
// 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
);
}
const [deleteCounter, setDeleteCounter] = useState(0);
React.useEffect(() => {
if (!nameValue || nameValue === '') {
setCurrentFile(null);
setIsDeleted(false);
setDeleteCounter(prev => prev + 1);
}
}, [currentSolutionData, fieldId, errorCodeForm]);
}, [nameValue]);
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.name} bukan file PDF atau gambar yang diizinkan.`,
});
return Upload.LIST_IGNORE;
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>
);
}
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);
if (solutionType === 'file') {
const hasOriginalFile = originalSolutionData && (
originalSolutionData.path_solution ||
originalSolutionData.path_document
);
const uploadResponse = await uploadFile(file, folder);
let displayFile = null;
const actualPath = uploadResponse.data?.path_solution || '';
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 (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!`,
});
if (displayFile) {
const getFileNameFromPath = () => {
const filePath = displayFile.uploadPath || displayFile.url || displayFile.path || '';
if (filePath) {
const fileName = filePath.split('/').pop();
return fileName || 'Uploaded File';
}
return displayFile.name || 'Uploaded File';
};
const displayFileName = getFileNameFromPath();
return (
<Card
style={{
marginBottom: 8,
borderRadius: 8,
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
border: '1px solid #e8e8e8'
}}
styles={{ body: { padding: '16px' } }}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: 48,
height: 48,
borderRadius: 8,
backgroundColor: '#f0f5ff',
flexShrink: 0
}}>
<FileOutlined style={{ fontSize: 24, color: '#1890ff' }} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontSize: 13,
fontWeight: 600,
color: '#262626',
marginBottom: 4,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}>
{displayFileName}
</div>
<div style={{ fontSize: 12, color: '#8c8c8c' }}>
{displayFile.size ? `${(displayFile.size / 1024).toFixed(1)} KB` : 'File uploaded'}
</div>
</div>
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
<Button
type="primary"
size="middle"
icon={<EyeOutlined />}
style={{
fontSize: 12,
display: 'flex',
alignItems: 'center',
gap: 4
}}
onClick={() => {
try {
let fileUrl = '';
let actualFileName = '';
const filePath = displayFile.uploadPath || displayFile.url || displayFile.path || '';
if (filePath) {
actualFileName = filePath.split('/').pop();
if (actualFileName) {
const fileExtension = actualFileName.split('.').pop()?.toLowerCase();
const folder = getFolderFromFileType(fileExtension);
fileUrl = getFileUrl(folder, actualFileName);
}
}
if (!fileUrl && filePath) {
fileUrl = filePath.startsWith('http') ? filePath : `${import.meta.env.VITE_API_SERVER}/${filePath}`;
}
if (fileUrl && actualFileName) {
const fileExtension = actualFileName.split('.').pop()?.toLowerCase();
const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'];
if (imageExtensions.includes(fileExtension)) {
const viewerUrl = `/image-viewer/${encodeURIComponent(actualFileName)}`;
window.open(viewerUrl, '_blank', 'noopener,noreferrer');
} else {
window.open(fileUrl, '_blank', 'noopener,noreferrer');
}
} else {
NotifAlert({
icon: 'error',
title: 'Error',
message: 'File URL not found'
});
}
} catch (error) {
NotifAlert({
icon: 'error',
title: 'Error',
message: 'Failed to open file preview'
});
}
}}
/>
<Button
danger
size="middle"
icon={<DeleteOutlined />}
style={{
fontSize: 12,
display: 'flex',
alignItems: 'center',
}}
onClick={() => {
setIsDeleted(true);
form.setFieldValue(['solution_items', fieldKey, 'fileUpload'], null);
form.setFieldValue(['solution_items', fieldKey, 'file'], null);
form.setFieldValue(['solution_items', fieldKey, 'path_solution'], null);
form.setFieldValue(['solution_items', fieldKey, 'fileName'], null);
setCurrentFile(null);
if (onFileUpload && typeof onFileUpload === 'function') {
onFileUpload(null);
}
setDeleteCounter(prev => prev + 1);
setTimeout(() => {
form.validateFields(['solution_items', fieldKey]);
}, 50);
}}
/>
</div>
</div>
</Card>
);
} else {
NotifAlert({
icon: 'error',
title: 'Gagal',
message: `Gagal mengupload ${file.name}`,
});
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="*"
/>
);
}
} catch (error) {
console.error('Error uploading file:', error);
NotifAlert({
icon: 'error',
title: 'Error',
message: `Gagal mengupload ${file.name}. Silakan coba lagi.`,
});
}
return false;
return null;
};
return (
<div
data-solution-id={fieldId}
style={{
marginBottom: 24,
padding: 16,
border: '1px solid #d9d9d9',
borderRadius: 8,
transition: 'all 0.3s ease',
<ConfigProvider
theme={{
components: {
Switch: {
colorPrimary: '#23A55A',
colorPrimaryHover: '#23A55A',
},
},
}}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 12,
}}
>
<Text strong>Solution {index + 1}</Text>
<Button
type="text"
danger
size="small"
icon={<DeleteOutlined />}
onClick={() => onRemove(fieldId)}
disabled={isReadOnly}
style={{ borderColor: '#ff4d4f' }}
/>
<div style={{
border: '1px solid #d9d9d9',
borderRadius: 6,
padding: 12,
marginBottom: 12,
backgroundColor: isReadOnly ? '#f5f5f5' : 'white'
}}>
<div style={{
marginBottom: 8,
gap: 8
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<Text strong style={{
fontSize: 12,
color: '#262626',
display: 'block'
}}>
Solution #{index + 1}
</Text>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<Form.Item name={['solution_items', fieldKey, 'status']} valuePropName="checked" noStyle>
<Switch
size="small"
disabled={isReadOnly}
onChange={(checked) => {
onStatusChange(fieldKey, checked);
}}
/>
</Form.Item>
<Text style={{
fontSize: 11,
color: '#666',
whiteSpace: 'nowrap'
}}>
{statusValue ? 'Active' : 'Inactive'}
</Text>
</div>
{canRemove && !isReadOnly && (
<Button
type="text"
danger
size="small"
icon={<DeleteOutlined />}
onClick={onRemove}
style={{
fontSize: 12,
padding: '2px 4px',
height: '24px'
}}
/>
)}
</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>
</div>
<Form.Item name={`solution_name_${fieldId}`} label="Solution Name">
<Input placeholder="Enter solution name" disabled={isReadOnly} />
</Form.Item>
<Form.Item
name={['solution_items', fieldKey, 'type']}
rules={[{ required: true, message: 'Solution type wajib diisi!' }]}
style={{ marginBottom: 8 }}
initialValue={solutionType || 'text'}
>
<Radio.Group
onChange={(e) => {
const newType = e.target.value;
<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' : 'Non Active'}
</Text>
</div>
</Form.Item>
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);
<Form.Item label="Solution Type">
<Form.Item name={`solution_type_${fieldId}`} noStyle>
<Radio.Group
onChange={(e) => {
onSolutionTypeChange(fieldId, e.target.value);
}}
disabled={isReadOnly}
>
<Radio value="text">Text Solution</Radio>
<Radio value="file">File Upload</Radio>
</Radio.Group>
</Form.Item>
if (onFileUpload && typeof onFileUpload === 'function') {
onFileUpload(null);
}
} else if (newType === 'file') {
form.setFieldValue(['solution_items', fieldKey, 'text'], null);
setIsDeleted(false);
}
onTypeChange(fieldKey, newType);
}}
disabled={isReadOnly}
size="small"
>
<Radio value="text" style={{ fontSize: 12 }}>Text</Radio>
<Radio value="file" style={{ fontSize: 12 }}>File</Radio>
</Radio.Group>
</Form.Item>
<Form.Item
shouldUpdate={(prevValues, currentValues) =>
prevValues[`solution_type_${fieldId}`] !==
currentValues[`solution_type_${fieldId}`]
}
name={['solution_items', fieldKey, 'status']}
initialValue={solutionStatus !== false ? true : false}
noStyle
>
{({ 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;
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>
</>
);
}}
<input type="hidden" />
</Form.Item>
</div>
{renderSolutionContent()}
</div>
</ConfigProvider>
);
};
export default SolutionField;
export default SolutionFieldNew;

View File

@@ -0,0 +1,77 @@
import React from 'react';
import { Typography, Divider, Button, Form } from 'antd';
import { PlusOutlined } from '@ant-design/icons';
import SolutionFieldNew from './SolutionField';
const { Text } = Typography;
const SolutionForm = ({
solutionForm,
solutionFields,
solutionTypes,
solutionStatuses,
onAddSolutionField,
onRemoveSolutionField,
onSolutionTypeChange,
onSolutionStatusChange,
onSolutionFileUpload,
onFileView,
fileList,
isReadOnly = false,
solutionData = [],
}) => {
return (
<div style={{ marginBottom: 0 }}>
<Form form={solutionForm} layout="vertical">
<div style={{
maxHeight: '400px',
overflowY: 'auto',
paddingRight: '8px'
}}>
{solutionFields.map((field, displayIndex) => (
<SolutionFieldNew
key={field}
fieldKey={field}
fieldName={['solution_items', field]}
index={displayIndex}
solutionType={solutionTypes[field]}
solutionStatus={solutionStatuses[field]}
onTypeChange={onSolutionTypeChange}
onStatusChange={onSolutionStatusChange}
onRemove={() => onRemoveSolutionField(field)}
onFileUpload={onSolutionFileUpload}
onFileView={onFileView}
fileList={fileList}
isReadOnly={isReadOnly}
canRemove={solutionFields.length > 1 && displayIndex > 0}
originalSolutionData={solutionData[displayIndex]}
/>
))}
</div>
{!isReadOnly && (
<div style={{ marginBottom: 8, marginTop: 12 }}>
<Button
type="dashed"
onClick={onAddSolutionField}
icon={<PlusOutlined />}
style={{
width: '100%',
borderColor: '#23A55A',
color: '#23A55A',
height: '32px',
fontSize: '12px'
}}
>
Add sollution
</Button>
</div>
)}
</Form>
</div>
);
};
export default SolutionForm;

View File

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

View File

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

View File

@@ -1,7 +1,8 @@
import React, { useEffect, useState } from 'react';
import { Modal, Input, Divider, Typography, Switch, Button, ConfigProvider } from 'antd';
import { Modal, Input, Divider, Typography, Switch, Button, ConfigProvider, Select } 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;
@@ -9,16 +10,20 @@ 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_device: '',
brand_id: '',
brand_code: '',
is_active: true,
device_location: '',
device_description: '',
ip_address: '',
listen_channel: '',
};
const [formData, setFormData] = useState(defaultData);
@@ -35,6 +40,7 @@ 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 (
@@ -54,8 +60,13 @@ const DetailDevice = (props) => {
device_name: formData.device_name,
is_active: formData.is_active,
device_location: formData.device_location,
device_description: formData.device_description,
device_description:
formData.device_description && formData.device_description.trim() !== ''
? formData.device_description
: ' ',
ip_address: formData.ip_address,
brand_id: formData.brand_id,
listen_channel: formData.listen_channel,
};
const response = formData.device_id
@@ -102,6 +113,13 @@ const DetailDevice = (props) => {
});
};
const handleSelectChange = (name, value) => {
setFormData({
...formData,
[name]: value,
});
};
const handleStatusToggle = (event) => {
const isChecked = event;
setFormData({
@@ -110,6 +128,32 @@ 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);
@@ -143,7 +187,6 @@ const DetailDevice = (props) => {
defaultBorderColor: '#23A55A',
defaultHoverColor: '#23A55A',
defaultHoverBorderColor: '#23A55A',
defaultHoverColor: '#23A55A',
},
},
}}
@@ -244,19 +287,26 @@ const DetailDevice = (props) => {
<div style={{ marginBottom: 12 }}>
<Text strong>Brand Device</Text>
<Text style={{ color: 'red' }}> *</Text>
<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',
}}
/>
<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>
</div>
<div style={{ marginBottom: 12 }}>
<Text strong>Device Location</Text>
@@ -280,6 +330,16 @@ const DetailDevice = (props) => {
readOnly={props.readOnly}
/>
</div>
<div style={{ marginBottom: 12 }}>
<Text strong>Listen Channel</Text>
<Input
name="listen_channel"
value={formData.listen_channel}
onChange={handleInputChange}
placeholder="Enter Listen Channel"
readOnly={props.readOnly}
/>
</div>
<div style={{ marginBottom: 12 }}>
<Text strong>Device Description</Text>
<TextArea

View File

@@ -1,4 +1,4 @@
import React, {useEffect, useState } from 'react';
import React, { useEffect, useState } from 'react';
import { Modal, Button, ConfigProvider } from 'antd';
import { 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;
@@ -45,32 +45,32 @@ const GeneratePdf = (props) => {
doc.setFontSize(11);
doc.setFont('helvetica', 'normal');
doc.setLineWidth(0.2);
doc.setLineWidth(0.2);
doc.line(10, 32, 200, 32);
doc.setLineWidth(0.6);
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("No. Permit", 10, 54);
doc.text(":", 59, 54);
doc.text("Spesifik Lokasi", 120, 54);
doc.text(":", 160, 54);
doc.text('Deskripsi Pekerjaan', 10, 48);
doc.text(':', 59, 48);
doc.text("No. Order", 10, 60);
doc.text(":", 59, 60);
doc.text("Jum. Personil Terlihat", 120, 60);
doc.text(":", 160, 60);
doc.text('No. Permit', 10, 54);
doc.text(':', 59, 54);
doc.text('Spesifik Lokasi', 120, 54);
doc.text(':', 160, 54);
doc.text("Peralatan yang digunakan", 10, 66);
doc.text(":", 59, 66);
doc.text('No. Order', 10, 60);
doc.text(':', 59, 60);
doc.text('Jum. Personil Terlihat', 120, 60);
doc.text(':', 160, 60);
doc.text("Jenis APD yang digunakan", 10, 72);
doc.text(":", 59, 72);
doc.text('Peralatan yang digunakan', 10, 66);
doc.text(':', 59, 66);
doc.text('Jenis APD yang digunakan', 10, 72);
doc.text(':', 59, 72);
const blob = doc.output('blob');
const url = URL.createObjectURL(blob);
@@ -84,7 +84,7 @@ const GeneratePdf = (props) => {
return (
<Modal
width='60%'
width="60%"
title="Preview PDF"
open={props.showPdf}
// open={true}
@@ -101,7 +101,6 @@ const GeneratePdf = (props) => {
defaultBorderColor: '#23A55A',
defaultHoverColor: '#23A55A',
defaultHoverBorderColor: '#23A55A',
defaultHoverColor: '#23A55A',
},
},
}}

View File

@@ -13,6 +13,7 @@ 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) => [
{
@@ -44,9 +45,10 @@ const columns = (showPreviewModal, showEditModal, showDeleteDialog) => [
},
{
title: 'Brand Device',
dataIndex: 'brand_device',
key: 'brand_device',
dataIndex: 'brand_name',
key: 'brand_name',
width: '20%',
render: (brand_name) => brand_name || '-'
},
{
title: 'Location',
@@ -60,6 +62,13 @@ const columns = (showPreviewModal, showEditModal, showDeleteDialog) => [
key: 'ip_address',
width: '10%',
},
{
title: 'Listen Channel',
dataIndex: 'listen_channel',
key: 'listen_channel',
width: '10%',
render: (listen_channel) => listen_channel || '-'
},
{
title: 'Status',
dataIndex: 'is_active',

View File

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

View File

@@ -237,7 +237,7 @@ const ListPlantSubSection = memo(function ListPlantSubSection(props) {
<TableList
mobile
cardColor={'#42AAFF'}
header={'sub_section_name'}
header={'plant_sub_section_name'}
showPreviewModal={showPreviewModal}
showEditModal={showEditModal}
showDeleteDialog={showDeleteDialog}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,395 @@
import React, { useState } from 'react';
import dayjs from 'dayjs';
import { Card, Button, Row, Col, Typography, Divider, Tag, Space, InputNumber, Input } from 'antd';
import { EditOutlined, DeleteOutlined, PlusOutlined, MinusOutlined } from '@ant-design/icons';
import { updateSparepart } from '../../../../api/sparepart';
import { NotifAlert, NotifOk } from '../../../../components/Global/ToastNotif';
const { Text, Title } = Typography;
const SparepartCardList = ({
data,
header,
showPreviewModal,
showEditModal,
showDeleteDialog,
fieldColor,
cardColor,
onStockUpdate, // Prop to refresh the list
}) => {
const [updateQuantities, setUpdateQuantities] = useState({});
const [loadingQuantities, setLoadingQuantities] = useState({});
const handleQuantityChange = (id, value) => {
// Prevent the adjustment from going below the negative value of the original quantity
// This ensures the final quantity (original + adjustment) never goes below 0
const originalQty = data.find((item) => item.sparepart_id === id)?.sparepart_qty || 0;
const maxNegativeAdjustment = -originalQty;
const clampedValue = Math.max(value, maxNegativeAdjustment);
const newQuantities = { ...updateQuantities };
newQuantities[id] = clampedValue;
setUpdateQuantities(newQuantities);
};
const handleUpdateStock = async (item) => {
const quantityToAdd = updateQuantities[item.sparepart_id] || 0;
if (quantityToAdd === 0) {
NotifAlert({
icon: 'info',
title: 'Info',
message: 'Please change the quantity first.',
});
return;
}
const currentQty = Number(item.sparepart_qty) || 0;
const newQty = currentQty + quantityToAdd;
if (newQty < 0) {
NotifAlert({ icon: 'error', title: 'Error', message: 'Quantity cannot be negative.' });
return;
}
setLoadingQuantities((prev) => ({ ...prev, [item.sparepart_id]: true }));
// sparepart_qty disimpan sebagai angka kuantitas (update boleh 0 sesuai validasi update schema)
const payload = {
sparepart_qty: newQty,
sparepart_stok: newQty > 0 ? 'Available' : 'Not Available', // Otomatis tentukan status
};
// Hanya tambahkan field jika nilainya tidak kosong untuk menghindari validasi error
if (item.sparepart_unit && item.sparepart_unit.trim() !== '') {
payload.sparepart_unit = item.sparepart_unit;
}
if (item.sparepart_merk && item.sparepart_merk.trim() !== '') {
payload.sparepart_merk = item.sparepart_merk;
}
if (item.sparepart_model && item.sparepart_model.trim() !== '') {
payload.sparepart_model = item.sparepart_model;
}
if (item.sparepart_description && item.sparepart_description.trim() !== '') {
payload.sparepart_description = item.sparepart_description;
}
if (item.sparepart_item_type && item.sparepart_item_type !== null) {
payload.sparepart_item_type = item.sparepart_item_type;
}
if (item.sparepart_foto && item.sparepart_foto.trim() !== '') {
payload.sparepart_foto = item.sparepart_foto;
}
try {
const response = await updateSparepart(item.sparepart_id, payload);
// Periksa apakah response valid sebelum mengakses propertinya
if (response && response.statusCode === 200) {
NotifOk({
icon: 'success',
title: 'Success',
message: 'Stock updated successfully.',
});
// Cek apakah qty baru kurang dari 1, tampilkan alert
if (newQty < 1) {
NotifAlert({
icon: 'warning',
title: 'Low Stock',
message: `Warning: Sparepart "${item.sparepart_name}" is out of stock. Please restock immediately.`,
});
}
if (onStockUpdate) {
onStockUpdate();
}
handleQuantityChange(item.sparepart_id, 0); // Reset quantity
} else {
NotifAlert({
icon: 'error',
title: 'Failed',
message: response?.message || 'Failed to update stock.',
});
}
} catch (error) {
NotifAlert({
icon: 'error',
title: 'Error',
message: error.message || 'An error occurred.',
});
} finally {
setLoadingQuantities((prev) => ({ ...prev, [item.sparepart_id]: false }));
}
};
return (
<Row gutter={[16, 16]} style={{ marginTop: '16px' }}>
{data.map((item) => {
const quantity = updateQuantities[item.sparepart_id] || 0;
const isLoading = loadingQuantities[item.sparepart_id] || false;
return (
<Col xs={24} sm={12} md={8} lg={6} key={item.sparepart_id || item.key}>
<Card
style={{
borderRadius: '8px',
overflow: 'hidden',
border: `1px solid ${
fieldColor ? item[fieldColor] : cardColor || '#E0E0E0'
}`,
}}
bodyStyle={{ padding: 0 }}
>
<Row>
<Col span={8}>
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'flex-start',
padding: '16px 8px',
height: '100%',
}}
>
{item.sparepart_item_type && (
<Tag
color="blue"
style={{
marginBottom: '8px',
}}
>
{item.sparepart_item_type}
</Tag>
)}
<div
style={{
backgroundColor: '#f0f0f0',
width: '100%',
paddingTop:
'100%' /* Ini membuat tinggi sama dengan lebar (aspect ratio 1:1) */,
position: 'relative',
borderRadius: '4px',
overflow: 'hidden',
}}
>
{(() => {
// Debug: log the image path construction
let imgSrc;
if (item.sparepart_foto) {
if (item.sparepart_foto.startsWith('http')) {
imgSrc = item.sparepart_foto;
} else {
// Gunakan format file URL seperti di brandDevice
const fileName = item.sparepart_foto
.split('/')
.pop();
// Jika filename adalah default file, gunakan dari public assets
if (
fileName === 'defaultSparepartImg.jpg'
) {
imgSrc = `/assets/defaultSparepartImg.jpg`;
} else {
// Gunakan API getFileUrl untuk mendapatkan URL yang benar untuk file upload
const token =
localStorage.getItem('token');
const baseURL =
import.meta.env.VITE_API_SERVER ||
'';
imgSrc = `${baseURL}/file-uploads/images/${encodeURIComponent(
fileName
)}${
token
? `?token=${encodeURIComponent(
token
)}`
: ''
}`;
}
}
console.log(
'Image path being constructed:',
imgSrc
);
} else {
imgSrc = 'https://via.placeholder.com/150';
}
return (
<div
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
}}
>
<img
src={imgSrc}
alt={item[header]}
style={{
width: '100%',
height: '100%',
objectFit: 'cover', // Mengisi container dan crop sisi berlebih
}}
onError={(e) => {
console.error(
'Image failed to load:',
imgSrc
);
e.target.src =
'https://via.placeholder.com/150';
}}
onLoad={() =>
console.log(
'Image loaded successfully:',
imgSrc
)
}
/>
</div>
);
})()}
</div>
</div>
</Col>
<Col span={16}>
<div
style={{
padding: '16px',
position: 'relative',
height: '100%',
}}
>
<div
style={{
position: 'absolute',
top: 8,
right: 8,
display: 'flex',
gap: '8px',
}}
>
{showEditModal && (
<Button
style={{
color: '#faad14',
borderColor: '#faad14',
}}
icon={<EditOutlined />}
key="edit"
onClick={() => showEditModal(item)}
size="small"
/>
)}
{showDeleteDialog && (
<Button
icon={<DeleteOutlined />}
key="delete"
onClick={() => showDeleteDialog(item)}
size="small"
danger
/>
)}
</div>
<Title
level={5}
style={{
margin: 0,
marginBottom: '8px',
paddingRight: '60px',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{item[header]}
</Title>
<Text type="secondary" style={{ display: 'block' }}>
Stok: {item.sparepart_stok || 'Not Available'}
</Text>
<Divider style={{ margin: '8px 0' }} />
<Space
align="center"
style={{
marginBottom: '8px',
display: 'flex',
}}
>
<Text type="secondary">Qty</Text>
<Button
icon={<MinusOutlined />}
onClick={() =>
handleQuantityChange(
item.sparepart_id,
quantity - 1
)
}
disabled={
isLoading || item.sparepart_qty + quantity <= 0
}
style={{ width: 28, height: 28 }}
/>
<Text
strong
style={{ padding: '0 8px', fontSize: '16px' }}
>
{item.sparepart_qty + (quantity || 0)}
</Text>
<Button
icon={<PlusOutlined />}
onClick={() =>
handleQuantityChange(
item.sparepart_id,
quantity + 1
)
}
disabled={isLoading}
style={{ width: 28, height: 28 }}
/>
<Text type="secondary">
{item.sparepart_unit
? ` / ${item.sparepart_unit}`
: ' / pcs'}
</Text>
</Space>
{quantity !== 0 && (
<Button
type={quantity === 0 ? 'default' : 'primary'}
size="small"
style={{ width: '100%' }}
onClick={() => handleUpdateStock(item)}
loading={isLoading}
>
Update Stock
</Button>
)}
<br />
<Text
type="secondary"
style={{
fontSize: '12px',
marginTop: '8px',
display: 'inline-block',
}}
>
Last updated:{' '}
{item.updated_at
? dayjs(item.updated_at).format('DD MMM YYYY')
: 'N/A'}
</Text>
</div>
</Col>
</Row>
</Card>
</Col>
);
})}
</Row>
);
};
export default SparepartCardList;

View File

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

View File

@@ -84,12 +84,32 @@ const DetailTag = (props) => {
const params = new URLSearchParams({ limit: 10000 });
const response = await getAllTag(params);
if (response && response.data && response.data.data) {
const existingTags = response.data.data;
// 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 (existingTags.length > 0) {
const isDuplicate = existingTags.some((tag) => {
const isSameNumber = Number(tag.tag_number) === tagNumberInt;
const isDifferentTag = formData.tag_id ? tag.tag_id !== formData.tag_id : true;
// 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;
return isSameNumber && isDifferentTag;
});
@@ -97,7 +117,7 @@ const DetailTag = (props) => {
NotifOk({
icon: 'warning',
title: 'Peringatan',
message: `Tag Number ${tagNumberInt} sudah digunakan. Silakan gunakan nomor yang berbeda.`,
message: `Tag Number ${formData.tag_number} sudah digunakan. Silakan gunakan nomor yang berbeda.`,
});
setConfirmLoading(false);
return;
@@ -148,10 +168,7 @@ const DetailTag = (props) => {
payload.unit = formData.unit.trim();
}
// Add tag_description only if it has a value
if (formData.tag_description && formData.tag_description.trim() !== '') {
payload.tag_description = formData.tag_description.trim();
}
payload.tag_description = (formData.tag_description && formData.tag_description.trim() !== '') ? formData.tag_description.trim() : ' ';
// Add device_id only if it has a value
if (formData.device_id) {

View File

@@ -63,7 +63,7 @@ const columns = (showPreviewModal, showEditModal, showDeleteDialog) => [
render: (text) => text || '-',
},
{
title: 'Sub Section',
title: 'Plant Sub Section',
dataIndex: 'plant_sub_section_name',
key: 'plant_sub_section_name',
width: '10%',

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,102 @@
import React from 'react';
import { Modal, Table, Tag, Typography } from 'antd';
import { ClockCircleOutlined, UserOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
const { Text } = Typography;
// Dummy data untuk log history
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 LogHistoryModal = ({ visible, onCancel, notificationData }) => {
const logHistoryData = getDummyLogHistory(notificationData);
return (
<Modal
title={
<Text strong>
Log History: <Text type="secondary">{notificationData?.title}</Text>
</Text>
}
open={visible}
onCancel={onCancel}
footer={null}
width={800}
destroyOnClose
>
<Table
columns={columns}
dataSource={logHistoryData}
pagination={{ pageSize: 5 }}
style={{ marginTop: 24 }}
/>
</Modal>
);
};
export default LogHistoryModal;

View File

@@ -0,0 +1,132 @@
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';
const { Text } = Typography;
// Dummy data baru untuk user history
const getDummyUsers = (notification) => {
if (!notification) return [];
return [
{
id: '1',
name: 'Budi Santoso',
phone: '081234567890',
status: 'delivered',
},
{
id: '2',
name: 'Citra Lestari',
phone: '082345678901',
status: 'sent',
},
{
id: '3',
name: 'Agus Wijaya',
phone: '083456789012',
status: 'failed',
},
{
id: '4',
name: 'Dewi Anggraini',
phone: '084567890123',
status: 'delivered',
},
];
};
const UserHistoryModal = ({ visible, onCancel, notificationData }) => {
const userData = getDummyUsers(notificationData);
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>;
}
};
return (
<Modal
title={
<Text strong style={{ fontSize: '18px' }}>
History User Notification
</Text>
}
open={visible}
onCancel={onCancel}
footer={[
<Button key="back" onClick={onCancel}>
Close
</Button>,
]}
width={600}
destroyOnClose
>
<div style={{ maxHeight: '60vh', overflowY: 'auto', padding: '8px' }}>
<Space direction="vertical" style={{ width: '100%' }}>
{userData.map((user) => (
<Card key={user.id} size="small" style={{ width: '100%' }}>
<Row align="middle" justify="space-between">
<Col>
<Space align="center">
<Avatar size="large" icon={<UserOutlined />} />
<div>
<Text strong>{user.name}</Text>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
}}
>
<PhoneOutlined style={{ color: '#8c8c8c' }} />
<Text type="secondary">{user.phone}</Text>
</div>
</div>
</Space>
</Col>
<Col>
<Space align="center" size="large">
{getStatusTag(user.status)}
<Button
type="primary"
icon={<SendOutlined />}
onClick={(e) => {
e.stopPropagation();
console.log(`Resend to ${user.name}`);
}}
>
Resend
</Button>
</Space>
</Col>
</Row>
</Card>
))}
</Space>
</div>
</Modal>
);
};
export default UserHistoryModal;

View File

@@ -0,0 +1,92 @@
import React from 'react';
import { Button, Row, Col, Card, Badge, Typography, Space, Divider } from 'antd';
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',
},
];
const UserHistory = ({ notification, onBack }) => {
return (
<Card>
<Row justify="space-between" align="middle" style={{ marginBottom: '20px' }}>
<Col>
<Space align="center">
<Button type="text" icon={<ArrowLeftOutlined />} onClick={onBack} />
<Typography.Title level={4} style={{ margin: 0 }}>
History User Notification
</Typography.Title>
</Space>
<Text type="secondary" style={{ marginLeft: '40px' }}>
{notification.title} - {notification.issue}
</Text>
</Col>
</Row>
<Space direction="vertical" size="middle" style={{ display: 'flex' }}>
{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>|</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>
</Space>
</Col>
<Col>
<Button type="primary" ghost icon={<SendOutlined />}>
Resend
</Button>
</Col>
</Row>
</Card>
))}
</Space>
</Card>
);
};
export default UserHistory;

View File

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

View File

@@ -1,91 +1,246 @@
import React, { memo, useState, useEffect } from 'react';
import { Button, Row, Col, Card, Input, DatePicker, Select, Typography } from 'antd';
import TableList from '../../../../components/Global/TableList';
import { Button, Row, Col, Card, DatePicker, Select, Typography, Table, Spin, Modal } from 'antd';
import dayjs from 'dayjs';
import { FileTextOutlined } from '@ant-design/icons';
import { FileTextOutlined, DownloadOutlined, LoadingOutlined } from '@ant-design/icons';
import {
getAllHistoryValueReport,
getAllHistoryValueReportPivot,
getAllHistoryValueReport,
} from '../../../../api/history-value';
import { getAllPlantSection } from '../../../../api/master-plant-section';
import jsPDF from 'jspdf';
import autoTable from 'jspdf-autotable';
import ExcelJS from 'exceljs';
import { saveAs } from 'file-saver';
const { Text } = Typography;
const ListReport = memo(function ListReport(props) {
const columns = [
{
title: 'No',
key: 'no',
width: '5%',
align: 'center',
render: (_, __, index) => index + 1,
},
{
title: 'Datetime',
dataIndex: 'datetime',
key: 'datetime',
width: '15%',
},
{
title: 'Tag Name',
dataIndex: 'tag_name',
key: 'tag_name',
width: '70%',
},
// {
// title: 'Value',
// dataIndex: 'val',
// key: 'val',
// width: '10%',
// render: (_, record) => Number(record.val).toFixed(4),
// },
// {
// title: 'Stat',
// dataIndex: 'status',
// key: 'status',
// width: '10%',
// },
];
const dateNow = dayjs();
const dateNowFormated = dateNow.format('YYYY-MM-DD');
const [trigerFilter, setTrigerFilter] = useState(false);
const [isLoadingModal, setIsLoadingModal] = useState(false);
const [isLoadingTable, setIsLoadingTable] = useState(false);
const [tableData, setTableData] = useState([]);
const [columns, setColumns] = useState([]);
const [pivotData, setPivotData] = useState([]);
const [valueReportData, setValueReportData] = useState([]);
const [pagination, setPagination] = useState({
current: 1,
pageSize: 10,
total: 0,
});
const [plantSubSection, setPlantSubSection] = useState(0);
const [plantSubSectionList, setPlantSubSectionList] = useState([]);
const [startDate, setStartDate] = useState(dateNow);
const [endDate, setEndDate] = useState(dateNow);
const [periode, setPeriode] = useState(10);
const [periode, setPeriode] = useState(30);
const defaultFilter = {
criteria: '',
plant_sub_section_id: 0,
from: dateNowFormated,
to: dateNowFormated,
interval: periode,
const generateFullDayTimes = (dateString, intervalMinutes) => {
const times = [];
const startOfDay = dayjs(dateString).startOf('day');
const endOfDay = dayjs(dateString).endOf('day');
let currentTime = startOfDay;
while (currentTime.isBefore(endOfDay) || currentTime.isSame(endOfDay)) {
times.push(currentTime.format('YYYY-MM-DD HH:mm:ss'));
currentTime = currentTime.add(intervalMinutes, 'minute');
if (currentTime.isAfter(endOfDay)) {
break;
}
}
return times;
};
const [formDataFilter, setFormDataFilter] = useState(defaultFilter);
const handleSearch = () => {
const formattedDateStart = startDate.format('YYYY-MM-DD');
const formattedDateEnd = endDate.format('YYYY-MM-DD');
const fetchData = async (page = 1, pageSize = 10, showModal = false) => {
// if (!plantSubSection) {
// return;
// }
setFormDataFilter({
criteria: '',
plant_sub_section_id: plantSubSection,
from: formattedDateStart,
to: formattedDateEnd,
interval: periode,
});
setTrigerFilter((prev) => !prev);
if (showModal) {
setIsLoadingModal(true);
} else {
setIsLoadingTable(true);
}
try {
const formattedDateStart = startDate.format('YYYY-MM-DD');
const formattedDateEnd = endDate.format('YYYY-MM-DD');
const params = new URLSearchParams({
plant_sub_section_id: plantSubSection,
from: formattedDateStart,
to: formattedDateEnd,
interval: periode,
page: 1,
limit: 1000,
});
const pivotResponse = await getAllHistoryValueReportPivot(params);
const valueReportResponse = await getAllHistoryValueReportPivot(params);
if (pivotResponse && pivotResponse.data) {
console.log('API Pivot Response:', pivotResponse);
setPivotData(pivotResponse.data);
if (valueReportResponse && valueReportResponse.data) {
console.log('API Value Report Response:', valueReportResponse);
setValueReportData(valueReportResponse.data);
}
// Buat struktur pivot: waktu sebagai baris, tag sebagai kolom
const timeMap = new Map();
const tagSet = new Set();
// Kumpulkan semua waktu unik dan tag unik
pivotResponse.data.forEach((row) => {
const tagName = row.id;
tagSet.add(tagName);
const dataPoints = row.data || [];
dataPoints.forEach((item) => {
if (item && typeof item === 'object' && 'x' in item && 'y' in item) {
const datetime = item.x;
if (!timeMap.has(datetime)) {
timeMap.set(datetime, {});
}
timeMap.get(datetime)[tagName] = item.y;
}
});
});
// Konversi ke array dan sort berdasarkan waktu
const sortedTimes = Array.from(timeMap.keys()).sort();
const sortedTags = Array.from(tagSet).sort();
// Buat data untuk table
const pivotTableData = sortedTimes.map((datetime, index) => {
const rowData = {
key: index,
datetime: datetime,
};
sortedTags.forEach((tagName) => {
rowData[tagName] = timeMap.get(datetime)[tagName];
});
return rowData;
});
console.log('Pivot table data sample:', pivotTableData.slice(0, 5));
console.log('Total pivot rows:', pivotTableData.length);
// Buat kolom dinamis
const dynamicColumns = [
{
title: 'No',
key: 'no',
width: 60,
align: 'center',
fixed: 'left',
render: (_, __, index) => {
return (page - 1) * pageSize + index + 1;
},
},
{
title: 'Datetime',
dataIndex: 'datetime',
key: 'datetime',
width: 180,
fixed: 'left',
sorter: (a, b) => new Date(a.datetime) - new Date(b.datetime),
},
...sortedTags.map((tagName) => ({
title: tagName,
dataIndex: tagName,
key: tagName,
width: 120,
align: 'center',
render: (value) => {
if (value === null || value === undefined) {
return '-';
}
return Number(value).toFixed(2);
},
})),
];
setColumns(dynamicColumns);
// Pagination
const total = pivotTableData.length;
const startIndex = (page - 1) * pageSize;
const endIndex = startIndex + pageSize;
const paginatedData = pivotTableData.slice(startIndex, endIndex);
setTableData(paginatedData);
setPagination({
current: page,
pageSize: pageSize,
total: total,
});
}
} catch (error) {
console.error('Error fetching data:', error);
} finally {
if (showModal) {
setIsLoadingModal(false);
} else {
setIsLoadingTable(false);
}
}
};
const handleTableChange = (pagination, filters, sorter) => {
fetchData(pagination.current, pagination.pageSize, false);
};
const handleSearch = async () => {
setIsLoadingModal(true);
try {
const formattedDateStart = startDate.format('YYYY-MM-DD');
const formattedDateEnd = endDate.format('YYYY-MM-DD');
const params = new URLSearchParams({
plant_sub_section_id: plantSubSection,
from: formattedDateStart,
to: formattedDateEnd,
interval: periode,
page: 1,
limit: 1000,
});
const pivotResponse = await getAllHistoryValueReportPivot(params);
// Jika response sukses, proses data
if (pivotResponse && pivotResponse.data) {
await fetchData(1, pagination.pageSize, false);
}
} catch (error) {
console.error('Error fetching data:', error);
// Error akan ditangkap oleh api-request.js dan muncul Swal otomatis
} finally {
setIsLoadingModal(false);
}
};
const handleReset = () => {
setPlantSubSection(0);
setStartDate(dateNow);
setEndDate(dateNow);
setPeriode(5);
setPeriode(30);
setTableData([]);
setColumns([]);
setPivotData([]);
setValueReportData([]);
setPagination({
current: 1,
pageSize: 10,
total: 0,
});
};
const getPlantSubSection = async () => {
@@ -104,8 +259,548 @@ const ListReport = memo(function ListReport(props) {
getPlantSubSection();
}, []);
const isWithinOneDay = startDate.isSame(endDate, 'day');
useEffect(() => {
if (!isWithinOneDay && periode < 60) {
setPeriode(60);
}
}, [startDate, endDate, periode, isWithinOneDay]);
const periodeOptions = [
{ value: 5, label: '5 Minute', disabled: !isWithinOneDay },
{ value: 10, label: '10 Minute', disabled: !isWithinOneDay },
{ value: 30, label: '30 Minute', disabled: !isWithinOneDay },
{ value: 60, label: '1 Hour', disabled: false },
{ value: 120, label: '2 Hour', disabled: false },
];
const exportToExcel = async () => {
if (pivotData.length === 0) {
alert('No data to export');
return;
}
const tagMapping = {};
valueReportData.forEach(item => {
if (item.tag_name && item.tag_number) {
tagMapping[item.tag_name] = item.tag_number;
}
});
const selectedSection = plantSubSectionList.find(
item => item.plant_sub_section_id === plantSubSection
);
const sectionName = selectedSection ? selectedSection.plant_sub_section_name : 'Unknown';
// Buat struktur pivot yang sama seperti di tabel
const timeMap = new Map();
const tagSet = new Set();
pivotData.forEach((row) => {
const tagName = row.id;
tagSet.add(tagName);
const dataPoints = row.data || [];
dataPoints.forEach((item) => {
if (item && typeof item === 'object' && 'x' in item && 'y' in item) {
const datetime = item.x;
if (!timeMap.has(datetime)) {
timeMap.set(datetime, {});
}
timeMap.get(datetime)[tagName] = item.y;
}
});
});
const sortedTimes = Array.from(timeMap.keys()).sort();
const sortedTags = Array.from(tagSet).sort();
const pivotTableData = sortedTimes.map((datetime) => {
const rowData = {
datetime: datetime,
};
sortedTags.forEach((tagName) => {
rowData[tagName] = timeMap.get(datetime)[tagName];
});
return rowData;
});
console.log('Excel Pivot data:', pivotTableData.slice(0, 5));
console.log('Total rows for Excel:', pivotTableData.length);
const workbook = new ExcelJS.Workbook();
const ws = workbook.addWorksheet('Pivot Report');
// Buat header info (3 baris pertama)
ws.addRow(['PT. PUPUK INDONESIA UTILITAS']);
ws.addRow(['GRESIK GAS COGENERATION PLANT']);
ws.addRow([`${sectionName}`]);
ws.addRow([]); // Baris kosong sebagai pemisah
// Buat header kolom dengan tag number
const headerRow = [
'Datetime',
...sortedTags.map(tag => tagMapping[tag] || tag)
];
ws.addRow(headerRow);
// Buat data rows - PERBAIKAN: Simpan sebagai number murni
pivotTableData.forEach((rowData) => {
const row = [dayjs(rowData.datetime).format('DD-MM-YYYY HH:mm')];
sortedTags.forEach((tagName) => {
const value = rowData[tagName];
// Simpan sebagai number, bukan string
if (value !== undefined && value !== null) {
row.push(Number(value));
} else {
row.push('-');
}
});
ws.addRow(row);
});
// Set column widths
ws.getColumn(1).width = 18; // Datetime column
for (let i = 2; i <= sortedTags.length + 1; i++) {
ws.getColumn(i).width = 12; // Tag columns
}
// Merge cells untuk header info
const totalCols = sortedTags.length + 1;
ws.mergeCells(1, 1, 1, totalCols); // Baris 1
ws.mergeCells(2, 1, 2, totalCols); // Baris 2
ws.mergeCells(3, 1, 3, totalCols); // Baris 3
// Style untuk header info (3 baris pertama - bold dan center)
for (let i = 1; i <= 3; i++) {
const cell = ws.getCell(i, 1);
cell.font = { bold: true, size: 12 };
cell.alignment = { horizontal: 'center', vertical: 'middle', wrapText: true };
}
// Style untuk header kolom (bold, background color, center, border)
const headerRowIndex = 5; // Baris header
for (let col = 1; col <= totalCols; col++) {
const cell = ws.getCell(headerRowIndex, col);
cell.font = { bold: true, size: 11 };
cell.fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FFDCDCDC' }
};
cell.alignment = { horizontal: 'center', vertical: 'middle', wrapText: true };
cell.border = {
top: { style: 'thin', color: { argb: 'FF000000' } },
bottom: { style: 'thin', color: { argb: 'FF000000' } },
left: { style: 'thin', color: { argb: 'FF000000' } },
right: { style: 'thin', color: { argb: 'FF000000' } }
};
}
// Style untuk data cells (border dan alignment) - PERBAIKAN: Format number dengan 2 desimal
for (let row = headerRowIndex + 1; row <= ws.rowCount; row++) {
for (let col = 1; col <= totalCols; col++) {
const cell = ws.getCell(row, col);
cell.alignment = {
horizontal: 'center',
vertical: 'middle',
wrapText: true
};
cell.border = {
top: { style: 'thin', color: { argb: 'FF000000' } },
bottom: { style: 'thin', color: { argb: 'FF000000' } },
left: { style: 'thin', color: { argb: 'FF000000' } },
right: { style: 'thin', color: { argb: 'FF000000' } }
};
// Format number dengan 2 desimal untuk kolom value (kolom 2 dst)
if (col > 1) {
const cellValue = cell.value;
// Hanya set format number jika cell berisi angka
if (typeof cellValue === 'number') {
cell.numFmt = '0.00';
}
}
}
}
// Generate file name
const fileName = `Report_Pivot_${startDate.format('DD-MM-YYYY')}_to_${endDate.format('DD-MM-YYYY')}.xlsx`;
// Save file
const buffer = await workbook.xlsx.writeBuffer();
const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
saveAs(blob, fileName);
};
const exportToPDF = async () => {
if (pivotData.length === 0) {
alert('No data to export');
return;
}
const tagMapping = {};
valueReportData.forEach(item => {
if (item.tag_name && item.tag_number) {
tagMapping[item.tag_name] = item.tag_number;
}
});
const selectedSection = plantSubSectionList.find(item => item.plant_sub_section_id === plantSubSection);
const sectionName = selectedSection ? selectedSection.plant_sub_section_name : 'Unknown';
// Buat struktur pivot yang sama seperti di tabel
const timeMap = new Map();
const tagSet = new Set();
pivotData.forEach((row) => {
const tagName = row.id;
tagSet.add(tagName);
const dataPoints = row.data || [];
dataPoints.forEach((item) => {
if (item && typeof item === 'object' && 'x' in item && 'y' in item) {
const datetime = item.x;
if (!timeMap.has(datetime)) {
timeMap.set(datetime, {});
}
timeMap.get(datetime)[tagName] = item.y;
}
});
});
const sortedTimes = Array.from(timeMap.keys()).sort();
const sortedTags = Array.from(tagSet).sort();
const pivotTableData = sortedTimes.map((datetime) => {
const rowData = {
datetime: datetime,
};
sortedTags.forEach((tagName) => {
rowData[tagName] = timeMap.get(datetime)[tagName];
});
return rowData;
});
console.log('PDF Pivot data:', pivotTableData.slice(0, 5));
console.log('Total rows for PDF:', pivotTableData.length);
const loadImage = (src) => {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = reject;
img.src = src;
});
};
let logo1, logo2;
try {
logo1 = await loadImage('/assets/pupuk-indonesia-2.jpg');
logo2 = await loadImage('/assets/pupuk-indonesia-1.png');
} catch (error) {
console.error('Error loading logos:', error);
}
const doc = new jsPDF({ orientation: 'landscape' });
const pageWidth = doc.internal.pageSize.width;
const pageHeight = doc.internal.pageSize.height;
const marginLeft = 10;
const marginRight = 10;
const tableWidth = pageWidth - marginLeft - marginRight;
const DATETIME_COLUMN_WIDTH = 25;
const HEADER_LEFT_COLUMN_WIDTH = 40;
const MAX_TAG_COLUMNS_PER_PAGE = 15;
const drawFullHeader = (doc) => {
doc.setLineWidth(0.5);
doc.line(marginLeft, 10, marginLeft + tableWidth, 10);
doc.line(marginLeft, 10, marginLeft, 50);
doc.line(marginLeft + tableWidth, 10, marginLeft + tableWidth, 50);
const col1Width = HEADER_LEFT_COLUMN_WIDTH;
const col3Width = tableWidth * 0.20;
const col2Width = tableWidth - col1Width - col3Width;
doc.line(marginLeft + col1Width, 10, marginLeft + col1Width, 30);
doc.line(marginLeft + tableWidth - col3Width, 10, marginLeft + tableWidth - col3Width, 30);
doc.line(marginLeft, 30, marginLeft + tableWidth, 30);
if (logo1) {
const maxLogoHeight = 18;
const maxLogoWidth = col1Width - 4;
const logoAspectRatio = logo1.width / logo1.height;
let logoWidth, logoHeight;
if (logoAspectRatio > (maxLogoWidth / maxLogoHeight)) {
logoWidth = maxLogoWidth;
logoHeight = logoWidth / logoAspectRatio;
} else {
logoHeight = maxLogoHeight;
logoWidth = logoHeight * logoAspectRatio;
}
const logoX = marginLeft + (col1Width - logoWidth) / 2;
const logoY = 10 + (20 - logoHeight) / 2;
doc.addImage(logo1, 'JPEG', logoX, logoY, logoWidth, logoHeight);
}
doc.setFontSize(12);
doc.setFont('helvetica', 'bold');
doc.text('PT. PUPUK INDONESIA UTILITAS', marginLeft + col1Width + col2Width / 2, 17, { align: 'center' });
doc.line(marginLeft + col1Width, 21, marginLeft + tableWidth - col3Width, 21);
doc.setFontSize(11);
doc.text('GRESIK GAS COGENERATION PLANT', marginLeft + col1Width + col2Width / 2, 27, { align: 'center' });
if (logo2) {
const maxLogoHeight = 18;
const maxLogoWidth = col3Width - 4;
const logoAspectRatio = logo2.width / logo2.height;
let logoWidth, logoHeight;
if (logoAspectRatio > (maxLogoWidth / maxLogoHeight)) {
logoWidth = maxLogoWidth;
logoHeight = logoWidth / logoAspectRatio;
} else {
logoHeight = maxLogoHeight;
logoWidth = logoHeight * logoAspectRatio;
}
const logoX = marginLeft + tableWidth - col3Width + (col3Width - logoWidth) / 2;
const logoY = 10 + (20 - logoHeight) / 2;
doc.addImage(logo2, 'PNG', logoX, logoY, logoWidth, logoHeight);
}
doc.setFontSize(9);
doc.setFont('helvetica', 'bold');
doc.setFontSize(10);
doc.text(`${sectionName}`, marginLeft + col1Width + col2Width / 2, 38, { align: 'center' });
};
// Hitung total kolom tag chunks
const totalTagColumns = sortedTags.length;
const totalTagChunks = Math.ceil(totalTagColumns / MAX_TAG_COLUMNS_PER_PAGE);
// PERBAIKAN: Variabel untuk tracking total halaman yang sebenarnya
let actualTotalPages = 0;
const pageInfoArray = []; // Array untuk menyimpan info setiap page
// Loop pertama: hitung dulu total halaman yang akan dibuat
for (let pageChunk = 0; pageChunk < totalTagChunks; pageChunk++) {
const startTagIndex = pageChunk * MAX_TAG_COLUMNS_PER_PAGE;
const endTagIndex = Math.min(startTagIndex + MAX_TAG_COLUMNS_PER_PAGE, totalTagColumns);
const pageTagColumns = sortedTags.slice(startTagIndex, endTagIndex);
const isFirstPage = (pageChunk === 0);
// Simulasi autoTable untuk menghitung jumlah halaman
const tempDoc = new jsPDF({ orientation: 'landscape' });
const headerRow = ['Datetime', ...pageTagColumns.map(tag => tagMapping[tag] || tag)];
const pdfRows = pivotTableData.map((rowData) => {
const row = [dayjs(rowData.datetime).format('DD-MM-YYYY HH:mm')];
pageTagColumns.forEach((tagName) => {
const value = rowData[tagName];
row.push(value !== undefined && value !== null ? Number(value).toFixed(2) : '-');
});
return row;
});
const availableWidthForTags = tableWidth - DATETIME_COLUMN_WIDTH;
const TAG_COLUMN_WIDTH = availableWidthForTags / pageTagColumns.length;
const tagColumnStyles = {};
for (let i = 0; i < pageTagColumns.length; i++) {
tagColumnStyles[i + 1] = {
cellWidth: TAG_COLUMN_WIDTH,
halign: 'center'
};
}
let pagesForThisChunk = 0;
autoTable(tempDoc, {
head: [headerRow],
body: pdfRows,
startY: isFirstPage ? 50 : 15,
theme: 'grid',
rowPageBreak: 'avoid',
styles: {
fontSize: 7,
cellPadding: 1.5,
minCellHeight: 8,
lineColor: [0, 0, 0],
lineWidth: 0.1,
halign: 'center',
valign: 'middle',
overflow: 'linebreak',
},
headStyles: {
fillColor: [220, 220, 220],
textColor: [0, 0, 0],
fontStyle: 'bold',
halign: 'center',
valign: 'middle',
lineColor: [0, 0, 0],
lineWidth: 0.3,
},
columnStyles: {
0: {
cellWidth: DATETIME_COLUMN_WIDTH,
fontStyle: 'bold',
halign: 'center',
valign: 'middle'
},
...tagColumnStyles
},
margin: { left: marginLeft, right: marginRight, top: 15 },
tableWidth: tableWidth,
pageBreak: 'auto',
didDrawPage: () => {
pagesForThisChunk++;
}
});
pageInfoArray.push({
chunkIndex: pageChunk,
pagesCount: pagesForThisChunk,
startPage: actualTotalPages + 1
});
actualTotalPages += pagesForThisChunk;
}
console.log('Total pages akan dibuat:', actualTotalPages);
// Loop kedua: buat PDF yang sebenarnya dengan nomor halaman yang benar
let globalPageNumber = 1;
for (let pageChunk = 0; pageChunk < totalTagChunks; pageChunk++) {
if (pageChunk > 0) {
doc.addPage();
}
const startTagIndex = pageChunk * MAX_TAG_COLUMNS_PER_PAGE;
const endTagIndex = Math.min(startTagIndex + MAX_TAG_COLUMNS_PER_PAGE, totalTagColumns);
const pageTagColumns = sortedTags.slice(startTagIndex, endTagIndex);
const isFirstPage = (pageChunk === 0);
if (isFirstPage) {
drawFullHeader(doc);
}
const headerRow = ['Datetime', ...pageTagColumns.map(tag => tagMapping[tag] || tag)];
const pdfRows = pivotTableData.map((rowData) => {
const row = [dayjs(rowData.datetime).format('DD-MM-YYYY HH:mm')];
pageTagColumns.forEach((tagName) => {
const value = rowData[tagName];
row.push(value !== undefined && value !== null ? Number(value).toFixed(2) : '-');
});
return row;
});
const availableWidthForTags = tableWidth - DATETIME_COLUMN_WIDTH;
const TAG_COLUMN_WIDTH = availableWidthForTags / pageTagColumns.length;
const tagColumnStyles = {};
for (let i = 0; i < pageTagColumns.length; i++) {
tagColumnStyles[i + 1] = {
cellWidth: TAG_COLUMN_WIDTH,
halign: 'center'
};
}
autoTable(doc, {
head: [headerRow],
body: pdfRows,
startY: isFirstPage ? 43 : 15,
theme: 'grid',
rowPageBreak: 'avoid',
styles: {
fontSize: 7,
cellPadding: 1.5,
minCellHeight: 8,
lineColor: [0, 0, 0],
lineWidth: 0.5,
halign: 'center',
valign: 'middle',
overflow: 'linebreak',
},
headStyles: {
fillColor: [220, 220, 220],
textColor: [0, 0, 0],
fontStyle: 'bold',
halign: 'center',
valign: 'middle',
lineColor: [0, 0, 0],
lineWidth: 0.5,
},
columnStyles: {
0: {
cellWidth: DATETIME_COLUMN_WIDTH,
fontStyle: 'bold',
halign: 'center',
valign: 'middle'
},
...tagColumnStyles
},
margin: { left: marginLeft, right: marginRight, top: 15 },
tableWidth: tableWidth,
pageBreak: 'auto',
didDrawPage: (data) => {
doc.setFontSize(8);
doc.setFont('helvetica', 'normal');
doc.text(
`Page ${globalPageNumber} of ${actualTotalPages}`,
doc.internal.pageSize.width / 2,
doc.internal.pageSize.height - 10,
{ align: 'center' }
);
globalPageNumber++;
},
});
}
doc.save(`Report_Pivot_${startDate.format('DD-MM-YYYY')}_to_${endDate.format('DD-MM-YYYY')}.pdf`);
};
return (
<React.Fragment>
<Modal
open={isLoadingModal}
footer={null}
closable={false}
centered
width={400}
bodyStyle={{
textAlign: 'center',
padding: '40px 20px'
}}
>
<Spin
indicator={<LoadingOutlined style={{ fontSize: 48, color: '#1890ff' }} spin />}
/>
<div style={{ marginTop: '24px' }}>
<Typography.Title level={4} style={{ marginBottom: '8px' }}>
Please Wait
</Typography.Title>
<Typography.Text type="secondary">
System is generating report data...
</Typography.Text>
</div>
</Modal>
<Card>
<Row>
<Col xs={24}>
@@ -167,14 +862,8 @@ const ListReport = memo(function ListReport(props) {
value={periode}
onChange={setPeriode}
style={{ width: '100%', marginTop: '4px' }}
options={[
{ value: 5, label: '5 Minute' },
{ value: 10, label: '10 Minute' },
{ value: 30, label: '30 Minute' },
{ value: 60, label: '1 Hour' },
{ value: 120, label: '2 Hour' },
]}
></Select>
options={periodeOptions}
/>
</div>
</Col>
</Row>
@@ -185,10 +874,33 @@ const ListReport = memo(function ListReport(props) {
danger
icon={<FileTextOutlined />}
onClick={handleSearch}
disabled={false}
>
Show
</Button>
</Col>
<Col>
<Button
type="primary"
icon={<DownloadOutlined />}
onClick={exportToPDF}
disabled={pivotData.length === 0}
style={{ backgroundColor: '#1890ff', borderColor: '#1890ff' }}
>
Export PDF
</Button>
</Col>
<Col>
<Button
type="primary"
icon={<DownloadOutlined />}
onClick={exportToExcel}
disabled={pivotData.length === 0}
style={{ backgroundColor: '#28a745', borderColor: '#28a745' }}
>
Export Excel
</Button>
</Col>
<Col>
<Button
onClick={handleReset}
@@ -199,18 +911,26 @@ const ListReport = memo(function ListReport(props) {
</Col>
</Row>
</Col>
<Col xs={24} sm={24} md={24} lg={24} xl={24} style={{ marginTop: '16px' }}>
<TableList
firstLoad={false}
mobile
cardColor={'#d38943ff'}
header={'datetime'}
getData={getAllHistoryValueReportPivot}
queryParams={formDataFilter}
columns={columns}
columnDynamic={'columns'}
triger={trigerFilter}
/>
<Col xs={24} style={{ marginTop: '16px' }}>
<Spin spinning={isLoadingTable}>
<div style={{ overflowX: 'auto', width: '100%' }}>
<Table
columns={columns}
dataSource={tableData}
pagination={{
...pagination,
showSizeChanger: true,
showTotal: (total) => `Total ${total} data`,
pageSizeOptions: ['10', '20', '50', '100'],
}}
onChange={handleTableChange}
scroll={{ x: 'max-content', y: 500 }}
bordered
size="small"
sticky
/>
</div>
</Spin>
</Col>
</Row>
</Card>
@@ -218,4 +938,4 @@ const ListReport = memo(function ListReport(props) {
);
});
export default ListReport;
export default ListReport;

View File

@@ -1,8 +1,17 @@
import React, { memo, useState, useEffect } from 'react';
import { Button, Row, Col, Card, Input, DatePicker, Select, Typography } from 'antd';
import { Button, Row, Col, Card, DatePicker, Select, Typography, Modal, Spin } from 'antd';
import dayjs from 'dayjs';
import { FileTextOutlined } from '@ant-design/icons';
import { ResponsiveLine } from '@nivo/line';
import { FileTextOutlined, LoadingOutlined } from '@ant-design/icons';
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer
} from 'recharts';
import './trending.css';
import { getAllPlantSection } from '../../../api/master-plant-section';
import { getAllHistoryValueTrendingPivot } from '../../../api/history-value';
@@ -18,6 +27,7 @@ const ReportTrending = memo(function ReportTrending(props) {
const [startDate, setStartDate] = useState(dateNow);
const [endDate, setEndDate] = useState(dateNow);
const [periode, setPeriode] = useState(60);
const [isLoading, setIsLoading] = useState(false);
const defaultFilter = {
criteria: '',
@@ -29,51 +39,83 @@ const ReportTrending = memo(function ReportTrending(props) {
const [formDataFilter, setFormDataFilter] = useState(defaultFilter);
const [trendingValue, setTrendingValue] = useState([]);
const [chartData, setChartData] = useState([]);
const [metrics, setMetrics] = useState([]);
// Palet warna
const colorPalette = [
'#3b82f6', '#ef4444', '#10b981', '#f59e0b', '#8b5cf6',
'#ec4899', '#14b8a6', '#f97316', '#6366f1', '#84cc16'
];
const handleSearch = async () => {
const formattedDateStart = startDate.format('YYYY-MM-DD');
const formattedDateEnd = endDate.format('YYYY-MM-DD');
setIsLoading(true);
try {
const formattedDateStart = startDate.format('YYYY-MM-DD');
const formattedDateEnd = endDate.format('YYYY-MM-DD');
const newFilter = {
criteria: '',
plant_sub_section_id: plantSubSection,
from: formattedDateStart,
to: formattedDateEnd,
interval: periode,
};
const newFilter = {
criteria: '',
plant_sub_section_id: plantSubSection,
from: formattedDateStart,
to: formattedDateEnd,
interval: periode,
};
setFormDataFilter(newFilter);
setFormDataFilter(newFilter);
const param = new URLSearchParams(newFilter);
const response = await getAllHistoryValueTrendingPivot(param);
const param = new URLSearchParams(newFilter);
const response = await getAllHistoryValueTrendingPivot(param);
if (response?.data?.length > 0) {
// 🔹 Bersihkan dan format data agar aman untuk Nivo
const cleanedData = response.data.map((serie) => ({
id: serie.id ?? 'Unknown',
data: Array.isArray(serie.data)
? serie.data.map((d) => ({
x: d?.x ?? null,
y:
d?.y !== null && d?.y !== undefined
? Number(d.y).toFixed(4) // format 4 angka di belakang koma
: null,
}))
: [],
}));
setTrendingValue(cleanedData);
} else {
// 🔹 Jika tidak ada data dari API
setTrendingValue([]);
if (response?.data?.length > 0) {
transformDataForRecharts(response.data);
} else {
setTrendingValue([]);
setChartData([]);
setMetrics([]);
}
} catch (error) {
console.error('Error fetching trending data:', error);
} finally {
setIsLoading(false);
}
};
const transformDataForRecharts = (nivoData) => {
setTrendingValue(nivoData);
const metricNames = nivoData.map(serie => serie.id);
setMetrics(metricNames);
const timeMap = new Map();
nivoData.forEach(serie => {
serie.data.forEach(point => {
if (!timeMap.has(point.x)) {
timeMap.set(point.x, { time: point.x });
}
const entry = timeMap.get(point.x);
entry[serie.id] = point.y !== null && point.y !== undefined
? parseFloat(point.y)
: null;
});
});
const transformedData = Array.from(timeMap.values()).sort((a, b) =>
new Date(a.time) - new Date(b.time)
);
setChartData(transformedData);
};
const handleReset = () => {
setPlantSubSection(0);
setStartDate(dateNow);
setEndDate(dateNow);
setPeriode(5);
setPeriode(60);
setChartData([]);
setMetrics([]);
};
const getPlantSubSection = async () => {
@@ -88,12 +130,171 @@ const ReportTrending = memo(function ReportTrending(props) {
}
};
// Fungsi untuk menentukan apakah rentang tanggal lebih dari 1 hari
const isMultipleDays = () => {
return !startDate.isSame(endDate, 'day');
};
// Format sumbu X yang otomatis menyesuaikan
const formatXAxis = (tickItem) => {
const date = new Date(tickItem);
const hours = date.getHours().toString().padStart(2, '0');
const minutes = date.getMinutes().toString().padStart(2, '0');
// Jika rentang lebih dari 1 hari, tampilkan tanggal + waktu
if (isMultipleDays()) {
const day = date.getDate().toString().padStart(2, '0');
const month = (date.getMonth() + 1).toString().padStart(2, '0');
return `${day}/${month} ${hours}:${minutes}`;
}
// Jika hanya 1 hari, tampilkan waktu saja
return `${hours}:${minutes}`;
};
const CustomTooltip = ({ active, payload, label }) => {
if (active && payload && payload.length) {
return (
<div style={{
backgroundColor: 'rgba(255, 255, 255, 0.98)',
padding: '12px',
border: '1px solid #ccc',
borderRadius: '8px',
boxShadow: '0 2px 8px rgba(0,0,0,0.15)'
}}>
<p style={{ margin: 0, fontWeight: 'bold', marginBottom: '8px' }}>
{new Date(label).toLocaleString('id-ID')}
</p>
{payload.map((entry, index) => (
<p key={index} style={{
margin: '4px 0',
color: entry.color,
fontSize: '13px'
}}>
<strong>{entry.name}:</strong> {Number(entry.value).toFixed(4)}
</p>
))}
</div>
);
}
return null;
};
const renderChart = () => {
if (!chartData || chartData.length === 0) {
return (
<div style={{
textAlign: 'center',
marginTop: '100px',
color: '#999',
fontSize: '16px'
}}>
Tidak ada data untuk ditampilkan
</div>
);
}
return (
<ResponsiveContainer width="100%" height={500}>
<LineChart
data={chartData}
margin={{ top: 20, right: 200, left: 80, bottom: 40 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="#e0e0e0" />
<XAxis
dataKey="time"
angle={-45}
textAnchor="end"
height={100}
tick={{ fontSize: 11 }}
tickFormatter={formatXAxis}
label={{
value: 'Waktu',
position: 'bottom',
offset: -50,
style: { fontSize: 14, fontWeight: 'bold' }
}}
/>
<YAxis
tick={{ fontSize: 11 }}
label={{
value: 'Nilai',
angle: -90,
position: 'right',
offset: -70,
dy: 0,
style: {
fontSize: 12,
fontWeight: 'bold',
fill: '#059669',
textAnchor: 'middle'
}
}}
tickFormatter={(value) => Number(value).toFixed(2)}
/>
<Tooltip content={<CustomTooltip />} />
<Legend
layout="vertical"
align="right"
verticalAlign="middle"
wrapperStyle={{
position: 'absolute',
right: 150,
top: '35%',
transform: 'translateY(-50%)'
}}
/>
{metrics.map((metric, index) => {
const color = colorPalette[index % colorPalette.length];
return (
<Line
key={metric}
type="monotone"
dataKey={metric}
stroke={color}
strokeWidth={2}
dot={chartData.length < 50}
name={metric}
connectNulls={true}
/>
);
})}
</LineChart>
</ResponsiveContainer>
);
};
useEffect(() => {
getPlantSubSection();
}, []);
return (
<React.Fragment>
{/* Loading Modal */}
<Modal
open={isLoading}
footer={null}
closable={false}
centered
width={400}
bodyStyle={{
textAlign: 'center',
padding: '40px 20px'
}}
>
<Spin
indicator={<LoadingOutlined style={{ fontSize: 48, color: '#1890ff' }} spin />}
/>
<div style={{ marginTop: '24px' }}>
<Typography.Title level={4} style={{ marginBottom: '8px' }}>
Please Wait
</Typography.Title>
<Typography.Text type="secondary">
System is generating trending data...
</Typography.Text>
</div>
</Modal>
<Card>
<Row>
<Col xs={24}>
@@ -162,10 +363,11 @@ const ReportTrending = memo(function ReportTrending(props) {
{ value: 60, label: '1 Hour' },
{ value: 120, label: '2 Hour' },
]}
></Select>
/>
</div>
</Col>
</Row>
<Row gutter={8} style={{ marginTop: '16px' }}>
<Col>
<Button
@@ -187,108 +389,9 @@ const ReportTrending = memo(function ReportTrending(props) {
</Col>
</Row>
</Col>
<Col xs={24} sm={24} md={24} lg={24} xl={24} style={{ marginTop: '16px' }}>
<div style={{ height: '500px', marginTop: '16px' }}>
{trendingValue && trendingValue.length > 0 ? (
<ResponsiveLine
data={trendingValue} // [{ id, data: [{x, y}] }]
// data={
// trendingValue && trendingValue.length
// ? trendingValue
// : [{ id, data: [{ x, y }] }]
// }
margin={{ top: 40, right: 100, bottom: 70, left: 70 }}
xScale={{
type: 'time',
format: '%Y-%m-%d %H:%M',
useUTC: false,
precision: 'minute',
}}
xFormat="time:%Y-%m-%d %H:%M"
yScale={{
type: 'linear',
min: 'auto',
max: 'auto',
stacked: false,
reverse: false,
}}
yFormat={(value) => Number(value).toFixed(4)} // ✅ format 4 angka di belakang koma
axisBottom={{
format: '%Y-%m-%d %H:%M', // ✅ tampilkan tanggal + jam
tickValues: 'every 2 hours', // tampilkan setiap 2 jam (bisa ubah ke every 30 minutes)
tickSize: 5,
tickPadding: 5,
tickRotation: -45,
legend: 'Tanggal & Waktu',
legendOffset: 60,
legendPosition: 'middle',
}}
axisLeft={{
tickSize: 5,
tickPadding: 5,
tickRotation: 0,
legend: 'Nilai (Avg)',
legendOffset: -60,
legendPosition: 'middle',
format: (value) => Number(value).toFixed(4), // ✅ tampilkan 4 angka di sumbu Y
}}
curve="monotoneX"
colors={{ scheme: 'category10' }}
pointSize={6}
pointColor={{ theme: 'background' }}
pointBorderWidth={2}
pointBorderColor={{ from: 'serieColor' }}
enablePointLabel={false}
enableGridX={true}
enableGridY={true}
useMesh={true}
tooltip={({ point }) => (
<div
style={{
background: 'white',
padding: '6px 9px',
border: '1px solid #ccc',
borderRadius: '6px',
}}
>
<strong>{point.serieId}</strong>
<br />
{point.data.xFormatted}
<br />
<span style={{ color: point.serieColor }}>
{Number(point.data.y).toFixed(4)}
</span>
</div>
)}
legends={[
{
anchor: 'bottom-right',
direction: 'column',
justify: false,
translateX: 100,
translateY: 0,
itemsSpacing: 2,
itemDirection: 'left-to-right',
itemWidth: 120,
itemHeight: 20,
itemOpacity: 0.85,
symbolSize: 12,
symbolShape: 'circle',
},
]}
/>
) : (
<div
style={{
textAlign: 'center',
marginTop: '40px',
color: '#999',
}}
>
Tidak ada data untuk ditampilkan
</div>
)}
</div>
<Col xs={24} sm={24} md={24} lg={24} xl={24} style={{ marginTop: '24px' }}>
{renderChart()}
</Col>
</Row>
</Card>
@@ -296,4 +399,4 @@ const ReportTrending = memo(function ReportTrending(props) {
);
});
export default ReportTrending;
export default ReportTrending;

View File

@@ -229,7 +229,7 @@ const ListRole = memo(function ListRole(props) {
onClick={() => showAddModal()}
size="large"
>
Add Data
Add Role
</Button>
</ConfigProvider>
</Space>

View File

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

View File

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

View File

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

View File

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