feat: revamp customer notes feature

This commit is contained in:
Sérgio Salgado 2021-12-09 19:09:58 +00:00
parent dcd3259484
commit eb8737872d
20 changed files with 678 additions and 146 deletions

View file

@ -4,27 +4,28 @@ const _ = require('lodash/fp')
const db = require('./db') const db = require('./db')
const getCustomerNotes = customerId => { const getCustomerNotes = customerId => {
const sql = `SELECT * FROM customer_notes WHERE customer_id=$1 LIMIT 1` const sql = `SELECT * FROM customer_notes WHERE customer_id=$1`
return db.oneOrNone(sql, [customerId]).then(res => _.mapKeys((_, key) => _.camelize(key), res)) return db.oneOrNone(sql, [customerId]).then(res => _.mapKeys((_, key) => _.camelize(key), res))
} }
const createCustomerNotes = (customerId, userId, content) => { const createCustomerNote = (customerId, userId, title, content) => {
const sql = `INSERT INTO customer_notes (id, customer_id, last_edited_by, last_edited_at, content) VALUES ($1, $2, $3, now(), $4)` const sql = `INSERT INTO customer_notes (id, customer_id, last_edited_by, last_edited_at, title, content) VALUES ($1, $2, $3, now(), $4, $5)`
return db.none(sql, [uuid.v4(), customerId, userId, content]) return db.none(sql, [uuid.v4(), customerId, userId, title, content])
} }
const updateCustomerNotes = (customerId, userId, content) => { const deleteCustomerNote = noteId => {
const sql = `UPDATE customer_notes SET last_edited_at=now(), last_edited_by=$1, content=$2 WHERE customer_id=$3 RETURNING *` const sql = `DELETE FROM customer_notes WHERE id=$1`
return db.any(sql, [userId, content, customerId]) return db.none(sql, [noteId])
.then(res => { }
if (_.isEmpty(res)) {
createCustomerNotes(customerId, userId, content) const updateCustomerNote = (noteId, userId, content) => {
} const sql = `UPDATE customer_notes SET last_edited_at=now(), last_edited_by=$1, content=$2 WHERE id=$3`
}) return db.none(sql, [userId, content, noteId])
} }
module.exports = { module.exports = {
getCustomerNotes, getCustomerNotes,
createCustomerNotes, createCustomerNote,
updateCustomerNotes deleteCustomerNote,
updateCustomerNote
} }

View file

