Skip to content

Commit 41cd9af

Browse files
FranklinD10claude
andcommitted
feat(meetings): flag same-meeting assignment conflicts in red
Add a red warning when the same person is given two roles in the same meeting, e.g. hall overseer and Watchtower reader in one midweek meeting. The existing orange week-level warning is untouched; red simply takes precedence when both would apply. Meeting identity comes from the MM_/WM_ prefix already present on every ASSIGNMENT_PATH key, so no new state or data plumbing is needed. New helper schedulesPersonHasMeetingConflict reads the existing assignmentsHistoryState and is shared by the brother and student selectors. Scoped to the current dataView so a collision the user cannot see is never flagged. Prayers, circuit overseer fields and WM_Speaker_Outgoing are exempt: prayers are commonly doubled with another part (the app ships a linked prayer setting for exactly that), the CO legitimately holds both his own field and a talk during a visit, and an outgoing talk belongs to another congregation's meeting. Part of #3147 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TRZ5tRSmSDbCpEHAbnVij9
1 parent 9e9a4b8 commit 41cd9af

8 files changed

Lines changed: 110 additions & 23 deletions

File tree

‎src/components/autocomplete/index.tsx‎

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -81,10 +81,13 @@ const Autocomplete = <T,>(props: AutocompletePropsType<T>) => {
8181
optionsHeader,
8282
styleIcon,
8383
decorator,
84+
decoratorColor,
8485
variant,
8586
...defaultProps
8687
} = props;
8788

89+
const decoratorColorValue = decoratorColor ?? 'var(--orange-dark)';
90+
8891
return (
8992
<MUIAutocomplete
9093
{...defaultProps}
@@ -139,19 +142,19 @@ const Autocomplete = <T,>(props: AutocompletePropsType<T>) => {
139142
'.MuiOutlinedInput-root': {
140143
borderRadius: 'var(--radius-l)',
141144
'& fieldset': {
142-
border: '1px solid var(--orange-dark)',
145+
border: `1px solid ${decoratorColorValue}`,
143146
},
144147
'&:hover fieldset': {
145-
border: '1px solid var(--orange-dark)',
148+
border: `1px solid ${decoratorColorValue}`,
146149
},
147150
'&.Mui-focused fieldset': {
148-
border: '1px solid var(--orange-dark)',
151+
border: `1px solid ${decoratorColorValue}`,
149152
},
150153
},
151154
'.MuiInputLabel-root': {
152-
color: 'var(--orange-dark)',
155+
color: decoratorColorValue,
153156
'&.Mui-focused': {
154-
color: 'var(--orange-dark)',
157+
color: decoratorColorValue,
155158
},
156159
},
157160
}

‎src/components/autocomplete/index.types.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ export type AutocompletePropsType<T> = Omit<
4646

4747
decorator?: boolean;
4848

49+
decoratorColor?: string;
50+
4951
variant?: TextFieldProps['variant'];
5052
};
5153

‎src/features/meetings/person_selector/brother_selector/index.tsx‎

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,17 @@ const BrotherSelector = (props: PersonSelectorType) => {
3434
inputValue,
3535
handleValueChange,
3636
isLinkedPart,
37+
isMeetingConflict,
3738
} = useBrotherSelector(props);
3839

