chore: use monorepo organization

This commit is contained in:
Rafael Taranto 2025-05-12 10:52:54 +01:00
parent deaf7d6ecc
commit a687827f7e
1099 changed files with 8184 additions and 11535 deletions

View file

@ -0,0 +1,40 @@
import CssBaseline from '@mui/material/CssBaseline'
import { ThemeProvider, StyledEngineProvider } from '@mui/material/styles'
import React, { useState } from 'react'
import { BrowserRouter as Router } from 'react-router-dom'
import ApolloProvider from 'src/utils/apollo'
import AppContext from 'src/AppContext'
import theme from 'src/styling/theme'
import Main from './Main'
import './styling/global/global.css'
const App = () => {
const [wizardTested, setWizardTested] = useState(false)
const [userData, setUserData] = useState(null)
const setRole = role => {
if (userData && role && userData.role !== role) {
setUserData({ ...userData, role })
}
}
return (
<AppContext.Provider
value={{ wizardTested, setWizardTested, userData, setUserData, setRole }}>
<Router>
<ApolloProvider>
<StyledEngineProvider enableCssLayer>
<ThemeProvider theme={theme}>
<CssBaseline />
<Main />
</ThemeProvider>
</StyledEngineProvider>
</ApolloProvider>
</Router>
</AppContext.Provider>
)
}
export default App

View file

@ -0,0 +1,3 @@
import React from 'react'
export default React.createContext()

View file

@ -0,0 +1,83 @@
import { useHistory, useLocation } from 'react-router-dom'
import React, { useContext } from 'react'
import { gql, useQuery } from '@apollo/client'
import Slide from '@mui/material/Slide'
import Grid from '@mui/material/Grid'
import Header from './components/layout/Header.jsx'
import Sidebar from './components/layout/Sidebar.jsx'
import TitleSection from './components/layout/TitleSection.jsx'
import { getParent, hasSidebar, Routes, tree } from './routing/routes.jsx'
import AppContext from './AppContext.js'
const GET_USER_DATA = gql`
query userData {
userData {
id
username
role
enabled
last_accessed
last_accessed_from
last_accessed_address
}
}
`
const Main = () => {
const location = useLocation()
const history = useHistory()
const { wizardTested, userData, setUserData } = useContext(AppContext)
const { loading } = useQuery(GET_USER_DATA, {
onCompleted: userResponse => {
if (!userData && userResponse?.userData)
setUserData(userResponse.userData)
}
})
const route = location.pathname
const sidebar = hasSidebar(route)
const parent = sidebar ? getParent(route) : {}
const is404 = location.pathname === '/404'
const isSelected = it => location.pathname === it.route
const onClick = it => history.push(it.route)
const contentClassName = sidebar ? 'flex-1 ml-12 pt-4' : 'w-[1200px]'
return (
<div className="flex flex-col w-full min-h-full">
{!is404 && wizardTested && userData && (
<Header tree={tree} user={userData} />
)}
<main className="flex flex-1 flex-col my-0 mx-auto h-full w-[1200px]">
{sidebar && !is404 && wizardTested && (
<Slide direction="left" in={true} mountOnEnter unmountOnExit>
<div>
<TitleSection title={parent.title}></TitleSection>
</div>
</Slide>
)}
<Grid sx={{ flex: 1, height: 1 }} container>
{sidebar && !is404 && wizardTested && (
<Sidebar
data={parent.children}
isSelected={isSelected}
displayName={it => it.label}
onClick={onClick}
/>
)}
<div className={contentClassName}>{!loading && <Routes />}</div>
</Grid>
</main>
</div>
)
}
export default Main

View file

@ -0,0 +1,48 @@
import React, { memo, useState } from 'react'
import styles from './Carousel.module.css'
import LeftArrow from 'src/styling/icons/arrow/carousel-left-arrow.svg?react'
import RightArrow from 'src/styling/icons/arrow/carousel-right-arrow.svg?react'
export const Carousel = memo(({ photosData, slidePhoto }) => {
const [activeIndex, setActiveIndex] = useState(0)
const handlePrev = () => {
const newIndex = activeIndex === 0 ? photosData.length - 1 : activeIndex - 1
setActiveIndex(newIndex)
slidePhoto(newIndex)
}
const handleNext = () => {
const newIndex = activeIndex === photosData.length - 1 ? 0 : activeIndex + 1
setActiveIndex(newIndex)
slidePhoto(newIndex)
}
if (!photosData || photosData.length === 0) {
return null
}
return (
<div className={styles.carouselContainer}>
{photosData.length > 1 && (
<button onClick={handlePrev} className={styles.navButton}>
<LeftArrow />
</button>
)}
<div className={styles.imageContainer}>
<img
className={styles.image}
src={`/${photosData[activeIndex]?.photoDir}/${photosData[activeIndex]?.path}`}
alt=""
/>
</div>
{photosData.length > 1 && (
<button onClick={handleNext} className={styles.navButton}>
<RightArrow />
</button>
)}
</div>
)
})

View file

@ -0,0 +1,45 @@
.carouselContainer {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
height: 100%;
box-sizing: border-box;
}
.imageContainer {
display: flex;
align-items: center;
justify-content: center;
flex: 1;
height: 100%;
overflow: hidden;
max-width: 80%;
}
.image {
object-fit: contain;
object-position: center;
width: 100%;
height: 100%;
margin-bottom: 40px;
}
.navButton {
background-color: transparent;
border: none;
border-radius: 0;
color: transparent;
opacity: 1;
cursor: pointer;
padding: 8px;
min-width: 44px;
min-height: 44px;
display: flex;
align-items: center;
justify-content: center;
}
.navButton:hover {
background-color: rgba(0, 0, 0, 0.04);
}

View file

@ -0,0 +1,26 @@
import PropTypes from 'prop-types'
import React from 'react'
import Paper from '@mui/material/Paper'
import classnames from 'classnames'
const cardState = Object.freeze({
DEFAULT: 'default',
SHRUNK: 'shrunk',
EXPANDED: 'expanded'
})
const CollapsibleCard = ({ className, state, shrunkComponent, children }) => {
return (
<Paper className={classnames('p-6', className)}>
{state === cardState.SHRUNK ? shrunkComponent : children}
</Paper>
)
}
CollapsibleCard.propTypes = {
shrunkComponent: PropTypes.node.isRequired
}
export default CollapsibleCard
export { cardState }

View file

@ -0,0 +1,105 @@
import Dialog from '@mui/material/Dialog'
import DialogActions from '@mui/material/DialogActions'
import DialogContent from '@mui/material/DialogContent'
import IconButton from '@mui/material/IconButton'
import InputLabel from '@mui/material/InputLabel'
import React, { memo, useState } from 'react'
import { H4, P } from 'src/components/typography'
import CloseIcon from 'src/styling/icons/action/close/zodiac.svg?react'
import { Button } from 'src/components/buttons'
import { TextInput } from 'src/components/inputs'
import ErrorMessage from './ErrorMessage'
import SvgIcon from '@mui/material/SvgIcon'
export const DialogTitle = ({ children, onClose }) => {
return (
<div className="p-4 pr-3 flex justify-between">
{children}
{onClose && (
<IconButton aria-label="close" onClick={onClose} className="p-0 -mt-1">
<SvgIcon fontSize="small">
<CloseIcon />
</SvgIcon>
</IconButton>
)}
</div>
)
}
export const ConfirmDialog = memo(
({
title = 'Confirm action',
errorMessage = 'This action requires confirmation',
open,
toBeConfirmed,
saveButtonAlwaysEnabled = false,
message,
confirmationMessage = `Write '${toBeConfirmed}' to confirm this action`,
onConfirmed,
onDismissed,
initialValue = '',
disabled = false,
...props
}) => {
const [value, setValue] = useState(initialValue)
const [error, setError] = useState(false)
const handleChange = event => setValue(event.target.value)
const innerOnClose = () => {
setValue('')
setError(false)
onDismissed()
}
const isOnErrorState =
(!saveButtonAlwaysEnabled && toBeConfirmed !== value) || value === ''
return (
<Dialog open={open} aria-labelledby="form-dialog-title" {...props}>
<DialogTitle id="customized-dialog-title" onClose={innerOnClose}>
<H4 noMargin>{title}</H4>
</DialogTitle>
{errorMessage && (
<DialogTitle>
<ErrorMessage>
{errorMessage.split(':').map(error => (
<>
{error}
<br />
</>
))}
</ErrorMessage>
</DialogTitle>
)}
<DialogContent className="w-108 p-4 pr-7">
{message && <P>{message}</P>}
<InputLabel htmlFor="confirm-input">{confirmationMessage}</InputLabel>
<TextInput
disabled={disabled}
name="confirm-input"
autoFocus
id="confirm-input"
type="text"
size="sm"
fullWidth
value={value}
touched={{}}
error={error}
InputLabelProps={{ shrink: true }}
onChange={handleChange}
/>
</DialogContent>
<DialogActions className="p-8 pt-4">
<Button
color="green"
disabled={isOnErrorState}
onClick={() => onConfirmed(value)}>
Confirm
</Button>
</DialogActions>
</Dialog>
)
}
)

View file

@ -0,0 +1,74 @@
import classnames from 'classnames'
import * as R from 'ramda'
import React, { useState, useEffect } from 'react'
import { CopyToClipboard as ReactCopyToClipboard } from 'react-copy-to-clipboard'
import Popover from 'src/components/Popper.jsx'
import CopyIcon from 'src/styling/icons/action/copy/copy.svg?react'
import { comet } from 'src/styling/variables.js'
import { Label1, Mono } from './typography/index.jsx'
const CopyToClipboard = ({
className,
buttonClassname,
children,
wrapperClassname,
removeSpace = true
}) => {
const [anchorEl, setAnchorEl] = useState(null)
useEffect(() => {
if (anchorEl) setTimeout(() => setAnchorEl(null), 3000)
}, [anchorEl])
const handleClick = event => {
setAnchorEl(anchorEl ? null : event.currentTarget)
}
const handleClose = () => {
setAnchorEl(null)
}
const open = Boolean(anchorEl)
const id = open ? 'simple-popper' : undefined
return (
<div className={classnames('flex items-center', wrapperClassname)}>
{children && (
<>
<Mono
noMargin
className={classnames('linebreak-anywhere', className)}>
{children}
</Mono>
<div className={buttonClassname}>
<ReactCopyToClipboard
text={removeSpace ? R.replace(/\s/g, '')(children) : children}>
<button
className="border-0 bg-transparent cursor-pointer"
aria-describedby={id}
onClick={event => handleClick(event)}>
<CopyIcon />
</button>
</ReactCopyToClipboard>
</div>
<Popover
id={id}
open={open}
anchorEl={anchorEl}
onClose={handleClose}
bgColor={comet}
className="py-1 px-2"
placement="top">
<Label1 noMargin className="text-white rounded-sm">
Copied to clipboard!
</Label1>
</Popover>
</>
)}
</div>
);
}
export default CopyToClipboard

View file

@ -0,0 +1,65 @@
import Dialog from '@mui/material/Dialog'
import DialogActions from '@mui/material/DialogActions'
import DialogContent from '@mui/material/DialogContent'
import IconButton from '@mui/material/IconButton'
import React from 'react'
import { H4, P } from 'src/components/typography'
import CloseIcon from 'src/styling/icons/action/close/zodiac.svg?react'
import { Button } from 'src/components/buttons'
import ErrorMessage from './ErrorMessage'
import SvgIcon from '@mui/material/SvgIcon'
export const DialogTitle = ({ children, close }) => {
return (
<div className="p-4 pr-3 flex justify-between m-0">
{children}
{close && (
<IconButton aria-label="close" onClick={close} className="p-0 -mt-1">
<SvgIcon fontSize="small">
<CloseIcon />
</SvgIcon>
</IconButton>
)}
</div>
)
}
export const DeleteDialog = ({
title = 'Confirm Delete',
open = false,
onConfirmed,
onDismissed,
item = 'item',
confirmationMessage = `Are you sure you want to delete this ${item}?`,
extraMessage,
errorMessage = ''
}) => {
return (
<Dialog open={open} aria-labelledby="form-dialog-title">
<DialogTitle close={() => onDismissed()}>
<H4 className="m-0">{title}</H4>
</DialogTitle>
{errorMessage && (
<DialogTitle>
<ErrorMessage>
{errorMessage.split(':').map(error => (
<>
{error}
<br />
</>
))}
</ErrorMessage>
</DialogTitle>
)}
<DialogContent className="w-108 p-4 pr-7">
{confirmationMessage && <P>{confirmationMessage}</P>}
{extraMessage}
</DialogContent>
<DialogActions className="p-8 pt-4">
<Button onClick={onConfirmed}>Confirm</Button>
</DialogActions>
</Dialog>
)
}

View file

@ -0,0 +1,18 @@
import classnames from 'classnames'
import React from 'react'
import ErrorIcon from 'src/styling/icons/warning-icon/tomato.svg?react'
import { Info3 } from './typography'
const ErrorMessage = ({ className, children }) => {
return (
<div className={classnames('flex items-center', className)}>
<ErrorIcon className="mr-3" />
<Info3 className="flex items-center text-tomato m-0 whitespace-break-spaces">
{children}
</Info3>
</div>
)
}
export default ErrorMessage

View file

@ -0,0 +1,56 @@
import ClickAwayListener from '@mui/material/ClickAwayListener'
import classnames from 'classnames'
import React, { memo, useState } from 'react'
import Popper from 'src/components/Popper'
import ZoomIconInverse from 'src/styling/icons/circle buttons/search/white.svg?react'
import ZoomIcon from 'src/styling/icons/circle buttons/search/zodiac.svg?react'
import { FeatureButton } from 'src/components/buttons'
const ImagePopper = memo(
({ className, width, height, popupWidth, popupHeight, src }) => {
const [popperAnchorEl, setPopperAnchorEl] = useState(null)
const handleOpenPopper = event => {
setPopperAnchorEl(popperAnchorEl ? null : event.currentTarget)
}
const handleClosePopper = () => {
setPopperAnchorEl(null)
}
const popperOpen = Boolean(popperAnchorEl)
const Image = ({ className, style }) => (
<img className={classnames(className)} style={style} src={src} alt="" />
)
return (
<ClickAwayListener onClickAway={handleClosePopper}>
<div className={classnames('flex flex-row', className)}>
<Image
className="object-cover rounded-tl-lg"
style={{ width, height }}
/>
<FeatureButton
Icon={ZoomIcon}
InverseIcon={ZoomIconInverse}
className="rounded-none rounded-tr-lg rounded-br-lg"
style={{ height }}
onClick={handleOpenPopper}
/>
<Popper open={popperOpen} anchorEl={popperAnchorEl} placement="top">
<div className="py-2 px-4">
<Image
className="object-cover"
style={{ width: popupWidth, height: popupHeight }}
/>
</div>
</Popper>
</div>
</ClickAwayListener>
)
}
)
export default ImagePopper

View file

@ -0,0 +1,39 @@
import Dialog from '@mui/material/Dialog'
import DialogContent from '@mui/material/DialogContent'
import SvgIcon from '@mui/material/SvgIcon'
import IconButton from '@mui/material/IconButton'
import React, { memo } from 'react'
import { H1 } from 'src/components/typography'
import CloseIcon from 'src/styling/icons/action/close/zodiac.svg?react'
export const InformativeDialog = memo(
({ title = '', open, onDissmised, disabled = false, data, ...props }) => {
const innerOnClose = () => {
onDissmised()
}
return (
<Dialog
PaperProps={{
style: {
borderRadius: 8
}
}}
fullWidth
open={open}
aria-labelledby="form-dialog-title"
{...props}>
<div className="flex justify-end pt-4 pr-3 pb-0 pl-4">
<IconButton aria-label="close" onClick={innerOnClose}>
<SvgIcon fontSize="small">
<CloseIcon />
</SvgIcon>
<CloseIcon />
</IconButton>
</div>
<H1 className="mt-0 mr-4 mb-2 ml-5">{title}</H1>
<DialogContent>{data}</DialogContent>
</Dialog>
)
}
)

View file

