diff --git a/src/App.tsx b/src/App.tsx index bcf4a8ec54d..9dd208d5438 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -41,6 +41,9 @@ const FieldServiceReportsPage = lazy( const MidweekMeeting = lazy(() => import('@pages/meetings/midweek')); const MinistryReport = lazy(() => import('@pages/ministry/ministry_report')); const ServiceYear = lazy(() => import('@pages/ministry/service_year')); +const PublicWitnessing = lazy( + () => import('@pages/ministry/public_witnessing') +); const AuxiliaryPioneerApplication = lazy( () => import('@pages/ministry/auxiliary_pioneer') ); @@ -128,6 +131,11 @@ const App = ({ updatePwa }: { updatePwa: VoidFunction }) => { children: [ { path: '/ministry-report', element: }, { path: '/service-year', element: }, + { path: '/public-witnessing', element: }, + { + path: '/public-witnessing/:locationId', + element: , + }, // only if connected { diff --git a/src/components/badge/index.tsx b/src/components/badge/index.tsx index 8dde2c06e6a..26c094a46e7 100644 --- a/src/components/badge/index.tsx +++ b/src/components/badge/index.tsx @@ -1,5 +1,6 @@ import { Box } from '@mui/material'; import Typography from '@components/typography'; +import { CustomClassName } from '@definition/app'; import { BadgeContentPropsType, BadgePropsType, @@ -60,6 +61,46 @@ const BadgeTypography = ({ ); }; +type ColorProps = Pick & + Pick; + +const resolveTextColor = ({ color, filled, faded, size }: ColorProps) => { + if (filled) return 'var(--always-white)'; + if (color === 'transparent') return 'var(--accent-400)'; + if (color === 'grey') return faded ? 'var(--grey-300)' : 'var(--grey-400)'; + if (color === 'green') return 'var(--green-main)'; + if (color === 'red' && size === 'big') return 'var(--red-main)'; + + return `var(--${color}-dark)`; +}; + +const resolveBackgroundColor = ({ color, filled, faded, light }: ColorProps) => { + if (color === 'transparent') return 'transparent'; + if (filled) { + return color === 'grey' ? 'var(--grey-400)' : `var(--${color}-main)`; + } + if (light) { + return color === 'accent' || color === 'grey' + ? `var(--${color}-150)` + : `var(--${color}-secondary)`; + } + if (color === 'grey') return faded ? 'var(--grey-100)' : 'var(--grey-150)'; + if (color === 'accent') return 'var(--accent-200)'; + + return `var(--${color}-secondary)`; +}; + +const bigBadgeHeight = (multiLine?: boolean, filled?: boolean) => { + if (multiLine) return 'unset'; + return filled ? '24px' : '28px'; +}; + +const sizeClassName: Record = { + small: 'label-small-medium', + medium: 'body-small-semibold', + big: 'body-regular', +}; + const Badge = (props: BadgePropsType) => { const { icon, @@ -72,52 +113,15 @@ const Badge = (props: BadgePropsType) => { borderStyle, className, faded, + light, sx = {}, } = props; - const getColor = () => { - if (filled) return `var(--always-white)`; - - if (color === 'grey') { - if (faded) { - return `var(--${color}-300)`; - } - - return `var(--${color}-400)`; - } else if (color === 'green') { - return `var(--${color}-main)`; - } else if (color === 'transparent') return 'var(--accent-400)'; - else { - if (size === 'big' && color === 'red') { - return `var(--${color}-main)`; - } else { - return `var(--${color}-dark)`; - } - } - }; - - const getBackgroundColor = () => { - if (color === 'transparent') return color; - if (filled) { - if (color === 'grey') { - return `var(--${color}-400)`; - } else { - return `var(--${color}-main)`; - } - } else { - if (color === 'grey') { - if (faded) { - return `var(--${color}-100)`; - } - - return `var(--${color}-150)`; - } else if (color === 'accent') { - return `var(--accent-200)`; - } else { - return `var(--${color}-secondary)`; - } - } - }; + const textClassName = className ?? sizeClassName[size]; + + const colorProps = { color, filled, faded, size, light }; + const textColor = resolveTextColor(colorProps); + const backgroundColor = resolveBackgroundColor(colorProps); return ( <> @@ -126,10 +130,10 @@ const Badge = (props: BadgePropsType) => { sx={{ border: '2px', height: props.multiLine ? 'unset' : '20px', - background: getBackgroundColor(), + background: backgroundColor, display: 'flex', flexDirection: 'row', - borderRadius: 'var(--radius-xs)', + borderRadius: 'var(--radius-s)', gap: '4px', padding: '2px 6px', flexShrink: '0', @@ -143,16 +147,11 @@ const Badge = (props: BadgePropsType) => { icon={icon} iconHeight={'16px'} iconWidth={'16px'} - color={getColor()} + color={textColor} > {text} @@ -165,7 +164,7 @@ const Badge = (props: BadgePropsType) => { border: '1px', borderColor: 'var(--accent-350)', height: props.multiLine ? 'unset' : '22px', - background: getBackgroundColor(), + background: backgroundColor, display: 'flex', flexDirection: 'row', borderRadius: 'var(--radius-s)', @@ -182,16 +181,11 @@ const Badge = (props: BadgePropsType) => { icon={icon} iconHeight={'18px'} iconWidth={'18px'} - color={getColor()} + color={textColor} > {text} @@ -202,8 +196,8 @@ const Badge = (props: BadgePropsType) => { { icon={icon} iconHeight={'20px'} iconWidth={'20px'} - color={getColor()} + color={textColor} > {text} diff --git a/src/components/badge/index.types.ts b/src/components/badge/index.types.ts index 26c86ffe641..53f04c7d694 100644 --- a/src/components/badge/index.types.ts +++ b/src/components/badge/index.types.ts @@ -52,6 +52,8 @@ export type BadgePropsType = { */ icon?: ReactElement; + light?: boolean; + /** * Custom styles for the badge. */ diff --git a/src/components/icons/IconReorder.tsx b/src/components/icons/IconReorder.tsx index 1134a600c7b..1c992fa9a54 100644 --- a/src/components/icons/IconReorder.tsx +++ b/src/components/icons/IconReorder.tsx @@ -42,10 +42,9 @@ const IconReorder = ({ - diff --git a/src/components/index.ts b/src/components/index.ts index cfa3c6ce01a..4328d8d4719 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -43,10 +43,6 @@ export { default as UserCard } from './user_card/index'; export { default as DatePicker } from './date_picker'; export { default as SearchBar } from './search_bar/index'; export { default as Accordion } from './accordion/index'; -export { - CustomPublicWitnessingPlaceCard as PublicWitnessingPlaceCard, - CustomPublicWitnessingTimeCard as PublicWitnessingTimeCard, -} from './public_witnessing_card/index'; export { default as ProgressBarSmall } from './progress_bar_small/index'; export { default as UserAccountItem } from './user_account_item/index'; export { default as DarkOverlay } from './dark_overlay/index'; diff --git a/src/components/month_calendar/index.tsx b/src/components/month_calendar/index.tsx new file mode 100644 index 00000000000..6d558fd18fd --- /dev/null +++ b/src/components/month_calendar/index.tsx @@ -0,0 +1,240 @@ +import { type KeyboardEvent, type ReactNode } from 'react'; +import { Box } from '@mui/material'; +import { useBreakpoints } from '@hooks/index'; +import Typography from '@components/typography'; +import { MonthCalendarDay, MonthCalendarProps } from './index.types'; + +const LINE = '1px solid var(--accent-200)'; + +const getCellBorderSx = ( + week: MonthCalendarDay[], + weeks: MonthCalendarDay[][], + dayIndex: number, + weekIndex: number, + inBlock: (cell: MonthCalendarDay | undefined, week: number) => boolean +): Record => { + const leftInBlock = inBlock(week[dayIndex - 1], weekIndex); + const topInBlock = inBlock(weeks[weekIndex - 1]?.[dayIndex], weekIndex - 1); + + return { + borderRight: LINE, + borderBottom: LINE, + ...(leftInBlock ? {} : { borderLeft: LINE }), + ...(weekIndex > 0 && !topInBlock ? { borderTop: LINE } : {}), + }; +}; + +type DayCellProps = { + day: MonthCalendarDay; + borderSx: Record; + selectable: boolean; + highlightWeekends: boolean; + ariaLabel?: string; + onSelect?: () => void; + children?: ReactNode; +}; + +const DayCell = ({ + day, + borderSx, + selectable, + highlightWeekends, + ariaLabel, + onSelect, + children, +}: DayCellProps) => { + const { tabletUp } = useBreakpoints(); + + const dotSize = tabletUp ? '12px' : '8px'; + + const interactiveProps = selectable + ? { + role: 'button', + tabIndex: 0, + 'aria-label': ariaLabel, + onClick: onSelect, + onKeyDown: (event: KeyboardEvent) => { + if (event.key !== 'Enter' && event.key !== ' ') return; + event.preventDefault(); + onSelect?.(); + }, + } + : {}; + + return ( + + + + {day.dayNumber} + + {day.isToday && ( + + )} + + + {children} + + ); +}; + +/** + * Monthly calendar grid: a weekday header row and one bordered cell per day, + * with the outline stepping away after the last day of the month. What a day + * shows below its number is up to the caller (`renderDay`). + */ +const MonthCalendar = ({ + weeks, + weekdayLabels, + renderDay, + isDaySelectable, + onSelectDay, + dayAriaLabel, + highlightWeekends = false, +}: MonthCalendarProps) => { + const { tabletUp } = useBreakpoints(); + + // A cell belongs to the rendered calendar "block" when it is an in-month day + // or a *leading* blank (any blank in the first week, i.e. before the 1st). + // Trailing blanks (after the last day) are excluded so the grid steps away at + // the end while still showing dividers at the beginning. + const inBlock = (cell: MonthCalendarDay | undefined, week: number) => + cell !== undefined && (cell.inMonth || week === 0); + + return ( + + + {weekdayLabels.map((label, index) => ( + + + {tabletUp ? label : label.charAt(0)} + + + ))} + + + {weeks.map((week, weekIndex) => ( + + {week.map((day, dayIndex) => { + // Trailing blanks render empty — grid outline steps away at the end. + if (!inBlock(day, weekIndex)) { + return ; + } + + const isLastWeek = weekIndex === weeks.length - 1; + + const borderSx = { + ...getCellBorderSx(week, weeks, dayIndex, weekIndex, inBlock), + ...(isLastWeek && + dayIndex === 0 && { + borderBottomLeftRadius: 'var(--radius-l)', + }), + ...(isLastWeek && + dayIndex === week.length - 1 && + inBlock(day, weekIndex) && { + borderBottomRightRadius: 'var(--radius-l)', + }), + }; + + // Leading blanks (before the 1st): keep dividers, muted fill. + if (!day.inMonth) { + return ( + + ); + } + + const selectable = isDaySelectable?.(day) ?? true; + + return ( + onSelectDay?.(day)} + > + {renderDay?.(day)} + + ); + })} + + ))} + + ); +}; + +export default MonthCalendar; diff --git a/src/components/month_calendar/index.types.ts b/src/components/month_calendar/index.types.ts new file mode 100644 index 00000000000..d417e6eda7d --- /dev/null +++ b/src/components/month_calendar/index.types.ts @@ -0,0 +1,40 @@ +import { ReactNode } from 'react'; + +export type MonthCalendarDay = { + date: Date; + dateStr: string; + dayNumber: number; + /** + * False for the neighbouring-month days that pad the first and last week. + */ + inMonth: boolean; + isToday: boolean; + isWeekend: boolean; +}; + +export type MonthCalendarProps = { + /** + * Full weeks of the displayed month, padded with the neighbouring-month + * days. + */ + weeks: MonthCalendarDay[][]; + /** + * Weekday headers, in the same order as the columns. + */ + weekdayLabels: string[]; + /** + * Content rendered under the day number — badges, counts, anything. + */ + renderDay?: (day: MonthCalendarDay) => ReactNode; + /** + * Days that answer false are rendered inert (no pointer, no focus). + * Defaults to every in-month day being selectable. + */ + isDaySelectable?: (day: MonthCalendarDay) => boolean; + onSelectDay?: (day: MonthCalendarDay) => void; + dayAriaLabel?: (day: MonthCalendarDay) => string; + /** + * Tints Saturdays and Sundays. + */ + highlightWeekends?: boolean; +}; diff --git a/src/components/nav_bar_button/index.tsx b/src/components/nav_bar_button/index.tsx index cb4f5940c7b..a52e103ffb9 100644 --- a/src/components/nav_bar_button/index.tsx +++ b/src/components/nav_bar_button/index.tsx @@ -57,7 +57,9 @@ const NavBarButton = (props: NavBarButtonProps) => { } }} sx={{ - padding: '10px', + // Keeps the icon-only button the same height as the text button next + // to it, so a bar of either kind measures the same. + padding: '8px', borderRadius: 'var(--radius-l)', display: 'flex', alignItems: 'center', diff --git a/src/components/public_witnessing_card/index.tsx b/src/components/public_witnessing_card/index.tsx deleted file mode 100644 index fb73930fe8b..00000000000 --- a/src/components/public_witnessing_card/index.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import CustomPublicWitnessingPlaceCard from './public_witnessing_place_card'; -import CustomPublicWitnessingTimeCard from './public_witnessing_time_card'; - -/** - * Exported components for custom public witnessing cards. - */ -export { CustomPublicWitnessingPlaceCard, CustomPublicWitnessingTimeCard }; diff --git a/src/components/public_witnessing_card/public_witnessing_card.styles.ts b/src/components/public_witnessing_card/public_witnessing_card.styles.ts deleted file mode 100644 index caba9433021..00000000000 --- a/src/components/public_witnessing_card/public_witnessing_card.styles.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { styled } from '@mui/system'; -import { ButtonBase } from '@mui/material'; -import { ButtonBaseProps } from '@mui/material/ButtonBase/ButtonBase'; -import { CustomAccordionVariant } from './public_witnessing_card.types'; - -export const StyledIconWrapper = styled(ButtonBase)( - (props) => ({ - borderRadius: 'var(--radius-l)', - padding: '2.5px', - color: props.color, - }) -); - -const borderViews = { - orange: 'var(--orange-dark)', - dashed: 'var(--accent-400)', - silver: 'var(--grey-200)', - disabled: 'var(--grey-200)', - accent: 'var(--accent-main)', -}; - -const bgColorView = { - orange: 'var(--orange-secondary)', - dashed: 'var(--white)', - silver: 'var(--white)', - disabled: 'var(--grey-100)', - accent: 'var(--white)', -}; - -const hoverColorView = { - orange: 'var(--orange-tertiary)', - dashed: 'transparent', - silver: 'transparent', - disabled: 'transparent', - accent: 'var(--accent-150)', -}; - -export const colorVariants = { - orange: 'var(--orange-dark)', - dashed: 'var(--accent-400)', - silver: 'var(--grey-300)', - accent: 'var(--accent-dark)', - disabled: 'var(--grey-300)', -}; -export const CardWrapper = styled(ButtonBase)< - ButtonBaseProps & { view: CustomAccordionVariant; hoverChildColor?: string } ->(({ view, hoverChildColor }) => ({ - minHeight: '48px', - width: '100%', - borderWidth: '1px', - borderColor: borderViews[view], - borderStyle: view === 'dashed' || view === 'silver' ? 'dashed' : 'solid', - borderRadius: 'var(--radius-l)', - padding: '10px', - backgroundColor: bgColorView[view], - pointerEvents: view === 'disabled' ? 'none' : null, - '&:hover': { - backgroundColor: hoverColorView[view], - button: { - backgroundColor: hoverChildColor, - }, - }, - color: view === 'orange' ? 'var(--orange-main)' : 'var(--accent-dark)', -})); diff --git a/src/components/public_witnessing_card/public_witnessing_card.types.ts b/src/components/public_witnessing_card/public_witnessing_card.types.ts deleted file mode 100644 index 62258222ecd..00000000000 --- a/src/components/public_witnessing_card/public_witnessing_card.types.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { ReactNode } from 'react'; - -export type CustomAccordionVariant = - | 'accent' - | 'orange' - | 'dashed' - | 'silver' - | 'disabled'; - -export interface CustomAccordionProps { - /** - * A unique identifier for the accordion. - */ - value?: string; - - /** - * The variant of the accordion. Determines its visual appearance. - */ - variant: CustomAccordionVariant; - - /** - * The label displayed for the accordion. - */ - label: string; - - /** - * Callback function invoked when the accordion state changes. - */ - onChange?: () => void; - - /** - * Callback function invoked when the accordion is clicked. - */ - onClick?: () => void; - - /** - * The content to be displayed inside the accordion. - */ - children?: ReactNode; - - /** - * Specifies whether the accordion is disabled or not. - */ - disabled?: boolean; -} - -/* - * Props for the PublicWitnessingPlaceCard component. - */ -export interface PublicWitnessingPlaceCardProps { - /* - * The label for the card. - */ - label: string; - - /* - * Callback function when the card is clicked. - */ - onClick?: () => void; - - /* - * Determines if the card is disabled. - */ - disabled?: boolean; - - /* - * Determines if the delete functionality is enabled for the card. - */ - isDelete?: boolean; - - /* - * Callback function when the delete action is clicked. - */ - onDelete?: () => void; -} - -/* - * Props for the PublicWitnessingTimeCard component. - */ -export interface PublicWitnessingTimeCardProps { - /* - * List of witnesses. - */ - witnesses?: string[]; - - /* - * Number of witnesses needed. - */ - needWitnesses?: number; - - /* - * Minimum number of witnesses required. - */ - minWitnesses?: number; - - /* - * Indicates if it's a day event. - */ - isDay?: boolean; -} - -/* - * Props for the PublicWitnessingCard component. - */ -export interface PublicWitnessingCardProps - extends Omit, - PublicWitnessingTimeCardProps { - /* - * Indicates if the event is in the past. - */ - isPast?: boolean; -} - -/* - * Props for the PublicWitnessingView component. - */ -export interface PublicWitnessingViewProps - extends Omit, - PublicWitnessingTimeCardProps { - /* - * Indicates if the content is visible. - */ - isContent: boolean; - - /* - * Indicates if the event is in the past. - */ - isPast?: boolean; -} diff --git a/src/components/public_witnessing_card/public_witnessing_place_card.tsx b/src/components/public_witnessing_card/public_witnessing_place_card.tsx deleted file mode 100644 index 409c35a747c..00000000000 --- a/src/components/public_witnessing_card/public_witnessing_place_card.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { Stack } from '@mui/material'; -import { IconArrowLink, IconNormalPin, IconDelete } from '@icons/index'; -import Typography from '@components/typography'; -import { PublicWitnessingPlaceCardProps } from './public_witnessing_card.types'; -import { - CardWrapper, - StyledIconWrapper, -} from './public_witnessing_card.styles'; - -/** - * Custom card component for public witnessing place. - * @param label - The label for the card. - * @param onClick - Callback function when the card is clicked. - * @param disabled - Determines if the card is disabled. - * @param isDelete - Determines if the delete functionality is enabled for the card. - * @param onDelete - Callback function when the delete action is clicked. - */ -const CustomPublicWitnessingPlaceCard = ({ - label, - onClick, - disabled, - isDelete = false, - onDelete, -}: PublicWitnessingPlaceCardProps) => { - return ( - - - - - {label} - - { - onDelete?.(); - onClick?.(); - }} - > - {isDelete ? ( - - ) : ( - - )} - - - - ); -}; -export default CustomPublicWitnessingPlaceCard; diff --git a/src/components/public_witnessing_card/public_witnessing_time_card.tsx b/src/components/public_witnessing_card/public_witnessing_time_card.tsx deleted file mode 100644 index 4cb85ed4d87..00000000000 --- a/src/components/public_witnessing_card/public_witnessing_time_card.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { useMediaQuery } from '@mui/material'; -import { CardWrapper } from './public_witnessing_card.styles'; -import PublicWitnessingDayView from './view/day'; -import PublicWitnessingDefaultView from './view/default'; -import { PublicWitnessingCardProps } from './public_witnessing_card.types'; - -/** - * Custom card component for public witnessing time. - * @param witnesses - An array of witnesses. - * @param needWitnesses - The required number of witnesses. - * @param minWitnesses - The minimum number of witnesses. - * @param isDay - Determines if the card is for the day view. - * @param isPast - Determines if the card represents a past event. - */ -const CustomPublicWitnessingTimeCard = (props: PublicWitnessingCardProps) => { - const { witnesses, needWitnesses, isDay = false, isPast = false } = props; - const tablet = useMediaQuery('(max-width:770px)'); - const isContent = !!witnesses && !!needWitnesses; - - const getVariant = () => { - if (isPast) return isContent ? 'silver' : 'disabled'; - if (!isContent) return 'accent'; - if (witnesses.length < needWitnesses) return 'orange'; - return 'dashed'; - }; - - return ( - - {isDay && !tablet ? ( - - ) : ( - - )} - - ); -}; - -export default CustomPublicWitnessingTimeCard; diff --git a/src/components/public_witnessing_card/view/day.tsx b/src/components/public_witnessing_card/view/day.tsx deleted file mode 100644 index 6fa0af9454a..00000000000 --- a/src/components/public_witnessing_card/view/day.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { Stack } from '@mui/material'; -import Typography from '@components/typography'; -import { IconPersonSearch } from '@icons/index'; -import { PublicWitnessingViewProps } from '../public_witnessing_card.types'; -import { colorVariants } from '@components/public_witnessing_card/public_witnessing_card.styles'; - -/** - * Component for rendering the day view of public witnessing. - * @param props - Component props. - * @returns JSX element representing the day view of public witnessing. - */ -const PublicWitnessingDayView = (props: PublicWitnessingViewProps) => { - const { witnesses, needWitnesses, isContent, variant, label } = props; - - return ( - - - {label} - - {witnesses ? ( - - - {witnesses - ? witnesses.map((x, index) => ( - - {index + 1 < witnesses.length ? `${x},` : x} - - )) - : null} - - {isContent && witnesses.length < needWitnesses ? ( - - - - Partner needed - - - ) : null} - - ) : null} - - ); -}; - -export default PublicWitnessingDayView; diff --git a/src/components/public_witnessing_card/view/default.tsx b/src/components/public_witnessing_card/view/default.tsx deleted file mode 100644 index fed1747d99d..00000000000 --- a/src/components/public_witnessing_card/view/default.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import Accordion from '@components/accordion'; -import { Stack } from '@mui/material'; -import Typography from '@components/typography'; -import { IconPersonSearch } from '@icons/index'; -import { PublicWitnessingViewProps } from '../public_witnessing_card.types'; -import { colorVariants } from '@components/public_witnessing_card/public_witnessing_card.styles'; - -/** - * Component for rendering the default view of public witnessing. - * @param props - Component props. - * @returns JSX element representing the default view of public witnessing. - */ -const PublicWitnessingDefaultView = (props: PublicWitnessingViewProps) => { - const { variant, witnesses, needWitnesses, isContent, ...rest } = props; - - return ( - - {witnesses ? ( - - {witnesses - ? witnesses.map((x, index) => ( - - {x} - - )) - : null} - {isContent && witnesses.length < needWitnesses ? ( - - - - Partner needed - - - ) : null} - - ) : null} - - ); -}; - -export default PublicWitnessingDefaultView; diff --git a/src/components/select/index.styles.tsx b/src/components/select/index.styles.tsx index 70cf448aaf7..3145e38d718 100644 --- a/src/components/select/index.styles.tsx +++ b/src/components/select/index.styles.tsx @@ -39,4 +39,9 @@ export const SelectStyled = styled(Select)({ borderColor: 'var(--accent-200)', }, }, + '&.Mui-error': { + '& .MuiOutlinedInput-notchedOutline': { + borderColor: 'var(--red-main)', + }, + }, }) as unknown as typeof Select; diff --git a/src/components/settings_tab/index.styles.tsx b/src/components/settings_tab/index.styles.tsx new file mode 100644 index 00000000000..0c753b5eed7 --- /dev/null +++ b/src/components/settings_tab/index.styles.tsx @@ -0,0 +1,63 @@ +import { styled } from '@mui/system'; +import { Box, ButtonBase } from '@mui/material'; + +export const StyledSettingsTab = styled(ButtonBase)({ + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + gap: '16px', + width: '100%', + borderRadius: 'var(--radius-s)', + padding: 0, + textAlign: 'start', + position: 'relative', + overflow: 'hidden', + cursor: 'pointer', + transition: 'background-color 0.15s ease', + '&:hover .chevron-container': { + opacity: 1, + }, +}) as unknown as typeof ButtonBase; + +export const IndicatorBar = styled(Box)(({ theme }) => ({ + width: '4px', + alignSelf: 'stretch', + backgroundColor: 'var(--accent-main)', + borderRadius: '2px', + transition: 'opacity 0.15s ease', + flexShrink: 0, + // Below the laptop layout the tabs are navigation rather than a selection, + // so nothing ever marks one active — the bar should not hold its slot (and + // the row gap it brings) open for nothing. + [theme.breakpoints.down('laptop')]: { + width: 0, + // Keeps a small 8px inset instead of the indicator's full slot. + marginInlineEnd: '-8px', + }, +})) as unknown as typeof Box; + +export const IconWrapper = styled(Box)({ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + width: 24, + height: 24, + flexShrink: 0, +}) as unknown as typeof Box; + +export const TextColumn = styled(Box)({ + display: 'flex', + flexDirection: 'column', + gap: '2px', + flex: 1, + minWidth: 0, + paddingTop: '8px', + paddingBottom: '8px', +}) as unknown as typeof Box; + +export const ChevronWrapper = styled(Box)({ + display: 'flex', + alignItems: 'center', + paddingInlineEnd: '8px', + transition: 'opacity 0.15s ease', +}) as unknown as typeof Box; diff --git a/src/components/settings_tab/index.tsx b/src/components/settings_tab/index.tsx new file mode 100644 index 00000000000..91e70e32ed8 --- /dev/null +++ b/src/components/settings_tab/index.tsx @@ -0,0 +1,76 @@ +import { HTMLAttributes, ReactNode } from 'react'; +import { IconChevronRight } from '@components/icons'; +import Typography from '@components/typography'; +import { + StyledSettingsTab, + IndicatorBar, + IconWrapper, + TextColumn, + ChevronWrapper, +} from './index.styles'; + +type SettingsTabProps = { + renderIcon: (color: string) => ReactNode; + label: string; + description: string; + active?: boolean; + onClick?: () => void; +} & Pick< + HTMLAttributes, + 'role' | 'aria-selected' | 'aria-controls' | 'id' | 'tabIndex' +>; + +const SettingsTab = ({ + renderIcon, + label, + description, + active = false, + onClick, + ...ariaProps +}: SettingsTabProps) => { + const iconColor = active ? 'var(--accent-dark)' : 'var(--black)'; + const titleColor = active ? 'var(--accent-dark)' : 'var(--black)'; + const descColor = active ? 'var(--accent-400)' : 'var(--grey-350)'; + + return ( + + + + {renderIcon(iconColor)} + + + + {label} + + + {description} + + + + + + + + ); +}; + +export default SettingsTab; diff --git a/src/components/stepper/index.tsx b/src/components/stepper/index.tsx new file mode 100644 index 00000000000..3da1918a8d4 --- /dev/null +++ b/src/components/stepper/index.tsx @@ -0,0 +1,39 @@ +import { Step, StepLabel, Stepper as MUIStepper } from '@mui/material'; +import Typography from '@components/typography'; +import { StepperProps } from './index.types'; + +const Stepper = ({ steps, activeStep, sx }: StepperProps) => ( + + {steps.map((label, index) => { + const done = index <= activeStep; + + return ( + + + + {label} + + + + ); + })} + +); + +export default Stepper; diff --git a/src/components/stepper/index.types.ts b/src/components/stepper/index.types.ts new file mode 100644 index 00000000000..02613e16b13 --- /dev/null +++ b/src/components/stepper/index.types.ts @@ -0,0 +1,15 @@ +import { SxProps, Theme } from '@mui/material'; + +export type StepperProps = { + /** + * Label of each step, in order. + */ + steps: string[]; + + /** + * Index of the step currently being filled in. + */ + activeStep: number; + + sx?: SxProps; +}; diff --git a/src/components/tab_switcher/index.tsx b/src/components/tab_switcher/index.tsx new file mode 100644 index 00000000000..87edca97d6a --- /dev/null +++ b/src/components/tab_switcher/index.tsx @@ -0,0 +1,149 @@ +import { Box, ButtonBase } from '@mui/material'; +import { cloneElement, KeyboardEvent, useRef } from 'react'; +import { TabSwitcherOption, TabSwitcherProps } from './index.types'; + +const ARROW_KEYS = new Set(['ArrowLeft', 'ArrowRight', 'Home', 'End']); + +/** Reusable segmented tab control. */ +const TabSwitcher = ({ + options, + value, + onChange, + ariaLabel, + sx, +}: TabSwitcherProps) => { + const activeIndex = options.findIndex((option) => option.value === value); + const gap = 8; + + const tabRefs = useRef([]); + + const selectableIndexes = options + .map((option, index) => (option.disabled ? -1 : index)) + .filter((index) => index !== -1); + + // Arrow keys move through the tabs, as a tablist is expected to — only the + // selected tab stays in the Tab order. + const handleKeyDown = (event: KeyboardEvent) => { + if (!ARROW_KEYS.has(event.key) || selectableIndexes.length === 0) { + return; + } + + event.preventDefault(); + + const position = selectableIndexes.indexOf(activeIndex); + + const nextPosition = { + ArrowLeft: position <= 0 ? selectableIndexes.length - 1 : position - 1, + ArrowRight: (position + 1) % selectableIndexes.length, + Home: 0, + End: selectableIndexes.length - 1, + }[event.key]; + + const nextIndex = selectableIndexes[nextPosition]; + if (nextIndex === activeIndex) return; + + onChange(options[nextIndex].value); + tabRefs.current[nextIndex]?.focus(); + }; + + return ( + + + + {options.map((option: TabSwitcherOption, index: number) => { + const isActive = option.value === value; + + return ( + { + tabRefs.current[index] = node; + }} + role="tab" + aria-selected={isActive} + tabIndex={isActive ? 0 : -1} + disabled={option.disabled} + disableRipple + onClick={() => onChange(option.value)} + sx={{ + position: 'relative', + zIndex: 1, + flex: 1, + minWidth: 0, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + gap: '8px', + padding: '4px 16px', + minHeight: '28px', + borderRadius: 'var(--radius-m)', + fontFamily: 'inherit', + fontSize: '15px', + lineHeight: '20px', + fontWeight: isActive ? 500 : 400, + color: isActive ? 'var(--accent-dark)' : 'var(--accent-400)', + transition: 'color 0.16s ease-out', + '&.Mui-disabled': { opacity: 0.5 }, + '& svg, & svg g, & svg g path': { + fill: isActive ? 'var(--accent-dark)' : 'var(--accent-400)', + transition: 'fill 0.16s ease-out', + }, + '&:focus-visible': { outline: 'var(--accent-main) auto 1px' }, + }} + > + {option.icon && ( + + {cloneElement(option.icon, { width: 20, height: 20 })} + + )} + + {option.label} + + + ); + })} + + ); +}; + +export default TabSwitcher; diff --git a/src/components/tab_switcher/index.types.ts b/src/components/tab_switcher/index.types.ts new file mode 100644 index 00000000000..28825cd1e07 --- /dev/null +++ b/src/components/tab_switcher/index.types.ts @@ -0,0 +1,17 @@ +import { ReactElement } from 'react'; +import { SxProps, Theme } from '@mui/material'; + +export type TabSwitcherOption = { + value: T; + label: string; + icon?: ReactElement<{ width?: number; height?: number; color?: string }>; + disabled?: boolean; +}; + +export type TabSwitcherProps = { + options: TabSwitcherOption[]; + value: T; + onChange: (value: T) => void; + ariaLabel?: string; + sx?: SxProps; +}; diff --git a/src/components/textfield/index.tsx b/src/components/textfield/index.tsx index 3a5b590ac33..bca0b8e843b 100644 --- a/src/components/textfield/index.tsx +++ b/src/components/textfield/index.tsx @@ -115,6 +115,9 @@ const TextField = (props: TextFieldTypeProps) => { : '1px solid var(--accent-main)', }, '&.Mui-error': { + '& .MuiOutlinedInput-notchedOutline': { + border: '1px solid var(--red-main)', + }, '&:hover fieldset': { border: '1px solid var(--red-main)', }, @@ -158,9 +161,7 @@ const TextField = (props: TextFieldTypeProps) => { }, '& .MuiAutocomplete-endAdornment .MuiSvgIcon-root': { - color: !props.disabled - ? 'var(--black)' - : 'var(--accent-200)', + color: !props.disabled ? 'var(--black)' : 'var(--accent-200)', '& g path': { fill: 'var(--black)', }, @@ -195,8 +196,8 @@ const TextField = (props: TextFieldTypeProps) => { marginRight: 0, '& svg, & svg g, & svg g path': styleIconLocal ? { - fill: endIconLocal.props.color ?? 'var(--accent-350)', - } + fill: endIconLocal.props.color ?? 'var(--accent-350)', + } : {}, }} > diff --git a/src/components/time_picker/index.styles.ts b/src/components/time_picker/index.styles.ts index 821c13bd44b..b5b3d2dd6e4 100644 --- a/src/components/time_picker/index.styles.ts +++ b/src/components/time_picker/index.styles.ts @@ -101,3 +101,80 @@ export const StyleTimePickerToolbar: SxProps = { }, }, }; + +// layout that depends on the viewport and the AM/PM selector +export const getTimePickerToolbarStyle = ( + isMobile: boolean, + ampm: boolean +): SxProps => ({ + ...StyleTimePickerToolbar, + maxWidth: isMobile ? 'none' : '250px', + '.MuiTimePickerToolbar-hourMinuteLabel': { + width: '100%', + height: isMobile ? '100%' : null, + marginTop: ampm ? 'auto' : 'unset', + span: { + color: 'var(--black)', + padding: '10px 0', + }, + }, + '.MuiTimePickerToolbar-ampmSelection': { + height: isMobile ? '100%' : null, + margin: isMobile ? null : '16px 0 auto', + '& > *:first-of-type': { + borderBottomLeftRadius: isMobile ? '0' : null, + borderBottomRightRadius: isMobile ? '0' : null, + }, + '& > *:last-of-type': { + borderTop: isMobile ? '0' : null, + borderTopLeftRadius: isMobile ? '0' : null, + borderTopRightRadius: isMobile ? '0' : null, + }, + button: { + border: '1px solid var(--accent-300)', + span: { + color: 'var(--accent-400)', + height: '100%', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + padding: '6px', + }, + minWidth: '52px', + height: '100%', + }, + }, +}); + +export const getTimePickerPopperStyle = ( + isMobile: boolean, + ampm: boolean +): SxProps => ({ + ...StyleTimePickerPopper, + '.MuiPickersToolbar-content': { + flexWrap: isMobile ? 'nowrap' : 'wrap', + alignItems: ampm ? 'flex-start' : 'center', + minWidth: '216px', + }, + '.MuiPickersLayout-contentWrapper': { + marginTop: isMobile ? '5px' : '40px', + gridColumn: '2 / 2', + }, + '.MuiTimeClock-arrowSwitcher': { + top: 0, + right: 0, + }, + '.MuiPickersArrowSwitcher-button': { + color: 'var(--accent-150)', + }, + '.MuiPickersArrowSwitcher-button:hover': { + backgroundColor: 'var(--accent-150)', + color: 'var(--accent-main)', + }, + '.MuiIconButton-root': { + color: 'var(--accent-350)', + }, + '.Mui-disabled': { + color: 'var(--accent-200)', + }, +}); diff --git a/src/components/time_picker/index.tsx b/src/components/time_picker/index.tsx index c667165e1c7..686ab584e15 100644 --- a/src/components/time_picker/index.tsx +++ b/src/components/time_picker/index.tsx @@ -2,7 +2,10 @@ import { DesktopTimePicker, renderTimeViewClock } from '@mui/x-date-pickers'; import { Box, ClickAwayListener, useMediaQuery } from '@mui/material'; import { useEffect, useRef, useState } from 'react'; import { CustomTimePickerProps } from './index.types'; -import { StyleTimePickerPopper, StyleTimePickerToolbar } from './index.styles'; +import { + getTimePickerPopperStyle, + getTimePickerToolbarStyle, +} from './index.styles'; import { IconClock } from '@components/icons'; import { useAppTranslation } from '@hooks/index'; import InputTextField from './slots/textfield'; @@ -24,6 +27,7 @@ const TimePicker = ({ onChange, sx, readOnly = false, + hideIcon = false, }: CustomTimePickerProps) => { const divRef = useRef(null); @@ -91,84 +95,21 @@ const TimePicker = ({ label: label, value: valueTmp, onClick: () => setOpen(!open), + sx: hideIcon + ? { '.MuiInputAdornment-root': { display: 'none' } } + : undefined, }, toolbar: { hidden: false, className: 'h3', - sx: { - ...StyleTimePickerToolbar, - maxWidth: isMobile ? 'none' : '250px', - '.MuiTimePickerToolbar-hourMinuteLabel': { - width: '100%', - height: isMobile ? '100%' : null, - marginTop: ampm ? 'auto' : 'unset', - span: { - color: 'var(--black)', - padding: '10px 0', - }, - }, - '.MuiTimePickerToolbar-ampmSelection': { - height: isMobile ? '100%' : null, - margin: isMobile ? null : '16px 0 auto', - '& > *:first-of-type': { - borderBottomLeftRadius: isMobile ? '0' : null, - borderBottomRightRadius: isMobile ? '0' : null, - }, - '& > *:last-of-type': { - borderTop: isMobile ? '0' : null, - borderTopLeftRadius: isMobile ? '0' : null, - borderTopRightRadius: isMobile ? '0' : null, - }, - button: { - border: '1px solid var(--accent-300)', - span: { - color: 'var(--accent-400)', - height: '100%', - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - padding: '6px', - }, - minWidth: '52px', - height: '100%', - }, - }, - }, + sx: getTimePickerToolbarStyle(isMobile, ampm), }, desktopPaper: { className: 'pop-up pop-up-shadow', }, popper: { anchorEl: divRef.current, - sx: { - ...StyleTimePickerPopper, - '.MuiPickersToolbar-content': { - flexWrap: isMobile ? 'nowrap' : 'wrap', - alignItems: ampm ? 'flex-start' : 'center', - minWidth: '216px', - }, - '.MuiPickersLayout-contentWrapper': { - marginTop: isMobile ? '5px' : '40px', - gridColumn: '2 / 2', - }, - '.MuiTimeClock-arrowSwitcher': { - top: 0, - right: 0, - }, - '.MuiPickersArrowSwitcher-button': { - color: 'var(--accent-150)', - }, - '.MuiPickersArrowSwitcher-button:hover': { - backgroundColor: 'var(--accent-150)', - color: 'var(--accent-main)', - }, - '.MuiIconButton-root': { - color: 'var(--accent-350)', - }, - '.Mui-disabled': { - color: 'var(--accent-200)', - }, - }, + sx: getTimePickerPopperStyle(isMobile, ampm), }, }} /> diff --git a/src/components/time_picker/index.types.ts b/src/components/time_picker/index.types.ts index 84bb4cd34d8..f9e64fe3ba7 100644 --- a/src/components/time_picker/index.types.ts +++ b/src/components/time_picker/index.types.ts @@ -22,4 +22,9 @@ export interface CustomTimePickerProps { onChange?: (value: Date) => void; sx?: SxProps; readOnly?: boolean; + /** + * Drops the clock button inside the field, freeing its width for the time + * itself — the field opens the picker on its own. Meant for narrow layouts. + */ + hideIcon?: boolean; } diff --git a/src/components/time_picker/slots/textfield.tsx b/src/components/time_picker/slots/textfield.tsx index b6217db85cd..fe27ec906df 100644 --- a/src/components/time_picker/slots/textfield.tsx +++ b/src/components/time_picker/slots/textfield.tsx @@ -95,6 +95,7 @@ const InputTextField = forwardRef(function DatePickerInputField( '& > .MuiAutocomplete-popupIndicator': { '& svg, & svg g, & svg g path': { fill: 'var(--black)' }, }, + ...props.sx, }} /> ); diff --git a/src/definition/public_witnessing.ts b/src/definition/public_witnessing.ts new file mode 100644 index 00000000000..977164abbb6 --- /dev/null +++ b/src/definition/public_witnessing.ts @@ -0,0 +1,53 @@ +// Period the shifts card lays out. +export type PublicWitnessingViewType = 'day' | 'week' | 'month'; + +export type PublicWitnessingShiftType = { + start_time: string; // "HH:mm" + end_time: string; // "HH:mm" +}; + +export type PublicWitnessingDayScheduleType = { + weekday: number; // 1 (Monday) – 7 (Sunday) + shifts: PublicWitnessingShiftType[]; +}; + +export type PublicWitnessingLocationType = { + location_uid: string; + location_data: { + _deleted: boolean; + updatedAt: string; + name: string; + address: string; + cart_stored_at: string; + max_publishers: number; + description: string; + sort_index: number; + schedule: PublicWitnessingDayScheduleType[]; + }; +}; + +export type PublicWitnessingPublisherType = { + name: string; + // Set when the publisher is a congregation person; free-text partners + // (non-app users) have a name only. + person_uid?: string; +}; + +export type PublicWitnessingArrangementType = { + arrangement_uid: string; + arrangement_data: { + _deleted: boolean; + updatedAt: string; + location_uid: string; + date: string; // "yyyy/MM/dd" + start_time: string; // "HH:mm" + end_time: string; // "HH:mm" + // person_uid of the author — only the author (and admins) may edit. + created_by: string; + partner_needed: boolean; + // Publishers still wanted when partner_needed; the shift stays joinable + // until this many partners have been arranged. + partner_count?: number; + publishers: PublicWitnessingPublisherType[]; + }; +}; diff --git a/src/features/app_start/vip/congregation_create/index.tsx b/src/features/app_start/vip/congregation_create/index.tsx index 5ea7e3ed0d4..9cedd50be38 100644 --- a/src/features/app_start/vip/congregation_create/index.tsx +++ b/src/features/app_start/vip/congregation_create/index.tsx @@ -1,8 +1,8 @@ -import { Box, Step, StepLabel, Stepper } from '@mui/material'; +import { Box } from '@mui/material'; import { useAppTranslation } from '@hooks/index'; import useCongregationCreate from './useCongregationCreate'; import PageHeader from '@features/app_start/shared/page_header'; -import Typography from '@components/typography'; +import Stepper from '@components/stepper'; const CongregationCreate = () => { const { t } = useAppTranslation(); @@ -22,44 +22,10 @@ const CongregationCreate = () => { step.label)} activeStep={currentStep} - alternativeLabel sx={{ marginBottom: '32px', marginTop: '-8px' }} - > - {steps.map((step, index) => ( - - - - {step.label} - - - - ))} - + /> {steps[currentStep].Component} diff --git a/src/features/ministry/public_witnessing/arrangement_form/index.tsx b/src/features/ministry/public_witnessing/arrangement_form/index.tsx new file mode 100644 index 00000000000..2be668f362f --- /dev/null +++ b/src/features/ministry/public_witnessing/arrangement_form/index.tsx @@ -0,0 +1,289 @@ +import { useState } from 'react'; +import { Box, Stack } from '@mui/material'; +import { useAppTranslation } from '@hooks/index'; +import { dateFormatFriendly } from '@utils/date'; +import { IconAdd, IconDelete } from '@components/icons'; +import Autocomplete from '@components/autocomplete'; +import Button from '@components/button'; +import Dialog from '@components/dialog'; +import MenuItem from '@components/menuitem'; +import Radio from '@components/radio'; +import Select from '@components/select'; +import Tabs from '@components/tabs'; +import Typography from '@components/typography'; +import useArrangementForm, { createPartnerName } from './useArrangementForm'; +import { ArrangementFormProps, PersonOption } from './index.types'; + +const optionLabel = (option?: PersonOption | string | null) => { + if (!option) return ''; + return typeof option === 'string' ? option : option.label; +}; + +const ArrangementForm = (props: ArrangementFormProps) => { + const { t } = useAppTranslation(); + const { slot } = props; + + const { + mode, + isAdmin, + partnerNeeded, + setPartnerNeeded, + partnerCount, + setPartnerCount, + partnerNames, + setPartnerNames, + forOthers, + setForOthers, + maxNames, + canInvitePartners, + personOptions, + handleConfirm, + handleDelete, + handleDownloadCalendar, + } = useArrangementForm(props); + + const [step, setStep] = useState<'form' | 'calendar'>('form'); + const [isSaving, setIsSaving] = useState(false); + + const partnerCounts = Array.from({ length: maxNames }, (_, i) => i + 1); + + const publisherForm = () => { + if (isAdmin) { + return ( + setForOthers(tab === 1)} + /> + ); + } + + return forOthers ? nameFields(true) : myselfForm; + }; + + const handleConfirmClick = async () => { + if (isSaving) return; + + setIsSaving(true); + + try { + const saved = await handleConfirm(); + if (!saved) return; + if (mode === 'edit') { + props.onClose(); + return; + } + setStep('calendar'); + } finally { + setIsSaving(false); + } + }; + + const updatePartnerName = (id: string, value: string) => { + setPartnerNames( + partnerNames.map((partner) => + partner.id === id ? { ...partner, name: value } : partner + ) + ); + }; + + const nameFields = (numbered: boolean) => ( + + {partnerNames.map((partner, index) => ( + + {numbered && ( + + {t('tr_publisherWithNumber', { publisherNumber: index + 1 })} + + )} + { + const option = Array.isArray(value) ? value.at(0) : value; + updatePartnerName(partner.id, optionLabel(option)); + }} + getOptionLabel={(option) => optionLabel(option)} + isOptionEqualToValue={(option, value) => + optionLabel(option) === optionLabel(value) + } + /> + + ))} + + {partnerNames.length < maxNames && ( + + )} + + ); + + const radioOption = ( + checked: boolean, + onSelect: VoidFunction, + label: string, + description: string + ) => ( + + + + {label} + + {description} + + + + ); + + const myselfForm = !canInvitePartners ? null : ( + + {radioOption( + partnerNeeded, + () => setPartnerNeeded(true), + t('tr_partnerNeeded'), + t('tr_partnerNeededDesc') + )} + + {partnerNeeded && ( + + )} + + {radioOption( + !partnerNeeded, + () => setPartnerNeeded(false), + t('tr_havePartner'), + t('tr_havePartnerDesc') + )} + + {!partnerNeeded && nameFields(false)} + + ); + + return ( + + {step === 'calendar' ? ( + <> + + {t('tr_addToCalendar')} + + {t('tr_addToCalendarDesc')} + + + + + + + + + ) : ( + <> + + + + + {t('tr_confirmArrangement')} + + {mode === 'edit' && ( + + )} + + + + {props.location.location_data.name} + + + {dateFormatFriendly(slot.date)}:{' '} + + {slot.start_time} - {slot.end_time} + + + + + + {mode === 'join' && ( + + {t('tr_arrangementWith', { + publisherName: slot.publishers.join(', '), + })} + + )} + + {mode !== 'join' && publisherForm()} + + + + + + + + )} + + ); +}; + +export default ArrangementForm; diff --git a/src/features/ministry/public_witnessing/arrangement_form/index.types.ts b/src/features/ministry/public_witnessing/arrangement_form/index.types.ts new file mode 100644 index 00000000000..f6054094af2 --- /dev/null +++ b/src/features/ministry/public_witnessing/arrangement_form/index.types.ts @@ -0,0 +1,21 @@ +import { PublicWitnessingLocationType } from '@definition/public_witnessing'; +import { ShiftSlotType } from '../shifts_card/index.types'; + +export type ArrangementFormProps = { + open: boolean; + onClose: VoidFunction; + location: PublicWitnessingLocationType; + slot: ShiftSlotType; +}; + +export type ArrangementMode = 'create' | 'join' | 'edit'; + +export type PartnerNameType = { + id: string; + name: string; +}; + +export type PersonOption = { + id: string; + label: string; +}; diff --git a/src/features/ministry/public_witnessing/arrangement_form/useArrangementForm.tsx b/src/features/ministry/public_witnessing/arrangement_form/useArrangementForm.tsx new file mode 100644 index 00000000000..bfc91eb21f3 --- /dev/null +++ b/src/features/ministry/public_witnessing/arrangement_form/useArrangementForm.tsx @@ -0,0 +1,251 @@ +import { useMemo, useState } from 'react'; +import { useAtomValue } from 'jotai'; +import { createEvent } from 'ics'; +import { saveAs } from 'file-saver'; +import { PublicWitnessingPublisherType } from '@definition/public_witnessing'; +import { ShiftSlotStatus } from '../shifts_card/index.types'; +import { personsActiveState } from '@states/persons'; +import { fullnameOptionState, userLocalUIDState } from '@states/settings'; +import { dbPublicWitnessingArrangementsSave } from '@services/dexie/public_witnessing_arrangements'; +import { displaySnackNotification } from '@services/states/app'; +import { getMessageByCode } from '@services/i18n/translation'; +import { buildPersonFullname } from '@utils/common'; +import usePublicWitnessingPermissions from '../usePermissions'; +import { + ArrangementFormProps, + ArrangementMode, + PartnerNameType, + PersonOption, +} from './index.types'; + +export const createPartnerName = (name = ''): PartnerNameType => ({ + id: crypto.randomUUID(), + name, +}); + +const getMode = ( + hasExisting: boolean, + status: ShiftSlotStatus +): ArrangementMode => { + if (hasExisting) return 'edit'; + if (status === 'partner_needed') return 'join'; + return 'create'; +}; + +const useArrangementForm = ({ + location, + slot, + onClose, +}: ArrangementFormProps) => { + const { canManageLocations } = usePublicWitnessingPermissions(); + + const persons = useAtomValue(personsActiveState); + const fullnameOption = useAtomValue(fullnameOptionState); + const userUID = useAtomValue(userLocalUIDState); + + const personOptions = useMemo( + () => + persons.map((person) => ({ + id: person.person_uid, + label: buildPersonFullname( + person.person_data.person_lastname.value, + person.person_data.person_firstname.value, + fullnameOption + ), + })), + [persons, fullnameOption] + ); + + const myName = + personOptions.find((option) => option.id === userUID)?.label ?? ''; + + const existing = + slot.myArrangement ?? + (canManageLocations ? slot.arrangements.at(0) : undefined); + + const mode = getMode(Boolean(existing), slot.status); + + const [partnerNeeded, setPartnerNeeded] = useState( + existing ? existing.arrangement_data.partner_needed : true + ); + const [partnerCount, setPartnerCount] = useState( + existing?.arrangement_data.partner_count ?? 1 + ); + const [partnerNames, setPartnerNames] = useState(() => { + const others = (existing?.arrangement_data.publishers ?? []) + .filter((publisher) => publisher.person_uid !== userUID) + .map((publisher) => createPartnerName(publisher.name)); + + return others.length > 0 ? others : [createPartnerName()]; + }); + const [forOthers, setForOthers] = useState( + existing + ? !existing.arrangement_data.publishers.some( + (publisher) => publisher.person_uid === userUID + ) + : false + ); + + const capacity = location.location_data.max_publishers; + + // Seats other people already hold in this shift. An edit does not count its + // own record — those seats are the ones being re-arranged. + const takenByOthers = slot.arrangements + .filter((record) => record.arrangement_uid !== existing?.arrangement_uid) + .reduce( + (total, record) => total + record.arrangement_data.publishers.length, + 0 + ); + + const seatsLeft = Math.max(0, capacity - takenByOthers); + + const maxNames = forOthers ? seatsLeft : Math.max(0, seatsLeft - 1); + + const canInvitePartners = maxNames > 0; + + const names = partnerNames.slice(0, maxNames); + + const buildPublishers = (): PublicWitnessingPublisherType[] => { + const toPublisher = (name: string): PublicWitnessingPublisherType => { + const match = personOptions.find((option) => option.label === name); + return match ? { name, person_uid: match.id } : { name }; + }; + + const named = names + .map((partner) => partner.name.trim()) + .filter((name) => name.length > 0) + .map(toPublisher); + + if (forOthers) return named; + if (partnerNeeded) return [{ name: myName, person_uid: userUID }]; + return [{ name: myName, person_uid: userUID }, ...named]; + }; + + const handleSave = async (arrangement: { + partner_needed: boolean; + partner_count?: number; + publishers: PublicWitnessingPublisherType[]; + }) => { + try { + await dbPublicWitnessingArrangementsSave({ + arrangement_uid: existing?.arrangement_uid ?? crypto.randomUUID(), + arrangement_data: { + _deleted: false, + updatedAt: new Date().toISOString(), + location_uid: location.location_uid, + date: slot.date, + start_time: slot.start_time, + end_time: slot.end_time, + created_by: existing?.arrangement_data.created_by ?? userUID, + ...arrangement, + }, + }); + return true; + } catch (error) { + console.error(error); + + displaySnackNotification({ + header: getMessageByCode('error_app_generic-title'), + message: getMessageByCode(error.message), + severity: 'error', + }); + return false; + } + }; + + const handleConfirm = async () => { + if (seatsLeft === 0) return false; + + if (mode === 'join') { + return handleSave({ + partner_needed: false, + publishers: [{ name: myName, person_uid: userUID }], + }); + } + + const seekingPartner = !forOthers && canInvitePartners && partnerNeeded; + + return handleSave({ + partner_needed: seekingPartner, + partner_count: seekingPartner + ? Math.min(partnerCount, maxNames) + : undefined, + publishers: buildPublishers(), + }); + }; + + const handleDelete = async () => { + if (!existing) return; + try { + const record = structuredClone(existing); + record.arrangement_data._deleted = true; + record.arrangement_data.updatedAt = new Date().toISOString(); + + await dbPublicWitnessingArrangementsSave(record); + + onClose(); + } catch (error) { + console.error(error); + + displaySnackNotification({ + header: getMessageByCode('error_app_generic-title'), + message: getMessageByCode(error.message), + severity: 'error', + }); + } + }; + + const handleDownloadCalendar = () => { + const [year, month, day] = slot.date.split('/').map(Number); + const [startHour, startMinute] = slot.start_time.split(':').map(Number); + const [endHour, endMinute] = slot.end_time.split(':').map(Number); + + createEvent( + { + title: location.location_data.name, + description: location.location_data.description, + location: location.location_data.address, + start: [year, month, day, startHour, startMinute], + end: [year, month, day, endHour, endMinute], + }, + (error, value) => { + if (error) { + console.error(error); + + displaySnackNotification({ + header: getMessageByCode('error_app_generic-title'), + message: getMessageByCode(error.message), + severity: 'error', + }); + + return; + } + saveAs( + new Blob([value], { type: 'text/calendar;charset=utf-8' }), + 'public_witnessing.ics' + ); + } + ); + }; + + return { + mode, + isAdmin: canManageLocations, + partnerNeeded, + setPartnerNeeded, + partnerCount, + setPartnerCount, + partnerNames: names, + setPartnerNames, + forOthers, + setForOthers, + maxNames, + canInvitePartners, + personOptions, + handleConfirm, + handleDelete, + handleDownloadCalendar, + }; +}; + +export default useArrangementForm; diff --git a/src/features/ministry/public_witnessing/index.tsx b/src/features/ministry/public_witnessing/index.tsx new file mode 100644 index 00000000000..47f41bc5083 --- /dev/null +++ b/src/features/ministry/public_witnessing/index.tsx @@ -0,0 +1,138 @@ +import { useEffect } from 'react'; +import { Box } from '@mui/material'; +import { keyframes } from '@mui/system'; +import { useNavigate, useParams } from 'react-router'; +import { useAtom, useAtomValue } from 'jotai'; +import { useAppTranslation, useBreakpoints } from '@hooks/index'; +import { + publicWitnessingLocationsState, + publicWitnessingSelectedLocationRecordState, + publicWitnessingSelectedLocationState, +} from '@states/public_witnessing'; +import InfoNote from '@components/info_note'; +import LocationsList from './locations_list'; +import LocationDetails from './location_details'; +import ShiftsCard from './shifts_card'; + +const pushIn = keyframes({ + from: { opacity: 0, transform: 'translateX(24px)' }, + to: { opacity: 1, transform: 'translateX(0)' }, +}); + +const popIn = keyframes({ + from: { opacity: 0, transform: 'translateX(-24px)' }, + to: { opacity: 1, transform: 'translateX(0)' }, +}); + +const PublicWitnessingContainer = () => { + const { t } = useAppTranslation(); + const { laptopUp, desktopLargeUp } = useBreakpoints(); + const navigate = useNavigate(); + + const { locationId } = useParams(); + + const locations = useAtomValue(publicWitnessingLocationsState); + const selectedLocation = useAtomValue( + publicWitnessingSelectedLocationRecordState + ); + const [selected, setSelected] = useAtom( + publicWitnessingSelectedLocationState + ); + + useEffect(() => { + if (locationId) { + // A stale link — a location deleted here or on another device — would + // otherwise open an empty subpage. + const exists = locations.some( + (record) => record.location_uid === locationId + ); + + if (!exists) { + navigate('/public-witnessing', { replace: true }); + return; + } + + setSelected(locationId); + + if (laptopUp) navigate('/public-witnessing', { replace: true }); + + return; + } + + const isValid = locations.some( + (record) => record.location_uid === selected + ); + if (!isValid) { + setSelected(locations.at(0)?.location_uid ?? null); + } + }, [locationId, locations, selected, setSelected, laptopUp, navigate]); + + if (locations.length === 0) { + return ; + } + + const details = selectedLocation && ( + <> + + + + ); + + if (!laptopUp) { + return ( + + {locationId ? ( + details + ) : ( + navigate(`/public-witnessing/${uid}`)} + /> + )} + + ); + } + + return ( + + + + + + + {details} + + + ); +}; + +export default PublicWitnessingContainer; diff --git a/src/features/ministry/public_witnessing/location_delete/index.tsx b/src/features/ministry/public_witnessing/location_delete/index.tsx new file mode 100644 index 00000000000..aa0fd65d444 --- /dev/null +++ b/src/features/ministry/public_witnessing/location_delete/index.tsx @@ -0,0 +1,60 @@ +import { Stack } from '@mui/material'; +import { useAppTranslation } from '@hooks/index'; +import { dbPublicWitnessingArrangementsDeleteByLocation } from '@services/dexie/public_witnessing_arrangements'; +import { dbPublicWitnessingLocationsSave } from '@services/dexie/public_witnessing_locations'; +import { displaySnackNotification } from '@services/states/app'; +import { getMessageByCode } from '@services/i18n/translation'; +import Button from '@components/button'; +import Dialog from '@components/dialog'; +import Typography from '@components/typography'; +import { LocationDeleteProps } from './index.types'; + +const LocationDelete = ({ open, onClose, location }: LocationDeleteProps) => { + const { t } = useAppTranslation(); + + const handleDelete = async () => { + try { + const record = structuredClone(location); + record.location_data._deleted = true; + record.location_data.updatedAt = new Date().toISOString(); + + await dbPublicWitnessingArrangementsDeleteByLocation( + location.location_uid + ); + await dbPublicWitnessingLocationsSave(record); + + onClose(); + } catch (error) { + console.error(error); + + displaySnackNotification({ + header: getMessageByCode('error_app_generic-title'), + message: getMessageByCode(error.message), + severity: 'error', + }); + } + }; + + return ( + + + {t('tr_deletePWLocation')} + + + {t('tr_deletePWLocationDesc')} + + + + + + + + + ); +}; + +export default LocationDelete; diff --git a/src/features/ministry/public_witnessing/location_delete/index.types.ts b/src/features/ministry/public_witnessing/location_delete/index.types.ts new file mode 100644 index 00000000000..a9cb7582029 --- /dev/null +++ b/src/features/ministry/public_witnessing/location_delete/index.types.ts @@ -0,0 +1,7 @@ +import { PublicWitnessingLocationType } from '@definition/public_witnessing'; + +export type LocationDeleteProps = { + open: boolean; + onClose: VoidFunction; + location: PublicWitnessingLocationType; +}; diff --git a/src/features/ministry/public_witnessing/location_details/index.tsx b/src/features/ministry/public_witnessing/location_details/index.tsx new file mode 100644 index 00000000000..9bfe2ba429b --- /dev/null +++ b/src/features/ministry/public_witnessing/location_details/index.tsx @@ -0,0 +1,67 @@ +import { Box } from '@mui/material'; +import { useAppTranslation, useBreakpoints } from '@hooks/index'; +import { IconCart, IconNormalPin, IconVisitors } from '@components/icons'; +import Badge from '@components/badge'; +import Card from '@components/card'; +import Typography from '@components/typography'; +import { LocationDetailsProps } from './index.types'; + +const LocationDetails = ({ location }: LocationDetailsProps) => { + const { t } = useAppTranslation(); + const { laptopUp } = useBreakpoints(); + + const { name, address, cart_stored_at, max_publishers, description } = + location.location_data; + + const badgeProps = { size: 'small', color: 'accent', light: true } as const; + + return ( + + + {name} + + + {cart_stored_at.length > 0 && ( + + } + text={t('tr_PWStoredAt', { name: cart_stored_at })} + /> + )} + } + text={t('tr_maxPublisherShift', { + maxPublisherCount: max_publishers, + })} + /> + {address.length > 0 && ( + } + text={address} + /> + )} + + + + {description.length > 0 && ( + + {description} + + )} + + ); +}; + +export default LocationDetails; diff --git a/src/features/ministry/public_witnessing/location_details/index.types.ts b/src/features/ministry/public_witnessing/location_details/index.types.ts new file mode 100644 index 00000000000..78e3845eed4 --- /dev/null +++ b/src/features/ministry/public_witnessing/location_details/index.types.ts @@ -0,0 +1,5 @@ +import { PublicWitnessingLocationType } from '@definition/public_witnessing'; + +export type LocationDetailsProps = { + location: PublicWitnessingLocationType; +}; diff --git a/src/features/ministry/public_witnessing/location_form/index.tsx b/src/features/ministry/public_witnessing/location_form/index.tsx new file mode 100644 index 00000000000..435a752ce28 --- /dev/null +++ b/src/features/ministry/public_witnessing/location_form/index.tsx @@ -0,0 +1,195 @@ +import { Box, Stack } from '@mui/material'; +import { useAppTranslation, useBreakpoints } from '@hooks/index'; +import { IconDelete } from '@components/icons'; +import Button from '@components/button'; +import Dialog from '@components/dialog'; +import Stepper from '@components/stepper'; +import Tabs from '@components/tabs'; +import TextField from '@components/textfield'; +import Typography from '@components/typography'; +import ScheduleEditor from './schedule_editor'; +import useLocationForm from './useLocationForm'; +import { LocationFormProps } from './index.types'; + +const LocationForm = (props: LocationFormProps) => { + const { t } = useAppTranslation(); + const { laptopUp, tabletUp } = useBreakpoints(); + + const { + name, + setName, + address, + setAddress, + cartStoredAt, + setCartStoredAt, + maxPublishers, + setMaxPublishers, + description, + setDescription, + scheduleMode, + handleScheduleModeChange, + approvedDays, + selectedDay, + selectedShifts, + errors, + isSaving, + step, + setStep, + handleNext, + handleToggleDay, + setSelectedDay, + handleAddShift, + handleRemoveShift, + handleShiftChange, + handleSave, + } = useLocationForm(props); + + const isEditing = Boolean(props.location); + const isLastStep = isEditing || step === 1; + + const mainAction = isLastStep + ? { label: t('tr_save'), onClick: handleSave } + : { label: t('tr_next'), onClick: handleNext }; + + const backAction = + !isEditing && step === 1 + ? { label: t('tr_back'), onClick: () => setStep(0) } + : { label: t('tr_cancel'), onClick: props.onClose }; + + const detailsTab = ( + + + setName(e.target.value)} + error={errors.name} + helperText={errors.name && t('tr_fillRequiredField')} + /> + setAddress(e.target.value)} + /> + setCartStoredAt(e.target.value)} + /> + + setMaxPublishers( + e.target.value === '' ? '' : Math.max(1, Number(e.target.value)) + ) + } + error={errors.maxPublishers} + helperText={errors.maxPublishers && t('tr_fillRequiredField')} + /> + + setDescription(e.target.value)} + multiline + rows={4} + /> + + ); + + const scheduleTab = ( + + ); + + return ( + + + + + {isEditing ? t('tr_PWLocationEdit') : t('tr_PWLocationAdd')} + + {props.onDelete && ( + + )} + + + {isEditing ? ( + + ) : ( + + + {step === 0 ? detailsTab : scheduleTab} + + )} + + + + + + + + ); +}; + +export default LocationForm; diff --git a/src/features/ministry/public_witnessing/location_form/index.types.ts b/src/features/ministry/public_witnessing/location_form/index.types.ts new file mode 100644 index 00000000000..6f08b61cfbb --- /dev/null +++ b/src/features/ministry/public_witnessing/location_form/index.types.ts @@ -0,0 +1,56 @@ +import { + PublicWitnessingLocationType, + PublicWitnessingShiftType, +} from '@definition/public_witnessing'; + +export type ScheduleMode = 'every_day' | 'custom'; + +// Shifts get an id while they are being edited so the rows keep their identity +// as the times change; it is dropped again on save. +export type EditableShiftType = PublicWitnessingShiftType & { id: string }; + +export type LocationFormProps = { + open: boolean; + onClose: VoidFunction; + /** + * The location being edited, or null to create a new one. + */ + location: PublicWitnessingLocationType | null; + + /** + * Opens the delete confirmation (edit mode only). + */ + onDelete?: VoidFunction; +}; + +export type ShiftRowProps = { + shift: EditableShiftType; + hour24: boolean; + startLabel: string; + endLabel: string; + onChange: (field: keyof PublicWitnessingShiftType, value: string) => void; + onRemove: VoidFunction; +}; + +export type ScheduleEditorProps = { + scheduleMode: ScheduleMode; + onModeChange: (mode: ScheduleMode) => void; + /** + * Weekdays the location is worked on — 1 (Monday) to 7 (Sunday). + */ + approvedDays: number[]; + /** + * The weekday whose shifts are being edited, or null when none is open. + */ + selectedDay: number | null; + selectedShifts: EditableShiftType[]; + onToggleDay: (weekday: number) => void; + onSelectDay: (weekday: number | null) => void; + onAddShift: VoidFunction; + onRemoveShift: (index: number) => void; + onShiftChange: ( + index: number, + field: keyof PublicWitnessingShiftType, + value: string + ) => void; +}; diff --git a/src/features/ministry/public_witnessing/location_form/schedule_editor.tsx b/src/features/ministry/public_witnessing/location_form/schedule_editor.tsx new file mode 100644 index 00000000000..58bd0ed278f --- /dev/null +++ b/src/features/ministry/public_witnessing/location_form/schedule_editor.tsx @@ -0,0 +1,239 @@ +import { Box, Collapse, Stack } from '@mui/material'; +import { useAtomValue } from 'jotai'; +import { useAppTranslation, useBreakpoints } from '@hooks/index'; +import { hour24FormatState } from '@states/settings'; +import { generateWeekday } from '@services/i18n/translation'; +import { + IconAdd, + IconChevronRight, + IconExpand, + IconInfo, +} from '@components/icons'; +import Button from '@components/button'; +import Checkbox from '@components/checkbox'; +import Divider from '@components/divider'; +import SwitchWithLabel from '@components/switch_with_label'; +import Typography from '@components/typography'; +import ShiftRow from './shift_row'; +import { ScheduleEditorProps } from './index.types'; + +const dayRowStyles = (checked: boolean) => ({ + display: 'flex', + alignItems: 'center', + gap: '8px', + padding: '8px 16px 8px 8px', + borderRadius: 'var(--radius-m)', + border: checked + ? '1px solid var(--accent-main)' + : '1px solid var(--accent-300)', + backgroundColor: checked ? 'var(--accent-150)' : 'var(--white)', + cursor: 'pointer', + '&:hover': { borderColor: 'var(--accent-main)' }, + '&:focus-visible': { outline: 'var(--accent-main) auto 1px' }, + '&:hover .day-row-chevron, &:focus-within .day-row-chevron': { + opacity: 1, + transform: 'translateX(0)', + }, +}); + +const ScheduleEditor = ({ + scheduleMode, + onModeChange, + approvedDays, + selectedDay, + selectedShifts, + onToggleDay, + onSelectDay, + onAddShift, + onRemoveShift, + onShiftChange, +}: ScheduleEditorProps) => { + const { t } = useAppTranslation(); + const { laptopUp } = useBreakpoints(); + const hour24 = useAtomValue(hour24FormatState); + const weekdayNames = generateWeekday(); + + const shiftsEditor = ( + <> + {selectedShifts.map((shift, index) => ( + onShiftChange(index, field, value)} + onRemove={() => onRemoveShift(index)} + /> + ))} + + + + ); + + const handleDayRowClick = (weekday: number, expanded: boolean) => { + if (!approvedDays.includes(weekday)) { + onToggleDay(weekday); + return; + } + onSelectDay(expanded ? null : weekday); + }; + + const dayRow = (weekday: number, dayName: string, expanded: boolean) => ( + handleDayRowClick(weekday, expanded)} + onKeyDown={(event) => { + if (event.key !== 'Enter' && event.key !== ' ') return; + event.preventDefault(); + handleDayRowClick(weekday, expanded); + }} + > + onToggleDay(weekday)} + label={dayName} + stopPropagation + sx={{ marginLeft: 0 }} + /> + + {approvedDays.includes(weekday) && !laptopUp && ( + + + + )} + + {laptopUp && ( + + + + )} + + ); + + const daySelector = ( + + + {t('tr_selectDays')} + + + {weekdayNames.map((dayName, index) => { + const weekday = index + 1; + const expanded = weekday === selectedDay; + + if (laptopUp) + return {dayRow(weekday, dayName, expanded)}; + + return ( + + {dayRow(weekday, dayName, expanded)} + + + + {shiftsEditor} + + + + ); + })} + + ); + + const customSchedule = !laptopUp ? ( + daySelector + ) : ( + + {daySelector} + + + + + {selectedDay === null ? ( + + + + {t('tr_PWScheduleSelectDay')} + + + ) : ( + <> + + {t('tr_daysShifts', { dayName: weekdayNames[selectedDay - 1] })} + + {shiftsEditor} + + )} + + + ); + + return ( + + onModeChange(checked ? 'every_day' : 'custom')} + /> + + {scheduleMode === 'every_day' ? ( + + + {t('tr_GeneralTimeRules')} + + {shiftsEditor} + + ) : ( + customSchedule + )} + + ); +}; + +export default ScheduleEditor; diff --git a/src/features/ministry/public_witnessing/location_form/shift_row.tsx b/src/features/ministry/public_witnessing/location_form/shift_row.tsx new file mode 100644 index 00000000000..dc3c61a52da --- /dev/null +++ b/src/features/ministry/public_witnessing/location_form/shift_row.tsx @@ -0,0 +1,87 @@ +import { useMemo } from 'react'; +import { Box } from '@mui/material'; +import { useBreakpoints } from '@hooks/index'; +import { generateDateFromTime, formatDate } from '@utils/date'; +import { IconDelete } from '@components/icons'; +import IconButton from '@components/icon_button'; +import TimePicker from '@components/time_picker'; +import { ShiftRowProps } from './index.types'; + +// Separate component so the TimePicker Date values keep a stable identity — +// the picker resets its in-progress edit whenever the value prop changes. +const ShiftRow = ({ + shift, + hour24, + startLabel, + endLabel, + onChange, + onRemove, +}: ShiftRowProps) => { + const { tabletUp, tablet688Up } = useBreakpoints(); + + const startValue = useMemo( + () => generateDateFromTime(shift.start_time), + [shift.start_time] + ); + const endValue = useMemo( + () => generateDateFromTime(shift.end_time), + [shift.end_time] + ); + + return ( + + + value && onChange('start_time', formatDate(value, 'HH:mm')) + } + sx={{ flex: '1 1 0', minWidth: 0 }} + /> + {tabletUp && ( + + )} + + value && onChange('end_time', formatDate(value, 'HH:mm')) + } + sx={{ flex: '1 1 0', minWidth: 0 }} + /> + + + + + ); +}; + +export default ShiftRow; diff --git a/src/features/ministry/public_witnessing/location_form/useLocationForm.tsx b/src/features/ministry/public_witnessing/location_form/useLocationForm.tsx new file mode 100644 index 00000000000..6386a614532 --- /dev/null +++ b/src/features/ministry/public_witnessing/location_form/useLocationForm.tsx @@ -0,0 +1,289 @@ +import { useMemo, useState } from 'react'; +import { useAtomValue } from 'jotai'; +import { + PublicWitnessingDayScheduleType, + PublicWitnessingShiftType, +} from '@definition/public_witnessing'; +import { publicWitnessingLocationsState } from '@states/public_witnessing'; +import { dbPublicWitnessingLocationsSave } from '@services/dexie/public_witnessing_locations'; +import { timeAddMinutes } from '@utils/date'; +import { displaySnackNotification } from '@services/states/app'; +import { getMessageByCode, getTranslation } from '@services/i18n/translation'; +import { + EditableShiftType, + LocationFormProps, + ScheduleMode, +} from './index.types'; + +const WEEKDAYS = [1, 2, 3, 4, 5, 6, 7]; + +const toEditable = (shift: PublicWitnessingShiftType): EditableShiftType => ({ + ...shift, + id: crypto.randomUUID(), +}); + +const useLocationForm = ({ location, onClose }: LocationFormProps) => { + const locations = useAtomValue(publicWitnessingLocationsState); + + const [name, setName] = useState(location?.location_data.name ?? ''); + const [address, setAddress] = useState(location?.location_data.address ?? ''); + const [cartStoredAt, setCartStoredAt] = useState( + location?.location_data.cart_stored_at ?? '' + ); + const [maxPublishers, setMaxPublishers] = useState( + location?.location_data.max_publishers ?? '' + ); + const [description, setDescription] = useState( + location?.location_data.description ?? '' + ); + + // Shifts are kept per weekday even for unchecked days, so toggling a day + // off and on again does not lose its shifts before saving. + const [shiftsByDay, setShiftsByDay] = useState< + Record + >(() => + Object.fromEntries( + WEEKDAYS.map((weekday) => [ + weekday, + ( + location?.location_data.schedule.find( + (day) => day.weekday === weekday + )?.shifts ?? [] + ).map(toEditable), + ]) + ) + ); + const [approvedDays, setApprovedDays] = useState( + () => location?.location_data.schedule.map((day) => day.weekday) ?? [] + ); + const [selectedDay, setSelectedDay] = useState( + () => location?.location_data.schedule.at(0)?.weekday ?? null + ); + + // Details / schedule — shown as steps when adding a location, as tabs when + // editing one. + const [step, setStep] = useState(0); + + // Missing details are only marked once the publisher tried to move on + // without them. + const [showErrors, setShowErrors] = useState(false); + + const [isSaving, setIsSaving] = useState(false); + + const [scheduleMode, setScheduleMode] = useState(() => { + const schedule = location?.location_data.schedule; + if (schedule?.length !== WEEKDAYS.length) return 'custom'; + + const template = JSON.stringify(schedule[0].shifts); + const sameEveryDay = schedule.every( + (day) => JSON.stringify(day.shifts) === template + ); + + return sameEveryDay ? 'every_day' : 'custom'; + }); + + const isEveryDay = scheduleMode === 'every_day'; + + const handleScheduleModeChange = (mode: ScheduleMode) => { + setScheduleMode(mode); + + if (mode === 'custom') { + setSelectedDay(approvedDays.at(0) ?? null); + return; + } + + const template = shiftsByDay[selectedDay ?? WEEKDAYS[0]] ?? []; + + setApprovedDays([...WEEKDAYS]); + setShiftsByDay( + Object.fromEntries( + WEEKDAYS.map((weekday) => [weekday, template.map(toEditable)]) + ) + ); + setSelectedDay(null); + }; + + const handleToggleDay = (weekday: number) => { + const isApproved = approvedDays.includes(weekday); + + setApprovedDays((prev) => + isApproved ? prev.filter((day) => day !== weekday) : [...prev, weekday] + ); + + if (!isApproved) { + setSelectedDay(weekday); + return; + } + + // Unchecking a day only closes the editor when it is the one open. + if (weekday === selectedDay) setSelectedDay(null); + }; + + const getEditedDays = () => { + if (isEveryDay) return WEEKDAYS; + return selectedDay === null ? [] : [selectedDay]; + }; + + const editedDays = getEditedDays(); + + const selectedShifts = isEveryDay + ? shiftsByDay[WEEKDAYS[0]] + : (shiftsByDay[selectedDay ?? 0] ?? []); + + const updateShifts = ( + updater: (shifts: EditableShiftType[]) => EditableShiftType[] + ) => { + if (editedDays.length === 0) return; + + setShiftsByDay((prev) => { + const next = { ...prev }; + for (const weekday of editedDays) next[weekday] = updater(prev[weekday]); + return next; + }); + }; + + const handleAddShift = () => { + updateShifts((shifts) => { + const start_time = shifts.at(-1)?.end_time ?? '09:00'; + return [ + ...shifts, + toEditable({ start_time, end_time: timeAddMinutes(start_time, 60) }), + ]; + }); + }; + + const handleRemoveShift = (index: number) => { + updateShifts((shifts) => shifts.filter((_, i) => i !== index)); + }; + + const handleShiftChange = ( + index: number, + field: keyof PublicWitnessingShiftType, + value: string + ) => { + updateShifts((shifts) => + shifts.map((shift, i) => + i === index ? { ...shift, [field]: value } : shift + ) + ); + }; + + const errors = { + name: showErrors && name.trim().length === 0, + maxPublishers: showErrors && Number(maxPublishers) <= 0, + }; + + const isDetailsValid = useMemo( + () => name.trim().length > 0 && Number(maxPublishers) > 0, + [name, maxPublishers] + ); + + const handleNext = () => { + if (!isDetailsValid) { + setShowErrors(true); + return; + } + + setStep(1); + }; + + const handleSave = async () => { + if (isSaving) return; + + if (!isDetailsValid) { + setShowErrors(true); + setStep(0); + return; + } + + // "HH:mm" compares as text, so a plain comparison catches shifts that end + // before they start — those would never show up as a slot. + const hasInvalidShift = approvedDays.some((weekday) => + shiftsByDay[weekday].some((shift) => shift.end_time <= shift.start_time) + ); + + if (hasInvalidShift) { + setStep(1); + + displaySnackNotification({ + header: getMessageByCode('error_app_generic-title'), + message: getTranslation({ key: 'tr_shiftEndsBeforeStart' }), + severity: 'error', + }); + + return; + } + + const schedule: PublicWitnessingDayScheduleType[] = WEEKDAYS.filter( + (weekday) => approvedDays.includes(weekday) + ).map((weekday) => ({ + weekday, + shifts: shiftsByDay[weekday].map(({ start_time, end_time }) => ({ + start_time, + end_time, + })), + })); + + setIsSaving(true); + + try { + await dbPublicWitnessingLocationsSave({ + location_uid: location?.location_uid ?? crypto.randomUUID(), + location_data: { + _deleted: false, + updatedAt: new Date().toISOString(), + name: name.trim(), + address: address.trim(), + cart_stored_at: cartStoredAt.trim(), + max_publishers: Number(maxPublishers), + description: description.trim(), + sort_index: location?.location_data.sort_index ?? locations.length, + schedule, + }, + }); + + onClose(); + } catch (error) { + console.error(error); + + displaySnackNotification({ + header: getMessageByCode('error_app_generic-title'), + message: getMessageByCode(error.message), + severity: 'error', + }); + } finally { + setIsSaving(false); + } + }; + + return { + name, + setName, + address, + setAddress, + cartStoredAt, + setCartStoredAt, + maxPublishers, + setMaxPublishers, + description, + setDescription, + scheduleMode, + handleScheduleModeChange, + approvedDays, + selectedDay, + selectedShifts, + errors, + isSaving, + step, + setStep, + handleNext, + handleToggleDay, + setSelectedDay, + handleAddShift, + handleRemoveShift, + handleShiftChange, + handleSave, + }; +}; + +export default useLocationForm; diff --git a/src/features/ministry/public_witnessing/locations_list/index.tsx b/src/features/ministry/public_witnessing/locations_list/index.tsx new file mode 100644 index 00000000000..a9d827709d1 --- /dev/null +++ b/src/features/ministry/public_witnessing/locations_list/index.tsx @@ -0,0 +1,50 @@ +import { Fragment } from 'react'; +import { Box } from '@mui/material'; +import { useAppTranslation, useBreakpoints } from '@hooks/index'; +import { IconNormalPin } from '@components/icons'; +import Card from '@components/card'; +import Divider from '@components/divider'; +import SettingsTab from '@components/settings_tab'; +import Typography from '@components/typography'; +import { LocationsListProps } from './index.types'; + +const LocationsList = ({ + locations, + selected, + onSelect, +}: LocationsListProps) => { + const { t } = useAppTranslation(); + const { laptopUp } = useBreakpoints(); + + return ( + + {t('tr_locations')} + + + {locations.map((location, index) => ( + + {index > 0 && } + } + label={location.location_data.name} + description={location.location_data.address} + active={location.location_uid === selected} + onClick={() => onSelect(location.location_uid)} + /> + + ))} + + + ); +}; + +export default LocationsList; diff --git a/src/features/ministry/public_witnessing/locations_list/index.types.ts b/src/features/ministry/public_witnessing/locations_list/index.types.ts new file mode 100644 index 00000000000..c7119f139e9 --- /dev/null +++ b/src/features/ministry/public_witnessing/locations_list/index.types.ts @@ -0,0 +1,7 @@ +import { PublicWitnessingLocationType } from '@definition/public_witnessing'; + +export type LocationsListProps = { + locations: PublicWitnessingLocationType[]; + selected: string | null; + onSelect: (location_uid: string) => void; +}; diff --git a/src/features/ministry/public_witnessing/locations_reorder/index.tsx b/src/features/ministry/public_witnessing/locations_reorder/index.tsx new file mode 100644 index 00000000000..80f694a076c --- /dev/null +++ b/src/features/ministry/public_witnessing/locations_reorder/index.tsx @@ -0,0 +1,60 @@ +import { Box, Stack } from '@mui/material'; +import { ReactSortable } from 'react-sortablejs'; +import { useAppTranslation } from '@hooks/index'; +import { IconDragHandle } from '@components/icons'; +import Button from '@components/button'; +import Dialog from '@components/dialog'; +import Typography from '@components/typography'; +import useLocationsReorder from './useLocationsReorder'; +import { LocationsReorderProps } from './index.types'; + +const LocationsReorder = (props: LocationsReorderProps) => { + const { t } = useAppTranslation(); + + const { locations, handleDragChange, handleSaveChanges } = + useLocationsReorder(props); + + return ( + + {t('tr_PWLocationsReorder')} + + + + {locations.map((location) => ( + + + {location.name} + + ))} + + + + + + + + + ); +}; + +export default LocationsReorder; diff --git a/src/features/ministry/public_witnessing/locations_reorder/index.types.ts b/src/features/ministry/public_witnessing/locations_reorder/index.types.ts new file mode 100644 index 00000000000..70a4b33639d --- /dev/null +++ b/src/features/ministry/public_witnessing/locations_reorder/index.types.ts @@ -0,0 +1,9 @@ +export type LocationsReorderProps = { + open: boolean; + onClose: VoidFunction; +}; + +export type LocationItemType = { + id: string; + name: string; +}; diff --git a/src/features/ministry/public_witnessing/locations_reorder/useLocationsReorder.tsx b/src/features/ministry/public_witnessing/locations_reorder/useLocationsReorder.tsx new file mode 100644 index 00000000000..bf61f3e24fb --- /dev/null +++ b/src/features/ministry/public_witnessing/locations_reorder/useLocationsReorder.tsx @@ -0,0 +1,68 @@ +import { useMemo, useState } from 'react'; +import { useAtomValue } from 'jotai'; +import { PublicWitnessingLocationType } from '@definition/public_witnessing'; +import { publicWitnessingLocationsState } from '@states/public_witnessing'; +import { dbPublicWitnessingLocationsBulkSave } from '@services/dexie/public_witnessing_locations'; +import { displaySnackNotification } from '@services/states/app'; +import { getMessageByCode } from '@services/i18n/translation'; +import { LocationItemType, LocationsReorderProps } from './index.types'; + +const useLocationsReorder = ({ onClose }: LocationsReorderProps) => { + const locationsList = useAtomValue(publicWitnessingLocationsState); + + const [locations, setLocations] = useState(() => + locationsList.map((record) => ({ + id: record.location_uid, + name: record.location_data.name, + })) + ); + + // Only the locations that actually moved are saved — touching the rest + // would bump their updatedAt and push them through the next sync. + const locations_sorted = useMemo(() => { + const result: PublicWitnessingLocationType[] = []; + + for (const location of locationsList) { + const findIndex = locations.findIndex( + (record) => record.id === location.location_uid + ); + + if (findIndex === location.location_data.sort_index) continue; + + const newLocation = structuredClone(location); + + newLocation.location_data.sort_index = findIndex; + newLocation.location_data.updatedAt = new Date().toISOString(); + + result.push(newLocation); + } + + return result; + }, [locations, locationsList]); + + const handleDragChange = (value: LocationItemType[]) => { + setLocations(value); + }; + + const handleSaveChanges = async () => { + try { + if (locations_sorted.length > 0) { + await dbPublicWitnessingLocationsBulkSave(locations_sorted); + } + + onClose(); + } catch (error) { + console.error(error); + + displaySnackNotification({ + header: getMessageByCode('error_app_generic-title'), + message: getMessageByCode(error.message), + severity: 'error', + }); + } + }; + + return { locations, handleDragChange, handleSaveChanges }; +}; + +export default useLocationsReorder; diff --git a/src/features/ministry/public_witnessing/shifts_card/day_view.tsx b/src/features/ministry/public_witnessing/shifts_card/day_view.tsx new file mode 100644 index 00000000000..45c1d2a0b6b --- /dev/null +++ b/src/features/ministry/public_witnessing/shifts_card/day_view.tsx @@ -0,0 +1,30 @@ +import { Stack } from '@mui/material'; +import { useAppTranslation } from '@hooks/index'; +import ShiftCell from './shift_cell'; +import ShiftsEmpty from './shifts_empty'; +import { ShiftsViewProps } from './index.types'; + +const DayView = ({ days, canInteract, onSelectSlot }: ShiftsViewProps) => { + const { t } = useAppTranslation(); + + const slots = days.at(0)?.slots ?? []; + + return ( + + {slots.length === 0 && ( + + )} + + {slots.map((slot) => ( + onSelectSlot(slot)} + /> + ))} + + ); +}; + +export default DayView; diff --git a/src/features/ministry/public_witnessing/shifts_card/index.tsx b/src/features/ministry/public_witnessing/shifts_card/index.tsx new file mode 100644 index 00000000000..dd92660cef8 --- /dev/null +++ b/src/features/ministry/public_witnessing/shifts_card/index.tsx @@ -0,0 +1,169 @@ +import { useState } from 'react'; +import { Box } from '@mui/material'; +import { useAppTranslation, useBreakpoints } from '@hooks/index'; +import { PublicWitnessingViewType } from '@definition/public_witnessing'; +import { + IconDate, + IconNavigateLeft, + IconNavigateRight, +} from '@components/icons'; +import Card from '@components/card'; +import Divider from '@components/divider'; +import IconButton from '@components/icon_button'; +import TabSwitcher from '@components/tab_switcher'; +import Tooltip from '@components/tooltip'; +import Typography from '@components/typography'; +import usePublicWitnessingPermissions from '../usePermissions'; +import ArrangementForm from '../arrangement_form'; +import DayView from './day_view'; +import MonthView from './month_view'; +import WeekView from './week_view'; +import useShiftsCard from './useShiftsCard'; +import { ShiftSlotType, ShiftsCardProps } from './index.types'; + +const arrowButtonStyles = { + padding: '8px', + borderRadius: '50%', + '&:hover': { backgroundColor: 'var(--accent-150)' }, + '& svg': { width: '24px', height: '24px' }, +}; + +const ShiftsCard = ({ location }: ShiftsCardProps) => { + const { t } = useAppTranslation(); + const { laptopUp, tabletUp } = useBreakpoints(); + const { canManageLocations } = usePublicWitnessingPermissions(); + + const { + view, + label, + isCurrentPeriod, + days, + handlePrevious, + handleNext, + goToToday, + handleViewChange, + handleSelectDay, + } = useShiftsCard({ location }); + + const [openSlot, setOpenSlot] = useState(null); + + const canInteract = (slot: ShiftSlotType) => { + if (slot.status === 'past') return false; + if (canManageLocations) return true; + if (slot.myArrangement) return true; + return slot.status === 'available' || slot.status === 'partner_needed'; + }; + + const viewProps = { + days, + canInteract, + onSelectSlot: (slot: ShiftSlotType) => setOpenSlot(slot), + }; + + return ( + + + + + + + + + + {label} + + + + + + + + + + + + + + + + value={view} + onChange={handleViewChange} + options={[ + { value: 'day', label: t('tr_day') }, + { value: 'week', label: t('tr_week') }, + { value: 'month', label: t('tr_month') }, + ]} + sx={{ + width: tabletUp ? '320px' : '100%', + maxWidth: '100%', + }} + /> + + + + + + {view === 'day' && } + {view === 'week' && } + {view === 'month' && ( + + )} + + {openSlot && ( + setOpenSlot(null)} + location={location} + slot={openSlot} + /> + )} + + ); +}; + +export default ShiftsCard; diff --git a/src/features/ministry/public_witnessing/shifts_card/index.types.ts b/src/features/ministry/public_witnessing/shifts_card/index.types.ts new file mode 100644 index 00000000000..51d90a58057 --- /dev/null +++ b/src/features/ministry/public_witnessing/shifts_card/index.types.ts @@ -0,0 +1,62 @@ +import { + PublicWitnessingArrangementType, + PublicWitnessingLocationType, +} from '@definition/public_witnessing'; + +export type ShiftSlotStatus = 'available' | 'partner_needed' | 'full' | 'past'; + +export type ShiftSlotType = { + /** + * Day the shift belongs to, "yyyy/MM/dd" — the week and month views show + * slots of several days at once, so every slot carries its own date. + */ + date: string; + start_time: string; + end_time: string; + status: ShiftSlotStatus; + /** + * Display names of everyone arranged for this slot. + */ + publishers: string[]; + arrangements: PublicWitnessingArrangementType[]; + /** + * The arrangement the current user authored, if any — only its author + * (and admins) may open and change it. + */ + myArrangement?: PublicWitnessingArrangementType; +}; + +export type DayShiftsType = { + date: string; + dateObj: Date; + isToday: boolean; + /** + * False for the neighbouring-month days that pad the month grid. + */ + inPeriod: boolean; + slots: ShiftSlotType[]; +}; + +export type ShiftsCardProps = { + location: PublicWitnessingLocationType; +}; + +export type ShiftsViewProps = { + days: DayShiftsType[]; + canInteract: (slot: ShiftSlotType) => boolean; + onSelectSlot: (slot: ShiftSlotType) => void; +}; + +export type MonthViewProps = { + days: DayShiftsType[]; + onSelectDay: (date: string) => void; +}; + +export type ShiftCellProps = { + slot: ShiftSlotType; + interactive: boolean; + compact?: boolean; + expanded?: boolean; + onToggle?: VoidFunction; + onClick?: VoidFunction; +}; diff --git a/src/features/ministry/public_witnessing/shifts_card/month_view.tsx b/src/features/ministry/public_witnessing/shifts_card/month_view.tsx new file mode 100644 index 00000000000..6e99daeeb61 --- /dev/null +++ b/src/features/ministry/public_witnessing/shifts_card/month_view.tsx @@ -0,0 +1,165 @@ +import { Box } from '@mui/material'; +import { useAtomValue } from 'jotai'; +import { useAppTranslation, useBreakpoints } from '@hooks/index'; +import { dayNamesState } from '@states/app'; +import { dateFormatFriendly } from '@utils/date'; +import MonthCalendar from '@components/month_calendar'; +import { MonthCalendarDay } from '@components/month_calendar/index.types'; +import Typography from '@components/typography'; +import ShiftsEmpty from './shifts_empty'; +import Badge from '@components/badge'; +import { DayShiftsType, MonthViewProps } from './index.types'; + +const countShifts = (day: DayShiftsType) => ({ + available: day.slots.filter( + (slot) => slot.status === 'available' || slot.status === 'partner_needed' + ).length, + occupied: day.slots.filter( + (slot) => + slot.status === 'full' || + (slot.status === 'past' && slot.publishers.length > 0) + ).length, + isOver: day.slots.every((slot) => slot.status === 'past'), +}); + +const countColor = (isOver: boolean, available: number) => { + if (isOver) return 'var(--grey-350)'; + return available > 0 ? 'var(--accent-main)' : 'var(--red-main)'; +}; + +const MonthView = ({ days, onSelectDay }: MonthViewProps) => { + const { t } = useAppTranslation(); + const { tabletUp } = useBreakpoints(); + + const dayNames = useAtomValue(dayNamesState); + + const hasShifts = days.some((day) => day.slots.length > 0); + + const weeks = Array.from({ length: Math.ceil(days.length / 7) }, (_, week) => + days.slice(week * 7, week * 7 + 7) + ); + + const byDate = new Map(days.map((day) => [day.date, day])); + + const calendarWeeks = weeks.map((week) => + week.map((day) => ({ + date: day.dateObj, + dateStr: day.date, + dayNumber: day.dateObj.getDate(), + inMonth: day.inPeriod, + isToday: day.isToday, + isWeekend: day.dateObj.getDay() === 0 || day.dateObj.getDay() === 6, + })) + ); + + const weekdayLabels = (weeks.at(0) ?? []).map( + (day) => dayNames[day.dateObj.getDay()] + ); + + const renderDay = (calendarDay: MonthCalendarDay) => { + const day = byDate.get(calendarDay.dateStr); + if (!day || day.slots.length === 0) return null; + + const { available, occupied, isOver } = countShifts(day); + + if (isOver && occupied === 0) return null; + + if (!tabletUp) { + const color = countColor(isOver, available); + + return ( + + + {isOver ? occupied : available} + + + ); + } + + return ( + + + {!isOver && ( + 0 ? 'accent' : 'red'} + text={t('tr_available', { number: available })} + fullWidth + centerContent + /> + )} + + ); + }; + + const dayAriaLabel = (calendarDay: MonthCalendarDay) => { + const day = byDate.get(calendarDay.dateStr); + if (!day || day.slots.length === 0) return calendarDay.dateStr; + + const { available, occupied, isOver } = countShifts(day); + + return [ + dateFormatFriendly(day.date), + (!isOver || occupied > 0) && t('tr_occupied', { number: occupied }), + !isOver && t('tr_available', { number: available }), + ] + .filter(Boolean) + .join(', '); + }; + + return ( + + {!hasShifts && } + + + (byDate.get(calendarDay.dateStr)?.slots.length ?? 0) > 0 + } + dayAriaLabel={dayAriaLabel} + onSelectDay={(calendarDay) => onSelectDay(calendarDay.dateStr)} + /> + + ); +}; + +export default MonthView; diff --git a/src/features/ministry/public_witnessing/shifts_card/shift_cell.tsx b/src/features/ministry/public_witnessing/shifts_card/shift_cell.tsx new file mode 100644 index 00000000000..e69629490f3 --- /dev/null +++ b/src/features/ministry/public_witnessing/shifts_card/shift_cell.tsx @@ -0,0 +1,239 @@ +import { KeyboardEvent, MouseEvent } from 'react'; +import { Box, Collapse } from '@mui/material'; +import { useAppTranslation } from '@hooks/index'; +import { BadgeColor } from '@definition/app'; +import { IconExpand, IconPersonSearch } from '@components/icons'; +import Badge from '@components/badge'; +import IconButton from '@components/icon_button'; +import Tooltip from '@components/tooltip'; +import Typography from '@components/typography'; +import { ShiftCellProps, ShiftSlotStatus, ShiftSlotType } from './index.types'; + +const cellStyles: Record = { + available: { + backgroundColor: 'var(--white)', + border: '1px solid var(--accent-main)', + }, + partner_needed: { + backgroundColor: 'var(--orange-secondary)', + border: '1px solid var(--orange-main)', + }, + full: { + backgroundColor: 'var(--white)', + border: '1px dashed var(--accent-300)', + }, + past: { + backgroundColor: 'var(--grey-100)', + border: '1px solid var(--grey-200)', + }, +}; + +const hoverStyles: Record = { + available: { backgroundColor: 'var(--accent-100)' }, + partner_needed: { borderColor: 'var(--orange-dark)' }, + full: { backgroundColor: 'var(--accent-100)' }, + past: {}, +}; + +const badgeColors: Record = { + available: 'accent', + partner_needed: 'orange', + full: 'grey', + past: 'grey', +}; + +const textColors: Record = { + available: 'var(--accent-dark)', + partner_needed: 'var(--orange-dark)', + full: 'var(--accent-400)', + past: 'var(--grey-350)', +}; + +type CellContentProps = { + slot: ShiftSlotType; + color: string; + statusLabel: string; +}; + +const PublisherNames = ({ + slot, + color, + compact, +}: Omit & { compact?: boolean }) => ( + + {slot.publishers.join(', ')} + +); + +const CompactContent = ({ + slot, + color, + statusLabel, + expanded, +}: CellContentProps & { expanded?: boolean }) => ( + + + + + {slot.status === 'partner_needed' && ( + + + + {statusLabel} + + + )} + + +); + +const FullContent = ({ slot, color, statusLabel }: CellContentProps) => ( + <> + {slot.publishers.length > 0 && } + + {(slot.status === 'available' || slot.status === 'partner_needed') && ( + : undefined + } + sx={{ marginLeft: 'auto' }} + /> + )} + +); + +const ShiftCell = ({ + slot, + interactive, + compact, + expanded, + onToggle, + onClick, +}: ShiftCellProps) => { + const { t } = useAppTranslation(); + + const statusLabels: Record = { + available: t('tr_shiftAvailable'), + partner_needed: t('tr_partnerNeeded'), + full: t('tr_shiftOccupied'), + past: '', + }; + + const color = textColors[slot.status]; + const statusLabel = statusLabels[slot.status]; + const times = `${slot.start_time} - ${slot.end_time}`; + const expandable = Boolean(compact && slot.publishers.length); + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== 'Enter' && event.key !== ' ') return; + event.preventDefault(); + onClick?.(); + }; + + const handleToggle = (event: MouseEvent) => { + event.stopPropagation(); + onToggle?.(); + }; + + // Keeps Enter and Space on the toggle from also opening the shift. + const handleTogglekeyDown = (event: KeyboardEvent) => { + if (event.key !== 'Enter' && event.key !== ' ') return; + event.stopPropagation(); + }; + + const interactiveProps = interactive + ? { role: 'button', tabIndex: 0, onClick, onKeyDown: handleKeyDown } + : {}; + + const cell = ( + + + + {times} + + + {expandable && ( + + + + )} + + + {compact ? ( + expandable && ( + + ) + ) : ( + + )} + + ); + + if (!compact || !statusLabel) return cell; + + return ( + + {cell} + + ); +}; + +export default ShiftCell; diff --git a/src/features/ministry/public_witnessing/shifts_card/shifts_empty.tsx b/src/features/ministry/public_witnessing/shifts_card/shifts_empty.tsx new file mode 100644 index 00000000000..d33e36642a7 --- /dev/null +++ b/src/features/ministry/public_witnessing/shifts_card/shifts_empty.tsx @@ -0,0 +1,14 @@ +import { Box } from '@mui/material'; +import { IconInfo } from '@components/icons'; +import Typography from '@components/typography'; + +const ShiftsEmpty = ({ message }: { message: string }) => ( + + + + {message} + + +); + +export default ShiftsEmpty; diff --git a/src/features/ministry/public_witnessing/shifts_card/useShiftsCard.tsx b/src/features/ministry/public_witnessing/shifts_card/useShiftsCard.tsx new file mode 100644 index 00000000000..470d19329ba --- /dev/null +++ b/src/features/ministry/public_witnessing/shifts_card/useShiftsCard.tsx @@ -0,0 +1,273 @@ +import { useCallback, useMemo } from 'react'; +import { useAtom, useAtomValue } from 'jotai'; +import { + publicWitnessingArrangementsState, + publicWitnessingSelectedDateState, + publicWitnessingViewState, +} from '@states/public_witnessing'; +import { firstDayWeekState, userLocalUIDState } from '@states/settings'; +import { PublicWitnessingArrangementType } from '@definition/public_witnessing'; +import { + addDays, + formatDate, + formatDateShortMonth, + getWeekStartDate, +} from '@utils/date'; +import { + generateDayNames, + generateMonthNames, + getTranslation, +} from '@services/i18n/translation'; +import { + DayShiftsType, + ShiftSlotStatus, + ShiftSlotType, + ShiftsCardProps, +} from './index.types'; + +const parseDate = (date: string) => { + const [year, month, day] = date.split('/').map(Number); + return new Date(year, month - 1, day); +}; + +// getDay(): 0 = Sunday; schedule weekdays: 1 (Monday) – 7 (Sunday). +const getScheduleWeekday = (date: Date) => date.getDay() || 7; + +// A seeker keeps the slot open until the partners they asked for have joined; +// a booking made with a partner closes the slot entirely. +const isSeekingPartner = ( + records: PublicWitnessingArrangementType[], + publisherCount: number, + capacity: number +) => + records.some((record) => { + const { partner_needed, partner_count, publishers } = + record.arrangement_data; + if (!partner_needed) return false; + + const wanted = publishers.length + (partner_count ?? 1); + return publisherCount < Math.min(wanted, capacity); + }); + +const getSlotStatus = ( + isPast: boolean, + records: PublicWitnessingArrangementType[], + seekingPartner: boolean +): ShiftSlotStatus => { + if (isPast) return 'past'; + if (records.length === 0) return 'available'; + return seekingPartner ? 'partner_needed' : 'full'; +}; + +const useShiftsCard = ({ location }: ShiftsCardProps) => { + const [view, setView] = useAtom(publicWitnessingViewState); + const [selectedDate, setSelectedDate] = useAtom( + publicWitnessingSelectedDateState + ); + + const arrangements = useAtomValue(publicWitnessingArrangementsState); + const userUID = useAtomValue(userLocalUIDState); + const firstDayWeek = useAtomValue(firstDayWeekState); + + const today = formatDate(new Date(), 'yyyy/MM/dd'); + const currentTime = formatDate(new Date(), 'HH:mm'); + + const dateObj = useMemo(() => parseDate(selectedDate), [selectedDate]); + + const periodDates = useMemo(() => { + if (view === 'day') return [dateObj]; + + if (view === 'week') { + const start = getWeekStartDate(dateObj, firstDayWeek); + return Array.from({ length: 7 }, (_, index) => addDays(start, index)); + } + + const lastOfMonth = new Date( + dateObj.getFullYear(), + dateObj.getMonth() + 1, + 0 + ); + + const dates: Date[] = []; + let cursor = getWeekStartDate( + new Date(dateObj.getFullYear(), dateObj.getMonth(), 1), + firstDayWeek + ); + + while (cursor <= lastOfMonth || dates.length % 7 !== 0) { + dates.push(cursor); + cursor = addDays(cursor, 1); + } + + return dates; + }, [view, dateObj, firstDayWeek]); + + const arrangementsBySlot = useMemo(() => { + const index = new Map(); + + for (const record of arrangements) { + const { location_uid, date, start_time } = record.arrangement_data; + if (location_uid !== location.location_uid) continue; + + const key = `${date}|${start_time}`; + const slotRecords = index.get(key); + + if (slotRecords) slotRecords.push(record); + else index.set(key, [record]); + } + + return index; + }, [arrangements, location.location_uid]); + + // Shifts are never stored per date — a day's slots come from the location's + // weekday schedule, merged with the arrangements booked for that date. + const buildSlots = useCallback( + (date: Date, dateKey: string): ShiftSlotType[] => { + const daySchedule = location.location_data.schedule.find( + (day) => day.weekday === getScheduleWeekday(date) + ); + if (!daySchedule) return []; + + const capacity = location.location_data.max_publishers; + + return daySchedule.shifts.map((shift) => { + const records = + arrangementsBySlot.get(`${dateKey}|${shift.start_time}`) ?? []; + + const publishers = records.flatMap((record) => + record.arrangement_data.publishers.map((publisher) => publisher.name) + ); + + const isPast = + dateKey < today || + (dateKey === today && shift.end_time <= currentTime); + + return { + date: dateKey, + start_time: shift.start_time, + end_time: shift.end_time, + status: getSlotStatus( + isPast, + records, + isSeekingPartner(records, publishers.length, capacity) + ), + publishers, + arrangements: records, + myArrangement: records.find( + (record) => record.arrangement_data.created_by === userUID + ), + }; + }); + }, + [location, arrangementsBySlot, today, currentTime, userUID] + ); + + const days = useMemo(() => { + return periodDates.map((date) => { + const dateKey = formatDate(date, 'yyyy/MM/dd'); + + return { + date: dateKey, + dateObj: date, + isToday: dateKey === today, + inPeriod: view !== 'month' || date.getMonth() === dateObj.getMonth(), + slots: buildSlots(date, dateKey), + }; + }); + }, [periodDates, buildSlots, today, view, dateObj]); + + const label = useMemo(() => { + if (view === 'month') { + return getTranslation({ + key: 'tr_monthYear', + params: { + month: generateMonthNames()[dateObj.getMonth()], + year: dateObj.getFullYear(), + }, + }); + } + + if (view === 'week') { + const start = getWeekStartDate(dateObj, firstDayWeek); + + return getTranslation({ + key: 'tr_dateRangeNoYear', + params: { + startDate: formatDateShortMonth(start), + endDate: formatDateShortMonth(addDays(start, 6)), + }, + }); + } + + const dayName = generateDayNames()[dateObj.getDay()]; + const shortDate = getTranslation({ + key: 'tr_longDateNoYearLocale', + params: { + month: generateMonthNames()[dateObj.getMonth()], + date: dateObj.getDate(), + }, + }); + + return `${dayName}, ${shortDate}`; + }, [view, dateObj, firstDayWeek]); + + const isCurrentPeriod = useMemo(() => { + const todayObj = parseDate(today); + + if (view === 'day') return selectedDate === today; + + if (view === 'week') { + return ( + getWeekStartDate(dateObj, firstDayWeek).getTime() === + getWeekStartDate(todayObj, firstDayWeek).getTime() + ); + } + + return ( + dateObj.getFullYear() === todayObj.getFullYear() && + dateObj.getMonth() === todayObj.getMonth() + ); + }, [view, selectedDate, today, dateObj, firstDayWeek]); + + const shiftPeriod = (direction: 1 | -1) => { + if (view === 'month') { + const next = new Date( + dateObj.getFullYear(), + dateObj.getMonth() + direction, + 1 + ); + setSelectedDate(formatDate(next, 'yyyy/MM/dd')); + return; + } + + const step = view === 'week' ? 7 : 1; + setSelectedDate( + formatDate(addDays(dateObj, direction * step), 'yyyy/MM/dd') + ); + }; + + const handlePrevious = () => shiftPeriod(-1); + + const handleNext = () => shiftPeriod(1); + + const goToToday = () => setSelectedDate(today); + + const handleSelectDay = (date: string) => { + setSelectedDate(date); + setView('day'); + }; + + return { + view, + label, + isCurrentPeriod, + days, + handlePrevious, + handleNext, + goToToday, + handleViewChange: setView, + handleSelectDay, + }; +}; + +export default useShiftsCard; diff --git a/src/features/ministry/public_witnessing/shifts_card/week_view.tsx b/src/features/ministry/public_witnessing/shifts_card/week_view.tsx new file mode 100644 index 00000000000..5c8bde5cce4 --- /dev/null +++ b/src/features/ministry/public_witnessing/shifts_card/week_view.tsx @@ -0,0 +1,147 @@ +import { useState } from 'react'; +import { Box, Stack } from '@mui/material'; +import { useAtomValue } from 'jotai'; +import { useAppTranslation, useBreakpoints } from '@hooks/index'; +import { dayNamesState } from '@states/app'; +import { formatDateShortMonth } from '@utils/date'; +import Typography from '@components/typography'; +import ShiftCell from './shift_cell'; +import ShiftsEmpty from './shifts_empty'; +import { DayShiftsType, ShiftsViewProps } from './index.types'; + +const headerColors = (day: DayShiftsType, past: boolean) => { + if (day.isToday) + return { date: 'var(--accent-dark)', weekday: 'var(--grey-400)' }; + if (past) return { date: 'var(--grey-350)', weekday: 'var(--grey-350)' }; + return { date: 'var(--black)', weekday: 'var(--grey-400)' }; +}; + +const WeekView = ({ days, canInteract, onSelectSlot }: ShiftsViewProps) => { + const { t } = useAppTranslation(); + const { desktopUp } = useBreakpoints(); + + const dayNames = useAtomValue(dayNamesState); + + const [expanded, setExpanded] = useState([]); + + const toggleSlot = (key: string) => { + setExpanded((current) => + current.includes(key) + ? current.filter((item) => item !== key) + : [...current, key] + ); + }; + + const hasShifts = days.some((day) => day.slots.length > 0); + + const isPastDay = (day: DayShiftsType) => + day.slots.length > 0 && day.slots.every((slot) => slot.status === 'past'); + + const dayHeader = (day: DayShiftsType) => { + const colors = headerColors(day, isPastDay(day)); + + return ( + + + {formatDateShortMonth(day.dateObj)} + + + {dayNames[day.dateObj.getDay()]} + + + ); + }; + + if (!hasShifts) { + return ; + } + + if (!desktopUp) { + return ( + + {days + .filter((day) => day.slots.length > 0) + .map((day) => ( + + {dayHeader(day)} + {day.slots.map((slot) => ( + onSelectSlot(slot)} + /> + ))} + + ))} + + ); + } + + const rowCount = Math.max(...days.map((day) => day.slots.length)); + + return ( + + {/* Column separators, drawn in the middle of the grid gap so they span + the header and every shift row. The end line is spelled out: with + implicit rows, `1 / -1` would only cover the header. */} + {days.slice(1).map((day, index) => ( + + ))} + + {days.map((day, column) => ( + + {dayHeader(day)} + + ))} + + {days.map((day, column) => + day.slots.map((slot, row) => { + const key = `${day.date}-${slot.start_time}`; + + return ( + + toggleSlot(key)} + onClick={() => onSelectSlot(slot)} + /> + + ); + }) + )} + + ); +}; + +export default WeekView; diff --git a/src/features/ministry/public_witnessing/usePermissions.tsx b/src/features/ministry/public_witnessing/usePermissions.tsx new file mode 100644 index 00000000000..8315a7277bf --- /dev/null +++ b/src/features/ministry/public_witnessing/usePermissions.tsx @@ -0,0 +1,27 @@ +import { useAtomValue } from 'jotai'; +import { useCurrentUser } from '@hooks/index'; +import { settingsState, userLocalUIDState } from '@states/settings'; + +/** + * Editing rights for the Public Witnessing feature. + * + * - Everyone can view locations and shifts. + * - App admins and the service overseer manage locations and can arrange + * shifts for others. (A dedicated public witnessing role may be added + * later — extend this hook when it lands.) + */ +const usePublicWitnessingPermissions = () => { + const { isAdmin } = useCurrentUser(); + const userUID = useAtomValue(userLocalUIDState); + const settings = useAtomValue(settingsState); + + const serviceOverseerUid = + settings.cong_settings.responsabilities?.service ?? ''; + const isServiceOverseer = Boolean(userUID) && userUID === serviceOverseerUid; + + const canManageLocations = isAdmin || isServiceOverseer; + + return { canManageLocations }; +}; + +export default usePublicWitnessingPermissions; diff --git a/src/indexedDb/appDb.ts b/src/indexedDb/appDb.ts index 038ea52ea4e..3dff2546cef 100644 --- a/src/indexedDb/appDb.ts +++ b/src/indexedDb/appDb.ts @@ -56,6 +56,14 @@ import { } from './tables/upcoming_events'; import { publicTalkSchema, PublicTalkTable } from './tables/public_talk'; import { songSchema, SongTable } from './tables/songs'; +import { + publicWitnessingLocationsSchema, + PublicWitnessingLocationsTable, +} from './tables/public_witnessing_locations'; +import { + publicWitnessingArrangementsSchema, + PublicWitnessingArrangementsTable, +} from './tables/public_witnessing_arrangements'; type DexieTables = PersonsTable & SettingsTable & @@ -77,7 +85,9 @@ type DexieTables = PersonsTable & MetadataTable & DelegatedFieldServiceReportsTable & PublicTalkTable & - SongTable; + SongTable & + PublicWitnessingLocationsTable & + PublicWitnessingArrangementsTable; type Dexie = BaseDexie & T; @@ -182,6 +192,18 @@ appDb.version(12).stores({ ...upcomingEventsSchema, }); +appDb.version(13).stores({ + ...schema, + ...metadataSchema, + ...delegatedFieldServiceReportsSchema, + ...weekTypeSchema, + ...publicTalkSchema, + ...songSchema, + ...upcomingEventsSchema, + ...publicWitnessingLocationsSchema, + ...publicWitnessingArrangementsSchema, +}); + appDb.on('populate', function () { appDb.app_settings.add(settingSchema); }); diff --git a/src/indexedDb/tables/public_witnessing_arrangements.ts b/src/indexedDb/tables/public_witnessing_arrangements.ts new file mode 100644 index 00000000000..aef19c36b9f --- /dev/null +++ b/src/indexedDb/tables/public_witnessing_arrangements.ts @@ -0,0 +1,10 @@ +import { PublicWitnessingArrangementType } from '@definition/public_witnessing'; +import { Table } from 'dexie'; + +export type PublicWitnessingArrangementsTable = { + public_witnessing_arrangements: Table; +}; + +export const publicWitnessingArrangementsSchema = { + public_witnessing_arrangements: '&arrangement_uid, arrangement_data', +}; diff --git a/src/indexedDb/tables/public_witnessing_locations.ts b/src/indexedDb/tables/public_witnessing_locations.ts new file mode 100644 index 00000000000..f0e3213f1a5 --- /dev/null +++ b/src/indexedDb/tables/public_witnessing_locations.ts @@ -0,0 +1,10 @@ +import { PublicWitnessingLocationType } from '@definition/public_witnessing'; +import { Table } from 'dexie'; + +export type PublicWitnessingLocationsTable = { + public_witnessing_locations: Table; +}; + +export const publicWitnessingLocationsSchema = { + public_witnessing_locations: '&location_uid, location_data', +}; diff --git a/src/layouts/bottom_menu/index.tsx b/src/layouts/bottom_menu/index.tsx index 8ec309f685f..37092a3c4dc 100644 --- a/src/layouts/bottom_menu/index.tsx +++ b/src/layouts/bottom_menu/index.tsx @@ -38,6 +38,7 @@ const BottomMenu = (props: BottomMenuProps) => { padding: '6px', display: 'flex', flexDirection: 'row', + alignItems: 'center', justifyContent: 'center', gap: '4px', }} diff --git a/src/layouts/navbar/index.tsx b/src/layouts/navbar/index.tsx index 6ac44b41407..1480c5d5866 100644 --- a/src/layouts/navbar/index.tsx +++ b/src/layouts/navbar/index.tsx @@ -506,9 +506,16 @@ const NavBar = ({ isSupported }: NavBarType) => { { whiteSpace: 'nowrap', textOverflow: 'ellipsis', overflow: 'hidden', + maxWidth: '100%', }} > {navBarOptions.title} @@ -529,6 +537,7 @@ const NavBar = ({ isSupported }: NavBarType) => { whiteSpace: 'nowrap', textOverflow: 'ellipsis', overflow: 'hidden', + maxWidth: '100%', }} > {navBarOptions.secondaryTitle} @@ -550,7 +559,16 @@ const NavBar = ({ isSupported }: NavBarType) => { ) : ( !tablet688Up && ( - + // Balances the back button so the title reads centred, + // but gives up its width before the title truncates. + ) )} diff --git a/src/locales/en/general.json b/src/locales/en/general.json index 34470d3a912..205a610ccaf 100644 --- a/src/locales/en/general.json +++ b/src/locales/en/general.json @@ -96,6 +96,8 @@ "tr_noPersonsFoundDesc": "No persons match your search parameters. Please try adjusting the category or date.", "tr_categories": "Categories", "tr_description": "Description", + "tr_locations": "Locations", + "tr_schedule": "Schedule", "tr_day": "Day", "tr_week": "Week", "tr_noThanks": "No, thanks", diff --git a/src/locales/en/ministry.json b/src/locales/en/ministry.json index ad93143ec5e..1a26adcb667 100644 --- a/src/locales/en/ministry.json +++ b/src/locales/en/ministry.json @@ -47,7 +47,7 @@ "tr_comments": "Comments", "tr_PWLocations": "Public witnessing locations", "tr_PW": "Public witnessing", - "tr_maxPublisherShift": "Max. publishers per shift: {{ maxPublisherCount }}", + "tr_maxPublisherShift": "Max. publishers: {{ maxPublisherCount }}", "tr_partnerNeeded": "Partner needed", "tr_partnerNeededDesc": "Other publishers will see this and be able to arrange with you", "tr_locationName": "Location name", @@ -56,8 +56,20 @@ "tr_PWShifts": "Public witnessing shifts", "tr_selectDays": "Select days", "tr_everyDay": "Every day", + "tr_everyDayDesc": "The same shifts repeat on every day of the week", "tr_daysShifts": "Shifts: {{ dayName }}", "tr_addShift": "Add shift", + "tr_PWStoredAt": "Stored at: {{ name }}", + "tr_shiftAvailable": "Available", + "tr_shiftOccupied": "Occupied", + "tr_shiftEndsBeforeStart": "A shift must end after it starts", + "tr_noShiftsForThisDay": "There are no shifts for this day", + "tr_noShiftsScheduled": "There are no shifts scheduled", + "tr_PWScheduleSelectDay": "Select a day to plan a shift", + "tr_PWLocationsEmpty": "There are no public witnessing locations yet. Add the first one to get started.", + "tr_PWLocationAdd": "Add location", + "tr_PWLocationEdit": "Edit location", + "tr_PWLocationsReorder": "Reorder locations", "tr_confirmArrangement": "Confirm the arrangement", "tr_havePartner": "I have a partner", "tr_havePartnerDesc": "Select your partner from the list or enter their name", @@ -89,7 +101,7 @@ "tr_rejected": "Rejected", "tr_alreadySubmittedWarning": "This month’s report has already been submitted to the Branch Office. Only late reports can be added, unless you undo the submission.", "tr_publishersCountReport": "Publishers: {{ publishersCount }}", - "tr_cartStorage": "Cart storage location", + "tr_cartStorage": "Stored at", "tr_individualBibleStudies": "Individual Bible studies", "tr_theocraticAssignments": "Theocratic assignments", "tr_submitReportDesc": "Submit the report for this month to your elders?", diff --git a/src/pages/dashboard/ministry/index.tsx b/src/pages/dashboard/ministry/index.tsx index 0aed65cae6b..35934bca8c4 100644 --- a/src/pages/dashboard/ministry/index.tsx +++ b/src/pages/dashboard/ministry/index.tsx @@ -1,5 +1,6 @@ import { ListItem } from '@mui/material'; import { + IconCart, IconMinistryReport, IconPioneerForm, IconStatsYear, @@ -40,6 +41,13 @@ const MinistryCard = () => { path="/service-year" /> + + } + primaryText={t('tr_PW')} + path="/public-witnessing" + /> + {enable_AP_application && ( diff --git a/src/pages/ministry/public_witnessing/index.tsx b/src/pages/ministry/public_witnessing/index.tsx new file mode 100644 index 00000000000..1c28a44c2f8 --- /dev/null +++ b/src/pages/ministry/public_witnessing/index.tsx @@ -0,0 +1,102 @@ +import { Stack } from '@mui/material'; +import { useAppTranslation } from '@hooks/index'; +import { IconAdd, IconEdit, IconReorder } from '@components/icons'; +import PageTitle from '@components/page_title'; +import NavBarButton from '@components/nav_bar_button'; +import PublicWitnessingContainer from '@features/ministry/public_witnessing'; +import LocationForm from '@features/ministry/public_witnessing/location_form'; +import LocationDelete from '@features/ministry/public_witnessing/location_delete'; +import LocationsReorder from '@features/ministry/public_witnessing/locations_reorder'; +import usePublicWitnessing from './usePublicWitnessing'; + +const PublicWitnessing = () => { + const { t } = useAppTranslation(); + + const { + canManageLocations, + canAddLocation, + canEditLocation, + canReorderLocations, + isSubpage, + selectedLocation, + formOpen, + formLocation, + reorderOpen, + deleteOpen, + handleStartCreate, + handleStartEdit, + handleCloseForm, + handleStartDelete, + handleCloseDelete, + handleOpenReorder, + handleCloseReorder, + } = usePublicWitnessing(); + + // An empty set still renders the mobile action bar, so the buttons are only + // built when at least one of them applies. + const hasActions = + canManageLocations && + (canReorderLocations || canEditLocation || canAddLocation); + + const actionButtons = hasActions ? ( + <> + {canReorderLocations && ( + } + onClick={handleOpenReorder} + /> + )} + {canEditLocation && ( + } + onClick={handleStartEdit} + /> + )} + {canAddLocation && ( + } + onClick={handleStartCreate} + /> + )} + + ) : undefined; + + return ( + + + + {formOpen && ( + + )} + {deleteOpen && selectedLocation && ( + + )} + {reorderOpen && ( + + )} + + + + ); +}; + +export default PublicWitnessing; diff --git a/src/pages/ministry/public_witnessing/usePublicWitnessing.tsx b/src/pages/ministry/public_witnessing/usePublicWitnessing.tsx new file mode 100644 index 00000000000..95e1ca17dff --- /dev/null +++ b/src/pages/ministry/public_witnessing/usePublicWitnessing.tsx @@ -0,0 +1,81 @@ +import { useState } from 'react'; +import { useParams } from 'react-router'; +import { useAtomValue } from 'jotai'; +import { PublicWitnessingLocationType } from '@definition/public_witnessing'; +import { useBreakpoints } from '@hooks/index'; +import { + publicWitnessingLocationsState, + publicWitnessingSelectedLocationRecordState, +} from '@states/public_witnessing'; +import usePublicWitnessingPermissions from '@features/ministry/public_witnessing/usePermissions'; + +const usePublicWitnessing = () => { + const { canManageLocations } = usePublicWitnessingPermissions(); + const { laptopUp } = useBreakpoints(); + + const { locationId } = useParams(); + + const locations = useAtomValue(publicWitnessingLocationsState); + const selectedLocation = useAtomValue( + publicWitnessingSelectedLocationRecordState + ); + + // On mobile a location opens as its own subpage, so the app navbar shows + // it as the current page with the feature name underneath. + const isSubpage = !laptopUp && Boolean(locationId); + + const [formOpen, setFormOpen] = useState(false); + // null while creating a new location + const [formLocation, setFormLocation] = + useState(null); + const [reorderOpen, setReorderOpen] = useState(false); + const [deleteOpen, setDeleteOpen] = useState(false); + + const handleStartCreate = () => { + setFormLocation(null); + setFormOpen(true); + }; + + const handleStartEdit = () => { + if (!selectedLocation) return; + setFormLocation(selectedLocation); + setFormOpen(true); + }; + + const handleCloseForm = () => setFormOpen(false); + + const handleStartDelete = () => { + setFormOpen(false); + setDeleteOpen(true); + }; + + const handleCloseDelete = () => setDeleteOpen(false); + + const handleOpenReorder = () => setReorderOpen(true); + const handleCloseReorder = () => setReorderOpen(false); + + return { + canManageLocations, + hasLocations: locations.length > 0, + isSubpage, + selectedLocation, + // Mobile opens a location as its own page: editing belongs there, and + // adding and reordering to the list they act on. + canEditLocation: Boolean(selectedLocation) && (laptopUp || isSubpage), + canAddLocation: !isSubpage, + canReorderLocations: locations.length > 0 && (laptopUp || !isSubpage), + formOpen, + formLocation, + reorderOpen, + deleteOpen, + handleStartCreate, + handleStartEdit, + handleCloseForm, + handleStartDelete, + handleCloseDelete, + handleOpenReorder, + handleCloseReorder, + }; +}; + +export default usePublicWitnessing; diff --git a/src/services/dexie/metadata.ts b/src/services/dexie/metadata.ts index a14711d3027..4788b0a4b66 100644 --- a/src/services/dexie/metadata.ts +++ b/src/services/dexie/metadata.ts @@ -1,35 +1,49 @@ import { MetadataRecordType } from '@definition/metadata'; import appDb from '@db/appDb'; +const metadataDefault = (): MetadataRecordType['metadata'] => ({ + cong_settings: { version: '', send_local: true }, + user_settings: { version: '', send_local: true }, + persons: { version: '', send_local: true }, + sources: { version: '', send_local: true }, + schedules: { version: '', send_local: true }, + field_service_groups: { version: '', send_local: true }, + visiting_speakers: { version: '', send_local: true }, + cong_field_service_reports: { version: '', send_local: true }, + branch_field_service_reports: { version: '', send_local: true }, + branch_cong_analysis: { version: '', send_local: true }, + speakers_congregations: { version: '', send_local: true }, + public_sources: { version: '', send_local: true }, + public_schedules: { version: '', send_local: true }, + meeting_attendance: { version: '', send_local: true }, + user_bible_studies: { version: '', send_local: true }, + user_field_service_reports: { version: '', send_local: true }, + upcoming_events: { version: '', send_local: true }, + delegated_field_service_reports: { version: '', send_local: true }, + public_witnessing_locations: { version: '', send_local: true }, + public_witnessing_arrangements: { version: '', send_local: true }, +}); + export const dbMetadataDefault = async () => { try { const metadata = await appDb.metadata.get(1); if (!metadata) { - await appDb.metadata.put({ - id: 1, - metadata: { - cong_settings: { version: '', send_local: true }, - user_settings: { version: '', send_local: true }, - persons: { version: '', send_local: true }, - sources: { version: '', send_local: true }, - schedules: { version: '', send_local: true }, - field_service_groups: { version: '', send_local: true }, - visiting_speakers: { version: '', send_local: true }, - cong_field_service_reports: { version: '', send_local: true }, - branch_field_service_reports: { version: '', send_local: true }, - branch_cong_analysis: { version: '', send_local: true }, - speakers_congregations: { version: '', send_local: true }, - public_sources: { version: '', send_local: true }, - public_schedules: { version: '', send_local: true }, - meeting_attendance: { version: '', send_local: true }, - user_bible_studies: { version: '', send_local: true }, - user_field_service_reports: { version: '', send_local: true }, - upcoming_events: { version: '', send_local: true }, - delegated_field_service_reports: { version: '', send_local: true }, - }, - }); + await appDb.metadata.put({ id: 1, metadata: metadataDefault() }); + return; } + + // Congregations that installed the app before a table existed have no + // entry for it — fill those gaps without touching the stored versions. + const missing = Object.entries(metadataDefault()).filter( + ([key]) => !metadata.metadata[key] + ); + + if (missing.length === 0) return; + + await appDb.metadata.update(metadata.id, { + metadata: { ...metadata.metadata, ...Object.fromEntries(missing) }, + }); } catch (error) { console.error(error); } @@ -52,29 +66,7 @@ export const dbResetExportState = async () => { export const dbMetadataReset = async () => { try { - await appDb.metadata.put({ - id: 1, - metadata: { - cong_settings: { version: '', send_local: true }, - user_settings: { version: '', send_local: true }, - persons: { version: '', send_local: true }, - sources: { version: '', send_local: true }, - schedules: { version: '', send_local: true }, - field_service_groups: { version: '', send_local: true }, - visiting_speakers: { version: '', send_local: true }, - cong_field_service_reports: { version: '', send_local: true }, - branch_field_service_reports: { version: '', send_local: true }, - branch_cong_analysis: { version: '', send_local: true }, - speakers_congregations: { version: '', send_local: true }, - public_sources: { version: '', send_local: true }, - public_schedules: { version: '', send_local: true }, - meeting_attendance: { version: '', send_local: true }, - user_bible_studies: { version: '', send_local: true }, - user_field_service_reports: { version: '', send_local: true }, - delegated_field_service_reports: { version: '', send_local: true }, - upcoming_events: { version: '', send_local: true }, - }, - }); + await appDb.metadata.put({ id: 1, metadata: metadataDefault() }); } catch (error) { console.error(error); } diff --git a/src/services/dexie/public_witnessing_arrangements.ts b/src/services/dexie/public_witnessing_arrangements.ts new file mode 100644 index 00000000000..d90921aac74 --- /dev/null +++ b/src/services/dexie/public_witnessing_arrangements.ts @@ -0,0 +1,74 @@ +import appDb from '@db/appDb'; +import { PublicWitnessingArrangementType } from '@definition/public_witnessing'; + +const dbUpdatePublicWitnessingArrangementsMetadata = async () => { + const metadata = await appDb.metadata.get(1); + if (!metadata) return; + metadata.metadata.public_witnessing_arrangements = { + ...metadata.metadata.public_witnessing_arrangements, + send_local: true, + }; + await appDb.metadata.put(metadata); +}; + +export const dbPublicWitnessingArrangementsSave = async ( + arrangement: PublicWitnessingArrangementType +) => { + await appDb.transaction( + 'rw', + appDb.public_witnessing_arrangements, + appDb.metadata, + async () => { + await appDb.public_witnessing_arrangements.put(arrangement); + await dbUpdatePublicWitnessingArrangementsMetadata(); + } + ); +}; + +const dbPublicWitnessingArrangementsBulkSave = async ( + arrangements: PublicWitnessingArrangementType[] +) => { + await appDb.transaction( + 'rw', + appDb.public_witnessing_arrangements, + appDb.metadata, + async () => { + await appDb.public_witnessing_arrangements.bulkPut(arrangements); + await dbUpdatePublicWitnessingArrangementsMetadata(); + } + ); +}; + +const softDelete = (record: PublicWitnessingArrangementType) => { + record.arrangement_data._deleted = true; + record.arrangement_data.updatedAt = new Date().toISOString(); + + return record; +}; + +/** + * Removes the bookings a location still holds — its shifts disappear with it, + * so the arrangements could no longer be reached or cancelled. + */ +export const dbPublicWitnessingArrangementsDeleteByLocation = async ( + location_uid: string +) => { + const records = await appDb.public_witnessing_arrangements + .filter( + (record) => + record.arrangement_data.location_uid === location_uid && + !record.arrangement_data._deleted + ) + .toArray(); + + if (records.length === 0) return; + + await dbPublicWitnessingArrangementsBulkSave(records.map(softDelete)); +}; + +export const dbPublicWitnessingArrangementsClear = async () => { + const records = await appDb.public_witnessing_arrangements.toArray(); + if (records.length === 0) return; + + await dbPublicWitnessingArrangementsBulkSave(records.map(softDelete)); +}; diff --git a/src/services/dexie/public_witnessing_locations.ts b/src/services/dexie/public_witnessing_locations.ts new file mode 100644 index 00000000000..46b575d14f2 --- /dev/null +++ b/src/services/dexie/public_witnessing_locations.ts @@ -0,0 +1,52 @@ +import appDb from '@db/appDb'; +import { PublicWitnessingLocationType } from '@definition/public_witnessing'; + +const dbUpdatePublicWitnessingLocationsMetadata = async () => { + const metadata = await appDb.metadata.get(1); + if (!metadata) return; + metadata.metadata.public_witnessing_locations = { + ...metadata.metadata.public_witnessing_locations, + send_local: true, + }; + await appDb.metadata.put(metadata); +}; + +export const dbPublicWitnessingLocationsSave = async ( + location: PublicWitnessingLocationType +) => { + await appDb.transaction( + 'rw', + appDb.public_witnessing_locations, + appDb.metadata, + async () => { + await appDb.public_witnessing_locations.put(location); + await dbUpdatePublicWitnessingLocationsMetadata(); + } + ); +}; + +export const dbPublicWitnessingLocationsBulkSave = async ( + locations: PublicWitnessingLocationType[] +) => { + await appDb.transaction( + 'rw', + appDb.public_witnessing_locations, + appDb.metadata, + async () => { + await appDb.public_witnessing_locations.bulkPut(locations); + await dbUpdatePublicWitnessingLocationsMetadata(); + } + ); +}; + +export const dbPublicWitnessingLocationsClear = async () => { + const records = await appDb.public_witnessing_locations.toArray(); + if (records.length === 0) return; + + for (const record of records) { + record.location_data._deleted = true; + record.location_data.updatedAt = new Date().toISOString(); + } + + await dbPublicWitnessingLocationsBulkSave(records); +}; diff --git a/src/states/public_witnessing.ts b/src/states/public_witnessing.ts new file mode 100644 index 00000000000..91be9e82ff3 --- /dev/null +++ b/src/states/public_witnessing.ts @@ -0,0 +1,53 @@ +import { atom } from 'jotai'; +import { + PublicWitnessingArrangementType, + PublicWitnessingLocationType, + PublicWitnessingViewType, +} from '@definition/public_witnessing'; +import { formatDate } from '@utils/date'; + +export const publicWitnessingLocationsDbState = atom< + PublicWitnessingLocationType[] +>([]); + +export const publicWitnessingArrangementsDbState = atom< + PublicWitnessingArrangementType[] +>([]); + +// uid of the location opened in the details view, or null before the first +// location is auto-selected. (Cast instead of atom(null): with +// strict mode off, a bare null matches jotai's read-only overload and the +// atom loses its setter.) +export const publicWitnessingSelectedLocationState = atom( + null as string | null +); + +// Day shown in the shifts card, "yyyy/MM/dd". In the week and month views it +// anchors the period the card displays. +export const publicWitnessingSelectedDateState = atom( + formatDate(new Date(), 'yyyy/MM/dd') +); + +// Period the shifts card lays out: a single day, a week, or a month. +export const publicWitnessingViewState = atom( + 'day' as PublicWitnessingViewType +); + +export const publicWitnessingLocationsState = atom((get) => { + const locations = get(publicWitnessingLocationsDbState); + return locations + .filter((record) => !record.location_data._deleted) + .sort((a, b) => a.location_data.sort_index - b.location_data.sort_index); +}); + +export const publicWitnessingArrangementsState = atom((get) => { + const arrangements = get(publicWitnessingArrangementsDbState); + return arrangements.filter((record) => !record.arrangement_data._deleted); +}); + +export const publicWitnessingSelectedLocationRecordState = atom((get) => { + const locations = get(publicWitnessingLocationsState); + const selected = get(publicWitnessingSelectedLocationState); + + return locations.find((record) => record.location_uid === selected) ?? null; +}); diff --git a/src/utils/date.ts b/src/utils/date.ts index f8eed596e14..53445fa935c 100644 --- a/src/utils/date.ts +++ b/src/utils/date.ts @@ -78,6 +78,22 @@ export const getWeekDate = (date: Date = new Date()) => { return monDay; }; +/** + * Start of the week the date belongs to, for a configurable first day of the + * week (0 = Sunday, 1 = Monday, 6 = Saturday — see FirstDayWeekOption). + * + * Unlike getWeekDate, which always returns the Monday of the meeting week and + * mutates the date it is given, this leaves its argument untouched. + */ +export const getWeekStartDate = (date: Date, firstDayWeek: number) => { + const weekStart = new Date(date); + weekStart.setDate( + weekStart.getDate() - ((weekStart.getDay() - firstDayWeek + 7) % 7) + ); + + return weekStart; +}; + export const getOldestWeekDate = () => { const weekDate = getWeekDate(); const validDate = weekDate.setMonth(weekDate.getMonth() - 12); diff --git a/src/wrapper/database_indexeddb/index.tsx b/src/wrapper/database_indexeddb/index.tsx index 76f36b6c3b1..a8c93c6a40a 100644 --- a/src/wrapper/database_indexeddb/index.tsx +++ b/src/wrapper/database_indexeddb/index.tsx @@ -27,6 +27,8 @@ const DatabaseWrapper = ({ children }: PropsWithChildren) => { loadUpcomingEvents, loadPublicTalks, loadSongs, + loadPublicWitnessingLocations, + loadPublicWitnessingArrangements, } = useIndexedDb(); useEffect(() => { @@ -51,6 +53,8 @@ const DatabaseWrapper = ({ children }: PropsWithChildren) => { loadDbNotifications(); loadDbDelegatedReports(); loadUpcomingEvents(); + loadPublicWitnessingLocations(); + loadPublicWitnessingArrangements(); }; refreshData(); @@ -75,6 +79,8 @@ const DatabaseWrapper = ({ children }: PropsWithChildren) => { loadUpcomingEvents, loadPublicTalks, loadSongs, + loadPublicWitnessingLocations, + loadPublicWitnessingArrangements, ]); return children; diff --git a/src/wrapper/database_indexeddb/useIndexedDb.tsx b/src/wrapper/database_indexeddb/useIndexedDb.tsx index f82df3908bf..8727b0b4614 100644 --- a/src/wrapper/database_indexeddb/useIndexedDb.tsx +++ b/src/wrapper/database_indexeddb/useIndexedDb.tsx @@ -23,6 +23,10 @@ import { delegatedFieldServiceReportsDbState } from '@states/delegated_field_ser import { upcomingEventsDbState } from '@states/upcoming_events'; import { publicTalksState } from '@states/public_talks'; import { songsState } from '@states/songs'; +import { + publicWitnessingArrangementsDbState, + publicWitnessingLocationsDbState, +} from '@states/public_witnessing'; const useIndexedDb = () => { const dbSettings = useLiveQuery(() => appDb.app_settings.toArray()); @@ -65,6 +69,12 @@ const useIndexedDb = () => { const dbUpcomingEvents = useLiveQuery(() => appDb.upcoming_events.toArray()); const dbPublicTalks = useLiveQuery(() => appDb.public_talks.toArray()); const dbSongs = useLiveQuery(() => appDb.songs.toArray()); + const dbPublicWitnessingLocations = useLiveQuery(() => + appDb.public_witnessing_locations.toArray() + ); + const dbPublicWitnessingArrangements = useLiveQuery(() => + appDb.public_witnessing_arrangements.toArray() + ); const setSettings = useSetAtom(settingsState); const setPersons = useSetAtom(personsState); @@ -88,6 +98,12 @@ const useIndexedDb = () => { const setUpcomingEvents = useSetAtom(upcomingEventsDbState); const setPublicTalks = useSetAtom(publicTalksState); const setSongs = useSetAtom(songsState); + const setPublicWitnessingLocations = useSetAtom( + publicWitnessingLocationsDbState + ); + const setPublicWitnessingArrangements = useSetAtom( + publicWitnessingArrangementsDbState + ); const loadSettings = useCallback(() => { if (dbSettings && dbSettings[0]) { @@ -209,6 +225,18 @@ const useIndexedDb = () => { } }, [dbSongs, setSongs]); + const loadPublicWitnessingLocations = useCallback(() => { + if (dbPublicWitnessingLocations) { + setPublicWitnessingLocations(dbPublicWitnessingLocations); + } + }, [dbPublicWitnessingLocations, setPublicWitnessingLocations]); + + const loadPublicWitnessingArrangements = useCallback(() => { + if (dbPublicWitnessingArrangements) { + setPublicWitnessingArrangements(dbPublicWitnessingArrangements); + } + }, [dbPublicWitnessingArrangements, setPublicWitnessingArrangements]); + return { loadSettings, loadPersons, @@ -230,6 +258,8 @@ const useIndexedDb = () => { loadUpcomingEvents, loadPublicTalks, loadSongs, + loadPublicWitnessingLocations, + loadPublicWitnessingArrangements, }; };