Pārlūkot izejas kodu

Add mobile timesheet input

tags/Baseline_30082024_FRONTEND_UAT
Wayne pirms 1 gada
vecāks
revīzija
4a9ff12af7
16 mainītis faili ar 1056 papildinājumiem un 315 dzēšanām
  1. +205
    -0
      src/components/DateHoursTable/DateHoursList.tsx
  2. +196
    -0
      src/components/DateHoursTable/DateHoursTable.tsx
  3. +1
    -0
      src/components/DateHoursTable/index.ts
  4. +46
    -0
      src/components/FullscreenModal/FullscreenModal.tsx
  5. +1
    -0
      src/components/FullscreenModal/index.ts
  6. +76
    -33
      src/components/LeaveModal/LeaveModal.tsx
  7. +132
    -0
      src/components/LeaveTable/LeaveEditModal.tsx
  8. +2
    -2
      src/components/LeaveTable/LeaveEntryTable.tsx
  9. +14
    -119
      src/components/LeaveTable/LeaveTable.tsx
  10. +179
    -0
      src/components/LeaveTable/MobileLeaveEntry.tsx
  11. +35
    -0
      src/components/LeaveTable/MobileLeaveTable.tsx
  12. +72
    -33
      src/components/TimesheetModal/TimesheetModal.tsx
  13. +40
    -0
      src/components/TimesheetTable/MobileTimesheetEntry.tsx
  14. +37
    -0
      src/components/TimesheetTable/MobileTimesheetTable.tsx
  15. +18
    -128
      src/components/TimesheetTable/TimesheetTable.tsx
  16. +2
    -0
      src/components/UserWorkspacePage/UserWorkspacePage.tsx

+ 205
- 0
src/components/DateHoursTable/DateHoursList.tsx Parādīt failu

@@ -0,0 +1,205 @@
import {
RecordLeaveInput,
RecordTimesheetInput,
} from "@/app/api/timesheets/actions";
import { manhourFormatter, shortDateFormatter } from "@/app/utils/formatUtil";
import { ArrowBack, Check } from "@mui/icons-material";
import {
Box,
Button,
Card,
CardActionArea,
CardContent,
Stack,
Typography,
} from "@mui/material";
import dayjs from "dayjs";
import React, { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import {
LEAVE_DAILY_MAX_HOURS,
TIMESHEET_DAILY_MAX_HOURS,
} from "@/app/api/timesheets/utils";

interface Props<EntryComponentProps = object> {
days: string[];
leaveEntries: RecordLeaveInput;
timesheetEntries: RecordTimesheetInput;
EntryComponent: React.FunctionComponent<
EntryComponentProps & { date: string }
>;
entryComponentProps: EntryComponentProps;
}

function DateHoursList<EntryTableProps>({
days,
leaveEntries,
timesheetEntries,
EntryComponent,
entryComponentProps,
}: Props<EntryTableProps>) {
const {
t,
i18n: { language },
} = useTranslation("home");

const [selectedDate, setSelectedDate] = useState("");
const isDateSelected = selectedDate !== "";

const makeSelectDate = useCallback(
(date: string) => () => {
setSelectedDate(date);
},
[],
);

const onDateDone = useCallback<React.MouseEventHandler<HTMLButtonElement>>(
(e) => {
setSelectedDate("");
e.preventDefault();
},
[],
);

return (
<>
{isDateSelected ? (
<EntryComponent date={selectedDate} {...entryComponentProps} />
) : (
<Box overflow="scroll" flex={1}>
{days.map((day, index) => {
const dayJsObj = dayjs(day);
const leaves = leaveEntries[day];
const leaveHours =
leaves?.reduce((acc, entry) => acc + entry.inputHours, 0) || 0;

const timesheet = timesheetEntries[day];
const timesheetHours =
timesheet?.reduce(
(acc, entry) =>
acc + (entry.inputHours || 0) + (entry.otHours || 0),
0,
) || 0;

const dailyTotal = leaveHours + timesheetHours;

const leaveExceeded = leaveHours > LEAVE_DAILY_MAX_HOURS;
const dailyTotalExceeded = dailyTotal > TIMESHEET_DAILY_MAX_HOURS;

return (
<Card
key={`${day}-${index}`}
sx={{ marginBlockEnd: 2, marginInline: 2 }}
>
<CardActionArea onClick={makeSelectDate(day)}>
<CardContent sx={{ padding: 3 }}>
<Typography
variant="overline"
component="div"
sx={{
color: dayJsObj.day() === 0 ? "error.main" : undefined,
}}
>
{shortDateFormatter(language).format(dayJsObj.toDate())}
</Typography>
<Stack spacing={1}>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "baseline",
}}
>
<Typography variant="body2">
{t("Timesheet Hours")}
</Typography>
<Typography>
{manhourFormatter.format(timesheetHours)}
</Typography>
</Box>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
flexWrap: "wrap",
alignItems: "baseline",
color: leaveExceeded ? "error.main" : undefined,
}}
>
<Typography variant="body2">
{t("Leave Hours")}
</Typography>
<Typography>
{manhourFormatter.format(leaveHours)}
</Typography>
{leaveExceeded && (
<Typography
component="div"
width="100%"
variant="caption"
>
{t("Leave hours cannot be more than {{hours}}", {
hours: LEAVE_DAILY_MAX_HOURS,
})}
</Typography>
)}
</Box>

<Box
sx={{
display: "flex",
justifyContent: "space-between",
flexWrap: "wrap",
alignItems: "baseline",
color: dailyTotalExceeded ? "error.main" : undefined,
}}
>
<Typography variant="body2">
{t("Daily Total Hours")}
</Typography>
<Typography>
{manhourFormatter.format(timesheetHours + leaveHours)}
</Typography>
{dailyTotalExceeded && (
<Typography
component="div"
width="100%"
variant="caption"
>
{t(
"The daily total hours cannot be more than {{hours}}",
{
hours: TIMESHEET_DAILY_MAX_HOURS,
},
)}
</Typography>
)}
</Box>
</Stack>
</CardContent>
</CardActionArea>
</Card>
);
})}
</Box>
)}
<Box padding={2} display="flex" justifyContent="flex-end">
{isDateSelected ? (
<Button
variant="outlined"
startIcon={<ArrowBack />}
onClick={onDateDone}
>
{t("Done")}
</Button>
) : (
<Button variant="contained" startIcon={<Check />} type="submit">
{t("Save")}
</Button>
)}
</Box>
</>
);
}

