feat: revamp customer notes feature
This commit is contained in:
parent
dcd3259484
commit
eb8737872d
20 changed files with 678 additions and 146 deletions
|
|
@ -4,27 +4,28 @@ const _ = require('lodash/fp')
|
|||
const db = require('./db')
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
const createCustomerNotes = (customerId, userId, content) => {
|
||||
const sql = `INSERT INTO customer_notes (id, customer_id, last_edited_by, last_edited_at, content) VALUES ($1, $2, $3, now(), $4)`
|
||||
return db.none(sql, [uuid.v4(), customerId, userId, content])
|
||||
const createCustomerNote = (customerId, userId, title, content) => {
|
||||
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, title, content])
|
||||
}
|
||||
|
||||
const updateCustomerNotes = (customerId, userId, content) => {
|
||||
const sql = `UPDATE customer_notes SET last_edited_at=now(), last_edited_by=$1, content=$2 WHERE customer_id=$3 RETURNING *`
|
||||
return db.any(sql, [userId, content, customerId])
|
||||
.then(res => {
|
||||
if (_.isEmpty(res)) {
|
||||
createCustomerNotes(customerId, userId, content)
|
||||
const deleteCustomerNote = noteId => {
|
||||
const sql = `DELETE FROM customer_notes WHERE id=$1`
|
||||
return db.none(sql, [noteId])
|
||||
}
|
||||
})
|
||||
|
||||
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 = {
|
||||
getCustomerNotes,
|
||||
createCustomerNotes,
|
||||
updateCustomerNotes
|
||||
createCustomerNote,
|
||||
deleteCustomerNote,
|
||||
updateCustomerNote
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
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,
|
||||
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 (
|
||||
SELECT c.id, c.authorized_override,
|
||||
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.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.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,
|
||||
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
|
||||
|
|
@ -656,7 +656,8 @@ function getCustomersList (phone = null, name = null, address = null, id = null)
|
|||
) cf GROUP BY cf.customer_id
|
||||
) ccf ON c.id = ccf.customer_id
|
||||
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
|
||||
WHERE c.id != $2
|
||||
) 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 => {
|
||||
return populateOverrideUsernames(customer)
|
||||
.then(camelize)
|
||||
.then(it => ({ ...it, notes: it.notes.map(camelize) }))
|
||||
}, customers)))
|
||||
}
|
||||
|
||||
|
|
@ -685,7 +687,7 @@ function getCustomerById (id) {
|
|||
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,
|
||||
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 (
|
||||
SELECT c.id, c.authorized_override,
|
||||
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.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.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,
|
||||
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
|
||||
|
|
@ -709,7 +711,8 @@ function getCustomerById (id) {
|
|||
) cf GROUP BY cf.customer_id
|
||||
) ccf ON c.id = ccf.customer_id
|
||||
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
|
||||
WHERE c.id = $2
|
||||
) AS cl WHERE rn = 1`
|
||||
|
|
@ -720,6 +723,7 @@ function getCustomerById (id) {
|
|||
})
|
||||
.then(populateOverrideUsernames)
|
||||
.then(camelize)
|
||||
.then(it => ({ ...it, notes: it.notes.map(camelize) }))
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
const authentication = require('../modules/authentication')
|
||||
const authentication = require('../modules/userManagement')
|
||||
const anonymous = require('../../../constants').anonymousCustomer
|
||||
const customers = require('../../../customers')
|
||||
const filters = require('../../filters')
|
||||
|
|
@ -39,9 +39,16 @@ const resolvers = {
|
|||
// TODO: NOT IMPLEMENTING THIS FEATURE FOR THE CURRENT VERSION
|
||||
return customers.getCustomerById(customerId)
|
||||
},
|
||||
setCustomerNotes: (...[, { customerId, newContent }, context]) => {
|
||||
createCustomerNote: (...[, { customerId, title, content }, 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ const typeDef = gql`
|
|||
subscriberInfo: JSONObject
|
||||
customFields: [CustomerCustomField]
|
||||
customInfoRequests: [CustomRequestData]
|
||||
notes: String
|
||||
notes: [CustomerNote]
|
||||
}
|
||||
|
||||
input CustomerInput {
|
||||
|
|
@ -76,6 +76,16 @@ const typeDef = gql`
|
|||
usSsn: String
|
||||
}
|
||||
|
||||
type CustomerNote {
|
||||
id: ID
|
||||
customerId: ID
|
||||
created: Date
|
||||
lastEditedAt: Date
|
||||
lastEditedBy: ID
|
||||
title: String
|
||||
content: String
|
||||
}
|
||||
|
||||
type Query {
|
||||
customers(phone: String, name: String, address: String, id: String): [Customer] @auth
|
||||
customer(customerId: ID!): Customer @auth
|
||||
|
|
@ -90,7 +100,9 @@ const typeDef = gql`
|
|||
editCustomer(customerId: ID!, customerEdit: CustomerEdit): Customer @auth
|
||||
deleteEditedData(customerId: ID!, customerEdit: CustomerEdit): 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
|
||||
}
|
||||
`
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ exports.up = function (next) {
|
|||
created TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
last_edited_at TIMESTAMPTZ,
|
||||
last_edited_by UUID REFERENCES users(id),
|
||||
title TEXT NOT NULL DEFAULT ''
|
||||
content TEXT NOT NULL DEFAULT ''
|
||||
)`
|
||||
]
|
||||
|
|
|
|||
94
new-lamassu-admin/src/pages/Customers/CustomerNotes.js
Normal file
94
new-lamassu-admin/src/pages/Customers/CustomerNotes.js
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
@ -23,6 +23,7 @@ import { ReactComponent as Discount } from 'src/styling/icons/button/discount/zo
|
|||
import { fromNamespace, namespaces } from 'src/utils/config'
|
||||
|
||||
import CustomerData from './CustomerData'
|
||||
import CustomerNotes from './CustomerNotes'
|
||||
import styles from './CustomerProfile.styles'
|
||||
import {
|
||||
CustomerDetails,
|
||||
|
|
@ -69,7 +70,14 @@ const GET_CUSTOMER = gql`
|
|||
label
|
||||
value
|
||||
}
|
||||
notes
|
||||
notes {
|
||||
id
|
||||
customerId
|
||||
title
|
||||
content
|
||||
created
|
||||
lastEditedAt
|
||||
}
|
||||
transactions {
|
||||
txClass
|
||||
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 history = useHistory()
|
||||
|
||||
|
|
@ -204,12 +244,15 @@ const CustomerProfile = memo(() => {
|
|||
const [clickedItem, setClickedItem] = useState('overview')
|
||||
const { id: customerId } = useParams()
|
||||
|
||||
const { data: customerResponse, refetch: getCustomer, loading } = useQuery(
|
||||
GET_CUSTOMER,
|
||||
{
|
||||
const {
|
||||
data: customerResponse,
|
||||
refetch: getCustomer,
|
||||
loading: customerLoading
|
||||
} = useQuery(GET_CUSTOMER, {
|
||||
variables: { customerId }
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
const { data: configResponse, loading: configLoading } = useQuery(GET_DATA)
|
||||
|
||||
const [replaceCustomerPhoto] = useMutation(REPLACE_CUSTOMER_PHOTO, {
|
||||
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 =>
|
||||
setCustomer({
|
||||
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 configData = R.path(['config'])(customerResponse) ?? []
|
||||
|
|
@ -287,6 +366,11 @@ const CustomerProfile = memo(() => {
|
|||
const isSuspended = customerData.isSuspended
|
||||
const isCustomerData = clickedItem === 'customerData'
|
||||
const isOverview = clickedItem === 'overview'
|
||||
const isNotes = clickedItem === 'notes'
|
||||
|
||||
const loading = customerLoading && configLoading
|
||||
|
||||
const timezone = R.path(['config', 'locale_timezone'], configResponse)
|
||||
|
||||
const classes = useStyles({ blocked })
|
||||
|
||||
|
|
@ -430,6 +514,16 @@ const CustomerProfile = memo(() => {
|
|||
authorizeCustomRequest={authorizeCustomRequest}></CustomerData>
|
||||
</div>
|
||||
)}
|
||||
{isNotes && (
|
||||
<div>
|
||||
<CustomerNotes
|
||||
customer={customerData}
|
||||
createNote={createCustomerNote}
|
||||
deleteNote={deleteCustomerNote}
|
||||
editNote={editCustomerNote}
|
||||
timezone={timezone}></CustomerNotes>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{wizard && (
|
||||
<Wizard
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
const styles = {
|
||||
root: {
|
||||
height: 'auto'
|
||||
},
|
||||
content: {
|
||||
height: 'auto'
|
||||
},
|
||||
form: {
|
||||
display: 'flex',
|
||||
flexBasis: '90%',
|
||||
flexShrink: 1,
|
||||
marginTop: 8
|
||||
}
|
||||
}
|
||||
|
||||
export default styles
|
||||
|
|
@ -4,6 +4,8 @@ import React from 'react'
|
|||
|
||||
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 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 OverviewIcon } from 'src/styling/icons/customer-nav/overview/white.svg'
|
||||
|
||||
|
|
@ -25,6 +27,12 @@ const CustomerSidebar = ({ isSelected, onClick }) => {
|
|||
display: 'Customer Data',
|
||||
Icon: CustomerDataIcon,
|
||||
InverseIcon: CustomerDataReversedIcon
|
||||
},
|
||||
{
|
||||
code: 'notes',
|
||||
display: 'Notes',
|
||||
Icon: NoteIcon,
|
||||
InverseIcon: NoteReversedIcon
|
||||
}
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
Loading…
Add table
Add a link
Reference in a new issue