Merge pull request 'lavoce' (#27) from lavoce into main

Reviewed-on: #27
This commit is contained in:
2025-12-22 09:28:34 +00:00
48 changed files with 7872 additions and 4216 deletions

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 309 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

View File

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

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

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

View File

@@ -47,4 +47,63 @@ const deleteBrand = async (id) => {
return response.data; 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
};

View File

@@ -18,4 +18,38 @@ const getNotificationById = async (id) => {
return response.data; return response.data;
}; };
export { getAllNotification, getNotificationById }; 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;
};
export {
getAllNotification,
getNotificationById,
getNotificationDetail,
createNotificationLog,
getNotificationLogByNotificationId
};

View File

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

View File

@@ -1,5 +1,5 @@
import React, { memo, useState, useEffect } from 'react'; import React, { memo, useState, useEffect } from 'react';
import { Button, Row, Col, Input, Tabs, Space, ConfigProvider, Card, Tag } from 'antd'; import { Button, Row, Col, Input, Tabs, Space, ConfigProvider, Card, Tag, Switch } from 'antd';
import { import {
PlusOutlined, PlusOutlined,
EditOutlined, EditOutlined,
@@ -10,9 +10,43 @@ import {
} from '@ant-design/icons'; } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { NotifAlert, NotifConfirmDialog } from '../../../components/Global/ToastNotif'; import { NotifAlert, NotifConfirmDialog } from '../../../components/Global/ToastNotif';
import { getAllContact, deleteContact } from '../../../api/contact'; import { getAllContact, deleteContact, updateContact } from '../../../api/contact';
const ContactCard = memo(function ContactCard({
contact,
showEditModal,
showDeleteModal,
onStatusToggle,
}) {
const handleStatusToggle = async (checked) => {
try {
const updatedContact = {
contact_name: contact.contact_name || contact.name,
contact_phone: contact.contact_phone || contact.phone,
is_active: checked,
contact_type: contact.contact_type,
};
await updateContact(contact.contact_id || contact.id, updatedContact);
NotifAlert({
icon: 'success',
title: 'Berhasil',
message: `Status "${contact.contact_name || contact.name}" berhasil diperbarui.`,
});
// Refresh contacts list
onStatusToggle && onStatusToggle();
} catch (error) {
console.error('Error updating contact status:', error);
NotifAlert({
icon: 'error',
title: 'Error',
message: 'Gagal memperbarui status kontak',
});
}
};
const ContactCard = memo(function ContactCard({ contact, showEditModal, showDeleteModal }) {
return ( return (
<Col xs={24} sm={12} md={8} lg={6}> <Col xs={24} sm={12} md={8} lg={6}>
<div <div
@@ -44,7 +78,7 @@ const ContactCard = memo(function ContactCard({ contact, showEditModal, showDele
}} }}
> >
{/* Type Badge - Top Left */} {/* Type Badge - Top Left */}
<div style={{ position: 'absolute', top: 0, left: 0, zIndex: 1 }}> {/* <div style={{ position: 'absolute', top: 0, left: 0, zIndex: 1 }}>
<Tag <Tag
color={ color={
contact.contact_type === 'operator' contact.contact_type === 'operator'
@@ -57,19 +91,37 @@ const ContactCard = memo(function ContactCard({ contact, showEditModal, showDele
> >
{contact.contact_type === 'operator' ? 'Operator' : contact.contact_type === 'gudang' ? 'Gudang' : 'Unknown'} {contact.contact_type === 'operator' ? 'Operator' : contact.contact_type === 'gudang' ? 'Gudang' : 'Unknown'}
</Tag> </Tag>
</div> </div> */}
{/* Status Badge - Top Right */} {/* Status Slider - Top Right */}
<div style={{ position: 'absolute', top: 0, right: 0, zIndex: 1 }}> <div
{contact.status === 'active' ? ( style={{
<Tag color={'green'} style={{ fontSize: '11px' }}> position: 'absolute',
Active top: 0,
</Tag> right: 0,
) : ( zIndex: 1,
<Tag color={'red'} style={{ fontSize: '11px' }}> padding: '4px 8px',
InActive }}
</Tag> >
)} <div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<Switch
checked={contact.status === 'active'}
onChange={handleStatusToggle}
style={{
backgroundColor:
contact.status === 'active' ? '#52c41a' : '#d9d9d9',
}}
/>
<span
style={{
fontSize: '12px',
color: contact.status === 'active' ? '#52c41a' : '#ff4d4f',
fontWeight: 500,
}}
>
{contact.status === 'active' ? 'Active' : 'Inactive'}
</span>
</div>
</div> </div>
{/* Main Content */} {/* Main Content */}
@@ -316,7 +368,7 @@ const ListContact = memo(function ListContact(props) {
<Row justify="space-between" align="middle" gutter={[8, 8]}> <Row justify="space-between" align="middle" gutter={[8, 8]}>
<Col xs={24} sm={24} md={12} lg={12}> <Col xs={24} sm={24} md={12} lg={12}>
<Input.Search <Input.Search
placeholder="Search by name or type..." placeholder="Search by name..."
value={formDataFilter.criteria} value={formDataFilter.criteria}
onChange={(e) => { onChange={(e) => {
const value = e.target.value; const value = e.target.value;
@@ -382,7 +434,8 @@ const ListContact = memo(function ListContact(props) {
marginBottom: '16px', marginBottom: '16px',
}} }}
> >
<Tabs {/* Tabs */}
{/* <Tabs
activeKey={activeTab} activeKey={activeTab}
onChange={setActiveTab} onChange={setActiveTab}
size="large" size="large"
@@ -400,7 +453,7 @@ const ListContact = memo(function ListContact(props) {
label: 'Gudang', label: 'Gudang',
}, },
]} ]}
/> /> */}
</div> </div>
{getFilteredContacts().length === 0 ? ( {getFilteredContacts().length === 0 ? (
@@ -423,6 +476,7 @@ const ListContact = memo(function ListContact(props) {
}} }}
showEditModal={showEditModal} showEditModal={showEditModal}
showDeleteModal={showDeleteModal} showDeleteModal={showDeleteModal}
onStatusToggle={fetchContacts}
/> />
))} ))}
</Row> </Row>

View File

@@ -1,357 +0,0 @@
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import {
Layout,
Card,
Row,
Col,
Typography,
Space,
Button,
Spin,
Result,
Input,
} from 'antd';
import {
ArrowLeftOutlined,
CloseCircleFilled,
WarningFilled,
CheckCircleFilled,
InfoCircleFilled,
CloseOutlined,
BookOutlined,
ToolOutlined,
HistoryOutlined,
FilePdfOutlined,
PlusOutlined,
UserOutlined,
} from '@ant-design/icons';
// Path disesuaikan karena lokasi file berubah
// import { getNotificationById } from '../../api/notification'; // Dihapus karena belum ada di file API
import UserHistoryModal from '../notification/component/UserHistoryModal';
import LogHistoryModal from '../notification/component/LogHistoryModal';
const { Content } = Layout;
const { Text, Paragraph, Link } = Typography;
// Menggunakan kembali fungsi transform dari ListNotification untuk konsistensi data
const transformNotificationData = (item) => ({
id: `notification-${item.notification_error_id || 'dummy'}-0`,
type: item.is_read ? 'resolved' : item.is_delivered ? 'warning' : 'critical',
title: item.device_name || 'Unknown Device',
issue: item.error_code_name || 'Unknown Error',
description: `${item.error_code} - ${item.error_code_name}`,
timestamp: new Date(item.created_at || Date.now()).toLocaleString('id-ID'),
location: item.device_location || 'Location not specified',
details: item.message_error_issue || 'No details available',
link: '#',
subsection: item.solution_name || 'N/A',
isRead: item.is_read || false,
status: item.is_read ? 'Resolved' : item.is_delivered ? 'Delivered' : 'Pending',
tag: item.error_code,
plc: item.plc || 'N/A',
});
const getDummyNotificationById = (id) => {
console.log("Fetching dummy data for ID:", id);
// Data mentah dummy, seolah-olah dari API
const rawDummyData = { device_name: 'Compressor C-101', error_code_name: 'High Temperature', error_code: 'TEMP-H-303', device_location: 'Gudang Produksi A', message_error_issue: 'Suhu kompresor terdeteksi melebihi ambang batas aman.', is_delivered: true, plc: 'PLC-UTL-01' };
// Mengolah data mentah dummy menggunakan transform function
return transformNotificationData(rawDummyData);
};
const getIconAndColor = (type) => {
switch (type) {
case 'critical':
return { IconComponent: CloseCircleFilled, color: '#ff4d4f', bgColor: '#fff1f0' };
case 'warning':
return { IconComponent: WarningFilled, color: '#faad14', bgColor: '#fffbe6' };
case 'resolved':
return { IconComponent: CheckCircleFilled, color: '#52c41a', bgColor: '#f6ffed' };
default:
return { IconComponent: InfoCircleFilled, color: '#1890ff', bgColor: '#e6f7ff' };
}
};
const DetailNotificationTab = () => {
const { notificationId } = useParams(); // Mungkin perlu disesuaikan jika route berbeda
const navigate = useNavigate();
const [notification, setNotification] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [modalContent, setModalContent] = useState(null); // 'user', 'log', atau null
const [isAddingLog, setIsAddingLog] = useState(false);
const logHistoryData = [
{
id: 1,
timestamp: '04-11-2025 11:55 WIB',
addedBy: {
name: 'Budi Santoso',
phone: '081122334455',
},
description: 'Suhu sudah coba diturunkan, namun masih belum mencapai treshold aman.',
},
{
id: 2,
timestamp: '04-11-2025 11:45 WIB',
addedBy: {
name: 'John Doe',
phone: '081234567890',
},
description: 'Suhu sudah coba diturunkan, namun masih belum mencapai treshold aman.',
},
{
id: 3,
timestamp: '04-11-2025 11:40 WIB',
addedBy: {
name: 'Jane Smith',
phone: '087654321098',
},
description: 'Suhu sudah coba diturunkan, namun masih belum mencapai treshold aman.',
},
];
useEffect(() => {
const fetchDetail = async () => {
try {
setLoading(true);
// Ganti dengan fungsi API asli Anda
// const response = await getNotificationById(notificationId);
// setNotification(response.data);
// Menggunakan data dummy untuk sekarang
const dummyData = getDummyNotificationById(notificationId);
if (dummyData) {
setNotification(dummyData);
} else {
throw new Error('Notification not found');
}
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
fetchDetail();
}, [notificationId]);
if (loading) {
return (
<Layout style={{ minHeight: '100vh', display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
<Spin size="large" />
</Layout>
);
}
if (error || !notification) {
return (
<Layout style={{ minHeight: '100vh', display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
<Result
status="404"
title="404"
subTitle="Sorry, the notification you visited does not exist."
extra={<Button type="primary" onClick={() => navigate('/notification')}>Back to List</Button>}
/>
</Layout>
);
}
const { color } = getIconAndColor(notification.type);
return (
<Layout style={{ padding: '24px', backgroundColor: '#f0f2f5' }}>
<Content>
<Card>
<div style={{ borderBottom: '1px solid #f0f0f0', paddingBottom: '16px', marginBottom: '24px' }}>
<Row justify="space-between" align="middle">
<Col>
<Button
type="text"
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/notification')}
style={{ paddingLeft: 0 }}
>
Back to notification list
</Button>
</Col>
<Col>
<Button
icon={<UserOutlined />}
onClick={() => setModalContent('user')}
>
User History
</Button>
</Col>
</Row>
<div style={{ backgroundColor: '#f6ffed', border: '1px solid #b7eb8f', borderRadius: '4px', padding: '8px 16px', textAlign: 'center', marginTop: '16px' }}>
<Typography.Title level={4} style={{ margin: 0, color: '#262626' }}>
Error Notification Detail
</Typography.Title>
</div>
</div>
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<Row gutter={[24, 24]}>
{/* Kolom Kiri: Data Kompresor */}
<Col xs={24} lg={12}>
<Card size="small" style={{ height: '100%', borderColor: '#d4380d' }} bodyStyle={{ padding: '16px' }}>
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<Row gutter={16} align="middle">
<Col>
<div style={{ width: '32px', height: '32px', borderRadius: '50%', backgroundColor: '#d4380d', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#ffffff', fontSize: '18px' }}><CloseOutlined /></div>
</Col>
<Col>
<Text>{notification.title}</Text>
<div style={{ marginTop: '2px' }}><Text strong style={{ fontSize: '16px' }}>{notification.issue}</Text></div>
</Col>
</Row>
<div>
<Text strong>Plant Subsection</Text>
<div>{notification.subsection}</div>
<Text strong style={{ display: 'block', marginTop: '8px' }}>Time</Text>
<div>{notification.timestamp}</div>
</div>
<div style={{ border: '1px solid #d4380d', borderRadius: '4px', padding: '8px', background: 'linear-gradient(to right, #ffe7e6, #ffffff)' }}>
<Row justify="space-around" align="middle">
<Col><Text style={{ fontSize: '12px', color: color }}>Value</Text><div style={{ fontWeight: 'bold', fontSize: '16px', color: color }}>N/A</div></Col>
<Col><Text type="secondary" style={{ fontSize: '12px' }}>Treshold</Text><div style={{ fontWeight: 500 }}>N/A</div></Col>
</Row>
</div>
</Space>
</Card>
</Col>
{/* Kolom Kanan: Informasi Teknis */}
<Col xs={24} lg={12}>
<Card title="Informasi Teknis" size="small" style={{ height: '100%' }}>
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<div><Text strong>PLC</Text><div>{notification.plc || 'N/A'}</div></div>
<div><Text strong>Status</Text><div style={{ color: '#faad14', fontWeight: 500 }}>{notification.status}</div></div>
<div><Text strong>Tag</Text><div style={{ fontFamily: 'monospace', backgroundColor: '#f0f0f0', padding: '2px 6px', borderRadius: '4px', display: 'inline-block' }}>{notification.tag}</div></div>
</Space>
</Card>
</Col>
</Row>
<Row gutter={[16, 16]}>
<Col xs={24} md={8}><Card hoverable bodyStyle={{ padding: '12px', textAlign: 'center' }}><Space><BookOutlined style={{ fontSize: '16px', color: '#1890ff' }} /><Text strong style={{ fontSize: '16px', color: '#262626' }}>Handling Guideline</Text></Space></Card></Col>
<Col xs={24} md={8}><Card hoverable bodyStyle={{ padding: '12px', textAlign: 'center' }}><Space><ToolOutlined style={{ fontSize: '16px', color: '#1890ff' }} /><Text strong style={{ fontSize: '16px', color: '#262626' }}>Spare Part</Text></Space></Card></Col>
<Col xs={24} md={8} onClick={() => setModalContent('log')} style={{ cursor: 'pointer' }}><Card hoverable bodyStyle={{ padding: '12px', textAlign: 'center' }}><Space><HistoryOutlined style={{ fontSize: '16px', color: '#1890ff' }} /><Text strong style={{ fontSize: '16px', color: '#262626' }}>Log Activity</Text></Space></Card></Col>
</Row>
<Row gutter={[16, 16]}>
<Col xs={24} md={8}>
<Card size="small" title="Guideline Documents" style={{ height: '100%' }}>
<Space direction="vertical" size="small" style={{ width: '100%' }}>
<Card size="small" hoverable>
<Text><FilePdfOutlined style={{ marginRight: '8px' }} /> Error 303.pdf</Text>
<Link href="#" target="_blank" style={{ fontSize: '12px', display: 'block', marginLeft: '24px' }}>lihat disini</Link>
</Card>
<Card size="small" hoverable>
<Text><FilePdfOutlined style={{ marginRight: '8px' }} /> SOP Kompresor.pdf</Text>
<Link href="#" target="_blank" style={{ fontSize: '12px', display: 'block', marginLeft: '24px' }}>lihat disini</Link>
</Card>
</Space>
</Card>
</Col>
<Col xs={24} md={8}>
<Card size="small" title="Required Spare Parts" style={{ height: '100%' }}>
<Space direction="vertical" size="small" style={{ width: '100%' }}>
<Card size="small">
<Row gutter={16} align="top">
<Col span={7} style={{ textAlign: 'center' }}>
<div style={{ width: '100%', height: '60px', backgroundColor: '#f0f0f0', display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: '4px', marginBottom: '8px' }}>
<ToolOutlined style={{ fontSize: '24px', color: '#bfbfbf' }} />
</div>
<Text style={{ fontSize: '12px', color: '#52c41a', fontWeight: 500 }}>Available</Text>
</Col>
<Col span={17}>
<Text strong>Air Filter</Text>
<Paragraph style={{ fontSize: '12px', margin: 0, color: '#595959' }}>Filters incoming air to remove dust.</Paragraph>
</Col>
</Row>
</Card>
</Space>
</Card>
</Col>
<Col span={8}>
<Card size="small" style={{ height: '100%' }}>
<Space direction="vertical" size="small" style={{ width: '100%' }}>
<Card
size="small"
bodyStyle={{
padding: '8px 12px',
backgroundColor: isAddingLog ? '#fafafa' : '#fff',
}}
>
<Space
direction="vertical"
style={{ width: '100%' }}
size="small"
>
{isAddingLog && (
<>
<Text strong style={{ fontSize: '12px' }}>
Add New Log / Update Progress
</Text>
<Input.TextArea
rows={2}
placeholder="Tuliskan update penanganan di sini..."
/>
</>
)}
<Button
type={isAddingLog ? 'primary' : 'dashed'}
size="small"
block
icon={!isAddingLog && <PlusOutlined />}
onClick={() => setIsAddingLog(!isAddingLog)}
>
{isAddingLog ? 'Submit Log' : 'Add Log'}
</Button>
</Space>
</Card>
{logHistoryData.map((log) => (
<Card
key={log.id}
size="small"
bodyStyle={{ padding: '8px 12px' }}
>
<Paragraph
style={{ fontSize: '12px', margin: 0 }}
ellipsis={{ rows: 2 }}
>
<Text strong>{log.addedBy.name}:</Text>{' '}
{log.description}
</Paragraph>
<Text type="secondary" style={{ fontSize: '11px' }}>
{log.timestamp}
</Text>
</Card>
))}
</Space>
</Card>
</Col>
</Row>
</Space>
</Card>
</Content>
<UserHistoryModal
visible={modalContent === 'user'}
onCancel={() => setModalContent(null)}
notificationData={notification}
/>
<LogHistoryModal
visible={modalContent === 'log'}
onCancel={() => setModalContent(null)}
notificationData={notification}
/>
</Layout>
);
};
export default DetailNotificationTab;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

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

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

View File

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

View File

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

View File

@@ -1,18 +1,45 @@
import { useState } from 'react'; import React, { useState } from 'react';
import { Upload, Modal } from 'antd'; import { Upload, Modal, Button, Typography, Space, Image } from 'antd';
import { UploadOutlined } from '@ant-design/icons'; import { UploadOutlined, EyeOutlined, DeleteOutlined, FileOutlined } from '@ant-design/icons';
import { NotifOk, NotifAlert } from '../../../../components/Global/ToastNotif'; 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 = ({ const FileUploadHandler = ({
solutionFields, type = 'solution',
fileList, maxCount = 1,
accept = '.pdf,.jpg,.jpeg,.png,.gif',
disabled = false,
fileList = [],
onFileUpload, onFileUpload,
onFileRemove onFileRemove,
existingFile = null,
clearSignal = null,
debugProps = {},
uploadText = 'Click or drag file to this area to upload',
uploadHint = 'Support for PDF and image files only',
buttonText = 'Upload File',
buttonType = 'default',
containerStyle = {},
buttonStyle = {},
showPreview = true
}) => { }) => {
const [previewOpen, setPreviewOpen] = useState(false); const [previewOpen, setPreviewOpen] = useState(false);
const [previewImage, setPreviewImage] = useState(''); const [previewImage, setPreviewImage] = useState('');
const [previewTitle, setPreviewTitle] = useState(''); const [previewTitle, setPreviewTitle] = useState('');
const [isUploading, setIsUploading] = useState(false);
const [uploadedFile, setUploadedFile] = useState(null);
React.useEffect(() => {
if (clearSignal !== null && clearSignal > 0) {
setUploadedFile(null);
}
}, [clearSignal, debugProps]);
const getBase64 = (file) => const getBase64 = (file) =>
new Promise((resolve, reject) => { new Promise((resolve, reject) => {
@@ -22,99 +49,372 @@ const FileUploadHandler = ({
reader.onerror = (error) => reject(error); reader.onerror = (error) => reject(error);
}); });
const handleUploadPreview = async (file) => { const handlePreview = async (file) => {
const preview = await getBase64(file); if (!file.url && !file.preview) {
setPreviewImage(preview); file.preview = await getBase64(file.originFileObj);
setPreviewTitle(file.name || file.url.substring(file.url.lastIndexOf('/') + 1)); }
setPreviewImage(file.url || file.preview);
setPreviewOpen(true); setPreviewOpen(true);
setPreviewTitle(file.name || file.url.substring(file.url.lastIndexOf('/') + 1));
}; };
const handleFileUpload = async (file) => { const validateFile = (file) => {
const isAllowedType = ['application/pdf', 'image/jpeg', 'image/png', 'image/gif'].includes(file.type); const isAllowedType = [
'application/pdf',
'image/jpeg',
'image/png',
'image/gif',
].includes(file.type);
if (!isAllowedType) { if (!isAllowedType) {
NotifAlert({ NotifAlert({
icon: 'error', icon: 'error',
title: 'Error', title: 'Error',
message: `${file.name} bukan file PDF atau gambar yang diizinkan.` message: `${file.name} bukan file PDF atau gambar yang diizinkan.`,
}); });
return Upload.LIST_IGNORE; return false;
}
return true;
};
const handleFileUpload = async (file) => {
if (isUploading) {
return false;
}
if (!validateFile(file)) {
return false;
} }
try { try {
setIsUploading(true);
const fileExtension = file.name.split('.').pop().toLowerCase(); const fileExtension = file.name.split('.').pop().toLowerCase();
const isImage = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'].includes(fileExtension); const isImage = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'].includes(fileExtension);
const fileType = isImage ? 'image' : 'pdf'; const fileType = isImage ? 'image' : 'pdf';
const folder = getFolderFromFileType(fileType); const folder = getFolderFromFileType(fileType);
const uploadResponse = await uploadFile(file, folder); const uploadResponse = await uploadFile(file, folder);
const actualPath = uploadResponse.data?.path_solution || '';
const isSuccess = uploadResponse && (
uploadResponse.statusCode === 200 ||
uploadResponse.statusCode === 201
);
if (!isSuccess) {
NotifAlert({
icon: 'error',
title: 'Gagal',
message: uploadResponse?.message || `Gagal mengupload ${file.name}`,
});
setIsUploading(false);
return false;
}
let actualPath = '';
if (uploadResponse && typeof uploadResponse === 'object') {
if (uploadResponse.data && uploadResponse.data.path_document) {
actualPath = uploadResponse.data.path_document;
}
else if (uploadResponse.path_document) {
actualPath = uploadResponse.path_document;
}
else if (uploadResponse.data && uploadResponse.data.path_solution) {
actualPath = uploadResponse.data.path_solution;
}
else if (uploadResponse.data && typeof uploadResponse.data === 'object') {
if (uploadResponse.data.file_url) {
actualPath = uploadResponse.data.file_url;
} else if (uploadResponse.data.url) {
actualPath = uploadResponse.data.url;
} else if (uploadResponse.data.path) {
actualPath = uploadResponse.data.path;
} else if (uploadResponse.data.location) {
actualPath = uploadResponse.data.location;
} else if (uploadResponse.data.filePath) {
actualPath = uploadResponse.data.filePath;
} else if (uploadResponse.data.file_path) {
actualPath = uploadResponse.data.file_path;
} else if (uploadResponse.data.publicUrl) {
actualPath = uploadResponse.data.publicUrl;
} else if (uploadResponse.data.public_url) {
actualPath = uploadResponse.data.public_url;
}
}
else if (uploadResponse && typeof uploadResponse === 'string') {
actualPath = uploadResponse;
}
}
if (actualPath) { if (actualPath) {
file.uploadPath = actualPath; let fileObject;
file.solution_name = file.name;
file.solutionId = solutionFields[0]; if (type === 'error_code') {
file.type_solution = fileType; fileObject = {
onFileUpload(file); 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({ NotifOk({
icon: 'success', icon: 'success',
title: 'Berhasil', title: 'Berhasil',
message: `${file.name} berhasil diupload!` message: `${file.name} berhasil diupload!`
}); });
setIsUploading(false);
return false;
} else { } else {
NotifAlert({ NotifAlert({
icon: 'error', icon: 'error',
title: 'Gagal', title: 'Gagal',
message: `Gagal mengupload ${file.name}` message: `Gagal mengupload ${file.name}. Tidak dapat menemukan path file dalam response.`,
}); });
setIsUploading(false);
return false;
} }
} catch (error) { } catch (error) {
console.error('Error uploading file:', error);
NotifAlert({ NotifAlert({
icon: 'error', icon: 'error',
title: 'Error', title: 'Error',
message: `Gagal mengupload ${file.name}. Silakan coba lagi.` message: `Gagal mengupload ${file.name}. Silakan coba lagi.`,
}); });
setIsUploading(false);
return false;
}
};
const handleFileChange = ({ fileList }) => {
if (fileList && fileList.length > 0 && fileList[0] && fileList[0].originFileObj) {
handleFileUpload(fileList[0].originFileObj);
}
};
const handleRemove = () => {
if (existingFile && onFileRemove) {
onFileRemove(existingFile);
} else if (onFileRemove) {
onFileRemove(null);
}
};
const renderExistingFile = () => {
const fileToShow = existingFile || uploadedFile;
if (!fileToShow) {
return null;
} }
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 = { const uploadProps = {
multiple: true, name: 'file',
accept: '.pdf,.jpg,.jpeg,.png,.gif', multiple: false,
onRemove: onFileRemove, accept,
beforeUpload: handleFileUpload, disabled: disabled || isUploading,
fileList, fileList: [],
onPreview: handleUploadPreview, beforeUpload: () => false,
onChange: handleFileChange,
onPreview: handlePreview,
maxCount,
}; };
return ( return (
<> <div style={{ ...containerStyle }}>
<Upload.Dragger {...uploadProps}> {!existingFile && (
<p className="ant-upload-drag-icon"> <Upload {...uploadProps}>
<UploadOutlined /> {type === 'drag' ? (
</p> <Upload.Dragger>
<p className="ant-upload-text">Click or drag file to this area to upload</p> <p className="ant-upload-drag-icon">
<p className="ant-upload-hint">Support for PDF and image files only</p> <UploadOutlined />
</Upload.Dragger> </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} {showPreview && (
footer={null} <Modal
onCancel={() => setPreviewOpen(false)} open={previewOpen}
width="80%" title={previewTitle}
style={{ top: 20 }} footer={null}
> onCancel={() => setPreviewOpen(false)}
{previewImage && ( width={600}
<img style={{ top: 100 }}
alt={previewTitle} >
style={{ width: '100%' }} {previewImage && (
src={previewImage} <img
/> alt={previewTitle}
)} style={{ width: '100%' }}
</Modal> 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', key: 'brand_name',
width: '20%', width: '20%',
}, },
{
title: 'Type',
dataIndex: 'brand_type',
key: 'brand_type',
width: '15%',
render: (text) => text || '-',
},
{ {
title: 'Manufacturer', title: 'Manufacturer',
dataIndex: 'brand_manufacture', dataIndex: 'brand_manufacture',
key: 'brand_manufacture', key: 'brand_manufacture',
width: '20%', width: '20%',
}, },
{
title: 'Model',
dataIndex: 'brand_model',
key: 'brand_model',
width: '15%',
render: (text) => text || '-',
},
{ {
title: 'Status', title: 'Status',
dataIndex: 'is_active', dataIndex: 'is_active',
@@ -105,9 +91,9 @@ const columns = (showPreviewModal, showEditModal, showDeleteDialog) => [
const ListBrandDevice = memo(function ListBrandDevice(props) { const ListBrandDevice = memo(function ListBrandDevice(props) {
const [trigerFilter, setTrigerFilter] = useState(false); const [trigerFilter, setTrigerFilter] = useState(false);
const defaultFilter = { search: '' }; const defaultFilter = { criteria: '' };
const [formDataFilter, setFormDataFilter] = useState(defaultFilter); const [formDataFilter, setFormDataFilter] = useState(defaultFilter);
const [searchValue, setSearchValue] = useState(''); const [searchText, setSearchText] = useState('');
const navigate = useNavigate(); const navigate = useNavigate();
@@ -128,23 +114,21 @@ const ListBrandDevice = memo(function ListBrandDevice(props) {
}; };
const handleSearch = () => { const handleSearch = () => {
setFormDataFilter({ search: searchValue }); setFormDataFilter({ criteria: searchText });
setTrigerFilter((prev) => !prev); setTrigerFilter((prev) => !prev);
}; };
const handleSearchClear = () => { const handleSearchClear = () => {
setSearchValue(''); setSearchText('');
setFormDataFilter({ search: '' }); setFormDataFilter({ criteria: '' });
setTrigerFilter((prev) => !prev); setTrigerFilter((prev) => !prev);
}; };
const showPreviewModal = (param) => { const showPreviewModal = (param) => {
// Direct navigation without loading, page will handle its own loading
navigate(`/master/brand-device/view/${param.brand_id}`); navigate(`/master/brand-device/view/${param.brand_id}`);
}; };
const showEditModal = (param = null) => { const showEditModal = (param = null) => {
// Direct navigation without loading, page will handle its own loading
if (param) { if (param) {
navigate(`/master/brand-device/edit/${param.brand_id}`); navigate(`/master/brand-device/edit/${param.brand_id}`);
} else { } else {
@@ -158,7 +142,7 @@ const ListBrandDevice = memo(function ListBrandDevice(props) {
title: 'Konfirmasi', title: 'Konfirmasi',
message: 'Apakah anda yakin hapus data "' + param.brand_name + '" ?', message: 'Apakah anda yakin hapus data "' + param.brand_name + '" ?',
onConfirm: () => handleDelete(param.brand_id, param.brand_name), onConfirm: () => handleDelete(param.brand_id, param.brand_name),
onCancel: () => {}, onCancel: () => { },
}); });
}; };
@@ -172,7 +156,7 @@ const ListBrandDevice = memo(function ListBrandDevice(props) {
title: 'Berhasil', title: 'Berhasil',
message: `Brand ${brand_name} deleted successfully.`, message: `Brand ${brand_name} deleted successfully.`,
}); });
doFilter(); // Refresh data doFilter();
} else { } else {
NotifAlert({ NotifAlert({
icon: 'error', icon: 'error',
@@ -181,7 +165,6 @@ const ListBrandDevice = memo(function ListBrandDevice(props) {
}); });
} }
} catch (error) { } catch (error) {
console.error('Delete Brand Device Error:', error);
NotifAlert({ NotifAlert({
icon: 'error', icon: 'error',
title: 'Error', title: 'Error',
@@ -199,13 +182,12 @@ const ListBrandDevice = memo(function ListBrandDevice(props) {
<Col xs={24} sm={24} md={12} lg={12}> <Col xs={24} sm={24} md={12} lg={12}>
<Input.Search <Input.Search
placeholder="Search brand device..." placeholder="Search brand device..."
value={searchValue} value={searchText}
onChange={(e) => { onChange={(e) => {
const value = e.target.value; const value = e.target.value;
setSearchValue(value); setSearchText(value);
// Auto search when clearing by backspace/delete
if (value === '') { if (value === '') {
setFormDataFilter({ search: '' }); setFormDataFilter({ criteria: '' });
setTrigerFilter((prev) => !prev); setTrigerFilter((prev) => !prev);
} }
}} }}
@@ -251,7 +233,7 @@ const ListBrandDevice = memo(function ListBrandDevice(props) {
}} }}
size="large" size="large"
> >
Add Brand Device Add data
</Button> </Button>
</ConfigProvider> </ConfigProvider>
</Space> </Space>

View File

@@ -1,84 +1,316 @@
import React from 'react'; import React, { useState, useEffect, useMemo } from 'react';
import { Table, Button, Space } from 'antd'; import { Card, Input, Button, Row, Col, Empty } from 'antd';
import { EyeOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons'; 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 = ({ const ListErrorCode = ({
errorCodes, brandId,
loading, selectedErrorCode,
onPreview, onErrorCodeSelect,
onEdit, onAddNew,
onDelete, tempErrorCodes = [],
onFileView trigerFilter,
searchText,
onSearchChange,
onSearch,
onSearchClear,
isReadOnly = false,
errorCodes: propErrorCodes = null
}) => { }) => {
const errorCodeColumns = [ const [errorCodes, setErrorCodes] = useState([]);
{ title: 'Error Code', dataIndex: 'error_code', key: 'error_code' }, const [loading, setLoading] = useState(false);
{ title: 'Error Code Name', dataIndex: 'error_code_name', key: 'error_code_name' }, const [pagination, setPagination] = useState({
{ current_page: 1,
title: 'Solutions', current_limit: 15,
dataIndex: 'solution', total_limit: 0,
key: 'solution', total_page: 0,
render: (solutions) => ( });
<div> const [currentPage, setCurrentPage] = useState(1);
{solutions && solutions.length > 0 ? ( const pageSize = 15;
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 dataSource = loading const queryParams = useMemo(() => {
? Array.from({ length: 3 }, (_, index) => ({ const params = new URLSearchParams();
key: `loading-${index}`, params.set('page', currentPage.toString());
error_code: 'Loading...', params.set('limit', pageSize.toString());
error_code_name: 'Loading...', if (searchText) {
solution: [] params.set('criteria', searchText);
})) }
: errorCodes; 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 ( return (
<Table <Card
columns={errorCodeColumns} title="Daftar Error Code"
dataSource={dataSource} style={{ width: '100%', minWidth: '472px' }}
rowKey="key" styles={{ body: { padding: '12px' } }}
pagination={false} >
/> <Input.Search
placeholder="Cari error code..."
value={searchText}
onChange={(e) => {
const value = e.target.value;
if (onSearchChange) {
onSearchChange(value);
}
}}
onSearch={handleSearch}
allowClear
enterButton={
<Button
type="primary"
icon={<SearchOutlined />}
onClick={handleSearch}
style={{
backgroundColor: '#23A55A',
borderColor: '#23A55A',
height: '32px'
}}
>
Search
</Button>
}
size="default"
style={{
marginBottom: 12,
height: '32px',
width: '100%',
maxWidth: '300px'
}}
/>
<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,8 +1,9 @@
import React, { useState, useEffect } from 'react'; import React, { useState } from 'react';
import { Form, Input, Button, Switch, Radio, Upload, Typography, Space } from 'antd'; import { Form, Input, Button, Switch, Radio, Typography, Space, Card, ConfigProvider } from 'antd';
import { DeleteOutlined, UploadOutlined, EyeOutlined } from '@ant-design/icons'; import { DeleteOutlined, EyeOutlined, FileOutlined } from '@ant-design/icons';
import { uploadFile, getFolderFromFileType } from '../../../../api/file-uploads'; import FileUploadHandler from './FileUploadHandler';
import { NotifAlert } from '../../../../components/Global/ToastNotif'; import { NotifAlert } from '../../../../components/Global/ToastNotif';
import { getFileUrl, getFolderFromFileType } from '../../../../api/file-uploads';
const { Text } = Typography; const { Text } = Typography;
const { TextArea } = Input; const { TextArea } = Input;
@@ -20,223 +21,475 @@ const SolutionFieldNew = ({
onRemove, onRemove,
onFileUpload, onFileUpload,
onFileView, onFileView,
fileList = [] fileList = [],
originalSolutionData = null
}) => { }) => {
const [currentStatus, setCurrentStatus] = useState(solutionStatus ?? true); const form = Form.useFormInstance();
const [currentFile, setCurrentFile] = useState(null);
const [isDeleted, setIsDeleted] = useState(false);
// Watch form values const fileUpload = Form.useWatch(['solution_items', fieldKey, 'fileUpload'], form);
const getFieldValue = () => { const file = Form.useWatch(['solution_items', fieldKey, 'file'], form);
try { const nameValue = Form.useWatch(['solution_items', fieldKey, 'name'], form);
const form = document.querySelector(`[data-field="${fieldName}"]`)?.form; const fileNameValue = Form.useWatch(['solution_items', fieldKey, 'fileName'], form);
if (form) { const statusValue = Form.useWatch(['solution_items', fieldKey, 'status'], form) ?? true;
const formData = new FormData(form);
return formData.get(`${fieldName}.status`) === 'on'; const pathSolution = Form.useWatch(['solution_items', fieldKey, 'path_solution'], form);
}
return currentStatus; const [deleteCounter, setDeleteCounter] = useState(0);
} catch {
return currentStatus; React.useEffect(() => {
if (!nameValue || nameValue === '') {
setCurrentFile(null);
setIsDeleted(false);
setDeleteCounter(prev => prev + 1);
} }
}; }, [nameValue]);
useEffect(() => { React.useEffect(() => {
setCurrentStatus(solutionStatus ?? true); const getFileFromFormValues = () => {
}, [solutionStatus]); const hasValidFileUpload = fileUpload && typeof fileUpload === 'object' && Object.keys(fileUpload).length > 0;
const handleFileUpload = async (file) => { const hasValidFile = file && typeof file === 'object' && Object.keys(file).length > 0;
try { const hasValidPath = pathSolution && pathSolution.trim() !== '';
const isAllowedType = [
'application/pdf',
'image/jpeg',
'image/png',
'image/gif',
].includes(file.type);
if (!isAllowedType) { const wasExplicitlyDeleted =
NotifAlert({ (fileUpload === null || file === null || pathSolution === null) &&
icon: 'error', !hasValidFileUpload &&
title: 'Error', !hasValidFile &&
message: `${file.name} bukan file PDF atau gambar yang diizinkan.`, !hasValidPath;
});
return; if (wasExplicitlyDeleted) {
return null;
} }
const fileExtension = file.name.split('.').pop().toLowerCase(); if (solutionType === 'text') {
const isImage = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'].includes(fileExtension); return null;
const fileType = isImage ? 'image' : 'pdf';
const folder = getFolderFromFileType(fileType);
const uploadResponse = await uploadFile(file, folder);
const actualPath = uploadResponse.data?.path_solution || '';
if (actualPath) {
// Store the file info with the solution field
file.uploadPath = actualPath;
file.solutionId = fieldKey;
file.type_solution = fileType;
onFileUpload(file);
NotifAlert({
icon: 'success',
title: 'Berhasil',
message: `${file.name} berhasil diupload!`,
});
} else {
NotifAlert({
icon: 'error',
title: 'Gagal',
message: `Gagal mengupload ${file.name}`,
});
} }
} catch (error) {
console.error('Error uploading file:', error); if (hasValidFileUpload) {
NotifAlert({ return fileUpload;
icon: 'error', }
title: 'Error', if (hasValidFile) {
message: `Gagal mengupload ${file.name}. Silakan coba lagi.`, 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 = () => { const renderSolutionContent = () => {
if (solutionType === 'text') { if (solutionType === 'text') {
return ( return (
<Form.Item <Form.Item
name={[fieldName, 'text']} name={['solution_items', fieldKey, 'text']}
rules={[{ required: true, message: 'Text solution wajib diisi!' }]} rules={[{ required: true, message: 'Text solution wajib diisi!' }]}
> >
<TextArea <TextArea
placeholder="Enter solution text" placeholder="Enter solution text"
rows={3} rows={3}
disabled={isReadOnly} disabled={isReadOnly}
style={{ fontSize: 12 }}
/> />
</Form.Item> </Form.Item>
); );
} }
if (solutionType === 'file') { if (solutionType === 'file') {
const currentFiles = fileList.filter(file => file.solutionId === fieldKey); const hasOriginalFile = originalSolutionData && (
originalSolutionData.path_solution ||
return ( originalSolutionData.path_document
<div>
<Form.Item
name={[fieldName, 'file']}
rules={[{ required: true, message: 'File solution wajib diupload!' }]}
>
<Upload
beforeUpload={handleFileUpload}
showUploadList={false}
accept=".pdf,.jpg,.jpeg,.png,.gif"
disabled={isReadOnly}
>
<Button
icon={<UploadOutlined />}
disabled={isReadOnly}
style={{ width: '100%' }}
>
Upload File (PDF/Image)
</Button>
</Upload>
</Form.Item>
{currentFiles.length > 0 && (
<div style={{ marginTop: 8 }}>
{currentFiles.map((file, index) => (
<div key={index} style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '4px 8px',
border: '1px solid #d9d9d9',
borderRadius: 4,
marginBottom: 4
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Text style={{ fontSize: 12 }}>{file.name}</Text>
<Text type="secondary" style={{ fontSize: 10 }}>
({(file.size / 1024).toFixed(1)} KB)
</Text>
</div>
<Button
type="text"
size="small"
icon={<EyeOutlined />}
onClick={() => onFileView(file.uploadPath, file.type_solution)}
/>
</div>
))}
</div>
)}
</div>
); );
let displayFile = null;
if (currentFile && Object.keys(currentFile).length > 0) {
displayFile = currentFile;
}
else if (hasOriginalFile && !isDeleted) {
displayFile = {
name: originalSolutionData.file_upload_name ||
(originalSolutionData.path_solution || originalSolutionData.path_document)?.split('/').pop() ||
'File',
uploadPath: originalSolutionData.path_solution || originalSolutionData.path_document,
url: originalSolutionData.path_solution || originalSolutionData.path_document,
path: originalSolutionData.path_solution || originalSolutionData.path_document,
isExisting: true
};
}
else if (fileUpload && typeof fileUpload === 'object' && Object.keys(fileUpload).length > 0) {
displayFile = fileUpload;
}
else if (file && typeof file === 'object' && Object.keys(file).length > 0) {
displayFile = file;
}
else if (pathSolution && pathSolution.trim() !== '') {
displayFile = {
name: pathSolution.split('/').pop() || 'File',
uploadPath: pathSolution,
url: pathSolution,
path: pathSolution
};
}
if (displayFile) {
const getFileNameFromPath = () => {
const filePath = displayFile.uploadPath || displayFile.url || displayFile.path || '';
if (filePath) {
const fileName = filePath.split('/').pop();
return fileName || 'Uploaded File';
}
return displayFile.name || 'Uploaded File';
};
const displayFileName = getFileNameFromPath();
return (
<Card
style={{
marginBottom: 8,
borderRadius: 8,
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
border: '1px solid #e8e8e8'
}}
styles={{ body: { padding: '16px' } }}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: 48,
height: 48,
borderRadius: 8,
backgroundColor: '#f0f5ff',
flexShrink: 0
}}>
<FileOutlined style={{ fontSize: 24, color: '#1890ff' }} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontSize: 13,
fontWeight: 600,
color: '#262626',
marginBottom: 4,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}>
{displayFileName}
</div>
<div style={{ fontSize: 12, color: '#8c8c8c' }}>
{displayFile.size ? `${(displayFile.size / 1024).toFixed(1)} KB` : 'File uploaded'}
</div>
</div>
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
<Button
type="primary"
size="middle"
icon={<EyeOutlined />}
style={{
fontSize: 12,
display: 'flex',
alignItems: 'center',
gap: 4
}}
onClick={() => {
try {
let fileUrl = '';
let actualFileName = '';
const filePath = displayFile.uploadPath || displayFile.url || displayFile.path || '';
if (filePath) {
actualFileName = filePath.split('/').pop();
if (actualFileName) {
const fileExtension = actualFileName.split('.').pop()?.toLowerCase();
const folder = getFolderFromFileType(fileExtension);
fileUrl = getFileUrl(folder, actualFileName);
}
}
if (!fileUrl && filePath) {
fileUrl = filePath.startsWith('http') ? filePath : `${import.meta.env.VITE_API_SERVER}/${filePath}`;
}
if (fileUrl && actualFileName) {
const fileExtension = actualFileName.split('.').pop()?.toLowerCase();
const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'];
if (imageExtensions.includes(fileExtension)) {
const viewerUrl = `/image-viewer/${encodeURIComponent(actualFileName)}`;
window.open(viewerUrl, '_blank', 'noopener,noreferrer');
} else {
window.open(fileUrl, '_blank', 'noopener,noreferrer');
}
} else {
NotifAlert({
icon: 'error',
title: 'Error',
message: 'File URL not found'
});
}
} catch (error) {
NotifAlert({
icon: 'error',
title: 'Error',
message: 'Failed to open file preview'
});
}
}}
/>
<Button
danger
size="middle"
icon={<DeleteOutlined />}
style={{
fontSize: 12,
display: 'flex',
alignItems: 'center',
}}
onClick={() => {
setIsDeleted(true);
form.setFieldValue(['solution_items', fieldKey, 'fileUpload'], null);
form.setFieldValue(['solution_items', fieldKey, 'file'], null);
form.setFieldValue(['solution_items', fieldKey, 'path_solution'], null);
form.setFieldValue(['solution_items', fieldKey, 'fileName'], null);
setCurrentFile(null);
if (onFileUpload && typeof onFileUpload === 'function') {
onFileUpload(null);
}
setDeleteCounter(prev => prev + 1);
setTimeout(() => {
form.validateFields(['solution_items', fieldKey]);
}, 50);
}}
/>
</div>
</div>
</Card>
);
} else {
return (
<FileUploadHandler
type="solution"
existingFile={null}
clearSignal={deleteCounter}
debugProps={{
currentFile: !!currentFile,
deleteCounter,
shouldClear: !currentFile && deleteCounter > 0
}}
onFileUpload={(fileObject) => {
setIsDeleted(false);
const filePath = fileObject.path_solution || fileObject.uploadPath || fileObject.path || fileObject.url;
const fileWithKey = {
...fileObject,
solutionId: fieldKey,
path_solution: filePath,
uploadPath: filePath
};
if (onFileUpload && typeof onFileUpload === 'function') {
onFileUpload(fileWithKey);
}
form.setFieldValue(['solution_items', fieldKey, 'fileUpload'], fileWithKey);
form.setFieldValue(['solution_items', fieldKey, 'file'], fileWithKey);
form.setFieldValue(['solution_items', fieldKey, 'type'], 'file');
form.setFieldValue(['solution_items', fieldKey, 'path_solution'], filePath);
form.setFieldValue(['solution_items', fieldKey, 'fileName'], fileObject.name);
setTimeout(() => {
const values = form.getFieldValue(['solution_items', fieldKey]);
const pathSolutionValue = form.getFieldValue(['solution_items', fieldKey, 'path_solution']);
}, 100);
setCurrentFile(fileWithKey);
}}
onFileRemove={() => {
form.setFieldValue(['solution_items', fieldKey, 'fileUpload'], null);
form.setFieldValue(['solution_items', fieldKey, 'file'], null);
form.setFieldValue(['solution_items', fieldKey, 'path_solution'], null);
setCurrentFile(null);
if (onFileUpload && typeof onFileUpload === 'function') {
onFileUpload(null);
}
setDeleteCounter(prev => prev + 1);
}}
disabled={isReadOnly}
buttonText="Upload File"
buttonStyle={{ width: '100%', fontSize: 12 }}
uploadText="Upload solution file (includes images, PDF, documents)"
acceptFileTypes="*"
/>
);
}
} }
return null; return null;
}; };
return ( return (
<div style={{ <ConfigProvider
border: '1px solid #d9d9d9', theme={{
borderRadius: 8, components: {
padding: 16, Switch: {
marginBottom: 16, colorPrimary: '#23A55A',
backgroundColor: isReadOnly ? '#f5f5f5' : 'white' colorPrimaryHover: '#23A55A',
}}> },
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}> },
<Text strong>Solution #{index + 1}</Text> }}
<Space> >
<Form.Item <div style={{
name={[fieldName, 'name']} border: '1px solid #d9d9d9',
rules={[{ required: true, message: 'Solution name wajib diisi!' }]} borderRadius: 6,
style={{ margin: 0, width: 200 }} padding: 12,
> marginBottom: 12,
<Input backgroundColor: isReadOnly ? '#f5f5f5' : 'white'
placeholder="Solution name" }}>
disabled={isReadOnly} <div style={{
/> marginBottom: 8,
</Form.Item> gap: 8
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<Text strong style={{
fontSize: 12,
color: '#262626',
display: 'block'
}}>
Solution #{index + 1}
</Text>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Form.Item name={[fieldName, 'status']} valuePropName="checked" noStyle> <div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<Switch <Form.Item name={['solution_items', fieldKey, 'status']} valuePropName="checked" noStyle>
disabled={isReadOnly} <Switch
onChange={(checked) => { size="small"
onStatusChange(fieldKey, checked); disabled={isReadOnly}
setCurrentStatus(checked); 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={{ style={{
backgroundColor: currentStatus ? '#23A55A' : '#bfbfbf' fontSize: 12,
padding: '2px 4px',
height: '24px'
}} }}
/> />
</Form.Item> )}
<Text style={{ fontSize: 12, color: '#666' }}>
{currentStatus ? 'Active' : 'Inactive'}
</Text>
</div> </div>
</div>
<Form.Item
name={['solution_items', fieldKey, 'name']}
rules={[{ required: true, message: 'Solution name wajib diisi!' }]}
style={{ margin: 0 }}
>
<Input
placeholder="Solution name"
disabled={isReadOnly}
size="default"
style={{ fontSize: 13 }}
/>
</Form.Item>
{canRemove && !isReadOnly && (
<Button
type="text"
danger
icon={<DeleteOutlined />}
onClick={onRemove}
/>
)}
</Space>
</div> </div>
<Form.Item <Form.Item
name={[fieldName, 'type']} name={['solution_items', fieldKey, 'type']}
rules={[{ required: true, message: 'Solution type wajib diisi!' }]} rules={[{ required: true, message: 'Solution type wajib diisi!' }]}
style={{ marginBottom: 8 }}
initialValue={solutionType || 'text'}
> >
<Radio.Group <Radio.Group
onChange={(e) => onTypeChange(fieldKey, e.target.value)} onChange={(e) => {
const newType = e.target.value;
if (newType === 'text') {
form.setFieldValue(['solution_items', fieldKey, 'fileUpload'], null);
form.setFieldValue(['solution_items', fieldKey, 'file'], null);
form.setFieldValue(['solution_items', fieldKey, 'path_solution'], null);
form.setFieldValue(['solution_items', fieldKey, 'fileName'], null);
setCurrentFile(null);
setIsDeleted(true);
if (onFileUpload && typeof onFileUpload === 'function') {
onFileUpload(null);
}
} else if (newType === 'file') {
form.setFieldValue(['solution_items', fieldKey, 'text'], null);
setIsDeleted(false);
}
onTypeChange(fieldKey, newType);
}}
disabled={isReadOnly} disabled={isReadOnly}
size="small"
> >
<Radio value="text">Text Solution</Radio> <Radio value="text" style={{ fontSize: 12 }}>Text</Radio>
<Radio value="file">File Solution</Radio> <Radio value="file" style={{ fontSize: 12 }}>File</Radio>
</Radio.Group> </Radio.Group>
</Form.Item> </Form.Item>
<Form.Item
name={['solution_items', fieldKey, 'status']}
initialValue={solutionStatus !== false ? true : false}
noStyle
>
<input type="hidden" />
</Form.Item>
{renderSolutionContent()} {renderSolutionContent()}
</div> </div>
</ConfigProvider>
); );
}; };

View File

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

View File

@@ -1,310 +0,0 @@
import React, { useState, useEffect } from 'react';
import { Card, Row, Col, Image, Typography, Tag, Space, Spin, Button, Empty } from 'antd';
import { CheckCircleOutlined, CloseCircleOutlined, SearchOutlined } from '@ant-design/icons';
import { getAllSparepart } from '../../../../api/sparepart';
const { Text, Title } = Typography;
const SparepartCardSelect = ({
selectedSparepartIds = [],
onSparepartChange,
isLoading: externalLoading = false,
isReadOnly = false
}) => {
const [spareparts, setSpareparts] = useState([]);
const [loading, setLoading] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
useEffect(() => {
loadSpareparts();
}, []);
const loadSpareparts = async () => {
setLoading(true);
try {
const params = new URLSearchParams();
params.set('limit', '1000'); // Get all spareparts
const response = await getAllSparepart(params);
if (response && (response.statusCode === 200 || response.data)) {
const sparepartData = response.data?.data || response.data || [];
setSpareparts(sparepartData);
} else {
// For demo purposes, use mock data if API fails
setSpareparts([
{
sparepart_id: 1,
sparepart_name: 'Compressor Oil Filter',
sparepart_description: 'Oil filter for compressor',
sparepart_foto: null,
sparepart_code: 'SP-001',
sparepart_merk: 'Brand A',
sparepart_model: 'Model X'
},
{
sparepart_id: 2,
sparepart_name: 'Air Intake Filter',
sparepart_description: 'Air intake filter',
sparepart_foto: null,
sparepart_code: 'SP-002',
sparepart_merk: 'Brand B',
sparepart_model: 'Model Y'
},
{
sparepart_id: 3,
sparepart_name: 'Cooling Fan Motor',
sparepart_description: 'Motor for cooling fan',
sparepart_foto: null,
sparepart_code: 'SP-003',
sparepart_merk: 'Brand C',
sparepart_model: 'Model Z'
},
]);
}
} catch (error) {
console.error('Error loading spareparts:', error);
// Default mock data
setSpareparts([
{
sparepart_id: 1,
sparepart_name: 'Compressor Oil Filter',
sparepart_description: 'Oil filter for compressor',
sparepart_foto: null,
sparepart_code: 'SP-001',
sparepart_merk: 'Brand A',
sparepart_model: 'Model X'
},
{
sparepart_id: 2,
sparepart_name: 'Air Intake Filter',
sparepart_description: 'Air intake filter',
sparepart_foto: null,
sparepart_code: 'SP-002',
sparepart_merk: 'Brand B',
sparepart_model: 'Model Y'
},
{
sparepart_id: 3,
sparepart_name: 'Cooling Fan Motor',
sparepart_description: 'Motor for cooling fan',
sparepart_foto: null,
sparepart_code: 'SP-003',
sparepart_merk: 'Brand C',
sparepart_model: 'Model Z'
},
]);
} finally {
setLoading(false);
}
};
const filteredSpareparts = spareparts.filter(sp =>
sp.sparepart_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
sp.sparepart_code.toLowerCase().includes(searchTerm.toLowerCase()) ||
sp.sparepart_merk?.toLowerCase().includes(searchTerm.toLowerCase()) ||
sp.sparepart_model?.toLowerCase().includes(searchTerm.toLowerCase())
);
const handleSparepartToggle = (sparepartId) => {
if (isReadOnly) return;
const newSelectedIds = selectedSparepartIds.includes(sparepartId)
? selectedSparepartIds.filter(id => id !== sparepartId)
: [...selectedSparepartIds, sparepartId];
onSparepartChange(newSelectedIds);
};
const isSelected = (sparepartId) => selectedSparepartIds.includes(sparepartId);
const combinedLoading = loading || externalLoading;
return (
<div>
<div style={{ marginBottom: 16 }}>
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
<Title level={5} style={{ margin: 0 }}>
Select Spareparts
</Title>
<div style={{ position: 'relative', width: '200px' }}>
<input
type="text"
placeholder="Search spareparts..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
style={{
padding: '8px 30px 8px 12px',
border: '1px solid #d9d9d9',
borderRadius: '6px',
width: '100%'
}}
/>
<SearchOutlined
style={{
position: 'absolute',
right: '10px',
top: '50%',
transform: 'translateY(-50%)',
color: '#bfbfbf'
}}
/>
</div>
</Space>
</div>
{combinedLoading ? (
<div style={{ textAlign: 'center', padding: '40px' }}>
<Spin size="large" />
</div>
) : filteredSpareparts.length === 0 ? (
<Empty
description="No spareparts found"
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
) : (
<Row gutter={[16, 16]}>
{filteredSpareparts.map(sparepart => (
<Col span={8} key={sparepart.sparepart_id}>
<Card
size="small"
hoverable
style={{
border: isSelected(sparepart.sparepart_id)
? '2px solid #23A55A'
: '1px solid #d9d9d9',
backgroundColor: isSelected(sparepart.sparepart_id)
? '#f6ffed'
: 'white',
cursor: isReadOnly ? 'default' : 'pointer',
position: 'relative'
}}
onClick={() => handleSparepartToggle(sparepart.sparepart_id)}
>
<div style={{ position: 'absolute', top: 8, right: 8 }}>
{isSelected(sparepart.sparepart_id) ? (
<CheckCircleOutlined
style={{
fontSize: '18px',
color: '#23A55A',
backgroundColor: 'white',
borderRadius: '50%'
}}
/>
) : (
<CloseCircleOutlined
style={{
fontSize: '18px',
color: '#d9d9d9',
backgroundColor: 'white',
borderRadius: '50%'
}}
/>
)}
</div>
<div style={{ textAlign: 'center', marginBottom: 12 }}>
<div style={{
width: '100%',
height: 120,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#f5f5f5',
borderRadius: 8,
overflow: 'hidden'
}}>
{sparepart.sparepart_foto ? (
<Image
src={sparepart.sparepart_foto}
alt={sparepart.sparepart_name}
style={{
maxWidth: '100%',
maxHeight: '100%',
objectFit: 'contain'
}}
preview={false}
fallback="/assets/defaultSparepartImg.jpg"
/>
) : (
<div style={{
color: '#bfbfbf',
fontSize: 12
}}>
No Image
</div>
)}
</div>
</div>
<div>
<Text
strong
style={{
display: 'block',
fontSize: '14px',
marginBottom: 4,
color: isSelected(sparepart.sparepart_id) ? '#23A55A' : 'inherit'
}}
>
{sparepart.sparepart_name}
</Text>
<Text
type="secondary"
style={{
fontSize: '12px',
display: 'block',
marginBottom: 4
}}
>
{sparepart.sparepart_description || 'No description'}
</Text>
<Space size="small" style={{ marginBottom: 4 }}>
<Tag color="blue" style={{ margin: 0 }}>
{sparepart.sparepart_code}
</Tag>
<Tag color="geekblue" style={{ margin: 0 }}>
{sparepart.sparepart_merk || 'N/A'}
</Tag>
</Space>
{sparepart.sparepart_model && (
<div style={{
fontSize: '12px',
color: '#666'
}}>
Model: {sparepart.sparepart_model}
</div>
)}
</div>
</Card>
</Col>
))}
</Row>
)}
{selectedSparepartIds.length > 0 && (
<div style={{ marginTop: 16 }}>
<Text strong>Selected Spareparts: </Text>
<Space wrap>
{selectedSparepartIds.map(id => {
const sparepart = spareparts.find(sp => sp.sparepart_id === id);
return sparepart ? (
<Tag key={id} color="green">
{sparepart.sparepart_name} (ID: {id})
</Tag>
) : (
<Tag key={id} color="green">
Sparepart ID: {id}
</Tag>
);
})}
</Space>
</div>
)}
</div>
);
};
export default SparepartCardSelect;

View File

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

View File

@@ -0,0 +1,178 @@
import React, { useState, useEffect } from 'react';
import { Select, Typography, Tag, Spin, Empty, Button } from 'antd';
import { PlusOutlined, DeleteOutlined, CheckOutlined, EyeOutlined, InfoCircleOutlined } from '@ant-design/icons';
import { getAllSparepart } from '../../../../api/sparepart';
import CustomSparepartCard from './CustomSparepartCard';
const { Text, Title } = Typography;
const { Option } = Select;
const SparepartSelect = ({
selectedSparepartIds = [],
onSparepartChange,
isReadOnly = false
}) => {
const [spareparts, setSpareparts] = useState([]);
const [loading, setLoading] = useState(false);
const [selectedSpareparts, setSelectedSpareparts] = useState([]);
const [dropdownOpen, setDropdownOpen] = useState(false);
useEffect(() => {
fetchSpareparts();
}, []);
useEffect(() => {
if (selectedSparepartIds && selectedSparepartIds.length > 0) {
const fullSelectedSpareparts = spareparts.filter(sp =>
selectedSparepartIds.includes(sp.sparepart_id)
);
setSelectedSpareparts(fullSelectedSpareparts);
} else {
setSelectedSpareparts([]);
}
}, [selectedSparepartIds, spareparts]);
const fetchSpareparts = async (searchQuery = '') => {
setLoading(true);
try {
const params = new URLSearchParams();
params.set('limit', '1000');
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, 5)
.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

@@ -194,7 +194,6 @@ export const useErrorCodeLogic = (errorCodeForm, fileList) => {
}; };
const handleSolutionStatusChange = (fieldId, status) => { const handleSolutionStatusChange = (fieldId, status) => {
// Only update local state - form is already updated by Form.Item
setSolutionStatuses(prev => ({ setSolutionStatuses(prev => ({
...prev, ...prev,
[fieldId]: status [fieldId]: status
@@ -213,8 +212,7 @@ export const useErrorCodeLogic = (errorCodeForm, fileList) => {
newSolutionTypes[fieldId] = solution.type_solution || 'text'; newSolutionTypes[fieldId] = solution.type_solution || 'text';
newSolutionStatuses[fieldId] = solution.is_active !== false; newSolutionStatuses[fieldId] = solution.is_active !== false;
newSolutionData[fieldId] = { newSolutionData[fieldId] = {
...solution, ...solution
brand_code_solution_id: solution.brand_code_solution_id
}; };
setTimeout(() => { setTimeout(() => {

View File

@@ -1,37 +1,82 @@
import { useState } from 'react'; import { useState, useEffect } from 'react';
export const useSolutionLogic = (solutionForm) => { export const useSolutionLogic = (solutionForm) => {
const [solutionFields, setSolutionFields] = useState([ const [solutionFields, setSolutionFields] = useState([0]);
{ name: ['solution_items', 0], key: 0 }
]);
const [solutionTypes, setSolutionTypes] = useState({ 0: 'text' }); const [solutionTypes, setSolutionTypes] = useState({ 0: 'text' });
const [solutionStatuses, setSolutionStatuses] = useState({ 0: true }); const [solutionStatuses, setSolutionStatuses] = useState({ 0: true });
const [solutionsToDelete, setSolutionsToDelete] = useState([]); const [solutionsToDelete, setSolutionsToDelete] = useState([]);
const handleAddSolutionField = () => { useEffect(() => {
const newKey = Date.now(); // Use timestamp for unique key setTimeout(() => {
const newField = { name: ['solution_items', newKey], key: newKey }; if (solutionForm) {
solutionForm.setFieldsValue({
solution_items: {
0: {
name: 'Solution 1',
status: true,
type: 'text',
text: 'Solution description',
file: null,
fileUpload: null
}
}
});
}
}, 100);
}, [solutionForm]);
setSolutionFields(prev => [...prev, newField]); const handleAddSolutionField = () => {
const newKey = Date.now();
setSolutionFields(prev => [...prev, newKey]);
setSolutionTypes(prev => ({ ...prev, [newKey]: 'text' })); setSolutionTypes(prev => ({ ...prev, [newKey]: 'text' }));
setSolutionStatuses(prev => ({ ...prev, [newKey]: true })); setSolutionStatuses(prev => ({ ...prev, [newKey]: true }));
// Set default values for the new field
setTimeout(() => { setTimeout(() => {
solutionForm.setFieldValue(['solution_items', newKey, 'name'], ''); const currentFormValues = solutionForm.getFieldsValue(true);
const existingNames = [];
Object.keys(currentFormValues).forEach(key => {
if (key.startsWith('solution_items,') || key.startsWith('solution_items.')) {
const solutionData = currentFormValues[key];
if (solutionData && solutionData.name) {
existingNames.push(solutionData.name);
}
}
});
if (currentFormValues.solution_items) {
Object.values(currentFormValues.solution_items).forEach(solution => {
if (solution && solution.name) {
existingNames.push(solution.name);
}
});
}
let solutionNumber = solutionFields.length + 1;
let defaultName = `Solution ${solutionNumber}`;
while (existingNames.includes(defaultName)) {
solutionNumber++;
defaultName = `Solution ${solutionNumber}`;
}
solutionForm.setFieldValue(['solution_items', newKey, 'name'], defaultName);
solutionForm.setFieldValue(['solution_items', newKey, 'type'], 'text'); solutionForm.setFieldValue(['solution_items', newKey, 'type'], 'text');
solutionForm.setFieldValue(['solution_items', newKey, 'text'], ''); solutionForm.setFieldValue(['solution_items', newKey, 'text'], 'Solution description');
}, 0); solutionForm.setFieldValue(['solution_items', newKey, 'status'], true);
solutionForm.setFieldValue(['solution_items', newKey, 'file'], null);
solutionForm.setFieldValue(['solution_items', newKey, 'fileUpload'], null);
}, 100);
}; };
const handleRemoveSolutionField = (key) => { const handleRemoveSolutionField = (key) => {
if (solutionFields.length <= 1) { if (solutionFields.length <= 1) {
return; // Keep at least one solution field return;
} }
setSolutionFields(prev => prev.filter(field => field.key !== key)); setSolutionFields(prev => prev.filter(field => field !== key));
// Clean up type and status
const newTypes = { ...solutionTypes }; const newTypes = { ...solutionTypes };
const newStatuses = { ...solutionStatuses }; const newStatuses = { ...solutionStatuses };
delete newTypes[key]; delete newTypes[key];
@@ -39,10 +84,60 @@ export const useSolutionLogic = (solutionForm) => {
setSolutionTypes(newTypes); setSolutionTypes(newTypes);
setSolutionStatuses(newStatuses); setSolutionStatuses(newStatuses);
setTimeout(() => {
try {
solutionForm.setFieldValue(['solution_items', key], undefined);
solutionForm.setFieldValue(['solution_items', key, 'name'], undefined);
solutionForm.setFieldValue(['solution_items', key, 'type'], undefined);
solutionForm.setFieldValue(['solution_items', key, 'text'], undefined);
solutionForm.setFieldValue(['solution_items', key, 'status'], undefined);
solutionForm.setFieldValue(['solution_items', key, 'file'], undefined);
solutionForm.setFieldValue(['solution_items', key, 'fileUpload'], undefined);
} catch (error) {
}
}, 50);
}; };
const handleSolutionTypeChange = (key, value) => { const handleSolutionTypeChange = (key, value) => {
setSolutionTypes(prev => ({ ...prev, [key]: value })); setSolutionTypes(prev => ({ ...prev, [key]: value }));
setTimeout(() => {
const fieldName = ['solution_items', key];
const currentSolutionData = solutionForm.getFieldsValue([fieldName]) || {};
const solutionData = currentSolutionData[`solution_items,${key}`] || currentSolutionData[`solution_items.${key}`] || {};
if (value === 'text') {
const updatedSolutionData = {
...solutionData,
fileUpload: null,
file: null,
path_solution: null,
fileName: null,
text: solutionData.text || 'Solution description'
};
solutionForm.setFieldValue([...fieldName, 'fileUpload'], null);
solutionForm.setFieldValue([...fieldName, 'file'], null);
solutionForm.setFieldValue([...fieldName, 'path_solution'], null);
solutionForm.setFieldValue([...fieldName, 'fileName'], null);
solutionForm.setFieldValue([...fieldName, 'text'], updatedSolutionData.text);
} else if (value === 'file') {
const updatedSolutionData = {
...solutionData,
text: '',
fileUpload: null,
file: null,
path_solution: null,
fileName: null
};
solutionForm.setFieldValue([...fieldName, 'text'], '');
solutionForm.setFieldValue([...fieldName, 'fileUpload'], null);
solutionForm.setFieldValue([...fieldName, 'file'], null);
solutionForm.setFieldValue([...fieldName, 'path_solution'], null);
solutionForm.setFieldValue([...fieldName, 'fileName'], null);
}
}, 0);
}; };
const handleSolutionStatusChange = (key, value) => { const handleSolutionStatusChange = (key, value) => {
@@ -50,27 +145,60 @@ export const useSolutionLogic = (solutionForm) => {
}; };
const resetSolutionFields = () => { const resetSolutionFields = () => {
setSolutionFields([{ name: ['solution_items', 0], key: 0 }]); setSolutionFields([0]);
setSolutionTypes({ 0: 'text' }); setSolutionTypes({ 0: 'text' });
setSolutionStatuses({ 0: true }); setSolutionStatuses({ 0: true });
// Reset form values if (!solutionForm || !solutionForm.resetFields) {
return;
}
solutionForm.resetFields(); solutionForm.resetFields();
solutionForm.setFieldsValue({ setTimeout(() => {
solution_status_0: true, solutionForm.setFieldsValue({
solution_type_0: 'text', solution_items: {
}); 0: {
name: 'Solution 1',
status: true,
type: 'text',
text: 'Solution description',
file: null,
fileUpload: null
}
}
});
solutionForm.setFieldValue(['solution_items', 0, 'name'], 'Solution 1');
solutionForm.setFieldValue(['solution_items', 0, 'type'], 'text');
solutionForm.setFieldValue(['solution_items', 0, 'text'], 'Solution description');
solutionForm.setFieldValue(['solution_items', 0, 'status'], true);
solutionForm.setFieldValue(['solution_items', 0, 'file'], null);
solutionForm.setFieldValue(['solution_items', 0, 'fileUpload'], null);
}, 100);
}; };
const checkFirstSolutionValid = () => { const checkFirstSolutionValid = () => {
if (!solutionForm || !solutionForm.getFieldsValue) {
return false;
}
const values = solutionForm.getFieldsValue(); const values = solutionForm.getFieldsValue();
const firstSolution = values.solution_items?.[0];
const firstField = solutionFields[0];
if (!firstField) {
return false;
}
const solutionKey = firstField.key || firstField;
const commaPath = `solution_items,${solutionKey}`;
const dotPath = `solution_items.${solutionKey}`;
const firstSolution = values[commaPath] || values[dotPath];
if (!firstSolution || !firstSolution.name || firstSolution.name.trim() === '') { if (!firstSolution || !firstSolution.name || firstSolution.name.trim() === '') {
return false; return false;
} }
if (solutionTypes[0] === 'text' && (!firstSolution.text || firstSolution.text.trim() === '')) { if (solutionTypes[solutionKey] === 'text' && (!firstSolution.text || firstSolution.text.trim() === '')) {
return false; return false;
} }
@@ -78,72 +206,231 @@ export const useSolutionLogic = (solutionForm) => {
}; };
const getSolutionData = () => { const getSolutionData = () => {
const values = solutionForm.getFieldsValue(); try {
const values = solutionForm.getFieldsValue(true);
const result = [];
const result = solutionFields.map(field => { solutionFields.forEach(key => {
const key = field.key; let solution = null;
// Access form values using the key from field.name (AntD stores with comma)
const solutionPath = field.name.join(',');
const solution = values[solutionPath];
const validSolution = solution && solution.name && solution.name.trim() !== ''; try {
solution = solutionForm.getFieldValue(['solution_items', key]);
} catch (error) {
}
if (validSolution) { if (!solution && values.solution_items && values.solution_items[key]) {
return { solution = values.solution_items[key];
solution_name: solution.name || 'Default Solution', }
type_solution: solutionTypes[key] || 'text',
text_solution: solution.text || '', if (!solution) {
path_solution: solution.file || '', const commaKey = `solution_items,${key}`;
is_active: solution.status !== false, // Use form value directly solution = values[commaKey];
}
if (!solution) {
const dotKey = `solution_items.${key}`;
solution = values[dotKey];
}
if (!solution) {
const allKeys = Object.keys(values);
const foundKey = allKeys.find(k =>
k.includes(key.toString()) &&
k.includes('solution_items')
);
if (foundKey) {
solution = values[foundKey];
}
}
if (!solution) {
const rawValues = solutionForm.getFieldsValue();
if (rawValues.solution_items && rawValues.solution_items[key]) {
solution = rawValues.solution_items[key];
}
}
if (!solution) {
return;
}
const hasName = solution.name && solution.name.trim() !== '';
if (!hasName) {
return;
}
const solutionType = solutionTypes[key] || solution.type || 'text';
let isValidType = true;
if (solutionType === 'text') {
isValidType = solution.text && solution.text.trim() !== '';
if (!isValidType) {
return;
}
} else if (solutionType === 'file') {
const hasPathSolution = solution.path_solution && solution.path_solution.trim() !== '';
const hasFileUpload = (solution.fileUpload && typeof solution.fileUpload === 'object' && Object.keys(solution.fileUpload).length > 0);
const hasFile = (solution.file && typeof solution.file === 'object' && Object.keys(solution.file).length > 0);
isValidType = hasPathSolution || hasFileUpload || hasFile;
if (!isValidType) {
return;
}
}
let pathSolution = '';
let fileObject = null;
const typeSolution = solutionTypes[key] || solution.type || 'text';
if (typeSolution === 'file') {
if (solution.fileUpload && typeof solution.fileUpload === 'object' && Object.keys(solution.fileUpload).length > 0) {
pathSolution = solution.fileUpload.path_solution || solution.fileUpload.uploadPath || '';
fileObject = solution.fileUpload;
} else if (solution.file && typeof solution.file === 'object' && Object.keys(solution.file).length > 0) {
pathSolution = solution.file.path_solution || solution.file.uploadPath || '';
fileObject = solution.file;
} else if (solution.file && typeof solution.file === 'string' && solution.file.trim() !== '') {
pathSolution = solution.file;
} else if (solution.path_solution && solution.path_solution.trim() !== '') {
pathSolution = solution.path_solution;
} else {
}
}
let finalTypeSolution = typeSolution;
if (typeSolution === 'file') {
if (fileObject && fileObject.type_solution) {
finalTypeSolution = fileObject.type_solution;
} else {
finalTypeSolution = 'image';
}
}
const finalSolution = {
solution_name: solution.name,
type_solution: finalTypeSolution,
is_active: solution.status !== false && solution.status !== undefined ? solution.status : (solutionStatuses[key] !== false),
}; };
}
return null;
}).filter(Boolean);
return result; if (typeSolution === 'text') {
finalSolution.text_solution = solution.text || '';
finalSolution.path_solution = '';
} else {
finalSolution.text_solution = '';
finalSolution.path_solution = pathSolution;
}
result.push(finalSolution);
});
return result;
} catch (error) {
return [];
}
}; };
const setSolutionsForExistingRecord = (solutions, form) => { const setSolutionsForExistingRecord = (solutions, form) => {
if (!solutions || solutions.length === 0) return; if (!solutions || solutions.length === 0) return;
const newFields = solutions.map((solution, index) => ({ const newFields = solutions.map((solution, index) => solution.id || index);
name: ['solution_items', solution.id || index],
key: solution.id || index
}));
setSolutionFields(newFields); setSolutionFields(newFields);
// Set solution values
const solutionsValues = {}; const solutionsValues = {};
const newTypes = {}; const newTypes = {};
const newStatuses = {}; const newStatuses = {};
solutions.forEach((solution, index) => { solutions.forEach((solution, index) => {
const key = solution.id || index; const key = solution.brand_code_solution_id || solution.id || index;
let fileObject = null;
if (solution.path_solution && solution.path_solution.trim() !== '') {
const fileName = solution.file_upload_name || solution.path_solution.split('/').pop() || `file_${index}`;
fileObject = {
uploadPath: solution.path_solution,
path_solution: solution.path_solution,
name: fileName,
type_solution: solution.type_solution || 'image',
isExisting: true,
size: 0,
type: solution.type_solution === 'pdf' ? 'application/pdf' : 'image/jpeg',
fileExtension: solution.type_solution === 'pdf' ? 'pdf' : (fileName.split('.').pop().toLowerCase() || 'jpg')
};
}
const isFileType = solution.type_solution && solution.type_solution !== 'text' && fileObject;
solutionsValues[key] = { solutionsValues[key] = {
name: solution.solution_name || '', name: solution.solution_name || '',
type: solution.type_solution || 'text', type: isFileType ? 'file' : 'text',
text: solution.text_solution || '', text: solution.text_solution || '',
file: solution.path_solution || '', file: fileObject,
fileUpload: fileObject,
status: solution.is_active !== false,
path_solution: solution.path_solution || ''
}; };
newTypes[key] = solution.type_solution || 'text'; newTypes[key] = isFileType ? 'file' : 'text';
newStatuses[key] = solution.is_active !== false; newStatuses[key] = solution.is_active !== false;
}); });
// Set all form values at once const nestedFormValues = {
const formValues = {}; solution_items: {}
};
Object.keys(solutionsValues).forEach(key => { Object.keys(solutionsValues).forEach(key => {
const solution = solutionsValues[key]; const solution = solutionsValues[key];
formValues[`solution_items,${key}`] = { nestedFormValues.solution_items[key] = {
name: solution.name, name: solution.name,
type: solution.type, type: solution.type,
text: solution.text, text: solution.text,
file: solution.file, file: solution.file,
status: solution.is_active !== false fileUpload: solution.fileUpload,
status: solution.status,
path_solution: solution.path_solution
}; };
}); });
form.setFieldsValue(formValues); form.setFieldsValue(nestedFormValues);
const fallbackFormValues = {};
Object.keys(solutionsValues).forEach(key => {
const solution = solutionsValues[key];
fallbackFormValues[`solution_items,${key}`] = {
name: solution.name,
type: solution.type,
text: solution.text,
file: solution.file,
fileUpload: solution.fileUpload,
status: solution.status,
path_solution: solution.path_solution
};
});
form.setFieldsValue(fallbackFormValues);
Object.keys(solutionsValues).forEach(key => {
const solution = solutionsValues[key];
form.setFieldValue([`solution_items,${key}`, 'name'], solution.name);
form.setFieldValue([`solution_items,${key}`, 'type'], solution.type);
form.setFieldValue([`solution_items,${key}`, 'text'], solution.text);
form.setFieldValue([`solution_items,${key}`, 'file'], solution.file);
form.setFieldValue([`solution_items,${key}`, 'fileUpload'], solution.fileUpload);
form.setFieldValue([`solution_items,${key}`, 'status'], solution.status);
form.setFieldValue([`solution_items,${key}`, 'path_solution'], solution.path_solution);
form.setFieldValue(['solution_items', key, 'name'], solution.name);
form.setFieldValue(['solution_items', key, 'type'], solution.type);
form.setFieldValue(['solution_items', key, 'text'], solution.text);
form.setFieldValue(['solution_items', key, 'file'], solution.file);
form.setFieldValue(['solution_items', key, 'fileUpload'], solution.fileUpload);
form.setFieldValue(['solution_items', key, 'status'], solution.status);
form.setFieldValue(['solution_items', key, 'path_solution'], solution.path_solution);
});
setSolutionTypes(newTypes); setSolutionTypes(newTypes);
setSolutionStatuses(newStatuses); setSolutionStatuses(newStatuses);
}; };

View File

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

View File

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

View File

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

View File

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

View File

@@ -78,7 +78,7 @@ const DetailPlantSubSection = (props) => {
const payload = { const payload = {
plant_sub_section_name: formData.plant_sub_section_name, plant_sub_section_name: formData.plant_sub_section_name,
plant_sub_section_description: 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 table_name_value: formData.table_name_value, // Fix field name
is_active: formData.is_active, is_active: formData.is_active,
}; };

View File

@@ -12,7 +12,7 @@ import {
Col, Col,
Image, Image,
} from 'antd'; } from 'antd';
import { PlusOutlined } from '@ant-design/icons'; import { PlusOutlined, EyeOutlined, DeleteOutlined } from '@ant-design/icons';
import { NotifAlert, NotifOk } from '../../../../components/Global/ToastNotif'; import { NotifAlert, NotifOk } from '../../../../components/Global/ToastNotif';
import { createSparepart, updateSparepart } from '../../../../api/sparepart'; import { createSparepart, updateSparepart } from '../../../../api/sparepart';
import { uploadFile } from '../../../../api/file-uploads'; import { uploadFile } from '../../../../api/file-uploads';
@@ -35,16 +35,18 @@ const DetailSparepart = (props) => {
const [previewOpen, setPreviewOpen] = useState(false); const [previewOpen, setPreviewOpen] = useState(false);
const [previewImage, setPreviewImage] = useState(''); const [previewImage, setPreviewImage] = useState('');
const [previewTitle, setPreviewTitle] = useState(''); const [previewTitle, setPreviewTitle] = useState('');
const [isHovering, setIsHovering] = useState(false);
const defaultData = { const defaultData = {
sparepart_id: '', sparepart_id: '',
sparepart_name: '', sparepart_name: '',
sparepart_description: '', sparepart_description: '',
sparepart_model: '', sparepart_model: '',
sparepart_item_type: '', sparepart_item_type: null,
sparepart_qty: 0,
sparepart_unit: '', sparepart_unit: '',
sparepart_merk: '', sparepart_merk: '',
sparepart_stok: '0', sparepart_stok: 'Not Available',
sparepart_foto: '', sparepart_foto: '',
}; };
@@ -69,6 +71,10 @@ const DetailSparepart = (props) => {
const handleChange = ({ fileList: newFileList }) => setFileList(newFileList); const handleChange = ({ fileList: newFileList }) => setFileList(newFileList);
const handleRemove = () => {
setFileList([]);
};
const handleSave = async () => { const handleSave = async () => {
setConfirmLoading(true); setConfirmLoading(true);
@@ -203,10 +209,7 @@ const DetailSparepart = (props) => {
sparepart_name: formData.sparepart_name, // Wajib sparepart_name: formData.sparepart_name, // Wajib
}; };
// Tambahkan field-field secara kondisional hanya jika nilainya tidak kosong payload.sparepart_description = (formData.sparepart_description && formData.sparepart_description.trim() !== '') ? formData.sparepart_description : ' ';
if (formData.sparepart_description && formData.sparepart_description.trim() !== '') {
payload.sparepart_description = formData.sparepart_description;
}
if (formData.sparepart_model && formData.sparepart_model.trim() !== '') { if (formData.sparepart_model && formData.sparepart_model.trim() !== '') {
payload.sparepart_model = formData.sparepart_model; payload.sparepart_model = formData.sparepart_model;
} }
@@ -219,11 +222,12 @@ const DetailSparepart = (props) => {
if (formData.sparepart_merk && formData.sparepart_merk.trim() !== '') { if (formData.sparepart_merk && formData.sparepart_merk.trim() !== '') {
payload.sparepart_merk = formData.sparepart_merk; payload.sparepart_merk = formData.sparepart_merk;
} }
if (formData.sparepart_stok && formData.sparepart_stok.trim() !== '') { // sparepart_qty disimpan sebagai angka kuantitas
payload.sparepart_stok = formData.sparepart_stok.toString(); const qty = parseInt(formData.sparepart_qty) || 0;
} else { payload.sparepart_qty = qty;
payload.sparepart_stok = '0'; // Set default value jika tidak diisi
} // sparepart_stok ditentukan otomatis berdasarkan qty sebenarnya
payload.sparepart_stok = qty > 0 ? 'Available' : 'Not Available';
// Sertakan sparepart_foto hanya jika nilainya tidak kosong, agar tidak memicu validasi // Sertakan sparepart_foto hanya jika nilainya tidak kosong, agar tidak memicu validasi
if (imageUrl && imageUrl.trim() !== '') { if (imageUrl && imageUrl.trim() !== '') {
payload.sparepart_foto = imageUrl; payload.sparepart_foto = imageUrl;
@@ -279,18 +283,33 @@ const DetailSparepart = (props) => {
if (props.selectedData) { if (props.selectedData) {
setFormData(props.selectedData); setFormData(props.selectedData);
if (props.selectedData.sparepart_foto) { if (props.selectedData.sparepart_foto) {
// Buat URL lengkap dengan token untuk file yang sudah ada 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(); const fileName = props.selectedData.sparepart_foto.split('/').pop();
const token = localStorage.getItem('token');
const baseURL = import.meta.env.VITE_API_SERVER || '';
const fullUrl = `${baseURL}/file-uploads/images/${encodeURIComponent(fileName)}${token ? `?token=${encodeURIComponent(token)}` : ''}`;
setFileList([ setFileList([
{ {
uid: '-1', uid: '-1',
name: fileName, name: fileName,
status: 'done', status: 'done',
url: fullUrl, url: displayUrl,
}, },
]); ]);
} else { } else {
@@ -364,85 +383,159 @@ const DetailSparepart = (props) => {
{formData && ( {formData && (
<div> <div>
<Row gutter={[16, 16]}> <Row gutter={[16, 16]}>
<Col span={12}> {/* Kolom untuk foto */}
<Text strong>Sparepart Name</Text> <Col span={10} style={{ display: 'flex', flexDirection: 'column' }}>
<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={12}>
<Text strong>Item Type</Text>
<Select
name="sparepart_item_type"
value={formData.sparepart_item_type}
onChange={(value) =>
handleSelectChange('sparepart_item_type', value)
}
placeholder="Select 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>
</Row>
<Row gutter={[16, 16]}>
<Col span={12}>
<Text strong>Stock</Text>
<Input
name="sparepart_stok"
value={formData.sparepart_stok}
onChange={handleInputChange}
placeholder="Initial stock quantity"
readOnly={props.readOnly}
type="number"
/>
</Col>
<Col span={12}>
<Text strong>Unit</Text>
<Input
name="sparepart_unit"
value={formData.sparepart_unit}
onChange={handleInputChange}
placeholder="e.g., pcs, box, roll"
readOnly={props.readOnly}
/>
</Col>
</Row>
<Row gutter={[16, 16]}>
<Col span={24}>
<Text strong>Foto</Text> <Text strong>Foto</Text>
<Upload <div
listType="picture-card" style={{
fileList={fileList} flexGrow: 1,
onPreview={handlePreview} display: 'flex',
onChange={handleChange} alignItems: 'center',
beforeUpload={() => false} justifyContent: 'center',
maxCount={1} width: '100%',
disabled={props.readOnly} }}
> >
{fileList.length >= 1 ? null : uploadButton} {fileList.length > 0 ? (
</Upload> <div
<Modal onMouseEnter={() => setIsHovering(true)}
open={previewOpen} onMouseLeave={() => setIsHovering(false)}
title={previewTitle} style={{
footer={null} position: 'relative',
onCancel={handlePreviewCancel} width: '180px', // Fixed width for square
> height: '180px', // Fixed height
<img alt="preview" style={{ width: '100%' }} src={previewImage} /> border: '1px solid #d9d9d9',
</Modal> 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> </Col>
</Row> </Row>
<Row gutter={[16, 16]}> <Row gutter={[16, 16]} style={{ marginTop: 16 }}>
<Col span={12}> <Col span={12}>
<Text strong>Brand</Text> <Text strong>Brand</Text>
<Input <Input
@@ -465,7 +558,7 @@ const DetailSparepart = (props) => {
</Col> </Col>
</Row> </Row>
<Row gutter={[16, 16]}> <Row gutter={[16, 16]} style={{ marginTop: 16 }}>
<Col span={24}> <Col span={24}>
<Text strong>Description</Text> <Text strong>Description</Text>
<TextArea <TextArea
@@ -480,6 +573,14 @@ const DetailSparepart = (props) => {
</Row> </Row>
</div> </div>
)} )}
<Modal
open={previewOpen}
title={previewTitle}
footer={null}
onCancel={handlePreviewCancel}
>
<img alt="preview" style={{ width: '100%' }} src={previewImage} />
</Modal>
</Modal> </Modal>
); );
}; };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,30 @@
import React, { memo } from 'react'; import React, { memo } from 'react';
import { Modal, Row, Col, Tag, Divider } from 'antd'; import { Row, Col, Tag, Card, Button } from 'antd';
import { CloseCircleFilled, WarningFilled, CheckCircleFilled, InfoCircleFilled } from '@ant-design/icons'; 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) => { const getIconAndColor = (type) => {
switch (type) { switch (type) {
case 'critical': 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 ( return (
<Modal <Card
title="Detail Notifikasi" title="Detail Notifikasi"
open={visible} extra={<Button onClick={onClose}>Tutup</Button>}
onCancel={onCancel} style={{ height: '100%' }}
onOk={onCancel} bodyStyle={{ padding: '0 24px' }}
okText="Tutup"
cancelButtonProps={{ style: { display: 'none' } }}
width={700}
> >
{selectedData && ( <div>
<div> {/* Header with Icon and Status */}
{/* Header with Icon and Status */} <div
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
marginBottom: '0',
padding: '2px 0',
backgroundColor: '#fafafa',
borderRadius: '8px',
}}
>
<div <div
style={{ style={{
width: '32px',
height: '32px',
borderRadius: '50%',
backgroundColor: bgColor,
color: color,
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: '16px', justifyContent: 'center',
marginBottom: '24px', fontSize: '18px',
padding: '16px', flexShrink: 0,
backgroundColor: '#fafafa',
borderRadius: '8px',
}} }}
> >
<div {IconComponent && <IconComponent style={{ fontSize: '18px' }} />}
style={{
width: '64px',
height: '64px',
borderRadius: '50%',
backgroundColor: bgColor,
color: color,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '32px',
flexShrink: 0,
}}
>
{IconComponent && <IconComponent style={{ fontSize: '32px' }} />}
</div>
<div style={{ flex: 1 }}>
<Tag color={tagColor} style={{ marginBottom: '8px', fontSize: '12px' }}>
{selectedData.type.toUpperCase()}
</Tag>
<div style={{ fontSize: '16px', fontWeight: 600, color: '#262626' }}>
{selectedData.title}
</div>
</div>
</div> </div>
<div style={{ flex: 1 }}>
<Divider style={{ margin: '16px 0' }} /> <Tag color={tagColor} style={{ marginBottom: '2px', fontSize: '11px' }}>
{notificationType.toUpperCase()}
{/* Information Grid */}
<Row gutter={[16, 16]}>
<Col span={12}>
<div style={{ marginBottom: '16px' }}>
<div style={{ fontSize: '12px', color: '#8c8c8c', marginBottom: '4px' }}>
PLC
</div>
<div style={{ fontSize: '14px', color: '#262626', fontWeight: 500 }}>
{selectedData.plc}
</div>
</div>
</Col>
<Col span={12}>
<div style={{ marginBottom: '16px' }}>
<div style={{ fontSize: '12px', color: '#8c8c8c', marginBottom: '4px' }}>Tag</div>
<div style={{ fontSize: '14px', color: '#262626', fontWeight: 500 }}>
{selectedData.tag}
</div>
</div>
</Col>
</Row>
<Row gutter={[16, 16]}>
<Col span={12}>
<div style={{ marginBottom: '16px' }}>
<div style={{ fontSize: '12px', color: '#8c8c8c', marginBottom: '4px' }}>
Engineer
</div>
<div style={{ fontSize: '14px', color: '#262626', fontWeight: 500 }}>
{selectedData.engineer}
</div>
</div>
</Col>
<Col span={12}>
<div style={{ marginBottom: '16px' }}>
<div style={{ fontSize: '12px', color: '#8c8c8c', marginBottom: '4px' }}>
Waktu
</div>
<div style={{ fontSize: '14px', color: '#262626', fontWeight: 500 }}>
{selectedData.time}
</div>
</div>
</Col>
</Row>
<Divider style={{ margin: '16px 0' }} />
{/* Status */}
<div style={{ marginBottom: '16px' }}>
<div style={{ fontSize: '12px', color: '#8c8c8c', marginBottom: '8px' }}>Status</div>
<Tag color={selectedData.isRead ? 'default' : 'blue'}>
{selectedData.isRead ? 'Sudah Dibaca' : 'Belum Dibaca'}
</Tag> </Tag>
</div> <div style={{ fontSize: '14px', fontWeight: 600, color: '#262626' }}>
{errorCodeData?.error_code_name || 'N/A'}
{/* 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> </div>
</div> </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>
); );
}); });

View File

@@ -47,28 +47,29 @@ const transformNotificationData = (apiData) => {
return apiData.map((item, index) => ({ return apiData.map((item, index) => ({
id: `notification-${item.notification_error_id}-${index}`, // Unique key prefix with array index id: `notification-${item.notification_error_id}-${index}`, // Unique key prefix with array index
type: item.is_read ? 'resolved' : item.is_delivered ? 'warning' : 'critical', type: item.is_read ? 'resolved' : item.is_delivered ? 'warning' : 'critical',
title: item.device_name || 'Unknown Device', title: item.error_code?.error_code_name || item.device_name || 'Unknown Error',
issue: item.error_code_name || 'Unknown Error', issue: item.error_code || item.error_code_name || 'Unknown Error',
description: `${item.error_code} - ${item.error_code_name}`, description: `${item.error_code} - ${item.error_code_name || ''}`,
timestamp: timestamp:
new Date(item.created_at).toLocaleString('id-ID', { item.created_at ? new Date(item.created_at).toLocaleString('id-ID', {
day: '2-digit', day: '2-digit',
month: '2-digit', month: '2-digit',
year: 'numeric', year: 'numeric',
hour: '2-digit', hour: '2-digit',
minute: '2-digit', minute: '2-digit',
}) + ' WIB', }) + ' WIB' : 'N/A',
location: item.device_location || 'Location not specified', location: item.plant_sub_section_name || item.device_location || 'Location not specified',
details: item.message_error_issue || 'No details available', details: item.message_error_issue || 'No details available',
link: `/verification-sparepart/${item.notification_error_id}`, // Dummy URL untuk verifikasi spare part link: `/verification-sparepart/${item.notification_error_id}`, // Dummy URL untuk verifikasi spare part
subsection: item.solution_name || 'N/A', subsection: item.plant_sub_section_name || 'N/A',
isRead: item.is_read, isRead: item.is_read,
status: item.is_read ? 'Resolved' : item.is_delivered ? 'Delivered' : 'Pending', status: item.is_read ? 'Resolved' : item.is_delivered ? 'Delivered' : 'Pending',
tag: item.error_code, tag: item.error_code,
errorCode: item.error_code, errorCode: item.error_code,
solutionName: item.solution_name, solutionName: item.error_code?.solution?.[0]?.solution_name || 'N/A',
typeSolution: item.type_solution, typeSolution: item.error_code?.solution?.[0]?.type_solution || 'N/A',
pathSolution: item.path_solution, pathSolution: item.error_code?.solution?.[0]?.path_document || item.error_code?.solution?.[0]?.path_solution || 'N/A',
error_code: item.error_code,
})); }));
}; };
@@ -202,7 +203,7 @@ const ListNotification = memo(function ListNotification(props) {
})); }));
// Fetch notifications with new pagination // Fetch notifications with new pagination
const isReadFilter = activeTab === 'read' ? true : activeTab === 'unread' ? false : null; const isReadFilter = activeTab === 'read' ? 1 : activeTab === 'unread' ? 0 : null;
fetchNotifications(page, pageSize, isReadFilter); fetchNotifications(page, pageSize, isReadFilter);
}; };
@@ -214,7 +215,7 @@ const ListNotification = memo(function ListNotification(props) {
} }
// Fetch notifications on component mount and when tab changes // Fetch notifications on component mount and when tab changes
const isReadFilter = activeTab === 'read' ? true : activeTab === 'unread' ? false : null; const isReadFilter = activeTab === 'read' ? 1 : activeTab === 'unread' ? 0 : null;
fetchNotifications(pagination.current_page, pagination.current_limit, isReadFilter); fetchNotifications(pagination.current_page, pagination.current_limit, isReadFilter);
}, [activeTab]); }, [activeTab]);
@@ -464,7 +465,7 @@ const ListNotification = memo(function ListNotification(props) {
}} }}
/> />
<RouterLink <RouterLink
to={`/detail-notification/${ to={`/notification-detail/${
notification.id.split('-')[1] notification.id.split('-')[1]
}`} }`}
target="_blank" target="_blank"
@@ -645,200 +646,199 @@ const ListNotification = memo(function ListNotification(props) {
</> </>
); );
const renderDetailsNotification = () => { const renderDetailsNotification = () => {
if (!selectedNotification) return null; if (!selectedNotification) return null;
const { IconComponent, color } = getIconAndColor(selectedNotification.type); const { IconComponent, color } = getIconAndColor(selectedNotification.type);
return ( return (
<Space direction="vertical" size="large" style={{ width: '100%' }}> <Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Row gutter={[16, 16]}> <Row gutter={[16, 8]}>
{/* Kolom Kiri: Data Kompresor */} {/* Kolom Kiri: Data Kompresor */}
<Col span={12}> <Col span={12}>
<Card <Card
title="" title=""
size="small" size="small"
style={{ height: '100%', borderColor: '#d4380d' }} style={{ height: '100%', borderColor: '#d4380d' }}
bodyStyle={{ padding: '12px' }} bodyStyle={{ padding: '12px' }}
> >
<Space direction="vertical" size="large" style={{ width: '100%' }}> <Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Row gutter={16} align="middle"> <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>{selectedNotification.title}</Text>
<div style={{ marginTop: '2px' }}>
<Text strong style={{ fontSize: '16px' }}>
{selectedNotification.issue}
</Text>
</div>
</Col>
</Row>
<div>
<Text strong>Plant Subsection</Text>
<div>{selectedNotification.subsection}</div>
<Text strong style={{ display: 'block', marginTop: '8px' }}>
Time
</Text>
<div>{selectedNotification.timestamp.split(' ')[1]} WIB</div>
</div>
<div
style={{
border: '1px solid #d4380d',
borderRadius: '4px',
padding: '8px',
background: 'linear-gradient(to right, #ffe7e6, #ffffff)',
}}
>
<Row justify="space-around" align="middle">
<Col> <Col>
<Text style={{ fontSize: '12px', color: color }}>
Value
</Text>
<div <div
style={{ style={{
fontWeight: 'bold', width: '32px',
fontSize: '16px', height: '32px',
color: color, borderRadius: '50%',
backgroundColor: '#d4380d',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#ffffff',
fontSize: '18px',
}} }}
> >
N/A <CloseOutlined />
</div> </div>
</Col> </Col>
<Col> <Col>
<Text type="secondary" style={{ fontSize: '12px' }}> <Text>{selectedNotification.title}</Text>
Treshold <div style={{ marginTop: '2px' }}>
</Text> <Text strong style={{ fontSize: '16px' }}>
<div style={{ fontWeight: 500 }}>N/A</div> {selectedNotification.issue}
</Text>
</div>
</Col> </Col>
</Row> </Row>
</div> <div>
</Space> <Text strong>Plant Subsection</Text>
</Card> <div>{selectedNotification.subsection}</div>
</Col> <Text strong style={{ display: 'block', marginTop: '8px' }}>
Date & Time
{/* Kolom Kanan: Informasi Teknis */} </Text>
<Col span={12}> <div>{selectedNotification.timestamp}</div>
<Card title="Informasi Teknis" size="small" style={{ height: '100%' }}>
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<div>
<Text strong>PLC</Text>
<div>{selectedNotification.plc}</div>
</div>
<div>
<Text strong>Status</Text>
<div style={{ color: '#faad14', fontWeight: 500 }}>
{selectedNotification.status}
</div> </div>
</div>
<div>
<Text strong>Tag</Text>
<div <div
style={{ style={{
fontFamily: 'monospace', border: '1px solid #d4380d',
backgroundColor: '#f0f0f0',
padding: '2px 6px',
borderRadius: '4px', borderRadius: '4px',
display: 'inline-block', padding: '8px',
background: 'linear-gradient(to right, #ffe7e6, #ffffff)',
}} }}
> >
{selectedNotification.tag} <Row justify="space-around" align="middle">
<Col>
<Text style={{ fontSize: '12px', color: color }}>
Value
</Text>
<div
style={{
fontWeight: 'bold',
fontSize: '16px',
color: color,
}}
>
N/A
</div>
</Col>
<Col>
<Text type="secondary" style={{ fontSize: '12px' }}>
Treshold
</Text>
<div style={{ fontWeight: 500 }}>N/A</div>
</Col>
</Row>
</div> </div>
</div>
</Space>
</Card>
</Col>
</Row>
<div>
<Row gutter={[16, 16]}>
<Col span={8}>
<Card
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
}}
bodyStyle={{ padding: '12px' }}
>
<Space>
<BookOutlined style={{ fontSize: '16px', color: '#1890ff' }} />
<Text strong style={{ fontSize: '16px', color: '#262626' }}>
Handling Guideline
</Text>
</Space> </Space>
</Card> </Card>
</Col> </Col>
<Col span={8}>
<Card {/* Kolom Kanan: Informasi Teknis */}
style={{ <Col span={12}>
display: 'flex', <Card title="Informasi Teknis" size="small" style={{ height: '100%' }}>
alignItems: 'center', <Space direction="vertical" size="middle" style={{ width: '100%' }}>
justifyContent: 'center', <div>
cursor: 'pointer', <Text strong>PLC</Text>
}} <div>{selectedNotification.plc}</div>
bodyStyle={{ padding: '12px' }} </div>
> <div>
<Space> <Text strong>Status</Text>
<ToolOutlined style={{ fontSize: '16px', color: '#1890ff' }} /> <div style={{ color: '#faad14', fontWeight: 500 }}>
<Text strong style={{ fontSize: '16px', color: '#262626' }}> {selectedNotification.status}
Spare Part </div>
</Text> </div>
</Space> <div>
</Card> <Text strong>Tag</Text>
</Col> <div
<Col span={8}> style={{
<Card fontFamily: 'monospace',
style={{ backgroundColor: '#f0f0f0',
display: 'flex', padding: '2px 6px',
alignItems: 'center', borderRadius: '4px',
justifyContent: 'center', display: 'inline-block',
cursor: 'pointer', }}
}} >
bodyStyle={{ padding: '12px' }} {selectedNotification.tag}
onClick={() => setModalContent('log')} </div>
> </div>
<Space>
<HistoryOutlined
style={{ fontSize: '16px', color: '#1890ff' }}
/>
<Text strong style={{ fontSize: '16px', color: '#262626' }}>
Log Activity
</Text>
</Space> </Space>
</Card> </Card>
</Col> </Col>
</Row> </Row>
<Row gutter={[16, 16]} style={{ marginTop: '16px' }}> <div>
<Col span={8}> <Row gutter={[16, 8]}>
<Card size="small" style={{ height: '100%' }}> <Col span={8}>
<Space direction="vertical" size="small" style={{ width: '100%' }}> <Card
<Card style={{
size="small" display: 'flex',
bodyStyle={{ padding: '8px 12px' }} alignItems: 'center',
hoverable justifyContent: 'center',
extra={ cursor: 'pointer',
<Text type="secondary" style={{ fontSize: '10px' }}> }}
PDF bodyStyle={{ padding: '12px' }}
</Text> >
} <Space>
> <BookOutlined style={{ fontSize: '16px', color: '#1890ff' }} />
<Text strong style={{ fontSize: '16px', color: '#262626' }}>
Handling Guideline
</Text>
</Space>
</Card>
</Col>
<Col span={8}>
<Card
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
}}
bodyStyle={{ padding: '12px' }}
>
<Space>
<ToolOutlined style={{ fontSize: '16px', color: '#1890ff' }} />
<Text strong style={{ fontSize: '16px', color: '#262626' }}>
Spare Part
</Text>
</Space>
</Card>
</Col>
<Col span={8}>
<Card
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
}}
bodyStyle={{ padding: '12px' }}
onClick={() => setModalContent('log')}
>
<Space>
<HistoryOutlined
style={{ fontSize: '16px', color: '#1890ff' }}
/>
<Text strong style={{ fontSize: '16px', color: '#262626' }}>
Log Activity
</Text>
</Space>
</Card>
</Col>
</Row>
<Row gutter={[16, 8]} style={{ marginTop: '0' }}>
<Col span={8}>
<Card size="small" style={{ height: '100%' }}>
<Space direction="vertical" size="small" style={{ width: '100%' }}>
<Card
size="small"
bodyStyle={{ padding: '8px 12px' }}
hoverable
extra={
<Text type="secondary" style={{ fontSize: '10px' }}>
PDF
</Text>
} >
<div <div
style={{ style={{
display: 'flex', display: 'flex',

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,793 @@
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 } from 'antd';
import {
ArrowLeftOutlined,
CloseCircleFilled,
WarningFilled,
CheckCircleFilled,
InfoCircleFilled,
CloseOutlined,
BookOutlined,
ToolOutlined,
HistoryOutlined,
FilePdfOutlined,
PlusOutlined,
UserOutlined,
LoadingOutlined,
} from '@ant-design/icons';
import { getNotificationDetail, createNotificationLog, getNotificationLogByNotificationId } from '../../api/notification';
import UserHistoryModal from '../notification/component/UserHistoryModal';
import LogHistoryCard from '../notification/component/LogHistoryCard';
const { Content } = Layout;
const { Text, Paragraph, Link } = Typography;
// Transform API response to component format
const transformNotificationData = (apiData) => {
// Extract nested data
const errorCodeData = apiData.error_code;
// 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,
},
};
};
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 = () => {
const { notificationId } = useParams(); // Mungkin perlu disesuaikan jika route berbeda
const navigate = useNavigate();
const [notification, setNotification] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [modalContent, setModalContent] = useState(null); // 'user', atau null
const [isAddingLog, setIsAddingLog] = useState(false);
// 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);
} else {
throw new Error('Notification not found');
}
} catch (err) {
setError(err.message);
console.error('Error fetching notification detail:', err);
} finally {
setLoading(false);
}
};
fetchDetail();
}, [notificationId]);
if (loading) {
return (
<Layout
style={{
minHeight: '100vh',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
}}
>
<Spin size="large" />
</Layout>
);
}
if (error || !notification) {
return (
<Layout
style={{
minHeight: '100vh',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
}}
>
<Result
status="404"
title="404"
subTitle="Sorry, the notification you visited does not exist."
extra={
<Button type="primary" onClick={() => navigate('/notification')}>
Back to List
</Button>
}
/>
</Layout>
);
}
const { color } = getIconAndColor(notification.type);
return (
<Layout style={{ padding: '24px', backgroundColor: '#f0f2f5' }}>
<Content>
<Card>
<div
style={{
borderBottom: '1px solid #f0f0f0',
paddingBottom: '16px',
marginBottom: '24px',
}}
>
<Row justify="space-between" align="middle">
<Col>
<Button
type="text"
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/notification')}
style={{ paddingLeft: 0 }}
>
Back to notification list
</Button>
</Col>
<Col>
<Button
icon={<UserOutlined />}
onClick={() => setModalContent('user')}
>
User History
</Button>
</Col>
</Row>
<div
style={{
backgroundColor: '#f6ffed',
border: '1px solid #b7eb8f',
borderRadius: '4px',
padding: '8px 16px',
textAlign: 'center',
marginTop: '16px',
}}
>
<Typography.Title level={4} style={{ margin: 0, color: '#262626' }}>
Error Notification Detail
</Typography.Title>
</div>
</div>
<Space direction="vertical" size="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' }}>
{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: Log History */}
<Col xs={24} lg={8}>
<LogHistoryCard
notificationData={notification}
logData={logHistoryData}
loading={logLoading}
/>
</Col>
</Row>
<Row gutter={[8, 8]} style={{ marginBottom: 'px' }}>
<Col xs={24} md={8}>
<Card
hoverable
bodyStyle={{ padding: '12px', textAlign: 'center' }}
>
<Space>
<BookOutlined
style={{ fontSize: '16px', color: '#1890ff' }}
/>
<Text strong style={{ fontSize: '16px', color: '#262626' }}>
Handling Guideline
</Text>
</Space>
</Card>
</Col>
<Col xs={24} md={8}>
<Card
hoverable
bodyStyle={{ padding: '12px', textAlign: 'center' }}
>
<Space>
<ToolOutlined
style={{ fontSize: '16px', color: '#1890ff' }}
/>
<Text strong style={{ fontSize: '16px', color: '#262626' }}>
Spare Part
</Text>
</Space>
</Card>
</Col>
<Col xs={24} md={8} style={{ cursor: 'pointer' }}>
<Card
hoverable
bodyStyle={{ padding: '12px', textAlign: 'center' }}
>
<Space>
<HistoryOutlined
style={{ fontSize: '16px', color: '#1890ff' }}
/>
<Text strong style={{ fontSize: '16px', color: '#262626' }}>
Log Activity
</Text>
</Space>
</Card>
</Col>
</Row>
<Row gutter={[8, 8]} style={{ marginTop: '-12px' }}>
<Col xs={24} md={8}>
<Card
size="small"
title="Guideline Documents"
style={{ height: '100%' }}
>
<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"
bodyStyle={{
padding: '8px 12px',
marginBottom: '4px',
}}
extra={
<Text
type="secondary"
style={{
fontSize: '10px',
}}
>
{sol.type_solution.toUpperCase()}
</Text>
}
>
<div>
<Text strong>
{sol.solution_name}:
</Text>
<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>
</Col>
<Col xs={24} md={8}>
<Card
size="small"
title="Required Spare Parts"
style={{ height: '100%' }}
>
<Space
direction="vertical"
size="small"
style={{ width: '100%' }}
>
{notification.spareparts &&
notification.spareparts.length > 0 ? (
notification.spareparts.map((sparepart, index) => (
<Card
size="small"
key={index}
bodyStyle={{ padding: '12px' }}
hoverable
>
<Row gutter={16} align="top">
<Col
span={7}
style={{ textAlign: 'center' }}
>
<div
style={{
width: '100%',
height: '60px',
backgroundColor: '#f0f0f0',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: '4px',
marginBottom: '8px',
}}
>
<ToolOutlined
style={{
fontSize: '24px',
color: '#bfbfbf',
}}
/>
</div>
<Text
style={{
fontSize: '12px',
color:
sparepart.sparepart_stok ===
'Available' ||
sparepart.sparepart_stok ===
'available'
? '#52c41a'
: '#ff4d4f',
fontWeight: 500,
}}
>
{sparepart.sparepart_stok}
</Text>
</Col>
<Col span={17}>
<Space
direction="vertical"
size={4}
style={{ width: '100%' }}
>
<Text strong>
{sparepart.sparepart_name}
</Text>
<Paragraph
style={{
fontSize: '12px',
margin: 0,
color: '#595959',
}}
>
{sparepart.sparepart_description ||
'Deskripsi tidak tersedia'}
</Paragraph>
<div
style={{
border: '1px solid #d9d9d9',
borderRadius: '4px',
padding: '4px 8px',
fontSize: '11px',
color: '#8c8c8c',
marginTop: '8px',
}}
>
Kode: {sparepart.sparepart_code}{' '}
| Qty: {sparepart.sparepart_qty}{' '}
| Unit:{' '}
{sparepart.sparepart_unit}
</div>
</Space>
</Col>
</Row>
</Card>
))
) : (
<div
style={{
textAlign: 'center',
padding: '20px',
color: '#8c8c8c',
}}
>
Tidak ada spare parts terkait
</div>
)}
</Space>
</Card>
</Col>
<Col span={8}>
<Card size="small" style={{ height: '100%' }}>
<Space
direction="vertical"
size="small"
style={{ width: '100%' }}
>
<Card
size="small"
bodyStyle={{
padding: '8px 12px',
backgroundColor: isAddingLog ? '#fafafa' : '#fff',
}}
>
<Space
direction="vertical"
style={{ width: '100%' }}
size="small"
>
{isAddingLog && (
<>
<Text strong style={{ fontSize: '12px' }}>
Add New Log / Update Progress
</Text>
<Input.TextArea
rows={2}
placeholder="Tuliskan update penanganan di sini..."
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>
</Col>
</Row>
</Space>
</Card>
</Content>
<UserHistoryModal
visible={modalContent === 'user'}
onCancel={() => setModalContent(null)}
notificationData={notification}
/>
</Layout>
);
};
export default NotificationDetailTab;

View File

@@ -1,91 +1,218 @@
import React, { memo, useState, useEffect } from 'react'; 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, Table, Spin, Modal } from 'antd';
import TableList from '../../../../components/Global/TableList';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { FileTextOutlined } from '@ant-design/icons'; import { FileTextOutlined, DownloadOutlined, LoadingOutlined } from '@ant-design/icons';
import { import {
getAllHistoryValueReport,
getAllHistoryValueReportPivot, getAllHistoryValueReportPivot,
getAllHistoryValueReport,
} from '../../../../api/history-value'; } from '../../../../api/history-value';
import { getAllPlantSection } from '../../../../api/master-plant-section'; import { getAllPlantSection } from '../../../../api/master-plant-section';
import jsPDF from 'jspdf';
import autoTable from 'jspdf-autotable';
const { Text } = Typography; const { Text } = Typography;
const ListReport = memo(function ListReport(props) { const ListReport = memo(function ListReport(props) {
const columns = [
{
title: 'No',
key: 'no',
width: '5%',
align: 'center',
render: (_, __, index) => index + 1,
},
{
title: 'Datetime',
dataIndex: 'datetime',
key: 'datetime',
width: '15%',
},
{
title: 'Tag Name',
dataIndex: 'tag_name',
key: 'tag_name',
width: '70%',
},
// {
// title: 'Value',
// dataIndex: 'val',
// key: 'val',
// width: '10%',
// render: (_, record) => Number(record.val).toFixed(4),
// },
// {
// title: 'Stat',
// dataIndex: 'status',
// key: 'status',
// width: '10%',
// },
];
const dateNow = dayjs(); const dateNow = dayjs();
const dateNowFormated = dateNow.format('YYYY-MM-DD'); const dateNowFormated = dateNow.format('YYYY-MM-DD');
const [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 [plantSubSection, setPlantSubSection] = useState(0);
const [plantSubSectionList, setPlantSubSectionList] = useState([]); const [plantSubSectionList, setPlantSubSectionList] = useState([]);
const [startDate, setStartDate] = useState(dateNow); const [startDate, setStartDate] = useState(dateNow);
const [endDate, setEndDate] = useState(dateNow); const [endDate, setEndDate] = useState(dateNow);
const [periode, setPeriode] = useState(10); const [periode, setPeriode] = useState(30);
const defaultFilter = { const generateFullDayTimes = (dateString, intervalMinutes) => {
criteria: '', const times = [];
plant_sub_section_id: 0, const startOfDay = dayjs(dateString).startOf('day');
from: dateNowFormated, const endOfDay = dayjs(dateString).endOf('day');
to: dateNowFormated,
interval: periode, let currentTime = startOfDay;
while (currentTime.isBefore(endOfDay) || currentTime.isSame(endOfDay)) {
times.push(currentTime.format('YYYY-MM-DD HH:mm:ss'));
currentTime = currentTime.add(intervalMinutes, 'minute');
if (currentTime.isAfter(endOfDay)) {
break;
}
}
return times;
};
const fetchData = async (page = 1, pageSize = 10, showModal = false) => {
if (!plantSubSection) {
return;
}
if (showModal) {
setIsLoadingModal(true);
} else {
setIsLoadingTable(true);
}
try {
const formattedDateStart = startDate.format('YYYY-MM-DD');
const formattedDateEnd = endDate.format('YYYY-MM-DD');
const params = new URLSearchParams({
plant_sub_section_id: plantSubSection,
from: formattedDateStart,
to: formattedDateEnd,
interval: periode,
page: 1,
limit: 1000,
});
const pivotResponse = await getAllHistoryValueReportPivot(params);
const valueReportResponse = await getAllHistoryValueReportPivot(params);
if (pivotResponse && pivotResponse.data) {
console.log('API Pivot Response:', pivotResponse);
setPivotData(pivotResponse.data);
if (valueReportResponse && valueReportResponse.data) {
console.log('API Value Report Response:', valueReportResponse);
setValueReportData(valueReportResponse.data);
}
// Buat struktur pivot: waktu sebagai baris, tag sebagai kolom
const timeMap = new Map();
const tagSet = new Set();
// Kumpulkan semua waktu unik dan tag unik
pivotResponse.data.forEach((row) => {
const tagName = row.id;
tagSet.add(tagName);
const dataPoints = row.data || [];
dataPoints.forEach((item) => {
if (item && typeof item === 'object' && 'x' in item && 'y' in item) {
const datetime = item.x;
if (!timeMap.has(datetime)) {
timeMap.set(datetime, {});
}
timeMap.get(datetime)[tagName] = item.y;
}
});
});
// Konversi ke array dan sort berdasarkan waktu
const sortedTimes = Array.from(timeMap.keys()).sort();
const sortedTags = Array.from(tagSet).sort();
// Buat data untuk table
const pivotTableData = sortedTimes.map((datetime, index) => {
const rowData = {
key: index,
datetime: datetime,
};
sortedTags.forEach((tagName) => {
rowData[tagName] = timeMap.get(datetime)[tagName];
});
return rowData;
});
console.log('Pivot table data sample:', pivotTableData.slice(0, 5));
console.log('Total pivot rows:', pivotTableData.length);
// Buat kolom dinamis
const dynamicColumns = [
{
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 [formDataFilter, setFormDataFilter] = useState(defaultFilter);
const handleSearch = () => { const handleSearch = () => {
const formattedDateStart = startDate.format('YYYY-MM-DD'); fetchData(1, pagination.pageSize, true);
const formattedDateEnd = endDate.format('YYYY-MM-DD');
setFormDataFilter({
criteria: '',
plant_sub_section_id: plantSubSection,
from: formattedDateStart,
to: formattedDateEnd,
interval: periode,
});
setTrigerFilter((prev) => !prev);
}; };
const handleReset = () => { const handleReset = () => {
setPlantSubSection(0); setPlantSubSection(0);
setStartDate(dateNow); setStartDate(dateNow);
setEndDate(dateNow); setEndDate(dateNow);
setPeriode(5); setPeriode(30);
setTableData([]);
setColumns([]);
setPivotData([]);
setValueReportData([]);
setPagination({
current: 1,
pageSize: 10,
total: 0,
});
}; };
const getPlantSubSection = async () => { const getPlantSubSection = async () => {
@@ -104,8 +231,386 @@ const ListReport = memo(function ListReport(props) {
getPlantSubSection(); getPlantSubSection();
}, []); }, []);
const isWithinOneDay = startDate.isSame(endDate, 'day');
useEffect(() => {
if (!isWithinOneDay && periode < 60) {
setPeriode(60);
}
}, [startDate, endDate, periode, isWithinOneDay]);
const periodeOptions = [
{ value: 5, label: '5 Minute', disabled: !isWithinOneDay },
{ value: 10, label: '10 Minute', disabled: !isWithinOneDay },
{ value: 30, label: '30 Minute', disabled: !isWithinOneDay },
{ value: 60, label: '1 Hour', disabled: false },
{ value: 120, label: '2 Hour', disabled: false },
];
const 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(`Plant Section : ${sectionName}`, marginLeft + col1Width + col2Width / 2, 41, { 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 ? 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: (data) => {
doc.setFontSize(8);
doc.setFont('helvetica', 'normal');
doc.text(
`Page ${globalPageNumber} of ${actualTotalPages}`,
doc.internal.pageSize.width / 2,
doc.internal.pageSize.height - 10,
{ align: 'center' }
);
globalPageNumber++;
},
});
}
doc.save(`Report_Pivot_${startDate.format('DD-MM-YYYY')}_to_${endDate.format('DD-MM-YYYY')}.pdf`);
};
return ( return (
<React.Fragment> <React.Fragment>
<Modal
open={isLoadingModal}
footer={null}
closable={false}
centered
width={400}
bodyStyle={{
textAlign: 'center',
padding: '40px 20px'
}}
>
<Spin
indicator={<LoadingOutlined style={{ fontSize: 48, color: '#1890ff' }} spin />}
/>
<div style={{ marginTop: '24px' }}>
<Typography.Title level={4} style={{ marginBottom: '8px' }}>
Please Wait
</Typography.Title>
<Typography.Text type="secondary">
System is generating report data...
</Typography.Text>
</div>
</Modal>
<Card> <Card>
<Row> <Row>
<Col xs={24}> <Col xs={24}>
@@ -167,14 +672,8 @@ const ListReport = memo(function ListReport(props) {
value={periode} value={periode}
onChange={setPeriode} onChange={setPeriode}
style={{ width: '100%', marginTop: '4px' }} style={{ width: '100%', marginTop: '4px' }}
options={[ options={periodeOptions}
{ value: 5, label: '5 Minute' }, />
{ value: 10, label: '10 Minute' },
{ value: 30, label: '30 Minute' },
{ value: 60, label: '1 Hour' },
{ value: 120, label: '2 Hour' },
]}
></Select>
</div> </div>
</Col> </Col>
</Row> </Row>
@@ -185,10 +684,21 @@ const ListReport = memo(function ListReport(props) {
danger danger
icon={<FileTextOutlined />} icon={<FileTextOutlined />}
onClick={handleSearch} onClick={handleSearch}
disabled={false}
> >
Show Show
</Button> </Button>
</Col> </Col>
<Col>
<Button
type="primary"
icon={<DownloadOutlined />}
onClick={exportToPDF}
disabled={false}
>
Export PDF
</Button>
</Col>
<Col> <Col>
<Button <Button
onClick={handleReset} onClick={handleReset}
@@ -199,18 +709,26 @@ const ListReport = memo(function ListReport(props) {
</Col> </Col>
</Row> </Row>
</Col> </Col>
<Col xs={24} sm={24} md={24} lg={24} xl={24} style={{ marginTop: '16px' }}> <Col xs={24} style={{ marginTop: '16px' }}>
<TableList <Spin spinning={isLoadingTable}>
firstLoad={false} <div style={{ overflowX: 'auto', width: '100%' }}>
mobile <Table
cardColor={'#d38943ff'} columns={columns}
header={'datetime'} dataSource={tableData}
getData={getAllHistoryValueReportPivot} pagination={{
queryParams={formDataFilter} ...pagination,
columns={columns} showSizeChanger: true,
columnDynamic={'columns'} showTotal: (total) => `Total ${total} data`,
triger={trigerFilter} pageSizeOptions: ['10', '20', '50', '100'],
/> }}
onChange={handleTableChange}
scroll={{ x: 'max-content', y: 500 }}
bordered
size="small"
sticky
/>
</div>
</Spin>
</Col> </Col>
</Row> </Row>
</Card> </Card>
@@ -218,4 +736,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 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 dayjs from 'dayjs';
import { FileTextOutlined } from '@ant-design/icons'; import { FileTextOutlined, LoadingOutlined } from '@ant-design/icons';
import { ResponsiveLine } from '@nivo/line'; import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer
} from 'recharts';
import './trending.css'; import './trending.css';
import { getAllPlantSection } from '../../../api/master-plant-section'; import { getAllPlantSection } from '../../../api/master-plant-section';
import { getAllHistoryValueTrendingPivot } from '../../../api/history-value'; import { getAllHistoryValueTrendingPivot } from '../../../api/history-value';
@@ -18,6 +27,7 @@ const ReportTrending = memo(function ReportTrending(props) {
const [startDate, setStartDate] = useState(dateNow); const [startDate, setStartDate] = useState(dateNow);
const [endDate, setEndDate] = useState(dateNow); const [endDate, setEndDate] = useState(dateNow);
const [periode, setPeriode] = useState(60); const [periode, setPeriode] = useState(60);
const [isLoading, setIsLoading] = useState(false);
const defaultFilter = { const defaultFilter = {
criteria: '', criteria: '',
@@ -29,51 +39,83 @@ const ReportTrending = memo(function ReportTrending(props) {
const [formDataFilter, setFormDataFilter] = useState(defaultFilter); const [formDataFilter, setFormDataFilter] = useState(defaultFilter);
const [trendingValue, setTrendingValue] = useState([]); const [trendingValue, setTrendingValue] = useState([]);
const [chartData, setChartData] = useState([]);
const [metrics, setMetrics] = useState([]);
// Palet warna
const colorPalette = [
'#3b82f6', '#ef4444', '#10b981', '#f59e0b', '#8b5cf6',
'#ec4899', '#14b8a6', '#f97316', '#6366f1', '#84cc16'
];
const handleSearch = async () => { const handleSearch = async () => {
const formattedDateStart = startDate.format('YYYY-MM-DD'); setIsLoading(true);
const formattedDateEnd = endDate.format('YYYY-MM-DD');
try {
const formattedDateStart = startDate.format('YYYY-MM-DD');
const formattedDateEnd = endDate.format('YYYY-MM-DD');
const newFilter = { const newFilter = {
criteria: '', criteria: '',
plant_sub_section_id: plantSubSection, plant_sub_section_id: plantSubSection,
from: formattedDateStart, from: formattedDateStart,
to: formattedDateEnd, to: formattedDateEnd,
interval: periode, interval: periode,
}; };
setFormDataFilter(newFilter); setFormDataFilter(newFilter);
const param = new URLSearchParams(newFilter); const param = new URLSearchParams(newFilter);
const response = await getAllHistoryValueTrendingPivot(param); const response = await getAllHistoryValueTrendingPivot(param);
if (response?.data?.length > 0) { if (response?.data?.length > 0) {
// 🔹 Bersihkan dan format data agar aman untuk Nivo transformDataForRecharts(response.data);
const cleanedData = response.data.map((serie) => ({ } else {
id: serie.id ?? 'Unknown', setTrendingValue([]);
data: Array.isArray(serie.data) setChartData([]);
? serie.data.map((d) => ({ setMetrics([]);
x: d?.x ?? null, }
y: } catch (error) {
d?.y !== null && d?.y !== undefined console.error('Error fetching trending data:', error);
? Number(d.y).toFixed(4) // format 4 angka di belakang koma } finally {
: null, setIsLoading(false);
}))
: [],
}));
setTrendingValue(cleanedData);
} else {
// 🔹 Jika tidak ada data dari API
setTrendingValue([]);
} }
}; };
const transformDataForRecharts = (nivoData) => {
setTrendingValue(nivoData);
const metricNames = nivoData.map(serie => serie.id);
setMetrics(metricNames);
const timeMap = new Map();
nivoData.forEach(serie => {
serie.data.forEach(point => {
if (!timeMap.has(point.x)) {
timeMap.set(point.x, { time: point.x });
}
const entry = timeMap.get(point.x);
entry[serie.id] = point.y !== null && point.y !== undefined
? parseFloat(point.y)
: null;
});
});
const transformedData = Array.from(timeMap.values()).sort((a, b) =>
new Date(a.time) - new Date(b.time)
);
setChartData(transformedData);
};
const handleReset = () => { const handleReset = () => {
setPlantSubSection(0); setPlantSubSection(0);
setStartDate(dateNow); setStartDate(dateNow);
setEndDate(dateNow); setEndDate(dateNow);
setPeriode(5); setPeriode(60);
setChartData([]);
setMetrics([]);
}; };
const getPlantSubSection = async () => { const getPlantSubSection = async () => {
@@ -88,12 +130,154 @@ const ReportTrending = memo(function ReportTrending(props) {
} }
}; };
const formatXAxis = (tickItem) => {
const date = new Date(tickItem);
return `${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`;
};
const CustomTooltip = ({ active, payload, label }) => {
if (active && payload && payload.length) {
return (
<div style={{
backgroundColor: 'rgba(255, 255, 255, 0.98)',
padding: '12px',
border: '1px solid #ccc',
borderRadius: '8px',
boxShadow: '0 2px 8px rgba(0,0,0,0.15)'
}}>
<p style={{ margin: 0, fontWeight: 'bold', marginBottom: '8px' }}>
{new Date(label).toLocaleString('id-ID')}
</p>
{payload.map((entry, index) => (
<p key={index} style={{
margin: '4px 0',
color: entry.color,
fontSize: '13px'
}}>
<strong>{entry.name}:</strong> {Number(entry.value).toFixed(4)}
</p>
))}
</div>
);
}
return null;
};
const renderChart = () => {
if (!chartData || chartData.length === 0) {
return (
<div style={{
textAlign: 'center',
marginTop: '100px',
color: '#999',
fontSize: '16px'
}}>
Tidak ada data untuk ditampilkan
</div>
);
}
return (
<ResponsiveContainer width="100%" height={500}>
<LineChart
data={chartData}
margin={{ top: 20, right: 200, left: 80, bottom: 40 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="#e0e0e0" />
<XAxis
dataKey="time"
angle={-45}
textAnchor="end"
height={100}
tick={{ fontSize: 11 }}
tickFormatter={formatXAxis}
label={{
value: 'Waktu',
position: 'bottom',
offset: -50,
style: { fontSize: 14, fontWeight: 'bold' }
}}
/>
<YAxis
tick={{ fontSize: 11 }}
label={{
value: 'Nilai',
angle: -90,
position: 'right',
offset: -70,
dy: 0,
style: {
fontSize: 12,
fontWeight: 'bold',
fill: '#059669',
textAnchor: 'middle'
}
}}
tickFormatter={(value) => Number(value).toFixed(2)}
/>
<Tooltip content={<CustomTooltip />} />
<Legend
layout="vertical"
align="right"
verticalAlign="middle"
wrapperStyle={{
position: 'absolute',
right: 150,
top: '35%',
transform: 'translateY(-50%)'
}}
/>
{metrics.map((metric, index) => {
const color = colorPalette[index % colorPalette.length];
return (
<Line
key={metric}
type="monotone"
dataKey={metric}
stroke={color}
strokeWidth={2}
dot={chartData.length < 50}
name={metric}
connectNulls={true}
/>
);
})}
</LineChart>
</ResponsiveContainer>
);
};
useEffect(() => { useEffect(() => {
getPlantSubSection(); getPlantSubSection();
}, []); }, []);
return ( return (
<React.Fragment> <React.Fragment>
{/* Loading Modal */}
<Modal
open={isLoading}
footer={null}
closable={false}
centered
width={400}
bodyStyle={{
textAlign: 'center',
padding: '40px 20px'
}}
>
<Spin
indicator={<LoadingOutlined style={{ fontSize: 48, color: '#1890ff' }} spin />}
/>
<div style={{ marginTop: '24px' }}>
<Typography.Title level={4} style={{ marginBottom: '8px' }}>
Please Wait
</Typography.Title>
<Typography.Text type="secondary">
System is generating trending data...
</Typography.Text>
</div>
</Modal>
<Card> <Card>
<Row> <Row>
<Col xs={24}> <Col xs={24}>
@@ -162,10 +346,11 @@ const ReportTrending = memo(function ReportTrending(props) {
{ value: 60, label: '1 Hour' }, { value: 60, label: '1 Hour' },
{ value: 120, label: '2 Hour' }, { value: 120, label: '2 Hour' },
]} ]}
></Select> />
</div> </div>
</Col> </Col>
</Row> </Row>
<Row gutter={8} style={{ marginTop: '16px' }}> <Row gutter={8} style={{ marginTop: '16px' }}>
<Col> <Col>
<Button <Button
@@ -187,108 +372,9 @@ const ReportTrending = memo(function ReportTrending(props) {
</Col> </Col>
</Row> </Row>
</Col> </Col>
<Col xs={24} sm={24} md={24} lg={24} xl={24} style={{ marginTop: '16px' }}>
<div style={{ height: '500px', marginTop: '16px' }}> <Col xs={24} sm={24} md={24} lg={24} xl={24} style={{ marginTop: '24px' }}>
{trendingValue && trendingValue.length > 0 ? ( {renderChart()}
<ResponsiveLine
data={trendingValue} // [{ id, data: [{x, y}] }]
// data={
// trendingValue && trendingValue.length
// ? trendingValue
// : [{ id, data: [{ x, y }] }]
// }
margin={{ top: 40, right: 100, bottom: 70, left: 70 }}
xScale={{
type: 'time',
format: '%Y-%m-%d %H:%M',
useUTC: false,
precision: 'minute',
}}
xFormat="time:%Y-%m-%d %H:%M"
yScale={{
type: 'linear',
min: 'auto',
max: 'auto',
stacked: false,
reverse: false,
}}
yFormat={(value) => Number(value).toFixed(4)} // ✅ format 4 angka di belakang koma
axisBottom={{
format: '%Y-%m-%d %H:%M', // ✅ tampilkan tanggal + jam
tickValues: 'every 2 hours', // tampilkan setiap 2 jam (bisa ubah ke every 30 minutes)
tickSize: 5,
tickPadding: 5,
tickRotation: -45,
legend: 'Tanggal & Waktu',
legendOffset: 60,
legendPosition: 'middle',
}}
axisLeft={{
tickSize: 5,
tickPadding: 5,
tickRotation: 0,
legend: 'Nilai (Avg)',
legendOffset: -60,
legendPosition: 'middle',
format: (value) => Number(value).toFixed(4), // ✅ tampilkan 4 angka di sumbu Y
}}
curve="monotoneX"
colors={{ scheme: 'category10' }}
pointSize={6}
pointColor={{ theme: 'background' }}
pointBorderWidth={2}
pointBorderColor={{ from: 'serieColor' }}
enablePointLabel={false}
enableGridX={true}
enableGridY={true}
useMesh={true}
tooltip={({ point }) => (
<div
style={{
background: 'white',
padding: '6px 9px',
border: '1px solid #ccc',
borderRadius: '6px',
}}
>
<strong>{point.serieId}</strong>
<br />
{point.data.xFormatted}
<br />
<span style={{ color: point.serieColor }}>
{Number(point.data.y).toFixed(4)}
</span>
</div>
)}
legends={[
{
anchor: 'bottom-right',
direction: 'column',
justify: false,
translateX: 100,
translateY: 0,
itemsSpacing: 2,
itemDirection: 'left-to-right',
itemWidth: 120,
itemHeight: 20,
itemOpacity: 0.85,
symbolSize: 12,
symbolShape: 'circle',
},
]}
/>
) : (
<div
style={{
textAlign: 'center',
marginTop: '40px',
color: '#999',
}}
>
Tidak ada data untuk ditampilkan
</div>
)}
</div>
</Col> </Col>
</Row> </Row>
</Card> </Card>
@@ -296,4 +382,4 @@ const ReportTrending = memo(function ReportTrending(props) {
); );
}); });
export default ReportTrending; export default ReportTrending;

View File

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