add menu contact

This commit is contained in:
2025-11-13 13:58:52 +07:00
parent 85db9e0a52
commit 08f8c4708f
5 changed files with 769 additions and 0 deletions

View File

@@ -33,6 +33,7 @@ import IndexReport from './pages/report/report/IndexReport';
import IndexNotification from './pages/notification/IndexNotification'; import IndexNotification from './pages/notification/IndexNotification';
import IndexRole from './pages/role/IndexRole'; import IndexRole from './pages/role/IndexRole';
import IndexUser from './pages/user/IndexUser'; import IndexUser from './pages/user/IndexUser';
import IndexContact from './pages/contact/IndexContact';
import SvgTest from './pages/home/SvgTest'; import SvgTest from './pages/home/SvgTest';
import SvgOverviewCompressor from './pages/home/SvgOverviewCompressor'; import SvgOverviewCompressor from './pages/home/SvgOverviewCompressor';
@@ -112,6 +113,10 @@ const App = () => {
<Route index element={<IndexUser />} /> <Route index element={<IndexUser />} />
</Route> </Route>
<Route path="/contact" element={<ProtectedRoute />}>
<Route index element={<IndexContact />} />
</Route>
<Route path="/jadwal-shift" element={<ProtectedRoute />}> <Route path="/jadwal-shift" element={<ProtectedRoute />}>
<Route index element={<IndexJadwalShift />} /> <Route index element={<IndexJadwalShift />} />
</Route> </Route>

View File

@@ -31,6 +31,7 @@ import {
GroupOutlined, GroupOutlined,
SlidersOutlined, SlidersOutlined,
SnippetsOutlined, SnippetsOutlined,
ContactsOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
const { Text } = Typography; const { Text } = Typography;
@@ -182,6 +183,15 @@ const allItems = [
}, },
], ],
}, },
{
key: 'contact',
icon: <ContactsOutlined style={{ fontSize: '19px' }} />,
label: (
<Link to="/contact" className="fontMenus">
Contact
</Link>
),
},
{ {
key: 'notification', key: 'notification',
icon: <BellOutlined style={{ fontSize: '19px' }} />, icon: <BellOutlined style={{ fontSize: '19px' }} />,
@@ -236,6 +246,7 @@ const LayoutMenu = () => {
if (pathname === '/role') return 'role'; if (pathname === '/role') return 'role';
if (pathname === '/notification') return 'notification'; if (pathname === '/notification') return 'notification';
if (pathname === '/jadwal-shift') return 'jadwal-shift'; if (pathname === '/jadwal-shift') return 'jadwal-shift';
if (pathname === '/contact') return 'contact';
// Handle master routes // Handle master routes
if (pathname.startsWith('/master/')) { if (pathname.startsWith('/master/')) {

View File

@@ -0,0 +1,69 @@
import React, { memo, useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import ListContact from './component/ListContact';
import DetailContact from './component/DetailContact';
import { useBreadcrumb } from '../../layout/LayoutBreadcrumb';
import { Typography } from 'antd';
const { Text } = Typography;
const IndexContact = memo(function IndexContact() {
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(param !== 'list');
setReadOnly(param === 'preview');
setActionMode(param);
};
const handleContactSaved = (contactData, actionMode) => {
setLastSavedContact({ contactData, actionMode });
// Clear after processing
setTimeout(() => setLastSavedContact(null), 100);
};
const [lastSavedContact, setLastSavedContact] = useState(null);
useEffect(() => {
const token = localStorage.getItem('token');
if (token) {
setBreadcrumbItems([
{ title: <Text strong style={{ fontSize: '14px' }}> Contact</Text> },
]);
} else {
navigate('/signin');
}
}, [navigate, setBreadcrumbItems]);
return (
<React.Fragment>
<ListContact
actionMode={actionMode}
setActionMode={setMode}
selectedData={selectedData}
setSelectedData={setSelectedData}
readOnly={readOnly}
lastSavedContact={lastSavedContact}
/>
<DetailContact
setActionMode={setMode}
selectedData={selectedData}
setSelectedData={setSelectedData}
readOnly={readOnly}
showModal={showModal}
actionMode={actionMode}
onContactSaved={handleContactSaved}
/>
</React.Fragment>
);
});
export default IndexContact;

View File

@@ -0,0 +1,236 @@
import React, { memo, useEffect, useState } from 'react';
import { Modal, Input, Button, Switch, ConfigProvider, Typography, Divider } from 'antd';
import { NotifAlert, NotifOk } from '../../../components/Global/ToastNotif';
import { validateRun } from '../../../Utils/validate';
const { Text } = Typography;
const DetailContact = memo(function DetailContact(props) {
const [confirmLoading, setConfirmLoading] = useState(false);
const defaultData = {
id: '',
name: '',
phone: '',
is_active: true,
};
const [formData, setFormData] = useState(defaultData);
const handleInputChange = (e) => {
let name, value;
if (e && e.target) {
name = e.target.name;
value = e.target.value;
} else if (e && e.type === 'change') {
name = e.name || e.target?.name;
value = e.value !== undefined ? e.value : e.checked;
} else {
return;
}
// Validasi untuk field phone - hanya angka yang diperbolehkan
if (name === 'phone') {
value = value.replace(/[^0-9+\-\s()]/g, '');
}
if (name) {
setFormData((prev) => ({
...prev,
[name]: value,
}));
}
};
const handleStatusToggle = (checked) => {
setFormData({
...formData,
is_active: checked,
});
};
const handleSave = async () => {
setConfirmLoading(true);
// Custom validation untuk phone
if (formData.phone && !/^[\d\s\+\-\(\)]+$/.test(formData.phone)) {
NotifOk({
icon: 'warning',
title: 'Peringatan',
message: 'Nomor telepon hanya boleh mengandung angka, spasi, +, -, dan ()',
});
setConfirmLoading(false);
return;
}
// Validation rules
const validationRules = [
{ field: 'name', label: 'Contact Name', required: true },
{ field: 'phone', label: 'Phone', required: true },
];
if (
validateRun(formData, validationRules, (errorMessages) => {
NotifOk({ icon: 'warning', title: 'Peringatan', message: errorMessages });
setConfirmLoading(false);
})
)
return;
try {
const contactData = {
id: props.selectedData?.id || null,
name: formData.name,
phone: formData.phone,
is_active: formData.is_active,
status: formData.is_active ? 'active' : 'inactive',
};
console.log('Saving contact data:', contactData);
await new Promise((resolve) => setTimeout(resolve, 1000));
NotifAlert({
icon: 'success',
title: 'Berhasil',
message: `Data Contact berhasil ${
props.actionMode === 'add' ? 'ditambahkan' : 'diperbarui'
}.`,
});
props.onContactSaved?.(contactData, props.actionMode);
handleCancel();
} catch (error) {
console.error('Save failed:', error);
NotifAlert({
icon: 'error',
title: 'Error',
message: 'Terjadi kesalahan saat menyimpan data.',
});
} finally {
setConfirmLoading(false);
}
};
const handleCancel = () => {
props.setActionMode('list');
props.setSelectedData(null);
};
useEffect(() => {
if (props.showModal) {
if (props.actionMode === 'edit' && props.selectedData) {
setFormData({
name: props.selectedData.name,
phone: props.selectedData.phone,
is_active: props.selectedData.status === 'active',
});
} else if (props.actionMode === 'add') {
setFormData({
name: '',
phone: '',
is_active: true,
});
}
}
}, [props.showModal, props.actionMode, props.selectedData]);
return (
<Modal
title={`${
props.actionMode === 'add'
? 'Tambah'
: props.actionMode === 'edit'
? 'Edit'
: 'Detail'
} Kontak`}
open={props.showModal}
onCancel={handleCancel}
footer={[
<React.Fragment key="modal-footer">
<ConfigProvider
theme={{
components: {
Button: {
defaultBg: 'white',
defaultColor: '#23A55A',
defaultBorderColor: '#23A55A',
},
},
}}
>
<Button onClick={handleCancel}>{props.readOnly ? 'Tutup' : 'Batal'}</Button>
</ConfigProvider>
<ConfigProvider
theme={{
components: {
Button: {
defaultBg: '#23a55a',
defaultColor: '#FFFFFF',
defaultBorderColor: '#23a55a',
},
},
}}
>
{!props.readOnly && (
<Button loading={confirmLoading} onClick={handleSave}>
Simpan
</Button>
)}
</ConfigProvider>
</React.Fragment>,
]}
>
<div style={{ padding: '8px 0' }}>
<div>
<div>
<Text strong>Status</Text>
</div>
<div style={{ display: 'flex', alignItems: 'center', marginTop: '8px' }}>
<div style={{ marginRight: '8px' }}>
<Switch
disabled={props.readOnly}
style={{
backgroundColor: formData.is_active ? '#23A55A' : '#bfbfbf',
}}
checked={formData.is_active}
onChange={handleStatusToggle}
/>
</div>
<div>
<Text>{formData.is_active ? 'Active' : 'Inactive'}</Text>
</div>
</div>
</div>
<Divider style={{ margin: '12px 0' }} />
<div style={{ marginBottom: 12 }}>
<Text strong>Name</Text>
<Text style={{ color: 'red' }}> *</Text>
<Input
name="name"
value={formData.name}
onChange={handleInputChange}
placeholder="Enter Name"
readOnly={props.readOnly}
/>
</div>
<div style={{ marginBottom: 12 }}>
<Text strong>Phone</Text>
<Text style={{ color: 'red' }}> *</Text>
<Input
name="phone"
value={formData.phone}
onChange={handleInputChange}
placeholder="Enter Phone Number"
readOnly={props.readOnly}
maxLength={15}
style={{ color: formData.is_active ? '#000000' : '#ff4d4f' }}
/>
</div>
</div>
</Modal>
);
});
export default DetailContact;

View File

@@ -0,0 +1,448 @@
import React, { memo, useState, useEffect } from 'react';
import { Button, Row, Col, Input, Tabs, Space, ConfigProvider, Card, Tag, Select } from 'antd';
import {
PlusOutlined,
EditOutlined,
DeleteOutlined,
SearchOutlined,
UserOutlined,
PhoneOutlined,
} from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { NotifAlert, NotifConfirmDialog } from '../../../components/Global/ToastNotif';
const { TabPane } = Tabs;
// Mock data
const initialMockOperators = [
{ id: 1, name: 'Shof Watun Niswah', phone: '+62 821 9049 8383', status: 'active' },
{ id: 2, name: 'Ahmad Susanto', phone: '+62 812 3456 7890', status: 'active' },
{ id: 3, name: 'Budi Santoso', phone: '+62 813 2345 6789', status: 'active' },
{ id: 4, name: 'Rina Wijaya', phone: '+62 814 3456 7891', status: 'active' },
];
const initialMockGudang = [
{ id: 101, name: 'Eko Prasetyo', phone: '+62 816 5678 9012', status: 'active' },
{ id: 102, name: 'Fajar Hidayat', phone: '+62 817 6789 0123', status: 'active' },
{ id: 103, name: 'Siti Nurhaliza', phone: '+62 818 7890 1234', status: 'active' },
{ id: 104, name: 'Andi Pratama', phone: '+62 819 8901 2345', status: 'inactive' },
];
const ContactCard = memo(function ContactCard({ contact, showEditModal, showDeleteModal }) {
return (
<Col xs={24} sm={12} md={8} lg={6}>
<div
className="contact-card"
style={{
marginBottom: 16,
borderRadius: 8,
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
height: '100%',
padding: '16px',
backgroundColor: '#f5f5f5',
border: '1px solid #e8e8e8',
transition: 'all 0.3s ease',
cursor: 'pointer',
}}
onMouseEnter={(e) => {
e.currentTarget.style.boxShadow = '0 4px 12px rgba(0,0,0,0.15)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.1)';
}}
>
<div
style={{
height: '100%',
display: 'flex',
flexDirection: 'column',
position: 'relative',
}}
>
{/* Status Badge - Top Right */}
<div style={{ position: 'absolute', top: 0, right: 0, zIndex: 1 }}>
{contact.status === 'active' ? (
<Tag color={'green'} style={{ fontSize: '11px' }}>
Active
</Tag>
) : (
<Tag color={'red'} style={{ fontSize: '11px' }}>
InActive
</Tag>
)}
</div>
{/* Main Content */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
flex: 1,
paddingTop: '4px',
}}
>
<div
className="avatar"
style={{
width: 55,
height: 55,
borderRadius: '50%',
backgroundColor:
contact.status === 'active' ? '#52c41a' : '#8c8c8c',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<UserOutlined style={{ color: 'white', fontSize: '25px' }} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontWeight: 600,
fontSize: '16px',
marginBottom: '4px',
color: '#262626',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{contact.name}
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
fontSize: '14px',
}}
>
<PhoneOutlined style={{ marginRight: 6, color: '#1890ff' }} />
<span
style={{
color: contact.status === 'active' ? '#52c41a' : '#ff4d4f',
}}
>
{contact.phone}
</span>
</div>
</div>
</div>
{/* Edit and Delete Buttons - Bottom Right */}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px', marginTop: '8px' }}>
<Space>
<Button
type="text"
size="small"
style={{
borderColor: '#faad14',
padding: '2px 6px',
fontSize: '11px',
height: '24px'
}}
icon={<EditOutlined style={{ color: '#faad14', fontSize: '11px' }} />}
onClick={(e) => {
e.stopPropagation();
showEditModal(contact);
}}
>
Edit info
</Button>
<Button
type="text"
danger
size="small"
style={{
borderColor: 'red',
padding: '2px 6px',
fontSize: '11px',
height: '24px'
}}
icon={<DeleteOutlined style={{ fontSize: '11px' }} />}
onClick={(e) => {
e.stopPropagation();
showDeleteModal(contact);
}}
>
Delete
</Button>
</Space>
</div>
</div>
</div>
</Col>
);
});
const ListContact = memo(function ListContact(props) {
const [activeTab, setActiveTab] = useState('operator');
const [searchValue, setSearchValue] = useState('');
const [operators, setOperators] = useState(initialMockOperators);
const [gudang, setGudang] = useState(initialMockGudang);
const [filteredOperators, setFilteredOperators] = useState(initialMockOperators);
const [filteredGudang, setFilteredGudang] = useState(initialMockGudang);
const [sortBy, setSortBy] = useState('name');
const [filterBy, setFilterBy] = useState('all');
const navigate = useNavigate();
// Listen for saved contact data
useEffect(() => {
if (props.lastSavedContact) {
handleContactSaved(
props.lastSavedContact.contactData,
props.lastSavedContact.actionMode
);
}
}, [props.lastSavedContact]);
// Handle contact data from modal
const handleContactSaved = (contactData, actionMode) => {
const updateContacts = (contacts) => {
if (actionMode === 'add') {
const maxId = Math.max(...contacts.map((item) => item.id), 0);
return [...contacts, { ...contactData, id: maxId + 1 }];
} else if (actionMode === 'edit') {
return contacts.map((item) =>
item.id === contactData.id ? { ...item, ...contactData } : item
);
}
return contacts;
};
if (activeTab === 'operator') {
setOperators(updateContacts(operators));
} else {
setGudang(updateContacts(gudang));
}
};
useEffect(() => {
const token = localStorage.getItem('token');
if (!token) {
navigate('/signin');
}
}, []);
// Filter and sort helper function
const filterAndSort = (contacts) => {
let filtered = contacts.filter(
(contact) =>
contact.name.toLowerCase().includes(searchValue.toLowerCase()) ||
contact.phone.includes(searchValue)
);
if (filterBy !== 'all') {
filtered = filtered.filter((contact) => contact.status === filterBy);
}
return [...filtered].sort((a, b) => {
if (sortBy === 'name') return a.name.localeCompare(b.name);
if (sortBy === 'phone') return a.phone.localeCompare(b.phone);
return 0;
});
};
useEffect(() => {
setFilteredOperators(filterAndSort(operators));
setFilteredGudang(filterAndSort(gudang));
}, [searchValue, sortBy, filterBy, operators, gudang]);
const showEditModal = (param) => {
props.setSelectedData(param);
props.setActionMode('edit');
};
const showAddModal = () => {
props.setSelectedData(null);
props.setActionMode('add');
};
const showDeleteModal = (contact) => {
NotifConfirmDialog({
icon: 'question',
title: 'Konfirmasi Hapus',
message: `Kontak "${contact.name}" akan dihapus?`,
onConfirm: () => handleDelete(contact),
onCancel: () => props.setSelectedData(null),
});
};
const handleDelete = (contact) => {
if (activeTab === 'operator') {
const updatedOperators = operators.filter((op) => op.id !== contact.id);
setOperators(updatedOperators);
} else {
const updatedGudang = gudang.filter((item) => item.id !== contact.id);
setGudang(updatedGudang);
}
NotifAlert({
icon: 'success',
title: 'Berhasil',
message: `Kontak "${contact.name}" berhasil dihapus.`,
});
};
const handleSearch = (value) => {
setSearchValue(value);
};
const handleSearchClear = () => {
setSearchValue('');
};
return (
<React.Fragment>
<Card>
<Row>
<Col xs={24}>
<Row justify="space-between" align="middle" gutter={[8, 8]}>
<Col xs={24} sm={24} md={12} lg={12}>
<Input.Search
placeholder="Search by name or phone..."
value={searchValue}
onChange={(e) => {
const value = e.target.value;
setSearchValue(value);
if (value === '') {
handleSearchClear();
}
}}
onSearch={handleSearch}
allowClear={{
clearIcon: <span onClick={handleSearchClear}></span>,
}}
enterButton={
<Button
type="primary"
icon={<SearchOutlined />}
style={{
backgroundColor: '#23A55A',
borderColor: '#23A55A',
}}
>
Search
</Button>
}
size="large"
/>
</Col>
<Col>
<Space wrap size="small">
<ConfigProvider
theme={{
components: {
Button: {
defaultBg: 'white',
defaultColor: '#23A55A',
defaultBorderColor: '#23A55A',
},
},
}}
>
<Button
icon={<PlusOutlined />}
onClick={() => showAddModal()}
size="large"
>
Add Contact
</Button>
</ConfigProvider>
</Space>
</Col>
</Row>
</Col>
<Col xs={24} style={{ marginTop: '16px' }}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '16px',
}}
>
<Tabs activeKey={activeTab} onChange={setActiveTab} size="large">
<TabPane tab="Operator" key="operator" />
<TabPane tab="Gudang" key="gudang" />
</Tabs>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ fontSize: '14px', color: '#666' }}>
Filter by:
</span>
<Select
value={filterBy}
onChange={setFilterBy}
style={{ width: 100 }}
size="small"
>
<Select.Option value="all">All</Select.Option>
<Select.Option value="active">Active</Select.Option>
<Select.Option value="inactive">Inactive</Select.Option>
</Select>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ fontSize: '14px', color: '#666' }}>
Sort by:
</span>
<Select
value={sortBy}
onChange={setSortBy}
style={{ width: 100 }}
size="small"
>
<Select.Option value="name">Name</Select.Option>
<Select.Option value="phone">Phone</Select.Option>
</Select>
</div>
</div>
</div>
{activeTab === 'operator' ? (
filteredOperators.length === 0 ? (
<div style={{ textAlign: 'center', padding: '40px' }}>
<span style={{ color: '#8c8c8c' }}>No operators found</span>
</div>
) : (
<Row gutter={[16, 16]}>
{filteredOperators.map((operator) => (
<ContactCard
key={operator.id}
contact={operator}
showEditModal={showEditModal}
showDeleteModal={showDeleteModal}
/>
))}
</Row>
)
) : filteredGudang.length === 0 ? (
<div style={{ textAlign: 'center', padding: '40px' }}>
<span style={{ color: '#8c8c8c' }}>
No warehouse contacts found
</span>
</div>
) : (
<Row gutter={[16, 16]}>
{filteredGudang.map((item) => (
<ContactCard
key={item.id}
contact={item}
showEditModal={showEditModal}
showDeleteModal={showDeleteModal}
/>
))}
</Row>
)}
</Col>
</Row>
</Card>
</React.Fragment>
);
});
export default ListContact;