@ -0,0 +1,236 @@
import { useLazyQuery } from '@apollo/client'
import ClickAwayListener from '@mui/material/ClickAwayListener'
import classnames from 'classnames'
import { format, set } from 'date-fns/fp'
import FileSaver from 'file-saver'
import * as R from 'ramda'
import React, { useState, useCallback } from 'react'
import Arrow from 'src/styling/icons/arrow/download_logs.svg?react'
import DownloadInverseIcon from 'src/styling/icons/button/download/white.svg?react'
import Download from 'src/styling/icons/button/download/zodiac.svg?react'
import { FeatureButton, Link } from 'src/components/buttons'
import { formatDate } from 'src/utils/timezones'
import Popper from './Popper'
import DateRangePicker from './date-range-picker/DateRangePicker'
import { RadioGroup } from './inputs'
import typographyStyles from './typography/styles'
import { H4, Info1, Label1, Label2 } from './typography/index.jsx'
const DateContainer = ({ date, children }) => {
return (
<div className="h-11 w-25">
<Label1 noMargin>{children}</Label1>
{date && (
<>
<div className="flex">
<Info1 noMargin className="mr-2">
{format('d', date)}
</Info1>
<div className="flex flex-col">
<Label2 noMargin>{`${format(
'MMM',
date
)} ${format('yyyy', date)}`}</Label2>
<Label1 noMargin className="text-comet">
{format('EEEE', date)}
</Label1>
</div>
</div>
</>
)}
</div>
)
}
const ALL = 'all'
const RANGE = 'range'
const ADVANCED = 'advanced'
const SIMPLIFIED = 'simplified'
const LogsDownloaderPopover = ({
name,
query,
args,
title,
getLogs,
timezone,
simplified,
className
}) => {
const [selectedRadio, setSelectedRadio] = useState(ALL)
const [selectedAdvancedRadio, setSelectedAdvancedRadio] = useState(ADVANCED)
const [range, setRange] = useState({ from: null, until: null })
const [anchorEl, setAnchorEl] = useState(null)
const [fetchLogs] = useLazyQuery(query, {
onCompleted: data => createLogsFile(getLogs(data), range)
})
const dateRangePickerClasses = {
'block h-full': selectedRadio === RANGE,
hidden: selectedRadio === ALL
}
const handleRadioButtons = evt => {
const selectedRadio = R.path(['target', 'value'])(evt)
setSelectedRadio(selectedRadio)
if (selectedRadio === ALL) setRange({ from: null, until: null })
}
const handleAdvancedRadioButtons = evt => {
const selectedAdvancedRadio = R.path(['target', 'value'])(evt)
setSelectedAdvancedRadio(selectedAdvancedRadio)
}
const handleRangeChange = useCallback(
(from, until) => {
setRange({ from, until })
},
[setRange]
)
const downloadLogs = (range, args) => {
if (selectedRadio === ALL) {
fetchLogs({
variables: {
...args,
simplified: selectedAdvancedRadio === SIMPLIFIED,
excludeTestingCustomers: true
}
})
}
if (!range || !range.from) return
if (range.from && !range.until) range.until = new Date()
if (selectedRadio === RANGE) {
fetchLogs({
variables: {
...args,
from: range.from,
until: range.until,
simplified: selectedAdvancedRadio === SIMPLIFIED,
excludeTestingCustomers: true
}
})
}
}
const createLogsFile = (logs, range) => {
const formatDateFile = date => {
return formatDate(date, timezone, 'yyyy-MM-dd_HH-mm')
}
const blob = new window.Blob([logs], {
type: 'text/plain;charset=utf-8'
})
FileSaver.saveAs(
blob,
selectedRadio === ALL
? `${formatDateFile(new Date())}_${name}.csv`
: `${formatDateFile(range.from)}_${formatDateFile(
range.until
)}_${name}.csv`
)
}
const handleOpenRangePicker = event => {
setAnchorEl(anchorEl ? null : event.currentTarget)
}
const handleClickAway = () => {
setAnchorEl(null)
}
const radioButtonOptions = [
{ display: 'All logs', code: ALL },
{ display: 'Date range', code: RANGE }
]
const advancedRadioButtonOptions = [
{ display: 'Advanced logs', code: ADVANCED },
{ display: 'Simplified logs', code: SIMPLIFIED }
]
const open = Boolean(anchorEl)
const id = open ? 'date-range-popover' : undefined
return (
<ClickAwayListener onClickAway={handleClickAway}>
<div className={className}>
<FeatureButton
Icon={Download}
InverseIcon={DownloadInverseIcon}
onClick={handleOpenRangePicker}
variant="contained"
/>
<Popper id={id} open={open} anchorEl={anchorEl} placement="bottom">
<div className="w-70">
<H4 noMargin className="p-4 pb-0">
{title}
</H4>
<div className="py-1 px-4">
<RadioGroup
name="logs-select"
value={selectedRadio}
options={radioButtonOptions}
ariaLabel="logs-select"
onChange={handleRadioButtons}
className="flex flex-row justify-between text-zodiac"
/>
</div>
{selectedRadio === RANGE && (
<div className={classnames(dateRangePickerClasses)}>
<div className="flex justify-between items-center py-0 px-4 bg-zircon relative min-h-20">
{range && (
<>
<DateContainer date={range.from}>From</DateContainer>
<div className="absolute left-31 top-6">
<Arrow className="m-auto" />
</div>
<DateContainer date={range.until}>To</DateContainer>
</>
)}
</div>
<DateRangePicker
maxDate={set(
{
hours: 23,
minutes: 59,
seconds: 59,
milliseconds: 999
},
new Date()
)}
onRangeChange={handleRangeChange}
/>
</div>
)}
{simplified && (
<div className="py-1 px-4">
<RadioGroup
name="simplified-tx-logs"
value={selectedAdvancedRadio}
options={advancedRadioButtonOptions}
ariaLabel="simplified-tx-logs"
onChange={handleAdvancedRadioButtons}
className="flex flex-row justify-between text-zodiac"
/>
</div>
)}
<div className="py-3 px-4">
<Link color="primary" onClick={() => downloadLogs(range, args)}>
Download
</Link>
</div>
</div>
</Popper>
</div>
</ClickAwayListener>
)
}
export default LogsDownloaderPopover

View file

@ -0,0 +1,97 @@
import MaterialModal from '@mui/material/Modal'
import IconButton from '@mui/material/IconButton'
import SvgIcon from '@mui/material/SvgIcon'
import Paper from '@mui/material/Paper'
import classnames from 'classnames'
import React from 'react'
import { H1, H4 } from 'src/components/typography'
import CloseIcon from 'src/styling/icons/action/close/zodiac.svg?react'
const Modal = ({
width,
height,
infoPanelHeight,
title,
small,
xl,
infoPanel,
handleClose,
children,
secondaryModal,
className,
closeOnEscape,
closeOnBackdropClick,
...props
}) => {
const TitleCase = small ? H4 : H1
const closeSize = xl ? 28 : small ? 16 : 20
const innerClose = (evt, reason) => {
if (!closeOnBackdropClick && reason === 'backdropClick') return
if (!closeOnEscape && reason === 'escapeKeyDown') return
handleClose()
}
const marginBySize = xl ? 0 : small ? 12 : 16
const paddingBySize = xl ? 88 : small ? 16 : 32
return (
<MaterialModal
onClose={innerClose}
className="flex justify-center flex-col items-center"
{...props}>
<>
<Paper
style={{ width, height, minHeight: height ?? 400 }}
className={classnames(
'flex flex-col max-h-[90vh] rounded-lg outline-0',
className
)}>
<div className="flex">
{title && (
<TitleCase
className={
small ? 'mt-5 mr-0 mb-2 ml-4' : 'mt-7 mr-0 mb-2 ml-8'
}>
{title}
</TitleCase>
)}
<div
className="ml-auto"
style={{ marginRight: marginBySize, marginTop: marginBySize }}>
<IconButton
className="p-0 mb-auto ml-auto"
onClick={() => handleClose()}>
<SvgIcon fontSize={xl ? 'large' : 'small'}>
<CloseIcon />
</SvgIcon>
</IconButton>
</div>
</div>
<div
className="w-full flex flex-col flex-1"
style={{ paddingRight: paddingBySize, paddingLeft: paddingBySize }}>
{children}
</div>
</Paper>
{infoPanel && (
<Paper
style={{
width,
height: infoPanelHeight,
minHeight: infoPanelHeight ?? 200
}}
className={classnames(
'mt-4 flex flex-col max-h-[90vh] overflow-y-auto rounded-lg outline-0',
className
)}>
<div className="w-full flex flex-col flex-1 py-0 px-6">
{infoPanel}
</div>
</Paper>
)}
</>
</MaterialModal>
)
}
export default Modal

View file

@ -0,0 +1,160 @@
import { useQuery, useMutation, gql } from '@apollo/client'
import * as R from 'ramda'
import React, { useState, useEffect } from 'react'
import ActionButton from 'src/components/buttons/ActionButton'
import { H5 } from 'src/components/typography'
import NotificationIconZodiac from 'src/styling/icons/menu/notification-zodiac.svg?react'
import ClearAllIconInverse from 'src/styling/icons/stage/spring/empty.svg?react'
import ClearAllIcon from 'src/styling/icons/stage/zodiac/empty.svg?react'
import ShowUnreadIcon from 'src/styling/icons/stage/zodiac/full.svg?react'
import NotificationRow from './NotificationRow'
import classes from './NotificationCenter.module.css'
const GET_NOTIFICATIONS = gql`
query getNotifications {
notifications {
id
type
detail
message
created
read
valid
}
hasUnreadNotifications
machines {
deviceId
name
}
}
`
const TOGGLE_CLEAR_NOTIFICATION = gql`
mutation toggleClearNotification($id: ID!, $read: Boolean!) {
toggleClearNotification(id: $id, read: $read) {
id
read
}
}
`
const CLEAR_ALL_NOTIFICATIONS = gql`
mutation clearAllNotifications {
clearAllNotifications {
id
}
}
`
const NotificationCenter = ({
close,
hasUnreadProp,
buttonCoords,
popperRef,
refetchHasUnreadHeader
}) => {
const { data, loading } = useQuery(GET_NOTIFICATIONS, {
pollInterval: 60000
})
const [xOffset, setXoffset] = useState(300)
const [showingUnread, setShowingUnread] = useState(false)
const machines = R.compose(
R.map(R.prop('name')),
R.indexBy(R.prop('deviceId'))
)(R.path(['machines'])(data) ?? [])
const notifications = R.path(['notifications'])(data) ?? []
const [hasUnread, setHasUnread] = useState(hasUnreadProp)
const [toggleClearNotification] = useMutation(TOGGLE_CLEAR_NOTIFICATION, {
onError: () => console.error('Error while clearing notification'),
refetchQueries: () => ['getNotifications']
})
const [clearAllNotifications] = useMutation(CLEAR_ALL_NOTIFICATIONS, {
onError: () => console.error('Error while clearing all notifications'),
refetchQueries: () => ['getNotifications']
})
useEffect(() => {
setXoffset(popperRef.current.getBoundingClientRect().x)
if (data && data.hasUnreadNotifications !== hasUnread) {
refetchHasUnreadHeader()
setHasUnread(!hasUnread)
}
}, [popperRef, data, hasUnread, refetchHasUnreadHeader])
const buildNotifications = () => {
const notificationsToShow =
!showingUnread || !hasUnread
? notifications
: R.filter(R.propEq('read', false))(notifications)
return notificationsToShow.map(n => {
return (
<NotificationRow
key={n.id}
id={n.id}
type={n.type}
detail={n.detail}
message={n.message}
deviceName={machines[n.detail.deviceId]}
created={n.created}
read={n.read}
valid={n.valid}
toggleClear={() =>
toggleClearNotification({
variables: { id: n.id, read: !n.read }
})
}
/>
)
})
}
return (
<>
<div className={classes.container}>
<div className={classes.header}>
<H5 className={classes.headerText}>Notifications</H5>
<button
onClick={close}
className={classes.notificationIcon}
style={{
top: buttonCoords?.y ?? 0,
left: buttonCoords?.x ? buttonCoords.x - xOffset : 0
}}>
<NotificationIconZodiac />
{hasUnread && <div className={classes.hasUnread} />}
</button>
</div>
<div className={classes.actionButtons}>
{hasUnread && (
<ActionButton
color="primary"
Icon={ShowUnreadIcon}
InverseIcon={ClearAllIconInverse}
className={classes.clearAllButton}
onClick={() => setShowingUnread(!showingUnread)}>
{showingUnread ? 'Show all' : 'Show unread'}
</ActionButton>
)}
{hasUnread && (
<ActionButton
color="primary"
Icon={ClearAllIcon}
InverseIcon={ClearAllIconInverse}
className={classes.clearAllButton}
onClick={clearAllNotifications}>
Mark all as read
</ActionButton>
)}
</div>
<div className={classes.notificationsList}>
{!loading && buildNotifications()}
</div>
</div>
</>
)
}
export default NotificationCenter

View file

@ -0,0 +1,145 @@
.container {
width: 40vw;
height: 110vh;
right: 0;
background-color: white;
box-shadow: 0 0 14px 0 rgba(0, 0, 0, 0.24);
}
.container @media only screen and (max-width: 1920px) {
width: 30vw;
}
.header {
display: flex;
justify-content: space-between;
}
.headerText {
margin-top: 20px;
margin-left: 12px;
}
.actionButtons {
display: flex;
margin-left: 16px;
height: 0;
}
.notificationIcon {
position: absolute;
cursor: pointer;
background: transparent;
box-shadow: 0 0 0 transparent;
border: 0 solid transparent;
text-shadow: 0 0 0 transparent;
outline: none;
}
.clearAllButton {
margin-top: -16px;
margin-left: 8px;
background-color: var(--zircon);
}
.notificationsList {
height: 90vh;
max-height: 100vh;
margin-top: 24px;
margin-left: 0;
overflow-y: auto;
overflow-x: hidden;
background-color: white;
z-index: 10;
}
.notificationRow {
display: flex;
flex-direction: row;
justify-content: flex-start;
position: relative;
margin-bottom: 16px;
padding-top: 12px;
gap: 10px;
}
.notificationRow > *:first-child {
margin-right: 24px;
}
.notificationContent {
display: flex;
flex-direction: column;
justify-content: center;
width: 300px;
}
.unread {
background-color: var(--spring3)
}
.notificationRowIcon {
align-self: center;
}
.notificationRowIcon > * {
margin-left: 24px
}
.readIconWrapper {
flex-grow: 1
}
.unreadIcon {
margin-top: 5px;
margin-left: 8px;
width: 12px;
height: 12px;
background-color: var(--spring);
border-radius: 50%;
cursor: pointer;
z-index: 1;
}
.readIcon {
margin-left: 8px;
margin-top: 5px;
width: 12px;
height: 12px;
border: 1px solid var(--comet);
border-radius: 50%;
cursor: pointer;
z-index: 1;
}
.notificationTitle {
margin: 0;
color: var(--comet);
}
.notificationBody {
margin: 0
}
.notificationSubtitle {
margin: 0;
margin-bottom: 8px;
color: var(--comet);
}
.stripes {
position: absolute;
height: 100%;
top: 0;
opacity: 60%;
}
.hasUnread {
position: absolute;
top: 0;
left: 16px;
width: 9px;
height: 9px;
background-color: var(--spring);
border-radius: 50%;
}

View file

@ -0,0 +1,92 @@
import classnames from 'classnames'
import prettyMs from 'pretty-ms'
import * as R from 'ramda'
import React from 'react'
import { Label1, Label2, TL2 } from 'src/components/typography'
import Wrench from 'src/styling/icons/action/wrench/zodiac.svg?react'
import Transaction from 'src/styling/icons/arrow/transaction.svg?react'
import WarningIcon from 'src/styling/icons/warning-icon/tomato.svg?react'
import classes from './NotificationCenter.module.css'
const types = {
transaction: {
display: 'Transactions',
icon: <Transaction height={16} width={16} />
},
highValueTransaction: {
display: 'Transactions',
icon: <Transaction height={16} width={16} />
},
fiatBalance: {
display: 'Maintenance',
icon: <Wrench height={16} width={16} />
},
cryptoBalance: {
display: 'Maintenance',
icon: <Wrench height={16} width={16} />
},
compliance: {
display: 'Compliance',
icon: <WarningIcon height={16} width={16} />
},
error: { display: 'Error', icon: <WarningIcon height={16} width={16} /> }
}
const NotificationRow = ({
id,
type,
detail,
message,
deviceName,
created,
read,
valid,
toggleClear
}) => {
const typeDisplay = R.path([type, 'display'])(types) ?? null
const icon = R.path([type, 'icon'])(types) ?? (
<Wrench height={16} width={16} />
)
const age = prettyMs(new Date().getTime() - new Date(created).getTime(), {
compact: true,
verbose: true
})
const notificationTitle =
typeDisplay && deviceName
? `${typeDisplay} - ${deviceName}`
: !typeDisplay && deviceName
? `${deviceName}`
: `${typeDisplay}`
const iconClass = {
[classes.readIcon]: read,
[classes.unreadIcon]: !read
}
return (
<div
className={classnames(
classes.notificationRow,
!read && valid ? classes.unread : ''
)}>
<div className={classes.notificationRowIcon}>
<div>{icon}</div>
</div>
<div className={classes.notificationContent}>
<Label2 className={classes.notificationTitle}>
{notificationTitle}
</Label2>
<TL2 className={classes.notificationBody}>{message}</TL2>
<Label1 className={classes.notificationSubtitle}>{age}</Label1>
</div>
<div className={classes.readIconWrapper}>
<div
onClick={() => toggleClear(id)}
className={classnames(iconClass)}
/>
</div>
</div>
)
}
export default NotificationRow

View file

@ -0,0 +1,2 @@
import NotificationCenter from './NotificationCenter'
export default NotificationCenter

View file

@ -0,0 +1,77 @@
import MaterialPopper from '@mui/material/Popper'
import Paper from '@mui/material/Paper'
import classnames from 'classnames'
import * as R from 'ramda'
import React, { useState } from 'react'
import { white } from 'src/styling/variables'
import classes from './Popper.module.css'
const Popover = ({ children, bgColor = white, className, ...props }) => {
const [arrowRef, setArrowRef] = useState(null)
const flipPlacements = {
top: ['bottom'],
bottom: ['top'],
left: ['right'],
right: ['left']
}
const modifiers = [
{
name: 'flip',
enabled: R.defaultTo(false, props.flip),
options: {
allowedAutoPlacements: flipPlacements[props.placement]
}
},
{
name: 'preventOverflow',
enabled: true,
options: {
rootBoundary: 'scrollParent'
}
},
{
name: 'offset',
enabled: true,
options: {
offset: [0, 10]
}
},
{
name: 'arrow',
enabled: R.defaultTo(true, props.showArrow),
options: {
element: arrowRef
}
},
{
name: 'computeStyles',
options: {
gpuAcceleration: false
}
}
]
return (
<>
<MaterialPopper
disablePortal={false}
modifiers={modifiers}
className={classnames(classes.tooltip, 'z-3000 rounded-sm')}
{...props}>
<Paper style={{ backgroundColor: bgColor }} className={className}>
<span
className={classes.newArrow}
data-popper-arrow
ref={setArrowRef}
/>
{children}
</Paper>
</MaterialPopper>
</>
)
}
export default Popover

View file

@ -0,0 +1,33 @@
.newArrow,
.newArrow::before {
position: absolute;
width: 8px;
height: 8px;
background: inherit;
}
.newArrow {
visibility: hidden;
}
.newArrow::before {
visibility: visible;
content: '';
transform: rotate(45deg);
}
.tooltip[data-popper-placement^='top'] > div > span {
bottom: -4px;
}
.tooltip[data-popper-placement^='bottom'] > div > span {
top: -4px;
}
.tooltip[data-popper-placement^='left'] > div > span {
right: -4px;
}
.tooltip[data-popper-placement^='right'] > div > span {
left: -4px;
}

View file

@ -0,0 +1,28 @@
import { useFormikContext } from 'formik'
import React, { useEffect } from 'react'
import { Prompt } from 'react-router-dom'
const PROMPT_DEFAULT_MESSAGE =
'You have unsaved changes on this page. Are you sure you want to leave?'
const PromptWhenDirty = ({ message = PROMPT_DEFAULT_MESSAGE }) => {
const formik = useFormikContext()
const hasChanges = formik.dirty && formik.submitCount === 0
useEffect(() => {
if (hasChanges) {
window.onbeforeunload = confirmExit
} else {
window.onbeforeunload = undefined
}
}, [hasChanges])
const confirmExit = () => {
return PROMPT_DEFAULT_MESSAGE
}
return <Prompt when={hasChanges} message={message} />
}
export default PromptWhenDirty

View file

@ -0,0 +1,83 @@
import InputBase from '@mui/material/InputBase'
import Paper from '@mui/material/Paper'
import MAutocomplete from '@mui/material/Autocomplete'
import classnames from 'classnames'
import React, { memo, useState } from 'react'
import { P } from 'src/components/typography'
import SearchIcon from 'src/styling/icons/circle buttons/search/zodiac.svg?react'
const SearchBox = memo(
({
loading = false,
filters = [],
options = [],
inputPlaceholder = '',
size,
onChange,
...props
}) => {
const [popupOpen, setPopupOpen] = useState(false)
const inputClasses = {
'flex flex-1 h-8 px-2 py-2 font-md items-center rounded-2xl bg-zircon text-comet': true,
'rounded-b-none': popupOpen
}
const innerOnChange = filters => onChange(filters)
return (
<MAutocomplete
loading={loading}
value={filters}
options={options}
getOptionLabel={it => it.label || it.value}
renderOption={(props, it) => (
<li {...props}>
<div className="flex flex-row w-full h-8">
<P className="m-0 whitespace-nowrap overflow-hidden text-ellipsis">
{it.label || it.value}
</P>
<P className="m-0 ml-auto text-sm text-come">{it.type}</P>
</div>
</li>
)}
autoHighlight
disableClearable
clearOnEscape
multiple
filterSelectedOptions
isOptionEqualToValue={(option, value) => option.type === value.type}
renderInput={params => {
return (
<InputBase
ref={params.InputProps.ref}
{...params}
className={classnames(inputClasses)}
startAdornment={<SearchIcon className="mr-3" />}
placeholder={inputPlaceholder}
inputProps={{
className: 'font-bold',
...params.inputProps
}}
/>
)
}}
onOpen={() => setPopupOpen(true)}
onClose={() => setPopupOpen(false)}
onChange={(_, filters) => innerOnChange(filters)}
{...props}
slots={{
paper: ({ children }) => (
<Paper
elevation={0}
className="flex flex-col rounded-b-xl bg-zircon shadow-2xl">
<div className="w-[88%] h-[1px] my-p mx-auto border-1 border-comet" />
{children}
</Paper>
)
}} />
);
}
)
export default SearchBox

