diff --git a/web/src/components/entry-dialog/EntryForm.tsx b/web/src/components/entry-dialog/EntryForm.tsx index 643edb1e..32bef0d2 100644 --- a/web/src/components/entry-dialog/EntryForm.tsx +++ b/web/src/components/entry-dialog/EntryForm.tsx @@ -44,14 +44,21 @@ export type EntryFormProps = { type LocationState = { date?: string; editEntry?: Entry; + template?: Entry; + templateEntries?: Entry[]; }; const EntryForm = () => { const { state } = useLocation(); const dayjs = useDayjs(); // state is possibly null - const { date: originalDate, editEntry }: LocationState = state || {}; - const { form, onSubmit, loading } = useEntryForm({ editEntry, date: dayjs(originalDate) }); + const { date: originalDate, editEntry, templateEntries }: LocationState = state || {}; + + const { form, onSubmit, loading } = useEntryForm({ + editEntry, + date: dayjs(originalDate), + template: templateEntries && templateEntries[0], + }); const navigate = useNavigate(); const { @@ -62,10 +69,25 @@ const EntryForm = () => { useEffect(() => { if (isSubmitSuccessful) { - reset(); - navigate(".."); + const remainingEntries = templateEntries && templateEntries.slice(1); + if (remainingEntries && remainingEntries.length > 0) { + const nextEntry = remainingEntries[0]; + reset({ + date: dayjs(originalDate), + duration: nextEntry.duration.toString(), + description: nextEntry.description || "", + product: nextEntry.product || "", + activity: nextEntry.activity || "", + issue: nextEntry.issue || null, + client: nextEntry.client || "", + }); + navigate(".", { state: { date: originalDate, templateEntries: remainingEntries } }); + } else { + reset(); + navigate(".."); + } } - }, [isSubmitSuccessful, navigate, reset]); + }, [dayjs, isSubmitSuccessful, navigate, originalDate, reset, templateEntries]); const { t } = useTranslation(); const theme = useTheme(); @@ -197,7 +219,7 @@ const EntryForm = () => { )} - {!editEntry && ( + {!editEntry && !templateEntries && ( { <> - {!editEntry && ( + {!editEntry && !templateEntries && ( { +const useEntryForm = ({ editEntry, date, template }: useEntryProps) => { const { t } = useTranslation(); const { showSuccessNotification } = useNotification(); const dayjs = useDayjs(); @@ -68,12 +69,17 @@ const useEntryForm = ({ editEntry, date }: useEntryProps) => { return { date: date ? dayjs(date) : dayjs(), - duration: editEntry?.duration.toString() || "", - description: editEntry?.description || "", - product: editEntry?.product || settingsData?.getMySettings.productPreset || "", - activity: editEntry?.activity || settingsData?.getMySettings.activityPreset || "", - issue: editEntry?.issue || null, - client: editEntry?.client || "", + duration: editEntry?.duration.toString() || template?.duration.toString() || "", + description: editEntry?.description || template?.description || "", + product: + editEntry?.product || template?.product || settingsData?.getMySettings.productPreset || "", + activity: + editEntry?.activity || + template?.activity || + settingsData?.getMySettings.activityPreset || + "", + issue: editEntry?.issue || template?.issue || null, + client: editEntry?.client || template?.client || "", }; }; @@ -83,7 +89,7 @@ const useEntryForm = ({ editEntry, date }: useEntryProps) => { const { loading: hoursLoading } = useFormSetRemainingHours({ form, - isEnabled: editEntry === undefined && !form.formState.isLoading, + isEnabled: editEntry === undefined && template === undefined && !form.formState.isLoading, }); const addWorkday: SubmitHandler = async (formValues) => { diff --git a/web/src/components/workday-accordion/PasteEditEntryButton.tsx b/web/src/components/workday-accordion/PasteEditEntryButton.tsx new file mode 100644 index 00000000..49f49f32 --- /dev/null +++ b/web/src/components/workday-accordion/PasteEditEntryButton.tsx @@ -0,0 +1,37 @@ +import SaveAsIcon from "@mui/icons-material/SaveAs"; +import { Box, IconButtonProps } from "@mui/material"; +import { useTranslation } from "react-i18next"; +import LabelledIconButton from "../LabelledIconButton"; +import { useLocation, useNavigate } from "react-router-dom"; +import { Dayjs } from "dayjs"; +import { useEntryContext } from "../workday-browser/entry-context/useEntryContext"; + +type PasteEntryButtonProps = { date: Dayjs } & IconButtonProps; + +const PasteEditEntryButton = ({ date, ...props }: PasteEntryButtonProps) => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const location = useLocation(); + const { selectedEntries, clearEntries } = useEntryContext(); + + return ( + e.stopPropagation()}> + { + const templateEntries = selectedEntries; + clearEntries(); + navigate(`${location.pathname}/create`, { + state: { date: date.format("YYYY-MM-DD"), templateEntries }, + }); + }} + {...props} + > + + + + ); +}; + +export default PasteEditEntryButton; diff --git a/web/src/components/workday-accordion/PasteEntryButton.tsx b/web/src/components/workday-accordion/PasteEntryButton.tsx new file mode 100644 index 00000000..b12d4e63 --- /dev/null +++ b/web/src/components/workday-accordion/PasteEntryButton.tsx @@ -0,0 +1,20 @@ +import ContentPasteGoIcon from "@mui/icons-material/ContentPasteGo"; +import { Box, IconButtonProps } from "@mui/material"; +import { useTranslation } from "react-i18next"; +import LabelledIconButton from "../LabelledIconButton"; + +type PasteEntryButtonProps = IconButtonProps; + +const PasteEntryButton = ({ ...props }: PasteEntryButtonProps) => { + const { t } = useTranslation(); + + return ( + e.stopPropagation()}> + + + + + ); +}; + +export default PasteEntryButton; diff --git a/web/src/components/workday-accordion/WorkdaySummary.tsx b/web/src/components/workday-accordion/WorkdaySummary.tsx index e463e728..ce2f6e0f 100644 --- a/web/src/components/workday-accordion/WorkdaySummary.tsx +++ b/web/src/components/workday-accordion/WorkdaySummary.tsx @@ -1,5 +1,7 @@ import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; import { AccordionSummary, Box, Chip, Typography, useMediaQuery, useTheme } from "@mui/material"; +import { useMutation } from "@apollo/client"; +import { useTranslation } from "react-i18next"; import { roundToFullMinutes, totalDurationOfEntries } from "../../common/duration"; import useDayjs from "../../common/useDayjs"; import { @@ -10,7 +12,12 @@ import { isVacation, isWeekend, } from "../../common/workdayUtils"; -import { Workday } from "../../graphql/generated/graphql"; +import { + AddWorkdayEntryDocument, + Entry, + FindWorkdaysDocument, + Workday, +} from "../../graphql/generated/graphql"; import EntryDialogButton from "../entry-dialog/EntryDialogButton"; import FlexLeaveChip from "./info-chips/FlexLeaveChip"; import HolidayChip from "./info-chips/HolidayChip"; @@ -18,6 +25,10 @@ import NoEntriesChip from "./info-chips/NoEntriesChip"; import SickLeaveChip from "./info-chips/SickLeaveChip"; import VacationChip from "./info-chips/VacationChip"; import WeekendChip from "./info-chips/WeekendChip"; +import { useEntryContext } from "../workday-browser/entry-context/useEntryContext"; +import { useNotification } from "../global-notification/useNotification"; +import PasteEntryButton from "./PasteEntryButton"; +import PasteEditEntryButton from "./PasteEditEntryButton"; type WorkdayAccordionProps = { workday: Workday; @@ -39,6 +50,34 @@ const WorkdaySummary = ({ workday }: WorkdayAccordionProps) => { const totalHoursFormatted = roundToFullMinutes(totalDuration).format("H:mm"); const empty = workday.entries.length === 0; + const { t } = useTranslation(); + + const { selectedEntries, hasEntries, clearEntries } = useEntryContext(); + const { showSuccessNotification } = useNotification(); + const [addWorkdayEntryMutation] = useMutation(AddWorkdayEntryDocument, { + refetchQueries: [FindWorkdaysDocument], + onCompleted: async () => { + showSuccessNotification(t("notifications.addEntry.success")); + }, + }); + const handlePasteEntries = (entries: Entry[]) => { + entries.forEach((entry) => { + addWorkdayEntryMutation({ + variables: { + entry: { + date: date.format("YYYY-MM-DD"), + duration: entry.duration, + description: entry.description, + product: entry.product, + activity: entry.activity, + issue: entry.issue, + client: entry.client, + }, + }, + }); + }); + clearEntries(); + }; const InfoChip = () => { if (vacation) { @@ -88,6 +127,17 @@ const WorkdaySummary = ({ workday }: WorkdayAccordionProps) => { {!mobile && } + {hasEntries ? ( + <> + + { + e.stopPropagation(); + handlePasteEntries(selectedEntries); + }} + /> + + ) : null} {!disabled && ( <> diff --git a/web/src/components/workday-accordion/entry-row/CopyEntryButton.tsx b/web/src/components/workday-accordion/entry-row/CopyEntryButton.tsx new file mode 100644 index 00000000..7e4daaee --- /dev/null +++ b/web/src/components/workday-accordion/entry-row/CopyEntryButton.tsx @@ -0,0 +1,43 @@ +import ContentCopyIcon from "@mui/icons-material/ContentCopy"; +import { ToggleButton, Tooltip } from "@mui/material"; +import { useTranslation } from "react-i18next"; +import { Entry } from "../../../graphql/generated/graphql"; +import { useEntryContext } from "../../workday-browser/entry-context/useEntryContext"; + +type CopyEntryButtonProps = { + entry: Entry; +}; + +const CopyEntryButton = ({ entry }: CopyEntryButtonProps) => { + const { t } = useTranslation(); + const { hasEntry, addSelectedEntry, removeSelectedEntry } = useEntryContext(); + + return ( + <> + + { + e.stopPropagation(); + !hasEntry(entry) ? addSelectedEntry(entry) : removeSelectedEntry(entry); + }} + sx={(theme) => ({ + color: + theme.palette.mode === "dark" + ? theme.palette.primary.main + : theme.palette.secondary.dark, + border: "none", + borderRadius: "50%", + })} + selected={hasEntry(entry)} + > + + + + + ); +}; + +export default CopyEntryButton; diff --git a/web/src/components/workday-accordion/entry-row/DesktopEntryRow.tsx b/web/src/components/workday-accordion/entry-row/DesktopEntryRow.tsx index 8eb92096..db38fbc1 100644 --- a/web/src/components/workday-accordion/entry-row/DesktopEntryRow.tsx +++ b/web/src/components/workday-accordion/entry-row/DesktopEntryRow.tsx @@ -11,6 +11,8 @@ import { EntryRowProps } from "./EntryRow"; import AcceptedChip from "./status-chips/AcceptedChip"; import OpenChip from "./status-chips/OpenChip"; import PaidChip from "./status-chips/PaidChip"; +import { useEntryContext } from "../../workday-browser/entry-context/useEntryContext"; +import CopyEntryButton from "./CopyEntryButton"; const DesktopEntryRow = ({ entry, date }: EntryRowProps) => { const { darkMode } = useDarkMode(); @@ -19,6 +21,7 @@ const DesktopEntryRow = ({ entry, date }: EntryRowProps) => { const paid = entry.acceptanceStatus === AcceptanceStatus.Paid; const open = entry.acceptanceStatus === AcceptanceStatus.Open; const roundedDuration = roundToFullMinutes(dayjs.duration(entry.duration, "hour")); + const { hasEntry } = useEntryContext(); return ( { display: "flex", alignItems: "stretch", justifyContent: "space-between", + backgroundColor: (theme) => + hasEntry(entry) + ? darkMode + ? theme.palette.grey[700] + : theme.palette.primary.main + : undefined, }} > { ) : ( + + + diff --git a/web/src/components/workday-accordion/entry-row/MobileEntryRow.tsx b/web/src/components/workday-accordion/entry-row/MobileEntryRow.tsx index 90dc1b91..f050cb5f 100644 --- a/web/src/components/workday-accordion/entry-row/MobileEntryRow.tsx +++ b/web/src/components/workday-accordion/entry-row/MobileEntryRow.tsx @@ -10,6 +10,8 @@ import { EntryRowProps } from "./EntryRow"; import AcceptedChip from "./status-chips/AcceptedChip"; import OpenChip from "./status-chips/OpenChip"; import PaidChip from "./status-chips/PaidChip"; +import CopyEntryButton from "./CopyEntryButton"; +import { useEntryContext } from "../../workday-browser/entry-context/useEntryContext"; const MobileEntryRow = ({ entry, date }: EntryRowProps) => { const { darkMode } = useDarkMode(); @@ -18,6 +20,7 @@ const MobileEntryRow = ({ entry, date }: EntryRowProps) => { const paid = entry.acceptanceStatus === AcceptanceStatus.Paid; const open = entry.acceptanceStatus === AcceptanceStatus.Open; const roundedDuration = roundToFullMinutes(dayjs.duration(entry.duration, "hour")); + const { hasEntry } = useEntryContext(); return ( { display: "flex", flexDirection: "column", alignItems: "stretch", + backgroundColor: (theme) => + hasEntry(entry) + ? darkMode + ? theme.palette.grey[700] + : theme.palette.primary.main + : undefined, }} > @@ -58,6 +67,7 @@ const MobileEntryRow = ({ entry, date }: EntryRowProps) => { ) : ( + diff --git a/web/src/components/workday-browser/WorkdayBrowser.tsx b/web/src/components/workday-browser/WorkdayBrowser.tsx index bf6d60c3..6ad5e12f 100644 --- a/web/src/components/workday-browser/WorkdayBrowser.tsx +++ b/web/src/components/workday-browser/WorkdayBrowser.tsx @@ -1,11 +1,14 @@ import { Outlet } from "react-router-dom"; import ListControls from "./ListControls"; import WorkdayList from "./WorkdayList"; +import { EntryContextProvider } from "./entry-context/EntryContextProvider"; const WorkdayBrowser = () => ( <> - - + + + + ); diff --git a/web/src/components/workday-browser/entry-context/EntryContext.ts b/web/src/components/workday-browser/entry-context/EntryContext.ts new file mode 100644 index 00000000..7b848b10 --- /dev/null +++ b/web/src/components/workday-browser/entry-context/EntryContext.ts @@ -0,0 +1,16 @@ +import { createContext } from "react"; +import { Entry } from "../../../graphql/generated/graphql"; +import { Dayjs } from "dayjs"; + +type EntryContextValue = { + selectedEntries: Entry[]; + addSelectedEntry: (entry: Entry) => void; + removeSelectedEntry: (entry: Entry) => void; + clearEntries: () => void; + hasEntry: (entry: Entry) => boolean; + hasEntries: boolean; + editDate: Dayjs | null; + setEditDate: (date: Dayjs | null) => void; +}; + +export const EntryContext = createContext(undefined); diff --git a/web/src/components/workday-browser/entry-context/EntryContextProvider.tsx b/web/src/components/workday-browser/entry-context/EntryContextProvider.tsx new file mode 100644 index 00000000..975e0c4b --- /dev/null +++ b/web/src/components/workday-browser/entry-context/EntryContextProvider.tsx @@ -0,0 +1,45 @@ +import { ReactNode, useCallback, useState } from "react"; +import { EntryContext } from "./EntryContext"; +import { Entry } from "../../../graphql/generated/graphql"; +import { Dayjs } from "dayjs"; + +type EntryContextProviderProps = { + children: ReactNode; +}; + +export const EntryContextProvider = ({ children }: EntryContextProviderProps) => { + const [selectedEntries, setSelected] = useState([]); + const [editDate, setDate] = useState(null); + + const addSelectedEntry = (entry: Entry) => setSelected((prev) => [...prev, entry]); + const removeSelectedEntry = useCallback( + (entry: Entry) => + setSelected((prev) => prev.filter((prevEntry) => prevEntry.key !== entry.key)), + [], + ); + const hasEntry = (entry: Entry) => + !!selectedEntries.find((prevEntry) => prevEntry.key === entry.key); + const clearEntries = () => setSelected([]); + + const setEditDate = useCallback((date: Dayjs | null) => { + setDate(date); + }, []); + const hasEntries = selectedEntries.length > 0; + + return ( + + {children} + + ); +}; diff --git a/web/src/components/workday-browser/entry-context/useEntryContext.ts b/web/src/components/workday-browser/entry-context/useEntryContext.ts new file mode 100644 index 00000000..fb72c419 --- /dev/null +++ b/web/src/components/workday-browser/entry-context/useEntryContext.ts @@ -0,0 +1,10 @@ +import { useContext } from "react"; +import { EntryContext } from "./EntryContext"; + +export const useEntryContext = () => { + const entryContext = useContext(EntryContext); + if (entryContext === undefined) { + throw new Error("context must be inside a entryProvider"); + } + return entryContext; +}; diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 63926c63..4d895d92 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -24,6 +24,9 @@ const en = { jiraDisconnect: "Disconnect from Jira", showWeekend: "Show Weekend", hideWeekend: "Hide Weekend", + copyEntry: "Copy Entry", + pasteEntry: "Paste Entry", + pasteAndEditEntry: "Paste and Edit Entry", }, dimensionNames: { product: "Product", diff --git a/web/src/i18n/fi.ts b/web/src/i18n/fi.ts index 507265bd..f3d6d4f0 100644 --- a/web/src/i18n/fi.ts +++ b/web/src/i18n/fi.ts @@ -24,6 +24,9 @@ const fi = { jiraDisconnect: "Katkaise Jira-yhteys", showWeekend: "Näytä Viikonloppu", hideWeekend: "Piilota Viikonloppu", + copyEntry: "Kopioi työaikakirjaus", + pasteEntry: "Liitä työaikakirjaus", + pasteAndEditEntry: "Liitä ja muokkaa kirjausta", }, dimensionNames: { product: "Tuote",