export default DateHoursList;

+ 196
- 0
src/components/DateHoursTable/DateHoursTable.tsx Parādīt failu

@@ -0,0 +1,196 @@
import {
RecordLeaveInput,
RecordTimesheetInput,
} from "@/app/api/timesheets/actions";
import { manhourFormatter, shortDateFormatter } from "@/app/utils/formatUtil";
import { Info, KeyboardArrowDown, KeyboardArrowUp } from "@mui/icons-material";
import {
Box,
Collapse,
IconButton,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Tooltip,
} from "@mui/material";
import dayjs from "dayjs";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import {
LEAVE_DAILY_MAX_HOURS,
TIMESHEET_DAILY_MAX_HOURS,
} from "@/app/api/timesheets/utils";

interface Props<EntryTableProps = object> {
days: string[];
leaveEntries: RecordLeaveInput;
timesheetEntries: RecordTimesheetInput;
EntryTableComponent: React.FunctionComponent<
EntryTableProps & { day: string }
>;
entryTableProps: EntryTableProps;
}

function DateHoursTable<EntryTableProps>({
days,
EntryTableComponent,
entryTableProps,
leaveEntries,
timesheetEntries,
}: Props<EntryTableProps>) {
const { t } = useTranslation("home");

return (
<TableContainer sx={{ maxHeight: 400 }}>
<Table stickyHeader>
<TableHead>
<TableRow>
<TableCell />
<TableCell>{t("Date")}</TableCell>
<TableCell>{t("Timesheet Hours")}</TableCell>
<TableCell>{t("Leave Hours")}</TableCell>
<TableCell>{t("Daily Total Hours")}</TableCell>
</TableRow>
</TableHead>
<TableBody>
{days.map((day, index) => {
return (
<DayRow
key={`${day}${index}`}
day={day}
leaveEntries={leaveEntries}
timesheetEntries={timesheetEntries}
EntryTableComponent={EntryTableComponent}
entryTableProps={entryTableProps}
/>
);
})}
</TableBody>
</Table>
</TableContainer>
);
}

function DayRow<EntryTableProps>({
day,
leaveEntries,
timesheetEntries,
entryTableProps,
EntryTableComponent,
}: {
day: string;
leaveEntries: RecordLeaveInput;
timesheetEntries: RecordTimesheetInput;
EntryTableComponent: React.FunctionComponent<
EntryTableProps & { day: string }
>;
entryTableProps: EntryTableProps;
}) {
const {
t,
i18n: { language },
} = useTranslation("home");
const dayJsObj = dayjs(day);
const [open, setOpen] = useState(false);

const leaves = leaveEntries[day];
const leaveHours =
leaves?.reduce((acc, entry) => acc + entry.inputHours, 0) || 0;

const timesheet = timesheetEntries[day];
const timesheetHours =
timesheet?.reduce(
(acc, entry) => acc + (entry.inputHours || 0) + (entry.otHours || 0),
0,
) || 0;

const dailyTotal = leaveHours + timesheetHours;

const leaveExceeded = leaveHours > LEAVE_DAILY_MAX_HOURS;
const dailyTotalExceeded = dailyTotal > TIMESHEET_DAILY_MAX_HOURS;

return (
<>
<TableRow>
<TableCell align="center" width={70}>
<IconButton
disableRipple
aria-label="expand row"
size="small"
onClick={() => setOpen(!open)}
>
{open ? <KeyboardArrowUp /> : <KeyboardArrowDown />}
</IconButton>
</TableCell>
<TableCell
sx={{ color: dayJsObj.day() === 0 ? "error.main" : undefined }}
>
{shortDateFormatter(language).format(dayJsObj.toDate())}
</TableCell>
{/* Timesheet */}
<TableCell>{manhourFormatter.format(timesheetHours)}</TableCell>
{/* Leave total */}
<TableCell
sx={{
color: leaveExceeded ? "error.main" : undefined,
}}
>
<Box display="flex" gap={1} alignItems="center">
{manhourFormatter.format(leaveHours)}
{leaveExceeded && (
<Tooltip
title={t("Leave hours cannot be more than {{hours}}", {
hours: LEAVE_DAILY_MAX_HOURS,
})}
>
<Info fontSize="small" />
</Tooltip>
)}
</Box>
</TableCell>
{/* Daily total */}
<TableCell
sx={{
color: dailyTotalExceeded ? "error.main" : undefined,
}}
>
<Box display="flex" gap={1} alignItems="center">
{manhourFormatter.format(dailyTotal)}
{dailyTotalExceeded && (
<Tooltip
title={t(
"The daily total hours cannot be more than {{hours}}",
{
hours: TIMESHEET_DAILY_MAX_HOURS,
},
)}
>
<Info fontSize="small" />
</Tooltip>
)}
</Box>
</TableCell>
</TableRow>
<TableRow>
<TableCell
sx={{
p: 0,
border: "none",
outline: open ? "1px solid" : undefined,
outlineColor: "primary.main",
}}
colSpan={5}
>
<Collapse in={open} timeout="auto" unmountOnExit>
<Box>{<EntryTableComponent day={day} {...entryTableProps} />}</Box>
</Collapse>
</TableCell>
</TableRow>
</>
);
}

