lavoce #1
64
src/components/Common/BasicButton.jsx
Normal file
64
src/components/Common/BasicButton.jsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import React from "react";
|
||||
import { Button, ConfigProvider } from 'antd';
|
||||
import propTypes from 'prop-types';
|
||||
|
||||
const BasicButton = ({
|
||||
// color,
|
||||
text,
|
||||
size,
|
||||
block,
|
||||
clickTheButton
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
token: {
|
||||
// colorBgContainer: '#23A55A',
|
||||
colorText: 'white',
|
||||
colorBgContainer: 'purple',
|
||||
// Seed Token
|
||||
// colorPrimary: '#23A55A',
|
||||
// // colorPrimary: `${color}`,
|
||||
// borderRadius: 8,
|
||||
|
||||
|
||||
// // Alias Token
|
||||
// // colorBgContainer: '#f6ffed',
|
||||
// // colorBgContainer: {color},
|
||||
},
|
||||
components: {
|
||||
Button: {
|
||||
defaultBg: '#23A55A',
|
||||
defaultColor: 'white',
|
||||
}
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
type=""
|
||||
block={block}
|
||||
size={size}
|
||||
onClick={() => clickTheButton(block)}
|
||||
// style={{
|
||||
// background: "#23A55A",
|
||||
// borderColor: "#23A55A",
|
||||
// color: "white",
|
||||
// }}
|
||||
>
|
||||
{text}
|
||||
</Button>
|
||||
</ConfigProvider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
BasicButton.propTypes = {
|
||||
// color: propTypes.string,
|
||||
text: propTypes.string,
|
||||
size: propTypes.string,
|
||||
block: propTypes.bool,
|
||||
clickTheButton: propTypes.any
|
||||
}
|
||||
|
||||
export default BasicButton;
|
||||
21
src/components/Common/BasicInput.jsx
Normal file
21
src/components/Common/BasicInput.jsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { theme } from "antd";
|
||||
import React from "react";
|
||||
// import packageJson from "../../../package.json";
|
||||
|
||||
const BasicInput = () => {
|
||||
const {
|
||||
token: { colorPrimary },
|
||||
} = theme.useToken();
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="versionContainer"
|
||||
className="cursor-default fixed top-1 right-1 z-20 m-1"
|
||||
style={{ color: colorPrimary }}
|
||||
>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BasicInput;
|
||||
58
src/components/Common/NavBar.jsx
Normal file
58
src/components/Common/NavBar.jsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Layout, theme } from "antd";
|
||||
import React from 'react'
|
||||
import propTypes from 'prop-types';
|
||||
|
||||
const { Header } = Layout;
|
||||
|
||||
const navbarClass =
|
||||
"text-white text-h5 flex flex-col whitespace-nowrap overflow-hidden text-ellipsis";
|
||||
const styles = {
|
||||
navbarTitleFullSize: navbarClass,
|
||||
navbarTitle: navbarClass + " max-w-[calc(100vw-200px)]",
|
||||
};
|
||||
const NavBar = ({ fullSize }) => {
|
||||
const {
|
||||
token: { colorPrimary },
|
||||
} = theme.useToken();
|
||||
|
||||
console.log("import.meta", import.meta)
|
||||
|
||||
return (
|
||||
<Header
|
||||
data-testid="navbarContainer"
|
||||
className="fixed z-10 flex w-full drop-shadow-md"
|
||||
style={{
|
||||
backgroundColor: colorPrimary,
|
||||
paddingInline: 0,
|
||||
}}
|
||||
>
|
||||
<div className="flex h-full w-full items-center gap-4 pl-[5%]">
|
||||
<a href="/">
|
||||
<div
|
||||
data-testid="navbarLogo"
|
||||
className="h-12 w-48 bg-white text-center"
|
||||
>
|
||||
LOGO
|
||||
</div>
|
||||
</a>
|
||||
<div
|
||||
data-testid="navbarTitle"
|
||||
className={fullSize ? styles.navbarTitleFullSize : styles.navbarTitle}
|
||||
>
|
||||
<div className="text-[2.9vw] font-bold leading-8 sm:text-h5">
|
||||
{import.meta.env.VITE_PROJECT_NAME}
|
||||
</div>
|
||||
<div className="pb-1 text-[2vw] font-bold uppercase leading-3 sm:text-small">
|
||||
{import.meta.env.VITE_PROJECT_DESCRIPTION}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Header>
|
||||
);
|
||||
};
|
||||
|
||||
NavBar.propTypes = {
|
||||
fullSize: propTypes.any,
|
||||
}
|
||||
|
||||
export default NavBar;
|
||||
20
src/components/Common/Version.jsx
Normal file
20
src/components/Common/Version.jsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { theme } from "antd";
|
||||
import React from "react";
|
||||
import packageJson from "../../../package.json";
|
||||
|
||||
const Version = () => {
|
||||
const {
|
||||
token: { colorPrimary },
|
||||
} = theme.useToken();
|
||||
return (
|
||||
<div
|
||||
data-testid="versionContainer"
|
||||
className="cursor-default fixed top-1 right-1 z-20 m-1"
|
||||
style={{ color: colorPrimary }}
|
||||
>
|
||||
v{packageJson.version}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Version;
|
||||
127
src/components/Global/ApiRequest.jsx
Normal file
127
src/components/Global/ApiRequest.jsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import axios from 'axios';
|
||||
import Swal from 'sweetalert2';
|
||||
|
||||
async function ApiRequest(
|
||||
urlParams = { method: 'GET', params: {}, url: '', prefix: '/', token: true }
|
||||
) {
|
||||
const baseURLDef = `${import.meta.env.VITE_API_SERVER}`;
|
||||
const instance = axios.create({
|
||||
baseURL: urlParams.url ?? baseURLDef,
|
||||
});
|
||||
|
||||
const isFormData = urlParams.params instanceof FormData;
|
||||
|
||||
const request = {
|
||||
method: urlParams.method,
|
||||
url: urlParams.prefix ?? '/',
|
||||
data: urlParams.params,
|
||||
// yang lama
|
||||
// headers: {
|
||||
// 'Content-Type': 'application/json',
|
||||
// 'Accept-Language': 'en_US',
|
||||
// },
|
||||
|
||||
// yang baru
|
||||
headers: {
|
||||
'Accept-Language': 'en_US',
|
||||
...(isFormData ? {} : { 'Content-Type': 'application/json' }),
|
||||
},
|
||||
};
|
||||
|
||||
if (urlParams.params === 'doc') {
|
||||
request.responseType = 'arraybuffer';
|
||||
request.headers['Content-Type'] = 'blob';
|
||||
}
|
||||
|
||||
// console.log(request);
|
||||
|
||||
// console.log('prefix', urlParams.prefix);
|
||||
|
||||
const tokenRedirect = sessionStorage.getItem('token_redirect');
|
||||
|
||||
let stringToken = '';
|
||||
|
||||
if (tokenRedirect !== null) {
|
||||
stringToken = tokenRedirect;
|
||||
// console.log(`sessionStorage: ${tokenRedirect}`);
|
||||
} else {
|
||||
stringToken = localStorage.getItem('token');
|
||||
// console.log(`localStorage: ${stringToken}`);
|
||||
}
|
||||
|
||||
if (urlParams.prefix !== 'auth/login') {
|
||||
const tokenWithQuotes = stringToken;
|
||||
const token = tokenWithQuotes.replace(/"/g, '');
|
||||
const AUTH_TOKEN = `Bearer ${token}`;
|
||||
instance.defaults.headers.common['Authorization'] = AUTH_TOKEN;
|
||||
} else if (urlParams.token == true) {
|
||||
const tokenWithQuotes = stringToken;
|
||||
const token = tokenWithQuotes.replace(/"/g, '');
|
||||
const AUTH_TOKEN = `Bearer ${token}`;
|
||||
instance.defaults.headers.common['Authorization'] = AUTH_TOKEN;
|
||||
}
|
||||
|
||||
return await instance(request)
|
||||
.then(function (response) {
|
||||
const responseCustom = response;
|
||||
responseCustom.error = false;
|
||||
return responseCustom;
|
||||
})
|
||||
.catch(function (error) {
|
||||
console.log('error', error.toJSON());
|
||||
|
||||
const errorData = error.toJSON();
|
||||
|
||||
const respError = error.response ?? {};
|
||||
cekError(
|
||||
errorData.status,
|
||||
error?.response?.data?.message ?? errorData?.message ?? 'Something Wrong'
|
||||
);
|
||||
respError.error = true;
|
||||
return respError;
|
||||
});
|
||||
}
|
||||
|
||||
async function cekError(props, message = '') {
|
||||
console.log('status code', props);
|
||||
if (props === 401) {
|
||||
Swal.fire({
|
||||
icon: 'warning',
|
||||
title: 'Peringatan',
|
||||
text: `${message}, Silahkan login`,
|
||||
}).then((result) => {
|
||||
if (result.isConfirmed) {
|
||||
localStorage.clear();
|
||||
location.replace('/signin');
|
||||
} else if (result.isDenied) {
|
||||
Swal.fire('Changes are not saved', '', 'info');
|
||||
}
|
||||
});
|
||||
} else {
|
||||
Swal.fire({
|
||||
icon: 'warning',
|
||||
title: 'Peringatan',
|
||||
text: message,
|
||||
}).then((result) => {});
|
||||
}
|
||||
}
|
||||
|
||||
const SendRequest = async (queryParams) => {
|
||||
try {
|
||||
const response = await ApiRequest(queryParams);
|
||||
return response || [];
|
||||
} catch (error) {
|
||||
console.log('error', error);
|
||||
if (error.response) {
|
||||
console.error('Error Status:', error.response.status); // Status error, misal: 401
|
||||
console.error('Error Data:', error.response.data); // Detail pesan error
|
||||
console.error('Error Pesan:', error.response.data.message); //Pesan error
|
||||
} else {
|
||||
console.error('Error:', error.message);
|
||||
}
|
||||
Swal.fire({ icon: 'error', text: error });
|
||||
// return error;
|
||||
}
|
||||
};
|
||||
|
||||
export { ApiRequest, SendRequest };
|
||||
7
src/components/Global/DataFilter.jsx
Normal file
7
src/components/Global/DataFilter.jsx
Normal file
@@ -0,0 +1,7 @@
|
||||
let filterData = [];
|
||||
|
||||
export const setFilterData = (data) => {
|
||||
filterData = data;
|
||||
};
|
||||
|
||||
export const getFilterData = () => filterData;
|
||||
24
src/components/Global/EmptyData.jsx
Normal file
24
src/components/Global/EmptyData.jsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Empty, Col, Row, Typography } from "antd";
|
||||
const {Text} = Typography;
|
||||
|
||||
const EmptyData = ({
|
||||
titleButton,
|
||||
titlePositionButton
|
||||
})=>{
|
||||
return(
|
||||
<Empty
|
||||
description={
|
||||
<Col xs={24} sm={24} md={24} lg={24} xl={24}>
|
||||
<Row justify="center" align="middle" style={{ height: "100%" }}>
|
||||
<Text style={{fontSize:'22px', fontWeight:'bold'}}>Data Kosong</Text>
|
||||
</Row>
|
||||
<Row justify="center" align="middle" style={{ height: "100%"}}>
|
||||
<Text style={{width:'20%', fontSize:'20px'}}>Untuk menampilkan data silahkan klik tombol <Text strong style={{fontSize:'20px'}}>{titleButton}</Text> {titlePositionButton}</Text>
|
||||
</Row>
|
||||
</Col>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmptyData;
|
||||
192
src/components/Global/Formatter.jsx
Normal file
192
src/components/Global/Formatter.jsx
Normal file
@@ -0,0 +1,192 @@
|
||||
const formatIDR = (value) => {
|
||||
if (!value) return '';
|
||||
return new Intl.NumberFormat('id-ID', {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(value);
|
||||
};
|
||||
|
||||
const formatCurrencyUSD = (value) => {
|
||||
if (!value) return '';
|
||||
const numericValue = value.replace(/[^0-9.]/g, ''); // Hanya angka dan titik
|
||||
const parts = numericValue.split('.'); // Pisahkan bagian desimal
|
||||
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ','); // Tambahkan koma
|
||||
return `${parts.join('.')}`; // Gabungkan kembali dengan simbol $
|
||||
};
|
||||
|
||||
const formatCurrencyIDR = (value) => {
|
||||
if (!value) return '';
|
||||
const numericValue = value.replace(/[^0-9]/g, '');
|
||||
return numericValue.replace(/\B(?=(\d{3})+(?!\d))/g, '.');
|
||||
};
|
||||
|
||||
const toApiNumberFormatter = (value) => {
|
||||
if (!value) return '';
|
||||
const formattedValue = value.replace(/[.,]/g, ''); // Hapus semua titik
|
||||
return Number(formattedValue);
|
||||
};
|
||||
|
||||
const toAppDateFormatter = (value) => {
|
||||
const date = new Date(value);
|
||||
|
||||
const months = [
|
||||
'Jan',
|
||||
'Feb',
|
||||
'Mar',
|
||||
'Apr',
|
||||
'May',
|
||||
'Jun',
|
||||
'Jul',
|
||||
'Aug',
|
||||
'Sep',
|
||||
'Oct',
|
||||
'Nov',
|
||||
'Dec',
|
||||
];
|
||||
const day = date.getUTCDate().toString().padStart(2, '0');
|
||||
const month = months[date.getUTCMonth()];
|
||||
const year = date.getUTCFullYear();
|
||||
|
||||
const formattedDate = `${day} ${month} ${year}`;
|
||||
return formattedDate;
|
||||
};
|
||||
|
||||
const toAppDateFormatterTwoDigit = (value) => {
|
||||
const date = new Date(value);
|
||||
|
||||
// Pastikan validitas tanggal
|
||||
if (isNaN(date.getTime())) {
|
||||
return 'Invalid Date'; // Handle nilai tidak valid
|
||||
}
|
||||
|
||||
const day = date.getUTCDate().toString().padStart(2, '0');
|
||||
const month = (date.getUTCMonth() + 1).toString().padStart(2, '0'); // Tambahkan 1 ke bulan karena bulan dimulai dari 0
|
||||
const year = date.getUTCFullYear();
|
||||
|
||||
const formattedDate = `${day}-${month}-${year}`;
|
||||
return formattedDate;
|
||||
};
|
||||
|
||||
const toApiDateFormatter = (value) => {
|
||||
const parts = value.split('-');
|
||||
if (parts.length === 3) {
|
||||
const day = parts[0];
|
||||
const month = parts[1];
|
||||
const year = parts[2];
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
return '';
|
||||
};
|
||||
|
||||
const toAppDateTimezoneFormatter = (value) => {
|
||||
const jakartaTimezone = 'Asia/Jakarta';
|
||||
const date = new Date(value);
|
||||
|
||||
const formatterDay = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: jakartaTimezone,
|
||||
day: '2-digit',
|
||||
});
|
||||
const formatterMonth = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: jakartaTimezone,
|
||||
month: 'short',
|
||||
});
|
||||
const formatterYear = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: jakartaTimezone,
|
||||
year: 'numeric',
|
||||
});
|
||||
|
||||
const day = formatterDay.format(date);
|
||||
const month = formatterMonth.format(date);
|
||||
const year = formatterYear.format(date);
|
||||
|
||||
return `${day} ${month} ${year}`;
|
||||
};
|
||||
|
||||
const toApiDateTimezoneFormatter = (value) => {
|
||||
const jakartaTimezone = 'Asia/Jakarta';
|
||||
const date = new Date(value);
|
||||
|
||||
const formatterDay = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: jakartaTimezone,
|
||||
day: '2-digit',
|
||||
});
|
||||
const formatterMonth = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: jakartaTimezone,
|
||||
month: '2-digit',
|
||||
});
|
||||
const formatterYear = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: jakartaTimezone,
|
||||
year: 'numeric',
|
||||
});
|
||||
|
||||
const day = formatterDay.format(date);
|
||||
const month = formatterMonth.format(date);
|
||||
const year = formatterYear.format(date);
|
||||
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
import { message } from 'antd';
|
||||
// cryptoHelper.js
|
||||
import CryptoJS from 'crypto-js';
|
||||
|
||||
const secretKey = `${import.meta.env.VITE_KEY_SESSION}`; // Ganti dengan kunci rahasia kamu
|
||||
|
||||
// Fungsi untuk mengenkripsi data
|
||||
const encryptData = (data) => {
|
||||
try {
|
||||
const ciphertext = CryptoJS.AES.encrypt(JSON.stringify(data), secretKey).toString();
|
||||
return ciphertext;
|
||||
} catch (error) {
|
||||
console.error('Encrypt Error:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Fungsi untuk mendekripsi data
|
||||
const decryptData = (ciphertext) => {
|
||||
try {
|
||||
const bytes = CryptoJS.AES.decrypt(ciphertext, secretKey);
|
||||
const decrypted = JSON.parse(bytes.toString(CryptoJS.enc.Utf8));
|
||||
if (decrypted?.error) {
|
||||
decrypted.error = false;
|
||||
}
|
||||
return decrypted;
|
||||
} catch (error) {
|
||||
// console.error('Decrypt Error:', error);
|
||||
return { error: true, message: `Decrypt Error: ${error}` };
|
||||
}
|
||||
};
|
||||
|
||||
const getSessionData = () => {
|
||||
try {
|
||||
const ciphertext = localStorage.getItem('session');
|
||||
|
||||
if (!ciphertext) {
|
||||
return {
|
||||
error: true,
|
||||
};
|
||||
}
|
||||
const result = decryptData(ciphertext);
|
||||
return result;
|
||||
} catch (error) {
|
||||
// console.error('Decrypt Error:', error);
|
||||
return { error: true, message: error };
|
||||
}
|
||||
};
|
||||
|
||||
export {
|
||||
formatIDR,
|
||||
formatCurrencyUSD,
|
||||
formatCurrencyIDR,
|
||||
toApiNumberFormatter,
|
||||
toAppDateFormatter,
|
||||
toApiDateFormatter,
|
||||
toAppDateTimezoneFormatter,
|
||||
toAppDateFormatterTwoDigit,
|
||||
toApiDateTimezoneFormatter,
|
||||
encryptData,
|
||||
getSessionData,
|
||||
decryptData,
|
||||
};
|
||||
23
src/components/Global/KopReport.jsx
Normal file
23
src/components/Global/KopReport.jsx
Normal file
@@ -0,0 +1,23 @@
|
||||
const toBase64 = (url) =>
|
||||
fetch(url)
|
||||
.then((res) => res.blob())
|
||||
.then(
|
||||
(blob) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => resolve(reader.result);
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(blob);
|
||||
})
|
||||
);
|
||||
|
||||
// Fungsi utama
|
||||
const kopReportPdf = async (logo, title) => {
|
||||
const images = await toBase64(logo);
|
||||
return {
|
||||
images,
|
||||
title,
|
||||
};
|
||||
};
|
||||
|
||||
export { kopReportPdf };
|
||||
72
src/components/Global/MqttConnection.jsx
Normal file
72
src/components/Global/MqttConnection.jsx
Normal file
@@ -0,0 +1,72 @@
|
||||
// mqttService.js
|
||||
import mqtt from 'mqtt';
|
||||
|
||||
const mqttUrl = 'ws://36.66.16.49:9001';
|
||||
const topics = ['SYPIU_GGCP', 'SYPIU_GGCP/list-permit/changed','SYPIU_GGCP/list-user/changed'];
|
||||
|
||||
const options = {
|
||||
keepalive: 30,
|
||||
clientId: 'react_mqtt_' + Math.random().toString(16).substr(2, 8),
|
||||
protocolId: 'MQTT',
|
||||
protocolVersion: 4,
|
||||
clean: true,
|
||||
reconnectPeriod: 1000,
|
||||
connectTimeout: 30 * 1000,
|
||||
username: 'ide', // jika ada
|
||||
password: 'aremania', // jika ada
|
||||
};
|
||||
|
||||
const client = mqtt.connect(mqttUrl, options);
|
||||
|
||||
// Track connection status
|
||||
let isConnected = false;
|
||||
|
||||
client.on('connect', () => {
|
||||
console.log('MQTT Connected');
|
||||
isConnected = true;
|
||||
|
||||
// Subscribe default topic
|
||||
client.subscribe(topics, (err) => {
|
||||
if (err) console.error('Subscribe error:', err);
|
||||
else console.log(`Subscribed to topics: ${topics.join(', ')}`);
|
||||
});
|
||||
});
|
||||
|
||||
client.on('error', (err) => {
|
||||
console.error('Connection error: ', err);
|
||||
client.end();
|
||||
});
|
||||
|
||||
client.on('close', () => {
|
||||
console.log('MQTT Disconnected');
|
||||
isConnected = false;
|
||||
});
|
||||
|
||||
/**
|
||||
* Publish message to MQTT
|
||||
* @param {string} topic
|
||||
* @param {string} message
|
||||
*/
|
||||
const publishMessage = (topic, message) => {
|
||||
if (client && isConnected && message.trim() !== '') {
|
||||
client.publish(topic, message);
|
||||
} else {
|
||||
console.warn('MQTT not connected or message empty');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Listen to incoming messages
|
||||
* @param {function} callback - Function(topic, message)
|
||||
*/
|
||||
const listenMessage = (callback) => {
|
||||
client.on('message', (topic, message) => {
|
||||
callback(topic, message.toString());
|
||||
});
|
||||
};
|
||||
|
||||
export {
|
||||
publishMessage,
|
||||
listenMessage,
|
||||
client,
|
||||
};
|
||||
109
src/components/Global/QrPermit.jsx
Normal file
109
src/components/Global/QrPermit.jsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { Typography } from 'antd';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import ImgPIU from '../../assets/freepik/LOGOPIU.png';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
function QrPermit() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const selectedRole = searchParams.get('role');
|
||||
const selectedName = searchParams.get('name');
|
||||
const selectedTime = searchParams.get('time');
|
||||
|
||||
const formattedTime = selectedTime
|
||||
? new Date(selectedTime).toLocaleString('id-ID', {
|
||||
day: '2-digit',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
: '-';
|
||||
|
||||
const userApproval = {
|
||||
role: ['Performing Authority', 'Permit Control', 'Site Authority', 'Area Authority'],
|
||||
roleCode: ['PA', 'PC', 'SA', 'AA'],
|
||||
};
|
||||
|
||||
const filteredRoles = userApproval.role.filter((role, index) => {
|
||||
const code = userApproval.roleCode[index];
|
||||
return !selectedRole || selectedRole === code;
|
||||
});
|
||||
|
||||
const isMobile = window.innerWidth <= 768;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '#fafafa',
|
||||
textAlign: 'center',
|
||||
minHeight: '100vh',
|
||||
padding: isMobile ? '1rem' : '3rem',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={ImgPIU}
|
||||
alt="Logo PIU"
|
||||
style={{
|
||||
width: isMobile ? '100%' : '100%',
|
||||
maxWidth: isMobile ? '300px' : '30%',
|
||||
height: 'auto',
|
||||
marginBottom: isMobile ? '1.5rem' : '2vh',
|
||||
marginTop: isMobile ? '-1rem' : '-75px',
|
||||
}}
|
||||
/>
|
||||
{filteredRoles.map((role, index) => (
|
||||
<div key={index} style={{ width: '100%' }}>
|
||||
<Title
|
||||
strong
|
||||
level={1}
|
||||
style={{
|
||||
marginTop: isMobile ? '-2rem' : '-75px',
|
||||
fontWeight: 600,
|
||||
color: '#004D80',
|
||||
textAlign: 'center',
|
||||
fontSize: isMobile ? '6vw' : '2.5rem',
|
||||
}}
|
||||
>
|
||||
Validasi Dokumen <br /> Pupuk Indonesia Utilitas
|
||||
</Title>
|
||||
<Title
|
||||
level={2}
|
||||
style={{
|
||||
marginTop: isMobile ? '1rem' : '-5px',
|
||||
fontWeight: 600,
|
||||
color: '#141414',
|
||||
textAlign: 'center',
|
||||
fontSize: isMobile ? '6vw' : '2.5rem',
|
||||
}}
|
||||
>
|
||||
{role}
|
||||
</Title>
|
||||
<div
|
||||
style={{
|
||||
fontSize: isMobile ? '4.5vw' : '25px',
|
||||
lineHeight: isMobile ? 1.5 : 1.8,
|
||||
marginTop: isMobile ? '1rem' : '1.5rem',
|
||||
}}
|
||||
>
|
||||
<Text strong style={{ fontSize: isMobile ? '4.5vw' : 25 }}>
|
||||
Nama:
|
||||
</Text>{' '}
|
||||
{selectedName ?? '-'}
|
||||
<br />
|
||||
<Text strong style={{ fontSize: isMobile ? '4.5vw' : 25 }}>
|
||||
{formattedTime}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default QrPermit;
|
||||
25
src/components/Global/RegisterRequest.jsx
Normal file
25
src/components/Global/RegisterRequest.jsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const RegistrationRequest = async ({ method, prefix, params, headers = {} }) => {
|
||||
const baseURL = `${import.meta.env.VITE_API_SERVER}`;
|
||||
|
||||
try {
|
||||
const response = await axios({
|
||||
method: method,
|
||||
url: `${baseURL}/${prefix}`,
|
||||
data: params,
|
||||
headers: {
|
||||
'Accept-Language': 'en_US',
|
||||
...headers,
|
||||
},
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
return response.data || {};
|
||||
} catch (error) {
|
||||
console.error(`Error saat ${prefix}:`, error.response?.data || error.message);
|
||||
throw error.response?.data || { message: 'Terjadi kesalahan pada server' };
|
||||
}
|
||||
};
|
||||
|
||||
export default RegistrationRequest;
|
||||
499
src/components/Global/StatusButton.jsx
Normal file
499
src/components/Global/StatusButton.jsx
Normal file
@@ -0,0 +1,499 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Modal,
|
||||
Divider,
|
||||
Card,
|
||||
Tag,
|
||||
ConfigProvider,
|
||||
Typography,
|
||||
message,
|
||||
Input,
|
||||
Radio,
|
||||
} from 'antd';
|
||||
import { getStatusHistory, approvalPermit } from '../../api/status-history';
|
||||
import { NotifAlert, NotifOk, NotifConfirmDialog } from './ToastNotif';
|
||||
import { getSessionData } from './Formatter';
|
||||
import dayjs from 'dayjs';
|
||||
import utc from 'dayjs/plugin/utc';
|
||||
dayjs.extend(utc);
|
||||
|
||||
const { Text } = Typography;
|
||||
const { TextArea } = Input;
|
||||
|
||||
const StatusButton = (props) => {
|
||||
const {
|
||||
color,
|
||||
name,
|
||||
style,
|
||||
canApprove = true,
|
||||
canReject = true,
|
||||
refreshData = (e) => {},
|
||||
} = props;
|
||||
|
||||
const session = getSessionData();
|
||||
const isVendor = session?.user?.role_id == `${import.meta.env.VITE_ROLE_VENDOR}`;
|
||||
|
||||
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||
const [confirmLoading, setConfirmLoading] = useState(false);
|
||||
const [historyData, setHistoryData] = useState([]);
|
||||
const [showConfirmModal, setShowConfirmModal] = useState(false);
|
||||
const [showPermitSelesai, setShowPermitSelesai] = useState(false);
|
||||
const [actionType, setActionType] = useState(null);
|
||||
const [deskripsi, setDeskripsi] = useState('');
|
||||
const [closeType, setCloseType] = useState('1');
|
||||
|
||||
const pengajuanPermitSelesai = 4;
|
||||
const permitSelesai = 7;
|
||||
|
||||
const fetchHistory = async () => {
|
||||
const permitId = props.permitId;
|
||||
const id = parseInt(permitId);
|
||||
|
||||
if (!permitId || isNaN(id)) {
|
||||
console.error('Permit ID tidak valid:', permitId);
|
||||
message.error('Permit ID tidak valid atau kosong');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await getStatusHistory(id);
|
||||
|
||||
const mapped =
|
||||
res?.data?.data?.map((item) => ({
|
||||
name: item.name,
|
||||
color: item.status_permit_color,
|
||||
text: item.status_permit_name,
|
||||
deskripsi: item.deskripsi,
|
||||
date: item.created_at,
|
||||
closed: item.close_type !== null ? item.close_type_name : null,
|
||||
})) ?? [];
|
||||
|
||||
setHistoryData(mapped);
|
||||
} catch (err) {
|
||||
console.error('API ERROR:', err);
|
||||
message.error('Gagal mengambil riwayat status dari server');
|
||||
}
|
||||
};
|
||||
|
||||
const showModal = () => {
|
||||
fetchHistory();
|
||||
setIsModalVisible(true);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setIsModalVisible(false);
|
||||
};
|
||||
|
||||
const handleSelesai = () => {
|
||||
setShowPermitSelesai(true);
|
||||
};
|
||||
|
||||
const handleApprove = () => {
|
||||
setActionType('approve');
|
||||
setShowConfirmModal(true);
|
||||
};
|
||||
|
||||
const handleReject = () => {
|
||||
setActionType('reject');
|
||||
setShowConfirmModal(true);
|
||||
};
|
||||
|
||||
const submitSelesai = async () => {
|
||||
const payload = {
|
||||
status_permit: true,
|
||||
close_type: closeType,
|
||||
};
|
||||
|
||||
try {
|
||||
|
||||
setConfirmLoading(true);
|
||||
const response = await approvalPermit(props.permitId, payload);
|
||||
|
||||
if (response?.status === 200) {
|
||||
NotifOk({
|
||||
icon: 'success',
|
||||
title: 'Pengajuan Selesai',
|
||||
message: `Permit berhasil diajukan sebagai ${
|
||||
closeType === '1' ? 'selesai' : 'belum selesai'
|
||||
}.`,
|
||||
});
|
||||
setIsModalVisible(false);
|
||||
setShowPermitSelesai(false);
|
||||
setCloseType('');
|
||||
setTimeout(() => {
|
||||
refreshData();
|
||||
}, 500);
|
||||
} else {
|
||||
throw new Error(response?.data?.message || 'Proses gagal');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error saat mengajukan permit:', err);
|
||||
NotifAlert({
|
||||
icon: 'error',
|
||||
title: 'Gagal',
|
||||
message: err.message || 'Terjadi kesalahan saat memproses permit.',
|
||||
});
|
||||
} finally {
|
||||
setConfirmLoading(false);
|
||||
setShowPermitSelesai(false);
|
||||
setCloseType('');
|
||||
}
|
||||
};
|
||||
|
||||
const submitApproval = async () => {
|
||||
const payload = {
|
||||
status_permit: actionType === 'approve' ? true : false,
|
||||
deskripsi: deskripsi.trim(),
|
||||
};
|
||||
|
||||
try {
|
||||
|
||||
setConfirmLoading(true);
|
||||
const response = await approvalPermit(props.permitId, payload);
|
||||
|
||||
if (response?.status === 200) {
|
||||
NotifOk({
|
||||
icon: 'success',
|
||||
title: actionType === 'approve' ? 'Disetujui' : 'Ditolak',
|
||||
message:
|
||||
actionType === 'approve'
|
||||
? 'Permit berhasil disetujui.'
|
||||
: 'Permit berhasil ditolak.',
|
||||
});
|
||||
setIsModalVisible(false);
|
||||
setShowConfirmModal(false);
|
||||
setDeskripsi('');
|
||||
setTimeout(() => {
|
||||
refreshData();
|
||||
}, 500);
|
||||
} else {
|
||||
throw new Error(response?.data?.message || 'Proses gagal');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error saat menyetujui permit:', err);
|
||||
NotifAlert({
|
||||
icon: 'error',
|
||||
title: 'Gagal',
|
||||
message: err.message || 'Terjadi kesalahan saat memproses permit.',
|
||||
});
|
||||
} finally {
|
||||
setConfirmLoading(false);
|
||||
setShowConfirmModal(false);
|
||||
setDeskripsi('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
size="middle"
|
||||
onClick={showModal}
|
||||
style={{
|
||||
backgroundColor: '#fff',
|
||||
color: color,
|
||||
borderColor: color,
|
||||
borderRadius: '8px',
|
||||
padding: '5px 16px',
|
||||
fontWeight: 500,
|
||||
...style?.button,
|
||||
}}
|
||||
>
|
||||
{name || 'N/A'}
|
||||
</Button>
|
||||
|
||||
<Modal
|
||||
title={
|
||||
<Tag
|
||||
style={{
|
||||
backgroundColor: '#fff',
|
||||
color: color || '#999',
|
||||
border: `1px solid ${color || '#ddd'}`,
|
||||
borderRadius: 8,
|
||||
padding: '4px 12px',
|
||||
fontSize: 16,
|
||||
display: 'inline-block',
|
||||
}}
|
||||
>
|
||||
{name ?? 'Belum ada status'}
|
||||
</Tag>
|
||||
}
|
||||
open={isModalVisible}
|
||||
onCancel={handleCancel}
|
||||
footer={[
|
||||
<>
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
token: { colorBgContainer: '#E9F6EF' },
|
||||
components: {
|
||||
Button: {
|
||||
defaultBg: 'white',
|
||||
defaultColor: '#23A55A',
|
||||
defaultBorderColor: '#23A55A',
|
||||
defaultHoverColor: '#23A55A',
|
||||
defaultHoverBorderColor: '#23A55A',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Button onClick={handleCancel}>Batal</Button>
|
||||
</ConfigProvider>
|
||||
{props.status_permit === pengajuanPermitSelesai &&
|
||||
historyData.length >= 0 &&
|
||||
isVendor && (
|
||||
<>
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
token: { colorBgContainer: '#23a55ade' },
|
||||
components: {
|
||||
Button: {
|
||||
defaultBg: '#23a55a',
|
||||
defaultColor: '#FFFFFF',
|
||||
defaultBorderColor: '#23a55a',
|
||||
defaultHoverColor: '#FFFFFF',
|
||||
defaultHoverBorderColor: '#23a55a',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Button loading={confirmLoading} onClick={handleSelesai}>
|
||||
Selesai
|
||||
</Button>
|
||||
</ConfigProvider>
|
||||
</>
|
||||
)}
|
||||
{props.status_permit !== 0 &&
|
||||
props.status_permit !== pengajuanPermitSelesai &&
|
||||
props.status_permit !== permitSelesai &&
|
||||
historyData.length >= 0 && (
|
||||
<>
|
||||
{canReject && (
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
token: { colorBgContainer: '#FF4D4Fde' },
|
||||
components: {
|
||||
Button: {
|
||||
defaultBg: '#FF4D4F',
|
||||
defaultColor: '#FFFFFF',
|
||||
defaultBorderColor: '#FF4D4F',
|
||||
defaultHoverColor: '#FFFFFF',
|
||||
defaultHoverBorderColor: '#FF4D4F',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Button onClick={handleReject}>Reject</Button>
|
||||
</ConfigProvider>
|
||||
)}
|
||||
{canApprove && (
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
token: { colorBgContainer: '#23a55ade' },
|
||||
components: {
|
||||
Button: {
|
||||
defaultBg: '#23a55a',
|
||||
defaultColor: '#FFFFFF',
|
||||
defaultBorderColor: '#23a55a',
|
||||
defaultHoverColor: '#FFFFFF',
|
||||
defaultHoverBorderColor: '#23a55a',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
loading={confirmLoading}
|
||||
onClick={handleApprove}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
</ConfigProvider>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>,
|
||||
]}
|
||||
>
|
||||
<Divider style={{ margin: '16px 0', borderTop: '2px solid #D9D9D9' }} />
|
||||
|
||||
{historyData.length > 0 ? (
|
||||
[...historyData]
|
||||
.sort((a, b) => new Date(b.date) - new Date(a.date))
|
||||
.map((item, index) => (
|
||||
<Card
|
||||
key={index}
|
||||
style={{
|
||||
marginBottom: 16,
|
||||
color: item.color,
|
||||
backgroundColor: '#FFFFFF',
|
||||
border: `1.5px solid ${item.color}`,
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
<Tag
|
||||
style={{
|
||||
backgroundColor: '#FFFFFF',
|
||||
color: item.color,
|
||||
border: `1.5px solid ${item.color}`,
|
||||
fontSize: 13,
|
||||
padding: '4px 10px',
|
||||
borderRadius: 6,
|
||||
fontWeight: '600',
|
||||
}}
|
||||
>
|
||||
{item.text}
|
||||
</Tag>
|
||||
{item.date != null && (
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{dayjs.utc(item.date).format('YYYY-MM-DD HH:mm:ss')}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 4,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 13, color: '#333' }}>{item.name}</div>
|
||||
|
||||
{item.closed && (
|
||||
<Tag
|
||||
style={{
|
||||
backgroundColor: '#f9f9f9',
|
||||
color: '#000',
|
||||
border: '1px dashed #999',
|
||||
fontSize: 12,
|
||||
fontStyle: 'italic',
|
||||
marginRight: 0,
|
||||
}}
|
||||
>
|
||||
Closed: {item.closed}
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{item.deskripsi && (
|
||||
<p style={{ fontSize: 12, color: '#333', margin: 0 }}>
|
||||
{item.deskripsi}
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
))
|
||||
) : (
|
||||
<Text type="secondary">Belum ada riwayat status.</Text>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={showConfirmModal}
|
||||
title={actionType === 'approve' ? 'Konfirmasi Persetujuan' : 'Konfirmasi Penolakan'}
|
||||
onCancel={() => {
|
||||
setShowConfirmModal(false);
|
||||
setDeskripsi('');
|
||||
}}
|
||||
confirmLoading={confirmLoading}
|
||||
footer={[
|
||||
<Button
|
||||
key="cancel"
|
||||
onClick={() => {
|
||||
setShowConfirmModal(false);
|
||||
setDeskripsi('');
|
||||
}}
|
||||
>
|
||||
Batal
|
||||
</Button>,
|
||||
|
||||
<ConfigProvider
|
||||
key="action"
|
||||
theme={{
|
||||
token: {
|
||||
colorBgContainer:
|
||||
actionType === 'approve' ? '#23a55ade' : '#FF4D4Fde',
|
||||
},
|
||||
components: {
|
||||
Button: {
|
||||
defaultBg: actionType === 'approve' ? '#23a55a' : '#FF4D4F',
|
||||
defaultColor: '#FFFFFF',
|
||||
defaultBorderColor:
|
||||
actionType === 'approve' ? '#23a55a' : '#FF4D4F',
|
||||
defaultHoverColor: '#FFFFFF',
|
||||
defaultHoverBorderColor:
|
||||
actionType === 'approve' ? '#23a55a' : '#FF4D4F',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Button key="submit" loading={confirmLoading} onClick={submitApproval}>
|
||||
{actionType === 'approve' ? 'Approve' : 'Reject'}
|
||||
</Button>
|
||||
</ConfigProvider>,
|
||||
]}
|
||||
>
|
||||
<p>Silakan isi deskripsi:</p>
|
||||
<TextArea
|
||||
rows={4}
|
||||
value={deskripsi}
|
||||
onChange={(e) => setDeskripsi(e.target.value)}
|
||||
placeholder="Contoh: Disetujui karena dokumen lengkap atau ditolak karena dokumen tidak lengkap."
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={showPermitSelesai}
|
||||
title={'Konfirmasi Pengajuan Selesai'}
|
||||
onCancel={() => {
|
||||
setShowPermitSelesai(false);
|
||||
setCloseType('');
|
||||
}}
|
||||
confirmLoading={confirmLoading}
|
||||
footer={[
|
||||
<Button
|
||||
key="cancel"
|
||||
onClick={() => {
|
||||
setShowPermitSelesai(false);
|
||||
setCloseType('');
|
||||
}}
|
||||
>
|
||||
Batal
|
||||
</Button>,
|
||||
<ConfigProvider
|
||||
key="action"
|
||||
theme={{
|
||||
token: { colorBgContainer: '#23a55ade' },
|
||||
components: {
|
||||
Button: {
|
||||
defaultBg: '#23a55a',
|
||||
defaultColor: '#FFFFFF',
|
||||
defaultBorderColor: '#23a55a',
|
||||
defaultHoverColor: '#FFFFFF',
|
||||
defaultHoverBorderColor: '#23a55a',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Button key="submit" loading={confirmLoading} onClick={submitSelesai}>
|
||||
Ajukan Permit Selesai
|
||||
</Button>
|
||||
</ConfigProvider>,
|
||||
]}
|
||||
>
|
||||
<p>Status Permit saat ini :</p>
|
||||
<Radio.Group onChange={(e) => setCloseType(e.target.value)} value={closeType}>
|
||||
<Radio value="0">Belum Selesai</Radio>
|
||||
<Radio value="1">Selesai</Radio>
|
||||
</Radio.Group>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default StatusButton;
|
||||
294
src/components/Global/StatusUserButton.jsx
Normal file
294
src/components/Global/StatusUserButton.jsx
Normal file
@@ -0,0 +1,294 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Button, Modal, Divider, Card, Tag, ConfigProvider, Typography } from 'antd';
|
||||
import { NotifAlert, NotifOk, NotifConfirmDialog } from './ToastNotif';
|
||||
import { approvalUser } from '../../api/user-admin';
|
||||
import { toAppDateFormatter } from './Formatter';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const StatusUserButton = ({ color, name, data, readOnly, style }) => {
|
||||
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||
const [confirmLoading, setConfirmLoading] = useState(false);
|
||||
|
||||
const showModal = () => {
|
||||
setIsModalVisible(true);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setIsModalVisible(false);
|
||||
};
|
||||
|
||||
const statusColor = data?.warna || color || '#999';
|
||||
const statusName = data?.status_name || name || 'N/A';
|
||||
const userCreated = data?.user_add_name || 'Pengguna tidak dikenal';
|
||||
const userUpdated = data?.user_upd_name || 'Pengguna tidak dikenal';
|
||||
|
||||
const handleApprove = async () => {
|
||||
setConfirmLoading(true);
|
||||
try {
|
||||
const payload = {
|
||||
approve_user: true,
|
||||
};
|
||||
const response = await approvalUser(data?.id_register, payload);
|
||||
|
||||
if (response?.data?.statusCode === 200) {
|
||||
NotifOk({
|
||||
icon: 'success',
|
||||
title: 'Berhasil',
|
||||
message: 'User berhasil di-approve.',
|
||||
});
|
||||
setIsModalVisible(false);
|
||||
} else {
|
||||
NotifAlert({
|
||||
icon: 'error',
|
||||
title: 'Gagal',
|
||||
message: response?.data?.message || 'Gagal approve user.',
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error saat approve user:', err);
|
||||
NotifAlert({
|
||||
icon: 'error',
|
||||
title: 'Error',
|
||||
message: 'Terjadi kesalahan pada server.',
|
||||
});
|
||||
} finally {
|
||||
setConfirmLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReject = async () => {
|
||||
setConfirmLoading(true);
|
||||
NotifConfirmDialog({
|
||||
icon: 'question',
|
||||
title: 'Konfirmasi Penolakan',
|
||||
message: 'Apakah kamu yakin ingin menolak permintaan ini?',
|
||||
confirmButtonText: 'Reject',
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
const payload = {
|
||||
approve_user: false,
|
||||
};
|
||||
const response = await approvalUser(data.id_register, payload);
|
||||
|
||||
if (response?.data?.statusCode === 200) {
|
||||
NotifOk({
|
||||
icon: 'success',
|
||||
title: 'Ditolak',
|
||||
message: 'User berhasil ditolak.',
|
||||
});
|
||||
setIsModalVisible(false);
|
||||
} else {
|
||||
NotifAlert({
|
||||
icon: 'error',
|
||||
title: 'Gagal',
|
||||
message: response?.message || 'Gagal reject user.',
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
NotifAlert({
|
||||
icon: 'error',
|
||||
title: 'Error',
|
||||
message: 'Terjadi kesalahan pada server.',
|
||||
});
|
||||
} finally {
|
||||
setConfirmLoading(false);
|
||||
}
|
||||
},
|
||||
onCancel: () => {
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
size="middle"
|
||||
onClick={showModal}
|
||||
style={{
|
||||
backgroundColor: '#fff',
|
||||
color: statusColor,
|
||||
borderColor: statusColor,
|
||||
borderRadius: '8px',
|
||||
padding: '4px 16px',
|
||||
fontWeight: 500,
|
||||
...style?.button,
|
||||
}}
|
||||
>
|
||||
{statusName}
|
||||
</Button>
|
||||
|
||||
<Modal
|
||||
title={
|
||||
<Tag
|
||||
style={{
|
||||
backgroundColor: '#fff',
|
||||
color: statusColor,
|
||||
border: `1px solid ${statusColor}`,
|
||||
borderRadius: 8,
|
||||
padding: '4px 12px',
|
||||
fontSize: 16,
|
||||
display: 'inline-block',
|
||||
}}
|
||||
>
|
||||
{statusName}
|
||||
</Tag>
|
||||
}
|
||||
open={isModalVisible}
|
||||
onCancel={handleCancel}
|
||||
footer={[
|
||||
<>
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
token: { colorBgContainer: '#E9F6EF' },
|
||||
components: {
|
||||
Button: {
|
||||
defaultBg: 'white',
|
||||
defaultColor: '#23A55A',
|
||||
defaultBorderColor: '#23A55A',
|
||||
defaultHoverColor: '#23A55A',
|
||||
defaultHoverBorderColor: '#23A55A',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Button onClick={handleCancel}>Batal</Button>
|
||||
</ConfigProvider>
|
||||
{data?.status_register === 1 && (
|
||||
<>
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
token: {
|
||||
colorBgContainer: '#FF4D4Fde',
|
||||
},
|
||||
components: {
|
||||
Button: {
|
||||
defaultBg: '#FF4D4F',
|
||||
defaultColor: '#FFFFFF',
|
||||
defaultBorderColor: '#FF4D4F',
|
||||
defaultHoverColor: '#FFFFFF',
|
||||
defaultHoverBorderColor: '#FF4D4F',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Button onClick={handleReject}>Reject</Button>
|
||||
</ConfigProvider>
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
token: {
|
||||
colorBgContainer: '#23a55ade',
|
||||
},
|
||||
components: {
|
||||
Button: {
|
||||
defaultBg: '#23a55a',
|
||||
defaultColor: '#FFFFFF',
|
||||
defaultBorderColor: '#23a55a',
|
||||
defaultHoverColor: '#FFFFFF',
|
||||
defaultHoverBorderColor: '#23a55a',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Button loading={confirmLoading} onClick={handleApprove}>
|
||||
Approve
|
||||
</Button>
|
||||
</ConfigProvider>
|
||||
</>
|
||||
)}
|
||||
</>,
|
||||
]}
|
||||
>
|
||||
<Divider style={{ margin: '16px 0', borderTop: '2px solid #D9D9D9' }} />
|
||||
|
||||
{data ? (
|
||||
<>
|
||||
{data.updated_at !== data.created_at && (
|
||||
<Card
|
||||
style={{
|
||||
marginBottom: 16,
|
||||
color: '#FF6E35',
|
||||
backgroundColor: '#FFFFFF',
|
||||
border: `1.5px solid #FF6E35`,
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
<Tag
|
||||
style={{
|
||||
backgroundColor: '#FFFFFF',
|
||||
color: '#FF6E35',
|
||||
border: `1.5px solid #FF6E35`,
|
||||
fontSize: 13,
|
||||
padding: '4px 10px',
|
||||
borderRadius: 6,
|
||||
fontWeight: '500',
|
||||
}}
|
||||
>
|
||||
Updated at
|
||||
</Tag>
|
||||
<Text
|
||||
type="secondary"
|
||||
style={{ fontSize: 12, fontWeight: '500' }}
|
||||
>
|
||||
{toAppDateFormatter(data.updated_at)}
|
||||
</Text>
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: '#333' }}>
|
||||
Diubah terakhir oleh <b>{userUpdated}</b>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
<Card
|
||||
style={{
|
||||
marginBottom: 16,
|
||||
color: '#3498DB',
|
||||
backgroundColor: '#FFFFFF',
|
||||
border: `1.5px solid #3498DB`,
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
<Tag
|
||||
style={{
|
||||
backgroundColor: '#FFFFFF',
|
||||
color: '#3498DB',
|
||||
border: `1.5px solid #3498DB`,
|
||||
fontSize: 13,
|
||||
padding: '4px 10px',
|
||||
borderRadius: 6,
|
||||
fontWeight: '500',
|
||||
}}
|
||||
>
|
||||
Created at
|
||||
</Tag>
|
||||
<Text type="secondary" style={{ fontSize: 12, fontWeight: '500' }}>
|
||||
{toAppDateFormatter(data.created_at)}
|
||||
</Text>
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: '#333' }}>
|
||||
Dibuat oleh <b>{userCreated}</b>
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
) : (
|
||||
<Text type="secondary">Belum ada riwayat status.</Text>
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default StatusUserButton;
|
||||
281
src/components/Global/TableList.jsx
Normal file
281
src/components/Global/TableList.jsx
Normal file
@@ -0,0 +1,281 @@
|
||||
import React, { memo, useState, useEffect, useRef } from 'react';
|
||||
import { Table, Pagination, Row, Col, Card, Grid, Button, Typography, Tag } from 'antd';
|
||||
import {
|
||||
PlusOutlined,
|
||||
FilterOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
EyeOutlined,
|
||||
SearchOutlined,
|
||||
FilePdfOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { setFilterData } from './DataFilter';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const defCard = {
|
||||
r1: {
|
||||
style: { fontWeight: 'bold', fontSize: 13 },
|
||||
type: 'primary',
|
||||
color: '',
|
||||
text: 'Cold Work Permit',
|
||||
name: '',
|
||||
},
|
||||
r2: {
|
||||
style: { marginLeft: 8, fontSize: 13 },
|
||||
type: 'primary',
|
||||
color: 'success',
|
||||
text: 'Pengajuan',
|
||||
name: '',
|
||||
},
|
||||
r3: {
|
||||
style: { fontSize: 12 },
|
||||
type: 'secondary',
|
||||
color: '',
|
||||
text: 'No. IVR/20250203/XXV/III',
|
||||
name: '',
|
||||
},
|
||||
r4: {
|
||||
style: { fontSize: 12 },
|
||||
type: 'primary',
|
||||
color: '',
|
||||
text: '3 Feb 2025',
|
||||
name: '',
|
||||
},
|
||||
r5: {
|
||||
style: { fontSize: 12 },
|
||||
type: 'primary',
|
||||
color: '',
|
||||
text: 'Lokasi Gudang Robang',
|
||||
name: '',
|
||||
},
|
||||
r6: {
|
||||
style: { fontSize: 12 },
|
||||
type: 'primary',
|
||||
color: '',
|
||||
text: 'maka tambahkan user tersebut dalam user_partner dengan partner baru yang ditambahkan diatas',
|
||||
name: '',
|
||||
},
|
||||
action: (e) => {},
|
||||
};
|
||||
|
||||
const TableList = memo(function TableList({
|
||||
getData,
|
||||
queryParams,
|
||||
columns,
|
||||
triger,
|
||||
mobile,
|
||||
rowSelection = null,
|
||||
}) {
|
||||
const [gridLoading, setGridLoading] = useState(false);
|
||||
|
||||
const [data, setData] = useState([]);
|
||||
const [pagingResponse, setPagingResponse] = useState({
|
||||
totalData: '',
|
||||
perPage: '',
|
||||
totalPage: '',
|
||||
});
|
||||
|
||||
const [pagination, setPagination] = useState({
|
||||
current: 1,
|
||||
limit: 10,
|
||||
total: 0,
|
||||
});
|
||||
|
||||
const { useBreakpoint } = Grid;
|
||||
|
||||
useEffect(() => {
|
||||
filter(1, 10);
|
||||
}, [triger]);
|
||||
|
||||
const filter = async (currentPage, pageSize) => {
|
||||
setGridLoading(true);
|
||||
|
||||
const paging = {
|
||||
page: currentPage,
|
||||
limit: pageSize,
|
||||
};
|
||||
|
||||
const param = new URLSearchParams({ ...paging, ...queryParams });
|
||||
|
||||
const resData = await getData(param);
|
||||
if (resData) {
|
||||
setTimeout(() => {
|
||||
setGridLoading(false);
|
||||
}, 900);
|
||||
}
|
||||
|
||||
setData(resData.data.data ?? []);
|
||||
setFilterData(resData.data.data ?? []);
|
||||
|
||||
if (resData.status == 200) {
|
||||
setPagingResponse({
|
||||
totalData: resData.data.total,
|
||||
perPage: resData.data.paging.page_total,
|
||||
totalPage: resData.data.paging.limit,
|
||||
});
|
||||
|
||||
setPagination((prev) => ({
|
||||
...prev,
|
||||
current: resData.data.paging.page,
|
||||
limit: resData.data.paging.limit,
|
||||
total: resData.data.paging.total,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const handlePaginationChange = (page, pageSize) => {
|
||||
setPagination((prev) => ({
|
||||
...prev,
|
||||
current: page,
|
||||
pageSize,
|
||||
}));
|
||||
filter(page, pageSize);
|
||||
};
|
||||
|
||||
const screens = useBreakpoint();
|
||||
|
||||
const isMobile = !screens.md; // kalau kurang dari md (768px) dianggap mobile
|
||||
|
||||
return (
|
||||
<div>
|
||||
{isMobile && mobile ? (
|
||||
<Row gutter={24}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||
{data.map((item) => (
|
||||
<Card
|
||||
key={item.id}
|
||||
title={
|
||||
(mobile.r1 || mobile.r2) && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
{mobile.r1 && (
|
||||
<span style={mobile.r1.style ?? {}}>
|
||||
{item[mobile.r1.name] ?? mobile.r1.text ?? ''}
|
||||
</span>
|
||||
)}
|
||||
{mobile.r2 && (
|
||||
<Tag
|
||||
color={mobile.r2.color ?? ''}
|
||||
style={mobile.r2.style ?? {}}
|
||||
>
|
||||
{item[mobile.r2.name] ?? mobile.r2.text ?? ''}
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{mobile.r3 && mobile.r4 && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
type={mobile.r3 ? mobile.r3.type ?? 'primary' : ''}
|
||||
style={mobile.r3 ? mobile.r3.style ?? {} : {}}
|
||||
>
|
||||
{item[mobile.r3 ? mobile.r3.name : ''] ?? ''}
|
||||
</Text>
|
||||
<Text style={mobile.r4 ? mobile.r4.style ?? {} : {}}>
|
||||
{item[mobile.r4 ? mobile.r4.name : ''] ?? ''}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
{mobile.r5 && (
|
||||
<Text
|
||||
type={mobile.r5 ? mobile.r5.type ?? 'secondary' : ''}
|
||||
style={mobile.r5 ? mobile.r5.style ?? {} : {}}
|
||||
>
|
||||
{item[mobile.r5 ? mobile.r5.name : ''] ?? ''}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
{mobile.r6 && (
|
||||
<div>
|
||||
<Text
|
||||
type={mobile.r6 ? mobile.r6.type ?? 'primary' : ''}
|
||||
style={mobile.r6 ? mobile.r6.style ?? {} : {}}
|
||||
>
|
||||
{item[mobile.r6 ? mobile.r6.name : ''] ?? ''}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
marginTop: 16,
|
||||
borderTop: '1px solid #f0f0f0',
|
||||
paddingTop: 8,
|
||||
textAlign: 'right',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
shape="round"
|
||||
icon={<EyeOutlined />}
|
||||
onClick={(e) => {
|
||||
mobile.action(item);
|
||||
}}
|
||||
>
|
||||
Detail
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</Row>
|
||||
) : (
|
||||
<Row gutter={24}>
|
||||
{/* TABLE */}
|
||||
<Table
|
||||
rowSelection={rowSelection || null}
|
||||
columns={columns}
|
||||
dataSource={data.map((item, index) => ({ ...item, key: index }))}
|
||||
pagination={false}
|
||||
loading={gridLoading}
|
||||
scroll={{
|
||||
y: 520,
|
||||
x: 1300,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* PAGINATION */}
|
||||
<Col xs={24} style={{ marginTop: '16px' }}>
|
||||
<Row justify="space-between" align="middle">
|
||||
<Col>
|
||||
<div>
|
||||
Menampilkan {pagingResponse.totalData} Data dari{' '}
|
||||
{pagingResponse.perPage} Halaman
|
||||
</div>
|
||||
</Col>
|
||||
<Col>
|
||||
<Pagination
|
||||
showSizeChanger
|
||||
onChange={handlePaginationChange}
|
||||
onShowSizeChange={handlePaginationChange}
|
||||
current={pagination.current}
|
||||
pageSize={pagination.pageSize}
|
||||
total={pagination.total}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default TableList;
|
||||
69
src/components/Global/ToastNotif.jsx
Normal file
69
src/components/Global/ToastNotif.jsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import Swal from 'sweetalert2';
|
||||
|
||||
const NotifAlert = ({ icon, title, message }) => {
|
||||
Swal.fire({
|
||||
icon: icon,
|
||||
title: title,
|
||||
text: message,
|
||||
showConfirmButton: false,
|
||||
position: 'center',
|
||||
timer: 2000,
|
||||
});
|
||||
};
|
||||
|
||||
const NotifOk = ({ icon, title, message }) => {
|
||||
Swal.fire({
|
||||
icon: icon,
|
||||
title: title,
|
||||
text: message,
|
||||
});
|
||||
};
|
||||
|
||||
const NotifConfirmDialog = ({
|
||||
icon,
|
||||
title,
|
||||
message,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
confirmButtonText = 'Hapus',
|
||||
}) => {
|
||||
Swal.fire({
|
||||
icon: icon,
|
||||
title: title,
|
||||
text: message,
|
||||
showCancelButton: true,
|
||||
cancelButtonColor: '#23A55A',
|
||||
cancelButtonText: 'Batal',
|
||||
confirmButtonColor: '#d33000',
|
||||
confirmButtonText: confirmButtonText,
|
||||
reverseButtons: true,
|
||||
}).then((result) => {
|
||||
if (result.isConfirmed) {
|
||||
onConfirm();
|
||||
} else if (result.dismiss) {
|
||||
onCancel();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const QuestionConfirmSubmit = ({ icon, title, message, onConfirm, onCancel }) => {
|
||||
Swal.fire({
|
||||
icon: icon,
|
||||
title: title,
|
||||
text: message,
|
||||
showCancelButton: true,
|
||||
cancelButtonColor: '#23A55A',
|
||||
cancelButtonText: 'Batal',
|
||||
confirmButtonColor: '#d33000',
|
||||
confirmButtonText: 'Proses',
|
||||
reverseButtons: true,
|
||||
}).then((result) => {
|
||||
if (result.isConfirmed) {
|
||||
onConfirm();
|
||||
} else if (result.dismiss) {
|
||||
onCancel();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export { NotifAlert, NotifOk, NotifConfirmDialog, QuestionConfirmSubmit };
|
||||
81
src/components/Global/headerReport.jsx
Normal file
81
src/components/Global/headerReport.jsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { Row, Col, Button, ConfigProvider, Divider, Typography } from "antd";
|
||||
import { ArrowLeftOutlined, SearchOutlined } from '@ant-design/icons';
|
||||
|
||||
const {Text} = Typography;
|
||||
|
||||
const HeaderReport = ({
|
||||
title,
|
||||
loadingPilihDataStep1,
|
||||
handleSelectData,
|
||||
step
|
||||
})=>{
|
||||
return(
|
||||
<>
|
||||
<Row style={{ justifyContent: "space-between" }}>
|
||||
<Col>
|
||||
<Row>
|
||||
<Text style={{fontSize:'18px'}} strong>{title}</Text>
|
||||
<div style={{width:'20px'}}></div>
|
||||
<div style={{
|
||||
width:'95px',
|
||||
border:'1px solid #e9f6ef',
|
||||
borderRadius:'5px',
|
||||
backgroundColor:'#e9f6ef',
|
||||
color:'#23a55a',
|
||||
padding:'3px',
|
||||
fontSize:'16px',
|
||||
fontWeight:'bold'
|
||||
}}>
|
||||
{step}
|
||||
</div>
|
||||
</Row>
|
||||
</Col>
|
||||
<Col>
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
token: {
|
||||
colorBgContainer: "#eff0f5",
|
||||
},
|
||||
components: {
|
||||
Button: {
|
||||
defaultBg: "white",
|
||||
defaultColor: "#000000",
|
||||
defaultBorderColor: "#000000",
|
||||
defaultHoverColor: "#000000",
|
||||
defaultHoverBorderColor: "#000000",
|
||||
defaultHoverColor: "#000000",
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Button icon={<ArrowLeftOutlined />}>Batal</Button>
|
||||
</ConfigProvider>
|
||||
</Col>
|
||||
</Row>
|
||||
<Divider/>
|
||||
<Row style={{ justifyContent: "space-between" }}>
|
||||
<Col>
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
token: {
|
||||
colorBgContainer: "#209652",
|
||||
},
|
||||
components: {
|
||||
Button: {
|
||||
defaultBg: "#23a55a",
|
||||
defaultColor: "#FFFFFF",
|
||||
defaultBorderColor: "#23a55a",
|
||||
defaultHoverColor: "#FFFFFF",
|
||||
defaultHoverBorderColor: "#209652",
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Button loading={loadingPilihDataStep1} onClick={handleSelectData}><SearchOutlined /> Pilih Data</Button>
|
||||
</ConfigProvider>
|
||||
</Col>
|
||||
</Row>
|
||||
</>
|
||||
);
|
||||
};
|
||||
export default HeaderReport;
|
||||
28
src/components/loading/Loading.jsx
Normal file
28
src/components/loading/Loading.jsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import React, { memo } from 'react'
|
||||
import './loading.css'
|
||||
|
||||
|
||||
const Loading = memo(function Loading() {
|
||||
return(
|
||||
<div className='mask'>
|
||||
<div className="main">
|
||||
<div className="loader">
|
||||
<p className="text">
|
||||
<span className="letter letter1">L</span>
|
||||
<span className="letter letter2">o</span>
|
||||
<span className="letter letter3">a</span>
|
||||
<span className="letter letter4">d</span>
|
||||
<span className="letter letter5">i</span>
|
||||
<span className="letter letter6">n</span>
|
||||
<span className="letter letter7">g</span>
|
||||
<span className="letter letter8">.</span>
|
||||
<span className="letter letter9">.</span>
|
||||
<span className="letter letter10">.</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
|
||||
export default Loading
|
||||
80
src/components/loading/loading.css
Normal file
80
src/components/loading/loading.css
Normal file
@@ -0,0 +1,80 @@
|
||||
.mask {
|
||||
/* background-color: rgba(0, 0, 0, .6); */
|
||||
background-color: rgba(255, 255, 255, .6);
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.main {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
text {
|
||||
font-weight: bolder;
|
||||
}
|
||||
|
||||
@keyframes letter {
|
||||
0% {
|
||||
font-size: 30px;
|
||||
}
|
||||
|
||||
50% {
|
||||
font-size: 40px;
|
||||
}
|
||||
|
||||
100% {
|
||||
font-size: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
.letter {
|
||||
animation: letter 1s infinite;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.letter1 {
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
.letter2 {
|
||||
animation-delay: -0.9s;
|
||||
}
|
||||
|
||||
.letter3 {
|
||||
animation-delay: -0.8s;
|
||||
}
|
||||
|
||||
.letter4 {
|
||||
animation-delay: -0.7s;
|
||||
}
|
||||
|
||||
.letter5 {
|
||||
animation-delay: -0.6s;
|
||||
}
|
||||
|
||||
.letter6 {
|
||||
animation-delay: -0.5s;
|
||||
}
|
||||
|
||||
.letter7 {
|
||||
animation-delay: -0.4s;
|
||||
}
|
||||
|
||||
.letter8 {
|
||||
animation-delay: -0.3s;
|
||||
}
|
||||
|
||||
.letter9 {
|
||||
animation-delay: -0.2s;
|
||||
}
|
||||
|
||||
.letter10 {
|
||||
animation-delay: -0.1s;
|
||||
}
|
||||
Reference in New Issue
Block a user