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 && (
+ }
+ onClick={() =>
+ setPartnerNames([...partnerNames, createPartnerName()])
+ }
+ sx={{ alignSelf: 'flex-start' }}
+ >
+ {t('tr_personAdd')}
+
+ )}
+
+ );
+
+ 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 (
+
+ );
+};
+
+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 (
+
+ );
+};
+
+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 (
+
+ );
+};
+
+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)}
+ />
+ ))}
+
+ }
+ onClick={onAddShift}
+ sx={{ alignSelf: 'flex-start' }}
+ >
+ {t('tr_addShift')}
+
+ >
+ );
+
+ 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 (
+
+ );
+};
+
+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,
};
};