Compare commits

...

3 Commits

Author SHA1 Message Date
d7a09840b9 feat: update device API with server-side pagination support
- Add support for backend pagination with current_page, current_limit, total_limit, total_page
- Handle multiple sources for total count (total_data, rows, data.length)
- Transform backend paging structure to frontend format
- Maintain client-side pagination as fallback
- Add comprehensive error handling with try-catch
- Add detailed console logging for debugging
- Map backend fields: current_page->page, current_limit->limit, total_limit->total
- Calculate total_page if not provided by backend

Backend response format: GET /api/device?page=1&limit=10
Supports both server-side and client-side pagination modes
2025-10-10 15:50:20 +07:00
e00ecbf116 feat: add change password functionality for users
Add ChangePasswordModal component:
- Create modal with new password and confirmation fields
- Implement password validation (min 8 chars, uppercase, lowercase, number, special char)
- Add real-time error validation and display
- Show password requirements info box
- Display username for confirmation
- Add loading state during password change
- Success/error notifications

Update IndexUser:
- Add state management for change password modal
- Pass props to ListUser and ChangePasswordModal
- Integrate ChangePasswordModal component

Update ListUser:
- Add KeyOutlined icon for change password button
- Add purple change password button in action column
- Implement showChangePasswordModal function
- Update columns to include change password handler
- Increase action column width to 18%

API endpoint: PUT /api/user/change-password/:id
2025-10-10 15:49:31 +07:00
2817f3c31c feat: update user API with server-side pagination and enhanced filtering
- Add support for server-side pagination from backend
- Maintain client-side pagination as fallback
- Filter out super admin users (is_sa = true or 1) in both paths
- Add comprehensive error handling with try-catch
- Add console logging for debugging
- Return standardized response structure
- Handle both boolean and integer values for is_sa field
- Recalculate pagination info after filtering SA users

API now supports both backend pagination and ensures SA users are always hidden from the list
2025-10-10 15:48:22 +07:00
4 changed files with 452 additions and 66 deletions

View File

