chore: use monorepo organization
This commit is contained in:
parent
deaf7d6ecc
commit
a687827f7e
1099 changed files with 8184 additions and 11535 deletions
40
packages/admin-ui/src/App.jsx
Normal file
40
packages/admin-ui/src/App.jsx
Normal 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
|
||||
3
packages/admin-ui/src/AppContext.js
Normal file
3
packages/admin-ui/src/AppContext.js
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import React from 'react'
|
||||
|
||||
export default React.createContext()
|
||||
83
packages/admin-ui/src/Main.jsx
Normal file
83
packages/admin-ui/src/Main.jsx
Normal 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
|
||||
48
packages/admin-ui/src/components/Carousel.jsx
Normal file
48
packages/admin-ui/src/components/Carousel.jsx
Normal 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>
|
||||
)
|
||||
})
|
||||
45
packages/admin-ui/src/components/Carousel.module.css
Normal file
45
packages/admin-ui/src/components/Carousel.module.css
Normal 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);
|
||||
}
|
||||
26
packages/admin-ui/src/components/CollapsibleCard.jsx
Normal file
26
packages/admin-ui/src/components/CollapsibleCard.jsx
Normal 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 }
|
||||
105
packages/admin-ui/src/components/ConfirmDialog.jsx
Normal file
105
packages/admin-ui/src/components/ConfirmDialog.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
)
|
||||
74
packages/admin-ui/src/components/CopyToClipboard.jsx
Normal file
74
packages/admin-ui/src/components/CopyToClipboard.jsx
Normal 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
|
||||
65
packages/admin-ui/src/components/DeleteDialog.jsx
Normal file
65
packages/admin-ui/src/components/DeleteDialog.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
18
packages/admin-ui/src/components/ErrorMessage.jsx
Normal file
18
packages/admin-ui/src/components/ErrorMessage.jsx
Normal 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
|
||||
56
packages/admin-ui/src/components/ImagePopper.jsx
Normal file
56
packages/admin-ui/src/components/ImagePopper.jsx
Normal 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
|
||||
39
packages/admin-ui/src/components/InformativeDialog.jsx
Normal file
39
packages/admin-ui/src/components/InformativeDialog.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
)
|
||||
236
packages/admin-ui/src/components/LogsDownloaderPopper.jsx
Normal file
236
packages/admin-ui/src/components/LogsDownloaderPopper.jsx
Normal 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
|
||||
97
packages/admin-ui/src/components/Modal.jsx
Normal file
97
packages/admin-ui/src/components/Modal.jsx
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
@ -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%;
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
import NotificationCenter from './NotificationCenter'
|
||||
export default NotificationCenter
|
||||
77
packages/admin-ui/src/components/Popper.jsx
Normal file
77
packages/admin-ui/src/components/Popper.jsx
Normal 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
|
||||
33
packages/admin-ui/src/components/Popper.module.css
Normal file
33
packages/admin-ui/src/components/Popper.module.css
Normal 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;
|
||||
}
|
||||
28
packages/admin-ui/src/components/PromptWhenDirty.jsx
Normal file
28
packages/admin-ui/src/components/PromptWhenDirty.jsx
Normal 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
|
||||
83
packages/admin-ui/src/components/SearchBox.jsx
Normal file
83
packages/admin-ui/src/components/SearchBox.jsx
Normal 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
|
||||
53
packages/admin-ui/src/components/SearchFilter.jsx
Normal file
53
packages/admin-ui/src/components/SearchFilter.jsx
Normal 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
|
||||
23
packages/admin-ui/src/components/Status.jsx
Normal file
23
packages/admin-ui/src/components/Status.jsx
Normal 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 }
|
||||
61
packages/admin-ui/src/components/Stepper.jsx
Normal file
61
packages/admin-ui/src/components/Stepper.jsx
Normal 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
|
||||
12
packages/admin-ui/src/components/Stepper.module.css
Normal file
12
packages/admin-ui/src/components/Stepper.module.css
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
.stage {
|
||||
display: flex;
|
||||
height: 28px;
|
||||
width: 28px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.stage > svg {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: visible;
|
||||
}
|
||||
15
packages/admin-ui/src/components/Subtitle.jsx
Normal file
15
packages/admin-ui/src/components/Subtitle.jsx
Normal 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
|
||||
9
packages/admin-ui/src/components/Title.jsx
Normal file
9
packages/admin-ui/src/components/Title.jsx
Normal 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
|
||||
121
packages/admin-ui/src/components/Tooltip.jsx
Normal file
121
packages/admin-ui/src/components/Tooltip.jsx
Normal 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 }
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
import BooleanPropertiesTable from './BooleanPropertiesTable'
|
||||
|
||||
export { BooleanPropertiesTable }
|
||||
49
packages/admin-ui/src/components/buttons/ActionButton.jsx
Normal file
49
packages/admin-ui/src/components/buttons/ActionButton.jsx
Normal 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
|
||||
145
packages/admin-ui/src/components/buttons/ActionButton.module.css
Normal file
145
packages/admin-ui/src/components/buttons/ActionButton.module.css
Normal 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 {
|
||||
}
|
||||
16
packages/admin-ui/src/components/buttons/AddButton.jsx
Normal file
16
packages/admin-ui/src/components/buttons/AddButton.jsx
Normal 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
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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'
|
||||
}
|
||||
}
|
||||
}
|
||||
43
packages/admin-ui/src/components/buttons/Button.jsx
Normal file
43
packages/admin-ui/src/components/buttons/Button.jsx
Normal 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
|
||||
49
packages/admin-ui/src/components/buttons/Button.module.css
Normal file
49
packages/admin-ui/src/components/buttons/Button.module.css
Normal 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);
|
||||
}
|
||||
37
packages/admin-ui/src/components/buttons/FeatureButton.jsx
Normal file
37
packages/admin-ui/src/components/buttons/FeatureButton.jsx
Normal 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
|
||||
|
|
@ -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;
|
||||
}
|
||||
80
packages/admin-ui/src/components/buttons/IDButton.jsx
Normal file
80
packages/admin-ui/src/components/buttons/IDButton.jsx
Normal 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
|
||||
58
packages/admin-ui/src/components/buttons/IDButton.module.css
Normal file
58
packages/admin-ui/src/components/buttons/IDButton.module.css
Normal 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;
|
||||
}
|
||||
27
packages/admin-ui/src/components/buttons/Link.jsx
Normal file
27
packages/admin-ui/src/components/buttons/Link.jsx
Normal 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
|
||||
47
packages/admin-ui/src/components/buttons/Link.module.css
Normal file
47
packages/admin-ui/src/components/buttons/Link.module.css
Normal 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);
|
||||
}
|
||||
62
packages/admin-ui/src/components/buttons/SubpageButton.jsx
Normal file
62
packages/admin-ui/src/components/buttons/SubpageButton.jsx
Normal 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
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
19
packages/admin-ui/src/components/buttons/index.js
Normal file
19
packages/admin-ui/src/components/buttons/index.js
Normal 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
|
||||
}
|
||||
138
packages/admin-ui/src/components/date-range-picker/Calendar.jsx
Normal file
138
packages/admin-ui/src/components/date-range-picker/Calendar.jsx
Normal 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
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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
|
||||
41
packages/admin-ui/src/components/date-range-picker/Tile.jsx
Normal file
41
packages/admin-ui/src/components/date-range-picker/Tile.jsx
Normal 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
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
import React from 'react'
|
||||
|
||||
export default React.createContext()
|
||||
129
packages/admin-ui/src/components/editableTable/Header.jsx
Normal file
129
packages/admin-ui/src/components/editableTable/Header.jsx
Normal 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
|
||||
|
|
@ -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
|
||||
299
packages/admin-ui/src/components/editableTable/Row.jsx
Normal file
299
packages/admin-ui/src/components/editableTable/Row.jsx
Normal 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
|
||||
|
|
@ -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;
|
||||
}
|
||||
249
packages/admin-ui/src/components/editableTable/Table.jsx
Normal file
249
packages/admin-ui/src/components/editableTable/Table.jsx
Normal 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
|
||||
|
|
@ -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;
|
||||
}
|
||||
4
packages/admin-ui/src/components/editableTable/index.js
Normal file
4
packages/admin-ui/src/components/editableTable/index.js
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import NamespacedTable from './NamespacedTable'
|
||||
import Table from './Table'
|
||||
|
||||
export { Table, NamespacedTable }
|
||||
145
packages/admin-ui/src/components/fake-table/Table.jsx
Normal file
145
packages/admin-ui/src/components/fake-table/Table.jsx
Normal 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
|
||||
}
|
||||
106
packages/admin-ui/src/components/fake-table/Table.module.css
Normal file
106
packages/admin-ui/src/components/fake-table/Table.module.css
Normal 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;
|
||||
}
|
||||
130
packages/admin-ui/src/components/inputs/base/Autocomplete.jsx
Normal file
130
packages/admin-ui/src/components/inputs/base/Autocomplete.jsx
Normal 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
|
||||
46
packages/admin-ui/src/components/inputs/base/Checkbox.jsx
Normal file
46
packages/admin-ui/src/components/inputs/base/Checkbox.jsx
Normal 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
|
||||
37
packages/admin-ui/src/components/inputs/base/CodeInput.jsx
Normal file
37
packages/admin-ui/src/components/inputs/base/CodeInput.jsx
Normal 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
|
||||
|
|
@ -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;
|
||||
}
|
||||
29
packages/admin-ui/src/components/inputs/base/Dropdown.jsx
Normal file
29
packages/admin-ui/src/components/inputs/base/Dropdown.jsx
Normal 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
|
||||
54
packages/admin-ui/src/components/inputs/base/NumberInput.jsx
Normal file
54
packages/admin-ui/src/components/inputs/base/NumberInput.jsx
Normal 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
|
||||
53
packages/admin-ui/src/components/inputs/base/RadioGroup.jsx
Normal file
53
packages/admin-ui/src/components/inputs/base/RadioGroup.jsx
Normal 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
|
||||
35
packages/admin-ui/src/components/inputs/base/SecretInput.jsx
Normal file
35
packages/admin-ui/src/components/inputs/base/SecretInput.jsx
Normal 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
|
||||
53
packages/admin-ui/src/components/inputs/base/Select.jsx
Normal file
53
packages/admin-ui/src/components/inputs/base/Select.jsx
Normal 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
|
||||
100
packages/admin-ui/src/components/inputs/base/Select.module.css
Normal file
100
packages/admin-ui/src/components/inputs/base/Select.module.css
Normal 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;
|
||||
}
|
||||
74
packages/admin-ui/src/components/inputs/base/TextInput.jsx
Normal file
74
packages/admin-ui/src/components/inputs/base/TextInput.jsx
Normal 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
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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
|
||||
20
packages/admin-ui/src/components/inputs/base/index.js
Normal file
20
packages/admin-ui/src/components/inputs/base/index.js
Normal 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
|
||||
}
|
||||
181
packages/admin-ui/src/components/inputs/cashbox/Cashbox.jsx
Normal file
181
packages/admin-ui/src/components/inputs/cashbox/Cashbox.jsx
Normal 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 }
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
34
packages/admin-ui/src/components/inputs/formik/Checkbox.jsx
Normal file
34
packages/admin-ui/src/components/inputs/formik/Checkbox.jsx
Normal 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
|
||||
25
packages/admin-ui/src/components/inputs/formik/Dropdown.jsx
Normal file
25
packages/admin-ui/src/components/inputs/formik/Dropdown.jsx
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
23
packages/admin-ui/src/components/inputs/formik/TextInput.jsx
Normal file
23
packages/admin-ui/src/components/inputs/formik/TextInput.jsx
Normal 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
|
||||
|
|
@ -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
|
||||
19
packages/admin-ui/src/components/inputs/formik/index.js
Normal file
19
packages/admin-ui/src/components/inputs/formik/index.js
Normal 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
|
||||
}
|
||||
18
packages/admin-ui/src/components/inputs/index.js
Normal file
18
packages/admin-ui/src/components/inputs/index.js
Normal 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
|
||||
}
|
||||
210
packages/admin-ui/src/components/layout/Header.jsx
Normal file
210
packages/admin-ui/src/components/layout/Header.jsx
Normal 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
|
||||
175
packages/admin-ui/src/components/layout/Header.module.css
Normal file
175
packages/admin-ui/src/components/layout/Header.module.css
Normal 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;
|
||||
}
|
||||
19
packages/admin-ui/src/components/layout/Section.jsx
Normal file
19
packages/admin-ui/src/components/layout/Section.jsx
Normal 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
|
||||
75
packages/admin-ui/src/components/layout/Sidebar.jsx
Normal file
75
packages/admin-ui/src/components/layout/Sidebar.jsx
Normal 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 }
|
||||
106
packages/admin-ui/src/components/layout/Sidebar.module.css
Normal file
106
packages/admin-ui/src/components/layout/Sidebar.module.css
Normal 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);
|
||||
}
|
||||
66
packages/admin-ui/src/components/layout/TitleSection.jsx
Normal file
66
packages/admin-ui/src/components/layout/TitleSection.jsx
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue