Merge pull request #811 from chaotixkilla/feat-customer-custom-notes

Customer custom notes
This commit is contained in:
Rafael Taranto 2021-12-10 10:41:36 +00:00 committed by GitHub
commit e0529efb92
21 changed files with 806 additions and 44 deletions

31
lib/customer-notes.js Normal file
View file

@ -0,0 +1,31 @@
const uuid = require('uuid')
const _ = require('lodash/fp')
const db = require('./db')
const getCustomerNotes = customerId => {
const sql = `SELECT * FROM customer_notes WHERE customer_id=$1`
return db.oneOrNone(sql, [customerId]).then(res => _.mapKeys((_, key) => _.camelize(key), res))
}
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 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,
createCustomerNote,
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,
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
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,
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
@ -655,6 +655,10 @@ function getCustomersList (phone = null, name = null, address = null, id = null)
LEFT OUTER JOIN customer_custom_field_pairs ccfp ON cfd.id = ccfp.custom_field_id
) cf GROUP BY cf.customer_id
) ccf ON c.id = ccf.customer_id
LEFT OUTER JOIN (
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
AND ($4 IS NULL OR phone = $4)
@ -666,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)))
}
@ -678,35 +683,39 @@ function getCustomersList (phone = null, name = null, address = null, id = null)
*/
function getCustomerById (id) {
const passableErrorCodes = _.map(Pgp.as.text, TX_PASSTHROUGH_ERROR_CODES).join(',')
const sql = `select id, authorized_override, days_suspended, is_suspended, front_camera_at, front_camera_path, front_camera_at, front_camera_override,
phone, sms_override, id_card_data_at, id_card_data, id_card_data_override, id_card_data_expiration,
id_card_photo_at, id_card_photo_path, id_card_photo_override, us_ssn_at, 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
from (
select c.id, c.authorized_override,
greatest(0, date_part('day', c.suspended_until - now())) as days_suspended,
c.suspended_until > now() as is_suspended,
c.front_camera_path, c.front_camera_at, c.front_camera_override,
c.phone, c.sms_override, c.id_card_data, c.id_card_data_at, c.id_card_data_override, c.id_card_data_expiration,
c.id_card_photo_path, c.id_card_photo_at, c.id_card_photo_override, c.us_ssn, c.us_ssn_at, 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,
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
from customers c left outer join (
select 'cashIn' as tx_class, id, fiat, fiat_code, created, customer_id, error_code
from cash_in_txs where send_confirmed = true union
select 'cashOut' as tx_class, id, fiat, fiat_code, created, customer_id, error_code
from cash_out_txs where confirmed_at is not null) t on c.id = t.customer_id
const sql = `SELECT id, authorized_override, days_suspended, is_suspended, front_camera_path, front_camera_override,
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, notes
FROM (
SELECT c.id, c.authorized_override,
greatest(0, date_part('day', c.suspended_until - now())) AS days_suspended,
c.suspended_until > now() AS is_suspended,
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.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
FROM customers c LEFT OUTER JOIN (
SELECT 'cashIn' AS tx_class, id, fiat, fiat_code, created, customer_id, error_code
FROM cash_in_txs WHERE send_confirmed = true UNION
SELECT 'cashOut' AS tx_class, id, fiat, fiat_code, created, customer_id, error_code
FROM cash_out_txs WHERE confirmed_at IS NOT NULL) t ON c.id = t.customer_id
LEFT OUTER JOIN (
SELECT cf.customer_id, json_agg(json_build_object('id', cf.custom_field_id, 'label', cf.label, 'value', cf.value)) AS custom_fields FROM (
SELECT ccfp.custom_field_id, ccfp.customer_id, cfd.label, ccfp.value FROM custom_field_definitions cfd
LEFT OUTER JOIN customer_custom_field_pairs ccfp ON cfd.id = ccfp.custom_field_id
) cf GROUP BY cf.customer_id
) ccf ON c.id = ccf.customer_id
where c.id = $2
) as cl where rn = 1`
LEFT OUTER JOIN (
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`
return db.oneOrNone(sql, [passableErrorCodes, id])
.then(customerData => {
return getEditedData(id)
@ -714,6 +723,7 @@ function getCustomerById (id) {
})
.then(populateOverrideUsernames)
.then(camelize)
.then(it => ({ ...it, notes: it.notes.map(camelize) }))
}
/**

View file

@ -1,4 +1,4 @@
const { ApolloError } = require('apollo-server-express')
const { ApolloError, AuthenticationError } = require('apollo-server-express')
class InvalidCredentialsError extends ApolloError {
constructor(message) {
@ -29,6 +29,7 @@ class InvalidUrlError extends ApolloError {
}
module.exports = {
AuthenticationError,
InvalidCredentialsError,
UserAlreadyExistsError,
InvalidTwoFactorError,

View file

@ -239,6 +239,13 @@ const reset2FA = (token, userID, code, context) => {
.then(() => true)
}
const getToken = context => {
if (_.isNil(context.req.cookies.lid) || _.isNil(context.req.session.user.id))
throw new authErrors.AuthenticationError('Authentication failed')
return context.req.session.user.id
}
module.exports = {
authenticateUser,
getUserData,
@ -259,5 +266,6 @@ module.exports = {
createRegisterToken,
register,
resetPassword,
reset2FA
reset2FA,
getToken
}

View file

@ -1,6 +1,8 @@
const authentication = require('../modules/userManagement')
const anonymous = require('../../../constants').anonymousCustomer
const customers = require('../../../customers')
const filters = require('../../filters')
const customerNotes = require('../../../customer-notes')
const resolvers = {
@ -14,8 +16,7 @@ const resolvers = {
},
Mutation: {
setCustomer: (root, { customerId, customerInput }, context, info) => {
// TODO: To be replaced by function that fetchs the token
const token = !!context.req.cookies.lamassu_sid && context.req.session.user.id
const token = authentication.getToken(context)
if (customerId === anonymous.uuid) return customers.getCustomerById(customerId)
return customers.updateCustomer(customerId, customerInput, token)
},
@ -23,14 +24,12 @@ const resolvers = {
saveCustomField: (...[, { customerId, fieldId, newValue }]) => customers.saveCustomField(customerId, fieldId, newValue),
removeCustomField: (...[, [ { customerId, fieldId } ]]) => customers.removeCustomField(customerId, fieldId),
editCustomer: async (root, { customerId, customerEdit }, context) => {
// TODO: To be replaced by function that fetchs the token
const token = !!context.req.cookies.lid && context.req.session.user.id
const token = authentication.getToken(context)
const editedData = await customerEdit
return customers.edit(customerId, editedData, token)
},
replacePhoto: async (root, { customerId, photoType, newPhoto }, context) => {
// TODO: To be replaced by function that fetchs the token
const token = !!context.req.cookies.lid && context.req.session.user.id
const token = authentication.getToken(context)
const photo = await newPhoto
if (!photo) return customers.getCustomerById(customerId)
return customers.updateEditedPhoto(customerId, photo, photoType)
@ -39,6 +38,17 @@ const resolvers = {
deleteEditedData: (root, { customerId, customerEdit }) => {
// TODO: NOT IMPLEMENTING THIS FEATURE FOR THE CURRENT VERSION
return customers.getCustomerById(customerId)
},
createCustomerNote: (...[, { customerId, title, content }, context]) => {
const token = authentication.getToken(context)
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,6 +41,7 @@ const typeDef = gql`
subscriberInfo: JSONObject
customFields: [CustomerCustomField]
customInfoRequests: [CustomRequestData]
notes: [CustomerNote]
}
input CustomerInput {
@ -75,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
@ -89,6 +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
createCustomerNote(customerId: ID!, title: String!, content: String!): Boolean @auth
editCustomerNote(noteId: ID!, newContent: String!): Boolean @auth
deleteCustomerNote(noteId: ID!): Boolean @auth
}
`

View file

@ -0,0 +1,21 @@
var db = require('./db')
exports.up = function (next) {
var sql = [
`CREATE TABLE customer_notes (
id UUID PRIMARY KEY,
customer_id UUID NOT NULL REFERENCES customers(id),
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 ''
)`
]
db.multi(sql, next)
}
exports.down = function (next) {
next()
}

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 CustomerData from './CustomerData'
import CustomerNotes from './CustomerNotes'
import styles from './CustomerProfile.styles'
import {
CustomerDetails,
@ -69,6 +70,14 @@ const GET_CUSTOMER = gql`
label
value
}
notes {
id
customerId
title
content
created
lastEditedAt
}
transactions {
txClass
id
@ -194,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()
@ -203,12 +244,15 @@ const CustomerProfile = memo(() => {
const [clickedItem, setClickedItem] = useState('overview')
const { id: customerId } = useParams()
const { data: customerResponse, refetch: getCustomer, loading } = useQuery(
GET_CUSTOMER,
{
variables: { customerId }
}
)
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()
@ -237,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: {
@ -270,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) ?? []
@ -286,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 })
@ -429,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

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 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
}
]

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

@ -10,6 +10,8 @@ import { ReactComponent as AuthorizeReversedIcon } from 'src/styling/icons/butto
import { ReactComponent as AuthorizeIcon } from 'src/styling/icons/button/authorize/zodiac.svg'
import { ReactComponent as RejectReversedIcon } from 'src/styling/icons/button/cancel/white.svg'
import { ReactComponent as RejectIcon } from 'src/styling/icons/button/cancel/zodiac.svg'
import { ReactComponent as EditReversedIcon } from 'src/styling/icons/button/edit/white.svg'
import { ReactComponent as EditIcon } from 'src/styling/icons/button/edit/zodiac.svg'
import { propertyCardStyles } from './PropertyCard.styles'
@ -20,7 +22,19 @@ const OVERRIDE_AUTHORIZED = 'verified'
const OVERRIDE_REJECTED = 'blocked'
const PropertyCard = memo(
({ className, title, state, authorize, reject, children }) => {
({
className,
contentClassName,
title,
state,
authorize,
reject,
edit,
confirm,
isEditing,
formName,
children
}) => {
const classes = useStyles()
const label1ClassNames = {
@ -52,6 +66,29 @@ const PropertyCard = memo(
</ActionButton>
)
const EditButton = () => (
<ActionButton
className={classes.cardActionButton}
color="secondary"
Icon={EditIcon}
InverseIcon={EditReversedIcon}
onClick={() => edit()}>
Edit
</ActionButton>
)
const ConfirmButton = () => (
<ActionButton
className={classes.cardActionButton}
type="submit"
form={formName}
color="secondary"
Icon={AuthorizeIcon}
InverseIcon={AuthorizeReversedIcon}>
Confirm
</ActionButton>
)
const authorized =
state === OVERRIDE_PENDING
? { label: 'Pending', type: 'neutral' }
@ -64,14 +101,22 @@ const PropertyCard = memo(
className={classnames(classes.propertyCard, className)}
elevation={0}>
<H3 className={classes.propertyCardTopRow}>{title}</H3>
<div className={classes.propertyCardBottomRow}>
<div className={classnames(label1ClassNames)}>
<MainStatus statuses={[authorized]} />
</div>
<div
className={classnames(
classes.propertyCardBottomRow,
contentClassName
)}>
{state && (
<div className={classnames(label1ClassNames)}>
<MainStatus statuses={[authorized]} />
</div>
)}
{children}
<div className={classes.buttonsWrapper}>
{authorize && state !== OVERRIDE_AUTHORIZED && AuthorizeButton()}
{reject && state !== OVERRIDE_REJECTED && RejectButton()}
{edit && !isEditing && EditButton()}
{confirm && isEditing && ConfirmButton()}
</div>
</div>
</Paper>

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