Add contact management functionality with CRUD operations and UI enhancements

This commit is contained in:
2025-11-15 13:36:38 +07:00
parent 7dd38aa50c
commit 8cf5878d46
4 changed files with 204 additions and 171 deletions

56
src/api/contact.jsx Normal file
View File

@@ -0,0 +1,56 @@
import { SendRequest } from '../components/Global/ApiRequest';
const getAllContact = async (queryParams) => {
const response = await SendRequest({
method: 'get',
prefix: `contact?${queryParams.toString()}`,
});
return response.data;
};
const getContactById = async (id) => {
const response = await SendRequest({
method: 'get',
prefix: `contact/${id}`,
});
return response.data;
};
const createContact = async (queryParams) => {
const response = await SendRequest({
method: 'post',
prefix: `contact`,
params: queryParams,
});
return response.data;
};
const updateContact = async (id, queryParams) => {
const response = await SendRequest({
method: 'put',
prefix: `contact/${id}`,
params: queryParams,
});
return response.data;
};
const deleteContact = async (id) => {
const response = await SendRequest({
method: 'delete',
prefix: `contact/${id}`,
});
return response.data;
};
export {
getAllContact,
getContactById,
createContact,
updateContact,
deleteContact,
};

View File

@@ -15,6 +15,7 @@ const IndexContact = memo(function IndexContact() {
const [selectedData, setSelectedData] = useState(null);
const [readOnly, setReadOnly] = useState(false);
const [showModal, setShowModal] = useState(false);
const [contactType, setContactType] = useState('operator');
const setMode = (param) => {
setShowModal(param !== 'list');
@@ -52,6 +53,7 @@ const IndexContact = memo(function IndexContact() {
setSelectedData={setSelectedData}
readOnly={readOnly}
lastSavedContact={lastSavedContact}
setContactType={setContactType}
/>
<DetailContact
setActionMode={setMode}
@@ -61,6 +63,7 @@ const IndexContact = memo(function IndexContact() {
showModal={showModal}
actionMode={actionMode}
onContactSaved={handleContactSaved}
contactType={contactType}
/>
</React.Fragment>
);

View File

@@ -1,7 +1,8 @@
import React, { memo, useEffect, useState } from 'react';
import { Modal, Input, Button, Switch, ConfigProvider, Typography, Divider } from 'antd';
import { Modal, Input, Button, Switch, ConfigProvider, Typography, Divider, Select } from 'antd';
import { NotifAlert, NotifOk } from '../../../components/Global/ToastNotif';
import { validateRun } from '../../../Utils/validate';
import { createContact, updateContact } from '../../../api/contact';
const { Text } = Typography;
@@ -13,6 +14,7 @@ const DetailContact = memo(function DetailContact(props) {
name: '',
phone: '',
is_active: true,
contact_type: 'operator',
};
const [formData, setFormData] = useState(defaultData);
@@ -26,6 +28,10 @@ const DetailContact = memo(function DetailContact(props) {
} else if (e && e.type === 'change') {
name = e.name || e.target?.name;
value = e.value !== undefined ? e.value : e.checked;
} else if (typeof e === 'string' || typeof e === 'number') {
// Handle Select onChange
value = e;
name = 'contact_type';
} else {
return;
}
@@ -53,12 +59,13 @@ const DetailContact = memo(function DetailContact(props) {
const handleSave = async () => {
setConfirmLoading(true);
// Custom validation untuk phone
if (formData.phone && !/^[\d\s\+\-\(\)]+$/.test(formData.phone)) {
// Custom validation untuk phone - Indonesian phone format
const phoneRegex = /^(?:\+62|0)8\d{7,10}$/;
if (formData.phone && !phoneRegex.test(formData.phone.replace(/[\s\-\(\)]/g, ''))) {
NotifOk({
icon: 'warning',
title: 'Peringatan',
message: 'Nomor telepon hanya boleh mengandung angka, spasi, +, -, dan ()',
message: 'Nomor telepon harus format Indonesia (+628XXXXXXXXX atau 08XXXXXXXXX)',
});
setConfirmLoading(false);
return;
@@ -68,6 +75,7 @@ const DetailContact = memo(function DetailContact(props) {
const validationRules = [
{ field: 'name', label: 'Contact Name', required: true },
{ field: 'phone', label: 'Phone', required: true },
{ field: 'contact_type', label: 'Contact Type', required: true },
];
if (
@@ -80,15 +88,18 @@ const DetailContact = memo(function DetailContact(props) {
try {
const contactData = {
id: props.selectedData?.id || null,
name: formData.name,
phone: formData.phone,
contact_name: formData.name,
contact_phone: formData.phone.replace(/[\s\-\(\)]/g, ''), // Clean phone number
is_active: formData.is_active,
status: formData.is_active ? 'active' : 'inactive',
contact_type: formData.contact_type,
};
console.log('Saving contact data:', contactData);
await new Promise((resolve) => setTimeout(resolve, 1000));
let response;
if (props.actionMode === 'edit') {
response = await updateContact(props.selectedData.contact_id || props.selectedData.id, contactData);
} else {
response = await createContact(contactData);
}
NotifAlert({
icon: 'success',
@@ -98,14 +109,14 @@ const DetailContact = memo(function DetailContact(props) {
}.`,
});
props.onContactSaved?.(contactData, props.actionMode);
props.onContactSaved?.(response.data, props.actionMode);
handleCancel();
} catch (error) {
console.error('Save failed:', error);
NotifAlert({
icon: 'error',
title: 'Error',
message: 'Terjadi kesalahan saat menyimpan data.',
message: error.response?.data?.message || 'Terjadi kesalahan saat menyimpan data.',
});
} finally {
setConfirmLoading(false);
@@ -121,19 +132,21 @@ const DetailContact = memo(function DetailContact(props) {
if (props.showModal) {
if (props.actionMode === 'edit' && props.selectedData) {
setFormData({
name: props.selectedData.name,
phone: props.selectedData.phone,
is_active: props.selectedData.status === 'active',
name: props.selectedData.contact_name || props.selectedData.name,
phone: props.selectedData.contact_phone || props.selectedData.phone,
is_active: props.selectedData.is_active || props.selectedData.status === 'active',
contact_type: props.selectedData.contact_type || props.contactType || 'operator',
});
} else if (props.actionMode === 'add') {
setFormData({
name: '',
phone: '',
is_active: true,
contact_type: props.contactType === 'all' ? 'operator' : props.contactType || 'operator',
});
}
}
}, [props.showModal, props.actionMode, props.selectedData]);
}, [props.showModal, props.actionMode, props.selectedData, props.contactType]);
return (
<Modal
@@ -228,6 +241,20 @@ const DetailContact = memo(function DetailContact(props) {
style={{ color: formData.is_active ? '#000000' : '#ff4d4f' }}
/>
</div>
<div style={{ marginBottom: 12 }}>
<Text strong>Contact Type</Text>
<Text style={{ color: 'red' }}> *</Text>
<Select
value={formData.contact_type}
onChange={handleInputChange}
placeholder="Select Contact Type"
disabled={props.readOnly}
style={{ width: '100%' }}
>
<Select.Option value="operator">Operator</Select.Option>
<Select.Option value="gudang">Gudang</Select.Option>
</Select>
</div>
</div>
</Modal>
);

View File

@@ -1,5 +1,5 @@
import React, { memo, useState, useEffect } from 'react';
import { Button, Row, Col, Input, Tabs, Space, ConfigProvider, Card, Tag, Select } from 'antd';
import { Button, Row, Col, Input, Tabs, Space, ConfigProvider, Card, Tag } from 'antd';
import {
PlusOutlined,
EditOutlined,
@@ -10,24 +10,10 @@ import {
} from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { NotifAlert, NotifConfirmDialog } from '../../../components/Global/ToastNotif';
import { getAllContact, deleteContact } from '../../../api/contact';
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}>
@@ -110,7 +96,7 @@ const ContactCard = memo(function ContactCard({ contact, showEditModal, showDele
whiteSpace: 'nowrap',
}}
>
{contact.name}
{contact.contact_name || contact.name}
</div>
<div
style={{
@@ -125,7 +111,7 @@ const ContactCard = memo(function ContactCard({ contact, showEditModal, showDele
color: contact.status === 'active' ? '#52c41a' : '#ff4d4f',
}}
>
{contact.phone}
{contact.contact_phone || contact.phone}
</span>
</div>
</div>
@@ -178,77 +164,81 @@ const ContactCard = memo(function ContactCard({ contact, showEditModal, showDele
});
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 [activeTab, setActiveTab] = useState('all');
const [filteredContacts, setFilteredContacts] = useState([]);
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
// Listen for saved contact data
useEffect(() => {
if (props.lastSavedContact) {
handleContactSaved(
props.lastSavedContact.contactData,
props.lastSavedContact.actionMode
);
}
}, [props.lastSavedContact]);
// Default filter object matching plantSection pattern
const defaultFilter = { criteria: '' };
const [formDataFilter, setFormDataFilter] = useState(defaultFilter);
// 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
);
// Fetch contacts from API
const fetchContacts = async () => {
setLoading(true);
try {
// Build search parameters matching database pattern
const searchParams = { ...formDataFilter };
// Add specific filters if not "all"
if (activeTab !== 'all') {
if (activeTab === 'operator') {
searchParams.code = 'operator';
} else if (activeTab === 'gudang') {
searchParams.code = 'gudang';
}
}
return contacts;
};
if (activeTab === 'operator') {
setOperators(updateContacts(operators));
} else {
setGudang(updateContacts(gudang));
// Backend doesn't support is_active filter or order parameter
// Contact hanya supports: criteria, name, code, limit, page
const queryParams = new URLSearchParams();
Object.entries(searchParams).forEach(([key, value]) => {
if (value !== '' && value !== null && value !== undefined) {
queryParams.append(key, value);
}
});
const response = await getAllContact(queryParams);
setFilteredContacts(response.data || []);
} catch (error) {
console.error('Error fetching contacts:', error);
NotifAlert({
icon: 'error',
title: 'Error',
message: 'Gagal memuat data kontak',
});
} finally {
setLoading(false);
}
};
// Fetch contacts on component mount
useEffect(() => {
const token = localStorage.getItem('token');
if (!token) {
navigate('/signin');
return;
}
fetchContacts();
}, []);
// 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;
});
};
// Refetch when filters change
useEffect(() => {
setFilteredOperators(filterAndSort(operators));
setFilteredGudang(filterAndSort(gudang));
}, [searchValue, sortBy, filterBy, operators, gudang]);
fetchContacts();
}, [formDataFilter, activeTab]);
// Listen for saved contact data
useEffect(() => {
if (props.lastSavedContact) {
fetchContacts(); // Refetch all contacts when data is saved
}
}, [props.lastSavedContact]);
// Get contacts (already filtered by backend)
const getFilteredContacts = () => {
return filteredContacts;
};
const showEditModal = (param) => {
props.setSelectedData(param);
@@ -258,41 +248,40 @@ const ListContact = memo(function ListContact(props) {
const showAddModal = () => {
props.setSelectedData(null);
props.setActionMode('add');
// Pass the current active tab to determine contact type
props.setContactType?.(activeTab);
};
const showDeleteModal = (contact) => {
NotifConfirmDialog({
icon: 'question',
title: 'Konfirmasi Hapus',
message: `Kontak "${contact.name}" akan dihapus?`,
message: `Kontak "${contact.contact_name || 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);
const handleDelete = async (contact) => {
try {
await deleteContact(contact.contact_id || contact.id);
NotifAlert({
icon: 'success',
title: 'Berhasil',
message: `Kontak "${contact.contact_name || contact.name}" berhasil dihapus.`,
});
// Refetch contacts after deletion
fetchContacts();
} catch (error) {
console.error('Error deleting contact:', error);
NotifAlert({
icon: 'error',
title: 'Error',
message: 'Gagal menghapus kontak',
});
}
NotifAlert({
icon: 'success',
title: 'Berhasil',
message: `Kontak "${contact.name}" berhasil dihapus.`,
});
};
const handleSearch = (value) => {
setSearchValue(value);
};
const handleSearchClear = () => {
setSearchValue('');
};
return (
<React.Fragment>
@@ -302,18 +291,18 @@ const ListContact = memo(function ListContact(props) {
<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}
placeholder="Search by name or type..."
value={formDataFilter.criteria}
onChange={(e) => {
const value = e.target.value;
setSearchValue(value);
setFormDataFilter({ criteria: value });
if (value === '') {
handleSearchClear();
setFormDataFilter(defaultFilter);
}
}}
onSearch={handleSearch}
onSearch={(value) => setFormDataFilter({ criteria: value })}
allowClear={{
clearIcon: <span onClick={handleSearchClear}></span>,
clearIcon: <span onClick={() => setFormDataFilter(defaultFilter)}></span>,
}}
enterButton={
<Button
@@ -365,73 +354,31 @@ const ListContact = memo(function ListContact(props) {
}}
>
<Tabs activeKey={activeTab} onChange={setActiveTab} size="large">
<TabPane tab="All" key="all" />
<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 ? (
{getFilteredContacts().length === 0 ? (
<div style={{ textAlign: 'center', padding: '40px' }}>
<span style={{ color: '#8c8c8c' }}>
No warehouse contacts found
{loading ? 'Loading contacts...' : 'No contacts found'}
</span>
</div>
) : (
<Row gutter={[16, 16]}>
{filteredGudang.map((item) => (
{getFilteredContacts().map((contact) => (
<ContactCard
key={item.id}
contact={item}
key={contact.contact_id || contact.id}
contact={{
...contact,
id: contact.contact_id || contact.id,
name: contact.contact_name || contact.name,
phone: contact.contact_phone || contact.phone,
status: contact.is_active ? 'active' : 'inactive'
}}
showEditModal={showEditModal}
showDeleteModal={showDeleteModal}
/>