Browse Source

Time entry for team leads

tags/Baseline_30082024_FRONTEND_UAT
Wayne 1 year ago
parent
commit
4503c31b7f
15 changed files with 469 additions and 100 deletions
  1. +3
    -1
      src/app/(main)/home/page.tsx
  2. +11
    -0
      src/app/api/timesheets/actions.ts
  3. +17
    -0
      src/app/api/timesheets/index.ts
  4. +61
    -0
      src/components/StyledFullCalendar/StyledFullCalendar.tsx
  5. +1
    -0
      src/components/StyledFullCalendar/index.ts
  6. +191
    -0
      src/components/TimesheetAmendment/TimesheetAmendment.tsx
  7. +77
    -0
      src/components/TimesheetAmendment/TimesheetAmendmentModal.tsx
  8. +1
    -0
      src/components/TimesheetAmendment/index.ts
  9. +2
    -2
      src/components/TimesheetTable/EntryInputTable.tsx
  10. +1
    -1
      src/components/TimesheetTable/MobileTimesheetEntry.tsx
  11. +0
    -72
      src/components/TimesheetTable/ProjectSelect.tsx
  12. +4
    -2
      src/components/TimesheetTable/TimesheetEditModal.tsx
  13. +93
    -22
      src/components/UserWorkspacePage/UserWorkspacePage.tsx
  14. +4
    -0
      src/components/UserWorkspacePage/UserWorkspaceWrapper.tsx
  15. +3
    -0
      src/i18n/en/common.json

+ 3
- 1
src/app/(main)/home/page.tsx View File

