lavoce #1

Merged
yogiedigital merged 11 commits from lavoce into main 2025-09-17 08:39:36 +00:00
12 changed files with 2012 additions and 0 deletions
Showing only changes of commit 291cbd5f94 - Show all commits

View File

@@ -0,0 +1,461 @@
import React, { useState } from 'react';
import {
Flex,
Input,
InputNumber,
Form,
Button,
Card,
Space,
Upload,
Divider,
Tooltip,
message,
Select,
} from 'antd';
import {
UploadOutlined,
UserOutlined,
IdcardOutlined,
PhoneOutlined,
LockOutlined,
InfoCircleOutlined,
MailOutlined,
} from '@ant-design/icons';
const { Item } = Form;
const { Option } = Select;
import sypiu_ggcp from 'assets/sypiu_ggcp.jpg';
import { useNavigate } from 'react-router-dom';
import { register, uploadFile, checkUsername } from '../../api/auth';
import { NotifAlert } from '../../components/Global/ToastNotif';
const Registration = () => {
const [form] = Form.useForm();
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const [fileListKontrak, setFileListKontrak] = useState([]);
const [fileListHsse, setFileListHsse] = useState([]);
const [fileListIcon, setFileListIcon] = useState([]);
// Daftar jenis vendor
const vendorTypes = [
{ vendor_type: 1, vendor_type_name: 'One-Time' },
{ vendor_type: 2, vendor_type_name: 'Rutin' },
];
const onFinish = async (values) => {
setLoading(true);
try {
if (!fileListKontrak.length || !fileListHsse.length) {
message.error('Harap unggah Lampiran Kontrak Kerja dan HSSE Plan!');
setLoading(false);
return;
}
const formData = new FormData();
formData.append('path_kontrak', fileListKontrak[0].originFileObj);
formData.append('path_hse_plant', fileListHsse[0].originFileObj);
if (fileListIcon.length) {
formData.append('path_icon', fileListIcon[0].originFileObj);
}
const uploadResponse = await uploadFile(formData);
if (!uploadResponse.data?.pathKontrak && !uploadResponse.data?.pathHsePlant) {
message.error(uploadResponse.message || 'Gagal mengunggah file.');
setLoading(false);
return;
}
const params = new URLSearchParams({ username: values.username });
const usernameCheck = await checkUsername(params);
if (usernameCheck.data.data && usernameCheck.data.data.available === false) {
NotifAlert({
icon: 'error',
title: 'Gagal',
message: usernameCheck.data.message || 'Terjadi kesalahan, silakan coba lagi',
});
setLoading(false);
return;
}
const registerData = {
nama_perusahaan: values.namaPerusahaan,
no_kontak_wo: values.noKontakWo,
path_kontrak: uploadResponse.data.pathKontrak || '',
durasi: values.durasiPekerjaan,
nilai_csms: values.nilaiCsms.toString(),
vendor_type: values.jenisVendor, // Tambahkan jenis vendor ke registerData
path_hse_plant: uploadResponse.data.pathHsePlant || '',
nama_leader: values.penanggungJawab,
no_identitas: values.noIdentitas,
no_hp: values.noHandphone,
email_register: values.username,
password_register: values.password,
};
const response = await register(registerData);
if (response.data?.id_register) {
message.success('Data berhasil disimpan!');
try {
form.resetFields();
setFileListKontrak([]);
setFileListHsse([]);
setFileListIcon([]);
navigate('/registration-submitted');
} catch (postSuccessError) {
message.warning(
'Registrasi berhasil, tetapi ada masalah setelahnya. Silakan ke halaman login secara manual.'
);
}
} else {
message.error(response.message || 'Pendaftaran gagal, silakan coba lagi.');
}
} catch (error) {
console.error('Error saat registrasi:', error);
NotifAlert({
icon: 'error',
title: 'Gagal',
message: error.message || 'Terjadi kesalahan, silakan coba lagi',
});
} finally {
setLoading(false);
}
};
const onCancel = () => {
form.resetFields();
setFileListKontrak([]);
setFileListHsse([]);
setFileListIcon([]);
navigate('/signin');
};
const handleChangeKontrak = ({ fileList }) => {
setFileListKontrak(fileList);
};
const handleChangeHsse = ({ fileList }) => {
setFileListHsse(fileList);
};
const handleChangeIcon = ({ fileList }) => {
setFileListIcon(fileList);
};
const beforeUpload = (file, fieldname) => {
const isValidType = [
'image/jpeg',
'image/jpg',
'image/png',
fieldname !== 'path_icon' ? 'application/pdf' : null,
]
.filter(Boolean)
.includes(file.type);
const isNotEmpty = file.size > 0;
const isSizeValid = file.size / 1024 / 1024 < 10;
if (!isValidType) {
message.error(
`Hanya file ${
fieldname === 'path_icon' ? 'JPG/PNG' : 'PDF/JPG/PNG'
} yang diperbolehkan!`
);
return false;
}
if (!isNotEmpty) {
message.error('File tidak boleh kosong!');
return false;
}
if (!isSizeValid) {
message.error('Ukuran file maksimal 10MB!');
return false;
}
return true;
};
return (
<Flex
align="center"
justify="center"
style={{
minHeight: '100vh',
backgroundImage: `url(${sypiu_ggcp})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
padding: '20px',
}}
>
<Card
style={{
width: '100%',
maxWidth: 800,
background: 'rgba(255, 255, 255, 0.9)',
backdropFilter: 'blur(10px)',
borderRadius: '12px',
boxShadow: '0 8px 16px rgba(0, 0, 0, 0.1)',
padding: '24px',
}}
title={
<Flex align="center" justify="space-between">
<h2 style={{ margin: 0, color: '#1a3c34' }}>Formulir Pendaftaran</h2>
<Button
type="link"
icon={<InfoCircleOutlined />}
onClick={() => navigate('/signin')}
>
Kembali
</Button>
</Flex>
}
>
<Form
form={form}
onFinish={onFinish}
layout="horizontal"
labelCol={{ span: 8 }}
wrapperCol={{ span: 16 }}
labelAlign="left"
style={{ maxWidth: 800 }}
>
{/* Informasi Perusahaan */}
<Divider
orientation="left"
orientationMargin={0}
style={{
color: '#23A55A',
fontWeight: 'bold',
marginLeft: 0,
paddingLeft: 0,
}}
>
Informasi Perusahaan
</Divider>
<Item
label="Nama Perusahaan"
name="namaPerusahaan"
rules={[{ required: true, message: 'Masukkan Nama Perusahaan!' }]}
>
<Input
prefix={<UserOutlined />}
placeholder="Masukkan Nama Perusahaan"
size="large"
/>
</Item>
<Item
label="Durasi Pekerjaan (Hari)"
name="durasiPekerjaan"
rules={[{ required: true, message: 'Masukkan Durasi Pekerjaan!' }]}
>
<InputNumber
min={1}
style={{ width: '100%' }}
placeholder="Masukkan Durasi Pekerjaan"
size="large"
/>
</Item>
<Item
label="No Kontrak Kerja / Agreement"
name="noKontakWo"
rules={[
{ required: true, message: 'Masukkan No Kontrak Kerja / Agreement!' },
]}
>
<Input
style={{
width: '100%',
}}
placeholder="Masukkan No Kontrak Kerja / Agreement"
size="large"
/>
</Item>
<Item
label="Lampiran Kontrak Kerja"
name="lampiranKontrak"
rules={[{ required: true, message: 'Unggah Lampiran Kontrak Kerja!' }]}
>
<Upload
beforeUpload={(file) => beforeUpload(file, 'path_kontrak')}
fileList={fileListKontrak}
onChange={handleChangeKontrak}
maxCount={1}
>
<Button icon={<UploadOutlined />} size="large">
Unggah PDF/JPG
</Button>
</Upload>
</Item>
<Item
label="HSSE Plan"
name="hssePlan"
rules={[{ required: true, message: 'Unggah HSSE Plan!' }]}
>
<Upload
beforeUpload={(file) => beforeUpload(file, 'path_hse_plant')}
fileList={fileListHsse}
onChange={handleChangeHsse}
maxCount={1}
>
<Button icon={<UploadOutlined />} size="large">
Unggah PDF/JPG
</Button>
</Upload>
</Item>
<Item
label="Nilai CSMS"
name="nilaiCsms"
rules={[{ required: true, message: 'Masukkan Nilai CSMS!' }]}
>
<InputNumber
min={0}
max={100}
style={{ width: '100%' }}
placeholder="Masukkan Nilai CSMS"
size="large"
/>
</Item>
<Item
label="Jenis Vendor"
name="jenisVendor"
rules={[{ required: true, message: 'Pilih Jenis Vendor!' }]}
>
<Select
placeholder="Pilih Jenis Vendor"
size="large"
style={{ width: '100%' }}
>
{vendorTypes.map((vendor) => (
<Option key={vendor.vendor_type} value={vendor.vendor_type}>
{vendor.vendor_type_name}
</Option>
))}
</Select>
</Item>
{/* Informasi Penanggung Jawab */}
<Divider
orientation="left"
orientationMargin={0}
style={{
color: '#23A55A',
fontWeight: 'bold',
marginLeft: 0,
paddingLeft: 0,
}}
>
Informasi Penanggung Jawab
</Divider>
<Item
label="Nama Penanggung Jawab"
name="penanggungJawab"
rules={[{ required: true, message: 'Masukkan Nama Penanggung Jawab!' }]}
>
<Input
prefix={<UserOutlined />}
placeholder="Masukkan Nama Penanggung Jawab"
size="large"
/>
</Item>
<Item
label="No Handphone"
name="noHandphone"
rules={[
{ required: true, message: 'Masukkan No Handphone!' },
{
pattern: /^(\+62|0)[0-9]{9,12}$/,
message:
'Format nomor telepon tidak valid! (Contoh: +62.... atau 0....)',
},
]}
>
<Input
prefix={<PhoneOutlined />}
placeholder="Masukkan No Handphone (+62)"
size="large"
/>
</Item>
<Item
label="No Identitas"
name="noIdentitas"
rules={[{ required: true, message: 'Masukkan No Identitas!' }]}
>
<Input
prefix={<IdcardOutlined />}
placeholder="Masukkan No Identitas"
size="large"
/>
</Item>
{/* Akun Pengguna */}
<Divider
orientation="left"
orientationMargin={0}
style={{
color: '#23A55A',
fontWeight: 'bold',
marginLeft: 0,
paddingLeft: 0,
}}
>
Akun Pengguna (digunakan sebagai user login SYPIU)
</Divider>
<Item
label="Email"
name="username"
rules={[
{ required: true, message: 'Masukkan Email!' },
{ type: 'email', message: 'Format email tidak valid!' },
]}
>
<Input
prefix={<MailOutlined />}
placeholder="Masukkan Email"
size="large"
/>
</Item>
<Item
label="Password"
name="password"
rules={[
{ required: true, message: 'Masukkan Password!' },
{ min: 6, message: 'Password minimal 6 karakter!' },
]}
>
<Input.Password
prefix={<LockOutlined />}
placeholder="Masukkan Password"
size="large"
/>
</Item>
{/* Tombol */}
<Item wrapperCol={{ offset: 8, span: 16 }}>
<Space style={{ marginTop: '24px', width: '100%' }}>
<Button
type="primary"
htmlType="submit"
size="large"
loading={loading}
style={{
backgroundColor: '#23A55A',
borderColor: '#23A55A',
width: 120,
}}
>
Simpan
</Button>
<Button onClick={onCancel} size="large" style={{ width: 120 }}>
Batal
</Button>
</Space>
</Item>
</Form>
</Card>
</Flex>
);
};
export default Registration;

187
src/pages/auth/SignIn.jsx Normal file
View File

@@ -0,0 +1,187 @@
import { Flex, Input, Form, Button, Card, Space, Image } from 'antd';
import React from 'react';
import handleSignIn from '../../Utils/Auth/SignIn';
import sypiu_ggcp from 'assets/sypiu_ggcp.jpg';
import logo from 'assets/freepik/LOGOPIU.png';
import { useNavigate } from 'react-router-dom';
import { NotifAlert } from '../../components/Global/ToastNotif';
import { decryptData } from '../../components/Global/Formatter';
const SignIn = () => {
const [captchaSvg, setCaptchaSvg] = React.useState('');
const [userInput, setUserInput] = React.useState('');
const [message, setMessage] = React.useState('');
const [captchaText, setcaptchaText] = React.useState('');
const navigate = useNavigate();
// let url = `${import.meta.env.VITE_API_SERVER}/users`;
React.useEffect(() => {
fetchCaptcha();
}, []);
// Fetch the CAPTCHA SVG from the backend
const fetchCaptcha = async () => {
try {
// let url = `${import.meta.env.VITE_API_SERVER}/operation`
// const response = await fetch('http://localhost:9528/generate-captcha');
const response = await fetch(
`${import.meta.env.VITE_API_SERVER}/auth/generate-captcha`,
{
credentials: 'include', // Wajib untuk mengirim cookie
}
);
// Ambil header
const captchaToken = response.headers.get('X-Captcha-Token');
// console.log('Captcha Token:', decryptData(captchaToken));
setcaptchaText(decryptData(captchaToken));
const data = await response.text();
setCaptchaSvg(data);
} catch (error) {
console.error('Error fetching CAPTCHA:', error);
}
};
const handleOnSubmit = async (e) => {
// console.log('Received values of form: ', e);
// e.preventDefault();
try {
// const response = await fetch(`${import.meta.env.VITE_API_SERVER}/auth/verify-captcha`, {
// method: 'POST',
// headers: { 'Content-Type': 'application/json' },
// credentials: 'include', // WAJIB: Agar cookie CAPTCHA dikirim
// body: JSON.stringify({ captcha: userInput }),
// });
// const data = await response.json();
// console.log(data);
const data = {
success: captchaText === userInput ? true : false,
};
if (data.success) {
setMessage('CAPTCHA verified successfully!');
await handleSignIn(e);
} else {
setMessage('CAPTCHA verification failed. Try again.');
fetchCaptcha(); // Refresh CAPTCHA on failure
NotifAlert({
icon: 'error',
title: 'Gagal',
message: data.message || 'CAPTCHA verification failed. Try again.',
});
}
} catch (error) {
console.error('Error verifying CAPTCHA:', error);
setMessage('An error occurred. Please try again.');
}
setUserInput(''); // Clear the input field
};
const moveToRegistration = (e) => {
// e.preventDefault();
// navigate("/signup")
navigate('/registration');
};
return (
<>
<Flex
align="center"
justify="center"
// vertical
style={{
height: '100vh',
// marginTop: '10vh',
// backgroundImage: `url('https://via.placeholder.com/300')`,
backgroundImage: `url(${sypiu_ggcp})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
}}
>
<Card
style={{
boxShadow: '0px 4px 8px rgba(0, 0, 0, 0.1)',
}}
>
<Flex align="center" justify="center">
<Image
// src="/src/assets/freepik/LOGOPIU.png"
src={logo}
height={150}
width={220}
preview={false}
alt="signin"
/>
</Flex>
<br />
<Form onFinish={handleOnSubmit} layout="vertical" style={{ width: '250px' }}>
<Form.Item
label="Username"
name="username"
rules={[
{
required: true,
// type: "email",
},
]}
>
<Input placeholder="username" size="large" />
</Form.Item>
<Form.Item
label="Password"
name="password"
rules={[
{
required: true,
message: 'Please input your password!',
},
]}
>
<Input.Password placeholder="password" size="large" />
</Form.Item>
<div
style={{ marginLeft: 45 }}
dangerouslySetInnerHTML={{ __html: captchaSvg }}
/>
{/* {message} */}
<Input
style={{ marginTop: 10, marginBottom: 15 }}
type="text"
placeholder="Enter CAPTCHA text"
size="large"
value={userInput}
onChange={(e) => setUserInput(e.target.value)}
/>
<Form.Item>
<Space direction="vertical" style={{ width: '100%' }}>
<Button type="primary" htmlType="submit" style={{ width: '100%' }}>
Sign In
</Button>
</Space>
</Form.Item>
</Form>
<Button
type="primary"
htmlType="submit"
style={{ width: '100%' }}
onClick={() => moveToRegistration()}
>
Registration
</Button>
</Card>
</Flex>
</>
);
};
export default SignIn;

