Unify modals and buttons style and layout
This commit is contained in:
@@ -1,16 +1,8 @@
|
|||||||
import { Check, Star } from '@mui/icons-material'
|
import { Check, Star } from '@mui/icons-material'
|
||||||
import {
|
import { Box, Card, Chip, Divider, Radio, Typography } from '@mui/joy'
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
Chip,
|
|
||||||
Divider,
|
|
||||||
Modal,
|
|
||||||
ModalDialog,
|
|
||||||
Radio,
|
|
||||||
Typography,
|
|
||||||
} from '@mui/joy'
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import AppModal from './common/AppModal'
|
||||||
|
import ModalActions from './common/ModalActions'
|
||||||
import { useNotification } from '../service/NotificationProvider'
|
import { useNotification } from '../service/NotificationProvider'
|
||||||
import { GetSubscriptionSession } from '../utils/Fetcher'
|
import { GetSubscriptionSession } from '../utils/Fetcher'
|
||||||
|
|
||||||
@@ -76,183 +68,158 @@ const SubscriptionModal = ({ open, onClose }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal open={open} onClose={onClose}>
|
<AppModal
|
||||||
<ModalDialog
|
open={open}
|
||||||
layout='center'
|
onClose={onClose}
|
||||||
sx={{
|
title='Upgrade to Plus'
|
||||||
width: 600,
|
description='Unlock reminders, rich task details, and advanced automation.'
|
||||||
maxWidth: '95vw',
|
size='lg'
|
||||||
maxHeight: '95vh',
|
closeOnBackdrop={!isLoading}
|
||||||
overflow: 'auto',
|
closeOnEscape={!isLoading}
|
||||||
p: 0,
|
footer={
|
||||||
}}
|
<ModalActions
|
||||||
>
|
stackOnMobile
|
||||||
<Box sx={{ p: 4 }}>
|
secondary={{ label: 'Cancel', onClick: onClose, disabled: isLoading }}
|
||||||
{/* Header */}
|
primary={{
|
||||||
<Box sx={{ textAlign: 'center', mb: 4 }}>
|
label: 'Subscribe',
|
||||||
<Typography level='h3' sx={{ mb: 1 }}>
|
onClick: handleSubscribe,
|
||||||
Upgrade to Plus
|
loading: isLoading,
|
||||||
</Typography>
|
}}
|
||||||
</Box>
|
/>
|
||||||
|
}
|
||||||
{/* Features List */}
|
>
|
||||||
<Box sx={{ mb: 2 }}>
|
{/* Features List */}
|
||||||
<Typography level='title-lg' sx={{ mb: 2 }}>
|
<Box sx={{ mb: 2 }}>
|
||||||
What's included:
|
<Typography level='title-lg' sx={{ mb: 2 }}>
|
||||||
</Typography>
|
What's included:
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
</Typography>
|
||||||
{features.map((feature, index) => (
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||||
<Box
|
{features.map((feature, index) => (
|
||||||
key={index}
|
<Box
|
||||||
sx={{ display: 'flex', alignItems: 'center', gap: 2 }}
|
key={index}
|
||||||
>
|
sx={{ display: 'flex', alignItems: 'center', gap: 2 }}
|
||||||
<Check color='success' sx={{ fontSize: 20 }} />
|
>
|
||||||
<Typography level='body-md'>{feature}</Typography>
|
<Check color='success' sx={{ fontSize: 20 }} />
|
||||||
</Box>
|
<Typography level='body-md'>{feature}</Typography>
|
||||||
))}
|
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
))}
|
||||||
<Divider sx={{ my: 3 }} />
|
|
||||||
|
|
||||||
{/* Plan Selection */}
|
|
||||||
<Box
|
|
||||||
sx={{ display: 'flex', flexDirection: 'column', gap: 1.2, mb: 4 }}
|
|
||||||
>
|
|
||||||
{Object.entries(plans).map(([key, plan]) => (
|
|
||||||
<Card
|
|
||||||
key={key}
|
|
||||||
color={selectedPlan === key ? 'primary' : 'neutral'}
|
|
||||||
onClick={() => setSelectedPlan(key)}
|
|
||||||
sx={{
|
|
||||||
width: '100%',
|
|
||||||
minHeight: 48,
|
|
||||||
maxHeight: 64,
|
|
||||||
cursor: 'pointer',
|
|
||||||
transition: 'all 0.2s',
|
|
||||||
mb: 0.2,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
px: 2.5,
|
|
||||||
py: 1.2,
|
|
||||||
position: 'relative',
|
|
||||||
overflow: 'visible',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 2,
|
|
||||||
justifyContent: 'flex-start',
|
|
||||||
width: '100%',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Radio
|
|
||||||
checked={selectedPlan === key}
|
|
||||||
onChange={() => setSelectedPlan(key)}
|
|
||||||
value={key}
|
|
||||||
name='subscription-plan'
|
|
||||||
color='primary'
|
|
||||||
sx={{ mr: 1 }}
|
|
||||||
/>
|
|
||||||
<Typography level='body-md' sx={{ fontWeight: 600 }}>
|
|
||||||
{key.charAt(0).toUpperCase() + key.slice(1)}
|
|
||||||
</Typography>
|
|
||||||
<Typography level='body-sm' sx={{ fontWeight: 500, ml: 1 }}>
|
|
||||||
{plan.price}
|
|
||||||
<span style={{ color: '#888', fontWeight: 400 }}>
|
|
||||||
{' '}
|
|
||||||
/ {plan.period}
|
|
||||||
</span>
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 0.5,
|
|
||||||
position: 'absolute',
|
|
||||||
right: 16,
|
|
||||||
top: -18,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{plan.popular && (
|
|
||||||
<Chip
|
|
||||||
variant='solid'
|
|
||||||
color='warning'
|
|
||||||
size='sm'
|
|
||||||
startDecorator={<Star />}
|
|
||||||
sx={{
|
|
||||||
fontWeight: 600,
|
|
||||||
fontSize: 12,
|
|
||||||
px: 1,
|
|
||||||
py: 0.1,
|
|
||||||
boxShadow: 2,
|
|
||||||
mt: 0.8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Most Popular
|
|
||||||
</Chip>
|
|
||||||
)}
|
|
||||||
{plan.savings && (
|
|
||||||
<Chip
|
|
||||||
variant='soft'
|
|
||||||
color='success'
|
|
||||||
size='sm'
|
|
||||||
sx={{
|
|
||||||
fontWeight: 600,
|
|
||||||
fontSize: 12,
|
|
||||||
px: 1,
|
|
||||||
py: 0.1,
|
|
||||||
boxShadow: 2,
|
|
||||||
mt: 0.8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{plan.savings}
|
|
||||||
</Chip>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Action Buttons */}
|
|
||||||
<Box
|
|
||||||
sx={{ display: 'flex', flexDirection: 'column', gap: 1.2, mt: 2 }}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant='solid'
|
|
||||||
color='primary'
|
|
||||||
onClick={handleSubscribe}
|
|
||||||
loading={isLoading}
|
|
||||||
fullWidth
|
|
||||||
size='lg'
|
|
||||||
sx={{ mb: 1 }}
|
|
||||||
>
|
|
||||||
Subscribe
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant='plain'
|
|
||||||
onClick={onClose}
|
|
||||||
disabled={isLoading}
|
|
||||||
fullWidth
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<Typography
|
|
||||||
level='body-xs'
|
|
||||||
color='neutral'
|
|
||||||
sx={{ textAlign: 'center', mt: 3 }}
|
|
||||||
>
|
|
||||||
Cancel anytime. No hidden fees. Secure payment powered by Stripe.
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
</Box>
|
||||||
</ModalDialog>
|
</Box>
|
||||||
</Modal>
|
<Divider sx={{ my: 3 }} />
|
||||||
|
|
||||||
|
{/* Plan Selection */}
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.2, mb: 4 }}>
|
||||||
|
{Object.entries(plans).map(([key, plan]) => (
|
||||||
|
<Card
|
||||||
|
component='label'
|
||||||
|
htmlFor={`subscription-plan-${key}`}
|
||||||
|
key={key}
|
||||||
|
color={selectedPlan === key ? 'primary' : 'neutral'}
|
||||||
|
onClick={() => setSelectedPlan(key)}
|
||||||
|
sx={{
|
||||||
|
width: '100%',
|
||||||
|
minHeight: 48,
|
||||||
|
maxHeight: 64,
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
mb: 0.2,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
px: 2.5,
|
||||||
|
py: 1.2,
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'visible',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 2,
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Radio
|
||||||
|
id={`subscription-plan-${key}`}
|
||||||
|
checked={selectedPlan === key}
|
||||||
|
onChange={() => setSelectedPlan(key)}
|
||||||
|
value={key}
|
||||||
|
name='subscription-plan'
|
||||||
|
color='primary'
|
||||||
|
sx={{ mr: 1 }}
|
||||||
|
/>
|
||||||
|
<Typography level='body-md' sx={{ fontWeight: 600 }}>
|
||||||
|
{key.charAt(0).toUpperCase() + key.slice(1)}
|
||||||
|
</Typography>
|
||||||
|
<Typography level='body-sm' sx={{ fontWeight: 500, ml: 1 }}>
|
||||||
|
{plan.price}
|
||||||
|
<span style={{ color: '#888', fontWeight: 400 }}>
|
||||||
|
{' '}
|
||||||
|
/ {plan.period}
|
||||||
|
</span>
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 0.5,
|
||||||
|
position: 'absolute',
|
||||||
|
right: 16,
|
||||||
|
top: -18,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{plan.popular && (
|
||||||
|
<Chip
|
||||||
|
variant='solid'
|
||||||
|
color='warning'
|
||||||
|
size='sm'
|
||||||
|
startDecorator={<Star />}
|
||||||
|
sx={{
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: 12,
|
||||||
|
px: 1,
|
||||||
|
py: 0.1,
|
||||||
|
boxShadow: 2,
|
||||||
|
mt: 0.8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Most Popular
|
||||||
|
</Chip>
|
||||||
|
)}
|
||||||
|
{plan.savings && (
|
||||||
|
<Chip
|
||||||
|
variant='soft'
|
||||||
|
color='success'
|
||||||
|
size='sm'
|
||||||
|
sx={{
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: 12,
|
||||||
|
px: 1,
|
||||||
|
py: 0.1,
|
||||||
|
boxShadow: 2,
|
||||||
|
mt: 0.8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{plan.savings}
|
||||||
|
</Chip>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<Typography
|
||||||
|
level='body-xs'
|
||||||
|
color='neutral'
|
||||||
|
sx={{ textAlign: 'center', mt: 3 }}
|
||||||
|
>
|
||||||
|
Cancel anytime. No hidden fees. Secure payment powered by Stripe.
|
||||||
|
</Typography>
|
||||||
|
</AppModal>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
230
src/components/common/AppModal.jsx
Normal file
230
src/components/common/AppModal.jsx
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
import { Close } from '@mui/icons-material'
|
||||||
|
import { Box, Divider, IconButton, Modal, Sheet, Typography } from '@mui/joy'
|
||||||
|
import useMediaQuery from '@mui/material/useMediaQuery'
|
||||||
|
import { forwardRef, useId } from 'react'
|
||||||
|
import { Z_INDEX } from '../../constants/zIndex'
|
||||||
|
|
||||||
|
const WIDTH_BY_SIZE = {
|
||||||
|
sm: 400,
|
||||||
|
md: 520,
|
||||||
|
lg: 680,
|
||||||
|
xl: 840,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The app's modal primitive. It owns modal layout, spacing, accessibility,
|
||||||
|
* responsive presentation, and motion so feature components only own content.
|
||||||
|
*/
|
||||||
|
const AppModal = forwardRef(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
children,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
footer,
|
||||||
|
size = 'md',
|
||||||
|
fullWidth = true,
|
||||||
|
isMobile: isMobileProp,
|
||||||
|
mobilePresentation = 'sheet',
|
||||||
|
role = 'dialog',
|
||||||
|
showCloseButton = true,
|
||||||
|
showHandle = false,
|
||||||
|
closeOnBackdrop = true,
|
||||||
|
closeOnEscape = true,
|
||||||
|
backdropBlur = true,
|
||||||
|
maxHeight = '90dvh',
|
||||||
|
contentSx,
|
||||||
|
footerSx,
|
||||||
|
sx,
|
||||||
|
unmountDelay = 180,
|
||||||
|
...modalProps
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) => {
|
||||||
|
const generatedId = useId()
|
||||||
|
const detectedMobile = useMediaQuery('(max-width:768px)')
|
||||||
|
const isMobile = isMobileProp ?? detectedMobile
|
||||||
|
const titleId = title ? `${generatedId}-title` : undefined
|
||||||
|
const descriptionId = description ? `${generatedId}-description` : undefined
|
||||||
|
const isSheet = isMobile && mobilePresentation === 'sheet'
|
||||||
|
const isFullscreen = isMobile && mobilePresentation === 'fullscreen'
|
||||||
|
|
||||||
|
const handleClose = (event, reason) => {
|
||||||
|
if (reason === 'backdropClick' && !closeOnBackdrop) return
|
||||||
|
if (reason === 'escapeKeyDown' && !closeOnEscape) return
|
||||||
|
onClose?.(event, reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
onClose={handleClose}
|
||||||
|
aria-labelledby={titleId}
|
||||||
|
aria-describedby={descriptionId}
|
||||||
|
keepMounted
|
||||||
|
sx={{
|
||||||
|
zIndex: Z_INDEX.MODAL_BACKDROP,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: isSheet ? 'flex-end' : 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
p: isMobile ? 0 : 2,
|
||||||
|
'& .MuiModal-backdrop': {
|
||||||
|
backgroundColor: 'rgba(8, 15, 24, 0.52)',
|
||||||
|
backdropFilter: backdropBlur ? 'blur(4px)' : 'none',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
{...modalProps}
|
||||||
|
>
|
||||||
|
<Sheet
|
||||||
|
ref={ref}
|
||||||
|
role={role}
|
||||||
|
aria-modal='true'
|
||||||
|
aria-labelledby={titleId}
|
||||||
|
aria-describedby={descriptionId}
|
||||||
|
variant='outlined'
|
||||||
|
sx={{
|
||||||
|
zIndex: Z_INDEX.MODAL_CONTENT,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
width: isFullscreen
|
||||||
|
? '100%'
|
||||||
|
: isSheet
|
||||||
|
? '100%'
|
||||||
|
: fullWidth
|
||||||
|
? `min(calc(100vw - 32px), ${WIDTH_BY_SIZE[size] || WIDTH_BY_SIZE.md}px)`
|
||||||
|
: 'auto',
|
||||||
|
maxWidth: isMobile
|
||||||
|
? 'none'
|
||||||
|
: WIDTH_BY_SIZE[size] || WIDTH_BY_SIZE.md,
|
||||||
|
height: isFullscreen ? '100dvh' : 'auto',
|
||||||
|
maxHeight: isFullscreen ? '100dvh' : maxHeight,
|
||||||
|
overflow: 'hidden',
|
||||||
|
borderRadius: isFullscreen ? 0 : isSheet ? '16px 16px 0 0' : '16px',
|
||||||
|
borderBottom: isSheet ? 0 : undefined,
|
||||||
|
boxShadow: 'lg',
|
||||||
|
outline: 0,
|
||||||
|
pb: isMobile ? 'env(safe-area-inset-bottom)' : 0,
|
||||||
|
animation: open
|
||||||
|
? `${isSheet ? 'appSheetEnter' : 'appModalEnter'} ${Math.min(unmountDelay, 300)}ms cubic-bezier(0.2, 0.8, 0.2, 1)`
|
||||||
|
: undefined,
|
||||||
|
'@keyframes appModalEnter': {
|
||||||
|
from: { opacity: 0, transform: 'translateY(8px) scale(0.99)' },
|
||||||
|
to: { opacity: 1, transform: 'translateY(0) scale(1)' },
|
||||||
|
},
|
||||||
|
'@keyframes appSheetEnter': {
|
||||||
|
from: { transform: 'translateY(24px)' },
|
||||||
|
to: { transform: 'translateY(0)' },
|
||||||
|
},
|
||||||
|
'@media (prefers-reduced-motion: reduce)': {
|
||||||
|
animation: 'none',
|
||||||
|
},
|
||||||
|
...sx,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isSheet && showHandle && (
|
||||||
|
<Box
|
||||||
|
aria-hidden='true'
|
||||||
|
sx={{ display: 'flex', justifyContent: 'center', pt: 1.25 }}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 36,
|
||||||
|
height: 4,
|
||||||
|
borderRadius: 999,
|
||||||
|
bgcolor: 'neutral.300',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(title || description || showCloseButton) && (
|
||||||
|
<Box
|
||||||
|
component='header'
|
||||||
|
sx={{
|
||||||
|
position: 'relative',
|
||||||
|
flexShrink: 0,
|
||||||
|
px: { xs: 2, sm: 3 },
|
||||||
|
pt: isSheet && showHandle ? 1.25 : { xs: 2, sm: 2.5 },
|
||||||
|
pb: description ? 1.5 : 2,
|
||||||
|
pr: showCloseButton ? { xs: 7, sm: 8 } : { xs: 2, sm: 3 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title && (
|
||||||
|
<Typography
|
||||||
|
id={titleId}
|
||||||
|
level='title-lg'
|
||||||
|
sx={{ fontWeight: 650 }}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
{description && (
|
||||||
|
<Typography
|
||||||
|
id={descriptionId}
|
||||||
|
level='body-sm'
|
||||||
|
sx={{ color: 'text.tertiary', mt: 0.5 }}
|
||||||
|
>
|
||||||
|
{description}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
{showCloseButton && (
|
||||||
|
<IconButton
|
||||||
|
aria-label='Close dialog'
|
||||||
|
variant='plain'
|
||||||
|
color='neutral'
|
||||||
|
onClick={handleClose}
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: isSheet && showHandle ? 6 : 12,
|
||||||
|
right: { xs: 10, sm: 16 },
|
||||||
|
borderRadius: '50%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Close />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
overflowY: 'auto',
|
||||||
|
overscrollBehavior: 'contain',
|
||||||
|
px: { xs: 2, sm: 3 },
|
||||||
|
pb: { xs: 2.5, sm: 3 },
|
||||||
|
...contentSx,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{footer && (
|
||||||
|
<>
|
||||||
|
<Divider />
|
||||||
|
<Box
|
||||||
|
component='footer'
|
||||||
|
sx={{
|
||||||
|
flexShrink: 0,
|
||||||
|
px: { xs: 2, sm: 3 },
|
||||||
|
py: 2,
|
||||||
|
bgcolor: 'background.surface',
|
||||||
|
...footerSx,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{footer}
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Sheet>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
AppModal.displayName = 'AppModal'
|
||||||
|
|
||||||
|
export default AppModal
|
||||||
@@ -1,254 +0,0 @@
|
|||||||
import { Close } from '@mui/icons-material'
|
|
||||||
import { Divider, IconButton, Modal, Sheet, Typography } from '@mui/joy'
|
|
||||||
import { forwardRef, useEffect, useState } from 'react'
|
|
||||||
import { Z_INDEX } from '../../constants/zIndex'
|
|
||||||
|
|
||||||
const BottomSheetModal = forwardRef(
|
|
||||||
(
|
|
||||||
{
|
|
||||||
open,
|
|
||||||
onClose,
|
|
||||||
children,
|
|
||||||
title,
|
|
||||||
footer,
|
|
||||||
height = 'auto',
|
|
||||||
maxHeight = '90vh',
|
|
||||||
expandedHeight = '95vh',
|
|
||||||
backdropBlur = true,
|
|
||||||
showHandle = true,
|
|
||||||
showCloseButton = true,
|
|
||||||
...props
|
|
||||||
},
|
|
||||||
ref,
|
|
||||||
) => {
|
|
||||||
const [isExpanded, setIsExpanded] = useState(false)
|
|
||||||
const [isClosing, setIsClosing] = useState(false)
|
|
||||||
const [internalOpen, setInternalOpen] = useState(open)
|
|
||||||
|
|
||||||
// Handle opening
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) {
|
|
||||||
setInternalOpen(true)
|
|
||||||
setIsClosing(false)
|
|
||||||
}
|
|
||||||
}, [open])
|
|
||||||
|
|
||||||
// Handle closing with animation
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open && internalOpen) {
|
|
||||||
setIsClosing(true)
|
|
||||||
// Wait for animation to complete before hiding modal
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
setInternalOpen(false)
|
|
||||||
setIsClosing(false)
|
|
||||||
setIsExpanded(false)
|
|
||||||
}, 250) // Match transition duration
|
|
||||||
|
|
||||||
return () => clearTimeout(timer)
|
|
||||||
}
|
|
||||||
}, [open, internalOpen])
|
|
||||||
|
|
||||||
// Handle toggle expansion
|
|
||||||
const handleToggleExpansion = () => {
|
|
||||||
setIsExpanded(prev => !prev)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close on escape key
|
|
||||||
useEffect(() => {
|
|
||||||
const handleEscape = event => {
|
|
||||||
if (event.key === 'Escape' && internalOpen) {
|
|
||||||
onClose?.()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (internalOpen) {
|
|
||||||
document.addEventListener('keydown', handleEscape)
|
|
||||||
// Prevent body scroll when modal is open
|
|
||||||
// document.body.style.overflow = 'hidden'
|
|
||||||
} else {
|
|
||||||
// Restore scroll immediately when modal starts closing
|
|
||||||
// document.body.style.overflow = 'unset'
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('keydown', handleEscape)
|
|
||||||
document.body.style.overflow = 'unset'
|
|
||||||
}
|
|
||||||
}, [internalOpen, onClose])
|
|
||||||
|
|
||||||
// Calculate current height
|
|
||||||
const currentHeight = isExpanded ? expandedHeight : height
|
|
||||||
|
|
||||||
// Filter out DOM props that shouldn't be passed to Modal
|
|
||||||
const {
|
|
||||||
fullWidth: _fullWidth,
|
|
||||||
unmountDelay: _unmountDelay,
|
|
||||||
...modalProps
|
|
||||||
} = props
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
open={internalOpen}
|
|
||||||
onClose={onClose}
|
|
||||||
sx={{
|
|
||||||
'& .MuiModal-backdrop': {
|
|
||||||
backdropFilter: backdropBlur ? 'blur(3px)' : 'none',
|
|
||||||
backgroundColor: 'rgba(0, 0, 0, 0.4)',
|
|
||||||
},
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'flex-end',
|
|
||||||
justifyContent: 'center',
|
|
||||||
}}
|
|
||||||
keepMounted
|
|
||||||
{...modalProps}
|
|
||||||
>
|
|
||||||
<Sheet
|
|
||||||
ref={ref}
|
|
||||||
sx={{
|
|
||||||
zIndex: Z_INDEX.MODAL_CONTENT,
|
|
||||||
minHeight: '20%',
|
|
||||||
width: '100%',
|
|
||||||
height: currentHeight,
|
|
||||||
maxHeight: isExpanded ? expandedHeight : maxHeight,
|
|
||||||
borderTopLeftRadius: 16,
|
|
||||||
borderTopRightRadius: 16,
|
|
||||||
borderBottomLeftRadius: 0,
|
|
||||||
borderBottomRightRadius: 0,
|
|
||||||
p: 0,
|
|
||||||
overflow: 'hidden',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
transition:
|
|
||||||
'height 0.3s cubic-bezier(0.32, 0.72, 0, 1), max-height 0.3s cubic-bezier(0.32, 0.72, 0, 1), transform 0.3s cubic-bezier(0.32, 0.72, 0, 1)',
|
|
||||||
transform:
|
|
||||||
open && !isClosing ? 'translateY(0)' : 'translateY(100%)',
|
|
||||||
// Handle safe area on mobile devices
|
|
||||||
paddingBottom: 'env(safe-area-inset-bottom)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Header Section with drag handle, title, and close button */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
backgroundColor: 'inherit',
|
|
||||||
borderTopLeftRadius: 16,
|
|
||||||
borderTopRightRadius: 16,
|
|
||||||
position: 'relative',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Close button positioned absolutely in top-right */}
|
|
||||||
{showCloseButton && (
|
|
||||||
<IconButton
|
|
||||||
variant='soft'
|
|
||||||
color='neutral'
|
|
||||||
size='sm'
|
|
||||||
onClick={onClose}
|
|
||||||
sx={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: 8,
|
|
||||||
right: 16,
|
|
||||||
zIndex: 1,
|
|
||||||
borderRadius: '50%',
|
|
||||||
width: 32,
|
|
||||||
height: 32,
|
|
||||||
backgroundColor: 'neutral.softBg',
|
|
||||||
color: 'neutral.softColor',
|
|
||||||
'&:hover': {
|
|
||||||
backgroundColor: 'neutral.softHoverBg',
|
|
||||||
transform: 'scale(1.05)',
|
|
||||||
},
|
|
||||||
transition: 'all 0.2s ease',
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Close fontSize='small' />
|
|
||||||
</IconButton>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Drag Handle */}
|
|
||||||
{showHandle && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'center',
|
|
||||||
padding: '12px 0 8px 0',
|
|
||||||
cursor: 'pointer',
|
|
||||||
userSelect: 'none',
|
|
||||||
marginBottom: 16,
|
|
||||||
}}
|
|
||||||
onClick={handleToggleExpansion}
|
|
||||||
title={isExpanded ? 'Collapse' : 'Expand'}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: 30,
|
|
||||||
height: 4,
|
|
||||||
borderRadius: 2,
|
|
||||||
backgroundColor: 'var(--joy-palette-neutral-300)',
|
|
||||||
transition: 'background-color 0.2s ease',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Title Row */}
|
|
||||||
{title && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
padding: showHandle
|
|
||||||
? '0 20px 16px 20px'
|
|
||||||
: '16px 20px 16px 20px',
|
|
||||||
paddingRight: showCloseButton ? '60px' : '20px', // Add space for close button
|
|
||||||
minHeight: 24,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography
|
|
||||||
level='title-lg'
|
|
||||||
sx={{
|
|
||||||
fontWeight: 600,
|
|
||||||
flex: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
</Typography>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{/* Content area */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
overflow: 'auto',
|
|
||||||
padding: '0 20px 20px 20px',
|
|
||||||
minHeight: 0, // Important for flex child with overflow
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{footer && (
|
|
||||||
<>
|
|
||||||
<Divider />
|
|
||||||
<footer
|
|
||||||
style={{
|
|
||||||
flexShrink: 0,
|
|
||||||
// borderTop: '1px solid var(--joy-palette-divider)',
|
|
||||||
padding: '16px 20px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{footer}
|
|
||||||
</footer>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Sheet>
|
|
||||||
</Modal>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
BottomSheetModal.displayName = 'BottomSheetModal'
|
|
||||||
|
|
||||||
export default BottomSheetModal
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
import { Modal, ModalClose, ModalDialog, ModalOverflow, Typography } from '@mui/joy'
|
|
||||||
import { Z_INDEX } from '../../constants/zIndex'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* FadeModal component with consistent fade-in/out animations
|
|
||||||
* Can be used as a drop-in replacement for Joy UI's Modal component
|
|
||||||
*/
|
|
||||||
const FadeModal = ({
|
|
||||||
open,
|
|
||||||
onClose,
|
|
||||||
children,
|
|
||||||
size = 'md',
|
|
||||||
fullWidth = true,
|
|
||||||
backdropBlur = true,
|
|
||||||
title,
|
|
||||||
footer,
|
|
||||||
...props
|
|
||||||
}) => {
|
|
||||||
// Filter out props that shouldn't be passed to Modal
|
|
||||||
const { unmountDelay: _unmountDelay, ...modalProps } = props
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
open={open}
|
|
||||||
onClose={onClose}
|
|
||||||
sx={{
|
|
||||||
'& .MuiModal-backdrop': {
|
|
||||||
backdropFilter: backdropBlur ? 'blur(3px)' : 'none',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
keepMounted
|
|
||||||
// These transition properties create a smooth fade + slide effect
|
|
||||||
transition={{
|
|
||||||
mount: { opacity: 1, transform: 'translateY(0px)' },
|
|
||||||
unmount: { opacity: 0, transform: 'translateY(20px)' },
|
|
||||||
duration: 250, // Animation duration in ms
|
|
||||||
easing: {
|
|
||||||
enter: 'cubic-bezier(0.34, 1.56, 0.64, 1)', // Slight overshoot for natural feel
|
|
||||||
exit: 'cubic-bezier(0.4, 0, 0.2, 1)', // Standard ease out
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
{...modalProps}
|
|
||||||
>
|
|
||||||
<ModalOverflow>
|
|
||||||
<ModalDialog
|
|
||||||
size={size}
|
|
||||||
sx={{
|
|
||||||
zIndex: Z_INDEX.MODAL_CONTENT,
|
|
||||||
minWidth: fullWidth ? '90%' : 'auto',
|
|
||||||
animation: open
|
|
||||||
? 'modalFadeIn 0.35s forwards'
|
|
||||||
: 'modalFadeOut 0.25s forwards',
|
|
||||||
'@keyframes modalFadeIn': {
|
|
||||||
from: { opacity: 0, transform: 'translateY(8px)' },
|
|
||||||
to: { opacity: 1, transform: 'translateY(0)' },
|
|
||||||
},
|
|
||||||
'@keyframes modalFadeOut': {
|
|
||||||
from: { opacity: 1, transform: 'translateY(0)' },
|
|
||||||
to: { opacity: 0, transform: 'translateY(8px)' },
|
|
||||||
},
|
|
||||||
// Add staggered animation for child elements
|
|
||||||
'& > *': {
|
|
||||||
opacity: 0,
|
|
||||||
animation: open
|
|
||||||
? 'contentFadeIn 0.35s forwards'
|
|
||||||
: 'contentFadeOut 0.2s forwards',
|
|
||||||
},
|
|
||||||
// Stagger child animations
|
|
||||||
'& > *:nth-of-type(1)': { animationDelay: '0.05s' },
|
|
||||||
'& > *:nth-of-type(2)': { animationDelay: '0.1s' },
|
|
||||||
'& > *:nth-of-type(3)': { animationDelay: '0.15s' },
|
|
||||||
'& > *:nth-of-type(4)': { animationDelay: '0.2s' },
|
|
||||||
'& > *:nth-of-type(5)': { animationDelay: '0.25s' },
|
|
||||||
'@keyframes contentFadeIn': {
|
|
||||||
to: { opacity: 1 },
|
|
||||||
},
|
|
||||||
'@keyframes contentFadeOut': {
|
|
||||||
to: { opacity: 0 },
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ModalClose />
|
|
||||||
{title && (
|
|
||||||
<Typography level='title-lg' sx={{ fontWeight: 600, mb: 2 }}>
|
|
||||||
{title}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
<div style={{ flex: 1, overflow: 'auto' }}>{children}</div>
|
|
||||||
{footer && <div style={{ marginTop: 16 }}>{footer}</div>}
|
|
||||||
</ModalDialog>
|
|
||||||
</ModalOverflow>
|
|
||||||
</Modal>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default FadeModal
|
|
||||||
@@ -10,7 +10,8 @@ import {
|
|||||||
Typography,
|
Typography,
|
||||||
} from '@mui/joy'
|
} from '@mui/joy'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import BottomSheetModal from './BottomSheetModal'
|
import AppModal from './AppModal'
|
||||||
|
import ModalActions from './ModalActions'
|
||||||
import ActiveFilterChips from './filter/ActiveFilterChips'
|
import ActiveFilterChips from './filter/ActiveFilterChips'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -41,7 +42,10 @@ const DATE_RANGE_PRESETS = [
|
|||||||
label: 'Today',
|
label: 'Today',
|
||||||
getRange: () => {
|
getRange: () => {
|
||||||
const t = d(new Date())
|
const t = d(new Date())
|
||||||
return { from: t.toISOString(), to: d(new Date(), 23, 59, 59, 999).toISOString() }
|
return {
|
||||||
|
from: t.toISOString(),
|
||||||
|
to: d(new Date(), 23, 59, 59, 999).toISOString(),
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -49,8 +53,12 @@ const DATE_RANGE_PRESETS = [
|
|||||||
label: 'Yesterday',
|
label: 'Yesterday',
|
||||||
getRange: () => {
|
getRange: () => {
|
||||||
const t = d(new Date())
|
const t = d(new Date())
|
||||||
const y = new Date(t); y.setDate(t.getDate() - 1)
|
const y = new Date(t)
|
||||||
return { from: d(y).toISOString(), to: d(y, 23, 59, 59, 999).toISOString() }
|
y.setDate(t.getDate() - 1)
|
||||||
|
return {
|
||||||
|
from: d(y).toISOString(),
|
||||||
|
to: d(y, 23, 59, 59, 999).toISOString(),
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -58,9 +66,14 @@ const DATE_RANGE_PRESETS = [
|
|||||||
label: 'This Week',
|
label: 'This Week',
|
||||||
getRange: () => {
|
getRange: () => {
|
||||||
const t = d(new Date())
|
const t = d(new Date())
|
||||||
const start = new Date(t); start.setDate(t.getDate() - t.getDay())
|
const start = new Date(t)
|
||||||
const end = new Date(start); end.setDate(start.getDate() + 6)
|
start.setDate(t.getDate() - t.getDay())
|
||||||
return { from: d(start).toISOString(), to: d(end, 23, 59, 59, 999).toISOString() }
|
const end = new Date(start)
|
||||||
|
end.setDate(start.getDate() + 6)
|
||||||
|
return {
|
||||||
|
from: d(start).toISOString(),
|
||||||
|
to: d(end, 23, 59, 59, 999).toISOString(),
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -68,8 +81,12 @@ const DATE_RANGE_PRESETS = [
|
|||||||
label: 'Last 7 Days',
|
label: 'Last 7 Days',
|
||||||
getRange: () => {
|
getRange: () => {
|
||||||
const t = d(new Date())
|
const t = d(new Date())
|
||||||
const start = new Date(t); start.setDate(t.getDate() - 6)
|
const start = new Date(t)
|
||||||
return { from: d(start).toISOString(), to: d(new Date(), 23, 59, 59, 999).toISOString() }
|
start.setDate(t.getDate() - 6)
|
||||||
|
return {
|
||||||
|
from: d(start).toISOString(),
|
||||||
|
to: d(new Date(), 23, 59, 59, 999).toISOString(),
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -79,7 +96,10 @@ const DATE_RANGE_PRESETS = [
|
|||||||
const n = new Date()
|
const n = new Date()
|
||||||
const start = new Date(n.getFullYear(), n.getMonth(), 1)
|
const start = new Date(n.getFullYear(), n.getMonth(), 1)
|
||||||
const end = new Date(n.getFullYear(), n.getMonth() + 1, 0)
|
const end = new Date(n.getFullYear(), n.getMonth() + 1, 0)
|
||||||
return { from: start.toISOString(), to: d(end, 23, 59, 59, 999).toISOString() }
|
return {
|
||||||
|
from: start.toISOString(),
|
||||||
|
to: d(end, 23, 59, 59, 999).toISOString(),
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -87,8 +107,12 @@ const DATE_RANGE_PRESETS = [
|
|||||||
label: 'Last 30 Days',
|
label: 'Last 30 Days',
|
||||||
getRange: () => {
|
getRange: () => {
|
||||||
const t = d(new Date())
|
const t = d(new Date())
|
||||||
const start = new Date(t); start.setDate(t.getDate() - 29)
|
const start = new Date(t)
|
||||||
return { from: d(start).toISOString(), to: d(new Date(), 23, 59, 59, 999).toISOString() }
|
start.setDate(t.getDate() - 29)
|
||||||
|
return {
|
||||||
|
from: d(start).toISOString(),
|
||||||
|
to: d(new Date(), 23, 59, 59, 999).toISOString(),
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -96,8 +120,12 @@ const DATE_RANGE_PRESETS = [
|
|||||||
label: 'Last 3 Months',
|
label: 'Last 3 Months',
|
||||||
getRange: () => {
|
getRange: () => {
|
||||||
const t = d(new Date())
|
const t = d(new Date())
|
||||||
const start = new Date(t); start.setMonth(t.getMonth() - 3)
|
const start = new Date(t)
|
||||||
return { from: d(start).toISOString(), to: d(new Date(), 23, 59, 59, 999).toISOString() }
|
start.setMonth(t.getMonth() - 3)
|
||||||
|
return {
|
||||||
|
from: d(start).toISOString(),
|
||||||
|
to: d(new Date(), 23, 59, 59, 999).toISOString(),
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -127,7 +155,8 @@ const FilterBar = ({
|
|||||||
const activeFilterCount = filterDefs.filter(def => {
|
const activeFilterCount = filterDefs.filter(def => {
|
||||||
const value = activeFilters[def.id]
|
const value = activeFilters[def.id]
|
||||||
if (value === undefined || value === null) return false
|
if (value === undefined || value === null) return false
|
||||||
if (def.defaultValue !== undefined && value === def.defaultValue) return false
|
if (def.defaultValue !== undefined && value === def.defaultValue)
|
||||||
|
return false
|
||||||
if (Array.isArray(value) && value.length === 0) return false
|
if (Array.isArray(value) && value.length === 0) return false
|
||||||
if (def.type === 'date-range') return !!(value?.from || value?.to)
|
if (def.type === 'date-range') return !!(value?.from || value?.to)
|
||||||
return true
|
return true
|
||||||
@@ -183,13 +212,18 @@ const FilterBar = ({
|
|||||||
if (value === undefined || value === null) return null
|
if (value === undefined || value === null) return null
|
||||||
|
|
||||||
if (def.type === 'single-select') {
|
if (def.type === 'single-select') {
|
||||||
if (def.defaultValue !== undefined && value === def.defaultValue) return null
|
if (def.defaultValue !== undefined && value === def.defaultValue)
|
||||||
|
return null
|
||||||
return def.options?.find(o => o.value === value)?.label ?? def.label
|
return def.options?.find(o => o.value === value)?.label ?? def.label
|
||||||
}
|
}
|
||||||
|
|
||||||
if (def.type === 'boolean') return def.label
|
if (def.type === 'boolean') return def.label
|
||||||
|
|
||||||
if (def.type === 'multi-select' && Array.isArray(value) && value.length > 0) {
|
if (
|
||||||
|
def.type === 'multi-select' &&
|
||||||
|
Array.isArray(value) &&
|
||||||
|
value.length > 0
|
||||||
|
) {
|
||||||
if (value.length === 1) {
|
if (value.length === 1) {
|
||||||
return def.options?.find(o => o.value === value[0])?.label ?? def.label
|
return def.options?.find(o => o.value === value[0])?.label ?? def.label
|
||||||
}
|
}
|
||||||
@@ -199,7 +233,10 @@ const FilterBar = ({
|
|||||||
if (def.type === 'date-range') {
|
if (def.type === 'date-range') {
|
||||||
if (!value?.from && !value?.to) return null
|
if (!value?.from && !value?.to) return null
|
||||||
if (value.preset) {
|
if (value.preset) {
|
||||||
return DATE_RANGE_PRESETS.find(p => p.value === value.preset)?.label ?? 'Date Range'
|
return (
|
||||||
|
DATE_RANGE_PRESETS.find(p => p.value === value.preset)?.label ??
|
||||||
|
'Date Range'
|
||||||
|
)
|
||||||
}
|
}
|
||||||
const from = fmtDisplayDate(value.from)
|
const from = fmtDisplayDate(value.from)
|
||||||
const to = fmtDisplayDate(value.to)
|
const to = fmtDisplayDate(value.to)
|
||||||
@@ -259,7 +296,15 @@ const FilterBar = ({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* ── Inline bar ─────────────────────────────────────── */}
|
{/* ── Inline bar ─────────────────────────────────────── */}
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap', mb: 2 }}>
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 1,
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
mb: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Badge
|
<Badge
|
||||||
badgeContent={activeFilterCount || null}
|
badgeContent={activeFilterCount || null}
|
||||||
color='primary'
|
color='primary'
|
||||||
@@ -309,37 +354,42 @@ const FilterBar = ({
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* ── Bottom sheet ────────────────────────────────────── */}
|
{/* ── Bottom sheet ────────────────────────────────────── */}
|
||||||
<BottomSheetModal
|
<AppModal
|
||||||
open={isOpen}
|
open={isOpen}
|
||||||
|
isMobile
|
||||||
onClose={() => setIsOpen(false)}
|
onClose={() => setIsOpen(false)}
|
||||||
title={
|
title={
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
<Tune sx={{ fontSize: 20 }} />
|
<Tune sx={{ fontSize: 20 }} />
|
||||||
Filters
|
Filters
|
||||||
{hasActive && (
|
{hasActive && (
|
||||||
<Chip size='sm' variant='solid' color='primary' sx={modalCountChipSx}>
|
<Chip
|
||||||
|
size='sm'
|
||||||
|
variant='solid'
|
||||||
|
color='primary'
|
||||||
|
sx={modalCountChipSx}
|
||||||
|
>
|
||||||
{activeFilterCount}
|
{activeFilterCount}
|
||||||
</Chip>
|
</Chip>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
}
|
}
|
||||||
footer={
|
footer={
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 1 }}>
|
<ModalActions
|
||||||
<Button
|
tertiary={{
|
||||||
variant='plain'
|
label: 'Clear all',
|
||||||
color='danger'
|
color: 'danger',
|
||||||
size='sm'
|
disabled: !hasActive,
|
||||||
disabled={!hasActive}
|
onClick: onClearAll,
|
||||||
onClick={onClearAll}
|
}}
|
||||||
>
|
primary={{
|
||||||
Clear all
|
label:
|
||||||
</Button>
|
resultCount !== undefined
|
||||||
<Button onClick={() => setIsOpen(false)} sx={{ minWidth: 140 }}>
|
? `Show ${resultCount} result${resultCount !== 1 ? 's' : ''}`
|
||||||
{resultCount !== undefined
|
: 'Done',
|
||||||
? `Show ${resultCount} result${resultCount !== 1 ? 's' : ''}`
|
onClick: () => setIsOpen(false),
|
||||||
: 'Done'}
|
}}
|
||||||
</Button>
|
/>
|
||||||
</Box>
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
|
||||||
@@ -348,9 +398,18 @@ const FilterBar = ({
|
|||||||
{idx > 0 && <Divider sx={{ my: 2.5 }} />}
|
{idx > 0 && <Divider sx={{ my: 2.5 }} />}
|
||||||
|
|
||||||
{/* Section header */}
|
{/* Section header */}
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
<Box
|
||||||
|
sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}
|
||||||
|
>
|
||||||
{def.icon && (
|
{def.icon && (
|
||||||
<Box sx={{ color: 'text.secondary', display: 'flex', alignItems: 'center', '& svg': { fontSize: 18 } }}>
|
<Box
|
||||||
|
sx={{
|
||||||
|
color: 'text.secondary',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
'& svg': { fontSize: 18 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
{def.icon}
|
{def.icon}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
@@ -359,21 +418,41 @@ const FilterBar = ({
|
|||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
{/* active badge in header */}
|
{/* active badge in header */}
|
||||||
{def.type === 'multi-select' && (activeFilters[def.id]?.length ?? 0) > 0 && (
|
{def.type === 'multi-select' &&
|
||||||
<Chip size='sm' variant='solid' color='primary' sx={sectionBadgeChipSx}>
|
(activeFilters[def.id]?.length ?? 0) > 0 && (
|
||||||
{activeFilters[def.id].length} selected
|
<Chip
|
||||||
</Chip>
|
size='sm'
|
||||||
)}
|
variant='solid'
|
||||||
{def.type === 'single-select' && activeFilters[def.id] != null && (() => {
|
color='primary'
|
||||||
const opt = def.options?.find(o => o.value === activeFilters[def.id])
|
sx={sectionBadgeChipSx}
|
||||||
return opt ? (
|
>
|
||||||
<Chip size='sm' variant='solid' color='primary' sx={sectionBadgeChipSx}>
|
{activeFilters[def.id].length} selected
|
||||||
{opt.label}
|
|
||||||
</Chip>
|
</Chip>
|
||||||
) : null
|
)}
|
||||||
})()}
|
{def.type === 'single-select' &&
|
||||||
|
activeFilters[def.id] != null &&
|
||||||
|
(() => {
|
||||||
|
const opt = def.options?.find(
|
||||||
|
o => o.value === activeFilters[def.id],
|
||||||
|
)
|
||||||
|
return opt ? (
|
||||||
|
<Chip
|
||||||
|
size='sm'
|
||||||
|
variant='solid'
|
||||||
|
color='primary'
|
||||||
|
sx={sectionBadgeChipSx}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</Chip>
|
||||||
|
) : null
|
||||||
|
})()}
|
||||||
{def.type === 'date-range' && getActiveChipLabel(def) && (
|
{def.type === 'date-range' && getActiveChipLabel(def) && (
|
||||||
<Chip size='sm' variant='solid' color='primary' sx={sectionBadgeChipSx}>
|
<Chip
|
||||||
|
size='sm'
|
||||||
|
variant='solid'
|
||||||
|
color='primary'
|
||||||
|
sx={sectionBadgeChipSx}
|
||||||
|
>
|
||||||
{getActiveChipLabel(def)}
|
{getActiveChipLabel(def)}
|
||||||
</Chip>
|
</Chip>
|
||||||
)}
|
)}
|
||||||
@@ -383,18 +462,28 @@ const FilterBar = ({
|
|||||||
{def.type === 'multi-select' && (
|
{def.type === 'multi-select' && (
|
||||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||||
{def.options?.map(opt => {
|
{def.options?.map(opt => {
|
||||||
const isSelected = (activeFilters[def.id] || []).includes(opt.value)
|
const isSelected = (activeFilters[def.id] || []).includes(
|
||||||
|
opt.value,
|
||||||
|
)
|
||||||
return (
|
return (
|
||||||
<Chip
|
<Chip
|
||||||
key={opt.value}
|
key={opt.value}
|
||||||
variant={isSelected ? 'solid' : 'soft'}
|
variant={isSelected ? 'solid' : 'soft'}
|
||||||
color={isSelected ? (opt.color ?? 'primary') : 'neutral'}
|
color={
|
||||||
|
isSelected ? (opt.color ?? 'primary') : 'neutral'
|
||||||
|
}
|
||||||
startDecorator={
|
startDecorator={
|
||||||
opt.avatar ? (
|
opt.avatar ? (
|
||||||
<Avatar src={opt.avatar} alt={opt.label} sx={{ '--Avatar-size': '20px' }} />
|
<Avatar
|
||||||
|
src={opt.avatar}
|
||||||
|
alt={opt.label}
|
||||||
|
sx={{ '--Avatar-size': '20px' }}
|
||||||
|
/>
|
||||||
) : isSelected ? (
|
) : isSelected ? (
|
||||||
<Check sx={{ fontSize: 14 }} />
|
<Check sx={{ fontSize: 14 }} />
|
||||||
) : (opt.icon ?? null)
|
) : (
|
||||||
|
(opt.icon ?? null)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
onClick={() => handleMultiToggle(def.id, opt.value)}
|
onClick={() => handleMultiToggle(def.id, opt.value)}
|
||||||
sx={selectableChipSx}
|
sx={selectableChipSx}
|
||||||
@@ -415,13 +504,21 @@ const FilterBar = ({
|
|||||||
<Chip
|
<Chip
|
||||||
key={opt.value}
|
key={opt.value}
|
||||||
variant={isSelected ? 'solid' : 'soft'}
|
variant={isSelected ? 'solid' : 'soft'}
|
||||||
color={isSelected ? (opt.color ?? 'primary') : 'neutral'}
|
color={
|
||||||
|
isSelected ? (opt.color ?? 'primary') : 'neutral'
|
||||||
|
}
|
||||||
startDecorator={
|
startDecorator={
|
||||||
opt.avatar ? (
|
opt.avatar ? (
|
||||||
<Avatar src={opt.avatar} alt={opt.label} sx={{ '--Avatar-size': '20px' }} />
|
<Avatar
|
||||||
|
src={opt.avatar}
|
||||||
|
alt={opt.label}
|
||||||
|
sx={{ '--Avatar-size': '20px' }}
|
||||||
|
/>
|
||||||
) : isSelected ? (
|
) : isSelected ? (
|
||||||
<Check sx={{ fontSize: 14 }} />
|
<Check sx={{ fontSize: 14 }} />
|
||||||
) : (opt.icon ?? null)
|
) : (
|
||||||
|
(opt.icon ?? null)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
onClick={() => handleSingleToggle(def.id, opt.value)}
|
onClick={() => handleSingleToggle(def.id, opt.value)}
|
||||||
sx={selectableChipSx}
|
sx={selectableChipSx}
|
||||||
@@ -438,7 +535,11 @@ const FilterBar = ({
|
|||||||
<Chip
|
<Chip
|
||||||
variant={activeFilters[def.id] ? 'solid' : 'soft'}
|
variant={activeFilters[def.id] ? 'solid' : 'soft'}
|
||||||
color={activeFilters[def.id] ? 'primary' : 'neutral'}
|
color={activeFilters[def.id] ? 'primary' : 'neutral'}
|
||||||
startDecorator={activeFilters[def.id] ? <Check sx={{ fontSize: 14 }} /> : null}
|
startDecorator={
|
||||||
|
activeFilters[def.id] ? (
|
||||||
|
<Check sx={{ fontSize: 14 }} />
|
||||||
|
) : null
|
||||||
|
}
|
||||||
onClick={() => handleBoolToggle(def.id)}
|
onClick={() => handleBoolToggle(def.id)}
|
||||||
sx={selectableChipSx}
|
sx={selectableChipSx}
|
||||||
>
|
>
|
||||||
@@ -447,58 +548,84 @@ const FilterBar = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* date-range */}
|
{/* date-range */}
|
||||||
{def.type === 'date-range' && (() => {
|
{def.type === 'date-range' &&
|
||||||
const val = activeFilters[def.id] || {}
|
(() => {
|
||||||
return (
|
const val = activeFilters[def.id] || {}
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
return (
|
||||||
{/* Preset chips */}
|
<Box
|
||||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
sx={{
|
||||||
{DATE_RANGE_PRESETS.map(preset => {
|
display: 'flex',
|
||||||
const isSelected = val.preset === preset.value
|
flexDirection: 'column',
|
||||||
return (
|
gap: 1.5,
|
||||||
<Chip
|
}}
|
||||||
key={preset.value}
|
>
|
||||||
variant={isSelected ? 'solid' : 'soft'}
|
{/* Preset chips */}
|
||||||
color={isSelected ? 'primary' : 'neutral'}
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||||
startDecorator={isSelected ? <Check sx={{ fontSize: 14 }} /> : null}
|
{DATE_RANGE_PRESETS.map(preset => {
|
||||||
onClick={() => handleDateRangePreset(def.id, preset.value)}
|
const isSelected = val.preset === preset.value
|
||||||
sx={selectableChipSx}
|
return (
|
||||||
>
|
<Chip
|
||||||
{preset.label}
|
key={preset.value}
|
||||||
</Chip>
|
variant={isSelected ? 'solid' : 'soft'}
|
||||||
)
|
color={isSelected ? 'primary' : 'neutral'}
|
||||||
})}
|
startDecorator={
|
||||||
</Box>
|
isSelected ? (
|
||||||
|
<Check sx={{ fontSize: 14 }} />
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
onClick={() =>
|
||||||
|
handleDateRangePreset(def.id, preset.value)
|
||||||
|
}
|
||||||
|
sx={selectableChipSx}
|
||||||
|
>
|
||||||
|
{preset.label}
|
||||||
|
</Chip>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
|
||||||
{/* Custom date inputs */}
|
{/* Custom date inputs */}
|
||||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
<Box
|
||||||
<Input
|
sx={{ display: 'flex', gap: 1, alignItems: 'center' }}
|
||||||
type='date'
|
>
|
||||||
size='sm'
|
<Input
|
||||||
value={toInputDate(val.from)}
|
type='date'
|
||||||
onChange={e => handleDateRangeInput(def.id, 'from', e.target.value)}
|
size='sm'
|
||||||
slotProps={{ input: { max: toInputDate(val.to) || undefined } }}
|
value={toInputDate(val.from)}
|
||||||
sx={{ flex: 1, fontSize: '0.8rem' }}
|
onChange={e =>
|
||||||
/>
|
handleDateRangeInput(def.id, 'from', e.target.value)
|
||||||
<Typography level='body-xs' sx={{ color: 'text.tertiary', flexShrink: 0 }}>
|
}
|
||||||
–
|
slotProps={{
|
||||||
</Typography>
|
input: { max: toInputDate(val.to) || undefined },
|
||||||
<Input
|
}}
|
||||||
type='date'
|
sx={{ flex: 1, fontSize: '0.8rem' }}
|
||||||
size='sm'
|
/>
|
||||||
value={toInputDate(val.to)}
|
<Typography
|
||||||
onChange={e => handleDateRangeInput(def.id, 'to', e.target.value)}
|
level='body-xs'
|
||||||
slotProps={{ input: { min: toInputDate(val.from) || undefined } }}
|
sx={{ color: 'text.tertiary', flexShrink: 0 }}
|
||||||
sx={{ flex: 1, fontSize: '0.8rem' }}
|
>
|
||||||
/>
|
–
|
||||||
|
</Typography>
|
||||||
|
<Input
|
||||||
|
type='date'
|
||||||
|
size='sm'
|
||||||
|
value={toInputDate(val.to)}
|
||||||
|
onChange={e =>
|
||||||
|
handleDateRangeInput(def.id, 'to', e.target.value)
|
||||||
|
}
|
||||||
|
slotProps={{
|
||||||
|
input: { min: toInputDate(val.from) || undefined },
|
||||||
|
}}
|
||||||
|
sx={{ flex: 1, fontSize: '0.8rem' }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
)
|
||||||
)
|
})()}
|
||||||
})()}
|
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
</BottomSheetModal>
|
</AppModal>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
61
src/components/common/ModalActions.jsx
Normal file
61
src/components/common/ModalActions.jsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { Box, Button } from '@mui/joy'
|
||||||
|
|
||||||
|
const ActionButton = ({ action, defaults, sx }) => {
|
||||||
|
if (!action) return null
|
||||||
|
|
||||||
|
const { label, sx: actionSx, ...props } = action
|
||||||
|
return (
|
||||||
|
<Button {...defaults} {...props} sx={{ ...sx, ...actionSx }}>
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Consistent modal action row. Secondary actions are rendered first and the
|
||||||
|
* primary action is always the final, highest-emphasis control.
|
||||||
|
*/
|
||||||
|
const ModalActions = ({
|
||||||
|
primary,
|
||||||
|
secondary,
|
||||||
|
tertiary,
|
||||||
|
children,
|
||||||
|
stackOnMobile = false,
|
||||||
|
sx,
|
||||||
|
}) => {
|
||||||
|
const responsiveButtonStyles = stackOnMobile
|
||||||
|
? { '& > button': { width: { xs: '100%', sm: 'auto' } } }
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const layoutSx = {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: stackOnMobile ? { xs: 'column-reverse', sm: 'row' } : 'row',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 1,
|
||||||
|
...responsiveButtonStyles,
|
||||||
|
...sx,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (children) return <Box sx={layoutSx}>{children}</Box>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={layoutSx}>
|
||||||
|
<ActionButton
|
||||||
|
action={tertiary}
|
||||||
|
defaults={{ color: 'neutral', variant: 'plain' }}
|
||||||
|
sx={{ mr: { sm: 'auto' } }}
|
||||||
|
/>
|
||||||
|
<ActionButton
|
||||||
|
action={secondary}
|
||||||
|
defaults={{ color: 'neutral', variant: 'outlined' }}
|
||||||
|
/>
|
||||||
|
<ActionButton
|
||||||
|
action={primary}
|
||||||
|
defaults={{ color: 'primary', variant: 'solid' }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ModalActions
|
||||||
52
src/components/common/README.md
Normal file
52
src/components/common/README.md
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# UI foundations
|
||||||
|
|
||||||
|
## Modals
|
||||||
|
|
||||||
|
Use `AppModal` for new work. Existing features may continue using
|
||||||
|
`useResponsiveModal`; it now renders the same primitive.
|
||||||
|
|
||||||
|
### Presentations
|
||||||
|
|
||||||
|
- Desktop: centered, constrained dialog (`sm` 400, `md` 520, `lg` 680,
|
||||||
|
`xl` 840 pixels).
|
||||||
|
- Mobile default: bottom sheet.
|
||||||
|
- Long mobile workflows: `mobilePresentation='fullscreen'`.
|
||||||
|
- Destructive confirmation: `size='sm'`, `role='alertdialog'`, and
|
||||||
|
`closeOnBackdrop={false}`.
|
||||||
|
|
||||||
|
`AppModal` owns the header, close control, content scrolling, safe-area spacing,
|
||||||
|
and footer. Do not add another close button or duplicate the title inside the
|
||||||
|
content.
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<AppModal
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
title='Create item'
|
||||||
|
description='Add a recognizable name.'
|
||||||
|
footer={
|
||||||
|
<ModalActions
|
||||||
|
secondary={{ label: 'Cancel', onClick: onClose }}
|
||||||
|
primary={{ label: 'Create', onClick: onCreate, loading: isSaving }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</AppModal>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Buttons
|
||||||
|
|
||||||
|
- Primary: `solid primary`; one primary action per surface.
|
||||||
|
- Secondary: `outlined neutral`.
|
||||||
|
- Tertiary: `plain neutral`.
|
||||||
|
- Destructive confirmation: `solid danger`.
|
||||||
|
- Destructive trigger: usually `outlined danger` or `plain danger`.
|
||||||
|
- Modal order: secondary first, primary last.
|
||||||
|
- Every icon-only button requires an `aria-label`.
|
||||||
|
- Use the built-in `loading` state to prevent repeated submission.
|
||||||
|
|
||||||
|
Button heights, radii, focus states, and reduced-motion behavior are defined in
|
||||||
|
`src/contexts/ThemeContext.jsx`. Avoid local overrides for those properties.
|
||||||
|
|
||||||
|
The live reference is available at `/test`.
|
||||||
@@ -4,22 +4,33 @@ import { CssVarsProvider, extendTheme } from '@mui/joy/styles'
|
|||||||
import PropType from 'prop-types'
|
import PropType from 'prop-types'
|
||||||
|
|
||||||
const primaryColor = 'cyan'
|
const primaryColor = 'cyan'
|
||||||
|
|
||||||
const shades = [
|
const shades = [
|
||||||
'50',
|
'50',
|
||||||
...Array.from({ length: 9 }, (_, i) => String((i + 1) * 100)),
|
...Array.from({ length: 9 }, (_, i) => String((i + 1) * 100)),
|
||||||
]
|
]
|
||||||
|
|
||||||
const getPallete = (key = primaryColor) => {
|
const getPalette = (key = primaryColor) =>
|
||||||
return shades.reduce((acc, shade) => {
|
shades.reduce((palette, shade) => {
|
||||||
acc[shade] = COLORS[key][shade]
|
palette[shade] = COLORS[key][shade]
|
||||||
return acc
|
return palette
|
||||||
}, {})
|
}, {})
|
||||||
}
|
|
||||||
|
|
||||||
const primaryPalette = getPallete(primaryColor)
|
const primaryPalette = getPalette(primaryColor)
|
||||||
|
|
||||||
|
// Fallbacks only. A parent that owns the radius (ButtonGroup, Input/Select
|
||||||
|
// decorator slots, CardActions) sets --Button-radius / --IconButton-radius and
|
||||||
|
// takes precedence, which is what keeps connected groups looking connected.
|
||||||
|
const CONTROL_RADIUS = '24px'
|
||||||
|
const ICON_BUTTON_RADIUS = '10px'
|
||||||
|
|
||||||
const theme = extendTheme({
|
const theme = extendTheme({
|
||||||
|
radius: {
|
||||||
|
xs: '6px',
|
||||||
|
sm: '8px',
|
||||||
|
md: '10px',
|
||||||
|
lg: '12px',
|
||||||
|
xl: '16px',
|
||||||
|
},
|
||||||
colorSchemes: {
|
colorSchemes: {
|
||||||
light: {
|
light: {
|
||||||
palette: {
|
palette: {
|
||||||
@@ -42,42 +53,100 @@ const theme = extendTheme({
|
|||||||
200: '#fbd5d5',
|
200: '#fbd5d5',
|
||||||
300: '#f9c1c1',
|
300: '#f9c1c1',
|
||||||
400: '#f6a8a8',
|
400: '#f6a8a8',
|
||||||
500: '',
|
500: '#ef4444',
|
||||||
600: '#f47272',
|
600: '#dc2626',
|
||||||
700: '#e33434',
|
700: '#b91c1c',
|
||||||
800: '#cc1f1a',
|
800: '#991b1b',
|
||||||
900: '#b91c1c',
|
900: '#7f1d1d',
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
50: '#fffdf7',
|
||||||
|
100: '#fef8e1',
|
||||||
|
200: '#fdecb2',
|
||||||
|
300: '#fcd982',
|
||||||
|
400: '#fbcf52',
|
||||||
|
500: '#f9c222',
|
||||||
|
600: '#f6b81e',
|
||||||
|
700: '#f3ae1a',
|
||||||
|
800: '#f0a416',
|
||||||
|
900: '#e99b0e',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
warning: {
|
},
|
||||||
50: '#fffdf7',
|
dark: {
|
||||||
100: '#fef8e1',
|
palette: {
|
||||||
200: '#fdecb2',
|
primary: primaryPalette,
|
||||||
300: '#fcd982',
|
|
||||||
400: '#fbcf52',
|
|
||||||
500: '#f9c222',
|
|
||||||
600: '#f6b81e',
|
|
||||||
700: '#f3ae1a',
|
|
||||||
800: '#f0a416',
|
|
||||||
900: '#e99b0e',
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
dark: {
|
components: {
|
||||||
palette: {
|
JoyButton: {
|
||||||
primary: primaryPalette,
|
styleOverrides: {
|
||||||
|
root: ({ ownerState }) => ({
|
||||||
|
minHeight:
|
||||||
|
ownerState.size === 'lg'
|
||||||
|
? '44px'
|
||||||
|
: ownerState.size === 'sm'
|
||||||
|
? '36px'
|
||||||
|
: '40px',
|
||||||
|
// Read through the CSS variable so parents that own the radius
|
||||||
|
// (ButtonGroup, Input/Select decorators, Card actions) still win.
|
||||||
|
borderRadius: `var(--Button-radius, ${CONTROL_RADIUS})`,
|
||||||
|
fontWeight: 600,
|
||||||
|
transition:
|
||||||
|
'background-color 140ms ease, border-color 140ms ease, color 140ms ease, box-shadow 140ms ease, transform 100ms ease',
|
||||||
|
'&:active:not(:disabled)': {
|
||||||
|
transform: 'scale(0.98)',
|
||||||
|
},
|
||||||
|
'@media (prefers-reduced-motion: reduce)': {
|
||||||
|
transition: 'none',
|
||||||
|
'&:active:not(:disabled)': { transform: 'none' },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
JoyIconButton: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: ({ ownerState }) => ({
|
||||||
|
borderRadius: `var(--IconButton-radius, ${ICON_BUTTON_RADIUS})`,
|
||||||
|
transition:
|
||||||
|
'background-color 140ms ease, border-color 140ms ease, color 140ms ease, transform 100ms ease',
|
||||||
|
'&:active:not(:disabled)': {
|
||||||
|
transform: 'scale(0.96)',
|
||||||
|
},
|
||||||
|
// Touch target only for the default size. `sm`/`lg` are explicit
|
||||||
|
// choices by the call site and keep Joy's own sizing.
|
||||||
|
...(ownerState.size === 'md' && {
|
||||||
|
minWidth: '40px',
|
||||||
|
minHeight: '40px',
|
||||||
|
'@media (max-width: 768px)': {
|
||||||
|
minWidth: '44px',
|
||||||
|
minHeight: '44px',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
'@media (prefers-reduced-motion: reduce)': {
|
||||||
|
transition: 'none',
|
||||||
|
'&:active:not(:disabled)': { transform: 'none' },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
JoyButtonGroup: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
'--ButtonGroup-radius': CONTROL_RADIUS,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const ThemeContext = ({ children }) => {
|
const ThemeContext = ({ children }) => (
|
||||||
return (
|
<CssVarsProvider theme={theme}>
|
||||||
<CssVarsProvider theme={theme}>
|
<CssBaseline />
|
||||||
<CssBaseline />
|
{children}
|
||||||
{children}
|
</CssVarsProvider>
|
||||||
</CssVarsProvider>
|
)
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
ThemeContext.propTypes = {
|
ThemeContext.propTypes = {
|
||||||
children: PropType.node,
|
children: PropType.node,
|
||||||
|
|||||||
@@ -1,18 +1,23 @@
|
|||||||
import BottomSheetModal from '../components/common/BottomSheetModal'
|
import useMediaQuery from '@mui/material/useMediaQuery'
|
||||||
import FadeModal from '../components/common/FadeModal'
|
import { createElement } from 'react'
|
||||||
import useWindowWidth from './useWindowWidth'
|
import AppModal from '../components/common/AppModal'
|
||||||
|
|
||||||
|
const MobileAppModal = props =>
|
||||||
|
createElement(AppModal, { ...props, isMobile: true })
|
||||||
|
const DesktopAppModal = props =>
|
||||||
|
createElement(AppModal, { ...props, isMobile: false })
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook that returns the appropriate modal component based on screen size
|
* Backwards-compatible access to the app modal system.
|
||||||
* @param {number} breakpoint - Screen width breakpoint to switch between modals (default: 768px)
|
*
|
||||||
* @returns {Object} - { Modal: Component, isMobile: boolean }
|
* New code may render AppModal directly when it already knows the desired
|
||||||
|
* presentation. Existing callers can continue using ResponsiveModal.
|
||||||
*/
|
*/
|
||||||
export const useResponsiveModal = (breakpoint = 768) => {
|
export const useResponsiveModal = (breakpoint = 768) => {
|
||||||
const windowWidth = useWindowWidth()
|
const isMobile = useMediaQuery(`(max-width:${breakpoint}px)`)
|
||||||
const isMobile = windowWidth <= breakpoint
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ResponsiveModal: isMobile ? BottomSheetModal : FadeModal,
|
ResponsiveModal: isMobile ? MobileAppModal : DesktopAppModal,
|
||||||
isMobile,
|
isMobile,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,8 @@
|
|||||||
import { Security, Smartphone } from '@mui/icons-material'
|
import { Security, Smartphone } from '@mui/icons-material'
|
||||||
import {
|
import { Alert, Box, Input, Link, Stack, Typography } from '@mui/joy'
|
||||||
Alert,
|
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
Input,
|
|
||||||
Link,
|
|
||||||
ModalClose,
|
|
||||||
Stack,
|
|
||||||
Typography,
|
|
||||||
} from '@mui/joy'
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
import ModalActions from '../../components/common/ModalActions'
|
||||||
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
|
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
|
||||||
import { VerifyMFA } from '../../utils/Fetcher'
|
import { VerifyMFA } from '../../utils/Fetcher'
|
||||||
|
|
||||||
@@ -43,12 +35,15 @@ const MFAVerificationModal = ({
|
|||||||
onSuccess(data)
|
onSuccess(data)
|
||||||
} else {
|
} else {
|
||||||
const errorData = await response.json()
|
const errorData = await response.json()
|
||||||
setError(
|
const message =
|
||||||
errorData.message || 'Invalid verification code. Please try again.',
|
errorData.message || 'Invalid verification code. Please try again.'
|
||||||
)
|
setError(message)
|
||||||
|
onError?.(message)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setError('Failed to verify code. Please try again.')
|
const message = 'Failed to verify code. Please try again.'
|
||||||
|
setError(message)
|
||||||
|
onError?.(message)
|
||||||
console.error('MFA verification error:', error)
|
console.error('MFA verification error:', error)
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
@@ -73,17 +68,29 @@ const MFAVerificationModal = ({
|
|||||||
<ResponsiveModal
|
<ResponsiveModal
|
||||||
open={open}
|
open={open}
|
||||||
onClose={handleClose}
|
onClose={handleClose}
|
||||||
size='lg'
|
size='md'
|
||||||
fullWidth={true}
|
|
||||||
title='Two-Factor Authentication'
|
title='Two-Factor Authentication'
|
||||||
|
description='Enter the verification code from your authenticator app.'
|
||||||
|
closeOnBackdrop={!loading}
|
||||||
|
closeOnEscape={!loading}
|
||||||
|
footer={
|
||||||
|
<ModalActions
|
||||||
|
secondary={{
|
||||||
|
label: 'Cancel',
|
||||||
|
onClick: handleClose,
|
||||||
|
disabled: loading,
|
||||||
|
}}
|
||||||
|
primary={{
|
||||||
|
label: 'Verify & Sign In',
|
||||||
|
onClick: handleVerify,
|
||||||
|
loading,
|
||||||
|
disabled: !verificationCode.trim(),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<ModalClose />
|
|
||||||
|
|
||||||
<Box className='mb-4 text-center'>
|
<Box className='mb-4 text-center'>
|
||||||
<Security sx={{ fontSize: 48, color: 'primary.main', mb: 2 }} />
|
<Security sx={{ fontSize: 48, color: 'primary.main', mb: 2 }} />
|
||||||
<Typography level='body-md' sx={{ color: 'text.secondary' }}>
|
|
||||||
Enter the verification code from your authenticator app
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Stack spacing={3}>
|
<Stack spacing={3}>
|
||||||
@@ -120,16 +127,6 @@ const MFAVerificationModal = ({
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button
|
|
||||||
color='primary'
|
|
||||||
loading={loading}
|
|
||||||
onClick={handleVerify}
|
|
||||||
disabled={!verificationCode.trim()}
|
|
||||||
size='lg'
|
|
||||||
>
|
|
||||||
Verify & Sign In
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Box className='text-center'>
|
<Box className='text-center'>
|
||||||
<Link
|
<Link
|
||||||
component='button'
|
component='button'
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
Pause,
|
Pause,
|
||||||
PlayArrow,
|
PlayArrow,
|
||||||
} from '@mui/icons-material'
|
} from '@mui/icons-material'
|
||||||
import { Box, ButtonGroup, IconButton, Menu, MenuItem } from '@mui/joy'
|
import { Box, Button, ButtonGroup, IconButton, Menu, MenuItem } from '@mui/joy'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
const TimerSplitButton = ({
|
const TimerSplitButton = ({
|
||||||
@@ -95,36 +95,25 @@ const TimerSplitButton = ({
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
{/* Main action button */}
|
{/* Main action button */}
|
||||||
<IconButton
|
<Button
|
||||||
onClick={handleMainAction}
|
onClick={handleMainAction}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
size='md'
|
size='md'
|
||||||
|
startDecorator={chore.status === 1 ? <Pause /> : <PlayArrow />}
|
||||||
sx={{
|
sx={{
|
||||||
px: 3,
|
|
||||||
py: 1,
|
|
||||||
borderTopRightRadius: 0,
|
|
||||||
borderBottomRightRadius: 0,
|
|
||||||
minWidth: fullWidth ? 'auto' : 120,
|
minWidth: fullWidth ? 'auto' : 120,
|
||||||
flex: fullWidth ? 1 : 'none',
|
flex: fullWidth ? 1 : 'none',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{chore.status === 1 ? <Pause /> : <PlayArrow />}
|
|
||||||
{chore.status === 1 ? 'Pause' : 'Resume'}
|
{chore.status === 1 ? 'Pause' : 'Resume'}
|
||||||
</IconButton>
|
</Button>
|
||||||
|
|
||||||
{/* Dropdown arrow button */}
|
{/* Dropdown arrow button */}
|
||||||
<IconButton
|
<IconButton
|
||||||
onClick={handleMenuOpen}
|
onClick={handleMenuOpen}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
size='lg'
|
size='md'
|
||||||
sx={{
|
sx={{ px: 1, minWidth: 'auto' }}
|
||||||
px: 1,
|
|
||||||
borderTopLeftRadius: 0,
|
|
||||||
borderBottomLeftRadius: 0,
|
|
||||||
borderLeft: '1px solid',
|
|
||||||
borderLeftColor: 'divider',
|
|
||||||
minWidth: 'auto',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<ArrowDropDown />
|
<ArrowDropDown />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
|||||||
@@ -706,7 +706,7 @@ const ArchivedTasks = () => {
|
|||||||
}}
|
}}
|
||||||
onChange={handleSearchChange}
|
onChange={handleSearchChange}
|
||||||
startDecorator={
|
startDecorator={
|
||||||
<KeyboardShortcutHint shortcut='F' show={showKeyboardShortcuts} />
|
showKeyboardShortcuts ? <KeyboardShortcutHint shortcut='F' /> : null
|
||||||
}
|
}
|
||||||
endDecorator={
|
endDecorator={
|
||||||
searchTerm && (
|
searchTerm && (
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Close, HelpOutline, Keyboard } from '@mui/icons-material'
|
import { HelpOutline } from '@mui/icons-material'
|
||||||
import { Box, Button, Card, Divider, IconButton, Typography } from '@mui/joy'
|
import { Box, Card, IconButton, Typography } from '@mui/joy'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import ModalActions from '../../components/common/ModalActions'
|
||||||
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
|
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
|
||||||
|
|
||||||
const MultiSelectHelp = ({ isVisible = true }) => {
|
const MultiSelectHelp = ({ isVisible = true }) => {
|
||||||
@@ -28,37 +29,24 @@ const MultiSelectHelp = ({ isVisible = true }) => {
|
|||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
boxShadow: 'lg',
|
boxShadow: 'lg',
|
||||||
}}
|
}}
|
||||||
|
aria-label='Show keyboard shortcuts'
|
||||||
title='Show keyboard shortcuts'
|
title='Show keyboard shortcuts'
|
||||||
>
|
>
|
||||||
<HelpOutline />
|
<HelpOutline />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
|
||||||
{/* Help Modal */}
|
{/* Help Modal */}
|
||||||
<ResponsiveModal open={isHelpOpen} onClose={() => setIsHelpOpen(false)}>
|
<ResponsiveModal
|
||||||
<Box
|
open={isHelpOpen}
|
||||||
sx={{
|
onClose={() => setIsHelpOpen(false)}
|
||||||
display: 'flex',
|
title='Multi-select Mode'
|
||||||
alignItems: 'center',
|
description='Use these keyboard shortcuts to work more efficiently.'
|
||||||
justifyContent: 'space-between',
|
footer={
|
||||||
mb: 2,
|
<ModalActions
|
||||||
}}
|
primary={{ label: 'Got it', onClick: () => setIsHelpOpen(false) }}
|
||||||
>
|
/>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
}
|
||||||
<Keyboard color='primary' />
|
>
|
||||||
<Typography level='title-lg'>Multi-select Mode</Typography>
|
|
||||||
</Box>
|
|
||||||
<IconButton
|
|
||||||
variant='plain'
|
|
||||||
size='sm'
|
|
||||||
onClick={() => setIsHelpOpen(false)}
|
|
||||||
>
|
|
||||||
<Close />
|
|
||||||
</IconButton>
|
|
||||||
</Box>
|
|
||||||
<Typography level='body-md' sx={{ mb: 3, color: 'text.secondary' }}>
|
|
||||||
Use these keyboard shortcuts to work more efficiently with multiple
|
|
||||||
tasks:
|
|
||||||
</Typography>
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
{/* Selection shortcuts */}
|
{/* Selection shortcuts */}
|
||||||
<Card variant='soft' sx={{ p: 2 }}>
|
<Card variant='soft' sx={{ p: 2 }}>
|
||||||
@@ -107,16 +95,6 @@ const MultiSelectHelp = ({ isVisible = true }) => {
|
|||||||
</Box>
|
</Box>
|
||||||
</Card>
|
</Card>
|
||||||
</Box>
|
</Box>
|
||||||
<Divider sx={{ my: 3 }} />
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
|
|
||||||
<Button
|
|
||||||
variant='soft'
|
|
||||||
onClick={() => setIsHelpOpen(false)}
|
|
||||||
sx={{ minWidth: 120 }}
|
|
||||||
>
|
|
||||||
Got it!
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</ResponsiveModal>
|
</ResponsiveModal>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ import {
|
|||||||
Typography,
|
Typography,
|
||||||
} from '@mui/joy'
|
} from '@mui/joy'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import BottomSheetModal from '../../../components/common/BottomSheetModal'
|
import AppModal from '../../../components/common/AppModal'
|
||||||
import ActiveFilterChips from '../../../components/common/filter/ActiveFilterChips'
|
import ActiveFilterChips from '../../../components/common/filter/ActiveFilterChips'
|
||||||
import { Z_INDEX } from '../../../constants/zIndex'
|
import { Z_INDEX } from '../../../constants/zIndex'
|
||||||
import KeyboardShortcutHint from '../../../components/common/KeyboardShortcutHint'
|
import KeyboardShortcutHint from '../../../components/common/KeyboardShortcutHint'
|
||||||
@@ -105,13 +105,15 @@ const OptionChips = ({ options, selected, multi, onToggle }) => (
|
|||||||
<Chip
|
<Chip
|
||||||
key={opt.value}
|
key={opt.value}
|
||||||
variant={isSelected ? 'solid' : 'soft'}
|
variant={isSelected ? 'solid' : 'soft'}
|
||||||
color={isSelected ? opt.color ?? 'primary' : 'neutral'}
|
color={isSelected ? (opt.color ?? 'primary') : 'neutral'}
|
||||||
startDecorator={
|
startDecorator={
|
||||||
opt.icon != null
|
opt.icon != null ? (
|
||||||
? isSelected
|
isSelected ? (
|
||||||
? <Check sx={{ fontSize: 14 }} />
|
<Check sx={{ fontSize: 14 }} />
|
||||||
: opt.icon
|
) : (
|
||||||
: undefined
|
opt.icon
|
||||||
|
)
|
||||||
|
) : undefined
|
||||||
}
|
}
|
||||||
onClick={() => onToggle(opt.value)}
|
onClick={() => onToggle(opt.value)}
|
||||||
sx={{
|
sx={{
|
||||||
@@ -287,7 +289,9 @@ const ChoreToolbar = ({
|
|||||||
return member?.displayName || member?.username || String(value)
|
return member?.displayName || member?.username || String(value)
|
||||||
}
|
}
|
||||||
if (condition.type === 'status') {
|
if (condition.type === 'status') {
|
||||||
return CHORE_STATUSES.find(s => s.value === value)?.label || String(value)
|
return (
|
||||||
|
CHORE_STATUSES.find(s => s.value === value)?.label || String(value)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if (condition.type === 'priority') {
|
if (condition.type === 'priority') {
|
||||||
return Priorities.find(p => p.value === value)?.name || String(value)
|
return Priorities.find(p => p.value === value)?.name || String(value)
|
||||||
@@ -363,8 +367,7 @@ const ChoreToolbar = ({
|
|||||||
setLocalSelections(conditionsToSelections(tempFilter.conditions))
|
setLocalSelections(conditionsToSelections(tempFilter.conditions))
|
||||||
if (tempFilterMeta?.sourceFilterId) {
|
if (tempFilterMeta?.sourceFilterId) {
|
||||||
const sourceFilter =
|
const sourceFilter =
|
||||||
savedFilters.find(f => f.id === tempFilterMeta.sourceFilterId) ||
|
savedFilters.find(f => f.id === tempFilterMeta.sourceFilterId) || null
|
||||||
null
|
|
||||||
setEditingSavedFilter(
|
setEditingSavedFilter(
|
||||||
sourceFilter ||
|
sourceFilter ||
|
||||||
(tempFilterMeta.sourceFilterId
|
(tempFilterMeta.sourceFilterId
|
||||||
@@ -444,7 +447,13 @@ const ChoreToolbar = ({
|
|||||||
FILTER_COLORS.find(c => !usedColors.includes(c.value))?.value ??
|
FILTER_COLORS.find(c => !usedColors.includes(c.value))?.value ??
|
||||||
FILTER_COLORS[0].value
|
FILTER_COLORS[0].value
|
||||||
|
|
||||||
saveFilter?.({ name, description: '', color, conditions, operator: 'AND' })?.then?.(() => {
|
saveFilter?.({
|
||||||
|
name,
|
||||||
|
description: '',
|
||||||
|
color,
|
||||||
|
conditions,
|
||||||
|
operator: 'AND',
|
||||||
|
})?.then?.(() => {
|
||||||
applyTempFilter?.({ conditions, operator: 'AND' }, { name })
|
applyTempFilter?.({ conditions, operator: 'AND' }, { name })
|
||||||
onFilterSaved?.(name)
|
onFilterSaved?.(name)
|
||||||
})
|
})
|
||||||
@@ -460,16 +469,13 @@ const ChoreToolbar = ({
|
|||||||
const conditions = selectionsToConditions(localSelections)
|
const conditions = selectionsToConditions(localSelections)
|
||||||
if (conditions.length === 0) return
|
if (conditions.length === 0) return
|
||||||
|
|
||||||
updateFilter(
|
updateFilter(editingSavedFilter.id, {
|
||||||
editingSavedFilter.id,
|
name: editingSavedFilter.name,
|
||||||
{
|
description: editingSavedFilter.description || '',
|
||||||
name: editingSavedFilter.name,
|
color: editingSavedFilter.color,
|
||||||
description: editingSavedFilter.description || '',
|
conditions,
|
||||||
color: editingSavedFilter.color,
|
operator: 'AND',
|
||||||
conditions,
|
})?.then?.(() => {
|
||||||
operator: 'AND',
|
|
||||||
},
|
|
||||||
)?.then?.(() => {
|
|
||||||
clearTempFilter?.()
|
clearTempFilter?.()
|
||||||
onSavedFilterClick?.(editingSavedFilter.id)
|
onSavedFilterClick?.(editingSavedFilter.id)
|
||||||
onFilterSaved?.(editingSavedFilter.name)
|
onFilterSaved?.(editingSavedFilter.name)
|
||||||
@@ -498,9 +504,21 @@ const ChoreToolbar = ({
|
|||||||
]
|
]
|
||||||
|
|
||||||
const viewOptions = [
|
const viewOptions = [
|
||||||
{ value: 'default', label: 'Cards', icon: <ViewAgenda sx={{ fontSize: 16 }} /> },
|
{
|
||||||
{ value: 'compact', label: 'Compact', icon: <ViewComfy sx={{ fontSize: 16 }} /> },
|
value: 'default',
|
||||||
{ value: 'calendar', label: 'Calendar', icon: <CalendarMonth sx={{ fontSize: 16 }} /> },
|
label: 'Cards',
|
||||||
|
icon: <ViewAgenda sx={{ fontSize: 16 }} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'compact',
|
||||||
|
label: 'Compact',
|
||||||
|
icon: <ViewComfy sx={{ fontSize: 16 }} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'calendar',
|
||||||
|
label: 'Calendar',
|
||||||
|
icon: <CalendarMonth sx={{ fontSize: 16 }} />,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -536,6 +554,7 @@ const ChoreToolbar = ({
|
|||||||
size='sm'
|
size='sm'
|
||||||
sx={{ height: 32, width: 32, borderRadius: '50%' }}
|
sx={{ height: 32, width: 32, borderRadius: '50%' }}
|
||||||
onClick={openFilterSheet}
|
onClick={openFilterSheet}
|
||||||
|
aria-label='Filters'
|
||||||
title='Filters'
|
title='Filters'
|
||||||
>
|
>
|
||||||
<FilterList />
|
<FilterList />
|
||||||
@@ -543,13 +562,14 @@ const ChoreToolbar = ({
|
|||||||
</Badge>
|
</Badge>
|
||||||
|
|
||||||
{/* Project selector */}
|
{/* Project selector */}
|
||||||
{!filterActive && projects.filter(p => p.id !== 'default').length > 0 && (
|
{!filterActive &&
|
||||||
<ProjectSelector
|
projects.filter(p => p.id !== 'default').length > 0 && (
|
||||||
selectedProject={selectedProject?.name || 'Default Project'}
|
<ProjectSelector
|
||||||
onProjectSelect={onProjectSelect}
|
selectedProject={selectedProject?.name || 'Default Project'}
|
||||||
showKeyboardShortcuts={showKeyboardShortcuts}
|
onProjectSelect={onProjectSelect}
|
||||||
/>
|
showKeyboardShortcuts={showKeyboardShortcuts}
|
||||||
)}
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Display button — View + Group combined */}
|
{/* Display button — View + Group combined */}
|
||||||
<IconButton
|
<IconButton
|
||||||
@@ -558,6 +578,7 @@ const ChoreToolbar = ({
|
|||||||
size='sm'
|
size='sm'
|
||||||
sx={{ height: 32, width: 32, borderRadius: '50%' }}
|
sx={{ height: 32, width: 32, borderRadius: '50%' }}
|
||||||
onClick={() => setDisplaySheetOpen(true)}
|
onClick={() => setDisplaySheetOpen(true)}
|
||||||
|
aria-label='View and group options'
|
||||||
title='View & Group'
|
title='View & Group'
|
||||||
>
|
>
|
||||||
{viewMode === 'calendar' ? (
|
{viewMode === 'calendar' ? (
|
||||||
@@ -577,6 +598,9 @@ const ChoreToolbar = ({
|
|||||||
size='sm'
|
size='sm'
|
||||||
sx={{ height: 32, width: 32, borderRadius: '50%' }}
|
sx={{ height: 32, width: 32, borderRadius: '50%' }}
|
||||||
onClick={onToggleMultiSelect}
|
onClick={onToggleMultiSelect}
|
||||||
|
aria-label={
|
||||||
|
isMultiSelectMode ? 'Exit multi-select' : 'Enter multi-select'
|
||||||
|
}
|
||||||
title={
|
title={
|
||||||
isMultiSelectMode
|
isMultiSelectMode
|
||||||
? 'Exit multi-select (Ctrl+S)'
|
? 'Exit multi-select (Ctrl+S)'
|
||||||
@@ -628,8 +652,9 @@ const ChoreToolbar = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Unified Filter bottom sheet ─────────────────────────────────────── */}
|
{/* ── Unified Filter bottom sheet ─────────────────────────────────────── */}
|
||||||
<BottomSheetModal
|
<AppModal
|
||||||
open={filterSheetOpen}
|
open={filterSheetOpen}
|
||||||
|
isMobile
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setSaveMenuAnchorEl(null)
|
setSaveMenuAnchorEl(null)
|
||||||
setFilterSheetOpen(false)
|
setFilterSheetOpen(false)
|
||||||
@@ -649,7 +674,12 @@ const ChoreToolbar = ({
|
|||||||
footer={
|
footer={
|
||||||
savingFilter ? (
|
savingFilter ? (
|
||||||
<Box
|
<Box
|
||||||
sx={{ display: 'flex', gap: 1, width: '100%', alignItems: 'center' }}
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: 1,
|
||||||
|
width: '100%',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
size='sm'
|
size='sm'
|
||||||
@@ -710,12 +740,11 @@ const ChoreToolbar = ({
|
|||||||
}}
|
}}
|
||||||
sx={{ minWidth: 140 }}
|
sx={{ minWidth: 140 }}
|
||||||
>
|
>
|
||||||
{resultCount != null
|
{resultCount != null ? `Show ${resultCount}` : 'Done'}
|
||||||
? `Show ${resultCount}`
|
|
||||||
: 'Done'}
|
|
||||||
</Button>
|
</Button>
|
||||||
<IconButton
|
<IconButton
|
||||||
ref={saveMenuRef}
|
ref={saveMenuRef}
|
||||||
|
aria-label='More save options'
|
||||||
onClick={e => setSaveMenuAnchorEl(e.currentTarget)}
|
onClick={e => setSaveMenuAnchorEl(e.currentTarget)}
|
||||||
>
|
>
|
||||||
<ArrowDropDown />
|
<ArrowDropDown />
|
||||||
@@ -825,11 +854,12 @@ const ChoreToolbar = ({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</BottomSheetModal>
|
</AppModal>
|
||||||
|
|
||||||
{/* ── Display bottom sheet (View + Group + Assignee + Project) ──────────── */}
|
{/* ── Display bottom sheet (View + Group + Assignee + Project) ──────────── */}
|
||||||
<BottomSheetModal
|
<AppModal
|
||||||
open={displaySheetOpen}
|
open={displaySheetOpen}
|
||||||
|
isMobile
|
||||||
onClose={() => setDisplaySheetOpen(false)}
|
onClose={() => setDisplaySheetOpen(false)}
|
||||||
title={
|
title={
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
@@ -838,7 +868,10 @@ const ChoreToolbar = ({
|
|||||||
</Box>
|
</Box>
|
||||||
}
|
}
|
||||||
footer={
|
footer={
|
||||||
<Button onClick={() => setDisplaySheetOpen(false)} sx={{ minWidth: 140 }}>
|
<Button
|
||||||
|
onClick={() => setDisplaySheetOpen(false)}
|
||||||
|
sx={{ minWidth: 140 }}
|
||||||
|
>
|
||||||
Done
|
Done
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
@@ -853,9 +886,11 @@ const ChoreToolbar = ({
|
|||||||
variant={viewMode === opt.value ? 'solid' : 'soft'}
|
variant={viewMode === opt.value ? 'solid' : 'soft'}
|
||||||
color={viewMode === opt.value ? 'primary' : 'neutral'}
|
color={viewMode === opt.value ? 'primary' : 'neutral'}
|
||||||
startDecorator={
|
startDecorator={
|
||||||
viewMode === opt.value
|
viewMode === opt.value ? (
|
||||||
? <Check sx={{ fontSize: 14 }} />
|
<Check sx={{ fontSize: 14 }} />
|
||||||
: opt.icon
|
) : (
|
||||||
|
opt.icon
|
||||||
|
)
|
||||||
}
|
}
|
||||||
onClick={() => onToggleViewMode?.(opt.value)}
|
onClick={() => onToggleViewMode?.(opt.value)}
|
||||||
sx={{
|
sx={{
|
||||||
@@ -910,7 +945,8 @@ const ChoreToolbar = ({
|
|||||||
label='Show tasks for'
|
label='Show tasks for'
|
||||||
badge={
|
badge={
|
||||||
selectedAssigneeFilter !== 'anyone'
|
selectedAssigneeFilter !== 'anyone'
|
||||||
? assigneeOptions.find(o => o.value === selectedAssigneeFilter)?.label
|
? assigneeOptions.find(o => o.value === selectedAssigneeFilter)
|
||||||
|
?.label
|
||||||
: null
|
: null
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -920,9 +956,8 @@ const ChoreToolbar = ({
|
|||||||
multi={false}
|
multi={false}
|
||||||
onToggle={v => onAssigneeFilterChange?.(v)}
|
onToggle={v => onAssigneeFilterChange?.(v)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
</Box>
|
</Box>
|
||||||
</BottomSheetModal>
|
</AppModal>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { Box, Button, FormLabel, Input } from '@mui/joy'
|
import { FormLabel, Input } from '@mui/joy'
|
||||||
import moment from 'moment'
|
import moment from 'moment'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
import ModalActions from '../../components/common/ModalActions'
|
||||||
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
|
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
|
||||||
import ConfirmationModal from './Inputs/ConfirmationModal'
|
import ConfirmationModal from './Inputs/ConfirmationModal'
|
||||||
|
|
||||||
@@ -41,31 +42,19 @@ function EditHistoryModal({ config, historyRecord }) {
|
|||||||
// fullWidth={true}
|
// fullWidth={true}
|
||||||
title='Edit History'
|
title='Edit History'
|
||||||
footer={
|
footer={
|
||||||
<Box display={'flex'} justifyContent={'space-around'} mt={1}>
|
<ModalActions
|
||||||
<Button
|
secondary={{ label: 'Cancel', onClick: config.onClose }}
|
||||||
size='lg'
|
primary={{
|
||||||
onClick={() =>
|
label: 'Save',
|
||||||
|
onClick: () =>
|
||||||
config.onSave({
|
config.onSave({
|
||||||
id: historyRecord.id,
|
id: historyRecord.id,
|
||||||
performedAt: moment(completedDate).toISOString(),
|
performedAt: moment(completedDate).toISOString(),
|
||||||
dueDate: moment(dueDate).toISOString(),
|
dueDate: moment(dueDate).toISOString(),
|
||||||
notes,
|
notes,
|
||||||
})
|
}),
|
||||||
}
|
}}
|
||||||
fullWidth
|
/>
|
||||||
sx={{ mr: 1 }}
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
fullWidth
|
|
||||||
size='lg'
|
|
||||||
onClick={config.onClose}
|
|
||||||
variant='outlined'
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<FormLabel>Due Date</FormLabel>
|
<FormLabel>Due Date</FormLabel>
|
||||||
@@ -119,6 +108,7 @@ function EditHistoryModal({ config, historyRecord }) {
|
|||||||
message: 'Are you sure you want to delete this history?',
|
message: 'Are you sure you want to delete this history?',
|
||||||
confirmText: 'Delete',
|
confirmText: 'Delete',
|
||||||
cancelText: 'Cancel',
|
cancelText: 'Cancel',
|
||||||
|
color: 'danger',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</ResponsiveModal>
|
</ResponsiveModal>
|
||||||
|
|||||||
@@ -15,28 +15,40 @@ import {
|
|||||||
import { Avatar, Box, Button, Chip, Divider, Stack, Typography } from '@mui/joy'
|
import { Avatar, Box, Button, Chip, Divider, Stack, Typography } from '@mui/joy'
|
||||||
import moment from 'moment'
|
import moment from 'moment'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import ModalActions from '../../components/common/ModalActions'
|
||||||
import { useLocalization } from '../../contexts/LocalizationContext'
|
import { useLocalization } from '../../contexts/LocalizationContext'
|
||||||
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
|
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
|
||||||
import { TASK_COLOR } from '../../utils/Colors.jsx'
|
import { TASK_COLOR } from '../../utils/Colors.jsx'
|
||||||
import RichTextEditor from '../components/RichTextEditor.jsx'
|
import RichTextEditor from '../components/RichTextEditor.jsx'
|
||||||
|
|
||||||
const STATUS_CONFIG = {
|
const STATUS_CONFIG = {
|
||||||
0: { label: 'In Progress', color: 'primary', icon: <AccessTime /> },
|
0: { label: 'In Progress', color: 'primary', icon: <AccessTime /> },
|
||||||
1: { label: 'Completed', color: 'success', icon: <Check /> },
|
1: { label: 'Completed', color: 'success', icon: <Check /> },
|
||||||
2: { label: 'Skipped', color: 'warning', icon: <Redo /> },
|
2: { label: 'Skipped', color: 'warning', icon: <Redo /> },
|
||||||
3: { label: 'Pending Approval', color: 'neutral', icon: <HourglassEmpty /> },
|
3: { label: 'Pending Approval', color: 'neutral', icon: <HourglassEmpty /> },
|
||||||
4: { label: 'Rejected', color: 'danger', icon: <ThumbDown /> },
|
4: { label: 'Rejected', color: 'danger', icon: <ThumbDown /> },
|
||||||
5: { label: 'Missed', color: 'danger', icon: <RunningWithErrors /> },
|
5: { label: 'Missed', color: 'danger', icon: <RunningWithErrors /> },
|
||||||
6: { label: 'Rescheduled', color: 'warning', icon: <Schedule /> },
|
6: { label: 'Rescheduled', color: 'warning', icon: <Schedule /> },
|
||||||
}
|
}
|
||||||
|
|
||||||
const DetailRow = ({ icon, label, value, children }) => (
|
const DetailRow = ({ icon, label, value, children }) => (
|
||||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1.5, py: 0.75 }}>
|
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1.5, py: 0.75 }}>
|
||||||
<Box sx={{ color: 'text.tertiary', mt: 0.25, flexShrink: 0, display: 'flex' }}>{icon}</Box>
|
<Box
|
||||||
|
sx={{ color: 'text.tertiary', mt: 0.25, flexShrink: 0, display: 'flex' }}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</Box>
|
||||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||||
<Typography level='body-xs' sx={{ color: 'text.tertiary', mb: 0.15 }}>{label}</Typography>
|
<Typography level='body-xs' sx={{ color: 'text.tertiary', mb: 0.15 }}>
|
||||||
|
{label}
|
||||||
|
</Typography>
|
||||||
{children ?? (
|
{children ?? (
|
||||||
<Typography level='body-sm' sx={{ color: 'text.primary', fontWeight: 'md' }}>{value}</Typography>
|
<Typography
|
||||||
|
level='body-sm'
|
||||||
|
sx={{ color: 'text.primary', fontWeight: 'md' }}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</Typography>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -52,15 +64,41 @@ const TimingBadge = ({ historyEntry }) => {
|
|||||||
const gracePeriod = 6 * 60 * 60 * 1000
|
const gracePeriod = 6 * 60 * 60 * 1000
|
||||||
|
|
||||||
if (Math.abs(performedAt - dueDate) <= gracePeriod) {
|
if (Math.abs(performedAt - dueDate) <= gracePeriod) {
|
||||||
return <Chip size='sm' variant='solid' sx={{ backgroundColor: TASK_COLOR.COMPLETED, color: 'white' }} startDecorator={<Check />}>On Time</Chip>
|
return (
|
||||||
|
<Chip
|
||||||
|
size='sm'
|
||||||
|
variant='solid'
|
||||||
|
sx={{ backgroundColor: TASK_COLOR.COMPLETED, color: 'white' }}
|
||||||
|
startDecorator={<Check />}
|
||||||
|
>
|
||||||
|
On Time
|
||||||
|
</Chip>
|
||||||
|
)
|
||||||
} else if (performedAt.isBefore(dueDate)) {
|
} else if (performedAt.isBefore(dueDate)) {
|
||||||
const abs = Math.abs(diffHours)
|
const abs = Math.abs(diffHours)
|
||||||
const label = abs >= 48 ? `${Math.floor(abs / 24)}d early` : `${abs}h early`
|
const label = abs >= 48 ? `${Math.floor(abs / 24)}d early` : `${abs}h early`
|
||||||
return <Chip size='sm' variant='soft' sx={{ backgroundColor: TASK_COLOR.SCHEDULED, color: 'white' }} startDecorator={<Check />}>{label}</Chip>
|
return (
|
||||||
|
<Chip
|
||||||
|
size='sm'
|
||||||
|
variant='soft'
|
||||||
|
sx={{ backgroundColor: TASK_COLOR.SCHEDULED, color: 'white' }}
|
||||||
|
startDecorator={<Check />}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Chip>
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
const abs = Math.abs(diffHours)
|
const abs = Math.abs(diffHours)
|
||||||
const label = abs >= 48 ? `${Math.floor(abs / 24)}d late` : `${abs}h late`
|
const label = abs >= 48 ? `${Math.floor(abs / 24)}d late` : `${abs}h late`
|
||||||
return <Chip size='sm' variant='solid' sx={{ backgroundColor: TASK_COLOR.LATE, color: 'white' }}>{label}</Chip>
|
return (
|
||||||
|
<Chip
|
||||||
|
size='sm'
|
||||||
|
variant='solid'
|
||||||
|
sx={{ backgroundColor: TASK_COLOR.LATE, color: 'white' }}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Chip>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,7 +117,8 @@ function HistoryDetailModal({ config }) {
|
|||||||
const statusLabel = isFirstSchedule ? 'Scheduled' : statusCfg.label
|
const statusLabel = isFirstSchedule ? 'Scheduled' : statusCfg.label
|
||||||
const performer = performers.find(p => p.userId === entry.completedBy)
|
const performer = performers.find(p => p.userId === entry.completedBy)
|
||||||
const assignedTo = performers.find(p => p.userId === entry.assignedTo)
|
const assignedTo = performers.find(p => p.userId === entry.assignedTo)
|
||||||
const isDifferentAssignee = entry.assignedTo && entry.completedBy !== entry.assignedTo
|
const isDifferentAssignee =
|
||||||
|
entry.assignedTo && entry.completedBy !== entry.assignedTo
|
||||||
|
|
||||||
// updatedAt is only meaningful if it differs from performedAt by more than a minute
|
// updatedAt is only meaningful if it differs from performedAt by more than a minute
|
||||||
const showUpdatedAt =
|
const showUpdatedAt =
|
||||||
@@ -100,14 +139,50 @@ function HistoryDetailModal({ config }) {
|
|||||||
open={config?.isOpen}
|
open={config?.isOpen}
|
||||||
onClose={config?.onClose}
|
onClose={config?.onClose}
|
||||||
title='Activity Detail'
|
title='Activity Detail'
|
||||||
|
footer={
|
||||||
|
<ModalActions>
|
||||||
|
{entry.choreId && (
|
||||||
|
<Button
|
||||||
|
variant='outlined'
|
||||||
|
color='neutral'
|
||||||
|
startDecorator={<OpenInNew sx={{ fontSize: 16 }} />}
|
||||||
|
onClick={() => {
|
||||||
|
config?.onClose?.()
|
||||||
|
navigate(`/chores/${entry.choreId}`)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Open Task
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{config?.onEdit && (
|
||||||
|
<Button
|
||||||
|
startDecorator={<Edit sx={{ fontSize: 16 }} />}
|
||||||
|
onClick={() => config.onEdit(entry)}
|
||||||
|
>
|
||||||
|
Edit Entry
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</ModalActions>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{/* Status header */}
|
{/* Status header */}
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1.5 }}>
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
mb: 1.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
<Avatar size='sm' color={statusCfg.color} variant='soft'>
|
<Avatar size='sm' color={statusCfg.color} variant='soft'>
|
||||||
{statusCfg.icon}
|
{statusCfg.icon}
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<Typography level='title-md' fontWeight='lg' sx={{ color: `${statusCfg.color}.plainColor` }}>
|
<Typography
|
||||||
|
level='title-md'
|
||||||
|
fontWeight='lg'
|
||||||
|
sx={{ color: `${statusCfg.color}.plainColor` }}
|
||||||
|
>
|
||||||
{statusLabel}
|
{statusLabel}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -119,17 +194,31 @@ function HistoryDetailModal({ config }) {
|
|||||||
<Stack spacing={0}>
|
<Stack spacing={0}>
|
||||||
{/* Who performed it */}
|
{/* Who performed it */}
|
||||||
{performer && (
|
{performer && (
|
||||||
<DetailRow icon={<Check sx={{ fontSize: 16 }} />} label='Performed by'>
|
<DetailRow
|
||||||
|
icon={<Check sx={{ fontSize: 16 }} />}
|
||||||
|
label='Performed by'
|
||||||
|
>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||||
<Avatar src={performer.image} alt={performer.displayName} size='sm' sx={{ width: 20, height: 20 }} />
|
<Avatar
|
||||||
<Typography level='body-sm' fontWeight='md'>{performer.displayName}</Typography>
|
src={performer.image}
|
||||||
|
alt={performer.displayName}
|
||||||
|
size='sm'
|
||||||
|
sx={{ width: 20, height: 20 }}
|
||||||
|
/>
|
||||||
|
<Typography level='body-sm' fontWeight='md'>
|
||||||
|
{performer.displayName}
|
||||||
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</DetailRow>
|
</DetailRow>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Assigned to (only if different) */}
|
{/* Assigned to (only if different) */}
|
||||||
{isDifferentAssignee && assignedTo && (
|
{isDifferentAssignee && assignedTo && (
|
||||||
<DetailRow icon={<Person sx={{ fontSize: 16 }} />} label='Assigned to' value={assignedTo.displayName} />
|
<DetailRow
|
||||||
|
icon={<Person sx={{ fontSize: 16 }} />}
|
||||||
|
label='Assigned to'
|
||||||
|
value={assignedTo.displayName}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
@@ -138,7 +227,15 @@ function HistoryDetailModal({ config }) {
|
|||||||
{entry.performedAt && (
|
{entry.performedAt && (
|
||||||
<DetailRow
|
<DetailRow
|
||||||
icon={<AccessTime sx={{ fontSize: 16 }} />}
|
icon={<AccessTime sx={{ fontSize: 16 }} />}
|
||||||
label={isFirstSchedule ? 'Scheduled on' : entry.status === 6 ? 'Rescheduled on' : entry.status === 2 ? 'Skipped on' : 'Completed on'}
|
label={
|
||||||
|
isFirstSchedule
|
||||||
|
? 'Scheduled on'
|
||||||
|
: entry.status === 6
|
||||||
|
? 'Rescheduled on'
|
||||||
|
: entry.status === 2
|
||||||
|
? 'Skipped on'
|
||||||
|
: 'Completed on'
|
||||||
|
}
|
||||||
value={fmt.dateTime(entry.performedAt)}
|
value={fmt.dateTime(entry.performedAt)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -147,7 +244,13 @@ function HistoryDetailModal({ config }) {
|
|||||||
{entry.dueDate && (
|
{entry.dueDate && (
|
||||||
<DetailRow
|
<DetailRow
|
||||||
icon={<CalendarMonth sx={{ fontSize: 16 }} />}
|
icon={<CalendarMonth sx={{ fontSize: 16 }} />}
|
||||||
label={entry.status === 6 ? 'Previous due date' : entry.status === 5 ? 'Was due' : 'Due date'}
|
label={
|
||||||
|
entry.status === 6
|
||||||
|
? 'Previous due date'
|
||||||
|
: entry.status === 5
|
||||||
|
? 'Was due'
|
||||||
|
: 'Due date'
|
||||||
|
}
|
||||||
value={fmt.dateTime(entry.dueDate)}
|
value={fmt.dateTime(entry.dueDate)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -184,45 +287,19 @@ function HistoryDetailModal({ config }) {
|
|||||||
<>
|
<>
|
||||||
<Divider />
|
<Divider />
|
||||||
<Box sx={{ pt: 1 }}>
|
<Box sx={{ pt: 1 }}>
|
||||||
<Typography level='body-xs' sx={{ color: 'text.tertiary', mb: 0.5 }}>
|
<Typography
|
||||||
|
level='body-xs'
|
||||||
|
sx={{ color: 'text.tertiary', mb: 0.5 }}
|
||||||
|
>
|
||||||
{entry.status === 2 || entry.status === 4 ? 'Reason' : 'Notes'}
|
{entry.status === 2 || entry.status === 4 ? 'Reason' : 'Notes'}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box sx={{ overflowY: 'auto', maxHeight: '60vh' }}>
|
<Box sx={{ overflowY: 'auto', maxHeight: '60vh' }}>
|
||||||
<RichTextEditor value={entry.notes || ''} isEditable={false} />
|
<RichTextEditor value={entry.notes || ''} isEditable={false} />
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
{/* Action buttons */}
|
|
||||||
<Box sx={{ display: 'flex', gap: 1, mt: 2, justifyContent: 'flex-end' }}>
|
|
||||||
{entry.choreId && (
|
|
||||||
<Button
|
|
||||||
variant='soft'
|
|
||||||
color='neutral'
|
|
||||||
size='sm'
|
|
||||||
startDecorator={<OpenInNew sx={{ fontSize: 16 }} />}
|
|
||||||
onClick={() => {
|
|
||||||
config?.onClose?.()
|
|
||||||
navigate(`/chores/${entry.choreId}`)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Open Task
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{config?.onEdit && (
|
|
||||||
<Button
|
|
||||||
variant='soft'
|
|
||||||
color='neutral'
|
|
||||||
size='md'
|
|
||||||
startDecorator={<Edit sx={{ fontSize: 16 }} />}
|
|
||||||
onClick={() => config.onEdit(entry)}
|
|
||||||
>
|
|
||||||
Edit Entry
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</ResponsiveModal>
|
</ResponsiveModal>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Box, Button, Typography } from '@mui/joy'
|
import { Typography } from '@mui/joy'
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import KeyboardShortcutHint from '../../../components/common/KeyboardShortcutHint'
|
import KeyboardShortcutHint from '../../../components/common/KeyboardShortcutHint'
|
||||||
|
import ModalActions from '../../../components/common/ModalActions'
|
||||||
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
||||||
|
|
||||||
function AcknowledgmentModal({ config }) {
|
function AcknowledgmentModal({ config }) {
|
||||||
@@ -11,42 +12,24 @@ function AcknowledgmentModal({ config }) {
|
|||||||
config.onClose()
|
config.onClose()
|
||||||
}, [config])
|
}, [config])
|
||||||
|
|
||||||
// Keyboard shortcuts for acknowledgment modal
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = event => {
|
const handleKeyDown = event => {
|
||||||
if (!config?.isOpen) return
|
if (!config?.isOpen) return
|
||||||
|
|
||||||
// Show keyboard shortcuts when Ctrl/Cmd is pressed
|
if (event.ctrlKey || event.metaKey) setShowKeyboardShortcuts(true)
|
||||||
if (event.ctrlKey || event.metaKey) {
|
|
||||||
setShowKeyboardShortcuts(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ctrl/Cmd + Y for acknowledge
|
if (
|
||||||
if ((event.ctrlKey || event.metaKey) && event.key === 'y') {
|
((event.ctrlKey || event.metaKey) && event.key === 'y') ||
|
||||||
|
event.key === 'Escape' ||
|
||||||
|
event.key === 'Enter'
|
||||||
|
) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
handleAction()
|
handleAction()
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Escape key for acknowledge
|
|
||||||
if (event.key === 'Escape') {
|
|
||||||
event.preventDefault()
|
|
||||||
handleAction()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enter key for acknowledge
|
|
||||||
if (event.key === 'Enter') {
|
|
||||||
event.preventDefault()
|
|
||||||
handleAction()
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleKeyUp = event => {
|
const handleKeyUp = event => {
|
||||||
if (!event.ctrlKey && !event.metaKey) {
|
if (!event.ctrlKey && !event.metaKey) setShowKeyboardShortcuts(false)
|
||||||
setShowKeyboardShortcuts(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config?.isOpen) {
|
if (config?.isOpen) {
|
||||||
@@ -63,43 +46,33 @@ function AcknowledgmentModal({ config }) {
|
|||||||
return (
|
return (
|
||||||
<ResponsiveModal
|
<ResponsiveModal
|
||||||
open={config?.isOpen}
|
open={config?.isOpen}
|
||||||
onClose={config?.onClose}
|
onClose={handleAction}
|
||||||
size='lg'
|
size='sm'
|
||||||
fullWidth={true}
|
|
||||||
unmountDelay={250}
|
|
||||||
title={config?.title}
|
title={config?.title}
|
||||||
>
|
showCloseButton={false}
|
||||||
<Box
|
footer={
|
||||||
sx={{ p: 2, minWidth: { xs: '100%', sm: '400px' }, maxWidth: '500px' }}
|
<ModalActions
|
||||||
>
|
primary={{
|
||||||
|
label: config?.acknowledgeText,
|
||||||
<Typography
|
color: config?.color || 'primary',
|
||||||
level='body-md'
|
onClick: handleAction,
|
||||||
mb={3}
|
endDecorator: showKeyboardShortcuts ? (
|
||||||
sx={{
|
<KeyboardShortcutHint shortcut='Y' />
|
||||||
lineHeight: 1.6,
|
) : undefined,
|
||||||
whiteSpace: 'pre-wrap',
|
|
||||||
wordBreak: 'break-word',
|
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
{config?.message}
|
}
|
||||||
</Typography>
|
>
|
||||||
|
<Typography
|
||||||
<Box display={'flex'} justifyContent={'center'} mt={2}>
|
level='body-md'
|
||||||
<Button
|
sx={{
|
||||||
size='lg'
|
lineHeight: 1.6,
|
||||||
onClick={handleAction}
|
whiteSpace: 'pre-wrap',
|
||||||
color={config?.color || 'primary'}
|
wordBreak: 'break-word',
|
||||||
fullWidth
|
}}
|
||||||
endDecorator={
|
>
|
||||||
<KeyboardShortcutHint shortcut='Y' show={showKeyboardShortcuts} />
|
{config?.message}
|
||||||
}
|
</Typography>
|
||||||
sx={{ minWidth: '120px' }}
|
|
||||||
>
|
|
||||||
{config?.acknowledgeText}
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</ResponsiveModal>
|
</ResponsiveModal>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,8 @@
|
|||||||
import { Save } from '@mui/icons-material'
|
import { Save } from '@mui/icons-material'
|
||||||
import {
|
import { Box, Button, Chip, Divider, Input, Typography } from '@mui/joy'
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
Chip,
|
|
||||||
Divider,
|
|
||||||
Input,
|
|
||||||
Typography,
|
|
||||||
} from '@mui/joy'
|
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import BottomSheetModal from '../../../components/common/BottomSheetModal'
|
import AppModal from '../../../components/common/AppModal'
|
||||||
|
import ModalActions from '../../../components/common/ModalActions'
|
||||||
import FilterBuilderContent, {
|
import FilterBuilderContent, {
|
||||||
conditionsToSelections,
|
conditionsToSelections,
|
||||||
defaultSelections,
|
defaultSelections,
|
||||||
@@ -18,6 +12,8 @@ import { FILTER_COLORS } from '../../../utils/Colors'
|
|||||||
import { applyFilter } from '../../../utils/FilterEngine'
|
import { applyFilter } from '../../../utils/FilterEngine'
|
||||||
import { useFilters } from '../../Filters/FilterQueries'
|
import { useFilters } from '../../Filters/FilterQueries'
|
||||||
|
|
||||||
|
const EMPTY_FILTERS = []
|
||||||
|
|
||||||
const AdvancedFilterBuilder = ({
|
const AdvancedFilterBuilder = ({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -33,7 +29,7 @@ const AdvancedFilterBuilder = ({
|
|||||||
const [filterColor, setFilterColor] = useState(FILTER_COLORS[0].value)
|
const [filterColor, setFilterColor] = useState(FILTER_COLORS[0].value)
|
||||||
const [selections, setSelections] = useState(defaultSelections())
|
const [selections, setSelections] = useState(defaultSelections())
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const { data: existedFilters = [] } = useFilters()
|
const { data: existedFilters = EMPTY_FILTERS } = useFilters()
|
||||||
|
|
||||||
const filterNameExists = (name, excludeId = null) =>
|
const filterNameExists = (name, excludeId = null) =>
|
||||||
existedFilters.some(
|
existedFilters.some(
|
||||||
@@ -55,9 +51,12 @@ const AdvancedFilterBuilder = ({
|
|||||||
setSelections(defaultSelections())
|
setSelections(defaultSelections())
|
||||||
}
|
}
|
||||||
setError('')
|
setError('')
|
||||||
}, [editingFilter, isOpen])
|
}, [editingFilter, existedFilters, isOpen])
|
||||||
|
|
||||||
const conditions = useMemo(() => selectionsToConditions(selections), [selections])
|
const conditions = useMemo(
|
||||||
|
() => selectionsToConditions(selections),
|
||||||
|
[selections],
|
||||||
|
)
|
||||||
|
|
||||||
const previewChores = useMemo(() => {
|
const previewChores = useMemo(() => {
|
||||||
if (conditions.length === 0) return []
|
if (conditions.length === 0) return []
|
||||||
@@ -100,8 +99,9 @@ const AdvancedFilterBuilder = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BottomSheetModal
|
<AppModal
|
||||||
open={isOpen}
|
open={isOpen}
|
||||||
|
isMobile
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
maxHeight='92vh'
|
maxHeight='92vh'
|
||||||
title={
|
title={
|
||||||
@@ -109,7 +109,8 @@ const AdvancedFilterBuilder = ({
|
|||||||
{editingFilter ? 'Edit Filter' : 'New Filter'}
|
{editingFilter ? 'Edit Filter' : 'New Filter'}
|
||||||
{activeConditionCount > 0 && (
|
{activeConditionCount > 0 && (
|
||||||
<Chip size='sm' variant='solid' color='primary'>
|
<Chip size='sm' variant='solid' color='primary'>
|
||||||
{activeConditionCount} condition{activeConditionCount !== 1 ? 's' : ''}
|
{activeConditionCount} condition
|
||||||
|
{activeConditionCount !== 1 ? 's' : ''}
|
||||||
</Chip>
|
</Chip>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
@@ -144,20 +145,19 @@ const AdvancedFilterBuilder = ({
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
<ModalActions>
|
||||||
<Button variant='plain' color='neutral' size='sm' onClick={onClose}>
|
<Button variant='outlined' color='neutral' onClick={onClose}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant='solid'
|
variant='solid'
|
||||||
color='primary'
|
color='primary'
|
||||||
size='sm'
|
|
||||||
startDecorator={<Save sx={{ fontSize: 16 }} />}
|
startDecorator={<Save sx={{ fontSize: 16 }} />}
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
>
|
>
|
||||||
Save Filter
|
Save Filter
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</ModalActions>
|
||||||
</Box>
|
</Box>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -231,7 +231,7 @@ const AdvancedFilterBuilder = ({
|
|||||||
projects={projects}
|
projects={projects}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</BottomSheetModal>
|
</AppModal>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { AttachFile, Close, Image } from '@mui/icons-material'
|
import { AttachFile, Image } from '@mui/icons-material'
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Button,
|
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
List,
|
List,
|
||||||
ListItem,
|
ListItem,
|
||||||
@@ -9,6 +8,7 @@ import {
|
|||||||
Typography,
|
Typography,
|
||||||
} from '@mui/joy'
|
} from '@mui/joy'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
import ModalActions from '../../../components/common/ModalActions'
|
||||||
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
||||||
import { GetChoreAttachments } from '../../../utils/Fetcher'
|
import { GetChoreAttachments } from '../../../utils/Fetcher'
|
||||||
import { resolvePhotoURL } from '../../../utils/Helpers'
|
import { resolvePhotoURL } from '../../../utils/Helpers'
|
||||||
@@ -87,16 +87,7 @@ function AttachmentBrowserModal({ choreId, isOpen, onClose }) {
|
|||||||
onClose={handleClose}
|
onClose={handleClose}
|
||||||
title='Attachments'
|
title='Attachments'
|
||||||
footer={
|
footer={
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
|
<ModalActions primary={{ label: 'Done', onClick: handleClose }} />
|
||||||
<Button
|
|
||||||
variant='plain'
|
|
||||||
color='neutral'
|
|
||||||
startDecorator={<Close />}
|
|
||||||
onClick={handleClose}
|
|
||||||
>
|
|
||||||
Close
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { Browser } from '@capacitor/browser'
|
import { Browser } from '@capacitor/browser'
|
||||||
import { Capacitor } from '@capacitor/core'
|
import { Capacitor } from '@capacitor/core'
|
||||||
import { Close, Download } from '@mui/icons-material'
|
import { Download } from '@mui/icons-material'
|
||||||
import { Box, Button, CircularProgress, Typography } from '@mui/joy'
|
import { Box, CircularProgress, Typography } from '@mui/joy'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import ModalActions from '../../../components/common/ModalActions'
|
||||||
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
||||||
|
|
||||||
const openUrl = async url => {
|
const openUrl = async url => {
|
||||||
@@ -47,25 +48,15 @@ function AttachmentViewerModal({ config }) {
|
|||||||
title={fileName || 'Attachment'}
|
title={fileName || 'Attachment'}
|
||||||
maxHeight='92vh'
|
maxHeight='92vh'
|
||||||
footer={
|
footer={
|
||||||
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
|
<ModalActions
|
||||||
<Button
|
secondary={{ label: 'Close', onClick: handleClose }}
|
||||||
variant='plain'
|
primary={{
|
||||||
color='neutral'
|
label: 'Download',
|
||||||
startDecorator={<Close />}
|
startDecorator: <Download />,
|
||||||
onClick={handleClose}
|
onClick: () => downloadUrl(url, fileName),
|
||||||
>
|
disabled: !url,
|
||||||
Close
|
}}
|
||||||
</Button>
|
/>
|
||||||
<Button
|
|
||||||
variant='soft'
|
|
||||||
color='neutral'
|
|
||||||
startDecorator={<Download />}
|
|
||||||
onClick={() => downloadUrl(url, fileName)}
|
|
||||||
disabled={!url}
|
|
||||||
>
|
|
||||||
Download
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Box
|
<Box
|
||||||
@@ -78,10 +69,7 @@ function AttachmentViewerModal({ config }) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{!imgLoaded && !imgError && (
|
{!imgLoaded && !imgError && (
|
||||||
<CircularProgress
|
<CircularProgress sx={{ position: 'absolute' }} size='md' />
|
||||||
sx={{ position: 'absolute' }}
|
|
||||||
size='md'
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
{imgError ? (
|
{imgError ? (
|
||||||
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
|
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Button,
|
|
||||||
Checkbox,
|
Checkbox,
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
FormControl,
|
FormControl,
|
||||||
@@ -13,6 +12,7 @@ import {
|
|||||||
Typography,
|
Typography,
|
||||||
} from '@mui/joy'
|
} from '@mui/joy'
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import ModalActions from '../../../components/common/ModalActions'
|
||||||
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
||||||
import { CreateBackup, RestoreBackup } from '../../../utils/Fetcher'
|
import { CreateBackup, RestoreBackup } from '../../../utils/Fetcher'
|
||||||
|
|
||||||
@@ -140,7 +140,6 @@ function BackupRestoreModal({ isOpen, onClose, showNotification }) {
|
|||||||
const response = await RestoreBackup(restoreEncryptionKey, backupData)
|
const response = await RestoreBackup(restoreEncryptionKey, backupData)
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json()
|
|
||||||
showNotification({
|
showNotification({
|
||||||
type: 'success',
|
type: 'success',
|
||||||
message: 'Backup restored successfully. Please refresh the page.',
|
message: 'Backup restored successfully. Please refresh the page.',
|
||||||
@@ -212,7 +211,7 @@ function BackupRestoreModal({ isOpen, onClose, showNotification }) {
|
|||||||
placeholder='Enter a strong encryption key'
|
placeholder='Enter a strong encryption key'
|
||||||
/>
|
/>
|
||||||
<Typography level='body-xs' sx={{ mt: 0.5 }}>
|
<Typography level='body-xs' sx={{ mt: 0.5 }}>
|
||||||
Keep this key safe - you'll need it to restore your backup
|
Keep this key safe—you'll need it to restore your backup
|
||||||
</Typography>
|
</Typography>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
@@ -238,22 +237,6 @@ function BackupRestoreModal({ isOpen, onClose, showNotification }) {
|
|||||||
{error}
|
{error}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Box display='flex' justifyContent='space-between' gap={2}>
|
|
||||||
<Button size='lg' variant='outlined' onClick={handleClose} fullWidth>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size='lg'
|
|
||||||
color='primary'
|
|
||||||
onClick={handleCreateBackup}
|
|
||||||
loading={loading}
|
|
||||||
disabled={!encryptionKey.trim()}
|
|
||||||
fullWidth
|
|
||||||
>
|
|
||||||
Create Backup
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -294,22 +277,6 @@ function BackupRestoreModal({ isOpen, onClose, showNotification }) {
|
|||||||
{error}
|
{error}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Box display='flex' justifyContent='space-between' gap={2}>
|
|
||||||
<Button size='lg' variant='outlined' onClick={handleClose} fullWidth>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size='lg'
|
|
||||||
color='warning'
|
|
||||||
onClick={handleRestore}
|
|
||||||
loading={loading}
|
|
||||||
disabled={!restoreEncryptionKey.trim() || !backupFile}
|
|
||||||
fullWidth
|
|
||||||
>
|
|
||||||
Restore Backup
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -320,7 +287,28 @@ function BackupRestoreModal({ isOpen, onClose, showNotification }) {
|
|||||||
size='lg'
|
size='lg'
|
||||||
fullWidth={true}
|
fullWidth={true}
|
||||||
unmountDelay={250}
|
unmountDelay={250}
|
||||||
title='🔄 Backup & Restore'
|
title='Backup & Restore'
|
||||||
|
closeOnBackdrop={!loading}
|
||||||
|
closeOnEscape={!loading}
|
||||||
|
footer={
|
||||||
|
<ModalActions
|
||||||
|
secondary={{
|
||||||
|
label: 'Cancel',
|
||||||
|
onClick: handleClose,
|
||||||
|
disabled: loading,
|
||||||
|
}}
|
||||||
|
primary={{
|
||||||
|
label: activeTab === 0 ? 'Create Backup' : 'Restore Backup',
|
||||||
|
color: activeTab === 0 ? 'primary' : 'warning',
|
||||||
|
onClick: activeTab === 0 ? handleCreateBackup : handleRestore,
|
||||||
|
loading,
|
||||||
|
disabled:
|
||||||
|
activeTab === 0
|
||||||
|
? !encryptionKey.trim()
|
||||||
|
: !restoreEncryptionKey.trim() || !backupFile,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<Box
|
<Box
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Box, Button, Typography } from '@mui/joy'
|
import { Typography } from '@mui/joy'
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import KeyboardShortcutHint from '../../../components/common/KeyboardShortcutHint'
|
import KeyboardShortcutHint from '../../../components/common/KeyboardShortcutHint'
|
||||||
|
import ModalActions from '../../../components/common/ModalActions'
|
||||||
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
||||||
|
|
||||||
function ConfirmationModal({ config }) {
|
function ConfirmationModal({ config }) {
|
||||||
@@ -14,49 +15,29 @@ function ConfirmationModal({ config }) {
|
|||||||
[config],
|
[config],
|
||||||
)
|
)
|
||||||
|
|
||||||
// Keyboard shortcuts for confirmation modal
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = event => {
|
const handleKeyDown = event => {
|
||||||
if (!config?.isOpen) return
|
if (!config?.isOpen) return
|
||||||
|
|
||||||
// Show keyboard shortcuts when Ctrl/Cmd is pressed
|
if (event.ctrlKey || event.metaKey) setShowKeyboardShortcuts(true)
|
||||||
if (event.ctrlKey || event.metaKey) {
|
|
||||||
setShowKeyboardShortcuts(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ctrl/Cmd + Y for confirm
|
|
||||||
if ((event.ctrlKey || event.metaKey) && event.key === 'y') {
|
if ((event.ctrlKey || event.metaKey) && event.key === 'y') {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
handleAction(true)
|
handleAction(true)
|
||||||
return
|
} else if ((event.ctrlKey || event.metaKey) && event.key === 'x') {
|
||||||
}
|
|
||||||
|
|
||||||
// Ctrl/Cmd + X for cancel
|
|
||||||
if ((event.ctrlKey || event.metaKey) && event.key === 'x') {
|
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
handleAction(false)
|
handleAction(false)
|
||||||
return
|
} else if (event.key === 'Escape') {
|
||||||
}
|
|
||||||
|
|
||||||
// Escape key for cancel
|
|
||||||
if (event.key === 'Escape') {
|
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
handleAction(false)
|
handleAction(false)
|
||||||
return
|
} else if (event.key === 'Enter' && config?.color !== 'danger') {
|
||||||
}
|
|
||||||
|
|
||||||
// Enter key for confirm
|
|
||||||
if (event.key === 'Enter') {
|
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
handleAction(true)
|
handleAction(true)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleKeyUp = event => {
|
const handleKeyUp = event => {
|
||||||
if (!event.ctrlKey && !event.metaKey) {
|
if (!event.ctrlKey && !event.metaKey) setShowKeyboardShortcuts(false)
|
||||||
setShowKeyboardShortcuts(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config?.isOpen) {
|
if (config?.isOpen) {
|
||||||
@@ -68,51 +49,45 @@ function ConfirmationModal({ config }) {
|
|||||||
document.removeEventListener('keydown', handleKeyDown)
|
document.removeEventListener('keydown', handleKeyDown)
|
||||||
document.removeEventListener('keyup', handleKeyUp)
|
document.removeEventListener('keyup', handleKeyUp)
|
||||||
}
|
}
|
||||||
}, [config?.isOpen, handleAction])
|
}, [config?.isOpen, config?.color, handleAction])
|
||||||
|
|
||||||
|
const isDestructive = config?.color === 'danger'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResponsiveModal
|
<ResponsiveModal
|
||||||
open={config?.isOpen}
|
open={config?.isOpen}
|
||||||
onClose={() => handleAction(false)}
|
onClose={() => handleAction(false)}
|
||||||
size='sm'
|
size='sm'
|
||||||
unmountDelay={250}
|
role={isDestructive ? 'alertdialog' : 'dialog'}
|
||||||
|
title={config?.title}
|
||||||
|
showCloseButton={false}
|
||||||
|
closeOnBackdrop={!isDestructive}
|
||||||
|
footer={
|
||||||
|
<ModalActions
|
||||||
|
stackOnMobile
|
||||||
|
secondary={{
|
||||||
|
label: config?.cancelText,
|
||||||
|
onClick: () => handleAction(false),
|
||||||
|
endDecorator: showKeyboardShortcuts ? (
|
||||||
|
<KeyboardShortcutHint shortcut='X' />
|
||||||
|
) : undefined,
|
||||||
|
}}
|
||||||
|
primary={{
|
||||||
|
label: config?.confirmText,
|
||||||
|
color: config?.color || 'primary',
|
||||||
|
onClick: () => handleAction(true),
|
||||||
|
endDecorator: showKeyboardShortcuts ? (
|
||||||
|
<KeyboardShortcutHint shortcut='Y' />
|
||||||
|
) : undefined,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Typography level='h4' mb={1}>
|
<Typography level='body-md' sx={{ whiteSpace: 'pre-wrap' }}>
|
||||||
{config?.title}
|
|
||||||
</Typography>
|
|
||||||
<Typography level='body-md' gutterBottom>
|
|
||||||
{config?.message}
|
{config?.message}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Box display={'flex'} justifyContent={'space-around'} mt={1} gap={1}>
|
|
||||||
<Button
|
|
||||||
size='lg'
|
|
||||||
onClick={() => {
|
|
||||||
handleAction(true)
|
|
||||||
}}
|
|
||||||
fullWidth
|
|
||||||
color={config?.color || 'primary'}
|
|
||||||
endDecorator={
|
|
||||||
<KeyboardShortcutHint shortcut='Y' show={showKeyboardShortcuts} />
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{config?.confirmText}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
size='lg'
|
|
||||||
onClick={() => {
|
|
||||||
handleAction(false)
|
|
||||||
}}
|
|
||||||
variant='outlined'
|
|
||||||
endDecorator={
|
|
||||||
<KeyboardShortcutHint shortcut='X' show={showKeyboardShortcuts} />
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{config?.cancelText}
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</ResponsiveModal>
|
</ResponsiveModal>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ConfirmationModal
|
export default ConfirmationModal
|
||||||
|
|||||||
@@ -1,12 +1,6 @@
|
|||||||
import {
|
import { FormControl, FormHelperText, Input, Typography } from '@mui/joy'
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
FormControl,
|
|
||||||
FormHelperText,
|
|
||||||
Input,
|
|
||||||
Typography,
|
|
||||||
} from '@mui/joy'
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
import ModalActions from '../../../components/common/ModalActions'
|
||||||
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
||||||
|
|
||||||
function CreateChildUserModal({ isOpen, onClose, onSuccess }) {
|
function CreateChildUserModal({ isOpen, onClose, onSuccess }) {
|
||||||
@@ -104,16 +98,30 @@ function CreateChildUserModal({ isOpen, onClose, onSuccess }) {
|
|||||||
password === confirmPassword
|
password === confirmPassword
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResponsiveModal open={isOpen} onClose={handleClose}>
|
<ResponsiveModal
|
||||||
<Typography level='h4' mb={2}>
|
open={isOpen}
|
||||||
Create Sub Account
|
onClose={handleClose}
|
||||||
</Typography>
|
title='Create Sub Account'
|
||||||
|
description='Create a login that can complete tasks assigned to this account.'
|
||||||
<Typography level='body-md' mb={3}>
|
size='md'
|
||||||
Create a new sub account. The user will be able to log in using their
|
closeOnBackdrop={!isSubmitting}
|
||||||
combined username and complete tasks assigned to them.
|
closeOnEscape={!isSubmitting}
|
||||||
</Typography>
|
footer={
|
||||||
|
<ModalActions
|
||||||
|
secondary={{
|
||||||
|
label: 'Cancel',
|
||||||
|
onClick: handleClose,
|
||||||
|
disabled: isSubmitting,
|
||||||
|
}}
|
||||||
|
primary={{
|
||||||
|
label: 'Create Account',
|
||||||
|
onClick: handleSubmit,
|
||||||
|
disabled: !isValid || isSubmitting,
|
||||||
|
loading: isSubmitting,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
<FormControl error={!!errors.childName} sx={{ mb: 2 }}>
|
<FormControl error={!!errors.childName} sx={{ mb: 2 }}>
|
||||||
<Typography level='body2' mb={1}>
|
<Typography level='body2' mb={1}>
|
||||||
Sub Account Name *
|
Sub Account Name *
|
||||||
@@ -196,27 +204,6 @@ function CreateChildUserModal({ isOpen, onClose, onSuccess }) {
|
|||||||
<FormHelperText>{errors.confirmPassword}</FormHelperText>
|
<FormHelperText>{errors.confirmPassword}</FormHelperText>
|
||||||
)}
|
)}
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<Box display='flex' justifyContent='space-between' gap={2}>
|
|
||||||
<Button
|
|
||||||
size='lg'
|
|
||||||
variant='outlined'
|
|
||||||
onClick={handleClose}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
sx={{ flex: 1 }}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size='lg'
|
|
||||||
onClick={handleSubmit}
|
|
||||||
disabled={!isValid || isSubmitting}
|
|
||||||
loading={isSubmitting}
|
|
||||||
sx={{ flex: 1 }}
|
|
||||||
>
|
|
||||||
Create Account
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</ResponsiveModal>
|
</ResponsiveModal>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
import {
|
import {
|
||||||
Box,
|
FormControl,
|
||||||
Button,
|
FormHelperText,
|
||||||
FormControl,
|
Input,
|
||||||
FormHelperText,
|
Option,
|
||||||
Input,
|
Select,
|
||||||
Option,
|
Textarea,
|
||||||
Select,
|
Typography,
|
||||||
Textarea,
|
|
||||||
Typography,
|
|
||||||
} from '@mui/joy'
|
} from '@mui/joy'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
import ModalActions from '../../../components/common/ModalActions'
|
||||||
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
||||||
|
|
||||||
function CreateThingModal({ isOpen, onClose, onSave, currentThing }) {
|
function CreateThingModal({ isOpen, onClose, onSave, currentThing }) {
|
||||||
@@ -29,7 +28,7 @@ function CreateThingModal({ isOpen, onClose, onSave, currentThing }) {
|
|||||||
setState(0)
|
setState(0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [type])
|
}, [type, state])
|
||||||
|
|
||||||
const isValid = () => {
|
const isValid = () => {
|
||||||
const newErrors = {}
|
const newErrors = {}
|
||||||
@@ -63,9 +62,20 @@ function CreateThingModal({ isOpen, onClose, onSave, currentThing }) {
|
|||||||
<ResponsiveModal
|
<ResponsiveModal
|
||||||
open={isOpen}
|
open={isOpen}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
size='lg'
|
size='md'
|
||||||
fullWidth={true}
|
|
||||||
title={`${currentThing?.id ? 'Edit' : 'Create'} Thing`}
|
title={`${currentThing?.id ? 'Edit' : 'Create'} Thing`}
|
||||||
|
footer={
|
||||||
|
<ModalActions
|
||||||
|
secondary={{
|
||||||
|
label: 'Cancel',
|
||||||
|
onClick: onClose,
|
||||||
|
}}
|
||||||
|
primary={{
|
||||||
|
label: currentThing?.id ? 'Update' : 'Create',
|
||||||
|
onClick: handleSave,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Typography>Name</Typography>
|
<Typography>Name</Typography>
|
||||||
@@ -79,9 +89,9 @@ function CreateThingModal({ isOpen, onClose, onSave, currentThing }) {
|
|||||||
</FormControl>
|
</FormControl>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Typography>Type</Typography>
|
<Typography>Type</Typography>
|
||||||
<Select value={type} sx={{ minWidth: 300 }}>
|
<Select value={type} onChange={(_, value) => setType(value)}>
|
||||||
{['text', 'number', 'boolean'].map(type => (
|
{['text', 'number', 'boolean'].map(type => (
|
||||||
<Option value={type} key={type} onClick={() => setType(type)}>
|
<Option value={type} key={type}>
|
||||||
{type.charAt(0).toUpperCase() + type.slice(1)}
|
{type.charAt(0).toUpperCase() + type.slice(1)}
|
||||||
</Option>
|
</Option>
|
||||||
))}
|
))}
|
||||||
@@ -118,24 +128,15 @@ function CreateThingModal({ isOpen, onClose, onSave, currentThing }) {
|
|||||||
{type === 'boolean' && (
|
{type === 'boolean' && (
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Typography>Value</Typography>
|
<Typography>Value</Typography>
|
||||||
<Select sx={{ minWidth: 300 }} value={state}>
|
<Select value={state} onChange={(_, value) => setState(value)}>
|
||||||
{['true', 'false'].map(value => (
|
{['true', 'false'].map(value => (
|
||||||
<Option value={value} key={value} onClick={() => setState(value)}>
|
<Option value={value} key={value}>
|
||||||
{value.charAt(0).toUpperCase() + value.slice(1)}
|
{value.charAt(0).toUpperCase() + value.slice(1)}
|
||||||
</Option>
|
</Option>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Box display={'flex'} justifyContent={'space-around'} mt={1}>
|
|
||||||
<Button size='lg' onClick={handleSave} fullWidth sx={{ mr: 1 }}>
|
|
||||||
{currentThing?.id ? 'Update' : 'Create'}
|
|
||||||
</Button>
|
|
||||||
<Button size='lg' onClick={onClose} variant='outlined'>
|
|
||||||
{currentThing?.id ? 'Cancel' : 'Close'}
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</ResponsiveModal>
|
</ResponsiveModal>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { Box, Button, Input } from '@mui/joy'
|
import { Input } from '@mui/joy'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import ModalActions from '../../../components/common/ModalActions'
|
||||||
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
||||||
|
|
||||||
function DateModal({ isOpen, onClose, onSave, current, title }) {
|
function DateModal({ isOpen, onClose, onSave, current, title }) {
|
||||||
const { ResponsiveModal } = useResponsiveModal()
|
const { ResponsiveModal } = useResponsiveModal()
|
||||||
|
|
||||||
const [date, setDate] = useState(
|
const [date, setDate] = useState(
|
||||||
current ? new Date(current).toISOString().split('T')[0] : '',
|
current ? new Date(current).toISOString().split('T')[0] : '',
|
||||||
)
|
)
|
||||||
@@ -18,74 +18,23 @@ function DateModal({ isOpen, onClose, onSave, current, title }) {
|
|||||||
<ResponsiveModal
|
<ResponsiveModal
|
||||||
open={isOpen}
|
open={isOpen}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
size='lg'
|
size='sm'
|
||||||
fullWidth={true}
|
|
||||||
title={title}
|
title={title}
|
||||||
|
footer={
|
||||||
|
<ModalActions
|
||||||
|
secondary={{ label: 'Cancel', onClick: onClose }}
|
||||||
|
primary={{ label: 'Save', onClick: handleSave, disabled: !date }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
sx={{ mt: 3 }}
|
autoFocus
|
||||||
type='date'
|
type='date'
|
||||||
value={date}
|
value={date}
|
||||||
onChange={e => setDate(e.target.value)}
|
onChange={event => setDate(event.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* <Box sx={{ mt: 3 }}>
|
|
||||||
<Typography level='body-sm' sx={{ mb: 1.5, fontWeight: 500 }}>
|
|
||||||
Quick select:
|
|
||||||
</Typography>
|
|
||||||
<Stack direction='row' spacing={1} flexWrap='wrap' useFlexGap>
|
|
||||||
<Chip
|
|
||||||
variant='soft'
|
|
||||||
color='primary'
|
|
||||||
startDecorator={<Today />}
|
|
||||||
size='lg'
|
|
||||||
onClick={() => handleQuickSchedule('today')}
|
|
||||||
sx={{ cursor: 'pointer' }}
|
|
||||||
>
|
|
||||||
Today
|
|
||||||
</Chip>
|
|
||||||
<Chip
|
|
||||||
variant='soft'
|
|
||||||
color='primary'
|
|
||||||
startDecorator={<WbSunny />}
|
|
||||||
size='lg'
|
|
||||||
onClick={() => handleQuickSchedule('tomorrow')}
|
|
||||||
sx={{ cursor: 'pointer' }}
|
|
||||||
>
|
|
||||||
Tomorrow
|
|
||||||
</Chip>
|
|
||||||
<Chip
|
|
||||||
variant='soft'
|
|
||||||
color='primary'
|
|
||||||
startDecorator={<Weekend />}
|
|
||||||
size='lg'
|
|
||||||
onClick={() => handleQuickSchedule('weekend')}
|
|
||||||
sx={{ cursor: 'pointer' }}
|
|
||||||
>
|
|
||||||
Weekend
|
|
||||||
</Chip>
|
|
||||||
<Chip
|
|
||||||
variant='soft'
|
|
||||||
color='primary'
|
|
||||||
startDecorator={<NextWeek />}
|
|
||||||
size='lg'
|
|
||||||
onClick={() => handleQuickSchedule('next-week')}
|
|
||||||
sx={{ cursor: 'pointer' }}
|
|
||||||
>
|
|
||||||
Next week
|
|
||||||
</Chip>
|
|
||||||
</Stack>
|
|
||||||
</Box> */}
|
|
||||||
|
|
||||||
<Box display={'flex'} justifyContent={'space-around'} mt={4}>
|
|
||||||
<Button size='lg' onClick={handleSave} fullWidth sx={{ mr: 1 }}>
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
<Button size='lg' onClick={onClose} variant='outlined'>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</ResponsiveModal>
|
</ResponsiveModal>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default DateModal
|
export default DateModal
|
||||||
|
|||||||
@@ -1,12 +1,6 @@
|
|||||||
import {
|
import { FormControl, FormHelperText, Input, Typography } from '@mui/joy'
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
FormControl,
|
|
||||||
FormHelperText,
|
|
||||||
Input,
|
|
||||||
Typography,
|
|
||||||
} from '@mui/joy'
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import ModalActions from '../../../components/common/ModalActions'
|
||||||
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
||||||
|
|
||||||
function EditThingStateModal({ isOpen, onClose, onSave, currentThing }) {
|
function EditThingStateModal({ isOpen, onClose, onSave, currentThing }) {
|
||||||
@@ -31,7 +25,7 @@ function EditThingStateModal({ isOpen, onClose, onSave, currentThing }) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
onSave({
|
onSave({
|
||||||
name,
|
name: currentThing?.name,
|
||||||
type: currentThing?.type,
|
type: currentThing?.type,
|
||||||
id: currentThing?.id,
|
id: currentThing?.id,
|
||||||
state: state || null,
|
state: state || null,
|
||||||
@@ -43,9 +37,14 @@ function EditThingStateModal({ isOpen, onClose, onSave, currentThing }) {
|
|||||||
<ResponsiveModal
|
<ResponsiveModal
|
||||||
open={isOpen}
|
open={isOpen}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
size='lg'
|
size='sm'
|
||||||
fullWidth={true}
|
|
||||||
title='Update state'
|
title='Update state'
|
||||||
|
footer={
|
||||||
|
<ModalActions
|
||||||
|
secondary={{ label: 'Cancel', onClick: onClose }}
|
||||||
|
primary={{ label: 'Update', onClick: handleSave }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Typography>Value</Typography>
|
<Typography>Value</Typography>
|
||||||
@@ -57,15 +56,6 @@ function EditThingStateModal({ isOpen, onClose, onSave, currentThing }) {
|
|||||||
/>
|
/>
|
||||||
<FormHelperText color='danger'>{errors.state}</FormHelperText>
|
<FormHelperText color='danger'>{errors.state}</FormHelperText>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<Box display={'flex'} justifyContent={'space-around'} mt={1}>
|
|
||||||
<Button size='lg' onClick={handleSave} fullWidth sx={{ mr: 1 }}>
|
|
||||||
{currentThing?.id ? 'Update' : 'Create'}
|
|
||||||
</Button>
|
|
||||||
<Button size='lg' onClick={onClose} variant='outlined'>
|
|
||||||
{currentThing?.id ? 'Cancel' : 'Close'}
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</ResponsiveModal>
|
</ResponsiveModal>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,5 @@
|
|||||||
import {
|
import { Avatar, Box, FormControl, FormLabel, Grid, Typography } from '@mui/joy'
|
||||||
Avatar,
|
import ModalActions from '../../../components/common/ModalActions'
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
FormControl,
|
|
||||||
FormLabel,
|
|
||||||
Grid,
|
|
||||||
Typography,
|
|
||||||
} from '@mui/joy'
|
|
||||||
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
||||||
import { getTextColorFromBackgroundColor } from '../../../utils/Colors'
|
import { getTextColorFromBackgroundColor } from '../../../utils/Colors'
|
||||||
import PROJECT_ICONS from '../../../utils/ProjectIcons'
|
import PROJECT_ICONS from '../../../utils/ProjectIcons'
|
||||||
@@ -33,8 +26,10 @@ const IconPickerModal = ({
|
|||||||
fullWidth={true}
|
fullWidth={true}
|
||||||
unmountDelay={250}
|
unmountDelay={250}
|
||||||
title='Choose Project Icon'
|
title='Choose Project Icon'
|
||||||
|
footer={
|
||||||
|
<ModalActions secondary={{ label: 'Cancel', onClick: onClose }} />
|
||||||
|
}
|
||||||
>
|
>
|
||||||
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<FormLabel>Available Icons</FormLabel>
|
<FormLabel>Available Icons</FormLabel>
|
||||||
<Grid
|
<Grid
|
||||||
@@ -58,7 +53,9 @@ const IconPickerModal = ({
|
|||||||
border: '2px solid',
|
border: '2px solid',
|
||||||
borderColor: isCurrentIcon ? 'primary.500' : 'transparent',
|
borderColor: isCurrentIcon ? 'primary.500' : 'transparent',
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
borderColor: isCurrentIcon ? 'primary.600' : 'neutral.300',
|
borderColor: isCurrentIcon
|
||||||
|
? 'primary.600'
|
||||||
|
: 'neutral.300',
|
||||||
},
|
},
|
||||||
transition: 'border-color 0.2s',
|
transition: 'border-color 0.2s',
|
||||||
}}
|
}}
|
||||||
@@ -96,12 +93,6 @@ const IconPickerModal = ({
|
|||||||
})}
|
})}
|
||||||
</Grid>
|
</Grid>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<Box display='flex' justifyContent='center' mt={3}>
|
|
||||||
<Button variant='outlined' onClick={onClose} fullWidth size='lg'>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</ResponsiveModal>
|
</ResponsiveModal>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { Box, Button, FormControl, Input, Typography } from '@mui/joy'
|
import { Box, FormControl, Input, Typography } from '@mui/joy'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
import { useQueryClient } from '@tanstack/react-query'
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
|
import ModalActions from '../../../components/common/ModalActions'
|
||||||
import { useResponsiveModal } from '../../../hooks/useResponsiveModal.js'
|
import { useResponsiveModal } from '../../../hooks/useResponsiveModal.js'
|
||||||
import { useNotification } from '../../../service/NotificationProvider.jsx'
|
import { useNotification } from '../../../service/NotificationProvider.jsx'
|
||||||
import LABEL_COLORS from '../../../utils/Colors.jsx'
|
import LABEL_COLORS from '../../../utils/Colors.jsx'
|
||||||
@@ -90,14 +91,13 @@ function LabelModal({ isOpen, onClose, label }) {
|
|||||||
fullWidth={true}
|
fullWidth={true}
|
||||||
title={label ? 'Edit Label' : 'Add Label'}
|
title={label ? 'Edit Label' : 'Add Label'}
|
||||||
footer={
|
footer={
|
||||||
<Box display='flex' justifyContent='space-around' mt={1}>
|
<ModalActions
|
||||||
<Button size='lg' onClick={handleSave} fullWidth sx={{ mr: 1 }}>
|
secondary={{ label: 'Cancel', onClick: onClose }}
|
||||||
{label ? 'Save Changes' : 'Add Label'}
|
primary={{
|
||||||
</Button>
|
label: label ? 'Save Changes' : 'Add Label',
|
||||||
<Button size='lg' onClick={onClose} variant='outlined'>
|
onClick: handleSave,
|
||||||
Cancel
|
}}
|
||||||
</Button>
|
/>
|
||||||
</Box>
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Box>
|
<Box>
|
||||||
@@ -120,12 +120,18 @@ function LabelModal({ isOpen, onClose, label }) {
|
|||||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||||
{LABEL_COLORS.map(colorOption => (
|
{LABEL_COLORS.map(colorOption => (
|
||||||
<Box
|
<Box
|
||||||
|
component='button'
|
||||||
|
type='button'
|
||||||
key={colorOption.value}
|
key={colorOption.value}
|
||||||
|
aria-label={`Select ${colorOption.name}`}
|
||||||
|
aria-pressed={color === colorOption.value}
|
||||||
title={colorOption.name}
|
title={colorOption.name}
|
||||||
onClick={() => setColor(colorOption.value)}
|
onClick={() => setColor(colorOption.value)}
|
||||||
sx={{
|
sx={{
|
||||||
width: 26,
|
width: 40,
|
||||||
height: 26,
|
height: 40,
|
||||||
|
border: 0,
|
||||||
|
p: 0,
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
background: colorOption.value,
|
background: colorOption.value,
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
|
|||||||
@@ -1,15 +1,33 @@
|
|||||||
import { Box, Button, Typography } from '@mui/joy'
|
import { Box, Typography } from '@mui/joy'
|
||||||
|
import ModalActions from '../../../components/common/ModalActions'
|
||||||
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
||||||
|
|
||||||
const NativeCancelSubscriptionModal = ({ isOpen, onClose }) => {
|
const NativeCancelSubscriptionModal = ({ isOpen, onClose }) => {
|
||||||
const { ResponsiveModal } = useResponsiveModal()
|
const { ResponsiveModal } = useResponsiveModal()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResponsiveModal open={isOpen} onClose={onClose} size='md' fullWidth>
|
<ResponsiveModal
|
||||||
<Typography level='h4' sx={{ mb: 2 }}>
|
open={isOpen}
|
||||||
Cancel Subscription
|
onClose={onClose}
|
||||||
</Typography>
|
size='lg'
|
||||||
<Box sx={{ p: 2 }}>
|
title='Cancel Subscription'
|
||||||
|
footer={
|
||||||
|
<ModalActions
|
||||||
|
stackOnMobile
|
||||||
|
tertiary={{ label: 'Dismiss', onClick: onClose }}
|
||||||
|
secondary={{
|
||||||
|
label: "I'll cancel from my app store",
|
||||||
|
onClick: onClose,
|
||||||
|
}}
|
||||||
|
primary={{
|
||||||
|
label: 'Cancel desktop subscription',
|
||||||
|
color: 'danger',
|
||||||
|
onClick: () => onClose('desktop'),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Box>
|
||||||
<Typography level='body-md' mb={3}>
|
<Typography level='body-md' mb={3}>
|
||||||
To cancel your subscription, please follow the instructions for your
|
To cancel your subscription, please follow the instructions for your
|
||||||
platform (you should cancel through the same platform you used to
|
platform (you should cancel through the same platform you used to
|
||||||
@@ -84,8 +102,8 @@ const NativeCancelSubscriptionModal = ({ isOpen, onClose }) => {
|
|||||||
<strong>Important:</strong> You must cancel your subscription
|
<strong>Important:</strong> You must cancel your subscription
|
||||||
through the same platform where you originally subscribed. If you
|
through the same platform where you originally subscribed. If you
|
||||||
subscribed through the iOS App Store or Google Play Store (even if
|
subscribed through the iOS App Store or Google Play Store (even if
|
||||||
you're now using the web/desktop version), you must cancel through
|
you're now using the web/desktop version), you must cancel
|
||||||
that original platform using the instructions above.
|
through that original platform using the instructions above.
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
@@ -93,24 +111,6 @@ const NativeCancelSubscriptionModal = ({ isOpen, onClose }) => {
|
|||||||
Your subscription will remain active until the end of your current
|
Your subscription will remain active until the end of your current
|
||||||
billing period.
|
billing period.
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
|
||||||
<Button size='lg' onClick={onClose} variant='outlined' fullWidth>
|
|
||||||
I'll cancel from my app store
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size='lg'
|
|
||||||
onClick={() => onClose('desktop')}
|
|
||||||
variant='solid'
|
|
||||||
color='danger'
|
|
||||||
fullWidth
|
|
||||||
>
|
|
||||||
I subscribed via desktop - Cancel now
|
|
||||||
</Button>
|
|
||||||
<Button size='lg' onClick={onClose} fullWidth>
|
|
||||||
Dismiss
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
</Box>
|
||||||
</ResponsiveModal>
|
</ResponsiveModal>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
FormControl,
|
||||||
FormControl,
|
FormLabel,
|
||||||
FormLabel,
|
Switch,
|
||||||
Switch,
|
Textarea,
|
||||||
Textarea,
|
Typography,
|
||||||
Typography,
|
|
||||||
} from '@mui/joy'
|
} from '@mui/joy'
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import KeyboardShortcutHint from '../../../components/common/KeyboardShortcutHint'
|
import KeyboardShortcutHint from '../../../components/common/KeyboardShortcutHint'
|
||||||
|
import ModalActions from '../../../components/common/ModalActions'
|
||||||
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
||||||
import { isOfficialDonetickInstanceSync } from '../../../utils/FeatureToggle'
|
import { isOfficialDonetickInstanceSync } from '../../../utils/FeatureToggle'
|
||||||
|
|
||||||
@@ -108,19 +108,34 @@ function NudgeModal({ config }) {
|
|||||||
fullWidth={true}
|
fullWidth={true}
|
||||||
unmountDelay={250}
|
unmountDelay={250}
|
||||||
title='Send Nudge'
|
title='Send Nudge'
|
||||||
|
description='Send a gentle reminder to the people assigned to this task.'
|
||||||
|
footer={
|
||||||
|
<ModalActions
|
||||||
|
secondary={{
|
||||||
|
label: 'Cancel',
|
||||||
|
onClick: () => handleAction(false),
|
||||||
|
endDecorator: showKeyboardShortcuts ? (
|
||||||
|
<KeyboardShortcutHint shortcut='X' />
|
||||||
|
) : undefined,
|
||||||
|
}}
|
||||||
|
primary={{
|
||||||
|
label: 'Send Nudge',
|
||||||
|
onClick: () => handleAction(true),
|
||||||
|
disabled: !isOfficialInstance,
|
||||||
|
endDecorator: showKeyboardShortcuts ? (
|
||||||
|
<KeyboardShortcutHint shortcut='Y' />
|
||||||
|
) : undefined,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Typography level='body-md' mb={2}>
|
|
||||||
Send a gentle reminder to the assignee about this task. You can
|
|
||||||
customize the message and choose who gets notified.
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
{!isOfficialInstance && (
|
{!isOfficialInstance && (
|
||||||
<Alert color='warning' sx={{ mb: 2 }}>
|
<Alert color='warning' sx={{ mb: 2 }}>
|
||||||
<Typography level='body-sm'>
|
<Typography level='body-sm'>
|
||||||
<strong>Heads up!</strong>This feature avaiable on Donetick Cloud!
|
<strong>Heads up!</strong>This feature avaiable on Donetick Cloud!
|
||||||
Since you're using a self-hosted instance, nudges will requires you
|
Since you're using a self-hosted instance, nudges will requires
|
||||||
to setup Google cloud account and Firebase Cloud Messaging (FCM).
|
you to setup Google cloud account and Firebase Cloud Messaging
|
||||||
and build the Android or the iOS app by yourself.
|
(FCM). and build the Android or the iOS app by yourself.
|
||||||
<br />
|
<br />
|
||||||
Will update if we come up with a solution to make this easier for to
|
Will update if we come up with a solution to make this easier for to
|
||||||
configure. for selfhosters
|
configure. for selfhosters
|
||||||
@@ -152,33 +167,6 @@ function NudgeModal({ config }) {
|
|||||||
onChange={e => setNotifyAllAssignees(e.target.checked)}
|
onChange={e => setNotifyAllAssignees(e.target.checked)}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<Box display={'flex'} justifyContent={'space-around'} gap={1}>
|
|
||||||
<Button
|
|
||||||
size='lg'
|
|
||||||
onClick={() => handleAction(true)}
|
|
||||||
disabled={!isOfficialInstance}
|
|
||||||
fullWidth
|
|
||||||
color='primary'
|
|
||||||
endDecorator={
|
|
||||||
<KeyboardShortcutHint shortcut='Y' show={showKeyboardShortcuts} />
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Send Nudge
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
size='lg'
|
|
||||||
onClick={() => handleAction(false)}
|
|
||||||
variant='outlined'
|
|
||||||
fullWidth
|
|
||||||
endDecorator={
|
|
||||||
<KeyboardShortcutHint shortcut='X' show={showKeyboardShortcuts} />
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</ResponsiveModal>
|
</ResponsiveModal>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,20 @@
|
|||||||
import {
|
import { FormControl, FormHelperText, Input, Typography } from '@mui/joy'
|
||||||
Box,
|
import { useEffect, useState } from 'react'
|
||||||
Button,
|
import ModalActions from '../../../components/common/ModalActions'
|
||||||
FormControl,
|
|
||||||
FormHelperText,
|
|
||||||
Input,
|
|
||||||
Typography,
|
|
||||||
} from '@mui/joy'
|
|
||||||
import React, { useEffect } from 'react'
|
|
||||||
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
||||||
|
|
||||||
function PassowrdChangeModal({ isOpen, onClose }) {
|
function PasswordChangeModal({ isOpen, onClose }) {
|
||||||
const { ResponsiveModal } = useResponsiveModal()
|
const { ResponsiveModal } = useResponsiveModal()
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('')
|
||||||
|
const [passwordError, setPasswordError] = useState(null)
|
||||||
|
const [passwordTouched, setPasswordTouched] = useState(false)
|
||||||
|
const [confirmPasswordTouched, setConfirmPasswordTouched] = useState(false)
|
||||||
|
|
||||||
const [password, setPassword] = React.useState('')
|
|
||||||
const [confirmPassword, setConfirmPassword] = React.useState('')
|
|
||||||
const [passwordError, setPasswordError] = React.useState(false)
|
|
||||||
const [passwordTouched, setPasswordTouched] = React.useState(false)
|
|
||||||
const [confirmPasswordTouched, setConfirmPasswordTouched] =
|
|
||||||
React.useState(false)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!passwordTouched || !confirmPasswordTouched) {
|
if (!passwordTouched || !confirmPasswordTouched) return
|
||||||
return
|
|
||||||
} else if (password !== confirmPassword) {
|
if (password !== confirmPassword) {
|
||||||
setPasswordError('Passwords do not match')
|
setPasswordError('Passwords do not match')
|
||||||
} else if (password.length < 8) {
|
} else if (password.length < 8) {
|
||||||
setPasswordError('Password must be at least 8 characters')
|
setPasswordError('Password must be at least 8 characters')
|
||||||
@@ -32,90 +25,66 @@ function PassowrdChangeModal({ isOpen, onClose }) {
|
|||||||
}
|
}
|
||||||
}, [password, confirmPassword, passwordTouched, confirmPasswordTouched])
|
}, [password, confirmPassword, passwordTouched, confirmPasswordTouched])
|
||||||
|
|
||||||
const handleAction = isConfirmed => {
|
const handleAction = isConfirmed => onClose(isConfirmed ? password : null)
|
||||||
if (!isConfirmed) {
|
const canSubmit =
|
||||||
onClose(null)
|
passwordTouched &&
|
||||||
return
|
confirmPasswordTouched &&
|
||||||
}
|
password.length >= 8 &&
|
||||||
onClose(password)
|
password === confirmPassword &&
|
||||||
}
|
passwordError == null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResponsiveModal
|
<ResponsiveModal
|
||||||
open={isOpen}
|
open={isOpen}
|
||||||
onClose={onClose}
|
onClose={() => handleAction(false)}
|
||||||
size='lg'
|
size='sm'
|
||||||
fullWidth={true}
|
|
||||||
title='Change Password'
|
title='Change Password'
|
||||||
|
description='Choose a password between 8 and 64 characters.'
|
||||||
|
footer={
|
||||||
|
<ModalActions
|
||||||
|
secondary={{ label: 'Cancel', onClick: () => handleAction(false) }}
|
||||||
|
primary={{
|
||||||
|
label: 'Change Password',
|
||||||
|
disabled: !canSubmit,
|
||||||
|
onClick: () => handleAction(true),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Typography level='body-md' gutterBottom>
|
<FormControl sx={{ mb: 2 }}>
|
||||||
Please enter your new password.
|
<Typography level='body-sm'>New password</Typography>
|
||||||
</Typography>
|
|
||||||
<FormControl>
|
|
||||||
<Typography level='body2' alignSelf={'start'}>
|
|
||||||
New Password
|
|
||||||
</Typography>
|
|
||||||
<Input
|
<Input
|
||||||
margin='normal'
|
|
||||||
required
|
required
|
||||||
fullWidth
|
|
||||||
name='password'
|
name='password'
|
||||||
label='Password'
|
|
||||||
type='password'
|
type='password'
|
||||||
id='password'
|
autoComplete='new-password'
|
||||||
placeholder='Enter password (8-64 characters)'
|
placeholder='Enter password'
|
||||||
value={password}
|
value={password}
|
||||||
onChange={e => {
|
onChange={event => {
|
||||||
setPasswordTouched(true)
|
setPasswordTouched(true)
|
||||||
setPassword(e.target.value)
|
setPassword(event.target.value)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<FormControl>
|
<FormControl error={Boolean(passwordError)}>
|
||||||
<Typography level='body2' alignSelf={'start'}>
|
<Typography level='body-sm'>Confirm password</Typography>
|
||||||
Confirm Password
|
|
||||||
</Typography>
|
|
||||||
<Input
|
<Input
|
||||||
margin='normal'
|
|
||||||
required
|
required
|
||||||
fullWidth
|
|
||||||
name='confirmPassword'
|
name='confirmPassword'
|
||||||
label='confirmPassword'
|
|
||||||
type='password'
|
type='password'
|
||||||
id='confirmPassword'
|
autoComplete='new-password'
|
||||||
|
placeholder='Repeat password'
|
||||||
value={confirmPassword}
|
value={confirmPassword}
|
||||||
onChange={e => {
|
onChange={event => {
|
||||||
setConfirmPasswordTouched(true)
|
setConfirmPasswordTouched(true)
|
||||||
setConfirmPassword(e.target.value)
|
setConfirmPassword(event.target.value)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{passwordError && <FormHelperText>{passwordError}</FormHelperText>}
|
||||||
<FormHelperText>{passwordError}</FormHelperText>
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<Box display={'flex'} justifyContent={'space-around'} mt={1}>
|
|
||||||
<Button
|
|
||||||
size='lg'
|
|
||||||
disabled={passwordError != null}
|
|
||||||
onClick={() => {
|
|
||||||
handleAction(true)
|
|
||||||
}}
|
|
||||||
fullWidth
|
|
||||||
sx={{ mr: 1 }}
|
|
||||||
>
|
|
||||||
Change Password
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size='lg'
|
|
||||||
onClick={() => {
|
|
||||||
handleAction(false)
|
|
||||||
}}
|
|
||||||
variant='outlined'
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</ResponsiveModal>
|
</ResponsiveModal>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
export default PassowrdChangeModal
|
|
||||||
|
export default PasswordChangeModal
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
Typography,
|
Typography,
|
||||||
} from '@mui/joy'
|
} from '@mui/joy'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
import ModalActions from '../../../components/common/ModalActions'
|
||||||
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
||||||
import PROJECT_COLORS, {
|
import PROJECT_COLORS, {
|
||||||
getTextColorFromBackgroundColor,
|
getTextColorFromBackgroundColor,
|
||||||
@@ -124,28 +125,23 @@ const ProjectModal = ({ isOpen, onClose, onSave, project }) => {
|
|||||||
unmountDelay={250}
|
unmountDelay={250}
|
||||||
fullWidth={true}
|
fullWidth={true}
|
||||||
title={project ? 'Edit Project' : 'Create New Project'}
|
title={project ? 'Edit Project' : 'Create New Project'}
|
||||||
|
closeOnBackdrop={!isSubmitting}
|
||||||
|
closeOnEscape={!isSubmitting}
|
||||||
footer={
|
footer={
|
||||||
<Box display='flex' justifyContent='space-around' gap={1}>
|
<ModalActions
|
||||||
<Button
|
secondary={{
|
||||||
type='submit'
|
label: 'Cancel',
|
||||||
form='project-form'
|
onClick: handleClose,
|
||||||
loading={isSubmitting}
|
disabled: isSubmitting,
|
||||||
disabled={!projectName.trim() || isSubmitting}
|
}}
|
||||||
fullWidth
|
primary={{
|
||||||
size='lg'
|
label: project ? 'Update' : 'Create',
|
||||||
>
|
type: 'submit',
|
||||||
{project ? 'Update' : 'Create'}
|
form: 'project-form',
|
||||||
</Button>
|
loading: isSubmitting,
|
||||||
<Button
|
disabled: !projectName.trim() || isSubmitting,
|
||||||
variant='outlined'
|
}}
|
||||||
onClick={handleClose}
|
/>
|
||||||
disabled={isSubmitting}
|
|
||||||
fullWidth
|
|
||||||
size='lg'
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<form onSubmit={handleSubmit} id='project-form'>
|
<form onSubmit={handleSubmit} id='project-form'>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Box, Button, Option, Select } from '@mui/joy'
|
import { Option, Select } from '@mui/joy'
|
||||||
import React from 'react'
|
import { useState } from 'react'
|
||||||
|
import ModalActions from '../../../components/common/ModalActions'
|
||||||
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
||||||
|
|
||||||
function SelectModal({
|
function SelectModal({
|
||||||
@@ -12,8 +13,8 @@ function SelectModal({
|
|||||||
placeholder,
|
placeholder,
|
||||||
}) {
|
}) {
|
||||||
const { ResponsiveModal } = useResponsiveModal()
|
const { ResponsiveModal } = useResponsiveModal()
|
||||||
|
const [selected, setSelected] = useState(null)
|
||||||
|
|
||||||
const [selected, setSelected] = React.useState(null)
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
onSave(options.find(item => item.id === selected))
|
onSave(options.find(item => item.id === selected))
|
||||||
onClose()
|
onClose()
|
||||||
@@ -23,33 +24,33 @@ function SelectModal({
|
|||||||
<ResponsiveModal
|
<ResponsiveModal
|
||||||
open={isOpen}
|
open={isOpen}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
size='lg'
|
size='sm'
|
||||||
fullWidth={true}
|
|
||||||
title={title}
|
title={title}
|
||||||
|
footer={
|
||||||
|
<ModalActions
|
||||||
|
secondary={{ label: 'Cancel', onClick: onClose }}
|
||||||
|
primary={{
|
||||||
|
label: 'Save',
|
||||||
|
onClick: handleSave,
|
||||||
|
disabled: selected == null,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Select placeholder={placeholder}>
|
<Select
|
||||||
{options.map((item, index) => (
|
autoFocus
|
||||||
<Option
|
placeholder={placeholder}
|
||||||
value={item.id}
|
value={selected}
|
||||||
key={item[displayKey]}
|
onChange={(_, value) => setSelected(value)}
|
||||||
onClick={() => {
|
>
|
||||||
setSelected(item.id)
|
{options.map(item => (
|
||||||
}}
|
<Option value={item.id} key={item[displayKey]}>
|
||||||
>
|
|
||||||
{item[displayKey]}
|
{item[displayKey]}
|
||||||
</Option>
|
</Option>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<Box display={'flex'} justifyContent={'space-around'} mt={1}>
|
|
||||||
<Button size='lg' onClick={handleSave} fullWidth sx={{ mr: 1 }}>
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
<Button size='lg' onClick={onClose} variant='outlined'>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</ResponsiveModal>
|
</ResponsiveModal>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default SelectModal
|
export default SelectModal
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Box, Button, Textarea } from '@mui/joy'
|
import { Textarea } from '@mui/joy'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import ModalActions from '../../../components/common/ModalActions'
|
||||||
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
||||||
|
|
||||||
function TextModal({
|
function TextModal({
|
||||||
@@ -12,7 +13,6 @@ function TextModal({
|
|||||||
cancelText,
|
cancelText,
|
||||||
}) {
|
}) {
|
||||||
const { ResponsiveModal } = useResponsiveModal()
|
const { ResponsiveModal } = useResponsiveModal()
|
||||||
|
|
||||||
const [text, setText] = useState(current)
|
const [text, setText] = useState(current)
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
@@ -24,28 +24,25 @@ function TextModal({
|
|||||||
<ResponsiveModal
|
<ResponsiveModal
|
||||||
open={isOpen}
|
open={isOpen}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
size='lg'
|
size='md'
|
||||||
fullWidth={true}
|
|
||||||
title={title}
|
title={title}
|
||||||
|
footer={
|
||||||
|
<ModalActions
|
||||||
|
secondary={{ label: cancelText || 'Cancel', onClick: onClose }}
|
||||||
|
primary={{ label: okText || 'Save', onClick: handleSave }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Textarea
|
<Textarea
|
||||||
|
autoFocus
|
||||||
placeholder='Type in here…'
|
placeholder='Type in here…'
|
||||||
value={text}
|
value={text}
|
||||||
onChange={e => setText(e.target.value)}
|
onChange={event => setText(event.target.value)}
|
||||||
minRows={2}
|
minRows={3}
|
||||||
maxRows={4}
|
maxRows={8}
|
||||||
sx={{ minWidth: 300 }}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Box display={'flex'} justifyContent={'space-around'} mt={1}>
|
|
||||||
<Button size='lg' onClick={handleSave} fullWidth sx={{ mr: 1 }}>
|
|
||||||
{okText ? okText : 'Save'}
|
|
||||||
</Button>
|
|
||||||
<Button size='lg' onClick={onClose} variant='outlined'>
|
|
||||||
{cancelText ? cancelText : 'Cancel'}
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</ResponsiveModal>
|
</ResponsiveModal>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default TextModal
|
export default TextModal
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
import moment from 'moment'
|
import moment from 'moment'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useLocalization } from '../../../contexts/LocalizationContext'
|
import { useLocalization } from '../../../contexts/LocalizationContext'
|
||||||
|
import ModalActions from '../../../components/common/ModalActions'
|
||||||
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
||||||
import { useNotification } from '../../../service/NotificationProvider'
|
import { useNotification } from '../../../service/NotificationProvider'
|
||||||
import {
|
import {
|
||||||
@@ -59,7 +60,6 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
|
|||||||
}
|
}
|
||||||
}, [isOpen, timerData])
|
}, [isOpen, timerData])
|
||||||
|
|
||||||
|
|
||||||
const formatTime = seconds => {
|
const formatTime = seconds => {
|
||||||
const hours = Math.floor(seconds / 3600)
|
const hours = Math.floor(seconds / 3600)
|
||||||
const minutes = Math.floor((seconds % 3600) / 60)
|
const minutes = Math.floor((seconds % 3600) / 60)
|
||||||
@@ -304,10 +304,37 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
|
|||||||
open={isOpen}
|
open={isOpen}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
size='lg'
|
size='lg'
|
||||||
fullWidth={true}
|
title='Timer Details'
|
||||||
|
footer={
|
||||||
|
<ModalActions
|
||||||
|
tertiary={
|
||||||
|
!loading && timerData && !editingSessions[timerData.id]
|
||||||
|
? {
|
||||||
|
label: 'Delete',
|
||||||
|
color: 'danger',
|
||||||
|
onClick: () => confirmDeleteSession(timerData.id),
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
secondary={{ label: 'Close', onClick: handleClose }}
|
||||||
|
primary={
|
||||||
|
!loading && timerData
|
||||||
|
? editingSessions[timerData.id]
|
||||||
|
? {
|
||||||
|
label: 'Save',
|
||||||
|
onClick: () => saveSession(timerData.id),
|
||||||
|
loading,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
label: 'Edit',
|
||||||
|
startDecorator: <Edit />,
|
||||||
|
onClick: () => startEditingSession(),
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Typography level='h4'>Timer Details</Typography>
|
|
||||||
|
|
||||||
{loading && (
|
{loading && (
|
||||||
<Alert color='neutral' sx={{ mb: 2 }}>
|
<Alert color='neutral' sx={{ mb: 2 }}>
|
||||||
Loading timer data...
|
Loading timer data...
|
||||||
@@ -919,56 +946,6 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
|
||||||
<Button variant='outlined' onClick={handleClose} color='neutral'>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
|
||||||
{/* Action buttons on the right */}
|
|
||||||
{!loading && timerData && !editingSessions[timerData.id] && (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
size='sm'
|
|
||||||
variant='outlined'
|
|
||||||
color='danger'
|
|
||||||
onClick={() => confirmDeleteSession(timerData.id)}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant='outlined'
|
|
||||||
startDecorator={<Edit />}
|
|
||||||
onClick={() => startEditingSession()}
|
|
||||||
>
|
|
||||||
Edit
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Save button when editing */}
|
|
||||||
{!loading && timerData && editingSessions[timerData.id] && (
|
|
||||||
<Button
|
|
||||||
variant='solid'
|
|
||||||
color='primary'
|
|
||||||
onClick={() => saveSession(timerData.id)}
|
|
||||||
loading={loading}
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</ResponsiveModal>
|
</ResponsiveModal>
|
||||||
|
|
||||||
<ConfirmationModal config={confirmDeleteConfig} />
|
<ConfirmationModal config={confirmDeleteConfig} />
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Button,
|
|
||||||
Card,
|
Card,
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
FormControl,
|
FormControl,
|
||||||
@@ -10,13 +9,13 @@ import {
|
|||||||
Select,
|
Select,
|
||||||
Typography,
|
Typography,
|
||||||
} from '@mui/joy'
|
} from '@mui/joy'
|
||||||
import { data } from 'autoprefixer'
|
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import ModalActions from '../../../components/common/ModalActions'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
||||||
import { CheckUserDeletion, DeleteUser } from '../../../utils/Fetcher'
|
import { CheckUserDeletion, DeleteUser } from '../../../utils/Fetcher'
|
||||||
|
|
||||||
function UserDeletionModal({ isOpen, onClose, userProfile }) {
|
function UserDeletionModal({ isOpen, onClose }) {
|
||||||
const { ResponsiveModal } = useResponsiveModal()
|
const { ResponsiveModal } = useResponsiveModal()
|
||||||
const Navigate = useNavigate()
|
const Navigate = useNavigate()
|
||||||
const [step, setStep] = useState(1) // 1: Warning, 2: Transfer, 3: Confirm
|
const [step, setStep] = useState(1) // 1: Warning, 2: Transfer, 3: Confirm
|
||||||
@@ -70,7 +69,8 @@ function UserDeletionModal({ isOpen, onClose, userProfile }) {
|
|||||||
setError(data.error || 'Failed to check deletion requirements')
|
setError(data.error || 'Failed to check deletion requirements')
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(data.error || 'Failed to check deletion requirements')
|
console.error('Failed to check deletion requirements:', err)
|
||||||
|
setError('Failed to check deletion requirements')
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -119,6 +119,7 @@ function UserDeletionModal({ isOpen, onClose, userProfile }) {
|
|||||||
setError(data.message || 'Failed to delete account')
|
setError(data.message || 'Failed to delete account')
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
console.error('Failed to delete account:', err)
|
||||||
setError('Failed to delete account')
|
setError('Failed to delete account')
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
@@ -148,10 +149,6 @@ function UserDeletionModal({ isOpen, onClose, userProfile }) {
|
|||||||
|
|
||||||
const renderWarningStep = () => (
|
const renderWarningStep = () => (
|
||||||
<>
|
<>
|
||||||
<Typography level='h4' mb={2} color='danger'>
|
|
||||||
Delete Account
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Typography level='body-md' mb={2}>
|
<Typography level='body-md' mb={2}>
|
||||||
<strong>This action cannot be undone.</strong> Deleting your account
|
<strong>This action cannot be undone.</strong> Deleting your account
|
||||||
will permanently remove:
|
will permanently remove:
|
||||||
@@ -193,30 +190,11 @@ function UserDeletionModal({ isOpen, onClose, userProfile }) {
|
|||||||
{error}
|
{error}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Box display='flex' justifyContent='space-between' mt={3} gap={2}>
|
|
||||||
<Button variant='outlined' onClick={() => handleClose(false)} fullWidth>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
color='danger'
|
|
||||||
onClick={checkDeletionRequirements}
|
|
||||||
loading={loading}
|
|
||||||
disabled={!password}
|
|
||||||
fullWidth
|
|
||||||
>
|
|
||||||
Continue
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
||||||
const renderTransferStep = () => (
|
const renderTransferStep = () => (
|
||||||
<>
|
<>
|
||||||
<Typography level='h4' mb={2} color='warning'>
|
|
||||||
Circle Ownership Transfer Required
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Typography level='body-md' mb={3}>
|
<Typography level='body-md' mb={3}>
|
||||||
You own circles that require ownership transfer before deletion. Please
|
You own circles that require ownership transfer before deletion. Please
|
||||||
select new owners:
|
select new owners:
|
||||||
@@ -253,29 +231,11 @@ function UserDeletionModal({ isOpen, onClose, userProfile }) {
|
|||||||
</FormControl>
|
</FormControl>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<Box display='flex' justifyContent='space-between' mt={3} gap={2}>
|
|
||||||
<Button variant='outlined' onClick={() => handleClose(false)} fullWidth>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
color='primary'
|
|
||||||
onClick={proceedToConfirmation}
|
|
||||||
disabled={circlesRequiringTransfer.length !== transferOptions.length}
|
|
||||||
fullWidth
|
|
||||||
>
|
|
||||||
Continue
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
||||||
const renderConfirmationStep = () => (
|
const renderConfirmationStep = () => (
|
||||||
<>
|
<>
|
||||||
<Typography level='h4' mb={2} color='danger'>
|
|
||||||
Final Confirmation
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Typography level='body-md' mb={3}>
|
<Typography level='body-md' mb={3}>
|
||||||
Please enter your password and type <strong>DELETE</strong> to confirm
|
Please enter your password and type <strong>DELETE</strong> to confirm
|
||||||
account deletion.
|
account deletion.
|
||||||
@@ -296,7 +256,7 @@ function UserDeletionModal({ isOpen, onClose, userProfile }) {
|
|||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<FormControl sx={{ mb: 3 }}>
|
<FormControl sx={{ mb: 3 }}>
|
||||||
<FormLabel>Type "DELETE" to confirm</FormLabel>
|
<FormLabel>Type "DELETE" to confirm</FormLabel>
|
||||||
<Input
|
<Input
|
||||||
value={confirmation}
|
value={confirmation}
|
||||||
onChange={e => setConfirmation(e.target.value)}
|
onChange={e => setConfirmation(e.target.value)}
|
||||||
@@ -309,21 +269,6 @@ function UserDeletionModal({ isOpen, onClose, userProfile }) {
|
|||||||
{error}
|
{error}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Box display='flex' justifyContent='space-between' gap={2}>
|
|
||||||
<Button variant='outlined' onClick={() => handleClose(false)} fullWidth>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
color='danger'
|
|
||||||
onClick={executeUserDeletion}
|
|
||||||
loading={loading}
|
|
||||||
disabled={!password || confirmation !== 'DELETE'}
|
|
||||||
fullWidth
|
|
||||||
>
|
|
||||||
Delete Account
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -345,8 +290,42 @@ function UserDeletionModal({ isOpen, onClose, userProfile }) {
|
|||||||
open={isOpen}
|
open={isOpen}
|
||||||
onClose={() => handleClose(false)}
|
onClose={() => handleClose(false)}
|
||||||
size='lg'
|
size='lg'
|
||||||
fullWidth={true}
|
title={
|
||||||
title='Delete Account'
|
step === 1
|
||||||
|
? 'Delete Account'
|
||||||
|
: step === 2
|
||||||
|
? 'Transfer Circle Ownership'
|
||||||
|
: 'Final Confirmation'
|
||||||
|
}
|
||||||
|
role={step === 3 ? 'alertdialog' : 'dialog'}
|
||||||
|
closeOnBackdrop={false}
|
||||||
|
footer={
|
||||||
|
!loading && (
|
||||||
|
<ModalActions
|
||||||
|
stackOnMobile
|
||||||
|
secondary={{
|
||||||
|
label: 'Cancel',
|
||||||
|
onClick: () => handleClose(false),
|
||||||
|
}}
|
||||||
|
primary={{
|
||||||
|
label: step === 3 ? 'Delete Account' : 'Continue',
|
||||||
|
color: step === 3 ? 'danger' : 'primary',
|
||||||
|
onClick:
|
||||||
|
step === 1
|
||||||
|
? checkDeletionRequirements
|
||||||
|
: step === 2
|
||||||
|
? proceedToConfirmation
|
||||||
|
: executeUserDeletion,
|
||||||
|
disabled:
|
||||||
|
step === 1
|
||||||
|
? !password
|
||||||
|
: step === 2
|
||||||
|
? circlesRequiringTransfer.length !== transferOptions.length
|
||||||
|
: !password || confirmation !== 'DELETE',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{loading && step === 1 ? (
|
{loading && step === 1 ? (
|
||||||
<Box
|
<Box
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Avatar, Box, Button, List, ListItem, Typography } from '@mui/joy'
|
import { Avatar, Box, List, ListItem, Typography } from '@mui/joy'
|
||||||
|
import ModalActions from '../../../components/common/ModalActions'
|
||||||
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
||||||
|
|
||||||
const UserModal = ({ isOpen, performers = [], onSelect, onClose }) => {
|
const UserModal = ({ isOpen, performers = [], onSelect, onClose }) => {
|
||||||
@@ -11,6 +12,9 @@ const UserModal = ({ isOpen, performers = [], onSelect, onClose }) => {
|
|||||||
size='lg'
|
size='lg'
|
||||||
fullWidth={true}
|
fullWidth={true}
|
||||||
title='Select User'
|
title='Select User'
|
||||||
|
footer={
|
||||||
|
<ModalActions secondary={{ label: 'Cancel', onClick: onClose }} />
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<List sx={{ mb: 2 }}>
|
<List sx={{ mb: 2 }}>
|
||||||
{performers.map(user => (
|
{performers.map(user => (
|
||||||
@@ -38,11 +42,6 @@ const UserModal = ({ isOpen, performers = [], onSelect, onClose }) => {
|
|||||||
</ListItem>
|
</ListItem>
|
||||||
))}
|
))}
|
||||||
</List>
|
</List>
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
|
|
||||||
<Button size='lg' variant='outlined' color='neutral' onClick={onClose}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</ResponsiveModal>
|
</ResponsiveModal>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,16 +5,9 @@ import {
|
|||||||
ErrorOutline,
|
ErrorOutline,
|
||||||
Nfc,
|
Nfc,
|
||||||
} from '@mui/icons-material'
|
} from '@mui/icons-material'
|
||||||
import {
|
import { Box, IconButton, Input, Switch, Typography } from '@mui/joy'
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
CircularProgress,
|
|
||||||
IconButton,
|
|
||||||
Input,
|
|
||||||
Switch,
|
|
||||||
Typography,
|
|
||||||
} from '@mui/joy'
|
|
||||||
import { useRef, useState } from 'react'
|
import { useRef, useState } from 'react'
|
||||||
|
import ModalActions from '../../../components/common/ModalActions'
|
||||||
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
||||||
import { startNativeNFCWrite } from '../../../service/NFCWriter'
|
import { startNativeNFCWrite } from '../../../service/NFCWriter'
|
||||||
|
|
||||||
@@ -29,9 +22,6 @@ const pulseKeyframes = `
|
|||||||
70% { transform: scale(2.1); opacity: 0; }
|
70% { transform: scale(2.1); opacity: 0; }
|
||||||
100% { transform: scale(2.1); opacity: 0; }
|
100% { transform: scale(2.1); opacity: 0; }
|
||||||
}
|
}
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.nfc-pulse-ring { animation: none !important; }
|
|
||||||
}
|
|
||||||
`
|
`
|
||||||
|
|
||||||
function NFCIcon({ status }) {
|
function NFCIcon({ status }) {
|
||||||
@@ -55,7 +45,6 @@ function NFCIcon({ status }) {
|
|||||||
{isWaiting && (
|
{isWaiting && (
|
||||||
<>
|
<>
|
||||||
<Box
|
<Box
|
||||||
className='nfc-pulse-ring'
|
|
||||||
sx={{
|
sx={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
inset: 0,
|
inset: 0,
|
||||||
@@ -63,10 +52,10 @@ function NFCIcon({ status }) {
|
|||||||
border: '2px solid',
|
border: '2px solid',
|
||||||
borderColor: 'primary.400',
|
borderColor: 'primary.400',
|
||||||
animation: 'nfc-pulse 1.8s ease-out infinite',
|
animation: 'nfc-pulse 1.8s ease-out infinite',
|
||||||
|
'@media (prefers-reduced-motion: reduce)': { animation: 'none' },
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Box
|
<Box
|
||||||
className='nfc-pulse-ring'
|
|
||||||
sx={{
|
sx={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
inset: 0,
|
inset: 0,
|
||||||
@@ -74,6 +63,7 @@ function NFCIcon({ status }) {
|
|||||||
border: '2px solid',
|
border: '2px solid',
|
||||||
borderColor: 'primary.300',
|
borderColor: 'primary.300',
|
||||||
animation: 'nfc-pulse-2 1.8s ease-out infinite 0.4s',
|
animation: 'nfc-pulse-2 1.8s ease-out infinite 0.4s',
|
||||||
|
'@media (prefers-reduced-motion: reduce)': { animation: 'none' },
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
@@ -215,27 +205,37 @@ function WriteNFCModal({ config }) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<style>{pulseKeyframes}</style>
|
<style>{pulseKeyframes}</style>
|
||||||
<ResponsiveModal open={config?.isOpen} onClose={handleClose}>
|
<ResponsiveModal
|
||||||
|
open={config?.isOpen}
|
||||||
|
onClose={handleClose}
|
||||||
|
title={title}
|
||||||
|
description={subtitle}
|
||||||
|
closeOnBackdrop={!isWaiting}
|
||||||
|
closeOnEscape={!isWaiting}
|
||||||
|
footer={
|
||||||
|
isSuccess ? (
|
||||||
|
<ModalActions primary={{ label: 'Done', onClick: handleClose }} />
|
||||||
|
) : isWaiting ? (
|
||||||
|
<ModalActions
|
||||||
|
secondary={{ label: 'Cancel', onClick: handleCancel }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ModalActions
|
||||||
|
secondary={{ label: 'Cancel', onClick: handleClose }}
|
||||||
|
primary={{
|
||||||
|
label: nfcStatus === 'writing' ? 'Starting…' : 'Write tag',
|
||||||
|
onClick: writeToNFC,
|
||||||
|
disabled: nfcStatus === 'writing',
|
||||||
|
startDecorator: <Nfc />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
<Box sx={{ px: 0.5, pb: 1 }}>
|
<Box sx={{ px: 0.5, pb: 1 }}>
|
||||||
{/* Icon */}
|
{/* Icon */}
|
||||||
<NFCIcon status={nfcStatus} />
|
<NFCIcon status={nfcStatus} />
|
||||||
|
|
||||||
{/* Heading */}
|
|
||||||
<Typography
|
|
||||||
level='title-lg'
|
|
||||||
textAlign='center'
|
|
||||||
sx={{ mb: 0.75, fontWeight: 600 }}
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
</Typography>
|
|
||||||
<Typography
|
|
||||||
level='body-sm'
|
|
||||||
textAlign='center'
|
|
||||||
sx={{ color: 'text.secondary', mb: 3, px: 2 }}
|
|
||||||
>
|
|
||||||
{subtitle}
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
{/* Idle / Error: URL + toggle + CTA */}
|
{/* Idle / Error: URL + toggle + CTA */}
|
||||||
{!isWaiting && !isSuccess && (
|
{!isWaiting && !isSuccess && (
|
||||||
<>
|
<>
|
||||||
@@ -264,6 +264,7 @@ function WriteNFCModal({ config }) {
|
|||||||
}}
|
}}
|
||||||
endDecorator={
|
endDecorator={
|
||||||
<IconButton
|
<IconButton
|
||||||
|
aria-label='Copy tag URL'
|
||||||
size='sm'
|
size='sm'
|
||||||
variant='plain'
|
variant='plain'
|
||||||
color={copied ? 'success' : 'neutral'}
|
color={copied ? 'success' : 'neutral'}
|
||||||
@@ -310,55 +311,8 @@ function WriteNFCModal({ config }) {
|
|||||||
size='sm'
|
size='sm'
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', gap: 1.5 }}>
|
|
||||||
<Button
|
|
||||||
size='lg'
|
|
||||||
variant='outlined'
|
|
||||||
color='neutral'
|
|
||||||
sx={{ flex: 1 }}
|
|
||||||
onClick={isError ? handleClose : handleClose}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size='lg'
|
|
||||||
sx={{ flex: 1 }}
|
|
||||||
onClick={writeToNFC}
|
|
||||||
disabled={nfcStatus === 'writing'}
|
|
||||||
startDecorator={
|
|
||||||
nfcStatus === 'writing' ? (
|
|
||||||
<CircularProgress size='sm' />
|
|
||||||
) : (
|
|
||||||
<Nfc />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{nfcStatus === 'writing' ? 'Starting…' : 'Write tag'}
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Waiting state */}
|
|
||||||
{isWaiting && (
|
|
||||||
<Button
|
|
||||||
size='lg'
|
|
||||||
variant='outlined'
|
|
||||||
color='neutral'
|
|
||||||
fullWidth
|
|
||||||
onClick={handleCancel}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Success state */}
|
|
||||||
{isSuccess && (
|
|
||||||
<Button size='lg' fullWidth onClick={handleClose}>
|
|
||||||
Done
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
</ResponsiveModal>
|
</ResponsiveModal>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -2,10 +2,8 @@ import { CreditCard, Person, Toll } from '@mui/icons-material'
|
|||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
|
||||||
Card,
|
Card,
|
||||||
Chip,
|
Chip,
|
||||||
Divider,
|
|
||||||
FormControl,
|
FormControl,
|
||||||
FormLabel,
|
FormLabel,
|
||||||
IconButton,
|
IconButton,
|
||||||
@@ -15,6 +13,7 @@ import {
|
|||||||
} from '@mui/joy'
|
} from '@mui/joy'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
import ModalActions from '../../components/common/ModalActions.jsx'
|
||||||
import { useResponsiveModal } from '../../hooks/useResponsiveModal.js'
|
import { useResponsiveModal } from '../../hooks/useResponsiveModal.js'
|
||||||
import { resolvePhotoURL } from '../../utils/Helpers.jsx'
|
import { resolvePhotoURL } from '../../utils/Helpers.jsx'
|
||||||
|
|
||||||
@@ -53,22 +52,28 @@ function RedeemPointsModal({ config }) {
|
|||||||
const canRedeem = points > 0 && points <= config.available
|
const canRedeem = points > 0 && points <= config.available
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResponsiveModal open={config?.isOpen} onClose={config?.onClose} size='md'>
|
<ResponsiveModal
|
||||||
{/* Header Section */}
|
open={config?.isOpen}
|
||||||
|
onClose={config?.onClose}
|
||||||
|
size='md'
|
||||||
|
title='Redeem Points'
|
||||||
|
footer={
|
||||||
|
<ModalActions
|
||||||
|
secondary={{ label: 'Cancel', onClick: config?.onClose }}
|
||||||
|
primary={{
|
||||||
|
label: 'Redeem',
|
||||||
|
startDecorator: <CreditCard />,
|
||||||
|
disabled: !canRedeem,
|
||||||
|
onClick: () =>
|
||||||
|
config?.onSave({
|
||||||
|
points: Number(points),
|
||||||
|
userId: config?.user?.userId,
|
||||||
|
}),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
<Stack spacing={2}>
|
<Stack spacing={2}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
|
||||||
<CreditCard
|
|
||||||
sx={{
|
|
||||||
fontSize: '1.5rem',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Typography level='h4' sx={{ fontWeight: 600 }}>
|
|
||||||
Redeem Points
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Divider />
|
|
||||||
|
|
||||||
{/* User Info Card */}
|
{/* User Info Card */}
|
||||||
<Card
|
<Card
|
||||||
variant='soft'
|
variant='soft'
|
||||||
@@ -155,6 +160,7 @@ function RedeemPointsModal({ config }) {
|
|||||||
{predefinedPoints.map(point => (
|
{predefinedPoints.map(point => (
|
||||||
<IconButton
|
<IconButton
|
||||||
key={point}
|
key={point}
|
||||||
|
aria-label={`Add ${point} points`}
|
||||||
variant='outlined'
|
variant='outlined'
|
||||||
disabled={points + point > config?.available}
|
disabled={points + point > config?.available}
|
||||||
onClick={() => addPredefinedPoints(point)}
|
onClick={() => addPredefinedPoints(point)}
|
||||||
@@ -209,43 +215,6 @@ function RedeemPointsModal({ config }) {
|
|||||||
</Typography>
|
</Typography>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Divider />
|
|
||||||
|
|
||||||
{/* Action Buttons */}
|
|
||||||
<Stack direction='row' spacing={2}>
|
|
||||||
<Button
|
|
||||||
size='lg'
|
|
||||||
onClick={config?.onClose}
|
|
||||||
variant='outlined'
|
|
||||||
color='neutral'
|
|
||||||
fullWidth
|
|
||||||
sx={{
|
|
||||||
'&:hover': {
|
|
||||||
backgroundColor: 'neutral.50',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size='lg'
|
|
||||||
onClick={() =>
|
|
||||||
config?.onSave({
|
|
||||||
points: Number(points),
|
|
||||||
userId: config?.user?.userId,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
disabled={!canRedeem}
|
|
||||||
fullWidth
|
|
||||||
startDecorator={<CreditCard />}
|
|
||||||
sx={{
|
|
||||||
transition: 'all 0.2s ease',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Redeem
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</ResponsiveModal>
|
</ResponsiveModal>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,23 +1,13 @@
|
|||||||
import { CheckCircle, Security, Smartphone } from '@mui/icons-material'
|
import { CheckCircle, Security, Smartphone } from '@mui/icons-material'
|
||||||
import {
|
import { Alert, Box, Button, Card, Input, Stack, Typography } from '@mui/joy'
|
||||||
Alert,
|
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
Input,
|
|
||||||
Modal,
|
|
||||||
ModalClose,
|
|
||||||
ModalDialog,
|
|
||||||
Stack,
|
|
||||||
Typography,
|
|
||||||
} from '@mui/joy'
|
|
||||||
import QRCode from 'qrcode'
|
import QRCode from 'qrcode'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
import AppModal from '../../components/common/AppModal'
|
||||||
|
import ModalActions from '../../components/common/ModalActions'
|
||||||
import {
|
import {
|
||||||
ConfirmMFA,
|
ConfirmMFA,
|
||||||
DisableMFA,
|
DisableMFA,
|
||||||
GetMFAStatus,
|
GetMFAStatus,
|
||||||
RegenerateBackupCodes,
|
|
||||||
SetupMFA,
|
SetupMFA,
|
||||||
} from '../../utils/Fetcher'
|
} from '../../utils/Fetcher'
|
||||||
import LoadingComponent from '../components/Loading'
|
import LoadingComponent from '../components/Loading'
|
||||||
@@ -168,24 +158,6 @@ const MFASettings = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleRegenerateBackupCodes = async () => {
|
|
||||||
try {
|
|
||||||
setError('')
|
|
||||||
const response = await RegenerateBackupCodes()
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json()
|
|
||||||
setBackupCodes(data.backupCodes)
|
|
||||||
setBackupCodesModalOpen(true)
|
|
||||||
setSuccess('New backup codes have been generated!')
|
|
||||||
} else {
|
|
||||||
setError('Failed to regenerate backup codes. Please try again.')
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
setError('Failed to regenerate backup codes. Please try again.')
|
|
||||||
console.error('Error regenerating backup codes:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const closeSetupModal = () => {
|
const closeSetupModal = () => {
|
||||||
setSetupModalOpen(false)
|
setSetupModalOpen(false)
|
||||||
setSetupStep(1)
|
setSetupStep(1)
|
||||||
@@ -290,193 +262,105 @@ const MFASettings = () => {
|
|||||||
)} */}
|
)} */}
|
||||||
|
|
||||||
{/* Setup MFA Modal */}
|
{/* Setup MFA Modal */}
|
||||||
<Modal open={setupModalOpen} onClose={closeSetupModal}>
|
<AppModal
|
||||||
<ModalDialog size='md' sx={{ maxWidth: 500 }}>
|
open={setupModalOpen}
|
||||||
<ModalClose />
|
onClose={closeSetupModal}
|
||||||
<Typography level='h4' sx={{ mb: 2 }}>
|
title='Set up Multi-Factor Authentication'
|
||||||
Set up Multi-Factor Authentication
|
size='md'
|
||||||
</Typography>
|
footer={
|
||||||
|
setupStep === 1 ? (
|
||||||
{setupStep === 1 && setupData && (
|
<ModalActions
|
||||||
<Stack spacing={3}>
|
secondary={{ label: 'Cancel', onClick: closeSetupModal }}
|
||||||
<Typography level='body-md'>
|
primary={{
|
||||||
<strong>Step 1:</strong> Scan the QR code below with your
|
label: "I've added the account",
|
||||||
authenticator app (Google Authenticator, Authy, etc.)
|
onClick: () => setSetupStep(2),
|
||||||
</Typography>
|
startDecorator: <Smartphone />,
|
||||||
|
}}
|
||||||
<Box className='flex justify-center rounded bg-white p-4'>
|
/>
|
||||||
{qrCodeDataUrl || setupData.qrCode ? (
|
) : setupStep === 2 ? (
|
||||||
<img
|
<ModalActions
|
||||||
src={
|
secondary={{ label: 'Back', onClick: () => setSetupStep(1) }}
|
||||||
qrCodeDataUrl ||
|
primary={{
|
||||||
`data:image/png;base64,${setupData.qrCode}`
|
label: 'Verify & Enable',
|
||||||
}
|
onClick: handleConfirmMFA,
|
||||||
alt='MFA QR Code'
|
disabled: verificationCode.length !== 6,
|
||||||
style={{ maxWidth: '200px', maxHeight: '200px' }}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Alert color='danger'>
|
<ModalActions
|
||||||
QR code could not be generated. Please try again or use
|
primary={{
|
||||||
the manual entry key below.
|
label: "I've saved my backup codes",
|
||||||
</Alert>
|
onClick: closeSetupModal,
|
||||||
)}
|
}}
|
||||||
</Box>
|
/>
|
||||||
|
)
|
||||||
<Alert
|
}
|
||||||
color='neutral'
|
>
|
||||||
variant='soft'
|
{setupStep === 1 && setupData && (
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'flex-start',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography level='title-sm'>
|
|
||||||
<strong>Manual entry key:</strong>
|
|
||||||
</Typography>
|
|
||||||
<Typography
|
|
||||||
level='body-sm'
|
|
||||||
sx={{ wordBreak: 'break-all', whiteSpace: 'pre-wrap' }}
|
|
||||||
>
|
|
||||||
{setupData.secret}
|
|
||||||
</Typography>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
color='primary'
|
|
||||||
onClick={() => setSetupStep(2)}
|
|
||||||
startDecorator={<Smartphone />}
|
|
||||||
>
|
|
||||||
I've added the account to my app
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{setupStep === 2 && (
|
|
||||||
<Stack spacing={3}>
|
|
||||||
<Typography level='body-md'>
|
|
||||||
<strong>Step 2:</strong> Enter the 6-digit verification code
|
|
||||||
from your authenticator app
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
placeholder='Enter 6-digit code'
|
|
||||||
value={verificationCode}
|
|
||||||
size='lg'
|
|
||||||
// send on enter:
|
|
||||||
onKeyDown={e => {
|
|
||||||
if (e.key === 'Enter' && verificationCode.length === 6) {
|
|
||||||
handleConfirmMFA()
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onChange={e => setVerificationCode(e.target.value)}
|
|
||||||
sx={{
|
|
||||||
textAlign: 'center',
|
|
||||||
fontSize: '1.2em',
|
|
||||||
letterSpacing: verificationCode.length === 0 ? '' : '0.4em',
|
|
||||||
}}
|
|
||||||
slotProps={{
|
|
||||||
input: {
|
|
||||||
maxLength: 6,
|
|
||||||
pattern: '[0-9]*',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{error && <Alert color='danger'>{error}</Alert>}
|
|
||||||
|
|
||||||
<Box className='flex gap-2'>
|
|
||||||
<Button
|
|
||||||
variant='outlined'
|
|
||||||
onClick={() => setSetupStep(1)}
|
|
||||||
sx={{ flex: 1 }}
|
|
||||||
>
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
color='primary'
|
|
||||||
onClick={handleConfirmMFA}
|
|
||||||
disabled={verificationCode.length !== 6}
|
|
||||||
sx={{ flex: 1 }}
|
|
||||||
>
|
|
||||||
Verify & Enable
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{setupStep === 3 && (
|
|
||||||
<Stack spacing={3}>
|
|
||||||
<Box className='text-center'>
|
|
||||||
<CheckCircle color='success' sx={{ fontSize: 48, mb: 2 }} />
|
|
||||||
<Typography level='h4' color='success'>
|
|
||||||
MFA Successfully Enabled!
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Alert color='warning'>
|
|
||||||
<Typography level='title-sm' sx={{ mb: 1 }}>
|
|
||||||
Save these backup codes in a safe place
|
|
||||||
</Typography>
|
|
||||||
<Typography level='body-sm'>
|
|
||||||
You can use these codes to access your account if you lose
|
|
||||||
your authenticator device. Each code can only be used once.
|
|
||||||
</Typography>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
<Card variant='outlined' sx={{ p: 2 }}>
|
|
||||||
<Box className='grid grid-cols-2 gap-2 font-mono text-sm'>
|
|
||||||
{backupCodes?.map((code, index) => (
|
|
||||||
<Typography
|
|
||||||
key={index}
|
|
||||||
level='body-sm'
|
|
||||||
sx={{ fontFamily: 'monospace' }}
|
|
||||||
>
|
|
||||||
{code}
|
|
||||||
</Typography>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Button color='primary' onClick={closeSetupModal}>
|
|
||||||
I've saved my backup codes
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
</ModalDialog>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
{/* Disable MFA Modal */}
|
|
||||||
<Modal open={disableModalOpen} onClose={closeDisableModal}>
|
|
||||||
<ModalDialog size='sm'>
|
|
||||||
<ModalClose />
|
|
||||||
<Typography level='h4' sx={{ mb: 2 }}>
|
|
||||||
Disable Multi-Factor Authentication
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Stack spacing={3}>
|
<Stack spacing={3}>
|
||||||
<Alert color='warning'>
|
<Typography level='body-md'>
|
||||||
<Typography level='body-sm'>
|
<strong>Step 1:</strong> Scan the QR code below with your
|
||||||
Disabling MFA will make your account less secure. Are you sure
|
authenticator app (Google Authenticator, Authy, etc.)
|
||||||
you want to continue?
|
</Typography>
|
||||||
|
|
||||||
|
<Box className='flex justify-center rounded bg-white p-4'>
|
||||||
|
{qrCodeDataUrl || setupData.qrCode ? (
|
||||||
|
<img
|
||||||
|
src={
|
||||||
|
qrCodeDataUrl ||
|
||||||
|
`data:image/png;base64,${setupData.qrCode}`
|
||||||
|
}
|
||||||
|
alt='MFA QR Code'
|
||||||
|
style={{ maxWidth: '200px', maxHeight: '200px' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Alert color='danger'>
|
||||||
|
QR code could not be generated. Please try again or use the
|
||||||
|
manual entry key below.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Alert
|
||||||
|
color='neutral'
|
||||||
|
variant='soft'
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography level='title-sm'>
|
||||||
|
<strong>Manual entry key:</strong>
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
level='body-sm'
|
||||||
|
sx={{ wordBreak: 'break-all', whiteSpace: 'pre-wrap' }}
|
||||||
|
>
|
||||||
|
{setupData.secret}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{setupStep === 2 && (
|
||||||
|
<Stack spacing={3}>
|
||||||
<Typography level='body-md'>
|
<Typography level='body-md'>
|
||||||
Enter a verification code from your authenticator app to
|
<strong>Step 2:</strong> Enter the 6-digit verification code
|
||||||
confirm:
|
from your authenticator app
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
placeholder='Enter 6-digit code'
|
placeholder='Enter 6-digit code'
|
||||||
value={disableCode}
|
value={verificationCode}
|
||||||
size='lg'
|
size='lg'
|
||||||
|
// send on enter:
|
||||||
onKeyDown={e => {
|
onKeyDown={e => {
|
||||||
if (e.key === 'Enter' && disableCode.length === 6) {
|
if (e.key === 'Enter' && verificationCode.length === 6) {
|
||||||
handleDisableMFA()
|
handleConfirmMFA()
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onChange={e => setDisableCode(e.target.value)}
|
onChange={e => setVerificationCode(e.target.value)}
|
||||||
sx={{
|
sx={{
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
fontSize: '1.2em',
|
fontSize: '1.2em',
|
||||||
@@ -491,44 +375,25 @@ const MFASettings = () => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{error && <Alert color='danger'>{error}</Alert>}
|
{error && <Alert color='danger'>{error}</Alert>}
|
||||||
|
|
||||||
<Box className='flex gap-2'>
|
|
||||||
<Button
|
|
||||||
variant='outlined'
|
|
||||||
onClick={closeDisableModal}
|
|
||||||
sx={{ flex: 1 }}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
color='danger'
|
|
||||||
onClick={handleDisableMFA}
|
|
||||||
disabled={disableCode.length !== 6}
|
|
||||||
sx={{ flex: 1 }}
|
|
||||||
>
|
|
||||||
Disable MFA
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</ModalDialog>
|
)}
|
||||||
</Modal>
|
|
||||||
|
|
||||||
{/* Backup Codes Modal */}
|
|
||||||
<Modal
|
|
||||||
open={backupCodesModalOpen}
|
|
||||||
onClose={() => setBackupCodesModalOpen(false)}
|
|
||||||
>
|
|
||||||
<ModalDialog size='sm'>
|
|
||||||
<ModalClose />
|
|
||||||
<Typography level='h4' sx={{ mb: 2 }}>
|
|
||||||
New Backup Codes
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
|
{setupStep === 3 && (
|
||||||
<Stack spacing={3}>
|
<Stack spacing={3}>
|
||||||
|
<Box className='text-center'>
|
||||||
|
<CheckCircle color='success' sx={{ fontSize: 48, mb: 2 }} />
|
||||||
|
<Typography level='h4' color='success'>
|
||||||
|
MFA Successfully Enabled!
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
<Alert color='warning'>
|
<Alert color='warning'>
|
||||||
|
<Typography level='title-sm' sx={{ mb: 1 }}>
|
||||||
|
Save these backup codes in a safe place
|
||||||
|
</Typography>
|
||||||
<Typography level='body-sm'>
|
<Typography level='body-sm'>
|
||||||
Your previous backup codes are now invalid. Save these new
|
You can use these codes to access your account if you lose
|
||||||
codes in a safe place. Each code can only be used once.
|
your authenticator device. Each code can only be used once.
|
||||||
</Typography>
|
</Typography>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
@@ -545,16 +410,107 @@ const MFASettings = () => {
|
|||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Button
|
|
||||||
color='primary'
|
|
||||||
onClick={() => setBackupCodesModalOpen(false)}
|
|
||||||
>
|
|
||||||
I've saved my backup codes
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</ModalDialog>
|
)}
|
||||||
</Modal>
|
</AppModal>
|
||||||
|
|
||||||
|
{/* Disable MFA Modal */}
|
||||||
|
<AppModal
|
||||||
|
open={disableModalOpen}
|
||||||
|
onClose={closeDisableModal}
|
||||||
|
title='Disable Multi-Factor Authentication'
|
||||||
|
size='sm'
|
||||||
|
role='alertdialog'
|
||||||
|
closeOnBackdrop={false}
|
||||||
|
footer={
|
||||||
|
<ModalActions
|
||||||
|
secondary={{ label: 'Cancel', onClick: closeDisableModal }}
|
||||||
|
primary={{
|
||||||
|
label: 'Disable MFA',
|
||||||
|
color: 'danger',
|
||||||
|
onClick: handleDisableMFA,
|
||||||
|
disabled: disableCode.length !== 6,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Stack spacing={3}>
|
||||||
|
<Alert color='warning'>
|
||||||
|
<Typography level='body-sm'>
|
||||||
|
Disabling MFA will make your account less secure. Are you sure
|
||||||
|
you want to continue?
|
||||||
|
</Typography>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<Typography level='body-md'>
|
||||||
|
Enter a verification code from your authenticator app to confirm:
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
placeholder='Enter 6-digit code'
|
||||||
|
value={disableCode}
|
||||||
|
size='lg'
|
||||||
|
onKeyDown={e => {
|
||||||
|
if (e.key === 'Enter' && disableCode.length === 6) {
|
||||||
|
handleDisableMFA()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onChange={e => setDisableCode(e.target.value)}
|
||||||
|
sx={{
|
||||||
|
textAlign: 'center',
|
||||||
|
fontSize: '1.2em',
|
||||||
|
letterSpacing: verificationCode.length === 0 ? '' : '0.4em',
|
||||||
|
}}
|
||||||
|
slotProps={{
|
||||||
|
input: {
|
||||||
|
maxLength: 6,
|
||||||
|
pattern: '[0-9]*',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && <Alert color='danger'>{error}</Alert>}
|
||||||
|
</Stack>
|
||||||
|
</AppModal>
|
||||||
|
|
||||||
|
{/* Backup Codes Modal */}
|
||||||
|
<AppModal
|
||||||
|
open={backupCodesModalOpen}
|
||||||
|
onClose={() => setBackupCodesModalOpen(false)}
|
||||||
|
title='New Backup Codes'
|
||||||
|
size='sm'
|
||||||
|
footer={
|
||||||
|
<ModalActions
|
||||||
|
primary={{
|
||||||
|
label: "I've saved my backup codes",
|
||||||
|
onClick: () => setBackupCodesModalOpen(false),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Stack spacing={3}>
|
||||||
|
<Alert color='warning'>
|
||||||
|
<Typography level='body-sm'>
|
||||||
|
Your previous backup codes are now invalid. Save these new codes
|
||||||
|
in a safe place. Each code can only be used once.
|
||||||
|
</Typography>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<Card variant='outlined' sx={{ p: 2 }}>
|
||||||
|
<Box className='grid grid-cols-2 gap-2 font-mono text-sm'>
|
||||||
|
{backupCodes?.map((code, index) => (
|
||||||
|
<Typography
|
||||||
|
key={index}
|
||||||
|
level='body-sm'
|
||||||
|
sx={{ fontFamily: 'monospace' }}
|
||||||
|
>
|
||||||
|
{code}
|
||||||
|
</Typography>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Card>
|
||||||
|
</Stack>
|
||||||
|
</AppModal>
|
||||||
</div>
|
</div>
|
||||||
</SettingsLayout>
|
</SettingsLayout>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,13 +7,13 @@ import {
|
|||||||
Input,
|
Input,
|
||||||
Typography,
|
Typography,
|
||||||
} from '@mui/joy'
|
} from '@mui/joy'
|
||||||
import Modal from '@mui/joy/Modal'
|
|
||||||
import ModalDialog from '@mui/joy/ModalDialog'
|
|
||||||
import { useQueryClient } from '@tanstack/react-query'
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
import imageCompression from 'browser-image-compression'
|
import imageCompression from 'browser-image-compression'
|
||||||
import { useRef, useState } from 'react'
|
import { useRef, useState } from 'react'
|
||||||
import Cropper from 'react-easy-crop'
|
import Cropper from 'react-easy-crop'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import AppModal from '../../components/common/AppModal'
|
||||||
|
import ModalActions from '../../components/common/ModalActions'
|
||||||
import { useUserProfile } from '../../queries/UserQueries'
|
import { useUserProfile } from '../../queries/UserQueries'
|
||||||
import { useNotification } from '../../service/NotificationProvider'
|
import { useNotification } from '../../service/NotificationProvider'
|
||||||
import { apiClient } from '../../utils/ApiClient'
|
import { apiClient } from '../../utils/ApiClient'
|
||||||
@@ -141,9 +141,7 @@ const ProfileSettings = () => {
|
|||||||
return (
|
return (
|
||||||
<SettingsLayout title={t('profile.title')}>
|
<SettingsLayout title={t('profile.title')}>
|
||||||
<div className='grid gap-4 py-4' id='profile'>
|
<div className='grid gap-4 py-4' id='profile'>
|
||||||
<Typography level='body-md'>
|
<Typography level='body-md'>{t('profile.description')}</Typography>
|
||||||
{t('profile.description')}
|
|
||||||
</Typography>
|
|
||||||
<Card
|
<Card
|
||||||
sx={{
|
sx={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -153,7 +151,10 @@ const ProfileSettings = () => {
|
|||||||
maxWidth: 400,
|
maxWidth: 400,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Avatar src={resolvePhotoURL(userProfile?.image)} sx={{ width: 64, height: 64 }} />
|
<Avatar
|
||||||
|
src={resolvePhotoURL(userProfile?.image)}
|
||||||
|
sx={{ width: 64, height: 64 }}
|
||||||
|
/>
|
||||||
<Box sx={{ flex: 1 }}>
|
<Box sx={{ flex: 1 }}>
|
||||||
<Button
|
<Button
|
||||||
variant='soft'
|
variant='soft'
|
||||||
@@ -173,74 +174,56 @@ const ProfileSettings = () => {
|
|||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</Card>
|
</Card>
|
||||||
<Modal
|
<AppModal
|
||||||
open={showCropper}
|
open={showCropper}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setShowCropper(false)
|
setShowCropper(false)
|
||||||
setSelectedFile(null)
|
setSelectedFile(null)
|
||||||
}}
|
}}
|
||||||
>
|
title={t('profile.editPhoto', { defaultValue: 'Edit profile photo' })}
|
||||||
<ModalDialog
|
size='sm'
|
||||||
layout='center'
|
closeOnBackdrop={!isUploading}
|
||||||
sx={{
|
closeOnEscape={!isUploading}
|
||||||
width: 360,
|
footer={
|
||||||
maxWidth: '90vw',
|
<ModalActions
|
||||||
bgcolor: '#fff',
|
secondary={{
|
||||||
borderRadius: 2,
|
label: t('profile.cancel'),
|
||||||
boxShadow: 24,
|
disabled: isUploading,
|
||||||
p: 0,
|
onClick: () => {
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
minHeight: 420,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box sx={{ width: 320, height: 320, position: 'relative', mt: 2 }}>
|
|
||||||
<Cropper
|
|
||||||
image={selectedFile}
|
|
||||||
crop={crop}
|
|
||||||
zoom={zoom}
|
|
||||||
aspect={1}
|
|
||||||
cropShape='round'
|
|
||||||
showGrid={false}
|
|
||||||
onCropChange={setCrop}
|
|
||||||
onZoomChange={setZoom}
|
|
||||||
onCropComplete={onCropComplete}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'flex-end',
|
|
||||||
width: '100%',
|
|
||||||
p: 2,
|
|
||||||
mt: 2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
onClick={handleCropSave}
|
|
||||||
loading={isUploading}
|
|
||||||
variant='solid'
|
|
||||||
color='primary'
|
|
||||||
size='md'
|
|
||||||
sx={{ mr: 1 }}
|
|
||||||
>
|
|
||||||
{t('profile.save')}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
setShowCropper(false)
|
setShowCropper(false)
|
||||||
setSelectedFile(null)
|
setSelectedFile(null)
|
||||||
}}
|
},
|
||||||
variant='soft'
|
}}
|
||||||
color='neutral'
|
primary={{
|
||||||
>
|
label: t('profile.save'),
|
||||||
{t('profile.cancel')}
|
loading: isUploading,
|
||||||
</Button>
|
onClick: handleCropSave,
|
||||||
</Box>
|
}}
|
||||||
</ModalDialog>
|
/>
|
||||||
</Modal>
|
}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: 320,
|
||||||
|
aspectRatio: '1',
|
||||||
|
position: 'relative',
|
||||||
|
mx: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Cropper
|
||||||
|
image={selectedFile}
|
||||||
|
crop={crop}
|
||||||
|
zoom={zoom}
|
||||||
|
aspect={1}
|
||||||
|
cropShape='round'
|
||||||
|
showGrid={false}
|
||||||
|
onCropChange={setCrop}
|
||||||
|
onZoomChange={setZoom}
|
||||||
|
onCropComplete={onCropComplete}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</AppModal>
|
||||||
<Box sx={{ maxWidth: 400, mt: 3 }}>
|
<Box sx={{ maxWidth: 400, mt: 3 }}>
|
||||||
<Typography level='body-sm' sx={{ mb: 0.5 }}>
|
<Typography level='body-sm' sx={{ mb: 0.5 }}>
|
||||||
{t('profile.displayName')}
|
{t('profile.displayName')}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
import SmartTaskTitleInput from './SmartTaskTitleInput'
|
import SmartTaskTitleInput from './SmartTaskTitleInput'
|
||||||
|
|
||||||
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
|
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
|
||||||
|
import ModalActions from '../../components/common/ModalActions'
|
||||||
import { useDocumentScanner } from '../../hooks/useDocumentScanner'
|
import { useDocumentScanner } from '../../hooks/useDocumentScanner'
|
||||||
import { localAIService } from '../../service/LocalAIService'
|
import { localAIService } from '../../service/LocalAIService'
|
||||||
import { voiceInputService } from '../../service/VoiceInputService'
|
import { voiceInputService } from '../../service/VoiceInputService'
|
||||||
@@ -831,16 +832,8 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
|
|||||||
fullWidth={true}
|
fullWidth={true}
|
||||||
title='Create new task'
|
title='Create new task'
|
||||||
footer={
|
footer={
|
||||||
<Box
|
<ModalActions>
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'row',
|
|
||||||
justifyContent: 'end',
|
|
||||||
gap: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button
|
<Button
|
||||||
size='lg'
|
|
||||||
variant='outlined'
|
variant='outlined'
|
||||||
color='neutral'
|
color='neutral'
|
||||||
onClick={handleCloseModal}
|
onClick={handleCloseModal}
|
||||||
@@ -857,7 +850,6 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
|
|||||||
{/* Sub-panels (voice/scan) own their own confirm action */}
|
{/* Sub-panels (voice/scan) own their own confirm action */}
|
||||||
{!showScan && !showVoice && (
|
{!showScan && !showVoice && (
|
||||||
<Button
|
<Button
|
||||||
size='lg'
|
|
||||||
variant='solid'
|
variant='solid'
|
||||||
color='primary'
|
color='primary'
|
||||||
disabled={!taskTitle.trim()}
|
disabled={!taskTitle.trim()}
|
||||||
@@ -869,7 +861,7 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</ModalActions>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{!showScan && !showVoice && (
|
{!showScan && !showVoice && (
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
import moment from 'moment'
|
import moment from 'moment'
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import Calendar from 'react-calendar'
|
import Calendar from 'react-calendar'
|
||||||
|
import ModalActions from '../../components/common/ModalActions'
|
||||||
import { useLocalization } from '../../contexts/LocalizationContext'
|
import { useLocalization } from '../../contexts/LocalizationContext'
|
||||||
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
|
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
|
||||||
|
|
||||||
@@ -202,6 +203,7 @@ const DueDatePickerField = ({
|
|||||||
</Button>
|
</Button>
|
||||||
{hasDueDate && onClear && (
|
{hasDueDate && onClear && (
|
||||||
<IconButton
|
<IconButton
|
||||||
|
aria-label='Clear due date'
|
||||||
size='sm'
|
size='sm'
|
||||||
variant='soft'
|
variant='soft'
|
||||||
color='danger'
|
color='danger'
|
||||||
@@ -211,11 +213,9 @@ const DueDatePickerField = ({
|
|||||||
}}
|
}}
|
||||||
sx={{
|
sx={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: -12,
|
top: -18,
|
||||||
right: -16,
|
right: -18,
|
||||||
zIndex: 10,
|
zIndex: 10,
|
||||||
maxHeight: 18,
|
|
||||||
maxWidth: 18,
|
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
bgcolor: 'danger.softBg',
|
bgcolor: 'danger.softBg',
|
||||||
@@ -233,38 +233,22 @@ const DueDatePickerField = ({
|
|||||||
title='Due Date'
|
title='Due Date'
|
||||||
fullWidth={false}
|
fullWidth={false}
|
||||||
footer={
|
footer={
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
|
<ModalActions
|
||||||
{hasDueDate && (
|
tertiary={
|
||||||
<Button
|
hasDueDate
|
||||||
variant='plain'
|
? {
|
||||||
color='danger'
|
label: 'Remove',
|
||||||
size='lg'
|
color: 'danger',
|
||||||
onClick={() => {
|
onClick: () => {
|
||||||
onClear?.()
|
onClear?.()
|
||||||
setIsOpen(false)
|
setIsOpen(false)
|
||||||
}}
|
},
|
||||||
sx={{ mr: 'auto' }}
|
}
|
||||||
>
|
: undefined
|
||||||
Remove
|
}
|
||||||
</Button>
|
secondary={{ label: 'Cancel', onClick: () => setIsOpen(false) }}
|
||||||
)}
|
primary={{ label: 'Apply', onClick: handleSave }}
|
||||||
<Button
|
/>
|
||||||
variant='outlined'
|
|
||||||
color='neutral'
|
|
||||||
size='lg'
|
|
||||||
onClick={() => setIsOpen(false)}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant='solid'
|
|
||||||
color='primary'
|
|
||||||
size='lg'
|
|
||||||
onClick={handleSave}
|
|
||||||
>
|
|
||||||
Apply
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Box sx={{ fontFamily: 'var(--joy-fontFamily-body)', maxWidth: 360 }}>
|
<Box sx={{ fontFamily: 'var(--joy-fontFamily-body)', maxWidth: 360 }}>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Close, NotificationsNone } from '@mui/icons-material'
|
import { Close, NotificationsNone } from '@mui/icons-material'
|
||||||
import { Box, Button, IconButton, Typography } from '@mui/joy'
|
import { Box, Button, IconButton, Typography } from '@mui/joy'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import ModalActions from '../../components/common/ModalActions'
|
||||||
import NotificationTemplate from '../../components/NotificationTemplate'
|
import NotificationTemplate from '../../components/NotificationTemplate'
|
||||||
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
|
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
|
||||||
|
|
||||||
@@ -11,8 +12,7 @@ const getDisplayLabel = templates => {
|
|||||||
const n = templates[0]
|
const n = templates[0]
|
||||||
const numericValue = Number(n.value)
|
const numericValue = Number(n.value)
|
||||||
if (numericValue === 0) return 'On due date'
|
if (numericValue === 0) return 'On due date'
|
||||||
const unitName =
|
const unitName = n.unit === 'm' ? 'min' : n.unit === 'h' ? 'hr' : 'day'
|
||||||
n.unit === 'm' ? 'min' : n.unit === 'h' ? 'hr' : 'day'
|
|
||||||
const absValue = Math.abs(numericValue)
|
const absValue = Math.abs(numericValue)
|
||||||
const plural = absValue !== 1 ? 's' : ''
|
const plural = absValue !== 1 ? 's' : ''
|
||||||
return `${absValue} ${unitName}${plural} ${numericValue < 0 ? 'before' : 'after'}`
|
return `${absValue} ${unitName}${plural} ${numericValue < 0 ? 'before' : 'after'}`
|
||||||
@@ -48,33 +48,22 @@ const NotificationPickerField = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const footer = (
|
const footer = (
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
|
<ModalActions
|
||||||
{hasNotifications && (
|
tertiary={
|
||||||
<Button
|
hasNotifications
|
||||||
variant='plain'
|
? {
|
||||||
color='danger'
|
label: 'Remove all',
|
||||||
size='lg'
|
color: 'danger',
|
||||||
onClick={() => {
|
onClick: () => {
|
||||||
onClear?.()
|
onClear?.()
|
||||||
setIsOpen(false)
|
setIsOpen(false)
|
||||||
}}
|
},
|
||||||
sx={{ mr: 'auto' }}
|
}
|
||||||
>
|
: undefined
|
||||||
Remove all
|
}
|
||||||
</Button>
|
secondary={{ label: 'Cancel', onClick: () => setIsOpen(false) }}
|
||||||
)}
|
primary={{ label: 'Apply', onClick: handleSave }}
|
||||||
<Button
|
/>
|
||||||
variant='outlined'
|
|
||||||
color='neutral'
|
|
||||||
size='lg'
|
|
||||||
onClick={() => setIsOpen(false)}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button variant='solid' color='primary' size='lg' onClick={handleSave}>
|
|
||||||
Apply
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -116,6 +105,7 @@ const NotificationPickerField = ({
|
|||||||
|
|
||||||
{hasNotifications && onClear && (
|
{hasNotifications && onClear && (
|
||||||
<IconButton
|
<IconButton
|
||||||
|
aria-label='Remove reminders'
|
||||||
size='sm'
|
size='sm'
|
||||||
variant='soft'
|
variant='soft'
|
||||||
color='danger'
|
color='danger'
|
||||||
@@ -125,11 +115,9 @@ const NotificationPickerField = ({
|
|||||||
}}
|
}}
|
||||||
sx={{
|
sx={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: -12,
|
top: -18,
|
||||||
right: -16,
|
right: -18,
|
||||||
zIndex: 10,
|
zIndex: 10,
|
||||||
maxHeight: 18,
|
|
||||||
maxWidth: 18,
|
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
'&:hover': { bgcolor: 'danger.softBg' },
|
'&:hover': { bgcolor: 'danger.softBg' },
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { Close, CloudSync } from '@mui/icons-material'
|
import { Close, CloudSync } from '@mui/icons-material'
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Button,
|
|
||||||
Divider,
|
|
||||||
IconButton,
|
IconButton,
|
||||||
List,
|
List,
|
||||||
ListItem,
|
ListItem,
|
||||||
@@ -11,6 +9,7 @@ import {
|
|||||||
} from '@mui/joy'
|
} from '@mui/joy'
|
||||||
import { useQueryClient } from '@tanstack/react-query'
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import ModalActions from '../../components/common/ModalActions'
|
||||||
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
|
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
|
||||||
import { commandQueue } from '../../utils/CommandQueue'
|
import { commandQueue } from '../../utils/CommandQueue'
|
||||||
|
|
||||||
@@ -139,10 +138,24 @@ function PendingBadge({ commands, size = 'sm', sx = {} }) {
|
|||||||
{/* </Badge> */}
|
{/* </Badge> */}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
|
||||||
<ResponsiveModal open={isOpen} onClose={handleClose} size='sm'>
|
<ResponsiveModal
|
||||||
<Typography level='title-lg' mb={0.5}>
|
open={isOpen}
|
||||||
Pending actions
|
onClose={handleClose}
|
||||||
</Typography>
|
size='sm'
|
||||||
|
title='Pending actions'
|
||||||
|
footer={
|
||||||
|
<ModalActions
|
||||||
|
secondary={{ label: 'Close', onClick: handleClose }}
|
||||||
|
primary={{
|
||||||
|
label: 'Cancel all',
|
||||||
|
color: 'danger',
|
||||||
|
onClick: handleCancelAll,
|
||||||
|
loading: isCancelingAll,
|
||||||
|
disabled: commands.length === 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
<Typography level='body-sm' sx={{ color: 'text.tertiary', mb: 1.5 }}>
|
<Typography level='body-sm' sx={{ color: 'text.tertiary', mb: 1.5 }}>
|
||||||
{commands.length} action{commands.length > 1 ? 's' : ''} waiting to be
|
{commands.length} action{commands.length > 1 ? 's' : ''} waiting to be
|
||||||
synced.
|
synced.
|
||||||
@@ -171,6 +184,7 @@ function PendingBadge({ commands, size = 'sm', sx = {} }) {
|
|||||||
</ListItemContent>
|
</ListItemContent>
|
||||||
|
|
||||||
<IconButton
|
<IconButton
|
||||||
|
aria-label={`Cancel ${formatCommandLabel(cmd.commandType)}`}
|
||||||
variant='plain'
|
variant='plain'
|
||||||
color='danger'
|
color='danger'
|
||||||
size='sm'
|
size='sm'
|
||||||
@@ -182,22 +196,6 @@ function PendingBadge({ commands, size = 'sm', sx = {} }) {
|
|||||||
</ListItem>
|
</ListItem>
|
||||||
))}
|
))}
|
||||||
</List>
|
</List>
|
||||||
|
|
||||||
<Divider sx={{ mb: 1 }} />
|
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
|
|
||||||
<Button variant='outlined' onClick={handleClose}>
|
|
||||||
Close
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
color='danger'
|
|
||||||
onClick={handleCancelAll}
|
|
||||||
loading={isCancelingAll}
|
|
||||||
disabled={commands.length === 0}
|
|
||||||
>
|
|
||||||
Cancel all
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</ResponsiveModal>
|
</ResponsiveModal>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import {
|
|||||||
ArrowBack,
|
ArrowBack,
|
||||||
CameraAlt,
|
CameraAlt,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
Close,
|
|
||||||
DocumentScanner,
|
DocumentScanner,
|
||||||
PhotoCamera,
|
PhotoCamera,
|
||||||
Replay,
|
Replay,
|
||||||
@@ -12,12 +11,12 @@ import {
|
|||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
IconButton,
|
|
||||||
LinearProgress,
|
LinearProgress,
|
||||||
Typography,
|
Typography,
|
||||||
} from '@mui/joy'
|
} from '@mui/joy'
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { useDocumentScanner } from '../../hooks/useDocumentScanner'
|
import { useDocumentScanner } from '../../hooks/useDocumentScanner'
|
||||||
|
import ModalActions from '../../components/common/ModalActions'
|
||||||
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
|
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
|
||||||
import { localAIService } from '../../service/LocalAIService'
|
import { localAIService } from '../../service/LocalAIService'
|
||||||
|
|
||||||
@@ -82,7 +81,10 @@ async function runNativeOCR(imageSource) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = await Ocr.process({ image })
|
const result = await Ocr.process({ image })
|
||||||
return result.results.map(r => r.text).join('\n').trim()
|
return result.results
|
||||||
|
.map(r => r.text)
|
||||||
|
.join('\n')
|
||||||
|
.trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runOCR(imageSource, onProgress) {
|
async function runOCR(imageSource, onProgress) {
|
||||||
@@ -214,7 +216,9 @@ const PhotoTaskModal = ({ open, onClose, onTaskExtracted }) => {
|
|||||||
try {
|
try {
|
||||||
text = await runNativeOCR(capturedImage)
|
text = await runNativeOCR(capturedImage)
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error('Native OCR is only available on iOS and Android devices.')
|
throw new Error(
|
||||||
|
'Native OCR is only available on iOS and Android devices.',
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
text = await runOCR(capturedImage, pct => setOcrProgress(pct))
|
text = await runOCR(capturedImage, pct => setOcrProgress(pct))
|
||||||
@@ -231,7 +235,9 @@ const PhotoTaskModal = ({ open, onClose, onTaskExtracted }) => {
|
|||||||
const task = await extractTaskFromOCR(text)
|
const task = await extractTaskFromOCR(text)
|
||||||
|
|
||||||
if (!task || !task.taskName) {
|
if (!task || !task.taskName) {
|
||||||
setErrorMsg('Could not identify a task from this image. Please try a different photo.')
|
setErrorMsg(
|
||||||
|
'Could not identify a task from this image. Please try a different photo.',
|
||||||
|
)
|
||||||
setPhase('error')
|
setPhase('error')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -258,7 +264,9 @@ const PhotoTaskModal = ({ open, onClose, onTaskExtracted }) => {
|
|||||||
const { image, cancelled, error } = await scanDocument()
|
const { image, cancelled, error } = await scanDocument()
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
if (error || !image) {
|
if (error || !image) {
|
||||||
setErrorMsg(error ? `Scanner error: ${error}` : 'Scan cancelled or failed.')
|
setErrorMsg(
|
||||||
|
error ? `Scanner error: ${error}` : 'Scan cancelled or failed.',
|
||||||
|
)
|
||||||
setPhase('error')
|
setPhase('error')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -377,7 +385,11 @@ const PhotoTaskModal = ({ open, onClose, onTaskExtracted }) => {
|
|||||||
<Typography level='body-sm'>
|
<Typography level='body-sm'>
|
||||||
Reading text from image… {ocrProgress}%
|
Reading text from image… {ocrProgress}%
|
||||||
</Typography>
|
</Typography>
|
||||||
<LinearProgress determinate value={ocrProgress} sx={{ width: '100%' }} />
|
<LinearProgress
|
||||||
|
determinate
|
||||||
|
value={ocrProgress}
|
||||||
|
sx={{ width: '100%' }}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{phase === 'ocr' && ocrMethod === 'native' && (
|
{phase === 'ocr' && ocrMethod === 'native' && (
|
||||||
@@ -466,7 +478,7 @@ const PhotoTaskModal = ({ open, onClose, onTaskExtracted }) => {
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
|
<ModalActions sx={{ mt: 1 }}>
|
||||||
{phase === 'capture' && (
|
{phase === 'capture' && (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
@@ -583,18 +595,7 @@ const PhotoTaskModal = ({ open, onClose, onTaskExtracted }) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
</ModalActions>
|
||||||
{!isProcessing && (
|
|
||||||
<IconButton
|
|
||||||
variant='plain'
|
|
||||||
color='neutral'
|
|
||||||
onClick={handleClose}
|
|
||||||
sx={{ ml: 'auto' }}
|
|
||||||
>
|
|
||||||
<Close />
|
|
||||||
</IconButton>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
</Box>
|
||||||
</ResponsiveModal>
|
</ResponsiveModal>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
} from '@mui/joy'
|
} from '@mui/joy'
|
||||||
import moment from 'moment'
|
import moment from 'moment'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
import ModalActions from '../../components/common/ModalActions'
|
||||||
import { getRecurrentChipText } from '../../utils/ChoreCardHelpers'
|
import { getRecurrentChipText } from '../../utils/ChoreCardHelpers'
|
||||||
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
|
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
|
||||||
|
|
||||||
@@ -472,6 +473,7 @@ const RepeatPickerField = ({
|
|||||||
|
|
||||||
{hasRepeat && onClear && (
|
{hasRepeat && onClear && (
|
||||||
<IconButton
|
<IconButton
|
||||||
|
aria-label='Clear repeat schedule'
|
||||||
size='sm'
|
size='sm'
|
||||||
variant='soft'
|
variant='soft'
|
||||||
color='danger'
|
color='danger'
|
||||||
@@ -481,11 +483,9 @@ const RepeatPickerField = ({
|
|||||||
}}
|
}}
|
||||||
sx={{
|
sx={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: -12,
|
top: -18,
|
||||||
right: -16,
|
right: -18,
|
||||||
zIndex: 10,
|
zIndex: 10,
|
||||||
maxHeight: 18,
|
|
||||||
maxWidth: 18,
|
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
'&:hover': { bgcolor: 'danger.softBg' },
|
'&:hover': { bgcolor: 'danger.softBg' },
|
||||||
}}
|
}}
|
||||||
@@ -500,38 +500,22 @@ const RepeatPickerField = ({
|
|||||||
onClose={() => setIsOpen(false)}
|
onClose={() => setIsOpen(false)}
|
||||||
title='Repeat Schedule'
|
title='Repeat Schedule'
|
||||||
footer={
|
footer={
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
|
<ModalActions
|
||||||
{hasRepeat && (
|
tertiary={
|
||||||
<Button
|
hasRepeat
|
||||||
variant='plain'
|
? {
|
||||||
color='danger'
|
label: 'Remove',
|
||||||
size='lg'
|
color: 'danger',
|
||||||
onClick={() => {
|
onClick: () => {
|
||||||
onClear?.()
|
onClear?.()
|
||||||
setIsOpen(false)
|
setIsOpen(false)
|
||||||
}}
|
},
|
||||||
sx={{ mr: 'auto' }}
|
}
|
||||||
>
|
: undefined
|
||||||
Remove
|
}
|
||||||
</Button>
|
secondary={{ label: 'Cancel', onClick: () => setIsOpen(false) }}
|
||||||
)}
|
primary={{ label: 'Apply', onClick: handleSave }}
|
||||||
<Button
|
/>
|
||||||
variant='outlined'
|
|
||||||
color='neutral'
|
|
||||||
size='lg'
|
|
||||||
onClick={() => setIsOpen(false)}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant='solid'
|
|
||||||
color='primary'
|
|
||||||
size='lg'
|
|
||||||
onClick={handleSave}
|
|
||||||
>
|
|
||||||
Apply
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{/* Frequency type selector */}
|
{/* Frequency type selector */}
|
||||||
|
|||||||
Reference in New Issue
Block a user