Untitled
unknown
plain_text
a month ago
19 kB
8
Indexable
'use client';
import { useMemo } from 'react';
import AdminOwnerRoute from '../../AdminOwnerRoute';
import SubHeader from '../../SubHeader';
import { SpinnerWithText } from '@/app/components/Spinner';
import WarningMessage from '@/app/components/WarningMessage';
import {
useFetchJobTags,
useFetchClientTags,
useFetchAllJobTagsLink,
useFetchAllClientTagsLink,
useCreateJobTag,
useRenameJobTag,
useDeleteJobTag,
useCreateClientTag,
useRenameClientTag,
useDeleteClientTag,
} from '@/app/utils/hooks/useTags';
import TagCard from './TagCard';
const TagsSettings = () => {
const {
data: jobTags,
isLoading: jobTagsLoading,
isError: jobTagsError,
isSuccess: jobTagsSuccess,
} = useFetchJobTags();
const {
data: clientTags,
isLoading: clientTagsLoading,
isError: clientTagsError,
isSuccess: clientTagsSuccess,
} = useFetchClientTags();
const { data: jobTagLinks } = useFetchAllJobTagsLink();
const { data: clientTagLinks } = useFetchAllClientTagsLink();
const { mutateAsync: createJobTag, isLoading: isCreatingJobTag } =
useCreateJobTag();
const { mutateAsync: renameJobTag, isLoading: isRenamingJobTag } =
useRenameJobTag();
const { mutateAsync: deleteJobTag, isLoading: isDeletingJobTag } =
useDeleteJobTag();
const { mutateAsync: createClientTag, isLoading: isCreatingClientTag } =
useCreateClientTag();
const { mutateAsync: renameClientTag, isLoading: isRenamingClientTag } =
useRenameClientTag();
const { mutateAsync: deleteClientTag, isLoading: isDeletingClientTag } =
useDeleteClientTag();
const jobTagCountMap = useMemo(() => {
const map: Record<string, number> = {};
jobTagLinks?.forEach((link) => {
const tagId = (link as { job_tag_id: string }).job_tag_id;
map[tagId] = (map[tagId] ?? 0) + 1;
});
return map;
}, [jobTagLinks]);
const clientTagCountMap = useMemo(() => {
const map: Record<string, number> = {};
clientTagLinks?.forEach((link) => {
const tagId = (link as { client_tag_id: string }).client_tag_id;
map[tagId] = (map[tagId] ?? 0) + 1;
});
return map;
}, [clientTagLinks]);
const isLoading = jobTagsLoading || clientTagsLoading;
const isError = jobTagsError || clientTagsError;
const isSuccess = jobTagsSuccess && clientTagsSuccess;
return (
<AdminOwnerRoute restrictedRoles={[3]}>
<SubHeader />
{isLoading && (
<div className='flex h-full items-center justify-center'>
<SpinnerWithText size='xl' />
</div>
)}
{isError && (
<div className='flex h-full items-center justify-center'>
<WarningMessage message='There was an error fetching tags.' />
</div>
)}
{isSuccess && (
<div className='flex flex-col gap-5 p-8 xl:flex-row'>
<div className='flex w-full flex-col'>
<TagCard
title='Job Tags'
tags={jobTags ?? []}
tagCountMap={jobTagCountMap}
onAdd={(name) => createJobTag(name)}
onRename={(id, name) => renameJobTag({ id, name })}
onDelete={(id) => deleteJobTag(id)}
isAdding={isCreatingJobTag}
isRenaming={isRenamingJobTag}
isDeleting={isDeletingJobTag}
/>
</div>
<div className='flex w-full flex-col'>
<TagCard
title='Client Tags'
tags={clientTags ?? []}
tagCountMap={clientTagCountMap}
onAdd={(name) => createClientTag(name)}
onRename={(id, name) => renameClientTag({ id, name })}
onDelete={(id) => deleteClientTag(id)}
isAdding={isCreatingClientTag}
isRenaming={isRenamingClientTag}
isDeleting={isDeletingClientTag}
/>
</div>
</div>
)}
</AdminOwnerRoute>
);
};
export default TagsSettings;
-----------------------------------------------
'use client';
import { useMemo, useState } from 'react';
import { ExistingCompanyTag } from '@autopilotapp/autopilot-core';
import Card from '@/app/components/Card';
import Icon from '@/app/components/Icon/Icon';
import Button from '@/app/components/Button';
import { FormField } from '@/app/components/form-fields/FormField';
import WarningMessage from '@/app/components/WarningMessage';
import { showErrorToast } from '@/app/components/Toast';
interface TagCardProps {
title: string;
tags: ExistingCompanyTag[];
tagCountMap: Record<string, number>;
onAdd: (name: string) => Promise<unknown>;
onRename: (id: string, name: string) => Promise<unknown>;
onDelete: (id: string) => Promise<unknown>;
isAdding: boolean;
isRenaming: boolean;
isDeleting: boolean;
}
const TagCard: React.FC<TagCardProps> = ({
title,
tags,
tagCountMap,
onAdd,
onRename,
onDelete,
isAdding,
isRenaming,
isDeleting,
}) => {
const [newTagName, setNewTagName] = useState('');
const [searchValue, setSearchValue] = useState('');
const [editingId, setEditingId] = useState<string | null>(null);
const [editingName, setEditingName] = useState('');
const [deletingId, setDeletingId] = useState<string | null>(null);
const filteredTags = useMemo(() => {
if (!searchValue) return tags;
return tags.filter((t) =>
t.name.toLowerCase().includes(searchValue.toLowerCase())
);
}, [tags, searchValue]);
const handleAdd = async (e: React.FormEvent) => {
e.preventDefault();
const trimmed = newTagName.trim();
if (!trimmed) return;
const duplicate = tags.some(
(t) => t.name.toLowerCase() === trimmed.toLowerCase()
);
if (duplicate) {
showErrorToast('A tag with this name already exists');
return;
}
await onAdd(trimmed);
setNewTagName('');
};
const handleRename = async (e: React.FormEvent) => {
e.preventDefault();
if (!editingId || !editingName.trim()) return;
await onRename(editingId, editingName.trim());
setEditingId(null);
setEditingName('');
};
const handleDelete = async () => {
if (!deletingId) return;
await onDelete(deletingId);
setDeletingId(null);
};
return (
<Card className='flex h-auto w-full flex-col'>
<div className='flex items-center justify-between p-5'>
<div className='flex items-center space-x-2'>
<Icon name='tag' className='text-xl' />
<h2 className='font-medium'>{title}</h2>
</div>
<span className='rounded-full bg-neutral-300 px-3 py-1 text-sm font-medium'>
{tags.length} {tags.length === 1 ? 'tag' : 'tags'}
</span>
</div>
<hr className='h-[2px] border-neutral-300' />
<div className='flex flex-col space-y-4 p-5'>
<form className='flex items-center gap-3' onSubmit={handleAdd}>
<FormField
label='New tag'
id='new-tag'
name='new-tag'
type='text'
placeholder='Enter tag name'
value={newTagName}
onChange={(e) =>
setNewTagName((e.target as HTMLInputElement).value)
}
labelHidden
required
/>
<Button
variant='rounded'
size='sm'
type='submit'
isLoading={isAdding}
icon='plus'
>
Add
</Button>
</form>
<FormField
label='Search'
id={`search-${title}`}
name='search'
type='text'
placeholder='Search tags...'
value={searchValue}
onChange={(e) => setSearchValue((e.target as HTMLInputElement).value)}
labelHidden
leftIconURL='magnifying-glass'
/>
{filteredTags.length === 0 && (
<div className='flex justify-center py-4'>
<WarningMessage message='No tags found' />
</div>
)}
{filteredTags.length > 0 && (
<table className='w-full table-auto text-sm'>
<thead>
<tr>
<th className='border-b border-neutral-400 bg-[#F9FAFB] p-3 text-left font-medium'>
Name
</th>
<th className='border-b border-neutral-400 bg-[#F9FAFB] p-3 text-center font-medium'>
In Use
</th>
<th className='border-b border-neutral-400 bg-[#F9FAFB] p-3 text-center font-medium'>
Actions
</th>
</tr>
</thead>
<tbody>
{filteredTags.map((tag) => (
<tr
key={tag.id}
className='h-[48px] border-neutral-400 even:border-b even:bg-[#F9FAFB]'
>
{editingId === tag.id ? (
<>
<td colSpan={2} className='p-3'>
<form
id={`rename-${tag.id}`}
onSubmit={handleRename}
className='flex items-center gap-2'
>
<FormField
label='Rename'
id={`rename-input-${tag.id}`}
name='rename'
type='text'
value={editingName}
onChange={(e) =>
setEditingName(
(e.target as HTMLInputElement).value
)
}
labelHidden
required
autoFocus
/>
</form>
</td>
<td className='space-x-3 whitespace-nowrap p-3 text-center'>
<button
type='submit'
form={`rename-${tag.id}`}
disabled={isRenaming}
aria-label='Save rename'
>
<Icon
name='check'
className='text-lg text-green-600'
/>
</button>
<button
type='button'
aria-label='Cancel rename'
onClick={() => {
setEditingId(null);
setEditingName('');
}}
>
<Icon
name='xmark'
className='text-lg text-neutral-500'
/>
</button>
</td>
</>
) : deletingId === tag.id ? (
<>
<td className='p-3 text-sm text-red-500'>
Delete “{tag.name}”?
</td>
<td className='p-3 text-center text-neutral-500'>
{tagCountMap[tag.id] ?? 0}
</td>
<td className='space-x-3 whitespace-nowrap p-3 text-center'>
<button
type='button'
onClick={handleDelete}
disabled={isDeleting}
aria-label='Confirm delete'
>
<Icon
name='check'
className='text-lg text-red-500'
/>
</button>
<button
type='button'
aria-label='Cancel delete'
onClick={() => setDeletingId(null)}
>
<Icon
name='xmark'
className='text-lg text-neutral-500'
/>
</button>
</td>
</>
) : (
<>
<td className='p-3'>{tag.name}</td>
<td className='p-3 text-center'>
<span className='rounded-full bg-neutral-300 px-2 py-0.5 text-xs font-medium'>
{tagCountMap[tag.id] ?? 0}
</span>
</td>
<td className='space-x-4 whitespace-nowrap p-3 text-center'>
<button
type='button'
aria-label='Edit tag'
onClick={() => {
setEditingId(tag.id);
setEditingName(tag.name);
setDeletingId(null);
}}
>
<Icon
name='pen-to-square'
className='text-lg text-neutral-500 hover:text-primary-200'
/>
</button>
<button
type='button'
aria-label='Delete tag'
onClick={() => {
setDeletingId(tag.id);
setEditingId(null);
}}
>
<Icon
name='trash'
className='text-lg text-red-200 hover:text-red-400'
/>
</button>
</td>
</>
)}
</tr>
))}
</tbody>
</table>
)}
</div>
</Card>
);
};
export default TagCard;
------------------------------
// MARK: TAG MANAGEMENT (create/rename/delete tags without link)
export const useCreateJobTag = () => {
const companyId = useCompanyId();
const queryClient = useQueryClient();
const supabase = createSupaClient();
return useMutation(
async (name: string) => {
const { data, error } = await supabase
.from('job_tag')
.insert({ name, company_id: companyId! })
.select()
.single();
if (error) throw error;
return data;
},
{
onSuccess: () => {
queryClient.invalidateQueries(['company_job_tags']);
showSuccessToast('Job tag created');
},
onError: (error) => {
handleSupaError('Error creating job tag', error);
},
}
);
};
export const useRenameJobTag = () => {
const queryClient = useQueryClient();
const supabase = createSupaClient();
return useMutation(
async ({ id, name }: { id: string; name: string }) => {
const { error } = await supabase
.from('job_tag')
.update({ name })
.eq('id', id);
if (error) throw error;
},
{
onSuccess: () => {
queryClient.invalidateQueries(['company_job_tags']);
showSuccessToast('Job tag renamed');
},
onError: (error) => {
handleSupaError('Error renaming job tag', error);
},
}
);
};
export const useDeleteJobTag = () => {
const queryClient = useQueryClient();
const supabase = createSupaClient();
return useMutation(
async (id: string) => {
await supabase.from('job_tag_link').delete().eq('job_tag_id', id);
const { error } = await supabase.from('job_tag').delete().eq('id', id);
if (error) throw error;
},
{
onSuccess: () => {
queryClient.invalidateQueries(['company_job_tags']);
queryClient.invalidateQueries(['all_job_tags_link']);
showSuccessToast('Job tag deleted');
},
onError: (error) => {
handleSupaError('Error deleting job tag', error);
},
}
);
};
export const useCreateClientTag = () => {
const companyId = useCompanyId();
const queryClient = useQueryClient();
const supabase = createSupaClient();
return useMutation(
async (name: string) => {
const { data, error } = await supabase
.from('client_tag')
.insert({ name, company_id: companyId! })
.select()
.single();
if (error) throw error;
return data;
},
{
onSuccess: () => {
queryClient.invalidateQueries(['company_client_tags']);
showSuccessToast('Client tag created');
},
onError: (error) => {
handleSupaError('Error creating client tag', error);
},
}
);
};
export const useRenameClientTag = () => {
const queryClient = useQueryClient();
const supabase = createSupaClient();
return useMutation(
async ({ id, name }: { id: string; name: string }) => {
const { error } = await supabase
.from('client_tag')
.update({ name })
.eq('id', id);
if (error) throw error;
},
{
onSuccess: () => {
queryClient.invalidateQueries(['company_client_tags']);
showSuccessToast('Client tag renamed');
},
onError: (error) => {
handleSupaError('Error renaming client tag', error);
},
}
);
};
export const useDeleteClientTag = () => {
const queryClient = useQueryClient();
const supabase = createSupaClient();
return useMutation(
async (id: string) => {
await supabase.from('client_tag_link').delete().eq('client_tag_id', id);
const { error } = await supabase
.from('client_tag')
.delete()
.eq('id', id);
if (error) throw error;
},
{
onSuccess: () => {
queryClient.invalidateQueries(['company_client_tags']);
queryClient.invalidateQueries(['all_client_tags_link']);
showSuccessToast('Client tag deleted');
},
onError: (error) => {
handleSupaError('Error deleting client tag', error);
},
}
);
};Editor is loading...
Leave a Comment