158
src/pages/auth/Signup.jsx Normal file
View File

@@ -0,0 +1,158 @@
import { Flex, Input, Form, Button, Card, Space, Image } from 'antd'
import React from 'react'
// import handleSignIn from '../../Utils/Auth/SignIn';
import sypiu_ggcp from 'assets/sypiu_ggcp.jpg';
import {useNavigate} from "react-router-dom";
const SignUp = () => {
const [captchaSvg, setCaptchaSvg] = React.useState('');
const [userInput, setUserInput] = React.useState('');
const [message, setMessage] = React.useState('');
const navigate = useNavigate();
// let url = `${import.meta.env.VITE_API_SERVER}/users`;
React.useEffect(() => {
fetchCaptcha();
}, []);
// Fetch the CAPTCHA SVG from the backend
const fetchCaptcha = async () => {
try {
const response = await fetch('http://localhost:9528/generate-captcha');
const data = await response.text();
setCaptchaSvg(data);
} catch (error) {
console.error('Error fetching CAPTCHA:', error);
}
};
const handleOnSubmt = async (e) => {
// console.log('Received values of form: ', e);
// await handleSignIn(e);
e.preventDefault();
try {
const response = await fetch('http://localhost:5000/verify-captcha', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userInput }),
});
const data = await response.json();
if (data.success) {
setMessage('CAPTCHA verified successfully!');
} else {
setMessage('CAPTCHA verification failed. Try again.');
fetchCaptcha(); // Refresh CAPTCHA on failure
}
} catch (error) {
console.error('Error verifying CAPTCHA:', error);
setMessage('An error occurred. Please try again.');
}
setUserInput(''); // Clear the input field
}
const moveToSignin = (e) => {
// e.preventDefault();
navigate("/signin")
}
return (
<>
<Flex
align='center'
justify='center'
// vertical
style={{
height: '100vh',
// marginTop: '10vh',
// backgroundImage: `url('https://via.placeholder.com/300')`,
backgroundImage: `url(${sypiu_ggcp})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
}}
>
<Card
style={{
boxShadow: '0px 4px 8px rgba(0, 0, 0, 0.1)',
}}
>
<Flex align='center' justify='center'>
<Image src='/vite.svg' height={150} width={150} preview={false} alt='signin' />
</Flex>
<br />
<Form
onFinish={handleOnSubmt}
layout='vertical'
style={{ width: '250px' }}
>
<Form.Item label="Email" name="email"
rules={[
{
required: true,
type: 'email'
},
]}
>
<Input placeholder='email' size='large' />
</Form.Item>
<Form.Item label="Password" name="password"
rules={[
{
required: true,
message: 'Please input your password!'
},
]}
>
<Input.Password placeholder='password' size='large' />
</Form.Item>
<div dangerouslySetInnerHTML={{ __html: captchaSvg }} />
<Form.Item label="Captcha" name="captcha"
rules={[
{
required: true,
type: 'text'
},
]}
>
<Input placeholder='Enter CAPTCHA text' size='large' />
</Form.Item>
{/* <input
type="text"
placeholder="Enter CAPTCHA text"
value={userInput}
onChange={(e) => setUserInput(e.target.value)}
/> */}
<Form.Item>
<Space direction='vertical' style={{ width: '100%' }}>
<Button type="primary" htmlType="submit" style={{ width: '100%' }}>
Registrasi
</Button>
</Space>
</Form.Item>
</Form>
<Button
type="primary"
htmlType="submit"
style={{ width: '100%' }}
onClick={() => moveToSignin()}
>
Sign In
</Button>
</Card>
</Flex>
</>
)
}
export default SignUp

11
src/pages/blank/Blank.jsx Normal file
View File

@@ -0,0 +1,11 @@
import React from 'react'
const Blank = () => {
return (
<div>
Hi From BlankPage
</div>
)
}
export default Blank

View File

@@ -0,0 +1,85 @@
import { Button, Typography } from 'antd';
import { Link } from 'react-router-dom';
import ImgRobot from '../../assets/freepik/404.png';
const { Title, Paragraph, Text } = Typography;
const NotFound = () => {
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#fafafa',
padding: '5vh 16px',
textAlign: 'center',
minHeight: '100vh',
}}
>
<Title
level={2}
style={{
fontWeight: 800,
marginBottom: 12,
fontSize: 'clamp(28px, 5vw, 42px)',
color: '#1f1f1f',
}}
>
Oops... You seem lost.
</Title>
<Paragraph
style={{
fontSize: 'clamp(14px, 2vw, 18px)',
maxWidth: '90%',
color: '#595959',
marginBottom: '3vh',
}}
>
We couldn't find the page you were looking for. Let us take you back to the main
page.
</Paragraph>
<img
src={ImgRobot}
alt="404 Not Found"
style={{
maxWidth: '90%',
width: '480px',
marginBottom: '4vh',
}}
/>
<Link to="/">
<Button
type="default"
size="large"
style={{
backgroundColor: '#2f2f2f',
color: '#fff',
border: 'none',
padding: '10px 20px',
}}
>
Go back
</Button>
</Link>
<Text type="secondary" style={{ fontSize: '12px', marginTop: '5vh' }}>
Illustration by{' '}
<a
href="https://www.freepik.com"
target="_blank"
rel="noopener noreferrer"
style={{ color: '#1890ff' }}
>
Freepik
</a>
</Text>
</div>
);
};
export default NotFound;

View File

@@ -0,0 +1,58 @@
import { Button, Spin, Typography } from 'antd';
import { Link } from 'react-router-dom';
import ImgPIU from '../../assets/freepik/LOGOPIU.png';
const { Title, Paragraph, Text } = Typography;
const Waiting = () => {
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#fafafa',
padding: '5vh 16px',
textAlign: 'center',
minHeight: '100vh',
}}
>
<img
src={ImgPIU}
alt="Loading"
style={{
maxWidth: '30%',
// width: '400px',
marginBottom: '4vh',
}}
/>
<Spin size="large" style={{ marginBottom: '4vh' }} />
<Title
level={2}
style={{
fontWeight: 800,
marginBottom: 12,
fontSize: 'clamp(28px, 5vw, 42px)',
color: '#1f1f1f',
}}
>
Please wait...
</Title>
<Paragraph
style={{
fontSize: 'clamp(14px, 2vw, 18px)',
maxWidth: '90%',
color: '#595959',
marginBottom: '3vh',
}}
>
We are loading your content. This wont take long.
</Paragraph>
</div>
);
};
export default Waiting;

52
src/pages/home/Home.jsx Normal file
View File

@@ -0,0 +1,52 @@
import { useEffect, useState } from 'react';
import { Card, Typography, Flex } from 'antd';
import { useBreadcrumb } from '../../layout/LayoutBreadcrumb';
const { Text } = Typography;
const Home = () => {
const { setBreadcrumbItems } = useBreadcrumb();
const [isMobile, setIsMobile] = useState(window.innerWidth <= 768);
useEffect(() => {
const handleResize = () => {
setIsMobile(window.innerWidth <= 768);
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
useEffect(() => {
const token = localStorage.getItem('token');
if (token) {
setBreadcrumbItems([
{
title: (
<Text strong style={{ fontSize: '14px' }}>
Dashboard
</Text>
),
},
{
title: (
<Text strong style={{ fontSize: '14px' }}>
Home
</Text>
),
},
]);
} else {
navigate('/signin');
}
}, []);
return (
<Card>
<Flex align="center" justify="center">
<Text strong style={{fontSize:'30px'}}>Wellcome Call Of Duty App</Text>
</Flex>
</Card>
);
};
export default Home;

View File

@@ -0,0 +1,71 @@
import { Card } from 'antd';
const StatCard = ({ title, data }) => {
const totalCount = data.reduce((sum, item) => sum + item.count, 0);
return (
<Card title={title} style={{ borderRadius: 12 }}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
marginBottom: 24,
}}
>
{data.map((item, idx) => (
<div key={idx} style={{ textAlign: 'center', flex: 1 }}>
<div style={{ fontSize: 'clamp(12px, 1.5vw, 16px)', fontWeight: 500 }}>
{item.label}
</div>
<div
style={{
fontSize: 'clamp(20px, 3vw, 28px)',
fontWeight: 700,
color: item.color,
}}
>
{item.percent}%
</div>
<div style={{ fontSize: 'clamp(12px, 1.5vw, 16px)', color: item.color }}>
{item.count.toLocaleString()}
</div>
</div>
))}
</div>
<div style={{ position: 'relative', height: 28, marginTop: 8 }}>
<div
style={{
display: 'flex',
height: 20,
borderRadius: 8,
overflow: 'hidden',
}}
>
{data.map((item, idx) => (
<div
key={idx}
style={{
width: `${item.percent}%`,
backgroundColor: item.color,
}}
/>
))}
</div>
</div>
<div
style={{
marginTop: 16,
textAlign: 'center',
fontSize: '20px',
fontWeight: 'bold',
}}
>
Total: {totalCount.toLocaleString()}
</div>
</Card>
);
};
export default StatCard;

View File

@@ -0,0 +1,86 @@
import React, { memo, useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import ListDevice from './component/ListDevice';
import DetailDevice from './component/DetailDevice';
import GeneratePdf from './component/GeneratePdf';
import { useBreadcrumb } from '../../../layout/LayoutBreadcrumb';
import { Typography } from 'antd';
const { Text } = Typography;
const IndexDevice = memo(function IndexDevice() {
const navigate = useNavigate();
const { setBreadcrumbItems } = useBreadcrumb();
const [actionMode, setActionMode] = useState('list');
const [selectedData, setSelectedData] = useState(null);
const [readOnly, setReadOnly] = useState(false);
const [showModal, setShowmodal] = useState(false);
const setMode = (param) => {
setShowmodal(true);
switch (param) {
case 'add':
setReadOnly(false);
break;
case 'edit':
setReadOnly(false);
break;
case 'preview':
setReadOnly(true);
break;
default:
setShowmodal(false);
break;
}
setActionMode(param);
};
useEffect(() => {
const token = localStorage.getItem('token');
if (token) {
setBreadcrumbItems([
{ title: <Text strong style={{ fontSize: '14px' }}> Master</Text> },
{ title: <Text strong style={{ fontSize: '14px' }}>Device</Text> }
]);
} else {
navigate('/signin');
}
}, []);
return (
<React.Fragment>
<ListDevice
actionMode={actionMode}
setActionMode={setMode}
selectedData={selectedData}
setSelectedData={setSelectedData}
readOnly={readOnly}
/>
<DetailDevice
setActionMode={setMode}
selectedData={selectedData}
setSelectedData={setSelectedData}
readOnly={readOnly}
showModal={showModal}
permitDefault={true}
actionMode={actionMode}
/>
{actionMode == 'generatepdf' && (
<GeneratePdf
setActionMode={setMode}
selectedData={selectedData}
setSelectedData={setSelectedData}
readOnly={readOnly}
showPdf={true}
permitDefault={true}
/>
)}
</React.Fragment>
);
});
export default IndexDevice;

View File

@@ -0,0 +1,308 @@
import React, { useEffect, useState } from 'react';
import { Modal, Input, Divider, Typography, Switch, Button, ConfigProvider, Radio } from 'antd';
import { NotifAlert, NotifOk } from '../../../../components/Global/ToastNotif';
import { createApd, getJenisPermit, updateApd } from '../../../../api/master-apd';
import { Checkbox } from 'antd';
const CheckboxGroup = Checkbox.Group;
const { Text } = Typography;
const DetailDevice = (props) => {
const [confirmLoading, setConfirmLoading] = useState(false);
const defaultData = {
id_apd: '',
nama_apd: '',
type_input: 1,
is_active: true,
jenis_permit_default: [],
};
const [FormData, setFormData] = useState(defaultData);
const [jenisPermit, setJenisPermit] = useState([]);
const [checkedList, setCheckedList] = useState([]);
const onChange = (list) => {
setCheckedList(list);
};
const onChangeRadio = (e) => {
setFormData({
...FormData,
type_input: e.target.value,
});
};
const getDataJenisPermit = async () => {
setCheckedList([]);
const result = await getJenisPermit();
const data = result.data ?? [];
const names = data.map((item) => ({
value: item.id_jenis_permit,
label: item.nama_jenis_permit,
}));
setJenisPermit(names);
};
const handleCancel = () => {
props.setSelectedData(null);
props.setActionMode('list');
};
const handleSave = async () => {
setConfirmLoading(true);
if (!FormData.nama_apd) {
NotifOk({
icon: 'warning',
title: 'Peringatan',
message: 'Kolom Nama APD Tidak Boleh Kosong',
});
setConfirmLoading(false);
return;
}
if (props.permitDefault && checkedList.length === 0) {
NotifOk({
icon: 'warning',
title: 'Peringatan',
message: 'Kolom Jenis Permit Tidak Boleh Kosong',
});
setConfirmLoading(false);
return;
}
const payload = {
nama_apd: FormData.nama_apd,
is_active: FormData.is_active,
type_input: FormData.type_input,
jenis_permit_default: checkedList,
};
if (props.permitDefault) {
try {
let response;
if (!FormData.id_apd) {
response = await createApd(payload);
} else {
response = await updateApd(FormData.id_apd, payload);
}
if (response.statusCode === 200) {
NotifOk({
icon: 'success',
title: 'Berhasil',
message: `Data "${response.data.nama_apd}" berhasil ${
FormData.id_apd ? 'diubah' : 'ditambahkan'
}.`,
});
props.setActionMode('list');
} else {
NotifAlert({
icon: 'error',
title: 'Gagal',
message: response.message || 'Terjadi kesalahan saat menyimpan data.',
});
}
} catch (error) {
NotifAlert({
icon: 'error',
title: 'Error',
message: 'Terjadi kesalahan pada server. Coba lagi nanti.',
});
}
} else {
props.setData((prevData) => [
...prevData,
{ nama_apd: payload.nama_apd, type_input: payload.type_input },
]);
props.setActionMode('list');
}
setConfirmLoading(false);
};
const handleInputChange = (e) => {
const { name, value } = e.target;
setFormData({
...FormData,
[name]: value,
});
};
const handleStatusToggle = (event) => {
const isChecked = event;
setFormData({
...FormData,
is_active: isChecked ? true : false,
});
};
useEffect(() => {
const token = localStorage.getItem('token');
if (token) {
getDataJenisPermit();
if (props.selectedData != null) {
setFormData(props.selectedData);
setCheckedList(props.selectedData.jenis_permit_default_arr);
} else {
setFormData(defaultData);
}
} else {
navigate('/signin');
}
}, [props.showModal]);
return (
<Modal
// title={`${FormData.id_apd === '' ? 'Tambah' : 'Edit'} APD`}
title={`${
props.actionMode === 'add'
? 'Tambah'
: props.actionMode === 'preview'
? 'Preview'
: 'Edit'
} Device`}
open={props.showModal}
// open={true}
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',
defaultHoverColor: '#23A55A',
},
},
}}
>
<Button onClick={handleCancel}>Batal</Button>
</ConfigProvider>
<ConfigProvider
theme={{
token: {
colorBgContainer: '#209652',
},
components: {
Button: {
defaultBg: '#23a55a',
defaultColor: '#FFFFFF',
defaultBorderColor: '#23a55a',
defaultHoverColor: '#FFFFFF',
defaultHoverBorderColor: '#23a55a',
},
},
}}
>
{!props.readOnly && (
<Button loading={confirmLoading} onClick={handleSave}>
Simpan
</Button>
)}
</ConfigProvider>
</React.Fragment>,
]}
>
{FormData && (
<div>
{props.permitDefault && (
<>
<div>
<div>
<Text strong>Aktif</Text>
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
marginTop: '8px',
}}
>
<div style={{ marginRight: '8px' }}>
<Switch
disabled={props.readOnly}
style={{
backgroundColor:
FormData.is_active == 1 ? '#23A55A' : '#bfbfbf',
}}
checked={FormData.is_active == 1 ? true : false}
onChange={handleStatusToggle}
/>
</div>
<div>
<Text>
{FormData.is_active == 1 ? 'Aktif' : 'Non Aktif'}
</Text>
</div>
</div>
</div>
<Divider style={{ margin: '5px 0' }} />
</>
)}
<div hidden>
<Text strong>Device ID</Text>
<Input
name="id_apd"
value={FormData.id_apd}
onChange={handleInputChange}
disabled
/>
</div>
<div>
<Text strong>Device Name</Text>
<Text style={{ color: 'red' }}> *</Text>
<Input
name="nama_apd"
value={FormData.nama_apd}
onChange={handleInputChange}
readOnly={props.readOnly}
/>
</div>
<div style={{ marginTop: 4, marginBottom: 4 }}>
<Text strong>Tipe Input</Text>
<Text style={{ color: 'red', marginLeft: 4 }}>*</Text>
<div>
<Radio.Group
value={FormData.type_input}
options={[
{ value: 1, label: 'Check' },
{ value: 2, label: 'Text' },
{ value: 3, label: 'Number' },
]}
onChange={onChangeRadio}
disabled={props.readOnly}
/>
</div>
</div>
{props.permitDefault && (
<div>
<Text strong>Jenis Permit</Text>
<Text style={{ color: 'red' }}> *</Text>
<CheckboxGroup
options={jenisPermit}
value={checkedList}
onChange={onChange}
disabled={props.readOnly}
/>
</div>
)}
</div>
)}
</Modal>
);
};
export default DetailDevice;

View File

@@ -0,0 +1,127 @@
import React, {useEffect, useState } from 'react';
import { Modal, Button, ConfigProvider } from 'antd';
import { jsPDF } from 'jspdf';
import logoPiEnergi from '../../../../assets/images/logo/pi-energi.png';
import { kopReportPdf } from '../../../../components/Global/KopReport';
const GeneratePdf = (props) => {
const [pdfUrl, setPdfUrl] = useState(null);
useEffect(() => {
const token = localStorage.getItem('token');
if (token) {
generatePdf();
} else {
navigate('/signin');
}
}, []);
const handleCancel = () => {
props.setSelectedData(null);
props.setActionMode('list');
};
const generatePdf = async () => {
const {images, title} = await kopReportPdf(logoPiEnergi, 'COLD WORK PERMIT');
const doc = new jsPDF({
orientation: "portrait",
unit: "mm",
format: "a4"
});
const width = 45;
const height = 23;
const marginTop = 6;
const marginLeft = 10;
doc.addImage(images, 'PNG', marginLeft, marginTop, width, height);
doc.setFont('helvetica', 'bold');
doc.setFontSize(25);
doc.setTextColor(35, 165, 90);
doc.setTextColor('#00b0f0');
doc.text(title, 100, 25);
doc.setTextColor('#000000');
doc.setFontSize(11);
doc.setFont('helvetica', 'normal');
doc.setLineWidth(0.2);
doc.line(10, 32, 200, 32);
doc.setLineWidth(0.6);
doc.line(10, 32.8, 200, 32.8);
doc.text("Tanggal Pengajuan", 10, 42);
doc.text(":", 59, 42);
doc.text("Deskripsi Pekerjaan", 10, 48);
doc.text(":", 59, 48);
doc.text("No. Permit", 10, 54);
doc.text(":", 59, 54);
doc.text("Spesifik Lokasi", 120, 54);
doc.text(":", 160, 54);
doc.text("No. Order", 10, 60);
doc.text(":", 59, 60);
doc.text("Jum. Personil Terlihat", 120, 60);
doc.text(":", 160, 60);
doc.text("Peralatan yang digunakan", 10, 66);
doc.text(":", 59, 66);
doc.text("Jenis APD yang digunakan", 10, 72);
doc.text(":", 59, 72);
const blob = doc.output('blob');
const url = URL.createObjectURL(blob);
setPdfUrl(url);
setTimeout(() => {
URL.revokeObjectURL(url);
}, 1000);
};
return (
<Modal
width='60%'
title="Preview PDF"
open={props.showPdf}
// open={true}
onCancel={handleCancel}
footer={[
<>
<ConfigProvider
theme={{
token: { colorBgContainer: '#E9F6EF' },
components: {
Button: {
defaultBg: 'white',
defaultColor: '#23A55A',
defaultBorderColor: '#23A55A',
defaultHoverColor: '#23A55A',
defaultHoverBorderColor: '#23A55A',
defaultHoverColor: '#23A55A',
},
},
}}
>
<Button onClick={handleCancel}>Batal</Button>
</ConfigProvider>
</>,
]}
>
{pdfUrl && (
<iframe
src={`${pdfUrl}#zoom=100`}
title="PDF Viewer"
width="100%"
height="600px"
style={{ marginTop: '20px', border: '1px solid #ccc' }}
/>
)}
</Modal>
);
};
export default GeneratePdf;

