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

Reviewed-on: #23
This commit is contained in:
2025-11-28 05:10:26 +00:00
9 changed files with 942 additions and 345 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -1,9 +1,21 @@
import { SendRequest } from '../components/Global/ApiRequest'; import { SendRequest } from '../components/Global/ApiRequest';
export const getAllNotification = async () => { const getAllNotification = async (queryParams) => {
const response = await SendRequest({ const response = await SendRequest({
method: 'get', method: 'get',
prefix: 'notification', prefix: `notification?${queryParams.toString()}`,
}); });
return response.data; return response.data;
}; };
const getNotificationById = async (id) => {
const response = await SendRequest({
method: 'get',
prefix: `notification/${id}`,
});
return response.data;
};
export { getAllNotification, getNotificationById };

View File

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

View File

@@ -159,7 +159,7 @@ const EditBrandDevice = () => {
sparepart: ec.sparepart || [], sparepart: ec.sparepart || [],
errorCodeIcon: ec.path_icon errorCodeIcon: ec.path_icon
? { ? {
name: 'icon', name: ec.path_icon.split('/').pop(), // Ambil nama file dari path
uploadPath: ec.path_icon, uploadPath: ec.path_icon,
url: (() => { url: (() => {
const pathParts = ec.path_icon.split('/'); const pathParts = ec.path_icon.split('/');

View File

@@ -152,7 +152,7 @@ const ErrorCodeSimpleForm = ({
<div style={{ marginTop: 8 }}> <div style={{ marginTop: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<img <img
src={errorCodeIcon.uploadPath} src={errorCodeIcon.url || errorCodeIcon.uploadPath}
alt="Error Code Icon" alt="Error Code Icon"
style={{ style={{
width: 50, width: 50,

View File

@@ -1,14 +1,40 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Modal, Input, Divider, Typography, Switch, Button, ConfigProvider, message } from 'antd'; import {
Modal,
Input,
Select,
Divider,
Typography,
Button,
ConfigProvider,
Upload,
Row,
Col,
Image,
} from 'antd';
import { PlusOutlined } 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 { validateRun } from '../../../../Utils/validate'; import { validateRun } from '../../../../Utils/validate';
const { Text } = Typography; const { Text } = Typography;
const { TextArea } = Input; const { TextArea } = Input;
const getBase64 = (file) =>
new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => resolve(reader.result);
reader.onerror = (error) => reject(error);
});
const DetailSparepart = (props) => { const DetailSparepart = (props) => {
const [confirmLoading, setConfirmLoading] = useState(false); const [confirmLoading, setConfirmLoading] = useState(false);
const [fileList, setFileList] = useState([]);
const [previewOpen, setPreviewOpen] = useState(false);
const [previewImage, setPreviewImage] = useState('');
const [previewTitle, setPreviewTitle] = useState('');
const defaultData = { const defaultData = {
sparepart_id: '', sparepart_id: '',
@@ -18,7 +44,8 @@ const DetailSparepart = (props) => {
sparepart_item_type: '', sparepart_item_type: '',
sparepart_unit: '', sparepart_unit: '',
sparepart_merk: '', sparepart_merk: '',
sparepart_stok: '', sparepart_stok: '0',
sparepart_foto: '',
}; };
const [formData, setFormData] = useState(defaultData); const [formData, setFormData] = useState(defaultData);
@@ -26,60 +53,200 @@ const DetailSparepart = (props) => {
const handleCancel = () => { const handleCancel = () => {
props.setSelectedData(null); props.setSelectedData(null);
props.setActionMode('list'); props.setActionMode('list');
setFileList([]);
}; };
const handlePreviewCancel = () => setPreviewOpen(false);
const handlePreview = async (file) => {
if (!file.url && !file.preview) {
file.preview = await getBase64(file.originFileObj);
}
setPreviewImage(file.url || file.preview);
setPreviewOpen(true);
setPreviewTitle(file.name || file.url.substring(file.url.lastIndexOf('/') + 1));
};
const handleChange = ({ fileList: newFileList }) => setFileList(newFileList);
const handleSave = async () => { const handleSave = async () => {
setConfirmLoading(true); setConfirmLoading(true);
// Daftar aturan validasi
const validationRules = [ const validationRules = [
{ field: 'sparepart_name', label: 'Sparepart Name', required: true }, { field: 'sparepart_name', label: 'Sparepart Name', required: true },
{ field: 'sparepart_model', label: 'Sparepart Model', required: true },
{ field: 'sparepart_unit', label: 'Sparepart Unit', required: true },
{ field: 'sparepart_merk', label: 'Sparepart Merk', required: true },
{ field: 'sparepart_stok', label: 'Sparepart Stok', required: true },
]; ];
if ( if (
validateRun(formData, validationRules, (errorMessages) => { validateRun(formData, validationRules, (errorMessages) => {
NotifOk({ NotifOk({ icon: 'warning', title: 'Peringatan', message: errorMessages });
icon: 'warning',
title: 'Peringatan',
message: errorMessages,
});
setConfirmLoading(false); setConfirmLoading(false);
}) })
) )
return; return;
try { try {
let imageUrl = formData.sparepart_foto;
const newFile = fileList.length > 0 ? fileList[0] : null;
if (newFile && newFile.originFileObj) {
console.log('Uploading file:', newFile.originFileObj);
const uploadResponse = await uploadFile(newFile.originFileObj, 'images');
// Log untuk debugging
console.log('Upload response:', uploadResponse);
// Cek berbagai kemungkinan struktur respons dari API
let uploadedUrl = null;
// Cek berbagai kemungkinan struktur respons dari API
// Cek langsung properti file_url atau url
if (uploadResponse && typeof uploadResponse === 'object') {
// Cek jika uploadResponse langsung memiliki file_url
if (uploadResponse.file_url) {
uploadedUrl = uploadResponse.file_url;
}
// Cek jika uploadResponse memiliki data yang berisi file_url
else if (uploadResponse.data && uploadResponse.data.file_url) {
uploadedUrl = uploadResponse.data.file_url;
}
// Cek jika uploadResponse memiliki data yang berisi url
else if (uploadResponse.data && uploadResponse.data.url) {
uploadedUrl = uploadResponse.data.url;
}
// Cek jika uploadResponse langsung memiliki url
else if (uploadResponse.url) {
uploadedUrl = uploadResponse.url;
}
// Cek jika uploadResponse.data adalah string URL
else if (uploadResponse.data && typeof uploadResponse.data === 'string') {
uploadedUrl = uploadResponse.data;
}
// Cek jika uploadResponse.data adalah objek yang berisi file URL dalam format berbeda
else if (uploadResponse.data && typeof uploadResponse.data === 'object') {
// Cek kemungkinan nama field lain
if (uploadResponse.data.file) {
uploadedUrl = uploadResponse.data.file;
} else if (uploadResponse.data.filename) {
// Jika hanya nama file dikembalikan, bangun URL
const baseUrl = import.meta.env.VITE_API_SERVER || '';
uploadedUrl = `${baseUrl}/uploads/images/${uploadResponse.data.filename}`;
} else if (uploadResponse.data.path) {
uploadedUrl = uploadResponse.data.path;
} else if (uploadResponse.data.location) {
uploadedUrl = uploadResponse.data.location;
}
// Tambahkan kemungkinan lain berdasarkan struktur respons umum
else if (uploadResponse.data.filePath) {
uploadedUrl = uploadResponse.data.filePath;
} else if (uploadResponse.data.file_path) {
uploadedUrl = uploadResponse.data.file_path;
} else if (uploadResponse.data.publicUrl) {
uploadedUrl = uploadResponse.data.publicUrl;
} else if (uploadResponse.data.public_url) {
uploadedUrl = uploadResponse.data.public_url;
}
// Berdasarkan log yang ditampilkan, API mengembalikan path_document atau path_solution
else if (uploadResponse.data.path_document) {
uploadedUrl = uploadResponse.data.path_document;
} else if (uploadResponse.data.path_solution) {
uploadedUrl = uploadResponse.data.path_solution;
} else if (uploadResponse.data.file_upload_name) {
// Jika hanya nama file dikembalikan, bangun URL
const baseUrl = import.meta.env.VITE_API_SERVER || '';
uploadedUrl = `${baseUrl}/uploads/images/${uploadResponse.data.file_upload_name}`;
}
}
}
// Jika respons adalah string, mungkin itu adalah URL
else if (uploadResponse && typeof uploadResponse === 'string') {
uploadedUrl = uploadResponse;
}
if (uploadedUrl) {
console.log('Successfully extracted image URL:', uploadedUrl);
imageUrl = uploadedUrl;
} else {
console.error('Upload response structure:', uploadResponse);
console.error('Available properties:', Object.keys(uploadResponse || {}));
console.error('Response type:', typeof uploadResponse);
console.error(
'Is response an object?',
uploadResponse && typeof uploadResponse === 'object'
);
if (uploadResponse && typeof uploadResponse === 'object') {
console.error('Response keys:', Object.keys(uploadResponse));
console.error(
'Response data keys:',
uploadResponse.data
? Object.keys(uploadResponse.data)
: 'No data property'
);
}
// Tampilkan notifikasi bahwa upload gagal tapi lanjutkan penyimpanan
NotifOk({
icon: 'warning',
title: 'Peringatan',
message: 'Upload gambar gagal. Data akan disimpan tanpa gambar.',
});
// Gunakan URL gambar yang sebelumnya jika ada, atau kosongkan
imageUrl = formData.sparepart_foto || '';
}
} else if (fileList.length === 0) {
// Jika tidak ada file di fileList (termasuk saat user menghapus file), gunakan gambar default
imageUrl = '/assets/defaultSparepartImg.jpg';
}
// Payload hanya berisi field yang tidak kosong untuk menghindari error validasi
const payload = { const payload = {
sparepart_name: formData.sparepart_name, sparepart_name: formData.sparepart_name, // Wajib
sparepart_description: formData.sparepart_description,
sparepart_model: formData.sparepart_model,
sparepart_item_type: formData.sparepart_item_type,
sparepart_unit: formData.sparepart_unit,
sparepart_merk: formData.sparepart_merk,
sparepart_stok: formData.sparepart_stok,
}; };
// Tambahkan field-field secara kondisional hanya jika nilainya tidak kosong
if (formData.sparepart_description && formData.sparepart_description.trim() !== '') {
payload.sparepart_description = formData.sparepart_description;
}
if (formData.sparepart_model && formData.sparepart_model.trim() !== '') {
payload.sparepart_model = formData.sparepart_model;
}
if (formData.sparepart_item_type && formData.sparepart_item_type.trim() !== '') {
payload.sparepart_item_type = formData.sparepart_item_type;
}
if (formData.sparepart_unit && formData.sparepart_unit.trim() !== '') {
payload.sparepart_unit = formData.sparepart_unit;
}
if (formData.sparepart_merk && formData.sparepart_merk.trim() !== '') {
payload.sparepart_merk = formData.sparepart_merk;
}
if (formData.sparepart_stok && formData.sparepart_stok.trim() !== '') {
payload.sparepart_stok = formData.sparepart_stok.toString();
} else {
payload.sparepart_stok = '0'; // Set default value jika tidak diisi
}
// Sertakan sparepart_foto hanya jika nilainya tidak kosong, agar tidak memicu validasi
if (imageUrl && imageUrl.trim() !== '') {
payload.sparepart_foto = imageUrl;
}
console.log('Sending payload:', payload);
const response = formData.sparepart_id const response = formData.sparepart_id
? await updateSparepart(formData.sparepart_id, payload) ? await updateSparepart(formData.sparepart_id, payload)
: await createSparepart(payload); : await createSparepart(payload);
// Check if response is successful console.log('API response:', response);
if (response && (response.statusCode === 200 || response.statusCode === 201)) {
const sparepartName = response.data?.sparepart_name || formData.sparepart_name;
if (response && (response.statusCode === 200 || response.statusCode === 201)) {
NotifOk({ NotifOk({
icon: 'success', icon: 'success',
title: 'Berhasil', title: 'Berhasil',
message: `Data Sparepart "${sparepartName}" berhasil ${ message: `Data Sparepart berhasil ${
formData.sparepart_id ? 'diubah' : 'ditambahkan' formData.sparepart_id ? 'diubah' : 'ditambahkan'
}.`, }.`,
}); });
props.setActionMode('list'); props.setActionMode('list');
setFileList([]);
} else { } else {
NotifAlert({ NotifAlert({
icon: 'error', icon: 'error',
@@ -101,35 +268,47 @@ const DetailSparepart = (props) => {
const handleInputChange = (e) => { const handleInputChange = (e) => {
const { name, value } = e.target; const { name, value } = e.target;
setFormData({ setFormData({ ...formData, [name]: value });
...formData,
[name]: value,
});
}; };
const handleFieldChange = (name, value) => { const handleSelectChange = (name, value) => {
setFormData({ setFormData({ ...formData, [name]: value });
...formData,
[name]: value,
});
};
const handleStatusToggle = (event) => {
const isChecked = event;
setFormData({
...formData,
is_active: isChecked ? true : false,
});
}; };
useEffect(() => { useEffect(() => {
if (props.selectedData) { if (props.selectedData) {
setFormData(props.selectedData); setFormData(props.selectedData);
if (props.selectedData.sparepart_foto) {
// Buat URL lengkap dengan token untuk file yang sudah ada
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([
{
uid: '-1',
name: fileName,
status: 'done',
url: fullUrl,
},
]);
} else {
setFileList([]);
}
} else { } else {
setFormData(defaultData); setFormData(defaultData);
setFileList([]);
} }
}, [props.showModal, props.selectedData, props.actionMode]); }, [props.showModal, props.selectedData, props.actionMode]);
const uploadButton = (
<div>
<PlusOutlined />
<div style={{ marginTop: 8 }}>Upload</div>
</div>
);
return ( return (
<Modal <Modal
title={`${ title={`${
@@ -153,7 +332,6 @@ const DetailSparepart = (props) => {
defaultBorderColor: '#23A55A', defaultBorderColor: '#23A55A',
defaultHoverColor: '#23A55A', defaultHoverColor: '#23A55A',
defaultHoverBorderColor: '#23A55A', defaultHoverBorderColor: '#23A55A',
defaultHoverColor: '#23A55A',
}, },
}, },
}} }}
@@ -162,9 +340,7 @@ const DetailSparepart = (props) => {
</ConfigProvider> </ConfigProvider>
<ConfigProvider <ConfigProvider
theme={{ theme={{
token: { token: { colorBgContainer: '#209652' },
colorBgContainer: '#209652',
},
components: { components: {
Button: { Button: {
defaultBg: '#23a55a', defaultBg: '#23a55a',
@@ -187,103 +363,125 @@ const DetailSparepart = (props) => {
> >
{formData && ( {formData && (
<div> <div>
<div hidden> <Row gutter={[16, 16]}>
<Text strong>Sparepart ID</Text> <Col span={12}>
<Input <Text strong>Sparepart Name</Text>
name="sparepart_id" <Text style={{ color: 'red' }}> *</Text>
value={formData.sparepart_id} <Input
onChange={handleInputChange} name="sparepart_name"
disabled value={formData.sparepart_name}
/> onChange={handleInputChange}
</div> placeholder="Enter Sparepart Name"
readOnly={props.readOnly}
<div style={{ marginBottom: 12 }}> />
<Text strong>Sparepart Name</Text> </Col>
<Text style={{ color: 'red' }}> *</Text> <Col span={12}>
<Input <Text strong>Item Type</Text>
name="sparepart_name" <Select
value={formData.sparepart_name} name="sparepart_item_type"
onChange={handleInputChange} value={formData.sparepart_item_type}
placeholder="Enter Sparepart Name" onChange={(value) =>
readOnly={props.readOnly} handleSelectChange('sparepart_item_type', value)
/> }
</div> placeholder="Select Item Type"
disabled={props.readOnly}
<div style={{ marginBottom: 12 }}> style={{ width: '100%' }}
<Text strong>Sparepart Description</Text> >
<TextArea <Select.Option value="Air Dryer">Air Dryer</Select.Option>
name="sparepart_description" <Select.Option value="Compressor">Compressor</Select.Option>
value={formData.sparepart_description} </Select>
onChange={handleInputChange} </Col>
placeholder="Enter Sparepart Description (Optional)" </Row>
readOnly={props.readOnly}
rows={4} <Row gutter={[16, 16]}>
/> <Col span={12}>
</div> <Text strong>Stock</Text>
<Input
<div style={{ marginBottom: 12 }}> name="sparepart_stok"
<Text strong>Sparepart Model</Text> value={formData.sparepart_stok}
<Text style={{ color: 'red' }}> *</Text> onChange={handleInputChange}
<Input placeholder="Initial stock quantity"
name="sparepart_model" readOnly={props.readOnly}
value={formData.sparepart_model} type="number"
onChange={handleInputChange} />
placeholder="Enter Sparepart Model" </Col>
readOnly={props.readOnly} <Col span={12}>
/> <Text strong>Unit</Text>
</div> <Input
name="sparepart_unit"
<div style={{ marginBottom: 12 }}> value={formData.sparepart_unit}
<Text strong>Sparepart Item Type</Text> onChange={handleInputChange}
<Input placeholder="e.g., pcs, box, roll"
name="sparepart_item_type" readOnly={props.readOnly}
value={formData.sparepart_item_type} />
onChange={handleInputChange} </Col>
placeholder="Enter Sparepart Item Type" </Row>
readOnly={props.readOnly}
/> <Row gutter={[16, 16]}>
</div> <Col span={24}>
<Text strong>Foto</Text>
<div style={{ marginBottom: 12 }}> <Upload
<Text strong>Sparepart Unit</Text> listType="picture-card"
<Text style={{ color: 'red' }}> *</Text> fileList={fileList}
<Input onPreview={handlePreview}
name="sparepart_unit" onChange={handleChange}
value={formData.sparepart_unit} beforeUpload={() => false}
onChange={handleInputChange} maxCount={1}
placeholder="Enter Sparepart Unit" disabled={props.readOnly}
readOnly={props.readOnly} >
/> {fileList.length >= 1 ? null : uploadButton}
</div> </Upload>
<Modal
<div style={{ marginBottom: 12 }}> open={previewOpen}
<Text strong>Sparepart Merk</Text> title={previewTitle}
<Text style={{ color: 'red' }}> *</Text> footer={null}
<Input onCancel={handlePreviewCancel}
name="sparepart_merk" >
value={formData.sparepart_merk} <img alt="preview" style={{ width: '100%' }} src={previewImage} />
onChange={handleInputChange} </Modal>
placeholder="Enter Sparepart Merk" </Col>
readOnly={props.readOnly} </Row>
/>
</div> <Row gutter={[16, 16]}>
<Col span={12}>
<div style={{ marginBottom: 12 }}> <Text strong>Brand</Text>
<Text strong>Sparepart Stok</Text> <Input
<Text style={{ color: 'red' }}> *</Text> name="sparepart_merk"
<Input value={formData.sparepart_merk}
name="sparepart_stok" onChange={handleInputChange}
value={formData.sparepart_stok} placeholder="Enter Brand (Optional)"
onChange={handleInputChange} readOnly={props.readOnly}
placeholder="Enter Sparepart Stok" />
readOnly={props.readOnly} </Col>
type="number" <Col span={12}>
/> <Text strong>Model</Text>
</div> <Input
name="sparepart_model"
value={formData.sparepart_model}
onChange={handleInputChange}
placeholder="Enter Model (Optional)"
readOnly={props.readOnly}
/>
</Col>
</Row>
<Row gutter={[16, 16]}>
<Col span={24}>
<Text strong>Description</Text>
<TextArea
name="sparepart_description"
value={formData.sparepart_description}
onChange={handleInputChange}
placeholder="Enter Description (Optional)"
readOnly={props.readOnly}
rows={3}
/>
</Col>
</Row>
</div> </div>
)} )}
</Modal> </Modal>
); );
}; };
export default DetailSparepart; export default DetailSparepart;

View File

@@ -13,6 +13,7 @@ import { NotifAlert, NotifOk, NotifConfirmDialog } from '../../../../components/
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { deleteSparepart, getAllSparepart } from '../../../../api/sparepart'; import { deleteSparepart, getAllSparepart } from '../../../../api/sparepart';
import TableList from '../../../../components/Global/TableList'; import TableList from '../../../../components/Global/TableList';
import SparepartCardList from './SparepartCardList'; // Import the new custom card component
const columns = (showPreviewModal, showEditModal, showDeleteDialog) => [ const columns = (showPreviewModal, showEditModal, showDeleteDialog) => [
{ {
@@ -265,6 +266,14 @@ const ListSparepart = memo(function ListSparepart(props) {
queryParams={formDataFilter} queryParams={formDataFilter}
columns={columns(showPreviewModal, showEditModal, showDeleteDialog)} columns={columns(showPreviewModal, showEditModal, showDeleteDialog)}
triger={trigerFilter} triger={trigerFilter}
cardComponent={SparepartCardList} // Pass the custom component here
onStockUpdate={doFilter}
onGetData={(data) => {
if(data && data.length > 0) {
console.log('Sample sparepart data from API:', data[0]);
console.log('Available fields:', Object.keys(data[0] || {}));
}
}} // Log untuk debugging field-field yang tersedia
/> />
</Col> </Col>
</Row> </Row>

View File

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

View File

@@ -146,21 +146,43 @@ const ListNotification = memo(function ListNotification(props) {
const navigate = useNavigate(); const navigate = useNavigate();
// Fetch notifications from API // Fetch notifications from API
const fetchNotifications = async () => { const fetchNotifications = async (page = 1, limit = 10, isRead = null) => {
setLoading(true); setLoading(true);
try { try {
const response = await getAllNotification(); const queryParams = new URLSearchParams({
page: page.toString(),
limit: limit.toString(),
});
if (isRead !== null) {
queryParams.append('is_read', isRead.toString());
}
const response = await getAllNotification(queryParams);
if (response && response.data) { if (response && response.data) {
const transformedData = transformNotificationData(response.data); const transformedData = transformNotificationData(response.data);
setNotifications(transformedData); setNotifications(transformedData);
// Update pagination with mock data (since API doesn't provide pagination info) // Update pagination with API response or calculate from data
const totalItems = transformedData.length; if (response.paging) {
setPagination((prev) => ({ setPagination({
...prev, current_page: response.paging.current_page || page,
total_limit: totalItems, current_limit: response.paging.current_limit || limit,
total_page: Math.ceil(totalItems / prev.current_limit), total_limit: response.paging.total_limit || transformedData.length,
})); total_page:
response.paging.total_page || Math.ceil(transformedData.length / limit),
});
} else {
// Fallback: calculate pagination from data
const totalItems = transformedData.length;
setPagination((prev) => ({
...prev,
current_page: page,
current_limit: limit,
total_limit: totalItems,
total_page: Math.ceil(totalItems / limit),
}));
}
} }
} catch (error) { } catch (error) {
console.error('Error fetching notifications:', error); console.error('Error fetching notifications:', error);
@@ -178,13 +200,10 @@ const ListNotification = memo(function ListNotification(props) {
current_page: page, current_page: page,
current_limit: pageSize, current_limit: pageSize,
})); }));
};
// Get paginated notifications // Fetch notifications with new pagination
const getPaginatedNotifications = () => { const isReadFilter = activeTab === 'read' ? true : activeTab === 'unread' ? false : null;
const startIndex = (pagination.current_page - 1) * pagination.current_limit; fetchNotifications(page, pageSize, isReadFilter);
const endIndex = startIndex + pagination.current_limit;
return filteredNotifications.slice(startIndex, endIndex);
}; };
useEffect(() => { useEffect(() => {
@@ -194,9 +213,10 @@ const ListNotification = memo(function ListNotification(props) {
return; return;
} }
// Fetch notifications on component mount // Fetch notifications on component mount and when tab changes
fetchNotifications(); const isReadFilter = activeTab === 'read' ? true : activeTab === 'unread' ? false : null;
}, []); fetchNotifications(pagination.current_page, pagination.current_limit, isReadFilter);
}, [activeTab]);
const getIconAndColor = (type) => { const getIconAndColor = (type) => {
switch (type) { switch (type) {
@@ -248,22 +268,17 @@ const ListNotification = memo(function ListNotification(props) {
setSearchTerm(''); setSearchTerm('');
}; };
const filteredNotifications = notifications const getUnreadCount = () => notifications.filter((n) => !n.isRead).length;
.filter((n) => {
const matchesTab = // Filter notifications based on search term
activeTab === 'all' || const getFilteredNotifications = () => {
(activeTab === 'unread' && !n.isRead) || if (!searchTerm) return notifications;
(activeTab === 'read' && n.isRead); // Search by title and error code name
return matchesTab; return notifications.filter((n) => {
})
.filter((n) => {
if (!searchTerm) return true;
// Search by title and error code name
const searchableText = `${n.title} ${n.issue}`.toLowerCase(); const searchableText = `${n.title} ${n.issue}`.toLowerCase();
return searchableText.includes(searchTerm.toLowerCase()); return searchableText.includes(searchTerm.toLowerCase());
}); });
};
const getUnreadCount = () => notifications.filter((n) => !n.isRead).length;
const tabButtonStyle = (isActive) => ({ const tabButtonStyle = (isActive) => ({
padding: '12px 16px', padding: '12px 16px',
@@ -279,8 +294,7 @@ const ListNotification = memo(function ListNotification(props) {
}); });
const renderDeviceNotifications = () => { const renderDeviceNotifications = () => {
const paginatedNotifications = getPaginatedNotifications(); const filteredNotifications = getFilteredNotifications();
return ( return (
<Space direction="vertical" size="middle" style={{ display: 'flex' }}> <Space direction="vertical" size="middle" style={{ display: 'flex' }}>
{filteredNotifications.length === 0 ? ( {filteredNotifications.length === 0 ? (
@@ -288,200 +302,215 @@ const ListNotification = memo(function ListNotification(props) {
Tidak ada notifikasi Tidak ada notifikasi
</div> </div>
) : ( ) : (
paginatedNotifications.map((notification) => { filteredNotifications.map((notification) => {
const { IconComponent, color, bgColor } = getIconAndColor(notification.type); const { IconComponent, color, bgColor } = getIconAndColor(
return ( notification.type
<Card );
key={notification.id} return (
style={{ <Card
backgroundColor: notification.isRead ? '#ffffff' : '#f6f9ff', key={notification.id}
borderColor: notification.isRead ? '#f0f0f0' : '#d6e4ff', style={{
cursor: 'pointer', backgroundColor: notification.isRead ? '#ffffff' : '#f6f9ff',
}} borderColor: notification.isRead ? '#f0f0f0' : '#d6e4ff',
onClick={() => handleMarkAsRead(notification.id)} cursor: 'pointer',
> }}
<div style={{ display: 'flex', gap: '16px', alignItems: 'flex-start' }}> onClick={() => handleMarkAsRead(notification.id)}
>
<div <div
style={{ style={{
width: '40px',
height: '40px',
borderRadius: '50%',
backgroundColor: bgColor,
color: color,
display: 'flex', display: 'flex',
alignItems: 'center', gap: '16px',
justifyContent: 'center', alignItems: 'flex-start',
fontSize: '22px',
flexShrink: 0,
}} }}
> >
<IconComponent style={{ fontSize: '22px' }} /> <div
</div> style={{
<div style={{ flex: 1 }}> width: '40px',
<Row align="top"> height: '40px',
<Col flex="220px"> borderRadius: '50%',
<div backgroundColor: bgColor,
style={{ color: color,
display: 'flex', display: 'flex',
justifyContent: 'space-between', alignItems: 'center',
alignItems: 'flex-start', justifyContent: 'center',
}} fontSize: '22px',
> flexShrink: 0,
<div> }}
<Text strong>{notification.title}</Text> >
<div style={{ marginTop: '4px' }}> <IconComponent style={{ fontSize: '22px' }} />
<Text style={{ color }}> </div>
{notification.issue} <div style={{ flex: 1 }}>
</Text> <Row align="top">
<Col flex="220px">
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
}}
>
<div>
<Text strong>{notification.title}</Text>
<div style={{ marginTop: '4px' }}>
<Text style={{ color }}>
{notification.issue}
</Text>
</div>
</div> </div>
{!notification.isRead && (
<Badge
color="red"
status="processing"
style={{
marginLeft: '8px',
marginTop: '4px',
}}
/>
)}
</div> </div>
{!notification.isRead && ( </Col>
<Badge <Col flex="auto">
color="red" <div
status="processing" style={{
display: 'flex',
gap: '8px',
alignItems: 'flex-start',
marginBottom: '12px',
}}
>
<MailOutlined
style={{ style={{
marginLeft: '8px',
marginTop: '4px', marginTop: '4px',
color: '#1890ff',
}} }}
/> />
)} <Paragraph
</div> style={{
</Col> color: '#595959',
<Col flex="auto"> margin: 0,
<div flex: 1,
style={{
display: 'flex',
gap: '8px',
alignItems: 'flex-start',
marginBottom: '12px',
}}
>
<MailOutlined
style={{ marginTop: '4px', color: '#1890ff' }}
/>
<Paragraph
style={{ color: '#595959', margin: 0, flex: 1 }}
>
{notification.details}
</Paragraph>
</div>
<Space
direction="vertical"
size={4}
style={{ fontSize: '13px', color: '#8c8c8c' }}
>
<Space>
<ClockCircleOutlined />
<Text type="secondary">
{notification.timestamp}
</Text>
</Space>
<Space>
<EnvironmentOutlined />
<Text type="secondary">
{notification.location}
</Text>
</Space>
<Space>
<LinkOutlined />
<AntdLink
href={notification.link}
target="_blank"
>
{notification.link}
</AntdLink>
<Button
type="link"
icon={<SendOutlined />}
style={{ paddingLeft: '8px' }}
onClick={(e) => {
e.stopPropagation();
handleResend(notification);
}} }}
> >
Resend {notification.details}
</Button> </Paragraph>
</div>
<Space
direction="vertical"
size={4}
style={{ fontSize: '13px', color: '#8c8c8c' }}
>
<Space>
<ClockCircleOutlined />
<Text type="secondary">
{notification.timestamp}
</Text>
</Space>
<Space>
<EnvironmentOutlined />
<Text type="secondary">
{notification.location}
</Text>
</Space>
<Space>
<LinkOutlined />
<AntdLink
href={notification.link}
target="_blank"
>
{notification.link}
</AntdLink>
<Button
type="link"
icon={<SendOutlined />}
style={{ paddingLeft: '8px' }}
onClick={(e) => {
e.stopPropagation();
handleResend(notification);
}}
>
Resend
</Button>
</Space>
</Space> </Space>
</Space> </Col>
</Col> <Col
<Col flex="120px"
flex="120px" style={{ textAlign: 'center' }}
style={{ textAlign: 'center' }} align="bottom"
align="bottom"
>
<Space
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100%',
}}
> >
<Button <Space
type="text"
icon={
<UserOutlined
style={{ color: '#1890ff' }}
/>
}
title="User History"
style={{ style={{
border: '1px solid #1890ff', display: 'flex',
borderRadius: '4px', justifyContent: 'center',
alignItems: 'center',
height: '100%',
}} }}
onClick={(e) => {
e.stopPropagation();
setModalContent('user');
}}
/>
<RouterLink
to={`/detail-notification/${
notification.id.split('-')[1]
}`}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
> >
<Button <Button
type="text" type="text"
icon={ icon={
<EyeOutlined <UserOutlined
style={{ color: '#1890ff' }} style={{ color: '#1890ff' }}
/> />
} }
title="Details" title="User History"
style={{ style={{
border: '1px solid #1890ff', border: '1px solid #1890ff',
borderRadius: '4px', borderRadius: '4px',
}} }}
onClick={(e) => {
e.stopPropagation();
setModalContent('user');
}}
/> />
</RouterLink> <RouterLink
<Button to={`/detail-notification/${
type="text" notification.id.split('-')[1]
icon={ }`}
<HistoryOutlined target="_blank"
style={{ color: '#1890ff' }} rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
>
<Button
type="text"
icon={
<EyeOutlined
style={{ color: '#1890ff' }}
/>
}
title="Details"
style={{
border: '1px solid #1890ff',
borderRadius: '4px',
}}
/> />
} </RouterLink>
title="Log History" <Button
style={{ type="text"
border: '1px solid #1890ff', icon={
borderRadius: '4px', <HistoryOutlined
}} style={{ color: '#1890ff' }}
onClick={(e) => { />
e.stopPropagation(); }
setModalContent('log'); title="Log History"
}} style={{
/> border: '1px solid #1890ff',
</Space> borderRadius: '4px',
</Col> }}
</Row> onClick={(e) => {
e.stopPropagation();
setModalContent('log');
}}
/>
</Space>
</Col>
</Row>
</div>
</div> </div>
</div> </Card>
</Card> );
); })
}) )}
)}
</Space> </Space>
); );
}; };
@@ -1212,16 +1241,15 @@ const ListNotification = memo(function ListNotification(props) {
</div> </div>
</div> </div>
<Spin spinning={loading}> <Spin spinning={loading}>{renderDeviceNotifications()}</Spin>
{renderDeviceNotifications()}
</Spin>
{/* PAGINATION */} {/* PAGINATION */}
<Row justify="space-between" align="middle" style={{ marginTop: '16px' }}> <Row justify="space-between" align="middle" style={{ marginTop: '16px' }}>
<Col> <Col>
<div> <div>
Menampilkan {pagination.current_limit} data halaman{' '} Menampilkan {pagination.current_limit} data halaman{' '}
{pagination.current_page} dari total {pagination.total_limit} data {pagination.current_page} dari total {pagination.total_limit}{' '}
data
</div> </div>
</Col> </Col>
<Col> <Col>