diff --git a/src/constants/table_encryption_map.ts b/src/constants/table_encryption_map.ts index 2ff0597d3e3..88d94fcdaeb 100644 --- a/src/constants/table_encryption_map.ts +++ b/src/constants/table_encryption_map.ts @@ -61,6 +61,7 @@ export const TABLE_ENCRYPTION_MAP = { format_24h_enabled: 'shared', week_start_sunday: 'shared', attendance_online_record: 'shared', + attendance_deaf_record: 'shared', data_sync: 'public', responsabilities: 'shared', cong_role: 'public', diff --git a/src/definition/meeting_attendance.ts b/src/definition/meeting_attendance.ts index e3d26daaf4e..9053f77a06a 100644 --- a/src/definition/meeting_attendance.ts +++ b/src/definition/meeting_attendance.ts @@ -1,10 +1,20 @@ export type AttendanceCongregation = { present: number; online: number; + present_deaf?: number; + online_deaf?: number; type: string; updatedAt: string; }; +export type AttendanceRecordField = + | 'present' + | 'online' + | 'present_deaf' + | 'online_deaf'; + +export type AttendanceValues = Partial>; + export type WeeklyAttendance = { midweek: AttendanceCongregation[]; weekend: AttendanceCongregation[]; @@ -20,12 +30,21 @@ export type MeetingAttendanceType = { week_5: WeeklyAttendance; }; +export type MeetingAttendanceStats = { + count: number; + total: number; + average: number; + average_online: number; + total_deaf: number; + average_deaf: number; +}; + export type MeetingAttendanceExport = { lang: string; locale: string; data: { name: string; - years: string[]; + columns: string[]; midweek_meeting: { month: string; table_1: { diff --git a/src/definition/settings.ts b/src/definition/settings.ts index 0cb849da469..ec1e9fcb634 100644 --- a/src/definition/settings.ts +++ b/src/definition/settings.ts @@ -168,6 +168,12 @@ export type SettingsType = { updatedAt: string; _deleted: boolean; }[]; + attendance_deaf_record: { + type: string; + value: boolean; + updatedAt: string; + _deleted: boolean; + }[]; data_sync: { value: boolean; updatedAt: string }; responsabilities: { coordinator: string; diff --git a/src/features/congregation/settings/congregation_basic/meeting_attendance/index.tsx b/src/features/congregation/settings/congregation_basic/meeting_attendance/index.tsx index 0faf7be37f2..2afc00a2310 100644 --- a/src/features/congregation/settings/congregation_basic/meeting_attendance/index.tsx +++ b/src/features/congregation/settings/congregation_basic/meeting_attendance/index.tsx @@ -1,3 +1,4 @@ +import { Stack } from '@mui/material'; import { useAppTranslation, useCurrentUser } from '@hooks/index'; import useMeetingAttendance from './useMeetingAttendance'; import SwitchWithLabel from '@components/switch_with_label'; @@ -7,16 +8,31 @@ const MeetingAttendance = () => { const { isSettingsEditor } = useCurrentUser(); - const { recordOnline, handleRecordOnlineToggle } = useMeetingAttendance(); + const { + recordOnline, + handleRecordOnlineToggle, + recordDeaf, + handleRecordDeafToggle, + } = useMeetingAttendance(); return ( - + + + + + ); }; diff --git a/src/features/congregation/settings/congregation_basic/meeting_attendance/useMeetingAttendance.tsx b/src/features/congregation/settings/congregation_basic/meeting_attendance/useMeetingAttendance.tsx index f577b11a573..24b648dcd3a 100644 --- a/src/features/congregation/settings/congregation_basic/meeting_attendance/useMeetingAttendance.tsx +++ b/src/features/congregation/settings/congregation_basic/meeting_attendance/useMeetingAttendance.tsx @@ -1,18 +1,48 @@ import { useEffect, useState } from 'react'; import { useAtomValue } from 'jotai'; import { + attendanceDeafRecordState, attendanceOnlineRecordState, settingsState, userDataViewState, } from '@states/settings'; import { dbAppSettingsUpdate } from '@services/dexie/settings'; +import { settingSchema } from '@services/dexie/schema'; + +type DataViewSetting = { + type: string; + value: boolean; + updatedAt: string; + _deleted: boolean; +}; + +const setValueForDataView = ( + records: DataViewSetting[], + dataView: string, + value: boolean +) => { + const updatedAt = new Date().toISOString(); + + const current = records.find((record) => record.type === dataView); + + if (current) { + current.value = value; + current.updatedAt = updatedAt; + } else { + records.push({ type: dataView, _deleted: false, updatedAt, value }); + } + + return records; +}; const useMeetingAttendance = () => { const settings = useAtomValue(settingsState); const dataView = useAtomValue(userDataViewState); const recordOnlineInitial = useAtomValue(attendanceOnlineRecordState); + const recordDeafInitial = useAtomValue(attendanceDeafRecordState); const [recordOnline, setRecordOnline] = useState(false); + const [recordDeaf, setRecordDeaf] = useState(false); const handleRecordOnlineToggle = async () => { let newRecordOnline = structuredClone( @@ -26,26 +56,27 @@ const useMeetingAttendance = () => { newRecordOnline = [{ type: 'main', _deleted: false, updatedAt, value }]; } - const findRecord = newRecordOnline.find( - (record) => record.type === dataView - ); - - if (findRecord) { - findRecord.value = !recordOnline; - findRecord.updatedAt = new Date().toISOString(); - } + await dbAppSettingsUpdate({ + 'cong_settings.attendance_online_record': setValueForDataView( + newRecordOnline, + dataView, + !recordOnline + ), + }); + }; - if (!findRecord) { - newRecordOnline.push({ - type: dataView, - _deleted: false, - updatedAt: new Date().toISOString(), - value: !recordOnline, - }); - } + const handleRecordDeafToggle = async () => { + const newRecordDeaf = structuredClone( + settings.cong_settings.attendance_deaf_record ?? + settingSchema.cong_settings.attendance_deaf_record + ); await dbAppSettingsUpdate({ - 'cong_settings.attendance_online_record': newRecordOnline, + 'cong_settings.attendance_deaf_record': setValueForDataView( + newRecordDeaf, + dataView, + !recordDeaf + ), }); }; @@ -53,9 +84,15 @@ const useMeetingAttendance = () => { setRecordOnline(recordOnlineInitial); }, [recordOnlineInitial]); + useEffect(() => { + setRecordDeaf(recordDeafInitial); + }, [recordDeafInitial]); + return { recordOnline, handleRecordOnlineToggle, + recordDeaf, + handleRecordDeafToggle, }; }; diff --git a/src/features/congregation/settings/language_groups/group_add/useGroupAdd.tsx b/src/features/congregation/settings/language_groups/group_add/useGroupAdd.tsx index 2cd44bdf61b..8a075d94479 100644 --- a/src/features/congregation/settings/language_groups/group_add/useGroupAdd.tsx +++ b/src/features/congregation/settings/language_groups/group_add/useGroupAdd.tsx @@ -150,9 +150,20 @@ const useGroupAdd = ({ onClose }: GroupAddProps) => { value: false, }); + const deafRecord = + appSettings.cong_settings.attendance_deaf_record || + structuredClone(settingSchema.cong_settings.attendance_deaf_record); + + deafRecord.push({ + _deleted: false, + type: group.group_id, + updatedAt: new Date().toISOString(), + value: false, + }); + const firstDayWeek = appSettings.cong_settings.first_day_week || - settingSchema.cong_settings.first_day_week; + structuredClone(settingSchema.cong_settings.first_day_week); firstDayWeek.push({ _deleted: false, @@ -163,7 +174,7 @@ const useGroupAdd = ({ onClose }: GroupAddProps) => { const weekendSongs = appSettings.cong_settings.schedule_songs_weekend || - settingSchema.cong_settings.schedule_songs_weekend; + structuredClone(settingSchema.cong_settings.schedule_songs_weekend); weekendSongs.push({ _deleted: false, @@ -194,6 +205,7 @@ const useGroupAdd = ({ onClose }: GroupAddProps) => { 'cong_settings.short_date_format': shortDateFormat, 'cong_settings.format_24h_enabled': format24h, 'cong_settings.attendance_online_record': onlineRecord, + 'cong_settings.attendance_deaf_record': deafRecord, 'cong_settings.first_day_week': firstDayWeek, 'cong_settings.schedule_songs_weekend': weekendSongs, 'cong_settings.midweek_meeting': midweekMeeting, diff --git a/src/features/congregation/settings/language_groups/group_delete/useGroupDelete.tsx b/src/features/congregation/settings/language_groups/group_delete/useGroupDelete.tsx index 28644da87ee..771fe885e17 100644 --- a/src/features/congregation/settings/language_groups/group_delete/useGroupDelete.tsx +++ b/src/features/congregation/settings/language_groups/group_delete/useGroupDelete.tsx @@ -139,6 +139,20 @@ const useGroupDelete = ({ group }: GroupDeleteProps) => { findOnline.updatedAt = new Date().toISOString(); } + const deafRecord = structuredClone( + settings.cong_settings.attendance_deaf_record || + settingSchema.cong_settings.attendance_deaf_record + ); + + const findDeaf = deafRecord.find( + (record) => record.type === group.group_id + ); + + if (findDeaf) { + findDeaf._deleted = true; + findDeaf.updatedAt = new Date().toISOString(); + } + const midweekMeeting = structuredClone( settings.cong_settings.midweek_meeting ); @@ -205,6 +219,7 @@ const useGroupDelete = ({ group }: GroupDeleteProps) => { 'cong_settings.short_date_format': shortDateFormat, 'cong_settings.format_24h_enabled': format24h, 'cong_settings.attendance_online_record': onlineRecord, + 'cong_settings.attendance_deaf_record': deafRecord, 'cong_settings.first_day_week': firstDayWeek, 'cong_settings.schedule_songs_weekend': weekendSongs, 'cong_settings.midweek_meeting': midweekMeeting, diff --git a/src/features/reports/meeting_attendance/export_S88/index.types.ts b/src/features/reports/meeting_attendance/export_S88/index.types.ts index 929f2fc0f3c..e33cbde7d42 100644 --- a/src/features/reports/meeting_attendance/export_S88/index.types.ts +++ b/src/features/reports/meeting_attendance/export_S88/index.types.ts @@ -1,13 +1,9 @@ -export type MeetingStatsData = { - count: number; - total: number; - average: number; -}; +import { MeetingAttendanceStats } from '@definition/meeting_attendance'; export type MonthData = { month: string; - midweek: MeetingStatsData; - weekend: MeetingStatsData; + midweek: MeetingAttendanceStats; + weekend: MeetingAttendanceStats; }; export type YearlyData = { @@ -20,3 +16,9 @@ export type AttendanceExport = { name: string; data: YearlyData[]; }; + +export type ColumnSource = { + label: string; + months: MonthData[]; + deaf?: boolean; +}; diff --git a/src/features/reports/meeting_attendance/export_S88/useExportS88.tsx b/src/features/reports/meeting_attendance/export_S88/useExportS88.tsx index 25dff270b4f..d9b8af4b325 100644 --- a/src/features/reports/meeting_attendance/export_S88/useExportS88.tsx +++ b/src/features/reports/meeting_attendance/export_S88/useExportS88.tsx @@ -6,15 +6,16 @@ import { displaySnackNotification } from '@services/states/app'; import { generateMonthNames, getMessageByCode, + getTranslation, } from '@services/i18n/translation'; import { createArrayFromMonths, currentServiceYear } from '@utils/date'; -import { AttendanceExport, MonthData, YearlyData } from './index.types'; +import { AttendanceExport, ColumnSource } from './index.types'; import { MeetingAttendanceExport, - MeetingAttendanceType, - WeeklyAttendance, + MeetingAttendanceStats, } from '@definition/meeting_attendance'; import { + attendanceDeafRecordState, JWLangLocaleState, JWLangState, languageGroupEnabledState, @@ -22,17 +23,45 @@ import { import { meetingAttendanceState } from '@states/meeting_attendance'; import { languageGroupsState } from '@states/field_service_groups'; import { MeetingType } from '@definition/app'; +import { meetingAttendanceGetStats } from '@services/app/meeting_attendance'; import TemplateS88 from '@views/reports/attendance'; +const getCell = (stats?: MeetingAttendanceStats, deaf?: boolean) => { + if (!stats) return { count: '', total: '', average: '' }; + + return { + count: stats.count || '', + total: (deaf ? stats.total_deaf : stats.total) || '', + average: (deaf ? stats.average_deaf : stats.average) || '', + }; +}; + +const getYearlyAverage = (column: ColumnSource, meeting: MeetingType) => { + const values = column.months + .map((month) => + column.deaf ? month[meeting].average_deaf : month[meeting].average + ) + .filter((average) => average > 0); + + if (values.length === 0) return 0; + + const sum = values.reduce((acc, current) => acc + current, 0); + + return Math.round(sum / values.length); +}; + const useExportS88 = () => { const attendances = useAtomValue(meetingAttendanceState); const lang = useAtomValue(JWLangState); const locale = useAtomValue(JWLangLocaleState); const languageGroups = useAtomValue(languageGroupsState); const languageGroupEnabled = useAtomValue(languageGroupEnabledState); + const recordDeaf = useAtomValue(attendanceDeafRecordState); const [isProcessing, setIsProcessing] = useState(false); + const monthNames = useMemo(() => generateMonthNames(locale), [locale]); + const groups = useMemo(() => { if (!languageGroupEnabled) return []; @@ -42,268 +71,83 @@ const useExportS88 = () => { ); }, [languageGroupEnabled, languageGroups]); - const getAttendance = (month: string) => { - return attendances.find((record) => record.month_date === month); - }; - - const getMidweekOnline = ( - attendance: MeetingAttendanceType, - category: string - ) => { - if (!attendance) return 0; - - let total = 0; - - for (let i = 1; i <= 5; i++) { - const weekData = attendance[`week_${i}`] as WeeklyAttendance; - - let meetingData = weekData.midweek; - - if (category !== 'main') { - meetingData = meetingData.filter((record) => record.type === category); - } - - total += meetingData.reduce((acc, current) => { - if (current?.online) { - return acc + current.online; - } - - return acc; - }, 0); - } - - return total; - }; - - const getWeekendOnline = ( - attendance: MeetingAttendanceType, - category: string - ) => { - if (!attendance) return 0; - - let total = 0; - - for (let i = 1; i <= 5; i++) { - const weekData = attendance[`week_${i}`] as WeeklyAttendance; - - let meetingData = weekData.weekend; - - if (category !== 'main') { - meetingData = meetingData.filter((record) => record.type === category); - } - - total += meetingData.reduce((acc, current) => { - if (current?.online) { - return acc + current.online; - } - - return acc; - }, 0); - } - - return total; - }; - - const getMidweekTotal = ( - attendance: MeetingAttendanceType, - category: string - ) => { - if (!attendance) return 0; - - let total = 0; - - for (let i = 1; i <= 5; i++) { - const weekData = attendance[`week_${i}`] as WeeklyAttendance; - - let meetingData = weekData.midweek; - - if (category !== 'main') { - meetingData = meetingData.filter((record) => record.type === category); - } - - total += meetingData.reduce((acc, current) => { - if (current?.present) { - return acc + current.present; - } - - return acc; - }, 0); - } - - const midweek_online = getMidweekOnline(attendance, category); - - return total + midweek_online; - }; - - const getWeekendTotal = ( - attendance: MeetingAttendanceType, - category: string - ) => { - if (!attendance) return 0; - - let total = 0; - - for (let i = 1; i <= 5; i++) { - const weekData = attendance[`week_${i}`] as WeeklyAttendance; - - let meetingData = weekData.weekend; - - if (category !== 'main') { - meetingData = meetingData.filter((record) => record.type === category); - } - - total += meetingData.reduce((acc, current) => { - if (current?.present) { - return acc + current.present; - } - - return acc; - }, 0); - } - - const weekend_online = getWeekendOnline(attendance, category); - - return total + weekend_online; - }; - - const getMidweekCount = ( - attendance: MeetingAttendanceType, - category: string - ) => { - if (!attendance) return 0; - - let count = 0; - - for (let i = 1; i <= 5; i++) { - const weekData = attendance[`week_${i}`] as WeeklyAttendance; - - let meetingData = weekData.midweek; - - if (category !== 'main') { - meetingData = meetingData.filter((record) => record.type === category); - } - - const total = meetingData.reduce((acc, current) => { - let value = acc; - - if (current?.present) { - value += current.present; - } - - if (current?.online) { - value += current.online; - } - - return value; - }, 0); - - if (total > 0) count++; - } - - return count; - }; - - const getWeekendCount = ( - attendance: MeetingAttendanceType, - category: string - ) => { - if (!attendance) return 0; - - let count = 0; - - for (let i = 1; i <= 5; i++) { - const weekData = attendance[`week_${i}`] as WeeklyAttendance; - - let meetingData = weekData.weekend; - - if (category !== 'main') { - meetingData = meetingData.filter((record) => record.type === category); - } - - const total = meetingData.reduce((acc, current) => { - let value = acc; - - if (current?.present) { - value += current.present; - } - - if (current?.online) { - value += current.online; - } + const getAttendanceDetails = (month: string, category: string) => { + const attendance = attendances.find( + (record) => record.month_date === month + ); - return value; - }, 0); + const filter = category === 'main' ? undefined : category; - if (total > 0) count++; - } - - return count; + return { + midweek: meetingAttendanceGetStats(attendance, 'midweek', filter), + weekend: meetingAttendanceGetStats(attendance, 'weekend', filter), + }; }; - const getMidweekAverage = ( - attendance: MeetingAttendanceType, - category: string - ) => { - if (!attendance) return 0; - - const midweek_total = getMidweekTotal(attendance, category); - const midweek_count = getMidweekCount(attendance, category); + const getYearlyData = (year: string, category: string) => { + const months = createArrayFromMonths(`${+year - 1}/09`, `${year}/08`); - const average = - midweek_total === 0 ? 0 : Math.round(midweek_total / midweek_count); - - return average; + return { + year, + months: months.map((month) => ({ + month, + ...getAttendanceDetails(month, category), + })), + }; }; - const getWeekendAverage = ( - attendance: MeetingAttendanceType, - category: string - ) => { - if (!attendance) return 0; + const buildPage = (name: string, columns: ColumnSource[]) => { + const [column1, column2] = columns; - const weekend_total = getWeekendTotal(attendance, category); - const weekend_count = getWeekendCount(attendance, category); + const buildRows = (meeting: MeetingType) => { + return column1.months.map((record, index) => { + const monthIndex = +record.month.split('/')[1] - 1; - const average = - weekend_total === 0 ? 0 : Math.round(weekend_total / weekend_count); - - return average; - }; - - const getAttendanceDetails = (month: string, category: string) => { - const attendance = getAttendance(month); + return { + month: monthNames[monthIndex], + table_1: getCell(record[meeting], column1.deaf), + table_2: getCell(column2.months.at(index)?.[meeting], column2.deaf), + }; + }); + }; return { - midweek: { - count: getMidweekCount(attendance, category), - total: getMidweekTotal(attendance, category), - average: getMidweekAverage(attendance, category), - }, - weekend: { - count: getWeekendCount(attendance, category), - total: getWeekendTotal(attendance, category), - average: getWeekendAverage(attendance, category), - }, + name, + columns: columns.map((column) => column.label), + midweek_meeting: buildRows('midweek'), + midweek_average: [ + getYearlyAverage(column1, 'midweek'), + getYearlyAverage(column2, 'midweek'), + ], + weekend_meeting: buildRows('weekend'), + weekend_average: [ + getYearlyAverage(column1, 'weekend'), + getYearlyAverage(column2, 'weekend'), + ], }; }; - const getYearlyMeetingAverage = ( - months: MonthData[], - meeting: MeetingType - ) => { - const values: number[] = []; + const buildCategoryPages = (category: AttendanceExport) => { + if (!recordDeaf) { + const columns: ColumnSource[] = [0, 1].map((index) => { + const yearly = category.data.at(index); - for (const month of months) { - const avg = month[meeting].average; + return yearly + ? { label: yearly.year, months: yearly.months } + : { label: '', months: [] }; + }); - if (avg > 0) values.push(avg); + return [buildPage(category.name, columns)]; } - const sum = values.reduce((acc, current) => acc + current, 0); - - if (sum === 0) return 0; + const deafLabel = getTranslation({ key: 'tr_deaf', language: locale }); - return Math.round(sum / values.length); + return category.data.map((yearly) => + buildPage(category.name, [ + { label: yearly.year, months: yearly.months }, + { label: deafLabel, months: yearly.months, deaf: true }, + ]) + ); }; const handleExportS88 = async () => { @@ -312,173 +156,44 @@ const useExportS88 = () => { try { setIsProcessing(true); - const result: AttendanceExport[] = []; - - const years = [currentServiceYear()]; - years.unshift(String(+years - 1)); - - const main = { - category: 'main', - name: 'main', - data: [], - } as AttendanceExport; - - for (const year of years) { - const obj = { year, months: [] }; - - const startMonth = `${+year - 1}/09`; - const endMonth = `${year}/08`; - const months = createArrayFromMonths(startMonth, endMonth); - - for (const month of months) { - const attendance = getAttendanceDetails(month, main.category); - obj.months.push({ month, ...attendance }); - } - - main.data.push(obj); - } - - result.push(main); + const currentYear = currentServiceYear(); + const years = [String(+currentYear - 1), currentYear]; - for (const group of groups) { - const groupData = { + const categories: AttendanceExport[] = [ + { category: 'main', name: 'main' }, + ...groups.map((group) => ({ category: group.group_id, name: group.group_data.name, - data: [], - } as AttendanceExport; - - for (const year of years) { - const obj = { year, months: [] }; - - const startMonth = `${+year - 1}/09`; - const endMonth = `${year}/08`; - const months = createArrayFromMonths(startMonth, endMonth); - - for (const month of months) { - const attendance = getAttendanceDetails(month, groupData.category); - obj.months.push({ month, ...attendance }); - } - - groupData.data.push(obj); - } - - result.push(groupData); - } - - // remove service year with no data - const resultClean = result.reduce((acc: AttendanceExport[], current) => { - const newCurrent = current.data.reduce( - (childAcc: YearlyData[], childCurrent) => { - const hasData = childCurrent.months.some( + })), + ].map((category) => ({ + ...category, + data: years + .map((year) => getYearlyData(year, category.category)) + .filter((yearly) => + yearly.months.some( (record) => record.midweek.count > 0 || record.weekend.count > 0 - ); - - if (hasData) { - childAcc.push(childCurrent); - } - - return childAcc; - }, - [] - ); - - acc.push({ ...current, data: newCurrent }); + ) + ), + })); - return acc; - }, []); - - if (resultClean.length === 0 || resultClean?.at(0).data.length === 0) { + if (categories.every((category) => category.data.length === 0)) { setIsProcessing(false); return; } - const monthNames = generateMonthNames(locale); - const finalData: MeetingAttendanceExport = { lang, locale, - data: resultClean - .filter((record) => record.data.length > 0) - .map((category) => { - const year1 = category.data.at(0).year; - const year2 = category.data.at(1)?.year; - - return { - name: category.name, - years: [year1, year2 || ''], - midweek_meeting: category.data - .at(0) - .months.map((record, index) => { - const table1 = category.data.at(0).months.at(index); - const table2 = category.data.at(1)?.months.at(index); - - const monthIndex = +record.month.split('/')[1] - 1; - - return { - month: monthNames[monthIndex], - table_1: { - count: table1.midweek.count || '', - total: table1.midweek.total || '', - average: table1.midweek.average || '', - }, - table_2: { - count: table2?.midweek.count || '', - total: table2?.midweek.total || '', - average: table2?.midweek.average || '', - }, - }; - }), - midweek_average: [ - getYearlyMeetingAverage(category.data.at(0).months, 'midweek'), - year2 - ? getYearlyMeetingAverage( - category.data.at(1)?.months ?? [], - 'midweek' - ) - : 0, - ], - weekend_meeting: category.data - .at(0) - .months.map((record, index) => { - const table1 = category.data.at(0).months.at(index); - const table2 = category.data.at(1)?.months.at(index); - - const monthIndex = +record.month.split('/')[1] - 1; - - return { - month: monthNames[monthIndex], - table_1: { - count: table1.weekend.count || '', - total: table1.weekend.total || '', - average: table1.weekend.average || '', - }, - table_2: { - count: table2?.weekend.count || '', - total: table2?.weekend.total || '', - average: table2?.weekend.average || '', - }, - }; - }), - weekend_average: [ - getYearlyMeetingAverage(category.data.at(0).months, 'weekend'), - year2 - ? getYearlyMeetingAverage( - category.data.at(1)?.months ?? [], - 'weekend' - ) - : 0, - ], - }; - }), + data: categories + .filter((category) => category.data.length > 0) + .flatMap(buildCategoryPages), }; const blob = await pdf( ).toBlob(); - const filename = `S-88.pdf`; - - saveAs(blob, filename); + saveAs(blob, 'S-88.pdf'); setIsProcessing(false); } catch (error) { diff --git a/src/features/reports/meeting_attendance/hooks/useMeetingAttendance.tsx b/src/features/reports/meeting_attendance/hooks/useMeetingAttendance.tsx index 2b94cad9f9e..9112b23cb19 100644 --- a/src/features/reports/meeting_attendance/hooks/useMeetingAttendance.tsx +++ b/src/features/reports/meeting_attendance/hooks/useMeetingAttendance.tsx @@ -2,7 +2,7 @@ import { useMemo } from 'react'; import { useAtomValue } from 'jotai'; import { useCurrentUser } from '@hooks/index'; import { meetingAttendanceState } from '@states/meeting_attendance'; -import { WeeklyAttendance } from '@definition/meeting_attendance'; +import { meetingAttendanceGetStats } from '@services/app/meeting_attendance'; const useMeetingAttendance = (month: string) => { const { isGroup, languageGroup } = useCurrentUser(); @@ -13,250 +13,17 @@ const useMeetingAttendance = (month: string) => { return attendances.find((record) => record.month_date === month); }, [attendances, month]); - const midweek_online = useMemo(() => { - if (!attendance) return 0; + const category = isGroup ? languageGroup?.group_id : undefined; - let total = 0; + const midweek = useMemo(() => { + return meetingAttendanceGetStats(attendance, 'midweek', category); + }, [attendance, category]); - for (let i = 1; i <= 5; i++) { - const weekData = attendance[`week_${i}`] as WeeklyAttendance; + const weekend = useMemo(() => { + return meetingAttendanceGetStats(attendance, 'weekend', category); + }, [attendance, category]); - let meetingData = weekData.midweek; - - if (isGroup) { - meetingData = meetingData.filter( - (record) => record.type === languageGroup?.group_id - ); - } - - total += meetingData.reduce((acc, current) => { - if (current?.online) { - return acc + current.online; - } - - return acc; - }, 0); - } - - return total; - }, [attendance, isGroup, languageGroup]); - - const weekend_online = useMemo(() => { - if (!attendance) return 0; - - let total = 0; - - for (let i = 1; i <= 5; i++) { - const weekData = attendance[`week_${i}`] as WeeklyAttendance; - - let meetingData = weekData.weekend; - - if (isGroup) { - meetingData = meetingData.filter( - (record) => record.type === languageGroup?.group_id - ); - } - - total += meetingData.reduce((acc, current) => { - if (current?.online) { - return acc + current.online; - } - - return acc; - }, 0); - } - - return total; - }, [attendance, isGroup, languageGroup]); - - const midweek_total = useMemo(() => { - if (!attendance) return 0; - - let total = 0; - - for (let i = 1; i <= 5; i++) { - const weekData = attendance[`week_${i}`] as WeeklyAttendance; - - let meetingData = weekData.midweek; - - if (isGroup) { - meetingData = meetingData.filter( - (record) => record.type === languageGroup?.group_id - ); - } - - total += meetingData.reduce((acc, current) => { - if (current?.present) { - return acc + current.present; - } - - return acc; - }, 0); - } - - return total + midweek_online; - }, [attendance, midweek_online, isGroup, languageGroup]); - - const weekend_total = useMemo(() => { - if (!attendance) return 0; - - let total = 0; - - for (let i = 1; i <= 5; i++) { - const weekData = attendance[`week_${i}`] as WeeklyAttendance; - - let meetingData = weekData.weekend; - - if (isGroup) { - meetingData = meetingData.filter( - (record) => record.type === languageGroup?.group_id - ); - } - - total += meetingData.reduce((acc, current) => { - if (current?.present) { - return acc + current.present; - } - - return acc; - }, 0); - } - - return total + weekend_online; - }, [attendance, weekend_online, isGroup, languageGroup]); - - const midweek_count = useMemo(() => { - if (!attendance) return 0; - - let count = 0; - - for (let i = 1; i <= 5; i++) { - const weekData = attendance[`week_${i}`] as WeeklyAttendance; - - let meetingData = weekData.midweek; - - if (isGroup) { - meetingData = meetingData.filter( - (record) => record.type === languageGroup?.group_id - ); - } - - const total = meetingData.reduce((acc, current) => { - let value = acc; - - if (current?.present) { - value += value + current.present; - } - - if (current?.online) { - value += value + current.online; - } - - return value; - }, 0); - - if (total > 0) count++; - } - - return count; - }, [attendance, isGroup, languageGroup]); - - const weekend_count = useMemo(() => { - if (!attendance) return 0; - - let count = 0; - - for (let i = 1; i <= 5; i++) { - const weekData = attendance[`week_${i}`] as WeeklyAttendance; - - let meetingData = weekData.weekend; - - if (isGroup) { - meetingData = meetingData.filter( - (record) => record.type === languageGroup?.group_id - ); - } - - const total = meetingData.reduce((acc, current) => { - let value = acc; - - if (current?.present) { - value += value + current.present; - } - - if (current?.online) { - value += value + current.online; - } - - return value; - }, 0); - - if (total > 0) count++; - } - - return count; - }, [attendance, isGroup, languageGroup]); - - const midweek_average = useMemo(() => { - if (!attendance) return 0; - - const average = - midweek_total === 0 ? 0 : Math.round(midweek_total / midweek_count); - - return average; - }, [attendance, midweek_total, midweek_count]); - - const weekend_average = useMemo(() => { - if (!attendance) return 0; - - const average = - weekend_total === 0 ? 0 : Math.round(weekend_total / weekend_count); - - return average; - }, [attendance, weekend_total, weekend_count]); - - const midweek_average_online = useMemo(() => { - if (!attendance) return '0%'; - - const avgOnline = - midweek_online === 0 ? 0 : Math.round(midweek_online / midweek_count); - - const percent = - midweek_average === 0 - ? 0 - : Math.round((avgOnline * 100) / midweek_average); - - return `${avgOnline} (${percent}%)`; - }, [attendance, midweek_average, midweek_online, midweek_count]); - - const weekend_average_online = useMemo(() => { - if (!attendance) return '0%'; - - const avgOnline = - weekend_online === 0 ? 0 : Math.round(weekend_online / weekend_count); - - const percent = - weekend_average === 0 - ? 0 - : Math.round((avgOnline * 100) / weekend_average); - - return `${avgOnline} (${percent}%)`; - }, [attendance, weekend_average, weekend_online, weekend_count]); - - return { - midweek: { - count: midweek_count, - total: midweek_total, - average: midweek_average, - average_online: midweek_average_online, - }, - weekend: { - count: weekend_count, - total: weekend_total, - average: weekend_average, - average_online: weekend_average_online, - }, - }; + return { midweek, weekend, hasRecord: !!attendance }; }; export default useMeetingAttendance; diff --git a/src/features/reports/meeting_attendance/monthly_history/details_row/index.types.ts b/src/features/reports/meeting_attendance/monthly_history/details_row/index.types.ts index 03d37a306a9..5def1863a90 100644 --- a/src/features/reports/meeting_attendance/monthly_history/details_row/index.types.ts +++ b/src/features/reports/meeting_attendance/monthly_history/details_row/index.types.ts @@ -1,7 +1,13 @@ import { MeetingType } from '@definition/app'; export type DetailsRowProps = { - type: 'count' | 'total' | 'average' | 'average_online'; + type: + | 'count' + | 'total' + | 'average' + | 'average_online' + | 'total_deaf' + | 'average_deaf'; month: string; meeting: MeetingType; }; diff --git a/src/features/reports/meeting_attendance/monthly_history/details_row/useDetailsRow.tsx b/src/features/reports/meeting_attendance/monthly_history/details_row/useDetailsRow.tsx index e17ffed314a..5c1a15870d2 100644 --- a/src/features/reports/meeting_attendance/monthly_history/details_row/useDetailsRow.tsx +++ b/src/features/reports/meeting_attendance/monthly_history/details_row/useDetailsRow.tsx @@ -1,82 +1,40 @@ import { useMemo } from 'react'; -import { useAtomValue } from 'jotai'; import { useAppTranslation } from '@hooks/index'; import { DetailsRowProps } from './index.types'; -import { meetingAttendanceState } from '@states/meeting_attendance'; import useMeetingAttendance from '../../hooks/useMeetingAttendance'; const useDetailsRow = ({ type, month, meeting }: DetailsRowProps) => { const { t } = useAppTranslation(); - const { midweek, weekend } = useMeetingAttendance(month); - - const attendances = useAtomValue(meetingAttendanceState); + const { midweek, weekend, hasRecord } = useMeetingAttendance(month); const label = useMemo(() => { - if (type === 'count') { - return t('tr_numberOfMeetings'); - } - - if (type === 'total') { - return t('tr_totalAttendance'); - } - - if (type === 'average') { - return t('tr_avgAttendance'); - } - - if (type === 'average_online') { - return t('tr_avgOnline'); - } + const labels: Record = { + count: t('tr_numberOfMeetings'), + total: t('tr_totalAttendance'), + average: t('tr_avgAttendance'), + average_online: t('tr_avgOnline'), + total_deaf: t('tr_totalAttendanceDeaf'), + average_deaf: t('tr_avgAttendanceDeaf'), + }; + + return labels[type]; }, [type, t]); - const attendance = useMemo(() => { - return attendances.find((record) => record.month_date === month); - }, [attendances, month]); - const value = useMemo(() => { - if (!attendance) return ''; - - if (type === 'count') { - if (meeting === 'midweek') { - return midweek.count; - } - - if (meeting === 'weekend') { - return weekend.count; - } - } - - if (type === 'total') { - if (meeting === 'midweek') { - return midweek.total; - } - - if (meeting === 'weekend') { - return weekend.total; - } - } + if (!hasRecord) return ''; - if (type === 'average') { - if (meeting === 'midweek') { - return midweek.average; - } + const stats = meeting === 'midweek' ? midweek : weekend; - if (meeting === 'weekend') { - return weekend.average; - } - } + if (type !== 'average_online') return stats[type]; - if (type === 'average_online') { - if (meeting === 'midweek') { - return midweek.average_online; - } + const percent = + stats.average === 0 + ? 0 + : Math.round((stats.average_online * 100) / stats.average); - if (meeting === 'weekend') { - return weekend.average_online; - } - } - }, [attendance, type, meeting, midweek, weekend]); + return `${stats.average_online} (${percent}%)`; + }, [type, meeting, midweek, weekend, hasRecord]); return { label, value }; }; diff --git a/src/features/reports/meeting_attendance/monthly_history/meeting_container/useMeetingContainer.tsx b/src/features/reports/meeting_attendance/monthly_history/meeting_container/useMeetingContainer.tsx index 0877fd53322..5d9a8516e71 100644 --- a/src/features/reports/meeting_attendance/monthly_history/meeting_container/useMeetingContainer.tsx +++ b/src/features/reports/meeting_attendance/monthly_history/meeting_container/useMeetingContainer.tsx @@ -1,10 +1,14 @@ +import { useMemo } from 'react'; import { useAtomValue } from 'jotai'; import { DetailsRowProps } from '../details_row/index.types'; -import { attendanceOnlineRecordState } from '@states/settings'; -import { useMemo } from 'react'; +import { + attendanceDeafRecordState, + attendanceOnlineRecordState, +} from '@states/settings'; const useMeetingContainer = () => { const recordOnline = useAtomValue(attendanceOnlineRecordState); + const recordDeaf = useAtomValue(attendanceDeafRecordState); const labels = useMemo(() => { const base: DetailsRowProps['type'][] = ['count', 'total', 'average']; @@ -13,8 +17,12 @@ const useMeetingContainer = () => { base.push('average_online'); } + if (recordDeaf) { + base.push('total_deaf', 'average_deaf'); + } + return base; - }, [recordOnline]); + }, [recordOnline, recordDeaf]); return { labels }; }; diff --git a/src/features/reports/meeting_attendance/monthly_record/attendance_summary/useAttendanceSummary.tsx b/src/features/reports/meeting_attendance/monthly_record/attendance_summary/useAttendanceSummary.tsx index f238660a74b..d5ec414277b 100644 --- a/src/features/reports/meeting_attendance/monthly_record/attendance_summary/useAttendanceSummary.tsx +++ b/src/features/reports/meeting_attendance/monthly_record/attendance_summary/useAttendanceSummary.tsx @@ -1,62 +1,16 @@ -import { useMemo } from 'react'; -import { useAtomValue } from 'jotai'; -import { useCurrentUser } from '@hooks/index'; -import { meetingAttendanceState } from '@states/meeting_attendance'; -import { WeeklyAttendance } from '@definition/meeting_attendance'; import { AttendanceSummaryProps } from './index.types'; +import useMeetingAttendance from '../../hooks/useMeetingAttendance'; const useAttendanceSummary = ({ month, summary, type, }: AttendanceSummaryProps) => { - const { isGroup, languageGroup } = useCurrentUser(); + const { midweek, weekend } = useMeetingAttendance(month); - const attendances = useAtomValue(meetingAttendanceState); + const stats = type === 'midweek' ? midweek : weekend; - const attendance = useMemo(() => { - return attendances.find((record) => record.month_date === month); - }, [attendances, month]); - - const value = useMemo(() => { - if (!attendance) return 0; - - const values: number[] = []; - - for (let i = 1; i <= 5; i++) { - const weekData = attendance[`week_${i}`] as WeeklyAttendance; - const meetingData = weekData[type]; - - let total = 0; - - for (const data of meetingData) { - if (isGroup && languageGroup && data.type !== languageGroup.group_id) - continue; - - if (data?.present || data?.online) { - total += data?.present || 0; - total += data?.online || 0; - } - } - - if (total > 0) { - values.push(total); - } - } - - const grandTotal = values.reduce((value, current) => { - return value + current; - }, 0); - - if (summary === 'total') { - return grandTotal; - } - - const cnMeet = values.length; - return grandTotal === 0 ? 0 : Math.round(grandTotal / cnMeet); - }, [attendance, summary, type, isGroup, languageGroup]); - - return { value }; + return { value: summary === 'total' ? stats.total : stats.average }; }; export default useAttendanceSummary; diff --git a/src/features/reports/meeting_attendance/monthly_record/week_box/index.tsx b/src/features/reports/meeting_attendance/monthly_record/week_box/index.tsx index e7134f87f61..de0b49fddf4 100644 --- a/src/features/reports/meeting_attendance/monthly_record/week_box/index.tsx +++ b/src/features/reports/meeting_attendance/monthly_record/week_box/index.tsx @@ -1,26 +1,21 @@ +import { memo } from 'react'; import { Box, Stack } from '@mui/material'; -import { useAppTranslation } from '@hooks/index'; import { TextFieldStyles } from './index.styles'; import { WeekBoxProps } from './index.types'; import useWeekBox from './useWeekBox'; import NowIndicator from './now_indicator'; import TextField from '@components/textfield'; import Typography from '@components/typography'; -import { memo } from 'react'; const WeekBox = (props: WeekBoxProps) => { - const { t } = useAppTranslation(); - const { isCurrent, - recordOnline, - handlePresentChange, - present, - online, - handleOnlineChange, + isMeetingDay, + detailed, + fields, + values, + handleValueChange, total, - isMidweek, - isWeekend, box_label, noMeeting, } = useWeekBox(props); @@ -28,7 +23,7 @@ const WeekBox = (props: WeekBoxProps) => { return ( - {recordOnline && ( + {detailed && ( { )} - + {fields.map((field, index) => { + const last = detailed && index === fields.length - 1; - {recordOnline && ( - - - {isCurrent && } - - )} + return ( + + {field.section && ( + + {field.section} + + )} + + + + {last && isCurrent && } + + ); + })} - {recordOnline && ( + {detailed && ( { )} - {!recordOnline && isCurrent && } + {!detailed && isCurrent && } ); }; diff --git a/src/features/reports/meeting_attendance/monthly_record/week_box/index.types.ts b/src/features/reports/meeting_attendance/monthly_record/week_box/index.types.ts index 0b06602c7b4..98c7371b293 100644 --- a/src/features/reports/meeting_attendance/monthly_record/week_box/index.types.ts +++ b/src/features/reports/meeting_attendance/monthly_record/week_box/index.types.ts @@ -6,3 +6,16 @@ export type WeekBoxProps = { type: MeetingType; view?: string; }; + +export type WeekBoxValues = { + present: string; + online: string; + presentDeaf: string; + onlineDeaf: string; +}; + +export type WeekBoxField = { + name: keyof WeekBoxValues; + label: string; + section?: string; +}; diff --git a/src/features/reports/meeting_attendance/monthly_record/week_box/useWeekBox.tsx b/src/features/reports/meeting_attendance/monthly_record/week_box/useWeekBox.tsx index 815f72cc753..1ff7b133376 100644 --- a/src/features/reports/meeting_attendance/monthly_record/week_box/useWeekBox.tsx +++ b/src/features/reports/meeting_attendance/monthly_record/week_box/useWeekBox.tsx @@ -1,4 +1,4 @@ -import { ChangeEvent, useEffect, useMemo, useState } from 'react'; +import { ChangeEvent, useEffect, useMemo, useRef, useState } from 'react'; import { useAtomValue } from 'jotai'; import { Week } from '@definition/week_type'; import { @@ -13,13 +13,14 @@ import { getWeekDate, weeksInMonth, } from '@utils/date'; -import { WeekBoxProps } from './index.types'; +import { WeekBoxField, WeekBoxProps, WeekBoxValues } from './index.types'; import { meetingAttendanceState } from '@states/meeting_attendance'; import { - AttendanceCongregation, + AttendanceValues, WeeklyAttendance, } from '@definition/meeting_attendance'; import { + attendanceDeafRecordState, attendanceOnlineRecordState, userDataViewState, } from '@states/settings'; @@ -28,15 +29,38 @@ import { monthShortNamesState } from '@states/app'; import { schedulesState } from '@states/schedules'; import { schedulesGetMeetingDate } from '@services/app/schedules'; +const EMPTY_VALUES: WeekBoxValues = { + present: '', + online: '', + presentDeaf: '', + onlineDeaf: '', +}; + +const sumCounts = (a: string, b: string) => { + if (a.length === 0 && b.length === 0) return ''; + + return String(+a + +b); +}; + +// stored counts include the deaf attendees, so the hearing input holds the rest +const hearingCount = (total?: number, deaf?: number) => { + const hearing = (total || 0) - (deaf || 0); + + return hearing > 0 ? String(hearing) : ''; +}; + const useWeekBox = ({ month, index, type, view }: WeekBoxProps) => { const { t } = useAppTranslation(); const attendances = useAtomValue(meetingAttendanceState); const dataView = useAtomValue(userDataViewState); const recordOnline = useAtomValue(attendanceOnlineRecordState); + const recordDeaf = useAtomValue(attendanceDeafRecordState); const months = useAtomValue(monthShortNamesState); const schedules = useAtomValue(schedulesState); + const currentView = view || dataView; + const schedule = useMemo(() => { const weeks = schedules.filter((record) => record.weekOf.includes(month)); const week = weeks.at(index - 1); @@ -44,80 +68,38 @@ const useWeekBox = ({ month, index, type, view }: WeekBoxProps) => { return week; }, [schedules, month, index]); - const presentInitial = useMemo(() => { + const weekRecord = useMemo(() => { const attendance = attendances.find( (record) => record.month_date === month ); - if (attendance) { - const weeklyAttendance = attendance[`week_${index}`] as WeeklyAttendance; - - let currentRecord: AttendanceCongregation; - - if (!view) { - currentRecord = weeklyAttendance[type].find( - (record) => record.type === dataView - ); - } - - if (view) { - currentRecord = weeklyAttendance[type].find( - (record) => record.type === view - ); - } - - const newPresent = currentRecord?.present?.toString() || ''; - return newPresent; - } - - return ''; - }, [attendances, dataView, index, month, type, view]); - - const onlineInitial = useMemo(() => { - const attendance = attendances.find( - (record) => record.month_date === month - ); - - if (attendance) { - const weeklyAttendance = attendance[`week_${index}`] as WeeklyAttendance; - - let currentRecord: AttendanceCongregation; - - if (!view) { - currentRecord = weeklyAttendance[type].find( - (record) => record.type === dataView - ); - } - - if (view) { - currentRecord = weeklyAttendance[type].find( - (record) => record.type === view - ); - } - - const newOnline = currentRecord?.online?.toString() || ''; - return newOnline; - } + if (!attendance) return; - return ''; - }, [attendances, dataView, index, month, type, view]); + const weeklyAttendance = attendance[`week_${index}`] as WeeklyAttendance; - const [present, setPresent] = useState(presentInitial); - const [online, setOnline] = useState(onlineInitial); + return weeklyAttendance[type].find((record) => record.type === currentView); + }, [attendances, currentView, index, month, type]); - const total = useMemo(() => { - let cnTotal = 0; + const initialValues = useMemo(() => { + if (!weekRecord) return EMPTY_VALUES; - if (present.length > 0) { - cnTotal += +present; + if (!recordDeaf) { + return { + ...EMPTY_VALUES, + present: weekRecord.present?.toString() || '', + online: weekRecord.online?.toString() || '', + }; } - if (online.length > 0) { - cnTotal += +online; - } + return { + present: hearingCount(weekRecord.present, weekRecord.present_deaf), + online: hearingCount(weekRecord.online, weekRecord.online_deaf), + presentDeaf: weekRecord.present_deaf?.toString() || '', + onlineDeaf: weekRecord.online_deaf?.toString() || '', + }; + }, [weekRecord, recordDeaf]); - return cnTotal; - }, [present, online]); + const [values, setValues] = useState(initialValues); const weeksList = useMemo(() => { const weeks = weeksInMonth(month); @@ -136,18 +118,20 @@ const useWeekBox = ({ month, index, type, view }: WeekBoxProps) => { return today === 0 || today === 6; }, []); + const isMeetingDay = useMemo(() => { + return ( + (type === 'midweek' && isMidweek) || (type === 'weekend' && isWeekend) + ); + }, [type, isMidweek, isWeekend]); + const isCurrent = useMemo(() => { + if (!isMeetingDay) return false; + const thisWeek = formatDate(getWeekDate(), 'yyyy/MM/dd'); const findIndex = weeksList.findIndex((record) => record === thisWeek); - if (type === 'midweek' && isMidweek) { - return findIndex === index - 1; - } - - if (type === 'weekend' && isWeekend) { - return findIndex === index - 1; - } - }, [weeksList, index, type, isMidweek, isWeekend]); + return findIndex === index - 1; + }, [weeksList, index, isMeetingDay]); const noMeeting = useMemo(() => { let weekType = Week.NORMAL; @@ -157,14 +141,14 @@ const useWeekBox = ({ month, index, type, view }: WeekBoxProps) => { if (type === 'midweek') { weekType = schedule.midweek_meeting.week_type.find( - (record) => record.type === (view || dataView) + (record) => record.type === currentView )?.value ?? Week.NORMAL; } if (type === 'weekend') { weekType = schedule.weekend_meeting.week_type.find( - (record) => record.type === (view || dataView) + (record) => record.type === currentView )?.value ?? Week.NORMAL; } @@ -172,7 +156,7 @@ const useWeekBox = ({ month, index, type, view }: WeekBoxProps) => { WEEK_TYPE_NO_MEETING.includes(weekType) || WEEK_TYPE_LANGUAGE_GROUPS.includes(weekType) ); - }, [type, schedule, view, dataView]); + }, [type, schedule, currentView]); const box_label = useMemo(() => { const [year, monthValue] = month.split('/').map(Number); @@ -200,61 +184,121 @@ const useWeekBox = ({ month, index, type, view }: WeekBoxProps) => { return dateLabel; }, [month, type, index, t, months, view]); - useEffect(() => setPresent(presentInitial), [presentInitial]); - - useEffect(() => setOnline(onlineInitial), [onlineInitial]); + const detailed = recordOnline || recordDeaf; + + const fields = useMemo(() => { + const result: WeekBoxField[] = []; + + if (recordDeaf) { + result.push( + { + name: 'presentDeaf', + label: t('tr_deaf'), + section: recordOnline ? t('tr_present') : undefined, + }, + { name: 'present', label: t('tr_hearing') } + ); + } else { + result.push({ + name: 'present', + label: recordOnline ? t('tr_present') : box_label, + }); + } - const handlePresentChange = (e: ChangeEvent) => { - if (e.target.value.match(/\D/)) { - e.preventDefault(); - return; + if (recordOnline && recordDeaf) { + result.push( + { name: 'onlineDeaf', label: t('tr_deaf'), section: t('tr_online') }, + { name: 'online', label: t('tr_hearing') } + ); + } else if (recordOnline) { + result.push({ name: 'online', label: t('tr_online') }); } - const tmpValue = e.target.value; - const value = tmpValue === '' ? '' : String(+tmpValue).toString(); - setPresent(value); + return result; + }, [recordDeaf, recordOnline, box_label, t]); - meetingAttendancePresentSave({ - count: value, - index, - month, - type, - record: 'present', - dataView: view || dataView, + const total = useMemo(() => { + return fields.reduce((acc, field) => acc + (+values[field.name] || 0), 0); + }, [fields, values]); + + const savedValues = useRef(initialValues); + + // saving one field echoes back the whole record, so only fields that changed in + // the database are refreshed, otherwise a slow echo overwrites a newer input + useEffect(() => { + const previous = savedValues.current; + savedValues.current = initialValues; + + setValues((current) => { + const fieldNames = Object.keys(current) as (keyof WeekBoxValues)[]; + + return fieldNames.reduce( + (acc, field) => ({ + ...acc, + [field]: + initialValues[field] === previous[field] + ? current[field] + : initialValues[field], + }), + {} as WeekBoxValues + ); }); - }; + }, [initialValues]); - const handleOnlineChange = (e: ChangeEvent) => { - if (e.target.value.match(/\D/)) { - e.preventDefault(); + const saveAttendance = (newValues: WeekBoxValues) => { + const counts: AttendanceValues = { + present: recordDeaf + ? sumCounts(newValues.present, newValues.presentDeaf) + : newValues.present, + }; - return; + if (recordDeaf) { + counts.present_deaf = newValues.presentDeaf; } - const tmpValue = e.target.value; - const value = tmpValue === '' ? '' : String(+tmpValue).toString(); - setOnline(value); + if (recordOnline) { + counts.online = recordDeaf + ? sumCounts(newValues.online, newValues.onlineDeaf) + : newValues.online; + } + + if (recordOnline && recordDeaf) { + counts.online_deaf = newValues.onlineDeaf; + } meetingAttendancePresentSave({ - count: value, + values: counts, index, month, type, - record: 'online', - dataView: view || dataView, + dataView: currentView, }); }; + const handleValueChange = + (field: keyof WeekBoxValues) => (e: ChangeEvent) => { + if (e.target.value.match(/\D/)) { + e.preventDefault(); + return; + } + + const tmpValue = e.target.value; + const value = tmpValue === '' ? '' : String(+tmpValue); + + const newValues = { ...values, [field]: value }; + + setValues(newValues); + saveAttendance(newValues); + }; + return { isCurrent, - handlePresentChange, - present, - recordOnline, - online, - handleOnlineChange, + isMeetingDay, + detailed, + fields, + values, + handleValueChange, total, - isMidweek, - isWeekend, box_label, noMeeting, }; diff --git a/src/locales/en/congregation.json b/src/locales/en/congregation.json index 0913da93a82..7e7df7a01ee 100644 --- a/src/locales/en/congregation.json +++ b/src/locales/en/congregation.json @@ -146,6 +146,8 @@ "tr_useDisplayNameOthersDesc": "Useful for duties or cleaning schedules with a lot of content. Try and see which option works better", "tr_recordOnlineAttendance": "Record online meeting attendance", "tr_recordOnlineAttendanceDesc": "Add an ’Online’ input field to track both online and in-person attendance", + "tr_recordDeafAttendance": "Record deaf attendance separately", + "tr_recordDeafAttendanceDesc": "For sign-language congregations: count deaf and hearing attendees separately, and report both on the S-88 form", "tr_kingdomHallAddressDesc": "Provide the address to inform visiting speakers of the location", "tr_meetingSettings": "Meeting settings", "tr_midweek": "Midweek", diff --git a/src/locales/en/meetings.json b/src/locales/en/meetings.json index a525c42bf92..398e802d003 100644 --- a/src/locales/en/meetings.json +++ b/src/locales/en/meetings.json @@ -92,11 +92,15 @@ "tr_average": "Average", "tr_online": "Online", "tr_present": "Present", + "tr_deaf": "Deaf", + "tr_hearing": "Hearing", "tr_numberOfMeetings": "Number of meetings", "tr_today": "Now", "tr_totalAttendance": "Total attendance", "tr_avgAttendance": "Average attendance", "tr_avgOnline": "Average online", + "tr_totalAttendanceDeaf": "Total attendance (deaf)", + "tr_avgAttendanceDeaf": "Average attendance (deaf)", "tr_meetingWeeks": "Meeting weeks", "tr_meetingMaterialsNotAvailable": "The meeting materials for this week have not been imported to Organized yet. If they are already accessible, import them from jw.org or an .epub file.", "tr_assistant": "Assistant", diff --git a/src/services/app/meeting_attendance.ts b/src/services/app/meeting_attendance.ts index af7412fcf80..02f19391b05 100644 --- a/src/services/app/meeting_attendance.ts +++ b/src/services/app/meeting_attendance.ts @@ -2,6 +2,10 @@ import { store } from '@states/index'; import { meetingAttendanceState } from '@states/meeting_attendance'; import { debounce } from '@utils/common'; import { + AttendanceCongregation, + AttendanceRecordField, + AttendanceValues, + MeetingAttendanceStats, MeetingAttendanceType, WeeklyAttendance, } from '@definition/meeting_attendance'; @@ -14,15 +18,13 @@ import { getMessageByCode, getTranslation } from '@services/i18n/translation'; const handleUpdateRecord = ({ index, month, - record, type, - value, + values, dataView, }: { month: string; index: number; - record: 'present' | 'online'; - value: number; + values: AttendanceValues; type: MeetingType; dataView: string; }) => { @@ -56,7 +58,12 @@ const handleUpdateRecord = ({ current = meetingRecord.find((record) => record.type === dataView); } - current[record] = value; + const entries = Object.entries(values) as [AttendanceRecordField, string][]; + + for (const [field, count] of entries) { + current[field] = count.length === 0 ? undefined : +count; + } + current.updatedAt = new Date().toISOString(); return attendance; @@ -66,25 +73,21 @@ const handlePresentSaveDb = async ({ index, month, type, - count, - record, + values, dataView, }: { - count: string; month: string; index: number; type: MeetingType; - record: 'present' | 'online'; + values: AttendanceValues; dataView: string; }) => { try { - const value = count.length === 0 ? undefined : +count; const attendance = handleUpdateRecord({ index, month, - record, type, - value, + values, dataView, }); @@ -101,3 +104,50 @@ const handlePresentSaveDb = async ({ }; export const meetingAttendancePresentSave = debounce(handlePresentSaveDb, 10); + +const sumField = ( + records: AttendanceCongregation[], + field: AttendanceRecordField +) => records.reduce((acc, record) => acc + (record[field] || 0), 0); + +export const meetingAttendanceGetStats = ( + attendance: MeetingAttendanceType | undefined, + meeting: MeetingType, + category?: string +): MeetingAttendanceStats => { + let count = 0; + let total = 0; + let online = 0; + let deaf = 0; + + for (let i = 1; i <= 5; i++) { + const weekData = attendance?.[`week_${i}`] as WeeklyAttendance; + + if (!weekData) continue; + + const records = category + ? weekData[meeting].filter((record) => record.type === category) + : weekData[meeting]; + + const weekTotal = sumField(records, 'present') + sumField(records, 'online'); + + if (weekTotal === 0) continue; + + count++; + total += weekTotal; + online += sumField(records, 'online'); + deaf += + sumField(records, 'present_deaf') + sumField(records, 'online_deaf'); + } + + const average = count === 0 ? 0 : Math.round(total / count); + + return { + count, + total, + average, + average_online: count === 0 ? 0 : Math.round(online / count), + total_deaf: deaf, + average_deaf: count === 0 ? 0 : Math.round(deaf / count), + }; +}; diff --git a/src/services/dexie/schema.ts b/src/services/dexie/schema.ts index c39bfd275b2..a5b4058c5da 100644 --- a/src/services/dexie/schema.ts +++ b/src/services/dexie/schema.ts @@ -283,6 +283,9 @@ export const settingSchema: SettingsType = { attendance_online_record: [ { type: 'main', value: false, updatedAt: '', _deleted: false }, ], + attendance_deaf_record: [ + { type: 'main', value: false, updatedAt: '', _deleted: false }, + ], special_months: [], source_material: { auto_import: { diff --git a/src/states/settings.ts b/src/states/settings.ts index 540fa4d6dd4..a3f6cde3b86 100644 --- a/src/states/settings.ts +++ b/src/states/settings.ts @@ -252,6 +252,17 @@ export const attendanceOnlineRecordState = atom((get) => { ); }); +export const attendanceDeafRecordState = atom((get) => { + const settings = get(settingsState); + const dataView = get(userDataViewState); + + return ( + settings?.cong_settings?.attendance_deaf_record?.find( + (record) => record.type === dataView + )?.value ?? false + ); +}); + export const congAddressState = atom((get) => { const settings = get(settingsState); diff --git a/src/views/registerFonts.ts b/src/views/registerFonts.ts index 80b49b132f0..44f6d1cea4a 100644 --- a/src/views/registerFonts.ts +++ b/src/views/registerFonts.ts @@ -20,6 +20,33 @@ import NotoSansJPFontRegular from '/assets/fonts/NotoSansJP-Regular.ttf'; import NotoSansHebrewBold from '/assets/fonts/NotoSansHebrew-SemiBold.ttf'; import NotoSansHebrewRegular from '/assets/fonts/NotoSansHebrew-Regular.ttf'; +const HYPHENATION_MIN_LENGTH = 14; +const HYPHENATION_EDGE = 3; +const VOWELS = /[aeiouyàáâäãåæèéêëìíîïòóôöõøùúûüýÿœаеёиоуыэюяіїє]/i; + +// react-pdf hyphenates with English patterns by default, which splits short words in +// narrow table cells. Short words are now kept whole, and since no dictionary is +// available, long ones are only offered vowel-consonant boundaries to break at. +Font.registerHyphenationCallback((word) => { + if (word.length < HYPHENATION_MIN_LENGTH) return [word]; + + const parts: string[] = []; + let start = 0; + + for (let i = HYPHENATION_EDGE; i <= word.length - HYPHENATION_EDGE; i++) { + const isBoundary = VOWELS.test(word[i - 1]) && !VOWELS.test(word[i]); + + if (isBoundary && i - start >= HYPHENATION_EDGE) { + parts.push(word.slice(start, i)); + start = i; + } + } + + parts.push(word.slice(start)); + + return parts.length > 1 ? parts : [word]; +}); + Font.register({ family: 'Inter', fonts: [ diff --git a/src/views/reports/attendance/TableHeader.tsx b/src/views/reports/attendance/TableHeader.tsx index 1f1bb2dc8a3..4cbe7d4573e 100644 --- a/src/views/reports/attendance/TableHeader.tsx +++ b/src/views/reports/attendance/TableHeader.tsx @@ -3,7 +3,7 @@ import { styles } from './index.styles'; import { TableHeaderProps } from './index.types'; import { useAppTranslation } from '@hooks/index'; -const TableHeader = ({ column, year, locale }: TableHeaderProps) => { +const TableHeader = ({ column, caption, locale }: TableHeaderProps) => { const { t } = useAppTranslation(); return ( @@ -20,7 +20,7 @@ const TableHeader = ({ column, year, locale }: TableHeaderProps) => { {t('tr_serviceYear', { lng: locale }).replaceAll('-', '-\u000A')} - {year} + {caption} diff --git a/src/views/reports/attendance/index.tsx b/src/views/reports/attendance/index.tsx index bfdffb7a2d3..4219376f4c3 100644 --- a/src/views/reports/attendance/index.tsx +++ b/src/views/reports/attendance/index.tsx @@ -15,8 +15,12 @@ const TemplateS88 = ({ attendance, lang }: TemplateS88Props) => { return ( - {attendance.data.map((data) => ( - + {attendance.data.map((data, index) => ( + {t('tr_S88Title', { lng: attendance.locale })} {data.name !== 'main' && ( @@ -33,10 +37,10 @@ const TemplateS88 = ({ attendance, lang }: TemplateS88Props) => { - {data.years.map((year, index) => ( + {data.columns.map((caption, index) => ( @@ -82,10 +86,10 @@ const TemplateS88 = ({ attendance, lang }: TemplateS88Props) => { - {data.years.map((year, index) => ( + {data.columns.map((caption, index) => ( diff --git a/src/views/reports/attendance/index.types.ts b/src/views/reports/attendance/index.types.ts index 114995215da..e77012286df 100644 --- a/src/views/reports/attendance/index.types.ts +++ b/src/views/reports/attendance/index.types.ts @@ -11,7 +11,11 @@ export type AverageRowProps = { average: number; }; -export type TableHeaderProps = { column: number; year: string; locale: string }; +export type TableHeaderProps = { + column: number; + caption: string; + locale: string; +}; export type MonthlyRowProps = { column: number;