diff --git a/src/App.jsx b/src/App.jsx
index ddaac6d..9081ffe 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -33,6 +33,7 @@ import IndexReport from './pages/report/report/IndexReport';
import IndexNotification from './pages/notification/IndexNotification';
import IndexRole from './pages/role/IndexRole';
import IndexUser from './pages/user/IndexUser';
+import IndexContact from './pages/contact/IndexContact';
import SvgTest from './pages/home/SvgTest';
import SvgOverviewCompressor from './pages/home/SvgOverviewCompressor';
@@ -112,6 +113,10 @@ const App = () => {
} />
+ }>
+ } />
+
+
}>
} />
diff --git a/src/layout/LayoutMenu.jsx b/src/layout/LayoutMenu.jsx
index c0f12f0..71ff29c 100644
--- a/src/layout/LayoutMenu.jsx
+++ b/src/layout/LayoutMenu.jsx
@@ -31,6 +31,7 @@ import {
GroupOutlined,
SlidersOutlined,
SnippetsOutlined,
+ ContactsOutlined,
} from '@ant-design/icons';
const { Text } = Typography;
@@ -182,6 +183,15 @@ const allItems = [
},
],
},
+ {
+ key: 'contact',
+ icon: ,
+ label: (
+
+ Contact
+
+ ),
+ },
{
key: 'notification',
icon: ,
@@ -236,6 +246,7 @@ const LayoutMenu = () => {
if (pathname === '/role') return 'role';
if (pathname === '/notification') return 'notification';
if (pathname === '/jadwal-shift') return 'jadwal-shift';
+ if (pathname === '/contact') return 'contact';
// Handle master routes
if (pathname.startsWith('/master/')) {
diff --git a/src/pages/contact/IndexContact.jsx b/src/pages/contact/IndexContact.jsx
new file mode 100644
index 0000000..d8df467
--- /dev/null
+++ b/src/pages/contact/IndexContact.jsx
@@ -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: • Contact },
+ ]);
+ } else {
+ navigate('/signin');
+ }
+ }, [navigate, setBreadcrumbItems]);
+
+ return (
+
+
+
+
+ );
+});
+
+export default IndexContact;
diff --git a/src/pages/contact/component/DetailContact.jsx b/src/pages/contact/component/DetailContact.jsx
new file mode 100644
index 0000000..ec0ada2
--- /dev/null
+++ b/src/pages/contact/component/DetailContact.jsx
@@ -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 (
+
+
+
+
+
+ {!props.readOnly && (
+
+ )}
+
+ ,
+ ]}
+ >
+
+
+
+ Status
+
+
+
+
+
+
+ {formData.is_active ? 'Active' : 'Inactive'}
+
+
+
+
+
+
+ Name
+ *
+
+
+
+ Phone
+ *
+
+
+
+
+ );
+});
+
+export default DetailContact;
diff --git a/src/pages/contact/component/ListContact.jsx b/src/pages/contact/component/ListContact.jsx
new file mode 100644
index 0000000..5cb789c
--- /dev/null
+++ b/src/pages/contact/component/ListContact.jsx
@@ -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 (
+
+ {
+ 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)';
+ }}
+ >
+
+ {/* Status Badge - Top Right */}
+
+ {contact.status === 'active' ? (
+
+ Active
+
+ ) : (
+
+ InActive
+
+ )}
+
+
+ {/* Main Content */}
+
+
+
+
+
+
+ {contact.name}
+
+
+
+
+ {contact.phone}
+
+
+
+
+
+ {/* Edit and Delete Buttons - Bottom Right */}
+
+
+ }
+ onClick={(e) => {
+ e.stopPropagation();
+ showEditModal(contact);
+ }}
+ >
+ Edit info
+
+ }
+ onClick={(e) => {
+ e.stopPropagation();
+ showDeleteModal(contact);
+ }}
+ >
+ Delete
+
+
+
+
+
+
+ );
+});
+
+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 (
+
+
+
+
+
+
+ {
+ const value = e.target.value;
+ setSearchValue(value);
+ if (value === '') {
+ handleSearchClear();
+ }
+ }}
+ onSearch={handleSearch}
+ allowClear={{
+ clearIcon: ✕,
+ }}
+ enterButton={
+ }
+ style={{
+ backgroundColor: '#23A55A',
+ borderColor: '#23A55A',
+ }}
+ >
+ Search
+
+ }
+ size="large"
+ />
+
+
+
+
+ }
+ onClick={() => showAddModal()}
+ size="large"
+ >
+ Add Contact
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Filter by:
+
+
+
+
+
+
+ Sort by:
+
+
+
+
+
+
+ {activeTab === 'operator' ? (
+ filteredOperators.length === 0 ? (
+
+ No operators found
+
+ ) : (
+
+ {filteredOperators.map((operator) => (
+
+ ))}
+
+ )
+ ) : filteredGudang.length === 0 ? (
+
+
+ No warehouse contacts found
+
+
+ ) : (
+
+ {filteredGudang.map((item) => (
+
+ ))}
+
+ )}
+
+
+
+
+ );
+});
+
+export default ListContact;