40+
let helperColor = 'var(--orange-dark)';
41+
42+
if (isLinkedPart) {
43+
helperColor = 'var(--grey-350)';
44+
} else if (isMeetingConflict) {
45+
helperColor = 'var(--red-main)';
46+
}
47+
3948
return (
4049
<Box sx={{ position: 'relative' }}>
4150
{isHistoryOpen && (
@@ -157,6 +166,7 @@ const BrotherSelector = (props: PersonSelectorType) => {
157166
styleIcon={false}
158167
startIcon={showIcon ? <IconMale /> : null}
159168
decorator={helperText.length > 0 && !isLinkedPart}
169+
decoratorColor={isMeetingConflict ? 'var(--red-main)' : undefined}
160170
clearIcon={<IconClose width={20} height={20} />}
161171
sx={{
162172
'& .MuiInputLabel-root': {
@@ -182,11 +192,7 @@ const BrotherSelector = (props: PersonSelectorType) => {
182192
onClick={handleOpenHistory}
183193
>
184194
<IconAssignmetHistory
185-
color={
186-
helperText.length > 0
187-
? 'var(--orange-dark)'
188-
: 'var(--accent-main)'
189-
}
195+
color={helperText.length > 0 ? helperColor : 'var(--accent-main)'}
190196
/>
191197
</IconButton>
192198
)}
@@ -197,19 +203,15 @@ const BrotherSelector = (props: PersonSelectorType) => {
197203
onClick={props.onEditClick}
198204
>
199205
<IconEdit
200-
color={
201-
helperText.length > 0
202-
? 'var(--orange-dark)'
203-
: 'var(--accent-main)'
204-
}
206+
color={helperText.length > 0 ? helperColor : 'var(--accent-main)'}
205207
/>
206208
</IconButton>
207209
)}
208210

209211
{helperText.length > 0 && (
210212
<Typography
211213
className="label-small-regular"
212-
color={isLinkedPart ? 'var(--grey-350)' : 'var(--orange-dark)'}
214+
color={helperColor}
213215
sx={{
214216
padding: '4px 16px 0 16px',
215217
maxWidth: desktopUp ? '350px' : '100%',

‎src/features/meetings/person_selector/brother_selector/useBrotherSelector.tsx‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import { personGetDisplayName, speakerGetDisplayName } from '@utils/common';
3434
import {
3535
schedulesGetData,
3636
schedulesGetMeetingDate,
37+
schedulesPersonHasMeetingConflict,
3738
schedulesSaveAssignment,
3839
} from '@services/app/schedules';
3940
import { ASSIGNMENT_PATH } from '@constants/index';
@@ -405,6 +406,18 @@ const useBrotherSelector = ({ type, week, assignment }: PersonSelectorType) => {
405406
);
406407
}, [value, assignmentsHistory]);
407408

409+
const isMeetingConflict = useMemo(() => {
410+
if (!value) return false;
411+
412+
return schedulesPersonHasMeetingConflict({
413+
history: assignmentsHistory,
414+
week,
415+
assignment,
416+
person_uid: value.person_uid,
417+
dataView,
418+
});
419+
}, [value, assignmentsHistory, week, assignment, dataView]);
420+
408421
const meetingDate = useMemo(() => {
409422
const meeting = location.pathname.includes('midweek')
410423
? 'midweek'
@@ -429,6 +442,10 @@ const useBrotherSelector = ({ type, week, assignment }: PersonSelectorType) => {
429442
return timeAwayNotice;
430443
}
431444

445+
if (isMeetingConflict) {
446+
return t('tr_personAlreadyAssignmentMeeting');
447+
}
448+
432449
// check week assignments
433450
const weekAssignments = personHistory.filter(
434451
(record) => record.weekOf === week
@@ -468,6 +485,7 @@ const useBrotherSelector = ({ type, week, assignment }: PersonSelectorType) => {
468485
isLinkedPart,
469486
persons,
470487
meetingDate,
488+
isMeetingConflict,
471489
]);
472490

473491
const defaultInputValue = useMemo(() => {
@@ -586,6 +604,7 @@ const useBrotherSelector = ({ type, week, assignment }: PersonSelectorType) => {
586604
inputValue,
587605
handleValueChange,
588606
isLinkedPart,
607+
isMeetingConflict,
589608
};
590609
};
591610

‎src/features/meetings/person_selector/student_selector/index.tsx‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,13 @@ const StudentSelector = (props: PersonSelectorType) => {
5555
showGroupToggle,
5656
mainStudentGender,
5757
showFamilyFilter,
58+
isMeetingConflict,
5859
} = useStudentSelector(props);
5960

61+
const helperColor = isMeetingConflict
62+
? 'var(--red-main)'
63+
: 'var(--orange-dark)';
64+
6065
return (
6166
<Box sx={{ position: 'relative' }}>
6267
{isHistoryOpen && (
@@ -302,6 +307,7 @@ const StudentSelector = (props: PersonSelectorType) => {
302307
showIcon ? <StudentIcon type={props.type} value={value} /> : null
303308
}
304309
decorator={helperText.length > 0}
310+
decoratorColor={isMeetingConflict ? 'var(--red-main)' : undefined}
305311
clearIcon={<IconClose width={20} height={20} />}
306312
sx={{
307313
'& .MuiInputLabel-root': {
@@ -326,19 +332,15 @@ const StudentSelector = (props: PersonSelectorType) => {
326332
onClick={handleOpenHistory}
327333
>
328334
<IconAssignmetHistory
329-
color={
330-
helperText.length > 0
331-
? 'var(--orange-dark)'
332-
: 'var(--accent-main)'
333-
}
335+
color={helperText.length > 0 ? helperColor : 'var(--accent-main)'}
334336
/>
335337
</IconButton>
336338
)}
337339

338340
{helperText.length > 0 && (
339341
<Typography
340342
className="label-small-regular"
341-
color="var(--orange-dark)"
343+
color={helperColor}
342344
sx={{
343345
padding: '4px 16px 0 16px',
344346
maxWidth: desktopUp ? '350px' : '100%',

‎src/features/meetings/person_selector/student_selector/useStudentSelector.tsx‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { Gender } from './index.types';
2525
import {
2626
schedulesGetData,
2727
schedulesGetMeetingDate,
28+
schedulesPersonHasMeetingConflict,
2829
schedulesSaveAssignment,
2930
} from '@services/app/schedules';
3031
import { AssignmentCongregation } from '@definition/schedules';
@@ -383,6 +384,18 @@ const useStudentSelector = ({ type, assignment, week }: PersonSelectorType) => {
383384
);
384385
}, [value, assignmentsHistory]);
385386

387+
const isMeetingConflict = useMemo(() => {
388+
if (!value) return false;
389+
390+
return schedulesPersonHasMeetingConflict({
391+
history: assignmentsHistory,
392+
week,
393+
assignment,
394+
person_uid: value.person_uid,
395+
dataView,
396+
});
397+
}, [value, assignmentsHistory, week, assignment, dataView]);
398+
386399
const meetingDate = useMemo(() => {
387400
const meeting = location.pathname.includes('midweek')
388401
? 'midweek'
@@ -407,6 +420,10 @@ const useStudentSelector = ({ type, assignment, week }: PersonSelectorType) => {
407420
return timeAwayNotice;
408421
}
409422

423+
if (isMeetingConflict) {
424+
return t('tr_personAlreadyAssignmentMeeting');
425+
}
426+
410427
// check week assignments
411428
const weekAssignments = personHistory.filter(
412429
(record) => record.weekOf === week
@@ -428,7 +445,7 @@ const useStudentSelector = ({ type, assignment, week }: PersonSelectorType) => {
428445
}
429446

430447
return '';
431-
}, [persons, value, week, personHistory, t, meetingDate]);
448+
}, [persons, value, week, personHistory, t, meetingDate, isMeetingConflict]);
432449

433450
const handleGenderChange = (
434451
e: MouseEvent<HTMLLabelElement>,
@@ -519,6 +536,7 @@ const useStudentSelector = ({ type, assignment, week }: PersonSelectorType) => {
519536
groupChecked,
520537
mainStudentGender,
521538
showFamilyFilter: familyMemberUIDs.size > 0,
539+
isMeetingConflict,
522540
};
523541
};
524542

‎src/locales/en/meetings.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
"tr_weekendScheduleSaved": "The weekend schedule saved",
5353
"tr_weekendScheduleSavedDesc": "After creating the desired schedules, remember to publish them for the congregation.",
5454
"tr_personAlreadyAssignmentWeek": "This person already has an assignment this week",
55+
"tr_personAlreadyAssignmentMeeting": "This person already has an assignment in this meeting",
5556
"tr_hostCongregation": "Host congregation",
5657
"tr_kingdomHallAddress": "Kingdom Hall address",
5758
"tr_clearAllAssignments": "Clear all assignments",

‎src/services/app/schedules.ts‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1627,6 +1627,46 @@ export const schedulesPersonNoConsecutivePart = ({
16271627
return selected;
16281628
};
16291629

1630+
const isConflictExemptAssignment = (assignment: AssignmentFieldType) =>
1631+
assignment.endsWith('OpeningPrayer') ||
1632+
assignment.endsWith('ClosingPrayer') ||
1633+
assignment.endsWith('CircuitOverseer') ||
1634+
assignment === 'WM_Speaker_Outgoing';
1635+
1636+
export const schedulesPersonHasMeetingConflict = ({
1637+
history,
1638+
week,
1639+
assignment,
1640+
person_uid,
1641+
dataView,
1642+
}: {
1643+
history: AssignmentHistoryType[];
1644+
week: string;
1645+
assignment: AssignmentFieldType;
1646+
person_uid: string;
1647+
dataView: string;
1648+
}) => {
1649+
if (!person_uid || week.length === 0) return false;
1650+
1651+
if (isConflictExemptAssignment(assignment)) return false;
1652+
1653+
const meeting = assignment.startsWith('WM_') ? 'WM_' : 'MM_';
1654+
1655+
return history.some((record) => {
1656+
const key = record.assignment.key;
1657+
1658+
if (!key) return false;
1659+
if (record.weekOf !== week) return false;
1660+
if (record.assignment.person !== person_uid) return false;
1661+
if (record.assignment.dataView !== dataView) return false;
1662+
if (!key.startsWith(meeting)) return false;
1663+
if (key === assignment) return false; // the field being edited
1664+
if (isConflictExemptAssignment(key)) return false;
1665+
1666+
return true;
1667+
});
1668+
};
1669+
16301670
export const schedulesPersonLatest = ({
16311671
persons,
16321672
type,

0 commit comments

Comments
 (0)