View file

@ -0,0 +1,53 @@
import Chip from '@mui/material/Chip'
import React from 'react'
import { P, Label3 } from 'src/components/typography'
import CloseIcon from 'src/styling/icons/action/close/zodiac.svg?react'
import FilterIcon from 'src/styling/icons/button/filter/white.svg?react'
import ReverseFilterIcon from 'src/styling/icons/button/filter/zodiac.svg?react'
import { ActionButton } from 'src/components/buttons'
import { onlyFirstToUpper, singularOrPlural } from 'src/utils/string'
const SearchFilter = ({
filters,
onFilterDelete,
deleteAllFilters,
entries = 0
}) => {
return (
<>
<P className="mx-0">{'Filters:'}</P>
<div className="flex mb-4">
<div className="mt-auto">
{filters.map((f, idx) => (
<Chip
key={idx}
label={`${onlyFirstToUpper(f.type)}: ${f.label || f.value}`}
onDelete={() => onFilterDelete(f)}
deleteIcon={<CloseIcon className="w-2 h-2 mx-2" />}
/>
))}
</div>
<div className="flex ml-auto justify-end flex-row">
{
<Label3 className="text-comet m-auto mr-3">{`${entries} ${singularOrPlural(
entries,
`entry`,
`entries`
)}`}</Label3>
}
<ActionButton
altTextColor
color="secondary"
Icon={ReverseFilterIcon}
InverseIcon={FilterIcon}
onClick={deleteAllFilters}>
Delete filters
</ActionButton>
</div>
</div>
</>
)
}
export default SearchFilter

View file

@ -0,0 +1,23 @@
import Chip from '@mui/material/Chip'
import React from 'react'
const Status = ({ status }) => {
return <Chip color={status.type} label={status.label} />
}
const MainStatus = ({ statuses }) => {
const mainStatus =
statuses.find(s => s.type === 'error') ||
statuses.find(s => s.type === 'warning') ||
statuses[0]
const plus = { label: `+${statuses.length - 1}`, type: mainStatus.type }
return (
<div>
<Status status={mainStatus} />
{statuses.length > 1 && <Status status={plus} />}
</div>
)
}
export { Status, MainStatus }

View file

@ -0,0 +1,61 @@
import classnames from 'classnames'
import * as R from 'ramda'
import React, { memo } from 'react'
import CompleteStageIconSpring from 'src/styling/icons/stage/spring/complete.svg?react'
import CurrentStageIconSpring from 'src/styling/icons/stage/spring/current.svg?react'
import EmptyStageIconSpring from 'src/styling/icons/stage/spring/empty.svg?react'
import CompleteStageIconZodiac from 'src/styling/icons/stage/zodiac/complete.svg?react'
import CurrentStageIconZodiac from 'src/styling/icons/stage/zodiac/current.svg?react'
import EmptyStageIconZodiac from 'src/styling/icons/stage/zodiac/empty.svg?react'
import classes from './Stepper.module.css'
const Stepper = memo(({ steps, currentStep, color = 'spring', className }) => {
if (currentStep < 1 || currentStep > steps)
throw Error('Value of currentStage is invalid')
if (steps < 1) throw Error('Value of stages is invalid')
const separatorClasses = {
'w-7 h-[2px] border-2 z-1': true,
'border-spring': color === 'spring',
'border-zodiac': color === 'zodiac'
}
const separatorEmptyClasses = {
'w-7 h-[2px] border-2 z-1': true,
'border-dust': color === 'spring',
'border-comet': color === 'zodiac'
}
return (
<div className={classnames(className, 'flex items-center')}>
{R.range(1, currentStep).map(idx => (
<div key={idx} className="flex items-center m-0">
{idx > 1 && <div className={classnames(separatorClasses)} />}
<div className={classes.stage}>
{color === 'spring' && <CompleteStageIconSpring />}
{color === 'zodiac' && <CompleteStageIconZodiac />}
</div>
</div>
))}
<div className="flex items-center m-0">
{currentStep > 1 && <div className={classnames(separatorClasses)} />}
<div className={classes.stage}>
{color === 'spring' && <CurrentStageIconSpring />}
{color === 'zodiac' && <CurrentStageIconZodiac />}
</div>
</div>
{R.range(currentStep + 1, steps + 1).map(idx => (
<div key={idx} className="flex items-center m-0">
<div className={classnames(separatorEmptyClasses)} />
<div className={classes.stage}>
{color === 'spring' && <EmptyStageIconSpring />}
{color === 'zodiac' && <EmptyStageIconZodiac />}
</div>
</div>
))}
</div>
)
})
export default Stepper

View file

@ -0,0 +1,12 @@
.stage {
display: flex;
height: 28px;
width: 28px;
z-index: 2;
}
.stage > svg {
height: 100%;
width: 100%;
overflow: visible;
}

View file

@ -0,0 +1,15 @@
import classnames from 'classnames'
import React, { memo } from 'react'
import { TL1 } from './typography'
const Subtitle = memo(({ children, className, extraMarginTop }) => {
const classNames = {
'text-comet my-4': true,
'mt-18': extraMarginTop
}
return <TL1 className={classnames(classNames, className)}>{children}</TL1>
})
export default Subtitle

View file

@ -0,0 +1,9 @@
import React, { memo } from 'react'
import { H1 } from './typography'
const Title = memo(({ children }) => {
return <H1 className="my-6">{children}</H1>
})
export default Title

View file

@ -0,0 +1,121 @@
import ClickAwayListener from '@mui/material/ClickAwayListener'
import * as R from 'ramda'
import React, { useState, memo } from 'react'
import Popper from 'src/components/Popper'
import HelpIcon from 'src/styling/icons/action/help/zodiac.svg?react'
const useStyles = {
transparentButton: {
border: 'none',
backgroundColor: 'transparent',
outline: 'none',
cursor: 'pointer',
marginTop: 4
},
relativelyPositioned: {
position: 'relative'
},
safeSpace: {
position: 'absolute',
backgroundColor: '#0000',
height: 40,
left: '-50%',
width: '200%'
},
popoverContent: ({ width }) => ({
width,
padding: [[10, 15]]
})
}
const usePopperHandler = () => {
const [helpPopperAnchorEl, setHelpPopperAnchorEl] = useState(null)
const handleOpenHelpPopper = event => {
setHelpPopperAnchorEl(helpPopperAnchorEl ? null : event.currentTarget)
}
const openHelpPopper = event => {
setHelpPopperAnchorEl(event.currentTarget)
}
const handleCloseHelpPopper = () => {
setHelpPopperAnchorEl(null)
}
const helpPopperOpen = Boolean(helpPopperAnchorEl)
return {
helpPopperAnchorEl,
helpPopperOpen,
handleOpenHelpPopper,
openHelpPopper,
handleCloseHelpPopper
}
}
const HelpTooltip = memo(({ children, width }) => {
const handler = usePopperHandler(width)
return (
<ClickAwayListener onClickAway={handler.handleCloseHelpPopper}>
<div className="relative" onMouseLeave={handler.handleCloseHelpPopper}>
{handler.helpPopperOpen && (
<div className="absolute bg-transparent h-10 -left-1/2 w-[200%]"></div>
)}
<button
type="button"
className="border-0 bg-transparent outline-0 cursor-pointer mt-1"
onMouseEnter={handler.openHelpPopper}>
<HelpIcon />
</button>
<Popper
open={handler.helpPopperOpen}
anchorEl={handler.helpPopperAnchorEl}
arrowEnabled={true}
placement="bottom">
<div className="py-2 px-4" style={{ width }}>
{children}
</div>
</Popper>
</div>
</ClickAwayListener>
)
})
const HoverableTooltip = memo(({ parentElements, children, width }) => {
const handler = usePopperHandler(width)
return (
<ClickAwayListener onClickAway={handler.handleCloseHelpPopper}>
<div>
{!R.isNil(parentElements) && (
<div
onMouseLeave={handler.handleCloseHelpPopper}
onMouseEnter={handler.handleOpenHelpPopper}>
{parentElements}
</div>
)}
{R.isNil(parentElements) && (
<button
type="button"
onMouseEnter={handler.handleOpenHelpPopper}
onMouseLeave={handler.handleCloseHelpPopper}
className="border-0 bg-transparent outline-0 cursor-pointer mt-1">
<HelpIcon />
</button>
)}
<Popper
open={handler.helpPopperOpen}
anchorEl={handler.helpPopperAnchorEl}
placement="bottom">
<div className="py-2 px-4" style={{ width }}>
{children}
</div>
</Popper>
</div>
</ClickAwayListener>
)
})
export { HoverableTooltip, HelpTooltip }

View file

