diff --git a/lib/customer-notes.js b/lib/customer-notes.js new file mode 100644 index 00000000..b9e4ae81 --- /dev/null +++ b/lib/customer-notes.js @@ -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 +} diff --git a/lib/customers.js b/lib/customers.js index 5384a320..89ea723f 100644 --- a/lib/customers.js +++ b/lib/customers.js @@ -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) })) } /** diff --git a/lib/new-admin/graphql/errors/authentication.js b/lib/new-admin/graphql/errors/authentication.js index 7ddaede5..ac9b0153 100644 --- a/lib/new-admin/graphql/errors/authentication.js +++ b/lib/new-admin/graphql/errors/authentication.js @@ -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, diff --git a/lib/new-admin/graphql/modules/userManagement.js b/lib/new-admin/graphql/modules/userManagement.js index eeee2a26..13c42e70 100644 --- a/lib/new-admin/graphql/modules/userManagement.js +++ b/lib/new-admin/graphql/modules/userManagement.js @@ -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 } diff --git a/lib/new-admin/graphql/resolvers/customer.resolver.js b/lib/new-admin/graphql/resolvers/customer.resolver.js index 36a24b0f..d3563669 100644 --- a/lib/new-admin/graphql/resolvers/customer.resolver.js +++ b/lib/new-admin/graphql/resolvers/customer.resolver.js @@ -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) } } } diff --git a/lib/new-admin/graphql/types/customer.type.js b/lib/new-admin/graphql/types/customer.type.js index 14336086..bdbf3a94 100644 --- a/lib/new-admin/graphql/types/customer.type.js +++ b/lib/new-admin/graphql/types/customer.type.js @@ -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 } ` diff --git a/migrations/1627868356883-customer-custom-notes.js b/migrations/1627868356883-customer-custom-notes.js new file mode 100644 index 00000000..4e8497ec --- /dev/null +++ b/migrations/1627868356883-customer-custom-notes.js @@ -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() +} diff --git a/new-lamassu-admin/src/pages/Customers/CustomerNotes.js b/new-lamassu-admin/src/pages/Customers/CustomerNotes.js new file mode 100644 index 00000000..b7f055ba --- /dev/null +++ b/new-lamassu-admin/src/pages/Customers/CustomerNotes.js @@ -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 ( +
+
+

{'Notes'}

+
+ {R.isNil(editing) && ( +
+ + {R.map( + it => ( + + ), + customerNotes + )} +
+ )} + {!R.isNil(editing) && ( + + )} + {openModal && ( + + )} +
+ ) +} + +export default CustomerNotes diff --git a/new-lamassu-admin/src/pages/Customers/CustomerNotes.styles.js b/new-lamassu-admin/src/pages/Customers/CustomerNotes.styles.js new file mode 100644 index 00000000..4ba177b2 --- /dev/null +++ b/new-lamassu-admin/src/pages/Customers/CustomerNotes.styles.js @@ -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 diff --git a/new-lamassu-admin/src/pages/Customers/CustomerProfile.js b/new-lamassu-admin/src/pages/Customers/CustomerProfile.js index 39f3d25e..626c5f38 100644 --- a/new-lamassu-admin/src/pages/Customers/CustomerProfile.js +++ b/new-lamassu-admin/src/pages/Customers/CustomerProfile.js @@ -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}> )} + {isNotes && ( +
+ +
+ )} {wizard && ( { display: 'Customer Data', Icon: CustomerDataIcon, InverseIcon: CustomerDataReversedIcon + }, + { + code: 'notes', + display: 'Notes', + Icon: NoteIcon, + InverseIcon: NoteReversedIcon } ] diff --git a/new-lamassu-admin/src/pages/Customers/components/notes/NewNoteCard.js b/new-lamassu-admin/src/pages/Customers/components/notes/NewNoteCard.js new file mode 100644 index 00000000..72a1e2b9 --- /dev/null +++ b/new-lamassu-admin/src/pages/Customers/components/notes/NewNoteCard.js @@ -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 ( +
setOpenModal(true)}> + + +

Add new

+
+
+ ) +} + +export default NewNoteCard diff --git a/new-lamassu-admin/src/pages/Customers/components/notes/NewNoteModal.js b/new-lamassu-admin/src/pages/Customers/components/notes/NewNoteModal.js new file mode 100644 index 00000000..eb832607 --- /dev/null +++ b/new-lamassu-admin/src/pages/Customers/components/notes/NewNoteModal.js @@ -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 ( + <> + + { + onSubmit({ title, content }) + }}> +
+ + +
+ {errorMsg && {errorMsg}} + +
+ +
+
+ + ) +} + +export default NewNoteModal diff --git a/new-lamassu-admin/src/pages/Customers/components/notes/NewNoteModal.styles.js b/new-lamassu-admin/src/pages/Customers/components/notes/NewNoteModal.styles.js new file mode 100644 index 00000000..a42ecd41 --- /dev/null +++ b/new-lamassu-admin/src/pages/Customers/components/notes/NewNoteModal.styles.js @@ -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 diff --git a/new-lamassu-admin/src/pages/Customers/components/notes/NoteCard.js b/new-lamassu-admin/src/pages/Customers/components/notes/NoteCard.js new file mode 100644 index 00000000..68f779d6 --- /dev/null +++ b/new-lamassu-admin/src/pages/Customers/components/notes/NoteCard.js @@ -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} +
+ + ) + }, fragments) +} + +const NoteCard = ({ note, deleteNote, handleClick, timezone }) => { + const classes = useStyles() + + return ( +
+ handleClick(note)}> +
+
+

{note?.title}

+

{formatDate(note?.created, timezone, 'yyyy-MM-dd')}

+
+
+ { + e.stopPropagation() + deleteNote({ noteId: note.id }) + }} + /> +
+
+

+ {formatContent(note?.content)} +

+
+
+ ) +} + +export default NoteCard diff --git a/new-lamassu-admin/src/pages/Customers/components/notes/NoteCard.styles.js b/new-lamassu-admin/src/pages/Customers/components/notes/NoteCard.styles.js new file mode 100644 index 00000000..9712fa77 --- /dev/null +++ b/new-lamassu-admin/src/pages/Customers/components/notes/NoteCard.styles.js @@ -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 diff --git a/new-lamassu-admin/src/pages/Customers/components/notes/NoteEdit.js b/new-lamassu-admin/src/pages/Customers/components/notes/NoteEdit.js new file mode 100644 index 00000000..dcfc36a8 --- /dev/null +++ b/new-lamassu-admin/src/pages/Customers/components/notes/NoteEdit.js @@ -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 ( + +
+

+ {`Last edited `} + {formatDurationWithOptions( + { delimited: ', ' }, + intervalToDuration({ + start: toTimezone(new Date(note.lastEditedAt), timezone), + end: toTimezone(new Date(), timezone) + }) + )} + {` ago`} +

+
+ + {`Cancel`} + + + {`Save changes`} + + formRef.current.setFieldValue('content', '')}> + {`Clear content`} + +
+
+ + edit({ + noteId: note.id, + newContent: content, + oldContent: note.content + }) + } + innerRef={formRef}> +
+ + +
+
+ ) +} + +export default NoteEdit diff --git a/new-lamassu-admin/src/pages/Customers/components/propertyCard/PropertyCard.js b/new-lamassu-admin/src/pages/Customers/components/propertyCard/PropertyCard.js index 115462f3..a2d1df31 100644 --- a/new-lamassu-admin/src/pages/Customers/components/propertyCard/PropertyCard.js +++ b/new-lamassu-admin/src/pages/Customers/components/propertyCard/PropertyCard.js @@ -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( ) + const EditButton = () => ( + edit()}> + Edit + + ) + + const ConfirmButton = () => ( + + Confirm + + ) + const authorized = state === OVERRIDE_PENDING ? { label: 'Pending', type: 'neutral' } @@ -64,14 +101,22 @@ const PropertyCard = memo( className={classnames(classes.propertyCard, className)} elevation={0}>

{title}

-
-
- -
+
+ {state && ( +
+ +
+ )} {children}
{authorize && state !== OVERRIDE_AUTHORIZED && AuthorizeButton()} {reject && state !== OVERRIDE_REJECTED && RejectButton()} + {edit && !isEditing && EditButton()} + {confirm && isEditing && ConfirmButton()}
diff --git a/new-lamassu-admin/src/styling/icons/customer-nav/note/comet.svg b/new-lamassu-admin/src/styling/icons/customer-nav/note/comet.svg new file mode 100644 index 00000000..951e3108 --- /dev/null +++ b/new-lamassu-admin/src/styling/icons/customer-nav/note/comet.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/new-lamassu-admin/src/styling/icons/customer-nav/note/white.svg b/new-lamassu-admin/src/styling/icons/customer-nav/note/white.svg new file mode 100644 index 00000000..def24ed8 --- /dev/null +++ b/new-lamassu-admin/src/styling/icons/customer-nav/note/white.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/new-lamassu-admin/src/styling/icons/customer-nav/note/zodiac.svg b/new-lamassu-admin/src/styling/icons/customer-nav/note/zodiac.svg new file mode 100644 index 00000000..6388cc81 --- /dev/null +++ b/new-lamassu-admin/src/styling/icons/customer-nav/note/zodiac.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file