export default DateHoursTable;

+ 1
- 0
src/components/DateHoursTable/index.ts Parādīt failu

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

+ 46
- 0
src/components/FullscreenModal/FullscreenModal.tsx Parādīt failu

@@ -0,0 +1,46 @@
import { Close } from "@mui/icons-material";
import {
Box,
IconButton,
Modal,
ModalProps,
Paper,
Slide,
} from "@mui/material";

interface Props extends ModalProps {
closeModal: () => void;
}

const FullscreenModal: React.FC<Props> = ({
children,
closeModal,
...props
}) => {
return (
<Modal {...props}>
<Slide in={props.open} direction="up">
<Paper
sx={{
width: "100%",
height: "100%",
borderRadius: 0,
display: "flex",
flexDirection: "column",
}}
>
<Box color="text.primary" flex="none">
<IconButton onClick={closeModal} color="inherit" size="large">
<Close />
</IconButton>
</Box>
<Box flex={1} overflow="hidden">
{children}
</Box>
</Paper>
</Slide>
</Modal>
);
};

export default FullscreenModal;

+ 1
- 0
src/components/FullscreenModal/index.ts Parādīt failu

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

+ 76
- 33
src/components/LeaveModal/LeaveModal.tsx Parādīt failu

@@ -9,15 +9,23 @@ import {
ModalProps,
SxProps,
Typography,
useMediaQuery,
useTheme,
} from "@mui/material";
import { useTranslation } from "react-i18next";
import { Check, Close } from "@mui/icons-material";
import { FormProvider, SubmitHandler, useForm } from "react-hook-form";
import { RecordLeaveInput, saveLeave } from "@/app/api/timesheets/actions";
import {
RecordLeaveInput,
RecordTimesheetInput,
saveLeave,
} from "@/app/api/timesheets/actions";
import dayjs from "dayjs";
import { INPUT_DATE_FORMAT } from "@/app/utils/formatUtil";
import LeaveTable from "../LeaveTable";
import { LeaveType } from "@/app/api/timesheets";
import FullscreenModal from "../FullscreenModal";
import MobileLeaveTable from "../LeaveTable/MobileLeaveTable";

