Skip to content

Commit

Permalink
Merge pull request #5 from Nine-Minds/contacts_delete_button
Browse files Browse the repository at this point in the history
Contacts delete button
  • Loading branch information
NatalliaBukhtsik authored Nov 5, 2024
2 parents 98305e1 + 4f78c10 commit 676c99a
Show file tree
Hide file tree
Showing 11 changed files with 421 additions and 70 deletions.
27 changes: 27 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@
"sharp": "^0.33.5",
"spawn-sync": "^2.0.0",
"speakeasy": "^2.0.0",
"tinycolor2": "^1.6.0",
"turndown": "^7.2.0",
"uuid": "^10.0.0",
"winston": "^3.13.1",
Expand All @@ -104,6 +105,7 @@
"@types/react-big-calendar": "^1.8.9",
"@types/react-dom": "^18",
"@types/speakeasy": "^2.0.10",
"@types/tinycolor2": "^1.4.6",
"@types/turndown": "^5.0.5",
"@types/uuid": "^10.0.0",
"@typescript-eslint/eslint-plugin": "^8.8.0",
Expand Down
2 changes: 0 additions & 2 deletions server/config.ini

This file was deleted.

187 changes: 168 additions & 19 deletions server/src/components/contacts/Contacts.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
// server/src/components/Contacts.tsx
'use client';

import React, { useState, useEffect, useRef } from 'react';
import { IContact } from '@/interfaces/contact.interfaces';
import { ICompany } from '@/interfaces/company.interfaces';
import { ITag } from '@/interfaces/tag.interfaces';
import { getAllContacts, getContactsByCompany, getAllCompanies, exportContactsToCSV } from '@/lib/actions/contact-actions/contactActions';
import { getAllContacts, getContactsByCompany, getAllCompanies, exportContactsToCSV, deleteContact } from '@/lib/actions/contact-actions/contactActions';
import { findTagsByEntityIds, createTag, deleteTag, findAllTagsByType } from '@/lib/actions/tagActions';
import { Button } from '@/components/ui/Button';
import { Pen, Eye, CloudDownload, MoreVertical, Upload, Search } from 'lucide-react';
import { Pen, Eye, CloudDownload, MoreVertical, Upload, Search, Trash2 } from 'lucide-react';
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import { QuickAddContact } from './QuickAddContact';
import { useDrawer } from '@/context/DrawerContext';
Expand All @@ -19,7 +18,9 @@ import CompanyDetails from '../companies/CompanyDetails';
import { DataTable } from '@/components/ui/DataTable';
import { ColumnDefinition } from '@/interfaces/dataTable.interfaces';
import { TagManager, TagFilter } from '@/components/tags';
import { getUniqueTagTexts } from '@/utils/tagUtils';
import { getUniqueTagTexts } from '@/utils/colorUtils';
import { getAvatarUrl } from '@/utils/colorUtils';
import GenericDialog from '@/components/ui/GenericDialog';

