diff --git a/src/constants/fields.constant.ts b/src/constants/fields.constant.ts index 749e351..796549d 100644 --- a/src/constants/fields.constant.ts +++ b/src/constants/fields.constant.ts @@ -39,15 +39,15 @@ export enum WorkDayType { export const headersTable: TableHeader[] = [ { label: "יום", viewMode: "both", rowSpan: 2 }, - { label: "", children: ["מחלה", "חופש"], widths: [48, 48], viewMode: "both" }, + { label: "", children: ["מחלה", "חופש"], widths: [40, 40], viewMode: "both" }, { label: "שעות", children: ["", "כניסה", "יציאה", ""], - widths: [48, 96, 96, 120], + widths: [48, 96, 96, 112], viewMode: "both", }, - { label: "סך שעות", rowSpan: 2, viewMode: "both" }, { label: "סך שעות בפועל", rowSpan: 2, viewMode: "both" }, + { label: "סך שעות", rowSpan: 2, viewMode: "both" }, { label: "רגילות", rowSpan: 2, viewMode: "compact" }, { label: "תוספות", rowSpan: 2, viewMode: "compact" }, { diff --git a/src/features/work-table/components/DayDetails.tsx b/src/features/work-table/components/DayDetails.tsx new file mode 100644 index 0000000..7dbea3a --- /dev/null +++ b/src/features/work-table/components/DayDetails.tsx @@ -0,0 +1,249 @@ +import { + Box, + Paper, + Table, + TableBody, + TableCell, + TableHead, + TableRow, +} from "@mui/material"; +import { useTranslation } from "react-i18next"; + +import { PayBreakdownViewModel } from "@/domain"; +import { formatValue } from "@/utils"; + +type DayDetailsProps = { + breakdown: PayBreakdownViewModel; + id: string; + showAbsence?: boolean; +}; + +type DetailItem = { + label: string; + value: number; +}; + +type DetailSection = { + items: DetailItem[]; + label: string; +}; + +type DetailGroupProps = { + items?: DetailItem[]; + sections?: DetailSection[]; + title?: string; +}; + +const DetailGroup = ({ items = [], sections, title }: DetailGroupProps) => { + const columns = sections + ? sections.flatMap((section) => section.items) + : items; + + return ( + + + + {title && ( + + + {title} + + + )} + {sections && ( + + {sections.map((section) => ( + + {section.label} + + ))} + + )} + + {columns.map(({ label }) => ( + + {label} + + ))} + + + + + {columns.map(({ label, value }) => ( + + {formatValue(value)} + + ))} + + +
+
+ ); +}; + +export const DayDetails = ({ + breakdown, + id, + showAbsence = true, +}: 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 primaryGroups = groups.slice(0, 2); + const secondaryGroups = groups.slice(2); + + return ( + + + {primaryGroups.map((group) => ( + section.label).join("-") + } + {...group} + /> + ))} + + + {secondaryGroups.map((group) => ( + section.label).join("-") + } + {...group} + /> + ))} + + + ); +}; diff --git a/src/features/work-table/components/DayRow.tsx b/src/features/work-table/components/DayRow.tsx index d605590..3c0180b 100644 --- a/src/features/work-table/components/DayRow.tsx +++ b/src/features/work-table/components/DayRow.tsx @@ -1,26 +1,25 @@ -import { useCallback, useEffect, useRef } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { Box, Checkbox, Chip, + Collapse, TableCell, TableRow, IconButton, + Tooltip, } from "@mui/material"; import { useTranslation } from "react-i18next"; 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 { - TableViewMode, - TimeFieldType, - WorkDayInfo, - WorkDayMap, -} from "@/domain"; +import { TimeFieldType, WorkDayInfo, WorkDayMap } from "@/domain"; import { WorkDayStatus, HolidayKey } from "@/constants"; import { - ExpandedDayRow, + DayDetails, isSameDayPayMap, ShiftRow, useDay, @@ -36,7 +35,6 @@ type DayRowProps = { domain: DomainContextType; workDay: WorkDayInfo; isLastInWeek?: boolean; - viewMode: TableViewMode; shabbatCreditHours: number; }; @@ -44,7 +42,6 @@ const DayRowComponent = ({ domain, workDay, isLastInWeek, - viewMode, shabbatCreditHours, }: DayRowProps) => { const { dateService } = domain.services; @@ -55,6 +52,7 @@ const DayRowComponent = ({ const { baseRate, standardHours, year, month, addDay, removeDay } = useGlobalState(); const { isSpecialFullDay } = useWorkDays(); + const [detailsOpen, setDetailsOpen] = useState(false); const { status, @@ -109,17 +107,20 @@ const DayRowComponent = ({ 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; return ( <> {(shifts.length ? shifts : [null]).map((item, index) => ( {index === 0 && ( <> @@ -175,11 +176,15 @@ const DayRowComponent = ({ rowSpan={shiftCount} sx={{ textAlign: "center", - width: 48, + width: 40, + minWidth: 40, + maxWidth: 40, + p: 0.25, verticalAlign: "middle", }} > handleStatusChanged( @@ -188,7 +193,10 @@ const DayRowComponent = ({ : WorkDayStatus.normal, ) } - sx={{ display: specialFullDay ? "none" : "inline-flex" }} + sx={{ + display: specialFullDay ? "none" : "inline-flex", + p: 0.5, + }} /> @@ -197,11 +205,15 @@ const DayRowComponent = ({ sx={{ borderRight: "1px solid black", textAlign: "center", - width: 48, + width: 40, + minWidth: 40, + maxWidth: 40, + p: 0.25, verticalAlign: "middle", }} > handleStatusChanged( @@ -210,7 +222,10 @@ const DayRowComponent = ({ : WorkDayStatus.normal, ) } - sx={{ display: specialFullDay ? "none" : "inline-flex" }} + sx={{ + display: specialFullDay ? "none" : "inline-flex", + p: 0.5, + }} /> @@ -270,36 +285,70 @@ const DayRowComponent = ({ )} - {index === 0 && - (viewMode === "compact" ? ( + {index === 0 && ( + <> - ) : ( - - ))} + sx={{ minWidth: 48, p: 0.5, verticalAlign: "middle" }} + > + + setDetailsOpen((open) => !open)} + > + {detailsOpen ? ( + + ) : ( + + )} + + + + + )} ))} + + + + + + + ); }; diff --git a/src/features/work-table/components/ShiftRow.tsx b/src/features/work-table/components/ShiftRow.tsx index a1a5a08..6dc47b5 100644 --- a/src/features/work-table/components/ShiftRow.tsx +++ b/src/features/work-table/components/ShiftRow.tsx @@ -148,8 +148,8 @@ export const ShiftRow = ({ borderRight: "1px solid black", textAlign: "center", whiteSpace: "nowrap", - width: 120, - maxWidth: 120, + width: 112, + maxWidth: 112, p: 0.25, overflow: "visible", verticalAlign: "middle", diff --git a/src/features/work-table/components/WorkTable.tsx b/src/features/work-table/components/WorkTable.tsx index 26702f3..821253c 100644 --- a/src/features/work-table/components/WorkTable.tsx +++ b/src/features/work-table/components/WorkTable.tsx @@ -12,9 +12,7 @@ import { CardContent, Alert, Divider, - Switch, - FormControlLabel, - Tooltip, + TableCell, } from "@mui/material"; import CalendarMonthIcon from "@mui/icons-material/CalendarMonth"; import { useTranslation } from "react-i18next"; @@ -22,35 +20,24 @@ import { useTranslation } from "react-i18next"; import { useGlobalState } from "@/hooks"; import { groupByShabbat } from "@/utils"; import { headersTable } from "@/constants"; -import { analyticsService } from "@/services"; import { - ExpandedDayRow, DayRow, WorkTableHeader, monthToCompactPayBreakdownVM, } from "@/features/work-table"; import { DomainContextType } from "@/app"; -import { monthToPayBreakdownVM } from "@/adapters"; -import { - ShabbatCreditAllocation, - TableViewMode, - WorkDayInfo, -} from "@/domain"; +import { ShabbatCreditAllocation, WorkDayInfo } from "@/domain"; import { CompactDayRow } from "./rows/CompactDayRow"; type WorkTableProps = { domain: DomainContextType; workDays: WorkDayInfo[]; - viewMode: TableViewMode; - onViewModeChange: (mode: TableViewMode) => void; shabbatCreditAllocation: ShabbatCreditAllocation; }; export const WorkTable = ({ domain, workDays, - viewMode, - onViewModeChange, shabbatCreditAllocation, }: WorkTableProps) => { const { year, month, baseRate, globalBreakdown } = useGlobalState(); @@ -74,31 +61,6 @@ export const WorkTable = ({ year, })} - - { - const mode = e.target.checked ? "expanded" : "compact"; - onViewModeChange(mode); - analyticsService.track({ - name: "view_mode_toggled", - params: { mode }, - }); - }} - color="primary" - /> - } - label={ - - {t("table.toggle_view_label")} - - } - labelPlacement="start" - /> - {/* Table */} @@ -132,7 +94,7 @@ export const WorkTable = ({ {groupByWeeks.map((group) => ( @@ -145,7 +107,6 @@ export const WorkTable = ({ key={day.meta.date} workDay={day} isLastInWeek={isLastInWeek} - viewMode={viewMode} shabbatCreditHours={ shabbatCreditAllocation.appliedHoursByDate[ day.meta.date @@ -170,27 +131,16 @@ export const WorkTable = ({ }, }} > - {viewMode === "compact" ? ( - - ) : ( - - )} + + diff --git a/src/features/work-table/components/WorkTableHeader.tsx b/src/features/work-table/components/WorkTableHeader.tsx index fefc8d8..62697f6 100644 --- a/src/features/work-table/components/WorkTableHeader.tsx +++ b/src/features/work-table/components/WorkTableHeader.tsx @@ -109,6 +109,24 @@ export const WorkTableHeader = ({ {t("daily_salary_header")} )} + + {viewMode === "compact" && ( + + {t("headers.details")} + + )} {/* Row 2: Sub-headers */} diff --git a/src/features/work-table/components/index.ts b/src/features/work-table/components/index.ts index dfcd87b..0ce2e43 100644 --- a/src/features/work-table/components/index.ts +++ b/src/features/work-table/components/index.ts @@ -1,4 +1,5 @@ export { ExpandedDayRow } from "./rows/ExpandedPayRow"; +export { DayDetails } from "./DayDetails"; export { DayRow } from "./DayRow"; export { WorkTable } from "./WorkTable"; export { WorkTableHeader } from "./WorkTableHeader"; diff --git a/src/features/work-table/components/rows/CompactDayRow.tsx b/src/features/work-table/components/rows/CompactDayRow.tsx index 4d1c378..f6ac69c 100644 --- a/src/features/work-table/components/rows/CompactDayRow.tsx +++ b/src/features/work-table/components/rows/CompactDayRow.tsx @@ -25,13 +25,13 @@ export const CompactDayRow = ({ rowSpan={rowSpan} sx={{ ...baseCellSx(isFooter), ...rightBorderIfNotFooter(isFooter) }} > - {formatValue(breakdown.totalHours)} + {formatValue(breakdown.actualHours)} - {formatValue(breakdown.actualHours)} + {formatValue(breakdown.totalHours)} - {formatValue(breakdown.totalHours)} + {formatValue(breakdown.actualHours)} - {formatValue(breakdown.actualHours)} + {formatValue(breakdown.totalHours)} {formatValue(breakdown.regular.hours100.hours)} diff --git a/src/i18n/locales/en/work-table.json b/src/i18n/locales/en/work-table.json index 98bbbde..8afe4f4 100644 --- a/src/i18n/locales/en/work-table.json +++ b/src/i18n/locales/en/work-table.json @@ -26,7 +26,8 @@ "small": "Small", "shabbat_credit": "Shabbat Credit", "entry": "In", - "exit": "Out" + "exit": "Out", + "details": "Details" }, "months": [ "January", @@ -71,6 +72,12 @@ "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." }, + "day_details": { + "show": "Show day details", + "hide": "Hide day details", + "region_label": "Day pay breakdown", + "points": "Points" + }, "shift_row": { "cross_midnight_warning": "A shift crossing midnight was detected. Please check \"cross day\" before saving.", "tooltip_cross_day_error": "⚠️ Mark as cross-day — end time is before start time", diff --git a/src/i18n/locales/he/work-table.json b/src/i18n/locales/he/work-table.json index 472e8ed..7c7e517 100644 --- a/src/i18n/locales/he/work-table.json +++ b/src/i18n/locales/he/work-table.json @@ -26,7 +26,8 @@ "small": "קטנה", "shabbat_credit": "זכות שבת", "entry": "כניסה", - "exit": "יציאה" + "exit": "יציאה", + "details": "פרטים" }, "months": [ "ינואר", @@ -71,6 +72,12 @@ "shabbat_credit_summary": "זכות שבת — נצברו: {{earned}}, נוצלו: {{used}}, לא נוצלו: {{unused}}.", "shabbat_credit_unused_note": "שעות זכות שבת שלא נוצלו אינן נכללות בסך השעות או בחישוב השכר." }, + "day_details": { + "show": "הצג פרטי יום", + "hide": "הסתר פרטי יום", + "region_label": "פירוט שכר יומי", + "points": "נקודות" + }, "shift_row": { "cross_midnight_warning": "זוהתה משמרת החוצה את חצות. יש לסמן \"חוצה יום\" לפני שמירה.", "tooltip_cross_day_error": "⚠️ יש לסמן חוצה יום - שעת סיום לפני שעת התחלה", diff --git a/src/pages/DailyPage.tsx b/src/pages/DailyPage.tsx index 08d7e57..d446a4a 100644 --- a/src/pages/DailyPage.tsx +++ b/src/pages/DailyPage.tsx @@ -20,14 +20,13 @@ import { Feedback, } from "@/features"; import { - useDeviceType, useFetch, useGlobalState, useShabbatCreditAllocation, useWorkDays, useAsync, } from "@/hooks"; -import { ApiResponse, CalendarEventMap, TableViewMode } from "@/domain"; +import { ApiResponse, CalendarEventMap } from "@/domain"; import { buildEventMap } from "@/adapters"; import { DomainContextType } from "@/app"; import { hebcalService, analyticsService } from "@/services"; @@ -38,8 +37,6 @@ const calendarApi = hebcalService(); export const DailyPage = ({ domain }: { domain: DomainContextType }) => { const { t } = useTranslation("work-table"); const { dateService } = domain.services; - const { isMobile } = useDeviceType(); - const { year, month, baseRate, reset } = useGlobalState(); const { workDays, generate } = useWorkDays(); @@ -47,10 +44,6 @@ export const DailyPage = ({ domain }: { domain: DomainContextType }) => { const [error, setError] = useState(undefined); - const [viewMode, setViewMode] = useState( - isMobile ? "compact" : "expanded", - ); - const { loading, callEndPoint, cancelEndPoint } = useFetch(); const handleCalendarResult = ({ @@ -173,8 +166,6 @@ export const DailyPage = ({ domain }: { domain: DomainContextType }) => { diff --git a/src/test/ui/features/DayDetails.test.tsx b/src/test/ui/features/DayDetails.test.tsx new file mode 100644 index 0000000..66fb726 --- /dev/null +++ b/src/test/ui/features/DayDetails.test.tsx @@ -0,0 +1,86 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import i18n from "@/i18n"; +import { PayBreakdownViewModel } from "@/domain"; +import { DayDetails } from "@/features/work-table/components/DayDetails"; +import { renderWithTheme, screen } 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("DayDetails", () => { + beforeAll(async () => { + await i18n.changeLanguage("en"); + }); + + afterAll(async () => { + await i18n.changeLanguage("he"); + }); + + it("renders the complete day breakdown in an accessible region", () => { + renderWithTheme(); + + expect( + screen.getByRole("region", { name: "Day pay breakdown" }), + ).toHaveAttribute("id", "day-details"); + expect( + screen.getByRole("columnheader", { name: "OT" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("columnheader", { name: "Shabbat" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("columnheader", { name: "Meal Allow." }), + ).toBeInTheDocument(); + expect( + screen.getByRole("columnheader", { name: "Per Diem" }), + ).toBeInTheDocument(); + expect(screen.getByText("Shabbat Credit")).toBeInTheDocument(); + expect(screen.getByText("0.75")).toBeInTheDocument(); + expect(screen.getByText("9.00")).toBeInTheDocument(); + }); + + it("hides absence details for special full days", () => { + renderWithTheme( + , + ); + + expect( + screen.queryByRole("columnheader", { name: "Absence" }), + ).not.toBeInTheDocument(); + expect( + screen.getByRole("columnheader", { name: "Meal Allow." }), + ).toBeInTheDocument(); + expect( + screen.getByRole("columnheader", { name: "Per Diem" }), + ).toBeInTheDocument(); + }); +});