@ -632,7 +632,7 @@ function getCustomersList (phone = null, name = null, address = null, id = null)
phone, sms_override, id_card_data, id_card_data_override, id_card_data_expiration, phone, sms_override, id_card_data, id_card_data_override, id_card_data_expiration,
id_card_photo_path, id_card_photo_override, us_ssn, us_ssn_override, sanctions, sanctions_at, id_card_photo_path, id_card_photo_override, us_ssn, us_ssn_override, sanctions, sanctions_at,
sanctions_override, total_txs, total_spent, created AS last_active, fiat AS last_tx_fiat, sanctions_override, total_txs, total_spent, created AS last_active, fiat AS last_tx_fiat,
fiat_code AS last_tx_fiat_code, tx_class AS last_tx_class, custom_fields, content AS notes fiat_code AS last_tx_fiat_code, tx_class AS last_tx_class, custom_fields, notes
FROM ( FROM (
SELECT c.id, c.authorized_override, SELECT c.id, c.authorized_override,
greatest(0, date_part('day', c.suspended_until - NOW())) AS days_suspended, greatest(0, date_part('day', c.suspended_until - NOW())) AS days_suspended,
@ -640,7 +640,7 @@ function getCustomersList (phone = null, name = null, address = null, id = null)
c.front_camera_path, c.front_camera_override, c.front_camera_path, c.front_camera_override,
c.phone, c.sms_override, c.id_card_data, c.id_card_data_override, c.id_card_data_expiration, c.phone, c.sms_override, c.id_card_data, c.id_card_data_override, c.id_card_data_expiration,
c.id_card_photo_path, c.id_card_photo_override, c.us_ssn, c.us_ssn_override, c.sanctions, c.id_card_photo_path, c.id_card_photo_override, c.us_ssn, c.us_ssn_override, c.sanctions,
c.sanctions_at, c.sanctions_override, t.tx_class, t.fiat, t.fiat_code, t.created, cn.content, c.sanctions_at, c.sanctions_override, t.tx_class, t.fiat, t.fiat_code, t.created, cn.notes,
row_number() OVER (partition by c.id order by t.created desc) AS rn, row_number() OVER (partition by c.id order by t.created desc) AS rn,
sum(CASE WHEN t.id IS NOT NULL THEN 1 ELSE 0 END) OVER (partition by c.id) AS total_txs, sum(CASE WHEN t.id IS NOT NULL THEN 1 ELSE 0 END) OVER (partition by c.id) AS total_txs,
coalesce(sum(CASE WHEN error_code IS NULL OR error_code NOT IN ($1^) THEN t.fiat ELSE 0 END) OVER (partition by c.id), 0) AS total_spent, ccf.custom_fields coalesce(sum(CASE WHEN error_code IS NULL OR error_code NOT IN ($1^) THEN t.fiat ELSE 0 END) OVER (partition by c.id), 0) AS total_spent, ccf.custom_fields
@ -656,7 +656,8 @@ function getCustomersList (phone = null, name = null, address = null, id = null)
) cf GROUP BY cf.customer_id ) cf GROUP BY cf.customer_id
) ccf ON c.id = ccf.customer_id ) ccf ON c.id = ccf.customer_id
LEFT OUTER JOIN ( LEFT OUTER JOIN (
SELECT customer_id, content FROM customer_notes SELECT customer_id, coalesce(json_agg(customer_notes.*), '[]'::json) AS notes FROM customer_notes
GROUP BY customer_notes.customer_id
) cn ON c.id = cn.customer_id ) cn ON c.id = cn.customer_id
WHERE c.id != $2 WHERE c.id != $2
) AS cl WHERE rn = 1 ) AS cl WHERE rn = 1
@ -669,6 +670,7 @@ function getCustomersList (phone = null, name = null, address = null, id = null)
.then(customers => Promise.all(_.map(customer => { .then(customers => Promise.all(_.map(customer => {
return populateOverrideUsernames(customer) return populateOverrideUsernames(customer)
.then(camelize) .then(camelize)
.then(it => ({ ...it, notes: it.notes.map(camelize) }))
}, customers))) }, customers)))
} }
@ -685,7 +687,7 @@ function getCustomerById (id) {
phone, sms_override, id_card_data, id_card_data_override, id_card_data_expiration, phone, sms_override, id_card_data, id_card_data_override, id_card_data_expiration,
id_card_photo_path, id_card_photo_override, us_ssn, us_ssn_override, sanctions, sanctions_at, id_card_photo_path, id_card_photo_override, us_ssn, us_ssn_override, sanctions, sanctions_at,
sanctions_override, total_txs, total_spent, created AS last_active, fiat AS last_tx_fiat, sanctions_override, total_txs, total_spent, created AS last_active, fiat AS last_tx_fiat,
fiat_code AS last_tx_fiat_code, tx_class AS last_tx_class, subscriber_info, custom_fields, content AS notes fiat_code AS last_tx_fiat_code, tx_class AS last_tx_class, subscriber_info, custom_fields, notes
FROM ( FROM (
SELECT c.id, c.authorized_override, SELECT c.id, c.authorized_override,
greatest(0, date_part('day', c.suspended_until - now())) AS days_suspended, greatest(0, date_part('day', c.suspended_until - now())) AS days_suspended,
@ -693,7 +695,7 @@ function getCustomerById (id) {
c.front_camera_path, c.front_camera_override, c.front_camera_path, c.front_camera_override,
c.phone, c.sms_override, c.id_card_data, c.id_card_data_override, c.id_card_data_expiration, c.phone, c.sms_override, c.id_card_data, c.id_card_data_override, c.id_card_data_expiration,
c.id_card_photo_path, c.id_card_photo_override, c.us_ssn, c.us_ssn_override, c.sanctions, c.id_card_photo_path, c.id_card_photo_override, c.us_ssn, c.us_ssn_override, c.sanctions,
c.sanctions_at, c.sanctions_override, c.subscriber_info, t.tx_class, t.fiat, t.fiat_code, t.created, cn.content, c.sanctions_at, c.sanctions_override, c.subscriber_info, t.tx_class, t.fiat, t.fiat_code, t.created, cn.notes,
row_number() OVER (PARTITION BY c.id ORDER BY t.created DESC) AS rn, row_number() OVER (PARTITION BY c.id ORDER BY t.created DESC) AS rn,
sum(CASE WHEN t.id IS NOT NULL THEN 1 ELSE 0 END) OVER (PARTITION BY c.id) AS total_txs, sum(CASE WHEN t.id IS NOT NULL THEN 1 ELSE 0 END) OVER (PARTITION BY c.id) AS total_txs,
sum(CASE WHEN error_code IS NULL OR error_code NOT IN ($1^) THEN t.fiat ELSE 0 END) OVER (PARTITION BY c.id) AS total_spent, ccf.custom_fields sum(CASE WHEN error_code IS NULL OR error_code NOT IN ($1^) THEN t.fiat ELSE 0 END) OVER (PARTITION BY c.id) AS total_spent, ccf.custom_fields
@ -709,7 +711,8 @@ function getCustomerById (id) {
) cf GROUP BY cf.customer_id ) cf GROUP BY cf.customer_id
) ccf ON c.id = ccf.customer_id ) ccf ON c.id = ccf.customer_id
LEFT OUTER JOIN ( LEFT OUTER JOIN (
SELECT customer_id, content FROM customer_notes SELECT customer_id, coalesce(json_agg(customer_notes.*), '[]'::json) AS notes FROM customer_notes
GROUP BY customer_notes.customer_id
) cn ON c.id = cn.customer_id ) cn ON c.id = cn.customer_id
WHERE c.id = $2 WHERE c.id = $2
) AS cl WHERE rn = 1` ) AS cl WHERE rn = 1`
@ -720,6 +723,7 @@ function getCustomerById (id) {
}) })
.then(populateOverrideUsernames) .then(populateOverrideUsernames)
.then(camelize) .then(camelize)
.then(it => ({ ...it, notes: it.notes.map(camelize) }))
} }
/** /**

View file

@ -1,4 +1,4 @@
const authentication = require('../modules/authentication') const authentication = require('../modules/userManagement')
const anonymous = require('../../../constants').anonymousCustomer const anonymous = require('../../../constants').anonymousCustomer
const customers = require('../../../customers') const customers = require('../../../customers')
const filters = require('../../filters') const filters = require('../../filters')
@ -39,9 +39,16 @@ const resolvers = {
// TODO: NOT IMPLEMENTING THIS FEATURE FOR THE CURRENT VERSION // TODO: NOT IMPLEMENTING THIS FEATURE FOR THE CURRENT VERSION
return customers.getCustomerById(customerId) return customers.getCustomerById(customerId)
}, },
setCustomerNotes: (...[, { customerId, newContent }, context]) => { createCustomerNote: (...[, { customerId, title, content }, context]) => {
const token = authentication.getToken(context) const token = authentication.getToken(context)
return customerNotes.updateCustomerNotes(customerId, token, newContent) return customerNotes.createCustomerNote(customerId, token, title, content)
},
editCustomerNote: (...[, { noteId, newContent }, context]) => {
const token = authentication.getToken(context)
return customerNotes.updateCustomerNote(noteId, token, newContent)
},
deleteCustomerNote: (...[, { noteId }]) => {
return customerNotes.deleteCustomerNote(noteId)
} }
} }
} }

View file

@ -41,7 +41,7 @@ const typeDef = gql`
subscriberInfo: JSONObject subscriberInfo: JSONObject
customFields: [CustomerCustomField] customFields: [CustomerCustomField]
customInfoRequests: [CustomRequestData] customInfoRequests: [CustomRequestData]
notes: String notes: [CustomerNote]
} }
input CustomerInput { input CustomerInput {
@ -76,6 +76,16 @@ const typeDef = gql`
usSsn: String usSsn: String
} }
type CustomerNote {
id: ID
customerId: ID
created: Date
lastEditedAt: Date
lastEditedBy: ID
title: String
content: String
}
type Query { type Query {
customers(phone: String, name: String, address: String, id: String): [Customer] @auth customers(phone: String, name: String, address: String, id: String): [Customer] @auth
customer(customerId: ID!): Customer @auth customer(customerId: ID!): Customer @auth
@ -90,7 +100,9 @@ const typeDef = gql`
editCustomer(customerId: ID!, customerEdit: CustomerEdit): Customer @auth editCustomer(customerId: ID!, customerEdit: CustomerEdit): Customer @auth
deleteEditedData(customerId: ID!, customerEdit: CustomerEdit): Customer @auth deleteEditedData(customerId: ID!, customerEdit: CustomerEdit): Customer @auth
replacePhoto(customerId: ID!, photoType: String, newPhoto: UploadGQL): Customer @auth replacePhoto(customerId: ID!, photoType: String, newPhoto: UploadGQL): Customer @auth
setCustomerNotes(customerId: ID!, newContent: String!): Customer @auth createCustomerNote(customerId: ID!, title: String!, content: String!): Boolean @auth
editCustomerNote(noteId: ID!, newContent: String!): Boolean @auth
deleteCustomerNote(noteId: ID!): Boolean @auth
} }
` `

View file

@ -8,6 +8,7 @@ exports.up = function (next) {
created TIMESTAMPTZ NOT NULL DEFAULT now(), created TIMESTAMPTZ NOT NULL DEFAULT now(),
last_edited_at TIMESTAMPTZ, last_edited_at TIMESTAMPTZ,
last_edited_by UUID REFERENCES users(id), last_edited_by UUID REFERENCES users(id),
title TEXT NOT NULL DEFAULT ''
content TEXT NOT NULL DEFAULT '' content TEXT NOT NULL DEFAULT ''
)` )`
] ]

View file

@ -0,0 +1,94 @@
import { makeStyles } from '@material-ui/core'
import * as R from 'ramda'
import { React, useState } from 'react'
import { H3 } from 'src/components/typography'
import styles from './CustomerNotes.styles'
import NewNoteCard from './components/notes/NewNoteCard'
import NewNoteModal from './components/notes/NewNoteModal'
import NoteCard from './components/notes/NoteCard'
import NoteEdit from './components/notes/NoteEdit'
const useStyles = makeStyles(styles)
const CustomerNotes = ({
customer,
createNote,
deleteNote,
editNote,
timezone
}) => {
const classes = useStyles()
const [openModal, setOpenModal] = useState(false)
const [editing, setEditing] = useState(null)
const customerNotes = R.sort(
(a, b) => new Date(b?.created).getTime() - new Date(a?.created).getTime(),
customer.notes ?? []
)
const handleModalClose = () => {
setOpenModal(false)
}
const handleModalSubmit = it => {
createNote(it)
return handleModalClose()
}
const cancelNoteEditing = () => {
setEditing(null)
}
const submitNoteEditing = it => {
if (!R.equals(it.newContent, it.oldContent)) {
editNote({
noteId: it.noteId,
newContent: it.newContent
})
}
setEditing(null)
}
return (
<div>
<div className={classes.header}>
<H3 className={classes.title}>{'Notes'}</H3>
</div>
{R.isNil(editing) && (
<div className={classes.notesChipList}>
<NewNoteCard setOpenModal={setOpenModal} />
{R.map(
it => (
<NoteCard
note={it}
deleteNote={deleteNote}
handleClick={setEditing}
timezone={timezone}
/>
),
customerNotes
)}
</div>
)}
{!R.isNil(editing) && (
<NoteEdit
note={editing}
cancel={cancelNoteEditing}
edit={submitNoteEditing}
timezone={timezone}
/>
)}
{openModal && (
<NewNoteModal
showModal={openModal}
onClose={handleModalClose}
onSubmit={handleModalSubmit}
/>
)}
</div>
)
}
export default CustomerNotes

View file

@ -0,0 +1,17 @@
const styles = {
header: {
display: 'flex',
flexDirection: 'row'
},
title: {
marginTop: 7,
marginRight: 24
},
notesChipList: {
display: 'flex',
flexDirection: 'row',
flexWrap: 'wrap'
}
}
export default styles

View file

@ -23,6 +23,7 @@ import { ReactComponent as Discount } from 'src/styling/icons/button/discount/zo
import { fromNamespace, namespaces } from 'src/utils/config' import { fromNamespace, namespaces } from 'src/utils/config'
import CustomerData from './CustomerData' import CustomerData from './CustomerData'
import CustomerNotes from './CustomerNotes'
import styles from './CustomerProfile.styles' import styles from './CustomerProfile.styles'
import { import {
CustomerDetails, CustomerDetails,
@ -69,7 +70,14 @@ const GET_CUSTOMER = gql`
label label
value value
} }
notes notes {
id
customerId
title
content
created
lastEditedAt
}
transactions { transactions {
txClass txClass
id id
@ -195,6 +203,38 @@ const SET_CUSTOMER_CUSTOM_INFO_REQUEST = gql`
} }
` `
const CREATE_NOTE = gql`
mutation createCustomerNote(
$customerId: ID!
$title: String!
$content: String!
) {
createCustomerNote(
customerId: $customerId
title: $title
content: $content
)
}
`
const DELETE_NOTE = gql`
mutation deleteCustomerNote($noteId: ID!) {
deleteCustomerNote(noteId: $noteId)
}
`
const EDIT_NOTE = gql`
mutation editCustomerNote($noteId: ID!, $newContent: String!) {
editCustomerNote(noteId: $noteId, newContent: $newContent)
}
`
const GET_DATA = gql`
query getData {
config
}
`
const CustomerProfile = memo(() => { const CustomerProfile = memo(() => {
const history = useHistory() const history = useHistory()
@ -204,12 +244,15 @@ const CustomerProfile = memo(() => {
const [clickedItem, setClickedItem] = useState('overview') const [clickedItem, setClickedItem] = useState('overview')
const { id: customerId } = useParams() const { id: customerId } = useParams()
const { data: customerResponse, refetch: getCustomer, loading } = useQuery( const {
GET_CUSTOMER, data: customerResponse,
{ refetch: getCustomer,
variables: { customerId } loading: customerLoading
} } = useQuery(GET_CUSTOMER, {
) variables: { customerId }
})
const { data: configResponse, loading: configLoading } = useQuery(GET_DATA)
const [replaceCustomerPhoto] = useMutation(REPLACE_CUSTOMER_PHOTO, { const [replaceCustomerPhoto] = useMutation(REPLACE_CUSTOMER_PHOTO, {
onCompleted: () => getCustomer() onCompleted: () => getCustomer()
@ -238,6 +281,18 @@ const CustomerProfile = memo(() => {
} }
) )
const [createNote] = useMutation(CREATE_NOTE, {
onCompleted: () => getCustomer()
})
const [deleteNote] = useMutation(DELETE_NOTE, {
onCompleted: () => getCustomer()
})
const [editNote] = useMutation(EDIT_NOTE, {
onCompleted: () => getCustomer()
})
const updateCustomer = it => const updateCustomer = it =>
setCustomer({ setCustomer({
variables: { variables: {
@ -271,6 +326,30 @@ const CustomerProfile = memo(() => {
} }
}) })
const createCustomerNote = it =>
createNote({
variables: {
customerId,
title: it.title,
content: it.content
}
})
const deleteCustomerNote = it =>
deleteNote({
variables: {
noteId: it.noteId
}
})
const editCustomerNote = it =>
editNote({
variables: {
noteId: it.noteId,
newContent: it.newContent
}
})
const onClickSidebarItem = code => setClickedItem(code) const onClickSidebarItem = code => setClickedItem(code)
const configData = R.path(['config'])(customerResponse) ?? [] const configData = R.path(['config'])(customerResponse) ?? []
@ -287,6 +366,11 @@ const CustomerProfile = memo(() => {
const isSuspended = customerData.isSuspended const isSuspended = customerData.isSuspended
const isCustomerData = clickedItem === 'customerData' const isCustomerData = clickedItem === 'customerData'
const isOverview = clickedItem === 'overview' const isOverview = clickedItem === 'overview'
const isNotes = clickedItem === 'notes'
const loading = customerLoading && configLoading
const timezone = R.path(['config', 'locale_timezone'], configResponse)
const classes = useStyles({ blocked }) const classes = useStyles({ blocked })
@ -430,6 +514,16 @@ const CustomerProfile = memo(() => {
authorizeCustomRequest={authorizeCustomRequest}></CustomerData> authorizeCustomRequest={authorizeCustomRequest}></CustomerData>
</div> </div>
)} )}
{isNotes && (
<div>
<CustomerNotes
customer={customerData}
createNote={createCustomerNote}
deleteNote={deleteCustomerNote}
editNote={editCustomerNote}
timezone={timezone}></CustomerNotes>
</div>
)}
</div> </div>
{wizard && ( {wizard && (
<Wizard <Wizard

View file

@ -1,98 +0,0 @@
import { useMutation } from '@apollo/react-hooks'
import { makeStyles } from '@material-ui/core'
import { Formik, Form, Field } from 'formik'
import gql from 'graphql-tag'
import * as R from 'ramda'
import React, { useState } from 'react'
import * as Yup from 'yup'
import { TextInput } from 'src/components/inputs/formik'
import { Info3 } from 'src/components/typography'
import styles from './CustomerNotes.styles'
import { PropertyCard } from './propertyCard'
const validationSchema = Yup.object().shape({
notes: Yup.string()
})
const SAVE_CUSTOMER_NOTES = gql`
mutation setCustomerNotes($customerId: ID!, $newContent: String!) {
setCustomerNotes(customerId: $customerId, newContent: $newContent) {
notes
}
}
`
const useStyles = makeStyles(styles)
const CustomerNotes = ({ customer }) => {
const classes = useStyles()
const [editing, setEditing] = useState(false)
const [setNotes] = useMutation(SAVE_CUSTOMER_NOTES, {
refetchQueries: () => ['customer']
})
const initialValues = {
notes: R.path(['notes'])(customer) ?? ''
}
const handleConfirm = values => {
setNotes({
variables: {
customerId: customer.id,
newContent: values.notes
}
})
setEditing(false)
}
const getFormattedNotes = content => {
const fragments = R.split(/\n/)(content ?? '')
return R.map((it, idx) => {
if (idx === fragments.length) return <>{it}</>
return (
<>
{it}
<br />
</>
)
}, fragments)
}
return (
<PropertyCard
title={'Notes'}
edit={() => setEditing(true)}
confirm={true}
isEditing={editing}
formName="notes-form"
className={classes.root}
contentClassName={classes.content}>
{!editing && (
<Info3>{getFormattedNotes(R.path(['notes'])(customer))}</Info3>
)}
{editing && (
<Formik
validateOnBlur={false}
validateOnChange={false}
initialValues={initialValues}
validationSchema={validationSchema}
onSubmit={values => handleConfirm(values)}>
<Form id="notes-form" className={classes.form}>
<Field
name="notes"
fullWidth
multiline={true}
rows={6}
component={TextInput}
/>
</Form>
</Formik>
)}
</PropertyCard>
)
}
export default CustomerNotes

View file

@ -1,16 +0,0 @@
const styles = {
root: {
height: 'auto'
},
content: {
height: 'auto'
},
form: {
display: 'flex',
flexBasis: '90%',
flexShrink: 1,
marginTop: 8
}
}
export default styles

View file

@ -4,6 +4,8 @@ import React from 'react'
import { ReactComponent as CustomerDataReversedIcon } from 'src/styling/icons/customer-nav/data/comet.svg' import { ReactComponent as CustomerDataReversedIcon } from 'src/styling/icons/customer-nav/data/comet.svg'
import { ReactComponent as CustomerDataIcon } from 'src/styling/icons/customer-nav/data/white.svg' import { ReactComponent as CustomerDataIcon } from 'src/styling/icons/customer-nav/data/white.svg'
import { ReactComponent as NoteReversedIcon } from 'src/styling/icons/customer-nav/note/comet.svg'
import { ReactComponent as NoteIcon } from 'src/styling/icons/customer-nav/note/white.svg'
import { ReactComponent as OverviewReversedIcon } from 'src/styling/icons/customer-nav/overview/comet.svg' import { ReactComponent as OverviewReversedIcon } from 'src/styling/icons/customer-nav/overview/comet.svg'
import { ReactComponent as OverviewIcon } from 'src/styling/icons/customer-nav/overview/white.svg' import { ReactComponent as OverviewIcon } from 'src/styling/icons/customer-nav/overview/white.svg'
@ -25,6 +27,12 @@ const CustomerSidebar = ({ isSelected, onClick }) => {
display: 'Customer Data', display: 'Customer Data',
Icon: CustomerDataIcon, Icon: CustomerDataIcon,
InverseIcon: CustomerDataReversedIcon InverseIcon: CustomerDataReversedIcon
},
{
code: 'notes',
display: 'Notes',
Icon: NoteIcon,
InverseIcon: NoteReversedIcon
} }
] ]

View file

@ -0,0 +1,24 @@
import { makeStyles, Paper } from '@material-ui/core'
import classNames from 'classnames'
import { React } from 'react'
import { P } from 'src/components/typography'
import { ReactComponent as AddIcon } from 'src/styling/icons/button/add/zodiac.svg'
import styles from './NoteCard.styles'
const useStyles = makeStyles(styles)
const NewNoteCard = ({ setOpenModal }) => {
const classes = useStyles()
return (
<div className={classes.noteCardWrapper} onClick={() => setOpenModal(true)}>
<Paper className={classNames(classes.noteCardChip, classes.newNoteCard)}>
<AddIcon width={20} height={20} />
<P>Add new</P>
</Paper>
</div>
)
}
export default NewNoteCard

View file

@ -0,0 +1,81 @@
import { makeStyles } from '@material-ui/core/styles'
import { Form, Formik, Field } from 'formik'
import { React } from 'react'
import * as Yup from 'yup'
import ErrorMessage from 'src/components/ErrorMessage'
import Modal from 'src/components/Modal'
import { Button } from 'src/components/buttons'
import { TextInput } from 'src/components/inputs/formik'
import styles from './NewNoteModal.styles'
const useStyles = makeStyles(styles)
const initialValues = {
title: '',
content: ''
}
const validationSchema = Yup.object().shape({
title: Yup.string()
.required()
.trim()
.max(25),
content: Yup.string().required()
})
const NewNoteModal = ({ showModal, onClose, onSubmit, errorMsg }) => {
const classes = useStyles()
return (
<>
<Modal
title="New note"
closeOnBackdropClick={true}
width={416}
height={472}
handleClose={onClose}
open={showModal}>
<Formik
validateOnBlur={false}
validateOnChange={false}
initialValues={initialValues}
validationSchema={validationSchema}
onSubmit={({ title, content }) => {
onSubmit({ title, content })
}}>
<Form id="note-form" className={classes.form}>
<Field
name="title"
autofocus
size="md"
autoComplete="off"
width={350}
component={TextInput}
label="Note title"
/>
<Field
name="content"
size="sm"
autoComplete="off"
width={350}
component={TextInput}
multiline={true}
rows={11}
label="Note content"
/>
<div className={classes.footer}>
{errorMsg && <ErrorMessage>{errorMsg}</ErrorMessage>}
<Button type="submit" form="note-form" className={classes.submit}>
Add note
</Button>
</div>
</Form>
</Formik>
</Modal>
</>
)
}
export default NewNoteModal

View file

@ -0,0 +1,25 @@
import { spacer } from 'src/styling/variables'
const styles = {
form: {
display: 'flex',
flexDirection: 'column',
height: '100%',
'& > *': {
marginTop: 20
},
'& > *:last-child': {
marginTop: 'auto'
}
},
submit: {
margin: [['auto', 0, 0, 'auto']]
},
footer: {
display: 'flex',
flexDirection: 'row',
margin: [['auto', 0, spacer * 3, 0]]
}
}
export default styles

View file

@ -0,0 +1,55 @@
import { makeStyles, Paper } from '@material-ui/core'
import * as R from 'ramda'
import { React } from 'react'
import { H3, P } from 'src/components/typography'
import { ReactComponent as DeleteIcon } from 'src/styling/icons/action/delete/enabled.svg'
import { formatDate } from 'src/utils/timezones'
import styles from './NoteCard.styles'
const useStyles = makeStyles(styles)
const formatContent = content => {
const fragments = R.split(/\n/)(content)
return R.map((it, idx) => {
if (idx === fragments.length) return <>{it}</>
return (
<>
{it}
<br />
</>
)
}, fragments)
}
const NoteCard = ({ note, deleteNote, handleClick, timezone }) => {
const classes = useStyles()
return (
<div className={classes.noteCardWrapper}>
<Paper className={classes.noteCardChip} onClick={() => handleClick(note)}>
<div className={classes.noteCardHeader}>
<div className={classes.noteCardTitle}>
<H3 noMargin>{note?.title}</H3>
<P noMargin>{formatDate(note?.created, timezone, 'yyyy-MM-dd')}</P>
</div>
<div>
<DeleteIcon
className={classes.deleteIcon}
onClick={e => {
e.stopPropagation()
deleteNote({ noteId: note.id })
}}
/>
</div>
</div>
<P noMargin className={classes.noteCardContent}>
{formatContent(note?.content)}
</P>
</Paper>
</div>
)
}
export default NoteCard

View file

@ -0,0 +1,93 @@
import { zircon } from 'src/styling/variables'
const styles = {
noteCardWrapper: {
flexGrow: 0,
flexShrink: 0,
flexBasis: `25%`,
minWidth: 0,
maxWidth: 500,
'&:nth-child(4n+1)': {
'& > div': {
margin: [[0, 10, 0, 0]]
}
},
'&:nth-child(4n)': {
'& > div': {
margin: [[0, 0, 0, 10]]
}
},
margin: [[10, 0]]
},
noteCardChip: {
height: 200,
margin: [[0, 10]],
padding: [[10, 10]],
cursor: 'pointer'
},
newNoteCard: {
backgroundColor: zircon,
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center'
},
noteCardHeader: {
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
width: '100%'
},
noteCardTitle: {
overflow: 'hidden',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
marginRight: 10
},
noteCardContent: {
display: 'box',
lineClamp: 7,
boxOrient: 'vertical',
margin: [[15, 0]],
overflow: 'hidden',
textOverflow: 'ellipsis',
wordWrap: 'break-word'
},
editCardChip: {
height: 325,
padding: 15
},
editCardHeader: {
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 15
},
editCardActions: {
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
'& > *': {
marginRight: 10
},
'& > *:last-child': {
marginRight: 0
}
},
editNotesContent: {
'& > div': {
'&:after': {
borderBottom: 'none'
},
'&:before': {
borderBottom: 'none'
},
'&:hover:not(.Mui-disabled)::before': {
borderBottom: 'none'
}
}
}
}
export default styles

View file

@ -0,0 +1,103 @@
import { makeStyles, Paper } from '@material-ui/core'
import { formatDurationWithOptions, intervalToDuration } from 'date-fns/fp'
import { Form, Formik, Field } from 'formik'
import { React, useRef } from 'react'
import * as Yup from 'yup'
import { ActionButton } from 'src/components/buttons'
import { TextInput } from 'src/components/inputs/formik'
import { P } from 'src/components/typography'
import { ReactComponent as CancelIconInverse } from 'src/styling/icons/button/cancel/white.svg'
import { ReactComponent as CancelIcon } from 'src/styling/icons/button/cancel/zodiac.svg'
import { ReactComponent as SaveIconInverse } from 'src/styling/icons/circle buttons/save/white.svg'
import { ReactComponent as SaveIcon } from 'src/styling/icons/circle buttons/save/zodiac.svg'
import { toTimezone } from 'src/utils/timezones'
import styles from './NoteCard.styles'
const useStyles = makeStyles(styles)
const NoteEdit = ({ note, cancel, edit, timezone }) => {
const formRef = useRef()
const classes = useStyles()
const validationSchema = Yup.object().shape({
content: Yup.string()
})
const initialValues = {
content: note.content
}
return (
<Paper className={classes.editCardChip}>
<div className={classes.editCardHeader}>
<P noMargin>
{`Last edited `}
{formatDurationWithOptions(
{ delimited: ', ' },
intervalToDuration({
start: toTimezone(new Date(note.lastEditedAt), timezone),
end: toTimezone(new Date(), timezone)
})
)}
{` ago`}
</P>
<div className={classes.editCardActions}>
<ActionButton
color="primary"
type="button"
Icon={CancelIcon}
InverseIcon={CancelIconInverse}
onClick={cancel}>
{`Cancel`}
</ActionButton>
<ActionButton
color="primary"
type="submit"
form="edit-note"
Icon={SaveIcon}
InverseIcon={SaveIconInverse}>
{`Save changes`}
</ActionButton>
<ActionButton
color="primary"
type="button"
Icon={CancelIcon}
InverseIcon={CancelIconInverse}
onClick={() => formRef.current.setFieldValue('content', '')}>
{`Clear content`}
</ActionButton>
</div>
</div>
<Formik
validateOnChange={false}
validateOnBlur={false}
validationSchema={validationSchema}
initialValues={initialValues}
onSubmit={({ content }) =>
edit({
noteId: note.id,
newContent: content,
oldContent: note.content
})
}
innerRef={formRef}>
<Form id="edit-note">
<Field
name="content"
component={TextInput}
className={classes.editNotesContent}
size="sm"
autoComplete="off"
fullWidth
multiline={true}
rows={15}
/>
</Form>
</Formik>
</Paper>
)
}
export default NoteEdit

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="20px" height="20px" viewBox="0 0 20 20" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="icon/customer-nav/note/comet" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<path d="M18,1 C18.2761424,1 18.5261424,1.11192881 18.7071068,1.29289322 C18.8880712,1.47385763 19,1.72385763 19,2 L19,2 L19,18 C19,18.2761424 18.8880712,18.5261424 18.7071068,18.7071068 C18.5261424,18.8880712 18.2761424,19 18,19 L18,19 L2,19 C1.72385763,19 1.47385763,18.8880712 1.29289322,18.7071068 C1.11192881,18.5261424 1,18.2761424 1,18 L1,18 L1,2 C1,1.72385763 1.11192881,1.47385763 1.29289322,1.29289322 C1.47385763,1.11192881 1.72385763,1 2,1 L2,1 Z" id="Rectangle" stroke="#5F668A" stroke-width="2"></path>
<line x1="5" y1="5" x2="15" y2="5" id="Line-4" stroke="#5F668A" stroke-width="2" stroke-linecap="round"></line>
<line x1="5" y1="13" x2="10" y2="13" id="Line-4-Copy" stroke="#5F668A" stroke-width="2" stroke-linecap="round"></line>
<line x1="5" y1="9" x2="15" y2="9" id="Line-4" stroke="#5F668A" stroke-width="2" stroke-linecap="round"></line>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="20px" height="20px" viewBox="0 0 20 20" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="icon/customer-nav/note/white" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<path d="M18,1 C18.2761424,1 18.5261424,1.11192881 18.7071068,1.29289322 C18.8880712,1.47385763 19,1.72385763 19,2 L19,2 L19,18 C19,18.2761424 18.8880712,18.5261424 18.7071068,18.7071068 C18.5261424,18.8880712 18.2761424,19 18,19 L18,19 L2,19 C1.72385763,19 1.47385763,18.8880712 1.29289322,18.7071068 C1.11192881,18.5261424 1,18.2761424 1,18 L1,18 L1,2 C1,1.72385763 1.11192881,1.47385763 1.29289322,1.29289322 C1.47385763,1.11192881 1.72385763,1 2,1 L2,1 Z" id="Rectangle" stroke="#FFFFFF" stroke-width="2"></path>
<line x1="5" y1="5" x2="15" y2="5" id="Line-4" stroke="#FFFFFF" stroke-width="2" stroke-linecap="round"></line>
<line x1="5" y1="13" x2="10" y2="13" id="Line-4-Copy" stroke="#FFFFFF" stroke-width="2" stroke-linecap="round"></line>
<line x1="5" y1="9" x2="15" y2="9" id="Line-4" stroke="#FFFFFF" stroke-width="2" stroke-linecap="round"></line>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="20px" height="20px" viewBox="0 0 20 20" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="icon/customer-nav/note/zodiac" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<path d="M18,1 C18.2761424,1 18.5261424,1.11192881 18.7071068,1.29289322 C18.8880712,1.47385763 19,1.72385763 19,2 L19,2 L19,18 C19,18.2761424 18.8880712,18.5261424 18.7071068,18.7071068 C18.5261424,18.8880712 18.2761424,19 18,19 L18,19 L2,19 C1.72385763,19 1.47385763,18.8880712 1.29289322,18.7071068 C1.11192881,18.5261424 1,18.2761424 1,18 L1,18 L1,2 C1,1.72385763 1.11192881,1.47385763 1.29289322,1.29289322 C1.47385763,1.11192881 1.72385763,1 2,1 L2,1 Z" id="Rectangle" stroke="#1B2559" stroke-width="2"></path>
<line x1="5" y1="5" x2="15" y2="5" id="Line-4" stroke="#1B2559" stroke-width="2" stroke-linecap="round"></line>
<line x1="5" y1="13" x2="10" y2="13" id="Line-4-Copy" stroke="#1B2559" stroke-width="2" stroke-linecap="round"></line>
<line x1="5" y1="9" x2="15" y2="9" id="Line-4" stroke="#1B2559" stroke-width="2" stroke-linecap="round"></line>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB