Merge pull request #811 from chaotixkilla/feat-customer-custom-notes
Customer custom notes
This commit is contained in:
commit
e0529efb92
21 changed files with 806 additions and 44 deletions
31
lib/customer-notes.js
Normal file
31
lib/customer-notes.js
Normal 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
|
||||
}
|
||||
|
|
@ -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) }))
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
`
|
||||
|
||||
|
|
|
|||
21
migrations/1627868356883-customer-custom-notes.js
Normal file
21
migrations/1627868356883-customer-custom-notes.js
Normal 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()
|
||||
}
|
||||
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,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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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