@ -0,0 +1,123 @@
import IconButton from '@mui/material/IconButton'
import { useFormikContext, Form, Formik, Field as FormikField } from 'formik'
import * as R from 'ramda'
import React, { useState, memo } from 'react'
import PromptWhenDirty from 'src/components/PromptWhenDirty'
import { H4 } from 'src/components/typography'
import EditIconDisabled from 'src/styling/icons/action/edit/disabled.svg?react'
import EditIcon from 'src/styling/icons/action/edit/enabled.svg?react'
import FalseIcon from 'src/styling/icons/table/false.svg?react'
import TrueIcon from 'src/styling/icons/table/true.svg?react'
import * as Yup from 'yup'
import { Link } from 'src/components/buttons'
import { RadioGroup } from 'src/components/inputs/formik'
import { Table, TableBody, TableRow, TableCell } from 'src/components/table'
import SvgIcon from '@mui/material/SvgIcon'
const BooleanCell = ({ name }) => {
const { values } = useFormikContext()
return values[name] === 'true' ? <TrueIcon /> : <FalseIcon />
}
const BooleanPropertiesTable = memo(
({ title, disabled, data, elements, save, forcedEditing = false }) => {
const [editing, setEditing] = useState(forcedEditing)
const initialValues = R.fromPairs(
elements.map(it => [it.name, data[it.name]?.toString() ?? 'false'])
)
const validationSchema = Yup.object().shape(
R.fromPairs(
elements.map(it => [
it.name,
Yup.mixed().oneOf(['true', 'false', true, false]).required()
])
)
)
const innerSave = async values => {
const toBoolean = (num, _) => R.equals(num, 'true')
save(R.mapObjIndexed(toBoolean, R.filter(R.complement(R.isNil))(values)))
setEditing(false)
}
const radioButtonOptions = [
{ display: 'Yes', code: 'true' },
{ display: 'No', code: 'false' }
]
return (
<div className="flex w-sm flex-col ">
<Formik
validateOnBlur={false}
validateOnChange={false}
enableReinitialize
onSubmit={innerSave}
initialValues={initialValues}
validationSchema={validationSchema}>
{({ resetForm }) => {
return (
<Form>
<div className="flex items-center">
<H4>{title}</H4>
{editing ? (
<div className="ml-auto">
<Link type="submit" color="primary">
Save
</Link>
<Link
type="reset"
className="ml-5"
onClick={() => {
resetForm()
setEditing(false)
}}
color="secondary">
Cancel
</Link>
</div>
) : (
<IconButton
className="my-auto mx-3"
onClick={() => setEditing(true)}>
<SvgIcon fontSize="small">
{disabled ? <EditIconDisabled /> : <EditIcon />}
</SvgIcon>
</IconButton>
)}
</div>
<PromptWhenDirty />
<Table className="w-full">
<TableBody className="w-full">
{elements.map((it, idx) => (
<TableRow
key={idx}
size="sm"
className="h-auto py-2 px-4 flex items-center justify-between min-h-8 even:bg-transparent odd:bg-zircon">
<TableCell className="p-0 w-50">{it.display}</TableCell>
<TableCell className="p-0 flex">
{editing && (
<FormikField
component={RadioGroup}
name={it.name}
options={radioButtonOptions}
className="flex flex-row m-[-15px] p-0"
/>
)}
{!editing && <BooleanCell name={it.name} />}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Form>
)
}}
</Formik>
</div>
)
}
)
export default BooleanPropertiesTable

View file

@ -0,0 +1,3 @@
import BooleanPropertiesTable from './BooleanPropertiesTable'
export { BooleanPropertiesTable }

View file

@ -0,0 +1,49 @@
import classnames from 'classnames'
import React, { memo } from 'react'
import moduleStyles from './ActionButton.module.css'
const ActionButton = memo(
({
className,
altTextColor,
Icon,
InverseIcon,
color,
center,
children,
...props
}) => {
const classNames = {
[moduleStyles.actionButton]: true,
[moduleStyles.altText]: altTextColor || color !== 'primary',
[moduleStyles.primary]: color === 'primary',
[moduleStyles.secondary]: color === 'secondary',
[moduleStyles.spring]: color === 'spring',
[moduleStyles.tomato]: color === 'tomato',
[moduleStyles.center]: center
}
return (
<button className={classnames(classNames, className)} {...props}>
{Icon && (
<div className={moduleStyles.actionButtonIcon}>
<Icon />
</div>
)}
{InverseIcon && (
<div
className={classnames(
moduleStyles.actionButtonIcon,
moduleStyles.actionButtonIconActive
)}>
<InverseIcon />
</div>
)}
{children && <div>{children}</div>}
</button>
)
}
)
export default ActionButton

View file

@ -0,0 +1,145 @@
.actionButton {
composes: p from '../typography/typography.module.css';
cursor: pointer;
border: none;
height: 28px;
outline: 0;
border-radius: 6px;
padding: 0 8px;
display: flex;
align-items: center;
}
.actionButton.altText {
color: white;
}
.primary {
background-color: var(--zircon);
}
.primary:hover {
background-color: var(--zircon2);
}
.primary:active {
background-color: var(--comet);
color: white;
}
.primary .actionButtonIconActive {
display: none;
}
.primary:active .actionButtonIcon {
display: none;
}
.primary:active .actionButtonIconActive {
display: flex;
}
.secondary {
background-color: var(--comet);
color: white;
}
.secondary:hover {
background-color: var(--comet2);
}
.secondary:active {
background-color: var(--comet3);
}
.secondary .actionButtonIcon {
display: none;
}
.secondary .actionButtonIconActive {
display: flex;
}
.secondary:active .actionButtonIcon {
display: flex;
}
.secondary:active .actionButtonIconActive {
display: none;
}
.spring {
background-color: var(--spring2);
color: white;
}
.spring:hover {
background-color: var(--spring);
}
.spring:active {
background-color: var(--spring4);
}
.spring .actionButtonIcon {
display: none;
}
.spring .actionButtonIconActive {
display: flex;
}
.spring:active .actionButtonIcon {
display: flex;
}
.spring:active .actionButtonIconActive {
display: none;
}
.tomato {
background-color: var(--tomato);
color: white;
}
.tomato:hover {
background-color: var(--tomato);
}
.tomato:active {
background-color: var(--tomato);
}
.tomato .actionButtonIcon {
display: none;
}
.tomato .actionButtonIconActive {
display: flex;
}
.tomato:active .actionButtonIcon {
display: flex;
}
.tomato:active .actionButtonIconActive {
display: none;
}
.actionButtonIcon {
display: flex;
padding-right: 7px;
}
.actionButtonIcon svg {
width: 14px;
height: 14px;
}
.center {
align-items: center;
justify-content: center;
}
.actionButtonIconActive {
}

View file

@ -0,0 +1,16 @@
import classnames from 'classnames'
import React, { memo } from 'react'
import AddIcon from 'src/styling/icons/button/add/zodiac.svg?react'
import classes from './AddButton.module.css'
const SimpleButton = memo(({ className, children, ...props }) => {
return (
<button className={classnames(classes.button, className)} {...props}>
<AddIcon />
{children}
</button>
)
})
export default SimpleButton

View file

@ -0,0 +1,30 @@
.button {
composes: p from '../typography/typography.module.css';
border: none;
background-color: var(--zircon);
cursor: pointer;
outline: 0;
display: flex;
justify-content: center;
align-items: center;
width: 167px;
height: 48px;
color: var(--zodiac);
}
.button:hover {
background-color: var(--zircon2);
}
.button:active {
background-color: var(--comet);
color: white;
}
.button:active svg g * {
stroke: white;
}
.button svg {
margin-right: 8px;
}

View file

@ -0,0 +1,70 @@
import {
white,
fontColor,
subheaderColor,
subheaderDarkColor,
offColor,
offDarkColor
} from 'src/styling/variables'
const colors = (color1, color2, color3) => {
return {
backgroundColor: color1,
'&:hover': {
backgroundColor: color2
},
'&:active': {
backgroundColor: color3
}
}
}
const buttonHeight = 32
export default {
baseButton: {
extend: colors(subheaderColor, subheaderDarkColor, offColor),
cursor: 'pointer',
border: 'none',
outline: 0,
height: buttonHeight,
color: fontColor,
'&:active': {
color: white
}
},
primary: {
extend: colors(subheaderColor, subheaderDarkColor, offColor),
'&:active': {
color: white,
'& $buttonIcon': {
display: 'none'
},
'& $buttonIconActive': {
display: 'block'
}
},
'& $buttonIconActive': {
display: 'none'
}
},
secondary: {
extend: colors(offColor, offDarkColor, white),
color: white,
'&:active': {
color: fontColor,
'& $buttonIcon': {
display: 'flex'
},
'& $buttonIconActive': {
display: 'none'
}
},
'& $buttonIcon': {
display: 'none'
},
'& $buttonIconActive': {
display: 'flex'
}
}
}

View file

@ -0,0 +1,43 @@
import classnames from 'classnames'
import React, { memo } from 'react'
import moduleStyles from './Button.module.css'
import { spacer } from '../../styling/variables.js'
const pickSize = size => {
switch (size) {
case 'xl':
return spacer * 7.625
case 'sm':
return spacer * 4
case 'lg':
default:
return spacer * 5
}
}
const ActionButton = memo(
({ size = 'lg', children, className, buttonClassName, ...props }) => {
const height = pickSize(size)
return (
<div className={className} style={{ height: height + height / 12 }}>
<button
className={classnames(
buttonClassName,
moduleStyles.button,
'text-white',
{
[moduleStyles.buttonSm]: size === 'sm',
[moduleStyles.buttonXl]: size === 'xl'
}
)}
{...props}>
{children}
</button>
</div>
)
}
)
export default ActionButton

View file

@ -0,0 +1,49 @@
.button {
composes: h3 from '../typography/typography.module.css';
border: none;
cursor: pointer;
outline: 0;
font-weight: 900;
background-color: var(--spring);
height: 40px;
padding: 0 20px;
border-radius: 10px;
box-shadow: 0 3px var(--spring4);
}
.buttonXl {
composes: h1 from '../typography/typography.module.css';
height: 61px;
border-radius: 15px
}
.buttonSm {
height: 32px;
padding: 0 16px;
border-radius: 8px
}
.button:disabled {
background-color: var(--dust);
box-shadow: none;
}
.button:disabled:hover {
background-color: var(--dust);
box-shadow: none;
}
.button:disabled:active {
margin-top: 0;
}
.button:hover {
background-color: var(--spring2);
box-shadow: 0 3px var(--spring4);
}
.button:active {
margin-top: 2px;
background-color: var(--spring2);
box-shadow: 0 2px var(--spring4);
}

View file

@ -0,0 +1,37 @@
import classnames from 'classnames'
import React, { memo } from 'react'
import classes from './FeatureButton.module.css'
const FeatureButton = memo(
({ className, Icon, InverseIcon, children, ...props }) => {
return (
<button
className={classnames(
classes.baseButton,
classes.roundButton,
classes.primary,
className
)}
{...props}>
{Icon && (
<div className={classes.buttonIcon}>
<Icon />
</div>
)}
{InverseIcon && (
<div
className={classnames(
classes.buttonIcon,
classes.buttonIconActive
)}>
<InverseIcon />
</div>
)}
{children}
</button>
)
}
)
export default FeatureButton

View file

@ -0,0 +1,87 @@
.baseButton {
cursor: pointer;
border: none;
outline: 0;
height: 32px;
color: var(--zodiac);
}
.roundButton {
width: 32px;
border-radius: 16px;
display: flex;
padding: 0;
}
.roundButton .buttonIcon {
margin: auto;
}
.roundButton .buttonIcon svg {
width: 16px;
height: 16px;
overflow: visible;
}
.roundButton .buttonIcon svg g {
stroke-width: 2px;
}
.baseButton:active {
color: white;
}
.primary {
background-color: var(--zircon);
}
.primary:hover {
background-color: var(--zircon2);
}
.primary:active {
background-color: var(--comet);
color: white;
}
.primary .buttonIconActive {
display: none;
}
.primary:active .buttonIcon {
display: none;
}
.primary:active .buttonIconActive {
display: block;
}
.secondary {
background-color: var(--comet);
color: white;
}
.secondary:hover {
background-color: var(--comet2);
}
.secondary:active {
background-color: white;
color: var(--zodiac);
}
.secondary .buttonIcon {
display: none;
}
.secondary .buttonIconActive {
display: flex;
}
.secondary:active .buttonIcon {
display: flex;
}
.secondary:active .buttonIconActive {
display: none;
}

View file

@ -0,0 +1,80 @@
import ClickAwayListener from '@mui/material/ClickAwayListener'
import classnames from 'classnames'
import React, { useState, memo } from 'react'
import Popover from 'src/components/Popper'
import classes from './IDButton.module.css'
const IDButton = memo(
({
name,
className,
Icon,
InverseIcon,
popoverWidth = 152,
children,
popoverClassname,
...props
}) => {
const [anchorEl, setAnchorEl] = useState(null)
const open = Boolean(anchorEl)
const id = open ? `simple-popper-${name}` : undefined
const classNames = {
[classes.idButton]: true,
[classes.primary]: true,
[classes.open]: open,
[classes.closed]: !open
}
const iconClassNames = {
[classes.buttonIcon]: true
}
const handleClick = event => {
setAnchorEl(anchorEl ? null : event.currentTarget)
}
const handleClose = () => {
setAnchorEl(null)
}
return (
<>
<ClickAwayListener onClickAway={handleClose}>
<button
aria-describedby={id}
onClick={handleClick}
className={classnames(classNames, className)}
{...props}>
{Icon && !open && (
<div className={classnames(iconClassNames)}>
<Icon />
</div>
)}
{InverseIcon && open && (
<div className={classnames(iconClassNames)}>
<InverseIcon />
</div>
)}
</button>
</ClickAwayListener>
<Popover
className={popoverClassname}
id={id}
open={open}
anchorEl={anchorEl}
onClose={handleClose}
placement="top"
flip>
<div className={classes.popoverContent}>
<div>{children}</div>
</div>
</Popover>
</>
)
}
)
export default IDButton

View file

@ -0,0 +1,58 @@
.idButton {
width: 34px;
height: 28px;
display: flex;
border-radius: 4px;
padding: 0;
border: none;
cursor: pointer;
}
.buttonIcon {
margin: auto;
line-height: 1px;
}
.buttonIcon svg {
overflow: visible;
}
.closed {
background-color: var(--zircon);
}
.closed:hover {
background-color: var(--zircon2);
}
.closed:active {
background-color: var(--comet);
color: white;
}
.open {
background-color: var(--comet);
color: white;
}
.open:hover {
background-color: var(--comet2);
}
.open:active {
background-color: var(--comet3);
}
.popoverContent {
composes: info2 from '../typography/typography.module.css';
padding: 8px;
display: flex;
justify-content: center;
align-items: center;
border-radius: 4px;
}
.popoverContent img {
height: 145px;
min-width: 200px;
}

View file

@ -0,0 +1,27 @@
import classnames from 'classnames'
import React, { memo } from 'react'
import classes from './Link.module.css'
const Link = memo(
({ submit, className, children, color = 'primary', ...props }) => {
const classNames = {
[classes.link]: true,
[classes.primary]: color === 'primary',
[classes.secondary]: color === 'secondary',
[classes.noColor]: color === 'noColor',
[classes.action]: color === 'action'
}
return (
<button
type={submit ? 'submit' : 'button'}
className={classnames(classNames, className)}
{...props}>
{children}
</button>
)
}
)
export default Link

View file

@ -0,0 +1,47 @@
.link {
composes: h4 from '../typography/typography.module.css';
text-decoration: none;
border: none;
background-color: transparent;
cursor: pointer;
padding: 0;
height: 100%;
}
.primary {
box-shadow: inset 0 -4px 0 0 rgba(72, 246, 148, 0.8);
}
.primary:hover {
box-shadow: none;
background-color: rgba(72, 246, 148, 0.8);
}
.secondary {
box-shadow: inset 0 -4px 0 0 rgba(255, 88, 74, 0.8);
}
.secondary:hover {
box-shadow: none;
background-color: rgba(255, 88, 74, 0.8);
color: white;
}
.noColor {
box-shadow: inset 0 -4px 0 0 rgba(255, 255, 255, 0.8);
}
.noColor:hover {
box-shadow: none;
background-color: rgba(255, 255, 255, 0.8);
}
.action {
box-shadow: inset 0 -4px 0 0 rgba(72, 246, 148, 0.8);
color: var(--zircon);
}
.action:hover {
box-shadow: none;
background-color: rgba(72, 246, 148, 0.8);
}

View file

@ -0,0 +1,62 @@
import classnames from 'classnames'
import React, { memo, useState } from 'react'
import { H4 } from 'src/components/typography'
import CancelIconInverse from 'src/styling/icons/button/cancel/white.svg?react'
import classes from './SubpageButton.module.css'
const SubpageButton = memo(
({
className,
Icon,
InverseIcon,
toggle,
forceDisable = false,
children
}) => {
const [active, setActive] = useState(false)
const isActive = forceDisable ? false : active
const classNames = {
[classes.button]: true,
[classes.normal]: !isActive,
[classes.active]: isActive
}
const normalButton = <Icon className={classes.buttonIcon} />
const activeButton = (
<>
<InverseIcon
className={classnames(
classes.buttonIcon,
classes.buttonIconActiveLeft
)}
/>
<H4 className="text-white">{children}</H4>
<CancelIconInverse
className={classnames(
classes.buttonIcon,
classes.buttonIconActiveRight
)}
/>
</>
)
const innerToggle = () => {
forceDisable = false
const newActiveState = !isActive
toggle(newActiveState)
setActive(newActiveState)
}
return (
<button
className={classnames(classNames, className)}
onClick={innerToggle}>
{isActive ? activeButton : normalButton}
</button>
)
}
)
export default SubpageButton

View file

@ -0,0 +1,51 @@
.button {
cursor: pointer;
border: none;
outline: 0;
height: 32px;
padding: 0;
color: white;
border-radius: 16px;
background-color: var(--zircon);
}
.button:hover {
background-color: var(--zircon2);
}
.normal {
width: 32px;
}
.active {
display: flex;
flex-direction: row;
align-items: center;
background-color: var(--comet);
font-weight: bold;
padding: 0 5px;
}
.active:hover {
background-color: var(--comet);
}
.buttonIcon {
width: 16px;
height: 16px;
overflow: visible;
}
.buttonIcon g {
stroke-width: 1.8px;
}
.buttonIconActiveLeft {
margin-right: 12px;
margin-left: 4px;
}
.buttonIconActiveRight {
margin-right: 5px;
margin-left: 20px;
}

View file

@ -0,0 +1,46 @@
import baseButtonStyles from 'src/components/buttons/BaseButton.styles'
import { offColor, white } from 'src/styling/variables'
const { baseButton } = baseButtonStyles
export default {
button: {
extend: baseButton,
padding: 0,
color: white,
borderRadius: baseButton.height / 2
},
normalButton: {
width: baseButton.height
},
activeButton: {
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
backgroundColor: offColor,
fontWeight: 'bold',
padding: '0 5px',
'&:hover': {
backgroundColor: offColor
}
},
buttonIcon: {
width: 16,
height: 16,
overflow: 'visible',
'& g': {
strokeWidth: 1.8
}
},
buttonIconActiveLeft: {
marginRight: 12,
marginLeft: 4
},
buttonIconActiveRight: {
marginRight: 5,
marginLeft: 20
},
white: {
color: white
}
}

View file

@ -0,0 +1,25 @@
import React from 'react'
import InverseLinkIcon from 'src/styling/icons/action/external link/white.svg?react'
import LinkIcon from 'src/styling/icons/action/external link/zodiac.svg?react'
import { ActionButton } from 'src/components/buttons'
const SupportLinkButton = ({ link, label }) => {
return (
<a
className="no-underline text-zodiac"
target="_blank"
rel="noopener noreferrer"
href={link}>
<ActionButton
className="mb-8"
color="primary"
Icon={LinkIcon}
InverseIcon={InverseLinkIcon}>
{label}
</ActionButton>
</a>
)
}
export default SupportLinkButton

View file

@ -0,0 +1,19 @@
import ActionButton from './ActionButton'
import AddButton from './AddButton'
import Button from './Button'
import FeatureButton from './FeatureButton'
import IDButton from './IDButton'
import Link from './Link'
import SubpageButton from './SubpageButton'
import SupportLinkButton from './SupportLinkButton'
export {
Button,
Link,
ActionButton,
FeatureButton,
IDButton,
AddButton,
SupportLinkButton,
SubpageButton
}

View file

@ -0,0 +1,138 @@
import {
add,
differenceInMonths,
format,
getDay,
getDaysInMonth,
isAfter,
isSameDay,
isSameMonth,
lastDayOfMonth,
startOfMonth,
startOfWeek,
sub
} from 'date-fns/fp'
import * as R from 'ramda'
import React, { useState } from 'react'
import Arrow from 'src/styling/icons/arrow/month_change.svg?react'
import RightArrow from 'src/styling/icons/arrow/month_change_right.svg?react'
import Tile from './Tile'
import classes from './Calendar.module.css'
const Calendar = ({ minDate, maxDate, handleSelect, ...props }) => {
const [currentDisplayedMonth, setCurrentDisplayedMonth] = useState(new Date())
const weekdays = Array.from(Array(7)).map((_, i) =>
format('EEEEE', add({ days: i }, startOfWeek(new Date())))
)
const monthLength = month => getDaysInMonth(month)
const monthdays = month => {
const lastMonth = sub({ months: 1 }, month)
const lastMonthRange = R.range(0, getDay(startOfMonth(month))).reverse()
const lastMonthDays = R.map(i =>
sub({ days: i }, lastDayOfMonth(lastMonth))
)(lastMonthRange)
const thisMonthRange = R.range(0, monthLength(month))
const thisMonthDays = R.map(i => add({ days: i }, startOfMonth(month)))(
thisMonthRange
)
const nextMonth = add({ months: 1 }, month)
const nextMonthRange = R.range(
0,
42 - lastMonthDays.length - thisMonthDays.length
)
const nextMonthDays = R.map(i => add({ days: i }, startOfMonth(nextMonth)))(
nextMonthRange
)
return R.concat(R.concat(lastMonthDays, thisMonthDays), nextMonthDays)
}
const getRow = (month, row) => monthdays(month).slice(row * 7 - 7, row * 7)
const handleNavPrev = currentMonth => {
const prevMonth = sub({ months: 1 }, currentMonth)
if (!minDate) setCurrentDisplayedMonth(prevMonth)
else {
setCurrentDisplayedMonth(
isSameMonth(minDate, prevMonth) ||
differenceInMonths(minDate, prevMonth) > 0
? prevMonth
: currentDisplayedMonth
)
}
}
const handleNavNext = currentMonth => {
const nextMonth = add({ months: 1 }, currentMonth)
if (!maxDate) setCurrentDisplayedMonth(nextMonth)
else {
setCurrentDisplayedMonth(
isSameMonth(maxDate, nextMonth) ||
differenceInMonths(nextMonth, maxDate) > 0
? nextMonth
: currentDisplayedMonth
)
}
}
return (
<div className={classes.wrapper}>
<div className={classes.navbar}>
<button
className={classes.button}
onClick={() => handleNavPrev(currentDisplayedMonth)}>
<Arrow />
</button>
<span>
{`${format('MMMM', currentDisplayedMonth)} ${format(
'yyyy',
currentDisplayedMonth
)}`}
</span>
<button
className={classes.button}
onClick={() => handleNavNext(currentDisplayedMonth)}>
<RightArrow />
</button>
</div>
<table className={classes.table}>
<thead>
<tr>
{weekdays.map((day, key) => (
<th key={key}>{day}</th>
))}
</tr>
</thead>
<tbody>
{R.range(1, 8).map((row, key) => (
<tr key={key}>
{getRow(currentDisplayedMonth, row).map((day, key) => (
<td key={key} onClick={() => handleSelect(day)}>
<Tile
isDisabled={
(maxDate && isAfter(maxDate, day)) ||
(minDate && isAfter(day, minDate))
}
isLowerBound={isSameDay(props.from, day)}
isUpperBound={isSameDay(props.to, day)}
isBetween={
isAfter(props.from, day) && isAfter(day, props.to)
}>
{format('d', day)}
</Tile>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)
}
export default Calendar

View file

@ -0,0 +1,66 @@
.wrapper {
display: flex;
flex-direction: column;
align-items: center;
}
.button {
outline: none;
}
.navbar {
font-size: 14px;
font-family: var(--museo);
font-weight: 500;
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
padding: 15px 15px;
color: var(--zodiac);
}
.navbar button {
display: flex;
align-items: center;
padding: 0;
border: none;
background-color: var(--zircon);
cursor: pointer;
border-radius: 50%;
width: 20px;
height: 20px;
position: relative;
overflow: hidden;
}
.navbar button svg {
position: absolute;
left: 0;
}
.table {
border-collapse: collapse;
width: 100%;
color: var(--zodiac);
}
.table tr:first-child {
padding-left: 5px;
}
.table tr:last-child {
padding-right: 5px;
}
.table th,
.table td {
margin: 0;
padding: 3px 0 3px 0;
}
.table th {
font-size: 13px;
font-family: var(--museo);
font-weight: 700;
}

View file

@ -0,0 +1,59 @@
import classnames from 'classnames'
import { compareAsc, differenceInDays, set } from 'date-fns/fp'
import * as R from 'ramda'
import React, { useState, useEffect } from 'react'
import Calendar from './Calendar'
const DateRangePicker = ({ minDate, maxDate, className, onRangeChange }) => {
const [from, setFrom] = useState(null)
const [to, setTo] = useState(null)
useEffect(() => {
onRangeChange(from, to)
}, [from, onRangeChange, to])
const handleSelect = day => {
if (
(maxDate && compareAsc(maxDate, day) > 0) ||
(minDate && differenceInDays(day, minDate) > 0)
)
return
if (from && !to) {
if (differenceInDays(from, day) >= 0) {
setTo(
set({ hours: 23, minutes: 59, seconds: 59, milliseconds: 999 }, day)
)
} else {
setTo(
set(
{ hours: 23, minutes: 59, seconds: 59, milliseconds: 999 },
R.clone(from)
)
)
setFrom(day)
}
return
}
setFrom(day)
setTo(null)
}
return (
<>
<div className={classnames('bg-white rounded-xl', className)}>
<Calendar
from={from}
to={to}
minDate={minDate}
maxDate={maxDate}
handleSelect={handleSelect}
/>
</div>
</>
)
}
export default DateRangePicker

View file

@ -0,0 +1,41 @@
import classnames from 'classnames'
import React from 'react'
import classes from './Tile.module.css'
const Tile = ({
isLowerBound,
isUpperBound,
isBetween,
isDisabled,
children
}) => {
const selected = isLowerBound || isUpperBound
const rangeClasses = {
[classes.between]: isBetween && !(isLowerBound && isUpperBound),
[classes.lowerBound]: isLowerBound && !isUpperBound,
[classes.upperBound]: isUpperBound && !isLowerBound
}
const buttonWrapperClasses = {
[classes.wrapper]: true,
[classes.selected]: selected
}
const buttonClasses = {
[classes.button]: true,
[classes.disabled]: isDisabled
}
return (
<div className={classes.wrapper}>
<div className={classnames(rangeClasses)} />
<div className={classnames(buttonWrapperClasses)}>
<button className={classnames(buttonClasses)}>{children}</button>
</div>
</div>
)
}
export default Tile

View file

@ -0,0 +1,53 @@
.wrapper {
height: 26px;
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
}
.button {
outline: none;
font-size: 13px;
font-family: var(--museo);
font-weight: 500;
border: none;
cursor: pointer;
background-color: transparent;
color: var(--zodiac);
z-index: 2;
}
.lowerBound {
left: 50%;
}
.upperBound {
right: 50%;
}
.selected {
width: 26px;
height: 26px;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--spring2);
border-radius: 50%;
position: absolute;
z-index: 1;
}
.between {
position: absolute;
width: 100%;
height: 100%;
z-index: 0;
background-color: var(--spring3);
}
.disabled {
color: var(--dust);
cursor: default;
}

View file

@ -0,0 +1,3 @@
import React from 'react'
export default React.createContext()

View file

@ -0,0 +1,129 @@
import classnames from 'classnames'
import * as R from 'ramda'
import React, { useContext } from 'react'
import {
Td,
THead,
TDoubleLevelHead,
ThDoubleLevel
} from 'src/components/fake-table/Table'
import { sentenceCase } from 'src/utils/string'
import TableCtx from './Context'
const groupSecondHeader = elements => {
const doubleHeader = R.prop('doubleHeader')
const sameDoubleHeader = (a, b) => doubleHeader(a) === doubleHeader(b)
const group = R.pipe(
R.groupWith(sameDoubleHeader),
R.map(group =>
R.isNil(doubleHeader(group[0])) // No doubleHeader
? group
: [
{
width: R.sum(R.map(R.prop('width'), group)),
elements: group,
name: doubleHeader(group[0])
}
]
),
R.reduce(R.concat, [])
)
return R.all(R.pipe(doubleHeader, R.isNil), elements)
? [elements, THead]
: [group(elements), TDoubleLevelHead]
}
const Header = () => {
const {
elements,
enableEdit,
enableEditText,
editWidth,
enableDelete,
deleteWidth,
enableToggle,
toggleWidth,
orderedBy,
DEFAULT_COL_SIZE
} = useContext(TableCtx)
const mapElement2 = (it, idx) => {
const { width, elements, name } = it
if (elements && elements.length) {
return (
<ThDoubleLevel key={idx} width={width} title={name}>
{elements.map(mapElement)}
</ThDoubleLevel>
)
}
return mapElement(it, idx)
}
const mapElement = (
{ name, display, width = DEFAULT_COL_SIZE, header, textAlign },
idx
) => {
const orderClasses = classnames({
'whitespace-nowrap':
R.isNil(header) && !R.isNil(orderedBy) && R.equals(name, orderedBy.code)
})
const attachOrderedByToComplexHeader = header => {
if (!R.isNil(orderedBy) && R.equals(name, orderedBy.code)) {
try {
const cloneHeader = R.clone(header)
const children = R.path(['props', 'children'], cloneHeader)
const spanChild = R.find(it => R.equals(it.type, 'span'), children)
spanChild.props.children = R.append(' -', spanChild.props.children)
return cloneHeader
} catch (e) {
return header
}
}
return header
}
return (
<Td header key={idx} width={width} textAlign={textAlign}>
{!R.isNil(header) ? (
<>{attachOrderedByToComplexHeader(header) ?? header}</>
) : (
<span className={orderClasses}>
{!R.isNil(display) ? display : sentenceCase(name)}{' '}
{!R.isNil(orderedBy) && R.equals(name, orderedBy.code) && '-'}
</span>
)}
</Td>
)
}
const [innerElements, HeaderElement] = groupSecondHeader(elements)
return (
<HeaderElement>
{innerElements.map(mapElement2)}
{enableEdit && (
<Td header width={editWidth} textAlign="center">
{enableEditText ?? `Edit`}
</Td>
)}
{enableDelete && (
<Td header width={deleteWidth} textAlign="center">
Delete
</Td>
)}
{enableToggle && (
<Td header width={toggleWidth} textAlign="center">
Enable
</Td>
)}
</HeaderElement>
)
}
export default Header

View file

@ -0,0 +1,29 @@
import * as R from 'ramda'
import React from 'react'
import { fromNamespace, toNamespace } from 'src/utils/config'
import EditableTable from './Table'
const NamespacedTable = ({
name,
save,
data = {},
namespaces = [],
...props
}) => {
const innerSave = (...[, it]) => {
return save(toNamespace(it.id)(R.omit(['id2'], it)))
}
const innerData = R.map(it => ({
id: it,
...fromNamespace(it)(data)
}))(namespaces)
return (
<EditableTable name={name} data={innerData} save={innerSave} {...props} />
)
}
export default NamespacedTable

View file

@ -0,0 +1,299 @@
import Switch from '@mui/material/Switch'
import IconButton from '@mui/material/IconButton'
import SvgIcon from '@mui/material/SvgIcon'
import classnames from 'classnames'
import { Field, useFormikContext } from 'formik'
import * as R from 'ramda'
import React, { useContext, useState } from 'react'
import { DeleteDialog } from 'src/components/DeleteDialog'
import { Td, Tr } from 'src/components/fake-table/Table'
import { Label2 } from 'src/components/typography'
import DisabledDeleteIcon from 'src/styling/icons/action/delete/disabled.svg?react'
import DeleteIcon from 'src/styling/icons/action/delete/enabled.svg?react'
import DisabledEditIcon from 'src/styling/icons/action/edit/disabled.svg?react'
import EditIcon from 'src/styling/icons/action/edit/enabled.svg?react'
import StripesSvg from 'src/styling/icons/stripes.svg?react'
import { Link } from 'src/components/buttons'
import TableCtx from './Context'
import moduleStyles from './Row.module.css'
const ActionCol = ({ disabled, editing }) => {
const { values, submitForm, resetForm } = useFormikContext()
const {
editWidth,
onEdit,
enableEdit,
enableDelete,
disableRowEdit,
onDelete,
deleteWidth,
enableToggle,
onToggle,
toggleWidth,
forceAdd,
clearError,
actionColSize,
error
} = useContext(TableCtx)
const disableEdit = disabled || (disableRowEdit && disableRowEdit(values))
const cancel = () => {
clearError()
resetForm()
}
const [deleteDialog, setDeleteDialog] = useState(false)
const onConfirmed = () => {
onDelete(values.id).then(res => {
if (!R.isNil(res)) setDeleteDialog(false)
})
}
return (
<>
{editing && (
<Td textAlign="center" width={actionColSize}>
<Link
className={moduleStyles.saveButton}
type="submit"
color="primary"
onClick={submitForm}>
Save
</Link>
{!forceAdd && (
<Link color="secondary" onClick={cancel}>
Cancel
</Link>
)}
</Td>
)}
{!editing && enableEdit && (
<Td textAlign="center" width={editWidth}>
<IconButton
disabled={disableEdit}
onClick={() => onEdit && onEdit(values.id)}
size="small">
<SvgIcon>
{disableEdit ? <DisabledEditIcon /> : <EditIcon />}
</SvgIcon>
</IconButton>
</Td>
)}
{!editing && enableDelete && (
<Td textAlign="center" width={deleteWidth}>
<IconButton
disabled={disabled}
onClick={() => {
setDeleteDialog(true)
}}
size="small">
<SvgIcon>
{disabled ? <DisabledDeleteIcon /> : <DeleteIcon />}
</SvgIcon>
</IconButton>
<DeleteDialog
open={deleteDialog}
setDeleteDialog={setDeleteDialog}
onConfirmed={onConfirmed}
onDismissed={() => {
setDeleteDialog(false)
clearError()
}}
errorMessage={error}
/>
</Td>
)}
{!editing && enableToggle && (
<Td textAlign="center" width={toggleWidth}>
<Switch
checked={!!values.active}
value={!!values.active}
disabled={disabled}
onChange={() => onToggle(values.id)}
/>
</Td>
)}
</>
)
}
const ECol = ({ editing, focus, config, extraPaddingRight, extraPadding }) => {
const {
name,
names,
bypassField,
input,
editable = true,
size,
bold,
width,
textAlign,
editingAlign = textAlign,
prefix,
PrefixComponent = Label2,
suffix,
SuffixComponent = Label2,
textStyle = it => {},
isHidden = it => false,
view = it => it?.toString(),
inputProps = {}
} = config
const fields = names ?? [name]
const { values } = useFormikContext()
const isEditable = editable => {
if (typeof editable === 'function') return editable(values)
return editable
}
const isEditing = editing && isEditable(editable)
const isField = !bypassField
const innerProps = {
fullWidth: true,
autoFocus: focus,
size,
bold,
textAlign: isEditing ? editingAlign : textAlign,
...inputProps
}
const newAlign = isEditing ? editingAlign : textAlign
const justifyContent = newAlign === 'right' ? 'flex-end' : newAlign
const style = suffix || prefix ? { justifyContent } : {}
return (
<div className={moduleStyles.fields}>
{fields.map((f, idx) => (
<Td
style={style}
key={idx}
className={{
[moduleStyles.extraPaddingRight]: extraPaddingRight,
[moduleStyles.extraPadding]: extraPadding,
'flex items-center': suffix || prefix
}}
width={width}
size={size}
bold={bold}
textAlign={textAlign}>
{prefix && !isHidden(values) && (
<PrefixComponent
className={moduleStyles.prefix}
style={isEditing ? {} : textStyle(values, isEditing)}>
{typeof prefix === 'function' ? prefix(f) : prefix}
</PrefixComponent>
)}
{isEditing && isField && !isHidden(values) && (
<Field name={f} component={input} {...innerProps} />
)}
{isEditing && !isField && !isHidden(values) && (
<config.input name={f} />
)}
{!isEditing && values && !isHidden(values) && (
<div style={textStyle(values, isEditing)}>
{view(values[f], values)}
</div>
)}
{suffix && !isHidden(values) && (
<SuffixComponent
className={moduleStyles.suffix}
style={isEditing ? {} : textStyle(values, isEditing)}>
{suffix}
</SuffixComponent>
)}
{isHidden(values) && <StripesSvg />}
</Td>
))}
</div>
)
}
const groupStriped = elements => {
const [toStripe, noStripe] = R.partition(R.propEq('stripe', true))(elements)
if (!toStripe.length) {
return elements
}
const index = R.indexOf(toStripe[0], elements)
const width = R.compose(R.sum, R.map(R.path(['width'])))(toStripe)
return R.insert(
index,
{ width, editable: false, view: () => <StripesSvg /> },
noStripe
)
}
const ERow = ({ editing, disabled, lastOfGroup, newRow }) => {
const { touched, errors, values } = useFormikContext()
const {
elements,
enableEdit,
enableDelete,
error,
enableToggle,
rowSize,
stripeWhen
} = useContext(TableCtx)
const shouldStripe = !editing && stripeWhen && stripeWhen(values)
const innerElements = shouldStripe ? groupStriped(elements) : elements
const [toSHeader] = R.partition(R.has('doubleHeader'))(elements)
const extraPaddingIndex = toSHeader?.length
? R.indexOf(toSHeader[0], elements)
: -1
const extraPaddingRightIndex = toSHeader?.length
? R.indexOf(toSHeader[toSHeader.length - 1], elements)
: -1
const elementToFocusIndex = innerElements.findIndex(
it => it.editable === undefined || it.editable
)
const classNames = {
[moduleStyles.lastOfGroup]: lastOfGroup
}
const touchedErrors = R.pick(R.keys(touched), errors)
const hasTouchedErrors = touchedErrors && R.keys(touchedErrors).length > 0
const hasErrors = hasTouchedErrors || !!error
const errorMessage =
error || (touchedErrors && R.values(touchedErrors).join(', '))
return (
<Tr
className={classnames(classNames)}
size={rowSize}
error={editing && hasErrors}
newRow={newRow && !hasErrors}
shouldShowError
errorMessage={errorMessage}>
{innerElements.map((it, idx) => {
return (
<ECol
key={idx}
config={it}
editing={editing}
focus={idx === elementToFocusIndex && editing}
extraPaddingRight={extraPaddingRightIndex === idx}
extraPadding={extraPaddingIndex === idx}
/>
)
})}
{(enableEdit || enableDelete || enableToggle) && (
<ActionCol disabled={disabled} editing={editing} />
)}
</Tr>
)
}
export default ERow

View file

@ -0,0 +1,29 @@
.saveButton {
margin-right: 20px;
}
.lastOfGroup {
margin-bottom: 24px;
}
.extraPadding {
padding-left: 35px;
padding-right: 30px;
}
.extraPaddingRight {
padding-right: 39px;
}
.suffix {
margin: 0 0 0 7px;
}
.prefix {
margin: 0 7px 0 0;
}
.fields {
display: flex;
flex-direction: column;
}

View file

@ -0,0 +1,249 @@
import { Form, Formik } from 'formik'
import * as R from 'ramda'
import React, { useState, useEffect } from 'react'
import PromptWhenDirty from 'src/components/PromptWhenDirty'
import Link from 'src/components/buttons/Link'
import { TBody, Table } from 'src/components/fake-table/Table'
import { Info2, TL1 } from 'src/components/typography'
import { v4 as uuidv4 } from 'uuid'
import { AddButton } from 'src/components/buttons/index'
import TableCtx from './Context'
import Header from './Header'
import ERow from './Row'
import classes from './Table.module.css'
const ACTION_COL_SIZE = 87
const DEFAULT_COL_SIZE = 100
const getWidth = R.compose(
R.reduce(R.add)(0),
R.map(it => it.width ?? DEFAULT_COL_SIZE)
)
const ETable = ({
name,
title,
titleLg,
elements = [],
data = [],
save,
error: externalError,
rowSize = 'md',
validationSchema,
enableCreate,
enableEdit,
enableEditText,
editWidth: outerEditWidth,
enableDelete,
deleteWidth = ACTION_COL_SIZE,
enableToggle,
toggleWidth = ACTION_COL_SIZE,
onToggle,
forceDisable,
disableAdd,
initialValues,
setEditing,
shouldOverrideEdit,
editOverride,
stripeWhen,
disableRowEdit,
groupBy,
sortBy,
createText = 'Add override',
forceAdd = false,
tbodyWrapperClass,
orderedBy = null
}) => {
const [editingId, setEditingId] = useState(null)
const [adding, setAdding] = useState(false)
const [saving, setSaving] = useState(false)
const [error, setError] = useState(null)
useEffect(() => setError(externalError), [externalError])
useEffect(() => {
setError(null)
setAdding(forceAdd)
}, [forceAdd])
const innerSave = async value => {
if (saving) return
setSaving(true)
const it = validationSchema.cast(value, { assert: 'ignore-optionality' })
const index = R.findIndex(R.propEq('id', it.id))(data)
const list = index !== -1 ? R.update(index, it, data) : R.prepend(it, data)
if (!R.equals(data[index], it)) {
try {
await save({ [name]: list }, it)
} catch (err) {
setSaving(false)
return
}
}
setAdding(false)
setEditing && setEditing(false)
setSaving(false)
}
const onDelete = id => {
const list = R.reject(it => it.id === id, data)
return save({ [name]: list })
}
const onReset = () => {
setAdding(false)
setEditingId(null)
setEditing && setEditing(false)
}
const onEdit = it => {
if (shouldOverrideEdit && shouldOverrideEdit(it)) return editOverride(it)
setEditingId(it)
setError(null)
setEditing && setEditing(it, true)
}
const addField = () => {
setAdding(true)
setError(null)
setEditing && setEditing(true, true)
}
const widthIfEditNull =
enableDelete || enableToggle ? ACTION_COL_SIZE : ACTION_COL_SIZE * 2
const editWidth = R.defaultTo(widthIfEditNull)(outerEditWidth)
const actionColSize =
((enableDelete && deleteWidth) ?? 0) +
((enableEdit && editWidth) ?? 0) +
((enableToggle && toggleWidth) ?? 0)
const width = getWidth(elements) + actionColSize
const showButtonOnEmpty = !data.length && enableCreate && !adding
const canAdd = !forceDisable && !editingId && !disableAdd && !adding
const showTable = adding || data.length !== 0
const innerData = sortBy ? R.sortWith(sortBy)(data) : data
const ctxValue = {
elements,
enableEdit,
enableEditText,
onEdit,
clearError: () => setError(null),
error: error,
disableRowEdit,
editWidth,
enableDelete,
onDelete,
deleteWidth,
enableToggle,
rowSize,
onToggle,
toggleWidth,
actionColSize,
stripeWhen,
forceAdd,
orderedBy,
DEFAULT_COL_SIZE
}
return (
<TableCtx.Provider value={ctxValue}>
<div style={{ width }}>
{showButtonOnEmpty && canAdd && (
<AddButton onClick={addField}>{createText}</AddButton>
)}
{showTable && (
<>
{(title || enableCreate) && (
<div className={classes.outerHeader}>
{title && titleLg && (
<TL1 className={classes.title}>{title}</TL1>
)}
{title && !titleLg && (
<Info2 className={classes.title}>{title}</Info2>
)}
{enableCreate && canAdd && (
<Link className={classes.addLink} onClick={addField}>
{createText}
</Link>
)}
</div>
)}
<Table>
<Header />
<div className={tbodyWrapperClass}>
<TBody>
{adding && (
<Formik
validateOnBlur={false}
validateOnChange={false}
initialValues={{ id: uuidv4(), ...initialValues }}
onReset={onReset}
validationSchema={validationSchema}
onSubmit={innerSave}>
<Form>
<PromptWhenDirty />
<ERow
editing={true}
disabled={forceDisable}
newRow={true}
/>
</Form>
</Formik>
)}
{innerData.map((it, idx) => {
const nextElement = innerData[idx + 1]
const canGroup = !!groupBy && nextElement
const isFunction = R.type(groupBy) === 'Function'
const groupFunction = isFunction ? groupBy : R.prop(groupBy)
const isLastOfGroup =
canGroup &&
groupFunction(it) !== groupFunction(nextElement)
return (
<Formik
validateOnBlur={false}
validateOnChange={false}
key={it.id ?? idx}
enableReinitialize
initialValues={it}
onReset={onReset}
validationSchema={validationSchema}
onSubmit={innerSave}>
<Form>
<PromptWhenDirty />
<ERow
lastOfGroup={isLastOfGroup}
editing={editingId === it.id}
disabled={
forceDisable ||
(editingId && editingId !== it.id) ||
adding
}
/>
</Form>
</Formik>
)
})}
</TBody>
</div>
</Table>
</>
)}
</div>
</TableCtx.Provider>
)
}
export default ETable

View file

@ -0,0 +1,16 @@
.addLink {
margin-left: auto;
}
.title {
margin: 0;
color: var(--comet);
}
.outerHeader {
min-height: 16px;
margin-bottom: 24px;
display: flex;
justify-content: space-between;
align-items: center;
}

View file

@ -0,0 +1,4 @@
import NamespacedTable from './NamespacedTable'
import Table from './Table'
export { Table, NamespacedTable }

View file

@ -0,0 +1,145 @@
import Card from '@mui/material/Card'
import CardContent from '@mui/material/CardContent'
import classnames from 'classnames'
import React from 'react'
import { Link } from 'src/components/buttons'
import styles from './Table.module.css'
const Table = ({ children, className, ...props }) => (
<div className={classnames(className)} {...props}>
{children}
</div>
)
const THead = ({ children, className }) => {
return <div className={classnames(className, styles.header)}>{children}</div>
}
const TDoubleLevelHead = ({ children, className }) => {
return (
<div className={classnames(className, styles.doubleHeader)}>{children}</div>
)
}
const TBody = ({ children, className }) => {
return <div className={classnames(className)}>{children}</div>
}
const Td = ({
style = {},
children,
header,
className,
width = 100,
size,
bold,
textAlign,
action
}) => {
const inlineStyle = {
...style,
width,
textAlign,
fontSize: size === 'sm' ? '14px' : size === 'lg' ? '24px' : ''
}
const cssClasses = {
[styles.td]: !header,
[styles.tdHeader]: header,
'font-bold': !header && (bold || size === 'lg'),
[styles.actionCol]: action
}
return (
<div
data-cy={`td-${header}`}
className={classnames(className, cssClasses)}
style={inlineStyle}>
{children}
</div>
)
}
const Th = ({ children, ...props }) => {
return (
<Td header {...props}>
{children}
</Td>
)
}
const ThDoubleLevel = ({ title, children, className, width }) => {
return (
<div
className={classnames(className, styles.thDoubleLevel)}
style={{ width }}>
<div className={styles.thDoubleLevelFirst}>{title}</div>
<div>{children}</div>
</div>
)
}
const Tr = ({
onClick,
error,
errorMessage,
shouldShowError,
children,
className,
size,
newRow
}) => {
const inlineStyle = {
minHeight: size === 'sm' ? '34px' : size === 'lg' ? '68px' : '48px'
}
const cardClasses = {
[styles.card]: true,
[styles.trError]: error,
[styles.trAdding]: newRow
}
const mainContentClasses = {
[styles.mainContent]: true,
[styles.sizeSm]: size === 'sm',
[styles.sizeLg]: size === 'lg'
}
return (
<>
<Card className={classnames(className, cardClasses)} onClick={onClick}>
<CardContent className={styles.cardContentRoot}>
<div className={classnames(mainContentClasses)} style={inlineStyle}>
{children}
</div>
{error && shouldShowError && (
<div className={styles.errorContent}>{errorMessage}</div>
)}
</CardContent>
</Card>
</>
)
}
const EditCell = ({ save, cancel }) => (
<Td>
<Link style={{ marginRight: '20px' }} color="secondary" onClick={cancel}>
Cancel
</Link>
<Link color="primary" onClick={save}>
Save
</Link>
</Td>
)
export {
Table,
THead,
TDoubleLevelHead,
TBody,
Tr,
Td,
Th,
ThDoubleLevel,
EditCell
}

View file

@ -0,0 +1,106 @@
.header {
composes: tl2 from '../typography/typography.module.css';
background-color: var(--zodiac);
height: 32px;
text-align: left;
color: white;
display: flex;
align-items: center;
}
.doubleHeader {
composes: tl2 from '../typography/typography.module.css';
background-color: var(--zodiac);
height: 64px;
color: white;
display: table-row;
}
.thDoubleLevel {
display: table-cell;
}
.thDoubleLevelFirst {
composes: label1 from '../typography/typography.module.css';
margin: 0 10px;
font-weight: 700;
display: flex;
justify-content: center;
align-items: center;
background-color: var(--comet);
color: white;
border-radius: 0 0 8px 8px;
height: 28px;
}
.thDoubleLevel > :last-child {
padding: 0 11px;
display: table-cell;
vertical-align: middle;
height: 36px;
}
.cellDoubleLevel {
display: flex;
padding: 0 16px;
}
.td {
padding: 1px 24px 0 24px;
}
.tdHeader {
vertical-align: middle;
display: table-cell;
padding: 0 24px;
}
.trError {
background-color: var(--misty-rose);
}
.trAdding {
background-color: var(--spring3);
}
.mainContent {
display: flex;
align-items: center;
}
.cardContentRoot {
margin: 0;
padding: 0;
}
.cardContentRoot:last-child {
padding: 0;
}
.card {
composes: p from '../typography/typography.module.css';
margin: 4px 0 0 0;
width: 100%;
box-shadow: 0 0 4px 0 rgba(0, 0, 0, 0.08);
}
.card:before {
height: 0;
}
.actionCol {
margin-left: auto;
}
.errorContent {
padding: 12px 0 12px 24px;
color: var(--tomato);
}
.sizeSm {
min-height: 34px;
}
.sizeLg {
min-height: 68px;
}

View file

@ -0,0 +1,130 @@
import MAutocomplete from '@mui/material/Autocomplete'
import classnames from 'classnames'
import sort from 'match-sorter'
import * as R from 'ramda'
import React from 'react'
import { HoverableTooltip } from 'src/components/Tooltip'
import { P } from 'src/components/typography'
import TextInput from './TextInput'
const Autocomplete = ({
optionsLimit = 5, // set limit = null for no limit
limit,
options,
label,
valueProp,
multiple,
onChange,
labelProp,
shouldStayOpen,
value: outsideValue,
error,
fullWidth,
textAlign,
size,
autoFocus,
...props
}) => {
const mapFromValue = options => it => R.find(R.propEq(valueProp, it))(options)
const mapToValue = R.prop(valueProp)
const getValue = () => {
if (!valueProp) return outsideValue
const transform = multiple
? R.map(mapFromValue(options))
: mapFromValue(options)
return transform(outsideValue)
}
const value = getValue()
const innerOnChange = (evt, value) => {
if (!valueProp) return onChange(evt, value)
const rValue = multiple ? R.map(mapToValue)(value) : mapToValue(value)
onChange(evt, rValue)
}
const valueArray = () => {
if (R.isNil(value)) return []
return multiple ? value : [value]
}
const filter = (array, input) => {
if (!input) return array
return sort(array, input, { keys: [valueProp, labelProp] })
}
const filterOptions = (array, { inputValue }) =>
R.union(
R.isEmpty(inputValue) ? valueArray() : [],
filter(array, inputValue)
).slice(
0,
R.defaultTo(undefined)(limit) &&
Math.max(limit, R.isEmpty(inputValue) ? valueArray().length : 0)
)
return (
<MAutocomplete
options={options}
multiple={multiple}
value={value}
onChange={innerOnChange}
getOptionLabel={R.path([labelProp])}
forcePopupIcon={false}
filterOptions={filterOptions}
openOnFocus
autoHighlight
disableClearable
clearOnEscape
isOptionEqualToValue={R.eqProps(valueProp)}
{...props}
renderInput={params => {
return (
<TextInput
{...params}
autoFocus={autoFocus}
label={label}
value={outsideValue}
error={error}
size={size}
fullWidth={fullWidth}
textAlign={textAlign}
/>
)
}}
renderOption={(iprops, props) => {
if (!props.warning && !props.warningMessage)
return <li {...iprops}>{R.path([labelProp])(props)}</li>
const className = {
'flex w-4 h-4 rounded-md': true,
'bg-spring4': props.warning === 'clean',
'bg-orange-yellow': props.warning === 'partial',
'bg-tomato': props.warning === 'important'
}
const hoverableElement = <div className={classnames(className)} />
return (
<li {...iprops}>
<div className="flex flex-row justify-between items-center w-full">
<div className="flex">{R.path([labelProp])(props)}</div>
<HoverableTooltip parentElements={hoverableElement} width={250}>
<P>{props.warningMessage}</P>
</HoverableTooltip>
</div>
</li>
)
}}
slotProps={{
chip: { onDelete: null }
}} />
);
}
export default Autocomplete

View file

@ -0,0 +1,46 @@
import Checkbox from '@mui/material/Checkbox'
import CheckBoxIcon from '@mui/icons-material/CheckBox'
import CheckBoxOutlineBlankIcon from '@mui/icons-material/CheckBoxOutlineBlank'
import React from 'react'
import { Label2, Info3 } from 'src/components/typography'
import WarningIcon from 'src/styling/icons/warning-icon/comet.svg?react'
import { fontSize2, fontSize3 } from 'src/styling/variables'
const CheckboxInput = ({ name, onChange, value, settings, ...props }) => {
const { enabled, label, disabledMessage, rightSideLabel } = settings
return (
<>
{enabled ? (
<div className="flex">
{!rightSideLabel && <Label2>{label}</Label2>}
<Checkbox
id={name}
onChange={onChange}
value={value}
checked={value}
icon={
<CheckBoxOutlineBlankIcon
style={{ marginLeft: 2, fontSize: fontSize3 }}
/>
}
checkedIcon={<CheckBoxIcon style={{ fontSize: fontSize2 }} />}
disableRipple
{...props}
/>
{rightSideLabel && <Label2>{label}</Label2>}
</div>
) : (
<div className="flex items-center gap-2">
<WarningIcon />
<Info3 className="flex items-center text-comet m-0 whitespace-break-spaces">
{disabledMessage}
</Info3>
</div>
)}
</>
)
}
export default CheckboxInput

View file

@ -0,0 +1,37 @@
import classnames from 'classnames'
import React from 'react'
import OtpInput from 'react-otp-input'
import classes from './CodeInput.module.css'
const CodeInput = ({
name,
value,
onChange,
numInputs,
error,
inputStyle,
containerStyle
}) => {
return (
<OtpInput
id={name}
value={value}
onChange={onChange}
numInputs={numInputs}
renderSeparator={<span> </span>}
shouldAutoFocus
containerStyle={classnames(containerStyle, 'justify-evenly')}
inputStyle={classnames(
inputStyle,
classes.input,
'font-museo font-black text-4xl',
error && 'border-tomato'
)}
inputType={'tel'}
renderInput={props => <input {...props} />}
/>
)
}
export default CodeInput

View file

@ -0,0 +1,14 @@
.input {
width: 3.5rem !important;
height: 5rem;
border: 2px solid;
border-color: var(--zircon);
border-radius: 4px;
}
.input:focus {
border: 2px solid;
border-color: var(--zodiac);
border-radius: 4px;
outline: none;
}

View file

@ -0,0 +1,29 @@
import FormControl from '@mui/material/FormControl'
import InputLabel from '@mui/material/InputLabel'
import MenuItem from '@mui/material/MenuItem'
import Select from '@mui/material/Select'
import classnames from 'classnames'
import React from 'react'
const Dropdown = ({ label, name, options, onChange, value, className }) => {
return (
<FormControl variant="standard" className={classnames(className)}>
<InputLabel>{label}</InputLabel>
<Select
variant="standard"
autoWidth={true}
labelId={label}
id={name}
value={value}
onChange={onChange}>
{options.map((option, index) => (
<MenuItem key={index} value={option.value}>
{option.display}
</MenuItem>
))}
</Select>
</FormControl>
);
}
export default Dropdown

View file

@ -0,0 +1,54 @@
import React, { memo } from 'react'
import NumberFormat from 'react-number-format'
import TextInput from './TextInput'
const NumberInput = memo(
({
name,
onChange,
onBlur,
value,
error,
suffix,
textAlign,
width,
// lg or sm
size,
bold,
className,
decimalPlaces,
InputProps,
...props
}) => {
return (
<NumberFormat
name={name}
onChange={onChange}
onBlur={onBlur}
value={value}
error={error}
suffix={suffix}
textAlign={textAlign}
width={width}
// lg or sm
size={size}
bold={bold}
className={className}
customInput={TextInput}
decimalScale={decimalPlaces}
onValueChange={values => {
onChange({
target: {
id: name,
value: values.floatValue
}
})
}}
{...props}
/>
)
}
)
export default NumberInput

View file

@ -0,0 +1,53 @@
import Radio from '@mui/material/Radio'
import MRadioGroup from '@mui/material/RadioGroup'
import FormControlLabel from '@mui/material/FormControlLabel'
import classnames from 'classnames'
import React from 'react'
import { Label1 } from 'src/components/typography'
const RadioGroup = ({
name,
label,
value,
options,
onChange,
className,
labelClassName,
radioClassName
}) => {
return (
<>
{label && (
<Label1 className="h-4 leading-4 m-0 mb-1 pl-1">{label}</Label1>
)}
<MRadioGroup
name={name}
value={value}
onChange={onChange}
className={classnames(className)}>
{options.map((option, idx) => (
<React.Fragment key={idx}>
<div>
<FormControlLabel
disabled={option.disabled}
value={option.code}
control={
<Radio
className={classnames('text-spring', radioClassName)}
/>
}
label={option.display}
className={classnames(labelClassName)}
/>
{option.subtitle && (
<Label1 className="-mt-2 ml-8">{option.subtitle}</Label1>
)}
</div>
</React.Fragment>
))}
</MRadioGroup>
</>
)
}
export default RadioGroup

View file

@ -0,0 +1,35 @@
import React, { memo, useState } from 'react'
import { TextInput } from '../base'
const SecretInput = memo(
({ value, onFocus, isPasswordFilled, onBlur, ...props }) => {
const [focused, setFocused] = useState(false)
const placeholder = '⚬ ⚬ ⚬ This field is set ⚬ ⚬ ⚬'
const innerOnFocus = event => {
setFocused(true)
onFocus && onFocus(event)
}
const innerOnBlur = event => {
setFocused(false)
onBlur && onBlur(event)
}
return (
<TextInput
{...props}
type="password"
onFocus={innerOnFocus}
onBlur={innerOnBlur}
isPasswordFilled={isPasswordFilled}
value={value}
InputProps={{ value: value }}
InputLabelProps={{ shrink: isPasswordFilled || value || focused }}
placeholder={isPasswordFilled ? placeholder : ''}
/>
)
}
)
export default SecretInput

View file

@ -0,0 +1,53 @@
import classnames from 'classnames'
import { useSelect } from 'downshift'
import * as R from 'ramda'
import React from 'react'
import Arrowdown from 'src/styling/icons/action/arrow/regular.svg?react'
import styles from './Select.module.css'
function Select({ className, label, items, ...props }) {
const {
isOpen,
selectedItem,
getToggleButtonProps,
getLabelProps,
getMenuProps,
getItemProps
} = useSelect({
items,
selectedItem: props.selectedItem,
onSelectedItemChange: item => {
props.onSelectedItemChange(item.selectedItem)
}
})
const selectClassNames = {
[styles.select]: true,
[styles.selectFiltered]: props.defaultAsFilter
? true
: !R.equals(selectedItem, props.default),
[styles.open]: isOpen
}
return (
<div className={classnames(selectClassNames, className)}>
<label {...getLabelProps()}>{label}</label>
<button {...getToggleButtonProps()}>
<span className={styles.selectedItem}>{selectedItem.display}</span>
<Arrowdown />
</button>
<ul {...getMenuProps()}>
{isOpen &&
items.map(({ code, display }, index) => (
<li key={`${code}${index}`} {...getItemProps({ code, index })}>
<span>{display}</span>
</li>
))}
</ul>
</div>
)
}
export default Select

View file

@ -0,0 +1,100 @@
.selectedItem {
width: 111px;
display: block;
white-space: nowrap;
overflow: hidden;
}
.select {
width: 152px;
z-index: 1;
}
.select label {
font-size: 13px;
font-family: var(--museo);
font-weight: 500;
color: var(--comet);
padding-left: 10px;
}
.select button {
font-size: 14px;
font-family: var(--museo);
font-weight: 500;
position: relative;
border: 0;
background-color: var(--zircon);
width: 152px;
padding: 6px 0 6px 12px;
border-radius: 20px;
line-height: 1.14;
text-align: left;
color: var(--comet);
cursor: pointer;
outline: 0 none;
}
.select ul {
max-height: 200px;
width: 152px;
overflow-y: auto;
position: absolute;
margin: 0;
border-top: 0;
padding: 0;
border-radius: 0 0 8px 8px;
background-color: var(--zircon);
outline: 0 none;
}
.select ul li {
font-size: 14px;
font-family: var(--museo);
font-weight: 500;
list-style-type: none;
padding: 6px 12px;
cursor: pointer;
}
.select ul li span {
width: 100%;
display: block;
overflow: hidden;
white-space: nowrap;
}
.select ul li:hover {
background-color: var(--comet);
color: white;
}
.select svg {
position: absolute;
top: 12px;
right: 14px;
fill: var(--comet);
}
.selectFiltered button {
background-color: var(--comet);
color: white;
}
.selectFiltered ul li {
background-color: var(--comet);
color: white;
}
.selectFiltered ul li:hover {
background-color: var(--zircon);
color: var(--comet);
}
.selectFiltered svg {
fill: white !important;
}
.open button {
border-radius: 8px 8px 0 0;
}

View file

@ -0,0 +1,74 @@
import TextField from '@mui/material/TextField'
import classnames from 'classnames'
import * as R from 'ramda'
import React, { memo } from 'react'
import styles from './TextInput.module.css'
const TextInput = memo(
({
name,
isPasswordFilled,
onChange,
onBlur,
value,
error,
suffix,
textAlign,
width,
inputClasses,
// lg or sm
size,
bold,
className,
InputProps,
...props
}) => {
const isTextFilled = !error && !R.isNil(value) && !R.isEmpty(value)
const filled = isPasswordFilled || isTextFilled
const style = {
width: width,
textAlign: textAlign
}
const sizeClass =
size === 'sm'
? styles.sizeSm
: size === 'lg'
? styles.sizeLg
: styles.size
const divClass = {
[styles.bold]: bold
}
return (
<TextField
variant="standard"
id={name}
onChange={onChange}
onBlur={onBlur}
error={error}
value={value}
className={className}
style={style}
{...props}
slotProps={{
input: {
className: classnames(divClass),
classes: {
root: sizeClass,
underline: filled ? styles.underline : null,
input: inputClasses
},
...InputProps
},
htmlInput: { style: { textAlign } }
}} />
);
}
)
export default TextInput

View file

@ -0,0 +1,24 @@
.size {
font-size: 16px;
}
.sizeSm {
font-size: 14px;
}
.sizeLg {
font-size: 24px;
font-weight: 700;
}
.bold {
font-weight: 700;
}
.underline:before {
border-bottom-color: var(--spring);
}
.underline:hover:not(.Mui-disabled)::before {
border-bottom-color: var(--spring);
}

View file

@ -0,0 +1,44 @@
import MUIToggleButtonGroup from '@mui/material/ToggleButtonGroup'
import ToggleButton from '@mui/material/ToggleButton'
import React from 'react'
import { H4, P } from 'src/components/typography'
const ToggleButtonGroup = ({
name,
orientation = 'vertical',
value,
exclusive = true,
onChange,
size = 'small',
...props
}) => {
return (
<MUIToggleButtonGroup
size={size}
name={name}
orientation={orientation}
value={value}
exclusive={exclusive}
onChange={onChange}>
{props.options.map(option => {
return (
<ToggleButton
className="bg-ghost"
value={option.value}
aria-label={option.value}
key={option.value}>
<div className="flex items-center justify-start w-9/10 overflow-hidden max-h-20">
<option.icon />
<div className="ml-8 normal-case text-left">
<H4>{option.title}</H4>
<P className="text-comet -mt-2"> {option.description}</P>
</div>
</div>
</ToggleButton>
)
})}
</MUIToggleButtonGroup>
)
}
export default ToggleButtonGroup

View file

@ -0,0 +1,20 @@
import Autocomplete from './Autocomplete'
import Checkbox from './Checkbox'
import CodeInput from './CodeInput'
import Dropdown from './Dropdown'
import NumberInput from './NumberInput'
import RadioGroup from './RadioGroup'
import SecretInput from './SecretInput'
import TextInput from './TextInput'
import ToggleButtonGroup from './ToggleButtonGroup'
export {
Checkbox,
CodeInput,
TextInput,
NumberInput,
SecretInput,
RadioGroup,
Autocomplete,
ToggleButtonGroup,
Dropdown
}

View file

@ -0,0 +1,181 @@
import Chip from '@mui/material/Chip'
import classnames from 'classnames'
import React from 'react'
import { Info2, Label1, Label2 } from 'src/components/typography'
import { numberToFiatAmount } from 'src/utils/number'
import classes from './Cashbox.module.css'
import { primaryColor as zodiac, tomato } from '../../../styling/variables.js'
const colors = {
cashOut: {
empty: tomato,
full: zodiac
},
cashIn: {
empty: zodiac,
full: tomato
}
}
const Cashbox = ({
percent = 0,
cashOut = false,
width = 80,
height = 118,
className,
emptyPartClassName,
labelClassName,
omitInnerPercentage,
isLow
}) => {
const ltHalf = percent <= 51
const color =
colors[cashOut ? 'cashOut' : 'cashIn'][!isLow ? 'full' : 'empty']
return (
<div
style={{ height, width, backgroundColor: color, borderColor: color }}
className={classnames(className, classes.cashbox)}>
<div
className={classnames(emptyPartClassName, classes.emptyPart)}
style={{ height: `${100 - percent}%` }}>
{!omitInnerPercentage && ltHalf && (
<Label2
style={{ color }}
className={classnames(labelClassName, classes.emptyPartP)}>
{percent.toFixed(0)}%
</Label2>
)}
</div>
<div style={{ backgroundColor: color }}>
{!omitInnerPercentage && !ltHalf && (
<Label2 className={classnames(classes.fullPartP, labelClassName)}>
{percent.toFixed(0)}%
</Label2>
)}
</div>
</div>
)
}
// https://support.lamassu.is/hc/en-us/articles/360025595552-Installing-the-Sintra-Forte
// Sintra and Sintra Forte can have up to 500 notes per cashOut box and up to 1000 per cashIn box
const CashIn = ({
capacity = 500,
currency,
notes,
className,
editingMode = false,
threshold,
width,
height,
total,
omitInnerPercentage
}) => {
const percent = (100 * notes) / capacity
const isLow = percent < threshold
return (
<>
<div className={classes.row}>
<div className={classes.col}>
<Cashbox
className={className}
percent={percent}
cashOut
isLow={isLow}
width={width}
height={height}
omitInnerPercentage={omitInnerPercentage}
/>
</div>
{!editingMode && (
<div className={classes.col2}>
<div className={classes.innerRow}>
<Info2 className={classes.noMarginText}>{notes} notes</Info2>
</div>
<div className={classes.innerRow}>
<Label1 className={classes.noMarginText}>
{total} {currency.code}
</Label1>
</div>
</div>
)}
</div>
</>
)
}
const CashOut = ({
capacity = 500,
denomination = 0,
currency,
notes,
className,
editingMode = false,
threshold,
width,
height,
omitInnerPercentage
}) => {
const percent = (100 * notes) / capacity
const isLow = percent < threshold
return (
<>
<div className={classes.row}>
<div className={classes.col}>
<Cashbox
className={className}
percent={percent}
cashOut
isLow={isLow}
width={width}
height={height}
omitInnerPercentage={omitInnerPercentage}
/>
</div>
{!editingMode && (
<div className={classes.col2}>
<div className={classes.innerRow}>
<Info2 className={classes.noMarginText}>{notes}</Info2>
<Chip label={`${denomination} ${currency.code}`} />
</div>
<div className={classes.innerRow}>
<Label1 className={classes.noMarginText}>
{numberToFiatAmount(notes * denomination)} {currency.code}
</Label1>
</div>
</div>
)}
</div>
</>
)
}
const CashOutLite = ({
capacity = 500,
denomination = 0,
currency,
notes,
threshold,
width
}) => {
const percent = (100 * notes) / capacity
const isLow = percent < threshold
return (
<div className={classes.col}>
<Cashbox
percent={percent}
cashOut
isLow={isLow}
width={width}
height={15}
omitInnerPercentage
/>
<Chip label={`${denomination} ${currency.code}`} />
</div>
)
}
export { Cashbox, CashIn, CashOut, CashOutLite }

View file

@ -0,0 +1,52 @@
.row {
display: flex;
align-items: center;
}
.col {
display: flex;
flex-direction: column;
align-items: center;
}
.innerRow {
display: flex;
justify-content: flex-start;
}
.col2 {
margin-left: 14px;
}
.noMarginText {
margin-top: 0;
margin-bottom: 0;
}
.link {
margin-top: 8px;
}
.fullPartP {
color: white;
display: inline;
}
.emptyPart {
background-color: var(--ghost);
position: relative;
}
.emptyPartP {
display: inline-block;
position: absolute;
margin: 0;
bottom: 0;
right: 0;
}
.cashbox {
border: 2px solid;
text-align: end;
display: inline-block;
}

View file

@ -0,0 +1,51 @@
import { useFormikContext } from 'formik'
import * as R from 'ramda'
import React, { useState } from 'react'
import { Autocomplete } from '../base'
const AutocompleteFormik = ({ options, onChange, ...props }) => {
const [open, setOpen] = useState(false)
const { name, onBlur, value } = props.field
const { touched, errors, setFieldValue, setFieldTouched } = props.form
const error = !!(touched[name] && errors[name])
const { initialValues, values } = useFormikContext()
const innerOptions =
R.type(options) === 'Function' ? options(initialValues, values) : options
const innerOnBlur = event => {
name && setFieldTouched(name, true)
onBlur && onBlur(event)
}
const onChangeHandler = value => setFieldValue(name, value)
const shouldStayOpen = !!props.shouldStayOpen
return (
<Autocomplete
name={name}
onChange={(event, item) => {
if (onChange) return onChange(value, item, onChangeHandler)
setFieldValue(name, item)
}}
onBlur={innerOnBlur}
value={value}
error={error}
open={open}
options={innerOptions}
onOpen={() => {
if (!props.multiple) return setOpen(true)
setOpen(value?.length !== props.limit)
}}
onClose={(event, reason) => {
if (shouldStayOpen && reason !== 'blur') setOpen(true)
else setOpen(false)
}}
{...props}
/>
)
}
export default AutocompleteFormik

View file

@ -0,0 +1,39 @@
import classNames from 'classnames'
import React, { memo, useState } from 'react'
import { CashOut } from 'src/components/inputs/cashbox/Cashbox'
import { NumberInput } from '../base'
const CashCassetteInput = memo(
({ decimalPlaces, width, threshold, inputClassName, ...props }) => {
const { name, onChange, onBlur, value } = props.field
const { touched, errors } = props.form
const [notes, setNotes] = useState(value)
const error = !!(touched[name] && errors[name])
return (
<div className="flex">
<CashOut
className={classNames('h-9 mr-4', inputClassName)}
notes={notes}
editingMode={true}
width={width}
threshold={threshold}
/>
<NumberInput
name={name}
onChange={e => {
setNotes(e.target.value)
return onChange(e)
}}
onBlur={onBlur}
value={value}
error={error}
decimalPlaces={decimalPlaces}
{...props}
/>
</div>
)
}
)
export default CashCassetteInput

View file

@ -0,0 +1,34 @@
import React, { memo } from 'react'
import { Checkbox } from '../base'
const CheckboxInput = memo(
({
label,
textAlign,
fullWidth,
enabled = true,
disabledMessage = '',
...props
}) => {
const { name, onChange, value } = props.field
const settings = {
enabled: enabled,
label: label,
disabledMessage: disabledMessage
}
return (
<Checkbox
name={name}
onChange={onChange}
value={value}
settings={settings}
{...props}
/>
)
}
)
export default CheckboxInput

View file

@ -0,0 +1,25 @@
import React, { memo } from 'react'
import { Dropdown } from '../base'
const RadioGroupFormik = memo(({ label, ...props }) => {
const { name, value } = props.field
const { setFieldValue } = props.form
return (
<Dropdown
name={name}
label={label}
value={value}
options={props.options}
ariaLabel={name}
onChange={e => {
setFieldValue(name, e.target.value)
props.resetError && props.resetError()
}}
className={props.className}
{...props}
/>
)
})
export default RadioGroupFormik

View file

@ -0,0 +1,24 @@
import React, { memo } from 'react'
import { NumberInput } from '../base'
const NumberInputFormik = memo(({ decimalPlaces, ...props }) => {
const { name, onChange, onBlur, value } = props.field
const { touched, errors } = props.form
const error = !!(touched[name] && errors[name])
return (
<NumberInput
name={name}
onChange={onChange}
onBlur={onBlur}
value={value}
error={error}
decimalPlaces={decimalPlaces}
{...props}
/>
)
})
export default NumberInputFormik

View file

@ -0,0 +1,25 @@
import React, { memo } from 'react'
import { RadioGroup } from '../base'
const RadioGroupFormik = memo(({ label, ...props }) => {
const { name, onChange, value } = props.field
return (
<RadioGroup
name={name}
label={label}
value={value}
options={props.options}
ariaLabel={name}
onChange={e => {
onChange(e)
props.resetError && props.resetError()
}}
className={props.className}
{...props}
/>
)
})
export default RadioGroupFormik

View file

@ -0,0 +1,24 @@
import React, { memo } from 'react'
import { SecretInput } from '../base'
const SecretInputFormik = memo(({ isPasswordFilled, ...props }) => {
const { name, onChange, onBlur, value } = props.field
const { touched, errors } = props.form
const error = !isPasswordFilled && !!(touched[name] && errors[name])
return (
<SecretInput
name={name}
isPasswordFilled={isPasswordFilled}
onChange={onChange}
onBlur={onBlur}
value={value}
error={error}
{...props}
/>
)
})
export default SecretInputFormik

View file

@ -0,0 +1,23 @@
import React, { memo } from 'react'
import { TextInput } from '../base'
const TextInputFormik = memo(({ ...props }) => {
const { name, onChange, onBlur, value } = props.field
const { touched, errors } = props.form
const error = !!(touched[name] && errors[name])
return (
<TextInput
name={name}
onChange={onChange}
onBlur={onBlur}
value={value}
error={error}
{...props}
/>
)
})
export default TextInputFormik

View file

@ -0,0 +1,27 @@
import React, { memo } from 'react'
import { ToggleButtonGroup } from '../base'
const ToggleButtonGroupFormik = memo(({ enforceValueSet = true, ...props }) => {
const { name, value } = props.field
const { setFieldValue } = props.form
return (
<ToggleButtonGroup
name={name}
value={value}
options={props.options}
ariaLabel={name}
onChange={(e, value) => {
// enforceValueSet prevents you from not having any button selected
// after selecting one the first time
if (enforceValueSet && !value) return null
setFieldValue(name, value)
props.resetError && props.resetError()
}}
className={props.className}
{...props}
/>
)
})
export default ToggleButtonGroupFormik

View file

@ -0,0 +1,19 @@
import Autocomplete from './Autocomplete'
import CashCassetteInput from './CashCassetteInput'
import Checkbox from './Checkbox'
import Dropdown from './Dropdown'
import NumberInput from './NumberInput'
import RadioGroup from './RadioGroup'
import SecretInput from './SecretInput'
import TextInput from './TextInput'
export {
Autocomplete,
Checkbox,
TextInput,
NumberInput,
SecretInput,
RadioGroup,
CashCassetteInput,
Dropdown
}

View file

@ -0,0 +1,18 @@
import Autocomplete from './base/Autocomplete'
import Checkbox from './base/Checkbox'
import CodeInput from './base/CodeInput'
import RadioGroup from './base/RadioGroup'
import Select from './base/Select'
import TextInput from './base/TextInput'
import { CashIn, CashOut } from './cashbox/Cashbox'
export {
Autocomplete,
TextInput,
Checkbox,
CodeInput,
Select,
RadioGroup,
CashIn,
CashOut
}

View file

@ -0,0 +1,210 @@
import { useQuery, gql } from '@apollo/client'
import ClickAwayListener from '@mui/material/ClickAwayListener'
import Popper from '@mui/material/Popper'
import classnames from 'classnames'
import * as R from 'ramda'
import React, { memo, useState, useEffect, useRef } from 'react'
import { NavLink, useHistory } from 'react-router-dom'
import ActionButton from 'src/components/buttons/ActionButton'
import { H4 } from 'src/components/typography'
import AddIconReverse from 'src/styling/icons/button/add/white.svg?react'
import AddIcon from 'src/styling/icons/button/add/zodiac.svg?react'
import Logo from 'src/styling/icons/menu/logo.svg?react'
import NotificationIcon from 'src/styling/icons/menu/notification.svg?react'
import NotificationCenter from 'src/components/NotificationCenter'
import AddMachine from 'src/pages/AddMachine'
import styles from './Header.module.css'
const HAS_UNREAD = gql`
query getUnread {
hasUnreadNotifications
}
`
const Subheader = ({ item, user }) => {
const [prev, setPrev] = useState(null)
return (
<div className={styles.subheader}>
<div className={styles.content}>
<nav>
<ul className={styles.subheaderUl}>
{item.children.map((it, idx) => {
if (!R.includes(user.role, it.allowedRoles)) return <></>
return (
<li key={idx} className={styles.subheaderLi}>
<NavLink
to={{ pathname: it.route, state: { prev } }}
className={styles.subheaderLink}
activeClassName={styles.activeSubheaderLink}
isActive={match => {
if (!match) return false
setPrev(it.route)
return true
}}>
{it.label}
</NavLink>
</li>
)
})}
</ul>
</nav>
</div>
</div>
)
}
const notNil = R.compose(R.not, R.isNil)
const Header = memo(({ tree, user }) => {
const [open, setOpen] = useState(false)
const [anchorEl, setAnchorEl] = useState(null)
const [notifButtonCoords, setNotifButtonCoords] = useState({ x: 0, y: 0 })
const [active, setActive] = useState()
const [hasUnread, setHasUnread] = useState(false)
const { data, refetch, startPolling, stopPolling } = useQuery(HAS_UNREAD)
const notifCenterButtonRef = useRef()
const popperRef = useRef()
const history = useHistory()
useEffect(() => {
if (data?.hasUnreadNotifications) return setHasUnread(true)
// if not true, make sure it's false and not undefined
if (notNil(data?.hasUnreadNotifications)) return setHasUnread(false)
}, [data])
useEffect(() => {
startPolling(60000)
return stopPolling
})
const onPaired = machine => {
setOpen(false)
history.push('/maintenance/machine-status', { id: machine.deviceId })
}
// these inline styles prevent scroll bubbling: when the user reaches the bottom of the notifications list and keeps scrolling,
// the body scrolls, stealing the focus from the notification center, preventing the admin from scrolling the notifications back up
// on the first scroll, needing to move the mouse to recapture the focus on the notification center
// it also disables the scrollbars caused by the notification center's background to the right of the page, but keeps the scrolling on the body enabled
const onClickAway = () => {
setAnchorEl(null)
document.querySelector('#root').classList.remove('root-notifcenter-open')
document.querySelector('body').classList.remove('body-notifcenter-open')
}
const handleClick = event => {
const coords = notifCenterButtonRef.current.getBoundingClientRect()
setNotifButtonCoords({ x: coords.x, y: coords.y + 5 })
setAnchorEl(anchorEl ? null : event.currentTarget)
document.querySelector('#root').classList.add('root-notifcenter-open')
document.querySelector('body').classList.add('body-notifcenter-open')
}
const popperOpen = Boolean(anchorEl)
const id = popperOpen ? 'notifications-popper' : undefined
return (
<header className={styles.headerContainer}>
<div className={styles.header}>
<div className={styles.content}>
<div
onClick={() => {
setActive(false)
history.push('/dashboard')
}}
className={classnames(styles.logo, styles.logoLink)}>
<Logo />
<H4 className="text-white">Lamassu Admin</H4>
</div>
<nav className={styles.nav}>
<ul className={styles.ul}>
{tree.map((it, idx) => {
if (!R.includes(user.role, it.allowedRoles)) return <></>
return (
<NavLink
key={idx}
to={it.route || it.children[0].route}
isActive={match => {
if (!match) return false
setActive(it)
return true
}}
className={classnames(styles.link)}
activeClassName={styles.activeLink}>
<li className={styles.li}>
<span
className={styles.forceSize}
data-forcesize={it.label}>
{it.label}
</span>
</li>
</NavLink>
)
})}
</ul>
</nav>
<div className={styles.actionButtonsContainer}>
<ActionButton
altTextColor
color="secondary"
Icon={AddIcon}
InverseIcon={AddIconReverse}
onClick={() => setOpen(true)}>
Add machine
</ActionButton>
<ClickAwayListener onClickAway={onClickAway}>
<div ref={notifCenterButtonRef}>
<button
onClick={handleClick}
className={styles.notificationIcon}>
<NotificationIcon />
{hasUnread && <div className={styles.hasUnread} />}
</button>
<Popper
ref={popperRef}
id={id}
open={popperOpen}
anchorEl={anchorEl}
className={styles.popper}
disablePortal={false}
placement="bottom-end"
modifiers={[
{
name: 'offset',
enabled: true,
options: {
offset: ['100vw', '100vw']
}
},
{
name: 'preventOverflow',
enabled: true,
options: {
rootBoundary: 'viewport'
}
}
]}>
<NotificationCenter
popperRef={popperRef}
buttonCoords={notifButtonCoords}
close={onClickAway}
hasUnreadProp={hasUnread}
refetchHasUnreadHeader={refetch}
/>
</Popper>
</div>
</ClickAwayListener>
</div>
</div>
</div>
{active && active.children && <Subheader item={active} user={user} />}
{open && <AddMachine close={() => setOpen(false)} onPaired={onPaired} />}
</header>
)
})
export default Header

View file

@ -0,0 +1,175 @@
.headerContainer {
position: relative;
}
.header {
background-color: var(--zodiac);
color: white;
height: 56px;
display: flex;
}
.content {
max-width: 1200px;
flex: 1;
display: flex;
align-items: center;
margin: 0 auto;
}
.nav {
flex: 1;
display: flex;
align-items: center;
justify-content: space-between;
}
.ul {
display: flex;
padding-left: 36px;
height: 56px;
margin: 0;
}
.li {
list-style: none;
color: white;
margin: 20px 20px 0 20px;
position: relative;
line-height: 17px;
}
.li:hover {
color: white;
}
.li:hover::after {
width: 50%;
margin-left: -25%;
}
.li::after {
content: "";
display: block;
background: white;
width: 0;
height: 4px;
left: 50%;
margin-left: 0;
position: absolute;
border-radius: 1000px;
transition: all 0.2s cubic-bezier(0.95, 0.1, 0.45, 0.94);
}
.link {
text-decoration: none;
border: none;
color: white;
background-color: transparent;
}
.forceSize {
display: inline-block;
text-align: center;
}
.forceSize::after {
display: block;
content: attr(data-forcesize);
font-weight: 700;
height: 0;
overflow: hidden;
visibility: hidden;
}
.activeLink {
color: white;
}
.activeLink li::after {
width: 50%;
margin-left: -25%;
}
.addMachine {
margin-left: auto;
}
.subheader {
background-color: var(--zircon);
color: white;
height: 40px;
display: flex;
}
.subheaderUl {
display: flex;
padding-left: 0;
margin: 0;
}
.subheaderLi {
list-style: none;
padding: 0 20px;
}
.subheaderLi:first-child {
padding-left: 0;
}
.subheaderLink {
text-decoration: none;
border: none;
color: var(--comet);
}
.activeSubheaderLink {
text-shadow: 0.2px 0 0 currentColor;
color: var(--zodiac);
}
.logo {
display: flex;
align-items: center;
}
.logo > svg {
margin-right: 16px;
}
.logoLink {
cursor: pointer;
}
.actionButtonsContainer {
z-index: 1;
position: relative;
display: flex;
justify-content: space-between;
min-width: 200px;
transform: translateZ(0);
}
.notificationIcon {
margin-top: 4px;
cursor: pointer;
background: transparent;
box-shadow: 0px 0px 0px transparent;
border: 0px solid transparent;
text-shadow: 0px 0px 0px transparent;
outline: none;
}
.hasUnread {
position: absolute;
top: 4px;
left: 186px;
width: 9px;
height: 9px;
background-color: var(--spring);
border-radius: 50%;
}
.popper {
z-index: 1;
}

View file

@ -0,0 +1,19 @@
import React from 'react'
import ErrorMessage from 'src/components/ErrorMessage'
import Subtitle from 'src/components/Subtitle'
const Section = ({ error, children, title }) => {
return (
<div className="mb-8">
{(title || error) && (
<div className="flex items-center">
<Subtitle className="mt-4 mr-5 mb-6 ml-0">{title}</Subtitle>
{error && <ErrorMessage>Failed to save changes</ErrorMessage>}
</div>
)}
{children}
</div>
)
}
export default Section

View file

@ -0,0 +1,75 @@
import classnames from 'classnames'
import React from 'react'
import { P } from 'src/components/typography'
import CompleteStageIconZodiac from 'src/styling/icons/stage/zodiac/complete.svg?react'
import CurrentStageIconZodiac from 'src/styling/icons/stage/zodiac/current.svg?react'
import EmptyStageIconZodiac from 'src/styling/icons/stage/zodiac/empty.svg?react'
import styles from './Sidebar.module.css'
const Sidebar = ({
data,
displayName,
isSelected,
onClick,
children,
itemRender,
loading = false
}) => {
return (
<div className={styles.sidebar}>
{loading && <P>Loading...</P>}
{!loading &&
data?.map((it, idx) => (
<div
key={idx}
className={styles.linkWrapper}
onClick={() => onClick(it)}>
<div
className={classnames({
[styles.activeLink]: isSelected(it),
[styles.customRenderActiveLink]: itemRender && isSelected(it),
[styles.customRenderLink]: itemRender,
[styles.link]: true
})}>
{itemRender ? itemRender(it, isSelected(it)) : displayName(it)}
</div>
</div>
))}
{!loading && children}
</div>
)
}
export default Sidebar
const Stepper = ({ step, it, idx, steps }) => {
const active = step === idx
const past = idx < step
const future = idx > step
return (
<div className={styles.item}>
<span
className={classnames({
[styles.itemText]: true,
[styles.itemTextActive]: active,
[styles.itemTextPast]: past
})}>
{it.label}
</span>
{active && <CurrentStageIconZodiac />}
{past && <CompleteStageIconZodiac />}
{future && <EmptyStageIconZodiac />}
{idx < steps.length - 1 && (
<div
className={classnames({
[styles.stepperPath]: true,
[styles.stepperPast]: past
})}></div>
)}
</div>
)
}
export { Stepper }

View file

@ -0,0 +1,106 @@
:root {
--sidebar-color: var(--zircon);
}
.sidebar {
display: flex;
background-color: var(--sidebar-color);
width: 520px;
margin-left: -300px;
box-shadow: -500px 0px 0px 0px var(--sidebar-color);
border-radius: 0 20px 0 0;
align-items: flex-end;
padding: 24px;
flex-direction: column;
}
@media (max-width: 1440px) {
.sidebar {
width: auto;
margin-left: 0;
min-width: 250px;
box-shadow: -200px 0px 0px 0px var(--sidebar-color);
}
}
.linkWrapper {
cursor: pointer;
}
.link {
position: relative;
color: var(--comet);
margin: 12px 24px 12px 0;
cursor: pointer;
}
.link:hover::after {
height: 140%;
}
.link::after {
content: "";
display: block;
background: var(--zodiac);
width: 4px;
height: 0;
left: 100%;
margin-left: 20px;
bottom: -2px;
position: absolute;
border-radius: 1000px;
transition: all 0.2s cubic-bezier(0.95, 0.1, 0.45, 0.94);
}
.activeLink {
font-weight: 700;
color: var(--zodiac);
}
.activeLink::after {
height: 140%;
}
.customRenderLink:hover::after {
height: 100%;
}
.customRenderLink::after {
bottom: 0;
}
.customRenderActiveLink::after {
height: 100%;
}
.item {
position: relative;
margin: 12px 0 12px 0;
display: flex;
}
.itemText {
color: var(--comet);
margin-right: 24px;
}
.itemTextActive {
color: var(--zodiac);
}
.itemTextPast {
color: var(--zodiac);
}
.stepperPath {
position: absolute;
height: 25px;
width: 1px;
border: 1px solid var(--comet);
right: 8px;
top: 18px;
}
.stepperPast {
border: 1px solid var(--zodiac);
}

View file

@ -0,0 +1,66 @@
import classnames from 'classnames'
import * as R from 'ramda'
import React from 'react'
import ErrorMessage from 'src/components/ErrorMessage'
import Title from 'src/components/Title'
import { Info1, Label1 } from 'src/components/typography'
import { SubpageButton } from 'src/components/buttons'
const TitleSection = ({
className,
title,
error,
labels,
buttons = [],
children,
appendix,
appendixRight
}) => {
return (
<div
className={classnames(
'flex justify-between items-center flex-row',
className
)}>
<div className="flex items-center">
<Title>{title}</Title>
{!!appendix && appendix}
{error && <ErrorMessage className="ml-3">Failed to save</ErrorMessage>}
{buttons.length > 0 && (
<>
{buttons.map((button, idx) =>
!R.isNil(button.component) ? (
button.component
) : (
<SubpageButton
key={idx}
className="ml-3"
Icon={button.icon}
InverseIcon={button.inverseIcon}
toggle={button.toggle}
forceDisable={button.forceDisable}>
<Info1 className="text-ghost font-mont text-base">
{button.text}
</Info1>
</SubpageButton>
)
)}
</>
)}
</div>
<div className="flex flex-row items-center">
{(labels ?? []).map(({ icon, label }, idx) => (
<div key={idx} className="flex items-center">
<div className="mr-1">{icon}</div>
<Label1 className="mr-6">{label}</Label1>
</div>
))}
{appendixRight}
</div>
{children}
</div>
)
}
export default TitleSection

View file

@ -0,0 +1,190 @@
import { useLazyQuery, useQuery, gql } from '@apollo/client'
import { subMinutes } from 'date-fns'
import FileSaver from 'file-saver'
import React, { useState, useEffect } from 'react'
import Modal from 'src/components/Modal'
import { H3, P } from 'src/components/typography'
import { Button } from 'src/components/buttons'
const STATES = {
INITIAL: 'INITIAL',
EMPTY: 'EMPTY',
RUNNING: 'RUNNING',
FAILURE: 'FAILURE',
FILLED: 'FILLED'
}
const MACHINE = gql`
query getMachine($deviceId: ID!) {
machine(deviceId: $deviceId) {
diagnostics {
timestamp
frontTimestamp
scanTimestamp
}
}
}
`
const MACHINE_LOGS = gql`
query machineLogsCsv(
$deviceId: ID!
$limit: Int
$from: DateTimeISO
$until: DateTimeISO
$timezone: String
) {
machineLogsCsv(
deviceId: $deviceId
limit: $limit
from: $from
until: $until
timezone: $timezone
)
}
`
const createCsv = async ({ machineLogsCsv }) => {
const machineLogs = new Blob([machineLogsCsv], {
type: 'text/plain;charset=utf-8'
})
FileSaver.saveAs(machineLogs, 'machineLogs.csv')
}
const DiagnosticsModal = ({ onClose, deviceId, sendAction }) => {
const [state, setState] = useState(STATES.INITIAL)
const [timestamp, setTimestamp] = useState(null)
let timeout = null
const [fetchSummary, { loading }] = useLazyQuery(MACHINE_LOGS, {
onCompleted: data => createCsv(data)
})
const { data, stopPolling, startPolling } = useQuery(MACHINE, {
variables: { deviceId }
})
useEffect(() => {
if (!data) return
if (!timestamp && !data.machine.diagnostics.timestamp) {
stopPolling()
setState(STATES.EMPTY)
}
if (
data.machine.diagnostics.timestamp &&
data.machine.diagnostics.timestamp !== timestamp
) {
clearTimeout(timeout)
setTimestamp(data.machine.diagnostics.timestamp)
setState(STATES.FILLED)
stopPolling()
}
}, [data, stopPolling, timeout, timestamp])
const path = `/operator-data/diagnostics/${deviceId}/`
function runDiagnostics() {
startPolling(2000)
timeout = setTimeout(() => {
setState(STATES.FAILURE)
stopPolling()
}, 60 * 1000)
setState(STATES.RUNNING)
sendAction()
}
const messageClass = 'm-auto flex flex-col items-center justify-center'
return (
<Modal
closeOnBackdropClick={true}
width={800}
height={600}
handleClose={onClose}
open={true}>
{state === STATES.INITIAL && (
<div className={messageClass}>
<H3>Loading...</H3>
</div>
)}
{state === STATES.EMPTY && (
<div className={messageClass}>
<H3>No diagnostics available</H3>
<P>Run diagnostics to generate a report</P>
</div>
)}
{state === STATES.RUNNING && (
<div className={messageClass}>
<H3>Running Diagnostics...</H3>
<P>This page should refresh automatically</P>
</div>
)}
{state === STATES.FAILURE && (
<div className={messageClass}>
<H3>Failed to run diagnostics</H3>
<P>Please try again. If the problem persists, contact support.</P>
</div>
)}
{state === STATES.FILLED && (
<div>
<div className="flex mt-6">
<div>
<H3>Scan</H3>
<img
className="w-88"
src={path + 'scan.jpg'}
alt="Failure getting photo"
/>
</div>
<div>
<H3>Front</H3>
<img
className="w-88"
src={path + 'front.jpg'}
alt="Failure getting photo"
/>
<P></P>
</div>
</div>
<div>
<P>Diagnostics executed at: {timestamp}</P>
</div>
</div>
)}
<div className="flex flex-row mt-auto ml-auto mr-2 mb-0">
<Button
disabled={state !== STATES.FILLED || !timestamp}
onClick={() => {
if (loading) return
fetchSummary({
variables: {
from: subMinutes(new Date(timestamp), 5),
deviceId,
limit: 500
}
})
}}
className="mt-auto ml-auto mr-2 mb-0">
Download Logs
</Button>
<Button
disabled={state === STATES.RUNNING}
onClick={() => {
runDiagnostics()
}}>
Run Diagnostics
</Button>
</div>
</Modal>
)
}
export default DiagnosticsModal

View file

@ -0,0 +1,269 @@
import { useMutation, useLazyQuery, gql } from '@apollo/client'
import React, { memo, useState } from 'react'
import { ConfirmDialog } from 'src/components/ConfirmDialog'
import ActionButton from 'src/components/buttons/ActionButton'
import { H3 } from 'src/components/typography'
import EditReversedIcon from 'src/styling/icons/button/edit/white.svg?react'
import EditIcon from 'src/styling/icons/button/edit/zodiac.svg?react'
import RebootReversedIcon from 'src/styling/icons/button/reboot/white.svg?react'
import RebootIcon from 'src/styling/icons/button/reboot/zodiac.svg?react'
import ShutdownReversedIcon from 'src/styling/icons/button/shut down/white.svg?react'
import ShutdownIcon from 'src/styling/icons/button/shut down/zodiac.svg?react'
import UnpairReversedIcon from 'src/styling/icons/button/unpair/white.svg?react'
import UnpairIcon from 'src/styling/icons/button/unpair/zodiac.svg?react'
import DiagnosticsModal from './DiagnosticsModal'
const MACHINE_ACTION = gql`
mutation MachineAction(
$deviceId: ID!
$action: MachineAction!
$newName: String
) {
machineAction(deviceId: $deviceId, action: $action, newName: $newName) {
deviceId
}
}
`
const MACHINE = gql`
query getMachine($deviceId: ID!) {
machine(deviceId: $deviceId) {
latestEvent {
note
}
}
}
`
const isStaticState = machineState => {
if (!machineState) {
return true
}
const staticStates = [
'chooseCoin',
'idle',
'pendingIdle',
'dualIdle',
'networkDown',
'unpaired',
'maintenance',
'virgin',
'wifiList'
]
return staticStates.includes(machineState)
}
const getState = machineEventsLazy =>
JSON.parse(machineEventsLazy.machine.latestEvent?.note ?? '{"state": null}')
.state
const MachineActions = memo(({ machine, onActionSuccess }) => {
const [action, setAction] = useState({ command: null })
const [preflightOptions, setPreflightOptions] = useState({})
const [showModal, setShowModal] = useState(false)
const [errorMessage, setErrorMessage] = useState(null)
const warningMessage = (
<span className="text-tomato">
A user may be in the middle of a transaction and they could lose their
funds if you continue.
</span>
)
const [fetchMachineEvents, { loading: loadingEvents }] = useLazyQuery(
MACHINE,
preflightOptions
)
const [simpleMachineAction] = useMutation(MACHINE_ACTION)
const [machineAction, { loading }] = useMutation(MACHINE_ACTION, {
onError: ({ message }) => {
const errorMessage = message ?? 'An error ocurred'
setErrorMessage(errorMessage)
},
onCompleted: () => {
onActionSuccess && onActionSuccess()
setAction({ display: action.display, command: null })
}
})
const confirmDialogOpen = Boolean(action.command)
const disabled = !!(action?.command === 'restartServices' && loadingEvents)
const machineStatusPreflight = actionToDo => {
setPreflightOptions({
variables: { deviceId: machine.deviceId },
onCompleted: machineEventsLazy => {
const message = !isStaticState(getState(machineEventsLazy))
? warningMessage
: null
setAction({ ...actionToDo, message })
}
})
fetchMachineEvents()
}
return (
<div>
<H3>Actions</H3>
<div className="flex flex-row flex-wrap justify-start gap-2">
<ActionButton
color="primary"
Icon={EditIcon}
InverseIcon={EditReversedIcon}
disabled={loading}
onClick={() =>
setAction({
command: 'rename',
display: 'Rename',
confirmationMessage: 'Write the new name for this machine'
})
}>
Rename
</ActionButton>
<ActionButton
color="primary"
Icon={UnpairIcon}
InverseIcon={UnpairReversedIcon}
disabled={loading}
onClick={() =>
setAction({
command: 'unpair',
display: 'Unpair'
})
}>
Unpair
</ActionButton>
<ActionButton
color="primary"
Icon={RebootIcon}
InverseIcon={RebootReversedIcon}
disabled={loading}
onClick={() =>
setAction({
command: 'reboot',
display: 'Reboot'
})
}>
Reboot
</ActionButton>
<ActionButton
color="primary"
Icon={ShutdownIcon}
InverseIcon={ShutdownReversedIcon}
disabled={loading}
onClick={() =>
setAction({
command: 'shutdown',
display: 'Shutdown',
message:
'In order to bring it back online, the machine will need to be visited and its power reset.'
})
}>
Shutdown
</ActionButton>
<ActionButton
color="primary"
Icon={RebootIcon}
InverseIcon={RebootReversedIcon}
disabled={loading}
onClick={() => {
machineStatusPreflight({
command: 'restartServices',
display: 'Restart services for'
})
}}>
Restart services
</ActionButton>
{machine.model === 'aveiro' && (
<ActionButton
color="primary"
Icon={RebootIcon}
InverseIcon={RebootReversedIcon}
disabled={loading}
onClick={() => {
setAction({
command: 'emptyUnit',
display: 'Empty',
message:
"Triggering this action will move all cash inside the machine towards its cashbox (if possible), allowing for the collection of cash from the machine via only its cashbox. Depending on how full the cash units are, it's possible that this action will need to be used more than once to ensure that the unit is left completely empty."
})
}}>
Empty Unit
</ActionButton>
)}
{machine.model === 'aveiro' && (
<ActionButton
color="primary"
Icon={RebootIcon}
InverseIcon={RebootReversedIcon}
disabled={loading}
onClick={() => {
setAction({
command: 'refillUnit',
display: 'Refill',
message:
'Triggering this action will refill the recyclers in this machine, by using bills present in its cassettes. This action may require manual operation of the cassettes and close attention to make sure that the denominations in the cassettes match the denominations in the recyclers.'
})
}}>
Refill Unit
</ActionButton>
)}
<ActionButton
color="primary"
Icon={RebootIcon}
InverseIcon={RebootReversedIcon}
disabled={loading}
onClick={() => {
setShowModal(true)
}}>
Diagnostics
</ActionButton>
</div>
{showModal && (
<DiagnosticsModal
sendAction={() =>
simpleMachineAction({
variables: {
deviceId: machine.deviceId,
action: 'diagnostics'
}
})
}
deviceId={machine.deviceId}
onClose={() => {
setShowModal(false)
}}
/>
)}
<ConfirmDialog
disabled={disabled}
open={confirmDialogOpen}
title={`${action.display} this machine?`}
errorMessage={errorMessage}
toBeConfirmed={machine.name}
message={action?.message}
confirmationMessage={action?.confirmationMessage}
saveButtonAlwaysEnabled={action?.command === 'rename'}
onConfirmed={value => {
setErrorMessage(null)
machineAction({
variables: {
deviceId: machine.deviceId,
action: `${action?.command}`,
...(action?.command === 'rename' && { newName: value })
}
})
}}
onDismissed={() => {
setAction({ display: action.display, command: null })
setErrorMessage(null)
}}
/>
</div>
)
})
export default MachineActions

View file

@ -0,0 +1,76 @@
import IconButton from '@mui/material/IconButton'
import SvgIcon from '@mui/material/SvgIcon'
import React from 'react'
import {
Table,
THead,
TBody,
Td,
Th,
Tr
} from 'src/components/fake-table/Table'
import EditIcon from 'src/styling/icons/action/edit/white.svg?react'
import { Label1, P } from '../typography/index.jsx'
const SingleRowTable = ({
width = 378,
height = 128,
title,
items,
onEdit,
className
}) => {
return (
<>
<Table className={className} style={{ width }}>
<THead>
<Th className="flex flex-1 justify-between items-center pr-3">
{title}
<IconButton onClick={onEdit} className="mb-[1px]">
<SvgIcon>
<EditIcon />
</SvgIcon>
</IconButton>
</Th>
</THead>
<TBody>
<Tr className="m-0" style={{ height }}>
<Td width={width}>
{items && (
<>
{items[0] && (
<div className="flex flex-col mt-4 min-h-9">
<Label1 noMargin className="color-comet mb-1">
{items[0].label}
</Label1>
<P
noMargin
className="overflow-hidden text-ellipsis whitespace-nowrap">
{items[0].value}
</P>
</div>
)}
{items[1] && (
<div className="flex flex-col mt-4 min-h-9">
<Label1 noMargin className="color-comet mb-1">
{items[1].label}
</Label1>
<P
noMargin
className="overflow-hidden text-ellipsis whitespace-nowrap">
{items[1].value}
</P>
</div>
)}
</>
)}
</Td>
</Tr>
</TBody>
</Table>
</>
)
}
export default SingleRowTable

Some files were not shown because too many files have changed in this diff Show more