@@ -1,6 +1,7 @@
import { SendRequest } from '../components/Global/ApiRequest'; import { SendRequest } from '../components/Global/ApiRequest';
const getAllDevice = async (queryParams) => { const getAllDevice = async (queryParams) => {
try {
const response = await SendRequest({ const response = await SendRequest({
method: 'get', method: 'get',
prefix: `device?${queryParams.toString()}`, prefix: `device?${queryParams.toString()}`,
@@ -8,23 +9,50 @@ const getAllDevice = async (queryParams) => {
console.log('getAllDevice response:', response); console.log('getAllDevice response:', response);
console.log('Query params:', queryParams.toString()); console.log('Query params:', queryParams.toString());
// Parse query params to get page and limit // Backend response structure:
// {
// statusCode: 200,
// data: [...devices],
// paging: {
// current_page: 1,
// current_limit: 10,
// total_limit: 50,
// total_page: 5
// }
// }
// Check if backend returns paginated data
if (response.paging) {
const totalData = response.data?.[0]?.total_data || response.rows || response.data?.length || 0;
return {
status: response.statusCode || 200,
data: {
data: response.data || [],
paging: {
page: response.paging.current_page || 1,
limit: response.paging.current_limit || 10,
total: totalData,
page_total: response.paging.total_page || Math.ceil(totalData / (response.paging.current_limit || 10))
},
total: totalData
}
};
}
// Fallback: If backend returns all data without pagination (old behavior)
const params = Object.fromEntries(queryParams); const params = Object.fromEntries(queryParams);
const currentPage = parseInt(params.page) || 1; const currentPage = parseInt(params.page) || 1;
const currentLimit = parseInt(params.limit) || 10; const currentLimit = parseInt(params.limit) || 10;
// Backend returns all data, so we need to do client-side pagination
const allData = response.data || []; const allData = response.data || [];
const totalData = allData.length; const totalData = allData.length;
// Calculate start and end index for current page // Client-side pagination
const startIndex = (currentPage - 1) * currentLimit; const startIndex = (currentPage - 1) * currentLimit;
const endIndex = startIndex + currentLimit; const endIndex = startIndex + currentLimit;
// Slice data for current page
const paginatedData = allData.slice(startIndex, endIndex); const paginatedData = allData.slice(startIndex, endIndex);
// Transform response to match TableList expected structure
return { return {
status: response.statusCode || 200, status: response.statusCode || 200,
data: { data: {
@@ -38,6 +66,23 @@ const getAllDevice = async (queryParams) => {
total: totalData total: totalData
} }
}; };
} catch (error) {
console.error('getAllDevice error:', error);
return {
status: 500,
data: {
data: [],
paging: {
page: 1,
limit: 10,
total: 0,
page_total: 0
},
total: 0
},
error: error.message
};
}
}; };
const getDeviceById = async (id) => { const getDeviceById = async (id) => {

View File

@@ -1,31 +1,66 @@
import { SendRequest } from '../components/Global/ApiRequest'; import { SendRequest } from '../components/Global/ApiRequest';
const getAllUser = async (queryParams) => { const getAllUser = async (queryParams) => {
try {
console.log('getAllUser queryParams:', queryParams.toString());
const response = await SendRequest({ const response = await SendRequest({
method: 'get', method: 'get',
prefix: `user?${queryParams.toString()}`, prefix: `user?${queryParams.toString()}`,
}); });
// Parse query params to get page and limit console.log('getAllUser response:', response);
// Backend now handles pagination, just return the response
// Expected backend response structure:
// {
// statusCode: 200,
// data: [...users],
// paging: { page, limit, total, page_total }
// }
// Check if backend returns paginated data
if (response.paging) {
// Filter out super admin users (is_sa = true)
const allData = response.data || [];
const filteredData = allData.filter(user => user.is_sa !== true && user.is_sa !== 1);
// Recalculate pagination info after filtering
const totalAfterFilter = filteredData.length;
const currentPage = response.paging.page || 1;
const currentLimit = response.paging.limit || 10;
return {
status: response.statusCode || 200,
data: {
data: filteredData,
paging: {
page: currentPage,
limit: currentLimit,
total: totalAfterFilter,
page_total: Math.ceil(totalAfterFilter / currentLimit)
},
total: totalAfterFilter
}
};
}
// Fallback: If backend returns all data without pagination (old behavior)
const params = Object.fromEntries(queryParams); const params = Object.fromEntries(queryParams);
const currentPage = parseInt(params.page) || 1; const currentPage = parseInt(params.page) || 1;
const currentLimit = parseInt(params.limit) || 10; const currentLimit = parseInt(params.limit) || 10;
// Backend returns all data, so we need to do client-side pagination
const allData = response.data || []; const allData = response.data || [];
// Filter out users with is_sa = true // Filter out users with is_sa = true or 1 (client-side filtering)
const filteredData = allData.filter(user => user.is_sa !== true); const filteredData = allData.filter(user => user.is_sa !== true && user.is_sa !== 1);
const totalData = filteredData.length; const totalData = filteredData.length;
// Calculate start and end index for current page // Client-side pagination
const startIndex = (currentPage - 1) * currentLimit; const startIndex = (currentPage - 1) * currentLimit;
const endIndex = startIndex + currentLimit; const endIndex = startIndex + currentLimit;
// Slice data for current page
const paginatedData = filteredData.slice(startIndex, endIndex); const paginatedData = filteredData.slice(startIndex, endIndex);
// Transform response to match TableList expected structure
return { return {
status: response.statusCode || 200, status: response.statusCode || 200,
data: { data: {
@@ -39,6 +74,24 @@ const getAllUser = async (queryParams) => {
total: totalData total: totalData
} }
}; };
} catch (error) {
console.error('getAllUser error:', error);
// Return empty data on error to prevent app crash
return {
status: 500,
data: {
data: [],
paging: {
page: 1,
limit: 10,
total: 0,
page_total: 0
},
total: 0
},
error: error.message
};
}
}; };
const getUserById = async (id) => { const getUserById = async (id) => {
@@ -103,4 +156,23 @@ const approveUser = async (user_id) => {
}; };
}; };
export { getAllUser, getUserById, createUser, updateUser, deleteUser, approveUser }; const changePassword = async (user_id, new_password) => {
const response = await SendRequest({
method: 'put',
prefix: `user/change-password/${user_id}`,
params: {
new_password: new_password
},
});
console.log('Change Password Response:', response);
// Return full response with statusCode
return {
statusCode: response.statusCode || 200,
data: response.data,
message: response.message || 'Password berhasil diubah'
};
};
export { getAllUser, getUserById, createUser, updateUser, deleteUser, approveUser, changePassword };

View File

@@ -2,6 +2,7 @@ import React, { memo, useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import ListUser from './component/ListUser'; import ListUser from './component/ListUser';
import DetailUser from './component/DetailUser'; import DetailUser from './component/DetailUser';
import ChangePasswordModal from './component/ChangePasswordModal';
import { useBreadcrumb } from '../../layout/LayoutBreadcrumb'; import { useBreadcrumb } from '../../layout/LayoutBreadcrumb';
import { Typography } from 'antd'; import { Typography } from 'antd';
@@ -15,6 +16,8 @@ const IndexUser = memo(function IndexUser() {
const [selectedData, setSelectedData] = useState(null); const [selectedData, setSelectedData] = useState(null);
const [readOnly, setReadOnly] = useState(false); const [readOnly, setReadOnly] = useState(false);
const [showModal, setShowmodal] = useState(false); const [showModal, setShowmodal] = useState(false);
const [showChangePasswordModal, setShowChangePasswordModal] = useState(false);
const [selectedUserForPassword, setSelectedUserForPassword] = useState(null);
const setMode = (param) => { const setMode = (param) => {
setShowmodal(true); setShowmodal(true);
@@ -63,6 +66,8 @@ const IndexUser = memo(function IndexUser() {
selectedData={selectedData} selectedData={selectedData}
setSelectedData={setSelectedData} setSelectedData={setSelectedData}
readOnly={readOnly} readOnly={readOnly}
setShowChangePasswordModal={setShowChangePasswordModal}
setSelectedUserForPassword={setSelectedUserForPassword}
/> />
<DetailUser <DetailUser
setActionMode={setMode} setActionMode={setMode}
@@ -72,6 +77,12 @@ const IndexUser = memo(function IndexUser() {
showModal={showModal} showModal={showModal}
actionMode={actionMode} actionMode={actionMode}
/> />
<ChangePasswordModal
showModal={showChangePasswordModal}
setShowModal={setShowChangePasswordModal}
selectedUser={selectedUserForPassword}
setSelectedUser={setSelectedUserForPassword}
/>
</React.Fragment> </React.Fragment>
); );
}); });

View File

@@ -0,0 +1,258 @@
import React, { useState, useEffect } from 'react';
import { Modal, Input, Typography, Button, ConfigProvider } from 'antd';
import { NotifAlert, NotifOk } from '../../../components/Global/ToastNotif';
import { changePassword } from '../../../api/user';
const { Text } = Typography;
const ChangePasswordModal = (props) => {
const [confirmLoading, setConfirmLoading] = useState(false);
const [formData, setFormData] = useState({
newPassword: '',
confirmPassword: '',
});
const [errors, setErrors] = useState({});
const validatePassword = (password) => {
if (!password) return 'Password wajib diisi';
// Must be at least 8 characters long
if (password.length < 8) {
return 'Password must be at least 8 characters long';
}
// Must contain at least one uppercase letter
if (!/[A-Z]/.test(password)) {
return 'Password must contain at least one uppercase letter';
}
// Must contain at least one lowercase letter
if (!/[a-z]/.test(password)) {
return 'Password must contain at least one lowercase letter';
}
// Must contain at least one number
if (!/\d/.test(password)) {
return 'Password must contain at least one number';
}
// Must contain at least one special character
if (!/[!@#$%^&*(),.?":{}|<>]/.test(password)) {
return 'Password must contain at least one special character';
}
return null;
};
const validateForm = () => {
const newErrors = {};
const passwordError = validatePassword(formData.newPassword);
if (passwordError) {
newErrors.newPassword = passwordError;
}
if (!formData.confirmPassword) {
newErrors.confirmPassword = 'Konfirmasi password wajib diisi';
} else if (formData.newPassword !== formData.confirmPassword) {
newErrors.confirmPassword = 'Password tidak cocok';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleCancel = () => {
props.setShowModal(false);
props.setSelectedUser(null);
setFormData({
newPassword: '',
confirmPassword: '',
});
setErrors({});
};
const handleSave = async () => {
if (!validateForm()) {
NotifOk({
icon: 'warning',
title: 'Peringatan',
message: 'Mohon periksa kembali form Anda',
});
return;
}
setConfirmLoading(true);
try {
const response = await changePassword(props.selectedUser.user_id, formData.newPassword);
console.log('Change Password Response:', response);
if (response && response.statusCode === 200) {
NotifOk({
icon: 'success',
title: 'Berhasil',
message: `Password untuk user "${props.selectedUser.user_fullname}" berhasil diubah.`,
});
handleCancel();
} else {
NotifAlert({
icon: 'error',
title: 'Gagal',
message: response?.message || 'Terjadi kesalahan saat mengubah password.',
});
}
} catch (error) {
console.error('Change Password Error:', error);
NotifAlert({
icon: 'error',
title: 'Error',
message: error.message || 'Terjadi kesalahan pada server. Coba lagi nanti.',
});
}
setConfirmLoading(false);
};
const handleInputChange = (e) => {
const { name, value } = e.target;
setFormData({
...formData,
[name]: value,
});
// Clear error for this field
if (errors[name]) {
setErrors({
...errors,
[name]: null,
});
}
};
useEffect(() => {
if (!props.showModal) {
setFormData({
newPassword: '',
confirmPassword: '',
});
setErrors({});
}
}, [props.showModal]);
return (
<Modal
title={`Ubah Password - ${props.selectedUser?.user_fullname || ''}`}
open={props.showModal}
onCancel={handleCancel}
footer={[
<React.Fragment key="modal-footer">
<ConfigProvider
theme={{
token: { colorBgContainer: '#E9F6EF' },
components: {
Button: {
defaultBg: 'white',
defaultColor: '#23A55A',
defaultBorderColor: '#23A55A',
defaultHoverColor: '#23A55A',
defaultHoverBorderColor: '#23A55A',
},
},
}}
>
<Button onClick={handleCancel}>Batal</Button>
</ConfigProvider>
<ConfigProvider
theme={{
token: {
colorBgContainer: '#209652',
},
components: {
Button: {
defaultBg: '#23a55a',
defaultColor: '#FFFFFF',
defaultBorderColor: '#23a55a',
defaultHoverColor: '#FFFFFF',
defaultHoverBorderColor: '#23a55a',
},
},
}}
>
<Button loading={confirmLoading} onClick={handleSave}>
Simpan
</Button>
</ConfigProvider>
</React.Fragment>,
]}
width={500}
>
<div>
<div style={{ marginBottom: 12 }}>
<Text strong>Username</Text>
<Input
value={props.selectedUser?.user_name || ''}
disabled
style={{ backgroundColor: '#f5f5f5' }}
/>
</div>
<div style={{ marginBottom: 12 }}>
<Text strong>Password Baru</Text>
<Text style={{ color: 'red' }}> *</Text>
<Input.Password
name="newPassword"
value={formData.newPassword}
onChange={handleInputChange}
placeholder="Masukkan password baru"
status={errors.newPassword ? 'error' : ''}
/>
{errors.newPassword && (
<Text style={{ color: 'red', fontSize: '12px' }}>{errors.newPassword}</Text>
)}
</div>
<div style={{ marginBottom: 12 }}>
<Text strong>Konfirmasi Password</Text>
<Text style={{ color: 'red' }}> *</Text>
<Input.Password
name="confirmPassword"
value={formData.confirmPassword}
onChange={handleInputChange}
placeholder="Konfirmasi password baru"
status={errors.confirmPassword ? 'error' : ''}
/>
{errors.confirmPassword && (
<Text style={{ color: 'red', fontSize: '12px' }}>
{errors.confirmPassword}
</Text>
)}
</div>
<div
style={{
marginTop: 16,
padding: 12,
backgroundColor: '#f0f8ff',
borderRadius: 4,
border: '1px solid #d6e4ff',
}}
>
<Text style={{ fontSize: '12px', color: '#595959' }}>
<strong>Persyaratan password:</strong>
<ul style={{ marginTop: 8, marginBottom: 0, paddingLeft: 20 }}>
<li>Minimal 8 karakter</li>
<li>Minimal 1 huruf besar (A-Z)</li>
<li>Minimal 1 huruf kecil (a-z)</li>
<li>Minimal 1 angka (0-9)</li>
<li>Minimal 1 karakter spesial (!@#$%^&*)</li>
</ul>
</Text>
</div>
</div>
</Modal>
);
};
export default ChangePasswordModal;