@@ -4,6 +4,7 @@ import UserWorkspacePage from "@/components/UserWorkspacePage";
import {
fetchLeaveTypes,
fetchLeaves,
fetchTeamMemberTimesheets,
fetchTimesheets,
} from "@/app/api/timesheets";
import { authOptions } from "@/config/authConfig";
@@ -29,9 +30,10 @@ const Home: React.FC = async () => {
fetchLeaveTypes();
fetchProjectWithTasks();
fetchHolidays();
fetchTeamMemberTimesheets(username);

return (
<I18nProvider namespaces={["home"]}>
<I18nProvider namespaces={["home", "common"]}>
<UserWorkspacePage username={username} />
</I18nProvider>
);


+ 11
- 0
src/app/api/timesheets/actions.ts View File

@@ -63,3 +63,14 @@ export const saveLeave = async (data: RecordLeaveInput, username: string) => {

return savedRecords;
};

export const saveMemberEntry = async (data: {
staffId: number;
entry: TimeEntry;
}) => {
return serverFetchJson<TimeEntry>(`${BASE_API_URL}/timesheets/saveMemberEntry`, {
method: "POST",
body: JSON.stringify(data),
headers: { "Content-Type": "application/json" },
});
};

+ 17
- 0
src/app/api/timesheets/index.ts View File

@@ -8,6 +8,14 @@ export interface LeaveType {
name: string;
}

export type TeamTimeSheets = {
[memberId: number]: {
timeEntries: RecordTimesheetInput;
staffId: string;
name: string;
};
};

export const fetchTimesheets = cache(async (username: string) => {
return serverFetchJson<RecordTimesheetInput>(`${BASE_API_URL}/timesheets`, {
next: { tags: [`timesheets_${username}`] },
@@ -28,3 +36,12 @@ export const fetchLeaveTypes = cache(async () => {
next: { tags: ["leaveTypes"] },
});
});

export const fetchTeamMemberTimesheets = cache(async (username: string) => {
return serverFetchJson<TeamTimeSheets>(
`${BASE_API_URL}/timesheets/teamTimesheets`,
{
next: { tags: [`team_timesheets_${username}`] },
},
);
});

+ 61
- 0
src/components/StyledFullCalendar/StyledFullCalendar.tsx View File

@@ -0,0 +1,61 @@
import FullCalendar from "@fullcalendar/react";
import { Box, useTheme } from "@mui/material";
import React from "react";

type Props = React.ComponentProps<typeof FullCalendar>;

const StyledFullCalendar: React.FC<Props> = (props) => {
const theme = useTheme();

return (
<Box
sx={{
".fc": {
fontFamily: theme.typography.fontFamily,
".fc-toolbar-title": theme.typography.h6,
".fc-button": {
borderRadius: "12px",
border: "none",
fontWeight: theme.typography.button.fontWeight,
fontSize: theme.typography.button.fontSize,
},
".fc-button-primary": {
backgroundColor: "primary.main",
"&:disabled": {
backgroundColor: "action.disabledBackground",
color: "action.disabled",
opacity: 1,
},
"&:active:not(:disabled)": {
backgroundColor: "primary.dark",
},
},
".fc-prev-button": { marginInlineEnd: 1 },
".fc-prev-button, .fc-next-button": {
background: "transparent",
border: "none",
color: "neutral.700",
padding: 0,
borderRadius: "50% !important",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
width: 40,
height: 40,
"&.fc-button:active": {
color: "neutral.800",
backgroundColor: "action.hover",
},
"&:focus": {
boxShadow: "none !important",
},
},
},
}}
>
<FullCalendar {...props} />
</Box>
);
};

export default StyledFullCalendar;

+ 1
- 0
src/components/StyledFullCalendar/index.ts View File

@@ -0,0 +1 @@
export { default } from "./StyledFullCalendar";

+ 191
- 0
src/components/TimesheetAmendment/TimesheetAmendment.tsx View File

@@ -0,0 +1,191 @@
import React, { useCallback, useMemo, useState } from "react";

import { HolidaysResult } from "@/app/api/holidays";
import { TeamTimeSheets } from "@/app/api/timesheets";
import dayGridPlugin from "@fullcalendar/daygrid";
import { Autocomplete, Stack, TextField, useTheme } from "@mui/material";
import { useTranslation } from "react-i18next";
import transform from "lodash/transform";
import { getPublicHolidaysForNYears } from "@/app/utils/holidayUtils";
import {
INPUT_DATE_FORMAT,
convertDateArrayToString,
} from "@/app/utils/formatUtil";
import StyledFullCalendar from "../StyledFullCalendar";
import { ProjectWithTasks } from "@/app/api/projects";
import { TimeEntry, saveMemberEntry } from "@/app/api/timesheets/actions";
import TimesheetEditModal, {
Props as TimesheetEditModalProps,
} from "../TimesheetTable/TimesheetEditModal";

export interface Props {
teamTimesheets: TeamTimeSheets;
companyHolidays: HolidaysResult[];
allProjects: ProjectWithTasks[];
}

type MemberOption = TeamTimeSheets[0] & { id: string };

interface EventClickArg {
event: {
start: Date | null;
extendedProps: {
calendar?: string;
entry?: TimeEntry;
memberId?: string;
};
};
}

const TimesheetAmendment: React.FC<Props> = ({
teamTimesheets,
companyHolidays,
allProjects,
}) => {
const { t } = useTranslation(["home", "common"]);

const theme = useTheme();

const projectMap = useMemo(() => {
return allProjects.reduce<{
[id: ProjectWithTasks["id"]]: ProjectWithTasks;
}>((acc, project) => {
return { ...acc, [project.id]: project };
}, {});
}, [allProjects]);

// member select
const allMembers = useMemo(() => {
return transform<TeamTimeSheets[0], MemberOption[]>(
teamTimesheets,
(acc, memberTimesheet, id) => {
return acc.push({
...memberTimesheet,
id,
});
},
[],
);
}, [teamTimesheets]);
const [selectedStaff, setSelectedStaff] = useState<MemberOption>(
allMembers[0],
);

// edit modal related
const [editModalProps, setEditModalProps] = useState<
Partial<TimesheetEditModalProps>
>({});
const [editModalOpen, setEditModalOpen] = useState(false);

const openEditModal = useCallback((defaultValues?: TimeEntry) => {
setEditModalProps({
defaultValues,
});
setEditModalOpen(true);
}, []);

const closeEditModal = useCallback(() => {
setEditModalOpen(false);
}, []);

// calendar related
const holidays = useMemo(() => {
return [
...getPublicHolidaysForNYears(2),
...companyHolidays.map((h) => ({
title: h.name,
date: convertDateArrayToString(h.date, INPUT_DATE_FORMAT),
extendedProps: {
calender: "holiday",
},
})),
].map((e) => ({
...e,
backgroundColor: theme.palette.error.main,
borderColor: theme.palette.error.main,
}));
}, [companyHolidays, theme.palette.error.main]);

const timeEntries = useMemo(
() =>
Object.keys(selectedStaff.timeEntries).flatMap((date, index) => {
return selectedStaff.timeEntries[date].map((entry) => ({
id: `${date}-${index}-entry-${entry.id}`,
date,
title: `${t("{{count}} hour", {
ns: "common",
count: (entry.inputHours || 0) + (entry.otHours || 0),
})} (${
entry.projectId
? projectMap[entry.projectId].code
: t("Non-billable task")
})`,
backgroundColor: theme.palette.info.main,
borderColor: theme.palette.info.main,
extendedProps: {
calendar: "timeEntry",
entry,
memberId: selectedStaff.id,
},
}));
}),
[projectMap, selectedStaff, t, theme],
);

const handleEventClick = useCallback(
({ event }: EventClickArg) => {
if (
event.extendedProps.calendar === "timeEntry" &&
event.extendedProps.entry
) {
openEditModal(event.extendedProps.entry);
}
},
[openEditModal],
);

const handleSave = useCallback(
async (timeEntry: TimeEntry) => {
// TODO: should be fine, but can handle parse error
const intStaffId = parseInt(selectedStaff.id);
await saveMemberEntry({ staffId: intStaffId, entry: timeEntry });
setEditModalOpen(false);
},
[selectedStaff.id],
);

return (
<Stack spacing={2}>
<Autocomplete
sx={{ maxWidth: 400 }}
noOptionsText={t("No team members")}
value={selectedStaff}
onChange={(_, value) => {
if (value) setSelectedStaff(value);
}}
options={allMembers}
isOptionEqualToValue={(option, value) => option.id === value.id}
getOptionLabel={(option) => `${option.staffId} - ${option.name}`}
renderInput={(params) => <TextField {...params} />}
/>
<StyledFullCalendar
plugins={[dayGridPlugin]}
initialView="dayGridMonth"
buttonText={{ today: t("Today") }}
events={[...holidays, ...timeEntries]}
eventClick={handleEventClick}
/>
<TimesheetEditModal
modalSx={{ maxWidth: 400 }}
allProjects={allProjects}
assignedProjects={[]}
open={editModalOpen}
onClose={closeEditModal}
onSave={handleSave}
{...editModalProps}
/>
</Stack>
);
};

export default TimesheetAmendment;

+ 77
- 0
src/components/TimesheetAmendment/TimesheetAmendmentModal.tsx View File

@@ -0,0 +1,77 @@
import useIsMobile from "@/app/utils/useIsMobile";
import React from "react";
import FullscreenModal from "../FullscreenModal";
import {
Box,
Card,
CardContent,
Dialog,
DialogContent,
DialogTitle,
Modal,
SxProps,
Typography,
} from "@mui/material";
import { useTranslation } from "react-i18next";
import TimesheetAmendment, {
Props as TimesheetAmendmentProps,
} from "./TimesheetAmendment";

const modalSx: SxProps = {
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
width: { xs: "calc(100% - 2rem)", sm: "90%" },
maxHeight: "90%",
maxWidth: 1400,
};

interface Props extends TimesheetAmendmentProps {
open: boolean;
onClose: () => void;
}

export const TimesheetAmendmentModal: React.FC<Props> = ({
open,
onClose,
teamTimesheets,
companyHolidays,
allProjects,
}) => {
const { t } = useTranslation("home");
const isMobile = useIsMobile();

const title = t("Timesheet Amendment");
const content = (
<TimesheetAmendment
companyHolidays={companyHolidays}
teamTimesheets={teamTimesheets}
allProjects={allProjects}
/>
);

return isMobile ? (
<FullscreenModal open={open} onClose={onClose} closeModal={onClose}>
<Box display="flex" flexDirection="column" gap={2} height="100%">
<Typography variant="h6" flex="none" padding={2}>
{title}
</Typography>
<Box paddingInline={2}>{content}</Box>
</Box>
</FullscreenModal>
) : (
<Modal open={open} onClose={onClose}>
<Card sx={modalSx}>
<CardContent>
<Typography variant="overline" display="block" marginBlockEnd={1}>
{title}
</Typography>
<Box maxHeight={900} overflow="scroll">
{content}
</Box>
</CardContent>
</Card>
</Modal>
);
};

+ 1
- 0
src/components/TimesheetAmendment/index.ts View File

@@ -0,0 +1 @@
export { default } from "./TimesheetAmendment";

+ 2
- 2
src/components/TimesheetTable/EntryInputTable.tsx View File

@@ -215,7 +215,7 @@ const EntryInputTable: React.FC<Props> = ({
width: 300,
editable: true,
valueFormatter(params) {
const project = assignedProjects.find((p) => p.id === params.value);
const project = allProjects.find((p) => p.id === params.value);
return project ? `${project.code} - ${project.name}` : t("None");
},
renderEditCell(params: GridRenderEditCellParams<TimeEntryRow, number>) {
@@ -295,7 +295,7 @@ const EntryInputTable: React.FC<Props> = ({
: undefined;

const task = projectId
? assignedProjects
? allProjects
.find((p) => p.id === projectId)
?.tasks.find((t) => t.id === params.value)
: undefined;


+ 1
- 1
src/components/TimesheetTable/MobileTimesheetEntry.tsx View File

@@ -84,7 +84,7 @@ const MobileTimesheetEntry: React.FC<Props> = ({
}, []);

const onSaveEntry = useCallback(
(entry: TimeEntry) => {
async (entry: TimeEntry) => {
const existingEntry = currentEntries.find((e) => e.id === entry.id);
if (existingEntry) {
setValue(


+ 0
- 72
src/components/TimesheetTable/ProjectSelect.tsx View File

@@ -102,76 +102,4 @@ const AutocompleteProjectSelect: React.FC<Props> = ({
);
};

// const ProjectSelect: React.FC<Props> = ({
// allProjects,
// assignedProjects,
// value,
// onProjectSelect,
// }) => {
// const { t } = useTranslation("home");

// const nonAssignedProjects = useMemo(() => {
// return differenceBy(allProjects, assignedProjects, "id");
// }, [allProjects, assignedProjects]);

// const onChange = useCallback(
// (event: SelectChangeEvent<number>) => {
// const newValue = event.target.value;
// onProjectSelect(newValue);
// },
// [onProjectSelect],
// );

// return (
// <Select
// displayEmpty
// value={value || ""}
// onChange={onChange}
// sx={{ width: "100%" }}
// MenuProps={{
// slotProps: {
// paper: {
// sx: { maxHeight: 400 },
// },
// },
// anchorOrigin: {
// vertical: "bottom",
// horizontal: "left",
// },
// transformOrigin: {
// vertical: "top",
// horizontal: "left",
// },
// }}
// >
// <ListSubheader>{t("Non-billable")}</ListSubheader>
// <MenuItem value={""}>{t("None")}</MenuItem>
// {assignedProjects.length > 0 && [
// <ListSubheader key="assignedProjectsSubHeader">
// {t("Assigned Projects")}
// </ListSubheader>,
// ...assignedProjects.map((project) => (
// <MenuItem
// key={project.id}
// value={project.id}
// sx={{ whiteSpace: "wrap" }}
// >{`${project.code} - ${project.name}`}</MenuItem>
// )),
// ]}
// {nonAssignedProjects.length > 0 && [
// <ListSubheader key="nonAssignedProjectsSubHeader">
// {t("Non-assigned Projects")}
// </ListSubheader>,
// ...nonAssignedProjects.map((project) => (
// <MenuItem
// key={project.id}
// value={project.id}
// sx={{ whiteSpace: "wrap" }}
// >{`${project.code} - ${project.name}`}</MenuItem>
// )),
// ]}
// </Select>
// );
// };

export default AutocompleteProjectSelect;

+ 4
- 2
src/components/TimesheetTable/TimesheetEditModal.tsx View File

@@ -23,11 +23,12 @@ import uniqBy from "lodash/uniqBy";
import { roundToNearestQuarter } from "@/app/utils/manhourUtils";

export interface Props extends Omit<ModalProps, "children"> {
onSave: (leaveEntry: TimeEntry) => void;
onSave: (timeEntry: TimeEntry) => Promise<void>;
onDelete?: () => void;
defaultValues?: Partial<TimeEntry>;
allProjects: ProjectWithTasks[];
assignedProjects: AssignedProject[];
modalSx?: SxProps;
}

const modalSx: SxProps = {
@@ -50,6 +51,7 @@ const TimesheetEditModal: React.FC<Props> = ({
defaultValues,
allProjects,
assignedProjects,
modalSx: mSx,
}) => {
const { t } = useTranslation("home");

@@ -110,7 +112,7 @@ const TimesheetEditModal: React.FC<Props> = ({

return (
<Modal open={open} onClose={closeHandler}>
<Paper sx={modalSx}>
<Paper sx={{ ...modalSx, ...mSx }}>
<FormControl fullWidth>
<InputLabel shrink>{t("Project Code and Name")}</InputLabel>
<Controller


+ 93
- 22
src/components/UserWorkspacePage/UserWorkspacePage.tsx View File

@@ -1,12 +1,16 @@
"use client";

import { useCallback, useState } from "react";
import React, { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import Button from "@mui/material/Button";
import Stack from "@mui/material/Stack";
import { Add } from "@mui/icons-material";
import { Box, Typography } from "@mui/material";
import ButtonGroup from "@mui/material/ButtonGroup";
import {
CalendarMonth,
EditCalendar,
Luggage,
MoreTime,
} from "@mui/icons-material";
import { Menu, MenuItem, SxProps, Typography } from "@mui/material";
import AssignedProjects from "./AssignedProjects";
import TimesheetModal from "../TimesheetModal";
import { AssignedProject, ProjectWithTasks } from "@/app/api/projects";
@@ -15,10 +19,11 @@ import {
RecordTimesheetInput,
} from "@/app/api/timesheets/actions";
import LeaveModal from "../LeaveModal";
import { LeaveType } from "@/app/api/timesheets";
import { LeaveType, TeamTimeSheets } from "@/app/api/timesheets";
import { CalendarIcon } from "@mui/x-date-pickers";
import PastEntryCalendarModal from "../PastEntryCalendar/PastEntryCalendarModal";
import { HolidaysResult } from "@/app/api/holidays";
import { TimesheetAmendmentModal } from "../TimesheetAmendment/TimesheetAmendmentModal";

export interface Props {
leaveTypes: LeaveType[];
@@ -28,8 +33,14 @@ export interface Props {
defaultLeaveRecords: RecordLeaveInput;
defaultTimesheets: RecordTimesheetInput;
holidays: HolidaysResult[];
teamTimesheets: TeamTimeSheets;
}

const menuItemSx: SxProps = {
gap: 1,
color: "neutral.700",
};

const UserWorkspacePage: React.FC<Props> = ({
leaveTypes,
allProjects,
@@ -38,13 +49,31 @@ const UserWorkspacePage: React.FC<Props> = ({
defaultLeaveRecords,
defaultTimesheets,
holidays,
teamTimesheets,
}) => {
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);

const [isTimeheetModalVisible, setTimeheetModalVisible] = useState(false);
const [isLeaveModalVisible, setLeaveModalVisible] = useState(false);
const [isPastEventModalVisible, setPastEventModalVisible] = useState(false);
const [isTimesheetAmendmentVisible, setisTimesheetAmendmentVisible] =
useState(false);
const { t } = useTranslation("home");

const showTimesheetAmendment = Object.keys(teamTimesheets).length > 0;

const handleOpenActionMenu = useCallback<
React.MouseEventHandler<HTMLButtonElement>
>((event) => {
setAnchorEl(event.currentTarget);
}, []);

const handleCloseActionMenu = useCallback(() => {
setAnchorEl(null);
}, []);

const handleAddTimesheetButtonClick = useCallback(() => {
setAnchorEl(null);
setTimeheetModalVisible(true);
}, []);

@@ -53,6 +82,7 @@ const UserWorkspacePage: React.FC<Props> = ({
}, []);

const handleAddLeaveButtonClick = useCallback(() => {
setAnchorEl(null);
setLeaveModalVisible(true);
}, []);

@@ -61,6 +91,7 @@ const UserWorkspacePage: React.FC<Props> = ({
}, []);

const handlePastEventClick = useCallback(() => {
setAnchorEl(null);
setPastEventModalVisible(true);
}, []);

@@ -68,6 +99,15 @@ const UserWorkspacePage: React.FC<Props> = ({
setPastEventModalVisible(false);
}, []);

const handleAmendmentClick = useCallback(() => {
setAnchorEl(null);
setisTimesheetAmendmentVisible(true);
}, []);

const handleAmendmentClose = useCallback(() => {
setisTimesheetAmendmentVisible(false);
}, []);

return (
<>
<Stack
@@ -79,24 +119,46 @@ const UserWorkspacePage: React.FC<Props> = ({
<Typography variant="h4" marginInlineEnd={2}>
{t("User Workspace")}
</Typography>
<Box display="flex" flexWrap="wrap" gap={2}>
<Button
startIcon={<CalendarIcon />}
variant="outlined"
onClick={handlePastEventClick}
>
{t("View Past Entries")}
</Button>
<ButtonGroup variant="contained">
<Button startIcon={<Add />} onClick={handleAddTimesheetButtonClick}>
{t("Enter Time")}
</Button>
<Button startIcon={<Add />} onClick={handleAddLeaveButtonClick}>
{t("Record Leave")}
</Button>
</ButtonGroup>
</Box>
<Button
startIcon={<CalendarIcon />}
variant="contained"
onClick={handleOpenActionMenu}
>
{t("Timesheet Actions")}
</Button>
</Stack>
<Menu
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={handleCloseActionMenu}
anchorOrigin={{
vertical: "bottom",
horizontal: "right",
}}
transformOrigin={{
vertical: "top",
horizontal: "right",
}}
>
<MenuItem onClick={handleAddTimesheetButtonClick} sx={menuItemSx}>
<MoreTime />
{t("Enter Time")}
</MenuItem>
<MenuItem onClick={handleAddLeaveButtonClick} sx={menuItemSx}>
<Luggage />
{t("Record Leave")}
</MenuItem>
<MenuItem onClick={handlePastEventClick} sx={menuItemSx}>
<CalendarMonth />
{t("View Past Entries")}
</MenuItem>
{showTimesheetAmendment && (
<MenuItem onClick={handleAmendmentClick} sx={menuItemSx}>
<EditCalendar />
{t("Timesheet Amendment")}
</MenuItem>
)}
</Menu>
<PastEntryCalendarModal
open={isPastEventModalVisible}
handleClose={handlePastEventClose}
@@ -131,6 +193,15 @@ const UserWorkspacePage: React.FC<Props> = ({
{t("You have no assigned projects!")}
</Typography>
)}
{showTimesheetAmendment && (
<TimesheetAmendmentModal
allProjects={allProjects}
companyHolidays={holidays}
teamTimesheets={teamTimesheets}
open={isTimesheetAmendmentVisible}
onClose={handleAmendmentClose}
/>
)}
</>
);
};


+ 4
- 0
src/components/UserWorkspacePage/UserWorkspaceWrapper.tsx View File

@@ -6,6 +6,7 @@ import UserWorkspacePage from "./UserWorkspacePage";
import {
fetchLeaveTypes,
fetchLeaves,
fetchTeamMemberTimesheets,
fetchTimesheets,
} from "@/app/api/timesheets";
import { fetchHolidays } from "@/app/api/holidays";
@@ -16,6 +17,7 @@ interface Props {

const UserWorkspaceWrapper: React.FC<Props> = async ({ username }) => {
const [
teamTimesheets,
assignedProjects,
allProjects,
timesheets,
@@ -23,6 +25,7 @@ const UserWorkspaceWrapper: React.FC<Props> = async ({ username }) => {
leaveTypes,
holidays,
] = await Promise.all([
fetchTeamMemberTimesheets(username),
fetchAssignedProjects(username),
fetchProjectWithTasks(),
fetchTimesheets(username),
@@ -33,6 +36,7 @@ const UserWorkspaceWrapper: React.FC<Props> = async ({ username }) => {

return (
<UserWorkspacePage
teamTimesheets={teamTimesheets}
allProjects={allProjects}
assignedProjects={assignedProjects}
username={username}


+ 3
- 0
src/i18n/en/common.json View File

@@ -1,6 +1,9 @@
{
"Grade {{grade}}": "Grade {{grade}}",

"{{count}} hour_one": "{{count}} hour",
"{{count}} hour_other": "{{count}} hours",

"All": "All",
"Petty Cash": "Petty Cash",


Loading…
Cancel
Save