{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "atomic-crm",
  "title": "Atomic CRM",
  "description": "A fully fledged CRM application built with Shadcn Admin Kit",
  "dependencies": [
    "@hello-pangea/dnd@^18.0.1",
    "@nivo/bar@^0.99.0",
    "faker@^5.5.3",
    "jsonexport@^3.2.0",
    "papaparse@^5.5.3",
    "ra-supabase-core@^3.5.1",
    "ra-supabase-language-english@^3.5.0",
    "zod@^4.0.5",
    "@tanstack/query-async-storage-persister@^5.90.22",
    "@tanstack/react-query-persist-client@^5.90.22",
    "dompurify@^3.3.1",
    "marked@^17.0.1",
    "@streamparser/json-whatwg@^0.0.22",
    "mime@^4.1.0"
  ],
  "registryDependencies": [
    "item",
    "progress",
    "tabs",
    "toggle",
    "toggle-group",
    "https://marmelab.com/shadcn-admin-kit/r/admin.json"
  ],
  "files": [
    {
      "path": "src/components/atomic-crm/types.ts",
      "content": "import type { Identifier, RaRecord } from \"ra-core\";\nimport type { ComponentType } from \"react\";\n\nimport type {\n  COMPANY_CREATED,\n  CONTACT_CREATED,\n  CONTACT_NOTE_CREATED,\n  DEAL_CREATED,\n  DEAL_NOTE_CREATED,\n} from \"./consts\";\n\nexport type SignUpData = {\n  email: string;\n  password: string;\n  first_name: string;\n  last_name: string;\n};\n\nexport type SalesFormData = {\n  avatar?: string;\n  email: string;\n  password?: string;\n  first_name: string;\n  last_name: string;\n  administrator: boolean;\n  disabled: boolean;\n};\n\nexport type Sale = {\n  first_name: string;\n  last_name: string;\n  administrator: boolean;\n  avatar?: RAFile;\n  disabled?: boolean;\n  user_id: string;\n\n  /**\n   * This is a copy of the user's email, to make it easier to handle by react admin\n   * DO NOT UPDATE this field directly, it should be updated by the backend\n   */\n  email: string;\n\n  /**\n   * This is used by the fake rest provider to store the password\n   * DO NOT USE this field in your code besides the fake rest provider\n   * @deprecated\n   */\n  password?: string;\n} & Pick<RaRecord, \"id\">;\n\nexport type Company = {\n  name: string;\n  logo: RAFile;\n  sector: string;\n  size: 1 | 10 | 50 | 250 | 500;\n  linkedin_url: string;\n  website: string;\n  phone_number: string;\n  address: string;\n  zipcode: string;\n  city: string;\n  state_abbr: string;\n  sales_id?: Identifier;\n  created_at: string;\n  description: string;\n  revenue: string;\n  tax_identifier: string;\n  country: string;\n  context_links?: string[];\n  nb_contacts?: number;\n  nb_deals?: number;\n} & Pick<RaRecord, \"id\">;\n\nexport type EmailAndType = {\n  email: string;\n  type: \"Work\" | \"Home\" | \"Other\";\n};\n\nexport type PhoneNumberAndType = {\n  number: string;\n  type: \"Work\" | \"Home\" | \"Other\";\n};\n\nexport type Contact = {\n  first_name: string;\n  last_name: string;\n  title: string;\n  company_id?: Identifier | null;\n  email_jsonb: EmailAndType[];\n  avatar?: Partial<RAFile>;\n  linkedin_url?: string | null;\n  first_seen: string;\n  last_seen: string;\n  has_newsletter: boolean;\n  tags: number[];\n  gender: string;\n  sales_id?: Identifier;\n  status: string;\n  background: string;\n  phone_jsonb: PhoneNumberAndType[];\n  nb_tasks?: number;\n  company_name?: string;\n} & Pick<RaRecord, \"id\">;\n\nexport type ContactNote = {\n  contact_id: Identifier;\n  text: string;\n  date: string;\n  sales_id: Identifier;\n  status: string;\n  attachments?: AttachmentNote[];\n} & Pick<RaRecord, \"id\">;\n\nexport type Deal = {\n  name: string;\n  company_id: Identifier;\n  contact_ids: Identifier[];\n  category: string;\n  stage: string;\n  description: string;\n  amount: number;\n  created_at: string;\n  updated_at: string;\n  archived_at?: string;\n  expected_closing_date: string;\n  sales_id: Identifier;\n  index: number;\n} & Pick<RaRecord, \"id\">;\n\nexport type DealNote = {\n  deal_id: Identifier;\n  text: string;\n  date: string;\n  sales_id: Identifier;\n  attachments?: AttachmentNote[];\n\n  // This is defined for compatibility with `ContactNote`\n  status?: undefined;\n} & Pick<RaRecord, \"id\">;\n\nexport type Tag = {\n  id: number;\n  name: string;\n  color: string;\n};\n\nexport type Task = {\n  contact_id: Identifier;\n  type: string;\n  text: string;\n  due_date: string;\n  done_date?: string | null;\n  sales_id?: Identifier;\n} & Pick<RaRecord, \"id\">;\n\nexport type ActivityCompanyCreated = {\n  type: typeof COMPANY_CREATED;\n  company_id: Identifier;\n  company: Company;\n  sales_id: Identifier;\n  date: string;\n} & Pick<RaRecord, \"id\">;\n\nexport type ActivityContactCreated = {\n  type: typeof CONTACT_CREATED;\n  company_id: Identifier;\n  sales_id?: Identifier;\n  contact: Contact;\n  date: string;\n} & Pick<RaRecord, \"id\">;\n\nexport type ActivityContactNoteCreated = {\n  type: typeof CONTACT_NOTE_CREATED;\n  sales_id?: Identifier;\n  contactNote: ContactNote;\n  date: string;\n} & Pick<RaRecord, \"id\">;\n\nexport type ActivityDealCreated = {\n  type: typeof DEAL_CREATED;\n  company_id: Identifier;\n  sales_id?: Identifier;\n  deal: Deal;\n  date: string;\n};\n\nexport type ActivityDealNoteCreated = {\n  type: typeof DEAL_NOTE_CREATED;\n  sales_id?: Identifier;\n  dealNote: DealNote;\n  date: string;\n};\n\nexport type Activity = RaRecord &\n  (\n    | ActivityCompanyCreated\n    | ActivityContactCreated\n    | ActivityContactNoteCreated\n    | ActivityDealCreated\n    | ActivityDealNoteCreated\n  );\n\nexport interface RAFile {\n  src: string;\n  title: string;\n  path?: string;\n  rawFile: File;\n  type?: string;\n}\n\nexport type AttachmentNote = RAFile;\n\nexport interface LabeledValue {\n  value: string;\n  label: string;\n}\n\nexport type DealStage = LabeledValue;\n\nexport interface NoteStatus extends LabeledValue {\n  color: string;\n}\n\nexport interface ContactGender {\n  value: string;\n  label: string;\n  icon: ComponentType<{ className?: string }>;\n}\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/consts.ts",
      "content": "export const COMPANY_CREATED = \"company.created\" as const;\nexport const CONTACT_CREATED = \"contact.created\" as const;\nexport const CONTACT_NOTE_CREATED = \"contactNote.created\" as const;\nexport const DEAL_CREATED = \"deal.created\" as const;\nexport const DEAL_NOTE_CREATED = \"dealNote.created\" as const;\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/tasks/tasksPredicate.ts",
      "content": "import { startOfToday } from \"date-fns/startOfToday\";\nimport { endOfToday } from \"date-fns/endOfToday\";\nimport { endOfTomorrow } from \"date-fns/endOfTomorrow\";\nimport { endOfWeek } from \"date-fns/endOfWeek\";\n\nimport { getDay, isAfter } from \"date-fns\";\n\nexport const isBeforeFriday = () => getDay(new Date()) < 5; // Friday is represented by 5\n\ntype Task = {\n  due_date: string;\n  done_date: string | null;\n};\n\nexport const isDone = (task: Task) => task.done_date != null;\n\n// A task is recently done if it was marked as done less than 5 minutes ago\n// useful to keep recently done tasks in the list to avoid flickering when a task is marked as done while the user is consulting the list of tasks. It gives a chance to the user to see that the task was marked as done and then it will disappear after 5 minutes.\nexport const isRecentlyDone = (task: Task) =>\n  task.done_date != null &&\n  isAfter(new Date(task.done_date), new Date(Date.now() - 5 * 60 * 1000));\n\nexport const isOverdue = (dateString: string) => {\n  return new Date(dateString) < startOfToday();\n};\n\nexport const isDueToday = (dateString: string) => {\n  const dueDate = new Date(dateString);\n  return dueDate >= startOfToday() && dueDate < endOfToday();\n};\n\nexport const isDueTomorrow = (dateString: string) => {\n  const dueDate = new Date(dateString);\n  return dueDate >= endOfToday() && dueDate < endOfTomorrow();\n};\n\nexport const isDueThisWeek = (dateString: string) => {\n  const dueDate = new Date(dateString);\n  return (\n    dueDate >= endOfTomorrow() &&\n    dueDate < endOfWeek(new Date(), { weekStartsOn: 0 })\n  );\n};\n\nexport const isDueLater = (dateString: string) => {\n  const dueDate = new Date(dateString);\n  return dueDate >= endOfWeek(new Date(), { weekStartsOn: 0 });\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/tasks/TasksListFilter.tsx",
      "content": "import {\n  ListContextProvider,\n  ResourceContextProvider,\n  useList,\n  useTranslate,\n} from \"ra-core\";\n\nimport { TasksIterator } from \"./TasksIterator\";\n\ntype TaskListProps = {\n  tasks: any[];\n  title: string;\n  showContact?: boolean;\n  isMobile: boolean;\n};\n\nexport const TaskListFilter = ({\n  tasks,\n  title,\n  showContact,\n  isMobile,\n}: TaskListProps) => {\n  const translate = useTranslate();\n  const listContext = useList({\n    data: tasks,\n    resource: \"tasks\",\n    perPage: isMobile ? 10 : 5,\n  });\n\n  const { total } = listContext;\n\n  if (!tasks?.length || !total) return null;\n\n  return (\n    <div className=\"flex flex-col gap-2\">\n      <p className=\"text-xs uppercase tracking-wider text-muted-foreground font-medium mb-2\">\n        {title}\n      </p>\n      <ResourceContextProvider value=\"tasks\">\n        <ListContextProvider value={listContext}>\n          <TasksIterator showContact={showContact} />\n        </ListContextProvider>\n      </ResourceContextProvider>\n      {total > listContext.perPage && (\n        <div className=\"flex justify-center\">\n          <a\n            href=\"#\"\n            onClick={(e) => {\n              listContext.setPerPage(listContext.perPage + 10);\n              e.preventDefault();\n            }}\n            className=\"text-sm underline hover:no-underline\"\n          >\n            {translate(\"crm.common.load_more\")}\n          </a>\n        </div>\n      )}\n    </div>\n  );\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/tasks/TasksListContent.tsx",
      "content": "import { TasksListByDueDate } from \"./TasksListByDueDate\";\nimport { useTranslate } from \"ra-core\";\n\nexport const TasksListContent = () => {\n  const translate = useTranslate();\n  return (\n    <div className=\"flex flex-col gap-4\">\n      <TasksListByDueDate\n        emptyPlaceholder={\n          <p className=\"text-sm\">\n            {translate(\"resources.tasks.empty_list_hint\")}\n          </p>\n        }\n      />\n    </div>\n  );\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/tasks/TasksListByDueDate.tsx",
      "content": "import { useMemo } from \"react\";\nimport {\n  type Identifier,\n  useGetIdentity,\n  useGetList,\n  useTimeout,\n  useTranslate,\n} from \"ra-core\";\nimport { useIsMobile } from \"@/hooks/use-mobile\";\n\nimport { TaskListFilter } from \"./TasksListFilter\";\nimport {\n  isBeforeFriday,\n  isDone,\n  isDueLater,\n  isDueThisWeek,\n  isDueToday,\n  isDueTomorrow,\n  isOverdue,\n  isRecentlyDone,\n} from \"./tasksPredicate\";\n\nexport const TasksListByDueDate = ({\n  filterByContact,\n  emptyPlaceholder,\n  pendingPlaceholder,\n}: {\n  filterByContact?: Identifier;\n  emptyPlaceholder?: React.ReactNode;\n  pendingPlaceholder?: React.ReactNode;\n}) => {\n  const { identity } = useGetIdentity();\n  const isMobile = useIsMobile();\n  const translate = useTranslate();\n\n  const { data: tasks, isPending } = useGetList(\n    \"tasks\",\n    {\n      pagination: { page: 1, perPage: 1000 },\n      sort: { field: \"due_date\", order: \"ASC\" },\n      filter: {\n        ...(filterByContact != null\n          ? { contact_id: filterByContact }\n          : { sales_id: identity?.id }),\n      },\n    },\n    { enabled: filterByContact != null ? true : !!identity },\n  );\n\n  const showContact = filterByContact == null;\n\n  const ongoingTasks = useMemo(\n    () => tasks?.filter((task) => !isDone(task) || isRecentlyDone(task)) || [],\n    [tasks],\n  );\n\n  const overdueTasks = useMemo(\n    () =>\n      ongoingTasks?.filter((task) => {\n        return isOverdue(task.due_date);\n      }) || [],\n    [ongoingTasks],\n  );\n\n  const dueTodayTasks = useMemo(\n    () =>\n      ongoingTasks?.filter((task) => {\n        return isDueToday(task.due_date);\n      }) || [],\n    [ongoingTasks],\n  );\n\n  const dueTomorrowTasks = useMemo(\n    () => ongoingTasks?.filter((task) => isDueTomorrow(task.due_date)) || [],\n    [ongoingTasks],\n  );\n\n  const dueThisWeekTasks = useMemo(\n    () => ongoingTasks?.filter((task) => isDueThisWeek(task.due_date)) || [],\n    [ongoingTasks],\n  );\n\n  const dueLaterTasks = useMemo(\n    () => ongoingTasks?.filter((task) => isDueLater(task.due_date)) || [],\n    [ongoingTasks],\n  );\n\n  const oneSecondHasPassed = useTimeout(1000);\n\n  if (isPending && oneSecondHasPassed) {\n    return pendingPlaceholder ?? null;\n  }\n\n  if (isPending) {\n    return null;\n  }\n\n  if (!ongoingTasks.length) {\n    return emptyPlaceholder ?? null;\n  }\n\n  return (\n    <div className=\"flex flex-col gap-4\">\n      <TaskListFilter\n        tasks={overdueTasks}\n        title={translate(\"resources.tasks.filters.overdue\")}\n        showContact={showContact}\n        isMobile={isMobile}\n      />\n      <TaskListFilter\n        tasks={dueTodayTasks}\n        title={translate(\"resources.tasks.filters.today\")}\n        showContact={showContact}\n        isMobile={isMobile}\n      />\n      <TaskListFilter\n        tasks={dueTomorrowTasks}\n        title={translate(\"resources.tasks.filters.tomorrow\")}\n        showContact={showContact}\n        isMobile={isMobile}\n      />\n      {(!filterByContact || (filterByContact && isBeforeFriday())) && (\n        <TaskListFilter\n          tasks={dueThisWeekTasks}\n          title={translate(\"resources.tasks.filters.this_week\")}\n          showContact={showContact}\n          isMobile={isMobile}\n        />\n      )}\n      <TaskListFilter\n        tasks={dueLaterTasks}\n        title={translate(\"resources.tasks.filters.later\")}\n        showContact={showContact}\n        isMobile={isMobile}\n      />\n    </div>\n  );\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/tasks/TasksIterator.tsx",
      "content": "import { useListContext } from \"ra-core\";\n\nimport { Task } from \"./Task\";\nimport { isDone, isRecentlyDone } from \"./tasksPredicate\";\n\nexport const TasksIterator = ({\n  showContact,\n  className,\n}: {\n  showContact?: boolean;\n  className?: string;\n}) => {\n  const { data, error, isPending } = useListContext();\n  if (isPending || error || data.length === 0) return null;\n\n  // Keep only tasks that are not done or done less than 5 minutes ago\n  const tasks = data.filter((task) => !isDone(task) || isRecentlyDone(task));\n\n  return (\n    <div className={`space-y-4 md:space-y-2 ${className || \"\"}`}>\n      {tasks.map((task) => (\n        <Task task={task} showContact={showContact} key={task.id} />\n      ))}\n    </div>\n  );\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/tasks/TaskFormContent.tsx",
      "content": "import { AutocompleteInput } from \"@/components/admin/autocomplete-input\";\nimport { ReferenceInput } from \"@/components/admin/reference-input\";\nimport { SelectInput } from \"@/components/admin/select-input\";\nimport { TextInput } from \"@/components/admin/text-input\";\nimport { required } from \"ra-core\";\nimport { DateTimeInput } from \"@/components/admin\";\n\nimport { contactOptionText } from \"../misc/ContactOption\";\nimport { useConfigurationContext } from \"../root/ConfigurationContext\";\n\nexport const TaskFormContent = ({\n  selectContact,\n}: {\n  selectContact?: boolean;\n}) => {\n  const { taskTypes } = useConfigurationContext();\n  return (\n    <div className=\"flex flex-col gap-4\">\n      <TextInput\n        autoFocus\n        source=\"text\"\n        validate={required()}\n        multiline\n        className=\"m-0\"\n        helperText={false}\n      />\n      {selectContact && (\n        <ReferenceInput source=\"contact_id\" reference=\"contacts_summary\">\n          <AutocompleteInput\n            label=\"resources.tasks.fields.contact_id\"\n            optionText={contactOptionText}\n            helperText={false}\n            validate={required()}\n            modal\n          />\n        </ReferenceInput>\n      )}\n\n      <div className=\"grid grid-cols-1 md:grid-cols-2 gap-4\">\n        <DateTimeInput\n          source=\"due_date\"\n          helperText={false}\n          validate={required()}\n        />\n        <SelectInput\n          source=\"type\"\n          validate={required()}\n          choices={taskTypes}\n          optionText=\"label\"\n          optionValue=\"value\"\n          defaultValue=\"none\"\n          helperText={false}\n        />\n      </div>\n    </div>\n  );\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/tasks/TaskEditSheet.tsx",
      "content": "import { ReferenceField } from \"@/components/admin\";\nimport type { Identifier } from \"ra-core\";\nimport { useGetRecordRepresentation, useTranslate } from \"ra-core\";\nimport { EditSheet } from \"../misc/EditSheet\";\nimport { TaskFormContent } from \"./TaskFormContent\";\n\nexport interface TaskEditSheetProps {\n  open: boolean;\n  onOpenChange: (open: boolean) => void;\n  taskId: Identifier;\n}\n\nexport const TaskEditSheet = ({\n  open,\n  onOpenChange,\n  taskId,\n}: TaskEditSheetProps) => {\n  const translate = useTranslate();\n  const getContactRepresentation = useGetRecordRepresentation(\"contacts\");\n  return (\n    <EditSheet\n      resource=\"tasks\"\n      id={taskId}\n      title={\n        <ReferenceField\n          source=\"contact_id\"\n          reference=\"contacts\"\n          render={({ referenceRecord }) => (\n            <span className=\"text-xl font-semibold truncate pr-10\">\n              {referenceRecord\n                ? translate(\"resources.tasks.sheet.edit_for\", {\n                    name: getContactRepresentation(referenceRecord),\n                  })\n                : translate(\"resources.tasks.sheet.edit\")}\n            </span>\n          )}\n        />\n      }\n      redirect={false}\n      open={open}\n      onOpenChange={onOpenChange}\n    >\n      <TaskFormContent />\n    </EditSheet>\n  );\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/tasks/TaskEdit.tsx",
      "content": "import {\n  EditBase,\n  Form,\n  useNotify,\n  useTranslate,\n  type Identifier,\n} from \"ra-core\";\nimport { DeleteButton } from \"@/components/admin/delete-button\";\nimport { SaveButton } from \"@/components/admin/form\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from \"@/components/ui/dialog\";\n\nimport { TaskFormContent } from \"./TaskFormContent\";\n\nexport const TaskEdit = ({\n  open,\n  close,\n  taskId,\n}: {\n  taskId: Identifier;\n  open: boolean;\n  close: () => void;\n}) => {\n  const notify = useNotify();\n  const translate = useTranslate();\n  return (\n    <Dialog open={open} onOpenChange={close}>\n      {open && taskId && (\n        <EditBase\n          id={taskId}\n          resource=\"tasks\"\n          className=\"mt-0\"\n          mutationOptions={{\n            onSuccess: () => {\n              close();\n              notify(\"resources.tasks.updated\", {\n                type: \"info\",\n                undoable: true,\n              });\n            },\n          }}\n          redirect={false}\n        >\n          <DialogContent className=\"lg:max-w-xl overflow-y-auto max-h-9/10 top-1/20 translate-y-0\">\n            <Form className=\"flex flex-col gap-4\">\n              <DialogHeader>\n                <DialogTitle>\n                  {translate(\"resources.tasks.action.edit\")}\n                </DialogTitle>\n              </DialogHeader>\n              <TaskFormContent />\n              <DialogFooter className=\"w-full sm:justify-between gap-4\">\n                <DeleteButton\n                  mutationOptions={{\n                    onSuccess: () => {\n                      close();\n                      notify(\"resources.tasks.deleted\", {\n                        type: \"info\",\n                        undoable: true,\n                      });\n                    },\n                  }}\n                  redirect={false}\n                />\n                <SaveButton label=\"ra.action.save\" />\n              </DialogFooter>\n            </Form>\n          </DialogContent>\n        </EditBase>\n      )}\n    </Dialog>\n  );\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/tasks/TaskCreateSheet.tsx",
      "content": "import {\n  type Identifier,\n  useDataProvider,\n  useGetIdentity,\n  useGetOne,\n  useGetRecordRepresentation,\n  useNotify,\n  useTranslate,\n  useUpdate,\n} from \"ra-core\";\nimport { CreateSheet } from \"../misc/CreateSheet\";\nimport { foreignKeyMapping } from \"../notes/foreignKeyMapping\";\nimport { TaskFormContent } from \"./TaskFormContent\";\nimport { useQueryClient } from \"@tanstack/react-query\";\n\nexport interface TaskCreateSheetProps {\n  open: boolean;\n  onOpenChange: (open: boolean) => void;\n  contact_id?: Identifier;\n}\n\nexport const TaskCreateSheet = ({\n  open,\n  onOpenChange,\n  contact_id,\n}: TaskCreateSheetProps) => {\n  const { identity } = useGetIdentity();\n  const translate = useTranslate();\n  const getContactRepresentation = useGetRecordRepresentation(\"contacts\");\n\n  const selectContact = contact_id == null;\n  const { data: contact } = useGetOne(\n    \"contacts\",\n    { id: contact_id! },\n    { enabled: !selectContact },\n  );\n  const [update] = useUpdate();\n  const dataProvider = useDataProvider();\n  const queryClient = useQueryClient();\n  const notify = useNotify();\n\n  if (!identity) return null;\n\n  const handleSuccess = async (data: any) => {\n    const referenceRecordId = data[foreignKeyMapping[\"contacts\"]];\n    if (!referenceRecordId) return;\n    const { data: contact } = await dataProvider.getOne(\"contacts\", {\n      id: referenceRecordId,\n    });\n    if (!contact) return;\n    await update(\"contacts\", {\n      id: referenceRecordId as unknown as Identifier,\n      data: { last_seen: new Date().toISOString() },\n      previousData: contact,\n    });\n    queryClient.invalidateQueries({\n      queryKey: [\"contacts\", \"getOne\"],\n    });\n\n    notify(\"resources.tasks.added\");\n    // No redirect, only close the sheet\n    onOpenChange(false);\n  };\n\n  return (\n    <CreateSheet\n      resource=\"tasks\"\n      title={\n        <span className=\"text-xl font-semibold truncate pr-10\">\n          {!selectContact\n            ? translate(\"resources.tasks.dialog.create_for\", {\n                name: getContactRepresentation(contact!),\n              })\n            : translate(\"resources.tasks.dialog.create\")}\n        </span>\n      }\n      redirect={false}\n      record={{\n        type: \"none\",\n        contact_id,\n        due_date: new Date().toISOString(),\n        sales_id: identity.id,\n      }}\n      mutationOptions={{\n        onSuccess: handleSuccess,\n      }}\n      open={open}\n      onOpenChange={onOpenChange}\n    >\n      <TaskFormContent selectContact={selectContact} />\n    </CreateSheet>\n  );\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/tasks/Task.tsx",
      "content": "import { useQueryClient } from \"@tanstack/react-query\";\nimport { MoreVertical } from \"lucide-react\";\nimport {\n  useDeleteWithUndoController,\n  useGetRecordRepresentation,\n  useNotify,\n  useTranslate,\n  useUpdate,\n} from \"ra-core\";\nimport { useEffect, useState } from \"react\";\nimport { ReferenceField } from \"@/components/admin/reference-field\";\nimport { DateField } from \"@/components/admin/date-field\";\nimport { Button } from \"@/components/ui/button\";\nimport { Checkbox } from \"@/components/ui/checkbox\";\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\n\nimport { useConfigurationContext } from \"../root/ConfigurationContext\";\nimport type { Contact, Task as TData } from \"../types\";\nimport { TaskEdit } from \"./TaskEdit\";\nimport { TaskEditSheet } from \"./TaskEditSheet\";\nimport { useIsMobile } from \"@/hooks/use-mobile\";\n\nexport const Task = ({\n  task,\n  showContact,\n}: {\n  task: TData;\n  showContact?: boolean;\n}) => {\n  const isMobile = useIsMobile();\n  const { taskTypes } = useConfigurationContext();\n  const notify = useNotify();\n  const translate = useTranslate();\n  const queryClient = useQueryClient();\n  const getContactRepresentation = useGetRecordRepresentation(\"contacts\");\n\n  const [openEdit, setOpenEdit] = useState(false);\n\n  const handleCloseEdit = () => {\n    setOpenEdit(false);\n  };\n\n  const [update, { isPending: isUpdatePending, isSuccess, variables }] =\n    useUpdate();\n  const { handleDelete } = useDeleteWithUndoController({\n    record: task,\n    redirect: false,\n    mutationOptions: {\n      onSuccess() {\n        notify(\"resources.tasks.deleted\", {\n          undoable: true,\n        });\n      },\n    },\n  });\n\n  const handleEdit = () => {\n    setOpenEdit(true);\n  };\n\n  const handleCheck = () => () => {\n    update(\"tasks\", {\n      id: task.id,\n      data: {\n        done_date: task.done_date ? null : new Date().toISOString(),\n      },\n      previousData: task,\n    });\n  };\n\n  useEffect(() => {\n    // We do not want to invalidate the query when a tack is checked or unchecked\n    if (\n      isUpdatePending ||\n      !isSuccess ||\n      variables?.data?.done_date != undefined\n    ) {\n      return;\n    }\n\n    queryClient.invalidateQueries({ queryKey: [\"tasks\", \"getList\"] });\n  }, [queryClient, isUpdatePending, isSuccess, variables]);\n\n  const labelId = `checkbox-list-label-${task.id}`;\n\n  return (\n    <>\n      <div className=\"flex items-start justify-between\">\n        <div\n          className=\"flex items-start gap-2 flex-1\"\n          onClick={isMobile ? handleCheck() : undefined}\n        >\n          <Checkbox\n            id={labelId}\n            checked={!!task.done_date}\n            onCheckedChange={handleCheck()}\n            disabled={isUpdatePending}\n            className=\"mt-1\"\n          />\n          <div className={`flex-grow ${task.done_date ? \"line-through\" : \"\"}`}>\n            <div className=\"text-sm\">\n              {task.type && task.type !== \"none\" && (\n                <>\n                  <span className=\"font-semibold text-sm\">\n                    {(() => {\n                      const matchedTaskType = taskTypes.find(\n                        (taskType) => taskType.value === task.type,\n                      );\n                      return matchedTaskType\n                        ? matchedTaskType.label\n                        : task.type;\n                    })()}\n                  </span>\n                  &nbsp;\n                </>\n              )}\n              {task.text}\n            </div>\n            <div className=\"text-sm text-muted-foreground\">\n              {translate(\"resources.tasks.fields.due_short\")}\n              &nbsp;\n              <DateField source=\"due_date\" record={task} showDate showTime />\n              {showContact && (\n                <ReferenceField<TData, Contact>\n                  source=\"contact_id\"\n                  reference=\"contacts\"\n                  record={task}\n                  link=\"show\"\n                  className=\"inline text-sm text-muted-foreground\"\n                  render={({ referenceRecord }) => {\n                    if (!referenceRecord) return null;\n                    return (\n                      <>\n                        {\" \"}\n                        {translate(\"resources.tasks.regarding_contact\", {\n                          name: getContactRepresentation(referenceRecord),\n                        })}\n                      </>\n                    );\n                  }}\n                />\n              )}\n            </div>\n          </div>\n        </div>\n\n        <DropdownMenu>\n          <DropdownMenuTrigger asChild>\n            <Button\n              variant=\"ghost\"\n              size=\"icon\"\n              className=\"h-5 pr-0! size-8 cursor-pointer\"\n              aria-label={translate(\"resources.tasks.actions.title\")}\n            >\n              <MoreVertical className=\"size-5 md:size-4\" />\n            </Button>\n          </DropdownMenuTrigger>\n          <DropdownMenuContent align=\"end\">\n            <DropdownMenuItem\n              className=\"cursor-pointer h-12 md:h-8 px-4 md:px-2 text-base md:text-sm\"\n              onClick={() => {\n                update(\"tasks\", {\n                  id: task.id,\n                  data: {\n                    due_date: new Date(Date.now() + 24 * 60 * 60 * 1000)\n                      .toISOString()\n                      .slice(0, 10),\n                  },\n                  previousData: task,\n                });\n              }}\n            >\n              {translate(\"resources.tasks.actions.postpone_tomorrow\")}\n            </DropdownMenuItem>\n            <DropdownMenuItem\n              className=\"cursor-pointer h-12 md:h-8 px-4 md:px-2 text-base md:text-sm\"\n              onClick={() => {\n                update(\"tasks\", {\n                  id: task.id,\n                  data: {\n                    due_date: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)\n                      .toISOString()\n                      .slice(0, 10),\n                  },\n                  previousData: task,\n                });\n              }}\n            >\n              {translate(\"resources.tasks.actions.postpone_next_week\")}\n            </DropdownMenuItem>\n            <DropdownMenuItem\n              className=\"cursor-pointer h-12 md:h-8 px-4 md:px-2 text-base md:text-sm\"\n              onClick={handleEdit}\n            >\n              {translate(\"ra.action.edit\")}\n            </DropdownMenuItem>\n            <DropdownMenuItem\n              className=\"cursor-pointer h-12 md:h-8 px-4 md:px-2 text-base md:text-sm\"\n              onClick={handleDelete}\n            >\n              {translate(\"ra.action.delete\")}\n            </DropdownMenuItem>\n          </DropdownMenuContent>\n        </DropdownMenu>\n      </div>\n\n      {isMobile ? (\n        <TaskEditSheet\n          taskId={task.id}\n          open={openEdit}\n          onOpenChange={setOpenEdit}\n        />\n      ) : (\n        <TaskEdit taskId={task.id} open={openEdit} close={handleCloseEdit} />\n      )}\n    </>\n  );\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/tasks/MobileTasksList.tsx",
      "content": "import { MobileContent } from \"../layout/MobileContent\";\nimport MobileHeader from \"../layout/MobileHeader\";\nimport { TasksListContent } from \"./TasksListContent\";\nimport { useTranslate } from \"ra-core\";\n\nexport const MobileTasksList = () => {\n  const translate = useTranslate();\n  return (\n    <>\n      <MobileHeader>\n        <h1 className=\"text-xl font-semibold\">\n          {translate(\"resources.tasks.name\", { smart_count: 2 })}\n        </h1>\n      </MobileHeader>\n      <MobileContent>\n        <TasksListContent />\n      </MobileContent>\n    </>\n  );\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/tasks/AddTask.tsx",
      "content": "import { Plus } from \"lucide-react\";\nimport {\n  CreateBase,\n  Form,\n  useDataProvider,\n  useGetIdentity,\n  useGetRecordRepresentation,\n  useNotify,\n  useRecordContext,\n  useTranslate,\n  useUpdate,\n} from \"ra-core\";\nimport { useState } from \"react\";\nimport { SaveButton } from \"@/components/admin/form\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from \"@/components/ui/dialog\";\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from \"@/components/ui/tooltip\";\n\nimport { TaskFormContent } from \"./TaskFormContent\";\n\nexport const AddTask = ({\n  selectContact,\n  display = \"chip\",\n}: {\n  selectContact?: boolean;\n  display?: \"chip\" | \"icon\";\n}) => {\n  const { identity } = useGetIdentity();\n  const dataProvider = useDataProvider();\n  const [update] = useUpdate();\n  const notify = useNotify();\n  const translate = useTranslate();\n  const contact = useRecordContext();\n  const [open, setOpen] = useState(false);\n  const handleOpen = () => {\n    setOpen(true);\n  };\n  const getContactRepresentation = useGetRecordRepresentation(\"contacts\");\n\n  const handleSuccess = async (data: any) => {\n    setOpen(false);\n    const contact = await dataProvider.getOne(\"contacts\", {\n      id: data.contact_id,\n    });\n    if (!contact.data) return;\n\n    await update(\"contacts\", {\n      id: contact.data.id,\n      data: { last_seen: new Date().toISOString() },\n      previousData: contact.data,\n    });\n\n    notify(\"resources.tasks.added\");\n  };\n\n  if (!identity) return null;\n\n  return (\n    <>\n      {display === \"icon\" ? (\n        <TooltipProvider>\n          <Tooltip>\n            <TooltipTrigger asChild>\n              <Button\n                size=\"sm\"\n                variant=\"ghost\"\n                className=\"p-2 cursor-pointer\"\n                onClick={handleOpen}\n              >\n                <Plus className=\"w-4 h-4\" />\n              </Button>\n            </TooltipTrigger>\n            <TooltipContent>\n              {translate(\"resources.tasks.action.create\")}\n            </TooltipContent>\n          </Tooltip>\n        </TooltipProvider>\n      ) : (\n        <div className=\"my-2\">\n          <Button\n            variant=\"outline\"\n            className=\"h-6 cursor-pointer\"\n            onClick={handleOpen}\n            size=\"sm\"\n          >\n            <Plus className=\"w-4 h-4\" />\n            {translate(\"resources.tasks.action.add\")}\n          </Button>\n        </div>\n      )}\n\n      <CreateBase\n        resource=\"tasks\"\n        record={{\n          type: \"none\",\n          contact_id: contact?.id,\n          due_date: new Date().toISOString(),\n          sales_id: identity.id,\n        }}\n        mutationOptions={{ onSuccess: handleSuccess }}\n      >\n        <Dialog open={open} onOpenChange={() => setOpen(false)}>\n          <DialogContent className=\"lg:max-w-xl overflow-y-auto max-h-9/10 top-1/20 translate-y-0\">\n            <Form className=\"flex flex-col gap-4\">\n              <DialogHeader>\n                <DialogTitle>\n                  {!selectContact\n                    ? translate(\"resources.tasks.dialog.create_for\", {\n                        name: getContactRepresentation(contact!),\n                      })\n                    : translate(\"resources.tasks.dialog.create\")}\n                </DialogTitle>\n              </DialogHeader>\n              <TaskFormContent selectContact={selectContact} />\n              <DialogFooter className=\"w-full justify-end\">\n                <SaveButton />\n              </DialogFooter>\n            </Form>\n          </DialogContent>\n        </Dialog>\n      </CreateBase>\n    </>\n  );\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/tags/useTags.ts",
      "content": "import { useGetList } from \"ra-core\";\n\nimport type { Tag } from \"../types\";\n\ntype UseTagsOptions = {\n  enabled?: boolean;\n  perPage?: number;\n};\n\nexport function useTags({ enabled, perPage = 1000 }: UseTagsOptions = {}) {\n  return useGetList<Tag>(\n    \"tags\",\n    {\n      pagination: { page: 1, perPage },\n      sort: { field: \"name\", order: \"ASC\" },\n    },\n    { enabled },\n  );\n}\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/tags/useCreateTag.ts",
      "content": "import { useCallback } from \"react\";\nimport { useDataProvider } from \"ra-core\";\n\nimport type { Tag } from \"../types\";\n\nexport function useCreateTag() {\n  const dataProvider = useDataProvider();\n\n  return useCallback(\n    async (data: Pick<Tag, \"name\" | \"color\">) => {\n      const response = await dataProvider.create<Tag>(\"tags\", { data });\n      return response.data;\n    },\n    [dataProvider],\n  );\n}\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/tags/colors.ts",
      "content": "export const colors = [\n  \"#eddcd2\",\n  \"#fff1e6\",\n  \"#fde2e4\",\n  \"#fad2e1\",\n  \"#c5dedd\",\n  \"#dbe7e4\",\n  \"#f0efeb\",\n  \"#d6e2e9\",\n  \"#bcd4e6\",\n  \"#99c1de\",\n];\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/tags/TagForm.tsx",
      "content": "import { SaveIcon } from \"lucide-react\";\nimport { useEffect, useState, type ChangeEvent, type FormEvent } from \"react\";\nimport { useTranslate } from \"ra-core\";\nimport { Button, buttonVariants } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport { cn } from \"@/lib/utils\";\n\nimport type { Tag } from \"../types\";\nimport { colors } from \"./colors\";\nimport { RoundButton } from \"./RoundButton\";\n\ntype TagFormProps = {\n  open: boolean;\n  cancelLabel?: string;\n  tag?: Pick<Tag, \"name\" | \"color\">;\n  onCancel?(): void;\n  onSubmit(tag: Pick<Tag, \"name\" | \"color\">): Promise<void>;\n};\n\nexport function TagForm({\n  open,\n  cancelLabel,\n  tag,\n  onCancel,\n  onSubmit,\n}: TagFormProps) {\n  const translate = useTranslate();\n  const [newTagName, setNewTagName] = useState(\"\");\n  const [newTagColor, setNewTagColor] = useState(colors[0]);\n  const [isSubmitting, setIsSubmitting] = useState(false);\n\n  const handleNewTagNameChange = (event: ChangeEvent<HTMLInputElement>) => {\n    setNewTagName(event.target.value);\n  };\n\n  const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {\n    event.preventDefault();\n    setIsSubmitting(true);\n\n    try {\n      await onSubmit({ name: newTagName.trim(), color: newTagColor });\n    } finally {\n      setIsSubmitting(false);\n    }\n  };\n\n  useEffect(() => {\n    if (!open) {\n      return;\n    }\n\n    setNewTagName(tag?.name ?? \"\");\n    setNewTagColor(tag?.color ?? colors[0]);\n    setIsSubmitting(false);\n  }, [open, tag]);\n\n  return (\n    <form onSubmit={handleSubmit}>\n      <div className=\"space-y-4 py-4\">\n        <div className=\"space-y-2\">\n          <Label htmlFor=\"tag-name\">\n            {translate(\"resources.tags.dialog.name_label\")}\n          </Label>\n          <Input\n            id=\"tag-name\"\n            autoFocus\n            value={newTagName}\n            onChange={handleNewTagNameChange}\n            placeholder={translate(\"resources.tags.dialog.name_placeholder\")}\n          />\n        </div>\n\n        <div className=\"space-y-2\">\n          <Label>{translate(\"resources.tags.dialog.color\")}</Label>\n          <div className=\"flex flex-wrap\">\n            {colors.map((color) => (\n              <RoundButton\n                key={color}\n                color={color}\n                selected={color === newTagColor}\n                handleClick={() => {\n                  setNewTagColor(color);\n                }}\n              />\n            ))}\n          </div>\n        </div>\n      </div>\n\n      <div className=\"flex justify-end gap-2 pt-4\">\n        {onCancel && (\n          <Button\n            type=\"button\"\n            variant=\"ghost\"\n            onClick={onCancel}\n            disabled={isSubmitting}\n          >\n            {cancelLabel ?? translate(\"ra.action.cancel\")}\n          </Button>\n        )}\n        <Button\n          type=\"submit\"\n          variant=\"outline\"\n          disabled={isSubmitting || !newTagName.trim()}\n          className={cn(\n            buttonVariants({ variant: \"outline\" }),\n            \"text-primary\",\n            isSubmitting ? \"cursor-not-allowed\" : \"cursor-pointer\",\n          )}\n        >\n          <SaveIcon />\n          {translate(\"ra.action.save\")}\n        </Button>\n      </div>\n    </form>\n  );\n}\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/tags/TagEditModal.tsx",
      "content": "import { useTranslate, useUpdate } from \"ra-core\";\n\nimport type { Tag } from \"../types\";\nimport { TagDialog } from \"./TagDialog\";\n\ntype TagEditModalProps = {\n  tag: Tag;\n  open: boolean;\n  onClose(): void;\n  onSuccess?(tag: Tag): Promise<void>;\n};\n\nexport function TagEditModal({\n  tag,\n  open,\n  onClose,\n  onSuccess,\n}: TagEditModalProps) {\n  const [update] = useUpdate<Tag>();\n  const translate = useTranslate();\n\n  const handleEditTag = async (data: Pick<Tag, \"name\" | \"color\">) => {\n    await update(\n      \"tags\",\n      { id: tag.id, data, previousData: tag },\n      {\n        onSuccess: async (tag) => {\n          await onSuccess?.(tag);\n        },\n      },\n    );\n  };\n\n  return (\n    <TagDialog\n      open={open}\n      title={translate(\"resources.tags.dialog.edit_title\")}\n      onClose={onClose}\n      onSubmit={handleEditTag}\n      tag={tag}\n    />\n  );\n}\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/tags/TagDialog.tsx",
      "content": "import {\n  Dialog,\n  DialogContent,\n  DialogHeader,\n  DialogTitle,\n} from \"@/components/ui/dialog\";\n\nimport type { Tag } from \"../types\";\nimport { TagForm } from \"./TagForm\";\n\ntype TagDialogProps = {\n  open: boolean;\n  tag?: Pick<Tag, \"name\" | \"color\">;\n  title: string;\n  onSubmit(tag: Pick<Tag, \"name\" | \"color\">): Promise<void>;\n  onClose(): void;\n};\n\nexport function TagDialog({\n  open,\n  tag,\n  title,\n  onClose,\n  onSubmit,\n}: TagDialogProps) {\n  const handleClose = (isOpen = false) => {\n    if (!isOpen) {\n      onClose();\n    }\n  };\n\n  const handleSubmit = async (data: Pick<Tag, \"name\" | \"color\">) => {\n    await onSubmit(data);\n    handleClose();\n  };\n\n  return (\n    <Dialog open={open} onOpenChange={handleClose}>\n      <DialogContent className=\"sm:max-w-lg\">\n        <DialogHeader>\n          <DialogTitle>{title}</DialogTitle>\n        </DialogHeader>\n        <TagForm open={open} tag={tag} onSubmit={handleSubmit} />\n      </DialogContent>\n    </Dialog>\n  );\n}\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/tags/TagCreateModal.tsx",
      "content": "import { useTranslate } from \"ra-core\";\n\nimport type { Tag } from \"../types\";\nimport { TagDialog } from \"./TagDialog\";\nimport { useCreateTag } from \"./useCreateTag\";\n\ntype TagCreateModalProps = {\n  open: boolean;\n  onClose(): void;\n  onSuccess?(tag: Tag): Promise<void>;\n};\n\nexport function TagCreateModal({\n  open,\n  onClose,\n  onSuccess,\n}: TagCreateModalProps) {\n  const createTag = useCreateTag();\n  const translate = useTranslate();\n\n  const handleCreateTag = async (data: Pick<Tag, \"name\" | \"color\">) => {\n    const tag = await createTag(data);\n    await onSuccess?.(tag);\n  };\n\n  return (\n    <TagDialog\n      open={open}\n      title={translate(\"resources.tags.dialog.create_title\")}\n      onClose={onClose}\n      onSubmit={handleCreateTag}\n    />\n  );\n}\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/tags/TagChip.tsx",
      "content": "import { X } from \"lucide-react\";\nimport { useState } from \"react\";\n\nimport type { Tag } from \"../types\";\nimport { TagEditModal } from \"./TagEditModal\";\n\ntype TagChipProps = {\n  tag: Tag;\n\n  onUnlink: () => Promise<void>;\n};\n\nexport function TagChip({ tag, onUnlink }: TagChipProps) {\n  const [open, setOpen] = useState(false);\n\n  const handleClose = () => {\n    setOpen(false);\n  };\n\n  const handleClick = () => {\n    setOpen(true);\n  };\n\n  return (\n    <>\n      <div\n        className=\"text-black inline-flex items-center gap-1 px-4 md:px-2 py-2 md:py-1 text-sm md:text-xs rounded-md cursor-pointer hover:opacity-80 transition-opacity\"\n        style={{ backgroundColor: tag.color }}\n        onClick={handleClick}\n      >\n        {tag.name}\n        <button\n          onClick={(e) => {\n            e.stopPropagation();\n            onUnlink();\n          }}\n          className=\"transition-colors p-0 ml-1 cursor-pointer\"\n        >\n          <X className=\"w-4 h-4 md:w-3 md:h-3\" />\n        </button>\n      </div>\n      <TagEditModal tag={tag} open={open} onClose={handleClose} />\n    </>\n  );\n}\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/tags/RoundButton.tsx",
      "content": "export const RoundButton = ({ color, handleClick, selected }: any) => (\n  <button\n    type=\"button\"\n    className={`w-8 h-8 rounded-full inline-block m-1 transition-all ${\n      selected ? \"ring-2 ring-gray-500 ring-offset-1\" : \"\"\n    }`}\n    style={{ backgroundColor: color }}\n    onClick={handleClick}\n  />\n);\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/simple-list/SimpleListLoading.tsx",
      "content": "import { useTimeout } from \"ra-core\";\nimport type { ReactNode } from \"react\";\n\nimport { ListPlaceholder } from \"./ListPlaceholder.tsx\";\n\nexport const SimpleListLoading = (props: SimpleListLoadingProps) => {\n  const {\n    className,\n    hasLeftAvatarOrIcon,\n    hasRightAvatarOrIcon,\n    hasSecondaryText,\n    hasTertiaryText,\n    nbFakeLines = 5,\n    ...rest\n  } = props;\n\n  const oneSecondHasPassed = useTimeout(1000);\n\n  return oneSecondHasPassed ? (\n    <ul className={className} {...rest}>\n      {times(nbFakeLines, (key) => (\n        <li key={key} className=\"flex items-center space-x-3 p-3\">\n          {hasLeftAvatarOrIcon && (\n            <div className=\"w-10 h-10 bg-gray-300 rounded-full flex-shrink-0\">\n              &nbsp;\n            </div>\n          )}\n          <div className=\"flex-1 min-w-0\">\n            <div className=\"mb-1\">\n              <ListPlaceholder className=\"w-1/3 inline-block mb-1\" />\n              {hasTertiaryText && (\n                <span className=\"float-right opacity-55 min-w-[10vw]\">\n                  <ListPlaceholder />\n                </span>\n              )}\n            </div>\n            {hasSecondaryText && <ListPlaceholder className=\"w-1/4\" />}\n          </div>\n          {hasRightAvatarOrIcon && (\n            <div className=\"w-10 h-10 bg-gray-300 rounded-full flex-shrink-0\">\n              &nbsp;\n            </div>\n          )}\n        </li>\n      ))}\n    </ul>\n  ) : null;\n};\n\nconst times = (nbChildren: number, fn: (key: number) => ReactNode) =>\n  Array.from({ length: nbChildren }, (_, key) => fn(key));\n\nexport interface SimpleListLoadingProps {\n  className?: string;\n  hasLeftAvatarOrIcon?: boolean;\n  hasRightAvatarOrIcon?: boolean;\n  hasSecondaryText?: boolean;\n  hasTertiaryText?: boolean;\n  nbFakeLines?: number;\n}\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/simple-list/SimpleListItem.tsx",
      "content": "import {\n  useEvent,\n  useGetPathForRecord,\n  useGetPathForRecordCallback,\n  useRecordContext,\n  useResourceContext,\n  type Identifier,\n  type LinkToType,\n  type RaRecord,\n} from \"ra-core\";\nimport type { CSSProperties, ReactElement, ReactNode } from \"react\";\nimport { Link, useNavigate } from \"react-router\";\n\nexport const SimpleListItem = <RecordType extends RaRecord = any>(\n  props: SimpleListItemProps<RecordType>,\n) => {\n  const { children, linkType, rowClick, style } = props;\n  const resource = useResourceContext(props);\n  const record = useRecordContext<RecordType>(props);\n  const navigate = useNavigate();\n  // If we don't have a function to get the path, we can compute the path immediately and set the href\n  // on the Link correctly without onClick (better for accessibility)\n  const isFunctionLink =\n    typeof linkType === \"function\" || typeof rowClick === \"function\";\n  const pathForRecord = useGetPathForRecord({\n    link: isFunctionLink ? false : (linkType ?? rowClick),\n    resource,\n  });\n  const getPathForRecord = useGetPathForRecordCallback();\n  const handleClick = useEvent(async () => {\n    // No need to handle non function linkType or rowClick\n    if (!isFunctionLink) return;\n    if (!record) return;\n\n    const link: LinkToType =\n      typeof linkType === \"function\"\n        ? linkType(record, record.id)\n        : typeof rowClick === \"function\"\n          ? (record, resource) => rowClick(record.id, resource, record)\n          : false;\n\n    const path = await getPathForRecord({\n      record,\n      resource,\n      link,\n    });\n    if (path === false || path == null) {\n      return;\n    }\n    navigate(path);\n  });\n\n  if (!record) return null;\n\n  if (isFunctionLink) {\n    return (\n      <li className=\"w-full\">\n        <button\n          onClick={handleClick}\n          style={style}\n          className=\"w-full text-left hover:bg-muted focus: bg-muted focus:outline-none\"\n        >\n          {children}\n        </button>\n      </li>\n    );\n  }\n\n  if (pathForRecord) {\n    return (\n      <li className=\"w-full\">\n        <Link\n          to={pathForRecord}\n          style={style}\n          className=\"block w-full hover:bg-muted focus:bg-muted focus:outline-none\"\n        >\n          {children}\n        </Link>\n      </li>\n    );\n  }\n\n  return <li className=\"w-full\">{children}</li>;\n};\n\nexport type FunctionToElement<RecordType extends RaRecord = any> = (\n  record: RecordType,\n  id: Identifier,\n) => ReactNode;\n\nexport type FunctionLinkType = (record: RaRecord, id: Identifier) => string;\n\nexport interface SimpleListBaseProps<RecordType extends RaRecord = any> {\n  leftAvatar?: FunctionToElement<RecordType>;\n  leftIcon?: FunctionToElement<RecordType>;\n  primaryText?: FunctionToElement<RecordType> | ReactElement | string;\n  /**\n   * @deprecated use rowClick instead\n   */\n  linkType?: string | FunctionLinkType | false;\n\n  /**\n   * The action to trigger when the user clicks on a row.\n   *\n   * @see https://marmelab.com/shadcn-admin-kit/docs/datatable/\n   * @example\n   * import { List, DataTable } from 'shadcn-admin-kit';\n   *\n   * export const PostList = () => (\n   *     <List>\n   *         <DataTable rowClick=\"edit\">\n   *             ...\n   *         </DataTable>                    </ListItem>\n\n   *     </List>\n   * );\n   */\n  rowClick?: string | RowClickFunction | false;\n  rightAvatar?: FunctionToElement<RecordType>;\n  rightIcon?: FunctionToElement<RecordType>;\n  secondaryText?: FunctionToElement<RecordType> | ReactElement | string;\n  tertiaryText?: FunctionToElement<RecordType> | ReactElement | string;\n}\n\nexport interface SimpleListItemProps<RecordType extends RaRecord = any>\n  extends SimpleListBaseProps<RecordType> {\n  rowIndex: number;\n  className?: string;\n  style?: CSSProperties;\n  children?: ReactNode;\n  resource?: string;\n}\n\nexport type RowClickFunction<RecordType extends RaRecord = RaRecord> = (\n  id: Identifier,\n  resource: string,\n  record: RecordType,\n) => string | false | Promise<string | false>;\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/simple-list/SimpleList.tsx",
      "content": "import {\n  ListIterator,\n  type RaRecord,\n  sanitizeListRestProps,\n  useGetRecordRepresentation,\n  useListContextWithProps,\n  useRecordContext,\n  useResourceContext,\n  useTranslate,\n} from \"ra-core\";\nimport { isValidElement, type ReactElement } from \"react\";\n\nimport { ListNoResults } from \"./ListNoResults.tsx\";\nimport type { FunctionToElement } from \"./SimpleListItem.tsx\";\nimport {\n  type SimpleListBaseProps,\n  SimpleListItem,\n  type SimpleListItemProps,\n} from \"./SimpleListItem.tsx\";\nimport { SimpleListLoading } from \"./SimpleListLoading.tsx\";\n\n/**\n * The <SimpleList> component renders a list of records\n * It is usually used as a child of shadcn-admin-kit's <List> and <ReferenceManyField> components.\n *\n * Also widely used on Mobile.\n *\n * Props:\n * - primaryText: function returning a React element (or some text) based on the record\n * - secondaryText: same\n * - tertiaryText: same\n * - leftAvatar: function returning a React element based on the record\n * - leftIcon: same\n * - rightAvatar: same\n * - rightIcon: same\n * - linkType: deprecated - 'edit' or 'show', or a function returning 'edit' or 'show' based on the record\n * - rowClick: The action to trigger when the user clicks on a row.\n * - rowStyle: function returning a style object based on (record, index)\n * - rowSx: function returning a sx object based on (record, index)\n *\n * @example // Display all posts as a List\n * const postRowSx = (record, index) => ({\n *     backgroundColor: record.views >= 500 ? '#efe' : 'white',\n * });\n * export const PostList = () => (\n *     <List>\n *         <SimpleList\n *             primaryText={record => record.title}\n *             secondaryText={record => `${record.views} views`}\n *             tertiaryText={record =>\n *                 new Date(record.published_at).toLocaleDateString()\n *             }\n *             rowSx={postRowSx}\n *          />\n *     </List>\n * );\n */\nexport const SimpleList = <RecordType extends RaRecord = any>(\n  props: SimpleListProps<RecordType>,\n) => {\n  const {\n    className,\n    empty = DefaultEmpty,\n    leftAvatar,\n    leftIcon,\n    linkType,\n    rowClick,\n    primaryText,\n    rightAvatar,\n    rightIcon,\n    secondaryText,\n    tertiaryText,\n    resource,\n    ...rest\n  } = props;\n  const { data, isPending, total } = useListContextWithProps<RecordType>(props);\n\n  if (isPending === true) {\n    return (\n      <SimpleListLoading\n        className={className}\n        hasLeftAvatarOrIcon={!!leftIcon || !!leftAvatar}\n        hasRightAvatarOrIcon={!!rightIcon || !!rightAvatar}\n        hasSecondaryText={!!secondaryText}\n        hasTertiaryText={!!tertiaryText}\n      />\n    );\n  }\n\n  if (data == null || data.length === 0 || total === 0) {\n    if (empty) {\n      return empty;\n    }\n\n    return null;\n  }\n\n  return (\n    <ul className={className} {...sanitizeListRestProps(rest)}>\n      <ListIterator<RecordType>\n        data={data}\n        total={total}\n        render={(record, rowIndex) => (\n          <SimpleListItem\n            key={record.id}\n            rowIndex={rowIndex}\n            linkType={linkType}\n            rowClick={rowClick}\n            resource={resource}\n          >\n            <SimpleListItemContent\n              leftAvatar={leftAvatar}\n              leftIcon={leftIcon}\n              primaryText={primaryText}\n              rightAvatar={rightAvatar}\n              rightIcon={rightIcon}\n              secondaryText={secondaryText}\n              tertiaryText={tertiaryText}\n              rowIndex={rowIndex}\n            />\n          </SimpleListItem>\n        )}\n      />\n    </ul>\n  );\n};\n\nexport interface SimpleListProps<RecordType extends RaRecord = any>\n  extends SimpleListBaseProps<RecordType> {\n  className?: string;\n  empty?: ReactElement;\n  // can be injected when using the component without context\n  resource?: string;\n  data?: RecordType[];\n  isLoading?: boolean;\n  isPending?: boolean;\n  isLoaded?: boolean;\n  total?: number;\n}\n\nconst SimpleListItemContent = <RecordType extends RaRecord = any>(\n  props: SimpleListItemProps<RecordType>,\n) => {\n  const {\n    leftAvatar,\n    leftIcon,\n    primaryText,\n    rightAvatar,\n    rightIcon,\n    secondaryText,\n    tertiaryText,\n  } = props;\n  const resource = useResourceContext(props);\n  const record = useRecordContext<RecordType>(props);\n  const getRecordRepresentation = useGetRecordRepresentation(resource);\n  const translate = useTranslate();\n\n  const renderAvatar = (\n    record: RecordType,\n    avatarCallback: FunctionToElement<RecordType>,\n  ) => {\n    const avatarValue = avatarCallback(record, record.id);\n    if (\n      typeof avatarValue === \"string\" &&\n      (avatarValue.startsWith(\"http\") || avatarValue.startsWith(\"data:\"))\n    ) {\n      return (\n        <img\n          src={avatarValue}\n          alt=\"\"\n          className=\"w-10 h-10 rounded-full object-cover\"\n        />\n      );\n    } else {\n      return (\n        <div className=\"w-10 h-10 rounded-full flex items-center justify-center text-sm\">\n          {avatarValue}\n        </div>\n      );\n    }\n  };\n\n  if (!record) return null;\n\n  return (\n    <div className=\"flex items-center space-x-3 p-3\">\n      {leftIcon && (\n        <div className=\"flex-shrink-0\">{leftIcon(record, record.id)}</div>\n      )}\n      {leftAvatar && (\n        <div className=\"flex-shrink-0\">{renderAvatar(record, leftAvatar)}</div>\n      )}\n      <div className=\"flex-1 min-w-0\">\n        <div className=\"flex items-center justify-between\">\n          <div className=\"text-sm font-medium truncate\">\n            {primaryText\n              ? typeof primaryText === \"string\"\n                ? translate(primaryText, {\n                    ...record,\n                    _: primaryText,\n                  })\n                : isValidElement(primaryText)\n                  ? primaryText\n                  : primaryText(record, record.id)\n              : getRecordRepresentation(record)}\n          </div>\n\n          {!!tertiaryText && (\n            <div className=\"text-xs text-muted-foreground ml-2\">\n              {typeof tertiaryText === \"string\"\n                ? translate(tertiaryText, {\n                    ...record,\n                    _: tertiaryText,\n                  })\n                : isValidElement(tertiaryText)\n                  ? tertiaryText\n                  : tertiaryText(record, record.id)}\n            </div>\n          )}\n        </div>\n\n        {!!secondaryText && (\n          <div className=\"text-sm text-muted-foreground truncate\">\n            {typeof secondaryText === \"string\"\n              ? translate(secondaryText, {\n                  ...record,\n                  _: secondaryText,\n                })\n              : isValidElement(secondaryText)\n                ? secondaryText\n                : secondaryText(record, record.id)}\n          </div>\n        )}\n      </div>\n      {(rightAvatar || rightIcon) && (\n        <div className=\"flex-shrink-0 flex items-center space-x-2\">\n          {rightAvatar && renderAvatar(record, rightAvatar)}\n          {rightIcon && rightIcon(record, record.id)}\n        </div>\n      )}\n    </div>\n  );\n};\n\nconst DefaultEmpty = <ListNoResults />;\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/simple-list/ListPlaceholder.tsx",
      "content": "import { cn } from \"@/lib/utils\";\n\ninterface ListPlaceholderProps {\n  className?: string;\n}\n\nexport const ListPlaceholder = ({ className }: ListPlaceholderProps) => {\n  return <span className={cn(\"bg-gray-300 flex\", className)}>&nbsp;</span>;\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/simple-list/ListNoResults.tsx",
      "content": "import {\n  useGetResourceLabel,\n  useListContextWithProps,\n  useResourceContext,\n  useTranslate,\n} from \"ra-core\";\nimport { Button } from \"@/components/ui/button\";\n\nexport const ListNoResults = (props: ListNoResultsProps) => {\n  const translate = useTranslate();\n  const resource = useResourceContext(props);\n  const { filterValues, setFilters } = useListContextWithProps(props);\n  const getResourceLabel = useGetResourceLabel();\n  if (!resource) {\n    throw new Error(\"<ListNoResults> must be used inside a <List> component\");\n  }\n  return (\n    <div className=\"p-6\">\n      <p className=\"text-sm text-muted-foreground\">\n        {filterValues && setFilters && Object.keys(filterValues).length > 0 ? (\n          <>\n            {translate(\"ra.navigation.no_filtered_results\", {\n              resource,\n              name: getResourceLabel(resource, 0),\n              _: \"No results found with the current filters.\",\n            })}{\" \"}\n            <Button\n              variant=\"outline\"\n              size=\"sm\"\n              onClick={() => setFilters({}, [])}\n            >\n              {translate(\"ra.navigation.clear_filters\", {\n                _: \"Clear filters\",\n              })}\n            </Button>\n          </>\n        ) : (\n          translate(\"ra.navigation.no_results\", {\n            resource,\n            name: getResourceLabel(resource, 0),\n            _: \"No results found.\",\n          })\n        )}\n      </p>\n    </div>\n  );\n};\n\nexport interface ListNoResultsProps {\n  resource?: string;\n  filterValues?: any;\n  setFilters?: (filters: any, filterTypes?: string[]) => void;\n}\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/settings/SettingsPageMobile.tsx",
      "content": "import { useMutation, useQueryClient } from \"@tanstack/react-query\";\nimport { useTheme } from \"@/components/admin/use-theme\";\nimport { ChevronRight, KeyRound } from \"lucide-react\";\nimport { Link } from \"react-router\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Item,\n  ItemContent,\n  ItemTitle,\n  ItemActions,\n  ItemGroup,\n  ItemSeparator,\n} from \"@/components/ui/item\";\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@/components/ui/select\";\nimport { ToggleGroup, ToggleGroupItem } from \"@/components/ui/toggle-group\";\nimport { Check, Copy, LogOut, Moon, Smartphone, Sun } from \"lucide-react\";\nimport {\n  Form,\n  Translate,\n  useAuthProvider,\n  useDataProvider,\n  useGetIdentity,\n  useGetOne,\n  useLocaleState,\n  useLocales,\n  useLogout,\n  useNotify,\n  useTranslate,\n} from \"ra-core\";\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from \"@/components/ui/tooltip\";\n\nimport { MobileContent } from \"../layout/MobileContent\";\nimport MobileHeader from \"../layout/MobileHeader\";\nimport { ChangelogPage } from \"../misc/ChangelogPage\";\nimport ImageEditorField from \"../misc/ImageEditorField\";\nimport type { CrmDataProvider } from \"../providers/types\";\nimport type { SalesFormData } from \"../types\";\n\nconst ChangePasswordButton = () => {\n  const translate = useTranslate();\n  const notify = useNotify();\n  const { identity } = useGetIdentity();\n  const dataProvider = useDataProvider<CrmDataProvider>();\n\n  const { mutate: updatePassword } = useMutation({\n    mutationKey: [\"updatePassword\"],\n    mutationFn: async () => {\n      if (!identity) {\n        throw new Error(\n          translate(\"crm.profile.record_not_found\", {\n            _: \"Record not found\",\n          }),\n        );\n      }\n      return dataProvider.updatePassword(identity.id);\n    },\n    onSuccess: () => {\n      notify(\"crm.profile.password_reset_sent\", {\n        messageArgs: {\n          _: \"A reset password email has been sent to your email address\",\n        },\n      });\n    },\n    onError: (e) => {\n      notify(`${e}`, { type: \"error\" });\n    },\n  });\n\n  return (\n    <Button\n      variant=\"outline\"\n      className=\"w-full text-base h-auto\"\n      onClick={() => updatePassword()}\n    >\n      <KeyRound className=\"size-5 mr-3\" />\n      {translate(\"crm.profile.password.change\")}\n    </Button>\n  );\n};\n\nexport const SettingsPageMobile = () => {\n  const translate = useTranslate();\n  const authProvider = useAuthProvider();\n  const logout = useLogout();\n\n  if (!authProvider) return null;\n\n  return (\n    <>\n      <MobileHeader>\n        <h1 className=\"text-xl font-semibold\">\n          {translate(\"crm.settings.title\")}\n        </h1>\n      </MobileHeader>\n      <MobileContent>\n        <div className=\"flex flex-col min-h-[calc(100dvh-3.5rem-4.5rem)]\">\n          <div className=\"space-y-6\">\n            <ProfileSection />\n            <PreferencesSection />\n            <InboundEmailSection />\n            <McpServerSection />\n            <AboutSection />\n          </div>\n\n          <div className=\"mt-auto pt-6 space-y-3 mb-4\">\n            <ChangePasswordButton />\n            <Button\n              variant=\"destructive\"\n              className=\"w-full text-base h-auto\"\n              onClick={() => logout()}\n            >\n              <LogOut className=\"size-5 mr-3\" />\n              <Translate i18nKey=\"ra.auth.logout\">Log out</Translate>\n            </Button>\n          </div>\n        </div>\n      </MobileContent>\n    </>\n  );\n};\n\nSettingsPageMobile.path = \"/settings\";\n\nconst SectionLabel = ({ children }: { children: React.ReactNode }) => (\n  <p className=\"text-xs font-medium text-muted-foreground uppercase tracking-wide px-1 mb-1.5\">\n    {children}\n  </p>\n);\n\nconst ProfileSection = () => {\n  const { identity, refetch: refetchIdentity } = useGetIdentity();\n  const { data, refetch: refetchUser } = useGetOne(\"sales\", {\n    id: identity?.id,\n  });\n  const translate = useTranslate();\n  const notify = useNotify();\n  const dataProvider = useDataProvider<CrmDataProvider>();\n  const queryClient = useQueryClient();\n\n  const saveField = useCallback(\n    async (field: string, value: string) => {\n      if (!identity || !data) return;\n      const current = data[field as keyof typeof data];\n      if (value === current) return;\n\n      const queryKey = [\n        \"sales\",\n        \"getOne\",\n        { id: String(identity.id), meta: undefined },\n      ];\n      const previousData = queryClient.getQueryData(queryKey);\n      queryClient.setQueryData(queryKey, (old: any) =>\n        old ? { ...old, [field]: value } : old,\n      );\n\n      try {\n        await dataProvider.salesUpdate(identity.id, {\n          ...data,\n          [field]: value,\n        } as SalesFormData);\n        refetchIdentity();\n        refetchUser();\n        notify(\"crm.profile.updated\", {\n          messageArgs: { _: \"Your profile has been updated\" },\n        });\n      } catch {\n        queryClient.setQueryData(queryKey, previousData);\n        notify(\"crm.profile.update_error\", {\n          type: \"error\",\n          messageArgs: { _: \"An error occurred. Please try again\" },\n        });\n      }\n    },\n    [\n      identity,\n      data,\n      dataProvider,\n      refetchIdentity,\n      refetchUser,\n      notify,\n      queryClient,\n    ],\n  );\n\n  const handleAvatarUpdate = useCallback(\n    async (values: SalesFormData) => {\n      if (!data) return;\n      try {\n        await dataProvider.salesUpdate(data.id, values);\n        refetchIdentity();\n        refetchUser();\n        notify(\"crm.profile.updated\", {\n          messageArgs: { _: \"Your profile has been updated\" },\n        });\n      } catch {\n        notify(\"crm.profile.update_error\", {\n          type: \"error\",\n          messageArgs: { _: \"An error occurred. Please try again.\" },\n        });\n      }\n    },\n    [data, dataProvider, refetchIdentity, refetchUser, notify],\n  );\n\n  if (!identity || !data) return null;\n\n  return (\n    <div>\n      <SectionLabel>\n        {translate(\"crm.profile.title\", { _: \"Profile\" })}\n      </SectionLabel>\n      <ItemGroup className=\"rounded-lg border overflow-hidden\">\n        <Form record={data}>\n          <Item size=\"sm\">\n            <ItemContent>\n              <ImageEditorField\n                source=\"avatar\"\n                type=\"avatar\"\n                onSave={handleAvatarUpdate}\n                linkPosition=\"right\"\n              />\n            </ItemContent>\n          </Item>\n        </Form>\n\n        <ItemSeparator />\n\n        <InlineEditRow\n          label={translate(\"resources.sales.fields.first_name\")}\n          value={data.first_name ?? \"\"}\n          onSave={(v) => saveField(\"first_name\", v)}\n        />\n\n        <ItemSeparator />\n\n        <InlineEditRow\n          label={translate(\"resources.sales.fields.last_name\")}\n          value={data.last_name ?? \"\"}\n          onSave={(v) => saveField(\"last_name\", v)}\n        />\n\n        <ItemSeparator />\n\n        <InlineEditRow\n          label={translate(\"resources.sales.fields.email\")}\n          value={data.email ?? \"\"}\n          onSave={(v) => saveField(\"email\", v)}\n        />\n      </ItemGroup>\n    </div>\n  );\n};\n\nconst InlineEditRow = ({\n  label,\n  value,\n  onSave,\n}: {\n  label: string;\n  value: string;\n  onSave: (value: string) => void;\n}) => {\n  const [isEditing, setIsEditing] = useState(false);\n  const [editValue, setEditValue] = useState(value);\n  const inputRef = useRef<HTMLInputElement>(null);\n\n  useEffect(() => {\n    setEditValue(value);\n  }, [value]);\n\n  useEffect(() => {\n    if (isEditing) {\n      inputRef.current?.focus();\n      inputRef.current?.select();\n    }\n  }, [isEditing]);\n\n  const handleSave = useCallback(() => {\n    setIsEditing(false);\n    const trimmed = editValue.trim();\n    if (trimmed !== value) {\n      onSave(trimmed);\n    }\n  }, [editValue, value, onSave]);\n\n  const handleCancel = useCallback(() => {\n    setEditValue(value);\n    setIsEditing(false);\n  }, [value]);\n\n  const handleKeyDown = useCallback(\n    (e: React.KeyboardEvent) => {\n      if (e.key === \"Enter\") {\n        e.preventDefault();\n        inputRef.current?.blur();\n      } else if (e.key === \"Escape\") {\n        e.preventDefault();\n        handleCancel();\n      }\n    },\n    [handleCancel],\n  );\n\n  if (isEditing) {\n    return (\n      <Item size=\"sm\">\n        <ItemContent>\n          <ItemTitle className=\"font-normal text-muted-foreground\">\n            {label}\n          </ItemTitle>\n        </ItemContent>\n        <ItemActions>\n          <input\n            ref={inputRef}\n            value={editValue}\n            onChange={(e) => setEditValue(e.target.value)}\n            onBlur={handleSave}\n            onKeyDown={handleKeyDown}\n            className=\"bg-transparent text-right !text-base outline-none w-48\"\n          />\n        </ItemActions>\n      </Item>\n    );\n  }\n\n  return (\n    <Item\n      size=\"sm\"\n      className=\"cursor-pointer\"\n      onClick={() => setIsEditing(true)}\n    >\n      <ItemContent>\n        <ItemTitle className=\"font-normal text-muted-foreground\">\n          {label}\n        </ItemTitle>\n      </ItemContent>\n      <ItemActions>\n        <span className=\"text-base\">{value}</span>\n      </ItemActions>\n    </Item>\n  );\n};\n\nconst PreferencesSection = () => {\n  const translate = useTranslate();\n\n  return (\n    <div>\n      <SectionLabel>\n        {translate(\"crm.settings.preferences\", { _: \"Preferences\" })}\n      </SectionLabel>\n      <ItemGroup className=\"rounded-lg border overflow-hidden\">\n        <LanguageRow />\n        <ItemSeparator />\n        <ThemeRow />\n      </ItemGroup>\n    </div>\n  );\n};\n\nconst LanguageRow = () => {\n  const translate = useTranslate();\n  const locales = useLocales();\n  const [locale, setLocale] = useLocaleState();\n\n  if (locales.length <= 1) return null;\n\n  return (\n    <Item size=\"sm\">\n      <ItemContent>\n        <ItemTitle className=\"font-normal text-muted-foreground\">\n          {translate(\"crm.language\")}\n        </ItemTitle>\n      </ItemContent>\n      <ItemActions>\n        <Select value={locale} onValueChange={setLocale}>\n          <SelectTrigger\n            size=\"sm\"\n            className=\"w-auto !h-auto py-0 border-none shadow-none\"\n          >\n            <SelectValue />\n          </SelectTrigger>\n          <SelectContent>\n            {locales.map((language) => (\n              <SelectItem key={language.locale} value={language.locale}>\n                {language.name}\n              </SelectItem>\n            ))}\n          </SelectContent>\n        </Select>\n      </ItemActions>\n    </Item>\n  );\n};\n\nconst ThemeRow = () => {\n  const translate = useTranslate();\n  const { theme, setTheme } = useTheme();\n\n  return (\n    <Item size=\"sm\" className=\"flex-col items-stretch gap-2\">\n      <ItemTitle className=\"font-normal text-muted-foreground\">\n        {translate(\"crm.theme.label\", { _: \"Theme\" })}\n      </ItemTitle>\n      <ToggleGroup\n        type=\"single\"\n        value={theme}\n        onValueChange={(value) =>\n          value && setTheme(value as \"light\" | \"dark\" | \"system\")\n        }\n        size=\"lg\"\n        variant=\"outline\"\n        className=\"w-full\"\n      >\n        <ToggleGroupItem\n          value=\"system\"\n          aria-label={translate(\"crm.theme.system\")}\n          className=\"flex-1 gap-2\"\n        >\n          <Smartphone className=\"size-4\" />\n          {translate(\"crm.theme.system\")}\n        </ToggleGroupItem>\n        <ToggleGroupItem\n          value=\"light\"\n          aria-label={translate(\"crm.theme.light\")}\n          className=\"flex-1 gap-2\"\n        >\n          <Sun className=\"size-4\" />\n          {translate(\"crm.theme.light\")}\n        </ToggleGroupItem>\n        <ToggleGroupItem\n          value=\"dark\"\n          aria-label={translate(\"crm.theme.dark\")}\n          className=\"flex-1 gap-2\"\n        >\n          <Moon className=\"size-4\" />\n          {translate(\"crm.theme.dark\")}\n        </ToggleGroupItem>\n      </ToggleGroup>\n    </Item>\n  );\n};\n\nconst InboundEmailSection = () => {\n  const translate = useTranslate();\n\n  if (!import.meta.env.VITE_INBOUND_EMAIL) return null;\n\n  return (\n    <div>\n      <SectionLabel>{translate(\"crm.profile.inbound.title\")}</SectionLabel>\n      <p className=\"text-sm text-muted-foreground mb-2 px-1\">\n        {translate(\"crm.profile.inbound.description\", {\n          _: \"You can start sending emails to your server's inbound email address, e.g. by adding it to the Cc: field. Atomic CRM will process the emails and add notes to the corresponding contacts.\",\n          field: \"Cc:\",\n        })}\n      </p>\n      <ItemGroup className=\"rounded-lg border overflow-hidden\">\n        <CopyPasteRow value={import.meta.env.VITE_INBOUND_EMAIL} />\n      </ItemGroup>\n    </div>\n  );\n};\n\nconst McpServerSection = () => {\n  const translate = useTranslate();\n\n  return (\n    <div>\n      <SectionLabel>\n        {translate(\"crm.profile.mcp.title\", { _: \"MCP Server\" })}\n      </SectionLabel>\n      <p className=\"text-sm text-muted-foreground mb-2 px-1\">\n        {translate(\"crm.profile.mcp.description\", {\n          _: \"Use this URL to connect your AI assistant to your CRM data via the Model Context Protocol (MCP).\",\n        })}\n      </p>\n      <ItemGroup className=\"rounded-lg border overflow-hidden\">\n        <CopyPasteRow\n          value={`${import.meta.env.VITE_SUPABASE_URL}/functions/v1/mcp`}\n        />\n      </ItemGroup>\n    </div>\n  );\n};\n\nconst AboutSection = () => {\n  const translate = useTranslate();\n\n  return (\n    <div>\n      <SectionLabel>{translate(\"crm.settings.about\")}</SectionLabel>\n      <ItemGroup className=\"rounded-lg border overflow-hidden\">\n        <Item asChild size=\"sm\" className=\"cursor-pointer\">\n          <Link to={ChangelogPage.path}>\n            <ItemContent>\n              <ItemTitle className=\"font-normal\">\n                {translate(\"crm.changelog.title\")}\n              </ItemTitle>\n            </ItemContent>\n            <ItemActions>\n              <ChevronRight className=\"size-4 text-muted-foreground\" />\n            </ItemActions>\n          </Link>\n        </Item>\n      </ItemGroup>\n    </div>\n  );\n};\n\nconst CopyPasteRow = ({ value }: { value: string }) => {\n  const translate = useTranslate();\n  const [copied, setCopied] = useState(false);\n  const handleCopy = () => {\n    setCopied(true);\n    navigator.clipboard.writeText(value);\n    setTimeout(() => {\n      setCopied(false);\n    }, 1500);\n  };\n\n  return (\n    <TooltipProvider>\n      <Tooltip>\n        <TooltipTrigger asChild>\n          <Item\n            size=\"sm\"\n            className=\"cursor-pointer flex-nowrap\"\n            onClick={handleCopy}\n          >\n            <ItemContent className=\"overflow-hidden\">\n              <ItemTitle className=\"font-normal truncate\">{value}</ItemTitle>\n            </ItemContent>\n            <ItemActions className=\"shrink-0\">\n              {copied ? (\n                <Check className=\"size-4 text-muted-foreground\" />\n              ) : (\n                <Copy className=\"size-4 text-muted-foreground\" />\n              )}\n            </ItemActions>\n          </Item>\n        </TooltipTrigger>\n        <TooltipContent>\n          <p>\n            {copied\n              ? translate(\"crm.common.copied\")\n              : translate(\"crm.common.copy\")}\n          </p>\n        </TooltipContent>\n      </Tooltip>\n    </TooltipProvider>\n  );\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/settings/SettingsPage.tsx",
      "content": "/* eslint-disable react-refresh/only-export-components */\nimport { RotateCcw, Save } from \"lucide-react\";\nimport type { RaRecord } from \"ra-core\";\nimport {\n  EditBase,\n  Form,\n  useGetList,\n  useInput,\n  useNotify,\n  useTranslate,\n} from \"ra-core\";\nimport { useCallback, useMemo } from \"react\";\nimport { useFormContext } from \"react-hook-form\";\nimport { Button } from \"@/components/ui/button\";\nimport { Card, CardContent } from \"@/components/ui/card\";\nimport { Separator } from \"@/components/ui/separator\";\nimport { toSlug } from \"@/lib/toSlug\";\nimport { ArrayInput } from \"@/components/admin/array-input\";\nimport { AutocompleteInput } from \"@/components/admin/autocomplete-input\";\nimport { SimpleFormIterator } from \"@/components/admin/simple-form-iterator\";\nimport { TextInput } from \"@/components/admin/text-input\";\n\nimport ImageEditorField from \"../misc/ImageEditorField\";\nimport {\n  useConfigurationContext,\n  useConfigurationUpdater,\n  type ConfigurationContextValue,\n} from \"../root/ConfigurationContext\";\nimport { defaultConfiguration } from \"../root/defaultConfiguration\";\n\nconst SECTIONS = [\n  {\n    id: \"branding\",\n    label: \"crm.settings.sections.branding\",\n    fallback: \"Branding\",\n  },\n  {\n    id: \"companies\",\n    label: \"resources.companies.name\",\n    fallback: \"Companies\",\n  },\n  { id: \"deals\", label: \"resources.deals.name\", fallback: \"Deals\" },\n  { id: \"notes\", label: \"resources.notes.name\", fallback: \"Notes\" },\n  { id: \"tasks\", label: \"resources.tasks.name\", fallback: \"Tasks\" },\n];\n\n/** Ensure every item in a { value, label } array has a value (slug from label). */\nconst ensureValues = (items: { value?: string; label: string }[] | undefined) =>\n  items?.map((item) => ({ ...item, value: item.value || toSlug(item.label) }));\n\ntype ValidateItemsInUseMessages = {\n  duplicate?: (displayName: string, duplicates: string[]) => string;\n  inUse?: (displayName: string, inUse: string[]) => string;\n  validating?: string;\n};\n\n/**\n * Validate that no items were removed if they are still referenced by existing deals.\n * Also rejects duplicate slug values.\n * Returns undefined if valid, or an error message string.\n */\nexport const validateItemsInUse = (\n  items: { value: string; label: string }[] | undefined,\n  deals: RaRecord[] | undefined,\n  fieldName: string,\n  displayName: string,\n  messages?: ValidateItemsInUseMessages,\n) => {\n  if (!items) return undefined;\n  // Check for duplicate slugs\n  const slugs = items.map((i) => i.value || toSlug(i.label));\n  const seen = new Set<string>();\n  const duplicates = new Set<string>();\n  for (const slug of slugs) {\n    if (seen.has(slug)) duplicates.add(slug);\n    seen.add(slug);\n  }\n  if (duplicates.size > 0) {\n    const duplicatesList = [...duplicates];\n    return (\n      messages?.duplicate?.(displayName, duplicatesList) ??\n      `Duplicate ${displayName}: ${duplicatesList.join(\", \")}`\n    );\n  }\n  // Check that no in-use value was removed (skip if deals haven't loaded)\n  if (!deals) return messages?.validating ?? \"Validating…\";\n  const values = new Set(slugs);\n  const inUse = [\n    ...new Set(\n      deals\n        .filter(\n          (deal) => deal[fieldName] && !values.has(deal[fieldName] as string),\n        )\n        .map((deal) => deal[fieldName] as string),\n    ),\n  ];\n  if (inUse.length > 0) {\n    return (\n      messages?.inUse?.(displayName, inUse) ??\n      `Cannot remove ${displayName} that are still used by deals: ${inUse.join(\", \")}`\n    );\n  }\n  return undefined;\n};\n\nconst getCurrencyChoices = () => {\n  const displayNames = new Intl.DisplayNames(\n    typeof navigator !== \"undefined\"\n      ? (navigator.languages as string[])\n      : [\"en\"],\n    { type: \"currency\" },\n  );\n  return Intl.supportedValuesOf(\"currency\").map((code) => ({\n    id: code,\n    name: `${code} – ${displayNames.of(code)}`,\n  }));\n};\n\nconst transformFormValues = (data: Record<string, any>) => ({\n  config: {\n    title: data.title,\n    lightModeLogo: data.lightModeLogo,\n    darkModeLogo: data.darkModeLogo,\n    currency: data.currency,\n    companySectors: ensureValues(data.companySectors),\n    dealCategories: ensureValues(data.dealCategories),\n    taskTypes: ensureValues(data.taskTypes),\n    dealStages: ensureValues(data.dealStages),\n    dealPipelineStatuses: data.dealPipelineStatuses,\n    noteStatuses: ensureValues(data.noteStatuses),\n  } as ConfigurationContextValue,\n});\n\nexport const SettingsPage = () => {\n  const updateConfiguration = useConfigurationUpdater();\n  const notify = useNotify();\n\n  return (\n    <EditBase\n      resource=\"configuration\"\n      id={1}\n      mutationMode=\"pessimistic\"\n      redirect={false}\n      transform={transformFormValues}\n      mutationOptions={{\n        onSuccess: (data: any) => {\n          updateConfiguration(data.config);\n          notify(\"crm.settings.saved\");\n        },\n        onError: () => {\n          notify(\"crm.settings.save_error\", {\n            type: \"error\",\n          });\n        },\n      }}\n    >\n      <SettingsForm />\n    </EditBase>\n  );\n};\n\nSettingsPage.path = \"/settings\";\n\nconst SettingsForm = () => {\n  const config = useConfigurationContext();\n\n  const defaultValues = useMemo(\n    () => ({\n      title: config.title,\n      lightModeLogo: { src: config.lightModeLogo },\n      darkModeLogo: { src: config.darkModeLogo },\n      currency: config.currency,\n      companySectors: config.companySectors,\n      dealCategories: config.dealCategories,\n      taskTypes: config.taskTypes,\n      dealStages: config.dealStages,\n      dealPipelineStatuses: config.dealPipelineStatuses,\n      noteStatuses: config.noteStatuses,\n    }),\n    [config],\n  );\n\n  return (\n    <Form defaultValues={defaultValues}>\n      <SettingsFormFields />\n    </Form>\n  );\n};\n\nconst SettingsFormFields = () => {\n  const translate = useTranslate();\n  const currencyChoices = useMemo(() => getCurrencyChoices(), []);\n  const {\n    watch,\n    setValue,\n    reset,\n    formState: { isSubmitting },\n  } = useFormContext();\n\n  const dealStages = watch(\"dealStages\");\n  const dealPipelineStatuses: string[] = watch(\"dealPipelineStatuses\") ?? [];\n  const stageDisplayName = translate(\"crm.settings.validation.entities.stages\");\n  const categoryDisplayName = translate(\n    \"crm.settings.validation.entities.categories\",\n  );\n\n  const { data: deals } = useGetList(\"deals\", {\n    pagination: { page: 1, perPage: 1000 },\n  });\n\n  const validateDealStages = useCallback(\n    (stages: { value: string; label: string }[] | undefined) =>\n      validateItemsInUse(stages, deals, \"stage\", stageDisplayName, {\n        duplicate: (displayName, duplicates) =>\n          translate(\"crm.settings.validation.duplicate\", {\n            display_name: displayName,\n            items: duplicates.join(\", \"),\n          }),\n        inUse: (displayName, inUse) =>\n          translate(\"crm.settings.validation.in_use\", {\n            display_name: displayName,\n            items: inUse.join(\", \"),\n          }),\n        validating: translate(\"crm.settings.validation.validating\"),\n      }),\n    [deals, stageDisplayName, translate],\n  );\n\n  const validateDealCategories = useCallback(\n    (categories: { value: string; label: string }[] | undefined) =>\n      validateItemsInUse(categories, deals, \"category\", categoryDisplayName, {\n        duplicate: (displayName, duplicates) =>\n          translate(\"crm.settings.validation.duplicate\", {\n            display_name: displayName,\n            items: duplicates.join(\", \"),\n          }),\n        inUse: (displayName, inUse) =>\n          translate(\"crm.settings.validation.in_use\", {\n            display_name: displayName,\n            items: inUse.join(\", \"),\n          }),\n        validating: translate(\"crm.settings.validation.validating\"),\n      }),\n    [categoryDisplayName, deals, translate],\n  );\n\n  return (\n    <div className=\"flex gap-8 mt-4 pb-20\">\n      {/* Left navigation */}\n      <nav className=\"hidden md:block w-48 shrink-0\">\n        <div className=\"sticky top-4 space-y-1\">\n          <h1 className=\"text-2xl font-semibold px-3 mb-2\">\n            {translate(\"crm.settings.title\")}\n          </h1>\n          {SECTIONS.map((section) => (\n            <button\n              key={section.id}\n              type=\"button\"\n              onClick={() => {\n                document\n                  .getElementById(section.id)\n                  ?.scrollIntoView({ behavior: \"smooth\" });\n              }}\n              className=\"block w-full text-left px-3 py-1 text-sm rounded-md hover:text-foreground hover:bg-muted transition-colors\"\n            >\n              {translate(section.label, { smart_count: 2 })}\n            </button>\n          ))}\n        </div>\n      </nav>\n\n      {/* Main content */}\n      <div className=\"flex-1 min-w-0 max-w-2xl space-y-6\">\n        {/* Branding */}\n        <Card id=\"branding\">\n          <CardContent className=\"space-y-4\">\n            <h2 className=\"text-xl font-semibold text-muted-foreground\">\n              {translate(\"crm.settings.sections.branding\")}\n            </h2>\n            <TextInput source=\"title\" label=\"crm.settings.app_title\" />\n            <div className=\"flex gap-8\">\n              <div className=\"flex flex-col items-center gap-1\">\n                <p className=\"text-sm text-muted-foreground\">\n                  {translate(\"crm.settings.light_mode_logo\")}\n                </p>\n                <ImageEditorField\n                  source=\"lightModeLogo\"\n                  width={100}\n                  height={100}\n                  linkPosition=\"bottom\"\n                  backgroundImageColor=\"#f5f5f5\"\n                />\n              </div>\n              <div className=\"flex flex-col items-center gap-1\">\n                <p className=\"text-sm text-muted-foreground\">\n                  {translate(\"crm.settings.dark_mode_logo\")}\n                </p>\n                <ImageEditorField\n                  source=\"darkModeLogo\"\n                  width={100}\n                  height={100}\n                  linkPosition=\"bottom\"\n                  backgroundImageColor=\"#1a1a1a\"\n                />\n              </div>\n            </div>\n          </CardContent>\n        </Card>\n\n        {/* Companies */}\n        <Card id=\"companies\">\n          <CardContent className=\"space-y-4\">\n            <h2 className=\"text-xl font-semibold text-muted-foreground\">\n              {translate(\"resources.companies.name\", {\n                smart_count: 2,\n              })}\n            </h2>\n            <h3 className=\"text-lg font-medium text-muted-foreground\">\n              {translate(\"crm.settings.companies.sectors\")}\n            </h3>\n            <ArrayInput\n              source=\"companySectors\"\n              label={false}\n              helperText={false}\n            >\n              <SimpleFormIterator disableReordering disableClear>\n                <TextInput source=\"label\" label={false} />\n              </SimpleFormIterator>\n            </ArrayInput>\n          </CardContent>\n        </Card>\n\n        {/* Deals */}\n        <Card id=\"deals\">\n          <CardContent className=\"space-y-4\">\n            <h2 className=\"text-xl font-semibold text-muted-foreground\">\n              {translate(\"resources.deals.name\", {\n                smart_count: 2,\n              })}\n            </h2>\n            <h3 className=\"text-lg font-medium text-muted-foreground\">\n              {translate(\"crm.settings.deals.currency\")}\n            </h3>\n            <AutocompleteInput\n              source=\"currency\"\n              label={false}\n              choices={currencyChoices}\n              inputText={(choice) => choice?.id}\n              modal\n            />\n\n            <Separator />\n\n            <h3 className=\"text-lg font-medium text-muted-foreground\">\n              {translate(\"crm.settings.deals.stages\")}\n            </h3>\n            <ArrayInput\n              source=\"dealStages\"\n              label={false}\n              helperText={false}\n              validate={validateDealStages}\n            >\n              <SimpleFormIterator disableClear>\n                <TextInput source=\"label\" label={false} />\n              </SimpleFormIterator>\n            </ArrayInput>\n\n            <Separator />\n\n            <h3 className=\"text-lg font-medium text-muted-foreground\">\n              {translate(\"crm.settings.deals.pipeline_statuses\")}\n            </h3>\n            <p className=\"text-sm text-muted-foreground\">\n              {translate(\"crm.settings.deals.pipeline_help\")}\n            </p>\n            <div className=\"flex flex-wrap gap-2\">\n              {dealStages?.map(\n                (stage: { value: string; label: string }, idx: number) => {\n                  const isSelected = dealPipelineStatuses.includes(stage.value);\n                  return (\n                    <Button\n                      key={idx}\n                      type=\"button\"\n                      variant={isSelected ? \"default\" : \"outline\"}\n                      size=\"sm\"\n                      onClick={() => {\n                        if (isSelected) {\n                          setValue(\n                            \"dealPipelineStatuses\",\n                            dealPipelineStatuses.filter(\n                              (s) => s !== stage.value,\n                            ),\n                          );\n                        } else {\n                          setValue(\"dealPipelineStatuses\", [\n                            ...dealPipelineStatuses,\n                            stage.value,\n                          ]);\n                        }\n                      }}\n                    >\n                      {stage.label || stage.value}\n                    </Button>\n                  );\n                },\n              )}\n            </div>\n\n            <Separator />\n\n            <h3 className=\"text-lg font-medium text-muted-foreground\">\n              {translate(\"crm.settings.deals.categories\")}\n            </h3>\n            <ArrayInput\n              source=\"dealCategories\"\n              label={false}\n              helperText={false}\n              validate={validateDealCategories}\n            >\n              <SimpleFormIterator disableReordering disableClear>\n                <TextInput source=\"label\" label={false} />\n              </SimpleFormIterator>\n            </ArrayInput>\n          </CardContent>\n        </Card>\n\n        {/* Notes */}\n        <Card id=\"notes\">\n          <CardContent className=\"space-y-4\">\n            <h2 className=\"text-xl font-semibold text-muted-foreground\">\n              {translate(\"resources.notes.name\", {\n                smart_count: 2,\n              })}\n            </h2>\n            <h3 className=\"text-lg font-medium text-muted-foreground\">\n              {translate(\"crm.settings.notes.statuses\")}\n            </h3>\n            <ArrayInput source=\"noteStatuses\" label={false} helperText={false}>\n              <SimpleFormIterator inline disableReordering disableClear>\n                <TextInput source=\"label\" label={false} className=\"flex-1\" />\n                <ColorInput source=\"color\" />\n              </SimpleFormIterator>\n            </ArrayInput>\n          </CardContent>\n        </Card>\n\n        {/* Tasks */}\n        <Card id=\"tasks\">\n          <CardContent className=\"space-y-4\">\n            <h2 className=\"text-xl font-semibold text-muted-foreground\">\n              {translate(\"resources.tasks.name\", {\n                smart_count: 2,\n              })}\n            </h2>\n            <h3 className=\"text-lg font-medium text-muted-foreground\">\n              {translate(\"crm.settings.tasks.types\")}\n            </h3>\n            <ArrayInput source=\"taskTypes\" label={false} helperText={false}>\n              <SimpleFormIterator disableReordering disableClear>\n                <TextInput source=\"label\" label={false} />\n              </SimpleFormIterator>\n            </ArrayInput>\n          </CardContent>\n        </Card>\n      </div>\n\n      {/* Sticky save button */}\n      <div className=\"fixed bottom-0 left-0 right-0 border-t bg-background p-4\">\n        <div className=\"max-w-screen-xl mx-auto flex gap-8 px-4\">\n          <div className=\"hidden md:block w-48 shrink-0\" />\n          <div className=\"flex-1 min-w-0 max-w-2xl flex justify-between\">\n            <Button\n              type=\"button\"\n              variant=\"ghost\"\n              onClick={() =>\n                reset({\n                  ...defaultConfiguration,\n                  lightModeLogo: {\n                    src: defaultConfiguration.lightModeLogo,\n                  },\n                  darkModeLogo: { src: defaultConfiguration.darkModeLogo },\n                })\n              }\n            >\n              <RotateCcw className=\"h-4 w-4 mr-1\" />\n              {translate(\"crm.settings.reset_defaults\")}\n            </Button>\n            <div className=\"flex gap-2\">\n              <Button\n                type=\"button\"\n                variant=\"outline\"\n                onClick={() => window.history.back()}\n              >\n                {translate(\"ra.action.cancel\")}\n              </Button>\n              <Button type=\"submit\" disabled={isSubmitting}>\n                <Save className=\"h-4 w-4 mr-1\" />\n                {isSubmitting\n                  ? translate(\"crm.settings.saving\")\n                  : translate(\"ra.action.save\")}\n              </Button>\n            </div>\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n};\n\n/** A minimal color picker input compatible with ra-core's useInput. */\nconst ColorInput = ({ source }: { source: string }) => {\n  const { field } = useInput({ source });\n  return (\n    <input\n      type=\"color\"\n      {...field}\n      value={field.value || \"#000000\"}\n      className=\"w-9 h-9 shrink-0 cursor-pointer appearance-none rounded border bg-transparent p-0.5 [&::-webkit-color-swatch-wrapper]:cursor-pointer [&::-webkit-color-swatch-wrapper]:p-0 [&::-webkit-color-swatch]:cursor-pointer [&::-webkit-color-swatch]:rounded-sm [&::-webkit-color-swatch]:border-none [&::-moz-color-swatch]:cursor-pointer [&::-moz-color-swatch]:rounded-sm [&::-moz-color-swatch]:border-none\"\n    />\n  );\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/settings/ProfilePage.tsx",
      "content": "import { useMutation } from \"@tanstack/react-query\";\nimport { Check, CircleX, Copy, Pencil, Save } from \"lucide-react\";\nimport {\n  Form,\n  useDataProvider,\n  useGetIdentity,\n  useGetOne,\n  useLocaleState,\n  useLocales,\n  useNotify,\n  useRecordContext,\n  useTranslate,\n} from \"ra-core\";\nimport { useState } from \"react\";\nimport { useFormState } from \"react-hook-form\";\nimport { RecordField } from \"@/components/admin/record-field\";\nimport { TextInput } from \"@/components/admin/text-input\";\nimport { Button } from \"@/components/ui/button\";\nimport { Card, CardContent } from \"@/components/ui/card\";\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@/components/ui/select\";\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from \"@/components/ui/tooltip\";\n\nimport ImageEditorField from \"../misc/ImageEditorField\";\nimport type { CrmDataProvider } from \"../providers/types\";\nimport type { Sale, SalesFormData } from \"../types\";\n\nexport const ProfilePage = () => {\n  const [isEditMode, setEditMode] = useState(false);\n  const { identity, refetch: refetchIdentity } = useGetIdentity();\n  const { data, refetch: refetchUser } = useGetOne(\"sales\", {\n    id: identity?.id,\n  });\n  const translate = useTranslate();\n  const notify = useNotify();\n  const dataProvider = useDataProvider<CrmDataProvider>();\n\n  const { mutate } = useMutation({\n    mutationKey: [\"signup\"],\n    mutationFn: async (data: SalesFormData) => {\n      if (!identity) {\n        throw new Error(\n          translate(\"crm.profile.record_not_found\", {\n            _: \"Record not found\",\n          }),\n        );\n      }\n      return dataProvider.salesUpdate(identity.id, data);\n    },\n    onSuccess: () => {\n      refetchIdentity();\n      refetchUser();\n      setEditMode(false);\n      notify(\"crm.profile.updated\", {\n        messageArgs: {\n          _: \"Your profile has been updated\",\n        },\n      });\n    },\n    onError: (_) => {\n      notify(\"crm.profile.update_error\", {\n        type: \"error\",\n        messageArgs: {\n          _: \"An error occurred. Please try again\",\n        },\n      });\n    },\n  });\n\n  if (!identity) return null;\n\n  const handleOnSubmit = async (values: any) => {\n    mutate(values);\n  };\n\n  return (\n    <div className=\"max-w-lg mx-auto mt-8\">\n      <Form onSubmit={handleOnSubmit} record={data}>\n        <ProfileForm isEditMode={isEditMode} setEditMode={setEditMode} />\n      </Form>\n    </div>\n  );\n};\n\nconst ProfileForm = ({\n  isEditMode,\n  setEditMode,\n}: {\n  isEditMode: boolean;\n  setEditMode: (value: boolean) => void;\n}) => {\n  const notify = useNotify();\n  const translate = useTranslate();\n  const record = useRecordContext<Sale>();\n  const { identity, refetch } = useGetIdentity();\n  const { isDirty } = useFormState();\n  const dataProvider = useDataProvider<CrmDataProvider>();\n\n  const { mutate: updatePassword } = useMutation({\n    mutationKey: [\"updatePassword\"],\n    mutationFn: async () => {\n      if (!identity) {\n        throw new Error(\n          translate(\"crm.profile.record_not_found\", {\n            _: \"Record not found\",\n          }),\n        );\n      }\n      return dataProvider.updatePassword(identity.id);\n    },\n    onSuccess: () => {\n      notify(\"crm.profile.password_reset_sent\", {\n        messageArgs: {\n          _: \"A reset password email has been sent to your email address\",\n        },\n      });\n    },\n    onError: (e) => {\n      notify(`${e}`, {\n        type: \"error\",\n      });\n    },\n  });\n\n  const { mutate: mutateSale } = useMutation({\n    mutationKey: [\"signup\"],\n    mutationFn: async (data: SalesFormData) => {\n      if (!record) {\n        throw new Error(\n          translate(\"crm.profile.record_not_found\", {\n            _: \"Record not found\",\n          }),\n        );\n      }\n      return dataProvider.salesUpdate(record.id, data);\n    },\n    onSuccess: () => {\n      refetch();\n      notify(\"crm.profile.updated\", {\n        messageArgs: {\n          _: \"Your profile has been updated\",\n        },\n      });\n    },\n    onError: () => {\n      notify(\"crm.profile.update_error\", {\n        type: \"error\",\n        messageArgs: {\n          _: \"An error occurred. Please try again.\",\n        },\n      });\n    },\n  });\n  if (!identity) return null;\n\n  const handleClickOpenPasswordChange = () => {\n    updatePassword();\n  };\n\n  const handleAvatarUpdate = async (values: any) => {\n    mutateSale(values);\n  };\n\n  return (\n    <div className=\"space-y-4\">\n      <Card>\n        <CardContent>\n          <div className=\"mb-4 flex flex-row justify-between\">\n            <h2 className=\"text-xl font-semibold text-muted-foreground\">\n              {translate(\"crm.profile.title\")}\n            </h2>\n          </div>\n\n          <div className=\"space-y-4 mb-4\">\n            <ImageEditorField\n              source=\"avatar\"\n              type=\"avatar\"\n              onSave={handleAvatarUpdate}\n              linkPosition=\"right\"\n            />\n            <div className=\"grid grid-cols-1 sm:grid-cols-2 gap-4\">\n              <TextRender source=\"first_name\" isEditMode={isEditMode} />\n              <TextRender source=\"last_name\" isEditMode={isEditMode} />\n            </div>\n            <TextRender source=\"email\" isEditMode={isEditMode} />\n            <LanguageSelector />\n          </div>\n\n          <div className=\"flex flex-row justify-end gap-2\">\n            {!isEditMode && (\n              <>\n                <Button\n                  variant=\"outline\"\n                  type=\"button\"\n                  onClick={handleClickOpenPasswordChange}\n                >\n                  {translate(\"crm.profile.password.change\")}\n                </Button>\n              </>\n            )}\n\n            <Button\n              type=\"button\"\n              variant={isEditMode ? \"ghost\" : \"outline\"}\n              onClick={() => setEditMode(!isEditMode)}\n              className=\"flex items-center\"\n            >\n              {isEditMode ? <CircleX /> : <Pencil />}\n              {isEditMode\n                ? translate(\"ra.action.cancel\")\n                : translate(\"ra.action.edit\")}\n            </Button>\n\n            {isEditMode && (\n              <Button type=\"submit\" disabled={!isDirty} variant=\"outline\">\n                <Save />\n                {translate(\"ra.action.save\")}\n              </Button>\n            )}\n          </div>\n        </CardContent>\n      </Card>\n      {import.meta.env.VITE_INBOUND_EMAIL && (\n        <Card>\n          <CardContent>\n            <div className=\"space-y-4 justify-between\">\n              <h2 className=\"text-xl font-semibold text-muted-foreground\">\n                {translate(\"crm.profile.inbound.title\")}\n              </h2>\n              <p className=\"text-sm text-muted-foreground\">\n                {translate(\"crm.profile.inbound.description\", {\n                  _: \"You can start sending emails to your server's inbound email address, e.g. by adding it to the Cc: field. Atomic CRM will process the emails and add notes to the corresponding contacts.\",\n                  field: \"Cc:\",\n                })}\n              </p>\n              <CopyPaste value={import.meta.env.VITE_INBOUND_EMAIL} />\n            </div>\n          </CardContent>\n        </Card>\n      )}\n      <Card>\n        <CardContent>\n          <div className=\"space-y-4 justify-between\">\n            <h2 className=\"text-xl font-semibold text-muted-foreground\">\n              {translate(\"crm.profile.mcp.title\", {\n                _: \"MCP Server\",\n              })}\n            </h2>\n            <p className=\"text-sm text-muted-foreground\">\n              {translate(\"crm.profile.mcp.description\", {\n                _: \"Use this URL to connect your AI assistant to your CRM data via the Model Context Protocol (MCP).\",\n              })}\n            </p>\n            <CopyPaste\n              value={`${import.meta.env.VITE_SUPABASE_URL}/functions/v1/mcp`}\n            />\n          </div>\n        </CardContent>\n      </Card>\n    </div>\n  );\n};\n\nconst LanguageSelector = () => {\n  const translate = useTranslate();\n  const locales = useLocales();\n  const [locale, setLocale] = useLocaleState();\n\n  if (locales.length <= 1) {\n    return null;\n  }\n\n  return (\n    <div className=\"space-y-2\">\n      <p className=\"text-xs text-muted-foreground\">\n        {translate(\"crm.language\")}\n      </p>\n      <Select value={locale} onValueChange={setLocale}>\n        <SelectTrigger className=\"w-full\">\n          <SelectValue />\n        </SelectTrigger>\n        <SelectContent>\n          {locales.map((language) => (\n            <SelectItem key={language.locale} value={language.locale}>\n              {language.name}\n            </SelectItem>\n          ))}\n        </SelectContent>\n      </Select>\n    </div>\n  );\n};\n\nconst TextRender = ({\n  source,\n  isEditMode,\n  className,\n}: {\n  source: string;\n  isEditMode: boolean;\n  className?: string;\n}) => {\n  const label = `resources.sales.fields.${source}`;\n  if (isEditMode) {\n    return (\n      <TextInput\n        source={source}\n        label={label}\n        helperText={false}\n        className={className}\n      />\n    );\n  }\n  return (\n    <div className={className}>\n      <RecordField source={source} label={label} />\n    </div>\n  );\n};\n\nconst CopyPaste = ({ value }: { value: string }) => {\n  const translate = useTranslate();\n  const [copied, setCopied] = useState(false);\n  const handleCopy = () => {\n    setCopied(true);\n    navigator.clipboard.writeText(value);\n    setTimeout(() => {\n      setCopied(false);\n    }, 1500);\n  };\n  return (\n    <TooltipProvider>\n      <Tooltip>\n        <TooltipTrigger asChild>\n          <Button\n            type=\"button\"\n            onClick={handleCopy}\n            variant=\"ghost\"\n            className=\"normal-case justify-between w-full\"\n          >\n            <span className=\"overflow-hidden text-ellipsis\">{value}</span>\n            {copied ? (\n              <Check className=\"h-4 w-4 ml-2\" />\n            ) : (\n              <Copy className=\"h-4 w-4 ml-2\" />\n            )}\n          </Button>\n        </TooltipTrigger>\n        <TooltipContent>\n          <p>\n            {copied\n              ? translate(\"crm.common.copied\")\n              : translate(\"crm.common.copy\")}\n          </p>\n        </TooltipContent>\n      </Tooltip>\n    </TooltipProvider>\n  );\n};\n\nProfilePage.path = \"/profile\";\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/sales/useGetSalesName.ts",
      "content": "import type { Identifier } from \"ra-core\";\nimport { useGetManyAggregate } from \"ra-core\";\n\nexport const useGetSalesName = (\n  id?: Identifier,\n  options?: { enabled?: boolean },\n) => {\n  const enabled = options?.enabled ?? id != null;\n  const { data, error } = useGetManyAggregate(\n    \"sales\",\n    { ids: id !== null ? [id] : undefined },\n    { enabled },\n  );\n\n  return data && data[0]\n    ? `${data[0].first_name} ${data[0].last_name}`\n    : error\n      ? \"??\"\n      : \"\";\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/sales/index.ts",
      "content": "import type { Sale } from \"../types\";\nimport { SalesCreate } from \"./SalesCreate\";\nimport { SalesEdit } from \"./SalesEdit\";\nimport { SalesList } from \"./SalesList\";\n\nexport default {\n  list: SalesList,\n  create: SalesCreate,\n  edit: SalesEdit,\n  recordRepresentation: (record: Sale) =>\n    `${record.first_name} ${record.last_name}`,\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/sales/SalesList.tsx",
      "content": "import { useRecordContext, useTranslate } from \"ra-core\";\nimport { CreateButton } from \"@/components/admin/create-button\";\nimport { DataTable } from \"@/components/admin/data-table\";\nimport { ExportButton } from \"@/components/admin/export-button\";\nimport { List } from \"@/components/admin/list\";\nimport { SearchInput } from \"@/components/admin/search-input\";\nimport { Badge } from \"@/components/ui/badge\";\n\nimport { TopToolbar } from \"../layout/TopToolbar\";\n\nconst SalesListActions = () => (\n  <TopToolbar>\n    <ExportButton />\n    <CreateButton label=\"resources.sales.action.new\" />\n  </TopToolbar>\n);\n\nconst filters = [<SearchInput source=\"q\" alwaysOn />];\n\nconst OptionsField = (_props: { label?: string | boolean }) => {\n  const record = useRecordContext();\n  const translate = useTranslate();\n  if (!record) return null;\n  return (\n    <div className=\"flex flex-row gap-1\">\n      {record.administrator && (\n        <Badge\n          variant=\"outline\"\n          className=\"border-blue-300 dark:border-blue-700\"\n        >\n          {translate(\"resources.sales.fields.administrator\")}\n        </Badge>\n      )}\n      {record.disabled && (\n        <Badge\n          variant=\"outline\"\n          className=\"border-orange-300 dark:border-orange-700\"\n        >\n          {translate(\"resources.sales.fields.disabled\")}\n        </Badge>\n      )}\n    </div>\n  );\n};\n\nexport function SalesList() {\n  return (\n    <List\n      filters={filters}\n      actions={<SalesListActions />}\n      sort={{ field: \"first_name\", order: \"ASC\" }}\n    >\n      <DataTable>\n        <DataTable.Col source=\"first_name\" />\n        <DataTable.Col source=\"last_name\" />\n        <DataTable.Col source=\"email\" />\n        <DataTable.Col label={false}>\n          <OptionsField />\n        </DataTable.Col>\n      </DataTable>\n    </List>\n  );\n}\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/sales/SalesInputs.tsx",
      "content": "import { email, required, useGetIdentity, useRecordContext } from \"ra-core\";\nimport { BooleanInput } from \"@/components/admin/boolean-input\";\nimport { TextInput } from \"@/components/admin/text-input\";\n\nimport type { Sale } from \"../types\";\n\nexport function SalesInputs() {\n  const { identity } = useGetIdentity();\n  const record = useRecordContext<Sale>();\n  return (\n    <div className=\"space-y-4 w-full\">\n      <TextInput source=\"first_name\" validate={required()} helperText={false} />\n      <TextInput source=\"last_name\" validate={required()} helperText={false} />\n      <TextInput\n        source=\"email\"\n        validate={[required(), email()]}\n        helperText={false}\n      />\n      <BooleanInput\n        source=\"administrator\"\n        readOnly={record?.id === identity?.id}\n        helperText={false}\n      />\n      <BooleanInput\n        source=\"disabled\"\n        readOnly={record?.id === identity?.id}\n        helperText={false}\n      />\n    </div>\n  );\n}\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/sales/SalesEdit.tsx",
      "content": "import { useMutation } from \"@tanstack/react-query\";\nimport {\n  useDataProvider,\n  useEditController,\n  useNotify,\n  useRecordContext,\n  useRedirect,\n  useTranslate,\n} from \"ra-core\";\nimport type { SubmitHandler } from \"react-hook-form\";\nimport { SimpleForm } from \"@/components/admin/simple-form\";\nimport { CancelButton } from \"@/components/admin/cancel-button\";\nimport { SaveButton } from \"@/components/admin/form\";\nimport { Card, CardContent } from \"@/components/ui/card\";\n\nimport type { CrmDataProvider } from \"../providers/types\";\nimport type { Sale, SalesFormData } from \"../types\";\nimport { SalesInputs } from \"./SalesInputs\";\n\nfunction EditToolbar() {\n  return (\n    <div className=\"flex justify-end gap-4\">\n      <CancelButton />\n      <SaveButton />\n    </div>\n  );\n}\n\nexport function SalesEdit() {\n  const { record } = useEditController();\n\n  const dataProvider = useDataProvider<CrmDataProvider>();\n  const notify = useNotify();\n  const redirect = useRedirect();\n  const translate = useTranslate();\n\n  const { mutate } = useMutation({\n    mutationKey: [\"signup\"],\n    mutationFn: async (data: SalesFormData) => {\n      if (!record) {\n        throw new Error(\n          translate(\"resources.sales.edit.record_not_found\", {\n            _: \"Record not found\",\n          }),\n        );\n      }\n      return dataProvider.salesUpdate(record.id, data);\n    },\n    onSuccess: () => {\n      redirect(\"/sales\");\n      notify(\"resources.sales.edit.success\", {\n        messageArgs: {\n          _: \"User updated successfully\",\n        },\n      });\n    },\n    onError: () => {\n      notify(\"resources.sales.edit.error\", {\n        type: \"error\",\n        messageArgs: {\n          _: \"An error occurred. Please try again.\",\n        },\n      });\n    },\n  });\n\n  const onSubmit: SubmitHandler<SalesFormData> = async (data) => {\n    mutate(data);\n  };\n\n  return (\n    <div className=\"max-w-lg w-full mx-auto mt-8\">\n      <Card>\n        <CardContent>\n          <SimpleForm\n            toolbar={<EditToolbar />}\n            onSubmit={onSubmit as SubmitHandler<any>}\n            record={record}\n          >\n            <SaleEditTitle />\n            <SalesInputs />\n          </SimpleForm>\n        </CardContent>\n      </Card>\n    </div>\n  );\n}\n\nconst SaleEditTitle = () => {\n  const record = useRecordContext<Sale>();\n  const translate = useTranslate();\n  if (!record) return null;\n  return (\n    <h2 className=\"text-lg font-semibold mb-4\">\n      {translate(\"resources.sales.edit.title\", {\n        name: `${record.first_name} ${record.last_name}`,\n      })}\n    </h2>\n  );\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/sales/SalesCreate.tsx",
      "content": "import { useMutation } from \"@tanstack/react-query\";\nimport { useDataProvider, useNotify, useRedirect, useTranslate } from \"ra-core\";\nimport type { SubmitHandler } from \"react-hook-form\";\nimport { SimpleForm } from \"@/components/admin/simple-form\";\nimport { Card, CardContent, CardHeader, CardTitle } from \"@/components/ui/card\";\n\nimport type { CrmDataProvider } from \"../providers/types\";\nimport type { SalesFormData } from \"../types\";\nimport { SalesInputs } from \"./SalesInputs\";\n\nexport function SalesCreate() {\n  const dataProvider = useDataProvider<CrmDataProvider>();\n  const notify = useNotify();\n  const translate = useTranslate();\n  const redirect = useRedirect();\n\n  const { mutate } = useMutation({\n    mutationKey: [\"signup\"],\n    mutationFn: async (data: SalesFormData) => {\n      return dataProvider.salesCreate(data);\n    },\n    onSuccess: () => {\n      notify(\"resources.sales.create.success\", {\n        messageArgs: {\n          _: \"User created. They will soon receive an email to set their password.\",\n        },\n      });\n      redirect(\"/sales\");\n    },\n    onError: (error) => {\n      notify(\n        error.message ||\n          translate(\"resources.sales.create.error\", {\n            _: \"An error occurred while creating the user.\",\n          }),\n        {\n          type: \"error\",\n        },\n      );\n    },\n  });\n  const onSubmit: SubmitHandler<SalesFormData> = async (data) => {\n    mutate(data);\n  };\n\n  return (\n    <div className=\"max-w-lg w-full mx-auto mt-8\">\n      <Card>\n        <CardHeader>\n          <CardTitle>\n            {translate(\"resources.sales.create.title\", {\n              _: \"Create a new user\",\n            })}\n          </CardTitle>\n        </CardHeader>\n        <CardContent>\n          <SimpleForm onSubmit={onSubmit as SubmitHandler<any>}>\n            <SalesInputs />\n          </SimpleForm>\n        </CardContent>\n      </Card>\n    </div>\n  );\n}\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/root/useConfigurationLoader.ts",
      "content": "import { useQuery } from \"@tanstack/react-query\";\nimport { useEffect } from \"react\";\nimport { useDataProvider } from \"ra-core\";\n\nimport type { CrmDataProvider } from \"../providers/types\";\nimport {\n  useConfigurationUpdater,\n  type ConfigurationContextValue,\n} from \"./ConfigurationContext\";\n\nexport const useConfigurationLoader = () => {\n  const dataProvider = useDataProvider<CrmDataProvider>();\n  const updateConfiguration = useConfigurationUpdater();\n\n  const { data } = useQuery<ConfigurationContextValue>({\n    queryKey: [\"configuration\"],\n    queryFn: () => dataProvider.getConfiguration(),\n    staleTime: 1000 * 60 * 5, // 5 minutes\n    retry: false,\n  });\n\n  useEffect(() => {\n    if (data && Object.keys(data).length > 0) {\n      updateConfiguration(data);\n    }\n  }, [data, updateConfiguration]);\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/root/defaultConfiguration.ts",
      "content": "import type { ConfigurationContextValue } from \"./ConfigurationContext\";\n// Import the logos as module assets so Vite resolves their URL relative to the\n// JS chunk (import.meta.url), not the current route. A plain \"./logos/...\" path\n// breaks on nested routes like /oauth/consent and under a deployment sub-path.\nimport darkModeLogo from \"./logos/logo_atomic_crm_dark.svg\";\nimport lightModeLogo from \"./logos/logo_atomic_crm_light.svg\";\n\nexport const defaultDarkModeLogo = darkModeLogo;\nexport const defaultLightModeLogo = lightModeLogo;\n\nexport const defaultCurrency = \"USD\";\n\nexport const defaultTitle = \"Atomic CRM\";\n\nexport const defaultCompanySectors = [\n  { value: \"communication-services\", label: \"Communication Services\" },\n  { value: \"consumer-discretionary\", label: \"Consumer Discretionary\" },\n  { value: \"consumer-staples\", label: \"Consumer Staples\" },\n  { value: \"energy\", label: \"Energy\" },\n  { value: \"financials\", label: \"Financials\" },\n  { value: \"health-care\", label: \"Health Care\" },\n  { value: \"industrials\", label: \"Industrials\" },\n  { value: \"information-technology\", label: \"Information Technology\" },\n  { value: \"materials\", label: \"Materials\" },\n  { value: \"real-estate\", label: \"Real Estate\" },\n  { value: \"utilities\", label: \"Utilities\" },\n];\n\nexport const defaultDealStages = [\n  { value: \"opportunity\", label: \"Opportunity\" },\n  { value: \"proposal-sent\", label: \"Proposal Sent\" },\n  { value: \"in-negociation\", label: \"In Negotiation\" },\n  { value: \"won\", label: \"Won\" },\n  { value: \"lost\", label: \"Lost\" },\n  { value: \"delayed\", label: \"Delayed\" },\n];\n\nexport const defaultDealPipelineStatuses = [\"won\"];\n\nexport const defaultDealCategories = [\n  { value: \"other\", label: \"Other\" },\n  { value: \"copywriting\", label: \"Copywriting\" },\n  { value: \"print-project\", label: \"Print project\" },\n  { value: \"ui-design\", label: \"UI Design\" },\n  { value: \"website-design\", label: \"Website design\" },\n];\n\nexport const defaultNoteStatuses = [\n  { value: \"cold\", label: \"Cold\", color: \"#7dbde8\" },\n  { value: \"warm\", label: \"Warm\", color: \"#e8cb7d\" },\n  { value: \"hot\", label: \"Hot\", color: \"#e88b7d\" },\n  { value: \"in-contract\", label: \"In Contract\", color: \"#a4e87d\" },\n];\n\nexport const defaultTaskTypes = [\n  { value: \"none\", label: \"None\" },\n  { value: \"email\", label: \"Email\" },\n  { value: \"demo\", label: \"Demo\" },\n  { value: \"lunch\", label: \"Lunch\" },\n  { value: \"meeting\", label: \"Meeting\" },\n  { value: \"follow-up\", label: \"Follow-up\" },\n  { value: \"thank-you\", label: \"Thank you\" },\n  { value: \"ship\", label: \"Ship\" },\n  { value: \"call\", label: \"Call\" },\n];\n\nexport const defaultConfiguration: ConfigurationContextValue = {\n  companySectors: defaultCompanySectors,\n  currency: defaultCurrency,\n  dealCategories: defaultDealCategories,\n  dealPipelineStatuses: defaultDealPipelineStatuses,\n  dealStages: defaultDealStages,\n  noteStatuses: defaultNoteStatuses,\n  taskTypes: defaultTaskTypes,\n  title: defaultTitle,\n  darkModeLogo: defaultDarkModeLogo,\n  lightModeLogo: defaultLightModeLogo,\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/root/ConfigurationContext.tsx",
      "content": "import { useMemo } from \"react\";\nimport { useStore } from \"ra-core\";\n\nimport type { DealStage, LabeledValue, NoteStatus } from \"../types\";\nimport { defaultConfiguration } from \"./defaultConfiguration\";\n\nexport const CONFIGURATION_STORE_KEY = \"app.configuration\";\n\nexport interface ConfigurationContextValue {\n  companySectors: LabeledValue[];\n  currency: string;\n  dealCategories: LabeledValue[];\n  dealPipelineStatuses: string[];\n  dealStages: DealStage[];\n  noteStatuses: NoteStatus[];\n  taskTypes: LabeledValue[];\n  title: string;\n  darkModeLogo: string;\n  lightModeLogo: string;\n}\n\nexport const useConfigurationContext = () => {\n  const [config] = useStore<ConfigurationContextValue>(\n    CONFIGURATION_STORE_KEY,\n    defaultConfiguration,\n  );\n  // Merge with defaults so that missing fields in stored config\n  // fall back to default values (e.g. when new settings are added)\n  return useMemo(() => ({ ...defaultConfiguration, ...config }), [config]);\n};\n\nexport const useConfigurationUpdater = () => {\n  const [, setConfig] = useStore<ConfigurationContextValue>(\n    CONFIGURATION_STORE_KEY,\n  );\n  return setConfig;\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/root/CRM.tsx",
      "content": "import type {\n  CoreAdminProps,\n  AuthProvider,\n  DashboardComponent,\n  LayoutComponent,\n} from \"ra-core\";\nimport { CustomRoutes, localStorageStore, Resource } from \"ra-core\";\nimport { useEffect, useMemo } from \"react\";\nimport { Route } from \"react-router\";\nimport { QueryClient } from \"@tanstack/react-query\";\nimport { PersistQueryClientProvider } from \"@tanstack/react-query-persist-client\";\nimport { createAsyncStoragePersister } from \"@tanstack/query-async-storage-persister\";\nimport { Admin } from \"@/components/admin/admin\";\nimport { ForgotPasswordPage } from \"@/components/supabase/forgot-password-page\";\nimport { SetPasswordPage } from \"@/components/supabase/set-password-page\";\nimport { OAuthConsentPage } from \"@/components/supabase/oauth-consent-page\";\n\nimport companies from \"../companies\";\nimport contacts from \"../contacts\";\nimport { Dashboard } from \"../dashboard/Dashboard\";\nimport { MobileDashboard } from \"../dashboard/MobileDashboard\";\nimport deals from \"../deals\";\nimport { Layout } from \"../layout/Layout\";\nimport { MobileLayout } from \"../layout/MobileLayout\";\nimport { SignupPage } from \"../login/SignupPage\";\nimport { ConfirmationRequired } from \"../login/ConfirmationRequired\";\nimport { ImportPage } from \"../misc/ImportPage\";\nimport { ChangelogPage } from \"../misc/ChangelogPage\";\nimport {\n  getAuthProvider as defaultAuthProviderBuilder,\n  getDataProvider as defaultDataProviderBuilder,\n} from \"../providers/supabase\";\nimport sales from \"../sales\";\nimport { SettingsPageMobile } from \"../settings/SettingsPageMobile\";\nimport { ProfilePage } from \"../settings/ProfilePage\";\nimport { SettingsPage } from \"../settings/SettingsPage\";\nimport {\n  CONFIGURATION_STORE_KEY,\n  type ConfigurationContextValue,\n} from \"./ConfigurationContext\";\nimport type { CrmDataProvider } from \"../providers/types\";\nimport {\n  defaultCompanySectors,\n  defaultCurrency,\n  defaultDarkModeLogo,\n  defaultDealCategories,\n  defaultDealPipelineStatuses,\n  defaultDealStages,\n  defaultLightModeLogo,\n  defaultNoteStatuses,\n  defaultTaskTypes,\n  defaultTitle,\n} from \"./defaultConfiguration\";\nimport { i18nProvider as defaulti18nProvider } from \"../providers/commons/i18nProvider\";\nimport { StartPage } from \"../login/StartPage.tsx\";\nimport { useIsMobile } from \"@/hooks/use-mobile.ts\";\nimport { MobileTasksList } from \"../tasks/MobileTasksList.tsx\";\nimport { ContactListMobile } from \"../contacts/ContactList.tsx\";\nimport { ContactShow } from \"../contacts/ContactShow.tsx\";\nimport { CompanyShow } from \"../companies/CompanyShow.tsx\";\nimport { NoteShowPage } from \"../notes/NoteShowPage.tsx\";\n\nconst defaultStore = localStorageStore(undefined, \"CRM\");\n\nexport type CRMProps = {\n  dataProvider?: CrmDataProvider;\n  authProvider?: AuthProvider;\n  i18nProvider?: CoreAdminProps[\"i18nProvider\"];\n  disableTelemetry?: boolean;\n  store?: CoreAdminProps[\"store\"];\n  dashboard?: DashboardComponent;\n  layout?: LayoutComponent;\n} & Partial<ConfigurationContextValue>;\n\n/**\n * CRM Component\n *\n * This component sets up and renders the main CRM application using `ra-core`. It provides\n * default configurations and themes but allows for customization through props. The component\n * seeds the store with any custom prop values for backwards compatibility.\n *\n * @param {LabeledValue[]} companySectors - The list of company sectors used in the application.\n * @param {string} currency - The ISO 4217 currency code used to format monetary values (e.g. \"USD\", \"EUR\", \"GBP\").\n * @param {RaThemeOptions} darkTheme - The theme to use when the application is in dark mode.\n * @param {LabeledValue[]} dealCategories - The categories of deals used in the application.\n * @param {string[]} dealPipelineStatuses - The statuses of deals in the pipeline used in the application.\n * @param {DealStage[]} dealStages - The stages of deals used in the application.\n * @param {RaThemeOptions} lightTheme - The theme to use when the application is in light mode.\n * @param {string} darkModeLogo - Logo shown in dark mode and on the auth pages. Must be an imported asset, an absolute URL, or a data URI — never a route-relative path like \"./logos/x.svg\", which breaks on nested routes such as /oauth/consent (issue #291).\n * @param {string} lightModeLogo - Logo shown in light mode. Same rule as darkModeLogo: imported asset, absolute URL, or data URI only.\n * @param {NoteStatus[]} noteStatuses - The statuses of notes used in the application.\n * @param {LabeledValue[]} taskTypes - The types of tasks used in the application.\n * @param {string} title - The title of the CRM application.\n *\n * @returns {JSX.Element} The rendered CRM application.\n *\n * @example\n * // Basic usage of the CRM component\n * import { CRM } from '@/components/atomic-crm/dashboard/CRM';\n *\n * const App = () => (\n *     <CRM\n *         darkModeLogo=\"https://example.com/logo-dark.svg\"\n *         lightModeLogo=\"https://example.com/logo-light.svg\"\n *         title=\"My Custom CRM\"\n *         lightTheme={{\n *             ...defaultTheme,\n *             palette: {\n *                 primary: { main: '#0000ff' },\n *             },\n *         }}\n *     />\n * );\n *\n * export default App;\n */\nexport const CRM = ({\n  companySectors = defaultCompanySectors,\n  currency = defaultCurrency,\n  dealCategories = defaultDealCategories,\n  dealPipelineStatuses = defaultDealPipelineStatuses,\n  dealStages = defaultDealStages,\n  darkModeLogo = defaultDarkModeLogo,\n  lightModeLogo = defaultLightModeLogo,\n  noteStatuses = defaultNoteStatuses,\n  taskTypes = defaultTaskTypes,\n  title = defaultTitle,\n  dataProvider = defaultDataProviderBuilder(),\n  authProvider = defaultAuthProviderBuilder(),\n  i18nProvider = defaulti18nProvider,\n  store = defaultStore,\n  disableTelemetry,\n  ...rest\n}: CRMProps) => {\n  useEffect(() => {\n    if (\n      disableTelemetry ||\n      process.env.NODE_ENV !== \"production\" ||\n      typeof window === \"undefined\" ||\n      typeof window.location === \"undefined\" ||\n      typeof Image === \"undefined\"\n    ) {\n      return;\n    }\n    const img = new Image();\n    img.src = `https://atomic-crm-telemetry.marmelab.com/atomic-crm-telemetry?domain=${window.location.hostname}`;\n  }, [disableTelemetry]);\n\n  // Seed the store with CRM prop values if not already stored\n  // (backwards compatibility for prop-based config)\n  useEffect(() => {\n    if (!store.getItem(CONFIGURATION_STORE_KEY)) {\n      store.setItem(CONFIGURATION_STORE_KEY, {\n        companySectors,\n        currency,\n        dealCategories,\n        dealPipelineStatuses,\n        dealStages,\n        noteStatuses,\n        taskTypes,\n        title,\n        darkModeLogo,\n        lightModeLogo,\n      } satisfies ConfigurationContextValue);\n    }\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [store]);\n\n  const isMobile = useIsMobile();\n\n  // on login, pre-fetch the configuration to avoid a flickering\n  // when accessing the app for the first time\n  const wrappedAuthProvider = useMemo<AuthProvider>(\n    () => ({\n      ...authProvider,\n      login: async (params: any) => {\n        const result = await authProvider.login(params);\n        try {\n          const config = await dataProvider.getConfiguration();\n          if (Object.keys(config).length > 0) {\n            store.setItem(CONFIGURATION_STORE_KEY, config);\n          }\n        } catch {\n          // Non-critical: config will load via useConfigurationLoader\n        }\n        return result;\n      },\n      handleCallback: async (params: any) => {\n        if (!authProvider.handleCallback) {\n          throw new Error(\n            \"handleCallback is not implemented in the authProvider\",\n          );\n        }\n        const result = await authProvider.handleCallback(params);\n        try {\n          const config = await dataProvider.getConfiguration();\n          if (Object.keys(config).length > 0) {\n            store.setItem(CONFIGURATION_STORE_KEY, config);\n          }\n        } catch {\n          // Non-critical: config will load via useConfigurationLoader\n        }\n        return result;\n      },\n      logout: async (params: any) => {\n        try {\n          store.removeItem(CONFIGURATION_STORE_KEY);\n        } catch {\n          // Ignore\n        }\n        return authProvider.logout(params);\n      },\n    }),\n    [authProvider, dataProvider, store],\n  );\n\n  const ResponsiveAdmin = isMobile ? MobileAdmin : DesktopAdmin;\n\n  return (\n    <ResponsiveAdmin\n      dataProvider={dataProvider}\n      authProvider={wrappedAuthProvider}\n      i18nProvider={i18nProvider}\n      store={store}\n      loginPage={StartPage}\n      requireAuth\n      disableTelemetry\n      {...rest}\n    />\n  );\n};\n\nconst DesktopAdmin = (\n  props: CoreAdminProps & {\n    dashboard?: DashboardComponent;\n    layout?: LayoutComponent;\n  },\n) => {\n  return (\n    <Admin\n      layout={props.layout ?? Layout}\n      dashboard={props.dashboard ?? Dashboard}\n      {...props}\n    >\n      <CustomRoutes noLayout>\n        <Route path={SignupPage.path} element={<SignupPage />} />\n        <Route\n          path={ConfirmationRequired.path}\n          element={<ConfirmationRequired />}\n        />\n        <Route path={SetPasswordPage.path} element={<SetPasswordPage />} />\n        <Route\n          path={ForgotPasswordPage.path}\n          element={<ForgotPasswordPage />}\n        />\n        <Route path={OAuthConsentPage.path} element={<OAuthConsentPage />} />\n      </CustomRoutes>\n\n      <CustomRoutes>\n        <Route path={ProfilePage.path} element={<ProfilePage />} />\n        <Route path={SettingsPage.path} element={<SettingsPage />} />\n        <Route path={ImportPage.path} element={<ImportPage />} />\n        <Route path={ChangelogPage.path} element={<ChangelogPage />} />\n      </CustomRoutes>\n      <Resource name=\"deals\" {...deals} />\n      <Resource name=\"contacts\" {...contacts} />\n      <Resource name=\"companies\" {...companies} />\n      <Resource name=\"contact_notes\" />\n      <Resource name=\"deal_notes\" />\n      <Resource name=\"tasks\" />\n      <Resource name=\"sales\" {...sales} />\n      <Resource name=\"tags\" />\n    </Admin>\n  );\n};\n\nconst MobileAdmin = (\n  props: CoreAdminProps & {\n    dashboard?: DashboardComponent;\n    layout?: LayoutComponent;\n  },\n) => {\n  const queryClient = new QueryClient({\n    defaultOptions: {\n      queries: {\n        gcTime: 1000 * 60 * 60 * 24, // 24 hours\n        networkMode: \"offlineFirst\",\n      },\n      mutations: {\n        networkMode: \"offlineFirst\",\n      },\n    },\n  });\n  const asyncStoragePersister = createAsyncStoragePersister({\n    storage: localStorage,\n  });\n\n  return (\n    <PersistQueryClientProvider\n      client={queryClient}\n      persistOptions={{ persister: asyncStoragePersister }}\n    >\n      <Admin\n        queryClient={queryClient}\n        layout={props.layout ?? MobileLayout}\n        dashboard={props.dashboard ?? MobileDashboard}\n        {...props}\n      >\n        <CustomRoutes noLayout>\n          <Route path={SignupPage.path} element={<SignupPage />} />\n          <Route\n            path={ConfirmationRequired.path}\n            element={<ConfirmationRequired />}\n          />\n          <Route path={SetPasswordPage.path} element={<SetPasswordPage />} />\n          <Route\n            path={ForgotPasswordPage.path}\n            element={<ForgotPasswordPage />}\n          />\n          <Route path={OAuthConsentPage.path} element={<OAuthConsentPage />} />\n        </CustomRoutes>\n        <CustomRoutes>\n          <Route\n            path={SettingsPageMobile.path}\n            element={<SettingsPageMobile />}\n          />\n          <Route path={ChangelogPage.path} element={<ChangelogPage />} />\n        </CustomRoutes>\n        <Resource\n          name=\"contacts\"\n          list={ContactListMobile}\n          show={ContactShow}\n          recordRepresentation={contacts.recordRepresentation}\n        >\n          <Route path=\":id/notes/:noteId\" element={<NoteShowPage />} />\n        </Resource>\n        <Resource name=\"companies\" show={CompanyShow} />\n        <Resource name=\"tasks\" list={MobileTasksList} />\n      </Admin>\n    </PersistQueryClientProvider>\n  );\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/providers/types.ts",
      "content": "export type { CrmDataProvider } from \"./supabase/dataProvider\";\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/providers/supabase/supabase.ts",
      "content": "import type { SupabaseClient } from \"@supabase/supabase-js\";\nimport { createClient } from \"@supabase/supabase-js\";\n\nlet supabaseClient: SupabaseClient | null = null;\n\nexport const getSupabaseClient = () => {\n  if (!supabaseClient) {\n    supabaseClient = createClient(\n      import.meta.env.VITE_SUPABASE_URL,\n      import.meta.env.VITE_SB_PUBLISHABLE_KEY,\n    );\n  }\n  return supabaseClient;\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/providers/supabase/index.ts",
      "content": "export { getAuthProvider } from \"./authProvider\";\nexport { getDataProvider } from \"./dataProvider\";\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/providers/supabase/dataProvider.ts",
      "content": "import { supabaseDataProvider } from \"ra-supabase-core\";\nimport {\n  withLifecycleCallbacks,\n  type DataProvider,\n  type GetListParams,\n  type Identifier,\n  type ResourceCallbacks,\n} from \"ra-core\";\nimport type {\n  ContactNote,\n  Deal,\n  DealNote,\n  RAFile,\n  Sale,\n  SalesFormData,\n  SignUpData,\n} from \"../../types\";\nimport type { ConfigurationContextValue } from \"../../root/ConfigurationContext\";\nimport { ATTACHMENTS_BUCKET } from \"../commons/attachments\";\nimport { getIsInitialized } from \"./authProvider\";\nimport { getSupabaseClient } from \"./supabase\";\n\nconst getBaseDataProvider = () =>\n  supabaseDataProvider({\n    instanceUrl: import.meta.env.VITE_SUPABASE_URL,\n    apiKey: import.meta.env.VITE_SB_PUBLISHABLE_KEY,\n    supabaseClient: getSupabaseClient(),\n    sortOrder: \"asc,desc.nullslast\" as any,\n  });\n\nconst processCompanyLogo = async (params: any) => {\n  const logo = params.data.logo;\n\n  if (logo?.rawFile instanceof File) {\n    await uploadToBucket(logo);\n  }\n\n  return {\n    ...params,\n    data: {\n      ...params.data,\n      logo,\n    },\n  };\n};\n\nconst getDataProviderWithCustomMethods = () => {\n  const baseDataProvider = getBaseDataProvider();\n\n  return {\n    ...baseDataProvider,\n    async getList(resource: string, params: GetListParams) {\n      if (resource === \"companies\") {\n        return baseDataProvider.getList(\"companies_summary\", params);\n      }\n      if (resource === \"contacts\") {\n        return baseDataProvider.getList(\"contacts_summary\", params);\n      }\n      if (resource === \"activity_log\") {\n        const { data, total } = await baseDataProvider.getList(\n          \"activity_log\",\n          params,\n        );\n        // Rename snake_case view columns to camelCase to match Activity type\n        return {\n          data: data.map((row: any) => ({\n            ...row,\n            contactNote: row.contact_note ?? undefined,\n            dealNote: row.deal_note ?? undefined,\n            contact_note: undefined,\n            deal_note: undefined,\n          })),\n          total,\n        };\n      }\n\n      return baseDataProvider.getList(resource, params);\n    },\n    async getOne(resource: string, params: any) {\n      if (resource === \"companies\") {\n        return baseDataProvider.getOne(\"companies_summary\", params);\n      }\n      if (resource === \"contacts\") {\n        return baseDataProvider.getOne(\"contacts_summary\", params);\n      }\n\n      return baseDataProvider.getOne(resource, params);\n    },\n\n    async signUp({ email, password, first_name, last_name }: SignUpData) {\n      const response = await getSupabaseClient().auth.signUp({\n        email,\n        password,\n        options: {\n          data: {\n            first_name,\n            last_name,\n          },\n        },\n      });\n\n      if (!response.data?.user || response.error) {\n        console.error(\"signUp.error\", response.error);\n        throw new Error(response?.error?.message || \"Failed to create account\");\n      }\n\n      // Update the is initialized cache\n      (getIsInitialized as any)._is_initialized_cache = true;\n\n      return {\n        id: response.data.user.id,\n        email,\n        password,\n      };\n    },\n    async salesCreate(body: SalesFormData) {\n      const { data, error } = await getSupabaseClient().functions.invoke<{\n        data: Sale;\n      }>(\"users\", {\n        method: \"POST\",\n        body,\n      });\n\n      if (!data || error) {\n        console.error(\"salesCreate.error\", error);\n        const errorDetails = await (async () => {\n          try {\n            return (await error?.context?.json()) ?? {};\n          } catch {\n            return {};\n          }\n        })();\n        throw new Error(errorDetails?.message || \"Failed to create the user\");\n      }\n\n      return data.data;\n    },\n    async salesUpdate(\n      id: Identifier,\n      data: Partial<Omit<SalesFormData, \"password\">>,\n    ) {\n      const { email, first_name, last_name, administrator, avatar, disabled } =\n        data;\n\n      const { data: updatedData, error } =\n        await getSupabaseClient().functions.invoke<{\n          data: Sale;\n        }>(\"users\", {\n          method: \"PATCH\",\n          body: {\n            sales_id: id,\n            email,\n            first_name,\n            last_name,\n            administrator,\n            disabled,\n            avatar,\n          },\n        });\n\n      if (!updatedData || error) {\n        console.error(\"salesCreate.error\", error);\n        throw new Error(\"Failed to update account manager\");\n      }\n\n      return updatedData.data;\n    },\n    async updatePassword(id: Identifier) {\n      const { data: passwordUpdated, error } =\n        await getSupabaseClient().functions.invoke<boolean>(\"update_password\", {\n          method: \"PATCH\",\n          body: {\n            sales_id: id,\n          },\n        });\n\n      if (!passwordUpdated || error) {\n        console.error(\"update_password.error\", error);\n        throw new Error(\"Failed to update password\");\n      }\n\n      return passwordUpdated;\n    },\n    async unarchiveDeal(deal: Deal) {\n      // get all deals where stage is the same as the deal to unarchive\n      const { data: deals } = await baseDataProvider.getList<Deal>(\"deals\", {\n        filter: { stage: deal.stage },\n        pagination: { page: 1, perPage: 1000 },\n        sort: { field: \"index\", order: \"ASC\" },\n      });\n\n      // set index for each deal starting from 1, if the deal to unarchive is found, set its index to the last one\n      const updatedDeals = deals.map((d, index) => ({\n        ...d,\n        index: d.id === deal.id ? 0 : index + 1,\n        archived_at: d.id === deal.id ? null : d.archived_at,\n      }));\n\n      return await Promise.all(\n        updatedDeals.map((updatedDeal) =>\n          baseDataProvider.update(\"deals\", {\n            id: updatedDeal.id,\n            data: updatedDeal,\n            previousData: deals.find((d) => d.id === updatedDeal.id),\n          }),\n        ),\n      );\n    },\n    async isInitialized() {\n      return getIsInitialized();\n    },\n    async mergeContacts(sourceId: Identifier, targetId: Identifier) {\n      const { data, error } = await getSupabaseClient().functions.invoke(\n        \"merge_contacts\",\n        {\n          method: \"POST\",\n          body: { loserId: sourceId, winnerId: targetId },\n        },\n      );\n\n      if (error) {\n        console.error(\"merge_contacts.error\", error);\n        throw new Error(\"Failed to merge contacts\");\n      }\n\n      return data;\n    },\n    async getConfiguration(): Promise<ConfigurationContextValue> {\n      const { data } = await baseDataProvider.getOne(\"configuration\", {\n        id: 1,\n      });\n      return (data?.config as ConfigurationContextValue) ?? {};\n    },\n    async updateConfiguration(\n      config: ConfigurationContextValue,\n    ): Promise<ConfigurationContextValue> {\n      const { data } = await baseDataProvider.update(\"configuration\", {\n        id: 1,\n        data: { config },\n        previousData: { id: 1 },\n      });\n      return data.config as ConfigurationContextValue;\n    },\n  } satisfies DataProvider;\n};\n\nexport type CrmDataProvider = ReturnType<\n  typeof getDataProviderWithCustomMethods\n>;\n\nconst processConfigLogo = async (logo: any): Promise<string> => {\n  if (typeof logo === \"string\") return logo;\n  if (logo?.rawFile instanceof File) {\n    await uploadToBucket(logo);\n    return logo.src;\n  }\n  return logo?.src ?? \"\";\n};\n\nconst lifeCycleCallbacks: ResourceCallbacks[] = [\n  {\n    resource: \"configuration\",\n    beforeUpdate: async (params) => {\n      const config = params.data.config;\n      if (config) {\n        config.lightModeLogo = await processConfigLogo(config.lightModeLogo);\n        config.darkModeLogo = await processConfigLogo(config.darkModeLogo);\n      }\n      return params;\n    },\n  },\n  {\n    resource: \"contact_notes\",\n    beforeSave: async (data: ContactNote, _, __) => {\n      if (data.attachments) {\n        data.attachments = await Promise.all(\n          data.attachments.map((fi) => uploadToBucket(fi)),\n        );\n      }\n      return data;\n    },\n  },\n  {\n    resource: \"deal_notes\",\n    beforeSave: async (data: DealNote, _, __) => {\n      if (data.attachments) {\n        data.attachments = await Promise.all(\n          data.attachments.map((fi) => uploadToBucket(fi)),\n        );\n      }\n      return data;\n    },\n  },\n  {\n    resource: \"sales\",\n    beforeSave: async (data: Sale, _, __) => {\n      if (data.avatar) {\n        await uploadToBucket(data.avatar);\n      }\n      return data;\n    },\n  },\n  {\n    resource: \"contacts\",\n    beforeGetList: async (params) => {\n      return applyFullTextSearch([\n        \"first_name\",\n        \"last_name\",\n        \"company_name\",\n        \"title\",\n        \"email\",\n        \"phone\",\n        \"background\",\n      ])(params);\n    },\n  },\n  {\n    resource: \"companies\",\n    beforeGetList: async (params) => {\n      return applyFullTextSearch([\n        \"name\",\n        \"phone_number\",\n        \"website\",\n        \"zipcode\",\n        \"city\",\n        \"state_abbr\",\n      ])(params);\n    },\n    beforeCreate: async (params) => {\n      const createParams = await processCompanyLogo(params);\n\n      return {\n        ...createParams,\n        data: {\n          created_at: new Date().toISOString(),\n          ...createParams.data,\n        },\n      };\n    },\n    beforeUpdate: async (params) => {\n      return await processCompanyLogo(params);\n    },\n  },\n  {\n    resource: \"contacts_summary\",\n    beforeGetList: async (params) => {\n      return applyFullTextSearch([\"first_name\", \"last_name\"])(params);\n    },\n  },\n  {\n    resource: \"deals\",\n    beforeGetList: async (params) => {\n      return applyFullTextSearch([\"name\", \"category\", \"description\"])(params);\n    },\n  },\n];\n\nexport const getDataProvider = () => {\n  if (import.meta.env.VITE_SUPABASE_URL === undefined) {\n    throw new Error(\"Please set the VITE_SUPABASE_URL environment variable\");\n  }\n  if (import.meta.env.VITE_SB_PUBLISHABLE_KEY === undefined) {\n    throw new Error(\n      \"Please set the VITE_SB_PUBLISHABLE_KEY environment variable\",\n    );\n  }\n  return withLifecycleCallbacks(\n    getDataProviderWithCustomMethods(),\n    lifeCycleCallbacks,\n  ) as CrmDataProvider;\n};\n\nconst applyFullTextSearch = (columns: string[]) => (params: GetListParams) => {\n  if (!params.filter?.q) {\n    return params;\n  }\n  const { q, ...filter } = params.filter;\n  return {\n    ...params,\n    filter: {\n      ...filter,\n      \"@or\": columns.reduce((acc, column) => {\n        if (column === \"email\")\n          return {\n            ...acc,\n            [`email_fts@ilike`]: q,\n          };\n        if (column === \"phone\")\n          return {\n            ...acc,\n            [`phone_fts@ilike`]: q,\n          };\n        else\n          return {\n            ...acc,\n            [`${column}@ilike`]: q,\n          };\n      }, {}),\n    },\n  };\n};\n\nconst uploadToBucket = async (fi: RAFile) => {\n  if (!fi.src.startsWith(\"blob:\") && !fi.src.startsWith(\"data:\")) {\n    // Sign URL check if path exists in the bucket\n    if (fi.path) {\n      const { error } = await getSupabaseClient()\n        .storage.from(ATTACHMENTS_BUCKET)\n        .createSignedUrl(fi.path, 60);\n\n      if (!error) {\n        return fi;\n      }\n    }\n  }\n\n  const dataContent = fi.src\n    ? await fetch(fi.src)\n        .then((res) => {\n          if (res.status !== 200) {\n            return null;\n          }\n          return res.blob();\n        })\n        .catch(() => null)\n    : fi.rawFile;\n\n  if (dataContent == null) {\n    // We weren't able to download the file from its src (e.g. user must be signed in on another website to access it)\n    // or the file has no content (not probable)\n    // In that case, just return it as is: when trying to download it, users should be redirected to the other website\n    // and see they need to be signed in. It will then be their responsibility to upload the file back to the note.\n    return fi;\n  }\n\n  const file = fi.rawFile;\n  const fileParts = file.name.split(\".\");\n  const fileExt = fileParts.length > 1 ? `.${file.name.split(\".\").pop()}` : \"\";\n  const fileName = `${Math.random()}${fileExt}`;\n  const filePath = `${fileName}`;\n  const { error: uploadError } = await getSupabaseClient()\n    .storage.from(ATTACHMENTS_BUCKET)\n    .upload(filePath, dataContent);\n\n  if (uploadError) {\n    console.error(\"uploadError\", uploadError);\n    throw new Error(\"Failed to upload attachment\");\n  }\n\n  const { data } = getSupabaseClient()\n    .storage.from(ATTACHMENTS_BUCKET)\n    .getPublicUrl(filePath);\n\n  fi.path = filePath;\n  fi.src = data.publicUrl;\n\n  // save MIME type\n  const mimeType = file.type;\n  fi.type = mimeType;\n\n  return fi;\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/providers/supabase/authProvider.ts",
      "content": "import type { AuthProvider } from \"ra-core\";\nimport { supabaseAuthProvider } from \"ra-supabase-core\";\n\nimport { canAccess } from \"../commons/canAccess\";\nimport { getSupabaseClient } from \"./supabase\";\n\nconst getBaseAuthProvider = () =>\n  supabaseAuthProvider(getSupabaseClient(), {\n    getIdentity: async () => {\n      const sale = await getSale();\n\n      if (sale == null) {\n        throw new Error();\n      }\n\n      return {\n        id: sale.id,\n        fullName: `${sale.first_name} ${sale.last_name}`,\n        avatar: sale.avatar?.src,\n      };\n    },\n  });\n\n// To speed up checks, we cache the initialization state\n// and the current sale in the local storage. They are cleared on logout.\nconst IS_INITIALIZED_CACHE_KEY = \"RaStore.auth.is_initialized\";\nconst CURRENT_SALE_CACHE_KEY = \"RaStore.auth.current_sale\";\n\nfunction getLocalStorage(): Storage | null {\n  if (typeof window !== \"undefined\" && window.localStorage) {\n    return window.localStorage;\n  }\n  return null;\n}\n\nexport async function getIsInitialized() {\n  const storage = getLocalStorage();\n  const cachedValue = storage?.getItem(IS_INITIALIZED_CACHE_KEY);\n  if (cachedValue != null) {\n    return cachedValue === \"true\";\n  }\n\n  const { data } = await getSupabaseClient()\n    .from(\"init_state\")\n    .select(\"is_initialized\");\n  const isInitialized = data?.at(0)?.is_initialized > 0;\n\n  if (isInitialized) {\n    storage?.setItem(IS_INITIALIZED_CACHE_KEY, \"true\");\n  }\n\n  return isInitialized;\n}\n\nconst getSale = async () => {\n  const storage = getLocalStorage();\n  const cachedValue = storage?.getItem(CURRENT_SALE_CACHE_KEY);\n  if (cachedValue != null) {\n    return JSON.parse(cachedValue);\n  }\n\n  const { data: dataSession, error: errorSession } =\n    await getSupabaseClient().auth.getSession();\n\n  // Shouldn't happen after login but just in case\n  if (dataSession?.session?.user == null || errorSession) {\n    return undefined;\n  }\n\n  const { data: dataSale, error: errorSale } = await getSupabaseClient()\n    .from(\"sales\")\n    .select(\"id, first_name, last_name, avatar, administrator\")\n    .match({ user_id: dataSession?.session?.user.id })\n    .single();\n\n  // Shouldn't happen either as all users are sales but just in case\n  if (dataSale == null || errorSale) {\n    return undefined;\n  }\n\n  storage?.setItem(CURRENT_SALE_CACHE_KEY, JSON.stringify(dataSale));\n  return dataSale;\n};\n\nfunction clearCache() {\n  const storage = getLocalStorage();\n  storage?.removeItem(IS_INITIALIZED_CACHE_KEY);\n  storage?.removeItem(CURRENT_SALE_CACHE_KEY);\n}\n\nexport const getAuthProvider = (): AuthProvider => {\n  const baseAuthProvider = getBaseAuthProvider();\n  return {\n    ...baseAuthProvider,\n    login: async (params) => {\n      if (params.ssoDomain) {\n        const { error } = await getSupabaseClient().auth.signInWithSSO({\n          domain: params.ssoDomain,\n        });\n        if (error) {\n          throw error;\n        }\n        return;\n      }\n      return baseAuthProvider.login(params);\n    },\n    logout: async (params) => {\n      clearCache();\n      return baseAuthProvider.logout(params);\n    },\n    checkAuth: async (params) => {\n      // Users are on the set-password page, nothing to do\n      if (\n        window.location.pathname === \"/set-password\" ||\n        window.location.hash.includes(\"#/set-password\")\n      ) {\n        return;\n      }\n      // Users are on the forgot-password page, nothing to do\n      if (\n        window.location.pathname === \"/forgot-password\" ||\n        window.location.hash.includes(\"#/forgot-password\")\n      ) {\n        return;\n      }\n      // Users are on the sign-up page, nothing to do\n      if (\n        window.location.pathname === \"/sign-up\" ||\n        window.location.hash.includes(\"#/sign-up\")\n      ) {\n        return;\n      }\n\n      const isInitialized = await getIsInitialized();\n\n      if (!isInitialized) {\n        await getSupabaseClient().auth.signOut();\n        throw {\n          redirectTo: \"/sign-up\",\n          message: false,\n        };\n      }\n\n      return baseAuthProvider.checkAuth(params);\n    },\n    canAccess: async (params) => {\n      const isInitialized = await getIsInitialized();\n      if (!isInitialized) return false;\n\n      // Get the current user\n      const sale = await getSale();\n      if (sale == null) return false;\n\n      // Compute access rights from the sale role\n      const role = sale.administrator ? \"admin\" : \"user\";\n      return canAccess(role, params);\n    },\n    getAuthorizationDetails(authorizationId: string) {\n      return getSupabaseClient().auth.oauth.getAuthorizationDetails(\n        authorizationId,\n      );\n    },\n    approveAuthorization(authorizationId: string) {\n      return getSupabaseClient().auth.oauth.approveAuthorization(\n        authorizationId,\n      );\n    },\n    denyAuthorization(authorizationId: string) {\n      return getSupabaseClient().auth.oauth.denyAuthorization(authorizationId);\n    },\n  };\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/providers/fakerest/index.ts",
      "content": "export { authProvider } from \"./authProvider\";\nexport { createDataProvider, dataProvider } from \"./dataProvider\";\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/providers/fakerest/dataProvider.ts",
      "content": "import {\n  withLifecycleCallbacks,\n  type CreateParams,\n  type DataProvider,\n  type Identifier,\n  type ResourceCallbacks,\n  type UpdateParams,\n} from \"ra-core\";\nimport fakeRestDataProvider from \"ra-data-fakerest\";\n\nimport type {\n  Company,\n  Contact,\n  ContactNote,\n  Deal,\n  DealNote,\n  Sale,\n  SalesFormData,\n  SignUpData,\n  Task,\n} from \"../../types\";\nimport type { ConfigurationContextValue } from \"../../root/ConfigurationContext\";\nimport { getActivityLog } from \"../commons/activity\";\nimport { getCompanyAvatar } from \"../commons/getCompanyAvatar\";\nimport { getContactAvatar } from \"../commons/getContactAvatar\";\nimport { mergeContacts } from \"../commons/mergeContacts\";\nimport type { CrmDataProvider } from \"../types\";\nimport {\n  authProvider as defaultAuthProvider,\n  USER_STORAGE_KEY,\n} from \"./authProvider\";\nimport generateData from \"./dataGenerator\";\nimport type { Db } from \"./dataGenerator/types\";\nimport { withSupabaseFilterAdapter } from \"./internal/supabaseAdapter\";\n\nconst TASK_MARKED_AS_DONE = \"TASK_MARKED_AS_DONE\";\nconst TASK_MARKED_AS_UNDONE = \"TASK_MARKED_AS_UNDONE\";\nconst TASK_DONE_NOT_CHANGED = \"TASK_DONE_NOT_CHANGED\";\n\nconst processCompanyLogo = async (params: any) => {\n  let logo = params.data.logo;\n\n  if (typeof logo !== \"object\" || logo === null || !logo.src) {\n    logo = await getCompanyAvatar(params.data);\n  } else if (logo.rawFile instanceof File) {\n    const base64Logo = await convertFileToBase64(logo);\n    logo = { src: base64Logo, title: logo.title };\n  }\n\n  return {\n    ...params,\n    data: {\n      ...params.data,\n      logo,\n    },\n  };\n};\n\nasync function processContactAvatar(\n  params: UpdateParams<Contact>,\n): Promise<UpdateParams<Contact>>;\n\nasync function processContactAvatar(\n  params: CreateParams<Contact>,\n): Promise<CreateParams<Contact>>;\n\nasync function processContactAvatar(\n  params: CreateParams<Contact> | UpdateParams<Contact>,\n): Promise<CreateParams<Contact> | UpdateParams<Contact>> {\n  const { data } = params;\n  if (data.avatar?.src || !data.email_jsonb || !data.email_jsonb.length) {\n    return params;\n  }\n  const avatarUrl = await getContactAvatar(data);\n\n  // Clone the data and modify the clone\n  const newData = { ...data, avatar: { src: avatarUrl || undefined } };\n\n  return { ...params, data: newData };\n}\n\nasync function fetchAndUpdateCompanyData(\n  params: UpdateParams<Contact>,\n  dataProvider: DataProvider,\n): Promise<UpdateParams<Contact>>;\n\nasync function fetchAndUpdateCompanyData(\n  params: CreateParams<Contact>,\n  dataProvider: DataProvider,\n): Promise<CreateParams<Contact>>;\n\nasync function fetchAndUpdateCompanyData(\n  params: CreateParams<Contact> | UpdateParams<Contact>,\n  dataProvider: DataProvider,\n): Promise<CreateParams<Contact> | UpdateParams<Contact>> {\n  const { data } = params;\n  const newData = { ...data };\n\n  if (!newData.company_id) {\n    return params;\n  }\n\n  const { data: company } = await dataProvider.getOne(\"companies\", {\n    id: newData.company_id,\n  });\n\n  if (!company) {\n    return params;\n  }\n\n  newData.company_name = company.name;\n  return { ...params, data: newData };\n}\n\nexport interface CreateFakeRestDataProviderOptions {\n  db?: Db;\n  latency?: number;\n  authProvider?: Pick<typeof defaultAuthProvider, \"getIdentity\">;\n  silent?: boolean;\n}\n\nconst processConfigLogo = async (logo: any): Promise<string> => {\n  if (typeof logo === \"string\") return logo;\n  if (logo?.rawFile instanceof File) {\n    return (await convertFileToBase64(logo)) as string;\n  }\n  return logo?.src ?? \"\";\n};\n\nconst preserveAttachmentMimeType = <\n  NoteType extends { attachments?: Array<{ rawFile?: File; type?: string }> },\n>(\n  note: NoteType,\n): NoteType => ({\n  ...note,\n  attachments: (note.attachments ?? []).map((attachment) => ({\n    ...attachment,\n    type: attachment.type ?? attachment.rawFile?.type,\n  })),\n});\n\nexport const createDataProvider = ({\n  db = generateData(),\n  latency = 300,\n  authProvider,\n  silent = false,\n}: CreateFakeRestDataProviderOptions = {}): CrmDataProvider => {\n  const baseDataProvider = fakeRestDataProvider(db, !silent, latency);\n  let taskUpdateType = TASK_DONE_NOT_CHANGED;\n  const getIdentity = async () =>\n    authProvider?.getIdentity?.() ?? defaultAuthProvider.getIdentity?.();\n\n  const updateCompany = async (\n    companyId: Identifier,\n    updateFn: (company: Company) => Partial<Company>,\n  ) => {\n    const { data: company } = await dataProvider.getOne<Company>(\"companies\", {\n      id: companyId,\n    });\n\n    return await dataProvider.update(\"companies\", {\n      id: companyId,\n      data: {\n        ...updateFn(company),\n      },\n      previousData: company,\n    });\n  };\n\n  const dataProviderWithCustomMethod: CrmDataProvider = {\n    ...baseDataProvider,\n    async getList(resource: string, params: any) {\n      if (resource === \"activity_log\") {\n        const { filter = {}, pagination } = params;\n        const all = await getActivityLog(\n          withSupabaseFilterAdapter(baseDataProvider),\n          filter.company_id,\n          filter.sales_id,\n        );\n        const { page, perPage } = pagination;\n        const start = (page - 1) * perPage;\n        return { data: all.slice(start, start + perPage), total: all.length };\n      }\n      return baseDataProvider.getList(resource, params);\n    },\n    unarchiveDeal: async (deal: Deal) => {\n      // get all deals where stage is the same as the deal to unarchive\n      const { data: deals } = await baseDataProvider.getList<Deal>(\"deals\", {\n        filter: { stage: deal.stage },\n        pagination: { page: 1, perPage: 1000 },\n        sort: { field: \"index\", order: \"ASC\" },\n      });\n\n      // set index for each deal starting from 1, if the deal to unarchive is found, set its index to the last one\n      const updatedDeals = deals.map((d, index) => ({\n        ...d,\n        index: d.id === deal.id ? 0 : index + 1,\n        archived_at: d.id === deal.id ? null : d.archived_at,\n      }));\n\n      return await Promise.all(\n        updatedDeals.map((updatedDeal) =>\n          dataProvider.update(\"deals\", {\n            id: updatedDeal.id,\n            data: updatedDeal,\n            previousData: deals.find((d) => d.id === updatedDeal.id),\n          }),\n        ),\n      );\n    },\n    signUp: async ({\n      email,\n      password,\n      first_name,\n      last_name,\n    }: SignUpData): Promise<{\n      id: string;\n      email: string;\n      password: string;\n    }> => {\n      const user = await baseDataProvider.create(\"sales\", {\n        data: {\n          email,\n          first_name,\n          last_name,\n        },\n      });\n\n      return {\n        ...user.data,\n        password,\n      };\n    },\n    salesCreate: async ({ ...data }: SalesFormData): Promise<Sale> => {\n      const response = await dataProvider.create(\"sales\", {\n        data: {\n          ...data,\n          password: \"new_password\",\n        },\n      });\n\n      return response.data;\n    },\n    salesUpdate: async (\n      id: Identifier,\n      data: Partial<Omit<SalesFormData, \"password\">>,\n    ): Promise<Sale> => {\n      const { data: previousData } = await dataProvider.getOne<Sale>(\"sales\", {\n        id,\n      });\n\n      if (!previousData) {\n        throw new Error(\"User not found\");\n      }\n\n      const { data: sale } = await dataProvider.update<Sale>(\"sales\", {\n        id,\n        data,\n        previousData,\n      });\n      return { ...sale, user_id: sale.id.toString() };\n    },\n    isInitialized: async (): Promise<boolean> => {\n      const sales = await dataProvider.getList<Sale>(\"sales\", {\n        filter: {},\n        pagination: { page: 1, perPage: 1 },\n        sort: { field: \"id\", order: \"ASC\" },\n      });\n      if (sales.data.length === 0) {\n        return false;\n      }\n      return true;\n    },\n    updatePassword: async (id: Identifier): Promise<true> => {\n      const currentUser = await getIdentity();\n      if (!currentUser) {\n        throw new Error(\"User not found\");\n      }\n      const { data: previousData } = await dataProvider.getOne<Sale>(\"sales\", {\n        id: currentUser.id,\n      });\n\n      if (!previousData) {\n        throw new Error(\"User not found\");\n      }\n\n      await dataProvider.update(\"sales\", {\n        id,\n        data: {\n          password: \"demo_newPassword\",\n        },\n        previousData,\n      });\n\n      return true;\n    },\n    mergeContacts: async (sourceId: Identifier, targetId: Identifier) => {\n      return mergeContacts(sourceId, targetId, baseDataProvider);\n    },\n    getConfiguration: async (): Promise<ConfigurationContextValue> => {\n      const { data } = await baseDataProvider.getOne(\"configuration\", {\n        id: 1,\n      });\n      return (data?.config as ConfigurationContextValue) ?? {};\n    },\n    updateConfiguration: async (\n      config: ConfigurationContextValue,\n    ): Promise<ConfigurationContextValue> => {\n      const { data: prev } = await baseDataProvider.getOne(\"configuration\", {\n        id: 1,\n      });\n      await baseDataProvider.update(\"configuration\", {\n        id: 1,\n        data: { config },\n        previousData: prev,\n      });\n      return config;\n    },\n  };\n\n  const dataProvider = withLifecycleCallbacks(\n    withSupabaseFilterAdapter(dataProviderWithCustomMethod),\n    [\n      {\n        resource: \"configuration\",\n        beforeUpdate: async (params) => {\n          const config = params.data.config;\n          if (config) {\n            config.lightModeLogo = await processConfigLogo(\n              config.lightModeLogo,\n            );\n            config.darkModeLogo = await processConfigLogo(config.darkModeLogo);\n          }\n          return params;\n        },\n      },\n      {\n        resource: \"sales\",\n        beforeCreate: async (params) => {\n          const { data } = params;\n          // If administrator role is not set, we simply set it to false\n          if (data.administrator == null) {\n            data.administrator = false;\n          }\n          return params;\n        },\n        afterSave: async (data) => {\n          // Since the current user is stored in localStorage in fakerest authProvider\n          // we need to update it to keep information up to date in the UI\n          const currentUser = await getIdentity();\n          if (currentUser?.id === data.id) {\n            localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(data));\n          }\n          return data;\n        },\n        beforeDelete: async (params) => {\n          if (params.meta?.identity?.id == null) {\n            throw new Error(\"Identity MUST be set in meta\");\n          }\n\n          const newSaleId = params.meta.identity.id as Identifier;\n\n          const [companies, contacts, contactNotes, deals] = await Promise.all([\n            dataProvider.getList(\"companies\", {\n              filter: { sales_id: params.id },\n              pagination: {\n                page: 1,\n                perPage: 10_000,\n              },\n              sort: { field: \"id\", order: \"ASC\" },\n            }),\n            dataProvider.getList(\"contacts\", {\n              filter: { sales_id: params.id },\n              pagination: {\n                page: 1,\n                perPage: 10_000,\n              },\n              sort: { field: \"id\", order: \"ASC\" },\n            }),\n            dataProvider.getList(\"contact_notes\", {\n              filter: { sales_id: params.id },\n              pagination: {\n                page: 1,\n                perPage: 10_000,\n              },\n              sort: { field: \"id\", order: \"ASC\" },\n            }),\n            dataProvider.getList(\"deals\", {\n              filter: { sales_id: params.id },\n              pagination: {\n                page: 1,\n                perPage: 10_000,\n              },\n              sort: { field: \"id\", order: \"ASC\" },\n            }),\n          ]);\n\n          await Promise.all([\n            dataProvider.updateMany(\"companies\", {\n              ids: companies.data.map((company) => company.id),\n              data: {\n                sales_id: newSaleId,\n              },\n            }),\n            dataProvider.updateMany(\"contacts\", {\n              ids: contacts.data.map((company) => company.id),\n              data: {\n                sales_id: newSaleId,\n              },\n            }),\n            dataProvider.updateMany(\"contact_notes\", {\n              ids: contactNotes.data.map((company) => company.id),\n              data: {\n                sales_id: newSaleId,\n              },\n            }),\n            dataProvider.updateMany(\"deals\", {\n              ids: deals.data.map((company) => company.id),\n              data: {\n                sales_id: newSaleId,\n              },\n            }),\n          ]);\n\n          return params;\n        },\n      } satisfies ResourceCallbacks<Sale>,\n      {\n        resource: \"contacts\",\n        beforeCreate: async (createParams, dataProvider) => {\n          const params = {\n            ...createParams,\n            data: {\n              ...createParams.data,\n              first_seen:\n                createParams.data.first_seen ?? new Date().toISOString(),\n              last_seen:\n                createParams.data.last_seen ?? new Date().toISOString(),\n            },\n          };\n          const newParams = await processContactAvatar(params);\n          return fetchAndUpdateCompanyData(newParams, dataProvider);\n        },\n        afterCreate: async (result) => {\n          if (result.data.company_id != null) {\n            await updateCompany(result.data.company_id, (company) => ({\n              nb_contacts: (company.nb_contacts ?? 0) + 1,\n            }));\n          }\n\n          return result;\n        },\n        beforeUpdate: async (params) => {\n          const newParams = await processContactAvatar(params);\n          return fetchAndUpdateCompanyData(newParams, dataProvider);\n        },\n        afterDelete: async (result) => {\n          if (result.data.company_id != null) {\n            await updateCompany(result.data.company_id, (company) => ({\n              nb_contacts: (company.nb_contacts ?? 1) - 1,\n            }));\n          }\n\n          return result;\n        },\n      } satisfies ResourceCallbacks<Contact>,\n      {\n        resource: \"tasks\",\n        afterCreate: async (result, dataProvider) => {\n          // update the task count in the related contact\n          const { contact_id } = result.data;\n          const { data: contact } = await dataProvider.getOne(\"contacts\", {\n            id: contact_id,\n          });\n          await dataProvider.update(\"contacts\", {\n            id: contact_id,\n            data: {\n              nb_tasks: (contact.nb_tasks ?? 0) + 1,\n            },\n            previousData: contact,\n          });\n          return result;\n        },\n        beforeUpdate: async (params) => {\n          const { data, previousData } = params;\n          if (previousData.done_date !== data.done_date) {\n            taskUpdateType = data.done_date\n              ? TASK_MARKED_AS_DONE\n              : TASK_MARKED_AS_UNDONE;\n          } else {\n            taskUpdateType = TASK_DONE_NOT_CHANGED;\n          }\n          return params;\n        },\n        afterUpdate: async (result, dataProvider) => {\n          // update the contact: if the task is done, decrement the nb tasks, otherwise increment it\n          const { contact_id } = result.data;\n          const { data: contact } = await dataProvider.getOne(\"contacts\", {\n            id: contact_id,\n          });\n          if (taskUpdateType !== TASK_DONE_NOT_CHANGED) {\n            await dataProvider.update(\"contacts\", {\n              id: contact_id,\n              data: {\n                nb_tasks:\n                  taskUpdateType === TASK_MARKED_AS_DONE\n                    ? (contact.nb_tasks ?? 0) - 1\n                    : (contact.nb_tasks ?? 0) + 1,\n              },\n              previousData: contact,\n            });\n          }\n          return result;\n        },\n        afterDelete: async (result, dataProvider) => {\n          // update the task count in the related contact\n          const { contact_id } = result.data;\n          const { data: contact } = await dataProvider.getOne(\"contacts\", {\n            id: contact_id,\n          });\n          await dataProvider.update(\"contacts\", {\n            id: contact_id,\n            data: {\n              nb_tasks: (contact.nb_tasks ?? 0) - 1,\n            },\n            previousData: contact,\n          });\n          return result;\n        },\n      } satisfies ResourceCallbacks<Task>,\n      {\n        resource: \"companies\",\n        beforeCreate: async (params) => {\n          const createParams = await processCompanyLogo(params);\n\n          return {\n            ...createParams,\n            data: {\n              ...createParams.data,\n              created_at: new Date().toISOString(),\n            },\n          };\n        },\n        beforeUpdate: async (params) => {\n          return await processCompanyLogo(params);\n        },\n        afterUpdate: async (result, dataProvider) => {\n          // get all contacts of the company and for each contact, update the company_name\n          const { id, name } = result.data;\n          const { data: contacts } = await dataProvider.getList(\"contacts\", {\n            filter: { company_id: id },\n            pagination: { page: 1, perPage: 1000 },\n            sort: { field: \"id\", order: \"ASC\" },\n          });\n\n          const contactIds = contacts.map((contact) => contact.id);\n          await dataProvider.updateMany(\"contacts\", {\n            ids: contactIds,\n            data: { company_name: name },\n          });\n          return result;\n        },\n      } satisfies ResourceCallbacks<Company>,\n      {\n        resource: \"deals\",\n        beforeCreate: async (params) => {\n          return {\n            ...params,\n            data: {\n              ...params.data,\n              created_at: new Date().toISOString(),\n              updated_at: new Date().toISOString(),\n            },\n          };\n        },\n        afterCreate: async (result) => {\n          await updateCompany(result.data.company_id, (company) => ({\n            nb_deals: (company.nb_deals ?? 0) + 1,\n          }));\n\n          return result;\n        },\n        beforeUpdate: async (params) => {\n          return {\n            ...params,\n            data: {\n              ...params.data,\n              updated_at: new Date().toISOString(),\n            },\n          };\n        },\n        afterDelete: async (result) => {\n          await updateCompany(result.data.company_id, (company) => ({\n            nb_deals: (company.nb_deals ?? 1) - 1,\n          }));\n\n          return result;\n        },\n      } satisfies ResourceCallbacks<Deal>,\n      {\n        resource: \"contact_notes\",\n        beforeSave: async (params) => preserveAttachmentMimeType(params),\n      } satisfies ResourceCallbacks<ContactNote>,\n      {\n        resource: \"deal_notes\",\n        beforeSave: async (params) => preserveAttachmentMimeType(params),\n      } satisfies ResourceCallbacks<DealNote>,\n    ],\n  ) as CrmDataProvider;\n\n  return dataProvider;\n};\n\nexport const dataProvider = createDataProvider();\n\n/**\n * Convert a `File` object returned by the upload input into a base 64 string.\n * That's not the most optimized way to store images in production, but it's\n * enough to illustrate the idea of dataprovider decoration.\n */\nconst convertFileToBase64 = (file: { rawFile: Blob }): Promise<string> =>\n  new Promise((resolve, reject) => {\n    const reader = new FileReader();\n    // We know result is a string as we used readAsDataURL\n    reader.onload = () => resolve(reader.result as string);\n    reader.onerror = reject;\n    reader.readAsDataURL(file.rawFile);\n  });\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/providers/fakerest/authProvider.ts",
      "content": "import type { AuthProvider } from \"ra-core\";\n\nimport type { Sale } from \"../../types\";\nimport { canAccess } from \"../commons/canAccess\";\nimport { dataProvider } from \"./dataProvider\";\n\nexport const DEFAULT_USER = {\n  id: 0,\n  first_name: \"Jane\",\n  last_name: \"Doe\",\n  email: \"janedoe@atomic.dev\",\n  password: \"demo\",\n  administrator: true,\n  avatar: {\n    src: \"data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEASABIAAD/4gKgSUNDX1BST0ZJTEUAAQEAAAKQbGNtcwQwAABtbnRyUkdCIFhZWiAH3wAIABMAEgAWADFhY3NwQVBQTAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA9tYAAQAAAADTLWxjbXMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAtkZXNjAAABCAAAADhjcHJ0AAABQAAAAE53dHB0AAABkAAAABRjaGFkAAABpAAAACxyWFlaAAAB0AAAABRiWFlaAAAB5AAAABRnWFlaAAAB+AAAABRyVFJDAAACDAAAACBnVFJDAAACLAAAACBiVFJDAAACTAAAACBjaHJtAAACbAAAACRtbHVjAAAAAAAAAAEAAAAMZW5VUwAAABwAAAAcAHMAUgBHAEIAIABiAHUAaQBsAHQALQBpAG4AAG1sdWMAAAAAAAAAAQAAAAxlblVTAAAAMgAAABwATgBvACAAYwBvAHAAeQByAGkAZwBoAHQALAAgAHUAcwBlACAAZgByAGUAZQBsAHkAAAAAWFlaIAAAAAAAAPbWAAEAAAAA0y1zZjMyAAAAAAABDEoAAAXj///zKgAAB5sAAP2H///7ov///aMAAAPYAADAlFhZWiAAAAAAAABvlAAAOO4AAAOQWFlaIAAAAAAAACSdAAAPgwAAtr5YWVogAAAAAAAAYqUAALeQAAAY3nBhcmEAAAAAAAMAAAACZmYAAPKnAAANWQAAE9AAAApbcGFyYQAAAAAAAwAAAAJmZgAA8qcAAA1ZAAAT0AAACltwYXJhAAAAAAADAAAAAmZmAADypwAADVkAABPQAAAKW2Nocm0AAAAAAAMAAAAAo9cAAFR7AABMzQAAmZoAACZmAAAPXP/bAEMACAYGBwYFCAcHBwkJCAoMFA0MCwsMGRITDxQdGh8eHRocHCAkLicgIiwjHBwoNyksMDE0NDQfJzk9ODI8LjM0Mv/bAEMBCQkJDAsMGA0NGDIhHCEyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMv/AABEIAIAAgAMBIgACEQEDEQH/xAAcAAABBAMBAAAAAAAAAAAAAAABAAUGBwIDBAj/xAA2EAABAwMBBgMHAwQDAQAAAAABAAIDBAUREgYTITFBUSJxgRQyYZGxwdEHQqEkUmLhFRYjkv/EABoBAAIDAQEAAAAAAAAAAAAAAAIDAAEEBQb/xAAkEQACAgIDAAEEAwAAAAAAAAAAAQIRAyEEEjETBSJRcTJBYf/aAAwDAQACEQMRAD8AuIIoIogAooIqygorB72xsc97g1rRkk9FFrlfnVD3Rwu0Qj5uQyko+hRi5eEjlr6aEkOlBI6N4rSbpCG6j4R0zzPoFEIpZpn+Ahv+TuJ9E9Qtipodc0haD15ud5BI+Vsb8aXoq/a6GgJ1UsrgBknTj6rgp/1JtEkgjnbNTk9Xxkt/+m5Cbrvbam9sc2XeU9G0jTFry6Q9M/hddk/T6kpId7UwNmqJCOBOGxjoD3KLvIroiX0VxprhCJaeVr2njlpyutR+k2dlttQZ4KwMZpwIGjwD5lbI785leaSppZWHmJG+JpHfKYppgOLQ9pIAhwBHIpIwBJFJBQhgiEAiFRYQkgmzaCuNDaZHsOJHeBvmVG6VkSt0Me0l93spoqY5Y0+N3Qn8JgjdrcA3xOPU9f8AS485Jy4c8ucep/CzirNBIh4D9zz1WGU7dmyMElRIIXspeBw+fGeJ4N8/wtsEstTUtHFzz1Pb7BNVHvJQA0EAnOo83fH/AGpBR7qniw14yfekz9+qFMNxHiljYwjhqc3kTxPxwnaIZA+6ZoJWDGhjnZ68gnaEvewftHw4BOixTVGc2GsOdI4c3KFXmSuq75Rw0kQ3EWZKieQaQG8g0D4n7KauaGgiNu8eeJJPBN89JLUN8btWg5HDAL++OwUZSMbTWxV9vZNFqxyIcMEELuTdTBtNUbiP3A0D5BOC0QdozzVMWUksoIwTBFBIKEMlFdtJSIKaMHHEuKlKg+3Ly6rgizhugl3wHVLyv7WMxK5Ih8k2+IAJEecNGeLvj5LvoossDsZHl9E2x6ZZNTvDGPp2WFde45P6WlqI4tPAZIGT6rAb0vwSiOox4RgA+84nn6ruiuAhLdA1n+5x5Kt6SsrYagtqqkyuJ8Jxj+FLpIql9qM0b9GW8Ceioaoa2TigrXStDsMb8SOKeYtUgGpxLe5+wVQ2eouftIabs52T7ugHPqrOtNNX7gb+r3zSPE1zNJPljknQaehGSFDo6piax5Em7iZ78h+gXJV1OId7lzIgzIYOePyue4QvxGGs8OsAN6D/ACPfH1QinirgZmODoi4Mb5BFYlqjfSsc58kjxgkN4dua61wWWqFbb2VGCDM4uAPMN6LsJ4p+LwRlM8pZWGUU0UBFYhFUWFV/t0S67xR8muhGT8MqwFDNvKJz4IqxgyWjQ49uyXl3EbidSKxrp31VWaKnJaxoySO6aX7INZG9swlcHuD3OyMkjlxKdLWA2uqJH/3NH8KRVNWz2XJ7LCm1tHSUIyWyL0NvdC6CnBeQHjRqOSB2VwVdnd/1YwRt/wDQx8Pkq4sX9XdoXubpZrGCeo7q7i3eW0OjbqLW8B3RRjdkm+tJHn6usdbV1JY6rnpmBww5jT4QPLn6q09jLdcKBsRgv0lZSFgaaaoYXBuBza4kuB7g5HwCa6y50tRWvi3ZjeHYc1wwQVMdm42NaC3lhXBu6JkikuzHS4QuNuqCPf3biPPCqj9OdonubHa6txLyXkPJ65JH1x6K27tKYrXVPAyRE/A7nBVE7OUEtJtDSs0k6JwHO8zghMemZ6bjZcdqjZT0jo2DAY4tC68rVBHuosY4klx81sWnGqiYcjuRkllAIhGCBFBFUWFc9dSR11FLTyDLXtI8l0BJUXZRt4tNRZ7hPFI3Trw5p6HHBcEk0jgwO93l6q3tsrJ/y1oL4WZqYTqZ8R1H0VRVEDKqikgkaQRkdiCsOWHWR0+Pk7RFRzVNHXQmNwc1pHAHBVq2m+19QIzTgMhaMFsjclx+ap2wW6gn3dNXvqIpA7G+DstcB9CrVtVqsVDa4Zpa2eUmPIDS4knIzgD4FUou9Dvtqpe/oZ9qrfUR1Elw3ZDy7UeGAVKtibgKi3skzwPDj0KiV7ornc6xk8EtdTW+QhraSZ+S49SW8cAeamWzlsFupBG39ztXkotSKl/CmO21N3pbLs7U3GtL/Z4tOsMGScuAwB6qK7JUrbs1t6ex+5kOuESBoceJ4uDeGfJP+1NpO0NLSWyRgNE6cS1Rzza3iGjzOPknKCnipadkEEbY4o2hrWtGAAFojj7O2YJ5eq6oyKCywlhaTIBEJJKEMQigEVRYUViioQJAIIPVVrtzYW0FY2507cQVLtMoH7X9/X6hWVlcl0pKWvtlRTVuPZ3sOs5xpxx1A9COaDJDtGhuKbhKyjW2uT2newSuZq544g+in2ytGYpGzTPMj28WgNAwofRV0cFU6F7w5oOGudw1DoVPbRdKCBrdU0eojg1pyT6BYbr+zsd5dKRIvZN6/ey4yPhyXRTx+PDeXfssaZz6wBzssj6N6nzTiyJrAABgI1vaMrdaZp06SQTniktkvv5WtbIO4o5+RVJgwkikjAAkigVCGsJZWuSaOGMySvaxg5uccAJiqtqqeNxbSxOmP9x8I/KCU4x9DjCUvESIJuvG0Fp2fp2zXWvhpWPzo3h4uxzwBxKi1XtTcJGnS5sDe7Bx+ZVD7WX+p2gvs1TNM+VkZ3cOp2fCD9+amOayPReTG4LZaF2/XVjKmRlotTZIG5DZal5Bce+kch5lcLNqL5erSJrhXPd7SNRiYA2No6AAKocHGOqs21NJs1K3HERgfwg5T6xSQzixTk2zAtEziCn6wwbmoa4DHHoE1tgO9BA9FJLfTua0ODeK5sjpw0WFaagua0Ek+akAcC3KiNnbI0AkKTsfiLJTsb0IyrZue3U3yULuW39stV+fbKlkmmMAPmZ4sPPTCeNoL9HZbPUVbiCWN8I7novPE9TLVVktTM4ukkLnuJ6kldPg4fkk2/DncyfSKr09C2zaizXZzI6SvidM/lE7wv8AkU7rznsdUvG1Nvbk5FWz6r0O2XuPkj5EYYpJJ+isPfIm6NqCQcDySSk0/A2mvStrpepLtWu0kimYcRt+5+K1sZlqa6LmMp13ga1cuUnJ2zrQioqkM21VULfs7VzA4foLWeZ4fdUnjAHmrI/Uiv8A6OmpQffeXkfAD8lVyBlkY75K6XDhWO/yc7ly++vwGMZljGM5I+qvWlpab2eMNiDQGjgAqNb4J2EftwR6cV6Et0Daihp5WjwyRtcPUIebGkhnCabZjTWykmOHsHDkU7U9BHF7ucLXFTljuSc4W8srnUb26OmmeI2Dgt7qzIw44C1BgwmLaq7w2a0S1Dj4sYYM8S7oEyKb0hcmvWRD9S9pI6qaCz0rsiM7ydw79B6c/kq/Mni7cM/RYmd9XUyVEpJe4l7s9StD3nWT105+y9NxsXw4lE8/nyfLkch32Lk07X21zjw3+r5L0HHUa25yvPWxzc7YW9nYk/wVdrKgsAHRcn6lKpx/R0+BG4N/6P8AFL4hxTg2IvZkc1HqSoy8EqUUUgfGFjxt3o0ZUq2f/9k=\",\n  },\n} as const;\n\nexport const USER_STORAGE_KEY = \"user\";\n\nlocalStorage.setItem(USER_STORAGE_KEY, JSON.stringify({ ...DEFAULT_USER }));\n\nasync function getUser(email: string) {\n  const sales = await dataProvider.getList(\"sales\", {\n    pagination: { page: 1, perPage: 200 },\n    sort: { field: \"name\", order: \"ASC\" },\n  });\n\n  if (!sales.data.length) {\n    return { ...DEFAULT_USER };\n  }\n\n  const user = sales.data.find((sale) => sale.email === email);\n  if (!user || user.disabled) {\n    return { ...DEFAULT_USER };\n  }\n  return user;\n}\n\nexport const authProvider: AuthProvider = {\n  login: async ({ email }) => {\n    const user = await getUser(email);\n    localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(user));\n    return Promise.resolve();\n  },\n  resetPassword: async () => {\n    // FakeRest doesn't send real emails. Keep this async to mimic network latency.\n    await new Promise((resolve) => setTimeout(resolve, 250));\n    return;\n  },\n  setPassword: async () => {\n    // FakeRest doesn't persist auth credentials. This is only for local UX testing.\n    await new Promise((resolve) => setTimeout(resolve, 250));\n    return;\n  },\n  logout: () => {\n    localStorage.removeItem(USER_STORAGE_KEY);\n    return Promise.resolve();\n  },\n  checkError: () => Promise.resolve(),\n  checkAuth: () =>\n    localStorage.getItem(USER_STORAGE_KEY)\n      ? Promise.resolve()\n      : Promise.reject(),\n  canAccess: async ({ signal: _signal, ...params }) => {\n    // Get the current user\n    const userItem = localStorage.getItem(USER_STORAGE_KEY);\n    const localUser = userItem ? (JSON.parse(userItem) as Sale) : null;\n    if (!localUser) return false;\n\n    // Compute access rights from the sale role\n    const role = localUser.administrator ? \"admin\" : \"user\";\n    return canAccess(role, params);\n  },\n  getIdentity: () => {\n    const userItem = localStorage.getItem(USER_STORAGE_KEY);\n    const user = userItem ? (JSON.parse(userItem) as Sale) : null;\n    return Promise.resolve({\n      id: user?.id ?? 0,\n      fullName: user ? `${user.first_name} ${user.last_name}` : \"Jane Doe\",\n      avatar: user?.avatar?.src,\n    });\n  },\n  async getAuthorizationDetails() {\n    await new Promise((resolve) => setTimeout(resolve, 500));\n    // return dummy data to avoid errors in OAuthConsentPage\n    return {\n      data: {\n        authorization_id: \"dummy\",\n        user: {\n          id: \"0\",\n          email: \"johndoe@example.com\",\n        },\n        client: {\n          name: \"Dummy Client\",\n        },\n        scope: \"openid profile email phone\",\n        redirect_uri: \"https://example.com/auth_callback\",\n      },\n      error: null,\n    };\n  },\n  async approveAuthorization() {\n    // return dummy success response\n    return {\n      data: {\n        redirect_url: \"https://example.com/auth_callback\",\n      },\n      error: null,\n    };\n  },\n  async denyAuthorization() {\n    // return dummy denied response\n    return {\n      data: {\n        redirect_url: \"https://example.com/denied\",\n      },\n      error: null,\n    };\n  },\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/providers/fakerest/internal/transformOrFilter.ts",
      "content": "import isObject from \"lodash/isObject\";\n\n// @or filter is an equivaluent of fakerest `q=`\nexport function transformOrFilter(values: any) {\n  if (!isObject(values) || Array.isArray(values)) {\n    throw new Error(\n      \"Invalid '@or' filter, expected an object as first element\",\n    );\n  }\n\n  const queries = Object.values(values);\n  if (queries.length === 0) {\n    throw new Error(\"Invalid '@or' filter, object is empty\");\n  }\n\n  return queries[0];\n}\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/providers/fakerest/internal/transformInFilter.ts",
      "content": "import { LIST_REGEX_BASE, parseList } from \"./listParser\";\n\nexport const IN_FILTER_REGEX = new RegExp(`^\\\\(${LIST_REGEX_BASE}\\\\)$`);\n\nexport function transformInFilter(value: any) {\n  if (value === \"()\") {\n    return [];\n  }\n\n  if (typeof value !== \"string\" || !value.match(IN_FILTER_REGEX)) {\n    throw new Error(\n      `Invalid '@in' filter value, expected a string matching '${IN_FILTER_REGEX.source}', got: ${value}`,\n    );\n  }\n\n  return parseList(value.slice(1, -1));\n}\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/providers/fakerest/internal/transformFilter.ts",
      "content": "import { transformContainsFilter } from \"./transformContainsFilter\";\nimport { transformInFilter } from \"./transformInFilter\";\nimport { transformOrFilter } from \"./transformOrFilter\";\n\nexport function transformFilter(filter: Record<string, any>) {\n  if (!filter) {\n    return undefined;\n  }\n  const transformedFilters: Record<string, any> = {};\n  for (const [key, value] of Object.entries(filter)) {\n    if (\n      key.endsWith(\"@eq\") ||\n      key.endsWith(\"@neq\") ||\n      key.endsWith(\"@lt\") ||\n      key.endsWith(\"@lte\") ||\n      key.endsWith(\"@gt\") ||\n      key.endsWith(\"@gte\")\n    ) {\n      const lastIndexOfAt = key.lastIndexOf(\"@\");\n      transformedFilters[\n        `${key.substring(0, lastIndexOfAt)}_${key.substring(lastIndexOfAt + 1)}`\n      ] = value;\n      continue;\n    }\n\n    if (key.endsWith(\"@is\")) {\n      transformedFilters[`${key.slice(0, -3)}_eq`] = value;\n      continue;\n    }\n\n    if (key.endsWith(\"@not.is\")) {\n      transformedFilters[`${key.slice(0, -7)}_neq`] = value;\n      continue;\n    }\n\n    if (key.endsWith(\"@in\")) {\n      transformedFilters[`${key.slice(0, -3)}_eq_any`] =\n        transformInFilter(value);\n      continue;\n    }\n\n    if (key.endsWith(\"@cs\")) {\n      transformedFilters[`${key.slice(0, -3)}`] =\n        transformContainsFilter(value);\n      continue;\n    }\n\n    // Search query\n    if (key.endsWith(\"@or\")) {\n      transformedFilters[\"q\"] = transformOrFilter(value);\n      continue;\n    }\n\n    transformedFilters[key] = value;\n  }\n  return transformedFilters;\n}\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/providers/fakerest/internal/transformContainsFilter.ts",
      "content": "import { LIST_REGEX_BASE, parseList } from \"./listParser\";\n\nexport const CONTAINS_FILTER_REGEX = new RegExp(`^\\\\{${LIST_REGEX_BASE}\\\\}$`);\n\nexport function transformContainsFilter(value: any) {\n  if (value === \"{}\") {\n    return [];\n  }\n\n  if (typeof value !== \"string\" || !value.match(CONTAINS_FILTER_REGEX)) {\n    throw new Error(\n      `Invalid '@cs' filter value, expected a string matching '${CONTAINS_FILTER_REGEX.source}', got: ${value}`,\n    );\n  }\n\n  return parseList(value.slice(1, -1));\n}\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/providers/fakerest/internal/supabaseAdapter.ts",
      "content": "import type { DataProvider } from \"ra-core\";\nimport { transformFilter } from \"./transformFilter\";\n\nfunction removeSummarySuffix(resource: string) {\n  return resource.endsWith(\"_summary\")\n    ? resource.replace(\"_summary\", \"\")\n    : resource;\n}\n\nexport function withSupabaseFilterAdapter<T extends DataProvider>(\n  dataProvider: T,\n): T {\n  return {\n    ...dataProvider,\n    getOne(resource, params) {\n      return dataProvider.getOne(removeSummarySuffix(resource), params);\n    },\n    getList(resource, params) {\n      return dataProvider.getList(removeSummarySuffix(resource), {\n        ...params,\n        filter: transformFilter(params.filter),\n      });\n    },\n    getMany(resource, params) {\n      return dataProvider.getMany(removeSummarySuffix(resource), params);\n    },\n    getManyReference(resource, params) {\n      return dataProvider.getManyReference(removeSummarySuffix(resource), {\n        ...params,\n        filter: transformFilter(params.filter),\n      });\n    },\n    create(resource, params) {\n      return dataProvider.create(removeSummarySuffix(resource), params);\n    },\n    delete(resource, params) {\n      return dataProvider.delete(removeSummarySuffix(resource), params);\n    },\n    deleteMany(resource, params) {\n      return dataProvider.deleteMany(removeSummarySuffix(resource), params);\n    },\n    update(resource, params) {\n      return dataProvider.update(removeSummarySuffix(resource), params);\n    },\n    updateMany(resource, params) {\n      return dataProvider.updateMany(removeSummarySuffix(resource), params);\n    },\n  };\n}\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/providers/fakerest/internal/listParser.ts",
      "content": "export const UNQUOTED_ALLOWED_CHARS = \"[A-Za-zÀ-ÖØ-öø-ÿ0-9-]+\";\nexport const QUOTED_ALLOWED_CHARS = \"[A-Za-zÀ-ÖØ-öø-ÿ0-9, -]+\";\nexport const LIST_REGEX_BASE = `(${UNQUOTED_ALLOWED_CHARS}|\"${QUOTED_ALLOWED_CHARS}\")(,(${UNQUOTED_ALLOWED_CHARS}|\"${QUOTED_ALLOWED_CHARS}\"))*`;\n\n/**\n * List represents a list of values, quoted or not.\n *\n * e.g. 1\n * e.g 1,2\n * e.g \"a\",\"b\"\n * e.g \"a\",b\n */\nexport function parseList(list: string) {\n  const parsedItems = [];\n\n  let currentItem = \"\";\n  let currentQuoted = false;\n  for (const char of list) {\n    if (char === \",\") {\n      if (currentQuoted) {\n        currentItem += char;\n        continue;\n      }\n\n      parsedItems.push(currentItem);\n      currentItem = \"\";\n      continue;\n    }\n\n    if (char === '\"') {\n      currentQuoted = !currentQuoted;\n      continue;\n    }\n\n    currentItem += char;\n  }\n  if (currentItem) {\n    parsedItems.push(currentItem);\n  }\n\n  return parsedItems.map((v: string) => {\n    const parsedFloat = Number.parseFloat(v);\n    if (!Number.isNaN(parsedFloat)) {\n      return parsedFloat;\n    }\n    return v;\n  });\n}\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/providers/fakerest/dataGenerator/utils.ts",
      "content": "import faker from \"faker/locale/en\";\n\nexport const weightedArrayElement = (values: any[], weights: any) =>\n  faker.random.arrayElement(\n    values.reduce(\n      (acc, value, index) => acc.concat(new Array(weights[index]).fill(value)),\n      [],\n    ),\n  );\n\nexport const weightedBoolean = (likelyhood: number) =>\n  faker.datatype.number(99) < likelyhood;\n\nexport const randomDate = (minDate?: Date, maxDate?: Date) => {\n  const minTs =\n    minDate instanceof Date\n      ? minDate.getTime()\n      : Date.now() - 5 * 365 * 24 * 60 * 60 * 1000; // 5 years\n  const maxTs = maxDate instanceof Date ? maxDate.getTime() : Date.now();\n  const range = maxTs - minTs;\n  const randomRange = faker.datatype.number({ max: range });\n  // move it more towards today to account for traffic increase\n  const ts = Math.sqrt(randomRange / range) * range;\n  return new Date(minTs + ts);\n};\n\nexport const randomFloat = (min: number, max: number) =>\n  parseFloat(faker.datatype.number({ min, max, precision: 0.01 }).toFixed(2));\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/providers/fakerest/dataGenerator/types.ts",
      "content": "import type {\n  Company,\n  Contact,\n  ContactNote,\n  Deal,\n  DealNote,\n  Sale,\n  Tag,\n  Task,\n} from \"../../../types\";\nimport type { ConfigurationContextValue } from \"../../../root/ConfigurationContext\";\n\nexport interface Db {\n  companies: Company[];\n  contacts: Contact[];\n  contact_notes: ContactNote[];\n  deals: Deal[];\n  deal_notes: DealNote[];\n  sales: Sale[];\n  tags: Tag[];\n  tasks: Task[];\n  configuration: Array<{ id: number; config: ConfigurationContextValue }>;\n}\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/providers/fakerest/dataGenerator/tasks.ts",
      "content": "import { datatype, lorem, random } from \"faker/locale/en_US\";\n\nimport { defaultTaskTypes } from \"../../../root/defaultConfiguration\";\nimport type { Task } from \"../../../types\";\nimport type { Db } from \"./types\";\nimport { randomDate } from \"./utils\";\n\nexport const type: string[] = [\n  \"email\",\n  \"email\",\n  \"email\",\n  \"email\",\n  \"email\",\n  \"email\",\n  \"call\",\n  \"call\",\n  \"call\",\n  \"call\",\n  \"call\",\n  \"call\",\n  \"call\",\n  \"call\",\n  \"call\",\n  \"call\",\n  \"call\",\n  \"demo\",\n  \"lunch\",\n  \"meeting\",\n  \"follow-up\",\n  \"follow-up\",\n  \"thank-you\",\n  \"ship\",\n  \"none\",\n];\n\nexport const generateTasks = (db: Db) => {\n  return Array.from(Array(400).keys()).map<Task>((id) => {\n    const contact = random.arrayElement(db.contacts);\n    contact.nb_tasks = (contact.nb_tasks ?? 0) + 1;\n    return {\n      id,\n      contact_id: contact.id,\n      type: random.arrayElement(defaultTaskTypes).value,\n      text: lorem.sentence(),\n      due_date: randomDate(\n        datatype.boolean() ? new Date() : new Date(contact.first_seen),\n        new Date(Date.now() + 100 * 24 * 60 * 60 * 1000),\n      ).toISOString(),\n      done_date: undefined,\n      sales_id: 0,\n    };\n  });\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/providers/fakerest/dataGenerator/tags.ts",
      "content": "import type { Db } from \"./types\";\n\nconst tags = [\n  { id: 0, name: \"football-fan\", color: \"#eddcd2\" },\n  { id: 1, name: \"holiday-card\", color: \"#fff1e6\" },\n  { id: 2, name: \"influencer\", color: \"#fde2e4\" },\n  { id: 3, name: \"manager\", color: \"#fad2e1\" },\n  { id: 4, name: \"musician\", color: \"#c5dedd\" },\n  { id: 5, name: \"vip\", color: \"#dbe7e4\" },\n];\n\nexport const generateTags = (_: Db) => {\n  return [...tags];\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/providers/fakerest/dataGenerator/sales.ts",
      "content": "import { internet, name } from \"faker/locale/en_US\";\n\nimport type { RAFile, Sale } from \"../../../types\";\nimport type { Db } from \"./types\";\n\nexport const generateSales = (_: Db): Sale[] => {\n  const randomSales = Array.from(Array(5).keys()).map((id) => {\n    const first_name = name.firstName();\n    const last_name = name.lastName();\n    const email = internet.email(first_name, last_name);\n\n    return {\n      id: id + 1,\n      user_id: `${id + 1}`,\n      first_name,\n      last_name,\n      email,\n      password: \"demo\",\n      administrator: false,\n      disabled: false,\n    };\n  });\n  return [\n    {\n      id: 0,\n      user_id: \"0\",\n      first_name: \"Jane\",\n      last_name: \"Doe\",\n      email: \"janedoe@atomic.dev\",\n      password: \"demo\",\n      administrator: true,\n      disabled: false,\n      avatar: {\n        src: \"data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEASABIAAD/4gKgSUNDX1BST0ZJTEUAAQEAAAKQbGNtcwQwAABtbnRyUkdCIFhZWiAH3wAIABMAEgAWADFhY3NwQVBQTAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA9tYAAQAAAADTLWxjbXMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAtkZXNjAAABCAAAADhjcHJ0AAABQAAAAE53dHB0AAABkAAAABRjaGFkAAABpAAAACxyWFlaAAAB0AAAABRiWFlaAAAB5AAAABRnWFlaAAAB+AAAABRyVFJDAAACDAAAACBnVFJDAAACLAAAACBiVFJDAAACTAAAACBjaHJtAAACbAAAACRtbHVjAAAAAAAAAAEAAAAMZW5VUwAAABwAAAAcAHMAUgBHAEIAIABiAHUAaQBsAHQALQBpAG4AAG1sdWMAAAAAAAAAAQAAAAxlblVTAAAAMgAAABwATgBvACAAYwBvAHAAeQByAGkAZwBoAHQALAAgAHUAcwBlACAAZgByAGUAZQBsAHkAAAAAWFlaIAAAAAAAAPbWAAEAAAAA0y1zZjMyAAAAAAABDEoAAAXj///zKgAAB5sAAP2H///7ov///aMAAAPYAADAlFhZWiAAAAAAAABvlAAAOO4AAAOQWFlaIAAAAAAAACSdAAAPgwAAtr5YWVogAAAAAAAAYqUAALeQAAAY3nBhcmEAAAAAAAMAAAACZmYAAPKnAAANWQAAE9AAAApbcGFyYQAAAAAAAwAAAAJmZgAA8qcAAA1ZAAAT0AAACltwYXJhAAAAAAADAAAAAmZmAADypwAADVkAABPQAAAKW2Nocm0AAAAAAAMAAAAAo9cAAFR7AABMzQAAmZoAACZmAAAPXP/bAEMACAYGBwYFCAcHBwkJCAoMFA0MCwsMGRITDxQdGh8eHRocHCAkLicgIiwjHBwoNyksMDE0NDQfJzk9ODI8LjM0Mv/bAEMBCQkJDAsMGA0NGDIhHCEyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMv/AABEIAIAAgAMBIgACEQEDEQH/xAAcAAABBAMBAAAAAAAAAAAAAAABAAUGBwIDBAj/xAA2EAABAwMBBgMHAwQDAQAAAAABAAIDBAUREgYTITFBUSJxgRQyYZGxwdEHQqEkUmLhFRYjkv/EABoBAAIDAQEAAAAAAAAAAAAAAAIDAAEEBQb/xAAkEQACAgIDAAEEAwAAAAAAAAAAAQIRAyEEEjETBSJRcTJBYf/aAAwDAQACEQMRAD8AuIIoIogAooIqygorB72xsc97g1rRkk9FFrlfnVD3Rwu0Qj5uQyko+hRi5eEjlr6aEkOlBI6N4rSbpCG6j4R0zzPoFEIpZpn+Ahv+TuJ9E9Qtipodc0haD15ud5BI+Vsb8aXoq/a6GgJ1UsrgBknTj6rgp/1JtEkgjnbNTk9Xxkt/+m5Cbrvbam9sc2XeU9G0jTFry6Q9M/hddk/T6kpId7UwNmqJCOBOGxjoD3KLvIroiX0VxprhCJaeVr2njlpyutR+k2dlttQZ4KwMZpwIGjwD5lbI785leaSppZWHmJG+JpHfKYppgOLQ9pIAhwBHIpIwBJFJBQhgiEAiFRYQkgmzaCuNDaZHsOJHeBvmVG6VkSt0Me0l93spoqY5Y0+N3Qn8JgjdrcA3xOPU9f8AS485Jy4c8ucep/CzirNBIh4D9zz1WGU7dmyMElRIIXspeBw+fGeJ4N8/wtsEstTUtHFzz1Pb7BNVHvJQA0EAnOo83fH/AGpBR7qniw14yfekz9+qFMNxHiljYwjhqc3kTxPxwnaIZA+6ZoJWDGhjnZ68gnaEvewftHw4BOixTVGc2GsOdI4c3KFXmSuq75Rw0kQ3EWZKieQaQG8g0D4n7KauaGgiNu8eeJJPBN89JLUN8btWg5HDAL++OwUZSMbTWxV9vZNFqxyIcMEELuTdTBtNUbiP3A0D5BOC0QdozzVMWUksoIwTBFBIKEMlFdtJSIKaMHHEuKlKg+3Ly6rgizhugl3wHVLyv7WMxK5Ih8k2+IAJEecNGeLvj5LvoossDsZHl9E2x6ZZNTvDGPp2WFde45P6WlqI4tPAZIGT6rAb0vwSiOox4RgA+84nn6ruiuAhLdA1n+5x5Kt6SsrYagtqqkyuJ8Jxj+FLpIql9qM0b9GW8Ceioaoa2TigrXStDsMb8SOKeYtUgGpxLe5+wVQ2eouftIabs52T7ugHPqrOtNNX7gb+r3zSPE1zNJPljknQaehGSFDo6piax5Em7iZ78h+gXJV1OId7lzIgzIYOePyue4QvxGGs8OsAN6D/ACPfH1QinirgZmODoi4Mb5BFYlqjfSsc58kjxgkN4dua61wWWqFbb2VGCDM4uAPMN6LsJ4p+LwRlM8pZWGUU0UBFYhFUWFV/t0S67xR8muhGT8MqwFDNvKJz4IqxgyWjQ49uyXl3EbidSKxrp31VWaKnJaxoySO6aX7INZG9swlcHuD3OyMkjlxKdLWA2uqJH/3NH8KRVNWz2XJ7LCm1tHSUIyWyL0NvdC6CnBeQHjRqOSB2VwVdnd/1YwRt/wDQx8Pkq4sX9XdoXubpZrGCeo7q7i3eW0OjbqLW8B3RRjdkm+tJHn6usdbV1JY6rnpmBww5jT4QPLn6q09jLdcKBsRgv0lZSFgaaaoYXBuBza4kuB7g5HwCa6y50tRWvi3ZjeHYc1wwQVMdm42NaC3lhXBu6JkikuzHS4QuNuqCPf3biPPCqj9OdonubHa6txLyXkPJ65JH1x6K27tKYrXVPAyRE/A7nBVE7OUEtJtDSs0k6JwHO8zghMemZ6bjZcdqjZT0jo2DAY4tC68rVBHuosY4klx81sWnGqiYcjuRkllAIhGCBFBFUWFc9dSR11FLTyDLXtI8l0BJUXZRt4tNRZ7hPFI3Trw5p6HHBcEk0jgwO93l6q3tsrJ/y1oL4WZqYTqZ8R1H0VRVEDKqikgkaQRkdiCsOWHWR0+Pk7RFRzVNHXQmNwc1pHAHBVq2m+19QIzTgMhaMFsjclx+ap2wW6gn3dNXvqIpA7G+DstcB9CrVtVqsVDa4Zpa2eUmPIDS4knIzgD4FUou9Dvtqpe/oZ9qrfUR1Elw3ZDy7UeGAVKtibgKi3skzwPDj0KiV7ornc6xk8EtdTW+QhraSZ+S49SW8cAeamWzlsFupBG39ztXkotSKl/CmO21N3pbLs7U3GtL/Z4tOsMGScuAwB6qK7JUrbs1t6ex+5kOuESBoceJ4uDeGfJP+1NpO0NLSWyRgNE6cS1Rzza3iGjzOPknKCnipadkEEbY4o2hrWtGAAFojj7O2YJ5eq6oyKCywlhaTIBEJJKEMQigEVRYUViioQJAIIPVVrtzYW0FY2507cQVLtMoH7X9/X6hWVlcl0pKWvtlRTVuPZ3sOs5xpxx1A9COaDJDtGhuKbhKyjW2uT2newSuZq544g+in2ytGYpGzTPMj28WgNAwofRV0cFU6F7w5oOGudw1DoVPbRdKCBrdU0eojg1pyT6BYbr+zsd5dKRIvZN6/ey4yPhyXRTx+PDeXfssaZz6wBzssj6N6nzTiyJrAABgI1vaMrdaZp06SQTniktkvv5WtbIO4o5+RVJgwkikjAAkigVCGsJZWuSaOGMySvaxg5uccAJiqtqqeNxbSxOmP9x8I/KCU4x9DjCUvESIJuvG0Fp2fp2zXWvhpWPzo3h4uxzwBxKi1XtTcJGnS5sDe7Bx+ZVD7WX+p2gvs1TNM+VkZ3cOp2fCD9+amOayPReTG4LZaF2/XVjKmRlotTZIG5DZal5Bce+kch5lcLNqL5erSJrhXPd7SNRiYA2No6AAKocHGOqs21NJs1K3HERgfwg5T6xSQzixTk2zAtEziCn6wwbmoa4DHHoE1tgO9BA9FJLfTua0ODeK5sjpw0WFaagua0Ek+akAcC3KiNnbI0AkKTsfiLJTsb0IyrZue3U3yULuW39stV+fbKlkmmMAPmZ4sPPTCeNoL9HZbPUVbiCWN8I7novPE9TLVVktTM4ukkLnuJ6kldPg4fkk2/DncyfSKr09C2zaizXZzI6SvidM/lE7wv8AkU7rznsdUvG1Nvbk5FWz6r0O2XuPkj5EYYpJJ+isPfIm6NqCQcDySSk0/A2mvStrpepLtWu0kimYcRt+5+K1sZlqa6LmMp13ga1cuUnJ2zrQioqkM21VULfs7VzA4foLWeZ4fdUnjAHmrI/Uiv8A6OmpQffeXkfAD8lVyBlkY75K6XDhWO/yc7ly++vwGMZljGM5I+qvWlpab2eMNiDQGjgAqNb4J2EftwR6cV6Et0Daihp5WjwyRtcPUIebGkhnCabZjTWykmOHsHDkU7U9BHF7ucLXFTljuSc4W8srnUb26OmmeI2Dgt7qzIw44C1BgwmLaq7w2a0S1Dj4sYYM8S7oEyKb0hcmvWRD9S9pI6qaCz0rsiM7ydw79B6c/kq/Mni7cM/RYmd9XUyVEpJe4l7s9StD3nWT105+y9NxsXw4lE8/nyfLkch32Lk07X21zjw3+r5L0HHUa25yvPWxzc7YW9nYk/wVdrKgsAHRcn6lKpx/R0+BG4N/6P8AFL4hxTg2IvZkc1HqSoy8EqUUUgfGFjxt3o0ZUq2f/9k=\",\n      } as RAFile,\n    },\n    ...randomSales,\n  ];\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/providers/fakerest/dataGenerator/index.ts",
      "content": "import { generateCompanies } from \"./companies\";\nimport { generateContactNotes } from \"./contactNotes\";\nimport { generateContacts } from \"./contacts\";\nimport { generateDealNotes } from \"./dealNotes\";\nimport { generateDeals } from \"./deals\";\nimport { finalize } from \"./finalize\";\nimport { generateSales } from \"./sales\";\nimport { generateTags } from \"./tags\";\nimport { generateTasks } from \"./tasks\";\nimport type { Db } from \"./types\";\n\nexport default (): Db => {\n  const db = {} as Db;\n  db.sales = generateSales(db);\n  db.tags = generateTags(db);\n  db.companies = generateCompanies(db);\n  db.contacts = generateContacts(db);\n  db.contact_notes = generateContactNotes(db);\n  db.deals = generateDeals(db);\n  db.deal_notes = generateDealNotes(db);\n  db.tasks = generateTasks(db);\n  db.configuration = [\n    {\n      id: 1,\n      config: {} as Db[\"configuration\"][number][\"config\"],\n    },\n  ];\n  finalize(db);\n\n  return db;\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/providers/fakerest/dataGenerator/finalize.ts",
      "content": "import type { Db } from \"./types\";\n\nexport const finalize = (db: Db) => {\n  // set contact status according to the latest note\n  db.contact_notes\n    .sort((a, b) => new Date(a.date).valueOf() - new Date(b.date).valueOf())\n    .forEach((note) => {\n      db.contacts[note.contact_id as number].status = note.status;\n    });\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/providers/fakerest/dataGenerator/deals.ts",
      "content": "import { add } from \"date-fns\";\nimport { datatype, lorem, random } from \"faker/locale/en_US\";\n\nimport {\n  defaultDealCategories,\n  defaultDealStages,\n} from \"../../../root/defaultConfiguration\";\nimport type { Deal } from \"../../../types\";\nimport type { Db } from \"./types\";\nimport { randomDate } from \"./utils\";\n\nexport const generateDeals = (db: Db): Deal[] => {\n  const deals = Array.from(Array(50).keys()).map((id) => {\n    const company = random.arrayElement(db.companies);\n    company.nb_deals = (company.nb_deals ?? 0) + 1;\n    const contacts = random.arrayElements(\n      db.contacts.filter((contact) => contact.company_id === company.id),\n      datatype.number({ min: 1, max: 3 }),\n    );\n    const lowercaseName = lorem.words();\n    const created_at = randomDate(new Date(company.created_at)).toISOString();\n\n    const expected_closing_date = randomDate(\n      new Date(created_at),\n      add(new Date(created_at), { months: 6 }),\n    )\n      .toISOString()\n      .split(\"T\")[0];\n\n    return {\n      id,\n      name: lowercaseName[0].toUpperCase() + lowercaseName.slice(1),\n      company_id: company.id,\n      contact_ids: contacts.map((contact) => contact.id),\n      category: random.arrayElement(defaultDealCategories).value,\n      stage: random.arrayElement(defaultDealStages).value,\n      description: lorem.paragraphs(datatype.number({ min: 1, max: 4 })),\n      amount: datatype.number(1000) * 100,\n      created_at,\n      updated_at: randomDate(new Date(created_at)).toISOString(),\n      expected_closing_date,\n      sales_id: company.sales_id!,\n      index: 0,\n    };\n  });\n  // compute index based on stage\n  defaultDealStages.forEach((stage) => {\n    deals\n      .filter((deal) => deal.stage === stage.value)\n      .forEach((deal, index) => {\n        deals[deal.id].index = index;\n      });\n  });\n  return deals;\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/providers/fakerest/dataGenerator/dealNotes.ts",
      "content": "import { datatype, lorem, random } from \"faker/locale/en_US\";\n\nimport type { Db } from \"./types\";\nimport { randomDate } from \"./utils\";\n\nexport const generateDealNotes = (db: Db) => {\n  return Array.from(Array(300).keys()).map((id) => {\n    const deal = random.arrayElement(db.deals);\n    return {\n      id,\n      deal_id: deal.id,\n      text: lorem.paragraphs(datatype.number({ min: 1, max: 4 })),\n      date: randomDate(\n        new Date(db.deals[deal.id as number].created_at),\n      ).toISOString(),\n      sales_id: deal.sales_id,\n    };\n  });\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/providers/fakerest/dataGenerator/contacts.ts",
      "content": "import {\n  company as fakerCompany,\n  internet,\n  lorem,\n  name,\n  phone,\n  random,\n} from \"faker/locale/en_US\";\n\nimport { defaultNoteStatuses } from \"../../../root/defaultConfiguration\";\nimport { contactGender } from \"../../../contacts/contactModel\";\nimport type { Company, Contact } from \"../../../types\";\nimport type { Db } from \"./types\";\nimport { randomDate, weightedBoolean } from \"./utils\";\n\nconst maxContacts = {\n  1: 1,\n  10: 4,\n  50: 12,\n  250: 25,\n  500: 50,\n};\n\nconst getRandomContactDetailsType = () =>\n  random.arrayElement([\"Work\", \"Home\", \"Other\"]) as \"Work\" | \"Home\" | \"Other\";\n\nexport const generateContacts = (db: Db, size = 500): Required<Contact>[] => {\n  const nbAvailblePictures = 223;\n  let numberOfContacts = 0;\n\n  return Array.from(Array(size).keys()).map((id) => {\n    const has_avatar =\n      weightedBoolean(25) && numberOfContacts < nbAvailblePictures;\n    const gender = random.arrayElement(contactGender).value;\n    const first_name = name.firstName(gender as any);\n    const last_name = name.lastName();\n    const email_jsonb = [\n      {\n        email: internet.email(first_name, last_name),\n        type: getRandomContactDetailsType(),\n      },\n    ];\n    const phone_jsonb = [\n      {\n        number: phone.phoneNumber(),\n        type: getRandomContactDetailsType(),\n      },\n      {\n        number: phone.phoneNumber(),\n        type: getRandomContactDetailsType(),\n      },\n    ];\n    const avatar = {\n      src: has_avatar\n        ? \"https://marmelab.com/posters/avatar-\" +\n          (223 - numberOfContacts) +\n          \".jpeg\"\n        : undefined,\n    };\n    const title = fakerCompany.bsAdjective();\n\n    if (has_avatar) {\n      numberOfContacts++;\n    }\n\n    // choose company with people left to know\n    let company: Company;\n    do {\n      company = random.arrayElement(db.companies);\n    } while ((company.nb_contacts ?? 0) >= maxContacts[company.size]);\n    company.nb_contacts = (company.nb_contacts ?? 0) + 1;\n\n    const first_seen = randomDate(new Date(company.created_at)).toISOString();\n    const last_seen = first_seen;\n\n    return {\n      id,\n      first_name,\n      last_name,\n      gender,\n      title: title.charAt(0).toUpperCase() + title.substr(1),\n      company_id: company.id,\n      company_name: company.name,\n      email_jsonb,\n      phone_jsonb,\n      background: lorem.sentence(),\n      acquisition: random.arrayElement([\"inbound\", \"outbound\"]),\n      avatar,\n      first_seen: first_seen,\n      last_seen: last_seen,\n      has_newsletter: weightedBoolean(30),\n      status: random.arrayElement(defaultNoteStatuses).value,\n      tags: random\n        .arrayElements(db.tags, random.arrayElement([0, 0, 0, 1, 1, 2]))\n        .map((tag) => tag.id), // finalize\n      sales_id: company.sales_id!,\n      nb_tasks: 0,\n      linkedin_url: null,\n    };\n  });\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/providers/fakerest/dataGenerator/contactNotes.ts",
      "content": "import { datatype, lorem, random } from \"faker/locale/en_US\";\n\nimport { defaultNoteStatuses } from \"../../../root/defaultConfiguration\";\nimport type { ContactNote } from \"../../../types\";\nimport type { Db } from \"./types\";\nimport { randomDate } from \"./utils\";\n\nexport const generateContactNotes = (db: Db): ContactNote[] => {\n  return Array.from(Array(1200).keys()).map((id) => {\n    const contact = random.arrayElement(db.contacts);\n    const date = randomDate(new Date(contact.first_seen));\n    contact.last_seen =\n      date > new Date(contact.last_seen)\n        ? date.toISOString()\n        : contact.last_seen;\n    return {\n      id,\n      contact_id: contact.id,\n      text: lorem.paragraphs(datatype.number({ min: 1, max: 4 })),\n      date: date.toISOString(),\n      sales_id: contact.sales_id!,\n      status: random.arrayElement(defaultNoteStatuses).value,\n    };\n  });\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/providers/fakerest/dataGenerator/companies.ts",
      "content": "import {\n  address,\n  company,\n  datatype,\n  internet,\n  lorem,\n  phone,\n  random,\n} from \"faker/locale/en_US\";\n\nimport { randomDate } from \"./utils\";\nimport { defaultCompanySectors } from \"../../../root/defaultConfiguration\";\nimport type { Company, RAFile } from \"../../../types\";\nimport type { Db } from \"./types\";\n\nconst sizes = [1, 10, 50, 250, 500];\n\nconst regex = /\\W+/;\n\nexport const generateCompanies = (db: Db, size = 55): Required<Company>[] => {\n  return Array.from(Array(size).keys()).map((id) => {\n    const name = company.companyName();\n    return {\n      id,\n      name: name,\n      logo: {\n        title: lorem.text(1),\n        src: `https://marmelab.com/react-admin-crm/logos/${id}.png`,\n      } as RAFile,\n      sector: random.arrayElement(defaultCompanySectors).value,\n      size: random.arrayElement(sizes) as 1 | 10 | 50 | 250 | 500,\n      linkedin_url: `https://www.linkedin.com/company/${name\n        .toLowerCase()\n        .replace(regex, \"_\")}`,\n      website: internet.url(),\n      phone_number: phone.phoneNumber(),\n      address: address.streetAddress(),\n      zipcode: address.zipCode(),\n      city: address.city(),\n      state_abbr: address.stateAbbr(),\n      nb_contacts: 0,\n      nb_deals: 0,\n      // at least 1/3rd of companies for Jane Doe\n      sales_id: datatype.number(2) === 0 ? 0 : random.arrayElement(db.sales).id,\n      created_at: randomDate().toISOString(),\n      description: lorem.paragraph(),\n      revenue: random.arrayElement([\"$1M\", \"$10M\", \"$100M\", \"$1B\"]),\n      tax_identifier: random.alphaNumeric(10),\n      country: random.arrayElement([\"USA\", \"France\", \"UK\"]),\n      context_links: [],\n    };\n  });\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/providers/commons/mergeContacts.ts",
      "content": "import type { Identifier, DataProvider } from \"ra-core\";\n\nimport type { Contact, Task, Deal, ContactNote } from \"../../types\";\n\n/**\n * Merge one contact (loser) into another contact (winner).\n *\n * This function copies properties from the loser to the winner contact,\n * transfers all associated data (tasks, notes, deals) from the loser to the winner,\n * and deletes the loser contact.\n */\nexport const mergeContacts = async (\n  loserId: Identifier,\n  winnerId: Identifier,\n  dataProvider: DataProvider,\n) => {\n  // Fetch both contacts using dataProvider to get fresh data\n  const { data: winnerContact } = await dataProvider.getOne<Contact>(\n    \"contacts\",\n    { id: winnerId },\n  );\n  const { data: loserContact } = await dataProvider.getOne<Contact>(\n    \"contacts\",\n    { id: loserId },\n  );\n\n  if (!winnerContact || !loserContact) {\n    throw new Error(\"Could not fetch contacts\");\n  }\n\n  // 1. Reassign all tasks from loser to winner\n  const { data: loserTasks } = await dataProvider.getManyReference<Task>(\n    \"tasks\",\n    {\n      target: \"contact_id\",\n      id: loserId,\n      pagination: { page: 1, perPage: 1000 },\n      sort: { field: \"id\", order: \"ASC\" },\n      filter: {},\n    },\n  );\n\n  const taskUpdates =\n    loserTasks?.map((task) =>\n      dataProvider.update(\"tasks\", {\n        id: task.id,\n        data: { contact_id: winnerId },\n        previousData: task,\n      }),\n    ) || [];\n\n  // 2. Reassign all notes from loser to winner\n  const { data: loserNotes } = await dataProvider.getManyReference<ContactNote>(\n    \"contact_notes\",\n    {\n      target: \"contact_id\",\n      id: loserId,\n      pagination: { page: 1, perPage: 1000 },\n      sort: { field: \"id\", order: \"ASC\" },\n      filter: {},\n    },\n  );\n\n  const noteUpdates =\n    loserNotes?.map((note) =>\n      dataProvider.update<ContactNote>(\"contact_notes\", {\n        id: note.id,\n        data: { contact_id: winnerId },\n        previousData: note,\n      }),\n    ) || [];\n\n  // 3. Change contact in deals - replace loser ID with winner ID in contact_ids array\n  const { data: loserDeals } = await dataProvider.getList<Deal>(\"deals\", {\n    filter: { \"contact_ids@cs\": `{${loserId}}` },\n    pagination: { page: 1, perPage: 1000 },\n    sort: { field: \"id\", order: \"ASC\" },\n  });\n\n  const dealUpdates =\n    loserDeals?.map((deal) => {\n      const newContactIds = deal.contact_ids\n        .filter((id) => id !== loserId)\n        .concat(winnerId)\n        .filter(\n          (id: Identifier, index: number, self: Identifier[]) =>\n            self.indexOf(id) === index,\n        ); // Remove duplicates\n\n      return dataProvider.update<Deal>(\"deals\", {\n        id: deal.id,\n        data: { contact_ids: newContactIds },\n        previousData: deal,\n      });\n    }) || [];\n\n  // 4. Update winner contact with loser data\n  const mergedEmails = mergeObjectArraysUnique(\n    winnerContact.email_jsonb || [],\n    loserContact.email_jsonb || [],\n    (email) => email.email,\n  );\n\n  const mergedPhones = mergeObjectArraysUnique(\n    winnerContact.phone_jsonb || [],\n    loserContact.phone_jsonb || [],\n    (phone) => phone.number,\n  );\n\n  const winnerUpdate = dataProvider.update<Contact>(\"contacts\", {\n    id: winnerId,\n    data: {\n      avatar:\n        winnerContact.avatar && winnerContact.avatar.src\n          ? winnerContact.avatar\n          : loserContact.avatar,\n      gender: winnerContact.gender ?? loserContact.gender,\n      first_name: winnerContact.first_name ?? loserContact.first_name,\n      last_name: winnerContact.last_name ?? loserContact.last_name,\n      title: winnerContact.title ?? loserContact.title,\n      company_id: winnerContact.company_id ?? loserContact.company_id,\n      email_jsonb: mergedEmails,\n      phone_jsonb: mergedPhones,\n      linkedin_url: winnerContact.linkedin_url || loserContact.linkedin_url,\n      background: winnerContact.background ?? loserContact.background,\n      has_newsletter:\n        winnerContact.has_newsletter ?? loserContact.has_newsletter,\n      first_seen: winnerContact.first_seen ?? loserContact.first_seen,\n      last_seen:\n        winnerContact.last_seen > loserContact.last_seen\n          ? winnerContact.last_seen\n          : loserContact.last_seen,\n      sales_id: winnerContact.sales_id ?? loserContact.sales_id,\n      tags: mergeArraysUnique(\n        winnerContact.tags || [],\n        loserContact.tags || [],\n      ),\n    },\n    previousData: winnerContact,\n  });\n\n  // Execute all updates\n  await Promise.all([\n    ...taskUpdates,\n    ...noteUpdates,\n    ...dealUpdates,\n    winnerUpdate,\n  ]);\n\n  // 5. Delete the loser contact\n  await dataProvider.delete<Contact>(\"contacts\", {\n    id: loserId,\n    previousData: loserContact,\n  });\n};\n\n// Helper functions to merge arrays and remove duplicates\n\n// For primitive arrays like tags\nconst mergeArraysUnique = <T>(arr1: T[], arr2: T[]): T[] => [\n  ...new Set([...arr1, ...arr2]),\n];\n\n// For object arrays like emails and phones\nfunction mergeObjectArraysUnique<T>(\n  arr1: T[],\n  arr2: T[],\n  getKey: (item: T) => string,\n): T[] {\n  const map = new Map<string, T>();\n\n  arr1.forEach((item) => {\n    const key = getKey(item);\n    if (key) map.set(key, item);\n  });\n\n  arr2.forEach((item) => {\n    const key = getKey(item);\n    if (key && !map.has(key)) {\n      map.set(key, item);\n    }\n  });\n\n  return Array.from(map.values());\n}\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/providers/commons/i18nProvider.ts",
      "content": "import { mergeTranslations } from \"ra-core\";\nimport polyglotI18nProvider from \"ra-i18n-polyglot\";\nimport englishMessages from \"ra-language-english\";\nimport frenchMessages from \"ra-language-french\";\nimport { raSupabaseEnglishMessages } from \"ra-supabase-language-english\";\nimport { raSupabaseFrenchMessages } from \"ra-supabase-language-french\";\nimport { englishCrmMessages } from \"./englishCrmMessages\";\nimport { frenchCrmMessages } from \"./frenchCrmMessages\";\n\nconst raSupabaseEnglishMessagesOverride = {\n  \"ra-supabase\": {\n    auth: {\n      password_reset: \"Check your emails for a Reset Password message.\",\n    },\n  },\n};\n\nconst raSupabaseFrenchMessagesOverride = {\n  \"ra-supabase\": {\n    auth: {\n      password_reset:\n        \"Consultez vos emails pour trouver le message de reinitialisation du mot de passe.\",\n    },\n  },\n};\n\nconst englishCatalog = mergeTranslations(\n  englishMessages,\n  raSupabaseEnglishMessages,\n  raSupabaseEnglishMessagesOverride,\n  englishCrmMessages,\n);\n\nconst frenchCatalog = mergeTranslations(\n  englishCatalog,\n  frenchMessages,\n  raSupabaseFrenchMessages,\n  raSupabaseFrenchMessagesOverride,\n  frenchCrmMessages,\n);\n\nexport const getInitialLocale = (): \"en\" | \"fr\" => {\n  if (typeof navigator === \"undefined\") {\n    return \"en\";\n  }\n\n  const browserLocale = navigator.languages?.[0] ?? navigator.language;\n  if (browserLocale?.toLowerCase().startsWith(\"fr\")) {\n    return \"fr\";\n  }\n\n  return \"en\";\n};\n\nexport const i18nProvider = polyglotI18nProvider(\n  (locale) => {\n    if (locale === \"fr\") {\n      return frenchCatalog;\n    }\n    return englishCatalog;\n  },\n  getInitialLocale(),\n  [\n    { locale: \"en\", name: \"English\" },\n    { locale: \"fr\", name: \"Français\" },\n  ],\n  { allowMissing: true },\n);\n\nexport const testI18nProvider = polyglotI18nProvider(\n  () => englishCatalog,\n  \"en\",\n  [{ locale: \"en\", name: \"English\" }],\n  { allowMissing: true },\n);\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/providers/commons/getContactAvatar.ts",
      "content": "import { fetchWithTimeout } from \"../../misc/fetchWithTimeout\";\nimport { DOMAINS_NOT_SUPPORTING_FAVICON } from \"../../misc/unsupportedDomains.const\";\nimport type { Contact } from \"../../types\";\n\nexport async function hash(string: string) {\n  const utf8 = new TextEncoder().encode(string);\n  const hashBuffer = await crypto.subtle.digest(\"SHA-256\", utf8);\n  const hashArray = Array.from(new Uint8Array(hashBuffer));\n  const hashHex = hashArray\n    .map((bytes) => bytes.toString(16).padStart(2, \"0\"))\n    .join(\"\");\n  return hashHex;\n}\n\n// Helper function to get the Gravatar URL\nasync function getGravatarUrl(email: string): Promise<string> {\n  const hashEmail = await hash(email);\n  return `https://www.gravatar.com/avatar/${hashEmail}?d=404`;\n}\n\n// Helper function to get the favicon URL\nasync function getFaviconUrl(domain: string): Promise<string | null> {\n  if (DOMAINS_NOT_SUPPORTING_FAVICON.includes(domain)) {\n    return null;\n  }\n\n  try {\n    const faviconUrl = `https://${domain}/favicon.ico`;\n    const response = await fetchWithTimeout(faviconUrl);\n    if (response.ok) {\n      return faviconUrl;\n    }\n  } catch {\n    return null;\n  }\n  return null;\n}\n\n// Main function to get the avatar URL\nexport async function getContactAvatar(\n  record: Partial<Contact>,\n): Promise<string | null> {\n  if (!record.email_jsonb || !record.email_jsonb.length) {\n    return null;\n  }\n\n  for (const { email } of record.email_jsonb) {\n    // Step 1: Try to get Gravatar image\n    const gravatarUrl = await getGravatarUrl(email);\n\n    try {\n      const gravatarResponse = await fetch(gravatarUrl);\n      if (gravatarResponse.ok) {\n        return gravatarUrl;\n      }\n    } catch {\n      // Gravatar not found\n    }\n\n    // Step 2: Try to get favicon from email domain\n    const domain = email.split(\"@\")[1];\n    const faviconUrl = await getFaviconUrl(domain);\n    if (faviconUrl) {\n      return faviconUrl;\n    }\n\n    // TODO: Step 3: Try to get image from LinkedIn.\n  }\n\n  return null;\n}\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/providers/commons/getCompanyAvatar.ts",
      "content": "import type { Company } from \"../../types\";\n\n// Main function to get the avatar URL\nexport async function getCompanyAvatar(record: Partial<Company>): Promise<{\n  src: string;\n  title: string;\n} | null> {\n  // TODO: Step 1: Try to get image from LinkedIn.\n\n  // Step 2: Fallback to the favicon from website domain\n  if (!record.website) {\n    return null;\n  }\n  const websiteUrlWithoutScheme = record.website\n    .replace(/^https?:\\/\\//, \"\")\n    .replace(/\\/$/, \"\");\n  return {\n    src: `https://favicon.show/${websiteUrlWithoutScheme}`,\n    title: \"Company favicon\",\n  };\n}\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/providers/commons/frenchCrmMessages.ts",
      "content": "import type { CrmMessages } from \"./englishCrmMessages\";\n\nexport const frenchCrmMessages = {\n  resources: {\n    companies: {\n      name: \"Entreprise |||| Entreprises\",\n      forcedCaseName: \"Entreprise\",\n      fields: {\n        name: \"Nom de l'entreprise\",\n        website: \"Site web\",\n        linkedin_url: \"LinkedIn\",\n        phone_number: \"Numéro de téléphone\",\n        created_at: \"Date de création\",\n        nb_contacts: \"Nombre de contacts\",\n        revenue: \"Chiffre d'affaires\",\n        sector: \"Secteur\",\n        size: \"Taille\",\n        tax_identifier: \"Identifiant fiscal\",\n        address: \"Adresse\",\n        city: \"Ville\",\n        zipcode: \"Code postal\",\n        state_abbr: \"État\",\n        country: \"Pays\",\n        description: \"Description\",\n        context_links: \"URLs de contexte\",\n        sales_id: \"Responsable de compte\",\n      },\n      empty: {\n        description: \"Il semble que la liste de vos entreprises soit vide.\",\n        title: \"Aucune entreprise trouvée\",\n      },\n      field_categories: {\n        contact: \"Contact\",\n        additional_info: \"Informations supplémentaires\",\n        address: \"Adresse\",\n        context: \"Contexte\",\n      },\n      action: {\n        create: \"Créer une entreprise\",\n        edit: \"Modifier l'entreprise\",\n        new: \"Nouvelle entreprise\",\n        show: \"Afficher l'entreprise\",\n      },\n      added_on: \"Ajoutée le %{date}\",\n      followed_by: \"Suivie par %{name}\",\n      followed_by_you: \"Suivie par vous\",\n      no_contacts: \"Aucun contact\",\n      nb_contacts: \"%{smart_count} contact |||| %{smart_count} contacts\",\n      nb_deals: \"%{smart_count} affaire |||| %{smart_count} affaires\",\n      sizes: {\n        one_employee: \"1 employé\",\n        two_to_nine_employees: \"2-9 employés\",\n        ten_to_forty_nine_employees: \"10-49 employés\",\n        fifty_to_two_hundred_forty_nine_employees: \"50-249 employés\",\n        two_hundred_fifty_or_more_employees: \"250 employés ou plus\",\n      },\n      autocomplete: {\n        create_error:\n          \"Une erreur s'est produite lors de la création de l'entreprise\",\n        create_item: \"Créer %{item}\",\n        create_label: \"Commencez à taper pour créer une nouvelle entreprise\",\n      },\n      filters: {\n        only_mine: \"Seulement les entreprises que je gère\",\n      },\n    },\n    contacts: {\n      name: \"Contact |||| Contacts\",\n      forcedCaseName: \"Contact\",\n      field_categories: {\n        background_info: \"Informations complémentaires\",\n        identity: \"Identité\",\n        misc: \"Divers\",\n        personal_info: \"Informations personnelles\",\n        position: \"Poste\",\n      },\n      fields: {\n        first_name: \"Prénom\",\n        last_name: \"Nom\",\n        last_seen: \"Dernière activité\",\n        title: \"Titre\",\n        company_id: \"Entreprise\",\n        email_jsonb: \"Adresses e-mail\",\n        email: \"E-mail\",\n        phone_jsonb: \"Numéros de téléphone\",\n        phone_number: \"Numéro de téléphone\",\n        linkedin_url: \"URL LinkedIn\",\n        background: \"Informations de contexte\",\n        has_newsletter: \"Abonné à la newsletter\",\n        sales_id: \"Responsable de compte\",\n      },\n      action: {\n        add: \"Ajouter un contact\",\n        add_first: \"Ajoutez votre premier contact\",\n        create: \"Créer un contact\",\n        edit: \"Modifier le contact\",\n        export_vcard: \"Exporter en vCard\",\n        new: \"Nouveau contact\",\n        show: \"Afficher le contact\",\n      },\n      background: {\n        last_activity_on: \"Dernière activité le %{date}\",\n        added_on: \"Ajouté le %{date}\",\n        followed_by: \"Suivi par %{name}\",\n        followed_by_you: \"Suivi par vous\",\n        status_none: \"Aucun\",\n      },\n      position_at: \"%{title} chez\",\n      position_at_company: \"%{title} chez %{company}\",\n      empty: {\n        description: \"Il semble que votre liste de contacts soit vide.\",\n        title: \"Aucun contact trouvé\",\n      },\n      import: {\n        title: \"Importer des contacts\",\n        button: \"Importer un fichier CSV\",\n        complete:\n          \"Import des contacts terminé. %{importCount} contacts importés, %{errorCount} erreurs\",\n        progress:\n          \"%{importCount} / %{rowCount} contacts importés, avec %{errorCount} erreurs.\",\n        error:\n          \"Échec de l'importation de ce fichier. Veuillez vous assurer que vous avez fourni un fichier CSV valide.\",\n        imported: \"Importé\",\n        remaining_time: \"Temps restant estimé :\",\n        running: \"L'import est en cours, merci de ne pas fermer cet onglet.\",\n        sample_download: \"Télécharger un exemple CSV\",\n        sample_hint:\n          \"Voici un exemple de fichier CSV que vous pouvez utiliser comme modèle\",\n        stop: \"Arrêter l'importation\",\n        csv_file: \"Fichier CSV\",\n        contacts_label: \"contact |||| contacts\",\n      },\n      inputs: {\n        genders: {\n          male: \"Monsieur\",\n          female: \"Madame\",\n          nonbinary: \"Indéterminé\",\n        },\n        personal_info_types: {\n          work: \"Pro\",\n          home: \"Perso\",\n          other: \"Autre\",\n        },\n      },\n      list: {\n        error_loading: \"Erreur lors du chargement des contacts\",\n      },\n      bulk_tag: {\n        action: \"Étiqueter\",\n        back: \"Retour aux étiquettes\",\n        create_description:\n          \"Créez une nouvelle étiquette et appliquez-la aux contacts sélectionnés.\",\n        description:\n          \"Choisissez une étiquette existante ou créez-en une pour les contacts sélectionnés.\",\n        empty:\n          \"Aucune étiquette pour le moment. Créez-en une pour étiqueter les contacts sélectionnés.\",\n        error: \"Impossible d'ajouter l'étiquette aux contacts\",\n        noop: \"Les contacts sélectionnés ont déjà cette étiquette\",\n        success:\n          \"Étiquette ajoutée à %{smart_count} contact |||| Étiquette ajoutée à %{smart_count} contacts\",\n        title: \"Ajouter une étiquette aux contacts\",\n      },\n      merge: {\n        action: \"Fusionner avec un autre contact\",\n        confirm: \"Fusionner les contacts\",\n        current_contact: \"Contact actuel (sera supprimé)\",\n        description: \"Fusionnez ce contact avec un autre.\",\n        error: \"Échec de la fusion des contacts\",\n        merging: \"Fusion...\",\n        no_additional_data: \"Aucune donnée supplémentaire à fusionner\",\n        select_target: \"Veuillez sélectionner un contact avec lequel fusionner\",\n        success: \"Contacts fusionnés avec succès\",\n        target_contact: \"Contact cible (sera conservé)\",\n        title: \"Fusionner les contacts\",\n        warning_description:\n          \"Toutes les données seront transférées au deuxième contact. Cette action ne peut pas être annulée.\",\n        warning_title: \"Avertissement : opération destructrice\",\n        what_will_be_merged: \"Ce qui sera fusionné :\",\n      },\n      filters: {\n        before_last_month: \"Avant le mois dernier\",\n        before_this_month: \"Avant ce mois-ci\",\n        before_this_week: \"Avant cette semaine\",\n        managed_by_me: \"Géré par moi\",\n        search: \"Rechercher nom, entreprise...\",\n        this_week: \"Cette semaine\",\n        today: \"Aujourd'hui\",\n        tags: \"Étiquettes\",\n        tasks: \"Tâches\",\n      },\n      hot: {\n        empty_change_status:\n          'Changez le statut d\\'un contact en ajoutant une note à ce contact et en cliquant sur \"afficher les options\".',\n        empty_hint: 'Les contacts avec un statut \"chaud\" apparaîtront ici.',\n        title: \"Contacts chauds\",\n      },\n    },\n    deals: {\n      name: \"Affaire |||| Affaires\",\n      fields: {\n        name: \"Nom\",\n        description: \"Description\",\n        company_id: \"Entreprise\",\n        contact_ids: \"Contacts\",\n        category: \"Catégorie\",\n        amount: \"Budget\",\n        expected_closing_date: \"Date de clôture prévue\",\n        stage: \"Étape\",\n      },\n      action: {\n        back_to_deal: \"Retour à l'affaire\",\n        create: \"Créer une affaire\",\n        new: \"Nouvelle affaire\",\n      },\n      field_categories: {\n        misc: \"Divers\",\n      },\n      archived: {\n        action: \"Archiver\",\n        error: \"Erreur : affaire non archivée\",\n        list_title: \"Affaires archivées\",\n        success: \"Affaire archivée\",\n        title: \"Affaire archivée\",\n        view: \"Afficher les affaires archivées\",\n      },\n      inputs: {\n        linked_to: \"Lié à\",\n      },\n      unarchived: {\n        action: \"Renvoyer au tableau\",\n        error: \"Erreur : affaire non désarchivée\",\n        success: \"Affaire désarchivée\",\n      },\n      updated: \"Affaire mise à jour\",\n      empty: {\n        before_create: \"avant de créer une affaire.\",\n        description: \"Il semble que votre liste d'affaires soit vide.\",\n        title: \"Aucune affaire trouvée\",\n      },\n      invalid_date: \"Date invalide\",\n    },\n    notes: {\n      name: \"Note |||| Notes\",\n      forcedCaseName: \"Note\",\n      fields: {\n        status: \"Statut\",\n        date: \"Date\",\n        attachments: \"Pièces jointes\",\n        contact_id: \"Contact\",\n        deal_id: \"Affaire\",\n      },\n      action: {\n        add: \"Ajouter une note\",\n        add_first: \"Ajoutez votre première note\",\n        delete: \"Supprimer la note\",\n        edit: \"Modifier la note\",\n        update: \"Mettre à jour la note\",\n        add_this: \"Ajouter cette note\",\n      },\n      sheet: {\n        create: \"Créer une note\",\n        create_for: \"Créer une note pour %{name}\",\n        edit: \"Modifier la note\",\n        edit_for: \"Modifier la note pour %{name}\",\n      },\n      deleted: \"Note supprimée\",\n      empty: \"Aucune note pour l'instant\",\n      author_added: \"%{name} a ajouté une note\",\n      you_added: \"Vous avez ajouté une note\",\n      me: \"Moi\",\n      list: {\n        error_loading: \"Erreur lors du chargement des notes\",\n      },\n      note_for_contact: \"Note pour %{name}\",\n      stepper: {\n        hint: \"Accédez à une page de contact et ajoutez une note\",\n      },\n      added: \"Note ajoutée\",\n      inputs: {\n        add_note: \"Ajouter une note\",\n        options_hint: \"(joindre des fichiers ou modifier les détails)\",\n        show_options: \"Afficher les options\",\n      },\n      actions: {\n        attach_document: \"Joindre un document\",\n      },\n      validation: {\n        note_or_attachment_required: \"Une note ou une pièce jointe est requise\",\n      },\n    },\n    sales: {\n      name: \"Utilisateur |||| Utilisateurs\",\n      fields: {\n        first_name: \"Prénom\",\n        last_name: \"Nom\",\n        email: \"E-mail\",\n        administrator: \"Admin\",\n        disabled: \"Désactivé\",\n      },\n      create: {\n        error:\n          \"Une erreur s'est produite lors de la création de l'utilisateur.\",\n        success:\n          \"Utilisateur créé. Ils recevront prochainement un email pour définir leur mot de passe.\",\n        title: \"Créer un nouvel utilisateur\",\n      },\n      edit: {\n        error: \"Une erreur s'est produite. Veuillez réessayer.\",\n        record_not_found: \"Enregistrement introuvable\",\n        success: \"Utilisateur mis à jour avec succès\",\n        title: \"Modifier %{name}\",\n      },\n      action: {\n        new: \"Nouvel utilisateur\",\n      },\n    },\n    tasks: {\n      name: \"Tâche |||| Tâches\",\n      forcedCaseName: \"Tâche\",\n      fields: {\n        text: \"Description\",\n        due_date: \"Date d'échéance\",\n        type: \"Type\",\n        contact_id: \"Contact\",\n        due_short: \"échéance\",\n      },\n      action: {\n        add: \"Ajouter une tâche\",\n        create: \"Créer une tâche\",\n        edit: \"Modifier la tâche\",\n      },\n      actions: {\n        postpone_next_week: \"Reporté à la semaine prochaine\",\n        postpone_tomorrow: \"Reporter à demain\",\n        title: \"Actions de tâche\",\n      },\n      added: \"Tâche ajoutée\",\n      deleted: \"Tâche supprimée avec succès\",\n      dialog: {\n        create: \"Créer une tâche\",\n        create_for: \"Créer une tâche pour %{name}\",\n      },\n      sheet: {\n        edit: \"Modifier la tâche\",\n        edit_for: \"Modifier la tâche pour %{name}\",\n      },\n      empty: \"Aucune tâche pour l'instant\",\n      empty_list_hint: \"Les tâches ajoutées à vos contacts apparaîtront ici.\",\n      filters: {\n        later: \"Plus tard\",\n        overdue: \"En retard\",\n        this_week: \"Cette semaine\",\n        today: \"Aujourd'hui\",\n        tomorrow: \"Demain\",\n        with_pending: \"Avec des tâches en attente\",\n      },\n      regarding_contact: \"(Concernant : %{name})\",\n      updated: \"Tâche mise à jour\",\n    },\n    tags: {\n      name: \"Étiquette |||| Étiquettes\",\n      action: {\n        add: \"Ajouter une étiquette\",\n        create: \"Créer une nouvelle étiquette\",\n      },\n      dialog: {\n        color: \"Couleur\",\n        create_title: \"Créer une nouvelle étiquette\",\n        edit_title: \"Modifier l'étiquette\",\n        name_label: \"Nom de l'étiquette\",\n        name_placeholder: \"Saisir le nom de l'étiquette\",\n      },\n    },\n  },\n  crm: {\n    action: {\n      reset_password: \"Réinitialiser le mot de passe\",\n    },\n    auth: {\n      first_name: \"Prénom\",\n      last_name: \"Nom\",\n      confirm_password: \"Confirmer le mot de passe\",\n      confirmation_required:\n        \"Veuillez suivre le lien que nous venons de vous envoyer par email pour confirmer votre compte.\",\n      recovery_email_sent:\n        \"Si vous êtes un utilisateur enregistré, vous devriez recevoir prochainement un e-mail de récupération de mot de passe.\",\n      sign_in_failed: \"Échec de la connexion.\",\n      sign_in_google_workspace: \"Connectez-vous avec Google Workplace\",\n      signup: {\n        create_account: \"Créer un compte\",\n        create_first_user:\n          \"Créez le premier compte utilisateur pour terminer la configuration.\",\n        creating: \"Création...\",\n        initial_user_created: \"Utilisateur initial créé avec succès\",\n      },\n      welcome_title: \"Bienvenue sur Atomic CRM\",\n    },\n    common: {\n      activity: \"Activité\",\n      added: \"ajoutée\",\n      details: \"Détails\",\n      last_activity_with_date: \"dernière activité %{date}\",\n      load_more: \"Charger plus\",\n      misc: \"Divers\",\n      past: \"Passé\",\n      read_more: \"En savoir plus\",\n      retry: \"Réessayer\",\n      show_less: \"Afficher moins\",\n      task_count: \"%{smart_count} tâche |||| %{smart_count} tâches\",\n      copied: \"Copié !\",\n      copy: \"Copier\",\n      loading: \"Chargement...\",\n      me: \"Moi\",\n    },\n    changelog: {\n      title: \"Notes de version\",\n    },\n    activity: {\n      added_company: \"%{name} a ajouté l'entreprise\",\n      you_added_company: \"Vous avez ajouté l'entreprise\",\n      added_contact: \"%{name} a ajouté le contact\",\n      you_added_contact: \"Vous avez ajouté le contact\",\n      added_note: \"%{name} a ajouté une note sur\",\n      you_added_note: \"Vous avez ajouté une note sur\",\n      added_note_about_deal: \"%{name} a ajouté une note sur l'affaire\",\n      you_added_note_about_deal: \"Vous avez ajouté une note sur l'affaire\",\n      added_deal: \"%{name} a ajouté l'affaire\",\n      you_added_deal: \"Vous avez ajouté l'affaire\",\n      at_company: \"chez\",\n      to: \"à\",\n      load_more: \"Charger plus d'activité\",\n    },\n    dashboard: {\n      deals_chart: \"Revenus des affaires à venir\",\n      deals_pipeline: \"Pipeline des affaires\",\n      latest_activity: \"Dernière activité\",\n      latest_activity_error:\n        \"Erreur lors du chargement de la dernière activité\",\n      latest_notes: \"Mes dernières notes\",\n      latest_notes_added_ago: \"ajouté %{timeAgo}\",\n      stepper: {\n        install: \"Installer Atomic CRM\",\n        progress: \"%{step}/3 terminé\",\n        whats_next: \"Et ensuite ?\",\n      },\n      upcoming_tasks: \"Tâches à venir\",\n    },\n    header: {\n      import_data: \"Importer des données\",\n    },\n    image_editor: {\n      change: \"Changer\",\n      drop_hint:\n        \"Déposez un fichier à télécharger ou cliquez pour le sélectionner.\",\n      editable_content: \"Contenu modifiable\",\n      title: \"Télécharger et redimensionner l'image\",\n      update_image: \"Mettre à jour l'image\",\n    },\n    import: {\n      action: {\n        download_error_report: \"Téléchargez le rapport d'erreur\",\n        import: \"Importer\",\n        import_another: \"Importer un autre fichier\",\n      },\n      error: {\n        unable: \"Impossible d'importer ce fichier.\",\n      },\n      idle: {\n        description_1:\n          \"Vous pouvez importer des ventes, des entreprises, des contacts, des entreprises, des notes et des tâches.\",\n        description_2:\n          \"Les données doivent se trouver dans un fichier JSON correspondant à l'exemple suivant :\",\n      },\n      status: {\n        all_success: \"Tous les enregistrements ont été importés avec succès.\",\n        complete: \"Importation terminée.\",\n        failed: \"Échoué\",\n        imported: \"Importé\",\n        in_progress: \"Import en cours, veuillez ne pas quitter cette page.\",\n        some_failed: \"Certains enregistrements n'ont pas été importés.\",\n        table_caption: \"Statut d'importation\",\n      },\n      title: \"Importer des données\",\n    },\n    settings: {\n      about: \"À propos\",\n      companies: {\n        sectors: \"Secteurs\",\n      },\n      dark_mode_logo: \"Logo du mode sombre\",\n      deals: {\n        categories: \"Catégories\",\n        currency: \"Devise\",\n        pipeline_help:\n          \"Sélectionnez les étapes d'affaire à considérer comme des affaires dans le pipeline.\",\n        pipeline_statuses: \"Statuts des pipelines\",\n        stages: \"Étapes\",\n      },\n      light_mode_logo: \"Logo du mode clair\",\n      notes: {\n        statuses: \"Statuts\",\n      },\n      reset_defaults: \"Réinitialiser aux valeurs par défaut\",\n      save_error: \"Échec de l'enregistrement de la configuration\",\n      saved: \"Configuration enregistrée avec succès\",\n      saving: \"Enregistrement...\",\n      tasks: {\n        types: \"Types\",\n      },\n      preferences: \"Préférences\",\n      title: \"Paramètres\",\n      app_title: \"Titre de l'application\",\n      sections: {\n        branding: \"Image de marque\",\n      },\n      validation: {\n        duplicate: \"%{display_name} en double : %{items}\",\n        in_use:\n          \"Impossible de supprimer %{display_name} encore utilisés par des affaires : %{items}\",\n        validating: \"Validation\\u2026\",\n        entities: {\n          categories: \"catégories\",\n          stages: \"étapes\",\n        },\n      },\n    },\n    theme: {\n      dark: \"Sombre\",\n      label: \"Thème\",\n      light: \"Clair\",\n      system: \"Système\",\n    },\n    language: \"Langue\",\n    navigation: {\n      label: \"Navigation CRM\",\n    },\n    profile: {\n      inbound: {\n        description:\n          \"Vous pouvez commencer à envoyer des e-mails vers l'adresse de réception de votre serveur, par exemple en l'ajoutant au champ %{field}. Atomic CRM traitera les e-mails et ajoutera des notes aux contacts correspondants.\",\n        title: \"E-mail entrant\",\n      },\n      mcp: {\n        title: \"Serveur MCP\",\n        description:\n          \"Utilisez cette URL pour connecter votre assistant IA aux données de votre CRM via le Model Context Protocol (MCP).\",\n      },\n      password: {\n        change: \"Changer le mot de passe\",\n      },\n      password_reset_sent:\n        \"Un e-mail de réinitialisation du mot de passe a été envoyé à votre adresse e-mail\",\n      record_not_found: \"Enregistrement introuvable\",\n      title: \"Profil\",\n      updated: \"Votre profil a été mis à jour\",\n      update_error: \"Une erreur s'est produite. Veuillez réessayer\",\n    },\n    validation: {\n      invalid_url: \"Doit être une URL valide\",\n      invalid_linkedin_url: \"L'URL doit provenir de linkedin.com\",\n    },\n  },\n} satisfies CrmMessages;\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/providers/commons/englishCrmMessages.ts",
      "content": "export const englishCrmMessages = {\n  resources: {\n    companies: {\n      name: \"Company |||| Companies\",\n      forcedCaseName: \"Company\",\n      fields: {\n        name: \"Company name\",\n        website: \"Website\",\n        linkedin_url: \"LinkedIn URL\",\n        phone_number: \"Phone number\",\n        created_at: \"Created at\",\n        nb_contacts: \"Number of contacts\",\n        revenue: \"Revenue\",\n        sector: \"Sector\",\n        size: \"Size\",\n        tax_identifier: \"Tax Identifier\",\n        address: \"Address\",\n        city: \"City\",\n        zipcode: \"Zip code\",\n        state_abbr: \"State\",\n        country: \"Country\",\n        description: \"Description\",\n        context_links: \"Context links\",\n        sales_id: \"Account manager\",\n      },\n      empty: {\n        description: \"It seems your company list is empty.\",\n        title: \"No companies found\",\n      },\n      field_categories: {\n        contact: \"Contact\",\n        additional_info: \"Additional information\",\n        address: \"Address\",\n        context: \"Context\",\n      },\n      action: {\n        create: \"Create Company\",\n        edit: \"Edit company\",\n        new: \"New Company\",\n        show: \"Show company\",\n      },\n      added_on: \"Added on %{date}\",\n      followed_by: \"Followed by %{name}\",\n      followed_by_you: \"Followed by you\",\n      no_contacts: \"No contact\",\n      nb_contacts: \"%{smart_count} contact |||| %{smart_count} contacts\",\n      nb_deals: \"%{smart_count} deal |||| %{smart_count} deals\",\n      sizes: {\n        one_employee: \"1 employee\",\n        two_to_nine_employees: \"2-9 employees\",\n        ten_to_forty_nine_employees: \"10-49 employees\",\n        fifty_to_two_hundred_forty_nine_employees: \"50-249 employees\",\n        two_hundred_fifty_or_more_employees: \"250 or more employees\",\n      },\n      autocomplete: {\n        create_error: \"An error occurred while creating the company\",\n        create_item: \"Create %{item}\",\n        create_label: \"Start typing to create a new company\",\n      },\n      filters: {\n        only_mine: \"Only companies I manage\",\n      },\n    },\n    contacts: {\n      name: \"Contact |||| Contacts\",\n      forcedCaseName: \"Contact\",\n      field_categories: {\n        background_info: \"Background info\",\n        identity: \"Identity\",\n        misc: \"Misc\",\n        personal_info: \"Personal info\",\n        position: \"Position\",\n      },\n      fields: {\n        first_name: \"First name\",\n        last_name: \"Last name\",\n        last_seen: \"Last seen\",\n        title: \"Title\",\n        company_id: \"Company\",\n        email_jsonb: \"Email addresses\",\n        email: \"Email\",\n        phone_jsonb: \"Phone numbers\",\n        phone_number: \"Phone number\",\n        linkedin_url: \"LinkedIn URL\",\n        background: \"Background info (bio, how you met, etc)\",\n        has_newsletter: \"Has newsletter\",\n        sales_id: \"Account manager\",\n      },\n      action: {\n        add: \"Add contact\",\n        add_first: \"Add your first contact\",\n        create: \"Create contact\",\n        edit: \"Edit contact\",\n        export_vcard: \"Export to vCard\",\n        new: \"New Contact\",\n        show: \"Show contact\",\n      },\n      background: {\n        last_activity_on: \"Last activity on %{date}\",\n        added_on: \"Added on %{date}\",\n        followed_by: \"Followed by %{name}\",\n        followed_by_you: \"Followed by you\",\n        status_none: \"None\",\n      },\n      position_at: \"%{title} at\",\n      position_at_company: \"%{title} at %{company}\",\n      empty: {\n        description: \"It seems your contact list is empty.\",\n        title: \"No contacts found\",\n      },\n      import: {\n        title: \"Import contacts\",\n        button: \"Import CSV\",\n        complete:\n          \"Contacts import complete. Imported %{importCount} contacts, with %{errorCount} errors\",\n        progress:\n          \"Imported %{importCount} / %{rowCount} contacts, with %{errorCount} errors.\",\n        error:\n          \"Failed to import this file, please make sure your provided a valid CSV file.\",\n        imported: \"Imported\",\n        remaining_time: \"Estimated remaining time:\",\n        running: \"The import is running, please do not close this tab.\",\n        sample_download: \"Download CSV sample\",\n        sample_hint: \"Here is a sample CSV file you can use as a template\",\n        stop: \"Stop import\",\n        csv_file: \"CSV File\",\n        contacts_label: \"contact |||| contacts\",\n      },\n      inputs: {\n        genders: {\n          male: \"He/Him\",\n          female: \"She/Her\",\n          nonbinary: \"They/Them\",\n        },\n        personal_info_types: {\n          work: \"Work\",\n          home: \"Home\",\n          other: \"Other\",\n        },\n      },\n      list: {\n        error_loading: \"Error loading contacts\",\n      },\n      bulk_tag: {\n        action: \"Tag\",\n        back: \"Back to tags\",\n        create_description:\n          \"Create a new tag and apply it to the selected contacts.\",\n        description:\n          \"Choose an existing tag or create a new one for the selected contacts.\",\n        empty: \"No tags yet. Create one to tag the selected contacts.\",\n        error: \"Failed to add tag to contacts\",\n        noop: \"Selected contacts already have this tag\",\n        success:\n          \"Tag added to %{smart_count} contact |||| Tag added to %{smart_count} contacts\",\n        title: \"Add tag to contacts\",\n      },\n      merge: {\n        action: \"Merge with another contact\",\n        confirm: \"Merge Contacts\",\n        current_contact: \"Current Contact (will be deleted)\",\n        description: \"Merge this contact with another one.\",\n        error: \"Failed to merge contacts\",\n        merging: \"Merging...\",\n        no_additional_data: \"No additional data to merge\",\n        select_target: \"Please select a contact to merge with\",\n        success: \"Contacts merged successfully\",\n        target_contact: \"Target Contact (will be kept)\",\n        title: \"Merge Contact\",\n        warning_description:\n          \"All data will be transferred to the second contact. This action cannot be undone.\",\n        warning_title: \"Warning: Destructive Operation\",\n        what_will_be_merged: \"What will be merged:\",\n      },\n      filters: {\n        before_last_month: \"Before last month\",\n        before_this_month: \"Before this month\",\n        before_this_week: \"Before this week\",\n        managed_by_me: \"Managed by me\",\n        search: \"Search name, company...\",\n        this_week: \"This week\",\n        today: \"Today\",\n        tags: \"Tags\",\n        tasks: \"Tasks\",\n      },\n      hot: {\n        empty_change_status:\n          'Change the status of a contact by adding a note to that contact and clicking on \"show options\".',\n        empty_hint: 'Contacts with a \"hot\" status will appear here.',\n        title: \"Hot Contacts\",\n      },\n    },\n    deals: {\n      name: \"Deal |||| Deals\",\n      fields: {\n        name: \"Name\",\n        description: \"Description\",\n        company_id: \"Company\",\n        contact_ids: \"Contacts\",\n        category: \"Category\",\n        amount: \"Budget\",\n        expected_closing_date: \"Expected closing date\",\n        stage: \"Stage\",\n      },\n      action: {\n        back_to_deal: \"Back to deal\",\n        create: \"Create deal\",\n        new: \"New Deal\",\n      },\n      field_categories: {\n        misc: \"Misc\",\n      },\n      archived: {\n        action: \"Archive\",\n        error: \"Error: deal not archived\",\n        list_title: \"Archived Deals\",\n        success: \"Deal archived\",\n        title: \"Archived Deal\",\n        view: \"View archived deals\",\n      },\n      inputs: {\n        linked_to: \"Linked to\",\n      },\n      unarchived: {\n        action: \"Send back to the board\",\n        error: \"Error: deal not unarchived\",\n        success: \"Deal unarchived\",\n      },\n      updated: \"Deal updated\",\n      empty: {\n        before_create: \"before creating a deal.\",\n        description: \"It seems your deal list is empty.\",\n        title: \"No deals found\",\n      },\n      invalid_date: \"Invalid date\",\n    },\n    notes: {\n      name: \"Note |||| Notes\",\n      forcedCaseName: \"Note\",\n      fields: {\n        status: \"Status\",\n        date: \"Date\",\n        attachments: \"Attachments\",\n        contact_id: \"Contact\",\n        deal_id: \"Deal\",\n      },\n      action: {\n        add: \"Add note\",\n        add_first: \"Add your first note\",\n        delete: \"Delete note\",\n        edit: \"Edit note\",\n        update: \"Update note\",\n        add_this: \"Add this note\",\n      },\n      sheet: {\n        create: \"Create note\",\n        create_for: \"Create note for %{name}\",\n        edit: \"Edit note\",\n        edit_for: \"Edit note for %{name}\",\n      },\n      deleted: \"Note deleted\",\n      empty: \"No notes yet\",\n      author_added: \"%{name} added a note\",\n      you_added: \"You added a note\",\n      me: \"Me\",\n      list: {\n        error_loading: \"Error loading notes\",\n      },\n      note_for_contact: \"Note for %{name}\",\n      stepper: {\n        hint: \"Go to a contact page and add a note\",\n      },\n      added: \"Note added\",\n      inputs: {\n        add_note: \"Add a note\",\n        options_hint: \"(attach files, or change details)\",\n        show_options: \"Show options\",\n      },\n      actions: {\n        attach_document: \"Attach document\",\n      },\n      validation: {\n        note_or_attachment_required: \"A note or an attachment is required\",\n      },\n    },\n    sales: {\n      name: \"User |||| Users\",\n      fields: {\n        first_name: \"First name\",\n        last_name: \"Last name\",\n        email: \"Email\",\n        administrator: \"Admin\",\n        disabled: \"Disabled\",\n      },\n      create: {\n        error: \"An error occurred while creating the user.\",\n        success:\n          \"User created. They will soon receive an email to set their password.\",\n        title: \"Create a new user\",\n      },\n      edit: {\n        error: \"An error occurred. Please try again.\",\n        record_not_found: \"Record not found\",\n        success: \"User updated successfully\",\n        title: \"Edit %{name}\",\n      },\n      action: {\n        new: \"New user\",\n      },\n    },\n    tasks: {\n      name: \"Task |||| Tasks\",\n      forcedCaseName: \"Task\",\n      fields: {\n        text: \"Description\",\n        due_date: \"Due date\",\n        type: \"Type\",\n        contact_id: \"Contact\",\n        due_short: \"due\",\n      },\n      action: {\n        add: \"Add task\",\n        create: \"Create task\",\n        edit: \"Edit task\",\n      },\n      actions: {\n        postpone_next_week: \"Postpone to next week\",\n        postpone_tomorrow: \"Postpone to tomorrow\",\n        title: \"task actions\",\n      },\n      added: \"Task added\",\n      deleted: \"Task deleted successfully\",\n      dialog: {\n        create: \"Create task\",\n        create_for: \"Create task for %{name}\",\n      },\n      sheet: {\n        edit: \"Edit task\",\n        edit_for: \"Edit task for %{name}\",\n      },\n      empty: \"No tasks yet\",\n      empty_list_hint: \"Tasks added to your contacts will appear here.\",\n      filters: {\n        later: \"Later\",\n        overdue: \"Overdue\",\n        this_week: \"This week\",\n        today: \"Today\",\n        tomorrow: \"Tomorrow\",\n        with_pending: \"With pending tasks\",\n      },\n      regarding_contact: \"(Re: %{name})\",\n      updated: \"Task updated\",\n    },\n    tags: {\n      name: \"Tag |||| Tags\",\n      action: {\n        add: \"Add tag\",\n        create: \"Create new tag\",\n      },\n      dialog: {\n        color: \"Color\",\n        create_title: \"Create a new tag\",\n        edit_title: \"Edit tag\",\n        name_label: \"Tag name\",\n        name_placeholder: \"Enter tag name\",\n      },\n    },\n  },\n  crm: {\n    action: {\n      reset_password: \"Reset Password\",\n    },\n    auth: {\n      first_name: \"First name\",\n      last_name: \"Last name\",\n      confirm_password: \"Confirm password\",\n      confirmation_required:\n        \"Please follow the link we just sent you by email to confirm your account.\",\n      recovery_email_sent:\n        \"If you're a registered user, you should receive a password recovery email shortly.\",\n      sign_in_failed: \"Failed to log in.\",\n      sign_in_google_workspace: \"Sign in with Google Workplace\",\n      signup: {\n        create_account: \"Create account\",\n        create_first_user:\n          \"Create the first user account to complete the setup.\",\n        creating: \"Creating...\",\n        initial_user_created: \"Initial user successfully created\",\n      },\n      welcome_title: \"Welcome to Atomic CRM\",\n    },\n    common: {\n      activity: \"Activity\",\n      added: \"added\",\n      details: \"Details\",\n      last_activity_with_date: \"last activity %{date}\",\n      load_more: \"Load more\",\n      misc: \"Misc\",\n      past: \"Past\",\n      read_more: \"Read more\",\n      retry: \"Retry\",\n      show_less: \"Show less\",\n      copied: \"Copied!\",\n      copy: \"Copy\",\n      loading: \"Loading...\",\n      me: \"Me\",\n      task_count: \"%{smart_count} task |||| %{smart_count} tasks\",\n    },\n    changelog: {\n      title: \"Changelog\",\n    },\n    activity: {\n      added_company: \"%{name} added company\",\n      you_added_company: \"You added company\",\n      added_contact: \"%{name} added\",\n      you_added_contact: \"You added\",\n      added_note: \"%{name} added a note about\",\n      you_added_note: \"You added a note about\",\n      added_note_about_deal: \"%{name} added a note about deal\",\n      you_added_note_about_deal: \"You added a note about deal\",\n      added_deal: \"%{name} added deal\",\n      you_added_deal: \"You added deal\",\n      at_company: \"at\",\n      to: \"to\",\n      load_more: \"Load more activity\",\n    },\n    dashboard: {\n      deals_chart: \"Upcoming Deal Revenue\",\n      deals_pipeline: \"Deals Pipeline\",\n      latest_activity: \"Latest Activity\",\n      latest_activity_error: \"Error loading latest activity\",\n      latest_notes: \"My Latest Notes\",\n      latest_notes_added_ago: \"added %{timeAgo}\",\n      stepper: {\n        install: \"Install Atomic CRM\",\n        progress: \"%{step}/3 done\",\n        whats_next: \"What's next?\",\n      },\n      upcoming_tasks: \"Upcoming Tasks\",\n    },\n    header: {\n      import_data: \"Import data\",\n    },\n    image_editor: {\n      change: \"Change\",\n      drop_hint: \"Drop a file to upload, or click to select it.\",\n      editable_content: \"Editable content\",\n      title: \"Upload and resize image\",\n      update_image: \"Update Image\",\n    },\n    import: {\n      action: {\n        download_error_report: \"Download the error report\",\n        import: \"Import\",\n        import_another: \"Import another file\",\n      },\n      error: {\n        unable: \"Unable to import this file.\",\n      },\n      idle: {\n        description_1:\n          \"You can import sales, companies, contacts, companies, notes, and tasks.\",\n        description_2:\n          \"Data must be in a JSON file matching the following sample:\",\n      },\n      status: {\n        all_success: \"All records were imported successfully.\",\n        complete: \"Import complete.\",\n        failed: \"Failed\",\n        imported: \"Imported\",\n        in_progress:\n          \"Import in progress, please don't navigate away from this page.\",\n        some_failed: \"Some records were not imported.\",\n        table_caption: \"Import status\",\n      },\n      title: \"Import Data\",\n    },\n    settings: {\n      about: \"About\",\n      companies: {\n        sectors: \"Sectors\",\n      },\n      dark_mode_logo: \"Dark Mode Logo\",\n      deals: {\n        categories: \"Categories\",\n        currency: \"Currency\",\n        pipeline_help:\n          \"Select which deal stages should count as pipeline deals.\",\n        pipeline_statuses: \"Pipeline Statuses\",\n        stages: \"Stages\",\n      },\n      light_mode_logo: \"Light Mode Logo\",\n      notes: {\n        statuses: \"Statuses\",\n      },\n      reset_defaults: \"Reset to Defaults\",\n      save_error: \"Failed to save configuration\",\n      saved: \"Configuration saved successfully\",\n      saving: \"Saving...\",\n      tasks: {\n        types: \"Types\",\n      },\n      preferences: \"Preferences\",\n      title: \"Settings\",\n      app_title: \"App Title\",\n      sections: {\n        branding: \"Branding\",\n      },\n      validation: {\n        duplicate: \"Duplicate %{display_name}: %{items}\",\n        in_use:\n          \"Cannot remove %{display_name} that are still used by deals: %{items}\",\n        validating: \"Validating\\u2026\",\n        entities: {\n          categories: \"categories\",\n          stages: \"stages\",\n        },\n      },\n    },\n    theme: {\n      dark: \"Dark\",\n      label: \"Theme\",\n      light: \"Light\",\n      system: \"System\",\n    },\n    language: \"Language\",\n    navigation: {\n      label: \"CRM navigation\",\n    },\n    profile: {\n      inbound: {\n        description:\n          \"You can start sending emails to your server's inbound email address, e.g. by adding it to the %{field} field. Atomic CRM will process the emails and add notes to the corresponding contacts.\",\n        title: \"Inbound email\",\n      },\n      mcp: {\n        title: \"MCP Server\",\n        description:\n          \"Use this URL to connect your AI assistant to your CRM data via the Model Context Protocol (MCP).\",\n      },\n      password: {\n        change: \"Change password\",\n      },\n      password_reset_sent:\n        \"A reset password email has been sent to your email address\",\n      record_not_found: \"Record not found\",\n      title: \"Profile\",\n      updated: \"Your profile has been updated\",\n      update_error: \"An error occurred. Please try again\",\n    },\n    validation: {\n      invalid_url: \"Must be a valid URL\",\n      invalid_linkedin_url: \"URL must be from linkedin.com\",\n    },\n  },\n} as const;\n\ntype MessageSchema<T> = {\n  [K in keyof T]: T[K] extends string\n    ? string\n    : T[K] extends Record<string, unknown>\n      ? MessageSchema<T[K]>\n      : never;\n};\n\ntype DeepPartial<T> = {\n  [K in keyof T]?: T[K] extends Record<string, unknown>\n    ? DeepPartial<T[K]>\n    : T[K];\n};\n\nexport type CrmMessages = MessageSchema<typeof englishCrmMessages>;\nexport type PartialCrmMessages = DeepPartial<CrmMessages>;\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/providers/commons/canAccess.ts",
      "content": "// FIXME: This should be exported from the ra-core package\ntype CanAccessParams<\n  RecordType extends Record<string, any> = Record<string, any>,\n> = {\n  action: string;\n  resource: string;\n  record?: RecordType;\n};\n\nexport const canAccess = <\n  RecordType extends Record<string, any> = Record<string, any>,\n>(\n  role: string,\n  params: CanAccessParams<RecordType>,\n) => {\n  if (role === \"admin\") {\n    return true;\n  }\n\n  // Non admins can't access the sales resource\n  if (params.resource === \"sales\") {\n    return false;\n  }\n\n  // Non admins can't access the configuration resource\n  if (params.resource === \"configuration\") {\n    return false;\n  }\n\n  return true;\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/providers/commons/attachments.ts",
      "content": "export const ATTACHMENTS_BUCKET =\n  import.meta.env.VITE_ATTACHMENTS_BUCKET || \"attachments\";\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/providers/commons/activity.ts",
      "content": "import type { DataProvider, Identifier } from \"ra-core\";\n\nimport {\n  COMPANY_CREATED,\n  CONTACT_CREATED,\n  CONTACT_NOTE_CREATED,\n  DEAL_CREATED,\n  DEAL_NOTE_CREATED,\n} from \"../../consts\";\nimport type {\n  Activity,\n  Company,\n  Contact,\n  ContactNote,\n  Deal,\n  DealNote,\n} from \"../../types\";\n\n// FIXME: Requires 5 large queries to get the latest activities.\n// Replace with a server-side view or a custom API endpoint.\nexport async function getActivityLog(\n  dataProvider: DataProvider,\n  companyId?: Identifier,\n  salesId?: Identifier,\n) {\n  const companyFilter = {} as any;\n  if (companyId) {\n    companyFilter.id = companyId;\n  } else if (salesId) {\n    companyFilter[\"sales_id@in\"] = `(${salesId})`;\n  }\n\n  const filter = {} as any;\n  if (companyId) {\n    filter.company_id = companyId;\n  } else if (salesId) {\n    filter[\"sales_id@in\"] = `(${salesId})`;\n  }\n\n  const [newCompanies, newContactsAndNotes, newDealsAndNotes] =\n    await Promise.all([\n      getNewCompanies(dataProvider, companyFilter),\n      getNewContactsAndNotes(dataProvider, filter),\n      getNewDealsAndNotes(dataProvider, filter),\n    ]);\n  return (\n    [...newCompanies, ...newContactsAndNotes, ...newDealsAndNotes]\n      // sort by date desc\n      .sort(\n        (a, b) =>\n          (a.date || new Date(0).toISOString()).localeCompare(\n            b.date || new Date(0).toISOString(),\n          ) * -1,\n      )\n      // limit to 250 activities\n      .slice(0, 250)\n  );\n}\n\nconst getNewCompanies = async (\n  dataProvider: DataProvider,\n  filter: any,\n): Promise<Activity[]> => {\n  const { data: companies } = await dataProvider.getList<Company>(\"companies\", {\n    filter,\n    pagination: { page: 1, perPage: 250 },\n    sort: { field: \"created_at\", order: \"DESC\" },\n  });\n  return companies\n    .filter((company: Company) => company.sales_id != null)\n    .map((company) => ({\n      id: `company.${company.id}.created`,\n      type: COMPANY_CREATED,\n      company_id: company.id,\n      company,\n      sales_id: company.sales_id!,\n      date: company.created_at,\n    }));\n};\n\nasync function getNewContactsAndNotes(\n  dataProvider: DataProvider,\n  filter: any,\n): Promise<Activity[]> {\n  const { data: contacts } = await dataProvider.getList<Contact>(\"contacts\", {\n    filter,\n    pagination: { page: 1, perPage: 250 },\n    sort: { field: \"first_seen\", order: \"DESC\" },\n  });\n\n  const recentContactNotesFilter = {} as any;\n  if (filter.sales_id) {\n    recentContactNotesFilter.sales_id = filter.sales_id;\n  }\n  if (filter.company_id) {\n    // No company_id field in contactNote, filtering by related contacts instead.\n    // This filter is only valid if a company has less than 250 contact.\n    const contactIds = contacts.map((contact) => contact.id).join(\",\");\n    recentContactNotesFilter[\"contact_id@in\"] = `(${contactIds})`;\n  }\n\n  const { data: contactNotes } = await dataProvider.getList<ContactNote>(\n    \"contact_notes\",\n    {\n      filter: recentContactNotesFilter,\n      pagination: { page: 1, perPage: 250 },\n      sort: { field: \"date\", order: \"DESC\" },\n    },\n  );\n\n  const newContacts = contacts\n    .filter(\n      (contact): contact is Contact & { company_id: Identifier } =>\n        contact.company_id != null,\n    )\n    .map((contact) => ({\n      id: `contact.${contact.id}.created`,\n      type: CONTACT_CREATED,\n      company_id: contact.company_id,\n      sales_id: contact.sales_id,\n      contact,\n      date: contact.first_seen,\n    }));\n\n  const newContactNotes = contactNotes.map((contactNote) => ({\n    id: `contactNote.${contactNote.id}.created`,\n    type: CONTACT_NOTE_CREATED,\n    sales_id: contactNote.sales_id,\n    contactNote,\n    date: contactNote.date,\n  }));\n\n  return [...newContacts, ...newContactNotes];\n}\n\nasync function getNewDealsAndNotes(\n  dataProvider: DataProvider,\n  filter: any,\n): Promise<Activity[]> {\n  const { data: deals } = await dataProvider.getList<Deal>(\"deals\", {\n    filter,\n    pagination: { page: 1, perPage: 250 },\n    sort: { field: \"created_at\", order: \"DESC\" },\n  });\n\n  const recentDealNotesFilter = {} as any;\n  if (filter.sales_id) {\n    recentDealNotesFilter.sales_id = filter.sales_id;\n  }\n  if (filter.company_id) {\n    // No company_id field in dealNote, filtering by related deals instead.\n    // This filter is only valid if a deal has less than 250 notes.\n    const dealIds = deals.map((deal) => deal.id).join(\",\");\n    recentDealNotesFilter[\"deal_id@in\"] = `(${dealIds})`;\n  }\n\n  const { data: dealNotes } = await dataProvider.getList<DealNote>(\n    \"deal_notes\",\n    {\n      filter: recentDealNotesFilter,\n      pagination: { page: 1, perPage: 250 },\n      sort: { field: \"date\", order: \"DESC\" },\n    },\n  );\n\n  const newDeals = deals.map((deal) => ({\n    id: `deal.${deal.id}.created`,\n    type: DEAL_CREATED,\n    company_id: deal.company_id,\n    sales_id: deal.sales_id,\n    deal,\n    date: deal.created_at,\n  }));\n\n  const newDealNotes = dealNotes.map((dealNote) => ({\n    id: `dealNote.${dealNote.id}.created`,\n    type: DEAL_NOTE_CREATED,\n    sales_id: dealNote.sales_id,\n    dealNote,\n    date: dealNote.date,\n  }));\n\n  return [...newDeals, ...newDealNotes];\n}\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/notes/utils.ts",
      "content": "export const getCurrentDate = () => {\n  const now = new Date();\n  const offset = now.getTimezoneOffset();\n  const localDate = new Date(now.getTime() - offset * 60 * 1000);\n  return localDate.toISOString().slice(0, 16);\n};\n\nexport const formatNoteDate = (dateString: string) => {\n  const date = new Date(dateString);\n  date.setSeconds(0);\n  date.setMilliseconds(0);\n  return date.toISOString();\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/notes/noteModel.ts",
      "content": "export const validateNoteOrAttachmentRequired = (\n  value: string | null | undefined,\n  values: { attachments?: unknown[] | null },\n) => {\n  const hasText = typeof value === \"string\" && value.trim().length > 0;\n  const hasAttachments =\n    Array.isArray(values?.attachments) && values.attachments.length > 0;\n\n  return hasText || hasAttachments\n    ? undefined\n    : \"resources.notes.validation.note_or_attachment_required\";\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/notes/index.ts",
      "content": "export * from \"./NoteCreate\";\nexport * from \"./NotesIterator\";\nexport * from \"./NotesIteratorMobile\";\nexport * from \"./StatusSelector\";\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/notes/foreignKeyMapping.ts",
      "content": "export const foreignKeyMapping = {\n  contacts: \"contact_id\",\n  deals: \"deal_id\",\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/notes/StatusSelector.tsx",
      "content": "import { cn } from \"@/lib/utils\";\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@/components/ui/select\";\nimport { ChevronDownIcon } from \"lucide-react\";\nimport { useIsMobile } from \"@/hooks/use-mobile\";\n\nimport { useConfigurationContext } from \"../root/ConfigurationContext\";\nimport { Translate, useTranslate } from \"ra-core\";\n\nconst NONE_VALUE = \"__none__\";\n\ntype StatusSelectorProps = {\n  disabled?: boolean;\n  status?: string;\n  setStatus: (status: string) => void;\n  triggerClassName?: string;\n};\n\nexport const StatusSelector = ({\n  disabled,\n  status,\n  setStatus,\n  triggerClassName,\n}: StatusSelectorProps) => {\n  const { noteStatuses } = useConfigurationContext();\n  const translate = useTranslate();\n  const isMobile = useIsMobile();\n  const noneLabel = translate(\"resources.contacts.background.status_none\", {\n    _: \"None\",\n  });\n\n  if (isMobile) {\n    // use native select on mobile for better performance and accessibility\n    const selectedOption = noteStatuses.find((s) => s.value === status);\n    return (\n      <div className={cn(\"relative\", \"w-32\", triggerClassName)}>\n        <div\n          aria-hidden=\"true\"\n          className={cn(\n            \"border-input flex w-full items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs h-9\",\n            disabled && \"cursor-not-allowed opacity-50\",\n          )}\n        >\n          <span className=\"flex items-center gap-2 line-clamp-1\">\n            {selectedOption ? (\n              <>\n                <span\n                  className=\"inline-block w-2.5 h-2.5 rounded-full shrink-0\"\n                  style={{ backgroundColor: selectedOption.color }}\n                />\n                {selectedOption.label}\n              </>\n            ) : (\n              noneLabel\n            )}\n          </span>\n          <ChevronDownIcon className=\"size-4 opacity-50 shrink-0\" />\n        </div>\n        <select\n          disabled={disabled}\n          value={status || \"\"}\n          onChange={(e) => setStatus(e.target.value)}\n          aria-label={translate(\"resources.notes.fields.status\", {\n            _: \"Status\",\n          })}\n          className=\"absolute inset-0 opacity-0 w-full h-full cursor-pointer\"\n        >\n          <option value=\"\">{noneLabel}</option>\n          {noteStatuses.map((opt) => (\n            <option key={opt.value} value={opt.value}>\n              {opt.label}\n            </option>\n          ))}\n        </select>\n      </div>\n    );\n  }\n\n  /**\n   * Radix's Select component doesn't allow empty string as value, so we use a placeholder value and convert it back to empty string on change\n   * @see https://github.com/radix-ui/primitives/issues/2706\n   */\n  const handleValueChange = (value: string) => {\n    setStatus(value === NONE_VALUE ? \"\" : value);\n  };\n\n  return (\n    <Select\n      disabled={disabled}\n      value={status || NONE_VALUE}\n      onValueChange={handleValueChange}\n    >\n      <SelectTrigger className={cn(\"w-32\", triggerClassName)}>\n        <SelectValue placeholder={noneLabel} />\n      </SelectTrigger>\n      <SelectContent>\n        <SelectItem value={NONE_VALUE}>\n          <Translate i18nKey=\"resources.contacts.background.status_none\">\n            None\n          </Translate>\n        </SelectItem>\n        {noteStatuses.map((statusOption) => (\n          <SelectItem key={statusOption.value} value={statusOption.value}>\n            <div className=\"flex items-center gap-2\">\n              <span\n                className=\"inline-block w-2.5 h-2.5 rounded-full\"\n                style={{ backgroundColor: statusOption.color }}\n              />\n              {statusOption.label}\n            </div>\n          </SelectItem>\n        ))}\n      </SelectContent>\n    </Select>\n  );\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/notes/NotesIteratorMobile.tsx",
      "content": "import type { Identifier } from \"ra-core\";\nimport {\n  useGetIdentity,\n  useListContext,\n  useTimeout,\n  useTranslate,\n} from \"ra-core\";\nimport { Link } from \"react-router\";\nimport { Button } from \"@/components/ui/button\";\nimport { Separator } from \"@/components/ui/separator\";\nimport { Skeleton } from \"@/components/ui/skeleton\";\nimport { RotateCcw } from \"lucide-react\";\n\nimport { RelativeDate } from \"../misc/RelativeDate\";\nimport { Status } from \"../misc/Status\";\nimport { useGetSalesName } from \"../sales/useGetSalesName\";\nimport type { ContactNote } from \"../types\";\nimport { InfinitePagination } from \"../misc/InfinitePagination\";\n\nexport const NotesIteratorMobile = ({\n  contactId,\n  showStatus,\n}: {\n  contactId: Identifier;\n  showStatus?: boolean;\n}) => {\n  const {\n    data = [],\n    error,\n    isPending,\n    refetch,\n  } = useListContext<ContactNote>();\n  const translate = useTranslate();\n  const oneSecondHasPassed = useTimeout(1000);\n  if (isPending) {\n    if (!oneSecondHasPassed) {\n      return null;\n    }\n    return (\n      <div>\n        {Array.from({ length: 3 }).map((_, index) => (\n          <div className=\"space-y-2 mt-1\" key={index}>\n            <div className=\"flex flex-row space-x-2 items-center\">\n              <Skeleton className=\"w-full h-4\" />\n            </div>\n            <Skeleton className=\"w-full h-12\" />\n            <Separator />\n          </div>\n        ))}\n      </div>\n    );\n  }\n  if (error && !data?.length) {\n    return (\n      <div className=\"p-4\">\n        <div className=\"text-center text-muted-foreground mb-4\">\n          {translate(\"resources.notes.list.error_loading\", {\n            _: \"Error loading notes\",\n          })}\n        </div>\n        <div className=\"text-center mt-2\">\n          <Button\n            onClick={() => {\n              refetch();\n            }}\n          >\n            <RotateCcw />\n            {translate(\"crm.common.retry\")}\n          </Button>\n        </div>\n      </div>\n    );\n  }\n\n  return (\n    <>\n      <div className=\"divide-y\">\n        {data.map((note) => (\n          <NoteMobile\n            key={note.id}\n            note={note}\n            contactId={contactId}\n            showStatus={showStatus}\n          />\n        ))}\n      </div>\n      <InfinitePagination />\n    </>\n  );\n};\n\nexport const NoteMobile = ({\n  note,\n  contactId,\n  showStatus,\n}: {\n  note: ContactNote;\n  contactId: Identifier;\n  showStatus?: boolean;\n}) => {\n  const translate = useTranslate();\n  const { identity } = useGetIdentity();\n  const isCurrentUser = note.sales_id === identity?.id;\n  const salesName = useGetSalesName(note.sales_id, {\n    enabled: !isCurrentUser,\n  });\n\n  return (\n    <Link\n      to={`/contacts/${contactId}/notes/${note.id}`}\n      className=\"block active:bg-accent/50 -mx-2 px-2 py-2 rounded-md\"\n    >\n      <div className=\"flex items-center space-x-2 w-full\">\n        <div className=\"inline-flex h-full items-center text-sm text-muted-foreground\">\n          {isCurrentUser ? translate(\"resources.notes.me\") : salesName}{\" \"}\n          {showStatus && note.status && (\n            <Status className=\"ml-2\" status={note.status} />\n          )}\n        </div>\n        <div className=\"flex-1\" />\n        <span className=\"text-sm text-muted-foreground\">\n          <RelativeDate date={note.date} />\n        </span>\n      </div>\n      {note.text && (\n        <p className=\"pt-2 text-sm line-clamp-3\">\n          {note.text.replace(/\\s+/g, \" \").trim()}\n        </p>\n      )}\n    </Link>\n  );\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/notes/NotesIterator.tsx",
      "content": "import { useListContext } from \"ra-core\";\nimport { Fragment } from \"react\";\nimport { Separator } from \"@/components/ui/separator\";\n\nimport { Note } from \"./Note\";\nimport { NoteCreate } from \"./NoteCreate\";\nimport { InfinitePagination } from \"../misc/InfinitePagination\";\n\nexport const NotesIterator = ({\n  reference,\n  showStatus,\n}: {\n  reference: \"contacts\" | \"deals\";\n  showStatus?: boolean;\n}) => {\n  const { isPending, error, data = [] } = useListContext();\n\n  if (isPending || error) return null;\n\n  return (\n    <div className=\"mt-4\">\n      <NoteCreate reference={reference} showStatus={showStatus} />\n      {data.length > 0 && (\n        <div className=\"mt-4 space-y-4\">\n          {data.map((note, index) => (\n            <Fragment key={note.id}>\n              <Note\n                note={note}\n                isLast={index === data.length - 1}\n                showStatus={showStatus}\n              />\n              {index < data.length - 1 && <Separator />}\n            </Fragment>\n          ))}\n        </div>\n      )}\n      <InfinitePagination />\n    </div>\n  );\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/notes/NoteShowPage.tsx",
      "content": "import { Pencil } from \"lucide-react\";\nimport {\n  useGetRecordRepresentation,\n  RecordRepresentation,\n  useGetIdentity,\n  useGetOne,\n  useTranslate,\n} from \"ra-core\";\nimport { useState } from \"react\";\nimport { Link, useParams } from \"react-router\";\nimport { ReferenceField } from \"@/components/admin/reference-field\";\nimport { Button } from \"@/components/ui/button\";\n\nimport { MobileContent } from \"../layout/MobileContent\";\nimport MobileHeader from \"../layout/MobileHeader\";\nimport { Markdown } from \"../misc/Markdown\";\nimport { MobileBackButton } from \"../misc/MobileBackButton\";\nimport { RelativeDate } from \"../misc/RelativeDate\";\nimport { Status } from \"../misc/Status\";\nimport type { ContactNote } from \"../types\";\nimport { NoteAttachments } from \"./NoteAttachments\";\nimport { NoteEditSheet } from \"./NoteEditSheet\";\nimport { useGetSalesName } from \"../sales/useGetSalesName\";\n\nexport const NoteShowPage = () => {\n  const translate = useTranslate();\n  const { id: contactId, noteId } = useParams<{\n    id: string;\n    noteId: string;\n  }>();\n  const [editOpen, setEditOpen] = useState(false);\n  const getContactRepresentation = useGetRecordRepresentation(\"contacts\");\n\n  const { data: note, isPending } = useGetOne<ContactNote>(\"contact_notes\", {\n    id: noteId!,\n  });\n  const { identity } = useGetIdentity();\n  const isCurrentUser = note?.sales_id === identity?.id;\n  const salesName = useGetSalesName(note?.sales_id, {\n    enabled: note && !isCurrentUser,\n  });\n\n  if (isPending || !note) return null;\n\n  return (\n    <>\n      <NoteEditSheet\n        open={editOpen}\n        onOpenChange={setEditOpen}\n        noteId={note.id}\n      />\n      <MobileHeader>\n        <MobileBackButton to={`/contacts/${contactId}/show`} />\n        <div className=\"flex flex-1 min-w-0\">\n          <Link to={`/contacts/${contactId}/show`} className=\"flex-1 min-w-0\">\n            <h1 className=\"truncate text-xl font-semibold\">\n              <ReferenceField\n                record={note}\n                resource=\"contact_notes\"\n                source=\"contact_id\"\n                reference=\"contacts\"\n                link={false}\n                render={({ referenceRecord }) =>\n                  referenceRecord\n                    ? translate(\"resources.notes.note_for_contact\", {\n                        name: getContactRepresentation(referenceRecord),\n                      })\n                    : null\n                }\n              >\n                <RecordRepresentation resource=\"contacts\" />\n              </ReferenceField>\n            </h1>\n          </Link>\n        </div>\n        <Button\n          type=\"button\"\n          variant=\"ghost\"\n          size=\"icon\"\n          className=\"rounded-full\"\n          onClick={() => setEditOpen(true)}\n        >\n          <Pencil className=\"size-5\" />\n          <span className=\"sr-only\">\n            {translate(\"resources.notes.action.edit\")}\n          </span>\n        </Button>\n      </MobileHeader>\n      <MobileContent>\n        <div className=\"mb-4\">\n          <div className=\"flex items-center space-x-2 w-full text-sm text-muted-foreground\">\n            <span>\n              {isCurrentUser ? translate(\"resources.notes.me\") : salesName}{\" \"}\n            </span>\n            {note.status && <Status status={note.status} />}\n            <div className=\"flex-1\" />\n            <RelativeDate date={note.date} />\n          </div>\n        </div>\n\n        {note.text && <Markdown className=\"text-sm\">{note.text}</Markdown>}\n\n        {note.attachments && (\n          <div className=\"mt-4\">\n            <NoteAttachments note={note} />\n          </div>\n        )}\n      </MobileContent>\n    </>\n  );\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/notes/NoteInputsMobile.tsx",
      "content": "import { useEffect, useRef } from \"react\";\nimport { Paperclip } from \"lucide-react\";\nimport {\n  required,\n  useInput,\n  useTranslate,\n  ValidationError,\n  RecordContextProvider,\n} from \"ra-core\";\nimport { AutocompleteInput, ReferenceInput } from \"@/components/admin\";\nimport { FileInputPreview } from \"@/components/admin/file-input\";\nimport { useFormContext, useWatch } from \"react-hook-form\";\n\nimport { contactOptionText } from \"../misc/ContactOption\";\nimport { AttachmentField } from \"./AttachmentField\";\nimport { foreignKeyMapping } from \"./foreignKeyMapping\";\nimport { validateNoteOrAttachmentRequired } from \"./noteModel\";\nimport type { ContactNote } from \"../types\";\n\nexport const NoteInputsMobile = ({\n  selectContact,\n}: {\n  selectContact?: boolean;\n}) => {\n  const translate = useTranslate();\n  const textareaRef = useRef<HTMLTextAreaElement | null>(null);\n  const { field, fieldState } = useInput({\n    source: \"text\",\n    validate: validateNoteOrAttachmentRequired,\n  });\n\n  useEffect(() => {\n    const node = textareaRef.current;\n    if (!node) return;\n    requestAnimationFrame(() => {\n      node.focus();\n      // move cursor to end of text\n      node.setSelectionRange(node.value.length, node.value.length);\n    });\n  }, []);\n\n  return (\n    <div className=\"flex flex-col flex-1 -m-4\">\n      <div className=\"flex-1 flex flex-col\">\n        <textarea\n          {...field}\n          ref={(node) => {\n            field.ref(node);\n            textareaRef.current = node;\n          }}\n          placeholder={translate(\"resources.notes.inputs.add_note\")}\n          className=\"flex-1 min-h-0 resize-none bg-background p-4 outline-none text-base\"\n        />\n        {fieldState.error && (\n          <p className=\"px-4 text-sm text-destructive\">\n            <ValidationError error={fieldState.error.message ?? \"\"} />\n          </p>\n        )}\n      </div>\n      {selectContact && (\n        <div className=\"px-4 py-4\">\n          <ReferenceInput\n            source={foreignKeyMapping[\"contacts\"]}\n            reference=\"contacts\"\n          >\n            <AutocompleteInput\n              label=\"resources.notes.fields.contact_id\"\n              optionText={contactOptionText}\n              helperText={false}\n              validate={required()}\n              modal\n            />\n          </ReferenceInput>\n        </div>\n      )}\n      <div className=\"px-4\">\n        <AttachmentPreviewsMobile />\n        <AttachButton />\n      </div>\n    </div>\n  );\n};\n\nconst AttachButton = () => {\n  const inputRef = useRef<HTMLInputElement>(null);\n  const { getValues, setValue } = useFormContext();\n  const translate = useTranslate();\n\n  const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {\n    const fileList = e.target.files;\n    if (!fileList || fileList.length === 0) return;\n\n    const newFiles = Array.from(fileList).map((file) => ({\n      rawFile: file,\n      src: URL.createObjectURL(file),\n      title: file.name,\n    }));\n\n    const existing = getValues(\"attachments\") || [];\n    const currentFiles = Array.isArray(existing) ? existing : [existing];\n    setValue(\"attachments\", [...currentFiles, ...newFiles], {\n      shouldDirty: true,\n    });\n\n    e.target.value = \"\";\n  };\n\n  return (\n    <>\n      <button\n        type=\"button\"\n        className=\"flex items-center gap-2 py-3 text-sm text-muted-foreground\"\n        onClick={() => inputRef.current?.click()}\n      >\n        <Paperclip className=\"size-4\" />\n        {translate(\"resources.notes.actions.attach_document\", {\n          _: \"Attach document\",\n        })}\n      </button>\n      <input\n        ref={inputRef}\n        type=\"file\"\n        multiple\n        className=\"hidden\"\n        onChange={handleFileChange}\n      />\n    </>\n  );\n};\n\nconst AttachmentPreviewsMobile = () => {\n  const { control, setValue } = useFormContext();\n  const attachments = useWatch({ control, name: \"attachments\" }) as\n    | ContactNote[\"attachments\"]\n    | undefined;\n\n  if (!Array.isArray(attachments) || attachments.length === 0) return null;\n\n  const onRemove = (index: number) => {\n    const updated = attachments.filter((_: unknown, i: number) => i !== index);\n    setValue(\"attachments\", updated, { shouldDirty: true });\n  };\n\n  return (\n    <div className=\"flex flex-col gap-1\">\n      {attachments.map((file, index: number) => (\n        <FileInputPreview\n          key={file.src}\n          file={file}\n          onRemove={() => onRemove(index)}\n        >\n          <RecordContextProvider value={file}>\n            <AttachmentField source=\"src\" title=\"title\" target=\"_blank\" />\n          </RecordContextProvider>\n        </FileInputPreview>\n      ))}\n    </div>\n  );\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/notes/NoteInputs.tsx",
      "content": "import { useEffect, useRef, useState } from \"react\";\nimport { required, useGetOne, useTranslate } from \"ra-core\";\nimport { TextInput } from \"@/components/admin/text-input\";\nimport { FileInput } from \"@/components/admin/file-input\";\nimport { SelectInput } from \"@/components/admin/select-input\";\nimport { DateTimeInput } from \"@/components/admin/date-time-input\";\nimport { Button } from \"@/components/ui/button\";\nimport { cn } from \"@/lib/utils\";\nimport { useFormContext, useWatch } from \"react-hook-form\";\n\nimport type { ContactNote, DealNote } from \"../types\";\nimport { Status } from \"../misc/Status\";\nimport { useConfigurationContext } from \"../root/ConfigurationContext\";\nimport { getCurrentDate } from \"./utils\";\nimport { AttachmentField } from \"./AttachmentField\";\nimport { foreignKeyMapping } from \"./foreignKeyMapping\";\nimport { AutocompleteInput, ReferenceInput } from \"@/components/admin\";\nimport { contactOptionText } from \"../misc/ContactOption\";\nimport { validateNoteOrAttachmentRequired } from \"./noteModel\";\n\nexport const NoteInputs = ({\n  defaultStatus,\n  showStatus,\n  selectReference,\n  reference,\n}: {\n  defaultStatus?: string;\n  showStatus?: boolean;\n  selectReference?: boolean;\n  reference?: \"contacts\" | \"deals\";\n}) => {\n  const { noteStatuses } = useConfigurationContext();\n  const translate = useTranslate();\n  const [displayMore, setDisplayMore] = useState(false);\n  const [isFocused, setIsFocused] = useState(false);\n  const containerRef = useRef<HTMLDivElement>(null);\n  const { control, formState, setValue } = useFormContext<\n    ContactNote | DealNote\n  >();\n  const selectedContactId = useWatch({ control, name: \"contact_id\" });\n  const selectedStatus = useWatch({ control, name: \"status\" });\n  const textValue = useWatch({ control, name: \"text\" as any });\n  const isExpanded = isFocused || !!textValue;\n  useEffect(() => {\n    if (!textValue) {\n      setIsFocused(false);\n      const textarea = containerRef.current?.querySelector(\"textarea\");\n      if (textarea) {\n        textarea.style.height = \"\";\n      }\n    }\n  }, [textValue]);\n  const shouldHydrateStatus =\n    showStatus &&\n    (defaultStatus !== undefined ||\n      (reference === \"contacts\" && Boolean(selectReference)));\n  const { data: selectedContact } = useGetOne(\n    \"contacts\",\n    { id: selectedContactId! },\n    {\n      enabled:\n        shouldHydrateStatus &&\n        reference === \"contacts\" &&\n        Boolean(selectReference) &&\n        selectedContactId != null,\n    },\n  );\n  const resolvedDefaultStatus = shouldHydrateStatus\n    ? reference === \"contacts\" && selectReference\n      ? selectedContact?.status\n      : defaultStatus\n    : undefined;\n\n  useEffect(() => {\n    if (!shouldHydrateStatus || !resolvedDefaultStatus) return;\n    if (\n      formState.dirtyFields.status ||\n      selectedStatus === resolvedDefaultStatus\n    ) {\n      return;\n    }\n\n    setValue(\"status\", resolvedDefaultStatus, { shouldDirty: false });\n  }, [\n    formState.dirtyFields.status,\n    resolvedDefaultStatus,\n    selectedStatus,\n    setValue,\n    shouldHydrateStatus,\n  ]);\n\n  // We manually define the input labels because the default ones\n  // would use the resource from the context, which is either \"contact_notes\" or \"deal_notes\",\n  // but we want it to be \"notes\" regardless of the context\n  return (\n    <div ref={containerRef} className=\"space-y-2\">\n      <TextInput\n        source=\"text\"\n        label={false}\n        multiline\n        helperText={false}\n        placeholder={translate(\"resources.notes.inputs.add_note\")}\n        rows={2}\n        inputClassName={cn(\n          \"transition-[min-height] duration-300 ease-in-out\",\n          isExpanded && \"min-h-[20rem]\",\n        )}\n        onFocus={() => setIsFocused(true)}\n        onBlur={() => setIsFocused(false)}\n        validate={validateNoteOrAttachmentRequired}\n      />\n\n      {selectReference && reference && (\n        <ReferenceInput\n          source={foreignKeyMapping[reference]}\n          reference={reference}\n        >\n          <AutocompleteInput\n            label={\n              reference === \"contacts\"\n                ? \"resources.notes.fields.contact_id\"\n                : \"resources.notes.fields.deal_id\"\n            }\n            optionText={\n              reference === \"contacts\" ? contactOptionText : undefined\n            }\n            helperText={false}\n            validate={required()}\n            modal\n          />\n        </ReferenceInput>\n      )}\n\n      {!displayMore && (\n        <div className=\"flex justify-end items-center gap-2\">\n          <Button\n            variant=\"link\"\n            size=\"sm\"\n            onClick={() => {\n              setDisplayMore(!displayMore);\n            }}\n            className=\"text-sm text-muted-foreground underline hover:no-underline p-0 h-auto cursor-pointer\"\n          >\n            {translate(\"resources.notes.inputs.show_options\")}\n          </Button>\n          <span className=\"text-sm text-muted-foreground\">\n            {translate(\"resources.notes.inputs.options_hint\")}\n          </span>\n        </div>\n      )}\n\n      <div\n        className={cn(\n          \"space-y-3 mt-3 overflow-hidden origin-top\",\n          \"transition-transform ease-in-out duration-300\",\n          !displayMore ? \"scale-y-0 max-h-0 h-0\" : \"scale-y-100\",\n        )}\n      >\n        <div className=\"grid grid-cols-1 md:grid-cols-2 gap-4\">\n          {showStatus && (\n            <SelectInput\n              source=\"status\"\n              label=\"resources.notes.fields.status\"\n              choices={noteStatuses.map((status) => ({\n                id: status.value,\n                name: status.label,\n                value: status.value,\n              }))}\n              optionText={optionRenderer}\n              defaultValue={resolvedDefaultStatus}\n              helperText={false}\n            />\n          )}\n          <DateTimeInput\n            source=\"date\"\n            label=\"resources.notes.fields.date\"\n            helperText={false}\n            className=\"text-primary\"\n            defaultValue={getCurrentDate()}\n          />\n        </div>\n        <FileInput\n          source=\"attachments\"\n          label=\"resources.notes.fields.attachments\"\n          multiple\n        >\n          <AttachmentField source=\"src\" title=\"title\" target=\"_blank\" />\n        </FileInput>\n      </div>\n    </div>\n  );\n};\n\nconst optionRenderer = (choice: any) => {\n  return (\n    <div>\n      <Status status={choice.value} /> {choice.name}\n    </div>\n  );\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/notes/NoteEditSheet.tsx",
      "content": "import { EllipsisVertical, Trash2 } from \"lucide-react\";\nimport {\n  type Identifier,\n  useCreatePath,\n  useDeleteController,\n  useGetRecordRepresentation,\n  useRecordContext,\n  useTranslate,\n} from \"ra-core\";\nimport { ReferenceField } from \"@/components/admin\";\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\n\nimport { EditSheet } from \"../misc/EditSheet\";\nimport { foreignKeyMapping } from \"./foreignKeyMapping\";\nimport { NoteInputsMobile } from \"./NoteInputsMobile\";\n\nexport interface NoteEditSheetProps {\n  open: boolean;\n  onOpenChange: (open: boolean) => void;\n  noteId: Identifier;\n}\n\nexport const NoteEditSheet = ({\n  open,\n  onOpenChange,\n  noteId,\n}: NoteEditSheetProps) => {\n  const createPath = useCreatePath();\n  const translate = useTranslate();\n  const getRedirectTo = (record: any) => {\n    return createPath({\n      resource: \"contacts\",\n      type: \"show\",\n      id: record ? record[foreignKeyMapping[\"contacts\"]] : undefined,\n    });\n  };\n  const getContactRepresentation = useGetRecordRepresentation(\"contacts\");\n\n  return (\n    <EditSheet\n      resource=\"contact_notes\"\n      id={noteId}\n      title={\n        <ReferenceField\n          source={foreignKeyMapping[\"contacts\"]}\n          reference=\"contacts\"\n          render={({ referenceRecord }) => (\n            <span className=\"text-xl font-semibold truncate\">\n              {referenceRecord\n                ? translate(\"resources.notes.sheet.edit_for\", {\n                    name: getContactRepresentation(referenceRecord),\n                  })\n                : translate(\"resources.notes.sheet.edit\")}\n            </span>\n          )}\n        />\n      }\n      redirect={(_resource, _id, record) => getRedirectTo(record)}\n      open={open}\n      onOpenChange={onOpenChange}\n      headerActions={\n        <NoteEditMenuButton\n          onOpenChange={onOpenChange}\n          getRedirectTo={getRedirectTo}\n        />\n      }\n    >\n      <NoteInputsMobile />\n    </EditSheet>\n  );\n};\n\nconst NoteEditMenuButton = ({\n  onOpenChange,\n  getRedirectTo,\n}: {\n  onOpenChange: (open: boolean) => void;\n  getRedirectTo: (record: any) => string;\n}) => {\n  const translate = useTranslate();\n  const record = useRecordContext();\n  const { handleDelete } = useDeleteController({\n    record,\n    resource: \"contact_notes\",\n    redirect: getRedirectTo(record),\n    mutationMode: \"undoable\",\n  });\n\n  const onDelete = () => {\n    onOpenChange(false);\n    handleDelete();\n  };\n\n  return (\n    <DropdownMenu>\n      <DropdownMenuTrigger asChild>\n        <button\n          type=\"button\"\n          className=\"opacity-70 transition-opacity hover:opacity-100 rounded-xs\"\n        >\n          <EllipsisVertical className=\"size-6\" />\n          <span className=\"sr-only\">\n            {translate(\"ra.action.open_menu\", { _: \"More\" })}\n          </span>\n        </button>\n      </DropdownMenuTrigger>\n      <DropdownMenuContent align=\"end\">\n        <DropdownMenuItem\n          variant=\"destructive\"\n          className=\"h-12 md:h-8 px-4 md:px-2 text-base md:text-sm\"\n          onSelect={onDelete}\n        >\n          <Trash2 />\n          {translate(\"ra.action.delete\")}\n        </DropdownMenuItem>\n      </DropdownMenuContent>\n    </DropdownMenu>\n  );\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/notes/NoteCreateSheet.tsx",
      "content": "import {\n  type Identifier,\n  useDataProvider,\n  useGetIdentity,\n  useGetOne,\n  useGetRecordRepresentation,\n  useNotify,\n  useRedirect,\n  useTranslate,\n  useUpdate,\n} from \"ra-core\";\nimport { CreateSheet } from \"../misc/CreateSheet\";\nimport { foreignKeyMapping } from \"./foreignKeyMapping\";\nimport { NoteInputsMobile } from \"./NoteInputsMobile\";\nimport { getCurrentDate } from \"./utils\";\n\nexport interface NoteCreateSheetProps {\n  open: boolean;\n  onOpenChange: (open: boolean) => void;\n  contact_id?: Identifier;\n}\n\nexport const NoteCreateSheet = ({\n  open,\n  onOpenChange,\n  contact_id,\n}: NoteCreateSheetProps) => {\n  const { identity } = useGetIdentity();\n\n  const selectContact = contact_id == null;\n  const { data: contact } = useGetOne(\n    \"contacts\",\n    { id: contact_id! },\n    { enabled: !selectContact },\n  );\n  const [update] = useUpdate();\n  const dataProvider = useDataProvider();\n  const notify = useNotify();\n  const redirect = useRedirect();\n  const translate = useTranslate();\n  const getContactRepresentation = useGetRecordRepresentation(\"contacts\");\n  const defaultStatus = selectContact ? undefined : contact?.status;\n\n  if (!identity) return null;\n\n  const handleSuccess = async (data: any) => {\n    const referenceRecordId = data[foreignKeyMapping[\"contacts\"]];\n    if (!referenceRecordId) return;\n    const { data: contact } = await dataProvider.getOne(\"contacts\", {\n      id: referenceRecordId,\n    });\n    if (!contact) return;\n    update(\"contacts\", {\n      id: referenceRecordId as unknown as Identifier,\n      data: { last_seen: new Date().toISOString(), status: data.status },\n      previousData: contact,\n    });\n    notify(\"resources.notes.added\", {\n      messageArgs: {\n        _: \"Note added\",\n      },\n    });\n    redirect(\"show\", \"contacts\", referenceRecordId);\n    onOpenChange(false);\n  };\n\n  return (\n    <CreateSheet\n      resource=\"contact_notes\"\n      title={\n        <span className=\"text-xl font-semibold truncate\">\n          {!selectContact\n            ? translate(\"resources.notes.sheet.create_for\", {\n                name: getContactRepresentation(contact!),\n              })\n            : translate(\"resources.notes.sheet.create\")}\n        </span>\n      }\n      redirect={false}\n      defaultValues={{ sales_id: identity?.id }}\n      transform={(data: any) => ({\n        ...data,\n        [foreignKeyMapping[\"contacts\"]]:\n          contact_id ?? data[foreignKeyMapping[\"contacts\"]],\n        sales_id: identity.id,\n        date: new Date(data.date || getCurrentDate()).toISOString(),\n        status: defaultStatus,\n      })}\n      mutationOptions={{ onSuccess: handleSuccess }}\n      open={open}\n      onOpenChange={onOpenChange}\n    >\n      <NoteInputsMobile selectContact={selectContact} />\n    </CreateSheet>\n  );\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/notes/NoteCreate.tsx",
      "content": "import {\n  CreateBase,\n  Form,\n  useGetIdentity,\n  useListContext,\n  useNotify,\n  useRecordContext,\n  useResourceContext,\n  useTranslate,\n  useUpdate,\n  type Identifier,\n  type RaRecord,\n} from \"ra-core\";\nimport { useFormContext } from \"react-hook-form\";\nimport { SaveButton } from \"@/components/admin/form\";\nimport { cn } from \"@/lib/utils\";\n\nimport { NoteInputs } from \"./NoteInputs\";\nimport { getCurrentDate } from \"./utils\";\nimport { foreignKeyMapping } from \"./foreignKeyMapping\";\n\nexport const NoteCreate = ({\n  reference,\n  showStatus,\n  className,\n}: {\n  reference: \"contacts\" | \"deals\";\n  showStatus?: boolean;\n  className?: string;\n}) => {\n  const resource = useResourceContext();\n  const record = useRecordContext();\n  const { identity } = useGetIdentity();\n\n  if (!record || !identity) return null;\n\n  const defaultStatus = reference === \"contacts\" ? record.status : undefined;\n\n  return (\n    <CreateBase resource={resource} redirect={false}>\n      <Form>\n        <div className={cn(\"space-y-3\", className)}>\n          <NoteInputs defaultStatus={defaultStatus} showStatus={showStatus} />\n          <NoteCreateButton\n            defaultStatus={defaultStatus}\n            record={record}\n            reference={reference}\n          />\n        </div>\n      </Form>\n    </CreateBase>\n  );\n};\n\nconst NoteCreateButton = ({\n  defaultStatus,\n  reference,\n  record,\n}: {\n  defaultStatus?: string;\n  reference: \"contacts\" | \"deals\";\n  record: RaRecord<Identifier>;\n}) => {\n  const [update] = useUpdate();\n  const notify = useNotify();\n  const translate = useTranslate();\n  const { identity } = useGetIdentity();\n  const { reset } = useFormContext();\n  const { refetch } = useListContext();\n\n  if (!record || !identity) return null;\n\n  const resetValues: {\n    date: string;\n    text: null;\n    attachments: null;\n    status?: string;\n  } = {\n    date: getCurrentDate(),\n    text: null,\n    attachments: null,\n  };\n\n  const handleSuccess = (data: any) => {\n    if (reference === \"contacts\") {\n      resetValues.status = data.status ?? defaultStatus;\n    }\n\n    reset(resetValues, { keepValues: false });\n    refetch();\n    update(reference, {\n      id: (record && record.id) as unknown as Identifier,\n      data: {\n        last_seen:\n          reference === \"contacts\" ? new Date().toISOString() : undefined,\n        status: data.status,\n      },\n      previousData: record,\n    });\n    notify(\"resources.notes.added\", {\n      messageArgs: {\n        _: \"Note added\",\n      },\n    });\n  };\n\n  return (\n    <div className=\"flex justify-end\">\n      <SaveButton\n        type=\"button\"\n        label={translate(\"resources.notes.action.add_this\")}\n        transform={(data) => ({\n          ...data,\n          [foreignKeyMapping[reference]]: record.id,\n          sales_id: identity.id,\n          date: new Date(data.date || getCurrentDate()).toISOString(),\n        })}\n        mutationOptions={{\n          onSuccess: handleSuccess,\n        }}\n      />\n    </div>\n  );\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/notes/NoteAttachments.tsx",
      "content": "import { Paperclip } from \"lucide-react\";\n\nimport type { AttachmentNote, ContactNote, DealNote } from \"../types\";\n\n/**\n * Displays persisted note attachments in note show/list views.\n *\n * This component receives a full note record and renders all attachments.\n *\n * @param props.note - Note record containing attachments to render.\n * @returns `null` when there are no attachments, otherwise attachment previews and links.\n */\nexport const NoteAttachments = ({ note }: { note: ContactNote | DealNote }) => {\n  if (!note.attachments || note.attachments.length === 0) {\n    return null;\n  }\n\n  const imageAttachments = note.attachments.filter(\n    (attachment: AttachmentNote) => isImageMimeType(attachment.type),\n  );\n  const otherAttachments = note.attachments.filter(\n    (attachment: AttachmentNote) => !isImageMimeType(attachment.type),\n  );\n\n  return (\n    <div className=\"mt-2 flex flex-col gap-2\">\n      {imageAttachments.length > 0 && (\n        <div className=\"grid grid-cols-4 gap-8\">\n          {imageAttachments.map((attachment: AttachmentNote, index: number) => (\n            <div key={index}>\n              <a\n                href={attachment.src}\n                title={attachment.title}\n                target=\"_blank\"\n                rel=\"noopener noreferrer\"\n                className=\"block\"\n                onClick={(e) => e.stopPropagation()}\n              >\n                <img\n                  src={attachment.src}\n                  alt={attachment.title}\n                  className=\"w-[200px] h-[100px] object-cover cursor-pointer object-left border border-border\"\n                />\n              </a>\n            </div>\n          ))}\n        </div>\n      )}\n      {otherAttachments.length > 0 &&\n        otherAttachments.map((attachment: AttachmentNote, index: number) => (\n          <div key={index} className=\"flex items-center gap-2\">\n            <Paperclip className=\"w-4 h-4\" />\n            <a\n              href={attachment.src}\n              target=\"_blank\"\n              rel=\"noopener noreferrer\"\n              className=\"underline hover:no-underline\"\n              onClick={(e) => e.stopPropagation()}\n            >\n              {attachment.title}\n            </a>\n          </div>\n        ))}\n    </div>\n  );\n};\n\n/**\n * Checks whether a mime type corresponds to an image.\n *\n * @param mimeType - The attachment mime type.\n * @returns `true` when the mime type starts with `image/`.\n */\nconst isImageMimeType = (mimeType?: string): boolean => {\n  if (!mimeType) {\n    return false;\n  }\n  return mimeType.startsWith(\"image/\");\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/notes/Note.tsx",
      "content": "import { CircleX, Edit, Save, Trash2 } from \"lucide-react\";\nimport {\n  Form,\n  useDelete,\n  useGetIdentity,\n  useNotify,\n  useResourceContext,\n  useTranslate,\n  useUpdate,\n} from \"ra-core\";\nimport { useEffect, useRef, useState } from \"react\";\nimport type { FieldValues, SubmitHandler } from \"react-hook-form\";\nimport { ReferenceField } from \"@/components/admin/reference-field\";\nimport { Button } from \"@/components/ui/button\";\nimport { cn } from \"@/lib/utils\";\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from \"@/components/ui/tooltip\";\n\nimport { CompanyAvatar } from \"../companies/CompanyAvatar\";\nimport { Markdown } from \"../misc/Markdown\";\nimport { RelativeDate } from \"../misc/RelativeDate\";\nimport { Status } from \"../misc/Status\";\nimport type { ContactNote, DealNote } from \"../types\";\nimport { NoteAttachments } from \"./NoteAttachments\";\nimport { NoteInputs } from \"./NoteInputs\";\nimport { useGetSalesName } from \"../sales/useGetSalesName\";\n\nexport const Note = ({\n  showStatus,\n  note,\n}: {\n  showStatus?: boolean;\n  note: DealNote | ContactNote;\n  isLast: boolean;\n}) => {\n  const [isHover, setHover] = useState(false);\n  const [isEditing, setEditing] = useState(false);\n  const [isExpanded, setExpanded] = useState(false);\n  const [isTruncated, setTruncated] = useState(false);\n  const contentRef = useRef<HTMLDivElement>(null);\n  const resource = useResourceContext();\n  const notify = useNotify();\n  const translate = useTranslate();\n  const { identity } = useGetIdentity();\n  const isCurrentUser = note.sales_id === identity?.id;\n  const salesName = useGetSalesName(note.sales_id, {\n    enabled: !isCurrentUser,\n  });\n\n  // Detect if content is truncated\n  useEffect(() => {\n    const el = contentRef.current;\n    if (el) {\n      setTruncated(el.scrollHeight > el.clientHeight);\n    }\n  }, [note.text]);\n\n  const [update, { isPending }] = useUpdate();\n\n  const [deleteNote] = useDelete(resource, undefined, {\n    mutationMode: \"undoable\",\n    onSuccess: () => {\n      notify(\"resources.notes.deleted\", {\n        type: \"info\",\n        undoable: true,\n        messageArgs: {\n          _: \"Note deleted\",\n        },\n      });\n    },\n  });\n\n  const handleDelete = () => {\n    deleteNote(resource, { id: note.id, previousData: note });\n  };\n\n  const handleEnterEditMode = () => {\n    setEditing(!isEditing);\n  };\n\n  const handleCancelEdit = () => {\n    setEditing(false);\n    setHover(false);\n  };\n\n  const handleNoteUpdate: SubmitHandler<FieldValues> = (values) => {\n    update(\n      resource,\n      { id: note.id, data: values, previousData: note },\n      {\n        onSuccess: () => {\n          setEditing(false);\n          setHover(false);\n        },\n      },\n    );\n  };\n\n  const content = (\n    <div\n      onMouseEnter={() => setHover(true)}\n      onMouseLeave={() => setHover(false)}\n      className=\"mb-4\"\n    >\n      <div className=\"flex items-center space-x-4 w-full\">\n        <ReferenceField source=\"company_id\" reference=\"companies\" link=\"show\">\n          <CompanyAvatar width={20} height={20} />\n        </ReferenceField>\n        <div className=\"inline-flex h-full items-center text-sm text-muted-foreground\">\n          {translate(\n            isCurrentUser\n              ? \"resources.notes.you_added\"\n              : \"resources.notes.author_added\",\n            { name: salesName },\n          )}{\" \"}\n          {showStatus && note.status && (\n            <Status className=\"ml-2\" status={note.status} />\n          )}\n        </div>\n        <span className={`${isHover ? \"visible\" : \"invisible\"}`}>\n          <TooltipProvider>\n            <Tooltip>\n              <TooltipTrigger asChild>\n                <Button\n                  variant=\"ghost\"\n                  size=\"sm\"\n                  onClick={handleEnterEditMode}\n                  className=\"p-1 h-auto cursor-pointer\"\n                >\n                  <Edit className=\"w-4 h-4\" />\n                </Button>\n              </TooltipTrigger>\n              <TooltipContent>\n                <p>{translate(\"resources.notes.action.edit\")}</p>\n              </TooltipContent>\n            </Tooltip>\n          </TooltipProvider>\n          <TooltipProvider>\n            <Tooltip>\n              <TooltipTrigger asChild>\n                <Button\n                  variant=\"ghost\"\n                  size=\"sm\"\n                  onClick={handleDelete}\n                  className=\"p-1 h-auto cursor-pointer\"\n                >\n                  <Trash2 className=\"w-4 h-4\" />\n                </Button>\n              </TooltipTrigger>\n              <TooltipContent>\n                <p>{translate(\"resources.notes.action.delete\")}</p>\n              </TooltipContent>\n            </Tooltip>\n          </TooltipProvider>\n        </span>\n        <div className=\"flex-1\"></div>\n        <span className=\"text-sm text-muted-foreground\">\n          <RelativeDate date={note.date} />\n        </span>\n      </div>\n      {isEditing ? (\n        <Form onSubmit={handleNoteUpdate} record={note} className=\"mt-1\">\n          <NoteInputs showStatus={showStatus} />\n          <div className=\"flex justify-end mt-2 space-x-4\">\n            <Button\n              variant=\"ghost\"\n              onClick={handleCancelEdit}\n              type=\"button\"\n              className=\"cursor-pointer\"\n            >\n              <CircleX className=\"w-4 h-4\" />\n              {translate(\"ra.action.cancel\")}\n            </Button>\n            <Button\n              type=\"submit\"\n              disabled={isPending}\n              className=\"flex items-center gap-2 cursor-pointer\"\n            >\n              <Save className=\"w-4 h-4\" />\n              {translate(\"resources.notes.action.update\")}\n            </Button>\n          </div>\n        </Form>\n      ) : (\n        <div className=\"pt-2 text-sm max-w-150\">\n          {note.text && (\n            <div\n              ref={contentRef}\n              className={cn(\n                \"overflow-hidden transition-[max-height] duration-300 ease-in-out\",\n                isExpanded ? \"max-h-[5000px]\" : \"max-h-46\",\n              )}\n            >\n              <Markdown>{note.text}</Markdown>\n            </div>\n          )}\n          {isTruncated && (\n            <button\n              onClick={(e) => {\n                e.stopPropagation();\n                setExpanded(!isExpanded);\n              }}\n              className=\"text-primary text-sm mt-1 underline hover:no-underline cursor-pointer\"\n            >\n              {isExpanded\n                ? translate(\"crm.common.show_less\")\n                : translate(\"crm.common.read_more\")}\n            </button>\n          )}\n\n          {note.attachments && <NoteAttachments note={note} />}\n        </div>\n      )}\n    </div>\n  );\n\n  return content;\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/notes/AttachmentField.tsx",
      "content": "import { useFieldValue, useRecordContext, useTranslate } from \"ra-core\";\nimport type { FileFieldProps } from \"@/components/admin\";\nimport { cn } from \"@/lib/utils\";\n\n/**\n * Displays a preview for a single attachment record.\n *\n * This component is inspired by react-admin's `ImageField` and is intended for\n * usage inside a `<FileInput>`, where the current attachment is provided through\n * the record context.\n *\n * @param props - FileFieldProps provided by react-admin file inputs.\n * @returns An image preview for image attachments, or a regular link for other files.\n */\nexport const AttachmentField = (props: FileFieldProps) => {\n  const {\n    className,\n    empty,\n    title,\n    target,\n    download,\n    defaultValue,\n    source,\n    record: _recordProp,\n    ...rest\n  } = props;\n  const record = useRecordContext();\n  const sourceValue = useFieldValue({ defaultValue, source, record });\n  const titleValue =\n    useFieldValue({\n      ...props,\n      // @ts-expect-error We ignore here because title might be a custom label or undefined instead of a field name\n      source: title,\n    })?.toString() ?? title;\n  const translate = useTranslate();\n\n  if (sourceValue == null) {\n    if (!empty) {\n      return null;\n    }\n\n    return (\n      <div className={cn(\"inline-block\", className)} {...rest}>\n        {typeof empty === \"string\" ? translate(empty, { _: empty }) : empty}\n      </div>\n    );\n  }\n\n  const type = record?.type ?? record?.rawFile?.type;\n  const srcValue = sourceValue.toString();\n\n  return (\n    <div className={cn(\"inline-block\", className)} {...rest}>\n      {isImageMimeType(type) ? (\n        <a\n          href={srcValue}\n          title={titleValue}\n          target={target}\n          rel=\"noopener noreferrer\"\n          download={download}\n          // useful to prevent click bubbling in a DataTable with rowClick\n          onClick={(e) => e.stopPropagation()}\n        >\n          <img\n            alt={titleValue}\n            title={titleValue}\n            src={srcValue}\n            className=\"w-[200px] h-[100px] object-cover cursor-pointer object-left border border-border\"\n          />\n        </a>\n      ) : (\n        <a\n          href={srcValue}\n          title={titleValue}\n          target={target}\n          rel=\"noopener noreferrer\"\n          download={download}\n          // useful to prevent click bubbling in a DataTable with rowClick\n          onClick={(e) => e.stopPropagation()}\n        >\n          {titleValue}\n        </a>\n      )}\n    </div>\n  );\n};\n\n/**\n * Checks whether a mime type corresponds to an image.\n *\n * @param mimeType - The attachment mime type.\n * @returns `true` when the mime type starts with `image/`.\n */\nconst isImageMimeType = (mimeType?: string): boolean => {\n  if (!mimeType) {\n    return false;\n  }\n  return mimeType.startsWith(\"image/\");\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/misc/usePapaParse.tsx",
      "content": "import * as Papa from \"papaparse\";\nimport { useCallback, useMemo, useRef, useState } from \"react\";\n\ntype Import =\n  | {\n      state: \"idle\";\n    }\n  | {\n      state: \"parsing\";\n    }\n  | {\n      state: \"running\" | \"complete\";\n\n      rowCount: number;\n      importCount: number;\n      errorCount: number;\n\n      // The remaining time in milliseconds\n      remainingTime: number | null;\n    }\n  | {\n      state: \"error\";\n\n      error: Error;\n    };\n\ntype usePapaParseProps<T> = {\n  // The import batch size\n  batchSize?: number;\n\n  // processBatch returns the number of imported items\n  processBatch(batch: T[]): Promise<void>;\n};\n\nexport function usePapaParse<T>({\n  batchSize = 10,\n  processBatch,\n}: usePapaParseProps<T>) {\n  const importIdRef = useRef<number>(0);\n\n  const [importer, setImporter] = useState<Import>({\n    state: \"idle\",\n  });\n\n  const reset = useCallback(() => {\n    setImporter({\n      state: \"idle\",\n    });\n    importIdRef.current += 1;\n  }, []);\n\n  const parseCsv = useCallback(\n    (file: File) => {\n      setImporter({\n        state: \"parsing\",\n      });\n\n      const importId = importIdRef.current;\n      Papa.parse<T>(file, {\n        header: true,\n        skipEmptyLines: true,\n        async complete(results) {\n          if (importIdRef.current !== importId) {\n            return;\n          }\n\n          setImporter({\n            state: \"running\",\n            rowCount: results.data.length,\n            errorCount: results.errors.length,\n            importCount: 0,\n            remainingTime: null,\n          });\n\n          let totalTime = 0;\n          for (let i = 0; i < results.data.length; i += batchSize) {\n            if (importIdRef.current !== importId) {\n              return;\n            }\n\n            const batch = results.data.slice(i, i + batchSize);\n            try {\n              const start = Date.now();\n              await processBatch(batch);\n              totalTime += Date.now() - start;\n\n              const meanTime = totalTime / (i + batch.length);\n              setImporter((previous) => {\n                if (previous.state === \"running\") {\n                  const importCount = previous.importCount + batch.length;\n                  return {\n                    ...previous,\n                    importCount,\n                    remainingTime:\n                      meanTime * (results.data.length - importCount),\n                  };\n                }\n                return previous;\n              });\n            } catch (error) {\n              console.error(\"Failed to import batch\", error);\n              setImporter((previous) =>\n                previous.state === \"running\"\n                  ? {\n                      ...previous,\n                      errorCount: previous.errorCount + batch.length,\n                    }\n                  : previous,\n              );\n            }\n          }\n\n          setImporter((previous) =>\n            previous.state === \"running\"\n              ? {\n                  ...previous,\n                  state: \"complete\",\n                  remainingTime: null,\n                }\n              : previous,\n          );\n        },\n        error(error) {\n          console.error(error);\n          setImporter({\n            state: \"error\",\n            error,\n          });\n        },\n        dynamicTyping: true,\n      });\n    },\n    [batchSize, processBatch],\n  );\n\n  return useMemo(\n    () => ({\n      importer,\n      parseCsv,\n      reset,\n    }),\n    [importer, parseCsv, reset],\n  );\n}\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/misc/useImportFromJson.ts",
      "content": "import { useState } from \"react\";\nimport {\n  type Identifier,\n  useDataProvider,\n  useEvent,\n  useGetIdentity,\n  useRefresh,\n} from \"ra-core\";\nimport { JSONParser, type JsonTypes } from \"@streamparser/json-whatwg\";\nimport mime from \"mime/lite\";\nimport type { CrmDataProvider } from \"../providers/types\";\nimport type { RAFile, Tag } from \"../types\";\nimport { colors } from \"../tags/colors\";\nimport { useConfigurationContext } from \"../root/ConfigurationContext\";\nimport { contactGender } from \"../contacts/contactModel\";\n\nexport type ImportFromJsonStats = {\n  sales: number;\n  companies: number;\n  contacts: number;\n  notes: number;\n  tasks: number;\n};\n\nexport type ImportFromJsonFailures = {\n  sales: Array<JsonTypes.JsonPrimitive | JsonTypes.JsonStruct | undefined>;\n  companies: Array<JsonTypes.JsonPrimitive | JsonTypes.JsonStruct | undefined>;\n  contacts: Array<JsonTypes.JsonPrimitive | JsonTypes.JsonStruct | undefined>;\n  notes: Array<JsonTypes.JsonPrimitive | JsonTypes.JsonStruct | undefined>;\n  tasks: Array<JsonTypes.JsonPrimitive | JsonTypes.JsonStruct | undefined>;\n};\n\nexport type ImportFromJsonIdleState = {\n  status: \"idle\";\n  error: null;\n  stats: ImportFromJsonStats;\n  failedImports: ImportFromJsonFailures;\n};\n\nexport type ImportFromJsonImportingState = {\n  status: \"importing\";\n  stats: ImportFromJsonStats;\n  error: null;\n  failedImports: ImportFromJsonFailures;\n};\n\nexport type ImportFromJsonErrorState = {\n  status: \"error\";\n  error: Error;\n  stats: ImportFromJsonStats;\n  failedImports: ImportFromJsonFailures;\n  duration: number;\n};\n\nexport type ImportFromJsonSuccessState = {\n  status: \"success\";\n  stats: ImportFromJsonStats;\n  error: null;\n  failedImports: ImportFromJsonFailures;\n  duration: number;\n};\n\nexport type ImportFromJsonState =\n  | ImportFromJsonErrorState\n  | ImportFromJsonIdleState\n  | ImportFromJsonImportingState\n  | ImportFromJsonSuccessState;\n\nexport type ImportFromJsonFunction = (file: File) => Promise<void>;\ntype ResetFunction = () => void;\n\nconst defaultFailedImports = {\n  sales: [],\n  companies: [],\n  contacts: [],\n  notes: [],\n  tasks: [],\n};\n\nconst defaultStats = {\n  sales: 0,\n  companies: 0,\n  contacts: 0,\n  notes: 0,\n  tasks: 0,\n};\n\n/**\n * A hook that returns a function to import data from a JSON file.\n * We do the import on the client because edge functions are limited in both execution time and memory.\n */\nexport const useImportFromJson = (): [\n  ImportFromJsonState,\n  ImportFromJsonFunction,\n  ResetFunction,\n] => {\n  const { data: currentSale } = useGetIdentity();\n  const dataProvider = useDataProvider<CrmDataProvider>();\n  const refresh = useRefresh();\n  const { companySectors } = useConfigurationContext();\n  const [state, setState] = useState<ImportFromJsonState>({\n    status: \"idle\",\n    error: null,\n    stats: defaultStats,\n    failedImports: defaultFailedImports,\n  });\n\n  const reset = useEvent(() => {\n    setState({\n      status: \"idle\",\n      error: null,\n      stats: defaultStats,\n      failedImports: defaultFailedImports,\n    });\n  });\n\n  const importFile = useEvent(async (file: File) => {\n    if (currentSale == null) {\n      throw new Error(\"Importing data requires to be authenticated\");\n    }\n    const startedAt = new Date();\n\n    setState({\n      status: \"importing\",\n      stats: defaultStats,\n      failedImports: defaultFailedImports,\n      error: null,\n    });\n\n    const idsMaps: {\n      sales: Record<number, Identifier>;\n      companies: Record<number, Identifier>;\n      contacts: Record<number, Identifier>;\n      tags: Record<string, Identifier>;\n    } = {\n      sales: {},\n      companies: {},\n      contacts: {},\n      tags: {},\n    };\n\n    const importSale = async (\n      dataToImport: JsonTypes.JsonPrimitive | JsonTypes.JsonStruct | undefined,\n    ) => {\n      try {\n        if (!isSale(dataToImport)) {\n          throw new Error(`Error while importing sale: Invalid data`);\n        }\n        const existingRecordResponse = await dataProvider.getList(\"sales\", {\n          filter: { email: dataToImport.email.trim() },\n          pagination: { page: 1, perPage: 1 },\n          sort: { field: \"id\", order: \"ASC\" },\n        });\n        if (existingRecordResponse.total === 1) {\n          idsMaps.sales[dataToImport.id] = existingRecordResponse.data[0].id;\n          return existingRecordResponse.data[0].id;\n        }\n\n        const data = await dataProvider.salesCreate({\n          email: dataToImport.email.trim(),\n          first_name: dataToImport.first_name.trim(),\n          last_name: dataToImport.last_name.trim(),\n          administrator: false,\n          disabled: false,\n        });\n\n        idsMaps.sales[dataToImport.id] = data.id;\n        setState((old) => {\n          if (old.status === \"error\") {\n            return {\n              ...old,\n              stats: {\n                ...(old.stats ?? defaultStats),\n                sales: (old.stats ?? defaultStats).sales + 1,\n              },\n            };\n          }\n          return {\n            ...old,\n            status: \"importing\",\n            stats: {\n              ...(old.stats ?? defaultStats),\n              sales: (old.stats ?? defaultStats).sales + 1,\n            },\n            error: null,\n          };\n        });\n        return data;\n      } catch (err) {\n        const duration = new Date().valueOf() - startedAt.valueOf();\n        setState((old) => ({\n          ...old,\n          status: \"error\",\n          error: new Error(\n            `Error while importing sale: ${(err as Error).message}`,\n          ),\n          failedImports: {\n            ...old.failedImports,\n            sales: [\n              ...old.failedImports.sales,\n              { ...(dataToImport as any), error: (err as Error).message },\n            ],\n          },\n          duration,\n        }));\n      }\n    };\n\n    const importCompany = async (\n      dataToImport: JsonTypes.JsonPrimitive | JsonTypes.JsonStruct | undefined,\n    ) => {\n      if (!isCompany(dataToImport)) {\n        setState((old) => ({\n          ...old,\n          status: \"importing\",\n          error: null,\n          failedImports: {\n            ...old.failedImports,\n            companies: [\n              ...old.failedImports.companies,\n              { ...(dataToImport as any), error: \"Invalid format\" },\n            ],\n          },\n        }));\n        return;\n      }\n      try {\n        // Validate sector against configuration\n        const sector = dataToImport.sector?.trim();\n        if (sector && !companySectors.some((s) => s.value === sector)) {\n          setState((old) => ({\n            ...old,\n            status: \"importing\",\n            error: null,\n            failedImports: {\n              ...old.failedImports,\n              companies: [\n                ...old.failedImports.companies,\n                {\n                  ...(dataToImport as any),\n                  error: `Invalid sector \"${sector}\". Must be one of: ${companySectors.map((s) => s.value).join(\", \")}`,\n                },\n              ],\n            },\n          }));\n          return;\n        }\n\n        const { data } = await dataProvider.create(\"companies\", {\n          data: {\n            name: dataToImport.name.trim(),\n            description: dataToImport.description?.trim(),\n            city: dataToImport.city?.trim(),\n            country: dataToImport.country?.trim(),\n            address: dataToImport.address?.trim(),\n            zipcode: dataToImport.zipcode?.trim(),\n            state_abbr: dataToImport.state_abbr?.trim(),\n            sector: sector || undefined,\n            size: dataToImport.size\n              ? mapSizeToCategory(dataToImport.size)\n              : undefined,\n            linkedin_url: dataToImport.linkedin_url?.trim(),\n            website: dataToImport.website?.trim(),\n            phone_number: dataToImport.phone_number?.trim(),\n            revenue: dataToImport.revenue?.trim(),\n            tax_identifier: dataToImport.tax_identifier?.trim(),\n            context_links: Array.isArray(dataToImport.context_links)\n              ? dataToImport.context_links\n              : undefined,\n            sales_id: dataToImport.sales_id\n              ? idsMaps.sales[dataToImport.sales_id]\n              : currentSale.id,\n            created_at: dataToImport.created_at,\n          },\n        });\n\n        idsMaps.companies[dataToImport.id] = data.id;\n        setState((old) => ({\n          ...old,\n          status: \"importing\",\n          stats: {\n            ...old.stats,\n            companies: old.stats.companies + 1,\n          },\n          error: null,\n        }));\n        return data;\n      } catch (err) {\n        console.error(err);\n        setState((old) => ({\n          ...old,\n          status: \"importing\",\n          error: null,\n          failedImports: {\n            ...old.failedImports,\n            companies: [\n              ...old.failedImports.companies,\n              { ...(dataToImport as any), error: (err as Error).message },\n            ],\n          },\n        }));\n      }\n    };\n\n    const importContact = async (\n      dataToImport: JsonTypes.JsonPrimitive | JsonTypes.JsonStruct | undefined,\n    ) => {\n      if (!isContact(dataToImport)) {\n        setState((old) => ({\n          ...old,\n          status: \"importing\",\n          error: null,\n          failedImports: {\n            ...old.failedImports,\n            contacts: [\n              ...old.failedImports.contacts,\n              { ...(dataToImport as any), error: \"Invalid format\" },\n            ],\n          },\n        }));\n        return;\n      }\n\n      try {\n        // Validate gender against valid values\n        const gender = dataToImport.gender?.trim();\n        if (gender && !contactGender.some((g) => g.value === gender)) {\n          setState((old) => ({\n            ...old,\n            status: \"importing\",\n            error: null,\n            failedImports: {\n              ...old.failedImports,\n              contacts: [\n                ...old.failedImports.contacts,\n                {\n                  ...(dataToImport as any),\n                  error: `Invalid gender \"${gender}\". Must be one of: ${contactGender.map((g) => g.value).join(\", \")}`,\n                },\n              ],\n            },\n          }));\n          return;\n        }\n\n        let tagsIds: Array<Identifier> = [];\n        if (dataToImport.tags && Array.isArray(dataToImport.tags)) {\n          tagsIds = await Promise.all(\n            dataToImport.tags.map(async (tag) => {\n              if (idsMaps.tags[tag]) {\n                return idsMaps.tags[tag];\n              }\n              const { data } = await dataProvider.create<Tag>(\"tags\", {\n                data: {\n                  name: tag,\n                  color: colors[Math.floor(Math.random() * colors.length)],\n                },\n              });\n              idsMaps.tags[tag] = data.id;\n              return data.id;\n            }),\n          );\n        }\n\n        const { data } = await dataProvider.create(\"contacts\", {\n          data: {\n            last_name: dataToImport.last_name.trim(),\n            first_name: dataToImport.first_name.trim(),\n            title: dataToImport.title?.trim(),\n            background: dataToImport.background?.trim(),\n            linkedin_url: dataToImport.linkedin_url?.trim(),\n            gender: gender || undefined,\n            has_newsletter: !!dataToImport.has_newsletter,\n            company_id: dataToImport.company_id\n              ? idsMaps.companies[dataToImport.company_id]\n              : undefined,\n            email_jsonb: Array.isArray(dataToImport.emails)\n              ? dataToImport.emails\n              : undefined,\n            phone_jsonb: Array.isArray(dataToImport.phones)\n              ? dataToImport.phones\n              : undefined,\n            sales_id: dataToImport.sales_id\n              ? idsMaps.sales[dataToImport.sales_id]\n              : currentSale.id,\n            tags: tagsIds,\n            first_seen: dataToImport.created_at,\n            last_seen: dataToImport.updated_at,\n          },\n        });\n        idsMaps.contacts[dataToImport.id] = data.id;\n        setState((old) => ({\n          ...old,\n          status: \"importing\",\n          stats: {\n            ...old.stats,\n            contacts: old.stats.contacts + 1,\n          },\n          error: null,\n        }));\n        return data;\n      } catch (err) {\n        console.error(err);\n        setState((old) => ({\n          ...old,\n          status: \"importing\",\n          error: null,\n          failedImports: {\n            ...old.failedImports,\n            contacts: [\n              ...old.failedImports.contacts,\n              { ...(dataToImport as any), error: (err as Error).message },\n            ],\n          },\n        }));\n      }\n    };\n\n    const importNote = async (\n      dataToImport: JsonTypes.JsonPrimitive | JsonTypes.JsonStruct | undefined,\n    ) => {\n      if (!isNote(dataToImport)) {\n        setState((old) => ({\n          ...old,\n          status: \"importing\",\n          failedImports: {\n            ...old.failedImports,\n            notes: [\n              ...old.failedImports.notes,\n              { ...(dataToImport as any), error: \"Invalid format\" },\n            ],\n          },\n          error: null,\n        }));\n        return;\n      }\n      try {\n        if (idsMaps.sales[dataToImport.sales_id] == null) {\n          console.error(\n            `note ${dataToImport.text} has an invalid sales ID: ${dataToImport.sales_id}. Fallback to default sale`,\n          );\n        }\n        if (idsMaps.contacts[dataToImport.contact_id] == null) {\n          setState((old) => ({\n            ...old,\n            status: \"importing\",\n            failedImports: {\n              ...old.failedImports,\n              notes: [\n                ...old.failedImports.notes,\n                {\n                  ...(dataToImport as any),\n                  error: `Invalid contact_id ${dataToImport.contact_id}`,\n                },\n              ],\n            },\n            error: null,\n          }));\n          return;\n        }\n\n        const attachments: Array<\n          Omit<RAFile, \"rawFile\"> & {\n            rawFile: { name: string; type: string | null };\n          }\n        > = [];\n        if (Array.isArray(dataToImport.attachments)) {\n          for (const file of dataToImport.attachments) {\n            attachments.push({\n              src: file.url,\n              title: file.name,\n              rawFile: {\n                name: file.name,\n                type: mime.getType(file.name.split(\".\").pop()!),\n              },\n            });\n          }\n        }\n\n        await dataProvider.create(\"contact_notes\", {\n          data: {\n            contact_id: idsMaps.contacts[dataToImport.contact_id],\n            sales_id: idsMaps.sales[dataToImport.sales_id] ?? currentSale.id,\n            text: dataToImport.text,\n            date: dataToImport.date,\n            attachments,\n          },\n        });\n        setState((old) => ({\n          ...old,\n          status: \"importing\",\n          stats: {\n            ...old.stats,\n            notes: old.stats.notes + 1,\n          },\n          error: null,\n        }));\n      } catch (err) {\n        console.error(err);\n        setState((old) => ({\n          ...old,\n          status: \"importing\",\n          failedImports: {\n            ...old.failedImports,\n            notes: [\n              ...old.failedImports.notes,\n              { ...(dataToImport as any), error: (err as Error).message },\n            ],\n          },\n          error: null,\n        }));\n      }\n    };\n\n    const importTask = async (\n      dataToImport: JsonTypes.JsonPrimitive | JsonTypes.JsonStruct | undefined,\n    ) => {\n      if (!isTask(dataToImport)) {\n        setState((old) => ({\n          ...old,\n          status: \"importing\",\n          failedImports: {\n            ...old.failedImports,\n            tasks: [\n              ...old.failedImports.tasks,\n              { ...(dataToImport as any), error: \"Invalid format\" },\n            ],\n          },\n          error: null,\n        }));\n        return;\n      }\n      try {\n        if (idsMaps.sales[dataToImport.sales_id] == null) {\n          console.error(\n            `task ${dataToImport.text} has an invalid sales ID: ${dataToImport.sales_id}. Fallback to default sale`,\n          );\n        }\n        if (idsMaps.contacts[dataToImport.contact_id] == null) {\n          setState((old) => ({\n            ...old,\n            status: \"importing\",\n            failedImports: {\n              ...old.failedImports,\n              tasks: [\n                ...old.failedImports.tasks,\n                {\n                  ...(dataToImport as any),\n                  error: `Invalid contact_id ${dataToImport.contact_id}`,\n                },\n              ],\n            },\n            error: null,\n          }));\n          return;\n        }\n\n        await dataProvider.create(\"tasks\", {\n          data: {\n            contact_id: idsMaps.contacts[dataToImport.contact_id],\n            sales_id: idsMaps.sales[dataToImport.sales_id] ?? currentSale.id,\n            text: dataToImport.text,\n            due_date: dataToImport.due_date || undefined,\n            done_date: dataToImport.done_date || undefined,\n          },\n        });\n        setState((old) => ({\n          ...old,\n          status: \"importing\",\n          stats: {\n            ...old.stats,\n            tasks: old.stats.tasks + 1,\n          },\n          error: null,\n        }));\n      } catch (err) {\n        console.error(err);\n        setState((old) => ({\n          ...old,\n          status: \"importing\",\n          failedImports: {\n            ...old.failedImports,\n            tasks: [\n              ...old.failedImports.tasks,\n              { ...(dataToImport as any), error: (err as Error).message },\n            ],\n          },\n          error: null,\n        }));\n      }\n    };\n\n    let currentTask: Promise<any> | null = null;\n    let currentBatch: Array<Promise<void>> = [];\n    const BATCH_SIZE = 50;\n\n    const parser = new JSONParser({\n      paths: [\n        \"$.sales.*\",\n        \"$.companies.*\",\n        \"$.contacts.*\",\n        \"$.notes.*\",\n        \"$.tasks.*\",\n      ],\n      keepStack: false,\n    });\n    const stream = file.stream();\n    const reader = stream.pipeThrough(parser).getReader();\n\n    const proccesBatchIfPossible = async (\n      shouldProcessIncompleteBatch: boolean = false,\n    ) => {\n      if (currentBatch.length === BATCH_SIZE || shouldProcessIncompleteBatch) {\n        currentTask = Promise.all(currentBatch);\n        await currentTask;\n        currentBatch = [];\n        currentTask = null;\n      }\n    };\n    let currentType: Types = \"sales\";\n    while (true) {\n      const { done, value: parsedElementInfo } = await reader.read();\n      if (done) {\n        await proccesBatchIfPossible(true);\n        break;\n      }\n      const { value, stack, partial } = parsedElementInfo;\n      if (partial) continue;\n      const type =\n        stack.length > 1 ? getType(stack[1].key?.toString()) : undefined;\n\n      if (type == null) {\n        continue;\n      }\n\n      if (type !== currentType) {\n        // When moving to another type, make sure we wait for the previous batch to be imported\n        await proccesBatchIfPossible(true);\n        currentType = type;\n      }\n      switch (type) {\n        case \"sales\": {\n          currentBatch.push(importSale(value));\n          break;\n        }\n        case \"companies\": {\n          currentBatch.push(importCompany(value));\n          break;\n        }\n        case \"contacts\": {\n          currentBatch.push(importContact(value));\n          break;\n        }\n        case \"notes\": {\n          currentBatch.push(importNote(value));\n          break;\n        }\n        case \"tasks\": {\n          currentBatch.push(importTask(value));\n          break;\n        }\n      }\n      try {\n        await proccesBatchIfPossible();\n      } catch {\n        // the state should have been set by the function that throw the error\n        // stop the import\n        await reader.cancel();\n      }\n    }\n\n    setState((old) => {\n      if (old.status === \"error\") {\n        return old;\n      }\n      const duration = new Date().valueOf() - startedAt.valueOf();\n      return {\n        ...old,\n        status: \"success\",\n        duration,\n      };\n    });\n    refresh();\n  });\n\n  return [state, importFile, reset];\n};\n\nconst TYPES = [\"sales\", \"companies\", \"contacts\", \"notes\", \"tasks\"] as const;\ntype Types = (typeof TYPES)[number];\n\nconst getType = (value: string | undefined): Types | undefined => {\n  const type = value as Types;\n  if (TYPES.includes(type)) return type;\n  return undefined;\n};\n\ntype SaleImport = {\n  id: number;\n  email: string;\n  first_name: string;\n  last_name: string;\n};\n\nconst isSale = (data: any): data is SaleImport =>\n  data != null &&\n  typeof data === \"object\" &&\n  !Array.isArray(data) &&\n  data.id != null &&\n  data.email != null &&\n  data.first_name !== null &&\n  data.last_name != null;\n\ntype CompanyImport = {\n  id: number;\n  name: string;\n  sales_id?: number;\n  description?: string;\n  city?: string;\n  country?: string;\n  address?: string;\n  zipcode?: string;\n  state_abbr?: string;\n  sector?: string;\n  size?: number;\n  linkedin_url?: string;\n  website?: string;\n  phone_number?: string;\n  revenue?: string;\n  tax_identifier?: string;\n  context_links?: string[];\n  created_at?: string;\n  updated_at?: string;\n};\n\nconst isCompany = (data: any): data is CompanyImport =>\n  data != null &&\n  typeof data === \"object\" &&\n  !Array.isArray(data) &&\n  data.id != null &&\n  data.name != null;\n\ntype ContactImport = {\n  id: number;\n  sales_id: number;\n  company_id?: number;\n  first_name: string;\n  last_name: string;\n  title?: string;\n  background?: string;\n  linkedin_url?: string;\n  avatar?: string;\n  gender?: string;\n  has_newsletter?: boolean;\n  emails: Array<{ email: string; type: string }>;\n  phones: Array<{ number: string; type: string }>;\n  tags: Array<string>;\n  created_at?: string;\n  updated_at?: string;\n};\n\nconst isContact = (data: any): data is ContactImport =>\n  data != null &&\n  typeof data === \"object\" &&\n  !Array.isArray(data) &&\n  data.id != null;\n\ntype NoteImport = {\n  contact_id: number;\n  sales_id: number;\n  text: string;\n  date: string;\n  attachments: Array<{ url: string; name: string }>;\n  created_at?: string;\n  updated_at?: string;\n};\n\nconst isNote = (data: any): data is NoteImport =>\n  data != null &&\n  typeof data === \"object\" &&\n  !Array.isArray(data) &&\n  data.sales_id != null &&\n  data.contact_id != null &&\n  data.text != null &&\n  data.date != null;\n\ntype TaskImport = {\n  contact_id: number;\n  sales_id: number;\n  text: string;\n  due_date?: string;\n  done_date?: string;\n  created_at?: string;\n  updated_at?: string;\n};\n\nconst isTask = (data: any): data is TaskImport =>\n  data != null &&\n  typeof data === \"object\" &&\n  !Array.isArray(data) &&\n  data.sales_id != null &&\n  data.contact_id != null &&\n  data.text != null;\n\n/**\n * Maps a company size number to the appropriate size category.\n * Categories: 1, 10, 50, 250, 500\n */\nconst mapSizeToCategory = (size: number): 1 | 10 | 50 | 250 | 500 => {\n  if (size === 1) return 1;\n  if (size < 10) return 10;\n  if (size < 50) return 50;\n  if (size < 250) return 250;\n  return 500;\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/misc/useAppBarHeight.ts",
      "content": "import { useIsMobile } from \"@/hooks/use-mobile\";\n\nconst DENSE_NAVBAR_HEIGHT = 48;\nconst DENSE_NAVBAR_HEIGHT_MOBILE = 64;\n\nexport default function useAppBarHeight(): number {\n  const isMobile = useIsMobile();\n  return isMobile ? DENSE_NAVBAR_HEIGHT_MOBILE : DENSE_NAVBAR_HEIGHT;\n}\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/misc/unsupportedDomains.const.ts",
      "content": "// If you want to add more domains to the list, you can do so by adding them to the DOMAINS_NOT_SUPPORTING_FAVICON array.\nexport const DOMAINS_NOT_SUPPORTING_FAVICON = [\n  \"gmail.com\",\n  \"yahoo.com\",\n  \"hotmail.com\",\n  \"aol.com\",\n  \"hotmail.co.uk\",\n  \"hotmail.fr\",\n  \"msn.com\",\n  \"yahoo.fr\",\n  \"wanadoo.fr\",\n  \"orange.fr\",\n  \"comcast.net\",\n  \"yahoo.co.uk\",\n  \"yahoo.com.br\",\n  \"yahoo.co.in\",\n  \"live.com\",\n  \"rediffmail.com\",\n  \"free.fr\",\n  \"gmx.de\",\n  \"web.de\",\n  \"yandex.ru\",\n  \"ymail.com\",\n  \"libero.it\",\n  \"outlook.com\",\n  \"uol.com.br\",\n  \"bol.com.br\",\n  \"mail.ru\",\n  \"cox.net\",\n  \"hotmail.it\",\n  \"sbcglobal.net\",\n  \"sfr.fr\",\n  \"live.fr\",\n  \"verizon.net\",\n  \"live.co.uk\",\n  \"googlemail.com\",\n  \"yahoo.es\",\n  \"ig.com.br\",\n  \"live.nl\",\n  \"bigpond.com\",\n  \"terra.com.br\",\n  \"yahoo.it\",\n  \"neuf.fr\",\n  \"yahoo.de\",\n  \"alice.it\",\n  \"rocketmail.com\",\n  \"att.net\",\n  \"laposte.net\",\n  \"facebook.com\",\n  \"bellsouth.net\",\n  \"yahoo.in\",\n  \"hotmail.es\",\n  \"charter.net\",\n  \"yahoo.ca\",\n  \"yahoo.com.au\",\n  \"rambler.ru\",\n  \"hotmail.de\",\n  \"tiscali.it\",\n  \"shaw.ca\",\n  \"yahoo.co.jp\",\n  \"sky.com\",\n  \"earthlink.net\",\n  \"optonline.net\",\n  \"freenet.de\",\n  \"t-online.de\",\n  \"aliceadsl.fr\",\n  \"virgilio.it\",\n  \"home.nl\",\n  \"qq.com\",\n  \"telenet.be\",\n  \"me.com\",\n  \"yahoo.com.ar\",\n  \"tiscali.co.uk\",\n  \"yahoo.com.mx\",\n  \"voila.fr\",\n  \"gmx.net\",\n  \"mail.com\",\n  \"planet.nl\",\n  \"tin.it\",\n  \"live.it\",\n  \"ntlworld.com\",\n  \"arcor.de\",\n  \"yahoo.co.id\",\n  \"frontiernet.net\",\n  \"hetnet.nl\",\n  \"live.com.au\",\n  \"yahoo.com.sg\",\n  \"zonnet.nl\",\n  \"club-internet.fr\",\n  \"juno.com\",\n  \"optusnet.com.au\",\n  \"blueyonder.co.uk\",\n  \"bluewin.ch\",\n  \"skynet.be\",\n  \"sympatico.ca\",\n  \"windstream.net\",\n  \"mac.com\",\n  \"centurytel.net\",\n  \"chello.nl\",\n  \"live.ca\",\n  \"aim.com\",\n  \"bigpond.net.au\",\n  \"online.de\",\n  \"apple.com\",\n];\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/misc/isLinkedInUrl.ts",
      "content": "const LINKEDIN_URL_REGEX = /^http(?:s)?:\\/\\/(?:www\\.)?linkedin.com\\//;\n\nexport const isLinkedinUrl = (url: string) => {\n  if (!url) return;\n  try {\n    // Parse the URL to ensure it is valid\n    const parsedUrl = new URL(url);\n    if (!parsedUrl.href.match(LINKEDIN_URL_REGEX)) {\n      return {\n        message: \"crm.validation.invalid_linkedin_url\",\n        args: { _: \"URL must be from linkedin.com\" },\n      };\n    }\n  } catch {\n    // If URL parsing fails, return false\n    return {\n      message: \"crm.validation.invalid_url\",\n      args: { _: \"Must be a valid URL\" },\n    };\n  }\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/misc/fetchWithTimeout.ts",
      "content": "type FetchParams = Parameters<typeof fetch>;\n\nexport async function fetchWithTimeout(\n  resource: string,\n  options: FetchParams[1] & { timeout?: number } = {},\n) {\n  const { timeout = 2000 } = options;\n\n  const controller = new AbortController();\n  const id = setTimeout(() => controller.abort(), timeout);\n\n  const response = await fetch(resource, {\n    ...options,\n    signal: controller.signal,\n  });\n\n  clearTimeout(id);\n\n  return response;\n}\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/misc/Status.tsx",
      "content": "import { cn } from \"@/lib/utils\";\n\nimport { useConfigurationContext } from \"../root/ConfigurationContext\";\n\nexport const Status = ({\n  status,\n  className,\n}: {\n  status: string;\n  className?: string;\n}) => {\n  const { noteStatuses } = useConfigurationContext();\n  if (!status || !noteStatuses) return null;\n  const statusObject = noteStatuses.find((s: any) => s.value === status);\n\n  if (!statusObject) return null;\n  return (\n    <div className={cn(\"group relative inline-block mr-2\", className)}>\n      <span\n        className=\"inline-block w-2.5 h-2.5 rounded-full\"\n        style={{ backgroundColor: statusObject.color }}\n      />\n      <div className=\"absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-2 py-1 text-xs text-white bg-gray-800 rounded opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none whitespace-nowrap z-10\">\n        {statusObject.label}\n      </div>\n    </div>\n  );\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/misc/ResponsiveFilters.tsx",
      "content": "import { FilterLiveForm, useListContext, useTranslate } from \"ra-core\";\nimport { SearchInput, type SearchInputProps } from \"@/components/admin\";\nimport {\n  Sheet,\n  SheetClose,\n  SheetContent,\n  SheetFooter,\n  SheetHeader,\n  SheetTitle,\n  SheetTrigger,\n} from \"@/components/ui/sheet\";\nimport { useIsMobile } from \"@/hooks/use-mobile\";\nimport { Button } from \"@/components/ui/button\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { Filter } from \"lucide-react\";\n\nconst FlexForm = (props: React.FormHTMLAttributes<HTMLFormElement>) => (\n  <form className=\"flex-1\" {...props} />\n);\n\nexport const ResponsiveFilters = ({\n  children,\n  searchInput,\n}: {\n  children: React.ReactNode;\n  searchInput?: Partial<SearchInputProps>;\n}) => {\n  const translate = useTranslate();\n  const {\n    source = \"q\",\n    className,\n    ...otherSearchInputProps\n  } = searchInput || {};\n  const isMobile = useIsMobile();\n  const { setFilters, filterValues } = useListContext();\n\n  // Count active filters excluding the search filter\n  const activeFiltersCount = Object.entries(filterValues || {}).filter(\n    ([key]) => key !== source,\n  ).length;\n\n  const handleClearFilters = () => {\n    // Preserve only the search filter\n    const searchValue = filterValues[source];\n    const preservedFilters = searchValue ? { [source]: searchValue } : {};\n    setFilters(preservedFilters, []);\n  };\n\n  if (isMobile) {\n    return (\n      <div className=\"flex flex-1 gap-2\">\n        <FilterLiveForm formComponent={FlexForm}>\n          <SearchInput\n            source={source}\n            className={className}\n            {...otherSearchInputProps}\n          />\n        </FilterLiveForm>\n        <Sheet>\n          <SheetTrigger asChild>\n            <Button\n              variant=\"ghost\"\n              size=\"icon\"\n              className=\"relative size-9\"\n              aria-label={translate(\"ra.action.add_filter\")}\n            >\n              <Filter className=\"size-5\" />\n              {activeFiltersCount > 0 && (\n                <Badge\n                  variant=\"destructive\"\n                  className=\"absolute -top-1 -right-1 h-5 w-5 p-0 text-xs flex items-center justify-center\"\n                >\n                  {activeFiltersCount}\n                </Badge>\n              )}\n            </Button>\n          </SheetTrigger>\n          <SheetContent side=\"bottom\" className=\"h-dvh p-4 flex flex-col\">\n            <SheetHeader className=\"-p-4\">\n              <SheetTitle>\n                <h1 className=\"text-xl font-semibold\">\n                  {translate(\"ra.action.add_filter\")}\n                </h1>\n              </SheetTitle>\n            </SheetHeader>\n            <div className=\"flex-1 overflow-y-auto flex flex-col gap-3 pb-4\">\n              {children}\n            </div>\n            <SheetFooter className=\"-p-4 relative\">\n              <div className=\"absolute -top-12 left-0 right-0 h-8 bg-gradient-to-t from-background to-transparent pointer-events-none\" />\n              <div className=\"flex w-full gap-4\">\n                <SheetClose asChild>\n                  <Button\n                    onClick={handleClearFilters}\n                    type=\"button\"\n                    variant=\"secondary\"\n                    className=\"flex-1\"\n                  >\n                    {translate(\"ra.navigation.clear_filters\", {\n                      _: \"Clear filters\",\n                    })}\n                  </Button>\n                </SheetClose>\n                <SheetClose asChild>\n                  <Button className=\"flex-1\">\n                    {translate(\"ra.action.confirm\")}\n                  </Button>\n                </SheetClose>\n              </div>\n            </SheetFooter>\n          </SheetContent>\n        </Sheet>\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"w-52 min-w-52 order-first pt-0.75 flex flex-col gap-4\">\n      <FilterLiveForm>\n        <SearchInput source={source} {...otherSearchInputProps} />\n      </FilterLiveForm>\n      {children}\n    </div>\n  );\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/misc/RelativeDate.tsx",
      "content": "/* eslint-disable react-refresh/only-export-components */\nimport { differenceInDays, formatRelative } from \"date-fns\";\nimport { enUS, fr } from \"date-fns/locale\";\nimport { useLocaleState } from \"ra-core\";\n\n/**\n * We use date-fns rather than Intl because Intl isn't yet capable of formatting relative dates as we want.\n *\n * The best we could do is this:\n *\n * const relativeDay = new Intl.RelativeTimeFormat(locale, {\n *   numeric: \"auto\",\n * }).format(diffInDays, \"day\");\n *\n * const time = new Intl.DateTimeFormat(locale, {\n *   hour: \"numeric\",\n *   minute: \"numeric\",\n * }).format(dateObj);\n *\n * return `${relativeDay} ${time}`;\n *\n * This would return relatives dates as \"3 days ago 3:00 PM\" which isn't ideal. We want \"3 days ago at 3:00 PM\".\n */\n\nconst getDateFnsLocale = (locale: string) =>\n  locale.startsWith(\"fr\") ? fr : enUS;\n\nexport const formatLocalizedDate = (date: string, locale = \"en\") =>\n  new Intl.DateTimeFormat(locale, {\n    year: \"numeric\",\n    month: \"long\",\n    day: \"numeric\",\n  }).format(new Date(date));\n\nexport const formatRelativeDate = (date: string, locale = \"en\") => {\n  const dateObj = new Date(date);\n  const now = new Date();\n  const dateFnsLocale = getDateFnsLocale(locale);\n\n  if (differenceInDays(now, dateObj) > 6) {\n    return new Intl.DateTimeFormat(locale).format(dateObj);\n  }\n\n  return formatRelative(dateObj, now, { locale: dateFnsLocale });\n};\n\nexport const useRelativeDate = (date: string) => {\n  const [locale = \"en\"] = useLocaleState();\n\n  return formatRelativeDate(date, locale);\n};\n\nexport function RelativeDate({ date }: { date: string }) {\n  return useRelativeDate(date);\n}\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/misc/MobileBackButton.tsx",
      "content": "import { useResourceContext, useCreatePath } from \"ra-core\";\nimport { useNavigate } from \"react-router\";\nimport { ChevronLeft } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\n\nexport const MobileBackButton = (props: { resource?: string; to?: string }) => {\n  const resource = useResourceContext(props);\n  const navigate = useNavigate();\n  const createPath = useCreatePath();\n  const { to } = props;\n  const finalTo =\n    to ??\n    createPath({\n      resource,\n      type: \"list\",\n    });\n\n  return (\n    <Button\n      type=\"button\"\n      variant=\"ghost\"\n      size=\"icon\"\n      className=\"rounded-full size-5 pr-2\"\n      onClick={(e) => {\n        e.preventDefault();\n        navigate(finalTo);\n      }}\n    >\n      <ChevronLeft className=\"size-6\" />\n      <span className=\"sr-only\">Back{to ? \"\" : \" to list\"}</span>\n    </Button>\n  );\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/misc/Markdown.tsx",
      "content": "import { cn } from \"@/lib/utils\";\nimport DOMPurify from \"dompurify\";\nimport { marked, type TokenizerExtension } from \"marked\";\n\n// Extension to auto-link URLs (but not URLs already in markdown link/image syntax)\nconst urlExtension: TokenizerExtension = {\n  name: \"autolink\",\n  level: \"inline\",\n  start(src) {\n    // Don't match URLs that are inside markdown link/image syntax\n    const match = src.match(/https?:\\/\\//);\n    if (!match) return;\n    // Check if this URL is preceded by ]( which indicates it's part of a link/image\n    const beforeMatch = src.slice(0, match.index);\n    if (beforeMatch.endsWith(\"](\") || beforeMatch.endsWith(\"(\")) {\n      return;\n    }\n    return match.index;\n  },\n  tokenizer(src) {\n    const match = src.match(/^https?:\\/\\/[^\\s<>)[\\]]+/);\n    if (match) {\n      return {\n        type: \"link\",\n        raw: match[0],\n        href: match[0],\n        text: match[0],\n        tokens: [{ type: \"text\", raw: match[0], text: match[0] }],\n      };\n    }\n  },\n};\n\nmarked.use({\n  extensions: [urlExtension],\n  breaks: true, // Single newlines become <br> (useful for emails)\n  hooks: {\n    postprocess: (html) => DOMPurify.sanitize(html),\n  },\n});\n\ntype MarkdownProps = {\n  children: string;\n  className?: string;\n};\n\nexport function Markdown({ children, className }: MarkdownProps) {\n  const html = marked.parse(children) as string;\n\n  return (\n    <div\n      className={cn(\n        // Paragraphs\n        \"[&_p]:leading-5 [&_p]:my-4 [&_p:first-child]:mt-0 [&_p:last-child]:mb-0\",\n        // Headings\n        \"[&_h1]:text-3xl [&_h1]:font-bold [&_h1]:mt-6 [&_h1]:mb-4 [&_h1:first-child]:mt-0\",\n        \"[&_h2]:text-2xl [&_h2]:font-bold [&_h2]:mt-5 [&_h2]:mb-3 [&_h2:first-child]:mt-0\",\n        \"[&_h3]:text-xl [&_h3]:font-bold [&_h3]:mt-4 [&_h3]:mb-2 [&_h3:first-child]:mt-0\",\n        \"[&_h4]:text-lg [&_h4]:font-semibold [&_h4]:mt-3 [&_h4]:mb-2 [&_h4:first-child]:mt-0\",\n        \"[&_h5]:text-base [&_h5]:font-semibold [&_h5]:mt-3 [&_h5]:mb-2 [&_h5:first-child]:mt-0\",\n        \"[&_h6]:text-sm [&_h6]:font-semibold [&_h6]:mt-3 [&_h6]:mb-2 [&_h6:first-child]:mt-0\",\n        // Blockquotes\n        \"[&_blockquote]:border-l-2 [&_blockquote]:pl-3 [&_blockquote]:my-2 [&_blockquote]:text-muted-foreground\",\n        // Links\n        \"[&_a]:text-primary [&_a]:underline [&_a:hover]:no-underline\",\n        // Lists\n        \"[&_ul]:list-disc [&_ul]:ml-6 [&_ul]:my-2\",\n        \"[&_ol]:list-decimal [&_ol]:ml-6 [&_ol]:my-2\",\n        \"[&_li]:my-1\",\n        \"[&_ul_ul]:my-0 [&_ol_ol]:my-0 [&_ul_ol]:my-0 [&_ol_ul]:my-0\",\n        // Code\n        \"[&_code]:bg-muted [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:rounded [&_code]:text-sm [&_code]:font-mono\",\n        \"[&_pre]:bg-muted [&_pre]:p-4 [&_pre]:rounded [&_pre]:overflow-x-auto [&_pre]:my-4\",\n        \"[&_pre_code]:bg-transparent [&_pre_code]:p-0\",\n        // Tables\n        \"[&_table]:w-full [&_table]:border-collapse [&_table]:my-4\",\n        \"[&_th]:border [&_th]:border-border [&_th]:bg-muted [&_th]:px-4 [&_th]:py-2 [&_th]:text-left [&_th]:font-semibold\",\n        \"[&_td]:border [&_td]:border-border [&_td]:px-4 [&_td]:py-2\",\n        \"[&_tr:nth-child(even)]:bg-muted/50\",\n        // Horizontal rule\n        \"[&_hr]:border-t [&_hr]:border-border [&_hr]:my-4\",\n        className,\n      )}\n      dangerouslySetInnerHTML={{ __html: html }}\n    />\n  );\n}\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/misc/InfinitePagination.tsx",
      "content": "import * as React from \"react\";\nimport { useEffect, useRef } from \"react\";\nimport {\n  useInfinitePaginationContext,\n  useListContext,\n  useEvent,\n  useTranslate,\n} from \"ra-core\";\nimport { Item, ItemContent, ItemMedia, ItemTitle } from \"@/components/ui/item\";\nimport { Spinner } from \"@/components/admin/spinner\";\n\n/**\n * A pagination component that loads more results when the user scrolls to the bottom of the list.\n *\n * Used as the default pagination component in the <InfiniteList> component.\n *\n * @example\n * import { InfiniteList, InfinitePagination, Datagrid, TextField } from 'react-admin';\n *\n * const PostList = () => (\n *    <InfiniteList pagination={<InfinitePagination sx={{ py: 5 }} />}>\n *       <Datagrid>\n *          <TextField source=\"id\" />\n *         <TextField source=\"title\" />\n *      </Datagrid>\n *   </InfiniteList>\n * );\n */\nexport const InfinitePagination = ({\n  offline = null,\n  options = defaultOptions,\n}: InfinitePaginationProps) => {\n  const translate = useTranslate();\n  const { isPaused, isPending } = useListContext();\n  const { fetchNextPage, hasNextPage, isFetchingNextPage } =\n    useInfinitePaginationContext();\n\n  if (!fetchNextPage) {\n    throw new Error(\n      \"InfinitePagination must be used inside an InfinitePaginationContext, usually created by <InfiniteList>. You cannot use it as child of a <List> component.\",\n    );\n  }\n\n  const [hasRequestedNextPage, setHasRequestedNextPage] = React.useState(false);\n  const observerElem = useRef(null);\n  const handleObserver = useEvent<[IntersectionObserverEntry[]], void>(\n    (entries) => {\n      const [target] = entries;\n      if (target.isIntersecting && hasNextPage && !isFetchingNextPage) {\n        setHasRequestedNextPage(true);\n        fetchNextPage();\n      }\n    },\n  );\n\n  useEffect(() => {\n    // Whenever the query is unpaused, reset the requested next page state\n    if (!isPaused) {\n      setHasRequestedNextPage(false);\n    }\n  }, [isPaused]);\n\n  useEffect(() => {\n    const element = observerElem.current;\n    if (!element) return;\n    const observer = new IntersectionObserver(handleObserver, options);\n    observer.observe(element);\n    return () => observer.unobserve(element);\n  }, [\n    fetchNextPage,\n    hasNextPage,\n    handleObserver,\n    options,\n    isPending,\n    isFetchingNextPage,\n  ]);\n\n  if (isPending) return null;\n\n  const showOffline =\n    isPaused &&\n    hasNextPage &&\n    hasRequestedNextPage &&\n    offline !== false &&\n    offline !== undefined;\n\n  return (\n    <div ref={observerElem} className=\"py-2 text-center\">\n      {showOffline ? (\n        offline\n      ) : isFetchingNextPage && hasNextPage ? (\n        <Item variant=\"default\">\n          <ItemMedia>\n            <Spinner />\n          </ItemMedia>\n          <ItemContent>\n            <ItemTitle className=\"line-clamp-1\">\n              {translate(\"crm.common.loading\")}\n            </ItemTitle>\n          </ItemContent>\n        </Item>\n      ) : (\n        <Item variant=\"default\">\n          <ItemContent>\n            <ItemTitle className=\"line-clamp-1\">&nbsp;</ItemTitle>\n          </ItemContent>\n        </Item>\n      )}\n    </div>\n  );\n};\n\nconst defaultOptions = { threshold: 0 };\n\nexport interface InfinitePaginationProps {\n  offline?: React.ReactNode;\n  options?: IntersectionObserverInit;\n}\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/misc/ImportPage.tsx",
      "content": "import { AlertCircleIcon } from \"lucide-react\";\nimport { Form, required, useTranslate } from \"ra-core\";\nimport { Alert, AlertDescription, AlertTitle } from \"@/components/ui/alert\";\nimport { Button } from \"@/components/ui/button\";\nimport { Card, CardContent, CardHeader, CardTitle } from \"@/components/ui/card\";\nimport { cn } from \"@/lib/utils\";\nimport {\n  Table,\n  TableBody,\n  TableCaption,\n  TableCell,\n  TableHead,\n  TableHeader,\n  TableRow,\n} from \"@/components/ui/table\";\nimport { Spinner } from \"@/components/ui/spinner\";\nimport { FileField, FileInput } from \"@/components/admin\";\nimport {\n  type ImportFromJsonErrorState,\n  type ImportFromJsonFailures,\n  type ImportFromJsonFunction,\n  type ImportFromJsonState,\n  useImportFromJson,\n} from \"./useImportFromJson\";\nimport sampleFile from \"./import-sample.json?url\";\n\nexport const ImportPage = () => {\n  const translate = useTranslate();\n  const [importState, importFile, reset] = useImportFromJson();\n\n  return (\n    <div className=\"max-w-2xl mx-auto mt-8\">\n      <Card>\n        <CardHeader>\n          <CardTitle>{translate(\"crm.import.title\")}</CardTitle>\n        </CardHeader>\n        <CardContent>\n          {importState.status === \"idle\" ? (\n            <ImportFromJsonIdle importFile={importFile} translate={translate} />\n          ) : importState.status === \"error\" ? (\n            <ImportFromJsonError\n              importState={importState}\n              importFile={importFile}\n              translate={translate}\n            />\n          ) : importState.status === \"importing\" ? (\n            <ImportFromJsonStatus\n              importState={importState}\n              translate={translate}\n            />\n          ) : (\n            <ImportFromJsonSuccess\n              importState={importState}\n              reset={reset}\n              translate={translate}\n            />\n          )}\n        </CardContent>\n      </Card>\n    </div>\n  );\n};\n\nImportPage.path = \"/import\";\n\nconst ImportFromJsonIdle = ({\n  importFile,\n  translate,\n}: {\n  importFile: ImportFromJsonFunction;\n  translate: (key: string, options?: any) => string;\n}) => (\n  <>\n    <div className=\"mb-4\">\n      <p className=\"text-sm\">\n        {translate(\"crm.import.idle.description_1\", {\n          _: \"You can import sales, companies, contacts, companies, notes, and tasks.\",\n        })}\n      </p>\n      <p className=\"text-sm\">\n        {translate(\"crm.import.idle.description_2\", {\n          _: \"Data must be in a JSON file matching the following sample:\",\n        })}{\" \"}\n        <a\n          className=\"underline\"\n          download=\"import-sample.json\"\n          href={sampleFile}\n        >\n          sample.json\n        </a>\n      </p>\n    </div>\n    <ImportFromJsonForm importFile={importFile} translate={translate} />\n  </>\n);\n\nconst ImportFromJsonError = ({\n  importState,\n  importFile,\n  translate,\n}: {\n  importFile: ImportFromJsonFunction;\n  importState: ImportFromJsonErrorState;\n  translate: (key: string, options?: any) => string;\n}) => (\n  <>\n    <Alert variant=\"destructive\" className=\"mb-4\">\n      <AlertCircleIcon />\n      <AlertTitle>\n        {translate(\"crm.import.error.unable\", {\n          _: \"Unable to import this file.\",\n        })}\n      </AlertTitle>\n      <AlertDescription>\n        <p>{importState.error.message}</p>\n      </AlertDescription>\n    </Alert>\n    <ImportFromJsonForm importFile={importFile} translate={translate} />\n  </>\n);\n\nconst ImportFromJsonForm = ({\n  importFile,\n  translate,\n}: {\n  importFile: ImportFromJsonFunction;\n  translate: (key: string, options?: any) => string;\n}) => (\n  <Form\n    onSubmit={(values: any) => {\n      importFile(values.file.rawFile);\n    }}\n  >\n    <FileInput className=\"mt-4\" source=\"file\" validate={required()}>\n      <FileField source=\"src\" title=\"title\" />\n    </FileInput>\n    <div className=\"flex justify-end mt-4\">\n      <Button type=\"submit\">{translate(\"crm.import.action.import\")}</Button>\n    </div>\n  </Form>\n);\n\nconst ImportFromJsonStatus = ({\n  importState,\n  translate,\n}: {\n  importState: ImportFromJsonState;\n  translate: (key: string, options?: any) => string;\n}) => (\n  <>\n    <Spinner />\n    <p className=\"my-4 text-sm text-center text-muted-foreground\">\n      {translate(\"crm.import.status.in_progress\", {\n        _: \"Import in progress, please don't navigate away from this page.\",\n      })}\n    </p>\n    <ImportStats importState={importState} translate={translate} />\n  </>\n);\n\nconst ImportFromJsonSuccess = ({\n  importState,\n  reset,\n  translate,\n}: {\n  importState: ImportFromJsonState;\n  reset: () => void;\n  translate: (key: string, options?: any) => string;\n}) => (\n  <>\n    <p className=\"mb-4 text-sm\">\n      {translate(\"crm.import.status.complete\")}{\" \"}\n      {hasFailedImports(importState.failedImports) ? (\n        <>\n          <span className=\"text-destructive\">\n            {translate(\"crm.import.status.some_failed\", {\n              _: \"Some records were not imported.\",\n            })}{\" \"}\n          </span>\n          <DownloadErrorFileButton\n            failedImports={importState.failedImports}\n            translate={translate}\n          />\n        </>\n      ) : (\n        <span>\n          {translate(\"crm.import.status.all_success\", {\n            _: \"All records were imported successfully.\",\n          })}\n        </span>\n      )}\n    </p>\n    <ImportStats importState={importState} translate={translate} />\n    <div className=\"flex justify-end mt-4\">\n      <Button variant=\"outline\" onClick={reset}>\n        {translate(\"crm.import.action.import_another\", {\n          _: \"Import another file\",\n        })}\n      </Button>\n    </div>\n  </>\n);\n\nconst hasFailedImports = (failedImports: ImportFromJsonFailures) => {\n  return (\n    failedImports.sales.length > 0 ||\n    failedImports.companies.length > 0 ||\n    failedImports.contacts.length > 0 ||\n    failedImports.notes.length > 0 ||\n    failedImports.tasks.length > 0\n  );\n};\n\nconst DownloadErrorFileButton = ({\n  failedImports,\n  translate,\n}: {\n  failedImports: ImportFromJsonFailures;\n  translate: (key: string, options?: any) => string;\n}) => {\n  return (\n    <a\n      className=\"font-semibold\"\n      onClick={async (event) => {\n        const json = JSON.stringify(failedImports);\n        const blob = new Blob([json], { type: \"octet/stream\" });\n        const url = window.URL.createObjectURL(blob);\n        event.currentTarget.href = url;\n      }}\n      download=\"atomic-crm-import-report.json\"\n    >\n      {translate(\"crm.import.action.download_error_report\", {\n        _: \"Download the error report\",\n      })}\n    </a>\n  );\n};\n\nconst ImportStats = ({\n  importState: { stats, failedImports },\n  translate,\n}: {\n  importState: ImportFromJsonState;\n  translate: (key: string, options?: any) => string;\n}) => {\n  const data = [\n    {\n      entity: \"sales\",\n      imported: stats.sales,\n      failed: failedImports.sales.length,\n    },\n    {\n      entity: \"companies\",\n      imported: stats.companies,\n      failed: failedImports.companies.length,\n    },\n    {\n      entity: \"contacts\",\n      imported: stats.contacts,\n      failed: failedImports.contacts.length,\n    },\n    {\n      entity: \"notes\",\n      imported: stats.notes,\n      failed: failedImports.notes.length,\n    },\n    {\n      entity: \"tasks\",\n      imported: stats.tasks,\n      failed: failedImports.tasks.length,\n    },\n  ];\n  return (\n    <Table>\n      <TableCaption className=\"sr-only\">\n        {translate(\"crm.import.status.table_caption\")}\n      </TableCaption>\n      <TableHeader>\n        <TableRow>\n          <TableHead className=\"w-25\"></TableHead>\n          <TableHead className=\"text-right\">\n            {translate(\"crm.import.status.imported\")}\n          </TableHead>\n          <TableHead className=\"text-right\">\n            {translate(\"crm.import.status.failed\")}\n          </TableHead>\n        </TableRow>\n      </TableHeader>\n      <TableBody>\n        {data.map((record) => (\n          <TableRow key={record.entity}>\n            <TableCell className=\"font-medium\">{record.entity}</TableCell>\n            <TableCell className=\"text-right text-success\">\n              {record.imported}\n            </TableCell>\n            <TableCell\n              className={cn(\n                \"text-right\",\n                record.failed > 0 && \"text-destructive\",\n              )}\n            >\n              {record.failed}\n            </TableCell>\n          </TableRow>\n        ))}\n      </TableBody>\n    </Table>\n  );\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/misc/ImageEditorField.tsx",
      "content": "import { useFieldValue, useTranslate } from \"ra-core\";\nimport { createRef, useCallback, useState } from \"react\";\nimport type { ReactCropperElement } from \"react-cropper\";\nimport { Cropper } from \"react-cropper\";\nimport { useDropzone } from \"react-dropzone\";\nimport { useFormContext } from \"react-hook-form\";\nimport { Avatar, AvatarFallback, AvatarImage } from \"@/components/ui/avatar\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from \"@/components/ui/dialog\";\n\nimport \"cropperjs/dist/cropper.css\";\n\nconst ImageEditorField = (props: ImageEditorFieldProps) => {\n  const translate = useTranslate();\n  const { getValues } = useFormContext();\n  const source = getValues(props.source);\n  const imageUrl = source?.src;\n  const [isDialogOpen, setIsDialogOpen] = useState(false);\n\n  const { type = \"image\", emptyText, linkPosition = \"none\" } = props;\n\n  const commonProps = {\n    src: imageUrl,\n    onClick: () => setIsDialogOpen(true),\n    style: { cursor: \"pointer\" },\n    className: `${props.className || \"\"}`,\n  };\n\n  const width = props.width || (type === \"avatar\" ? 50 : 200);\n  const height = props.height || (type === \"avatar\" ? 50 : 200);\n\n  return (\n    <>\n      <div\n        className={`flex ${\n          linkPosition === \"right\" ? \"flex-row\" : \"flex-col\"\n        } items-center ${linkPosition === \"right\" ? \"gap-2\" : \"gap-1\"}`}\n      >\n        <div\n          className={`rounded ${props.backgroundImageColor ? \"p-4\" : \"p-0\"}`}\n          style={{\n            backgroundColor: props.backgroundImageColor || \"transparent\",\n          }}\n        >\n          {props.type === \"avatar\" ? (\n            <Avatar\n              {...commonProps}\n              className={`cursor-pointer`}\n              style={{ width, height }}\n            >\n              <AvatarImage src={imageUrl} />\n              <AvatarFallback>{emptyText}</AvatarFallback>\n            </Avatar>\n          ) : (\n            <img\n              {...commonProps}\n              className=\"cursor-pointer object-cover\"\n              style={{ width, height }}\n              alt={translate(\"crm.image_editor.editable_content\", {\n                _: \"Editable content\",\n              })}\n            />\n          )}\n        </div>\n        {linkPosition !== \"none\" && (\n          <button\n            type=\"button\"\n            onClick={() => setIsDialogOpen(true)}\n            className=\"text-xs underline hover:no-underline cursor-pointer text-center\"\n          >\n            {translate(\"crm.image_editor.change\")}\n          </button>\n        )}\n      </div>\n      <ImageEditorDialog\n        open={isDialogOpen}\n        onClose={() => setIsDialogOpen(false)}\n        {...props}\n      />\n    </>\n  );\n};\n\nconst ImageEditorDialog = (props: ImageEditorDialogProps) => {\n  const translate = useTranslate();\n  const { setValue, handleSubmit } = useFormContext();\n  const cropperRef = createRef<ReactCropperElement>();\n  const initialValue = useFieldValue({ source: props.source });\n  const [file, setFile] = useState<File | undefined>();\n  const [imageSrc, setImageSrc] = useState<string | undefined>(\n    initialValue?.src,\n  );\n  const onDrop = useCallback((files: File[]) => {\n    const preview = URL.createObjectURL(files[0]);\n    setFile(files[0]);\n    setImageSrc(preview);\n  }, []);\n\n  const updateImage = () => {\n    const cropper = cropperRef.current?.cropper;\n    const croppedImage = cropper?.getCroppedCanvas().toDataURL();\n    if (croppedImage) {\n      setImageSrc(croppedImage);\n\n      const newFile = file ?? new File([], initialValue?.src);\n      setValue(\n        props.source,\n        {\n          src: croppedImage,\n          title: newFile.name,\n          rawFile: newFile,\n        },\n        { shouldDirty: true },\n      );\n      props.onClose();\n\n      if (props.onSave) {\n        handleSubmit(props.onSave)();\n      }\n    }\n  };\n\n  const deleteImage = () => {\n    setValue(props.source, null, { shouldDirty: true });\n    if (props.onSave) {\n      handleSubmit(props.onSave)();\n    }\n    setImageSrc(undefined);\n    props.onClose();\n  };\n\n  const { getRootProps, getInputProps } = useDropzone({\n    accept: { \"image/jpeg\": [\".jpeg\", \".png\"] },\n    onDrop,\n    maxFiles: 1,\n  });\n\n  return (\n    <Dialog open={props.open} onOpenChange={props.onClose}>\n      {props.type === \"avatar\" && (\n        <style>\n          {`\n                        .cropper-crop-box,\n                        .cropper-view-box {\n                            border-radius: 50%;\n                        }\n                    `}\n        </style>\n      )}\n      <DialogContent>\n        <DialogHeader>\n          <DialogTitle>\n            {translate(\"crm.image_editor.title\", {\n              _: \"Upload and resize image\",\n            })}\n          </DialogTitle>\n        </DialogHeader>\n        <div className=\"flex flex-col gap-2 justify-center\">\n          <div\n            className=\"flex flex-row justify-center bg-gray-50 cursor-pointer p-4 border-2 border-dashed border-gray-300 rounded-lg hover:bg-gray-100 transition-colors\"\n            {...getRootProps()}\n          >\n            <input {...getInputProps()} />\n            <p className=\"text-gray-600\">\n              {translate(\"crm.image_editor.drop_hint\", {\n                _: \"Drop a file to upload, or click to select it.\",\n              })}\n            </p>\n          </div>\n\n          {imageSrc && (\n            <Cropper\n              ref={cropperRef}\n              src={imageSrc}\n              aspectRatio={1}\n              guides={false}\n              cropBoxResizable={false}\n            />\n          )}\n        </div>\n\n        <DialogFooter className=\"flex justify-between w-full\">\n          <Button type=\"button\" onClick={updateImage}>\n            {translate(\"crm.image_editor.update_image\")}\n          </Button>\n          <Button type=\"button\" variant=\"destructive\" onClick={deleteImage}>\n            {translate(\"ra.action.delete\")}\n          </Button>\n        </DialogFooter>\n      </DialogContent>\n    </Dialog>\n  );\n};\n\nexport default ImageEditorField;\n\nexport interface ImageEditorFieldProps {\n  source: string;\n  width?: number;\n  height?: number;\n  type?: \"avatar\" | \"image\";\n  onSave?: any;\n  linkPosition?: \"right\" | \"bottom\" | \"none\";\n  backgroundImageColor?: string;\n  className?: string;\n  emptyText?: string;\n}\n\nexport interface ImageEditorDialogProps extends ImageEditorFieldProps {\n  open: boolean;\n  onClose: () => void;\n}\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/misc/EditSheet.tsx",
      "content": "import { SaveButton } from \"@/components/admin/form\";\nimport {\n  Sheet,\n  SheetContent,\n  SheetFooter,\n  SheetHeader,\n  SheetTitle,\n} from \"@/components/ui/sheet\";\nimport {\n  EditBase,\n  Form,\n  useEditContext,\n  useNotify,\n  useRedirect,\n  useResourceContext,\n  useTranslate,\n  type EditBaseProps,\n  type FormProps,\n} from \"ra-core\";\nimport { type ReactNode } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface EditSheetProps extends EditBaseProps {\n  /**\n   * The children elements that will be rendered inside the sheet as form inputs\n   */\n  children: ReactNode;\n\n  /**\n   * Controls whether the sheet is open\n   */\n  open: boolean;\n\n  /**\n   * Callback fired when the sheet open state changes\n   */\n  onOpenChange: (open: boolean) => void;\n\n  /**\n   * The title displayed in the sheet header\n   */\n  title?: ReactNode;\n\n  /**\n   * Default values for the form\n   */\n  defaultValues?: FormProps[\"defaultValues\"];\n\n  /**\n   * Optional actions to render in the sheet header, next to the title\n   */\n  headerActions?: ReactNode;\n}\n\n/**\n * A Sheet component that contains an edit form with externally controlled open state.\n *\n * Renders a Sheet containing an EditBase form. The sheet has a fixed footer with Save and Delete buttons.\n * The open state is controlled externally via the open and onOpenChange props. The sheet will automatically\n * close itself on successful submission (if redirect is false) or when the Delete/Close actions are triggered.\n *\n * @example\n * ```tsx\n * const [open, setOpen] = useState(false);\n *\n * return (\n *   <>\n *     <Button onClick={() => setOpen(true)}>Edit Contact</Button>\n *     <EditSheet\n *       resource=\"contacts\"\n *       id={contactId}\n *       title=\"Edit Contact\"\n *       open={open}\n *       onOpenChange={setOpen}\n *     >\n *       <TextInput source=\"first_name\" />\n *       <TextInput source=\"last_name\" />\n *       <TextInput source=\"email\" />\n *     </EditSheet>\n *   </>\n * );\n * ```\n */\nexport const EditSheet = ({\n  children,\n  open,\n  onOpenChange,\n  title,\n  redirect: redirectTo = \"show\",\n  mutationOptions,\n  mutationMode = \"undoable\",\n  defaultValues,\n  headerActions,\n  ...editBaseProps\n}: EditSheetProps) => {\n  const resource = useResourceContext(editBaseProps);\n  const translate = useTranslate();\n  const notify = useNotify();\n  const redirect = useRedirect();\n\n  // Handle success - close sheet in addition to default behavior\n  const handleSuccess = (...args: any[]) => {\n    if (mutationOptions?.onSuccess) {\n      return mutationOptions.onSuccess(\n        ...(args as Parameters<typeof mutationOptions.onSuccess>),\n      );\n    }\n    const [data] = args;\n    notify(`resources.${resource}.notifications.updated`, {\n      type: \"info\",\n      messageArgs: {\n        smart_count: 1,\n        _: translate(`ra.notification.updated`, {\n          smart_count: 1,\n        }),\n      },\n      undoable: mutationMode === \"undoable\",\n    });\n    redirect(redirectTo, resource, data.id, data);\n    onOpenChange(false);\n  };\n\n  const enhancedMutationOptions = {\n    ...mutationOptions,\n    onSuccess: handleSuccess,\n  };\n\n  return (\n    <Sheet open={open} onOpenChange={onOpenChange}>\n      <SheetContent\n        side=\"bottom\"\n        className=\"h-dvh flex flex-col\"\n        aria-describedby={undefined}\n      >\n        <EditBase\n          {...editBaseProps}\n          redirect={redirectTo}\n          mutationOptions={enhancedMutationOptions}\n          mutationMode={mutationMode}\n        >\n          <Form\n            defaultValues={defaultValues}\n            className=\"h-dvh flex-1 flex flex-col\"\n          >\n            <SheetHeader className=\"border-b\">\n              <div\n                className={cn(\n                  \"flex items-center gap-2\",\n                  headerActions && \"pr-12\",\n                )}\n              >\n                <SheetTitle className=\"min-w-0 flex-1 truncate\">\n                  <EditSheetTitle title={title} />\n                </SheetTitle>\n                {headerActions && (\n                  <div className=\"shrink-0\">{headerActions}</div>\n                )}\n              </div>\n            </SheetHeader>\n\n            <div className=\"flex-1 overflow-y-auto flex flex-col gap-3 p-4\">\n              {children}\n            </div>\n\n            <SheetFooter className=\"border-t flex flex-row w-full gap-4\">\n              <SaveButton className=\"flex-1 h-12\" />\n            </SheetFooter>\n          </Form>\n        </EditBase>\n      </SheetContent>\n    </Sheet>\n  );\n};\n\nconst EditSheetTitle = ({ title }: { title?: ReactNode | string | false }) => {\n  const { defaultTitle } = useEditContext();\n\n  if (title === false) {\n    return null;\n  }\n\n  const resolvedTitle = title === undefined ? defaultTitle : title;\n  if (resolvedTitle == null) {\n    return null;\n  }\n\n  return typeof resolvedTitle === \"string\" ? (\n    <span className=\"text-xl font-semibold\">{resolvedTitle}</span>\n  ) : (\n    resolvedTitle\n  );\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/misc/CreateSheet.tsx",
      "content": "import { SaveButton } from \"@/components/admin/form\";\nimport {\n  Sheet,\n  SheetContent,\n  SheetFooter,\n  SheetHeader,\n  SheetTitle,\n} from \"@/components/ui/sheet\";\nimport {\n  CreateBase,\n  Form,\n  useNotify,\n  useRedirect,\n  useResourceContext,\n  useTranslate,\n  type CreateBaseProps,\n  type FormProps,\n} from \"ra-core\";\nimport { type ReactNode } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface CreateSheetProps extends CreateBaseProps {\n  /**\n   * The children elements that will be rendered inside the sheet as form inputs\n   */\n  children: ReactNode;\n\n  /**\n   * Controls whether the sheet is open\n   */\n  open: boolean;\n\n  /**\n   * Callback fired when the sheet open state changes\n   */\n  onOpenChange: (open: boolean) => void;\n\n  /**\n   * The title displayed in the sheet header\n   */\n  title?: ReactNode;\n\n  /**\n   * Default values for the form\n   */\n  defaultValues?: FormProps[\"defaultValues\"];\n\n  /**\n   * Optional actions to render in the sheet header, next to the title\n   */\n  headerActions?: ReactNode;\n}\n\n/**\n * A Sheet component that contains a create form with externally controlled open state.\n *\n * Renders a Sheet containing a CreateBase form. The sheet has a fixed footer with Save and Close buttons.\n * The open state is controlled externally via the open and onOpenChange props. The sheet will automatically\n * close itself on successful submission (if redirect is false) or when the Close button is clicked.\n *\n * @example\n * ```tsx\n * const [open, setOpen] = useState(false);\n *\n * return (\n *   <>\n *     <Button onClick={() => setOpen(true)}>Create Contact</Button>\n *     <CreateSheet\n *       resource=\"contacts\"\n *       title=\"Create Contact\"\n *       open={open}\n *       onOpenChange={setOpen}\n *     >\n *       <TextInput source=\"first_name\" />\n *       <TextInput source=\"last_name\" />\n *       <TextInput source=\"email\" />\n *     </CreateSheet>\n *   </>\n * );\n * ```\n */\nexport const CreateSheet = ({\n  children,\n  open,\n  onOpenChange,\n  title = \"Create\",\n  redirect: redirectTo = \"show\",\n  mutationOptions,\n  defaultValues,\n  headerActions,\n  ...createBaseProps\n}: CreateSheetProps) => {\n  const resource = useResourceContext(createBaseProps);\n  const translate = useTranslate();\n  const notify = useNotify();\n  const redirect = useRedirect();\n\n  // Handle success - close sheet in addition to default behavior\n  const handleSuccess = (...args: any[]) => {\n    if (mutationOptions?.onSuccess) {\n      return mutationOptions.onSuccess(\n        ...(args as Parameters<typeof mutationOptions.onSuccess>),\n      );\n    }\n    const [data] = args;\n    notify(`resources.${resource}.notifications.created`, {\n      type: \"info\",\n      messageArgs: {\n        smart_count: 1,\n        _: translate(`ra.notification.created`, {\n          smart_count: 1,\n        }),\n      },\n      undoable: createBaseProps.mutationMode === \"undoable\",\n    });\n    redirect(redirectTo, resource, data.id, data);\n    onOpenChange(false);\n  };\n\n  const enhancedMutationOptions = {\n    ...mutationOptions,\n    onSuccess: handleSuccess,\n  };\n\n  return (\n    <Sheet open={open} onOpenChange={onOpenChange}>\n      <SheetContent\n        side=\"bottom\"\n        className=\"h-dvh flex flex-col\"\n        aria-describedby={undefined}\n      >\n        <CreateBase\n          {...createBaseProps}\n          redirect={redirectTo}\n          mutationOptions={enhancedMutationOptions}\n        >\n          <Form\n            defaultValues={defaultValues}\n            className=\"h-dvh flex-1 flex flex-col\"\n          >\n            <SheetHeader className=\"border-b\">\n              <div\n                className={cn(\n                  \"flex items-center gap-2\",\n                  headerActions && \"pr-12\",\n                )}\n              >\n                <SheetTitle className=\"min-w-0 flex-1 truncate\">\n                  {typeof title === \"string\" ? (\n                    <span className=\"text-xl font-semibold\">{title}</span>\n                  ) : (\n                    title\n                  )}\n                </SheetTitle>\n                {headerActions && (\n                  <div className=\"shrink-0\">{headerActions}</div>\n                )}\n              </div>\n            </SheetHeader>\n\n            <div className=\"flex-1 overflow-y-auto flex flex-col gap-3 p-4\">\n              {children}\n            </div>\n\n            <SheetFooter className=\"border-t flex flex-row w-full gap-4\">\n              <SaveButton className=\"flex-1 h-12\" />\n            </SheetFooter>\n          </Form>\n        </CreateBase>\n      </SheetContent>\n    </Sheet>\n  );\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/misc/ContactOption.tsx",
      "content": "import { useRecordContext, useTranslate } from \"ra-core\";\n\nimport { Avatar } from \"../contacts/Avatar\";\nimport type { Contact } from \"../types\";\n\n// eslint-disable-next-line react-refresh/only-export-components\nconst ContactOptionRender = () => {\n  const record: Contact | undefined = useRecordContext();\n  const translate = useTranslate();\n  if (!record) return null;\n  return (\n    <div className=\"flex flex-row gap-4 items-center justify-start whitespace-normal text-left\">\n      <Avatar height={40} width={40} record={record} />\n      <div className=\"flex flex-col items-start gap-1\">\n        <span>\n          {record.first_name} {record.last_name}\n        </span>\n        <span className=\"text-xs text-muted-foreground\">\n          {record.title && record.company_name\n            ? translate(\"resources.contacts.position_at_company\", {\n                title: record.title,\n                company: record.company_name,\n              })\n            : record.title || record.company_name}\n        </span>\n      </div>\n    </div>\n  );\n};\nexport const contactOptionText = <ContactOptionRender />;\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/misc/ChangelogPage.tsx",
      "content": "import { useTranslate } from \"ra-core\";\nimport { Card, CardContent, CardHeader, CardTitle } from \"@/components/ui/card\";\nimport { useIsMobile } from \"@/hooks/use-mobile\";\nimport { MobileContent } from \"../layout/MobileContent\";\nimport MobileHeader from \"../layout/MobileHeader\";\nimport { Markdown } from \"./Markdown\";\nimport changelogContent from \"../../../../CHANGELOG.md?raw\";\nimport { MobileBackButton } from \"./MobileBackButton\";\n\nexport const ChangelogPage = () => {\n  const translate = useTranslate();\n  const isMobile = useIsMobile();\n\n  if (isMobile) {\n    return (\n      <>\n        <MobileHeader>\n          <MobileBackButton to=\"/settings\" />\n          <div className=\"flex flex-1 min-w-0\">\n            <h1 className=\"text-xl font-semibold\">\n              {translate(\"crm.changelog.title\")}\n            </h1>\n          </div>\n        </MobileHeader>\n        <MobileContent>\n          <Markdown>{changelogContent}</Markdown>\n        </MobileContent>\n      </>\n    );\n  }\n\n  return (\n    <div className=\"max-w-3xl mx-auto my-8\">\n      <Card>\n        <CardHeader>\n          <CardTitle>{translate(\"crm.changelog.title\")}</CardTitle>\n        </CardHeader>\n        <CardContent>\n          <Markdown>{changelogContent}</Markdown>\n        </CardContent>\n      </Card>\n    </div>\n  );\n};\n\nChangelogPage.path = \"/changelog\";\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/misc/AsideSection.tsx",
      "content": "import type { ReactNode } from \"react\";\nimport { Separator } from \"@/components/ui/separator\";\nimport { cn } from \"@/lib/utils\";\nimport { useIsMobile } from \"@/hooks/use-mobile\";\n\nexport type AsideSectionProps = {\n  title: string;\n  children?: ReactNode;\n  noGap?: boolean;\n};\n\nexport function AsideSection({ title, children, noGap }: AsideSectionProps) {\n  const isMobile = useIsMobile();\n  return (\n    <div className=\"mb-6 text-sm\">\n      <h3 className={isMobile ? \"text-lg font-semibold\" : \"font-medium pb-1\"}>\n        {title}\n      </h3>\n      <Separator />\n      <div className={cn(\"pt-2 flex flex-col\", { \"gap-1\": !noGap })}>\n        {children}\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/misc/ActiveFilterButton.tsx",
      "content": "import React from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { Button } from \"@/components/ui/button\";\nimport { useListContext, useTranslate } from \"ra-core\";\nimport matches from \"lodash/matches\";\nimport pickBy from \"lodash/pickBy\";\nimport { CircleX } from \"lucide-react\";\n\n/**\n * A button that renders only if a specific filter is on, and allows to remove the filter.\n *\n * @example\n * import { ActiveFilterButton } from '@/components/atomic-crm/misc';\n *\n * const PostFilters = () => (\n *   <div className=\"flex flex-row gap-2\">\n *     <ActiveFilterButton label=\"Published\" value={{ status: 'published' }} />\n *     <ActiveFilterButton label=\"Draft\" value={{ status: 'draft' }} />\n *   </div>\n * );\n */\nexport const ActiveFilterButton = ({\n  label,\n  size = \"sm\",\n  value,\n  className,\n}: {\n  label: React.ReactElement | string;\n  value: any;\n  className?: string;\n  size?: \"default\" | \"sm\" | \"lg\" | \"icon\" | null | undefined;\n}) => {\n  const { filterValues, setFilters } = useListContext();\n  const translate = useTranslate();\n  const isSelected = getIsSelected(value, filterValues);\n  const handleClick = () => setFilters(toggleFilter(value, filterValues));\n  return isSelected ? (\n    <Button\n      variant=\"secondary\"\n      onClick={handleClick}\n      className={cn(\n        \"cursor-pointer\",\n        \"flex flex-row items-center justify-between gap-2 px-2 w-full h-8\",\n        className,\n      )}\n      size={size}\n    >\n      {typeof label === \"string\" ? translate(label, { _: label }) : label}\n      <CircleX className=\"opacity-50\" />\n    </Button>\n  ) : null;\n};\n\nconst toggleFilter = (value: any, filters: any) => {\n  const isSelected = matches(\n    pickBy(value, (val) => typeof val !== \"undefined\"),\n  )(filters);\n\n  if (isSelected) {\n    const keysToRemove = Object.keys(value);\n    return Object.keys(filters).reduce(\n      (acc, key) =>\n        keysToRemove.includes(key) ? acc : { ...acc, [key]: filters[key] },\n      {},\n    );\n  }\n\n  return { ...filters, ...value };\n};\n\nconst getIsSelected = (value: any, filters: any) =>\n  matches(pickBy(value, (val) => typeof val !== \"undefined\"))(filters);\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/login/StartPage.tsx",
      "content": "import { useQuery } from \"@tanstack/react-query\";\nimport { useDataProvider } from \"ra-core\";\nimport { Navigate } from \"react-router-dom\";\n\nimport type { CrmDataProvider } from \"../providers/types\";\nimport { LoginSkeleton } from \"./LoginSkeleton\";\nimport { LoginPage } from \"./LoginPage\";\nimport { disableEmailPasswordAuthentication } from \"./authConfig\";\n\nexport const StartPage = () => {\n  const dataProvider = useDataProvider<CrmDataProvider>();\n  const {\n    data: isInitialized,\n    error,\n    isPending,\n  } = useQuery({\n    queryKey: [\"init\"],\n    queryFn: async () => {\n      return dataProvider.isInitialized();\n    },\n  });\n\n  if (isPending) return <LoginSkeleton />;\n  if (error) return <LoginPage />;\n  if (isInitialized) return <LoginPage />;\n  if (disableEmailPasswordAuthentication) return <LoginPage />;\n\n  return <Navigate to=\"/sign-up\" />;\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/login/SignupPage.tsx",
      "content": "import { useMutation, useQuery, useQueryClient } from \"@tanstack/react-query\";\nimport { Loader2 } from \"lucide-react\";\nimport { useDataProvider, useLogin, useNotify, useTranslate } from \"ra-core\";\nimport { useForm, type SubmitHandler } from \"react-hook-form\";\nimport { Navigate, useNavigate } from \"react-router\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\n\nimport type { CrmDataProvider } from \"../providers/types\";\nimport { useConfigurationContext } from \"../root/ConfigurationContext\";\nimport type { SignUpData } from \"../types\";\nimport { LoginSkeleton } from \"./LoginSkeleton\";\nimport { Notification } from \"@/components/admin/notification\";\nimport { ConfirmationRequired } from \"./ConfirmationRequired\";\nimport { SSOAuthButton } from \"./SSOAuthButton\";\nimport { googleWorkplaceDomain } from \"./authConfig\";\n\nexport const SignupPage = () => {\n  const queryClient = useQueryClient();\n  const dataProvider = useDataProvider<CrmDataProvider>();\n  const { darkModeLogo: logo, title } = useConfigurationContext();\n  const navigate = useNavigate();\n  const translate = useTranslate();\n  const { data: isInitialized, isPending } = useQuery({\n    queryKey: [\"init\"],\n    queryFn: async () => {\n      return dataProvider.isInitialized();\n    },\n  });\n\n  const { isPending: isSignUpPending, mutate } = useMutation({\n    mutationKey: [\"signup\"],\n    mutationFn: async (data: SignUpData) => {\n      return dataProvider.signUp(data);\n    },\n    onSuccess: (data) => {\n      login({\n        email: data.email,\n        password: data.password,\n        redirectTo: \"/contacts\",\n      })\n        .then(() => {\n          notify(\"crm.auth.signup.initial_user_created\", {\n            messageArgs: {\n              _: \"Initial user successfully created\",\n            },\n          });\n          // FIXME: We should probably provide a hook for that in the ra-core package\n          queryClient.invalidateQueries({\n            queryKey: [\"auth\", \"canAccess\"],\n          });\n        })\n        .catch((err) => {\n          if (err.code === \"email_not_confirmed\") {\n            // An email confirmation is required to continue.\n            navigate(ConfirmationRequired.path);\n          } else {\n            notify(\"crm.auth.sign_in_failed\", {\n              type: \"error\",\n              messageArgs: {\n                _: \"Failed to log in.\",\n              },\n            });\n            navigate(\"/login\");\n          }\n        });\n    },\n    onError: (error) => {\n      notify(error.message);\n    },\n  });\n\n  const login = useLogin();\n  const notify = useNotify();\n\n  const {\n    register,\n    handleSubmit,\n    formState: { isValid },\n  } = useForm<SignUpData>({\n    mode: \"onChange\",\n  });\n\n  if (isPending) {\n    return <LoginSkeleton />;\n  }\n\n  // For the moment, we only allow one user to sign up. Other users must be created by the administrator.\n  if (isInitialized) {\n    return <Navigate to=\"/login\" />;\n  }\n\n  const onSubmit: SubmitHandler<SignUpData> = async (data) => {\n    mutate(data);\n  };\n\n  return (\n    <div className=\"h-screen p-8\">\n      <div className=\"flex items-center gap-4\">\n        <img\n          src={logo}\n          alt={title}\n          width={24}\n          className=\"filter brightness-0 dark:invert\"\n        />\n        <h1 className=\"text-xl font-semibold\">{title}</h1>\n      </div>\n      <div className=\"h-full\">\n        <div className=\"max-w-sm mx-auto h-full flex flex-col justify-center gap-4\">\n          <h1 className=\"text-2xl font-bold mb-4\">\n            {translate(\"crm.auth.welcome_title\", {\n              _: \"Welcome to Atomic CRM\",\n            })}\n          </h1>\n          <p className=\"text-base mb-4\">\n            {translate(\"crm.auth.signup.create_first_user\", {\n              _: \"Create the first user account to complete the setup.\",\n            })}\n          </p>\n          <form onSubmit={handleSubmit(onSubmit)} className=\"space-y-4\">\n            <div className=\"flex flex-col gap-2\">\n              <Label htmlFor=\"first_name\">\n                {translate(\"crm.auth.first_name\")}\n              </Label>\n              <Input\n                {...register(\"first_name\", { required: true })}\n                id=\"first_name\"\n                type=\"text\"\n                required\n              />\n            </div>\n            <div className=\"flex flex-col gap-2\">\n              <Label htmlFor=\"last_name\">\n                {translate(\"crm.auth.last_name\")}\n              </Label>\n              <Input\n                {...register(\"last_name\", { required: true })}\n                id=\"last_name\"\n                type=\"text\"\n                required\n              />\n            </div>\n            <div className=\"flex flex-col gap-2\">\n              <Label htmlFor=\"email\">{translate(\"ra.auth.email\")}</Label>\n              <Input\n                {...register(\"email\", { required: true })}\n                id=\"email\"\n                type=\"email\"\n                required\n              />\n            </div>\n            <div className=\"flex flex-col gap-2\">\n              <Label htmlFor=\"password\">{translate(\"ra.auth.password\")}</Label>\n              <Input\n                {...register(\"password\", { required: true })}\n                id=\"password\"\n                type=\"password\"\n                required\n              />\n            </div>\n            <div className=\"flex flex-col gap-4 justify-between items-center mt-8\">\n              <Button\n                type=\"submit\"\n                disabled={!isValid || isSignUpPending}\n                className=\"w-full\"\n              >\n                {isSignUpPending ? (\n                  <>\n                    <Loader2 className=\"w-4 h-4 animate-spin mr-2\" />\n                    {translate(\"crm.auth.signup.creating\", {\n                      _: \"Creating...\",\n                    })}\n                  </>\n                ) : (\n                  translate(\"crm.auth.signup.create_account\", {\n                    _: \"Create account\",\n                  })\n                )}\n              </Button>\n              {googleWorkplaceDomain ? (\n                <SSOAuthButton\n                  className=\"w-full\"\n                  domain={googleWorkplaceDomain}\n                >\n                  {translate(\"crm.auth.sign_in_google_workspace\", {\n                    _: \"Sign in with Google Workplace\",\n                  })}\n                </SSOAuthButton>\n              ) : null}\n            </div>\n          </form>\n        </div>\n      </div>\n      <Notification />\n    </div>\n  );\n};\n\nSignupPage.path = \"/sign-up\";\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/login/SSOAuthButton.tsx",
      "content": "import { useState, type MouseEvent, type ComponentProps } from \"react\";\nimport { useLogin, useNotify } from \"ra-core\";\nimport { Button } from \"@/components/ui/button\";\nimport { Spinner } from \"@/components/ui/spinner\";\n\nexport const SSOAuthButton = ({\n  children,\n  domain,\n  redirect: redirectTo,\n  ...props\n}: SSOAuthButtonProps) => {\n  const login = useLogin();\n  const notify = useNotify();\n  const [isPending, setIsPending] = useState(false);\n\n  const handleClick = (event: MouseEvent<HTMLButtonElement>) => {\n    event.preventDefault();\n    setIsPending(true);\n    login(\n      { ssoDomain: domain },\n      redirectTo ?? window.location.toString(),\n    ).catch((error) => {\n      setIsPending(false);\n      // The authProvide always reject for OAuth login but there will be no error\n      // if the call actually succeeds. This is to avoid react-admin redirecting\n      // immediately to the provided redirect prop before users are redirected to\n      // the OAuth provider.\n      if (error) {\n        notify(\n          typeof error === \"string\"\n            ? error\n            : typeof error === \"undefined\" || !error.message\n              ? \"ra.auth.sign_in_error\"\n              : error.message,\n          {\n            type: \"error\",\n            messageArgs: {\n              _:\n                typeof error === \"string\"\n                  ? error\n                  : error && error.message\n                    ? error.message\n                    : undefined,\n            },\n          },\n        );\n      }\n    });\n  };\n\n  return (\n    <Button type=\"button\" onClick={handleClick} disabled={isPending} {...props}>\n      {children}\n      {isPending ? (\n        <Spinner\n          className=\"text-primary-foreground size-4\"\n          data-icon=\"inline-start\"\n        />\n      ) : null}\n    </Button>\n  );\n};\n\nexport type SSOAuthButtonProps = {\n  domain: string;\n  redirect?: string;\n} & ComponentProps<typeof Button>;\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/login/LoginSkeleton.tsx",
      "content": "import { Skeleton } from \"@/components/ui/skeleton\";\n\nexport const LoginSkeleton = () => {\n  return (\n    <div className=\"max-w-screen-xl mx-auto h-screen pt-8\">\n      <div className=\"h-full\">\n        <div className=\"max-w-sm mx-auto h-full flex flex-col justify-center gap-8\">\n          <Skeleton className=\"w-full h-[100px]\" />\n          <Skeleton className=\"w-4/5 h-[50px]\" />\n          <Skeleton className=\"w-full h-9\" />\n          <Skeleton className=\"w-full h-9\" />\n          <Skeleton className=\"w-full h-9\" />\n          <Skeleton className=\"w-2/5 h-9\" />\n        </div>\n      </div>\n    </div>\n  );\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/login/LoginPage.tsx",
      "content": "import { useEffect, useRef, useState } from \"react\";\nimport { Form, required, useLogin, useNotify, useTranslate } from \"ra-core\";\nimport type { SubmitHandler, FieldValues } from \"react-hook-form\";\nimport { Link, useLocation, useNavigate } from \"react-router\";\nimport { Button } from \"@/components/ui/button\";\nimport { TextInput } from \"@/components/admin/text-input\";\nimport { Notification } from \"@/components/admin/notification\";\nimport { useConfigurationContext } from \"@/components/atomic-crm/root/ConfigurationContext.tsx\";\nimport { SSOAuthButton } from \"./SSOAuthButton\";\nimport {\n  disableEmailPasswordAuthentication,\n  googleWorkplaceDomain,\n} from \"./authConfig\";\n\n/**\n * Login page displayed when authentication is enabled and the user is not authenticated.\n *\n * Automatically shown when an unauthenticated user tries to access a protected route.\n * Handles login via authProvider.login() and displays error notifications on failure.\n *\n * @see {@link https://marmelab.com/shadcn-admin-kit/docs/loginpage LoginPage documentation}\n * @see {@link https://marmelab.com/shadcn-admin-kit/docs/security Security documentation}\n */\nexport const LoginPage = (props: { redirectTo?: string }) => {\n  const { darkModeLogo, title } = useConfigurationContext();\n  const { redirectTo } = props;\n  const [loading, setLoading] = useState(false);\n  const hasDisplayedRecoveryNotification = useRef(false);\n  const location = useLocation();\n  const navigate = useNavigate();\n  const login = useLogin();\n  const notify = useNotify();\n  const translate = useTranslate();\n\n  useEffect(() => {\n    const searchParams = new URLSearchParams(location.search);\n    const shouldNotify = searchParams.get(\"passwordRecoveryEmailSent\") === \"1\";\n\n    if (!shouldNotify || hasDisplayedRecoveryNotification.current) {\n      return;\n    }\n\n    hasDisplayedRecoveryNotification.current = true;\n    notify(\"crm.auth.recovery_email_sent\", {\n      type: \"success\",\n      messageArgs: {\n        _: \"If you're a registered user, you should receive a password recovery email shortly.\",\n      },\n    });\n\n    searchParams.delete(\"passwordRecoveryEmailSent\");\n    const nextSearch = searchParams.toString();\n    navigate(\n      {\n        pathname: location.pathname,\n        search: nextSearch ? `?${nextSearch}` : \"\",\n      },\n      { replace: true },\n    );\n  }, [location.pathname, location.search, navigate, notify]);\n\n  const handleSubmit: SubmitHandler<FieldValues> = (values) => {\n    setLoading(true);\n    login(values, redirectTo)\n      .then(() => {\n        setLoading(false);\n      })\n      .catch((error) => {\n        setLoading(false);\n        notify(\n          typeof error === \"string\"\n            ? error\n            : typeof error === \"undefined\" || !error.message\n              ? \"ra.auth.sign_in_error\"\n              : error.message,\n          {\n            type: \"error\",\n            messageArgs: {\n              _:\n                typeof error === \"string\"\n                  ? error\n                  : error && error.message\n                    ? error.message\n                    : undefined,\n            },\n          },\n        );\n      });\n  };\n\n  return (\n    <div className=\"min-h-screen flex\">\n      <div className=\"relative grid w-full lg:grid-cols-2\">\n        <div className=\"relative hidden h-full flex-col bg-muted p-10 text-white dark:border-r lg:flex\">\n          <div className=\"absolute inset-0 bg-zinc-900\" />\n          <div className=\"relative z-20 flex items-center text-lg font-medium\">\n            <img className=\"h-6 mr-2\" src={darkModeLogo} alt={title} />\n            {title}\n          </div>\n        </div>\n        <div className=\"flex flex-col justify-center w-full p-4 lg:p-8\">\n          <div className=\"w-full space-y-6 lg:mx-auto lg:w-[350px]\">\n            <div className=\"text-center\">\n              <h1 className=\"text-2xl font-semibold tracking-tight\">\n                {translate(\"ra.auth.sign_in\")}\n              </h1>\n            </div>\n            {disableEmailPasswordAuthentication ? null : (\n              <Form className=\"space-y-8\" onSubmit={handleSubmit}>\n                <TextInput\n                  label=\"ra.auth.email\"\n                  source=\"email\"\n                  type=\"email\"\n                  validate={required()}\n                />\n                <TextInput\n                  label=\"ra.auth.password\"\n                  source=\"password\"\n                  type=\"password\"\n                  validate={required()}\n                />\n                <div className=\"flex flex-col gap-4\">\n                  <Button\n                    type=\"submit\"\n                    className=\"cursor-pointer\"\n                    disabled={loading}\n                  >\n                    {translate(\"ra.auth.sign_in\")}\n                  </Button>\n                </div>\n              </Form>\n            )}\n            {googleWorkplaceDomain ? (\n              <SSOAuthButton className=\"w-full\" domain={googleWorkplaceDomain}>\n                {translate(\"crm.auth.sign_in_google_workspace\", {\n                  _: \"Sign in with Google Workplace\",\n                })}\n              </SSOAuthButton>\n            ) : null}\n            {disableEmailPasswordAuthentication ? null : (\n              <Link\n                to={\"/forgot-password\"}\n                className=\"block text-sm text-center hover:underline\"\n              >\n                {translate(\"ra-supabase.auth.forgot_password\", {\n                  _: \"Forgot password?\",\n                })}\n              </Link>\n            )}\n          </div>\n        </div>\n      </div>\n      <Notification />\n    </div>\n  );\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/login/ConfirmationRequired.tsx",
      "content": "import { Notification } from \"@/components/admin/notification\";\nimport { useTranslate } from \"ra-core\";\nimport { useConfigurationContext } from \"../root/ConfigurationContext\";\n\nexport const ConfirmationRequired = () => {\n  const translate = useTranslate();\n  const { darkModeLogo: logo, title } = useConfigurationContext();\n\n  return (\n    <div className=\"h-screen p-8\">\n      <div className=\"flex items-center gap-4\">\n        <img\n          src={logo}\n          alt={title}\n          width={24}\n          className=\"filter brightness-0 dark:invert\"\n        />\n        <h1 className=\"text-xl font-semibold\">{title}</h1>\n      </div>\n      <div className=\"h-full text-center\">\n        <div className=\"max-w-sm mx-auto h-full flex flex-col justify-center gap-4\">\n          <h1 className=\"text-2xl font-bold mb-4\">\n            {translate(\"crm.auth.welcome_title\", {\n              _: \"Welcome to Atomic CRM\",\n            })}\n          </h1>\n          <p className=\"text-base mb-4\">\n            {translate(\"crm.auth.confirmation_required\", {\n              _: \"Please follow the link we just sent you by email to confirm your account.\",\n            })}\n          </p>\n        </div>\n      </div>\n      <Notification />\n    </div>\n  );\n};\n\nConfirmationRequired.path = \"/sign-up/confirm\";\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/layout/TopToolbar.tsx",
      "content": "import { cn } from \"@/lib/utils\";\nimport type { HTMLAttributes, ReactNode } from \"react\";\n\nexport interface TopToolbarProps extends HTMLAttributes<HTMLDivElement> {\n  children?: ReactNode;\n}\n\nexport const TopToolbar = (inProps: TopToolbarProps) => {\n  const { className, children, ...props } = inProps;\n\n  return (\n    <div\n      className={cn(\n        \"flex flex-auto justify-end items-end gap-2 whitespace-nowrap\",\n        className,\n      )}\n      {...props}\n    >\n      {children}\n    </div>\n  );\n};\n\nexport default TopToolbar;\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/layout/MobileNavigation.tsx",
      "content": "import { Button } from \"@/components/ui/button\";\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport { cn } from \"@/lib/utils\";\nimport { Home, ListTodo, Plus, Settings, Users } from \"lucide-react\";\nimport { useTranslate } from \"ra-core\";\nimport { Link, matchPath, useLocation, useMatch } from \"react-router\";\nimport { ContactCreateSheet } from \"../contacts/ContactCreateSheet\";\nimport { useState } from \"react\";\nimport { NoteCreateSheet } from \"../notes/NoteCreateSheet\";\nimport { TaskCreateSheet } from \"../tasks/TaskCreateSheet\";\n\nexport const MobileNavigation = () => {\n  const location = useLocation();\n  const translate = useTranslate();\n\n  let currentPath: string | boolean = \"/\";\n  if (matchPath(\"/\", location.pathname)) {\n    currentPath = \"/\";\n  } else if (matchPath(\"/contacts/*\", location.pathname)) {\n    currentPath = \"/contacts\";\n  } else if (matchPath(\"/companies/*\", location.pathname)) {\n    currentPath = \"/companies\";\n  } else if (matchPath(\"/tasks/*\", location.pathname)) {\n    currentPath = \"/tasks\";\n  } else if (matchPath(\"/deals/*\", location.pathname)) {\n    currentPath = \"/deals\";\n  } else {\n    currentPath = false;\n  }\n\n  // Check if the app is running as a PWA (standalone mode)\n  const isPwa = window.matchMedia(\"(display-mode: standalone)\").matches;\n  // Check if it's iOS on the web\n  const isWebiOS = /iPad|iPod|iPhone/.test(window.navigator.userAgent);\n\n  return (\n    <nav\n      aria-label={translate(\"crm.navigation.label\")}\n      className=\"fixed bottom-0 left-0 right-0 z-50 bg-secondary h-14\"\n      style={{\n        // iOS bug: even though viewport is set correctly, the bottom safe area inset is not accounted for\n        // So we manually add some padding to avoid the navigation being too close to the home bar\n        paddingBottom: isPwa && isWebiOS ? 15 : undefined,\n        // We use box-sizing: border-box, so the height contains the padding.\n        // To actually increase the padding, we need to increase the height as well\n        height:\n          \"calc(var(--spacing)) * 6\" + (isPwa && isWebiOS ? \" + 15px\" : \"\"),\n      }}\n    >\n      <div className=\"flex justify-center\">\n        <>\n          <NavigationButton\n            href=\"/\"\n            Icon={Home}\n            label={translate(\"ra.page.dashboard\")}\n            isActive={currentPath === \"/\"}\n          />\n          <NavigationButton\n            href=\"/contacts\"\n            Icon={Users}\n            label={translate(\"resources.contacts.name\", {\n              smart_count: 2,\n            })}\n            isActive={currentPath === \"/contacts\"}\n          />\n          <CreateButton />\n          <NavigationButton\n            href=\"/tasks\"\n            Icon={ListTodo}\n            label={translate(\"resources.tasks.name\", { smart_count: 2 })}\n            isActive={currentPath === \"/tasks\"}\n          />\n          <SettingsButton />\n        </>\n      </div>\n    </nav>\n  );\n};\n\nconst NavigationButton = ({\n  href,\n  Icon,\n  label,\n  isActive,\n}: {\n  href: string;\n  Icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;\n  label: string;\n  isActive: boolean;\n}) => (\n  <Button\n    asChild\n    variant=\"ghost\"\n    className={cn(\n      \"flex-col gap-1 h-auto py-2 px-1 rounded-md w-16\",\n      isActive ? null : \"text-muted-foreground\",\n    )}\n  >\n    <Link to={href}>\n      <Icon className=\"size-6\" />\n      <span className=\"text-[0.6rem] font-medium\">{label}</span>\n    </Link>\n  </Button>\n);\n\nconst CreateButton = () => {\n  const translate = useTranslate();\n  const contact_id = useMatch(\"/contacts/:id/*\")?.params.id;\n  const [contactCreateOpen, setContactCreateOpen] = useState(false);\n  const [noteCreateOpen, setNoteCreateOpen] = useState(false);\n  const [taskCreateOpen, setTaskCreateOpen] = useState(false);\n\n  return (\n    <>\n      <ContactCreateSheet\n        open={contactCreateOpen}\n        onOpenChange={setContactCreateOpen}\n      />\n      <NoteCreateSheet\n        open={noteCreateOpen}\n        onOpenChange={setNoteCreateOpen}\n        contact_id={contact_id}\n      />\n      <TaskCreateSheet\n        open={taskCreateOpen}\n        onOpenChange={setTaskCreateOpen}\n        contact_id={contact_id}\n      />\n      <DropdownMenu>\n        <DropdownMenuTrigger asChild>\n          <Button\n            variant=\"default\"\n            size=\"icon\"\n            className=\"h-16 w-16 rounded-full -mt-3\"\n            aria-label={translate(\"ra.action.create\")}\n          >\n            <Plus className=\"size-10\" />\n          </Button>\n        </DropdownMenuTrigger>\n        <DropdownMenuContent>\n          <DropdownMenuItem\n            className=\"h-12 px-4 text-base\"\n            onSelect={() => {\n              setContactCreateOpen(true);\n            }}\n          >\n            {translate(\"resources.contacts.forcedCaseName\")}\n          </DropdownMenuItem>\n          <DropdownMenuItem\n            className=\"h-12 px-4 text-base\"\n            onSelect={() => {\n              setNoteCreateOpen(true);\n            }}\n          >\n            {translate(\"resources.notes.forcedCaseName\")}\n          </DropdownMenuItem>\n          <DropdownMenuItem\n            className=\"h-12 px-4 text-base\"\n            onSelect={() => {\n              setTaskCreateOpen(true);\n            }}\n          >\n            {translate(\"resources.tasks.forcedCaseName\")}\n          </DropdownMenuItem>\n        </DropdownMenuContent>\n      </DropdownMenu>\n    </>\n  );\n};\n\nconst SettingsButton = () => {\n  const translate = useTranslate();\n  const location = useLocation();\n  const isActive = !!matchPath(\"/settings\", location.pathname);\n\n  return (\n    <NavigationButton\n      href=\"/settings\"\n      Icon={Settings}\n      label={translate(\"crm.settings.title\")}\n      isActive={isActive}\n    />\n  );\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/layout/MobileLayout.tsx",
      "content": "import { Error } from \"@/components/admin/error\";\nimport { Notification } from \"@/components/admin/notification\";\nimport { Skeleton } from \"@/components/ui/skeleton\";\nimport { Suspense, type ReactNode } from \"react\";\nimport { ErrorBoundary } from \"react-error-boundary\";\n\nimport { useConfigurationLoader } from \"../root/useConfigurationLoader\";\nimport { MobileNavigation } from \"./MobileNavigation\";\n\nexport const MobileLayout = ({ children }: { children: ReactNode }) => {\n  useConfigurationLoader();\n  return (\n    <>\n      <ErrorBoundary FallbackComponent={Error}>\n        <Suspense fallback={<Skeleton className=\"h-12 w-12 rounded-full\" />}>\n          {children}\n        </Suspense>\n      </ErrorBoundary>\n      <MobileNavigation />\n      <Notification mobileOffset={{ bottom: \"72px\" }} />\n    </>\n  );\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/layout/MobileHeader.tsx",
      "content": "const MobileHeader = ({ children }: { children: React.ReactNode }) => {\n  return (\n    <header className=\"fixed top-0 left-0 right-0 z-10 bg-secondary h-14 px-4 w-full flex justify-between items-center\">\n      {children}\n    </header>\n  );\n};\n\nexport default MobileHeader;\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/layout/MobileContent.tsx",
      "content": "import { type ReactNode } from \"react\";\n\nexport const MobileContent = ({ children }: { children: ReactNode }) => (\n  <main\n    className=\"max-w-screen-xl mx-auto pt-18 px-4 pb-20 min-h-screen overflow-y-auto\"\n    id=\"main-content\"\n  >\n    {children}\n  </main>\n);\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/layout/Layout.tsx",
      "content": "import { Suspense, type ReactNode } from \"react\";\nimport { ErrorBoundary } from \"react-error-boundary\";\nimport { Notification } from \"@/components/admin/notification\";\nimport { Error } from \"@/components/admin/error\";\nimport { Skeleton } from \"@/components/ui/skeleton\";\n\nimport { useConfigurationLoader } from \"../root/useConfigurationLoader\";\nimport Header from \"./Header\";\n\nexport const Layout = ({ children }: { children: ReactNode }) => {\n  useConfigurationLoader();\n  return (\n    <>\n      <Header />\n      <main className=\"max-w-screen-xl mx-auto pt-4 px-4\" id=\"main-content\">\n        <ErrorBoundary FallbackComponent={Error}>\n          <Suspense fallback={<Skeleton className=\"h-12 w-12 rounded-full\" />}>\n            {children}\n          </Suspense>\n        </ErrorBoundary>\n      </main>\n      <Notification />\n    </>\n  );\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/layout/Header.tsx",
      "content": "import { FileText, Import, Settings, User, Users } from \"lucide-react\";\nimport { CanAccess, useTranslate, useUserMenu } from \"ra-core\";\nimport { Link, matchPath, useLocation } from \"react-router\";\nimport { RefreshButton } from \"@/components/admin/refresh-button\";\nimport { ThemeModeToggle } from \"@/components/admin/theme-mode-toggle\";\nimport { UserMenu } from \"@/components/admin/user-menu\";\nimport { DropdownMenuItem } from \"@/components/ui/dropdown-menu\";\n\nimport { useConfigurationContext } from \"../root/ConfigurationContext\";\nimport { ImportPage } from \"../misc/ImportPage\";\nimport { ChangelogPage } from \"../misc/ChangelogPage\";\n\nconst Header = () => {\n  const { darkModeLogo, lightModeLogo, title } = useConfigurationContext();\n  const location = useLocation();\n  const translate = useTranslate();\n\n  let currentPath: string | boolean = \"/\";\n  if (matchPath(\"/\", location.pathname)) {\n    currentPath = \"/\";\n  } else if (matchPath(\"/contacts/*\", location.pathname)) {\n    currentPath = \"/contacts\";\n  } else if (matchPath(\"/companies/*\", location.pathname)) {\n    currentPath = \"/companies\";\n  } else if (matchPath(\"/deals/*\", location.pathname)) {\n    currentPath = \"/deals\";\n  } else {\n    currentPath = false;\n  }\n\n  return (\n    <>\n      <nav className=\"grow\">\n        <header className=\"bg-secondary\">\n          <div className=\"px-4\">\n            <div className=\"flex justify-between items-center flex-1\">\n              <Link\n                to=\"/\"\n                className=\"flex items-center gap-2 text-secondary-foreground no-underline\"\n              >\n                <img\n                  className=\"[.light_&]:hidden h-6\"\n                  src={darkModeLogo}\n                  alt={title}\n                />\n                <img\n                  className=\"[.dark_&]:hidden h-6\"\n                  src={lightModeLogo}\n                  alt={title}\n                />\n                <h1 className=\"text-xl font-semibold\">{title}</h1>\n              </Link>\n              <div>\n                <nav className=\"flex\">\n                  <NavigationTab\n                    label={translate(\"ra.page.dashboard\")}\n                    to=\"/\"\n                    isActive={currentPath === \"/\"}\n                  />\n                  <NavigationTab\n                    label={translate(\"resources.contacts.name\", {\n                      smart_count: 2,\n                    })}\n                    to=\"/contacts\"\n                    isActive={currentPath === \"/contacts\"}\n                  />\n                  <NavigationTab\n                    label={translate(\"resources.companies.name\", {\n                      smart_count: 2,\n                    })}\n                    to=\"/companies\"\n                    isActive={currentPath === \"/companies\"}\n                  />\n                  <NavigationTab\n                    label={translate(\"resources.deals.name\", {\n                      smart_count: 2,\n                    })}\n                    to=\"/deals\"\n                    isActive={currentPath === \"/deals\"}\n                  />\n                </nav>\n              </div>\n              <div className=\"flex items-center\">\n                <ThemeModeToggle />\n                <RefreshButton />\n                <UserMenu>\n                  <ProfileMenu />\n                  <CanAccess resource=\"sales\" action=\"list\">\n                    <UsersMenu />\n                  </CanAccess>\n                  <CanAccess resource=\"configuration\" action=\"edit\">\n                    <SettingsMenu />\n                  </CanAccess>\n                  <ImportFromJsonMenuItem />\n                  <ChangelogMenuItem />\n                </UserMenu>\n              </div>\n            </div>\n          </div>\n        </header>\n      </nav>\n    </>\n  );\n};\n\nconst NavigationTab = ({\n  label,\n  to,\n  isActive,\n}: {\n  label: string;\n  to: string;\n  isActive: boolean;\n}) => (\n  <Link\n    to={to}\n    className={`px-6 py-3 text-sm font-medium transition-colors border-b-2 ${\n      isActive\n        ? \"text-secondary-foreground border-secondary-foreground\"\n        : \"text-secondary-foreground/70 border-transparent hover:text-secondary-foreground/80\"\n    }`}\n  >\n    {label}\n  </Link>\n);\n\nconst UsersMenu = () => {\n  const translate = useTranslate();\n  const userMenuContext = useUserMenu();\n  if (!userMenuContext) {\n    throw new Error(\"<UsersMenu> must be used inside <UserMenu?\");\n  }\n  return (\n    <DropdownMenuItem asChild onClick={userMenuContext.onClose}>\n      <Link to=\"/sales\" className=\"flex items-center gap-2\">\n        <Users />\n        {translate(\"resources.sales.name\", { smart_count: 2 })}\n      </Link>\n    </DropdownMenuItem>\n  );\n};\n\nconst ProfileMenu = () => {\n  const translate = useTranslate();\n  const userMenuContext = useUserMenu();\n  if (!userMenuContext) {\n    throw new Error(\"<ProfileMenu> must be used inside <UserMenu?\");\n  }\n  return (\n    <DropdownMenuItem asChild onClick={userMenuContext.onClose}>\n      <Link to=\"/profile\" className=\"flex items-center gap-2\">\n        <User />\n        {translate(\"crm.profile.title\")}\n      </Link>\n    </DropdownMenuItem>\n  );\n};\n\nconst SettingsMenu = () => {\n  const translate = useTranslate();\n  const userMenuContext = useUserMenu();\n  if (!userMenuContext) {\n    throw new Error(\"<SettingsMenu> must be used inside <UserMenu>\");\n  }\n  return (\n    <DropdownMenuItem asChild onClick={userMenuContext.onClose}>\n      <Link to=\"/settings\" className=\"flex items-center gap-2\">\n        <Settings />\n        {translate(\"crm.settings.title\")}\n      </Link>\n    </DropdownMenuItem>\n  );\n};\n\nconst ImportFromJsonMenuItem = () => {\n  const translate = useTranslate();\n  const userMenuContext = useUserMenu();\n  if (!userMenuContext) {\n    throw new Error(\"<ImportFromJsonMenuItem> must be used inside <UserMenu>\");\n  }\n  return (\n    <DropdownMenuItem asChild onClick={userMenuContext.onClose}>\n      <Link to={ImportPage.path} className=\"flex items-center gap-2\">\n        <Import />\n        {translate(\"crm.header.import_data\")}\n      </Link>\n    </DropdownMenuItem>\n  );\n};\n\nconst ChangelogMenuItem = () => {\n  const translate = useTranslate();\n  const userMenuContext = useUserMenu();\n  if (!userMenuContext) {\n    throw new Error(\"<ChangelogMenuItem> must be used inside <UserMenu>\");\n  }\n  return (\n    <DropdownMenuItem asChild onClick={userMenuContext.onClose}>\n      <Link to={ChangelogPage.path} className=\"flex items-center gap-2\">\n        <FileText />\n        {translate(\"crm.changelog.title\")}\n      </Link>\n    </DropdownMenuItem>\n  );\n};\nexport default Header;\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/layout/FormToolbar.tsx",
      "content": "import { CancelButton } from \"@/components/admin/cancel-button\";\nimport { SaveButton } from \"@/components/admin/form\";\n\nexport const FormToolbar = () => (\n  <div\n    role=\"toolbar\"\n    className=\"sticky flex pt-4 pb-4 md:pb-0 bottom-0 bg-linear-to-b from-transparent to-card to-10% flex-row justify-end gap-2\"\n  >\n    <CancelButton />\n    <SaveButton />\n  </div>\n);\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/filters/FilterCategory.tsx",
      "content": "import { Translate } from \"ra-core\";\nimport type { ReactNode } from \"react\";\n\nexport const FilterCategory = ({\n  icon,\n  label,\n  children,\n}: {\n  icon: ReactNode;\n  label: string;\n  children?: ReactNode;\n}) => (\n  <div className=\"flex flex-col gap-2\">\n    <h3 className=\"flex flex-row items-center gap-2 font-bold text-sm\">\n      {icon}\n      <Translate i18nKey={label} />\n    </h3>\n    <div className=\"flex md:flex-col flex-wrap items-start pl-4\">\n      {children}\n    </div>\n  </div>\n);\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/deals/stages.ts",
      "content": "import type { ConfigurationContextValue } from \"../root/ConfigurationContext\";\nimport type { Deal } from \"../types\";\n\nexport type DealsByStage = Record<Deal[\"stage\"], Deal[]>;\n\nexport const getDealsByStage = (\n  unorderedDeals: Deal[],\n  dealStages: ConfigurationContextValue[\"dealStages\"],\n) => {\n  if (!dealStages) return {};\n  const dealsByStage: Record<Deal[\"stage\"], Deal[]> = unorderedDeals.reduce(\n    (acc, deal) => {\n      // if deal has a stage that does not exist in configuration, assign it to the first stage\n      const stage = dealStages.find((s) => s.value === deal.stage)\n        ? deal.stage\n        : dealStages[0].value;\n      acc[stage].push(deal);\n      return acc;\n    },\n    dealStages.reduce(\n      (obj, stage) => ({ ...obj, [stage.value]: [] }),\n      {} as Record<Deal[\"stage\"], Deal[]>,\n    ),\n  );\n  // order each column by index\n  dealStages.forEach((stage) => {\n    dealsByStage[stage.value] = dealsByStage[stage.value].sort(\n      (recordA: Deal, recordB: Deal) => recordA.index - recordB.index,\n    );\n  });\n  return dealsByStage;\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/deals/index.ts",
      "content": "import React from \"react\";\n\nconst DealList = React.lazy(() => import(\"./DealList\"));\n\nexport default {\n  list: DealList,\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/deals/dealUtils.ts",
      "content": "import { format } from \"date-fns\";\n\nimport type { DealStage } from \"../types\";\n\nexport const findDealLabel = (dealStages: DealStage[], dealValue: string) => {\n  const dealStage = dealStages.find((stage) => stage.value === dealValue);\n  return dealStage?.label;\n};\n\nexport function getRelativeTimeString(\n  dateString: string,\n  locale = \"en\",\n): string {\n  const date = new Date(dateString);\n  date.setHours(0, 0, 0, 0);\n\n  const today = new Date();\n  today.setHours(0, 0, 0, 0);\n\n  const diff = date.getTime() - today.getTime();\n  const unitDiff = Math.round(diff / (1000 * 60 * 60 * 24));\n\n  // Check if the date is more than one week old\n  if (Math.abs(unitDiff) > 7) {\n    return new Intl.DateTimeFormat(locale, {\n      day: \"numeric\",\n      month: \"long\",\n    }).format(date);\n  }\n\n  // Intl.RelativeTimeFormat for dates within the last week\n  const rtf = new Intl.RelativeTimeFormat(locale, { numeric: \"auto\" });\n  return ucFirst(rtf.format(unitDiff, \"day\"));\n}\n\nfunction ucFirst(str: string): string {\n  return str.charAt(0).toUpperCase() + str.slice(1);\n}\n\nconst isoDateStringRegex = /^\\d{4}-\\d{2}-\\d{2}$/;\n\nexport function formatISODateString(dateString: string) {\n  if (!isoDateStringRegex.test(dateString)) {\n    throw new Error(\"Invalid date format. Expected YYYY-MM-DD.\");\n  }\n  // Some browsers will consider a date in the format YYYY-MM-DD as UTC, which can cause off-by-one-day issues depending on the user's timezone.\n  // To avoid this, we can parse the date components manually and create a date object in the local timezone.\n  const [year, month, day] = dateString.split(\"-\").map(Number);\n  const date = new Date(year, month - 1, day);\n\n  return format(date, \"PP\");\n}\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/deals/OnlyMineInput.tsx",
      "content": "import { useGetIdentity, useListFilterContext, useTranslate } from \"ra-core\";\nimport { Label } from \"@/components/ui/label\";\nimport { Switch } from \"@/components/ui/switch\";\n\nexport const OnlyMineInput = (_: { alwaysOn: boolean; source: string }) => {\n  const translate = useTranslate();\n  const { filterValues, displayedFilters, setFilters } = useListFilterContext();\n  const { identity } = useGetIdentity();\n\n  const handleChange = () => {\n    const newFilterValues = { ...filterValues };\n    if (typeof filterValues.sales_id !== \"undefined\") {\n      delete newFilterValues.sales_id;\n    } else {\n      newFilterValues.sales_id = identity && identity?.id;\n    }\n    setFilters(newFilterValues, displayedFilters);\n  };\n  return (\n    <div className=\"mt-auto pb-2.25\">\n      <div className=\"flex items-center space-x-2\">\n        <Switch\n          id=\"only-mine\"\n          checked={typeof filterValues.sales_id !== \"undefined\"}\n          onCheckedChange={handleChange}\n        />\n        <Label htmlFor=\"only-mine\">\n          {translate(\"resources.companies.filters.only_mine\", {\n            _: \"Only companies I manage\",\n          })}\n        </Label>\n      </div>\n    </div>\n  );\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/deals/DealShow.tsx",
      "content": "import { useMutation } from \"@tanstack/react-query\";\nimport { isValid } from \"date-fns\";\nimport { Archive, ArchiveRestore } from \"lucide-react\";\nimport {\n  InfiniteListBase,\n  ShowBase,\n  useDataProvider,\n  useNotify,\n  useRecordContext,\n  useRedirect,\n  useRefresh,\n  useTranslate,\n  useUpdate,\n} from \"ra-core\";\nimport { DeleteButton } from \"@/components/admin/delete-button\";\nimport { EditButton } from \"@/components/admin/edit-button\";\nimport { ReferenceArrayField } from \"@/components/admin/reference-array-field\";\nimport { ReferenceField } from \"@/components/admin/reference-field\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { Button } from \"@/components/ui/button\";\nimport { Dialog, DialogContent } from \"@/components/ui/dialog\";\nimport { Separator } from \"@/components/ui/separator\";\n\nimport { CompanyAvatar } from \"../companies/CompanyAvatar\";\nimport { NoteCreate } from \"../notes/NoteCreate\";\nimport { NotesIterator } from \"../notes/NotesIterator\";\nimport { useConfigurationContext } from \"../root/ConfigurationContext\";\nimport type { Deal } from \"../types\";\nimport { ContactList } from \"./ContactList\";\nimport { findDealLabel, formatISODateString } from \"./dealUtils\";\n\nexport const DealShow = ({ open, id }: { open: boolean; id?: string }) => {\n  const redirect = useRedirect();\n  const handleClose = () => {\n    redirect(\"list\", \"deals\");\n  };\n\n  return (\n    <Dialog open={open} onOpenChange={(open) => !open && handleClose()}>\n      <DialogContent className=\"lg:max-w-4xl p-4 overflow-y-auto max-h-9/10 top-1/20 translate-y-0\">\n        {id ? (\n          <ShowBase id={id}>\n            <DealShowContent />\n          </ShowBase>\n        ) : null}\n      </DialogContent>\n    </Dialog>\n  );\n};\n\nconst DealShowContent = () => {\n  const translate = useTranslate();\n  const { dealStages, dealCategories, currency } = useConfigurationContext();\n  const record = useRecordContext<Deal>();\n  if (!record) return null;\n\n  return (\n    <>\n      <div className=\"space-y-2\">\n        {record.archived_at ? <ArchivedTitle /> : null}\n        <div className=\"flex-1\">\n          <div className=\"flex justify-between items-start mb-8\">\n            <div className=\"flex items-center gap-4\">\n              <ReferenceField\n                source=\"company_id\"\n                reference=\"companies\"\n                link=\"show\"\n              >\n                <CompanyAvatar />\n              </ReferenceField>\n              <h2 className=\"text-2xl font-semibold\">{record.name}</h2>\n            </div>\n            <div className={`flex gap-2 ${record.archived_at ? \"\" : \"pr-12\"}`}>\n              {record.archived_at ? (\n                <>\n                  <UnarchiveButton record={record} />\n                  <DeleteButton />\n                </>\n              ) : (\n                <>\n                  <ArchiveButton record={record} />\n                  <EditButton />\n                </>\n              )}\n            </div>\n          </div>\n\n          <div className=\"flex gap-8 m-4\">\n            <div className=\"flex flex-col mr-10\">\n              <span className=\"text-xs text-muted-foreground tracking-wide\">\n                {translate(\"resources.deals.fields.expected_closing_date\")}\n              </span>\n              <div className=\"flex items-center gap-2\">\n                <span className=\"text-sm\">\n                  {isValid(new Date(record.expected_closing_date))\n                    ? formatISODateString(record.expected_closing_date)\n                    : translate(\"resources.deals.invalid_date\")}\n                </span>\n                {new Date(record.expected_closing_date) < new Date() ? (\n                  <Badge variant=\"destructive\">\n                    {translate(\"crm.common.past\")}\n                  </Badge>\n                ) : null}\n              </div>\n            </div>\n\n            <div className=\"flex flex-col mr-10\">\n              <span className=\"text-xs text-muted-foreground tracking-wide\">\n                {translate(\"resources.deals.fields.amount\")}\n              </span>\n              <span className=\"text-sm\">\n                {record.amount.toLocaleString(\"en-US\", {\n                  notation: \"compact\",\n                  style: \"currency\",\n                  currency,\n                  currencyDisplay: \"narrowSymbol\",\n                  minimumSignificantDigits: 3,\n                })}\n              </span>\n            </div>\n\n            {record.category && (\n              <div className=\"flex flex-col mr-10\">\n                <span className=\"text-xs text-muted-foreground tracking-wide\">\n                  {translate(\"resources.deals.fields.category\")}\n                </span>\n                <span className=\"text-sm\">\n                  {dealCategories.find((c) => c.value === record.category)\n                    ?.label ?? record.category}\n                </span>\n              </div>\n            )}\n\n            <div className=\"flex flex-col mr-10\">\n              <span className=\"text-xs text-muted-foreground tracking-wide\">\n                {translate(\"resources.deals.fields.stage\")}\n              </span>\n              <span className=\"text-sm\">\n                {findDealLabel(dealStages, record.stage)}\n              </span>\n            </div>\n          </div>\n\n          {!!record.contact_ids?.length && (\n            <div className=\"m-4\">\n              <div className=\"flex flex-col min-h-12 mr-10\">\n                <span className=\"text-xs text-muted-foreground tracking-wide\">\n                  {translate(\"resources.deals.fields.contact_ids\")}\n                </span>\n                <ReferenceArrayField\n                  source=\"contact_ids\"\n                  reference=\"contacts_summary\"\n                >\n                  <ContactList />\n                </ReferenceArrayField>\n              </div>\n            </div>\n          )}\n\n          {record.description && (\n            <div className=\"m-4 whitespace-pre-line\">\n              <span className=\"text-xs text-muted-foreground tracking-wide\">\n                {translate(\"resources.deals.fields.description\")}\n              </span>\n              <p className=\"text-sm leading-6\">{record.description}</p>\n            </div>\n          )}\n\n          <div className=\"m-4\">\n            <Separator className=\"mb-4\" />\n            <InfiniteListBase\n              resource=\"deal_notes\"\n              filter={{ deal_id: record.id }}\n              sort={{ field: \"date\", order: \"DESC\" }}\n              perPage={25}\n              disableSyncWithLocation\n              storeKey={false}\n              empty={<NoteCreate reference={\"deals\"} />}\n            >\n              <NotesIterator reference=\"deals\" />\n            </InfiniteListBase>\n          </div>\n        </div>\n      </div>\n    </>\n  );\n};\n\nconst ArchivedTitle = () => {\n  const translate = useTranslate();\n  return (\n    <div className=\"bg-orange-500 px-6 py-4\">\n      <h3 className=\"text-lg font-bold text-white\">\n        {translate(\"resources.deals.archived.title\")}\n      </h3>\n    </div>\n  );\n};\n\nconst ArchiveButton = ({ record }: { record: Deal }) => {\n  const translate = useTranslate();\n  const [update] = useUpdate();\n  const redirect = useRedirect();\n  const notify = useNotify();\n  const refresh = useRefresh();\n  const handleClick = () => {\n    update(\n      \"deals\",\n      {\n        id: record.id,\n        data: { archived_at: new Date().toISOString() },\n        previousData: record,\n      },\n      {\n        onSuccess: () => {\n          redirect(\"list\", \"deals\");\n          notify(\"resources.deals.archived.success\", {\n            type: \"info\",\n            undoable: false,\n          });\n          refresh();\n        },\n        onError: () => {\n          notify(\"resources.deals.archived.error\", {\n            type: \"error\",\n          });\n        },\n      },\n    );\n  };\n\n  return (\n    <Button\n      onClick={handleClick}\n      size=\"sm\"\n      variant=\"outline\"\n      className=\"flex items-center gap-2 h-9\"\n    >\n      <Archive className=\"w-4 h-4\" />\n      {translate(\"resources.deals.archived.action\")}\n    </Button>\n  );\n};\n\nconst UnarchiveButton = ({ record }: { record: Deal }) => {\n  const translate = useTranslate();\n  const dataProvider = useDataProvider();\n  const redirect = useRedirect();\n  const notify = useNotify();\n  const refresh = useRefresh();\n\n  const { mutate } = useMutation({\n    mutationFn: () => dataProvider.unarchiveDeal(record),\n    onSuccess: () => {\n      redirect(\"list\", \"deals\");\n      notify(\"resources.deals.unarchived.success\", {\n        type: \"info\",\n        undoable: false,\n      });\n      refresh();\n    },\n    onError: () => {\n      notify(\"resources.deals.unarchived.error\", {\n        type: \"error\",\n      });\n    },\n  });\n\n  const handleClick = () => {\n    mutate();\n  };\n\n  return (\n    <Button\n      onClick={handleClick}\n      size=\"sm\"\n      variant=\"outline\"\n      className=\"flex items-center gap-2 h-9\"\n    >\n      <ArchiveRestore className=\"w-4 h-4\" />\n      {translate(\"resources.deals.unarchived.action\")}\n    </Button>\n  );\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/deals/DealListContent.tsx",
      "content": "import { DragDropContext, type OnDragEndResponder } from \"@hello-pangea/dnd\";\nimport isEqual from \"lodash/isEqual\";\nimport { useDataProvider, useListContext, type DataProvider } from \"ra-core\";\nimport { useEffect, useState } from \"react\";\n\nimport { useConfigurationContext } from \"../root/ConfigurationContext\";\nimport type { Deal } from \"../types\";\nimport { DealColumn } from \"./DealColumn\";\nimport type { DealsByStage } from \"./stages\";\nimport { getDealsByStage } from \"./stages\";\n\nexport const DealListContent = () => {\n  const { dealStages } = useConfigurationContext();\n  const { data: unorderedDeals, isPending, refetch } = useListContext<Deal>();\n  const dataProvider = useDataProvider();\n\n  const [dealsByStage, setDealsByStage] = useState<DealsByStage>(\n    getDealsByStage([], dealStages),\n  );\n\n  useEffect(() => {\n    if (unorderedDeals) {\n      const newDealsByStage = getDealsByStage(unorderedDeals, dealStages);\n      if (!isEqual(newDealsByStage, dealsByStage)) {\n        setDealsByStage(newDealsByStage);\n      }\n    }\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [unorderedDeals]);\n\n  if (isPending) return null;\n\n  const onDragEnd: OnDragEndResponder = (result) => {\n    const { destination, source } = result;\n\n    if (!destination) {\n      return;\n    }\n\n    if (\n      destination.droppableId === source.droppableId &&\n      destination.index === source.index\n    ) {\n      return;\n    }\n\n    const sourceStage = source.droppableId;\n    const destinationStage = destination.droppableId;\n    const sourceDeal = dealsByStage[sourceStage][source.index]!;\n    const destinationDeal = dealsByStage[destinationStage][\n      destination.index\n    ] ?? {\n      stage: destinationStage,\n      index: undefined, // undefined if dropped after the last item\n    };\n\n    // compute local state change synchronously\n    setDealsByStage(\n      updateDealStageLocal(\n        sourceDeal,\n        { stage: sourceStage, index: source.index },\n        { stage: destinationStage, index: destination.index },\n        dealsByStage,\n      ),\n    );\n\n    // persist the changes\n    updateDealStage(sourceDeal, destinationDeal, dataProvider).then(() => {\n      refetch();\n    });\n  };\n\n  return (\n    <DragDropContext onDragEnd={onDragEnd}>\n      <div className=\"flex gap-4\">\n        {dealStages.map((stage) => (\n          <DealColumn\n            stage={stage.value}\n            deals={dealsByStage[stage.value]}\n            key={stage.value}\n          />\n        ))}\n      </div>\n    </DragDropContext>\n  );\n};\n\nconst updateDealStageLocal = (\n  sourceDeal: Deal,\n  source: { stage: string; index: number },\n  destination: {\n    stage: string;\n    index?: number; // undefined if dropped after the last item\n  },\n  dealsByStage: DealsByStage,\n) => {\n  if (source.stage === destination.stage) {\n    // moving deal inside the same column\n    const column = dealsByStage[source.stage];\n    column.splice(source.index, 1);\n    column.splice(destination.index ?? column.length + 1, 0, sourceDeal);\n    return {\n      ...dealsByStage,\n      [destination.stage]: column,\n    };\n  } else {\n    // moving deal across columns\n    const sourceColumn = dealsByStage[source.stage];\n    const destinationColumn = dealsByStage[destination.stage];\n    sourceColumn.splice(source.index, 1);\n    destinationColumn.splice(\n      destination.index ?? destinationColumn.length + 1,\n      0,\n      sourceDeal,\n    );\n    return {\n      ...dealsByStage,\n      [source.stage]: sourceColumn,\n      [destination.stage]: destinationColumn,\n    };\n  }\n};\n\nconst updateDealStage = async (\n  source: Deal,\n  destination: {\n    stage: string;\n    index?: number; // undefined if dropped after the last item\n  },\n  dataProvider: DataProvider,\n) => {\n  if (source.stage === destination.stage) {\n    // moving deal inside the same column\n    // Fetch all the deals in this stage (because the list may be filtered, but we need to update even non-filtered deals)\n    const { data: columnDeals } = await dataProvider.getList(\"deals\", {\n      sort: { field: \"index\", order: \"ASC\" },\n      pagination: { page: 1, perPage: 100 },\n      filter: { stage: source.stage },\n    });\n    const destinationIndex = destination.index ?? columnDeals.length + 1;\n\n    if (source.index > destinationIndex) {\n      // deal moved up, eg\n      // dest   src\n      //  <------\n      // [4, 7, 23, 5]\n      await Promise.all([\n        // for all deals between destinationIndex and source.index, increase the index\n        ...columnDeals\n          .filter(\n            (deal) =>\n              deal.index >= destinationIndex && deal.index < source.index,\n          )\n          .map((deal) =>\n            dataProvider.update(\"deals\", {\n              id: deal.id,\n              data: { index: deal.index + 1 },\n              previousData: deal,\n            }),\n          ),\n        // for the deal that was moved, update its index\n        dataProvider.update(\"deals\", {\n          id: source.id,\n          data: { index: destinationIndex },\n          previousData: source,\n        }),\n      ]);\n    } else {\n      // deal moved down, e.g\n      // src   dest\n      //  ------>\n      // [4, 7, 23, 5]\n      await Promise.all([\n        // for all deals between source.index and destinationIndex, decrease the index\n        ...columnDeals\n          .filter(\n            (deal) =>\n              deal.index <= destinationIndex && deal.index > source.index,\n          )\n          .map((deal) =>\n            dataProvider.update(\"deals\", {\n              id: deal.id,\n              data: { index: deal.index - 1 },\n              previousData: deal,\n            }),\n          ),\n        // for the deal that was moved, update its index\n        dataProvider.update(\"deals\", {\n          id: source.id,\n          data: { index: destinationIndex },\n          previousData: source,\n        }),\n      ]);\n    }\n  } else {\n    // moving deal across columns\n    // Fetch all the deals in both stages (because the list may be filtered, but we need to update even non-filtered deals)\n    const [{ data: sourceDeals }, { data: destinationDeals }] =\n      await Promise.all([\n        dataProvider.getList(\"deals\", {\n          sort: { field: \"index\", order: \"ASC\" },\n          pagination: { page: 1, perPage: 100 },\n          filter: { stage: source.stage },\n        }),\n        dataProvider.getList(\"deals\", {\n          sort: { field: \"index\", order: \"ASC\" },\n          pagination: { page: 1, perPage: 100 },\n          filter: { stage: destination.stage },\n        }),\n      ]);\n    const destinationIndex = destination.index ?? destinationDeals.length + 1;\n\n    await Promise.all([\n      // decrease index on the deals after the source index in the source columns\n      ...sourceDeals\n        .filter((deal) => deal.index > source.index)\n        .map((deal) =>\n          dataProvider.update(\"deals\", {\n            id: deal.id,\n            data: { index: deal.index - 1 },\n            previousData: deal,\n          }),\n        ),\n      // increase index on the deals after the destination index in the destination columns\n      ...destinationDeals\n        .filter((deal) => deal.index >= destinationIndex)\n        .map((deal) =>\n          dataProvider.update(\"deals\", {\n            id: deal.id,\n            data: { index: deal.index + 1 },\n            previousData: deal,\n          }),\n        ),\n      // change the dragged deal to take the destination index and column\n      dataProvider.update(\"deals\", {\n        id: source.id,\n        data: {\n          index: destinationIndex,\n          stage: destination.stage,\n        },\n        previousData: source,\n      }),\n    ]);\n  }\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/deals/DealList.tsx",
      "content": "import type { ReactNode } from \"react\";\nimport type { InputProps } from \"ra-core\";\nimport { useGetIdentity, useListContext, useTranslate } from \"ra-core\";\nimport { matchPath, useLocation } from \"react-router\";\nimport { AutocompleteInput } from \"@/components/admin/autocomplete-input\";\nimport { CreateButton } from \"@/components/admin/create-button\";\nimport { ExportButton } from \"@/components/admin/export-button\";\nimport { List } from \"@/components/admin/list\";\nimport { ReferenceInput } from \"@/components/admin/reference-input\";\nimport { FilterButton } from \"@/components/admin/filter-form\";\nimport { SearchInput } from \"@/components/admin/search-input\";\nimport { SelectInput } from \"@/components/admin/select-input\";\n\nimport { useConfigurationContext } from \"../root/ConfigurationContext\";\nimport { TopToolbar } from \"../layout/TopToolbar\";\nimport { DealArchivedList } from \"./DealArchivedList\";\nimport { DealCreate } from \"./DealCreate\";\nimport { DealEdit } from \"./DealEdit\";\nimport { DealEmpty } from \"./DealEmpty\";\nimport { DealListContent } from \"./DealListContent\";\nimport { DealShow } from \"./DealShow\";\nimport { OnlyMineInput } from \"./OnlyMineInput\";\n\nconst DealList = () => {\n  const { identity } = useGetIdentity();\n  const { dealCategories } = useConfigurationContext();\n  const translate = useTranslate();\n\n  if (!identity) return null;\n\n  const dealFilters = [\n    <SearchInput source=\"q\" alwaysOn />,\n    <ReferenceInput source=\"company_id\" reference=\"companies\">\n      <AutocompleteInput\n        label={false}\n        placeholder={translate(\"resources.deals.fields.company_id\")}\n      />\n    </ReferenceInput>,\n    <WrapperField source=\"category\" label=\"resources.deals.fields.category\">\n      <SelectInput\n        source=\"category\"\n        label={false}\n        emptyText=\"resources.deals.fields.category\"\n        choices={dealCategories}\n        optionText=\"label\"\n        optionValue=\"value\"\n      />\n    </WrapperField>,\n    <OnlyMineInput source=\"sales_id\" alwaysOn />,\n  ];\n\n  return (\n    <List\n      perPage={100}\n      filter={{ \"archived_at@is\": null }}\n      title={false}\n      sort={{ field: \"index\", order: \"DESC\" }}\n      filters={dealFilters}\n      actions={<DealActions />}\n      pagination={null}\n    >\n      <DealLayout />\n    </List>\n  );\n};\n\nconst DealLayout = () => {\n  const location = useLocation();\n  const matchCreate = matchPath(\"/deals/create\", location.pathname);\n  const matchShow = matchPath(\"/deals/:id/show\", location.pathname);\n  const matchEdit = matchPath(\"/deals/:id\", location.pathname);\n\n  const { data, isPending, filterValues } = useListContext();\n  const hasFilters = filterValues && Object.keys(filterValues).length > 0;\n\n  if (isPending) return null;\n  if (!data?.length && !hasFilters)\n    return (\n      <>\n        <DealEmpty>\n          <DealShow open={!!matchShow} id={matchShow?.params.id} />\n          <DealArchivedList />\n        </DealEmpty>\n      </>\n    );\n\n  return (\n    <div className=\"w-full\">\n      <DealListContent />\n      <DealArchivedList />\n      <DealCreate open={!!matchCreate} />\n      <DealEdit open={!!matchEdit && !matchCreate} id={matchEdit?.params.id} />\n      <DealShow open={!!matchShow} id={matchShow?.params.id} />\n    </div>\n  );\n};\n\nconst DealActions = () => (\n  <TopToolbar>\n    <FilterButton />\n    <ExportButton />\n    <CreateButton label=\"resources.deals.action.new\" />\n  </TopToolbar>\n);\n\n/**\n *\n * Used so that label of filters can be inferred for the select display,\n * but not be displayed when showing the input.\n */\nconst WrapperField = ({ children }: InputProps & { children: ReactNode }) =>\n  children;\n\nexport default DealList;\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/deals/DealInputs.tsx",
      "content": "import { required, useTranslate } from \"ra-core\";\nimport { AutocompleteArrayInput } from \"@/components/admin/autocomplete-array-input\";\nimport { ReferenceArrayInput } from \"@/components/admin/reference-array-input\";\nimport { ReferenceInput } from \"@/components/admin/reference-input\";\nimport { TextInput } from \"@/components/admin/text-input\";\nimport { NumberInput } from \"@/components/admin/number-input\";\nimport { DateInput } from \"@/components/admin/date-input\";\nimport { SelectInput } from \"@/components/admin/select-input\";\nimport { Separator } from \"@/components/ui/separator\";\nimport { useIsMobile } from \"@/hooks/use-mobile\";\n\nimport { contactOptionText } from \"../misc/ContactOption\";\nimport { useConfigurationContext } from \"../root/ConfigurationContext\";\nimport { AutocompleteCompanyInput } from \"../companies/AutocompleteCompanyInput.tsx\";\n\nexport const DealInputs = () => {\n  const isMobile = useIsMobile();\n  return (\n    <div className=\"flex flex-col gap-8\">\n      <DealInfoInputs />\n\n      <div className={`flex gap-6 ${isMobile ? \"flex-col\" : \"flex-row\"}`}>\n        <DealLinkedToInputs />\n        <Separator orientation={isMobile ? \"horizontal\" : \"vertical\"} />\n        <DealMiscInputs />\n      </div>\n    </div>\n  );\n};\n\nconst DealInfoInputs = () => {\n  return (\n    <div className=\"flex flex-col gap-4 flex-1\">\n      <TextInput source=\"name\" validate={required()} helperText={false} />\n      <TextInput source=\"description\" multiline rows={3} helperText={false} />\n    </div>\n  );\n};\n\nconst DealLinkedToInputs = () => {\n  const translate = useTranslate();\n  return (\n    <div className=\"flex flex-col gap-4 flex-1\">\n      <h3 className=\"text-base font-medium\">\n        {translate(\"resources.deals.inputs.linked_to\")}\n      </h3>\n      <ReferenceInput source=\"company_id\" reference=\"companies\">\n        <AutocompleteCompanyInput\n          label=\"resources.deals.fields.company_id\"\n          validate={required()}\n          modal\n        />\n      </ReferenceInput>\n\n      <ReferenceArrayInput source=\"contact_ids\" reference=\"contacts_summary\">\n        <AutocompleteArrayInput\n          label=\"resources.deals.fields.contact_ids\"\n          optionText={contactOptionText}\n          helperText={false}\n        />\n      </ReferenceArrayInput>\n    </div>\n  );\n};\n\nconst DealMiscInputs = () => {\n  const { dealStages, dealCategories } = useConfigurationContext();\n  const translate = useTranslate();\n  return (\n    <div className=\"flex flex-col gap-4 flex-1\">\n      <h3 className=\"text-base font-medium\">\n        {translate(\"resources.deals.field_categories.misc\")}\n      </h3>\n\n      <SelectInput\n        source=\"category\"\n        choices={dealCategories}\n        optionText=\"label\"\n        optionValue=\"value\"\n        helperText={false}\n      />\n      <NumberInput\n        source=\"amount\"\n        defaultValue={0}\n        helperText={false}\n        validate={required()}\n      />\n      <DateInput\n        validate={required()}\n        source=\"expected_closing_date\"\n        helperText={false}\n        defaultValue={new Date().toISOString().split(\"T\")[0]}\n      />\n      <SelectInput\n        source=\"stage\"\n        choices={dealStages}\n        optionText=\"label\"\n        optionValue=\"value\"\n        defaultValue=\"opportunity\"\n        helperText={false}\n        validate={required()}\n      />\n    </div>\n  );\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/deals/DealEmpty.tsx",
      "content": "import { useGetList, useTranslate } from \"ra-core\";\nimport { matchPath, useLocation, Link } from \"react-router\";\nimport type { ReactNode } from \"react\";\nimport { CreateButton } from \"@/components/admin/create-button\";\nimport { Progress } from \"@/components/ui/progress\";\n\nimport useAppBarHeight from \"../misc/useAppBarHeight\";\nimport type { Contact } from \"../types\";\nimport { DealCreate } from \"./DealCreate\";\n\nexport const DealEmpty = ({ children }: { children?: ReactNode }) => {\n  const translate = useTranslate();\n  const location = useLocation();\n  const matchCreate = matchPath(\"/deals/create\", location.pathname);\n  const appbarHeight = useAppBarHeight();\n\n  // get Contact data\n  const { data: contacts, isPending: contactsLoading } = useGetList<Contact>(\n    \"contacts\",\n    {\n      pagination: { page: 1, perPage: 1 },\n    },\n  );\n\n  if (contactsLoading) return <Progress value={50} />;\n\n  return (\n    <div\n      className=\"flex flex-col justify-center items-center gap-12\"\n      style={{\n        height: `calc(100dvh - ${appbarHeight}px)`,\n      }}\n    >\n      <img\n        src=\"./img/empty.svg\"\n        alt={translate(\"resources.deals.empty.title\")}\n      />\n      {contacts && contacts.length > 0 ? (\n        <>\n          <div className=\"flex flex-col items-center gap-0\">\n            <h3 className=\"text-lg font-bold\">\n              {translate(\"resources.deals.empty.title\")}\n            </h3>\n            <p className=\"text-sm text-center text-muted-foreground mb-4\">\n              {translate(\"resources.deals.empty.description\")}\n            </p>\n          </div>\n          <div className=\"flex space-x-8\">\n            <CreateButton label=\"resources.deals.action.create\" />\n          </div>\n          <DealCreate open={!!matchCreate} />\n          {children}\n        </>\n      ) : (\n        <div className=\"flex flex-col items-center gap-0\">\n          <h3 className=\"text-lg font-bold\">\n            {translate(\"resources.deals.empty.title\")}\n          </h3>\n          <p className=\"text-sm text-center text-muted-foreground mb-4\">\n            {translate(\"resources.contacts.empty.description\")}\n            <br />\n            <Link to=\"/contacts/create\" className=\"hover:underline\">\n              {translate(\"resources.contacts.action.add_first\")}\n            </Link>{\" \"}\n            {translate(\"resources.deals.empty.before_create\")}\n          </p>\n        </div>\n      )}\n    </div>\n  );\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/deals/DealEdit.tsx",
      "content": "import {\n  EditBase,\n  Form,\n  useEditContext,\n  useNotify,\n  useRecordContext,\n  useRedirect,\n  useTranslate,\n} from \"ra-core\";\nimport { Link } from \"react-router\";\nimport { DeleteButton } from \"@/components/admin/delete-button\";\nimport { ReferenceField } from \"@/components/admin/reference-field\";\nimport { Button } from \"@/components/ui/button\";\nimport { Dialog, DialogContent, DialogTitle } from \"@/components/ui/dialog\";\n\nimport { FormToolbar } from \"../layout/FormToolbar\";\nimport { CompanyAvatar } from \"../companies/CompanyAvatar\";\nimport type { Deal } from \"../types\";\nimport { DealInputs } from \"./DealInputs\";\n\nexport const DealEdit = ({ open, id }: { open: boolean; id?: string }) => {\n  const redirect = useRedirect();\n  const notify = useNotify();\n\n  const handleClose = () => {\n    redirect(\"/deals\", undefined, undefined, undefined, {\n      _scrollToTop: false,\n    });\n  };\n\n  return (\n    <Dialog open={open} onOpenChange={() => handleClose()}>\n      <DialogContent className=\"lg:max-w-4xl p-4 overflow-y-auto max-h-9/10 top-1/20 translate-y-0\">\n        {id ? (\n          <EditBase\n            id={id}\n            mutationMode=\"pessimistic\"\n            mutationOptions={{\n              onSuccess: () => {\n                notify(\"resources.deals.updated\", {});\n                redirect(`/deals/${id}/show`, undefined, undefined, undefined, {\n                  _scrollToTop: false,\n                });\n              },\n            }}\n          >\n            <EditHeader />\n            <Form>\n              <DealInputs />\n              <FormToolbar />\n            </Form>\n          </EditBase>\n        ) : null}\n      </DialogContent>\n    </Dialog>\n  );\n};\n\nfunction EditHeader() {\n  const translate = useTranslate();\n  const { defaultTitle } = useEditContext<Deal>();\n  const deal = useRecordContext<Deal>();\n  if (!deal) {\n    return null;\n  }\n\n  return (\n    <DialogTitle className=\"pb-0\">\n      <div className=\"flex justify-between items-start mb-8\">\n        <div className=\"flex items-center gap-4\">\n          <ReferenceField source=\"company_id\" reference=\"companies\" link=\"show\">\n            <CompanyAvatar />\n          </ReferenceField>\n          <h2 className=\"text-2xl font-semibold\">{defaultTitle}</h2>\n        </div>\n        <div className=\"flex gap-2 pr-12\">\n          <DeleteButton />\n          <Button asChild variant=\"outline\" className=\"h-9\">\n            <Link to={`/deals/${deal.id}/show`}>\n              {translate(\"resources.deals.action.back_to_deal\")}\n            </Link>\n          </Button>\n        </div>\n      </div>\n    </DialogTitle>\n  );\n}\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/deals/DealCreate.tsx",
      "content": "import { useQueryClient } from \"@tanstack/react-query\";\nimport {\n  Form,\n  useDataProvider,\n  useGetIdentity,\n  useListContext,\n  useRedirect,\n  type GetListResult,\n} from \"ra-core\";\nimport { Create } from \"@/components/admin/create\";\nimport { SaveButton } from \"@/components/admin/form\";\nimport { FormToolbar } from \"@/components/admin/simple-form\";\nimport { Dialog, DialogContent } from \"@/components/ui/dialog\";\n\nimport type { Deal } from \"../types\";\nimport { DealInputs } from \"./DealInputs\";\n\nexport const DealCreate = ({ open }: { open: boolean }) => {\n  const redirect = useRedirect();\n  const dataProvider = useDataProvider();\n  const { data: allDeals } = useListContext<Deal>();\n\n  const handleClose = () => {\n    redirect(\"/deals\");\n  };\n\n  const queryClient = useQueryClient();\n\n  const onSuccess = async (deal: Deal) => {\n    if (!allDeals) {\n      redirect(\"/deals\");\n      return;\n    }\n    // increase the index of all deals in the same stage as the new deal\n    // first, get the list of deals in the same stage\n    const deals = allDeals.filter(\n      (d: Deal) => d.stage === deal.stage && d.id !== deal.id,\n    );\n    // update the actual deals in the database\n    await Promise.all(\n      deals.map(async (oldDeal) =>\n        dataProvider.update(\"deals\", {\n          id: oldDeal.id,\n          data: { index: oldDeal.index + 1 },\n          previousData: oldDeal,\n        }),\n      ),\n    );\n    // refresh the list of deals in the cache as we used dataProvider.update(),\n    // which does not update the cache\n    const dealsById = deals.reduce(\n      (acc, d) => ({\n        ...acc,\n        [d.id]: { ...d, index: d.index + 1 },\n      }),\n      {} as { [key: string]: Deal },\n    );\n    const now = Date.now();\n    queryClient.setQueriesData<GetListResult | undefined>(\n      { queryKey: [\"deals\", \"getList\"] },\n      (res) => {\n        if (!res) return res;\n        return {\n          ...res,\n          data: res.data.map((d: Deal) => dealsById[d.id] || d),\n        };\n      },\n      { updatedAt: now },\n    );\n    redirect(\"/deals\");\n  };\n\n  const { identity } = useGetIdentity();\n\n  return (\n    <Dialog open={open} onOpenChange={() => handleClose()}>\n      <DialogContent className=\"lg:max-w-4xl overflow-y-auto max-h-9/10 top-1/20 translate-y-0\">\n        <Create resource=\"deals\" mutationOptions={{ onSuccess }}>\n          <Form\n            defaultValues={{\n              sales_id: identity?.id,\n              contact_ids: [],\n              index: 0,\n            }}\n          >\n            <DealInputs />\n            <FormToolbar>\n              <SaveButton />\n            </FormToolbar>\n          </Form>\n        </Create>\n      </DialogContent>\n    </Dialog>\n  );\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/deals/DealColumn.tsx",
      "content": "import { Droppable } from \"@hello-pangea/dnd\";\n\nimport { useConfigurationContext } from \"../root/ConfigurationContext\";\nimport type { Deal } from \"../types\";\nimport { findDealLabel } from \"./dealUtils\";\nimport { DealCard } from \"./DealCard\";\n\nexport const DealColumn = ({\n  stage,\n  deals,\n}: {\n  stage: string;\n  deals: Deal[];\n}) => {\n  const totalAmount = deals.reduce((sum, deal) => sum + deal.amount, 0);\n  const { dealStages, currency } = useConfigurationContext();\n  return (\n    <div className=\"flex-1 pb-8\">\n      <div className=\"flex flex-col items-center\">\n        <h3 className=\"text-base font-medium\">\n          {findDealLabel(dealStages, stage)}\n        </h3>\n        <p className=\"text-sm text-muted-foreground\">\n          {totalAmount.toLocaleString(\"en-US\", {\n            notation: \"compact\",\n            style: \"currency\",\n            currency,\n            currencyDisplay: \"narrowSymbol\",\n            minimumSignificantDigits: 3,\n          })}\n        </p>\n      </div>\n      <Droppable droppableId={stage}>\n        {(droppableProvided, snapshot) => (\n          <div\n            ref={droppableProvided.innerRef}\n            {...droppableProvided.droppableProps}\n            className={`flex flex-col rounded-2xl mt-2 gap-2 ${\n              snapshot.isDraggingOver ? \"bg-muted\" : \"\"\n            }`}\n          >\n            {deals.map((deal, index) => (\n              <DealCard key={deal.id} deal={deal} index={index} />\n            ))}\n            {droppableProvided.placeholder}\n          </div>\n        )}\n      </Droppable>\n    </div>\n  );\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/deals/DealCard.tsx",
      "content": "import { Draggable } from \"@hello-pangea/dnd\";\nimport { useRedirect, RecordContextProvider } from \"ra-core\";\nimport { ReferenceField } from \"@/components/admin/reference-field\";\nimport { NumberField } from \"@/components/admin/number-field\";\nimport { SelectField } from \"@/components/admin/select-field\";\nimport { Card, CardContent } from \"@/components/ui/card\";\n\nimport { CompanyAvatar } from \"../companies/CompanyAvatar\";\nimport { useConfigurationContext } from \"../root/ConfigurationContext\";\nimport type { Deal } from \"../types\";\n\nexport const DealCard = ({ deal, index }: { deal: Deal; index: number }) => {\n  if (!deal) return null;\n\n  return (\n    <Draggable draggableId={String(deal.id)} index={index}>\n      {(provided, snapshot) => (\n        <DealCardContent provided={provided} snapshot={snapshot} deal={deal} />\n      )}\n    </Draggable>\n  );\n};\n\nexport const DealCardContent = ({\n  provided,\n  snapshot,\n  deal,\n}: {\n  provided?: any;\n  snapshot?: any;\n  deal: Deal;\n}) => {\n  const { dealCategories, currency } = useConfigurationContext();\n  const redirect = useRedirect();\n  const handleClick = () => {\n    redirect(`/deals/${deal.id}/show`, undefined, undefined, undefined, {\n      _scrollToTop: false,\n    });\n  };\n\n  return (\n    <div\n      className=\"cursor-pointer\"\n      {...provided?.draggableProps}\n      {...provided?.dragHandleProps}\n      ref={provided?.innerRef}\n      onClick={handleClick}\n    >\n      <RecordContextProvider value={deal}>\n        <Card\n          className={`py-3 transition-all duration-200 ${\n            snapshot?.isDragging\n              ? \"opacity-90 transform rotate-1 shadow-lg\"\n              : \"shadow-sm hover:shadow-md\"\n          }`}\n        >\n          <CardContent className=\"px-3 flex flex-col\">\n            <div className=\"flex-1 flex\">\n              <p className=\"flex-1 text-sm font-medium mb-2\">\n                <ReferenceField\n                  source=\"company_id\"\n                  reference=\"companies\"\n                  link={false}\n                />\n                {\" - \"}\n                {deal.name}\n              </p>\n              <ReferenceField\n                source=\"company_id\"\n                reference=\"companies\"\n                link={false}\n              >\n                <CompanyAvatar width={20} height={20} />\n              </ReferenceField>\n            </div>\n            <p className=\"text-xs text-muted-foreground\">\n              <NumberField\n                source=\"amount\"\n                options={{\n                  notation: \"compact\",\n                  style: \"currency\",\n                  currency,\n                  currencyDisplay: \"narrowSymbol\",\n                  minimumSignificantDigits: 3,\n                }}\n              />\n              {deal.category && \", \"}\n              <SelectField\n                source=\"category\"\n                choices={dealCategories}\n                optionText=\"label\"\n                optionValue=\"value\"\n              />\n            </p>\n          </CardContent>\n        </Card>\n      </RecordContextProvider>\n    </div>\n  );\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/deals/DealArchivedList.tsx",
      "content": "import {\n  useGetIdentity,\n  useGetList,\n  useLocaleState,\n  useTranslate,\n} from \"ra-core\";\nimport { useEffect, useState } from \"react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Dialog, DialogContent, DialogTitle } from \"@/components/ui/dialog\";\n\nimport type { Deal } from \"../types\";\nimport { DealCardContent } from \"./DealCard\";\nimport { getRelativeTimeString } from \"./dealUtils\";\n\nexport const DealArchivedList = () => {\n  const translate = useTranslate();\n  const [locale = \"en\"] = useLocaleState();\n  const { identity } = useGetIdentity();\n  const {\n    data: archivedLists,\n    total,\n    isPending,\n  } = useGetList(\"deals\", {\n    pagination: { page: 1, perPage: 1000 },\n    sort: { field: \"archived_at\", order: \"DESC\" },\n    filter: { \"archived_at@not.is\": null },\n  });\n  const [openDialog, setOpenDialog] = useState(false);\n\n  useEffect(() => {\n    if (!isPending && total === 0) {\n      setOpenDialog(false);\n    }\n  }, [isPending, total]);\n\n  useEffect(() => {\n    setOpenDialog(false);\n  }, [archivedLists]);\n\n  if (!identity || isPending || !total || !archivedLists) return null;\n\n  // Group archived lists by date\n  const archivedListsByDate: { [date: string]: Deal[] } = archivedLists.reduce(\n    (acc, deal) => {\n      const date = new Date(deal.archived_at).toDateString();\n      if (!acc[date]) {\n        acc[date] = [];\n      }\n      acc[date].push(deal);\n      return acc;\n    },\n    {} as { [date: string]: Deal[] },\n  );\n\n  return (\n    <div className=\"w-full flex flex-row items-center justify-center\">\n      <Button\n        variant=\"ghost\"\n        onClick={() => setOpenDialog(true)}\n        className=\"my-4\"\n      >\n        {translate(\"resources.deals.archived.view\")}\n      </Button>\n      <Dialog open={openDialog} onOpenChange={() => setOpenDialog(false)}>\n        <DialogContent className=\"lg:max-w-4xl overflow-y-auto max-h-9/10 top-1/20 translate-y-0\">\n          <DialogTitle>\n            {translate(\"resources.deals.archived.list_title\")}\n          </DialogTitle>\n          <div className=\"flex flex-col gap-8\">\n            {Object.entries(archivedListsByDate).map(([date, deals]) => (\n              <div key={date} className=\"flex flex-col gap-4\">\n                <h4 className=\"font-bold\">\n                  {getRelativeTimeString(date, locale)}\n                </h4>\n                <div className=\"grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-8\">\n                  {deals.map((deal: Deal) => (\n                    <div key={deal.id}>\n                      <DealCardContent deal={deal} />\n                    </div>\n                  ))}\n                </div>\n              </div>\n            ))}\n          </div>\n        </DialogContent>\n      </Dialog>\n    </div>\n  );\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/deals/ContactList.tsx",
      "content": "import { useListContext, useTranslate } from \"ra-core\";\nimport { Link as RouterLink } from \"react-router\";\n\nimport { Avatar } from \"../contacts/Avatar\";\n\nexport const ContactList = () => {\n  const { data, error, isPending } = useListContext();\n  const translate = useTranslate();\n  if (isPending || error) return <div className=\"h-8\" />;\n  return (\n    <div className=\"flex flex-row flex-wrap gap-4 mt-4\">\n      {data.map((contact) => (\n        <div className=\"flex flex-row gap-4 items-center\" key={contact.id}>\n          <Avatar record={contact} />\n          <div className=\"flex flex-col\">\n            <RouterLink\n              to={`/contacts/${contact.id}/show`}\n              className=\"text-sm hover:underline\"\n            >\n              {contact.first_name} {contact.last_name}\n            </RouterLink>\n            <span className=\"text-xs text-muted-foreground\">\n              {contact.title && contact.company_name\n                ? translate(\"resources.contacts.position_at_company\", {\n                    title: contact.title,\n                    company: contact.company_name,\n                  })\n                : contact.title || contact.company_name}\n            </span>\n          </div>\n        </div>\n      ))}\n    </div>\n  );\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/dashboard/Welcome.tsx",
      "content": "import { Card, CardContent, CardHeader, CardTitle } from \"@/components/ui/card\";\n\nexport const Welcome = () => (\n  <Card>\n    <CardHeader className=\"px-4\">\n      <CardTitle>Your CRM Starter Kit</CardTitle>\n    </CardHeader>\n    <CardContent className=\"px-4\">\n      <p className=\"text-sm mb-4\">\n        <a\n          href=\"https://marmelab.com/atomic-crm\"\n          className=\"underline hover:no-underline\"\n        >\n          Atomic CRM\n        </a>{\" \"}\n        is a template designed to help you quickly build your own CRM.\n      </p>\n      <p className=\"text-sm mb-4\">\n        This demo runs on a mock API, so you can explore and modify the data. It\n        resets on reload. The full version uses Supabase for the backend.\n      </p>\n      <p className=\"text-sm\">\n        Powered by{\" \"}\n        <a\n          href=\"https://marmelab.com/shadcn-admin-kit\"\n          className=\"underline hover:no-underline\"\n        >\n          shadcn-admin-kit\n        </a>\n        , Atomic CRM is fully open-source. You can find the code at{\" \"}\n        <a\n          href=\"https://github.com/marmelab/atomic-crm\"\n          className=\"underline hover:no-underline\"\n        >\n          marmelab/atomic-crm\n        </a>\n        .\n      </p>\n    </CardContent>\n  </Card>\n);\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/dashboard/TasksList.tsx",
      "content": "import { CheckSquare } from \"lucide-react\";\nimport { useTranslate } from \"ra-core\";\nimport { Card } from \"@/components/ui/card\";\n\nimport { AddTask } from \"../tasks/AddTask\";\nimport { TasksListContent } from \"../tasks/TasksListContent\";\n\nexport const TasksList = () => {\n  const translate = useTranslate();\n  return (\n    <div className=\"flex flex-col gap-2\">\n      <div className=\"flex items-center\">\n        <div className=\"mr-3 flex\">\n          <CheckSquare className=\"text-muted-foreground w-6 h-6\" />\n        </div>\n        <h2 className=\"text-xl font-semibold text-muted-foreground flex-1\">\n          {translate(\"crm.dashboard.upcoming_tasks\", {\n            _: \"Upcoming Tasks\",\n          })}\n        </h2>\n        <AddTask display=\"icon\" selectContact />\n      </div>\n      <Card className=\"p-4 mb-2\">\n        <TasksListContent />\n      </Card>\n    </div>\n  );\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/dashboard/MobileDashboard.tsx",
      "content": "import { useGetList, useTimeout } from \"ra-core\";\nimport { Skeleton } from \"@/components/ui/skeleton\";\n\nimport type { Contact, ContactNote } from \"../types\";\nimport { DashboardActivityLog } from \"./DashboardActivityLog\";\nimport { DashboardStepper } from \"./DashboardStepper\";\nimport { Welcome } from \"./Welcome\";\nimport MobileHeader from \"../layout/MobileHeader\";\nimport { MobileContent } from \"../layout/MobileContent\";\nimport { useConfigurationContext } from \"../root/ConfigurationContext\";\n\nconst Wrapper = ({ children }: { children: React.ReactNode }) => {\n  const { darkModeLogo, lightModeLogo, title } = useConfigurationContext();\n  return (\n    <>\n      <MobileHeader>\n        <div className=\"flex items-center gap-2 text-secondary-foreground no-underline py-3\">\n          <img\n            className=\"[.light_&]:hidden h-6\"\n            src={darkModeLogo}\n            alt={title}\n          />\n          <img\n            className=\"[.dark_&]:hidden h-6\"\n            src={lightModeLogo}\n            alt={title}\n          />\n          <h1 className=\"text-xl font-semibold\">{title}</h1>\n        </div>\n      </MobileHeader>\n      <MobileContent>{children}</MobileContent>\n    </>\n  );\n};\n\nconst Loading = () => (\n  <Wrapper>\n    <Skeleton className=\"h-4 w-3/4 mb-4\" />\n    <Skeleton className=\"h-4 w-full mb-2\" />\n    <Skeleton className=\"h-4 w-full mb-2\" />\n    <Skeleton className=\"h-4 w-full mb-2\" />\n    <Skeleton className=\"h-4 w-full mb-2\" />\n  </Wrapper>\n);\n\nexport const MobileDashboard = () => {\n  const {\n    data: dataContact,\n    total: totalContact,\n    isPending: isPendingContact,\n  } = useGetList<Contact>(\"contacts\", {\n    pagination: { page: 1, perPage: 1 },\n  });\n  const { total: totalContactNotes, isPending: isPendingContactNotes } =\n    useGetList<ContactNote>(\"contact_notes\", {\n      pagination: { page: 1, perPage: 1 },\n    });\n  const oneSecondHasPassed = useTimeout(1000);\n\n  const isPending = isPendingContact || isPendingContactNotes;\n\n  if (isPending) {\n    return oneSecondHasPassed ? <Loading /> : null;\n  }\n\n  if (!totalContact) {\n    return (\n      <Wrapper>\n        <DashboardStepper step={1} />\n      </Wrapper>\n    );\n  }\n\n  if (!totalContactNotes) {\n    return (\n      <Wrapper>\n        <DashboardStepper step={2} contactId={dataContact?.[0]?.id} />\n      </Wrapper>\n    );\n  }\n\n  return (\n    <Wrapper>\n      <div className=\"grid grid-cols-1 md:grid-cols-12 gap-6 mt-1\">\n        {import.meta.env.VITE_IS_DEMO === \"true\" ? <Welcome /> : null}\n        <DashboardActivityLog />\n      </div>\n    </Wrapper>\n  );\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/dashboard/LatestNotes.tsx",
      "content": "import { formatDistance } from \"date-fns\";\nimport { FileText } from \"lucide-react\";\nimport { useGetIdentity, useGetList, useTranslate } from \"ra-core\";\nimport { ReferenceField } from \"@/components/admin/reference-field\";\nimport { TextField } from \"@/components/admin/text-field\";\nimport { Card, CardContent } from \"@/components/ui/card\";\n\nimport type { Contact, ContactNote } from \"../types\";\n\nexport const LatestNotes = () => {\n  const { identity } = useGetIdentity();\n  const translate = useTranslate();\n  const { data: contactNotesData, isPending: contactNotesLoading } = useGetList(\n    \"contact_notes\",\n    {\n      pagination: { page: 1, perPage: 5 },\n      sort: { field: \"date\", order: \"DESC\" },\n      filter: { sales_id: identity?.id },\n    },\n    { enabled: Number.isInteger(identity?.id) },\n  );\n  const { data: dealNotesData, isPending: dealNotesLoading } = useGetList(\n    \"deal_notes\",\n    {\n      pagination: { page: 1, perPage: 5 },\n      sort: { field: \"date\", order: \"DESC\" },\n      filter: { sales_id: identity?.id },\n    },\n    { enabled: Number.isInteger(identity?.id) },\n  );\n  if (contactNotesLoading || dealNotesLoading) {\n    return null;\n  }\n  // TypeScript guards\n  if (!contactNotesData || !dealNotesData) {\n    return null;\n  }\n\n  const allNotes = ([] as any[])\n    .concat(\n      contactNotesData.map((note) => ({\n        ...note,\n        type: \"contactNote\",\n      })),\n      dealNotesData.map((note) => ({ ...note, type: \"dealNote\" })),\n    )\n    .sort((a, b) => new Date(b.date).valueOf() - new Date(a.date).valueOf())\n    .slice(0, 5);\n\n  return (\n    <div>\n      <div className=\"flex items-center mb-4\">\n        <div className=\"ml-8 mr-8 flex\">\n          <FileText className=\"text-muted-foreground w-6 h-6\" />\n        </div>\n        <h2 className=\"text-xl font-semibold text-muted-foreground\">\n          {translate(\"crm.dashboard.latest_notes\")}\n        </h2>\n      </div>\n      <Card>\n        <CardContent>\n          {allNotes.map((note) => (\n            <div\n              id={`${note.type}_${note.id}`}\n              key={`${note.type}_${note.id}`}\n              className=\"mb-8\"\n            >\n              <div className=\"text-sm text-muted-foreground\">\n                {note.type === \"dealNote\" ? (\n                  <Deal note={note} />\n                ) : (\n                  <Contact note={note} />\n                )}\n                {\", \"}\n                {translate(\"crm.dashboard.latest_notes_added_ago\", {\n                  timeAgo: formatDistance(note.date, new Date(), {\n                    addSuffix: true,\n                  }),\n                })}\n              </div>\n              <div>\n                <p className=\"text-sm line-clamp-3 overflow-hidden\">\n                  {note.text}\n                </p>\n              </div>\n            </div>\n          ))}\n        </CardContent>\n      </Card>\n    </div>\n  );\n};\n\nconst Deal = ({ note }: any) => {\n  const translate = useTranslate();\n  return (\n    <>\n      {translate(\"resources.deals.forcedCaseName\")}{\" \"}\n      <ReferenceField\n        record={note}\n        source=\"deal_id\"\n        reference=\"deals\"\n        link=\"show\"\n      >\n        <TextField source=\"name\" />\n      </ReferenceField>\n    </>\n  );\n};\n\nconst Contact = ({ note }: any) => {\n  const translate = useTranslate();\n  return (\n    <>\n      {translate(\"resources.contacts.forcedCaseName\")}{\" \"}\n      <ReferenceField<ContactNote, Contact>\n        record={note}\n        source=\"contact_id\"\n        reference=\"contacts\"\n        link=\"show\"\n      />\n    </>\n  );\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/dashboard/HotContacts.tsx",
      "content": "import { Plus, Users } from \"lucide-react\";\nimport { useGetIdentity, useGetList, useTranslate } from \"ra-core\";\nimport { Link } from \"react-router\";\nimport { Button } from \"@/components/ui/button\";\nimport { Card } from \"@/components/ui/card\";\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from \"@/components/ui/tooltip\";\n\nimport { SimpleList } from \"../simple-list/SimpleList\";\nimport { Avatar } from \"../contacts/Avatar\";\nimport type { Contact } from \"../types\";\n\nexport const HotContacts = () => {\n  const { identity } = useGetIdentity();\n  const translate = useTranslate();\n  const {\n    data: contactData,\n    total: contactTotal,\n    isPending: contactsLoading,\n  } = useGetList<Contact>(\n    \"contacts\",\n    {\n      pagination: { page: 1, perPage: 10 },\n      sort: { field: \"last_seen\", order: \"DESC\" },\n      filter: { status: \"hot\", sales_id: identity?.id },\n    },\n    { enabled: Number.isInteger(identity?.id) },\n  );\n\n  return (\n    <div className=\"flex flex-col gap-2\">\n      <div className=\"flex items-center\">\n        <div className=\"mr-3 flex\">\n          <Users className=\"text-muted-foreground w-6 h-6\" />\n        </div>\n        <h2 className=\"text-xl font-semibold text-muted-foreground\">\n          {translate(\"resources.contacts.hot.title\")}\n        </h2>\n        <TooltipProvider>\n          <Tooltip>\n            <TooltipTrigger asChild>\n              <Button\n                variant=\"ghost\"\n                size=\"sm\"\n                className=\"ml-auto text-muted-foreground\"\n                asChild\n              >\n                <Link to=\"/contacts/create\">\n                  <Plus className=\"w-4 h-4 text-primary\" />\n                </Link>\n              </Button>\n            </TooltipTrigger>\n            <TooltipContent>\n              {translate(\"resources.contacts.action.create\")}\n            </TooltipContent>\n          </Tooltip>\n        </TooltipProvider>\n      </div>\n      <Card className=\"py-0\">\n        <SimpleList<Contact>\n          linkType=\"show\"\n          data={contactData}\n          total={contactTotal}\n          isPending={contactsLoading}\n          resource=\"contacts\"\n          className=\"[&>li:first-child>a]:rounded-t-xl [&>li:last-child>a]:rounded-b-xl\"\n          primaryText={(contact) =>\n            `${contact.first_name} ${contact.last_name}`\n          }\n          secondaryText={(contact) => (\n            <>\n              {contact.title && contact.company_name\n                ? translate(\"resources.contacts.position_at_company\", {\n                    title: contact.title,\n                    company: contact.company_name,\n                  })\n                : contact.title || contact.company_name}\n            </>\n          )}\n          leftAvatar={(contact) => <Avatar record={contact} />}\n          empty={\n            <div className=\"p-4\">\n              <p className=\"text-sm mb-4\">\n                {translate(\"resources.contacts.hot.empty_hint\")}\n              </p>\n              <p className=\"text-sm\">\n                {translate(\"resources.contacts.hot.empty_change_status\")}\n              </p>\n            </div>\n          }\n        />\n      </Card>\n    </div>\n  );\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/dashboard/DealsPipeline.tsx",
      "content": "import { DollarSign } from \"lucide-react\";\nimport { useGetIdentity, useGetList, useTranslate } from \"ra-core\";\nimport { Link } from \"react-router\";\nimport { ReferenceField } from \"@/components/admin/reference-field\";\nimport { Card } from \"@/components/ui/card\";\n\nimport { SimpleList } from \"../simple-list/SimpleList\";\nimport { CompanyAvatar } from \"../companies/CompanyAvatar\";\nimport { findDealLabel } from \"../deals/dealUtils\";\nimport { useConfigurationContext } from \"../root/ConfigurationContext\";\nimport type { Deal } from \"../types\";\n\n/**\n * This component displays the deals pipeline for the current user.\n * It's currently not used in the application but can be added to the dashboard.\n */\nexport const DealsPipeline = () => {\n  const translate = useTranslate();\n  const { identity } = useGetIdentity();\n  const { dealStages, dealPipelineStatuses, currency } =\n    useConfigurationContext();\n  const { data, total, isPending } = useGetList<Deal>(\n    \"deals\",\n    {\n      pagination: { page: 1, perPage: 10 },\n      sort: { field: \"last_seen\", order: \"DESC\" },\n      filter: { \"stage@neq\": \"lost\", sales_id: identity?.id },\n    },\n    { enabled: Number.isInteger(identity?.id) },\n  );\n\n  const getOrderedDeals = (data?: Deal[]): Deal[] | undefined => {\n    if (!data) {\n      return;\n    }\n    const deals: Deal[] = [];\n    dealStages\n      .filter((stage) => !dealPipelineStatuses.includes(stage.value))\n      .forEach((stage) =>\n        data\n          .filter((deal) => deal.stage === stage.value)\n          .forEach((deal) => deals.push(deal)),\n      );\n    return deals;\n  };\n\n  return (\n    <>\n      <div className=\"flex items-center mb-4\">\n        <div className=\"ml-8 mr-8 flex\">\n          <DollarSign className=\"text-muted-foreground w-6 h-6\" />\n        </div>\n        <Link\n          className=\"text-xl font-semibold text-muted-foreground hover:underline\"\n          to=\"/deals\"\n        >\n          {translate(\"crm.dashboard.deals_pipeline\")}\n        </Link>\n      </div>\n      <Card>\n        <SimpleList<Deal>\n          resource=\"deals\"\n          linkType=\"show\"\n          data={getOrderedDeals(data)}\n          total={total}\n          isPending={isPending}\n          primaryText={(deal) => deal.name}\n          secondaryText={(deal) =>\n            `${deal.amount.toLocaleString(\"en-US\", {\n              notation: \"compact\",\n              style: \"currency\",\n              currency,\n              currencyDisplay: \"narrowSymbol\",\n              minimumSignificantDigits: 3,\n            })} , ${findDealLabel(dealStages, deal.stage)}`\n          }\n          leftAvatar={(deal) => (\n            <ReferenceField\n              source=\"company_id\"\n              record={deal}\n              reference=\"companies\"\n              resource=\"deals\"\n              link={false}\n            >\n              <CompanyAvatar width={20} height={20} />\n            </ReferenceField>\n          )}\n        />\n      </Card>\n    </>\n  );\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/dashboard/DealsChart.tsx",
      "content": "import { ResponsiveBar } from \"@nivo/bar\";\nimport { format, startOfMonth } from \"date-fns\";\nimport { TrendingUp } from \"lucide-react\";\nimport { useGetList, useTranslate } from \"ra-core\";\nimport { memo, useMemo } from \"react\";\n\nimport { findDealLabel } from \"../deals/dealUtils\";\nimport { useConfigurationContext } from \"../root/ConfigurationContext\";\nimport type { Deal } from \"../types\";\n\nconst multiplier = {\n  opportunity: 0.2,\n  \"proposal-sent\": 0.5,\n  \"in-negociation\": 0.8,\n  delayed: 0.3,\n};\n\nconst threeMonthsAgo = new Date(\n  new Date().setMonth(new Date().getMonth() - 6),\n).toISOString();\n\nconst DEFAULT_LOCALE = \"en-US\";\n\nexport const DealsChart = memo(() => {\n  const translate = useTranslate();\n  const { dealStages, currency } = useConfigurationContext();\n  const acceptedLanguages = navigator\n    ? navigator.languages || [navigator.language]\n    : [DEFAULT_LOCALE];\n  const wonLabel = findDealLabel(dealStages, \"won\") ?? \"Won\";\n  const lostLabel = findDealLabel(dealStages, \"lost\") ?? \"Lost\";\n\n  const { data, isPending } = useGetList<Deal>(\"deals\", {\n    pagination: { perPage: 100, page: 1 },\n    sort: {\n      field: \"created_at\",\n      order: \"ASC\",\n    },\n    filter: {\n      \"created_at@gte\": threeMonthsAgo,\n    },\n  });\n  const months = useMemo(() => {\n    if (!data) return [];\n    const dealsByMonth = data.reduce((acc, deal) => {\n      const month = startOfMonth(deal.created_at ?? new Date()).toISOString();\n      if (!acc[month]) {\n        acc[month] = [];\n      }\n      acc[month].push(deal);\n      return acc;\n    }, {} as any);\n\n    const amountByMonth = Object.keys(dealsByMonth).map((month) => {\n      return {\n        date: format(month, \"MMM\"),\n        won: dealsByMonth[month]\n          .filter((deal: Deal) => deal.stage === \"won\")\n          .reduce((acc: number, deal: Deal) => {\n            acc += deal.amount;\n            return acc;\n          }, 0),\n        pending: dealsByMonth[month]\n          .filter((deal: Deal) => ![\"won\", \"lost\"].includes(deal.stage))\n          .reduce((acc: number, deal: Deal) => {\n            // @ts-expect-error - multiplier type issue\n            acc += deal.amount * multiplier[deal.stage];\n            return acc;\n          }, 0),\n        lost: dealsByMonth[month]\n          .filter((deal: Deal) => deal.stage === \"lost\")\n          .reduce((acc: number, deal: Deal) => {\n            acc -= deal.amount;\n            return acc;\n          }, 0),\n      };\n    });\n\n    return amountByMonth;\n  }, [data]);\n\n  if (isPending) return null; // FIXME return skeleton instead\n  const range = months.reduce(\n    (acc, month) => {\n      acc.min = Math.min(acc.min, month.lost);\n      acc.max = Math.max(acc.max, month.won + month.pending);\n      return acc;\n    },\n    { min: 0, max: 0 },\n  );\n  return (\n    <div className=\"flex flex-col\">\n      <div className=\"flex items-center mb-4\">\n        <div className=\"mr-3 flex\">\n          <TrendingUp className=\"text-muted-foreground w-6 h-6\" />\n        </div>\n        <h2 className=\"text-xl font-semibold text-muted-foreground\">\n          {translate(\"crm.dashboard.deals_chart\")}\n        </h2>\n      </div>\n      <div className=\"h-[400px]\">\n        <ResponsiveBar\n          data={months}\n          indexBy=\"date\"\n          keys={[\"won\", \"pending\", \"lost\"]}\n          colors={[\"#61cdbb\", \"#97e3d5\", \"#e25c3b\"]}\n          margin={{ top: 30, right: 50, bottom: 30, left: 0 }}\n          padding={0.3}\n          valueScale={{\n            type: \"linear\",\n            min: range.min * 1.2,\n            max: range.max * 1.2,\n          }}\n          indexScale={{ type: \"band\", round: true }}\n          enableGridX={true}\n          enableGridY={false}\n          enableLabel={false}\n          tooltip={({ value, indexValue }) => (\n            <div className=\"p-2 bg-secondary rounded shadow inline-flex items-center gap-1 text-secondary-foreground\">\n              <strong>{indexValue}: </strong>&nbsp;{value > 0 ? \"+\" : \"\"}\n              {value.toLocaleString(acceptedLanguages.at(0) ?? DEFAULT_LOCALE, {\n                style: \"currency\",\n                currency,\n              })}\n            </div>\n          )}\n          axisTop={{\n            tickSize: 0,\n            tickPadding: 12,\n            style: {\n              ticks: {\n                text: {\n                  fill: \"var(--color-muted-foreground)\",\n                },\n              },\n              legend: {\n                text: {\n                  fill: \"var(--color-muted-foreground)\",\n                },\n              },\n            },\n          }}\n          axisBottom={{\n            legendPosition: \"middle\",\n            legendOffset: 50,\n            tickSize: 0,\n            tickPadding: 12,\n            style: {\n              ticks: {\n                text: {\n                  fill: \"var(--color-muted-foreground)\",\n                },\n              },\n              legend: {\n                text: {\n                  fill: \"var(--color-muted-foreground)\",\n                },\n              },\n            },\n          }}\n          axisLeft={null}\n          axisRight={{\n            format: (v: any) => `${Math.abs(v / 1000)}k`,\n            tickValues: 8,\n            style: {\n              ticks: {\n                text: {\n                  fill: \"var(--color-muted-foreground)\",\n                },\n              },\n              legend: {\n                text: {\n                  fill: \"var(--color-muted-foreground)\",\n                },\n              },\n            },\n          }}\n          markers={\n            [\n              {\n                axis: \"y\",\n                value: 0,\n                lineStyle: { strokeOpacity: 0 },\n                textStyle: { fill: \"#2ebca6\" },\n                legend: wonLabel,\n                legendPosition: \"top-left\",\n                legendOrientation: \"vertical\",\n              },\n              {\n                axis: \"y\",\n                value: 0,\n                lineStyle: {\n                  stroke: \"#f47560\",\n                  strokeWidth: 1,\n                },\n                textStyle: { fill: \"#e25c3b\" },\n                legend: lostLabel,\n                legendPosition: \"bottom-left\",\n                legendOrientation: \"vertical\",\n              },\n            ] as any\n          }\n        />\n      </div>\n    </div>\n  );\n});\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/dashboard/DashboardStepper.tsx",
      "content": "import { CreateButton } from \"@/components/admin/create-button\";\nimport { Button } from \"@/components/ui/button\";\nimport { Card, CardContent } from \"@/components/ui/card\";\nimport { Progress } from \"@/components/ui/progress\";\nimport { CheckCircle, Circle, Plus } from \"lucide-react\";\nimport type { Identifier } from \"ra-core\";\nimport { useTranslate } from \"ra-core\";\nimport { useState } from \"react\";\nimport { Link } from \"react-router\";\n\nimport { useIsMobile } from \"@/hooks/use-mobile\";\nimport { ContactCreateSheet } from \"../contacts/ContactCreateSheet\";\nimport { ContactImportButton } from \"../contacts/ContactImportButton\";\nimport useAppBarHeight from \"../misc/useAppBarHeight\";\nimport { NoteCreateSheet } from \"../notes/NoteCreateSheet\";\n\nexport const DashboardStepper = ({\n  step,\n  contactId,\n}: {\n  step: number;\n  contactId?: Identifier;\n}) => {\n  const translate = useTranslate();\n  const appbarHeight = useAppBarHeight();\n  const isMobile = useIsMobile();\n  const [contactCreateOpen, setContactCreateOpen] = useState(false);\n  const [noteCreateOpen, setNoteCreateOpen] = useState(false);\n  return (\n    <>\n      <ContactCreateSheet\n        open={contactCreateOpen}\n        onOpenChange={setContactCreateOpen}\n      />\n      <NoteCreateSheet\n        open={noteCreateOpen}\n        onOpenChange={setNoteCreateOpen}\n        contact_id={contactId}\n      />\n      <div\n        className=\"flex justify-center items-center\"\n        style={{\n          height: isMobile ? undefined : `calc(100dvh - ${appbarHeight}px)`,\n        }}\n      >\n        <Card className=\"w-full max-w-[600px]\">\n          <CardContent className=\"px-6\">\n            <div className=\"flex items-center justify-between mb-8\">\n              <h3 className=\"text-lg font-bold\">\n                {translate(\"crm.dashboard.stepper.whats_next\", {\n                  _: \"What's next?\",\n                })}\n              </h3>\n              <div className=\"w-[150px]\">\n                <Progress value={(step / 3) * 100} className=\"mb-2\" />\n                <div className=\"text-right text-sm\">\n                  {translate(\"crm.dashboard.stepper.progress\", {\n                    _: `${step}/3 done`,\n                    step,\n                  })}\n                </div>\n              </div>\n            </div>\n            <div className=\"flex flex-col gap-12\">\n              <div className=\"flex gap-8 items-center\">\n                <CheckCircle className=\"text-green-600 w-5 h-5 shrink-0\" />\n                <h4 className=\"font-bold\">\n                  {translate(\"crm.dashboard.stepper.install\", {\n                    _: \"Install Atomic CRM\",\n                  })}\n                </h4>\n              </div>\n              <div className=\"flex gap-8 items-start\">\n                {step > 1 ? (\n                  <CheckCircle className=\"text-green-600 w-5 h-5 mt-1 shrink-0\" />\n                ) : (\n                  <Circle className=\"text-muted-foreground w-5 h-5 mt-1 shrink-0\" />\n                )}\n\n                <div className=\"flex flex-col gap-4\">\n                  <h4 className=\"font-bold\">\n                    {translate(\"resources.contacts.action.add_first\", {\n                      _: \"Add your first contact\",\n                    })}\n                  </h4>\n\n                  <div className=\"flex gap-8\">\n                    {isMobile ? (\n                      <Button\n                        onClick={() => setContactCreateOpen(true)}\n                        className=\"gap-2\"\n                        variant=\"outline\"\n                      >\n                        <Plus className=\"h-4 w-4\" />\n                        {translate(\"resources.contacts.action.new\", {\n                          _: \"New Contact\",\n                        })}\n                      </Button>\n                    ) : (\n                      <>\n                        <CreateButton\n                          label=\"resources.contacts.action.new\"\n                          resource=\"contacts\"\n                        />\n                        <ContactImportButton />\n                      </>\n                    )}\n                  </div>\n                </div>\n              </div>\n              <div className=\"flex gap-8 items-start\">\n                <Circle className=\"text-muted-foreground w-5 h-5 mt-1 shrink-0\" />\n                <div className=\"flex flex-col gap-4\">\n                  <h4 className=\"font-bold\">\n                    {translate(\"resources.notes.action.add_first\", {\n                      _: \"Add your first note\",\n                    })}\n                  </h4>\n                  <p>\n                    {translate(\"resources.notes.stepper.hint\", {\n                      _: \"Go to a contact page and add a note\",\n                    })}\n                  </p>\n                  {isMobile ? (\n                    <Button\n                      onClick={() => setNoteCreateOpen(true)}\n                      disabled={step < 2}\n                      className=\"w-fit gap-2\"\n                    >\n                      <Plus className=\"h-4 w-4\" />\n                      {translate(\"resources.notes.action.add\", {\n                        _: \"Add note\",\n                      })}\n                    </Button>\n                  ) : (\n                    <Button asChild disabled={step < 2} className=\"w-fit\">\n                      <Link role=\"button\" to={`/contacts/${contactId}/show`}>\n                        {translate(\"resources.notes.action.add\", {\n                          _: \"Add note\",\n                        })}\n                      </Link>\n                    </Button>\n                  )}\n                </div>\n              </div>\n            </div>\n          </CardContent>\n        </Card>\n      </div>\n    </>\n  );\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/dashboard/DashboardActivityLog.tsx",
      "content": "import { Clock } from \"lucide-react\";\nimport { useTranslate } from \"ra-core\";\nimport { Card } from \"@/components/ui/card\";\nimport { useIsMobile } from \"@/hooks/use-mobile\";\n\nimport { ActivityLog } from \"../activity/ActivityLog\";\n\nexport function DashboardActivityLog() {\n  const isMobile = useIsMobile();\n  const translate = useTranslate();\n  return (\n    <div className=\"flex flex-col\">\n      <div className=\"flex items-center mb-4 md:mb-2\">\n        <div className=\"mr-3 flex\">\n          <Clock className=\"text-muted-foreground w-6 h-6\" />\n        </div>\n        <h2 className=\"text-xl font-semibold text-muted-foreground\">\n          {translate(\"crm.dashboard.latest_activity\", {\n            _: \"Latest Activity\",\n          })}\n        </h2>\n      </div>\n      {isMobile ? (\n        <ActivityLog pageSize={10} />\n      ) : (\n        <Card className=\"mb-2 p-6\">\n          <ActivityLog pageSize={10} />\n        </Card>\n      )}\n    </div>\n  );\n}\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/dashboard/Dashboard.tsx",
      "content": "import { useGetList } from \"ra-core\";\n\nimport type { Contact, ContactNote } from \"../types\";\nimport { DashboardActivityLog } from \"./DashboardActivityLog\";\nimport { DashboardStepper } from \"./DashboardStepper\";\nimport { DealsChart } from \"./DealsChart\";\nimport { HotContacts } from \"./HotContacts\";\nimport { TasksList } from \"./TasksList\";\nimport { Welcome } from \"./Welcome\";\n\nexport const Dashboard = () => {\n  const {\n    data: dataContact,\n    total: totalContact,\n    isPending: isPendingContact,\n  } = useGetList<Contact>(\"contacts\", {\n    pagination: { page: 1, perPage: 1 },\n  });\n\n  const { total: totalContactNotes, isPending: isPendingContactNotes } =\n    useGetList<ContactNote>(\"contact_notes\", {\n      pagination: { page: 1, perPage: 1 },\n    });\n\n  const { total: totalDeal, isPending: isPendingDeal } = useGetList<Contact>(\n    \"deals\",\n    {\n      pagination: { page: 1, perPage: 1 },\n    },\n  );\n\n  const isPending = isPendingContact || isPendingContactNotes || isPendingDeal;\n\n  if (isPending) {\n    return null;\n  }\n\n  if (!totalContact) {\n    return <DashboardStepper step={1} />;\n  }\n\n  if (!totalContactNotes) {\n    return <DashboardStepper step={2} contactId={dataContact?.[0]?.id} />;\n  }\n\n  return (\n    <div className=\"grid grid-cols-1 md:grid-cols-12 gap-6 mt-1\">\n      <div className=\"md:col-span-3\">\n        <div className=\"flex flex-col gap-4\">\n          {import.meta.env.VITE_IS_DEMO === \"true\" ? <Welcome /> : null}\n          <HotContacts />\n        </div>\n      </div>\n      <div className=\"md:col-span-6\">\n        <div className=\"flex flex-col gap-6\">\n          {totalDeal ? <DealsChart /> : null}\n          <DashboardActivityLog />\n        </div>\n      </div>\n\n      <div className=\"md:col-span-3\">\n        <TasksList />\n      </div>\n    </div>\n  );\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/contacts/useContactImport.tsx",
      "content": "import { useDataProvider, useGetIdentity, type DataProvider } from \"ra-core\";\nimport { useCallback, useMemo } from \"react\";\n\nimport type { Company, Tag } from \"../types\";\n\nexport type ContactImportSchema = {\n  first_name: string;\n  last_name: string;\n  gender: string;\n  title: string;\n  company: string;\n  email_work: string;\n  email_home: string;\n  email_other: string;\n  phone_work: string;\n  phone_home: string;\n  phone_other: string;\n  background: string;\n  avatar: string;\n  first_seen: string;\n  last_seen: string;\n  has_newsletter: string;\n  status: string;\n  tags: string;\n  linkedin_url: string;\n};\n\nexport function useContactImport() {\n  const today = new Date().toISOString();\n  const user = useGetIdentity();\n  const dataProvider = useDataProvider();\n\n  // company cache to avoid creating the same company multiple times and costly roundtrips\n  // Cache is dependent of dataProvider, so it's safe to use it as a dependency\n  const companiesCache = useMemo(\n    () => new Map<string, Company>(),\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n    [dataProvider],\n  );\n  const getCompanies = useCallback(\n    async (names: string[]) =>\n      fetchRecordsWithCache<Company>(\n        \"companies\",\n        companiesCache,\n        names,\n        (name) => ({\n          name,\n          created_at: new Date().toISOString(),\n          sales_id: user?.identity?.id,\n        }),\n        dataProvider,\n      ),\n    [companiesCache, user?.identity?.id, dataProvider],\n  );\n\n  // Tags cache to avoid creating the same tag multiple times and costly roundtrips\n  // Cache is dependent of dataProvider, so it's safe to use it as a dependency\n  // eslint-disable-next-line react-hooks/exhaustive-deps\n  const tagsCache = useMemo(() => new Map<string, Tag>(), [dataProvider]);\n  const getTags = useCallback(\n    async (names: string[]) =>\n      fetchRecordsWithCache<Tag>(\n        \"tags\",\n        tagsCache,\n        names,\n        (name) => ({\n          name,\n          color: \"#f9f9f9\",\n        }),\n        dataProvider,\n      ),\n    [tagsCache, dataProvider],\n  );\n\n  const processBatch = useCallback(\n    async (batch: ContactImportSchema[]) => {\n      const [companies, tags] = await Promise.all([\n        getCompanies(\n          batch\n            .map((contact) => contact.company?.trim())\n            .filter((name) => name),\n        ),\n        getTags(batch.flatMap((batch) => parseTags(batch.tags))),\n      ]);\n\n      await Promise.all(\n        batch.map(\n          async ({\n            first_name,\n            last_name,\n            gender,\n            title,\n            email_work,\n            email_home,\n            email_other,\n            phone_work,\n            phone_home,\n            phone_other,\n            background,\n            first_seen,\n            last_seen,\n            has_newsletter,\n            status,\n            company: companyName,\n            tags: tagNames,\n            linkedin_url,\n          }) => {\n            const email_jsonb = [\n              { email: email_work, type: \"Work\" },\n              { email: email_home, type: \"Home\" },\n              { email: email_other, type: \"Other\" },\n            ].filter(({ email }) => email);\n            const phone_jsonb = [\n              { number: phone_work, type: \"Work\" },\n              { number: phone_home, type: \"Home\" },\n              { number: phone_other, type: \"Other\" },\n            ].filter(({ number }) => number);\n            const company = companyName?.trim()\n              ? companies.get(companyName.trim())\n              : undefined;\n            const tagList = parseTags(tagNames)\n              .map((name) => tags.get(name))\n              .filter((tag): tag is Tag => !!tag);\n\n            return dataProvider.create(\"contacts\", {\n              data: {\n                first_name,\n                last_name,\n                gender,\n                title,\n                email_jsonb,\n                phone_jsonb,\n                background,\n                first_seen: first_seen\n                  ? new Date(first_seen).toISOString()\n                  : today,\n                last_seen: last_seen\n                  ? new Date(last_seen).toISOString()\n                  : today,\n                has_newsletter,\n                status,\n                company_id: company?.id,\n                tags: tagList.map((tag) => tag.id),\n                sales_id: user?.identity?.id,\n                linkedin_url,\n              },\n            });\n          },\n        ),\n      );\n    },\n    [dataProvider, getCompanies, getTags, user?.identity?.id, today],\n  );\n\n  return processBatch;\n}\n\nconst fetchRecordsWithCache = async function <T>(\n  resource: string,\n  cache: Map<string, T>,\n  names: string[],\n  getCreateData: (name: string) => Partial<T>,\n  dataProvider: DataProvider,\n) {\n  const trimmedNames = [...new Set(names.map((name) => name.trim()))];\n  const uncachedRecordNames = trimmedNames.filter((name) => !cache.has(name));\n\n  // check the backend for existing records\n  if (uncachedRecordNames.length > 0) {\n    const response = await dataProvider.getList(resource, {\n      filter: {\n        \"name@in\": `(${uncachedRecordNames\n          .map((name) => `\"${name}\"`)\n          .join(\",\")})`,\n      },\n      pagination: { page: 1, perPage: trimmedNames.length },\n      sort: { field: \"id\", order: \"ASC\" },\n    });\n    for (const record of response.data) {\n      cache.set(record.name.trim(), record);\n    }\n  }\n\n  // create missing records in parallel\n  await Promise.all(\n    uncachedRecordNames.map(async (name) => {\n      if (cache.has(name)) return;\n      const response = await dataProvider.create(resource, {\n        data: getCreateData(name),\n      });\n      cache.set(name, response.data);\n    }),\n  );\n\n  // now all records are in cache, return a map of all records\n  return trimmedNames.reduce((acc, name) => {\n    acc.set(name, cache.get(name) as T);\n    return acc;\n  }, new Map<string, T>());\n};\n\nconst parseTags = (tags: string) =>\n  tags\n    ?.split(\",\")\n    ?.map((tag: string) => tag.trim())\n    ?.filter((tag: string) => tag) ?? [];\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/contacts/index.tsx",
      "content": "import type { Contact } from \"../types\";\nimport { ContactCreate } from \"./ContactCreate\";\nimport { ContactEdit } from \"./ContactEdit\";\nimport { ContactList } from \"./ContactList\";\nimport { ContactShow } from \"./ContactShow\";\n\nexport default {\n  list: ContactList,\n  show: ContactShow,\n  edit: ContactEdit,\n  create: ContactCreate,\n  recordRepresentation: (record: Contact) =>\n    record?.first_name + \" \" + record?.last_name,\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/contacts/contactModel.ts",
      "content": "import { Mars, NonBinary, Venus } from \"lucide-react\";\n\nimport type { Company, Contact, ContactGender } from \"../types\";\n\nexport const defaultEmailJsonb = [{ email: null, type: null }];\nexport const defaultPhoneJsonb = [{ number: null, type: null }];\n\nconst cleanContactArrayFields = (data: Contact) => {\n  const cleanedEmailJsonb =\n    data.email_jsonb?.filter((e) => e.email != null) || [];\n  const cleanedPhoneJsonb =\n    data.phone_jsonb?.filter((p) => p.number != null) || [];\n  return {\n    ...data,\n    phone_jsonb: cleanedPhoneJsonb.length > 0 ? cleanedPhoneJsonb : null,\n    email_jsonb: cleanedEmailJsonb.length > 0 ? cleanedEmailJsonb : null,\n  };\n};\n\nexport const cleanupContactForCreate = (data: Contact) => {\n  return cleanContactArrayFields({\n    ...data,\n    first_seen: new Date().toISOString(),\n    last_seen: new Date().toISOString(),\n    tags: [],\n  });\n};\n\nexport const cleanupContactForEdit = cleanContactArrayFields;\n\ntype TranslateFn = (key: string, options?: { [key: string]: any }) => string;\n\nexport const contactGenderDefaultLabels: Record<string, string> = {\n  male: \"He/Him\",\n  female: \"She/Her\",\n  nonbinary: \"They/Them\",\n};\n\nconst personalInfoTypeMap: Record<string, string> = {\n  Work: \"work\",\n  Home: \"home\",\n  Other: \"other\",\n};\n\nexport const contactGender: ContactGender[] = [\n  {\n    value: \"male\",\n    label: \"resources.contacts.inputs.genders.male\",\n    icon: Mars,\n  },\n  {\n    value: \"female\",\n    label: \"resources.contacts.inputs.genders.female\",\n    icon: Venus,\n  },\n  {\n    value: \"nonbinary\",\n    label: \"resources.contacts.inputs.genders.nonbinary\",\n    icon: NonBinary,\n  },\n];\n\nexport const translateContactGenderLabel = (\n  gender: { value: string; label: string },\n  translate: TranslateFn,\n) =>\n  translate(gender.label, {\n    _: contactGenderDefaultLabels[gender.value] ?? gender.label,\n  });\n\nexport const translatePersonalInfoTypeLabel = (\n  type: string,\n  translate: TranslateFn,\n) =>\n  translate(\n    `resources.contacts.inputs.personal_info_types.${personalInfoTypeMap[type] ?? type.toLowerCase()}`,\n    {\n      _: type,\n    },\n  );\n\n/**\n * Folds a long line according to vCard specification (max 75 chars per line)\n * Continuation lines start with a space\n */\nfunction foldLine(line: string): string {\n  const maxLength = 75;\n  if (line.length <= maxLength) return line;\n\n  const result: string[] = [];\n  let currentLine = line.substring(0, maxLength);\n  let remaining = line.substring(maxLength);\n\n  result.push(currentLine);\n\n  while (remaining.length > 0) {\n    // Continuation lines start with a space and can have 74 more chars\n    const chunkSize = maxLength - 1;\n    currentLine = \" \" + remaining.substring(0, chunkSize);\n    remaining = remaining.substring(chunkSize);\n    result.push(currentLine);\n  }\n\n  return result.join(\"\\r\\n\");\n}\n\n/**\n * Converts a contact and their company to vCard 3.0 format\n */\nexport function exportToVCard(\n  contact: Contact,\n  company?: Company,\n  photoData?: { base64: string; mimeType: string },\n): string {\n  const lines: string[] = [];\n\n  // vCard header\n  lines.push(\"BEGIN:VCARD\");\n  lines.push(\"VERSION:3.0\");\n\n  // Name (N: Family Name;Given Name;Additional Names;Honorific Prefixes;Honorific Suffixes)\n  lines.push(`N:${contact.last_name};${contact.first_name};;;`);\n\n  // Formatted name\n  lines.push(`FN:${contact.first_name} ${contact.last_name}`);\n\n  // Title/Job position\n  if (contact.title) {\n    lines.push(`TITLE:${contact.title}`);\n  }\n\n  // Organization\n  if (company?.name) {\n    lines.push(`ORG:${company.name}`);\n  }\n\n  // Emails\n  if (contact.email_jsonb && contact.email_jsonb.length > 0) {\n    contact.email_jsonb.forEach((emailObj) => {\n      const type = emailObj.type.toUpperCase();\n      lines.push(`EMAIL;TYPE=${type}:${emailObj.email}`);\n    });\n  }\n\n  // Phone numbers\n  if (contact.phone_jsonb && contact.phone_jsonb.length > 0) {\n    contact.phone_jsonb.forEach((phoneObj) => {\n      const type = phoneObj.type.toUpperCase();\n      lines.push(`TEL;TYPE=${type}:${phoneObj.number}`);\n    });\n  }\n\n  // LinkedIn URL\n  if (contact.linkedin_url) {\n    lines.push(`URL:${contact.linkedin_url}`);\n  }\n\n  // Background/Note\n  if (contact.background) {\n    // Escape newlines and special characters in notes\n    const escapedNote = contact.background\n      .replace(/\\\\/g, \"\\\\\\\\\")\n      .replace(/\\n/g, \"\\\\n\")\n      .replace(/,/g, \"\\\\,\")\n      .replace(/;/g, \"\\\\;\");\n    lines.push(`NOTE:${escapedNote}`);\n  }\n\n  // Photo/Avatar - vCard 3.0 format with base64 encoding\n  if (photoData) {\n    // Extract image type from MIME type (e.g., \"image/png\" -> \"PNG\")\n    const imageType = photoData.mimeType.split(\"/\")[1]?.toUpperCase() || \"PNG\";\n\n    // vCard 3.0 format: PHOTO;ENCODING=b;TYPE=PNG:\n    const photoLine = `PHOTO;ENCODING=b;TYPE=${imageType}:${photoData.base64}`;\n    lines.push(foldLine(photoLine));\n  }\n\n  // vCard footer\n  lines.push(\"END:VCARD\");\n\n  return lines.join(\"\\r\\n\");\n}\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/contacts/TagsListEdit.tsx",
      "content": "import { Edit, Plus } from \"lucide-react\";\nimport {\n  useGetMany,\n  useRecordContext,\n  useTranslate,\n  useUpdate,\n  type Identifier,\n} from \"ra-core\";\nimport { useCallback, useState } from \"react\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\n\nimport { TagChip } from \"../tags/TagChip\";\nimport { TagCreateModal } from \"../tags/TagCreateModal\";\nimport { useTags } from \"../tags/useTags\";\nimport type { Contact, Tag } from \"../types\";\n\nexport const TagsListEdit = () => {\n  const record = useRecordContext<Contact>();\n  const [open, setOpen] = useState(false);\n  const translate = useTranslate();\n\n  const { data: allTags, isPending: isPendingAllTags } = useTags({\n    perPage: 10,\n  });\n  const { data: tags, isPending: isPendingRecordTags } = useGetMany<Tag>(\n    \"tags\",\n    { ids: record?.tags },\n    { enabled: record && record.tags && record.tags.length > 0 },\n  );\n  const [update] = useUpdate<Contact>();\n\n  const unselectedTags =\n    allTags &&\n    record &&\n    allTags.filter((tag) => !record.tags?.includes(tag.id));\n\n  const handleTagAdd = (id: number) => {\n    if (!record) {\n      throw new Error(\"No contact record found\");\n    }\n    const tags = [...(record.tags ?? []), id];\n    update(\"contacts\", {\n      id: record.id,\n      data: { tags },\n      previousData: record,\n    });\n  };\n\n  const handleTagDelete = async (id: Identifier) => {\n    if (!record) {\n      throw new Error(\"No contact record found\");\n    }\n    const tags = record.tags.filter((tagId) => tagId !== id);\n    await update(\"contacts\", {\n      id: record.id,\n      data: { tags },\n      previousData: record,\n    });\n  };\n\n  const openTagCreateDialog = () => {\n    setOpen(true);\n  };\n\n  const handleTagCreateClose = () => {\n    setOpen(false);\n  };\n\n  const handleTagCreated = useCallback(\n    async (tag: Tag) => {\n      if (!record) {\n        throw new Error(\"No contact record found\");\n      }\n\n      await update(\n        \"contacts\",\n        {\n          id: record.id,\n          data: { tags: [...record.tags, tag.id] },\n          previousData: record,\n        },\n        {\n          onSuccess: () => {\n            setOpen(false);\n          },\n        },\n      );\n    },\n    [update, record],\n  );\n\n  if (isPendingRecordTags || isPendingAllTags) return null;\n\n  return (\n    <div className=\"flex flex-wrap gap-2\">\n      {tags?.map((tag) => (\n        <div key={tag.id}>\n          <TagChip tag={tag} onUnlink={() => handleTagDelete(tag.id)} />\n        </div>\n      ))}\n\n      <div>\n        <DropdownMenu>\n          <DropdownMenuTrigger asChild>\n            <Button\n              variant=\"outline\"\n              size=\"sm\"\n              className=\"h-9 md:h-6 cursor-pointer\"\n            >\n              <Plus className=\"w-4 h-4 md:w-3 md:h-3 mr-1\" />\n              {translate(\"resources.tags.action.add\")}\n            </Button>\n          </DropdownMenuTrigger>\n          <DropdownMenuContent>\n            {unselectedTags?.map((tag) => (\n              <DropdownMenuItem\n                key={tag.id}\n                onClick={() => handleTagAdd(tag.id)}\n              >\n                <Badge\n                  variant=\"secondary\"\n                  className=\"text-sm md:text-xs font-normal text-black\"\n                  style={{\n                    backgroundColor: tag.color,\n                  }}\n                >\n                  {tag.name}\n                </Badge>\n              </DropdownMenuItem>\n            ))}\n            <DropdownMenuItem onClick={openTagCreateDialog}>\n              <Button\n                variant=\"ghost\"\n                size=\"sm\"\n                className=\"w-full justify-start p-0 cursor-pointer text-base md:text-sm\"\n              >\n                <Edit className=\"w-4 h-4 md:w-3 md:h-3 mr-2\" />\n                {translate(\"resources.tags.action.create\")}\n              </Button>\n            </DropdownMenuItem>\n          </DropdownMenuContent>\n        </DropdownMenu>\n      </div>\n\n      <TagCreateModal\n        open={open}\n        onClose={handleTagCreateClose}\n        onSuccess={handleTagCreated}\n      />\n    </div>\n  );\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/contacts/TagsList.tsx",
      "content": "import { useRecordContext } from \"ra-core\";\nimport { ReferenceArrayField } from \"@/components/admin/reference-array-field\";\nimport { SingleFieldList } from \"@/components/admin/single-field-list\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { cn } from \"@/lib/utils\";\n\nconst ColoredBadge = (props: any) => {\n  const record = useRecordContext();\n  if (!record) return null;\n  return (\n    <Badge\n      {...props}\n      style={{ backgroundColor: record.color, border: 0 }}\n      variant=\"outline\"\n      className={cn(\"text-black font-normal\", props.className)}\n    >\n      {record.name}\n    </Badge>\n  );\n};\n\nexport const TagsList = () => (\n  <ReferenceArrayField\n    className=\"inline-block\"\n    resource=\"contacts\"\n    source=\"tags\"\n    reference=\"tags\"\n  >\n    <SingleFieldList>\n      <ColoredBadge source=\"name\" />\n    </SingleFieldList>\n  </ReferenceArrayField>\n);\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/contacts/ExportVCardButton.tsx",
      "content": "import { Download } from \"lucide-react\";\nimport { useGetOne, useRecordContext, useTranslate } from \"ra-core\";\nimport { Button } from \"@/components/ui/button\";\nimport type { Contact, Company } from \"../types\";\nimport { exportToVCard } from \"./contactModel\";\n\nexport const ExportVCardButton = () => {\n  const contact = useRecordContext<Contact>();\n  const translate = useTranslate();\n\n  // Fetch the company data on mount\n  const { data: company } = useGetOne<Company>(\n    \"companies\",\n    { id: contact?.company_id ?? undefined },\n    { enabled: !!contact?.company_id },\n  );\n\n  const handleExport = async () => {\n    if (!contact) return;\n\n    // Fetch and convert avatar to base64 if it exists\n    let photoData: { base64: string; mimeType: string } | undefined = undefined;\n\n    if (contact.avatar?.src) {\n      try {\n        const response = await fetch(contact.avatar.src);\n        const blob = await response.blob();\n        const mimeType = blob.type || \"image/png\";\n\n        // Convert blob to base64\n        const base64 = await new Promise<string>((resolve, reject) => {\n          const reader = new FileReader();\n          reader.onloadend = () => {\n            const result = reader.result as string;\n            // Remove the data URL prefix (data:image/png;base64,)\n            const base64Data = result.split(\",\")[1];\n            resolve(base64Data);\n          };\n          reader.onerror = reject;\n          reader.readAsDataURL(blob);\n        });\n\n        photoData = { base64, mimeType };\n      } catch (error) {\n        console.error(\"Failed to fetch avatar image:\", error);\n        // Continue without the photo\n      }\n    }\n\n    // Generate vCard content\n    const vCardContent = exportToVCard(contact, company, photoData);\n\n    // Create blob and download\n    const blob = new Blob([vCardContent], {\n      type: \"text/vcard;charset=utf-8\",\n    });\n    const url = URL.createObjectURL(blob);\n    const link = document.createElement(\"a\");\n    link.href = url;\n    link.download = `${contact.first_name}_${contact.last_name}.vcf`;\n    document.body.appendChild(link);\n    link.click();\n    document.body.removeChild(link);\n    URL.revokeObjectURL(url);\n  };\n\n  if (!contact) return null;\n\n  return (\n    <Button\n      variant=\"outline\"\n      size=\"sm\"\n      onClick={handleExport}\n      className=\"h-6 cursor-pointer\"\n    >\n      <Download className=\"w-4 h-4\" />\n      {translate(\"resources.contacts.action.export_vcard\", {\n        _: \"Export to vCard\",\n      })}\n    </Button>\n  );\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/contacts/ContactTasksList.tsx",
      "content": "import { useState } from \"react\";\nimport { useRecordContext, useTranslate } from \"ra-core\";\nimport { Skeleton } from \"@/components/ui/skeleton\";\nimport { Button } from \"@/components/ui/button\";\n\nimport { TasksListByDueDate } from \"../tasks/TasksListByDueDate\";\nimport { TaskCreateSheet } from \"../tasks/TaskCreateSheet\";\nimport type { Contact } from \"../types\";\n\nexport const ContactTasksList = () => {\n  const record = useRecordContext<Contact>();\n  const translate = useTranslate();\n  const [taskCreateOpen, setTaskCreateOpen] = useState(false);\n\n  if (!record) return null;\n\n  return (\n    <TasksListByDueDate\n      filterByContact={record.id}\n      emptyPlaceholder={\n        <>\n          <TaskCreateSheet\n            open={taskCreateOpen}\n            onOpenChange={setTaskCreateOpen}\n            contact_id={record.id}\n          />\n          <div className=\"flex flex-col items-center justify-center py-8 text-center\">\n            <p className=\"text-muted-foreground mb-4\">\n              {translate(\"resources.tasks.empty\")}\n            </p>\n            <Button variant=\"outline\" onClick={() => setTaskCreateOpen(true)}>\n              {translate(\"resources.tasks.action.add\")}\n            </Button>\n          </div>\n        </>\n      }\n      pendingPlaceholder={\n        <div className=\"flex flex-col gap-4\">\n          {Array.from({ length: 3 }).map((_, index) => (\n            <Skeleton className=\"w-full h-10\" key={index} />\n          ))}\n        </div>\n      }\n    />\n  );\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/contacts/ContactShow.tsx",
      "content": "import { useState } from \"react\";\nimport {\n  InfiniteListBase,\n  RecordRepresentation,\n  ShowBase,\n  useShowContext,\n  useTranslate,\n} from \"ra-core\";\nimport type { ShowBaseProps } from \"ra-core\";\nimport { useIsMobile } from \"@/hooks/use-mobile\";\nimport { ReferenceField } from \"@/components/admin/reference-field\";\nimport { TextField } from \"@/components/admin/text-field\";\nimport { Card, CardContent } from \"@/components/ui/card\";\nimport { Tabs, TabsList, TabsTrigger, TabsContent } from \"@/components/ui/tabs\";\nimport { Button } from \"@/components/ui/button\";\nimport { Separator } from \"@/components/ui/separator\";\nimport { Pencil } from \"lucide-react\";\nimport { Link } from \"react-router\";\n\nimport MobileHeader from \"../layout/MobileHeader\";\nimport { MobileContent } from \"../layout/MobileContent\";\nimport { CompanyAvatar } from \"../companies/CompanyAvatar\";\nimport { NoteCreate, NotesIterator, NotesIteratorMobile } from \"../notes\";\nimport { NoteCreateSheet } from \"../notes/NoteCreateSheet\";\nimport { TagsListEdit } from \"./TagsListEdit\";\nimport { ContactEditSheet } from \"./ContactEditSheet\";\nimport { ContactStatusSelector } from \"./ContactInputs\";\nimport { ContactPersonalInfo } from \"./ContactPersonalInfo\";\nimport { ContactBackgroundInfo } from \"./ContactBackgroundInfo\";\nimport { ContactTasksList } from \"./ContactTasksList\";\nimport type { Contact } from \"../types\";\nimport { Avatar } from \"./Avatar\";\nimport { ContactAside } from \"./ContactAside\";\nimport { MobileBackButton } from \"../misc/MobileBackButton\";\n\nexport const ContactShow = (props: ShowBaseProps = {}) => {\n  const isMobile = useIsMobile();\n\n  return (\n    <ShowBase\n      queryOptions={{\n        onError: isMobile\n          ? () => {\n              {\n                /** Disable error notification as the content handles offline */\n              }\n            }\n          : undefined,\n      }}\n      {...props}\n    >\n      {isMobile ? <ContactShowContentMobile /> : <ContactShowContent />}\n    </ShowBase>\n  );\n};\n\nconst ContactShowContentMobile = () => {\n  const translate = useTranslate();\n  const { defaultTitle, record, isPending } = useShowContext<Contact>();\n  const [noteCreateOpen, setNoteCreateOpen] = useState(false);\n  const [editOpen, setEditOpen] = useState(false);\n  if (isPending || !record) return null;\n\n  const taskCount = record.nb_tasks ?? 0;\n\n  return (\n    <>\n      {/* We need to repeat the note creation sheet here to support the note \n      create button that is rendered when there are no notes. */}\n      <NoteCreateSheet\n        open={noteCreateOpen}\n        onOpenChange={setNoteCreateOpen}\n        contact_id={record.id}\n      />\n      <ContactEditSheet\n        open={editOpen}\n        onOpenChange={setEditOpen}\n        contactId={record.id}\n      />\n      <MobileHeader>\n        <MobileBackButton />\n        <div className=\"flex flex-1 min-w-0\">\n          <Link to=\"/contacts\" className=\"flex-1 min-w-0\">\n            <h1 className=\"truncate text-xl font-semibold\">{defaultTitle}</h1>\n          </Link>\n        </div>\n        <Button\n          type=\"button\"\n          variant=\"ghost\"\n          size=\"icon\"\n          className=\"rounded-full\"\n          aria-label={translate(\"ra.action.edit\")}\n          onClick={() => setEditOpen(true)}\n        >\n          <Pencil className=\"size-5\" />\n        </Button>\n      </MobileHeader>\n      <MobileContent>\n        <div className=\"mb-6\">\n          <div className=\"flex items-center mb-4\">\n            <Avatar />\n            <div className=\"mx-3 flex-1\">\n              <h2 className=\"text-2xl font-bold\">\n                <RecordRepresentation />\n              </h2>\n              <div className=\"text-sm text-muted-foreground\">\n                {record.title && record.company_id != null\n                  ? `${translate(\"resources.contacts.position_at\", {\n                      title: record.title,\n                    })} `\n                  : record.title}\n                {record.company_id != null && (\n                  <ReferenceField\n                    source=\"company_id\"\n                    reference=\"companies\"\n                    link=\"show\"\n                  >\n                    <TextField source=\"name\" className=\"underline\" />\n                  </ReferenceField>\n                )}\n              </div>\n            </div>\n            <div>\n              <ReferenceField\n                source=\"company_id\"\n                reference=\"companies\"\n                link=\"show\"\n                className=\"no-underline\"\n              >\n                <CompanyAvatar />\n              </ReferenceField>\n            </div>\n          </div>\n        </div>\n\n        <Tabs defaultValue=\"notes\" className=\"w-full\">\n          <TabsList className=\"grid w-full grid-cols-3 h-10\">\n            <TabsTrigger value=\"notes\">\n              {translate(\"resources.notes.name\", { smart_count: 2 })}\n            </TabsTrigger>\n            <TabsTrigger value=\"tasks\">\n              {translate(\"crm.common.task_count\", {\n                smart_count: taskCount ?? 0,\n              })}\n            </TabsTrigger>\n            <TabsTrigger value=\"details\">\n              {translate(\"crm.common.details\")}\n            </TabsTrigger>\n          </TabsList>\n\n          <TabsContent value=\"notes\" className=\"mt-2\">\n            <InfiniteListBase\n              resource=\"contact_notes\"\n              filter={{ contact_id: record.id }}\n              sort={{ field: \"date\", order: \"DESC\" }}\n              perPage={25}\n              disableSyncWithLocation\n              storeKey={false}\n              empty={\n                <div className=\"flex flex-col items-center justify-center py-8 text-center\">\n                  <p className=\"text-muted-foreground mb-4\">\n                    {translate(\"resources.notes.empty\")}\n                  </p>\n                  <Button\n                    variant=\"outline\"\n                    onClick={() => setNoteCreateOpen(true)}\n                  >\n                    {translate(\"resources.notes.action.add\")}\n                  </Button>\n                </div>\n              }\n              loading={false}\n              error={false}\n              queryOptions={{\n                onError: () => {\n                  /** override to hide notification as error case is handled by NotesIteratorMobile */\n                },\n              }}\n            >\n              <NotesIteratorMobile contactId={record.id} showStatus />\n            </InfiniteListBase>\n          </TabsContent>\n\n          <TabsContent value=\"tasks\" className=\"mt-4\">\n            <ContactTasksList />\n          </TabsContent>\n\n          <TabsContent value=\"details\" className=\"mt-4\">\n            <div className=\"space-y-6\">\n              <div>\n                <h3 className=\"text-lg font-semibold\">\n                  {translate(\"resources.notes.fields.status\")}\n                </h3>\n                <Separator />\n                <div className=\"mt-3\">\n                  <ContactStatusSelector />\n                </div>\n              </div>\n              <div>\n                <h3 className=\"text-lg font-semibold\">\n                  {translate(\n                    \"resources.contacts.field_categories.personal_info\",\n                  )}\n                </h3>\n                <Separator />\n                <div className=\"mt-3\">\n                  <ContactPersonalInfo />\n                </div>\n              </div>\n              <div>\n                <h3 className=\"text-lg font-semibold\">\n                  {translate(\n                    \"resources.contacts.field_categories.background_info\",\n                  )}\n                </h3>\n                <Separator />\n                <div className=\"mt-3\">\n                  <ContactBackgroundInfo />\n                </div>\n              </div>\n              <div>\n                <h3 className=\"text-lg font-semibold\">\n                  {translate(\"resources.tags.name\", { smart_count: 2 })}\n                </h3>\n                <Separator />\n                <div className=\"mt-3\">\n                  <TagsListEdit />\n                </div>\n              </div>\n            </div>\n          </TabsContent>\n        </Tabs>\n      </MobileContent>\n    </>\n  );\n};\n\nconst ContactShowContent = () => {\n  const translate = useTranslate();\n  const { record, isPending } = useShowContext<Contact>();\n  if (isPending || !record) return null;\n\n  return (\n    <div className=\"mt-2 mb-2 flex gap-8\">\n      <div className=\"flex-1\">\n        <Card>\n          <CardContent>\n            <div className=\"flex\">\n              <Avatar />\n              <div className=\"ml-2 flex-1\">\n                <h5 className=\"text-xl font-semibold\">\n                  <RecordRepresentation />\n                </h5>\n                <div className=\"inline-flex text-sm text-muted-foreground\">\n                  {record.title && record.company_id != null\n                    ? `${translate(\"resources.contacts.position_at\", {\n                        title: record.title,\n                      })} `\n                    : record.title}\n                  {record.company_id != null && (\n                    <ReferenceField\n                      source=\"company_id\"\n                      reference=\"companies\"\n                      link=\"show\"\n                    >\n                      &nbsp;\n                      <TextField source=\"name\" />\n                    </ReferenceField>\n                  )}\n                </div>\n              </div>\n              <div>\n                <ReferenceField\n                  source=\"company_id\"\n                  reference=\"companies\"\n                  link=\"show\"\n                  className=\"no-underline\"\n                >\n                  <CompanyAvatar />\n                </ReferenceField>\n              </div>\n            </div>\n            <InfiniteListBase\n              resource=\"contact_notes\"\n              filter={{ contact_id: record.id }}\n              sort={{ field: \"date\", order: \"DESC\" }}\n              perPage={25}\n              disableSyncWithLocation\n              storeKey={false}\n              empty={\n                <NoteCreate reference=\"contacts\" showStatus className=\"mt-4\" />\n              }\n            >\n              <NotesIterator reference=\"contacts\" showStatus />\n            </InfiniteListBase>\n          </CardContent>\n        </Card>\n      </div>\n      <ContactAside />\n    </div>\n  );\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/contacts/ContactPersonalInfo.tsx",
      "content": "import { useState } from \"react\";\nimport { useRecordContext, useTranslate, WithRecord } from \"ra-core\";\nimport { ArrayField } from \"@/components/admin/array-field\";\nimport { SingleFieldList } from \"@/components/admin/single-field-list\";\nimport { TextField } from \"@/components/admin/text-field\";\nimport { EmailField } from \"@/components/admin/email-field\";\nimport { Mail, Phone, Linkedin, Check } from \"lucide-react\";\nimport type { ReactNode } from \"react\";\nimport {\n  contactGender,\n  translateContactGenderLabel,\n  translatePersonalInfoTypeLabel,\n} from \"./contactModel\";\nimport type { Contact } from \"../types\";\n\nexport const ContactPersonalInfo = () => {\n  const record = useRecordContext<Contact>();\n  const translate = useTranslate();\n\n  if (!record) return null;\n\n  return (\n    <div>\n      <ArrayField source=\"email_jsonb\">\n        <SingleFieldList className=\"flex-col gap-y-0\">\n          <EmailRow />\n        </SingleFieldList>\n      </ArrayField>\n\n      {record.has_newsletter && (\n        <p className=\"pl-6 py-1 text-sm text-muted-foreground\">\n          {translate(\"resources.contacts.fields.has_newsletter\")}\n        </p>\n      )}\n\n      {record.linkedin_url && (\n        <PersonalInfoRow\n          icon={<Linkedin className=\"w-4 h-4 text-muted-foreground\" />}\n          primary={\n            <a\n              className=\"underline hover:no-underline text-sm text-muted-foreground\"\n              href={record.linkedin_url}\n              target=\"_blank\"\n              rel=\"noopener noreferrer\"\n              title={record.linkedin_url}\n            >\n              LinkedIn\n            </a>\n          }\n        />\n      )}\n      <ArrayField source=\"phone_jsonb\">\n        <SingleFieldList className=\"flex-col gap-y-0\">\n          <PersonalInfoRow\n            icon={<Phone className=\"w-4 h-4 text-muted-foreground\" />}\n            primary={<TextField source=\"number\" />}\n            showType\n          />\n        </SingleFieldList>\n      </ArrayField>\n      {contactGender\n        .map((genderOption) => {\n          if (record.gender === genderOption.value) {\n            return (\n              <PersonalInfoRow\n                key={genderOption.value}\n                icon={\n                  <genderOption.icon className=\"w-4 h-4 text-muted-foreground\" />\n                }\n                primary={\n                  <div>\n                    {translateContactGenderLabel(genderOption, translate)}\n                  </div>\n                }\n              />\n            );\n          }\n          return null;\n        })\n        .filter(Boolean)}\n    </div>\n  );\n};\n\nconst EmailRow = () => {\n  const record = useRecordContext<{ email: string }>();\n  const translate = useTranslate();\n  const [copied, setCopied] = useState(false);\n\n  if (!record) return null;\n\n  const handleCopy = () => {\n    navigator.clipboard.writeText(record.email).then(() => {\n      setCopied(true);\n      setTimeout(() => setCopied(false), 2000);\n    });\n  };\n\n  return (\n    <PersonalInfoRow\n      icon={\n        <button\n          type=\"button\"\n          onClick={handleCopy}\n          title={translate(\"crm.common.copy\")}\n          className=\"text-muted-foreground hover:text-foreground transition-colors cursor-pointer\"\n        >\n          {copied ? (\n            <Check className=\"w-4 h-4 text-green-500\" />\n          ) : (\n            <Mail className=\"w-4 h-4\" />\n          )}\n        </button>\n      }\n      primary={<EmailField source=\"email\" />}\n    />\n  );\n};\n\nconst PersonalInfoRow = ({\n  icon,\n  primary,\n  showType,\n}: {\n  icon: ReactNode;\n  primary: ReactNode;\n  showType?: boolean;\n}) => {\n  const translate = useTranslate();\n\n  return (\n    <div className=\"flex flex-row items-center gap-x-2 py-1 min-h-6\">\n      {icon}\n      <div className=\"flex flex-wrap gap-x-2 gap-y-0 text-sm\">\n        {primary}\n        {showType ? (\n          <WithRecord\n            render={(row) =>\n              row.type !== \"Other\" && (\n                <span className=\"text-muted-foreground\">\n                  {translatePersonalInfoTypeLabel(row.type, translate)}\n                </span>\n              )\n            }\n          />\n        ) : null}\n      </div>\n    </div>\n  );\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/contacts/ContactMergeButton.tsx",
      "content": "import { useState, useEffect } from \"react\";\nimport { Merge, CircleX, AlertTriangle, ArrowDown } from \"lucide-react\";\nimport {\n  useDataProvider,\n  useRecordContext,\n  useGetList,\n  useGetManyReference,\n  required,\n  Form,\n  useNotify,\n  useRedirect,\n  useTranslate,\n} from \"ra-core\";\nimport type { Identifier } from \"ra-core\";\nimport { useMutation } from \"@tanstack/react-query\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogHeader,\n  DialogTitle,\n  DialogDescription,\n  DialogFooter,\n} from \"@/components/ui/dialog\";\nimport { Button } from \"@/components/ui/button\";\nimport { ReferenceInput } from \"@/components/admin/reference-input\";\nimport { AutocompleteInput } from \"@/components/admin/autocomplete-input\";\nimport { Alert, AlertDescription, AlertTitle } from \"@/components/ui/alert\";\nimport type { Contact } from \"../types\";\nimport { contactOptionText } from \"../misc/ContactOption\";\n\nexport const ContactMergeButton = () => {\n  const translate = useTranslate();\n  const [mergeDialogOpen, setMergeDialogOpen] = useState(false);\n  return (\n    <>\n      <Button\n        variant=\"outline\"\n        className=\"h-6 cursor-pointer\"\n        size=\"sm\"\n        onClick={() => setMergeDialogOpen(true)}\n      >\n        <Merge className=\"w-4 h-4\" />\n        {translate(\"resources.contacts.merge.action\", {\n          _: \"Merge with another contact\",\n        })}\n      </Button>\n      <ContactMergeDialog\n        open={mergeDialogOpen}\n        onClose={() => setMergeDialogOpen(false)}\n      />\n    </>\n  );\n};\n\ninterface ContactMergeDialogProps {\n  open: boolean;\n  onClose: () => void;\n}\n\nconst ContactMergeDialog = ({ open, onClose }: ContactMergeDialogProps) => {\n  const loserContact = useRecordContext<Contact>();\n  const notify = useNotify();\n  const redirect = useRedirect();\n  const translate = useTranslate();\n  const dataProvider = useDataProvider();\n  const [winnerId, setWinnerId] = useState<Identifier | null>(null);\n  const [suggestedWinnerId, setSuggestedWinnerId] = useState<Identifier | null>(\n    null,\n  );\n  const [isMerging, setIsMerging] = useState(false);\n  const { mutateAsync } = useMutation({\n    mutationKey: [\"contacts\", \"merge\", { loserId: loserContact?.id, winnerId }],\n    mutationFn: async () => {\n      return dataProvider.mergeContacts(loserContact?.id, winnerId);\n    },\n  });\n\n  // Find potential contacts with matching first and last name\n  const { data: matchingContacts } = useGetList(\n    \"contacts\",\n    {\n      filter: {\n        first_name: loserContact?.first_name,\n        last_name: loserContact?.last_name,\n        \"id@neq\": `${loserContact?.id}`, // Exclude current contact\n      },\n      pagination: { page: 1, perPage: 10 },\n    },\n    { enabled: open && !!loserContact },\n  );\n\n  // Get counts of items to be merged\n  const canFetchCounts = open && !!loserContact && !!winnerId;\n  const { total: tasksCount } = useGetManyReference(\n    \"tasks\",\n    {\n      target: \"contact_id\",\n      id: loserContact?.id,\n      pagination: { page: 1, perPage: 1 },\n    },\n    { enabled: canFetchCounts },\n  );\n\n  const { total: notesCount } = useGetManyReference(\n    \"contact_notes\",\n    {\n      target: \"contact_id\",\n      id: loserContact?.id,\n      pagination: { page: 1, perPage: 1 },\n    },\n    { enabled: canFetchCounts },\n  );\n\n  const { total: dealsCount } = useGetList(\n    \"deals\",\n    {\n      filter: { \"contact_ids@cs\": `{${loserContact?.id}}` },\n      pagination: { page: 1, perPage: 1 },\n    },\n    { enabled: canFetchCounts },\n  );\n\n  useEffect(() => {\n    if (matchingContacts && matchingContacts.length > 0) {\n      const suggestedWinnerId = matchingContacts[0].id;\n      setSuggestedWinnerId(suggestedWinnerId);\n      setWinnerId(suggestedWinnerId);\n    }\n  }, [matchingContacts]);\n\n  const handleMerge = async () => {\n    if (!winnerId || !loserContact) {\n      notify(\"resources.contacts.merge.select_target\", {\n        type: \"warning\",\n        messageArgs: {\n          _: \"Please select a contact to merge with\",\n        },\n      });\n      return;\n    }\n\n    try {\n      setIsMerging(true);\n      await mutateAsync();\n      setIsMerging(false);\n      notify(\"resources.contacts.merge.success\", {\n        type: \"success\",\n        messageArgs: {\n          _: \"Contacts merged successfully\",\n        },\n      });\n      redirect(`/contacts/${winnerId}/show`);\n      onClose();\n    } catch (error) {\n      setIsMerging(false);\n      notify(\"resources.contacts.merge.error\", {\n        type: \"error\",\n        messageArgs: {\n          _: \"Failed to merge contacts\",\n        },\n      });\n      console.error(\"Merge failed:\", error);\n    }\n  };\n\n  if (!loserContact) return null;\n\n  return (\n    <Dialog open={open} onOpenChange={onClose}>\n      <DialogContent className=\"md:min-w-lg max-w-2xl\">\n        <DialogHeader>\n          <DialogTitle>\n            {translate(\"resources.contacts.merge.title\", {\n              _: \"Merge Contact\",\n            })}\n          </DialogTitle>\n          <DialogDescription>\n            {translate(\"resources.contacts.merge.description\", {\n              _: \"Merge this contact with another one.\",\n            })}\n          </DialogDescription>\n        </DialogHeader>\n\n        <div className=\"space-y-4\">\n          <div className=\"p-4 bg-primary/5 rounded-lg border border-primary/20\">\n            <p className=\"font-medium text-sm\">\n              {translate(\"resources.contacts.merge.current_contact\", {\n                _: \"Current Contact (will be deleted)\",\n              })}\n            </p>\n            <div className=\"font-medium text-sm mt-4\">{contactOptionText}</div>\n\n            <div className=\"flex justify-center my-4\">\n              <ArrowDown className=\"h-5 w-5 text-muted-foreground\" />\n            </div>\n\n            <p className=\"font-medium text-sm mb-2\">\n              {translate(\"resources.contacts.merge.target_contact\", {\n                _: \"Target Contact (will be kept)\",\n              })}\n            </p>\n            <Form>\n              <ReferenceInput\n                source=\"winner_id\"\n                reference=\"contacts\"\n                filter={{ \"id@neq\": loserContact.id }}\n              >\n                <AutocompleteInput\n                  label=\"\"\n                  optionText={contactOptionText}\n                  validate={required()}\n                  onChange={setWinnerId}\n                  defaultValue={suggestedWinnerId}\n                  helperText={false}\n                />\n              </ReferenceInput>\n            </Form>\n          </div>\n\n          {winnerId && (\n            <>\n              <div className=\"space-y-2\">\n                <p className=\"font-medium text-sm\">\n                  {translate(\"resources.contacts.merge.what_will_be_merged\", {\n                    _: \"What will be merged:\",\n                  })}\n                </p>\n                <ul className=\"text-sm text-muted-foreground space-y-1 ml-4\">\n                  {notesCount != null && notesCount > 0 && (\n                    <li>\n                      • {notesCount} note\n                      {notesCount !== 1 ? \"s\" : \"\"} will be reassigned\n                    </li>\n                  )}\n                  {tasksCount != null && tasksCount > 0 && (\n                    <li>\n                      • {tasksCount} task\n                      {tasksCount !== 1 ? \"s\" : \"\"} will be reassigned\n                    </li>\n                  )}\n                  {dealsCount != null && dealsCount > 0 && (\n                    <li>\n                      • {dealsCount} deal\n                      {dealsCount !== 1 ? \"s\" : \"\"} will be updated\n                    </li>\n                  )}\n                  {loserContact.email_jsonb?.length > 0 && (\n                    <li>\n                      • {loserContact.email_jsonb.length} email address\n                      {loserContact.email_jsonb.length !== 1 ? \"es\" : \"\"} will\n                      be added\n                    </li>\n                  )}\n                  {loserContact.phone_jsonb?.length > 0 && (\n                    <li>\n                      • {loserContact.phone_jsonb.length} phone number\n                      {loserContact.phone_jsonb.length !== 1 ? \"s\" : \"\"} will be\n                      added\n                    </li>\n                  )}\n                  {!notesCount &&\n                    !tasksCount &&\n                    !dealsCount &&\n                    !loserContact.email_jsonb?.length &&\n                    !loserContact.phone_jsonb?.length && (\n                      <li className=\"text-muted-foreground/60\">\n                        {translate(\n                          \"resources.contacts.merge.no_additional_data\",\n                          {\n                            _: \"No additional data to merge\",\n                          },\n                        )}\n                      </li>\n                    )}\n                </ul>\n              </div>\n              <Alert variant=\"destructive\">\n                <AlertTriangle className=\"h-4 w-4\" />\n                <AlertTitle>\n                  {translate(\"resources.contacts.merge.warning_title\", {\n                    _: \"Warning: Destructive Operation\",\n                  })}\n                </AlertTitle>\n                <AlertDescription>\n                  {translate(\"resources.contacts.merge.warning_description\", {\n                    _: \"All data will be transferred to the second contact. This action cannot be undone.\",\n                  })}\n                </AlertDescription>\n              </Alert>\n            </>\n          )}\n        </div>\n\n        <DialogFooter>\n          <Button variant=\"ghost\" onClick={onClose} disabled={isMerging}>\n            <CircleX />\n            {translate(\"ra.action.cancel\")}\n          </Button>\n          <Button onClick={handleMerge} disabled={!winnerId || isMerging}>\n            <Merge />\n            {isMerging\n              ? translate(\"resources.contacts.merge.merging\", {\n                  _: \"Merging...\",\n                })\n              : translate(\"resources.contacts.merge.confirm\", {\n                  _: \"Merge Contacts\",\n                })}\n          </Button>\n        </DialogFooter>\n      </DialogContent>\n    </Dialog>\n  );\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/contacts/ContactListFilter.tsx",
      "content": "import { endOfYesterday, startOfMonth, startOfWeek, subMonths } from \"date-fns\";\nimport { CheckSquare, Clock, Tag, TrendingUp, Users } from \"lucide-react\";\nimport {\n  useGetIdentity,\n  useGetList,\n  useListContext,\n  useTranslate,\n} from \"ra-core\";\nimport { ToggleFilterButton } from \"@/components/admin/toggle-filter-button\";\nimport { Badge } from \"@/components/ui/badge\";\n\nimport { FilterCategory } from \"../filters/FilterCategory\";\nimport { Status } from \"../misc/Status\";\nimport { useConfigurationContext } from \"../root/ConfigurationContext\";\nimport { ResponsiveFilters } from \"../misc/ResponsiveFilters\";\nimport { useIsMobile } from \"@/hooks/use-mobile\";\nimport { ActiveFilterButton } from \"../misc/ActiveFilterButton\";\n\nexport const ContactListFilter = () => {\n  const { noteStatuses } = useConfigurationContext();\n  const isMobile = useIsMobile();\n  const { identity } = useGetIdentity();\n  const translate = useTranslate();\n  const { data } = useGetList(\"tags\", {\n    pagination: { page: 1, perPage: 10 },\n    sort: { field: \"name\", order: \"ASC\" },\n  });\n\n  return (\n    <ResponsiveFilters\n      searchInput={{\n        placeholder: translate(\"resources.contacts.filters.search\"),\n      }}\n    >\n      <FilterCategory\n        label=\"resources.contacts.fields.last_seen\"\n        icon={<Clock />}\n      >\n        <ToggleFilterButton\n          className=\"w-auto md:w-full justify-between h-10 md:h-8\"\n          label=\"resources.contacts.filters.today\"\n          value={{\n            \"last_seen@gte\": endOfYesterday().toISOString(),\n            \"last_seen@lte\": undefined,\n          }}\n          size={isMobile ? \"lg\" : undefined}\n        />\n        <ToggleFilterButton\n          className=\"w-auto md:w-full justify-between h-10 md:h-8\"\n          label=\"resources.contacts.filters.this_week\"\n          value={{\n            \"last_seen@gte\": startOfWeek(new Date()).toISOString(),\n            \"last_seen@lte\": undefined,\n          }}\n          size={isMobile ? \"lg\" : undefined}\n        />\n        <ToggleFilterButton\n          className=\"w-auto md:w-full justify-between h-10 md:h-8\"\n          label=\"resources.contacts.filters.before_this_week\"\n          value={{\n            \"last_seen@gte\": undefined,\n            \"last_seen@lte\": startOfWeek(new Date()).toISOString(),\n          }}\n          size={isMobile ? \"lg\" : undefined}\n        />\n        <ToggleFilterButton\n          className=\"w-auto md:w-full justify-between h-10 md:h-8\"\n          label=\"resources.contacts.filters.before_this_month\"\n          value={{\n            \"last_seen@gte\": undefined,\n            \"last_seen@lte\": startOfMonth(new Date()).toISOString(),\n          }}\n          size={isMobile ? \"lg\" : undefined}\n        />\n        <ToggleFilterButton\n          className=\"w-auto md:w-full justify-between h-10 md:h-8\"\n          label=\"resources.contacts.filters.before_last_month\"\n          value={{\n            \"last_seen@gte\": undefined,\n            \"last_seen@lte\": subMonths(\n              startOfMonth(new Date()),\n              1,\n            ).toISOString(),\n          }}\n          size={isMobile ? \"lg\" : undefined}\n        />\n      </FilterCategory>\n\n      <FilterCategory\n        label=\"resources.notes.fields.status\"\n        icon={<TrendingUp />}\n      >\n        {noteStatuses.map((status) => (\n          <ToggleFilterButton\n            key={status.value}\n            className=\"w-auto md:w-full justify-between h-10 md:h-8\"\n            label={\n              <span>\n                {status.label} <Status status={status.value} />\n              </span>\n            }\n            value={{ status: status.value }}\n            size={isMobile ? \"lg\" : undefined}\n          />\n        ))}\n      </FilterCategory>\n\n      <FilterCategory label=\"resources.contacts.filters.tags\" icon={<Tag />}>\n        {data &&\n          data.map((record) => (\n            <ToggleFilterButton\n              className=\"w-auto md:w-full justify-between h-10 md:h-8\"\n              key={record.id}\n              label={\n                <Badge\n                  variant=\"secondary\"\n                  className=\"text-black text-sm md:text-xs font-normal cursor-pointer\"\n                  style={{\n                    backgroundColor: record?.color,\n                  }}\n                >\n                  {record?.name}\n                </Badge>\n              }\n              value={{ \"tags@cs\": `{${record.id}}` }}\n              size={isMobile ? \"lg\" : undefined}\n            />\n          ))}\n      </FilterCategory>\n\n      <FilterCategory\n        icon={<CheckSquare />}\n        label=\"resources.contacts.filters.tasks\"\n      >\n        <ToggleFilterButton\n          className=\"w-full justify-between h-10 md:h-8\"\n          label=\"resources.tasks.filters.with_pending\"\n          value={{ \"nb_tasks@gt\": 0 }}\n          size={isMobile ? \"lg\" : undefined}\n        />\n      </FilterCategory>\n\n      <FilterCategory\n        icon={<Users />}\n        label=\"resources.contacts.fields.sales_id\"\n      >\n        <ToggleFilterButton\n          className=\"w-full justify-between h-10 md:h-8\"\n          label=\"crm.common.me\"\n          value={{ sales_id: identity?.id }}\n          size={isMobile ? \"lg\" : undefined}\n        />\n      </FilterCategory>\n    </ResponsiveFilters>\n  );\n};\n\nexport const ContactListFilterSummary = () => {\n  const { noteStatuses } = useConfigurationContext();\n  const { identity } = useGetIdentity();\n  const { data } = useGetList(\"tags\", {\n    pagination: { page: 1, perPage: 10 },\n    sort: { field: \"name\", order: \"ASC\" },\n  });\n  const { filterValues } = useListContext();\n  const hasFilters = !!Object.entries(filterValues || {}).filter(\n    ([key]) => key !== \"q\",\n  ).length;\n\n  if (!hasFilters) {\n    return null;\n  }\n\n  return (\n    <div className=\"flex flex-wrap items-start mb-4 gap-1\">\n      <ActiveFilterButton\n        className=\"w-auto justify-between h-8\"\n        label=\"resources.contacts.filters.today\"\n        value={{\n          \"last_seen@gte\": endOfYesterday().toISOString(),\n          \"last_seen@lte\": undefined,\n        }}\n      />\n      <ActiveFilterButton\n        className=\"w-auto justify-between h-8\"\n        label=\"resources.contacts.filters.this_week\"\n        value={{\n          \"last_seen@gte\": startOfWeek(new Date()).toISOString(),\n          \"last_seen@lte\": undefined,\n        }}\n      />\n      <ActiveFilterButton\n        className=\"w-auto justify-between h-8\"\n        label=\"resources.contacts.filters.before_this_week\"\n        value={{\n          \"last_seen@gte\": undefined,\n          \"last_seen@lte\": startOfWeek(new Date()).toISOString(),\n        }}\n      />\n      <ActiveFilterButton\n        className=\"w-auto justify-between h-8\"\n        label=\"resources.contacts.filters.before_this_month\"\n        value={{\n          \"last_seen@gte\": undefined,\n          \"last_seen@lte\": startOfMonth(new Date()).toISOString(),\n        }}\n      />\n      <ActiveFilterButton\n        className=\"w-auto justify-between h-8\"\n        label=\"resources.contacts.filters.before_last_month\"\n        value={{\n          \"last_seen@gte\": undefined,\n          \"last_seen@lte\": subMonths(startOfMonth(new Date()), 1).toISOString(),\n        }}\n      />\n\n      {noteStatuses.map((status) => (\n        <ActiveFilterButton\n          key={status.value}\n          className=\"w-auto justify-between h-8\"\n          label={\n            <span>\n              {status.label} <Status status={status.value} />\n            </span>\n          }\n          value={{ status: status.value }}\n        />\n      ))}\n\n      {data &&\n        data.map((record) => (\n          <ActiveFilterButton\n            className=\"w-auto justify-between h-8\"\n            key={record.id}\n            label={\n              <Badge\n                variant=\"secondary\"\n                className=\"text-black text-sm md:text-xs font-normal cursor-pointer\"\n                style={{\n                  backgroundColor: record?.color,\n                }}\n              >\n                {record?.name}\n              </Badge>\n            }\n            value={{ \"tags@cs\": `{${record.id}}` }}\n          />\n        ))}\n\n      <ActiveFilterButton\n        className=\"w-auto justify-between h-8\"\n        label=\"resources.tasks.filters.with_pending\"\n        value={{ \"nb_tasks@gt\": 0 }}\n      />\n\n      <ActiveFilterButton\n        className=\"w-auto justify-between h-8\"\n        label=\"resources.contacts.filters.managed_by_me\"\n        value={{ sales_id: identity?.id }}\n      />\n    </div>\n  );\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/contacts/ContactListContent.tsx",
      "content": "import { difference, union } from \"lodash\";\nimport {\n  type Identifier,\n  RecordContextProvider,\n  RecordRepresentation,\n  useListContext,\n  useLocaleState,\n  useTimeout,\n  useTranslate,\n} from \"ra-core\";\nimport { type MouseEvent, useCallback, useRef } from \"react\";\nimport { Link } from \"react-router\";\nimport { ReferenceField } from \"@/components/admin/reference-field\";\nimport { TextField } from \"@/components/admin/text-field\";\nimport { Checkbox } from \"@/components/ui/checkbox\";\nimport { Skeleton } from \"@/components/ui/skeleton\";\nimport { Button } from \"@/components/ui/button\";\nimport { RotateCcw } from \"lucide-react\";\n\nimport { Status } from \"../misc/Status\";\nimport { formatRelativeDate } from \"../misc/RelativeDate\";\nimport type { Contact } from \"../types\";\nimport { Avatar } from \"./Avatar\";\nimport { TagsList } from \"./TagsList\";\n\nexport const ContactListContent = () => {\n  const translate = useTranslate();\n  const {\n    data: contacts,\n    error,\n    isPending,\n    onToggleItem,\n    onSelect,\n    selectedIds,\n  } = useListContext<Contact>();\n  const lastSelected = useRef<Identifier | null>(null);\n\n  // Handle shift+click to select a range of rows\n  const handleToggleItem = useCallback(\n    (id: Identifier, event: MouseEvent) => {\n      if (!contacts) return;\n\n      const ids = contacts.map((contact) => contact.id);\n      const lastSelectedIndex = lastSelected.current\n        ? ids.indexOf(lastSelected.current)\n        : -1;\n\n      if (event.shiftKey && lastSelectedIndex !== -1) {\n        const index = ids.indexOf(id);\n        const idsBetweenSelections = ids.slice(\n          Math.min(lastSelectedIndex, index),\n          Math.max(lastSelectedIndex, index) + 1,\n        );\n\n        const isClickedItemSelected = selectedIds?.includes(id);\n        const newSelectedIds = isClickedItemSelected\n          ? difference(selectedIds, idsBetweenSelections)\n          : union(selectedIds, idsBetweenSelections);\n\n        onSelect?.(newSelectedIds);\n      } else {\n        onToggleItem(id);\n      }\n\n      lastSelected.current = id;\n    },\n    [contacts, selectedIds, onSelect, onToggleItem],\n  );\n\n  if (isPending) {\n    return <Skeleton className=\"w-full h-9\" />;\n  }\n\n  if (error) {\n    return null;\n  }\n\n  return (\n    <div className=\"md:divide-y\">\n      {contacts.map((contact) => (\n        <RecordContextProvider key={contact.id} value={contact}>\n          <ContactItemContent\n            contact={contact}\n            handleToggleItem={handleToggleItem}\n          />\n        </RecordContextProvider>\n      ))}\n\n      {contacts.length === 0 && (\n        <div className=\"p-4\">\n          <div className=\"text-muted-foreground\">\n            {translate(\"resources.contacts.empty.title\", {})}\n          </div>\n        </div>\n      )}\n    </div>\n  );\n};\n\nconst ContactItemContent = ({\n  contact,\n  handleToggleItem,\n}: {\n  contact: Contact;\n  handleToggleItem: (id: Identifier, event: MouseEvent) => void;\n}) => {\n  const translate = useTranslate();\n  const [locale = \"en\"] = useLocaleState();\n  const { selectedIds } = useListContext<Contact>();\n  const lastActivity = contact.last_seen\n    ? formatRelativeDate(contact.last_seen, locale)\n    : null;\n\n  return (\n    <div className=\"flex flex-row items-center pl-2 pr-4 py-2 hover:bg-muted transition-colors first:rounded-t-xl last:rounded-b-xl\">\n      <div\n        className=\"px-4 py-3 flex items-center cursor-pointer\"\n        onClick={(e) => handleToggleItem(contact.id, e)}\n      >\n        <Checkbox\n          className=\"cursor-pointer\"\n          checked={selectedIds.includes(contact.id)}\n        />\n      </div>\n      <Link\n        to={`/contacts/${contact.id}/show`}\n        className=\"flex-1 flex flex-row gap-4 items-center\"\n      >\n        <Avatar />\n        <div className=\"flex-1 min-w-0\">\n          <div className=\"font-medium\">\n            {`${contact.first_name} ${contact.last_name ?? \"\"}`}\n          </div>\n          {contact.title || contact.company_id != null || contact.nb_tasks ? (\n            <div className=\"text-sm text-muted-foreground\">\n              {contact.title && contact.company_id != null\n                ? `${translate(\"resources.contacts.position_at\", {\n                    title: contact.title,\n                  })} `\n                : contact.title}\n              {contact.company_id != null && (\n                <ReferenceField\n                  source=\"company_id\"\n                  reference=\"companies\"\n                  link={false}\n                >\n                  <TextField source=\"name\" />\n                </ReferenceField>\n              )}\n              {contact.nb_tasks\n                ? ` - ${translate(\"crm.common.task_count\", {\n                    smart_count: contact.nb_tasks,\n                  })}`\n                : \"\"}\n              &nbsp;&nbsp;\n              <TagsList />\n            </div>\n          ) : null}\n        </div>\n        {contact.last_seen && (\n          <div className=\"text-right ml-4\">\n            <div\n              className=\"text-sm text-muted-foreground\"\n              title={contact.last_seen}\n            >\n              {translate(\"crm.common.last_activity_with_date\", {\n                date: lastActivity,\n              })}{\" \"}\n              <Status status={contact.status} />\n            </div>\n          </div>\n        )}\n      </Link>\n    </div>\n  );\n};\n\nexport const ContactListContentMobile = () => {\n  const translate = useTranslate();\n  const {\n    data: contacts,\n    error,\n    isPending,\n    refetch,\n  } = useListContext<Contact>();\n  const oneSecondHasPassed = useTimeout(1000);\n\n  if (isPending) {\n    if (!oneSecondHasPassed) {\n      return null;\n    }\n    return (\n      <>\n        {[...Array(5)].map((_, index) => (\n          <div\n            key={index}\n            className=\"flex flex-row items-center py-2 hover:bg-muted transition-colors first:rounded-t-xl last:rounded-b-xl\"\n          >\n            <div className=\"flex flex-row gap-4 items-center mr-4\">\n              <Skeleton className=\"w-10 h-10 rounded-full\" />\n            </div>\n            <div className=\"flex-1 min-w-0\">\n              <Skeleton className=\"w-32 h-5 mb-2\" />\n              <Skeleton className=\"w-48 h-4\" />\n            </div>\n          </div>\n        ))}\n      </>\n    );\n  }\n\n  if (error && !contacts) {\n    return (\n      <div className=\"p-4\">\n        <div className=\"text-center text-muted-foreground mb-4\">\n          {translate(\"resources.contacts.list.error_loading\")}\n        </div>\n        <div className=\"text-center mt-2\">\n          <Button\n            onClick={() => {\n              refetch();\n            }}\n          >\n            <RotateCcw />\n            {translate(\"crm.common.retry\")}\n          </Button>\n        </div>\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"md:divide-y\">\n      {contacts.map((contact) => (\n        <RecordContextProvider key={contact.id} value={contact}>\n          <ContactItemContentMobile contact={contact} />\n        </RecordContextProvider>\n      ))}\n      {contacts.length === 0 && (\n        <div className=\"p-4\">\n          <div className=\"text-muted-foreground\">\n            {translate(\"resources.contacts.empty.title\")}\n          </div>\n        </div>\n      )}\n    </div>\n  );\n};\n\nconst ContactItemContentMobile = ({ contact }: { contact: Contact }) => {\n  const translate = useTranslate();\n  return (\n    <Link\n      to={`/contacts/${contact.id}/show`}\n      className=\"flex flex-row gap-4 items-center py-2 hover:bg-muted transition-colors\"\n    >\n      <Avatar />\n      <div className=\"flex flex-col grow justify-between\">\n        <div className=\"flex-1 min-w-0\">\n          <div className=\"flex justify-between\">\n            <div className=\"font-medium\">\n              <RecordRepresentation />\n            </div>\n            <Status status={contact.status} />\n          </div>\n          <div className=\"text-sm text-muted-foreground\">\n            <div className=\"flex flex-col gap-1\">\n              <span>\n                {contact.title && contact.company_id != null\n                  ? `${translate(\"resources.contacts.position_at\", {\n                      title: contact.title,\n                    })} `\n                  : contact.title}\n                {contact.company_id != null && (\n                  <ReferenceField\n                    source=\"company_id\"\n                    reference=\"companies\"\n                    link={false}\n                  >\n                    <TextField source=\"name\" />\n                  </ReferenceField>\n                )}\n              </span>\n              {contact.nb_tasks ? (\n                <span>\n                  {translate(\"crm.common.task_count\", {\n                    smart_count: contact.nb_tasks,\n                  })}\n                </span>\n              ) : null}\n            </div>\n          </div>\n        </div>\n      </div>\n    </Link>\n  );\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/contacts/ContactList.tsx",
      "content": "import jsonExport from \"jsonexport/dist\";\nimport {\n  downloadCSV,\n  InfiniteListBase,\n  useGetIdentity,\n  useListContext,\n  type Exporter,\n} from \"ra-core\";\nimport { BulkActionsToolbar } from \"@/components/admin/bulk-actions-toolbar\";\nimport { BulkDeleteButton } from \"@/components/admin/bulk-delete-button\";\nimport { BulkExportButton } from \"@/components/admin/bulk-export-button\";\nimport { CreateButton } from \"@/components/admin/create-button\";\nimport { ExportButton } from \"@/components/admin/export-button\";\nimport { List } from \"@/components/admin/list\";\nimport { SelectAllButton } from \"@/components/admin/select-all-button\";\nimport { SortButton } from \"@/components/admin/sort-button\";\nimport { Card } from \"@/components/ui/card\";\n\nimport type { Company, Contact, Sale, Tag } from \"../types\";\nimport { BulkTagButton } from \"./BulkTagButton\";\nimport { ContactEmpty } from \"./ContactEmpty\";\nimport { ContactImportButton } from \"./ContactImportButton\";\nimport {\n  ContactListContent,\n  ContactListContentMobile,\n} from \"./ContactListContent\";\nimport {\n  ContactListFilterSummary,\n  ContactListFilter,\n} from \"./ContactListFilter\";\nimport { TopToolbar } from \"../layout/TopToolbar\";\nimport { InfinitePagination } from \"../misc/InfinitePagination\";\nimport MobileHeader from \"../layout/MobileHeader\";\nimport { MobileContent } from \"../layout/MobileContent\";\n\nexport const ContactList = () => {\n  const { identity } = useGetIdentity();\n\n  if (!identity) return null;\n\n  return (\n    <List\n      title={false}\n      actions={<ContactListActions />}\n      perPage={25}\n      sort={{ field: \"last_seen\", order: \"DESC\" }}\n      exporter={exporter}\n    >\n      <ContactListLayoutDesktop />\n    </List>\n  );\n};\n\nconst ContactListLayoutDesktop = () => {\n  const { data, isPending, filterValues } = useListContext();\n\n  const hasFilters = filterValues && Object.keys(filterValues).length > 0;\n\n  if (isPending) return null;\n\n  if (!data?.length && !hasFilters) return <ContactEmpty />;\n\n  return (\n    <div className=\"flex flex-row gap-8\">\n      <ContactListFilter />\n      <div className=\"w-full flex flex-col gap-4\">\n        <Card className=\"py-0\">\n          <ContactListContent />\n        </Card>\n      </div>\n      <BulkActionsToolbar>\n        <ContactBulkActionButtons />\n      </BulkActionsToolbar>\n    </div>\n  );\n};\n\nconst ContactBulkActionButtons = () => (\n  <>\n    <SelectAllButton />\n    <BulkTagButton />\n    <BulkExportButton />\n    <BulkDeleteButton />\n  </>\n);\n\nconst ContactListActions = () => (\n  <TopToolbar>\n    <SortButton fields={[\"first_name\", \"last_name\", \"last_seen\"]} />\n    <ContactImportButton />\n    <ExportButton exporter={exporter} />\n    <CreateButton />\n  </TopToolbar>\n);\n\nexport const ContactListMobile = () => {\n  const { identity } = useGetIdentity();\n  if (!identity) return null;\n\n  return (\n    <InfiniteListBase\n      perPage={25}\n      sort={{ field: \"last_seen\", order: \"DESC\" }}\n      exporter={exporter}\n      queryOptions={{\n        onError: () => {\n          /* Disable error notification as ContactListLayoutMobile handles it */\n        },\n      }}\n    >\n      <ContactListLayoutMobile />\n    </InfiniteListBase>\n  );\n};\n\nconst ContactListLayoutMobile = () => {\n  const { isPending, data, error, filterValues } = useListContext();\n\n  const hasFilters = filterValues && Object.keys(filterValues).length > 0;\n\n  if (!isPending && !data?.length && !hasFilters) return <ContactEmpty />;\n\n  return (\n    <div>\n      <MobileHeader>\n        <ContactListFilter />\n      </MobileHeader>\n      <MobileContent>\n        <ContactListFilterSummary />\n        <ContactListContentMobile />\n        {!error && (\n          <div className=\"flex justify-center\">\n            <InfinitePagination />\n          </div>\n        )}\n      </MobileContent>\n    </div>\n  );\n};\n\nconst exporter: Exporter<Contact> = async (records, fetchRelatedRecords) => {\n  const companies = await fetchRelatedRecords<Company>(\n    records,\n    \"company_id\",\n    \"companies\",\n  );\n  const sales = await fetchRelatedRecords<Sale>(records, \"sales_id\", \"sales\");\n  const tags = await fetchRelatedRecords<Tag>(records, \"tags\", \"tags\");\n\n  const contacts = records.map((contact) => {\n    const exportedContact = {\n      ...contact,\n      company:\n        contact.company_id != null\n          ? companies[contact.company_id].name\n          : undefined,\n      sales:\n        contact.sales_id != null\n          ? `${sales[contact.sales_id].first_name} ${sales[contact.sales_id].last_name}`\n          : undefined,\n      tags: contact.tags.map((tagId) => tags[tagId].name).join(\", \"),\n      email_work: contact.email_jsonb?.find((email) => email.type === \"Work\")\n        ?.email,\n      email_home: contact.email_jsonb?.find((email) => email.type === \"Home\")\n        ?.email,\n      email_other: contact.email_jsonb?.find((email) => email.type === \"Other\")\n        ?.email,\n      email_jsonb: JSON.stringify(contact.email_jsonb),\n      email_fts: undefined,\n      phone_work: contact.phone_jsonb?.find((phone) => phone.type === \"Work\")\n        ?.number,\n      phone_home: contact.phone_jsonb?.find((phone) => phone.type === \"Home\")\n        ?.number,\n      phone_other: contact.phone_jsonb?.find((phone) => phone.type === \"Other\")\n        ?.number,\n      phone_jsonb: JSON.stringify(contact.phone_jsonb),\n      phone_fts: undefined,\n    };\n    delete exportedContact.email_fts;\n    delete exportedContact.phone_fts;\n    return exportedContact;\n  });\n  return jsonExport(contacts, {}, (_err: any, csv: string) => {\n    downloadCSV(csv, \"contacts\");\n  });\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/contacts/ContactInputs.tsx",
      "content": "import {\n  email,\n  required,\n  useRecordContext,\n  useTranslate,\n  useUpdate,\n  useNotify,\n} from \"ra-core\";\nimport type { FocusEvent, ClipboardEventHandler } from \"react\";\nimport { useFormContext } from \"react-hook-form\";\nimport { Separator } from \"@/components/ui/separator\";\nimport { useIsMobile } from \"@/hooks/use-mobile\";\nimport { BooleanInput } from \"@/components/admin/boolean-input\";\nimport { ReferenceInput } from \"@/components/admin/reference-input\";\nimport { TextInput } from \"@/components/admin/text-input\";\nimport { RadioButtonGroupInput } from \"@/components/admin/radio-button-group-input\";\nimport { SelectInput } from \"@/components/admin/select-input\";\nimport { ArrayInput } from \"@/components/admin/array-input\";\nimport { SimpleFormIterator } from \"@/components/admin/simple-form-iterator\";\n\nimport { isLinkedinUrl } from \"../misc/isLinkedInUrl\";\nimport { StatusSelector } from \"../notes\";\nimport type { Sale, Contact } from \"../types\";\nimport { Avatar } from \"./Avatar\";\nimport { AutocompleteCompanyInput } from \"../companies/AutocompleteCompanyInput.tsx\";\nimport {\n  contactGender,\n  translateContactGenderLabel,\n  translatePersonalInfoTypeLabel,\n} from \"./contactModel.ts\";\n\nexport const ContactInputs = () => {\n  const isMobile = useIsMobile();\n\n  return (\n    <div className=\"flex flex-col gap-2 p-1 relative md:static\">\n      <div className=\"absolute top-0 right-1 md:static\">\n        <Avatar />\n      </div>\n      <div className=\"flex gap-10 md:gap-6 flex-col md:flex-row\">\n        <div className=\"flex flex-col gap-10 flex-1\">\n          <ContactIdentityInputs />\n          <ContactPositionInputs />\n        </div>\n        {isMobile ? null : (\n          <Separator orientation=\"vertical\" className=\"flex-shrink-0\" />\n        )}\n        <div className=\"flex flex-col gap-10 flex-1\">\n          <ContactPersonalInformationInputs />\n          <ContactMiscInputs />\n        </div>\n      </div>\n    </div>\n  );\n};\n\nconst ContactIdentityInputs = () => {\n  const translate = useTranslate();\n  return (\n    <div className=\"flex flex-col gap-4\">\n      <h6 className=\"text-lg font-semibold\">\n        {translate(\"resources.contacts.field_categories.identity\")}\n      </h6>\n      <RadioButtonGroupInput\n        label={false}\n        row\n        source=\"gender\"\n        choices={contactGender}\n        helperText={false}\n        optionText={(choice) => translateContactGenderLabel(choice, translate)}\n        translateChoice={false}\n        optionValue=\"value\"\n        defaultValue={contactGender[0].value}\n      />\n      <TextInput source=\"first_name\" validate={required()} helperText={false} />\n      <TextInput source=\"last_name\" validate={required()} helperText={false} />\n    </div>\n  );\n};\n\nconst ContactPositionInputs = () => {\n  const translate = useTranslate();\n  return (\n    <div className=\"flex flex-col gap-4\">\n      <h6 className=\"text-lg font-semibold\">\n        {translate(\"resources.contacts.field_categories.position\")}\n      </h6>\n      <TextInput source=\"title\" helperText={false} />\n      <ReferenceInput source=\"company_id\" reference=\"companies\" perPage={10}>\n        <AutocompleteCompanyInput label=\"resources.contacts.fields.company_id\" />\n      </ReferenceInput>\n    </div>\n  );\n};\n\nconst ContactPersonalInformationInputs = () => {\n  const translate = useTranslate();\n  const { getValues, setValue } = useFormContext();\n  const personalInfoTypes = [\n    {\n      id: \"Work\",\n      name: translatePersonalInfoTypeLabel(\"Work\", translate),\n    },\n    {\n      id: \"Home\",\n      name: translatePersonalInfoTypeLabel(\"Home\", translate),\n    },\n    {\n      id: \"Other\",\n      name: translatePersonalInfoTypeLabel(\"Other\", translate),\n    },\n  ];\n\n  // set first and last name based on email\n  const handleEmailChange = (email: string) => {\n    const { first_name, last_name } = getValues();\n    if (first_name || last_name || !email) return;\n    const [first, last] = email.split(\"@\")[0].split(\".\");\n    setValue(\"first_name\", first.charAt(0).toUpperCase() + first.slice(1));\n    setValue(\n      \"last_name\",\n      last ? last.charAt(0).toUpperCase() + last.slice(1) : \"\",\n    );\n  };\n\n  const handleEmailPaste: ClipboardEventHandler<\n    HTMLTextAreaElement | HTMLInputElement\n  > = (e) => {\n    const email = e.clipboardData?.getData(\"text/plain\");\n    handleEmailChange(email);\n  };\n\n  const handleEmailBlur = (\n    e: FocusEvent<HTMLTextAreaElement | HTMLInputElement>,\n  ) => {\n    const email = e.target.value;\n    handleEmailChange(email);\n  };\n\n  return (\n    <div className=\"flex flex-col gap-4\">\n      <h6 className=\"text-lg font-semibold\">\n        {translate(\"resources.contacts.field_categories.personal_info\")}\n      </h6>\n      <ArrayInput source=\"email_jsonb\" helperText={false}>\n        <SimpleFormIterator\n          inline\n          disableReordering\n          disableClear\n          className=\"[&>ul>li]:border-b-0 [&>ul>li]:pb-0\"\n        >\n          <TextInput\n            source=\"email\"\n            className=\"w-full\"\n            helperText={false}\n            label={false}\n            placeholder={translate(\"resources.contacts.fields.email\")}\n            validate={email()}\n            onPaste={handleEmailPaste}\n            onBlur={handleEmailBlur}\n          />\n          <SelectInput\n            source=\"type\"\n            helperText={false}\n            label={false}\n            optionText=\"name\"\n            choices={personalInfoTypes}\n            defaultValue=\"Work\"\n            className=\"w-24 min-w-24\"\n          />\n        </SimpleFormIterator>\n      </ArrayInput>\n      <ArrayInput source=\"phone_jsonb\" helperText={false}>\n        <SimpleFormIterator\n          inline\n          disableReordering\n          disableClear\n          className=\"[&>ul>li]:border-b-0 [&>ul>li]:pb-0\"\n        >\n          <TextInput\n            source=\"number\"\n            className=\"w-full\"\n            helperText={false}\n            label={false}\n            placeholder={translate(\"resources.contacts.fields.phone_number\")}\n          />\n          <SelectInput\n            source=\"type\"\n            helperText={false}\n            label={false}\n            optionText=\"name\"\n            choices={personalInfoTypes}\n            defaultValue=\"Work\"\n            className=\"w-24 min-w-24\"\n          />\n        </SimpleFormIterator>\n      </ArrayInput>\n      <TextInput\n        source=\"linkedin_url\"\n        helperText={false}\n        validate={isLinkedinUrl}\n      />\n    </div>\n  );\n};\n\nconst ContactMiscInputs = () => {\n  const translate = useTranslate();\n  return (\n    <div className=\"flex flex-col gap-4\">\n      <h6 className=\"text-lg font-semibold\">\n        {translate(\"resources.contacts.field_categories.misc\")}\n      </h6>\n      <TextInput source=\"background\" multiline helperText={false} />\n      <BooleanInput source=\"has_newsletter\" helperText={false} />\n      <ReferenceInput\n        reference=\"sales\"\n        source=\"sales_id\"\n        sort={{ field: \"last_name\", order: \"ASC\" }}\n        filter={{\n          \"disabled@neq\": true,\n        }}\n      >\n        <SelectInput\n          helperText={false}\n          optionText={saleOptionRenderer}\n          validate={required()}\n        />\n      </ReferenceInput>\n    </div>\n  );\n};\n\nconst saleOptionRenderer = (choice: Sale) =>\n  `${choice.first_name} ${choice.last_name}`;\n\nexport const ContactStatusSelector = () => {\n  const record = useRecordContext<Contact>();\n  const [update] = useUpdate<Contact>();\n  const notify = useNotify();\n  if (!record) return null;\n\n  const handleStatusChange = (nextStatus: string) => {\n    if (nextStatus === record?.status) return;\n\n    update(\n      \"contacts\",\n      {\n        id: record.id,\n        data: { status: nextStatus },\n        previousData: record,\n      },\n      {\n        mutationMode: \"optimistic\",\n        onError: (error) => {\n          notify(\n            typeof error === \"string\"\n              ? error\n              : error?.message || \"ra.notification.http_error\",\n            {\n              type: \"error\",\n              messageArgs: {\n                _: typeof error === \"string\" ? error : error?.message,\n              },\n            },\n          );\n        },\n      },\n    );\n  };\n\n  return (\n    <div className=\"[&_button]:w-auto\">\n      <StatusSelector\n        status={record?.status}\n        setStatus={handleStatusChange}\n        triggerClassName=\"w-full\"\n      />\n    </div>\n  );\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/contacts/ContactImportButton.tsx",
      "content": "import { useEffect, useState } from \"react\";\nimport type { MouseEvent } from \"react\";\nimport { Upload, Loader2 } from \"lucide-react\";\nimport { Form, useRefresh, useTranslate } from \"ra-core\";\nimport { Link } from \"react-router\";\nimport { Alert, AlertDescription } from \"@/components/ui/alert\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogHeader,\n  DialogTitle,\n} from \"@/components/ui/dialog\";\nimport { FormToolbar } from \"@/components/admin/simple-form\";\nimport { FileInput } from \"@/components/admin/file-input\";\nimport { FileField } from \"@/components/admin/file-field\";\n\nimport { usePapaParse } from \"../misc/usePapaParse\";\nimport type { ContactImportSchema } from \"./useContactImport\";\nimport { useContactImport } from \"./useContactImport\";\nimport * as sampleCsv from \"./contacts_export.csv?raw\";\n\nexport const ContactImportButton = () => {\n  const translate = useTranslate();\n  const [modalOpen, setModalOpen] = useState(false);\n\n  const handleOpenModal = () => {\n    setModalOpen(true);\n  };\n\n  const handleCloseModal = () => {\n    setModalOpen(false);\n  };\n\n  return (\n    <>\n      <Button\n        variant=\"outline\"\n        onClick={handleOpenModal}\n        className=\"flex items-center gap-2 cursor-pointer\"\n      >\n        <Upload /> {translate(\"resources.contacts.import.button\")}\n      </Button>\n      <ContactImportDialog open={modalOpen} onClose={handleCloseModal} />\n    </>\n  );\n};\n\nconst SAMPLE_URL = `data:text/csv;name=crm_contacts_sample.csv;charset=utf-8,${encodeURIComponent(\n  sampleCsv.default,\n)}`;\n\ntype ContactImportModalProps = {\n  open: boolean;\n  onClose(): void;\n};\n\nexport function ContactImportDialog({\n  open,\n  onClose,\n}: ContactImportModalProps) {\n  const translate = useTranslate();\n  const refresh = useRefresh();\n  const processBatch = useContactImport();\n  const { importer, parseCsv, reset } = usePapaParse<ContactImportSchema>({\n    batchSize: 10,\n    processBatch,\n  });\n\n  const [file, setFile] = useState<File | null>(null);\n\n  useEffect(() => {\n    if (importer.state === \"complete\") {\n      refresh();\n    }\n  }, [importer.state, refresh]);\n\n  const handleFileChange = (file: File | null) => {\n    setFile(file);\n  };\n\n  const startImport = () => {\n    if (!file) return;\n    parseCsv(file);\n  };\n\n  const handleClose = () => {\n    reset();\n    onClose();\n  };\n\n  const handleReset = (e: MouseEvent<HTMLButtonElement>) => {\n    e.preventDefault();\n    reset();\n  };\n\n  return (\n    <Dialog open={open} onOpenChange={handleClose}>\n      <DialogContent className=\"max-w-2xl\">\n        <Form className=\"flex flex-col gap-4\">\n          <DialogHeader>\n            <DialogTitle>\n              {translate(\"resources.contacts.import.title\")}\n            </DialogTitle>\n          </DialogHeader>\n\n          <div className=\"flex flex-col space-y-2\">\n            {importer.state === \"running\" && (\n              <div className=\"flex flex-col gap-2\">\n                <Alert>\n                  <AlertDescription className=\"flex flex-row gap-4\">\n                    <Loader2 className=\"h-5 w-5 animate-spin\" />\n                    {translate(\"resources.contacts.import.running\")}\n                  </AlertDescription>\n                </Alert>\n\n                <div className=\"text-sm\">\n                  {translate(\"resources.contacts.import.progress\", {\n                    importCount: importer.importCount,\n                    rowCount: importer.rowCount,\n                    errorCount: importer.errorCount,\n                  })}\n                  {importer.remainingTime !== null && (\n                    <>\n                      {\" \"}\n                      {translate(\n                        \"resources.contacts.import.remaining_time\",\n                      )}{\" \"}\n                      <strong>\n                        {millisecondsToTime(importer.remainingTime)}\n                      </strong>\n                      .{\" \"}\n                      <button\n                        onClick={handleReset}\n                        className=\"text-red-600 underline hover:text-red-800\"\n                      >\n                        {translate(\"resources.contacts.import.stop\")}\n                      </button>\n                    </>\n                  )}\n                </div>\n              </div>\n            )}\n\n            {importer.state === \"error\" && (\n              <Alert variant=\"destructive\">\n                <AlertDescription>\n                  {translate(\"resources.contacts.import.error\")}\n                </AlertDescription>\n              </Alert>\n            )}\n\n            {importer.state === \"complete\" && (\n              <Alert>\n                <AlertDescription>\n                  {translate(\"resources.contacts.import.complete\", {\n                    importCount: importer.importCount,\n                    errorCount: importer.errorCount,\n                  })}\n                </AlertDescription>\n              </Alert>\n            )}\n\n            {importer.state === \"idle\" && (\n              <>\n                <Alert>\n                  <AlertDescription className=\"flex flex-col gap-4\">\n                    {translate(\"resources.contacts.import.sample_hint\")}\n                    <Button asChild variant=\"outline\" size=\"sm\">\n                      <Link\n                        to={SAMPLE_URL}\n                        download={\"crm_contacts_sample.csv\"}\n                      >\n                        {translate(\"resources.contacts.import.sample_download\")}\n                      </Link>\n                    </Button>{\" \"}\n                  </AlertDescription>\n                </Alert>\n\n                <FileInput\n                  source=\"csv\"\n                  label=\"resources.contacts.import.csv_file\"\n                  accept={{ \"text/csv\": [\".csv\"] }}\n                  onChange={handleFileChange}\n                >\n                  <FileField source=\"src\" title=\"title\" target=\"_blank\" />\n                </FileInput>\n              </>\n            )}\n          </div>\n        </Form>\n\n        <div className=\"flex justify-start pt-6\">\n          <FormToolbar>\n            {importer.state === \"idle\" ? (\n              <Button onClick={startImport} disabled={!file}>\n                {translate(\"resources.contacts.import.button\")}\n              </Button>\n            ) : (\n              <Button\n                variant=\"outline\"\n                onClick={handleClose}\n                disabled={importer.state === \"running\"}\n              >\n                {translate(\"ra.action.close\")}\n              </Button>\n            )}\n          </FormToolbar>\n        </div>\n      </DialogContent>\n    </Dialog>\n  );\n}\n\nfunction millisecondsToTime(ms: number) {\n  const seconds = Math.floor((ms / 1000) % 60);\n  const minutes = Math.floor((ms / (60 * 1000)) % 60);\n\n  return `${minutes}m ${seconds}s`;\n}\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/contacts/ContactEmpty.tsx",
      "content": "import { CreateButton } from \"@/components/admin/create-button\";\nimport { useState } from \"react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Plus } from \"lucide-react\";\nimport { useTranslate } from \"ra-core\";\n\nimport useAppBarHeight from \"../misc/useAppBarHeight\";\nimport { ContactImportButton } from \"./ContactImportButton\";\nimport { ContactCreateSheet } from \"./ContactCreateSheet\";\nimport { useIsMobile } from \"@/hooks/use-mobile\";\n\nexport const ContactEmpty = () => {\n  const appbarHeight = useAppBarHeight();\n  const isMobile = useIsMobile();\n  const translate = useTranslate();\n  const [createOpen, setCreateOpen] = useState(false);\n  return (\n    <>\n      <ContactCreateSheet open={createOpen} onOpenChange={setCreateOpen} />\n      <div\n        className=\"flex flex-col justify-center items-center gap-3\"\n        style={{\n          height: `calc(100dvh - ${appbarHeight}px)`,\n        }}\n      >\n        <img\n          src=\"./img/empty.svg\"\n          alt={translate(\"resources.contacts.empty.title\")}\n        />\n        <div className=\"flex flex-col gap-0 items-center\">\n          <h6 className=\"text-lg font-bold\">\n            {translate(\"resources.contacts.empty.title\")}\n          </h6>\n          <p className=\"text-sm text-muted-foreground text-center mb-4\">\n            {translate(\"resources.contacts.empty.description\")}\n          </p>\n        </div>\n        <div className=\"flex flex-row gap-2\">\n          {isMobile ? (\n            <Button\n              onClick={() => setCreateOpen(true)}\n              variant=\"outline\"\n              className=\"gap-2\"\n            >\n              <Plus className=\"h-4 w-4\" />\n              {translate(\"resources.contacts.action.new\")}\n            </Button>\n          ) : (\n            <>\n              <CreateButton label=\"resources.contacts.action.new\" />\n              <ContactImportButton />\n            </>\n          )}\n        </div>\n      </div>\n    </>\n  );\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/contacts/ContactEditSheet.tsx",
      "content": "import type { Identifier } from \"ra-core\";\nimport { useTranslate, useDeleteController, useRecordContext } from \"ra-core\";\nimport { EllipsisVertical, Trash2 } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\n\nimport { EditSheet } from \"../misc/EditSheet\";\nimport { ContactInputs } from \"./ContactInputs\";\nimport {\n  cleanupContactForEdit,\n  defaultEmailJsonb,\n  defaultPhoneJsonb,\n} from \"./contactModel\";\n\nexport interface ContactEditSheetProps {\n  open: boolean;\n  onOpenChange: (open: boolean) => void;\n  contactId: Identifier;\n}\n\nexport const ContactEditSheet = ({\n  open,\n  onOpenChange,\n  contactId,\n}: ContactEditSheetProps) => {\n  return (\n    <EditSheet\n      resource=\"contacts\"\n      id={contactId}\n      open={open}\n      onOpenChange={onOpenChange}\n      transform={cleanupContactForEdit}\n      defaultValues={{\n        email_jsonb: defaultEmailJsonb,\n        phone_jsonb: defaultPhoneJsonb,\n      }}\n      headerActions={<ContactEditMenuButton onOpenChange={onOpenChange} />}\n    >\n      <ContactInputs />\n    </EditSheet>\n  );\n};\n\nconst ContactEditMenuButton = ({\n  onOpenChange,\n}: {\n  onOpenChange: (open: boolean) => void;\n}) => {\n  const translate = useTranslate();\n  const record = useRecordContext();\n  const { handleDelete } = useDeleteController({\n    record,\n    resource: \"contacts\",\n    redirect: \"list\",\n    mutationMode: \"undoable\",\n  });\n\n  const onDelete = () => {\n    onOpenChange(false);\n    handleDelete();\n  };\n\n  return (\n    <DropdownMenu>\n      <DropdownMenuTrigger asChild>\n        <Button variant=\"ghost\" size=\"icon\">\n          <EllipsisVertical />\n          <span className=\"sr-only\">\n            {translate(\"ra.action.open_menu\", { _: \"More\" })}\n          </span>\n        </Button>\n      </DropdownMenuTrigger>\n      <DropdownMenuContent align=\"end\">\n        <DropdownMenuItem\n          variant=\"destructive\"\n          className=\"h-12 md:h-8 px-4 md:px-2 text-base md:text-sm\"\n          onSelect={onDelete}\n        >\n          <Trash2 />\n          {translate(\"ra.action.delete\")}\n        </DropdownMenuItem>\n      </DropdownMenuContent>\n    </DropdownMenu>\n  );\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/contacts/ContactEdit.tsx",
      "content": "import { Card, CardContent } from \"@/components/ui/card\";\nimport { EditBase, Form, useEditContext, type MutationMode } from \"ra-core\";\n\nimport type { Contact } from \"../types\";\nimport { ContactAside } from \"./ContactAside\";\nimport { ContactInputs } from \"./ContactInputs\";\nimport { FormToolbar } from \"../layout/FormToolbar\";\nimport {\n  cleanupContactForEdit,\n  defaultEmailJsonb,\n  defaultPhoneJsonb,\n} from \"./contactModel\";\n\nexport const ContactEdit = ({\n  mutationMode,\n}: {\n  mutationMode?: MutationMode;\n}) => (\n  <EditBase\n    redirect=\"show\"\n    transform={cleanupContactForEdit}\n    mutationMode={mutationMode}\n  >\n    <ContactEditContent />\n  </EditBase>\n);\n\nconst normalizeContactArrayFields = (record: Contact) => ({\n  ...record,\n  email_jsonb:\n    record.email_jsonb && record.email_jsonb.length > 0\n      ? record.email_jsonb\n      : defaultEmailJsonb,\n  phone_jsonb:\n    record.phone_jsonb && record.phone_jsonb.length > 0\n      ? record.phone_jsonb\n      : defaultPhoneJsonb,\n});\n\nconst ContactEditContent = () => {\n  const { isPending, record } = useEditContext<Contact>();\n  if (isPending || !record) return null;\n  return (\n    <div className=\"mt-2 flex gap-8\">\n      <Form\n        className=\"flex flex-1 flex-col gap-4\"\n        record={normalizeContactArrayFields(record)}\n      >\n        <Card>\n          <CardContent>\n            <ContactInputs />\n            <FormToolbar />\n          </CardContent>\n        </Card>\n      </Form>\n\n      <ContactAside link=\"show\" />\n    </div>\n  );\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/contacts/ContactCreateSheet.tsx",
      "content": "import { useGetIdentity, useTranslate } from \"ra-core\";\nimport { CreateSheet } from \"../misc/CreateSheet\";\nimport { ContactInputs } from \"./ContactInputs\";\nimport {\n  cleanupContactForCreate,\n  defaultEmailJsonb,\n  defaultPhoneJsonb,\n} from \"./contactModel\";\n\nexport interface ContactCreateSheetProps {\n  open: boolean;\n  onOpenChange: (open: boolean) => void;\n}\n\nexport const ContactCreateSheet = ({\n  open,\n  onOpenChange,\n}: ContactCreateSheetProps) => {\n  const { identity } = useGetIdentity();\n  const translate = useTranslate();\n  return (\n    <CreateSheet\n      resource=\"contacts\"\n      title={translate(\"resources.contacts.action.new\")}\n      defaultValues={{\n        sales_id: identity?.id,\n        email_jsonb: defaultEmailJsonb,\n        phone_jsonb: defaultPhoneJsonb,\n      }}\n      transform={cleanupContactForCreate}\n      open={open}\n      onOpenChange={onOpenChange}\n    >\n      <ContactInputs />\n    </CreateSheet>\n  );\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/contacts/ContactCreate.tsx",
      "content": "import { CreateBase, Form, useGetIdentity, type MutationMode } from \"ra-core\";\nimport { Card, CardContent } from \"@/components/ui/card\";\n\nimport { ContactInputs } from \"./ContactInputs\";\nimport { FormToolbar } from \"../layout/FormToolbar\";\nimport {\n  cleanupContactForCreate,\n  defaultEmailJsonb,\n  defaultPhoneJsonb,\n} from \"./contactModel\";\n\nexport const ContactCreate = ({\n  mutationMode,\n}: {\n  mutationMode?: MutationMode;\n}) => {\n  const { identity } = useGetIdentity();\n\n  return (\n    <CreateBase\n      redirect=\"show\"\n      transform={cleanupContactForCreate}\n      mutationMode={mutationMode}\n    >\n      <div className=\"mt-2 flex lg:mr-72\">\n        <div className=\"flex-1\">\n          <Form\n            defaultValues={{\n              sales_id: identity?.id,\n              email_jsonb: defaultEmailJsonb,\n              phone_jsonb: defaultPhoneJsonb,\n            }}\n          >\n            <Card>\n              <CardContent>\n                <ContactInputs />\n                <FormToolbar />\n              </CardContent>\n            </Card>\n          </Form>\n        </div>\n      </div>\n    </CreateBase>\n  );\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/contacts/ContactBackgroundInfo.tsx",
      "content": "import {\n  useGetIdentity,\n  useLocaleState,\n  useRecordContext,\n  useTranslate,\n  WithRecord,\n} from \"ra-core\";\nimport { TextField } from \"@/components/admin/text-field\";\nimport { formatLocalizedDate } from \"../misc/RelativeDate\";\nimport { useGetSalesName } from \"../sales/useGetSalesName\";\nimport type { Contact } from \"../types\";\n\nexport const ContactBackgroundInfo = () => {\n  const record = useRecordContext<Contact>();\n  const translate = useTranslate();\n  const [locale = \"en\"] = useLocaleState();\n  const { identity } = useGetIdentity();\n  const isCurrentUser = record?.sales_id === identity?.id;\n  const salesName = useGetSalesName(record?.sales_id, {\n    enabled: !isCurrentUser,\n  });\n\n  if (!record) return null;\n\n  const formattedLastSeen = record.last_seen\n    ? formatLocalizedDate(record.last_seen, locale)\n    : \"\";\n  const formattedFirstSeen = formatLocalizedDate(record.first_seen, locale);\n\n  return (\n    <div>\n      <WithRecord<Contact>\n        render={(record) =>\n          record?.background ? (\n            <div className=\"pb-2 text-sm\">\n              <TextField source=\"background\" record={record} />\n            </div>\n          ) : null\n        }\n      />\n      <div className=\"text-muted-foreground md:py-0.5\">\n        <span className=\"text-sm\">\n          {translate(\"resources.contacts.background.added_on\", {\n            date: formattedFirstSeen,\n          })}\n        </span>{\" \"}\n      </div>\n\n      <div className=\"text-muted-foreground md:py-0.5\">\n        <span className=\"text-sm\">\n          {translate(\"resources.contacts.background.last_activity_on\", {\n            date: formattedLastSeen,\n          })}\n        </span>\n      </div>\n\n      <div className=\"inline-flex text-muted-foreground text-sm md:py-0.5\">\n        {translate(\n          isCurrentUser\n            ? \"resources.contacts.background.followed_by_you\"\n            : \"resources.contacts.background.followed_by\",\n          { name: salesName },\n        )}\n      </div>\n    </div>\n  );\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/contacts/ContactAside.tsx",
      "content": "import { useRecordContext, useTranslate } from \"ra-core\";\nimport { EditButton } from \"@/components/admin/edit-button\";\nimport { DeleteButton } from \"@/components/admin\";\nimport { ReferenceManyField } from \"@/components/admin/reference-many-field\";\nimport { ShowButton } from \"@/components/admin/show-button\";\n\nimport { AddTask } from \"../tasks/AddTask\";\nimport { TasksIterator } from \"../tasks/TasksIterator\";\nimport { TagsListEdit } from \"./TagsListEdit\";\nimport { ContactStatusSelector } from \"./ContactInputs\";\nimport { ContactPersonalInfo } from \"./ContactPersonalInfo\";\nimport { ContactBackgroundInfo } from \"./ContactBackgroundInfo\";\nimport { AsideSection } from \"../misc/AsideSection\";\nimport type { Contact } from \"../types\";\nimport { ContactMergeButton } from \"./ContactMergeButton\";\nimport { ExportVCardButton } from \"./ExportVCardButton\";\n\nexport const ContactAside = ({ link = \"edit\" }: { link?: \"edit\" | \"show\" }) => {\n  const record = useRecordContext<Contact>();\n  const translate = useTranslate();\n\n  if (!record) return null;\n\n  return (\n    <div className=\"hidden sm:block w-92 min-w-92 text-sm\">\n      <div className=\"mb-4 -ml-1\">\n        {link === \"edit\" ? (\n          <EditButton label=\"resources.contacts.action.edit\" />\n        ) : (\n          <ShowButton label=\"resources.contacts.action.show\" />\n        )}\n      </div>\n\n      <AsideSection title={translate(\"resources.notes.fields.status\")}>\n        <ContactStatusSelector />\n      </AsideSection>\n\n      <AsideSection\n        title={translate(\"resources.contacts.field_categories.personal_info\")}\n      >\n        <ContactPersonalInfo />\n      </AsideSection>\n\n      <AsideSection\n        title={translate(\"resources.contacts.field_categories.background_info\")}\n      >\n        <ContactBackgroundInfo />\n      </AsideSection>\n\n      <AsideSection\n        title={translate(\"resources.tags.name\", { smart_count: 2 })}\n      >\n        <TagsListEdit />\n      </AsideSection>\n\n      <AsideSection\n        title={translate(\"resources.tasks.name\", { smart_count: 2 })}\n      >\n        <ReferenceManyField\n          target=\"contact_id\"\n          reference=\"tasks\"\n          sort={{ field: \"due_date\", order: \"ASC\" }}\n          perPage={1000}\n        >\n          <TasksIterator />\n        </ReferenceManyField>\n        <AddTask />\n      </AsideSection>\n\n      {link !== \"edit\" && (\n        <>\n          <div className=\"mt-6 pt-6 border-t hidden sm:flex flex-col gap-2 items-start\">\n            <ExportVCardButton />\n            <ContactMergeButton />\n          </div>\n          <div className=\"mt-6 pt-6 border-t hidden sm:flex flex-col gap-2 items-start\">\n            <DeleteButton\n              className=\"h-6 cursor-pointer hover:bg-destructive/10! text-destructive! border-destructive! focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40\"\n              size=\"sm\"\n            />\n          </div>\n        </>\n      )}\n    </div>\n  );\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/contacts/BulkTagButton.tsx",
      "content": "import { Plus, Tag as TagIcon } from \"lucide-react\";\nimport { useCallback, useEffect, useState } from \"react\";\nimport {\n  useGetMany,\n  useListContext,\n  useNotify,\n  useRefresh,\n  useTranslate,\n  useUpdate,\n} from \"ra-core\";\nimport { Button } from \"@/components/ui/button\";\nimport { Badge } from \"@/components/ui/badge\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogHeader,\n  DialogTitle,\n} from \"@/components/ui/dialog\";\n\nimport { TagForm } from \"../tags/TagForm\";\nimport { useCreateTag } from \"../tags/useCreateTag\";\nimport { useTags } from \"../tags/useTags\";\nimport type { Contact, Tag } from \"../types\";\n\ntype BulkTagDialogMode = \"select\" | \"create\";\n\nexport function BulkTagButton() {\n  const translate = useTranslate();\n  const notify = useNotify();\n  const refresh = useRefresh();\n  const [update] = useUpdate<Contact>(\"contacts\", undefined, {\n    returnPromise: true,\n  });\n  const createTag = useCreateTag();\n  const { onUnselectItems, selectedIds = [] } = useListContext<Contact>();\n  const [open, setOpen] = useState(false);\n  const [mode, setMode] = useState<BulkTagDialogMode>(\"select\");\n  const [isApplying, setIsApplying] = useState(false);\n\n  const { data: selectedContacts = [], isPending: isPendingContacts } =\n    useGetMany<Contact>(\n      \"contacts\",\n      { ids: selectedIds },\n      { enabled: open && selectedIds.length > 0 },\n    );\n  const { data: tags = [], isPending: isPendingTags } = useTags({\n    enabled: open,\n  });\n\n  const closeDialog = useCallback(() => {\n    setOpen(false);\n    setMode(\"select\");\n  }, []);\n\n  useEffect(() => {\n    if (!selectedIds.length && open) {\n      closeDialog();\n    }\n  }, [closeDialog, open, selectedIds.length]);\n\n  const applyTagToSelection = useCallback(\n    async (tag: Tag) => {\n      const contactsToUpdate = selectedContacts.filter(\n        (contact) => !contact.tags.includes(tag.id),\n      );\n\n      setIsApplying(true);\n\n      try {\n        await Promise.all(\n          contactsToUpdate.map((contact) =>\n            update(\"contacts\", {\n              id: contact.id,\n              data: { tags: [...(contact.tags ?? []), tag.id] },\n              previousData: contact,\n            }),\n          ),\n        );\n\n        notify(\n          contactsToUpdate.length > 0\n            ? \"resources.contacts.bulk_tag.success\"\n            : \"resources.contacts.bulk_tag.noop\",\n          {\n            messageArgs: { smart_count: contactsToUpdate.length },\n            type: \"success\",\n          },\n        );\n        closeDialog();\n        onUnselectItems();\n        refresh();\n      } catch (error) {\n        notify(\"resources.contacts.bulk_tag.error\", {\n          type: \"error\",\n        });\n        console.error(\"Bulk tag failed:\", error);\n      } finally {\n        setIsApplying(false);\n      }\n    },\n    [closeDialog, update, notify, onUnselectItems, refresh, selectedContacts],\n  );\n\n  const handleCreateTag = async (data: Pick<Tag, \"name\" | \"color\">) => {\n    const tag = await createTag(data);\n    await applyTagToSelection(tag);\n  };\n\n  if (!selectedIds.length) {\n    return null;\n  }\n\n  const isBusy = isApplying || isPendingContacts || isPendingTags;\n\n  return (\n    <>\n      <Button\n        type=\"button\"\n        variant=\"outline\"\n        size=\"sm\"\n        className=\"h-9\"\n        onClick={() => setOpen(true)}\n      >\n        <TagIcon />\n        {translate(\"resources.contacts.bulk_tag.action\")}\n      </Button>\n\n      <Dialog\n        open={open}\n        onOpenChange={(isOpen) => {\n          if (!isOpen) {\n            closeDialog();\n          }\n        }}\n      >\n        <DialogContent className=\"sm:max-w-lg\">\n          {mode === \"select\" ? (\n            <>\n              <DialogHeader>\n                <DialogTitle>\n                  {translate(\"resources.contacts.bulk_tag.title\")}\n                </DialogTitle>\n                <DialogDescription>\n                  {translate(\"resources.contacts.bulk_tag.description\")}\n                </DialogDescription>\n              </DialogHeader>\n\n              <div className=\"flex flex-col space-y-2 items-start\">\n                {isPendingTags ? (\n                  <p className=\"text-sm text-muted-foreground\">\n                    {translate(\"crm.common.loading\")}\n                  </p>\n                ) : tags.length > 0 ? (\n                  tags.map((tag) => (\n                    <Button\n                      key={tag.id}\n                      type=\"button\"\n                      variant=\"ghost\"\n                      disabled={isBusy}\n                      className=\"px-0 py-0 hover:bg-default dark:hover:bg-default mb-0\"\n                      onClick={() => applyTagToSelection(tag)}\n                    >\n                      <Badge\n                        variant=\"secondary\"\n                        className=\"font-normal text-black cursor-pointer hover:opacity-80 transition-opacity\"\n                        style={{ backgroundColor: tag.color }}\n                      >\n                        {tag.name}\n                      </Badge>\n                    </Button>\n                  ))\n                ) : (\n                  <p className=\"text-sm text-muted-foreground\">\n                    {translate(\"resources.contacts.bulk_tag.empty\")}\n                  </p>\n                )}\n              </div>\n\n              <div className=\"flex justify-start\">\n                <Button\n                  type=\"button\"\n                  variant=\"outline\"\n                  disabled={isBusy}\n                  onClick={() => setMode(\"create\")}\n                >\n                  <Plus />\n                  {translate(\"resources.tags.action.create\")}\n                </Button>\n              </div>\n            </>\n          ) : (\n            <>\n              <DialogHeader>\n                <DialogTitle>\n                  {translate(\"resources.tags.dialog.create_title\")}\n                </DialogTitle>\n                <DialogDescription>\n                  {translate(\"resources.contacts.bulk_tag.create_description\")}\n                </DialogDescription>\n              </DialogHeader>\n\n              <TagForm\n                cancelLabel={translate(\"resources.contacts.bulk_tag.back\")}\n                open={open && mode === \"create\"}\n                onCancel={() => setMode(\"select\")}\n                onSubmit={handleCreateTag}\n              />\n            </>\n          )}\n        </DialogContent>\n      </Dialog>\n    </>\n  );\n}\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/contacts/Avatar.tsx",
      "content": "import {\n  AvatarFallback,\n  AvatarImage,\n  Avatar as ShadcnAvatar,\n} from \"@/components/ui/avatar\";\nimport { useRecordContext } from \"ra-core\";\n\nimport type { Contact } from \"../types\";\n\nexport const Avatar = (props: {\n  record?: Contact;\n  width?: 20 | 25 | 40;\n  height?: 20 | 25 | 40;\n  title?: string;\n}) => {\n  const record = useRecordContext<Contact>(props);\n  // If we come from company page, the record is defined (to pass the company as a prop),\n  // but neither of those fields are and this lead to an error when creating contact.\n  if (!record?.avatar && !record?.first_name && !record?.last_name) {\n    return null;\n  }\n\n  const size = props.width || props.height;\n  const sizeClass =\n    props.width === 20\n      ? `w-[20px] h-[20px]`\n      : props.width === 25\n        ? \"w-[25px] h-[25px]\"\n        : \"w-10 h-10\";\n\n  return (\n    <ShadcnAvatar className={sizeClass} title={props.title}>\n      <AvatarImage src={record.avatar?.src ?? undefined} />\n      <AvatarFallback className={size && size < 40 ? \"text-[10px]\" : \"text-sm\"}>\n        {record.first_name?.charAt(0).toUpperCase()}\n        {record.last_name?.charAt(0).toUpperCase()}\n      </AvatarFallback>\n    </ShadcnAvatar>\n  );\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/companies/sizes.ts",
      "content": "export const sizes = [\n  { id: 1, name: \"1 employee\" },\n  { id: 10, name: \"2-9 employees\" },\n  { id: 50, name: \"10-49 employees\" },\n  { id: 250, name: \"50-249 employees\" },\n  { id: 500, name: \"250 or more employees\" },\n];\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/companies/index.ts",
      "content": "import { CompanyList } from \"./CompanyList\";\nimport { CompanyCreate } from \"./CompanyCreate\";\nimport { CompanyShow } from \"./CompanyShow\";\nimport { CompanyEdit } from \"./CompanyEdit\";\n\nexport default {\n  list: CompanyList,\n  create: CompanyCreate,\n  edit: CompanyEdit,\n  show: CompanyShow,\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/companies/getTranslatedCompanySizeLabel.ts",
      "content": "type TranslateFn = (key: string, options?: { [key: string]: any }) => string;\n\nconst defaultCompanySizeLabels: Record<number, string> = {\n  1: \"1 employee\",\n  10: \"2-9 employees\",\n  50: \"10-49 employees\",\n  250: \"50-249 employees\",\n  500: \"250 or more employees\",\n};\n\nconst companySizeTranslationKeys: Record<number, string> = {\n  1: \"resources.companies.sizes.one_employee\",\n  10: \"resources.companies.sizes.two_to_nine_employees\",\n  50: \"resources.companies.sizes.ten_to_forty_nine_employees\",\n  250: \"resources.companies.sizes.fifty_to_two_hundred_forty_nine_employees\",\n  500: \"resources.companies.sizes.two_hundred_fifty_or_more_employees\",\n};\n\nexport const getTranslatedCompanySizeLabel = (\n  size: { id: number; name: string },\n  translate: TranslateFn,\n) => {\n  const defaultLabel = defaultCompanySizeLabels[size.id];\n  const translationKey = companySizeTranslationKeys[size.id];\n  if (!defaultLabel || !translationKey || size.name !== defaultLabel) {\n    return size.name;\n  }\n\n  return translate(translationKey, { _: size.name });\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/companies/GridList.tsx",
      "content": "import { RecordContextProvider, useListContext, useTranslate } from \"ra-core\";\n\nimport type { Company } from \"../types\";\nimport { CompanyCard } from \"./CompanyCard\";\n\nconst times = (nbChildren: number, fn: (key: number) => any) =>\n  Array.from({ length: nbChildren }, (_, key) => fn(key));\n\nconst LoadingGridList = () => (\n  <div className=\"flex flex-wrap w-[1008px] gap-1\">\n    {times(15, (key) => (\n      <div\n        className=\"h-[200px] w-[194px] flex flex-col bg-gray-200\"\n        key={key}\n      />\n    ))}\n  </div>\n);\n\nconst LoadedGridList = () => {\n  const { data, error, isPending } = useListContext<Company>();\n  const translate = useTranslate();\n\n  if (isPending || error) return null;\n\n  return (\n    <div\n      className=\"w-full gap-2 grid\"\n      style={{\n        gridTemplateColumns: \"repeat(auto-fill, minmax(180px, 1fr))\",\n      }}\n    >\n      {data.map((record) => (\n        <RecordContextProvider key={record.id} value={record}>\n          <CompanyCard />\n        </RecordContextProvider>\n      ))}\n\n      {data.length === 0 && (\n        <div className=\"p-2\">\n          {translate(\"resources.companies.empty.title\", {\n            _: \"No companies found\",\n          })}\n        </div>\n      )}\n    </div>\n  );\n};\n\nexport const ImageList = () => {\n  const { isPending } = useListContext();\n  return isPending ? <LoadingGridList /> : <LoadedGridList />;\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/companies/CompanyShow.tsx",
      "content": "import { ReferenceManyField } from \"@/components/admin/reference-many-field\";\nimport { SortButton } from \"@/components/admin/sort-button\";\nimport { Button } from \"@/components/ui/button\";\nimport { Card, CardContent } from \"@/components/ui/card\";\nimport { Tabs, TabsContent, TabsList, TabsTrigger } from \"@/components/ui/tabs\";\nimport { UserPlus } from \"lucide-react\";\nimport {\n  RecordContextProvider,\n  ShowBase,\n  useListContext,\n  useLocaleState,\n  useRecordContext,\n  useShowContext,\n  useTranslate,\n} from \"ra-core\";\nimport {\n  Link,\n  Link as RouterLink,\n  useLocation,\n  useMatch,\n  useNavigate,\n} from \"react-router-dom\";\n\nimport { useIsMobile } from \"@/hooks/use-mobile\";\nimport { ActivityLog } from \"../activity/ActivityLog\";\nimport { Avatar } from \"../contacts/Avatar\";\nimport { TagsList } from \"../contacts/TagsList\";\nimport { findDealLabel } from \"../deals/dealUtils\";\nimport { MobileContent } from \"../layout/MobileContent\";\nimport MobileHeader from \"../layout/MobileHeader\";\nimport { MobileBackButton } from \"../misc/MobileBackButton\";\nimport { formatRelativeDate } from \"../misc/RelativeDate\";\nimport { Status } from \"../misc/Status\";\nimport { useConfigurationContext } from \"../root/ConfigurationContext\";\nimport type { Company, Contact, Deal } from \"../types\";\nimport {\n  AdditionalInfo,\n  AddressInfo,\n  CompanyAside,\n  CompanyInfo,\n  ContextInfo,\n} from \"./CompanyAside\";\nimport { CompanyAvatar } from \"./CompanyAvatar\";\n\nexport const CompanyShow = () => {\n  const isMobile = useIsMobile();\n\n  return (\n    <ShowBase>\n      {isMobile ? <CompanyShowContentMobile /> : <CompanyShowContent />}\n    </ShowBase>\n  );\n};\n\nconst CompanyShowContentMobile = () => {\n  const translate = useTranslate();\n  const { record, isPending } = useShowContext<Company>();\n  if (isPending || !record) return null;\n\n  return (\n    <>\n      <MobileHeader>\n        <MobileBackButton to=\"/\" />\n        <div className=\"flex flex-1\">\n          <Link to=\"/\">\n            <h1 className=\"text-xl font-semibold\">\n              {translate(\"resources.companies.forcedCaseName\")}\n            </h1>\n          </Link>\n        </div>\n      </MobileHeader>\n\n      <MobileContent>\n        <div className=\"mb-6\">\n          <div className=\"flex items-center mb-4\">\n            <CompanyAvatar />\n            <div className=\"mx-3 flex-1\">\n              <h2 className=\"text-2xl font-bold\">{record.name}</h2>\n            </div>\n          </div>\n        </div>\n        <CompanyInfo record={record} />\n        <AddressInfo record={record} />\n        <ContextInfo record={record} />\n        <AdditionalInfo record={record} />\n      </MobileContent>\n    </>\n  );\n};\n\nconst CompanyShowContent = () => {\n  const translate = useTranslate();\n  const { record, isPending } = useShowContext<Company>();\n  const navigate = useNavigate();\n\n  // Get tab from URL or default to \"activity\"\n  const tabMatch = useMatch(\"/companies/:id/show/:tab\");\n  const currentTab = tabMatch?.params?.tab || \"activity\";\n\n  const handleTabChange = (value: string) => {\n    if (value === currentTab) return;\n    if (value === \"activity\") {\n      navigate(`/companies/${record?.id}/show`);\n      return;\n    }\n    navigate(`/companies/${record?.id}/show/${value}`);\n  };\n\n  if (isPending || !record) return null;\n\n  return (\n    <div className=\"mt-2 flex pb-2 gap-8\">\n      <div className=\"flex-1\">\n        <Card>\n          <CardContent>\n            <div className=\"flex mb-3\">\n              <CompanyAvatar />\n              <h5 className=\"text-xl ml-2 flex-1\">{record.name}</h5>\n            </div>\n            <Tabs defaultValue={currentTab} onValueChange={handleTabChange}>\n              <TabsList className=\"grid w-full grid-cols-3\">\n                <TabsTrigger value=\"activity\">\n                  {translate(\"crm.common.activity\")}\n                </TabsTrigger>\n                <TabsTrigger value=\"contacts\">\n                  {record.nb_contacts === 0\n                    ? translate(\"resources.companies.no_contacts\")\n                    : translate(\"resources.companies.nb_contacts\", {\n                        smart_count: record.nb_contacts ?? 0,\n                      })}\n                </TabsTrigger>\n                {record.nb_deals ? (\n                  <TabsTrigger value=\"deals\">\n                    {translate(\"resources.companies.nb_deals\", {\n                      smart_count: record.nb_deals ?? 0,\n                    })}\n                  </TabsTrigger>\n                ) : null}\n              </TabsList>\n              <TabsContent value=\"activity\" className=\"pt-2\">\n                <ActivityLog companyId={record.id} context=\"company\" />\n              </TabsContent>\n              <TabsContent value=\"contacts\">\n                {record.nb_contacts ? (\n                  <ReferenceManyField\n                    reference=\"contacts_summary\"\n                    target=\"company_id\"\n                    sort={{ field: \"last_name\", order: \"ASC\" }}\n                  >\n                    <div className=\"flex flex-col gap-4\">\n                      <div className=\"flex flex-row justify-end space-x-2 mt-1\">\n                        {!!record.nb_contacts && (\n                          <SortButton\n                            fields={[\"last_name\", \"first_name\", \"last_seen\"]}\n                          />\n                        )}\n                        <CreateRelatedContactButton />\n                      </div>\n                      <ContactsIterator />\n                    </div>\n                  </ReferenceManyField>\n                ) : (\n                  <div className=\"flex flex-col gap-4\">\n                    <div className=\"flex flex-row justify-end space-x-2 mt-1\">\n                      <CreateRelatedContactButton />\n                    </div>\n                  </div>\n                )}\n              </TabsContent>\n              <TabsContent value=\"deals\">\n                {record.nb_deals ? (\n                  <ReferenceManyField\n                    reference=\"deals\"\n                    target=\"company_id\"\n                    sort={{ field: \"name\", order: \"ASC\" }}\n                  >\n                    <DealsIterator />\n                  </ReferenceManyField>\n                ) : null}\n              </TabsContent>\n            </Tabs>\n          </CardContent>\n        </Card>\n      </div>\n      <CompanyAside />\n    </div>\n  );\n};\n\nconst ContactsIterator = () => {\n  const translate = useTranslate();\n  const [locale = \"en\"] = useLocaleState();\n  const location = useLocation();\n  const { data: contacts, error, isPending } = useListContext<Contact>();\n\n  if (isPending || error) return null;\n\n  return (\n    <div className=\"pt-0\">\n      {contacts.map((contact) => (\n        <RecordContextProvider key={contact.id} value={contact}>\n          <div className=\"p-0 text-sm\">\n            <RouterLink\n              to={`/contacts/${contact.id}/show`}\n              state={{ from: location.pathname }}\n              className=\"flex items-center justify-between hover:bg-muted py-2 transition-colors\"\n            >\n              <div className=\"mr-4\">\n                <Avatar />\n              </div>\n              <div className=\"flex-1 min-w-0\">\n                <div className=\"font-medium\">\n                  {`${contact.first_name} ${contact.last_name}`}\n                </div>\n                <div className=\"text-sm text-muted-foreground\">\n                  {contact.title}\n                  {contact.nb_tasks\n                    ? ` - ${translate(\"crm.common.task_count\", {\n                        smart_count: contact.nb_tasks ?? 0,\n                      })}`\n                    : \"\"}\n                  &nbsp; &nbsp;\n                  <TagsList />\n                </div>\n              </div>\n              {contact.last_seen && (\n                <div className=\"text-right\">\n                  <div className=\"text-sm text-muted-foreground\">\n                    {translate(\"crm.common.last_activity_with_date\", {\n                      date: formatRelativeDate(contact.last_seen, locale),\n                    })}{\" \"}\n                    <Status status={contact.status} />\n                  </div>\n                </div>\n              )}\n            </RouterLink>\n          </div>\n        </RecordContextProvider>\n      ))}\n    </div>\n  );\n};\n\nconst CreateRelatedContactButton = () => {\n  const translate = useTranslate();\n  const company = useRecordContext<Company>();\n  return (\n    <Button variant=\"outline\" asChild size=\"sm\" className=\"h-9\">\n      <RouterLink\n        to=\"/contacts/create\"\n        state={company ? { record: { company_id: company.id } } : undefined}\n        className=\"flex items-center gap-2\"\n      >\n        <UserPlus className=\"h-4 w-4\" />\n        {translate(\"resources.contacts.action.add\")}\n      </RouterLink>\n    </Button>\n  );\n};\n\nconst DealsIterator = () => {\n  const translate = useTranslate();\n  const [locale = \"en\"] = useLocaleState();\n  const { data: deals, error, isPending } = useListContext<Deal>();\n  const { dealStages, dealCategories, currency } = useConfigurationContext();\n  if (isPending || error) return null;\n  return (\n    <div>\n      <div>\n        {deals.map((deal) => (\n          <div key={deal.id} className=\"p-0 text-sm\">\n            <RouterLink\n              to={`/deals/${deal.id}/show`}\n              className=\"flex items-center justify-between hover:bg-muted py-2 px-4 transition-colors\"\n            >\n              <div className=\"flex-1 min-w-0\">\n                <div className=\"font-medium\">{deal.name}</div>\n                <div className=\"text-sm text-muted-foreground\">\n                  {findDealLabel(dealStages, deal.stage)},{\" \"}\n                  {deal.amount.toLocaleString(\"en-US\", {\n                    notation: \"compact\",\n                    style: \"currency\",\n                    currency,\n                    currencyDisplay: \"narrowSymbol\",\n                    minimumSignificantDigits: 3,\n                  })}\n                  {deal.category\n                    ? `, ${dealCategories.find((c) => c.value === deal.category)?.label ?? deal.category}`\n                    : \"\"}\n                </div>\n              </div>\n              <div className=\"text-right\">\n                <div className=\"text-sm text-muted-foreground\">\n                  {translate(\"crm.common.last_activity_with_date\", {\n                    date: formatRelativeDate(deal.updated_at, locale),\n                  })}{\" \"}\n                </div>\n              </div>\n            </RouterLink>\n          </div>\n        ))}\n      </div>\n    </div>\n  );\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/companies/CompanyListFilter.tsx",
      "content": "import { Building, Truck, Users } from \"lucide-react\";\nimport { FilterLiveForm, useGetIdentity, useTranslate } from \"ra-core\";\nimport { ToggleFilterButton } from \"@/components/admin/toggle-filter-button\";\nimport { SearchInput } from \"@/components/admin/search-input\";\n\nimport { FilterCategory } from \"../filters/FilterCategory\";\nimport { useConfigurationContext } from \"../root/ConfigurationContext\";\nimport { getTranslatedCompanySizeLabel } from \"./getTranslatedCompanySizeLabel\";\nimport { sizes } from \"./sizes\";\n\nexport const CompanyListFilter = () => {\n  const { identity } = useGetIdentity();\n  const { companySectors } = useConfigurationContext();\n  const translate = useTranslate();\n  const translatedSizes = sizes.map((size) => ({\n    ...size,\n    name: getTranslatedCompanySizeLabel(size, translate),\n  }));\n  return (\n    <div className=\"w-52 min-w-52 flex flex-col gap-8\">\n      <FilterLiveForm>\n        <SearchInput source=\"q\" />\n      </FilterLiveForm>\n\n      <FilterCategory\n        icon={<Building className=\"h-4 w-4\" />}\n        label=\"resources.companies.fields.size\"\n      >\n        {translatedSizes.map((size) => (\n          <ToggleFilterButton\n            className=\"w-full justify-between\"\n            label={size.name}\n            key={size.name}\n            value={{ size: size.id }}\n          />\n        ))}\n      </FilterCategory>\n\n      <FilterCategory\n        icon={<Truck className=\"h-4 w-4\" />}\n        label=\"resources.companies.fields.sector\"\n      >\n        {companySectors.map((sector) => (\n          <ToggleFilterButton\n            className=\"w-full justify-between\"\n            label={sector.label}\n            key={sector.value}\n            value={{ sector: sector.value }}\n          />\n        ))}\n      </FilterCategory>\n\n      <FilterCategory\n        icon={<Users className=\"h-4 w-4\" />}\n        label=\"resources.companies.fields.sales_id\"\n      >\n        <ToggleFilterButton\n          className=\"w-full justify-between\"\n          label={translate(\"crm.common.me\")}\n          value={{ sales_id: identity?.id }}\n        />\n      </FilterCategory>\n    </div>\n  );\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/companies/CompanyList.tsx",
      "content": "import { useGetIdentity, useListContext, useTranslate } from \"ra-core\";\nimport { CreateButton } from \"@/components/admin/create-button\";\nimport { ExportButton } from \"@/components/admin/export-button\";\nimport { List } from \"@/components/admin/list\";\nimport { ListPagination } from \"@/components/admin/list-pagination\";\nimport { SortButton } from \"@/components/admin/sort-button\";\n\nimport { TopToolbar } from \"../layout/TopToolbar\";\nimport { CompanyEmpty } from \"./CompanyEmpty\";\nimport { CompanyListFilter } from \"./CompanyListFilter\";\nimport { ImageList } from \"./GridList\";\n\nexport const CompanyList = () => {\n  const { identity } = useGetIdentity();\n  if (!identity) return null;\n  return (\n    <List\n      title={false}\n      perPage={25}\n      sort={{ field: \"name\", order: \"ASC\" }}\n      actions={<CompanyListActions />}\n      pagination={<ListPagination rowsPerPageOptions={[10, 25, 50, 100]} />}\n    >\n      <CompanyListLayout />\n    </List>\n  );\n};\n\nconst CompanyListLayout = () => {\n  const { data, isPending, filterValues } = useListContext();\n  const hasFilters = filterValues && Object.keys(filterValues).length > 0;\n\n  if (isPending) return null;\n  if (!data?.length && !hasFilters) return <CompanyEmpty />;\n\n  return (\n    <div className=\"w-full flex flex-row gap-8\">\n      <CompanyListFilter />\n      <div className=\"flex flex-col flex-1 gap-4\">\n        <ImageList />\n      </div>\n    </div>\n  );\n};\n\nconst CompanyListActions = () => {\n  const translate = useTranslate();\n  return (\n    <TopToolbar>\n      <SortButton fields={[\"name\", \"created_at\", \"nb_contacts\"]} />\n      <ExportButton />\n      <CreateButton\n        label={translate(\"resources.companies.action.new\", {\n          _: \"New Company\",\n        })}\n      />\n    </TopToolbar>\n  );\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/companies/CompanyInputs.tsx",
      "content": "import { required, useRecordContext, useTranslate } from \"ra-core\";\nimport { ReferenceInput } from \"@/components/admin/reference-input\";\nimport { TextInput } from \"@/components/admin/text-input\";\nimport { SelectInput } from \"@/components/admin/select-input\";\nimport { ArrayInput } from \"@/components/admin/array-input\";\nimport { SimpleFormIterator } from \"@/components/admin/simple-form-iterator\";\nimport { Separator } from \"@/components/ui/separator\";\nimport { useIsMobile } from \"@/hooks/use-mobile\";\n\nimport ImageEditorField from \"../misc/ImageEditorField\";\nimport { isLinkedinUrl } from \"../misc/isLinkedInUrl\";\nimport { useConfigurationContext } from \"../root/ConfigurationContext\";\nimport type { Company, Sale } from \"../types\";\nimport { getTranslatedCompanySizeLabel } from \"./getTranslatedCompanySizeLabel\";\nimport { sizes } from \"./sizes\";\n\nconst isUrl = (url: string) => {\n  if (!url) return;\n  const UrlRegex = new RegExp(\n    /^(http:\\/\\/www\\.|https:\\/\\/www\\.|http:\\/\\/|https:\\/\\/)?[a-z0-9]+([-.]{1}[a-z0-9]+)*\\.[a-z]{2,5}(:[0-9]{1,5})?(\\/.*)?$/i,\n  );\n  if (!UrlRegex.test(url)) {\n    return {\n      message: \"crm.validation.invalid_url\",\n      args: { _: \"Must be a valid URL\" },\n    };\n  }\n};\n\nexport const CompanyInputs = () => {\n  const isMobile = useIsMobile();\n\n  return (\n    <div className=\"flex flex-col gap-4 p-1\">\n      <CompanyDisplayInputs />\n      <div className={`flex gap-6 ${isMobile ? \"flex-col\" : \"flex-row\"}`}>\n        <div className=\"flex flex-col gap-10 flex-1\">\n          <CompanyContactInputs />\n          <CompanyContextInputs />\n        </div>\n        <Separator orientation={isMobile ? \"horizontal\" : \"vertical\"} />\n        <div className=\"flex flex-col gap-8 flex-1\">\n          <CompanyAddressInputs />\n          <CompanyAdditionalInformationInputs />\n        </div>\n      </div>\n    </div>\n  );\n};\n\nconst CompanyDisplayInputs = () => {\n  const translate = useTranslate();\n  const record = useRecordContext<Company>();\n  return (\n    <div className=\"flex gap-4 flex-1 flex-row\">\n      <ImageEditorField\n        source=\"logo\"\n        type=\"avatar\"\n        width={60}\n        height={60}\n        emptyText={record?.name.charAt(0)}\n        linkPosition=\"bottom\"\n      />\n      <TextInput\n        source=\"name\"\n        className=\"w-full h-fit\"\n        validate={required()}\n        helperText={false}\n        placeholder={translate(\"resources.companies.fields.name\", {\n          _: \"Company name\",\n        })}\n      />\n    </div>\n  );\n};\n\nconst CompanyContactInputs = () => {\n  const translate = useTranslate();\n  return (\n    <div className=\"flex flex-col gap-4\">\n      <h6 className=\"text-lg font-semibold\">\n        {translate(\"resources.companies.field_categories.contact\", {\n          _: \"Company info\",\n        })}\n      </h6>\n      <TextInput source=\"website\" helperText={false} validate={isUrl} />\n      <TextInput\n        source=\"linkedin_url\"\n        helperText={false}\n        validate={isLinkedinUrl}\n      />\n      <TextInput source=\"phone_number\" helperText={false} />\n    </div>\n  );\n};\n\nconst CompanyContextInputs = () => {\n  const translate = useTranslate();\n  const { companySectors } = useConfigurationContext();\n  const translatedSizes = sizes.map((size) => ({\n    ...size,\n    name: getTranslatedCompanySizeLabel(size, translate),\n  }));\n  return (\n    <div className=\"flex flex-col gap-4\">\n      <h6 className=\"text-lg font-semibold\">\n        {translate(\"resources.companies.field_categories.context\", {\n          _: \"Context\",\n        })}\n      </h6>\n      <SelectInput\n        source=\"sector\"\n        choices={companySectors}\n        optionText=\"label\"\n        optionValue=\"value\"\n        helperText={false}\n      />\n      <SelectInput source=\"size\" choices={translatedSizes} helperText={false} />\n      <TextInput source=\"revenue\" helperText={false} />\n      <TextInput source=\"tax_identifier\" helperText={false} />\n    </div>\n  );\n};\n\nconst CompanyAddressInputs = () => {\n  const translate = useTranslate();\n  return (\n    <div className=\"flex flex-col gap-4\">\n      <h6 className=\"text-lg font-semibold\">\n        {translate(\"resources.companies.field_categories.address\", {\n          _: \"Address\",\n        })}\n      </h6>\n      <TextInput source=\"address\" helperText={false} />\n      <TextInput source=\"city\" helperText={false} />\n      <TextInput source=\"zipcode\" helperText={false} />\n      <TextInput source=\"state_abbr\" helperText={false} />\n      <TextInput source=\"country\" helperText={false} />\n    </div>\n  );\n};\n\nconst CompanyAdditionalInformationInputs = () => {\n  const translate = useTranslate();\n  return (\n    <div className=\"flex flex-col gap-4\">\n      <h6 className=\"text-lg font-semibold\">\n        {translate(\"resources.companies.field_categories.additional_info\", {\n          _: \"Additional information\",\n        })}\n      </h6>\n      <TextInput source=\"description\" multiline helperText={false} />\n      <ArrayInput source=\"context_links\" helperText={false}>\n        <SimpleFormIterator disableReordering fullWidth getItemLabel={false}>\n          <TextInput\n            source=\"\"\n            label={false}\n            helperText={false}\n            validate={isUrl}\n          />\n        </SimpleFormIterator>\n      </ArrayInput>\n      <ReferenceInput\n        source=\"sales_id\"\n        reference=\"sales\"\n        filter={{\n          \"disabled@neq\": true,\n        }}\n      >\n        <SelectInput helperText={false} optionText={saleOptionRenderer} />\n      </ReferenceInput>\n    </div>\n  );\n};\n\nconst saleOptionRenderer = (choice: Sale) =>\n  `${choice.first_name} ${choice.last_name}`;\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/companies/CompanyEmpty.tsx",
      "content": "import { CreateButton } from \"@/components/admin/create-button\";\nimport { useTranslate } from \"ra-core\";\n\nimport useAppBarHeight from \"../misc/useAppBarHeight\";\n\nexport const CompanyEmpty = () => {\n  const appbarHeight = useAppBarHeight();\n  const translate = useTranslate();\n  return (\n    <div\n      className=\"flex flex-col justify-center items-center gap-6\"\n      style={{\n        height: `calc(100dvh - ${appbarHeight}px)`,\n      }}\n    >\n      <img\n        src=\"./img/empty.svg\"\n        alt={translate(\"resources.companies.empty.title\", {\n          _: \"No companies found\",\n        })}\n      />\n      <div className=\"flex flex-col gap-0 items-center\">\n        <h6 className=\"text-lg font-bold\">\n          {translate(\"resources.companies.empty.title\", {\n            _: \"No companies found\",\n          })}\n        </h6>\n        <p className=\"text-sm text-center text-muted-foreground mb-4\">\n          {translate(\"resources.companies.empty.description\", {\n            _: \"It seems your company list is empty.\",\n          })}\n        </p>\n      </div>\n      <div className=\"flex space-x-2\">\n        <CreateButton label=\"resources.companies.action.create\" />\n      </div>\n    </div>\n  );\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/companies/CompanyEdit.tsx",
      "content": "import { EditBase, Form } from \"ra-core\";\nimport { Card, CardContent } from \"@/components/ui/card\";\n\nimport { CompanyInputs } from \"./CompanyInputs\";\nimport { CompanyAside } from \"./CompanyAside\";\nimport { FormToolbar } from \"../layout/FormToolbar\";\n\nexport const CompanyEdit = () => (\n  <EditBase\n    actions={false}\n    redirect=\"show\"\n    transform={(values) => {\n      // add https:// before website if not present\n      if (values.website && !values.website.startsWith(\"http\")) {\n        values.website = `https://${values.website}`;\n      }\n      return values;\n    }}\n  >\n    <div className=\"mt-2 flex gap-8\">\n      <Form className=\"flex flex-1 flex-col gap-4 pb-2\">\n        <Card>\n          <CardContent>\n            <CompanyInputs />\n            <FormToolbar />\n          </CardContent>\n        </Card>\n      </Form>\n\n      <CompanyAside link=\"show\" />\n    </div>\n  </EditBase>\n);\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/companies/CompanyCreate.tsx",
      "content": "import { CreateBase, Form, useGetIdentity, useTranslate } from \"ra-core\";\nimport { Card, CardContent } from \"@/components/ui/card\";\nimport { CancelButton } from \"@/components/admin/cancel-button\";\nimport { SaveButton } from \"@/components/admin/form\";\n\nimport { CompanyInputs } from \"./CompanyInputs\";\n\nexport const CompanyCreate = () => {\n  const { identity } = useGetIdentity();\n  const translate = useTranslate();\n  return (\n    <CreateBase\n      redirect=\"show\"\n      transform={(values) => {\n        // add https:// before website if not present\n        if (values.website && !values.website.startsWith(\"http\")) {\n          values.website = `https://${values.website}`;\n        }\n        return values;\n      }}\n    >\n      <div className=\"mt-2 flex lg:mr-72\">\n        <div className=\"flex-1\">\n          <Form defaultValues={{ sales_id: identity?.id }}>\n            <Card>\n              <CardContent>\n                <CompanyInputs />\n                <div\n                  role=\"toolbar\"\n                  className=\"sticky flex pt-4 pb-4 md:pb-0 bottom-0 bg-linear-to-b from-transparent to-card to-10% flex-row justify-end gap-2\"\n                >\n                  <CancelButton />\n                  <SaveButton\n                    label={translate(\"resources.companies.action.create\", {\n                      _: \"Create Company\",\n                    })}\n                  />\n                </div>\n              </CardContent>\n            </Card>\n          </Form>\n        </div>\n      </div>\n    </CreateBase>\n  );\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/companies/CompanyCard.tsx",
      "content": "import { Handshake } from \"lucide-react\";\nimport { Link } from \"react-router\";\nimport {\n  useCreatePath,\n  useListContext,\n  useRecordContext,\n  useTranslate,\n} from \"ra-core\";\nimport { ReferenceManyField } from \"@/components/admin/reference-many-field\";\nimport { Card } from \"@/components/ui/card\";\n\nimport { Avatar as ContactAvatar } from \"../contacts/Avatar\";\nimport { useConfigurationContext } from \"../root/ConfigurationContext\";\nimport type { Company } from \"../types\";\nimport { CompanyAvatar } from \"./CompanyAvatar\";\n\nexport const CompanyCard = (props: { record?: Company }) => {\n  const createPath = useCreatePath();\n  const record = useRecordContext<Company>(props);\n  const translate = useTranslate();\n  const { companySectors } = useConfigurationContext();\n  if (!record) return null;\n\n  const sector = companySectors.find((s) => s.value === record.sector);\n  const sectorLabel = sector?.label;\n\n  return (\n    <Link\n      to={createPath({\n        resource: \"companies\",\n        id: record.id,\n        type: \"show\",\n      })}\n      className=\"no-underline\"\n    >\n      <Card className=\"h-[200px] flex flex-col justify-between p-4 hover:bg-muted\">\n        <div className=\"flex flex-col items-center gap-1\">\n          <CompanyAvatar />\n          <div className=\"text-center mt-1\">\n            <h6 className=\"text-sm font-medium\">{record.name}</h6>\n            <p className=\"text-xs text-muted-foreground\">{sectorLabel}</p>\n          </div>\n        </div>\n        <div className=\"flex flex-row w-full justify-between gap-2\">\n          <div className=\"flex items-center\">\n            {record.nb_contacts ? (\n              <ReferenceManyField reference=\"contacts\" target=\"company_id\">\n                <AvatarGroupIterator />\n              </ReferenceManyField>\n            ) : null}\n          </div>\n          {record.nb_deals ? (\n            <div className=\"flex items-center ml-2 gap-0.5\">\n              <Handshake className=\"w-4 h-4 text-muted-foreground\" />\n              <span className=\"text-sm font-medium\">{record.nb_deals}</span>\n              <span className=\"text-xs text-muted-foreground\">\n                {translate(\"resources.deals.name\", {\n                  smart_count: record.nb_deals ?? 0,\n                  _: \"Deal |||| Deals\",\n                })}\n              </span>\n            </div>\n          ) : null}\n        </div>\n      </Card>\n    </Link>\n  );\n};\n\nconst AvatarGroupIterator = () => {\n  const { data, total, error, isPending } = useListContext();\n  if (isPending || error) return null;\n\n  const MAX_AVATARS = 3;\n  return (\n    <div className=\"*:data-[slot=avatar]:ring-background flex -space-x-0.5 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:grayscale-50\">\n      {data.slice(0, MAX_AVATARS).map((record: any) => (\n        <ContactAvatar\n          key={record.id}\n          record={record}\n          width={25}\n          height={25}\n          title={`${record.first_name} ${record.last_name}`}\n        />\n      ))}\n      {total > MAX_AVATARS && (\n        <span\n          className=\"relative flex size-8 shrink-0 overflow-hidden rounded-full w-[25px] h-[25px]\"\n          data-slot=\"avatar\"\n        >\n          <span className=\"bg-muted flex size-full items-center justify-center rounded-full text-[10px]\">\n            +{total - MAX_AVATARS}\n          </span>\n        </span>\n      )}\n    </div>\n  );\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/companies/CompanyAvatar.tsx",
      "content": "import { useRecordContext } from \"ra-core\";\nimport { Avatar, AvatarFallback, AvatarImage } from \"@/components/ui/avatar\";\n\nimport type { Company } from \"../types\";\n\nexport const CompanyAvatar = (props: {\n  record?: Company;\n  width?: 20 | 40;\n  height?: 20 | 40;\n}) => {\n  const { width = 40 } = props;\n  const record = useRecordContext<Company>(props);\n  if (!record) return null;\n\n  const sizeClass = width !== 40 ? `w-[20px] h-[20px]` : \"w-10 h-10\";\n\n  return (\n    <Avatar className={sizeClass}>\n      <AvatarImage\n        src={record.logo?.src}\n        alt={record.name}\n        className=\"object-contain\"\n      />\n      <AvatarFallback className={width !== 40 ? \"text-xs\" : \"text-sm\"}>\n        {record.name.charAt(0)}\n      </AvatarFallback>\n    </Avatar>\n  );\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/companies/CompanyAside.tsx",
      "content": "import { Globe, Linkedin, Phone } from \"lucide-react\";\nimport {\n  useGetIdentity,\n  useLocaleState,\n  useRecordContext,\n  useTranslate,\n} from \"ra-core\";\nimport { EditButton } from \"@/components/admin/edit-button\";\nimport { DeleteButton } from \"@/components/admin/delete-button\";\nimport { ShowButton } from \"@/components/admin/show-button\";\nimport { TextField } from \"@/components/admin/text-field\";\nimport { UrlField } from \"@/components/admin/url-field\";\nimport { SelectField } from \"@/components/admin/select-field\";\n\nimport { formatLocalizedDate } from \"../misc/RelativeDate\";\nimport { AsideSection } from \"../misc/AsideSection\";\nimport { useConfigurationContext } from \"../root/ConfigurationContext\";\nimport type { Company } from \"../types\";\nimport { getTranslatedCompanySizeLabel } from \"./getTranslatedCompanySizeLabel\";\nimport { sizes } from \"./sizes\";\nimport { useGetSalesName } from \"../sales/useGetSalesName\";\n\ninterface CompanyAsideProps {\n  link?: string;\n}\n\nexport const CompanyAside = ({ link = \"edit\" }: CompanyAsideProps) => {\n  const record = useRecordContext<Company>();\n  const translate = useTranslate();\n  if (!record) return null;\n\n  return (\n    <div className=\"hidden sm:block w-92 min-w-92 space-y-4\">\n      <div className=\"flex flex-row space-x-1\">\n        {link === \"edit\" ? (\n          <EditButton label={translate(\"resources.companies.action.edit\")} />\n        ) : (\n          <ShowButton label={translate(\"resources.companies.action.show\")} />\n        )}\n      </div>\n\n      <CompanyInfo record={record} />\n\n      <AddressInfo record={record} />\n\n      <ContextInfo record={record} />\n\n      <AdditionalInfo record={record} />\n\n      {link !== \"edit\" && (\n        <div className=\"mt-6 pt-6 border-t hidden sm:flex flex-col gap-2 items-start\">\n          <DeleteButton\n            className=\"h-6 cursor-pointer hover:bg-destructive/10! text-destructive! border-destructive! focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40\"\n            size=\"sm\"\n          />\n        </div>\n      )}\n    </div>\n  );\n};\n\nexport const CompanyInfo = ({ record }: { record: Company }) => {\n  const translate = useTranslate();\n  if (!record.website && !record.linkedin_url && !record.phone_number) {\n    return null;\n  }\n\n  return (\n    <AsideSection\n      title={translate(\"resources.companies.field_categories.contact\")}\n    >\n      {record.website && (\n        <div className=\"flex flex-row items-center gap-1 min-h-[24px]\">\n          <Globe className=\"w-4 h-4\" />\n          <UrlField\n            source=\"website\"\n            target=\"_blank\"\n            rel=\"noopener\"\n            content={record.website\n              .replace(\"http://\", \"\")\n              .replace(\"https://\", \"\")}\n          />\n        </div>\n      )}\n      {record.linkedin_url && (\n        <div className=\"flex flex-row items-center gap-1 min-h-[24px]\">\n          <Linkedin className=\"w-4 h-4\" />\n          <a\n            className=\"underline hover:no-underline\"\n            href={record.linkedin_url}\n            target=\"_blank\"\n            rel=\"noopener noreferrer\"\n            title={record.linkedin_url}\n          >\n            LinkedIn\n          </a>\n        </div>\n      )}\n      {record.phone_number && (\n        <div className=\"flex flex-row items-center gap-1 min-h-[24px]\">\n          <Phone className=\"w-4 h-4\" />\n          <TextField source=\"phone_number\" />\n        </div>\n      )}\n    </AsideSection>\n  );\n};\n\nexport const ContextInfo = ({ record }: { record: Company }) => {\n  const { companySectors } = useConfigurationContext();\n  const translate = useTranslate();\n  if (!record.revenue && !record.id) {\n    return null;\n  }\n\n  const sector = companySectors.find((s) => s.value === record.sector);\n  const sectorLabel = sector?.label;\n  const translatedSizes = sizes.map((size) => ({\n    ...size,\n    name: getTranslatedCompanySizeLabel(size, translate),\n  }));\n\n  return (\n    <AsideSection\n      title={translate(\"resources.companies.field_categories.context\")}\n    >\n      {sectorLabel && (\n        <span>\n          {translate(\"resources.companies.fields.sector\")}: {sectorLabel}\n        </span>\n      )}\n      {record.size && (\n        <span>\n          {translate(\"resources.companies.fields.size\")}:{\" \"}\n          <SelectField source=\"size\" choices={translatedSizes} />\n        </span>\n      )}\n      {record.revenue && (\n        <span>\n          {translate(\"resources.companies.fields.revenue\")}:{\" \"}\n          <TextField source=\"revenue\" />\n        </span>\n      )}\n      {record.tax_identifier && (\n        <span>\n          {translate(\"resources.companies.fields.tax_identifier\", {})}\n          : <TextField source=\"tax_identifier\" />\n        </span>\n      )}\n    </AsideSection>\n  );\n};\n\nexport const AddressInfo = ({ record }: { record: Company }) => {\n  const translate = useTranslate();\n  if (\n    !record.address &&\n    !record.city &&\n    !record.zipcode &&\n    !record.state_abbr\n  ) {\n    return null;\n  }\n\n  return (\n    <AsideSection\n      title={translate(\"resources.companies.field_categories.address\")}\n      noGap\n    >\n      <TextField source=\"address\" />\n      <TextField source=\"city\" />\n      <TextField source=\"zipcode\" />\n      <TextField source=\"state_abbr\" />\n      <TextField source=\"country\" />\n    </AsideSection>\n  );\n};\n\nexport const AdditionalInfo = ({ record }: { record: Company }) => {\n  const translate = useTranslate();\n  const [locale = \"en\"] = useLocaleState();\n  const { identity } = useGetIdentity();\n  const isCurrentUser = record.sales_id === identity?.id;\n  const salesName = useGetSalesName(record.sales_id, {\n    enabled: !isCurrentUser,\n  });\n  if (\n    !record.created_at &&\n    !record.sales_id &&\n    !record.description &&\n    !record.context_links\n  ) {\n    return null;\n  }\n  const getBaseURL = (url: string) => {\n    const urlObject = new URL(url.startsWith(\"http\") ? url : `https://${url}`);\n    return urlObject.hostname;\n  };\n\n  return (\n    <AsideSection\n      title={translate(\"resources.companies.field_categories.additional_info\")}\n    >\n      {record.description && (\n        <p className=\"text-sm  mb-1\">{record.description}</p>\n      )}\n      {record.context_links && (\n        <div className=\"flex flex-col\">\n          {record.context_links.map((link, index) =>\n            link ? (\n              <a\n                key={index}\n                className=\"text-sm underline hover:no-underline mb-1\"\n                href={link.startsWith(\"http\") ? link : `https://${link}`}\n                target=\"_blank\"\n                rel=\"noopener noreferrer\"\n                title={link}\n              >\n                {getBaseURL(link)}\n              </a>\n            ) : null,\n          )}\n        </div>\n      )}\n      {record.sales_id !== null && (\n        <div className=\"inline-flex text-sm text-muted-foreground mb-1\">\n          {translate(\n            isCurrentUser\n              ? \"resources.companies.followed_by_you\"\n              : \"resources.companies.followed_by\",\n            { name: salesName },\n          )}\n        </div>\n      )}\n      {record.created_at && (\n        <p className=\"text-sm text-muted-foreground mb-1\">\n          {translate(\"resources.companies.added_on\", {\n            date: formatLocalizedDate(record.created_at, locale),\n          })}{\" \"}\n        </p>\n      )}\n    </AsideSection>\n  );\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/companies/AutocompleteCompanyInput.tsx",
      "content": "import { useCreate, useGetIdentity, useNotify } from \"ra-core\";\nimport { AutocompleteInput } from \"@/components/admin/autocomplete-input\";\nimport type { InputProps } from \"ra-core\";\nimport { useIsMobile } from \"@/hooks/use-mobile\";\nimport type { PopoverProps } from \"@radix-ui/react-popover\";\n\nexport const AutocompleteCompanyInput = ({\n  validate,\n  label,\n  modal,\n}: Pick<InputProps, \"validate\" | \"label\"> & Pick<PopoverProps, \"modal\">) => {\n  const [create] = useCreate();\n  const { identity } = useGetIdentity();\n  const notify = useNotify();\n  const handleCreateCompany = async (name?: string) => {\n    if (!name) return;\n    try {\n      const newCompany = await create(\n        \"companies\",\n        {\n          data: {\n            name,\n            sales_id: identity?.id,\n            created_at: new Date().toISOString(),\n          },\n        },\n        { returnPromise: true },\n      );\n      return newCompany;\n    } catch {\n      notify(\"resources.companies.autocomplete.create_error\", {\n        type: \"error\",\n        messageArgs: {\n          _: \"An error occurred while creating the company\",\n        },\n      });\n    }\n  };\n  const isMobile = useIsMobile();\n\n  return (\n    <AutocompleteInput\n      label={label}\n      optionText=\"name\"\n      helperText={false}\n      onCreate={handleCreateCompany}\n      createItemLabel=\"resources.companies.autocomplete.create_item\"\n      createLabel=\"resources.companies.autocomplete.create_label\"\n      validate={validate}\n      modal={modal ?? isMobile}\n    />\n  );\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/activity/ActivityLogNote.tsx",
      "content": "import { type ReactNode } from \"react\";\nimport { Link } from \"react-router\";\n\ntype ActivityLogNoteProps = {\n  header: ReactNode;\n  text: string;\n  link: string | false;\n};\n\nexport function ActivityLogNote({ header, text, link }: ActivityLogNoteProps) {\n  if (!text) {\n    return null;\n  }\n\n  const plainText = text.replace(/\\s+/g, \" \").trim();\n\n  const textElement = (\n    <p className=\"text-sm line-clamp-3 overflow-hidden\">{plainText}</p>\n  );\n\n  return (\n    <div className=\"p-0\">\n      <div className=\"flex flex-col space-y-2 w-full\">\n        <div className=\"flex flex-row space-x-1 items-center w-full\">\n          {header}\n        </div>\n        <div className=\"md:max-w-150 [&_p]:my-auto\">\n          {link !== false ? (\n            <Link\n              to={link}\n              className=\"hover:bg-muted rounded transition-colors\"\n            >\n              {textElement}\n            </Link>\n          ) : (\n            textElement\n          )}\n        </div>\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/activity/ActivityLogIterator.tsx",
      "content": "import { Fragment } from \"react\";\nimport {\n  useListContext,\n  useInfinitePaginationContext,\n  useTranslate,\n} from \"ra-core\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { Separator } from \"@/components/ui/separator\";\nimport { Skeleton } from \"@/components/ui/skeleton\";\nimport { Spinner } from \"@/components/admin/spinner\";\nimport { RotateCcw } from \"lucide-react\";\nimport {\n  COMPANY_CREATED,\n  CONTACT_CREATED,\n  CONTACT_NOTE_CREATED,\n  DEAL_CREATED,\n  DEAL_NOTE_CREATED,\n} from \"../consts\";\nimport type { Activity } from \"../types\";\nimport { ActivityLogCompanyCreated } from \"./ActivityLogCompanyCreated\";\nimport { ActivityLogContactCreated } from \"./ActivityLogContactCreated\";\nimport { ActivityLogContactNoteCreated } from \"./ActivityLogContactNoteCreated\";\nimport { ActivityLogDealCreated } from \"./ActivityLogDealCreated\";\nimport { ActivityLogDealNoteCreated } from \"./ActivityLogDealNoteCreated\";\nimport { InfinitePagination } from \"../misc/InfinitePagination\";\nimport { useIsMobile } from \"@/hooks/use-mobile\";\n\nexport function ActivityLogIterator() {\n  const isMobile = useIsMobile();\n  const { data, isPending, error, refetch } = useListContext<Activity>();\n  const { hasNextPage, fetchNextPage, isFetchingNextPage } =\n    useInfinitePaginationContext();\n  const translate = useTranslate();\n\n  if (isPending) {\n    return (\n      <div className=\"mt-1\">\n        {Array.from({ length: 5 }).map((_, index) => (\n          <div className=\"space-y-2 mt-1\" key={index}>\n            <div className=\"flex flex-row space-x-2 items-center\">\n              <Skeleton className=\"w-5 h-5 rounded-full\" />\n              <Skeleton className=\"w-full h-4\" />\n            </div>\n            <Skeleton className=\"w-full h-12\" />\n            <Separator />\n          </div>\n        ))}\n      </div>\n    );\n  }\n\n  if (error && !data?.length) {\n    return (\n      <div className=\"p-4\">\n        <div className=\"text-center text-muted-foreground mb-4\">\n          {translate(\"crm.dashboard.latest_activity_error\", {\n            _: \"Error loading latest activity\",\n          })}\n        </div>\n        <div className=\"text-center mt-2\">\n          <Button onClick={() => refetch()}>\n            <RotateCcw />\n            {translate(\"crm.common.retry\")}\n          </Button>\n        </div>\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"space-y-4\">\n      {data?.map((activity, index) => (\n        <Fragment key={index}>\n          <ActivityItem activity={activity} />\n          {index < data.length - 1 && <Separator />}\n        </Fragment>\n      ))}\n\n      {/* Desktop: explicit Load More button */}\n      {!isMobile && hasNextPage && (\n        <a\n          href=\"#\"\n          onClick={(e) => {\n            e.preventDefault();\n            fetchNextPage();\n          }}\n          className=\"flex w-full justify-center text-sm underline hover:no-underline\"\n        >\n          {isFetchingNextPage ? (\n            <Spinner />\n          ) : (\n            translate(\"crm.activity.load_more\")\n          )}\n        </a>\n      )}\n\n      {/* Mobile: auto-load on scroll via IntersectionObserver */}\n      {isMobile && (\n        <div className=\"flex justify-center\">\n          <InfinitePagination />\n        </div>\n      )}\n    </div>\n  );\n}\n\nfunction ActivityItem({ activity }: { activity: Activity }) {\n  if (activity.type === COMPANY_CREATED) {\n    return <ActivityLogCompanyCreated activity={activity} />;\n  }\n\n  if (activity.type === CONTACT_CREATED) {\n    return <ActivityLogContactCreated activity={activity} />;\n  }\n\n  if (activity.type === CONTACT_NOTE_CREATED) {\n    return <ActivityLogContactNoteCreated activity={activity} />;\n  }\n\n  if (activity.type === DEAL_CREATED) {\n    return <ActivityLogDealCreated activity={activity} />;\n  }\n\n  if (activity.type === DEAL_NOTE_CREATED) {\n    return <ActivityLogDealNoteCreated activity={activity} />;\n  }\n\n  return null;\n}\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/activity/ActivityLogDealNoteCreated.tsx",
      "content": "import { type RaRecord, useGetIdentity, useTranslate } from \"ra-core\";\n\nimport { ReferenceField } from \"@/components/admin/reference-field\";\nimport { CompanyAvatar } from \"../companies/CompanyAvatar\";\nimport { RelativeDate } from \"../misc/RelativeDate\";\nimport { useGetSalesName } from \"../sales/useGetSalesName\";\nimport type { ActivityDealNoteCreated } from \"../types\";\nimport { useActivityLogContext } from \"./ActivityLogContext\";\nimport { ActivityLogNote } from \"./ActivityLogNote\";\nimport { useIsMobile } from \"@/hooks/use-mobile\";\n\ntype ActivityLogDealNoteCreatedProps = {\n  activity: RaRecord & ActivityDealNoteCreated;\n};\n\nexport function ActivityLogDealNoteCreated({\n  activity,\n}: ActivityLogDealNoteCreatedProps) {\n  const context = useActivityLogContext();\n  const isMobile = useIsMobile();\n  const translate = useTranslate();\n  const { identity } = useGetIdentity();\n  const { dealNote } = activity;\n  const isCurrentUser = activity.sales_id === identity?.id;\n  const salesName = useGetSalesName(activity.sales_id, {\n    enabled: !isCurrentUser,\n  });\n  return (\n    <ActivityLogNote\n      header={\n        <div className=\"flex flex-row items-start gap-2 flex-grow\">\n          <ReferenceField\n            source=\"deal_id\"\n            reference=\"deals\"\n            record={dealNote}\n            link={false}\n          >\n            <ReferenceField\n              source=\"company_id\"\n              reference=\"companies\"\n              link={false}\n            >\n              <CompanyAvatar width={20} height={20} />\n            </ReferenceField>\n          </ReferenceField>\n\n          <span className=\"text-muted-foreground text-sm flex-grow\">\n            {translate(\n              isCurrentUser\n                ? \"crm.activity.you_added_note_about_deal\"\n                : \"crm.activity.added_note_about_deal\",\n              { name: salesName },\n            )}{\" \"}\n            <ReferenceField\n              source=\"deal_id\"\n              reference=\"deals\"\n              record={dealNote}\n              link={isMobile ? false : \"show\"}\n            />\n            {context !== \"company\" && (\n              <>\n                {\" \"}\n                {translate(\"crm.activity.at_company\")}{\" \"}\n                <ReferenceField\n                  source=\"deal_id\"\n                  reference=\"deals\"\n                  record={dealNote}\n                  link={false}\n                >\n                  <ReferenceField\n                    source=\"company_id\"\n                    reference=\"companies\"\n                    link=\"show\"\n                  />\n                </ReferenceField>{\" \"}\n                <RelativeDate date={activity.date} />\n              </>\n            )}\n          </span>\n\n          {context === \"company\" && (\n            <span className=\"text-muted-foreground text-sm\">\n              <RelativeDate date={activity.date} />\n            </span>\n          )}\n        </div>\n      }\n      text={dealNote.text}\n      link={isMobile ? false : `/deals/${dealNote.deal_id}/show`}\n    />\n  );\n}\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/activity/ActivityLogDealCreated.tsx",
      "content": "import { type RaRecord, useGetIdentity, useTranslate } from \"ra-core\";\nimport { Link } from \"react-router\";\n\nimport { ReferenceField } from \"@/components/admin/reference-field\";\nimport { RelativeDate } from \"../misc/RelativeDate\";\nimport { useGetSalesName } from \"../sales/useGetSalesName\";\nimport type { ActivityDealCreated } from \"../types\";\nimport { useActivityLogContext } from \"./ActivityLogContext\";\nimport { useIsMobile } from \"@/hooks/use-mobile\";\n\ntype ActivityLogDealCreatedProps = {\n  activity: RaRecord & ActivityDealCreated;\n};\n\nexport function ActivityLogDealCreated({\n  activity,\n}: ActivityLogDealCreatedProps) {\n  const context = useActivityLogContext();\n  const isMobile = useIsMobile();\n  const translate = useTranslate();\n  const { deal } = activity;\n  const { identity, isPending } = useGetIdentity();\n  const isCurrentUser = !isPending && identity?.id === activity.sales_id;\n  const salesName = useGetSalesName(activity.sales_id, {\n    enabled: !isCurrentUser,\n  });\n  return (\n    <div className=\"p-0\">\n      <div className=\"flex flex-row gap-2 items-start w-full\">\n        <div className=\"w-[20px] h-[20px] bg-gray-300 rounded-full shrink-0\" />\n        <span className=\"text-muted-foreground text-sm flex-grow\">\n          {translate(\n            isCurrentUser\n              ? \"crm.activity.you_added_deal\"\n              : \"crm.activity.added_deal\",\n            { name: salesName },\n          )}{\" \"}\n          {isMobile ? (\n            deal.name\n          ) : (\n            <Link to={`/deals/${deal.id}/show`}>{deal.name}</Link>\n          )}{\" \"}\n          {context !== \"company\" && (\n            <>\n              {translate(\"crm.activity.to\")}{\" \"}\n              <ReferenceField\n                source=\"company_id\"\n                reference=\"companies\"\n                record={activity}\n                link=\"show\"\n              />{\" \"}\n              <RelativeDate date={activity.date} />\n            </>\n          )}\n        </span>\n        {context === \"company\" && (\n          <span className=\"text-muted-foreground text-sm\">\n            <RelativeDate date={activity.date} />\n          </span>\n        )}\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/activity/ActivityLogContext.tsx",
      "content": "import { createContext, useContext } from \"react\";\n\nexport type activityLogContextValue = \"company\" | \"contact\" | \"deal\" | \"all\";\n\nexport const ActivityLogContext = createContext<activityLogContextValue>(\"all\");\n\nexport const useActivityLogContext = () => {\n  const context = useContext(ActivityLogContext);\n\n  return context;\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/activity/ActivityLogContactNoteCreated.tsx",
      "content": "import { useGetIdentity, useRecordContext, useTranslate } from \"ra-core\";\n\nimport { ReferenceField } from \"@/components/admin/reference-field\";\nimport { TextField } from \"@/components/admin/text-field\";\nimport { useIsMobile } from \"@/hooks/use-mobile\";\nimport { Avatar } from \"../contacts/Avatar\";\nimport { RelativeDate } from \"../misc/RelativeDate\";\nimport { useGetSalesName } from \"../sales/useGetSalesName\";\nimport type { ActivityContactNoteCreated, Contact } from \"../types\";\nimport { useActivityLogContext } from \"./ActivityLogContext\";\nimport { ActivityLogNote } from \"./ActivityLogNote\";\n\ntype ActivityLogContactNoteCreatedProps = {\n  activity: ActivityContactNoteCreated;\n};\n\nfunction ContactAvatar() {\n  const record = useRecordContext<Contact>();\n  return <Avatar width={20} height={20} record={record} />;\n}\n\nexport function ActivityLogContactNoteCreated({\n  activity,\n}: ActivityLogContactNoteCreatedProps) {\n  const context = useActivityLogContext();\n  const isMobile = useIsMobile();\n  const translate = useTranslate();\n  const { identity } = useGetIdentity();\n  const { contactNote } = activity;\n  const isCurrentUser = activity.sales_id === identity?.id;\n  const salesName = useGetSalesName(activity.sales_id, {\n    enabled: !isCurrentUser,\n  });\n  const link = isMobile\n    ? `/contacts/${contactNote.contact_id}/notes/${contactNote.id}`\n    : `/contacts/${contactNote.contact_id}/show`;\n  return (\n    <ActivityLogNote\n      header={\n        <div className=\"flex items-start gap-2 w-full\">\n          <ReferenceField\n            source=\"contact_id\"\n            reference=\"contacts\"\n            record={activity.contactNote}\n          >\n            <ContactAvatar />\n          </ReferenceField>\n\n          <span className=\"text-muted-foreground text-sm flex-grow\">\n            {translate(\n              isCurrentUser\n                ? \"crm.activity.you_added_note\"\n                : \"crm.activity.added_note\",\n              { name: salesName },\n            )}{\" \"}\n            <ReferenceField\n              source=\"contact_id\"\n              reference=\"contacts\"\n              record={activity.contactNote}\n            >\n              <TextField source=\"first_name\" /> <TextField source=\"last_name\" />\n            </ReferenceField>\n            {context !== \"company\" && (\n              <>\n                {\" \"}\n                <RelativeDate date={activity.date} />\n              </>\n            )}\n          </span>\n\n          {context === \"company\" && (\n            <span className=\"text-muted-foreground text-sm\">\n              <RelativeDate date={activity.date} />\n            </span>\n          )}\n        </div>\n      }\n      text={contactNote.text}\n      link={link}\n    />\n  );\n}\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/activity/ActivityLogContactCreated.tsx",
      "content": "import { useGetIdentity, useTranslate } from \"ra-core\";\nimport { Link } from \"react-router\";\n\nimport { ReferenceField } from \"@/components/admin/reference-field\";\nimport { Avatar } from \"../contacts/Avatar\";\nimport { RelativeDate } from \"../misc/RelativeDate\";\nimport type { ActivityContactCreated } from \"../types\";\nimport { useActivityLogContext } from \"./ActivityLogContext\";\nimport { useGetSalesName } from \"../sales/useGetSalesName\";\n\ntype ActivityLogContactCreatedProps = {\n  activity: ActivityContactCreated;\n};\n\nexport function ActivityLogContactCreated({\n  activity,\n}: ActivityLogContactCreatedProps) {\n  const context = useActivityLogContext();\n  const translate = useTranslate();\n  const { contact } = activity;\n  const { identity, isPending } = useGetIdentity();\n  const isCurrentUser = !isPending && identity?.id === activity.sales_id;\n  const salesName = useGetSalesName(activity.sales_id, {\n    enabled: !isCurrentUser,\n  });\n  return (\n    <div className=\"p-0\">\n      <div className=\"flex flex-row gap-2 items-start w-full\">\n        <Avatar width={20} height={20} record={contact} />\n        <span className=\"text-muted-foreground text-sm flex-grow\">\n          {translate(\n            isCurrentUser\n              ? \"crm.activity.you_added_contact\"\n              : \"crm.activity.added_contact\",\n            { name: salesName },\n          )}{\" \"}\n          <Link to={`/contacts/${contact.id}/show`}>\n            {contact.first_name} {contact.last_name}\n          </Link>\n          {context !== \"company\" && (\n            <>\n              {activity.company_id != null && (\n                <>\n                  {\" \"}\n                  {translate(\"crm.activity.to\")}{\" \"}\n                  <ReferenceField\n                    source=\"company_id\"\n                    reference=\"companies\"\n                    record={activity}\n                    link=\"show\"\n                  />\n                </>\n              )}{\" \"}\n              <RelativeDate date={activity.date} />\n            </>\n          )}\n        </span>\n        {context === \"company\" && (\n          <span className=\"text-muted-foreground text-sm\">\n            <RelativeDate date={activity.date} />\n          </span>\n        )}\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/activity/ActivityLogCompanyCreated.tsx",
      "content": "import { useGetIdentity, useTranslate } from \"ra-core\";\nimport { Link } from \"react-router\";\n\nimport { CompanyAvatar } from \"../companies/CompanyAvatar\";\nimport { useGetSalesName } from \"../sales/useGetSalesName\";\nimport { RelativeDate } from \"../misc/RelativeDate\";\nimport type { ActivityCompanyCreated } from \"../types\";\nimport { useActivityLogContext } from \"./ActivityLogContext\";\n\ntype ActivityLogCompanyCreatedProps = {\n  activity: ActivityCompanyCreated;\n};\n\nexport function ActivityLogCompanyCreated({\n  activity,\n}: ActivityLogCompanyCreatedProps) {\n  const context = useActivityLogContext();\n  const translate = useTranslate();\n  const { identity, isPending } = useGetIdentity();\n  const { company } = activity;\n  const isCurrentUser = !isPending && identity?.id === activity.sales_id;\n  const salesName = useGetSalesName(activity.sales_id, {\n    enabled: !isCurrentUser,\n  });\n  return (\n    <div className=\"p-0\">\n      <div className=\"flex flex-row gap-2 items-start w-full\">\n        <CompanyAvatar width={20} height={20} record={company} />\n\n        <span className=\"text-muted-foreground text-sm flex-grow\">\n          {translate(\n            isCurrentUser\n              ? \"crm.activity.you_added_company\"\n              : \"crm.activity.added_company\",\n            { name: salesName },\n          )}{\" \"}\n          <Link to={`/companies/${company.id}/show`}>{company.name}</Link>\n          {context === \"all\" && (\n            <>\n              {\" \"}\n              <RelativeDate date={activity.date} />\n            </>\n          )}\n        </span>\n        {context === \"company\" && (\n          <span className=\"text-muted-foreground text-sm\">\n            <RelativeDate date={activity.date} />\n          </span>\n        )}\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/atomic-crm/activity/ActivityLog.tsx",
      "content": "import { InfiniteListBase } from \"ra-core\";\nimport type { Identifier } from \"ra-core\";\n\nimport { ActivityLogContext } from \"./ActivityLogContext\";\nimport { ActivityLogIterator } from \"./ActivityLogIterator\";\n\ntype ActivityLogProps = {\n  companyId?: Identifier;\n  pageSize?: number;\n  context?: \"company\" | \"contact\" | \"deal\" | \"all\";\n};\n\nexport function ActivityLog({\n  companyId,\n  pageSize = 20,\n  context = \"all\",\n}: ActivityLogProps) {\n  return (\n    <ActivityLogContext.Provider value={context}>\n      <InfiniteListBase\n        resource=\"activity_log\"\n        filter={companyId ? { company_id: companyId } : {}}\n        sort={{ field: \"date\", order: \"DESC\" }}\n        perPage={pageSize}\n        disableSyncWithLocation\n      >\n        <ActivityLogIterator />\n      </InfiniteListBase>\n    </ActivityLogContext.Provider>\n  );\n}\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/supabase/set-password-page.tsx",
      "content": "import { useState } from \"react\";\nimport { Form, required, useNotify, useTranslate } from \"ra-core\";\nimport { useSetPassword, useSupabaseAccessToken } from \"ra-supabase-core\";\nimport { Button } from \"@/components/ui/button\";\nimport { TextInput } from \"@/components/admin/text-input\";\nimport { Layout } from \"@/components/supabase/layout\";\n\ninterface SetPasswordFormData {\n  password: string;\n  confirmPassword: string;\n}\n\nexport const SetPasswordPage = () => {\n  const [loading, setLoading] = useState(false);\n\n  const access_token = useSupabaseAccessToken();\n  const refresh_token = useSupabaseAccessToken({\n    parameterName: \"refresh_token\",\n  });\n\n  const notify = useNotify();\n  const translate = useTranslate();\n  const [, { mutateAsync: setPassword }] = useSetPassword();\n\n  const validate = (values: SetPasswordFormData) => {\n    if (values.password !== values.confirmPassword) {\n      return {\n        password: \"ra-supabase.validation.password_mismatch\",\n        confirmPassword: \"ra-supabase.validation.password_mismatch\",\n      };\n    }\n    return {};\n  };\n\n  if (!access_token || !refresh_token) {\n    if (process.env.NODE_ENV === \"development\") {\n      console.error(\"Missing access_token or refresh_token for set password\");\n    }\n    return (\n      <Layout>\n        <p>{translate(\"ra-supabase.auth.missing_tokens\")}</p>\n      </Layout>\n    );\n  }\n\n  const submit = async (values: SetPasswordFormData) => {\n    try {\n      setLoading(true);\n      await setPassword({\n        access_token,\n        refresh_token,\n        password: values.password,\n      });\n    } catch (error: any) {\n      notify(\n        typeof error === \"string\"\n          ? error\n          : typeof error === \"undefined\" || !error.message\n            ? \"ra.auth.sign_in_error\"\n            : error.message,\n        {\n          type: \"warning\",\n          messageArgs: {\n            _:\n              typeof error === \"string\"\n                ? error\n                : error && error.message\n                  ? error.message\n                  : undefined,\n          },\n        },\n      );\n    } finally {\n      setLoading(false);\n    }\n  };\n\n  return (\n    <Layout>\n      <div className=\"flex flex-col space-y-2 text-center\">\n        <h1 className=\"text-2xl font-semibold tracking-tight\">\n          {translate(\"ra-supabase.set_password.new_password\", {\n            _: \"Choose your password\",\n          })}\n        </h1>\n      </div>\n      <Form\n        className=\"space-y-8\"\n        onSubmit={submit as any}\n        validate={validate as any}\n      >\n        <TextInput\n          label={translate(\"ra.auth.password\", {\n            _: \"Password\",\n          })}\n          autoComplete=\"new-password\"\n          source=\"password\"\n          type=\"password\"\n          validate={required()}\n        />\n        <TextInput\n          label={translate(\"crm.auth.confirm_password\", {\n            _: \"Confirm password\",\n          })}\n          source=\"confirmPassword\"\n          type=\"password\"\n          validate={required()}\n        />\n        <Button type=\"submit\" className=\"cursor-pointer\" disabled={loading}>\n          {translate(\"ra.action.save\")}\n        </Button>\n      </Form>\n    </Layout>\n  );\n};\n\nSetPasswordPage.path = \"set-password\";\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/supabase/oauth-consent-page.tsx",
      "content": "import { useEffect, useState } from \"react\";\nimport { useNavigate, useSearchParams } from \"react-router-dom\";\nimport { useAuthProvider, useTranslate } from \"ra-core\";\nimport { Layout } from \"@/components/supabase/layout\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Card,\n  CardContent,\n  CardDescription,\n  CardFooter,\n  CardHeader,\n  CardTitle,\n} from \"@/components/ui/card\";\n\n/**\n * Authorization UI for OAuth Consent Page\n *\n * When third-party apps initiate OAuth, users will be redirected to this page\n * to approve or deny the authorization request.\n *\n * Anonymous users will be redirected to the login page first.\n *\n * Inspired from https://supabase.com/docs/guides/auth/oauth-server/getting-started?queryGroups=oauth-setup&oauth-setup=dashboard#example-authorization-ui\n */\nexport function OAuthConsentPage() {\n  const navigate = useNavigate();\n  const [searchParams] = useSearchParams();\n  const authorizationId = searchParams.get(\"authorization_id\");\n  const authProvider = useAuthProvider();\n  const translate = useTranslate();\n\n  const [authDetails, setAuthDetails] =\n    useState<OAuthAuthorizationDetails | null>(null);\n  const [loading, setLoading] = useState(true);\n  const [error, setError] = useState<string | null>(null);\n  const [submitting, setSubmitting] = useState(false);\n  const [approved, setApproved] = useState(false);\n\n  useEffect(() => {\n    async function loadAuthDetails() {\n      if (!authorizationId) {\n        setError(\"Missing authorization_id\");\n        setLoading(false);\n        return;\n      }\n      if (!authProvider) {\n        setError(\"Auth provider not available\");\n        setLoading(false);\n        return;\n      }\n\n      // Check if user is authenticated\n      try {\n        await authProvider.checkAuth({});\n      } catch {\n        navigate(\n          `/login?redirect=/oauth/consent?authorization_id=${authorizationId}`,\n        );\n        return;\n      }\n\n      // Get authorization details using the authorization_id\n      const { data, error } =\n        await authProvider.getAuthorizationDetails(authorizationId);\n\n      if (error) {\n        setError(error.message);\n      } else {\n        setAuthDetails(data as OAuthAuthorizationDetails);\n      }\n\n      setLoading(false);\n    }\n\n    loadAuthDetails();\n  }, [authProvider, authorizationId, navigate]);\n\n  async function handleApprove() {\n    if (!authorizationId || !authProvider) return;\n\n    setSubmitting(true);\n    const { data, error } =\n      await authProvider.approveAuthorization(authorizationId);\n\n    if (error) {\n      setError(error.message);\n      setSubmitting(false);\n    } else {\n      // Show success message and redirect to client app\n      setApproved(true);\n      window.location.href = data.redirect_url;\n    }\n  }\n\n  async function handleDeny() {\n    if (!authorizationId || !authProvider) return;\n\n    setSubmitting(true);\n    const { data, error } =\n      await authProvider.denyAuthorization(authorizationId);\n    if (error) {\n      setError(error.message);\n      setSubmitting(false);\n    } else {\n      // Redirect to client app with error\n      window.location.href = data.redirect_url;\n    }\n  }\n\n  if (loading) {\n    return (\n      <Layout>\n        <div className=\"flex flex-col space-y-2 text-center\">\n          <p className=\"text-muted-foreground\">\n            {translate(\"ra.message.loading\", { _: \"Loading...\" })}\n          </p>\n        </div>\n      </Layout>\n    );\n  }\n\n  if (error) {\n    return (\n      <Layout>\n        <div className=\"flex flex-col space-y-2 text-center\">\n          <h1 className=\"text-2xl font-semibold tracking-tight\">\n            {translate(\"ra.message.error\", { _: \"Error\" })}\n          </h1>\n          <p className=\"text-destructive\">{error}</p>\n        </div>\n      </Layout>\n    );\n  }\n\n  if (!authDetails) {\n    return (\n      <Layout>\n        <div className=\"flex flex-col space-y-2 text-center\">\n          <p className=\"text-muted-foreground\">\n            {translate(\"ra-supabase.oauth.no_request\", {\n              _: \"No authorization request found\",\n            })}\n          </p>\n        </div>\n      </Layout>\n    );\n  }\n\n  if (approved) {\n    return (\n      <Layout>\n        <div className=\"flex flex-col space-y-2 text-center\">\n          <h1 className=\"text-2xl font-semibold tracking-tight\">\n            {translate(\"ra-supabase.oauth.approved\", {\n              _: \"Authorization Approved\",\n            })}\n          </h1>\n          <p className=\"text-muted-foreground\">\n            {translate(\"ra-supabase.oauth.close_tab\", {\n              _: \"You can now close this tab.\",\n            })}\n          </p>\n        </div>\n      </Layout>\n    );\n  }\n\n  return (\n    <Layout>\n      <div className=\"flex flex-col space-y-2 text-center\">\n        <h1 className=\"text-2xl font-semibold tracking-tight\">\n          {translate(\"ra-supabase.oauth.authorize\", {\n            _: \"Authorize Application\",\n          })}\n        </h1>\n        <p className=\"text-muted-foreground\">\n          {translate(\"ra-supabase.oauth.authorize_details\", {\n            _: \"This application wants to access your account\",\n          })}\n        </p>\n      </div>\n\n      <Card>\n        <CardHeader>\n          <CardTitle>{authDetails.client.name}</CardTitle>\n          <CardDescription>{authDetails.redirect_uri}</CardDescription>\n        </CardHeader>\n        <CardContent className=\"space-y-4\">\n          {authDetails.scope && authDetails.scope.length > 0 && (\n            <div>\n              <p className=\"text-sm font-medium text-muted-foreground mb-2\">\n                {translate(\"ra-supabase.oauth.permissions\", {\n                  _: \"Requested permissions\",\n                })}\n              </p>\n              <ul className=\"list-disc list-inside space-y-1\">\n                {authDetails.scope.split(\" \").map((scopeItem) => (\n                  <li key={scopeItem} className=\"text-sm\">\n                    {scopeItem}\n                  </li>\n                ))}\n              </ul>\n            </div>\n          )}\n        </CardContent>\n        <CardFooter className=\"flex gap-2\">\n          <Button\n            variant=\"outline\"\n            onClick={handleDeny}\n            disabled={submitting}\n            className=\"flex-1\"\n          >\n            {translate(\"ra.action.cancel\", { _: \"Deny\" })}\n          </Button>\n          <Button\n            onClick={handleApprove}\n            disabled={submitting}\n            className=\"flex-1\"\n          >\n            {translate(\"ra.action.confirm\", { _: \"Approve\" })}\n          </Button>\n        </CardFooter>\n      </Card>\n    </Layout>\n  );\n}\n\nOAuthConsentPage.path = \"/oauth/consent\";\n\n/**\n * copied from @supabase/auth-js/src/lib/types.ts\n * to avoid adding a hard import to a Supabase package\n * because this page can also be used with FakeRest\n */\ntype OAuthAuthorizationDetails = {\n  /** The authorization ID */\n  authorization_id: string;\n  /** Redirect URL - present if user already consented (can be used to trigger immediate redirect) */\n  redirect_uri?: string;\n  /** User object associated with the authorization */\n  /** OAuth client requesting authorization */\n  client: {\n    /** Unique identifier for the OAuth client (UUID) */\n    id: string;\n    /** Human-readable name of the OAuth client */\n    name: string;\n    /** URI of the OAuth client's website */\n    uri: string;\n    /** URI of the OAuth client's logo */\n    logo_uri: string;\n  };\n  user: {\n    /** User ID (UUID) */\n    id: string;\n    /** User email */\n    email: string;\n  };\n  /** Space-separated list of requested scopes */\n  scope: string;\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/supabase/layout.tsx",
      "content": "import * as React from \"react\";\nimport { Notification } from \"@/components/admin/notification\";\nimport { useConfigurationContext } from \"@/components/atomic-crm/root/ConfigurationContext\";\n\nexport const Layout = ({ children }: React.PropsWithChildren) => {\n  const { darkModeLogo, title } = useConfigurationContext();\n\n  return (\n    <div className=\"min-h-screen flex\">\n      <div className=\"container relative grid flex-col items-center justify-center sm:max-w-none lg:grid-cols-2 lg:px-0\">\n        <div className=\"relative hidden h-full flex-col bg-muted p-10 text-white dark:border-r lg:flex\">\n          <div className=\"absolute inset-0 bg-zinc-900\" />\n          <div className=\"relative z-20 flex items-center text-lg font-medium\">\n            <img className=\"h-6 mr-2\" src={darkModeLogo} alt={title} />\n            {title}\n          </div>\n        </div>\n        <div className=\"lg:p-8\">\n          <div className=\"mx-auto flex w-full flex-col justify-center space-y-6 sm:w-[350px]\">\n            {children}\n          </div>\n        </div>\n      </div>\n      <Notification />\n    </div>\n  );\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/supabase/forgot-password-page.tsx",
      "content": "import { useState } from \"react\";\nimport { useResetPassword } from \"ra-supabase-core\";\nimport { Form, required, useNotify, useRedirect, useTranslate } from \"ra-core\";\nimport { Layout } from \"@/components/supabase/layout\";\nimport type { FieldValues, SubmitHandler } from \"react-hook-form\";\nimport { TextInput } from \"@/components/admin/text-input\";\nimport { Button } from \"@/components/ui/button\";\n\ninterface FormData {\n  email: string;\n}\n\nexport const ForgotPasswordPage = () => {\n  const [loading, setLoading] = useState(false);\n\n  const notify = useNotify();\n  const redirect = useRedirect();\n  const translate = useTranslate();\n  const [, { mutateAsync: resetPassword }] = useResetPassword({\n    onSuccess: () => {\n      redirect(\"/login?passwordRecoveryEmailSent=1\");\n    },\n    onError: () => undefined,\n  });\n\n  const submit = async (values: FormData) => {\n    try {\n      setLoading(true);\n      await resetPassword({\n        email: values.email,\n      });\n    } catch (error: any) {\n      notify(\n        typeof error === \"string\"\n          ? error\n          : typeof error === \"undefined\" || !error.message\n            ? \"ra.auth.sign_in_error\"\n            : error.message,\n        {\n          type: \"warning\",\n          messageArgs: {\n            _:\n              typeof error === \"string\"\n                ? error\n                : error && error.message\n                  ? error.message\n                  : undefined,\n          },\n        },\n      );\n    } finally {\n      setLoading(false);\n    }\n  };\n\n  return (\n    <Layout>\n      <div className=\"flex flex-col space-y-2 text-center\">\n        <h1 className=\"text-2xl font-semibold tracking-tight\">\n          {translate(\"ra-supabase.reset_password.forgot_password\", {\n            _: \"Forgot password?\",\n          })}\n        </h1>\n        <p>\n          {translate(\"ra-supabase.reset_password.forgot_password_details\", {\n            _: \"Enter your email to receive a reset password link.\",\n          })}\n        </p>\n      </div>\n      <Form<FormData>\n        className=\"space-y-8\"\n        onSubmit={submit as SubmitHandler<FieldValues>}\n      >\n        <TextInput\n          source=\"email\"\n          label={translate(\"ra.auth.email\", {\n            _: \"Email\",\n          })}\n          autoComplete=\"email\"\n          validate={required()}\n        />\n        <Button type=\"submit\" className=\"cursor-pointer\" disabled={loading}>\n          {translate(\"crm.action.reset_password\", {\n            _: \"Reset password\",\n          })}\n        </Button>\n      </Form>\n    </Layout>\n  );\n};\n\nForgotPasswordPage.path = \"forgot-password\";\n",
      "type": "registry:component"
    },
    {
      "path": "src/hooks/user-menu-context.tsx",
      "content": "import { createContext, useContext } from \"react\";\n\n/**\n * @deprecated Use UserMenuContextValue from `ra-core` once available.\n */\nexport type UserMenuContextValue = {\n  /**\n   * Closes the user menu\n   * @see UserMenu\n   */\n  onClose: () => void;\n};\n\n/**\n * @deprecated Use UserMenuContext from `ra-core` once available.\n */\nexport const UserMenuContext = createContext<UserMenuContextValue | undefined>(\n  undefined,\n);\n\n/**\n * @deprecated Use useUserMenu from `ra-core` once available.\n */\nexport const useUserMenu = () => useContext(UserMenuContext);\n",
      "type": "registry:hook"
    },
    {
      "path": "src/hooks/useBulkExport.tsx",
      "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport type { Exporter } from \"ra-core\";\nimport {\n  fetchRelatedRecords,\n  useDataProvider,\n  useListContext,\n  useNotify,\n  useResourceContext,\n} from \"ra-core\";\nimport { useCallback, useMemo } from \"react\";\n\n/**\n * This fill will be backported to 'ra-core' in the future.\n *\n * @deprecated Import from 'ra-core' instead.\n */\nexport function useBulkExport<\n  ResourceInformationsType extends Partial<{ resource: string }>,\n>(props: UseBulkExportProps<ResourceInformationsType>) {\n  const { exporter: customExporter, meta } = props;\n\n  const resource = useResourceContext(props);\n  const { exporter: exporterFromContext, selectedIds } = useListContext();\n  const exporter = customExporter || exporterFromContext;\n  const dataProvider = useDataProvider();\n  const notify = useNotify();\n\n  const bulkExport = useCallback(() => {\n    if (exporter && resource) {\n      dataProvider\n        .getMany(resource, { ids: selectedIds, meta })\n        .then(({ data }) =>\n          exporter(\n            data,\n            fetchRelatedRecords(dataProvider),\n            dataProvider,\n            resource,\n          ),\n        )\n        .catch((error) => {\n          console.error(error);\n          notify(\"ra.notification.http_error\", {\n            type: \"error\",\n          });\n        });\n    }\n  }, [dataProvider, exporter, notify, resource, selectedIds, meta]);\n\n  return useMemo(() => {\n    return {\n      bulkExport,\n    };\n  }, [bulkExport]);\n}\n\nexport type ResourceInformation = Partial<{ resource: string }>;\n\nexport type UseBulkExportProps<T extends ResourceInformation> = T & {\n  exporter?: Exporter;\n\n  meta?: any;\n};\n",
      "type": "registry:hook"
    },
    {
      "path": "src/hooks/simple-form-iterator-context.tsx",
      "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport { createContext, useContext } from \"react\";\n\n/**\n * A React context that provides access to a SimpleFormIterator data (the total number of items) and mutators (add, reorder and remove).\n * Useful to create custom array input iterators.\n * @deprecated Use SimpleFormIteratorContext from `ra-core` once available.\n * @see {SimpleFormIterator}\n * @see {ArrayInput}\n */\nexport const SimpleFormIteratorContext = createContext<\n  SimpleFormIteratorContextValue | undefined\n>(undefined);\n\n/**\n * @deprecated Use SimpleFormIteratorContextValue from `ra-core` once available.\n */\nexport type SimpleFormIteratorContextValue = {\n  add: (item?: any) => void;\n  remove: (index: number) => void;\n  reOrder: (index: number, newIndex: number) => void;\n  source: string;\n  total: number;\n};\n\n/**\n * @deprecated Use useSimpleFormIterator from `ra-core` once available.\n */\nexport const useSimpleFormIterator = () => {\n  const context = useContext(SimpleFormIteratorContext);\n  if (!context) {\n    throw new Error(\n      \"useSimpleFormIterator must be used inside a SimpleFormIterator\",\n    );\n  }\n  return context;\n};\n\n/**\n * A React context that provides access to a SimpleFormIterator item meta (its index and the total number of items) and mutators (reorder and remove this remove).\n * Useful to create custom array input iterators.\n * @deprecated Use SimpleFormIteratorItemContext from `ra-core` once available.\n * @see {SimpleFormIterator}\n * @see {ArrayInput}\n */\nexport const SimpleFormIteratorItemContext = createContext<\n  SimpleFormIteratorItemContextValue | undefined\n>(undefined);\n\n/**\n * @deprecated Use SimpleFormIteratorItemContextValue from `ra-core` once available.\n */\nexport type SimpleFormIteratorItemContextValue = {\n  index: number;\n  total: number;\n  remove: () => void;\n  reOrder: (newIndex: number) => void;\n};\n\n/**\n * @deprecated Use useSimpleFormIteratorItem from `ra-core` once available.\n */\nexport const useSimpleFormIteratorItem = () => {\n  const context = useContext(SimpleFormIteratorItemContext);\n  if (!context) {\n    throw new Error(\n      \"useSimpleFormIteratorItem must be used inside a SimpleFormIteratorItem\",\n    );\n  }\n  return context;\n};\n",
      "type": "registry:hook"
    },
    {
      "path": "src/lib/toSlug.ts",
      "content": "/**\n * Derive a stable slug value from a display label.\n * e.g. \"Communication Services\" → \"communication-services\"\n *\n * Must stay in sync with the SQL equivalent in\n * supabase/migrations/20260211194545_app_configuration.sql\n */\nexport const toSlug = (label: string): string =>\n  label\n    .toLowerCase()\n    .replace(/[^a-z0-9]+/g, \"-\")\n    .replace(/^-|-$/g, \"\");\n",
      "type": "registry:lib"
    },
    {
      "path": "CHANGELOG.md",
      "content": "## v1.5.0 - 2026-03-10\n\nRead about the updates online: [Atomic CRM March 2026 Updates](https://marmelab.com/blog/2026/03/13/atomic-crm-march-updates.html)\r\n\r\n## Breaking Change\r\n\r\n* table `contactNotes` has been renamed `contact_notes`\r\n* table `dealNotes` has been renamed `deal_notes`\r\n* column `stateAbbr` in table `companies` has been renamed `state_abbr`\r\n\r\nYou must run the migration to update your database schema:\r\n\r\n```\r\nmake supabase-migrate-database\r\n```\r\n\r\n## What's Changed\r\n\r\n* Replace React Admin with Shadcn Admin Kit by @Madeorsk in #104\r\n* Add SSO support and documentation by @djhi in #159, #161\r\n* Add Settings page by @fzaninotto in #162\r\n* Add mail forwarding by @ThieryMichel in #185\r\n* Add ability to import data from another CRM by @djhi in #133\r\n* Add support for attachments in inbound emails by @slax57 in #158\r\n* Add mobile app by @slax57 in #134\r\n* Add support for multiple emails and phone numbers per contact by @slax57 in #80\r\n* Add new fields to JSON import by @slax57 in #179\r\n* Add ability to load older notes on demand by @ThieryMichel in #177\r\n* Add custom telemetry by @djhi in #79\r\n* Add a confirmation page when the first user needs to confirm their email by @Madeorsk in #155\r\n* Add access control by @djhi in #70\r\n* Fix consistency in table and field names by @djhi in #136\r\n* Fix dates sometimes appearing shifted by 1 day by @ThieryMichel in #190\r\n* Fix error message when user creation fails by @Madeorsk in #151\r\n* Fix note list performance on mobile by @fzaninotto in #160\r\n* Fix attachment previews by @djhi in #154\r\n* Fix RLS policies on the sales table by @djhi in #74\r\n* Fix on-the-fly company creation by @fzaninotto in #120\r\n* Fix Deal list error by @djhi in #122\r\n* Fix New Task dialog closing even if task is invalid by @fzaninotto in #85\r\n* Fix signup error notification not being displayed by @WiXSL in #132\r\n* Fix password recovery email sent notification not showing by @WiXSL in #165\r\n* Fix Supabase authentication system for edge functions by @Madeorsk in #152\r\n* Fix JWT locally by @Madeorsk in #153\r\n* Fix mobile sheets height on Google Pixel devices by @slax57 in #172\r\n* Fix mobile note/task/contact headers to use ellipsis by @WiXSL in #176\r\n* Fix contact edit sheet header truncation on mobile by @WiXSL in #178\r\n* Fix DateInput and DateTimeInput on mobile Safari by @slax57 in #180\r\n* Fix ContactInput options cannot be scrolled on mobile by @slax57 in #181\r\n* Fix note attachment deletion on note remove by @WiXSL in #171\r\n* Fix remote init script by asking for org and region by @ThieryMichel in #191\r\n* Fix supabase-remote-init and prod-start scripts by @slax57 in #143\r\n* Fix Atomic registry components imports by @djhi in #118\r\n* Fix registry.json missing files and dependencies by @slax57 in #197\r\n* Fix UI contact component search input and icon contact filter by @mpsalunggg in #107\r\n* Fix typos and remove unused imports by @eithe in #69\r\n* Bump various dependencies (rollup, vitest, hono, lodash, dompurify, qs, devalue, storybook, minimatch, @modelcontextprotocol/sdk) by @dependabot[bot] in #67, #87, #130, #135, #137, #138, #142, #148, #149, #157, #166, #173, #174, #186, #187,  #188, #189, #194, #196\r\n* [Doc] Improve documentation about initial production setup by @djhi in #77\r\n* [Doc] Document email setup by @djhi in #71\r\n* [Doc] Add Starlight documentation by @jonathanarnault in #110\r\n* [Doc] Fix documentation links by @main-uk in #116, @djhi in #127\r\n* [Doc] Add getting started link to menu and fix logo size by @jonathanarnault in #111\r\n* [Chore] Refactor remote init script by @djhi in #76\r\n* [Chore] Add registry file for Atomic CRM by @jonathanarnault in #115\r\n* [Chore] Add a build-lib command to publish an atomic-crm node module by @ThieryMichel in #66\r\n* [Chore] Allow TS sourcemaps in production by @djhi in #88\r\n* [Chore] Improve GitHub community standards by @arimet in #73\r\n\r\n## New Contributors\r\n* @dependabot[bot] made their first contribution in https://github.com/marmelab/atomic-crm/pull/67\r\n* @eithe made their first contribution in https://github.com/marmelab/atomic-crm/pull/69\r\n* @djhi made their first contribution in https://github.com/marmelab/atomic-crm/pull/70\r\n* @SxMShaDoW made their first contribution in https://github.com/marmelab/atomic-crm/pull/78\r\n* @fzaninotto made their first contribution in https://github.com/marmelab/atomic-crm/pull/85\r\n* @anthonycmain made their first contribution in https://github.com/marmelab/atomic-crm/pull/86\r\n* @0xflotus made their first contribution in https://github.com/marmelab/atomic-crm/pull/93\r\n* @erwanMarmelab made their first contribution in https://github.com/marmelab/atomic-crm/pull/96\r\n* @Madeorsk made their first contribution in https://github.com/marmelab/atomic-crm/pull/104\r\n* @mpsalunggg made their first contribution in https://github.com/marmelab/atomic-crm/pull/107\r\n* @main-uk made their first contribution in https://github.com/marmelab/atomic-crm/pull/116\r\n* @CMiksche made their first contribution in https://github.com/marmelab/atomic-crm/pull/123\r\n* @WiXSL made their first contribution in https://github.com/marmelab/atomic-crm/pull/132\r\n\r\n**Full Changelog**: https://github.com/marmelab/atomic-crm/compare/v1.0.0...v1.5.0\n\n## v1.0.0 - 2026-03-10\n\n## What's Changed\r\n* Fix(ops): Upgrade packages by @jonathanarnault in https://github.com/marmelab/atomic-crm/pull/1\r\n* Feat(signup): Add user signup support by @jonathanarnault in https://github.com/marmelab/atomic-crm/pull/3\r\n* Feat(upload): Send files to storage supabase by @arimet in https://github.com/marmelab/atomic-crm/pull/4\r\n* Fix(db): Add missing row policies to database by @jonathanarnault in https://github.com/marmelab/atomic-crm/pull/5\r\n* Feat(crm): Backport demo features to atomic-crm by @arimet in https://github.com/marmelab/atomic-crm/pull/7\r\n* Feat(database): Update columns to match the CRM demo types by @jonathanarnault in https://github.com/marmelab/atomic-crm/pull/2\r\n* Feat(supabase): Update init project script by @jonathanarnault in https://github.com/marmelab/atomic-crm/pull/6\r\n* Feat(atomic): Create view for companies and contact + ban users by @arimet in https://github.com/marmelab/atomic-crm/pull/10\r\n* Feat(crm): Add supabase deploy scripts by @jonathanarnault in https://github.com/marmelab/atomic-crm/pull/9\r\n* Feat(ops): Add deploy script by @jonathanarnault in https://github.com/marmelab/atomic-crm/pull/8\r\n* Feat(ops): Add CI/CD pipeline by @jonathanarnault in https://github.com/marmelab/atomic-crm/pull/11\r\n* Fix(init): Remove login required notification if crm is not initialized by @jonathanarnault in https://github.com/marmelab/atomic-crm/pull/13\r\n* Feat(crm): Set isImage into dataProvider by @arimet in https://github.com/marmelab/atomic-crm/pull/12\r\n* Fix(crm): Handle tags for Contact export by @arimet in https://github.com/marmelab/atomic-crm/pull/15\r\n* Fix(crm): Set phone number into split fields + update getCompanyAvatar by @arimet in https://github.com/marmelab/atomic-crm/pull/14\r\n* Fix(contacts): Return all sales in sales selector by @jonathanarnault in https://github.com/marmelab/atomic-crm/pull/16\r\n* Fix(crm): Date validation + refactor uploadToBucket by @arimet in https://github.com/marmelab/atomic-crm/pull/19\r\n* Fix(contact): Display LinkedIn profile as URL label in contact aside by @jonathanarnault in https://github.com/marmelab/atomic-crm/pull/18\r\n* Fix(ops): GitHub pages were not pushed as expected by @jonathanarnault in https://github.com/marmelab/atomic-crm/pull/22\r\n* Fix(crm): Apply suggestions from reviews by @arimet in https://github.com/marmelab/atomic-crm/pull/21\r\n* Fix(ops): Github pages were not pushed as expected by @jonathanarnault in https://github.com/marmelab/atomic-crm/pull/24\r\n* Fix(setting): Update current user information in supabase by @arimet in https://github.com/marmelab/atomic-crm/pull/23\r\n* Feat(auth): Add reset-password for Sales by @arimet in https://github.com/marmelab/atomic-crm/pull/26\r\n* Fix(crm): Handle deploy for Browser Router by @arimet in https://github.com/marmelab/atomic-crm/pull/30\r\n* Fix(views): Add security invokers to views to avoid data leak by @jonathanarnault in https://github.com/marmelab/atomic-crm/pull/29\r\n* Fix(deploy) by @arimet in https://github.com/marmelab/atomic-crm/pull/31\r\n* Fix(deploy): Add supabase project url and anon key to CI/CD by @jonathanarnault in https://github.com/marmelab/atomic-crm/pull/32\r\n* Fix(login): Fix admin base name by @jonathanarnault in https://github.com/marmelab/atomic-crm/pull/33\r\n* Fix: Search contact + improve import contact. by @arimet in https://github.com/marmelab/atomic-crm/pull/28\r\n* Feat(task): Associate task to an sales_id + improve documentation by @arimet in https://github.com/marmelab/atomic-crm/pull/25\r\n* Fix(migrations): recreate contact_summary view in remove acquisition migration by @jonathanarnault in https://github.com/marmelab/atomic-crm/pull/35\r\n* Feat(crm): Replace browserRouter with HashRouter and handle reset cal… by @arimet in https://github.com/marmelab/atomic-crm/pull/36\r\n* Feat(auth): Update supabase password via CRM UI by @arimet in https://github.com/marmelab/atomic-crm/pull/34\r\n* Feat(dataProvider): Add supabase to fakerest filter adapter by @jonathanarnault in https://github.com/marmelab/atomic-crm/pull/20\r\n* Fix(deals): Company name was not displayed in deal show modal by @jonathanarnault in https://github.com/marmelab/atomic-crm/pull/38\r\n* Fix(macOS): Update package lock to include rollup native binaries by @jonathanarnault in https://github.com/marmelab/atomic-crm/pull/41\r\n* Add fake rest provider by @jonathanarnault in https://github.com/marmelab/atomic-crm/pull/27\r\n* Feat(ops): Add option to deploy to another repository by @jonathanarnault in https://github.com/marmelab/atomic-crm/pull/37\r\n* Feat(UI): Improve Dashboard and display Empty Pages only if no filters are present by @arimet in https://github.com/marmelab/atomic-crm/pull/40\r\n* Feat(doc): Add linked supabase configuration guide by @jonathanarnault in https://github.com/marmelab/atomic-crm/pull/39\r\n* Fix(ux): Reduce initial loading time by @jonathanarnault in https://github.com/marmelab/atomic-crm/pull/42\r\n* Feat(mail): Add contact note via email by @slax57 in https://github.com/marmelab/atomic-crm/pull/17\r\n* Fix(perf): Logout user if db has been reset and improve login page load performance by @jonathanarnault in https://github.com/marmelab/atomic-crm/pull/45\r\n* Sec(init_state): init_state view is no longer leaking sales count by @jonathanarnault in https://github.com/marmelab/atomic-crm/pull/46\r\n* fix(crm): Fix signup page logo color by @slax57 in https://github.com/marmelab/atomic-crm/pull/44\r\n* Fix(avatar): Avatar upload does not fail anymore if no change in file… by @jonathanarnault in https://github.com/marmelab/atomic-crm/pull/48\r\n* Feat(auth): Handle resetPassword and Invite user by @arimet in https://github.com/marmelab/atomic-crm/pull/43\r\n* Feat(auth): For reseting user password, send reset email by @arimet in https://github.com/marmelab/atomic-crm/pull/47\r\n* Fix(ops): Update cross deploy documentation by @jonathanarnault in https://github.com/marmelab/atomic-crm/pull/50\r\n* Fix(import): tags and companies are no loger duplicated during imports by @jonathanarnault in https://github.com/marmelab/atomic-crm/pull/53\r\n* fix(mail): Support recipient with empty Name by @slax57 in https://github.com/marmelab/atomic-crm/pull/52\r\n* Feat(crm): Update mail templates by @arimet in https://github.com/marmelab/atomic-crm/pull/51\r\n* fix(login): Fix user is not automatically logged in after signup by @slax57 in https://github.com/marmelab/atomic-crm/pull/49\r\n* Fix(note): Improve note spacing by @jonathanarnault in https://github.com/marmelab/atomic-crm/pull/55\r\n* Fix(mail): Add debug log when creating a user and add documentation about email rate limit by @jonathanarnault in https://github.com/marmelab/atomic-crm/pull/56\r\n* Feat(task): Display Tasks for curent calendar week and not for seven … by @arimet in https://github.com/marmelab/atomic-crm/pull/54\r\n* Feat(setting): Display inboud email for user by @arimet in https://github.com/marmelab/atomic-crm/pull/59\r\n* Fix(avatar): Avatar deletion is now persisted as expected by @jonathanarnault in https://github.com/marmelab/atomic-crm/pull/57\r\n* Fix(contact): Update last_seen when a note is added to the contact by @jonathanarnault in https://github.com/marmelab/atomic-crm/pull/58\r\n* Feat(mailing): Add support for multiple recipients and fix some typos in mails by @jonathanarnault in https://github.com/marmelab/atomic-crm/pull/60\r\n* Feat(tasks): Update contact last seen when creating a task by @jonathanarnault in https://github.com/marmelab/atomic-crm/pull/61\r\n* Feat(task): Add task edit support by @jonathanarnault in https://github.com/marmelab/atomic-crm/pull/63\r\n* Feat(doc): Improve documentation by @jonathanarnault in https://github.com/marmelab/atomic-crm/pull/64\r\n* skip gh action task when needed secrets are missing by @ThieryMichel in https://github.com/marmelab/atomic-crm/pull/65\r\n\r\n\r\n**Full Changelog**: https://github.com/marmelab/atomic-crm/commits/v1.0.0\n\n",
      "type": "registry:file",
      "target": "~/CHANGELOG.md"
    }
  ],
  "type": "registry:block"
}