View File

@@ -0,0 +1,408 @@
import React, { memo, useState, useEffect } from 'react';
import {
Space,
Tag,
ConfigProvider,
Button, Row, Col, Card, Divider, Form, Input, Dropdown } from 'antd';
import {
PlusOutlined,
FilterOutlined,
EditOutlined,
DeleteOutlined,
EyeOutlined,
SearchOutlined,
FilePdfOutlined,
FileExcelOutlined,
EllipsisOutlined
} from '@ant-design/icons';
import { NotifAlert, NotifOk, NotifConfirmDialog } from '../../../../components/Global/ToastNotif';
import { useNavigate } from 'react-router-dom';
import { deleteApd, getAllApd } from '../../../../api/master-apd';
import TableList from '../../../../components/Global/TableList';
import { getFilterData } from '../../../../components/Global/DataFilter';
import ExcelJS from 'exceljs';
import { saveAs } from 'file-saver';
import logoPiEnergi from '../../../../assets/images/logo/pi-energi.png';
const columns = (items, handleClickMenu) => [
{
title: 'ID',
dataIndex: 'id_apd',
key: 'id_apd',
width: '5%',
hidden: 'true',
},
{
title: 'Device Name',
dataIndex: 'nama_apd',
key: 'nama_apd',
width: '55%',
},
{
title: 'Aktif',
dataIndex: 'is_active',
key: 'is_active',
width: '10%',
align: 'center',
render: (_, { is_active }) => (
<>
{is_active === true ? (
<Tag color={'green'} key={'aaa'}>
Aktif
</Tag>
) : (
<Tag color={'red'} key={'aaa'}>
Non-Aktif
</Tag>
)}
</>
),
},
{
title: 'Aksi',
key: 'aksi',
align: 'center',
width: '5%',
render: (_, record) => (
<Dropdown
menu={{
items,
onClick: ({key})=>handleClickMenu(key, record)
}}
trigger={['click']}
placement="bottomRight"
>
<Button
shape="default"
icon={<EllipsisOutlined />}
/>
</Dropdown>
),
},
];
const ListDevice = memo(function ListDevice(props) {
const [showFilter, setShowFilter] = useState(false);
const [trigerFilter, setTrigerFilter] = useState(false);
const defaultFilter = { nama_apd: '' };
const [formDataFilter, setFormDataFilter] = useState(defaultFilter);
const navigate = useNavigate();
useEffect(() => {
const token = localStorage.getItem('token');
if (token) {
if (props.actionMode == 'list') {
setFormDataFilter(defaultFilter);
doFilter();
}
} else {
navigate('/signin');
}
}, [props.actionMode]);
const toggleFilter = () => {
setFormDataFilter(defaultFilter);
setShowFilter((prev) => !prev);
};
const doFilter = () => {
setTrigerFilter((prev) => !prev);
};
const handleInputChangeFilter = (e) => {
const { name, value } = e.target;
setFormDataFilter((prevData) => ({
...prevData,
[name]: value,
}));
};
const showPreviewModal = (param) => {
props.setSelectedData(param);
props.setActionMode('preview');
};
const showEditModal = (param = null) => {
props.setSelectedData(param);
props.setActionMode('edit');
};
const showAddModal = (param = null) => {
props.setSelectedData(param);
props.setActionMode('add');
};
const showDeleteDialog = (param) => {
NotifConfirmDialog({
icon: 'question',
title: 'Konfirmasi',
message: 'Apakah anda yakin hapus data "' + param.nama_apd + '" ?',
onConfirm: () => handleDelete(param.id_apd),
onCancel: () => props.setSelectedData(null),
});
};
const handleDelete = async (id_apd) => {
const response = await deleteApd(id_apd);
if (response.statusCode == 200) {
NotifAlert({
icon: 'success',
title: 'Berhasil',
message: 'Data Data "' + response.data[0].nama_apd + '" berhasil dihapus.',
});
doFilter();
} else {
NotifOk({
icon: 'error',
title: 'Gagal',
message: 'Gagal Menghapus Data Data "' + response.data[0].nama_apd + '"',
});
}
};
const generatePdf = () => {
props.setActionMode('generatepdf');
};
const exportExcel = async()=>{
const data = getFilterData();
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet('Data APD');
let rowCursor = 1;
// Kop Logo PIE
if (logoPiEnergi) {
const response = await fetch(logoPiEnergi);
const blob = await response.blob();
const buffer = await blob.arrayBuffer();
const imageId = workbook.addImage({
buffer,
extension: 'png',
});
// Tempatkan gambar di pojok atas
sheet.addImage(imageId, {
tl: { col: 0.2, row: 0.8 },
ext: { width: 163, height: 80 },
});
sheet.getRow(5).height = 15; // biar ada jarak ke tabel
rowCursor = 3;
}
// Tambah Judul
const titleCell = sheet.getCell(`C${rowCursor}`);
titleCell.value = 'Data APD';
titleCell.font = { size: 20, bold: true, color: { argb: 'FF00AEEF' } };
titleCell.alignment = { vertical: 'middle', horizontal: 'center' };
sheet.mergeCells(`C${rowCursor}:F${rowCursor}`);
// Header tabel
const headers = [
'ID APD',
'Nama APD',
'Deskripsi',
'Jenis Permit Default',
'Aktif',
'Dibuat',
'Diubah',
];
sheet.addRow(headers);
const headerRow = sheet.getRow(6);
headerRow.font = { bold: true, size: 12 };
headerRow.eachCell((cell) => {
cell.alignment = {
horizontal: 'center', // rata tengah kiri-kanan
vertical: 'middle', // rata tengah atas-bawah
};
});
// Tambahkan data
data.forEach((item) => {
sheet.addRow([
item.id_apd,
item.nama_apd,
item.deskripsi_apd ?? '',
item.jenis_permit_default ?? '',
item.is_active ? 'Ya' : 'Tidak',
new Date(item.created_at).toLocaleString(),
new Date(item.updated_at).toLocaleString(),
]);
});
// Auto width
sheet.columns.forEach((col) => {
let maxLength = 10;
col.eachCell({ includeEmpty: true }, (cell) => {
const len = cell.value?.toString().length || 0;
if (len > maxLength) maxLength = len;
});
col.width = maxLength + 2;
});
// Export
const buffer = await workbook.xlsx.writeBuffer();
saveAs(new Blob([buffer]), 'Data_APD.xlsx');
};
const handleClickMenu = (key, record) => {
switch (key) {
case 'preview':
showPreviewModal(record);
break;
case 'edit':
showEditModal(record);
break;
case 'delete':
showDeleteDialog(record);
break;
default:
break;
}
};
const menu = [
{
key: 'preview',
label: <span style={{fontSize:'17px'}}>Preview</span>,
icon: <EyeOutlined style={{fontSize:'17px', marginTop:'5px'}} />,
},
{
key: 'edit',
label: <span style={{fontSize:'17px'}}>Edit</span>,
icon: <EditOutlined style={{fontSize:'17px'}} />,
},
{
key: 'delete',
label: <span style={{fontSize:'17px'}}>Delete</span>,
icon: <DeleteOutlined style={{fontSize:'17px'}} />,
danger: true,
},
];
return (
<React.Fragment>
<Card>
<Row>
<Col xs={24}>
<Row justify="space-between" align="middle" gutter={[8, 8]}>
<Col>
<ConfigProvider
theme={{
token: { colorBgContainer: '#E9F6EF' },
components: {
Button: {
defaultBg: 'white',
defaultColor: '#23A55A',
defaultBorderColor: '#23A55A',
},
},
}}
>
<Button onClick={toggleFilter} icon={<FilterOutlined />}>
Filter
</Button>
</ConfigProvider>
</Col>
<Col>
<Space wrap size="small">
<ConfigProvider
theme={{
token: { colorBgContainer: '#E9F6EF' },
components: {
Button: {
defaultBg: 'white',
defaultColor: '#23A55A',
defaultBorderColor: '#23A55A',
defaultHoverColor: '#23A55A',
defaultHoverBorderColor: '#23A55A',
},
},
}}
>
<Button
icon={<PlusOutlined />}
onClick={() => showAddModal()}
>
Tambah Data
</Button>
</ConfigProvider>
</Space>
</Col>
</Row>
</Col>
{/* filter */}
<Col xs={24} sm={24} md={24} lg={24} xl={24} style={{ marginTop: '16px' }}>
{showFilter && (
<>
<Divider />
<Form layout="vertical">
<Form.Item label="Nama APD">
<div
style={{
display: 'flex',
gap: '8px',
alignItems: 'center',
maxWidth: '500px',
width: '100%',
}}
>
<Input
name="nama_apd"
value={formDataFilter.nama_apd}
onChange={handleInputChangeFilter}
placeholder="Enter Nama APD"
style={{ height: '40px' }}
/>
<ConfigProvider
theme={{
token: {
colorText: 'white',
colorBgContainer: 'purple',
},
components: {
Button: {
defaultBg: 'white',
defaultColor: '#23A55A',
defaultBorderColor: '#23A55A',
},
},
}}
>
<Button
icon={<SearchOutlined />}
onClick={doFilter}
style={{ height: '40px' }}
>
Cari
</Button>
</ConfigProvider>
</div>
</Form.Item>
</Form>
</>
)}
</Col>
<Col xs={24} sm={24} md={24} lg={24} xl={24} style={{ marginTop: '16px' }}>
<TableList
getData={getAllApd}
queryParams={formDataFilter}
columns={columns(menu, handleClickMenu)}
triger={trigerFilter}
/>
</Col>
</Row>
</Card>
</React.Fragment>
);
});
export default ListDevice;