Skip to content

Commit

Permalink
Merge pull request #80 from marmelab/multiple-contact-emails
Browse files Browse the repository at this point in the history
Support multiple emails and phone numbers per contact
  • Loading branch information
erwanMarmelab authored Jan 14, 2025
2 parents ba6cd76 + a49d838 commit 7cd356e
Show file tree
Hide file tree
Showing 18 changed files with 917 additions and 416 deletions.
2 changes: 1 addition & 1 deletion .github/pull_request_template.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,6 @@ _Describe the steps required to test the changes_
## Additional Checks

- [ ] The **documentation** is up to date
- [ ] Tested with **fakerest** provider (see [related documentation](../doc/data-providers.md))
- [ ] Tested with **fakerest** provider (see [related documentation](https://github.com/marmelab/atomic-crm/blob/main/doc/developer/data-providers.md))

Also, please make sure to read the [contributing guidelines](https://github.com/marmelab/atomic-crm#contributing).
7 changes: 3 additions & 4 deletions doc/user/import-contacts.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,9 @@ Atomic CRM displays an import contact buttons in the initial user onboarding pag
An example of the expected CSV file is available in the contact import modal:

```csv
first_name,last_name,gender,title,company,email,phone_1_number,phone_1_type,phone_2_number,phone_2_type,background,first_seen,last_seen,has_newsletter,status,tags,linkedin_url
John,Doe,male,Sales Executive,Acme,[email protected],659-980-2015,work,740.645.3807,home,,2024-07-01,2024-07-01T11:54:49.950Z,FALSE,in-contract,"influencer, developer",https://www.linkedin.com/in/johndoe
Jane,Doe,female,Designer,Acme,[email protected],659-980-2020,work,740.647.3802,home,,2024-07-01,2024-07-01T11:54:49.950Z,FALSE,in-contract,"UI, design",https://www.linkedin.com/in/janedoe
Camille,Brown,nonbinary,Accountant,Atomic Corp,[email protected],659-910-3010,work,740.698.3752,home,,2024-07-01,2024-07-01T11:54:49.950Z,FALSE,in-contract,"payroll, accountant",,
first_name,last_name,gender,title,background,first_seen,last_seen,has_newsletter,status,tags,linkedin_url,company,email_work,email_home,email_other,phone_work,phone_home,phone_other
John,Doe,male,Sales Executive,,2024-07-01T00:00:00+00:00,2024-07-01T11:54:49.95+00:00,false,in-contract,"influencer, developer",https://www.linkedin.com/in/johndoe,Acme,[email protected],[email protected],[email protected],659-980-2015,740.645.3807,(446) 758-2122
Jane,Doe,female,Designer,,2024-07-01T00:00:00+00:00,2024-07-01T11:54:49.95+00:00,false,in-contract,"UI, design",https://www.linkedin.com/in/janedoe,Acme,,,[email protected],659-980-2020,740.647.3802,
```

When importing contacts, companies and tags will be automatically matched if they exist on the system, or imported ortherwise.
Expand Down
6 changes: 6 additions & 0 deletions makefile
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ start-supabase: ## start supabase locally
start-supabase-functions: ## start the supabase Functions watcher
npx supabase functions serve --env-file supabase/functions/.env.development

supabase-migrate-database: ## apply the migrations to the database
npx supabase migration up

supabase-reset-database: ## reset (and clear!) the database
npx supabase db reset

start-app: ## start the app locally
npm run dev

Expand Down
140 changes: 69 additions & 71 deletions src/contacts/ContactAside.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import LinkedInIcon from '@mui/icons-material/LinkedIn';
import PhoneIcon from '@mui/icons-material/Phone';
import { Box, Divider, Stack, SvgIcon, Typography } from '@mui/material';
import {
ArrayField,
DateField,
DeleteButton,
EditButton,
Expand All @@ -12,9 +13,11 @@ import {
ReferenceManyField,
SelectField,
ShowButton,
SingleFieldList,
TextField,
UrlField,
useRecordContext,
WithRecord,
} from 'react-admin';
import { AddTask } from '../tasks/AddTask';
import { TasksIterator } from '../tasks/TasksIterator';
Expand All @@ -23,6 +26,7 @@ import { TagsListEdit } from './TagsListEdit';
import { useLocation } from 'react-router';
import { useConfigurationContext } from '../root/ConfigurationContext';
import { Contact, Sale } from '../types';
import { ReactNode } from 'react';

export const ContactAside = ({ link = 'edit' }: { link?: 'edit' | 'show' }) => {
const location = useLocation();
Expand All @@ -40,89 +44,57 @@ export const ContactAside = ({ link = 'edit' }: { link?: 'edit' | 'show' }) => {
</Box>
<Typography variant="subtitle2">Personal info</Typography>
<Divider sx={{ mb: 2 }} />
{record.email && (
<Stack
direction="row"
alignItems="center"
gap={1}
minHeight={24}
>
<EmailIcon color="disabled" fontSize="small" />
<EmailField source="email" />
</Stack>
)}
<ArrayField source="email_jsonb">
<SingleFieldList linkType={false} gap={0} direction="column">
<PersonalInfoRow
icon={<EmailIcon color="disabled" fontSize="small" />}
primary={<EmailField source="email" />}
showType
/>
</SingleFieldList>
</ArrayField>
{record.has_newsletter && (
<Typography variant="body2" color="textSecondary" pl={3.5}>
Subscribed to newsletter
</Typography>
)}

{record.linkedin_url && (
<Stack
direction="row"
alignItems="center"
gap={1}
minHeight={24}
>
<LinkedInIcon color="disabled" fontSize="small" />
<UrlField
source="linkedin_url"
content="LinkedIn profile"
target="_blank"
rel="noopener"
/>
</Stack>
)}
{record.phone_1_number && (
<Stack direction="row" alignItems="center" gap={1}>
<PhoneIcon color="disabled" fontSize="small" />
<Box>
<TextField source="phone_1_number" />{' '}
{record.phone_1_type !== 'Other' && (
<TextField
source="phone_1_type"
color="textSecondary"
/>
)}
</Box>
</Stack>
)}
{record.phone_2_number && (
<Stack
direction="row"
alignItems="center"
gap={1}
minHeight={24}
>
<PhoneIcon color="disabled" fontSize="small" />
<Box>
<TextField source="phone_2_number" />{' '}
{record.phone_2_type !== 'Other' && (
<TextField
source="phone_2_type"
color="textSecondary"
/>
)}
</Box>
</Stack>
<PersonalInfoRow
icon={<LinkedInIcon color="disabled" fontSize="small" />}
primary={
<UrlField
source="linkedin_url"
content="LinkedIn profile"
target="_blank"
rel="noopener"
/>
}
/>
)}
<ArrayField source="phone_jsonb">
<SingleFieldList linkType={false} gap={0} direction="column">
<PersonalInfoRow
icon={<PhoneIcon color="disabled" fontSize="small" />}
primary={<TextField source="number" />}
showType
/>
</SingleFieldList>
</ArrayField>
<SelectField
source="gender"
choices={contactGender}
optionText={choice => (
<Stack
direction="row"
alignItems="center"
gap={1}
minHeight={24}
>
<SvgIcon
component={choice.icon}
color="disabled"
fontSize="small"
></SvgIcon>
<span>{choice.label}</span>
</Stack>
<PersonalInfoRow
icon={
<SvgIcon
component={choice.icon}
color="disabled"
fontSize="small"
/>
}
primary={<span>{choice.label}</span>}
/>
)}
optionValue="value"
/>
Expand Down Expand Up @@ -197,3 +169,29 @@ export const ContactAside = ({ link = 'edit' }: { link?: 'edit' | 'show' }) => {
</Box>
);
};

const PersonalInfoRow = ({
icon,
primary,
showType,
}: {
icon: ReactNode;
primary: ReactNode;
showType?: boolean;
}) => (
<Stack direction="row" alignItems="center" gap={1} minHeight={24}>
{icon}
<Box display="flex" flexWrap="wrap" columnGap={0.5} rowGap={0}>
{primary}
{showType ? (
<WithRecord
render={row =>
row.type !== 'Other' && (
<TextField source="type" color="textSecondary" />
)
}
/>
) : null}
</Box>
</Stack>
);
87 changes: 48 additions & 39 deletions src/contacts/ContactInputs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@ import {
useTheme,
} from '@mui/material';
import {
ArrayInput,
AutocompleteInput,
BooleanInput,
RadioButtonGroupInput,
ReferenceInput,
SelectInput,
SimpleFormIterator,
TextInput,
email,
required,
Expand All @@ -33,16 +35,16 @@ export const ContactInputs = () => {
return (
<Stack gap={2} p={1}>
<Avatar />
<Stack gap={4} direction={isMobile ? 'column' : 'row'}>
<Stack gap={4} flex={1}>
<Stack gap={3} direction={isMobile ? 'column' : 'row'}>
<Stack gap={4} flex={4}>
<ContactIdentityInputs />
<ContactPositionInputs />
</Stack>
<Divider
orientation={isMobile ? 'horizontal' : 'vertical'}
flexItem
/>
<Stack gap={4} flex={1}>
<Stack gap={4} flex={5}>
<ContactPersonalInformationInputs />
<ContactMiscInputs />
</Stack>
Expand Down Expand Up @@ -148,43 +150,48 @@ const ContactPersonalInformationInputs = () => {
return (
<Stack>
<Typography variant="h6">Personal info</Typography>
<TextInput
source="email"
<ArrayInput
source="email_jsonb"
label="Email addresses"
helperText={false}
validate={email()}
onPaste={handleEmailPaste}
onBlur={handleEmailBlur}
/>
<Stack gap={1} flexDirection="row">
<TextInput
source="phone_1_number"
label="Phone number 1"
helperText={false}
/>
<SelectInput
source="phone_1_type"
label="Type"
helperText={false}
optionText={choice => choice.id}
choices={[{ id: 'Work' }, { id: 'Home' }, { id: 'Other' }]}
defaultValue={'Work'}
/>
</Stack>
<Stack gap={1} flexDirection="row">
<TextInput
source="phone_2_number"
label="Phone number 2"
helperText={false}
/>
<SelectInput
source="phone_2_type"
label="Type"
helperText={false}
optionText={choice => choice.id}
choices={[{ id: 'Work' }, { id: 'Home' }, { id: 'Other' }]}
defaultValue={'Work'}
/>
</Stack>
>
<SimpleFormIterator inline disableReordering>
<TextInput
source="email"
helperText={false}
validate={email()}
onPaste={handleEmailPaste}
onBlur={handleEmailBlur}
/>
<SelectInput
source="type"
helperText={false}
optionText="id"
choices={personalInfoTypes}
defaultValue="Work"
fullWidth={false}
sx={{ width: 100, minWidth: 100 }}
/>
</SimpleFormIterator>
</ArrayInput>
<ArrayInput
source="phone_jsonb"
label="Phone numbers"
helperText={false}
>
<SimpleFormIterator inline disableReordering>
<TextInput source="number" helperText={false} />
<SelectInput
source="type"
helperText={false}
optionText="id"
choices={personalInfoTypes}
defaultValue="Work"
fullWidth={false}
sx={{ width: 100, minWidth: 100 }}
/>
</SimpleFormIterator>
</ArrayInput>
<TextInput
source="linkedin_url"
label="Linkedin URL"
Expand All @@ -195,6 +202,8 @@ const ContactPersonalInformationInputs = () => {
);
};

const personalInfoTypes = [{ id: 'Work' }, { id: 'Home' }, { id: 'Other' }];

const ContactMiscInputs = () => {
return (
<Stack>
Expand Down
Loading

0 comments on commit 7cd356e

Please sign in to comment.