diff --git a/.github/assets/breakdown-mobile-1.png b/.github/assets/breakdown-mobile-1.png new file mode 100644 index 0000000..549765d Binary files /dev/null and b/.github/assets/breakdown-mobile-1.png differ diff --git a/.github/assets/breakdown-mobile-2.png b/.github/assets/breakdown-mobile-2.png new file mode 100644 index 0000000..0d2dd0c Binary files /dev/null and b/.github/assets/breakdown-mobile-2.png differ diff --git a/.github/assets/breakdown-summary-1.png b/.github/assets/breakdown-summary-1.png index 17243ea..250ba9a 100644 Binary files a/.github/assets/breakdown-summary-1.png and b/.github/assets/breakdown-summary-1.png differ diff --git a/.github/assets/breakdown-summary-2.png b/.github/assets/breakdown-summary-2.png index 0b03166..a46448e 100644 Binary files a/.github/assets/breakdown-summary-2.png and b/.github/assets/breakdown-summary-2.png differ diff --git a/.github/assets/shabbat-sick-vacation.png b/.github/assets/shabbat-sick-vacation.png index 38f8ad8..6c0c41c 100644 Binary files a/.github/assets/shabbat-sick-vacation.png and b/.github/assets/shabbat-sick-vacation.png differ diff --git a/.github/assets/sick-vacation-select.png b/.github/assets/sick-vacation-select.png index 11db2a9..d0ab3d2 100644 Binary files a/.github/assets/sick-vacation-select.png and b/.github/assets/sick-vacation-select.png differ diff --git a/README.md b/README.md index 124a123..d3b4f79 100644 --- a/README.md +++ b/README.md @@ -442,19 +442,19 @@ End time is next day; system asks to confirm crossing day. ### Breakdown Summaries -#### Daily Breakdown - Expanded View +#### Daily Breakdown - Desktop View Detailed breakdown showing all calculation components for a single day. -| Breakdown Summary 1 | Breakdown Summary 2 | -| :----------------------------------------------------------: | :----------------------------------------------------------: | -| ![Breakdown Day 1](./.github/assets/breakdown-summary-1.png) | ![Breakdown Day 2](./.github/assets/breakdown-summary-2.png) | +| Collapsed Details | Expanded Details | +| :-------------------------------------------------------------------------: | :------------------------------------------------------------------------: | +| ![Desktop breakdown with collapsed details](./.github/assets/breakdown-summary-1.png) | ![Desktop breakdown with expanded details](./.github/assets/breakdown-summary-2.png) | -#### Daily Breakdown - Compact View +#### Daily Breakdown - Mobile View -Condensed view for quick daily salary overview. - -![Breakdown Compact Summary](./.github/assets/breakdown-compact-summary.png) +| Collapsed Details | Expanded Details | +| :-----------------------------------------------------------------------: | :----------------------------------------------------------------------: | +| ![Mobile breakdown with collapsed details](./.github/assets/breakdown-mobile-1.png) | ![Mobile breakdown with expanded details](./.github/assets/breakdown-mobile-2.png) | #### Monthly Summary diff --git a/README_HE.md b/README_HE.md index c4c54d6..881f6c6 100644 --- a/README_HE.md +++ b/README_HE.md @@ -360,12 +360,17 @@ Shiftly כוללת שתי תצוגות חישוב עיקריות: ![Cross Day Warning](./.github/assets/cross-day-warning.png) ![Cross Day](./.github/assets/shift-save.png) -**סיכום פירוק יומי - מורחב** -![Breakdown Day 1](./.github/assets/breakdown-summary-1.png) -![Breakdown Day 2](./.github/assets/breakdown-summary-2.png) +**סיכום פירוט יומי - תצוגת מחשב** -**סיכום פירוק יומי - קומפקטי** -![Breakdown Compact](./.github/assets/breakdown-compact-summary.png) +| פרטים סגורים | פרטים מורחבים | +| :----------------------------------------------------------------------------: | :-----------------------------------------------------------------------------: | +| ![פירוט יומי במחשב עם פרטים סגורים](./.github/assets/breakdown-summary-1.png) | ![פירוט יומי במחשב עם פרטים מורחבים](./.github/assets/breakdown-summary-2.png) | + +**סיכום פירוט יומי - תצוגת מובייל** + +| פרטים סגורים | פרטים מורחבים | +| :---------------------------------------------------------------------------: | :----------------------------------------------------------------------------: | +| ![פירוט יומי במובייל עם פרטים סגורים](./.github/assets/breakdown-mobile-1.png) | ![פירוט יומי במובייל עם פרטים מורחבים](./.github/assets/breakdown-mobile-2.png) | **סיכום חודשי** ![Monthly Summary](./.github/assets/monthly-summary.png) diff --git a/src/constants/fields.constant.ts b/src/constants/fields.constant.ts index 796549d..f3d29c0 100644 --- a/src/constants/fields.constant.ts +++ b/src/constants/fields.constant.ts @@ -50,41 +50,6 @@ export const headersTable: TableHeader[] = [ { label: "סך שעות", rowSpan: 2, viewMode: "both" }, { label: "רגילות", rowSpan: 2, viewMode: "compact" }, { label: "תוספות", rowSpan: 2, viewMode: "compact" }, - { - label: "ש״נ", - children: ["100%", "125%", "150%"], - widths: [64, 64, 64], - viewMode: "expanded", - }, - { - label: "שבת", - children: ["150%", "200%"], - widths: [64, 64], - viewMode: "expanded", - }, - { - label: "תוספות", - children: ["זכות שבת", "20%", "50%"], - widths: [96, 64, 64], - viewMode: "expanded", - }, - { - label: "היעדרות", - children: ["מחלה", "חופש"], - widths: [64, 64], - viewMode: "expanded", - }, - { - label: "אש״ל", - rowSpan: 2, - viewMode: "expanded", - }, - { - label: "כלכלה", - children: ["גדולה", "קטנה"], - widths: [64, 64], - viewMode: "expanded", - }, ]; export type HolidayKey = diff --git a/src/domain/composition.ts b/src/domain/composition.ts index d9ea87c..b80f741 100644 --- a/src/domain/composition.ts +++ b/src/domain/composition.ts @@ -11,7 +11,7 @@ import { DomainConfig, PayMapPipeline } from "./types/domain.types"; export const buildPayMapPipeline = (config: DomainConfig): PayMapPipeline => { const services = buildCoreServices(config); - const resolvers = buildResolvers(); + const resolvers = buildResolvers(services.dateService); const calculators = buildCalculators(); diff --git a/src/domain/pipelines/buildResolvers.pipeline.ts b/src/domain/pipelines/buildResolvers.pipeline.ts index 09ad59c..a03bed5 100644 --- a/src/domain/pipelines/buildResolvers.pipeline.ts +++ b/src/domain/pipelines/buildResolvers.pipeline.ts @@ -5,11 +5,12 @@ import { TimelinePerDiemRateResolver, WorkDayInfoResolver, } from "../resolve"; +import { DateService } from "../services/date.service"; import { Resolvers } from "../types/domain.types"; -export const buildResolvers = (): Resolvers => { +export const buildResolvers = (dateService: DateService): Resolvers => { const holidayResolver = new HolidayResolverService(); - const workDayInfoResolver = new WorkDayInfoResolver(); + const workDayInfoResolver = new WorkDayInfoResolver(dateService); const monthResolver = new DefaultMonthResolver(); const perDiemRateResolver = new TimelinePerDiemRateResolver(); const mealAllowanceRateResolver = new TimelineMealAllowanceRateResolver(); diff --git a/src/domain/resolve/workdayinfo.resolver.ts b/src/domain/resolve/workdayinfo.resolver.ts index 2570746..290e65d 100644 --- a/src/domain/resolve/workdayinfo.resolver.ts +++ b/src/domain/resolve/workdayinfo.resolver.ts @@ -1,7 +1,10 @@ import { WorkDayType } from "@/constants"; import { DayInfoResolver, WorkDayInfo } from "@/domain"; +import { DateService } from "@/domain/services/date.service"; export class WorkDayInfoResolver implements DayInfoResolver { + constructor(private readonly dateService: DateService) {} + isSpecialFullDay(day: WorkDayInfo): boolean { return day.meta.typeDay === WorkDayType.SpecialFull; } @@ -14,10 +17,8 @@ export class WorkDayInfoResolver implements DayInfoResolver { return day.meta.crossDayContinuation === true; } - formatHebrewWorkDay(day: WorkDayInfo): string { - const dayNumber = new Date(day.meta.date).toLocaleDateString("he-IL", { - day: "2-digit", - }); - return `${day.hebrewDay}-${dayNumber}`; + formatHebrewWorkDay(day: WorkDayInfo, weekdayLabel: string): string { + const dayNumber = this.dateService.getDayOfMonth(day.meta.date); + return `${weekdayLabel}-${dayNumber}`; } } diff --git a/src/domain/services/date.service.ts b/src/domain/services/date.service.ts index 9c9b360..bae4f0a 100644 --- a/src/domain/services/date.service.ts +++ b/src/domain/services/date.service.ts @@ -43,6 +43,14 @@ export class DateService { return format(date, "yyyy-MM-dd"); } + getWeekday(date: string): number { + return this.createDateWithTime(date).getDay(); + } + + getDayOfMonth(date: string): string { + return format(this.createDateWithTime(date), "dd"); + } + createDateWithTime( day: string, hours: number = 0, diff --git a/src/domain/types/types.ts b/src/domain/types/types.ts index 91bba57..32765c6 100644 --- a/src/domain/types/types.ts +++ b/src/domain/types/types.ts @@ -1,6 +1,6 @@ import { WorkDayType, HolidayKey } from "@/constants"; -export type TableViewMode = "compact" | "expanded" | "both"; +export type TableViewMode = "compact" | "both"; import { DailyPerDiemInfo, @@ -79,7 +79,7 @@ export interface DayInfoResolver { isSpecialFullDay(day: WorkDayInfo): boolean; isPartialHolidayStart(day: WorkDayInfo): boolean; hasCrossDayContinuation(day: WorkDayInfo): boolean; - formatHebrewWorkDay(day: WorkDayInfo): string; + formatHebrewWorkDay(day: WorkDayInfo, weekdayLabel: string): string; } export enum Mode { diff --git a/src/features/work-table/components/DayDetails.tsx b/src/features/work-table/components/DayDetails.tsx index 7dbea3a..011ea82 100644 --- a/src/features/work-table/components/DayDetails.tsx +++ b/src/features/work-table/components/DayDetails.tsx @@ -11,6 +11,7 @@ import { useTranslation } from "react-i18next"; import { PayBreakdownViewModel } from "@/domain"; import { formatValue } from "@/utils"; +import { breakdownToDetailGroups, DetailGroupData } from "../mappers"; type DayDetailsProps = { breakdown: PayBreakdownViewModel; @@ -18,21 +19,7 @@ type DayDetailsProps = { showAbsence?: boolean; }; -type DetailItem = { - label: string; - value: number; -}; - -type DetailSection = { - items: DetailItem[]; - label: string; -}; - -type DetailGroupProps = { - items?: DetailItem[]; - sections?: DetailSection[]; - title?: string; -}; +type DetailGroupProps = Omit; const DetailGroup = ({ items = [], sections, title }: DetailGroupProps) => { const columns = sections @@ -122,71 +109,7 @@ export const DayDetails = ({ }: DayDetailsProps) => { const { t } = useTranslation("work-table"); - const groups: DetailGroupProps[] = [ - { - title: t("headers.overtime"), - items: [ - { label: "100%", value: breakdown.regular.hours100.hours }, - { label: "125%", value: breakdown.regular.hours125.hours }, - { label: "150%", value: breakdown.regular.hours150.hours }, - ], - }, - { - title: t("headers.shabbat"), - items: [ - { label: "150%", value: breakdown.special.shabbat150.hours }, - { label: "200%", value: breakdown.special.shabbat200.hours }, - { - label: t("headers.shabbat_credit"), - value: breakdown.appliedShabbatCredit.hours, - }, - ], - }, - { - title: t("headers.extras"), - items: [ - { label: "20%", value: breakdown.extra.hours20.hours }, - { label: "50%", value: breakdown.extra.hours50.hours }, - ], - }, - ...(showAbsence - ? [ - { - title: t("headers.absence"), - items: [ - { - label: t("headers.sick"), - value: breakdown.hours100Sick.hours, - }, - { - label: t("headers.vacation"), - value: breakdown.hours100Vacation.hours, - }, - ], - }, - ] - : []), - { - sections: [ - { - label: t("headers.meal_allowance"), - items: [ - { - label: t("day_details.points"), - value: breakdown.perDiemPoints, - }, - ], - }, - { - label: t("headers.meal_per_diem"), - items: [ - { label: t("headers.large"), value: breakdown.largePoints }, - { label: t("headers.small"), value: breakdown.smallPoints }, - ], - }, - ], - }, - ]; + const groups = breakdownToDetailGroups(breakdown, t, showAbsence); const primaryGroups = groups.slice(0, 2); const secondaryGroups = groups.slice(2); @@ -216,13 +139,8 @@ export const DayDetails = ({ }, }} > - {primaryGroups.map((group) => ( - section.label).join("-") - } - {...group} - /> + {primaryGroups.map(({ key, ...group }) => ( + ))} - {secondaryGroups.map((group) => ( - section.label).join("-") - } - {...group} - /> + {secondaryGroups.map(({ key, ...group }) => ( + ))} diff --git a/src/features/work-table/components/DayRow.tsx b/src/features/work-table/components/DayRow.tsx index 3c0180b..fef1c87 100644 --- a/src/features/work-table/components/DayRow.tsx +++ b/src/features/work-table/components/DayRow.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useState } from "react"; import { Box, Checkbox, @@ -15,21 +15,17 @@ import AddIcon from "@mui/icons-material/Add"; import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp"; -import { useGlobalState, useWorkDays } from "@/hooks"; -import { TimeFieldType, WorkDayInfo, WorkDayMap } from "@/domain"; -import { WorkDayStatus, HolidayKey } from "@/constants"; +import { WorkDayInfo } from "@/domain"; +import { WorkDayStatus, HolidayKey, headersTable } from "@/constants"; import { + CompactDayRow, + countTableColumns, DayDetails, - isSameDayPayMap, ShiftRow, - useDay, + useDayController, } from "@/features/work-table"; import { DomainContextType } from "@/app"; -import { dayToPayBreakdownVM } from "@/adapters"; -import { CompactDayRow } from "./rows/CompactDayRow"; -import { dayToCompactPayBreakdownVM } from "../mappers/dayToCompactPayBreakdownVM"; import { withErrorBoundary } from "@/hoc"; -import { analyticsService } from "@/services/analytics"; type DayRowProps = { domain: DomainContextType; @@ -46,75 +42,33 @@ const DayRowComponent = ({ }: DayRowProps) => { const { dateService } = domain.services; const { dayInfoResolver } = domain.resolvers; - const { t, i18n } = useTranslation("work-table"); + const { t } = useTranslation("work-table"); const tHoliday = (key: string) => t(`holidays.${key}` as `holidays.${HolidayKey}`); - const { baseRate, standardHours, year, month, addDay, removeDay } = - useGlobalState(); - const { isSpecialFullDay } = useWorkDays(); const [detailsOpen, setDetailsOpen] = useState(false); const { status, - setStatus, - dayPayMap, - shiftEntries, - setShiftEntries, - addShift, + isEditable, + specialFullDay, + shifts, updateShift, removeShift, - } = useDay({ domain, meta: workDay.meta, standardHours, year, month }); - - const prevDayPayMapRef = useRef(null); - - const specialFullDay = isSpecialFullDay(workDay.meta.date); - const isEditable = status === WorkDayStatus.normal; - - const handleStatusChanged = useCallback( - (newStatus: WorkDayStatus) => { - setStatus(newStatus); - setShiftEntries({}); - }, - [setStatus, setShiftEntries], - ); - - const handleAddShift = useCallback(() => { - const id = crypto.randomUUID(); - const time = dateService.createDateWithTime(workDay.meta.date); - const start: TimeFieldType = { date: time }; - const end: TimeFieldType = { date: time }; - addShift({ id, start, end, isDuty: false }); - analyticsService.track({ name: "shift_added", params: { month, year } }); - }, [workDay.meta.date, addShift, dateService, month, year]); - - useEffect(() => { - const dateKey = workDay.meta.date; - const prev = prevDayPayMapRef.current; - - if (dayPayMap.totalHours === 0) { - if (prev) { - removeDay(dateKey); - prevDayPayMapRef.current = null; - } - return; - } - - if (!prev || !isSameDayPayMap(prev, dayPayMap)) { - addDay(dateKey, dayPayMap); - prevDayPayMapRef.current = dayPayMap; - } - }, [dayPayMap, workDay.meta.date, addDay, removeDay]); + handleStatusChanged, + handleAddShift, + expandedBreakdown, + compactBreakdown, + standardHours, + baseRate, + } = useDayController({ domain, workDay, shabbatCreditHours }); - const shifts = Object.values(shiftEntries); const shiftCount = Math.max(shifts.length, 1); const detailsId = `day-details-${workDay.meta.date}`; - const expandedBreakdown = dayToPayBreakdownVM(dayPayMap, shabbatCreditHours); - const compactBreakdown = dayToCompactPayBreakdownVM( - dayPayMap, - baseRate, - shabbatCreditHours, - ); - const columnCount = baseRate > 0 ? 13 : 12; + const columnCount = countTableColumns(headersTable, "compact", baseRate); + + const days = t("days", { returnObjects: true }) as string[]; + const weekdayLabel = days[dateService.getWeekday(workDay.meta.date)]; + const dayLabel = dayInfoResolver.formatHebrewWorkDay(workDay, weekdayLabel); return ( <> @@ -142,26 +96,12 @@ const DayRowComponent = ({ gap: 0.5, }} > - {(() => { - const days = t("days", { returnObjects: true }) as string[]; - const weekday = new Date(workDay.meta.date).getDay(); - const dayNumber = new Date( - workDay.meta.date, - ).toLocaleDateString( - i18n.language === "he" ? "he-IL" : "en-US", - { day: "2-digit" }, - ); - return `${days[weekday]}-${dayNumber}`; - })()} + {dayLabel} {workDay.meta.holidayKey && ( { - const { dateService, shiftService } = domain.services; - const { localShift, update, toggleDuty, saved, setSaved } = - useShift({ shift }); - const { month, year } = useGlobalState(); - const snackbar = useAppSnackbar(); const { t } = useTranslation("work-table"); - - const handleChange = (field: "start" | "end", newDate: Date | null) => { - if (!newDate) return; - - const tf: TimeFieldType = { date: newDate }; - - const newStart = field === "start" ? tf : localShift.start; - const newEnd = field === "end" ? tf : localShift.end; - - update(newStart, newEnd); - }; - - const handleToggleNextDay = (checked: boolean) => { - const updatedEnd = domain.services.shiftService.toggleNextDay( - localShift, - checked, - ); - update(localShift.start, updatedEnd); - }; - - const crossDay = - dateService.getDaysDifference(localShift.end.date, localShift.start.date) > - 0; - - const startMinutes = shiftService.getMinutesFromMidnight( - localShift.start.date, - ); - const endMinutes = shiftService.getMinutesFromMidnight(localShift.end.date); - - const hasError = - !crossDay && endMinutes + (crossDay ? 1440 : 0) <= startMinutes; - - const handleSave = useCallback(() => { - if (hasError) { - snackbar.warning(t("shift_row.cross_midnight_warning")); - return; - } - const payMap = domain.payMap.shiftMapBuilder.build({ - shift: localShift, - meta, - standardHours, - isFieldDutyShift: localShift.isDuty, - }); - setSaved(true); - onShiftUpdate(localShift, payMap); - analyticsService.track({ name: "shift_saved", params: { month, year } }); - }, [hasError, snackbar, t, domain, localShift, meta, standardHours, setSaved, onShiftUpdate, month, year]); - - const handleEdit = () => { - setSaved(false); - }; + const { + localShift, + saved, + crossDay, + startMinutes, + endMinutes, + hasError, + handleChange, + handleToggleNextDay, + handleSave, + handleEdit, + toggleDuty, + } = useShiftEditor({ domain, shift, meta, standardHours, onShiftUpdate }); return ( <> diff --git a/src/features/work-table/components/WorkTable.tsx b/src/features/work-table/components/WorkTable.tsx index 821253c..6d8f794 100644 --- a/src/features/work-table/components/WorkTable.tsx +++ b/src/features/work-table/components/WorkTable.tsx @@ -13,21 +13,25 @@ import { Alert, Divider, TableCell, + Stack, } from "@mui/material"; import CalendarMonthIcon from "@mui/icons-material/CalendarMonth"; import { useTranslation } from "react-i18next"; -import { useGlobalState } from "@/hooks"; +import { useDeviceType, useGlobalState } from "@/hooks"; import { groupByShabbat } from "@/utils"; import { headersTable } from "@/constants"; import { + CompactDayRow, + DayCard, DayRow, + MonthSummaryCard, WorkTableHeader, + WorkTableDayStateProvider, monthToCompactPayBreakdownVM, } from "@/features/work-table"; import { DomainContextType } from "@/app"; import { ShabbatCreditAllocation, WorkDayInfo } from "@/domain"; -import { CompactDayRow } from "./rows/CompactDayRow"; type WorkTableProps = { domain: DomainContextType; @@ -41,6 +45,7 @@ export const WorkTable = ({ shabbatCreditAllocation, }: WorkTableProps) => { const { year, month, baseRate, globalBreakdown } = useGlobalState(); + const { isMobile } = useDeviceType(); const { t } = useTranslation("work-table"); const monthNames = t("months", { returnObjects: true }) as string[]; @@ -49,6 +54,12 @@ export const WorkTable = ({ // useMemo here prevents recreation on every render, but the gain is minimal const groupByWeeks = useMemo(() => groupByShabbat(workDays), [workDays]); + const monthBreakdown = monthToCompactPayBreakdownVM( + globalBreakdown, + baseRate, + shabbatCreditAllocation.usedHours, + ); + return ( @@ -63,89 +74,107 @@ export const WorkTable = ({ - {/* Table */} - - - theme.palette.grey[100], - borderBottom: "2px solid", - borderColor: "divider", - }, - "& td": { - textAlign: "center", - }, - }} - > - - {groupByWeeks.map((group) => ( - - {group.map((day, dayIndex) => { - const isLastInWeek = dayIndex === group.length - 1; - return ( - - ); - })} - + + {isMobile ? ( + + {workDays.map((day) => ( + ))} - - - + + ) : ( + + +
theme.palette.grey[100], + borderBottom: "2px solid", + borderColor: "divider", + }, + "& td": { + textAlign: "center", }, }} > - - - - -
-
-
+ + {groupByWeeks.map((group) => ( + + {group.map((day, dayIndex) => { + const isLastInWeek = dayIndex === group.length - 1; + return ( + + ); + })} + + ))} + + + + + + + + + + + )} + {shabbatCreditAllocation.earnedHours > 0 && ( { + const { dateService } = domain.services; + const { dayInfoResolver } = domain.resolvers; + const { t } = useTranslation("work-table"); + const tHoliday = (key: string) => + t(`holidays.${key}` as `holidays.${HolidayKey}`); + const [detailsOpen, setDetailsOpen] = useState(false); + + const { + status, + isEditable, + specialFullDay, + shifts, + updateShift, + removeShift, + handleStatusChanged, + handleAddShift, + expandedBreakdown, + compactBreakdown, + standardHours, + } = useDayController({ domain, workDay, shabbatCreditHours }); + + const days = t("days", { returnObjects: true }) as string[]; + const weekdayLabel = days[dateService.getWeekday(workDay.meta.date)]; + const dayLabel = dayInfoResolver.formatHebrewWorkDay(workDay, weekdayLabel); + const detailsId = `day-card-details-${workDay.meta.date}`; + + return ( + + + + {dayLabel} + {workDay.meta.holidayKey && ( + + )} + + + setDetailsOpen((open) => !open)} + sx={{ + transform: detailsOpen ? "rotate(180deg)" : "none", + transition: "transform 0.2s", + }} + > + + + + + + {!specialFullDay && ( + + + + + handleStatusChanged( + e.target.checked ? WorkDayStatus.sick : WorkDayStatus.normal, + ) + } + sx={{ p: 0.5 }} + /> + {t("headers.sick")} + + + + handleStatusChanged( + e.target.checked ? WorkDayStatus.vacation : WorkDayStatus.normal, + ) + } + sx={{ p: 0.5 }} + /> + {t("headers.vacation")} + + + + )} + + {/* Add-shift column + shifts column, mirroring the desktop table's + rowSpan'd add-shift cell sitting beside the shift-cell columns. */} + {(isEditable || shifts.length > 0) && ( + 0 ? "stretch" : "center", + gap: 1, + px: 1.5, + py: 1, + borderTop: "1px solid", + borderColor: "divider", + }} + > + {isEditable && ( + + + + + + + + )} + + + {shifts.map((entry) => ( + + ))} + + + )} + + + + + + + {compactBreakdown.dailySalary !== undefined && ( + + 0 + ? `₪${formatValue(compactBreakdown.dailySalary)}` + : "—" + } + emphasize + /> + + )} + + + + + + + + + ); +}; + +export const DayCard = withErrorBoundary(DayCardComponent, { + componentName: "DayCard", +}); diff --git a/src/features/work-table/components/cards/DayCardDetails.tsx b/src/features/work-table/components/cards/DayCardDetails.tsx new file mode 100644 index 0000000..e7dd406 --- /dev/null +++ b/src/features/work-table/components/cards/DayCardDetails.tsx @@ -0,0 +1,98 @@ +import { Accordion, AccordionDetails, AccordionSummary, Box, Typography } from "@mui/material"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import { useTranslation } from "react-i18next"; + +import { PayBreakdownViewModel } from "@/domain"; +import { formatValue } from "@/utils"; +import { breakdownToDetailGroups, DetailItem } from "../../mappers"; + +type DayCardDetailsProps = { + breakdown: PayBreakdownViewModel; + showAbsence?: boolean; +}; + +const GroupValues = ({ items }: { items: DetailItem[] }) => ( + + {items.map(({ label, value }) => ( + + + {label} + + + {formatValue(value)} + + + ))} + +); + +/** + * Mobile equivalent of DayDetails: same breakdown groups, but each group is + * independently collapsible (an accordion nested inside the day card's own + * expand toggle) instead of DayDetails' side-by-side desktop grid, since a + * phone-width column has no room to show every group open at once. + */ +export const DayCardDetails = ({ + breakdown, + showAbsence = true, +}: DayCardDetailsProps) => { + const { t } = useTranslation("work-table"); + const groups = breakdownToDetailGroups(breakdown, t, showAbsence); + + return ( + + {groups.map((group) => ( + + } + sx={{ minHeight: 40, "& .MuiAccordionSummary-content": { my: 0.75 } }} + > + + {group.title ?? group.sections?.map((section) => section.label).join(" / ")} + + + + {group.items && } + {group.sections && ( + + {group.sections.map((section) => ( + + + {section.label} + + + + ))} + + )} + + + ))} + + ); +}; diff --git a/src/features/work-table/components/cards/MonthSummaryCard.tsx b/src/features/work-table/components/cards/MonthSummaryCard.tsx new file mode 100644 index 0000000..d041f18 --- /dev/null +++ b/src/features/work-table/components/cards/MonthSummaryCard.tsx @@ -0,0 +1,50 @@ +import { Box, Card, Typography } from "@mui/material"; +import { useTranslation } from "react-i18next"; + +import { CompactPayBreakdownVM } from "@/domain"; +import { formatValue } from "@/utils"; +import { StatTile } from "./StatTile"; + +type MonthSummaryCardProps = { + breakdown: CompactPayBreakdownVM; +}; + +export const MonthSummaryCard = ({ breakdown }: MonthSummaryCardProps) => { + const { t } = useTranslation("work-table"); + + return ( + + + {t("feature_name_salary_summary")} + + + + + + + {breakdown.dailySalary !== undefined && ( + + 0 ? `₪${formatValue(breakdown.dailySalary)}` : "—" + } + emphasize + /> + + )} + + + ); +}; diff --git a/src/features/work-table/components/cards/ShiftCard.tsx b/src/features/work-table/components/cards/ShiftCard.tsx new file mode 100644 index 0000000..b9c3a3c --- /dev/null +++ b/src/features/work-table/components/cards/ShiftCard.tsx @@ -0,0 +1,177 @@ +import { Box, Checkbox, IconButton, Tooltip } from "@mui/material"; +import { useTranslation } from "react-i18next"; + +import SaveIcon from "@mui/icons-material/Save"; +import EditIcon from "@mui/icons-material/Edit"; +import DeleteIcon from "@mui/icons-material/Delete"; +import DirectionsCarOutlinedIcon from "@mui/icons-material/DirectionsCarOutlined"; +import DirectionsCarIcon from "@mui/icons-material/DirectionsCar"; + +import { Shift, ShiftPayMap, WorkDayMeta } from "@/domain"; +import { DomainContextType } from "@/app"; +import { useGlobalState } from "@/hooks"; +import { analyticsService } from "@/services/analytics"; +import { ShiftTimeInput, ShiftTimeReadonly, useShiftEditor } from "@/features/work-table"; + +type ShiftCardProps = { + domain: DomainContextType; + shift: Shift; + meta: WorkDayMeta; + standardHours: number; + isEditable: boolean; + onShiftUpdate: (shift: Shift, payMap: ShiftPayMap) => void; + onRemove: (id: string) => void; +}; + +export const ShiftCard = ({ + domain, + shift, + meta, + standardHours, + isEditable, + onShiftUpdate, + onRemove, +}: ShiftCardProps) => { + const { t } = useTranslation("work-table"); + const { month, year } = useGlobalState(); + const { + localShift, + saved, + crossDay, + startMinutes, + endMinutes, + hasError, + handleChange, + handleToggleNextDay, + handleSave, + handleEdit, + toggleDuty, + } = useShiftEditor({ domain, shift, meta, standardHours, onShiftUpdate }); + + return ( + + + {!saved ? ( + <> + handleChange("start", newVal)} + disabled={!isEditable} + /> + handleChange("end", newVal)} + disabled={!isEditable} + /> + + ) : ( + <> + + + + )} + + + {isEditable && ( + + {!saved && ( + + handleToggleNextDay(e.target.checked)} + size="small" + sx={{ + p: 0.75, + color: hasError ? "warning.main" : undefined, + "&.Mui-checked": { + color: hasError ? "warning.main" : undefined, + }, + ...(hasError && { + animation: "blink 1.5s ease-in-out infinite", + "@keyframes blink": { + "0%, 100%": { opacity: 1 }, + "50%": { opacity: 0.3 }, + }, + }), + }} + /> + + )} + + + + + {localShift.isDuty ? ( + + ) : ( + + )} + + + + + + + (saved ? handleEdit() : handleSave())} + sx={{ p: 0.75 }} + > + {saved ? ( + + ) : ( + + )} + + + + + + { + onRemove(shift.id); + analyticsService.track({ + name: "shift_deleted", + params: { month, year }, + }); + }} + sx={{ p: 0.75 }} + > + + + + + )} + + ); +}; diff --git a/src/features/work-table/components/cards/StatTile.tsx b/src/features/work-table/components/cards/StatTile.tsx new file mode 100644 index 0000000..b478089 --- /dev/null +++ b/src/features/work-table/components/cards/StatTile.tsx @@ -0,0 +1,26 @@ +import { Box, Typography } from "@mui/material"; + +type StatTileProps = { + label: string; + value: string; + emphasize?: boolean; +}; + +export const StatTile = ({ label, value, emphasize = false }: StatTileProps) => ( + + + {label} + + + {value} + + +); diff --git a/src/features/work-table/components/index.ts b/src/features/work-table/components/index.ts index 0ce2e43..51d867a 100644 --- a/src/features/work-table/components/index.ts +++ b/src/features/work-table/components/index.ts @@ -1,4 +1,3 @@ -export { ExpandedDayRow } from "./rows/ExpandedPayRow"; export { DayDetails } from "./DayDetails"; export { DayRow } from "./DayRow"; export { WorkTable } from "./WorkTable"; @@ -6,3 +5,6 @@ export { WorkTableHeader } from "./WorkTableHeader"; export { ShiftRow } from "./ShiftRow"; export { ShiftTimeInput } from "./ShiftTimeInput"; export { ShiftTimeReadonly } from "./ShiftTimeReadonly"; +export { CompactDayRow } from "./rows/CompactDayRow"; +export { DayCard } from "./cards/DayCard"; +export { MonthSummaryCard } from "./cards/MonthSummaryCard"; diff --git a/src/features/work-table/components/rows/ExpandedPayRow.tsx b/src/features/work-table/components/rows/ExpandedPayRow.tsx deleted file mode 100644 index b7a727c..0000000 --- a/src/features/work-table/components/rows/ExpandedPayRow.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import { useMemo } from "react"; -import { TableCell } from "@mui/material"; - -import { PayBreakdownViewModel } from "@/domain"; -import { computeTotalPay, formatValue } from "@/utils"; -import { baseCellSx, rightBorderIfNotFooter } from "../../helpers"; - -type ExpandedDayRowProps = { - breakdown: PayBreakdownViewModel; - baseRate: number; - isFooter?: boolean; - emptyStartCells?: number; - rowSpan?: number; -}; - -export const ExpandedDayRow = ({ - breakdown, - baseRate, - isFooter = false, - emptyStartCells = 0, - rowSpan, -}: ExpandedDayRowProps) => { - const salary = useMemo(() => { - return computeTotalPay(breakdown, baseRate); - }, [baseRate, breakdown]); - - return ( - <> - {Array.from({ length: emptyStartCells }, (_, i) => i).map((n) => ( - - ))} - - {formatValue(breakdown.actualHours)} - - - {formatValue(breakdown.totalHours)} - - - {formatValue(breakdown.regular.hours100.hours)} - - - {formatValue(breakdown.regular.hours125.hours)} - - - {formatValue(breakdown.regular.hours150.hours)} - - - - {formatValue(breakdown.special.shabbat150.hours)} - - - {formatValue(breakdown.special.shabbat200.hours)} - - - {formatValue(breakdown.appliedShabbatCredit.hours)} - - - - {formatValue(breakdown.extra.hours20.hours)} - - - {formatValue(breakdown.extra.hours50.hours)} - - - - {formatValue(breakdown.hours100Sick.hours)} - - - {formatValue(breakdown.hours100Vacation.hours)} - - - {formatValue(breakdown.perDiemPoints)} - - - - {formatValue(breakdown.largePoints)} - - - - {formatValue(breakdown.smallPoints)} - - - {baseRate > 0 && ( - - {salary > 0 ? `₪${formatValue(salary)}` : ""} - - )} - - ); -}; diff --git a/src/features/work-table/helpers/helpers.ts b/src/features/work-table/helpers/helpers.ts index 1441cf7..0d23775 100644 --- a/src/features/work-table/helpers/helpers.ts +++ b/src/features/work-table/helpers/helpers.ts @@ -27,6 +27,29 @@ export const rightBorderIfNotFooter = ( ...(isFooter ? {} : { borderRight: "1px solid black" }), }); +/** + * Total number of columns WorkTableHeader actually renders for a given + * mode — the single source of truth for the details row's colSpan, instead + * of a hand-maintained constant that silently drifts if a column is added + * or removed from headersTable or from the baseRate/details columns + * WorkTableHeader appends on top of it. + */ +export const countTableColumns = ( + headers: TableHeader[], + viewMode: TableViewMode, + baseRate: number, +): number => { + const filtered = filterHeadersByViewMode(headers, viewMode); + const headerColumns = filtered.reduce( + (sum, header) => sum + (header.children?.length ?? 1), + 0, + ); + const salaryColumn = baseRate > 0 ? 1 : 0; + const detailsColumn = viewMode === "compact" ? 1 : 0; + + return headerColumns + salaryColumn + detailsColumn; +}; + export const isSameDayPayMap = (a: WorkDayMap, b: WorkDayMap) => { return ( a.totalHours === b.totalHours && diff --git a/src/features/work-table/hooks/WorkTableDayStateProvider.tsx b/src/features/work-table/hooks/WorkTableDayStateProvider.tsx new file mode 100644 index 0000000..9887431 --- /dev/null +++ b/src/features/work-table/hooks/WorkTableDayStateProvider.tsx @@ -0,0 +1,23 @@ +import { ReactNode, useMemo, useReducer } from "react"; + +import { + WorkTableDayStateContext, + workTableDayStateReducer, +} from "./workTableDayStateContext"; + +type WorkTableDayStateProviderProps = { + children: ReactNode; +}; + +export const WorkTableDayStateProvider = ({ + children, +}: WorkTableDayStateProviderProps) => { + const [state, dispatch] = useReducer(workTableDayStateReducer, {}); + const value = useMemo(() => ({ state, dispatch }), [state]); + + return ( + + {children} + + ); +}; diff --git a/src/features/work-table/hooks/index.ts b/src/features/work-table/hooks/index.ts index 2a2cf57..f185fd0 100644 --- a/src/features/work-table/hooks/index.ts +++ b/src/features/work-table/hooks/index.ts @@ -1,2 +1,9 @@ export { useDay } from "./useDay"; export { useShift } from "./useShift"; +export { useDayController } from "./useDayController"; +export { useShiftEditor } from "./useShiftEditor"; +export { useSyncDayToGlobalState } from "./useSyncDayToGlobalState"; +export { + useWorkTableDayState, +} from "./useWorkTableDayState"; +export { WorkTableDayStateProvider } from "./WorkTableDayStateProvider"; diff --git a/src/features/work-table/hooks/useDay.ts b/src/features/work-table/hooks/useDay.ts index 1b43237..e9d0fb3 100644 --- a/src/features/work-table/hooks/useDay.ts +++ b/src/features/work-table/hooks/useDay.ts @@ -1,8 +1,8 @@ -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useMemo } from "react"; -import { WorkDayStatus } from "@/constants"; import { Shift, ShiftPayMap, WorkDayMeta } from "@/domain"; import { DomainContextType } from "@/app"; +import { useWorkTableDayState } from "./useWorkTableDayState"; type UseDayProps = { domain: DomainContextType; @@ -12,13 +12,6 @@ type UseDayProps = { month: number; }; -type ShiftEntry = { - shift: Shift; - payMap: ShiftPayMap | null; -}; - -type ShiftEntries = Record; - export const useDay = ({ domain, meta, @@ -27,10 +20,8 @@ export const useDay = ({ month, }: UseDayProps) => { const daymapBuilder = domain.payMap.dayPayMapBuilder; - - const [shiftEntries, setShiftEntries] = useState({}); - - const [status, setStatus] = useState(WorkDayStatus.normal); + const { status, setStatus, shiftEntries, setShiftEntries } = + useWorkTableDayState(meta.date); const addShift = useCallback((shift: Shift) => { setShiftEntries((prev) => { @@ -40,7 +31,7 @@ export const useDay = ({ }; return next; }); - }, []); + }, [setShiftEntries]); const updateShift = useCallback((shift: Shift, payMap: ShiftPayMap) => { setShiftEntries((prev) => { @@ -50,7 +41,7 @@ export const useDay = ({ }; return next; }); - }, []); + }, [setShiftEntries]); const removeShift = useCallback((id: string) => { setShiftEntries((prev) => { @@ -59,7 +50,7 @@ export const useDay = ({ return copy; }); - }, []); + }, [setShiftEntries]); const dayPayMap = useMemo(() => { const payMaps = Object.values(shiftEntries) diff --git a/src/features/work-table/hooks/useDayController.ts b/src/features/work-table/hooks/useDayController.ts new file mode 100644 index 0000000..43dab0d --- /dev/null +++ b/src/features/work-table/hooks/useDayController.ts @@ -0,0 +1,95 @@ +import { useCallback } from "react"; + +import { useGlobalState, useWorkDays } from "@/hooks"; +import { TimeFieldType, WorkDayInfo } from "@/domain"; +import { WorkDayStatus } from "@/constants"; +import { DomainContextType } from "@/app"; +import { dayToPayBreakdownVM } from "@/adapters"; +import { analyticsService } from "@/services/analytics"; +import { useDay } from "./useDay"; +import { useSyncDayToGlobalState } from "./useSyncDayToGlobalState"; +import { dayToCompactPayBreakdownVM } from "../mappers/dayToCompactPayBreakdownVM"; + +type UseDayControllerProps = { + domain: DomainContextType; + workDay: WorkDayInfo; + shabbatCreditHours: number; +}; + +/** + * Orchestration shared by every presentation of a work day (desktop table + * row, mobile card): owns the day's editing state, keeps it synced to the + * global daily pay map, and derives the view models both layouts render. + */ +export const useDayController = ({ + domain, + workDay, + shabbatCreditHours, +}: UseDayControllerProps) => { + const { dateService } = domain.services; + const { baseRate, standardHours, year, month, addDay, removeDay } = + useGlobalState(); + const { isSpecialFullDay } = useWorkDays(); + + const { + status, + setStatus, + dayPayMap, + shiftEntries, + setShiftEntries, + addShift, + updateShift, + removeShift, + } = useDay({ domain, meta: workDay.meta, standardHours, year, month }); + + useSyncDayToGlobalState({ + dateKey: workDay.meta.date, + dayPayMap, + addDay, + removeDay, + }); + + const specialFullDay = isSpecialFullDay(workDay.meta.date); + const isEditable = status === WorkDayStatus.normal; + + const handleStatusChanged = useCallback( + (newStatus: WorkDayStatus) => { + setStatus(newStatus); + setShiftEntries({}); + }, + [setStatus, setShiftEntries], + ); + + const handleAddShift = useCallback(() => { + const id = crypto.randomUUID(); + const time = dateService.createDateWithTime(workDay.meta.date); + const start: TimeFieldType = { date: time }; + const end: TimeFieldType = { date: time }; + addShift({ id, start, end, isDuty: false }); + analyticsService.track({ name: "shift_added", params: { month, year } }); + }, [workDay.meta.date, addShift, dateService, month, year]); + + const shifts = Object.values(shiftEntries); + + const expandedBreakdown = dayToPayBreakdownVM(dayPayMap, shabbatCreditHours); + const compactBreakdown = dayToCompactPayBreakdownVM( + dayPayMap, + baseRate, + shabbatCreditHours, + ); + + return { + status, + isEditable, + specialFullDay, + shifts, + updateShift, + removeShift, + handleStatusChanged, + handleAddShift, + expandedBreakdown, + compactBreakdown, + standardHours, + baseRate, + }; +}; diff --git a/src/features/work-table/hooks/useShiftEditor.ts b/src/features/work-table/hooks/useShiftEditor.ts new file mode 100644 index 0000000..4af514b --- /dev/null +++ b/src/features/work-table/hooks/useShiftEditor.ts @@ -0,0 +1,111 @@ +import { useCallback } from "react"; +import { useTranslation } from "react-i18next"; + +import { Shift, ShiftPayMap, TimeFieldType, WorkDayMeta } from "@/domain"; +import { useAppSnackbar, useGlobalState } from "@/hooks"; +import { DomainContextType } from "@/app"; +import { analyticsService } from "@/services/analytics"; +import { useShift } from "./useShift"; + +type UseShiftEditorProps = { + domain: DomainContextType; + shift: Shift; + meta: WorkDayMeta; + standardHours: number; + onShiftUpdate: (shift: Shift, payMap: ShiftPayMap) => void; +}; + +/** + * Editing behavior shared by every presentation of a shift (desktop row, + * mobile card): draft state, cross-midnight validation, save/edit toggling. + */ +export const useShiftEditor = ({ + domain, + shift, + meta, + standardHours, + onShiftUpdate, +}: UseShiftEditorProps) => { + const { dateService, shiftService } = domain.services; + const { localShift, update, toggleDuty, saved, setSaved } = useShift({ + shift, + }); + + const { month, year } = useGlobalState(); + const snackbar = useAppSnackbar(); + const { t } = useTranslation("work-table"); + + const handleChange = (field: "start" | "end", newDate: Date | null) => { + if (!newDate) return; + + const tf: TimeFieldType = { date: newDate }; + + const newStart = field === "start" ? tf : localShift.start; + const newEnd = field === "end" ? tf : localShift.end; + + update(newStart, newEnd); + }; + + const handleToggleNextDay = (checked: boolean) => { + const updatedEnd = shiftService.toggleNextDay(localShift, checked); + update(localShift.start, updatedEnd); + }; + + const crossDay = + dateService.getDaysDifference(localShift.end.date, localShift.start.date) > + 0; + + const startMinutes = shiftService.getMinutesFromMidnight( + localShift.start.date, + ); + const endMinutes = shiftService.getMinutesFromMidnight(localShift.end.date); + + const hasError = + !crossDay && endMinutes + (crossDay ? 1440 : 0) <= startMinutes; + + const handleSave = useCallback(() => { + if (hasError) { + snackbar.warning(t("shift_row.cross_midnight_warning")); + return; + } + const payMap = domain.payMap.shiftMapBuilder.build({ + shift: localShift, + meta, + standardHours, + isFieldDutyShift: localShift.isDuty, + }); + setSaved(true); + onShiftUpdate(localShift, payMap); + analyticsService.track({ name: "shift_saved", params: { month, year } }); + }, [ + hasError, + snackbar, + t, + domain, + localShift, + meta, + standardHours, + setSaved, + onShiftUpdate, + month, + year, + ]); + + const handleEdit = () => { + setSaved(false); + }; + + return { + localShift, + saved, + crossDay, + startMinutes, + endMinutes, + hasError, + handleChange, + handleToggleNextDay, + handleSave, + handleEdit, + toggleDuty, + }; +}; diff --git a/src/features/work-table/hooks/useSyncDayToGlobalState.ts b/src/features/work-table/hooks/useSyncDayToGlobalState.ts new file mode 100644 index 0000000..6df63b1 --- /dev/null +++ b/src/features/work-table/hooks/useSyncDayToGlobalState.ts @@ -0,0 +1,41 @@ +import { useEffect, useRef } from "react"; + +import { WorkDayMap } from "@/domain"; +import { isSameDayPayMap } from "../helpers"; + +type UseSyncDayToGlobalStateProps = { + dateKey: string; + dayPayMap: WorkDayMap; + addDay: (dateKey: string, dayPayMap: WorkDayMap) => void; + removeDay: (dateKey: string) => void; +}; + +/** + * Keeps the global daily pay map in sync with a day's local pay map, + * without re-dispatching when nothing actually changed. + */ +export const useSyncDayToGlobalState = ({ + dateKey, + dayPayMap, + addDay, + removeDay, +}: UseSyncDayToGlobalStateProps) => { + const prevDayPayMapRef = useRef(null); + + useEffect(() => { + const prev = prevDayPayMapRef.current; + + if (dayPayMap.totalHours === 0) { + if (prev) { + removeDay(dateKey); + prevDayPayMapRef.current = null; + } + return; + } + + if (!prev || !isSameDayPayMap(prev, dayPayMap)) { + addDay(dateKey, dayPayMap); + prevDayPayMapRef.current = dayPayMap; + } + }, [dayPayMap, dateKey, addDay, removeDay]); +}; diff --git a/src/features/work-table/hooks/useWorkTableDayState.tsx b/src/features/work-table/hooks/useWorkTableDayState.tsx new file mode 100644 index 0000000..f470c3a --- /dev/null +++ b/src/features/work-table/hooks/useWorkTableDayState.tsx @@ -0,0 +1,37 @@ +import { Dispatch, SetStateAction, useCallback, useContext } from "react"; + +import { WorkDayStatus } from "@/constants"; +import { + emptyDayState, + ShiftEntries, + WorkTableDayStateContext, +} from "./workTableDayStateContext"; + +export const useWorkTableDayState = (dateKey: string) => { + const context = useContext(WorkTableDayStateContext); + + if (!context) { + throw new Error( + "useWorkTableDayState must be used within WorkTableDayStateProvider", + ); + } + + const { state, dispatch } = context; + const dayState = state[dateKey] ?? emptyDayState; + + const setStatus: Dispatch> = useCallback( + (value) => dispatch({ type: "setStatus", dateKey, value }), + [dispatch, dateKey], + ); + + const setShiftEntries: Dispatch> = useCallback( + (value) => dispatch({ type: "setShiftEntries", dateKey, value }), + [dispatch, dateKey], + ); + + return { + ...dayState, + setStatus, + setShiftEntries, + }; +}; diff --git a/src/features/work-table/hooks/workTableDayStateContext.ts b/src/features/work-table/hooks/workTableDayStateContext.ts new file mode 100644 index 0000000..4b5a917 --- /dev/null +++ b/src/features/work-table/hooks/workTableDayStateContext.ts @@ -0,0 +1,72 @@ +import { createContext, Dispatch, SetStateAction } from "react"; + +import { WorkDayStatus } from "@/constants"; +import { Shift, ShiftPayMap } from "@/domain"; + +export type ShiftEntry = { + shift: Shift; + payMap: ShiftPayMap | null; +}; + +export type ShiftEntries = Record; + +export type DayEditingState = { + status: WorkDayStatus; + shiftEntries: ShiftEntries; +}; + +export type WorkTableDayState = Record; + +export type WorkTableDayStateAction = + | { + type: "setStatus"; + dateKey: string; + value: SetStateAction; + } + | { + type: "setShiftEntries"; + dateKey: string; + value: SetStateAction; + }; + +export type WorkTableDayStateContextValue = { + state: WorkTableDayState; + dispatch: Dispatch; +}; + +export const emptyDayState: DayEditingState = { + status: WorkDayStatus.normal, + shiftEntries: {}, +}; + +export const WorkTableDayStateContext = + createContext(null); + +export const workTableDayStateReducer = ( + state: WorkTableDayState, + action: WorkTableDayStateAction, +): WorkTableDayState => { + const currentDayState = state[action.dateKey] ?? emptyDayState; + + if (action.type === "setStatus") { + const status = + typeof action.value === "function" + ? action.value(currentDayState.status) + : action.value; + + return { + ...state, + [action.dateKey]: { ...currentDayState, status }, + }; + } + + const shiftEntries = + typeof action.value === "function" + ? action.value(currentDayState.shiftEntries) + : action.value; + + return { + ...state, + [action.dateKey]: { ...currentDayState, shiftEntries }, + }; +}; diff --git a/src/features/work-table/mappers/breakdownToDetailGroups.ts b/src/features/work-table/mappers/breakdownToDetailGroups.ts new file mode 100644 index 0000000..944d767 --- /dev/null +++ b/src/features/work-table/mappers/breakdownToDetailGroups.ts @@ -0,0 +1,89 @@ +import { TFunction } from "i18next"; + +import { PayBreakdownViewModel } from "@/domain"; + +export type DetailItem = { label: string; value: number }; +export type DetailSection = { label: string; items: DetailItem[] }; +export type DetailGroupData = { + key: string; + title?: string; + items?: DetailItem[]; + sections?: DetailSection[]; +}; + +type Translate = TFunction<"work-table">; + +/** + * Builds the per-day breakdown groups (overtime, shabbat, extras, absence, + * meal allowance) shared by the desktop grid (DayDetails) and the mobile + * nested accordion (DayCardDetails) — only the group/label/value data is + * shared; each layout owns its own presentation of it. + */ +export const breakdownToDetailGroups = ( + breakdown: PayBreakdownViewModel, + t: Translate, + showAbsence: boolean, +): DetailGroupData[] => [ + { + key: "overtime", + title: t("headers.overtime"), + items: [ + { label: "100%", value: breakdown.regular.hours100.hours }, + { label: "125%", value: breakdown.regular.hours125.hours }, + { label: "150%", value: breakdown.regular.hours150.hours }, + ], + }, + { + key: "shabbat", + title: t("headers.shabbat"), + items: [ + { label: "150%", value: breakdown.special.shabbat150.hours }, + { label: "200%", value: breakdown.special.shabbat200.hours }, + { + label: t("headers.shabbat_credit"), + value: breakdown.appliedShabbatCredit.hours, + }, + ], + }, + { + key: "extras", + title: t("headers.extras"), + items: [ + { label: "20%", value: breakdown.extra.hours20.hours }, + { label: "50%", value: breakdown.extra.hours50.hours }, + ], + }, + ...(showAbsence + ? [ + { + key: "absence", + title: t("headers.absence"), + items: [ + { label: t("headers.sick"), value: breakdown.hours100Sick.hours }, + { + label: t("headers.vacation"), + value: breakdown.hours100Vacation.hours, + }, + ], + }, + ] + : []), + { + key: "meal", + sections: [ + { + label: t("headers.meal_allowance"), + items: [ + { label: t("day_details.points"), value: breakdown.perDiemPoints }, + ], + }, + { + label: t("headers.meal_per_diem"), + items: [ + { label: t("headers.large"), value: breakdown.largePoints }, + { label: t("headers.small"), value: breakdown.smallPoints }, + ], + }, + ], + }, +]; diff --git a/src/features/work-table/mappers/index.ts b/src/features/work-table/mappers/index.ts index 1b093b1..0d92154 100644 --- a/src/features/work-table/mappers/index.ts +++ b/src/features/work-table/mappers/index.ts @@ -1,3 +1,4 @@ +export * from "./breakdownToDetailGroups"; export * from "./dayToCompactPayBreakdownVM"; export * from "./dayToPayBreakdownVM"; export * from "./monthToCompactPayBreakdownVM"; diff --git a/src/i18n/locales/en/work-table.json b/src/i18n/locales/en/work-table.json index 8afe4f4..e0301ad 100644 --- a/src/i18n/locales/en/work-table.json +++ b/src/i18n/locales/en/work-table.json @@ -63,14 +63,14 @@ }, "table": { "month_hours_title": "{{monthName}} {{year}} Hours", - "toggle_view_tooltip": "Toggle between compact and full view", - "toggle_view_label": "Full view", "hint_add_shift": "Click ➕ to add a shift", "hint_cross_midnight": "Check ✅ for shifts crossing midnight", "hint_duty_shift": "Click 🚗 to mark a duty shift (meal allowance eligibility)", "hint_save_shift": "Click 💾 to save changes and update salary", "shabbat_credit_summary": "Shabbat credit — earned: {{earned}}, used: {{used}}, unused: {{unused}}.", - "shabbat_credit_unused_note": "Unused Shabbat credit is not included in total hours or salary." + "shabbat_credit_unused_note": "Unused Shabbat credit is not included in total hours or salary.", + "add_shift_label": "Add shift", + "total_gross_label": "Total gross" }, "day_details": { "show": "Show day details", diff --git a/src/i18n/locales/he/work-table.json b/src/i18n/locales/he/work-table.json index 7c7e517..da0739b 100644 --- a/src/i18n/locales/he/work-table.json +++ b/src/i18n/locales/he/work-table.json @@ -63,14 +63,14 @@ }, "table": { "month_hours_title": "שעות חודש {{monthName}} {{year}}", - "toggle_view_tooltip": "החלף בין תצוגה מצומצמת למלאה", - "toggle_view_label": "תצוגה מלאה", "hint_add_shift": "לחץ על ➕ להוספת משמרת", "hint_cross_midnight": "סמן ✅ למשמרת שחוצה את חצות", "hint_duty_shift": "לחץ על 🚗 לסימון משמרת בתפקיד (זכאות אש״ל)", "hint_save_shift": "לחץ על 💾 לשמירת שינויים ועדכון שכר", "shabbat_credit_summary": "זכות שבת — נצברו: {{earned}}, נוצלו: {{used}}, לא נוצלו: {{unused}}.", - "shabbat_credit_unused_note": "שעות זכות שבת שלא נוצלו אינן נכללות בסך השעות או בחישוב השכר." + "shabbat_credit_unused_note": "שעות זכות שבת שלא נוצלו אינן נכללות בסך השעות או בחישוב השכר.", + "add_shift_label": "הוסף משמרת", + "total_gross_label": "סה״כ ברוטו" }, "day_details": { "show": "הצג פרטי יום", diff --git a/src/services/analytics/events.ts b/src/services/analytics/events.ts index 41c7cdf..ed3402d 100644 --- a/src/services/analytics/events.ts +++ b/src/services/analytics/events.ts @@ -19,10 +19,6 @@ export type AnalyticsEvent = error_type: string; }; } - | { - name: "view_mode_toggled"; - params: { mode: "compact" | "expanded" }; - } | { name: "salary_summary_viewed"; params: { month: number; year: number }; diff --git a/src/test/domain/builder/workdaysformonth.builder.test.ts b/src/test/domain/builder/workdaysformonth.builder.test.ts index 8105505..6c62f64 100644 --- a/src/test/domain/builder/workdaysformonth.builder.test.ts +++ b/src/test/domain/builder/workdaysformonth.builder.test.ts @@ -26,7 +26,7 @@ describe("DefaultWorkDaysForMonthBuilder", () => { beforeEach(() => { dateService = new DateService("Asia/Jerusalem"); holidayResolver = new HolidayResolverService(); - workDayInfoResolver = new WorkDayInfoResolver(); + workDayInfoResolver = new WorkDayInfoResolver(dateService); builder = new DefaultWorkDaysForMonthBuilder( holidayResolver, diff --git a/src/test/domain/resolve/workdayinfo.resolver.test.ts b/src/test/domain/resolve/workdayinfo.resolver.test.ts index 6d4f367..48aac14 100644 --- a/src/test/domain/resolve/workdayinfo.resolver.test.ts +++ b/src/test/domain/resolve/workdayinfo.resolver.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, beforeEach } from "vitest"; import { WorkDayInfoResolver } from "@/domain/resolve/workdayinfo.resolver"; +import { DateService } from "@/domain/services/date.service"; import { WorkDayType } from "@/constants/fields.constant"; import type { WorkDayInfo } from "@/domain/types/types"; @@ -7,7 +8,7 @@ describe("WorkDayInfoResolver", () => { let resolver: WorkDayInfoResolver; beforeEach(() => { - resolver = new WorkDayInfoResolver(); + resolver = new WorkDayInfoResolver(new DateService("Asia/Jerusalem")); }); // Helper function to create WorkDayInfo @@ -312,7 +313,7 @@ describe("WorkDayInfoResolver", () => { "א" ); - const result = resolver.formatHebrewWorkDay(day); + const result = resolver.formatHebrewWorkDay(day, day.hebrewDay); expect(result).toBe("א-01"); }); @@ -325,7 +326,7 @@ describe("WorkDayInfoResolver", () => { "ב" ); - const result = resolver.formatHebrewWorkDay(day); + const result = resolver.formatHebrewWorkDay(day, day.hebrewDay); expect(result).toBe("ב-15"); }); @@ -338,7 +339,7 @@ describe("WorkDayInfoResolver", () => { "ו" ); - const result = resolver.formatHebrewWorkDay(day); + const result = resolver.formatHebrewWorkDay(day, day.hebrewDay); expect(result).toBe("ו-05"); }); @@ -351,7 +352,7 @@ describe("WorkDayInfoResolver", () => { "ש" ); - const result = resolver.formatHebrewWorkDay(day); + const result = resolver.formatHebrewWorkDay(day, day.hebrewDay); expect(result).toBe("ש-06"); }); @@ -376,7 +377,7 @@ describe("WorkDayInfoResolver", () => { hebrewDays[index] ); - const result = resolver.formatHebrewWorkDay(day); + const result = resolver.formatHebrewWorkDay(day, day.hebrewDay); expect(result).toMatch(new RegExp(`^${hebrewDays[index]}-\\d{2}$`)); }); @@ -390,7 +391,7 @@ describe("WorkDayInfoResolver", () => { "ו" ); - const result = resolver.formatHebrewWorkDay(day); + const result = resolver.formatHebrewWorkDay(day, day.hebrewDay); expect(result).toBe("ו-05"); expect(result).toMatch(/^.+-\d{2}$/); @@ -404,7 +405,7 @@ describe("WorkDayInfoResolver", () => { "ה" ); - const result = resolver.formatHebrewWorkDay(day); + const result = resolver.formatHebrewWorkDay(day, day.hebrewDay); expect(result).toBe("ה-25"); }); @@ -417,20 +418,20 @@ describe("WorkDayInfoResolver", () => { "ד" ); - const result = resolver.formatHebrewWorkDay(day); + const result = resolver.formatHebrewWorkDay(day, day.hebrewDay); expect(result).toBe("ד-31"); }); - it("should format Hebrew day with ISO date string", () => { + it("should format Hebrew day for a mid-month date", () => { const day = createWorkDayInfo( - "2024-01-15T00:00:00.000Z", + "2024-01-15", WorkDayType.Regular, false, "ב" ); - const result = resolver.formatHebrewWorkDay(day); + const result = resolver.formatHebrewWorkDay(day, day.hebrewDay); expect(result).toMatch(/^ב-\d{2}$/); }); @@ -449,8 +450,8 @@ describe("WorkDayInfoResolver", () => { "ד" ); - const result1 = resolver.formatHebrewWorkDay(day1); - const result2 = resolver.formatHebrewWorkDay(day2); + const result1 = resolver.formatHebrewWorkDay(day1, day1.hebrewDay); + const result2 = resolver.formatHebrewWorkDay(day2, day2.hebrewDay); expect(result1).toBe("ד-14"); expect(result2).toBe("ד-25"); @@ -464,11 +465,36 @@ describe("WorkDayInfoResolver", () => { "ה" ); - const result = resolver.formatHebrewWorkDay(day); + const result = resolver.formatHebrewWorkDay(day, day.hebrewDay); expect(result).toMatch(/^.+-\d{2}$/); expect(result.split("-")).toHaveLength(2); }); + + it("uses the given weekday label instead of day.hebrewDay, so a non-Hebrew locale renders its own weekday abbreviation", () => { + const day = createWorkDayInfo( + "2024-01-10", + WorkDayType.Regular, + false, + "ד" + ); + + const result = resolver.formatHebrewWorkDay(day, "We"); + + expect(result).toBe("We-10"); + }); + + it("formats the day number the same way regardless of which weekday label is passed", () => { + const day = createWorkDayInfo( + "2024-01-31", + WorkDayType.Regular, + false, + "ד" + ); + + expect(resolver.formatHebrewWorkDay(day, day.hebrewDay)).toBe("ד-31"); + expect(resolver.formatHebrewWorkDay(day, "We")).toBe("We-31"); + }); }); describe("Edge Cases and Boundaries", () => { @@ -480,7 +506,7 @@ describe("WorkDayInfoResolver", () => { "ה" ); - const result = resolver.formatHebrewWorkDay(day); + const result = resolver.formatHebrewWorkDay(day, day.hebrewDay); expect(result).toBe("ה-29"); }); @@ -493,7 +519,7 @@ describe("WorkDayInfoResolver", () => { "א" ); - expect(resolver.formatHebrewWorkDay(day)).toBe("א-01"); + expect(resolver.formatHebrewWorkDay(day, day.hebrewDay)).toBe("א-01"); expect(resolver.isSpecialFullDay(day)).toBe(false); expect(resolver.isPartialHolidayStart(day)).toBe(false); expect(resolver.hasCrossDayContinuation(day)).toBe(false); @@ -507,7 +533,7 @@ describe("WorkDayInfoResolver", () => { "ג" ); - expect(resolver.formatHebrewWorkDay(day)).toBe("ג-31"); + expect(resolver.formatHebrewWorkDay(day, day.hebrewDay)).toBe("ג-31"); expect(resolver.isSpecialFullDay(day)).toBe(false); expect(resolver.isPartialHolidayStart(day)).toBe(false); expect(resolver.hasCrossDayContinuation(day)).toBe(false); @@ -562,7 +588,7 @@ describe("WorkDayInfoResolver", () => { expect(resolver.isSpecialFullDay(friday)).toBe(false); expect(resolver.isPartialHolidayStart(friday)).toBe(true); expect(resolver.hasCrossDayContinuation(friday)).toBe(true); - expect(resolver.formatHebrewWorkDay(friday)).toBe("ו-05"); + expect(resolver.formatHebrewWorkDay(friday, friday.hebrewDay)).toBe("ו-05"); }); it("should handle typical Shabbat (Saturday)", () => { @@ -576,7 +602,7 @@ describe("WorkDayInfoResolver", () => { expect(resolver.isSpecialFullDay(saturday)).toBe(true); expect(resolver.isPartialHolidayStart(saturday)).toBe(false); expect(resolver.hasCrossDayContinuation(saturday)).toBe(false); - expect(resolver.formatHebrewWorkDay(saturday)).toBe("ש-06"); + expect(resolver.formatHebrewWorkDay(saturday, saturday.hebrewDay)).toBe("ש-06"); }); it("should handle typical weekday (Monday)", () => { @@ -590,7 +616,7 @@ describe("WorkDayInfoResolver", () => { expect(resolver.isSpecialFullDay(monday)).toBe(false); expect(resolver.isPartialHolidayStart(monday)).toBe(false); expect(resolver.hasCrossDayContinuation(monday)).toBe(false); - expect(resolver.formatHebrewWorkDay(monday)).toBe("ב-01"); + expect(resolver.formatHebrewWorkDay(monday, monday.hebrewDay)).toBe("ב-01"); }); it("should handle Erev Pesach scenario", () => { @@ -604,7 +630,7 @@ describe("WorkDayInfoResolver", () => { expect(resolver.isSpecialFullDay(erevPesach)).toBe(false); expect(resolver.isPartialHolidayStart(erevPesach)).toBe(true); expect(resolver.hasCrossDayContinuation(erevPesach)).toBe(true); - expect(resolver.formatHebrewWorkDay(erevPesach)).toBe("ב-22"); + expect(resolver.formatHebrewWorkDay(erevPesach, erevPesach.hebrewDay)).toBe("ב-22"); }); it("should handle Pesach I scenario", () => { @@ -618,7 +644,7 @@ describe("WorkDayInfoResolver", () => { expect(resolver.isSpecialFullDay(pesach)).toBe(true); expect(resolver.isPartialHolidayStart(pesach)).toBe(false); expect(resolver.hasCrossDayContinuation(pesach)).toBe(false); - expect(resolver.formatHebrewWorkDay(pesach)).toBe("ג-23"); + expect(resolver.formatHebrewWorkDay(pesach, pesach.hebrewDay)).toBe("ג-23"); }); it("should handle regular Thursday before regular Friday", () => { @@ -632,7 +658,7 @@ describe("WorkDayInfoResolver", () => { expect(resolver.isSpecialFullDay(thursday)).toBe(false); expect(resolver.isPartialHolidayStart(thursday)).toBe(false); expect(resolver.hasCrossDayContinuation(thursday)).toBe(false); - expect(resolver.formatHebrewWorkDay(thursday)).toBe("ה-04"); + expect(resolver.formatHebrewWorkDay(thursday, thursday.hebrewDay)).toBe("ה-04"); }); it("should handle crossDayShift scenario starting Thursday night", () => { @@ -646,7 +672,7 @@ describe("WorkDayInfoResolver", () => { expect(resolver.isSpecialFullDay(thursday)).toBe(false); expect(resolver.isPartialHolidayStart(thursday)).toBe(false); expect(resolver.hasCrossDayContinuation(thursday)).toBe(true); - expect(resolver.formatHebrewWorkDay(thursday)).toBe("ה-04"); + expect(resolver.formatHebrewWorkDay(thursday, thursday.hebrewDay)).toBe("ה-04"); }); }); @@ -659,9 +685,9 @@ describe("WorkDayInfoResolver", () => { "ו" ); - const result1 = resolver.formatHebrewWorkDay(day); - const result2 = resolver.formatHebrewWorkDay(day); - const result3 = resolver.formatHebrewWorkDay(day); + const result1 = resolver.formatHebrewWorkDay(day, day.hebrewDay); + const result2 = resolver.formatHebrewWorkDay(day, day.hebrewDay); + const result3 = resolver.formatHebrewWorkDay(day, day.hebrewDay); expect(result1).toBe(result2); expect(result2).toBe(result3); @@ -688,13 +714,13 @@ describe("WorkDayInfoResolver", () => { "ב" ); - resolver.formatHebrewWorkDay(day); + resolver.formatHebrewWorkDay(day, day.hebrewDay); resolver.isSpecialFullDay(day); resolver.isPartialHolidayStart(day); resolver.hasCrossDayContinuation(day); // Should still work correctly after multiple calls - expect(resolver.formatHebrewWorkDay(day)).toBe("ב-15"); + expect(resolver.formatHebrewWorkDay(day, day.hebrewDay)).toBe("ב-15"); expect(resolver.isSpecialFullDay(day)).toBe(false); }); }); @@ -711,7 +737,7 @@ describe("WorkDayInfoResolver", () => { expect(resolver.isSpecialFullDay(day)).toBe(false); expect(resolver.isPartialHolidayStart(day)).toBe(false); expect(resolver.hasCrossDayContinuation(day)).toBe(false); - expect(resolver.formatHebrewWorkDay(day)).toBe("ד-10"); + expect(resolver.formatHebrewWorkDay(day, day.hebrewDay)).toBe("ד-10"); }); it("should correctly evaluate all methods for SpecialPartialStart day", () => { @@ -725,7 +751,7 @@ describe("WorkDayInfoResolver", () => { expect(resolver.isSpecialFullDay(day)).toBe(false); expect(resolver.isPartialHolidayStart(day)).toBe(true); expect(resolver.hasCrossDayContinuation(day)).toBe(true); - expect(resolver.formatHebrewWorkDay(day)).toBe("ו-12"); + expect(resolver.formatHebrewWorkDay(day, day.hebrewDay)).toBe("ו-12"); }); it("should correctly evaluate all methods for SpecialFull day", () => { @@ -739,7 +765,7 @@ describe("WorkDayInfoResolver", () => { expect(resolver.isSpecialFullDay(day)).toBe(true); expect(resolver.isPartialHolidayStart(day)).toBe(false); expect(resolver.hasCrossDayContinuation(day)).toBe(false); - expect(resolver.formatHebrewWorkDay(day)).toBe("ש-13"); + expect(resolver.formatHebrewWorkDay(day, day.hebrewDay)).toBe("ש-13"); }); }); }); diff --git a/src/test/ui/features/DayCardDetails.test.tsx b/src/test/ui/features/DayCardDetails.test.tsx new file mode 100644 index 0000000..438914a --- /dev/null +++ b/src/test/ui/features/DayCardDetails.test.tsx @@ -0,0 +1,69 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import i18n from "@/i18n"; +import { PayBreakdownViewModel } from "@/domain"; +import { DayCardDetails } from "@/features/work-table/components/cards/DayCardDetails"; +import { renderWithTheme, screen, userEvent } from "@/test/ui/utils"; + +const breakdown: PayBreakdownViewModel = { + totalHours: 12, + actualHours: 10, + regular: { + hours100: { hours: 6, percent: 1 }, + hours125: { hours: 2, percent: 1.25 }, + hours150: { hours: 1, percent: 1.5 }, + }, + extra: { + hours20: { hours: 0.5, percent: 0.2 }, + hours50: { hours: 0.75, percent: 0.5 }, + }, + special: { + shabbat150: { hours: 1.5, percent: 1.5 }, + shabbat200: { hours: 0.25, percent: 2 }, + }, + hours100Sick: { hours: 3, percent: 1 }, + hours100Vacation: { hours: 4, percent: 1 }, + appliedShabbatCredit: { hours: 5, percent: 1 }, + perDiemPoints: 7, + perDiemAmount: 0, + largePoints: 8, + largeAmount: 0, + smallPoints: 9, + smallAmount: 0, +}; + +describe("DayCardDetails", () => { + beforeAll(async () => { + await i18n.changeLanguage("en"); + }); + + afterAll(async () => { + await i18n.changeLanguage("he"); + }); + + it("renders each breakdown group as its own, independently collapsible section", async () => { + renderWithTheme(); + + const overtimeToggle = screen.getByRole("button", { name: "OT" }); + const shabbatToggle = screen.getByRole("button", { name: "Shabbat" }); + + expect(overtimeToggle).toHaveAttribute("aria-expanded", "false"); + expect(shabbatToggle).toHaveAttribute("aria-expanded", "false"); + + await userEvent.click(overtimeToggle); + + expect(overtimeToggle).toHaveAttribute("aria-expanded", "true"); + expect(shabbatToggle).toHaveAttribute("aria-expanded", "false"); + }); + + it("hides the absence group for special full days", () => { + renderWithTheme(); + + expect( + screen.queryByRole("button", { name: "Absence" }), + ).not.toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Meal Allow. / Per Diem" }), + ).toBeInTheDocument(); + }); +}); diff --git a/src/test/ui/hooks/useWorkTableDayState.test.tsx b/src/test/ui/hooks/useWorkTableDayState.test.tsx new file mode 100644 index 0000000..9785387 --- /dev/null +++ b/src/test/ui/hooks/useWorkTableDayState.test.tsx @@ -0,0 +1,78 @@ +import { useState } from "react"; +import { describe, expect, it } from "vitest"; + +import { WorkDayStatus } from "@/constants"; +import { + useWorkTableDayState, +} from "@/features/work-table/hooks/useWorkTableDayState"; +import { WorkTableDayStateProvider } from "@/features/work-table/hooks/WorkTableDayStateProvider"; +import { renderPure, screen, userEvent } from "@/test/ui/utils"; + +const dateKey = "2026-08-26"; + +const DesktopDayEditor = () => { + const { status, shiftEntries, setStatus, setShiftEntries } = + useWorkTableDayState(dateKey); + + const editDay = () => { + const time = new Date(`${dateKey}T08:00:00`); + + setStatus(WorkDayStatus.sick); + setShiftEntries({ + shift: { + shift: { + id: "shift", + start: { date: time }, + end: { date: time }, + isDuty: false, + }, + payMap: null, + }, + }); + }; + + return ( +
+ {`desktop:${status}:${Object.keys(shiftEntries).length}`} + +
+ ); +}; + +const MobileDayEditor = () => { + const { status, shiftEntries } = useWorkTableDayState(dateKey); + + return {`mobile:${status}:${Object.keys(shiftEntries).length}`}; +}; + +const ResponsiveWorkTableHarness = () => { + const [isMobile, setIsMobile] = useState(false); + + return ( + + + {isMobile ? : } + + ); +}; + +describe("WorkTableDayStateProvider", () => { + it("preserves a day's editing state when its responsive view is replaced", async () => { + renderPure(); + + expect(screen.getByText("desktop:normal:0")).toBeInTheDocument(); + + await userEvent.click(screen.getByRole("button", { name: "Edit day" })); + expect(screen.getByText("desktop:sick:1")).toBeInTheDocument(); + + await userEvent.click( + screen.getByRole("button", { name: "Change layout" }), + ); + + expect(screen.getByText("mobile:sick:1")).toBeInTheDocument(); + }); +});