Untitled

 avatar
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 &ldquo;{tag.name}&rdquo;?
                      </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