interface ContactsProps {
initialContacts: IContact[];
Expand All @@ -40,6 +41,9 @@ const Contacts: React.FC<ContactsProps> = ({ initialContacts, companyId, preSele
const { openDrawer } = useDrawer();
const contactTagsRef = useRef<Record<string, ITag[]>>({});
const [allUniqueTags, setAllUniqueTags] = useState<string[]>([]);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [contactToDelete, setContactToDelete] = useState<IContact | null>(null);
const [deleteError, setDeleteError] = useState<string | null>(null);

useEffect(() => {
const fetchData = async () => {
Expand Down Expand Up @@ -83,12 +87,9 @@ const Contacts: React.FC<ContactsProps> = ({ initialContacts, companyId, preSele
setAllUniqueTags(getUniqueTagTexts(Object.values(contactTagsRef.current).flat()));
};

const getAvatarUrl = (contact: IContact) => {
// Using contact_name_id to generate a consistent background color
const backgroundColors = ['0D8ABC', '7C3AED', '059669', 'DC2626', 'D97706'];
const index = Math.abs(contact.contact_name_id.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0)) % backgroundColors.length;
return `https://ui-avatars.com/api/?name=${encodeURIComponent(contact.full_name)}&background=${backgroundColors[index]}&color=ffffff`;
};
const getContactAvatar = (contact: IContact) => {
return getAvatarUrl(contact.full_name, contact.contact_name_id);
};

const getCompanyName = (companyId: string) => {
const company = companies.find(c => c.company_id === companyId);
Expand Down Expand Up @@ -139,6 +140,92 @@ const Contacts: React.FC<ContactsProps> = ({ initialContacts, companyId, preSele
);
};

const handleDeleteContact = (contact: IContact) => {
setContactToDelete(contact);
setDeleteError(null);
setIsDeleteDialogOpen(true);
};

const confirmDelete = async () => {
if (!contactToDelete) return;

try {
const result = await deleteContact(contactToDelete.contact_name_id);

if (!result.success) {
if ('code' in result && result.code === 'CONTACT_HAS_DEPENDENCIES' && 'dependencies' in result && 'counts' in result) {
const dependencies = result.dependencies || [];
const counts = result.counts || {};
const dependencyText = dependencies.map((dep: string): string => {
const count = counts[dep] || 0;
const readableTypes: Record<string, string> = {
'ticket': 'active tickets',
'interaction': 'interactions',
'project': 'active projects',
'document': 'documents',
'timeEntry': 'time entries'
};
return `${count} ${readableTypes[dep] || `${dep}s`}`;
}).join(', ');

setDeleteError(
`This contact cannot be deleted because it has the following associated records: ${dependencyText}. ` +
`To maintain data integrity, you can edit the contact and set its status to inactive instead.`
);
return;
}
if ('message' in result) {
throw new Error(result.message);
}
throw new Error('Failed to delete contact');
}

setContacts(prevContacts =>
prevContacts.filter(c => c.contact_name_id !== contactToDelete.contact_name_id)
);

setIsDeleteDialogOpen(false);
setContactToDelete(null);
setDeleteError(null);
} catch (error) {
console.error('Error deleting contact:', error);
setDeleteError('An error occurred while deleting the contact. Please try again.');
}
};

const handleMakeInactive = async () => {
if (!contactToDelete) return;

try {
const response = await fetch(`/api/contacts/${contactToDelete.contact_name_id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ is_inactive: true }),
});

if (!response.ok) {
throw new Error('Failed to update contact status');
}

setContacts(prevContacts =>
prevContacts.map((c): IContact =>
c.contact_name_id === contactToDelete.contact_name_id
? { ...c, is_inactive: true }
: c
)
);

setIsDeleteDialogOpen(false);
setContactToDelete(null);
setDeleteError(null);
} catch (error) {
console.error('Error updating contact:', error);
setDeleteError('An error occurred while updating the contact status. Please try again.');
}
};

const handleAddTag = async (contactId: string, tagText: string): Promise<ITag | undefined> => {
if (!tagText.trim()) return undefined;
try {
Expand Down Expand Up @@ -226,8 +313,9 @@ const Contacts: React.FC<ContactsProps> = ({ initialContacts, companyId, preSele
<div className="flex items-center">
<img
className="h-8 w-8 rounded-full mr-2"
src={getAvatarUrl(record)}
src={getAvatarUrl(value, record.contact_name_id, 32)}
alt={`${value} avatar`}
loading="lazy"
/>
<button
onClick={() => handleViewDetails(record)}
Expand Down Expand Up @@ -296,6 +384,13 @@ const Contacts: React.FC<ContactsProps> = ({ initialContacts, companyId, preSele
<Pen size={14} className="mr-2" />
Edit
</DropdownMenu.Item>
<DropdownMenu.Item
className="px-2 py-1 text-sm cursor-pointer hover:bg-gray-100 flex items-center text-red-600"
onSelect={() => handleDeleteContact(record)}
>
<Trash2 size={14} className="mr-2" />
Delete
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
),
Expand Down Expand Up @@ -384,14 +479,16 @@ const Contacts: React.FC<ContactsProps> = ({ initialContacts, companyId, preSele
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
<DataTable
data={filteredContacts}
columns={columns}
pagination={true}
currentPage={currentPage}
onPageChange={handlePageChange}
pageSize={10}
/>
<DataTable
data={filteredContacts.map((contact): IContact => ({
...contact
}))}
columns={columns}
pagination={true}
currentPage={currentPage}
onPageChange={handlePageChange}
pageSize={10}
/>
</div>
<QuickAddContact
isOpen={isQuickAddOpen}
Expand All @@ -407,6 +504,58 @@ const Contacts: React.FC<ContactsProps> = ({ initialContacts, companyId, preSele
onImportComplete={handleImportComplete}
companies={companies}
/>

{/* Delete Confirmation Dialog */}
<GenericDialog
isOpen={isDeleteDialogOpen}
onClose={() => {
setIsDeleteDialogOpen(false);
setContactToDelete(null);
setDeleteError(null);
}}
title="Delete Contact"
>
<div className="p-6">
{deleteError ? (
<>
<p className="mb-4 text-red-600">{deleteError}</p>
<div className="flex justify-end">
<button
onClick={() => {
setIsDeleteDialogOpen(false);
setContactToDelete(null);
setDeleteError(null);
}}
className="px-4 py-2 text-sm font-medium text-gray-600 bg-gray-100 rounded"
>
Close
</button>
</div>
</>
) : (
<>
<p className="mb-4">Are you sure you want to delete this contact? This action cannot be undone.</p>
<div className="flex justify-end gap-4">
<button
onClick={() => {
setIsDeleteDialogOpen(false);
setContactToDelete(null);
}}
className="px-4 py-2 text-sm font-medium text-gray-600 bg-gray-100 rounded"
>
Cancel
</button>
<button
onClick={confirmDelete}
className="px-4 py-2 text-sm font-medium text-white bg-red-600 rounded"
>
Delete
</button>
</div>
</>
)}
</div>
</GenericDialog>
</div>
);
};
Expand Down
7 changes: 4 additions & 3 deletions server/src/components/tags/TagFilter.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import React, { useState } from 'react';
import { Tag as TagIcon } from 'lucide-react';
import { Input } from '@/components/ui/Input';
import * as Popover from '@radix-ui/react-popover';
import { TagGrid } from './TagGrid';
import { filterTagsByText } from '@/utils/tagUtils';
import { filterTagsByText } from '@/utils/colorUtils';

interface TagFilterProps {
allTags: string[];
Expand Down Expand Up @@ -35,12 +36,12 @@ export const TagFilter: React.FC<TagFilterProps> = ({
</Popover.Trigger>
<Popover.Content className="bg-white rounded-lg shadow-lg border border-gray-200 w-72">
<div className="p-2">
<input
<Input
type="text"
placeholder="Search tags"
className="w-full border border-gray-300 rounded-md p-2 mb-2"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="mb-2"
/>
<TagGrid
tags={filteredTags}
Expand Down
4 changes: 2 additions & 2 deletions server/src/components/tags/TagGrid.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from 'react';
import { generateTagColor } from '@/utils/tagUtils';
import { generateEntityColor } from '@/utils/colorUtils';

interface TagGridProps {
tags: string[];
Expand All @@ -18,7 +18,7 @@ export const TagGrid: React.FC<TagGridProps> = ({
<div className={`grid grid-cols-3 gap-2 p-2 max-h-60 overflow-y-auto ${className}`}>
{tags.map((tag):JSX.Element => {
const isSelected = selectedTags.includes(tag);
const colors = generateTagColor(tag);
const colors = generateEntityColor(tag);
return (
<button
key={tag}
Expand Down
4 changes: 2 additions & 2 deletions server/src/components/tags/TagInput.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React, { useState, useEffect, useRef } from 'react';
import { Plus } from 'lucide-react';
import { generateTagColor } from '@/utils/tagUtils';
import { generateEntityColor } from '@/utils/colorUtils';

interface TagInputProps {
existingTags: string[];
Expand Down Expand Up @@ -115,7 +115,7 @@ export const TagInput: React.FC<TagInputProps> = ({
{suggestions.length > 0 && (
<div className="absolute z-10 mt-1 w-48 bg-white border border-gray-200 rounded-md shadow-lg top-full">
{suggestions.map((suggestion, index): JSX.Element => {
const colors = generateTagColor(suggestion);
const colors = generateEntityColor(suggestion);
return (
<button
key={index}
Expand Down
Loading

0 comments on commit 676c99a

Please sign in to comment.