interface Props {
isOpen: boolean;
@@ -25,6 +33,7 @@ interface Props {
username: string;
defaultLeaveRecords?: RecordLeaveInput;
leaveTypes: LeaveType[];
timesheetRecords: RecordTimesheetInput;
}

const modalSx: SxProps = {
@@ -34,7 +43,7 @@ const modalSx: SxProps = {
transform: "translate(-50%, -50%)",
width: { xs: "calc(100% - 2rem)", sm: "90%" },
maxHeight: "90%",
maxWidth: 1200,
maxWidth: 1400,
};

const LeaveModal: React.FC<Props> = ({
@@ -42,6 +51,7 @@ const LeaveModal: React.FC<Props> = ({
onClose,
username,
defaultLeaveRecords,
timesheetRecords,
leaveTypes,
}) => {
const { t } = useTranslation("home");
@@ -90,47 +100,80 @@ const LeaveModal: React.FC<Props> = ({
const onModalClose = useCallback<NonNullable<ModalProps["onClose"]>>(
(_, reason) => {
if (reason !== "backdropClick") {
onClose();
onCancel();
}
},
[onClose],
[onCancel],
);

const theme = useTheme();
const matches = useMediaQuery(theme.breakpoints.up("sm"));

return (
<Modal open={isOpen} onClose={onModalClose}>
<Card sx={modalSx}>
<FormProvider {...formProps}>
<CardContent
<FormProvider {...formProps}>
{matches ? (
// Desktop version
<Modal open={isOpen} onClose={onModalClose}>
<Card sx={modalSx}>
<CardContent
component="form"
onSubmit={formProps.handleSubmit(onSubmit)}
>
<Typography variant="overline" display="block" marginBlockEnd={1}>
{t("Record Leave")}
</Typography>
<Box
sx={{
marginInline: -3,
marginBlock: 4,
}}
>
<LeaveTable
leaveTypes={leaveTypes}
timesheetRecords={timesheetRecords}
/>
</Box>
<CardActions sx={{ justifyContent: "flex-end" }}>
<Button
variant="outlined"
startIcon={<Close />}
onClick={onCancel}
>
{t("Cancel")}
</Button>
<Button variant="contained" startIcon={<Check />} type="submit">
{t("Save")}
</Button>
</CardActions>
</CardContent>
</Card>
</Modal>
) : (
// Mobile version
<FullscreenModal
open={isOpen}
onClose={onModalClose}
closeModal={onCancel}
>
<Box
display="flex"
flexDirection="column"
gap={2}
height="100%"
component="form"
onSubmit={formProps.handleSubmit(onSubmit)}
>
<Typography variant="overline" display="block" marginBlockEnd={1}>
<Typography variant="h6" padding={2} flex="none">
{t("Record Leave")}
</Typography>
<Box
sx={{
marginInline: -3,
marginBlock: 4,
}}
>
<LeaveTable leaveTypes={leaveTypes} />
</Box>
<CardActions sx={{ justifyContent: "flex-end" }}>
<Button
variant="outlined"
startIcon={<Close />}
onClick={onCancel}
>
{t("Cancel")}
</Button>
<Button variant="contained" startIcon={<Check />} type="submit">
{t("Save")}
</Button>
</CardActions>
</CardContent>
</FormProvider>
</Card>
</Modal>
<MobileLeaveTable
leaveTypes={leaveTypes}
timesheetRecords={timesheetRecords}
/>
</Box>
</FullscreenModal>
)}
</FormProvider>
);
};



+ 132
- 0
src/components/LeaveTable/LeaveEditModal.tsx Parādīt failu

@@ -0,0 +1,132 @@
import { LeaveType } from "@/app/api/timesheets";
import { LeaveEntry } from "@/app/api/timesheets/actions";
import { Check, Delete } from "@mui/icons-material";
import {
Box,
Button,
FormControl,
InputLabel,
MenuItem,
Modal,
ModalProps,
Paper,
Select,
SxProps,
TextField,
} from "@mui/material";
import React, { useCallback, useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";

export interface Props extends Omit<ModalProps, "children"> {
onSave: (leaveEntry: LeaveEntry) => void;
onDelete?: () => void;
leaveTypes: LeaveType[];
defaultValues?: Partial<LeaveEntry>;
}

const modalSx: SxProps = {
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
width: "90%",
maxHeight: "90%",
padding: 3,
display: "flex",
flexDirection: "column",
gap: 2,
};
const LeaveEditModal: React.FC<Props> = ({
onSave,
onDelete,
open,
onClose,
leaveTypes,
defaultValues,
}) => {
const { t } = useTranslation("home");
const { register, control, reset, getValues, trigger, formState } =
useForm<LeaveEntry>();

useEffect(() => {
reset(defaultValues ?? { leaveTypeId: leaveTypes[0].id, id: Date.now() });
}, [defaultValues, leaveTypes, reset]);

const saveHandler = useCallback(async () => {
const valid = await trigger();
if (valid) {
onSave(getValues());
}
}, [getValues, onSave, trigger]);

const closeHandler = useCallback<NonNullable<ModalProps["onClose"]>>(
(...args) => {
onClose?.(...args);
reset();
},
[onClose, reset],
);

return (
<Modal open={open} onClose={closeHandler}>
<Paper sx={modalSx}>
<FormControl fullWidth>
<InputLabel>{t("Leave Type")}</InputLabel>
<Controller
defaultValue={leaveTypes[0].id}
control={control}
name="leaveTypeId"
render={({ field }) => (
<Select label={t("Leave Type")} {...field}>
{leaveTypes.map((type, index) => (
<MenuItem key={`${type.id}-${index}`} value={type.id}>
{type.name}
</MenuItem>
))}
</Select>
)}
/>
</FormControl>
<TextField
type="number"
label={t("Hours")}
fullWidth
{...register("inputHours", {
valueAsNumber: true,
validate: (value) => value > 0,
})}
error={Boolean(formState.errors.inputHours)}
/>
<TextField
label={t("Remark")}
fullWidth
multiline
rows={2}
{...register("remark")}
/>
<Box display="flex" justifyContent="flex-end" gap={1}>
{onDelete && (
<Button
variant="outlined"
startIcon={<Delete />}
color="error"
onClick={onDelete}
>
{t("Delete")}
</Button>
)}
<Button
variant="contained"
startIcon={<Check />}
onClick={saveHandler}
>
{t("Save")}
</Button>
</Box>
</Paper>
</Modal>
);
};

export default LeaveEditModal;

+ 2
- 2
src/components/LeaveTable/LeaveEntryTable.tsx Parādīt failu

@@ -169,8 +169,8 @@ const EntryInputTable: React.FC<Props> = ({ day, leaveTypes }) => {
},
{
field: "inputHours",
headerName: t("Hours"),
width: 100,
headerName: t("Leave Hours"),
width: 150,
editable: true,
type: "number",
valueFormatter(params) {


+ 14
- 119
src/components/LeaveTable/LeaveTable.tsx Parādīt failu

@@ -1,136 +1,31 @@
import { RecordLeaveInput, LeaveEntry } from "@/app/api/timesheets/actions";
import { manhourFormatter, shortDateFormatter } from "@/app/utils/formatUtil";
import { KeyboardArrowDown, KeyboardArrowUp } from "@mui/icons-material";
import {
Box,
Collapse,
IconButton,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Typography,
} from "@mui/material";
import dayjs from "dayjs";
import React, { useState } from "react";
RecordLeaveInput,
RecordTimesheetInput,
} from "@/app/api/timesheets/actions";
import React from "react";
import { useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next";
import LeaveEntryTable from "./LeaveEntryTable";
import { LeaveType } from "@/app/api/timesheets";
import { LEAVE_DAILY_MAX_HOURS } from "@/app/api/timesheets/utils";
import DateHoursTable from "../DateHoursTable";

interface Props {
leaveTypes: LeaveType[];
timesheetRecords: RecordTimesheetInput;
}

const LeaveTable: React.FC<Props> = ({ leaveTypes }) => {
const { t } = useTranslation("home");

const LeaveTable: React.FC<Props> = ({ leaveTypes, timesheetRecords }) => {
const { watch } = useFormContext<RecordLeaveInput>();
const currentInput = watch();
const days = Object.keys(currentInput);

return (
<TableContainer sx={{ maxHeight: 400 }}>
<Table stickyHeader>
<TableHead>
<TableRow>
<TableCell />
<TableCell>{t("Date")}</TableCell>
<TableCell>{t("Daily Total Hours")}</TableCell>
</TableRow>
</TableHead>
<TableBody>
{days.map((day, index) => {
const entries = currentInput[day];
return (
<DayRow
key={`${day}${index}`}
day={day}
entries={entries}
leaveTypes={leaveTypes}
/>
);
})}
</TableBody>
</Table>
</TableContainer>
);
};

const DayRow: React.FC<{
day: string;
entries: LeaveEntry[];
leaveTypes: LeaveType[];
}> = ({ day, entries, leaveTypes }) => {
const {
t,
i18n: { language },
} = useTranslation("home");
const dayJsObj = dayjs(day);
const [open, setOpen] = useState(false);

const totalHours = entries.reduce((acc, entry) => acc + entry.inputHours, 0);

return (
<>
<TableRow>
<TableCell align="center" width={70}>
<IconButton
disableRipple
aria-label="expand row"
size="small"
onClick={() => setOpen(!open)}
>
{open ? <KeyboardArrowUp /> : <KeyboardArrowDown />}
</IconButton>
</TableCell>
<TableCell
sx={{ color: dayJsObj.day() === 0 ? "error.main" : undefined }}
>
{shortDateFormatter(language).format(dayJsObj.toDate())}
</TableCell>
<TableCell
sx={{
color:
totalHours > LEAVE_DAILY_MAX_HOURS ? "error.main" : undefined,
}}
>
{manhourFormatter.format(totalHours)}
{totalHours > LEAVE_DAILY_MAX_HOURS && (
<Typography
color="error.main"
variant="body2"
component="span"
sx={{ marginInlineStart: 1 }}
>
{t("(the daily total hours cannot be more than {{hours}})", {
hours: LEAVE_DAILY_MAX_HOURS,
})}
</Typography>
)}
</TableCell>
</TableRow>
<TableRow>
<TableCell
sx={{
p: 0,
border: "none",
outline: open ? "1px solid" : undefined,
outlineColor: "primary.main",
}}
colSpan={3}
>
<Collapse in={open} timeout="auto" unmountOnExit>
<Box>
<LeaveEntryTable day={day} leaveTypes={leaveTypes} />
</Box>
</Collapse>
</TableCell>
</TableRow>
</>
<DateHoursTable
days={days}
leaveEntries={currentInput}
timesheetEntries={timesheetRecords}
EntryTableComponent={LeaveEntryTable}
entryTableProps={{ leaveTypes }}
/>
);
};



+ 179
- 0
src/components/LeaveTable/MobileLeaveEntry.tsx Parādīt failu

@@ -0,0 +1,179 @@
import { LeaveType } from "@/app/api/timesheets";
import { LeaveEntry, RecordLeaveInput } from "@/app/api/timesheets/actions";
import { manhourFormatter, shortDateFormatter } from "@/app/utils/formatUtil";
import { Add, Edit } from "@mui/icons-material";
import {
Box,
Button,
Card,
CardContent,
IconButton,
Typography,
} from "@mui/material";
import dayjs from "dayjs";
import React, { useCallback, useMemo, useState } from "react";
import { useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next";
import LeaveEditModal, { Props as LeaveEditModalProps } from "./LeaveEditModal";

interface Props {
date: string;
leaveTypes: LeaveType[];
}

const MobileLeaveEntry: React.FC<Props> = ({ date, leaveTypes }) => {
const {
t,
i18n: { language },
} = useTranslation("home");
const dayJsObj = dayjs(date);

const leaveTypeMap = useMemo<{ [id: LeaveType["id"]]: LeaveType }>(() => {
return leaveTypes.reduce(
(acc, leaveType) => ({ ...acc, [leaveType.id]: leaveType }),
{},
);
}, [leaveTypes]);

const { watch, setValue } = useFormContext<RecordLeaveInput>();
const currentEntries = watch(date);

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

const openEditModal = useCallback(
(defaultValues?: LeaveEntry) => () => {
setEditModalProps({
defaultValues,
onDelete: defaultValues
? () => {
setValue(
date,
currentEntries.filter((entry) => entry.id !== defaultValues.id),
);
setEditModalOpen(false);
}
: undefined,
});
setEditModalOpen(true);
},
[currentEntries, date, setValue],
);

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

const onSaveEntry = useCallback(
(entry: LeaveEntry) => {
const existingEntry = currentEntries.find((e) => e.id === entry.id);
if (existingEntry) {
setValue(
date,
currentEntries.map((e) => ({
...(e.id === existingEntry.id ? entry : e),
})),
);
} else {
setValue(date, [...currentEntries, entry]);
}
setEditModalOpen(false);
},
[currentEntries, date, setValue],
);

return (
<Box
marginInline={2}
flex={1}
display="flex"
flexDirection="column"
gap={2}
>
<Typography
variant="overline"
color={dayJsObj.day() === 0 ? "error.main" : undefined}
>
{shortDateFormatter(language).format(dayJsObj.toDate())}
</Typography>
{currentEntries.length ? (
currentEntries.map((entry, index) => {
return (
<Card key={`${entry.id}-${index}`} sx={{ marginInline: 1 }}>
<CardContent
sx={{
padding: 2,
display: "flex",
flexDirection: "column",
gap: 2,
"&:last-child": {
paddingBottom: 2,
},
}}
>
<Box
display="flex"
justifyContent="space-between"
alignItems="flex-start"
>
<Box>
<Typography
variant="body2"
component="div"
fontWeight="bold"
>
{leaveTypeMap[entry.leaveTypeId].name}
</Typography>
<Typography component="p">
{manhourFormatter.format(entry.inputHours)}
</Typography>
</Box>
<IconButton
size="small"
color="primary"
onClick={openEditModal(entry)}
>
<Edit />
</IconButton>
</Box>
{entry.remark && (
<Box>
<Typography
variant="body2"
component="div"
fontWeight="bold"
>
{t("Remark")}
</Typography>
<Typography component="p">{entry.remark}</Typography>
</Box>
)}
</CardContent>
</Card>
);
})
) : (
<Typography variant="body2" display="block">
{t("Add some leave entries!")}
</Typography>
)}
<Box>
<Button startIcon={<Add />} onClick={openEditModal()}>
{t("Record leave")}
</Button>
</Box>
<LeaveEditModal
leaveTypes={leaveTypes}
open={editModalOpen}
onClose={closeEditModal}
onSave={onSaveEntry}
{...editModalProps}
/>
</Box>
);
};

export default MobileLeaveEntry;

+ 35
- 0
src/components/LeaveTable/MobileLeaveTable.tsx Parādīt failu

@@ -0,0 +1,35 @@
import {
RecordLeaveInput,
RecordTimesheetInput,
} from "@/app/api/timesheets/actions";
import React from "react";
import { useFormContext } from "react-hook-form";
import { LeaveType } from "@/app/api/timesheets";
import MobileLeaveEntry from "./MobileLeaveEntry";
import DateHoursList from "../DateHoursTable/DateHoursList";

interface Props {
leaveTypes: LeaveType[];
timesheetRecords: RecordTimesheetInput;
}

const MobileLeaveTable: React.FC<Props> = ({
timesheetRecords,
leaveTypes,
}) => {
const { watch } = useFormContext<RecordLeaveInput>();
const currentInput = watch();
const days = Object.keys(currentInput);

return (
<DateHoursList
days={days}
leaveEntries={currentInput}
timesheetEntries={timesheetRecords}
EntryComponent={MobileLeaveEntry}
entryComponentProps={{ leaveTypes }}
/>
);
};

export default MobileLeaveTable;

+ 72
- 33
src/components/TimesheetModal/TimesheetModal.tsx Parādīt failu

@@ -9,18 +9,23 @@ import {
ModalProps,
SxProps,
Typography,
useMediaQuery,
useTheme,
} from "@mui/material";
import TimesheetTable from "../TimesheetTable";
import { useTranslation } from "react-i18next";
import { Check, Close } from "@mui/icons-material";
import { FormProvider, SubmitHandler, useForm } from "react-hook-form";
import {
RecordLeaveInput,
RecordTimesheetInput,
saveTimesheet,
} from "@/app/api/timesheets/actions";
import dayjs from "dayjs";
import { INPUT_DATE_FORMAT } from "@/app/utils/formatUtil";
import { AssignedProject, ProjectWithTasks } from "@/app/api/projects";
import FullscreenModal from "../FullscreenModal";
import MobileTimesheetTable from "../TimesheetTable/MobileTimesheetTable";

interface Props {
isOpen: boolean;
@@ -29,6 +34,7 @@ interface Props {
assignedProjects: AssignedProject[];
username: string;
defaultTimesheets?: RecordTimesheetInput;
leaveRecords: RecordLeaveInput;
}

const modalSx: SxProps = {
@@ -38,7 +44,7 @@ const modalSx: SxProps = {
transform: "translate(-50%, -50%)",
width: { xs: "calc(100% - 2rem)", sm: "90%" },
maxHeight: "90%",
maxWidth: 1200,
maxWidth: 1400,
};

const TimesheetModal: React.FC<Props> = ({
@@ -48,6 +54,7 @@ const TimesheetModal: React.FC<Props> = ({
assignedProjects,
username,
defaultTimesheets,
leaveRecords,
}) => {
const { t } = useTranslation("home");

@@ -101,44 +108,76 @@ const TimesheetModal: React.FC<Props> = ({
[onClose],
);

const theme = useTheme();
const matches = useMediaQuery(theme.breakpoints.up("sm"));

return (
<Modal open={isOpen} onClose={onModalClose}>
<Card sx={modalSx}>
<FormProvider {...formProps}>
<CardContent
<FormProvider {...formProps}>
{matches ? (
// Desktop version
<Modal open={isOpen} onClose={onModalClose}>
<Card sx={modalSx}>
<CardContent
component="form"
onSubmit={formProps.handleSubmit(onSubmit)}
>
<Typography variant="overline" display="block" marginBlockEnd={1}>
{t("Timesheet Input")}
</Typography>
<Box
sx={{
marginInline: -3,
marginBlock: 4,
}}
>
<TimesheetTable
assignedProjects={assignedProjects}
allProjects={allProjects}
leaveRecords={leaveRecords}
/>
</Box>
<CardActions sx={{ justifyContent: "flex-end" }}>
<Button
variant="outlined"
startIcon={<Close />}
onClick={onCancel}
>
{t("Cancel")}
</Button>
<Button variant="contained" startIcon={<Check />} type="submit">
{t("Save")}
</Button>
</CardActions>
</CardContent>
</Card>
</Modal>
) : (
// Mobile version
<FullscreenModal
open={isOpen}
onClose={onModalClose}
closeModal={onCancel}
>
<Box
display="flex"
flexDirection="column"
gap={2}
height="100%"
component="form"
onSubmit={formProps.handleSubmit(onSubmit)}
>
<Typography variant="overline" display="block" marginBlockEnd={1}>
<Typography variant="h6" padding={2} flex="none">
{t("Timesheet Input")}
</Typography>
<Box
sx={{
marginInline: -3,
marginBlock: 4,
}}
>
<TimesheetTable
assignedProjects={assignedProjects}
allProjects={allProjects}
/>
</Box>
<CardActions sx={{ justifyContent: "flex-end" }}>
<Button
variant="outlined"
startIcon={<Close />}
onClick={onCancel}
>
{t("Cancel")}
</Button>
<Button variant="contained" startIcon={<Check />} type="submit">
{t("Save")}
</Button>
</CardActions>
</CardContent>
</FormProvider>
</Card>
</Modal>
<MobileTimesheetTable
assignedProjects={assignedProjects}
allProjects={allProjects}
leaveRecords={leaveRecords}
/>
</Box>
</FullscreenModal>
)}
</FormProvider>
);
};



+ 40
- 0
src/components/TimesheetTable/MobileTimesheetEntry.tsx Parādīt failu

@@ -0,0 +1,40 @@
import { TimeEntry, RecordTimesheetInput } from "@/app/api/timesheets/actions";
import { manhourFormatter, shortDateFormatter } from "@/app/utils/formatUtil";
import { Add, Edit } from "@mui/icons-material";
import {
Box,
Button,
Card,
CardContent,
IconButton,
Typography,
} from "@mui/material";
import dayjs from "dayjs";
import React from "react";
import { useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { AssignedProject, ProjectWithTasks } from "@/app/api/projects";

interface Props {
date: string;
allProjects: ProjectWithTasks[];
assignedProjects: AssignedProject[];
}

const MobileTimesheetEntry: React.FC<Props> = ({
date,
allProjects,
assignedProjects,
}) => {
const {
t,
i18n: { language },
} = useTranslation("home");
const dayJsObj = dayjs(date);
const { watch, setValue } = useFormContext<RecordTimesheetInput>();
const currentEntries = watch(date);

return null;
};

export default MobileTimesheetEntry;

+ 37
- 0
src/components/TimesheetTable/MobileTimesheetTable.tsx Parādīt failu

@@ -0,0 +1,37 @@
import {
RecordLeaveInput,
RecordTimesheetInput,
} from "@/app/api/timesheets/actions";
import React from "react";
import { useFormContext } from "react-hook-form";
import DateHoursList from "../DateHoursTable/DateHoursList";
import { AssignedProject, ProjectWithTasks } from "@/app/api/projects";
import MobileTimesheetEntry from "./MobileTimesheetEntry";

interface Props {
allProjects: ProjectWithTasks[];
assignedProjects: AssignedProject[];
leaveRecords: RecordLeaveInput;
}

const MobileTimesheetTable: React.FC<Props> = ({
allProjects,
assignedProjects,
leaveRecords,
}) => {
const { watch } = useFormContext<RecordTimesheetInput>();
const currentInput = watch();
const days = Object.keys(currentInput);

return (
<DateHoursList
days={days}
leaveEntries={leaveRecords}
timesheetEntries={currentInput}
EntryComponent={MobileTimesheetEntry}
entryComponentProps={{ allProjects, assignedProjects }}
/>
);
};

export default MobileTimesheetTable;

+ 18
- 128
src/components/TimesheetTable/TimesheetTable.tsx Parādīt failu

@@ -1,146 +1,36 @@
import { RecordTimesheetInput, TimeEntry } from "@/app/api/timesheets/actions";
import { manhourFormatter, shortDateFormatter } from "@/app/utils/formatUtil";
import { KeyboardArrowDown, KeyboardArrowUp } from "@mui/icons-material";
import {
Box,
Collapse,
IconButton,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Typography,
} from "@mui/material";
import dayjs from "dayjs";
import React, { useState } from "react";
RecordLeaveInput,
RecordTimesheetInput,
} from "@/app/api/timesheets/actions";
import React from "react";
import { useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next";
import EntryInputTable from "./EntryInputTable";
import { AssignedProject, ProjectWithTasks } from "@/app/api/projects";
import { TIMESHEET_DAILY_MAX_HOURS } from "@/app/api/timesheets/utils";
import DateHoursTable from "../DateHoursTable";

interface Props {
allProjects: ProjectWithTasks[];
assignedProjects: AssignedProject[];
leaveRecords: RecordLeaveInput;
}

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

const TimesheetTable: React.FC<Props> = ({
allProjects,
assignedProjects,
leaveRecords,
}) => {
const { watch } = useFormContext<RecordTimesheetInput>();
const currentInput = watch();
const days = Object.keys(currentInput);

return (
<TableContainer sx={{ maxHeight: 400 }}>
<Table stickyHeader>
<TableHead>
<TableRow>
<TableCell />
<TableCell>{t("Date")}</TableCell>
<TableCell>{t("Daily Total Hours")}</TableCell>
</TableRow>
</TableHead>
<TableBody>
{days.map((day, index) => {
const entries = currentInput[day];
return (
<DayRow
key={`${day}${index}`}
day={day}
entries={entries}
allProjects={allProjects}
assignedProjects={assignedProjects}
/>
);
})}
</TableBody>
</Table>
</TableContainer>
);
};

const DayRow: React.FC<{
day: string;
entries: TimeEntry[];
allProjects: ProjectWithTasks[];
assignedProjects: AssignedProject[];
}> = ({ day, entries, allProjects, assignedProjects }) => {
const {
t,
i18n: { language },
} = useTranslation("home");
const dayJsObj = dayjs(day);
const [open, setOpen] = useState(false);

const totalHours = entries.reduce(
(acc, entry) => acc + (entry.inputHours || 0) + (entry.otHours || 0),
0,
);

return (
<>
<TableRow>
<TableCell align="center" width={70}>
<IconButton
disableRipple
aria-label="expand row"
size="small"
onClick={() => setOpen(!open)}
>
{open ? <KeyboardArrowUp /> : <KeyboardArrowDown />}
</IconButton>
</TableCell>
<TableCell
sx={{ color: dayJsObj.day() === 0 ? "error.main" : undefined }}
>
{shortDateFormatter(language).format(dayJsObj.toDate())}
</TableCell>
<TableCell
sx={{
color:
totalHours > TIMESHEET_DAILY_MAX_HOURS ? "error.main" : undefined,
}}
>
{manhourFormatter.format(totalHours)}
{totalHours > TIMESHEET_DAILY_MAX_HOURS && (
<Typography
color="error.main"
variant="body2"
component="span"
sx={{ marginInlineStart: 1 }}
>
{t("(the daily total hours cannot be more than {{hours}})", {
hours: TIMESHEET_DAILY_MAX_HOURS,
})}
</Typography>
)}
</TableCell>
</TableRow>
<TableRow>
<TableCell
sx={{
p: 0,
border: "none",
outline: open ? "1px solid" : undefined,
outlineColor: "primary.main",
}}
colSpan={3}
>
<Collapse in={open} timeout="auto" unmountOnExit>
<Box>
<EntryInputTable
day={day}
assignedProjects={assignedProjects}
allProjects={allProjects}
/>
</Box>
</Collapse>
</TableCell>
</TableRow>
</>
<DateHoursTable
days={days}
leaveEntries={leaveRecords}
timesheetEntries={currentInput}
EntryTableComponent={EntryInputTable}
entryTableProps={{ assignedProjects, allProjects }}
/>
);
};



+ 2
- 0
src/components/UserWorkspacePage/UserWorkspacePage.tsx Parādīt failu

@@ -88,12 +88,14 @@ const UserWorkspacePage: React.FC<Props> = ({
assignedProjects={assignedProjects}
username={username}
defaultTimesheets={defaultTimesheets}
leaveRecords={defaultLeaveRecords}
/>
<LeaveModal
leaveTypes={leaveTypes}
isOpen={isLeaveModalVisible}
onClose={handleCloseLeaveModal}
defaultLeaveRecords={defaultLeaveRecords}
timesheetRecords={defaultTimesheets}
username={username}
/>
{assignedProjects.length > 0 ? (


Notiek ielāde…
Atcelt
Saglabāt