feat: enhance status management with validation and improved UI components

This commit is contained in:
2025-10-21 23:05:39 +07:00
parent 5ec26ecbe8
commit 9091392dfb
4 changed files with 379 additions and 359 deletions

View File

@@ -5,87 +5,23 @@ import { Typography } from 'antd';
import ListStatus from './component/ListStatus';
import DetailStatus from './component/DetailStatus';
import { NotifConfirmDialog, NotifAlert } from '../../../components/Global/ToastNotif';
const { Text } = Typography;
// Mock Data
const initialData = [
{
key: '3',
statusCode: 3,
statusName: 'Done',
description: 'Indicates that the process is complete.',
},
{
key: '1',
statusCode: 1,
statusName: 'Warning',
description: 'Indicates a warning condition.',
},
{
key: '2',
statusCode: 2,
statusName: 'Alarm',
description: 'Indicates an alarm condition.',
},
{
key: '4',
statusCode: 4,
statusName: 'Critical',
description: 'Indicates a critical condition.',
},
];
const IndexStatus = memo(function IndexStatus() {
const navigate = useNavigate();
const { setBreadcrumbItems } = useBreadcrumb();
const [data, setData] = useState(initialData);
const [actionMode, setActionMode] = useState('list');
const [selectedData, setSelectedData] = useState(null);
const [isModalVisible, setIsModalVisible] = useState(false);
const [readOnly, setReadOnly] = useState(false);
// Mock API function
const getAllStatus = async (params) => {
const { page = 1, limit = 10, search = '' } = Object.fromEntries(params.entries());
let filteredData = data;
if (search) {
filteredData = data.filter(item =>
item.statusName.toLowerCase().includes(search.toLowerCase())
);
}
const start = (page - 1) * limit;
const end = start + limit;
const paginatedData = filteredData.slice(start, end);
return new Promise(resolve => {
setTimeout(() => {
resolve({
status: 200,
data: {
data: paginatedData,
total: filteredData.length,
paging: {
page: parseInt(page),
limit: parseInt(limit),
total: filteredData.length,
},
},
});
}, 500);
});
};
useEffect(() => {
const token = localStorage.getItem('token');
if (token) {
setBreadcrumbItems([
{ title: <Text strong style={{ fontSize: '14px' }}> Master</Text> },
{ title: <Text strong style={{ fontSize: '14px' }}>Status</Text> }
{ title: <Text strong> Master</Text> },
{ title: <Text strong>Status</Text> }
]);
} else {
navigate('/signin');
@@ -101,64 +37,21 @@ const IndexStatus = memo(function IndexStatus() {
}
}, [actionMode]);
const handleDataSaved = (values) => {
let newData = [...data];
if (values.key) { // Editing
const index = newData.findIndex((item) => values.key === item.key);
if (index > -1) {
newData.splice(index, 1, values);
}
} else { // Adding
const newKey = (Math.max(...data.map(item => parseInt(item.key))) + 1).toString();
newData = [{ key: newKey, ...values }, ...newData];
}
setData(newData);
};
const handleEdit = (record) => {
setSelectedData(record);
setActionMode('edit');
};
const handlePreview = (record) => {
setSelectedData(record);
setActionMode('preview');
};
const handleDelete = (record) => {
NotifConfirmDialog({
icon: 'question',
title: 'Konfirmasi',
message: `Apakah anda yakin ingin menghapus status "${record.statusName}"?`,
onConfirm: () => {
const newData = data.filter((item) => item.key !== record.key);
setData(newData);
NotifAlert({
icon: 'success',
title: 'Berhasil',
message: `Status "${record.statusName}" berhasil dihapus.`,
});
},
});
};
return (
<React.Fragment>
{actionMode === 'list' &&
<ListStatus
setActionMode={setActionMode}
handleEdit={handleEdit}
handleDelete={handleDelete}
handlePreview={handlePreview}
getAllStatus={getAllStatus}
data={data}
setSelectedData={setSelectedData}
actionMode={actionMode}
/>
}
<DetailStatus
showModal={isModalVisible}
setActionMode={setActionMode}
selectedData={selectedData}
readOnly={readOnly}
onDataSaved={handleDataSaved}
setSelectedData={setSelectedData}
readOnly={readOnly}
/>
</React.Fragment>
);

View File

@@ -1,66 +1,113 @@
import React, { useEffect, useState } from 'react';
import { Modal, Input, Divider, Typography, Button, ConfigProvider, InputNumber, Form } from 'antd';
import { Modal, Input, Divider, Typography, Button, ConfigProvider, InputNumber, Switch } from 'antd';
import { NotifAlert, NotifOk } from '../../../../components/Global/ToastNotif';
import { validateRun } from '../../../../Utils/validate';
import { createStatus, updateStatus } from '../../../../api/master-status';
const { Text } = Typography;
const { TextArea } = Input;
const DetailStatus = (props) => {
const [form] = Form.useForm();
const [confirmLoading, setConfirmLoading] = useState(false);
const defaultData = {
key: '',
statusCode: '',
statusName: '',
description: '',
status_id: '',
status_number: null,
status_name: '',
status_color: '',
status_description: '',
is_active: true,
};
const [FormData, setFormData] = useState(defaultData);
const [formData, setFormData] = useState(defaultData);
const handleInputChange = (e) => {
const { name, value } = e.target;
setFormData({ ...formData, [name]: value });
};
const handleInputNumberChange = (value) => {
setFormData({ ...formData, status_number: value });
};
const handleStatusToggle = (checked) => {
setFormData({ ...formData, is_active: checked });
};
const handleCancel = () => {
props.setSelectedData(null);
props.setActionMode('list');
form.resetFields();
};
const handleSave = async () => {
try {
const values = await form.validateFields();
setConfirmLoading(true);
const validationRules = [
{ field: 'status_number', label: 'Status Number', required: true },
{ field: 'status_name', label: 'Status Name', required: true },
{ field: 'status_color', label: 'Status Color', required: true },
{ field: 'status_description', label: 'Description', required: true },
];
if (
validateRun(formData, validationRules, (errorMessages) => {
NotifOk({
icon: 'warning',
title: 'Peringatan',
message: errorMessages,
});
setConfirmLoading(false);
})
) {
return;
}
try {
const payload = {
key: FormData.key,
...values,
status_number: formData.status_number,
status_name: formData.status_name,
status_color: formData.status_color,
status_description: formData.status_description,
is_active: formData.is_active,
};
props.onDataSaved(payload);
const response = formData.status_id
? await updateStatus(formData.status_id, payload)
: await createStatus(payload);
if (response && (response.statusCode === 200 || response.statusCode === 201)) {
const action = formData.status_id ? 'diubah' : 'ditambahkan';
NotifOk({
icon: 'success',
title: 'Berhasil',
message: `Data Status "${payload.statusName}" berhasil ${
payload.key ? 'diubah' : 'ditambahkan'
}.`,
message: `Data Status "${payload.status_name}" berhasil ${action}.`,
});
setConfirmLoading(false);
props.setActionMode('list');
form.resetFields();
} catch (errorInfo) {
console.log('Failed:', errorInfo);
} else {
NotifAlert({
icon: 'error',
title: 'Gagal',
message: response?.message || 'Gagal menyimpan data.',
});
}
} catch (error) {
NotifAlert({
icon: 'error',
title: 'Error',
message: error.message || 'Terjadi kesalahan pada server.',
});
} finally {
setConfirmLoading(false);
}
};
useEffect(() => {
if (props.selectedData) {
setFormData(props.selectedData);
form.setFieldsValue(props.selectedData);
setFormData({ ...defaultData, ...props.selectedData });
} else {
setFormData(defaultData);
form.resetFields();
}
}, [props.showModal, props.selectedData, form]);
}, [props.showModal, props.selectedData]);
return (
<Modal
@@ -78,81 +125,72 @@ const DetailStatus = (props) => {
footer={
!props.readOnly && (
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '10px', paddingTop: '15px' }}>
<ConfigProvider
theme={{
token: { colorPrimary: '#23A55A' },
components: {
Button: {
defaultBg: 'white',
defaultColor: '#23A55A',
defaultBorderColor: '#23A55A',
defaultHoverColor: 'white',
defaultHoverBg: '#23A55A',
defaultHoverBorderColor: '#23A55A',
},
},
}}
>
<Button onClick={handleCancel}>Batal</Button>
</ConfigProvider>
<ConfigProvider
theme={{
token: { colorPrimary: '#23A55A' },
components: {
Button: {
defaultBg: '#23A55A',
defaultColor: 'white',
defaultBorderColor: '#23A55A',
defaultHoverColor: 'white',
defaultHoverBg: '#23A55A',
defaultHoverBorderColor: '#23A55A',
},
},
}}
>
<Button type="primary" loading={confirmLoading} onClick={handleSave}>
Simpan
</Button>
</ConfigProvider>
</div>
)
}
>
<Divider />
<Form form={form} layout="vertical" name="detailStatusForm">
<Form.Item
name="statusCode"
label={<Text strong>Status Code</Text>}
rules={[{ required: true, message: 'Silakan masukkan kode status!' }]}
>
<div style={{ marginBottom: 12 }}>
<Text strong>Status</Text>
<div style={{ display: 'flex', alignItems: 'center', marginTop: '8px' }}>
<Switch
disabled={props.readOnly}
checked={formData.is_active}
onChange={handleStatusToggle}
/>
<Text style={{ marginLeft: 8 }}>{formData.is_active ? 'Active' : 'Inactive'}</Text>
</div>
</div>
<div style={{ marginBottom: 12 }}>
<Text strong>Status Number</Text>
<Text style={{ color: 'red' }}> *</Text>
<InputNumber
placeholder="Masukan code status"
name="status_number"
value={formData.status_number}
placeholder="Masukan nomor status"
readOnly={props.readOnly}
style={{ width: '100%' }}
onChange={handleInputNumberChange}
/>
</Form.Item>
<Form.Item
name="statusName"
label={<Text strong>Status Name</Text>}
rules={[{ required: true, message: 'Silakan masukkan nama status!' }]}
>
</div>
<div style={{ marginBottom: 12 }}>
<Text strong>Status Name</Text>
<Text style={{ color: 'red' }}> *</Text>
<Input
name="status_name"
value={formData.status_name}
placeholder="Masukan nama status"
readOnly={props.readOnly}
onChange={handleInputChange}
/>
</Form.Item>
<Form.Item
name="description"
label={<Text strong>Description</Text>}
rules={[{ required: true, message: 'Silakan masukkan deskripsi!' }]}
>
</div>
<div style={{ marginBottom: 12 }}>
<Text strong>Status Color</Text>
<Text style={{ color: 'red' }}> *</Text>
<Input
name="status_color"
value={formData.status_color}
placeholder="Masukan warna status (e.g., hijau, #00ff00)"
readOnly={props.readOnly}
onChange={handleInputChange}
/>
</div>
<div style={{ marginBottom: 12 }}>
<Text strong>Description</Text>
<Text style={{ color: 'red' }}> *</Text>
<TextArea
name="status_description"
value={formData.status_description}
placeholder="Masukan deskripsi"
readOnly={props.readOnly}
rows={4}
onChange={handleInputChange}
/>
</Form.Item>
</Form>
</div>
</Modal>
);
};

View File

@@ -1,68 +1,152 @@
import React from 'react';
import { Card, Button, Row, Col, Typography, Space, ConfigProvider } from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined, EyeOutlined } from '@ant-design/icons';
import React, { memo, useState, useEffect } from 'react';
import { Space, ConfigProvider, Button, Row, Col, Card, Input, Segmented, Table, Pagination } from 'antd';
import {
PlusOutlined,
EditOutlined,
DeleteOutlined,
EyeOutlined,
SearchOutlined,
AppstoreOutlined,
TableOutlined,
} from '@ant-design/icons';
import { NotifAlert, NotifConfirmDialog } from '../../../../components/Global/ToastNotif';
import { useNavigate } from 'react-router-dom';
import { deleteStatus, getAllStatuss } from '../../../../api/master-status';
const { Title, Text } = Typography;
const ListStatus = memo(function ListStatus(props) {
const [data, setData] = useState([]);
const [loading, setLoading] = useState(true);
const [viewMode, setViewMode] = useState('card');
const [trigerFilter, setTrigerFilter] = useState(false);
const [searchValue, setSearchValue] = useState('');
const [pagination, setPagination] = useState({ current: 1, pageSize: 10, total: 0 });
const navigate = useNavigate();
const ListStatus = ({
setActionMode,
handleEdit,
handleDelete,
handlePreview,
data,
}) => {
const getCardStyle = (statusName) => {
let color;
switch (statusName.toLowerCase()) {
case 'done':
color = '#52c41a'; // green
break;
case 'warning':
color = '#faad14'; // orange
break;
case 'alarm':
color = '#f5222d'; // red
break;
case 'critical':
color = '#000000'; // black
break;
default:
color = '#d9d9d9'; // default antd border color
const fetchData = async (page = 1, pageSize = 10) => {
setLoading(true);
try {
const params = new URLSearchParams();
params.append('page', page);
params.append('limit', pageSize);
if (searchValue) {
params.append('search', searchValue);
}
const response = await getAllStatuss(params);
setData(response.data || []);
setPagination(prev => ({ ...prev, total: response.paging?.total || 0, current: page, pageSize: pageSize }));
} catch (error) {
console.error("Failed to fetch status data:", error);
setData([]);
} finally {
setLoading(false);
}
return { border: `2px solid ${color}` };
};
const getTitleStyle = (statusName) => {
let backgroundColor;
switch (statusName.toLowerCase()) {
case 'done':
backgroundColor = '#52c41a'; // green
break;
case 'warning':
backgroundColor = '#faad14'; // orange
break;
case 'alarm':
backgroundColor = '#f5222d'; // red
break;
case 'critical':
backgroundColor = '#000000'; // black
break;
default:
backgroundColor = 'transparent';
useEffect(() => {
const token = localStorage.getItem('token');
if (!token) {
navigate('/signin');
return;
}
return {
backgroundColor,
color: '#fff',
padding: '2px 8px',
borderRadius: '4px',
display: 'inline-block'
fetchData(pagination.current, pagination.pageSize);
}, [props.actionMode, trigerFilter, navigate]);
const doFilter = () => {
setTrigerFilter(prev => !prev);
};
const handleSearch = (value) => {
setSearchValue(value);
setPagination(prev => ({ ...prev, current: 1 })); // Reset to first page on search
doFilter();
};
const handlePaginationChange = (page, pageSize) => {
fetchData(page, pageSize);
};
const showPreviewModal = (record) => {
props.setSelectedData(record);
props.setActionMode('preview');
};
const showEditModal = (record) => {
props.setSelectedData(record);
props.setActionMode('edit');
};
const showAddModal = () => {
props.setSelectedData(null);
props.setActionMode('add');
};
const showDeleteDialog = (record) => {
NotifConfirmDialog({
icon: 'question',
title: 'Konfirmasi Hapus',
message: `Status "${record.status_name}" akan dihapus?`,
onConfirm: () => handleDelete(record.status_id),
});
};
const handleDelete = async (status_id) => {
try {
const response = await deleteStatus(status_id);
if (response.statusCode === 200) {
NotifAlert({ icon: 'success', title: 'Berhasil', message: 'Data Status berhasil dihapus.' });
doFilter();
} else {
NotifAlert({ icon: 'error', title: 'Gagal', message: response?.message || 'Gagal Menghapus Data' });
}
} catch (error) {
NotifAlert({ icon: 'error', title: 'Error', message: error.message });
}
};
const columns = [
{ title: 'Number', dataIndex: 'status_number', key: 'status_number', width: '15%' },
{ title: 'Name', dataIndex: 'status_name', key: 'status_name', width: '25%' },
{ title: 'Description', dataIndex: 'status_description', key: 'status_description', width: '40%' },
{
title: 'Aksi', key: 'aksi', align: 'center', width: '20%',
render: (_, record) => (
<Space>
<Button type="text" icon={<EyeOutlined />} onClick={() => showPreviewModal(record)} />
<Button type="text" icon={<EditOutlined />} onClick={() => showEditModal(record)} />
<Button danger type="text" icon={<DeleteOutlined />} onClick={() => showDeleteDialog(record)} />
</Space>
),
},
];
const getCardStyle = (color) => {
return { border: `2px solid ${color || '#d9d9d9'}`, height: '100%' };
};
const getTitleStyle = (color) => {
return { backgroundColor: color || 'transparent', color: '#fff', padding: '2px 8px', borderRadius: '4px', display: 'inline-block' };
};
return (
<div style={{ padding: 24, minHeight: 360 }}>
<Row justify="end" style={{ marginBottom: 16 }}>
<Card>
<Row justify="space-between" align="middle" gutter={[16, 16]}>
<Col xs={24} sm={24} md={12} lg={12}>
<Input.Search
placeholder="Search by status name..."
onSearch={handleSearch}
allowClear
enterButton={
<Button
type="primary"
icon={<SearchOutlined />}
style={{ backgroundColor: '#23A55A', borderColor: '#23A55A' }}
>
Search
</Button>
}
size="large"
/>
</Col>
<Col>
<ConfigProvider
theme={{
@@ -78,37 +162,64 @@ const ListStatus = ({
},
}}
>
<Button
icon={<PlusOutlined />}
onClick={() => setActionMode('add')}
>
<Button icon={<PlusOutlined />} onClick={showAddModal} size="large">
Tambah Data
</Button>
</ConfigProvider>
</Col>
</Row>
<Row style={{ marginTop: 16 }}>
<Col>
<Segmented
options={[{ value: 'card', icon: <AppstoreOutlined /> }, { value: 'table', icon: <TableOutlined /> }]}
value={viewMode}
onChange={setViewMode}
/>
</Col>
</Row>
<div style={{ marginTop: 24 }}>
{viewMode === 'card' ? (
<Row gutter={[16, 16]}>
{data.map(item => (
<Col xs={24} sm={12} md={8} lg={6} key={item.key}>
<Col xs={24} sm={12} md={8} lg={6} key={item.status_id}>
<Card
title={<span style={getTitleStyle(item.statusName)}>{item.statusName}</span>}
style={getCardStyle(item.statusName)}
title={<span style={getTitleStyle(item.status_color)}>{item.status_name}</span>}
style={getCardStyle(item.status_color)}
actions={[
<Space size="middle" style={{ display: 'flex', justifyContent: 'center' }}>
<Button style={{ border: '1px solid #1890ff', color: '#1890ff', borderRadius: '6px', padding: '4px 8px' }} icon={<EyeOutlined />} onClick={() => handlePreview(item)} />
<Button style={{ border: '1px solid #faad14', color: '#faad14', borderRadius: '6px', padding: '4px 8px' }} icon={<EditOutlined />} onClick={() => handleEdit(item)} />
<Button danger style={{ border: '1px solid red', borderRadius: '6px', padding: '4px 8px' }} icon={<DeleteOutlined />} onClick={() => handleDelete(item)} />
</Space>
<EyeOutlined key="preview" onClick={() => showPreviewModal(item)} />,
<EditOutlined key="edit" onClick={() => showEditModal(item)} />,
<DeleteOutlined key="delete" onClick={() => showDeleteDialog(item)} />,
]}
>
<p><Text strong>Code:</Text> {item.statusCode}</p>
<p><Text strong>Description:</Text> {item.description}</p>
<p><b>Number:</b> {item.status_number}</p>
<p><b>Description:</b> {item.status_description}</p>
</Card>
</Col>
))}
</Row>
) : (
<>
<Table
columns={columns}
dataSource={data.map(item => ({ ...item, key: item.status_id }))}
pagination={false}
loading={loading}
/>
<Pagination
style={{ marginTop: 16, textAlign: 'right' }}
current={pagination.current}
pageSize={pagination.pageSize}
total={pagination.total}
onChange={handlePaginationChange}
showSizeChanger
/>
</>
)}
</div>
</Card>
);
};
});
export default ListStatus;

View File

@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import { Modal, Input, Typography, Button, ConfigProvider, Switch } from 'antd';
import { NotifAlert, NotifOk } from '../../../../components/Global/ToastNotif';
import { createUnit, updateUnit, getAllUnit } from '../../../../api/master-unit';
import { validateRun } from '../../../../Utils/validate';
const { Text } = Typography;
@@ -26,71 +27,48 @@ const DetailUnit = (props) => {
const handleSave = async () => {
setConfirmLoading(true);
// Validasi required fields
if (!FormData.unit_name || FormData.unit_name.trim() === '') {
const validationRules = [
{ field: 'unit_name', label: 'Name', required: true },
];
if (
validateRun(FormData, validationRules, (errorMessages) => {
NotifOk({
icon: 'warning',
title: 'Peringatan',
message: 'Kolom Name Tidak Boleh Kosong',
message: errorMessages,
});
setConfirmLoading(false);
})
)
return;
}
try {
if (FormData.unit_id) {
// Update existing unit
const payload = {
name: FormData.unit_name,
unit_name: FormData.unit_name,
is_active: FormData.is_active,
};
const response = await updateUnit(FormData.unit_id, payload);
console.log('updateUnit response:', response);
if (response.statusCode === 200) {
// Get updated data to show unit_code in notification
const unitCode = response.data?.unit_code || FormData.unit_code;
NotifOk({
icon: 'success',
title: 'Berhasil',
message: `Data Unit "${unitCode} - ${FormData.unit_name}" berhasil diubah.`,
});
props.setActionMode('list');
} else {
NotifAlert({
icon: 'error',
title: 'Gagal',
message: response.message || 'Gagal mengubah data Unit.',
});
}
} else {
// Create new unit
const payload = {
name: FormData.unit_name,
is_active: FormData.is_active,
};
const response = await createUnit(payload);
console.log('createUnit response:', response);
const response = FormData.unit_id
? await updateUnit(FormData.unit_id, payload)
: await createUnit(payload);
if (response.statusCode === 200 || response.statusCode === 201) {
// Get unit_code from response
const unitCode = response.data?.unit_code || 'N/A';
const unitCode = response.data?.unit_code || FormData.unit_code || 'N/A';
const action = FormData.unit_id ? 'diubah' : 'ditambahkan';
NotifOk({
icon: 'success',
title: 'Berhasil',
message: `Data Unit "${unitCode} - ${FormData.unit_name}" berhasil ditambahkan.`,
message: `Data Unit "${unitCode} - ${payload.unit_name}" berhasil ${action}.`,
});
props.setActionMode('list');
} else {
NotifAlert({
icon: 'error',
title: 'Gagal',
message: response.message || 'Gagal menambahkan data Unit.',
message: response.message || 'Gagal menyimpan data Unit.',
});
}
}
} catch (error) {
console.error('Save Unit Error:', error);
NotifAlert({
@@ -98,9 +76,9 @@ const DetailUnit = (props) => {
title: 'Error',
message: error.message || 'Terjadi kesalahan saat menyimpan data.',
});
}
} finally {
setConfirmLoading(false);
}
};
const handleInputChange = (e) => {