Skip to content

Commit 720d38b

Browse files
committed
feat: Add time spend entry
- Add time spend entry - Added Modal component - Added SelectField & TextareaField components - Fixed z index
1 parent a0b4e6b commit 720d38b

File tree

14 files changed

+341
-18
lines changed

14 files changed

+341
-18
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
"eslint-plugin-react-refresh": "^0.4.1",
4949
"postcss": "^8.4.23",
5050
"tailwindcss": "^3.3.2",
51+
"tailwindcss-animate": "^1.0.5",
5152
"typescript": "^5.0.4",
5253
"vite": "^4.3.5"
5354
}

pnpm-lock.yaml

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/App.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ function App() {
1111

1212
return (
1313
<>
14-
<nav className="sticky top-0 z-50 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 p-1">
14+
<nav className="sticky top-0 z-10 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 p-1">
1515
<ul className="flex flex-wrap gap-x-2 -mb-px text-sm font-medium text-center text-gray-500 dark:text-gray-400">
1616
<li>
1717
<Link

src/api/redmine.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,18 @@
1-
import { TIssue } from "../types/redmine";
1+
import { TCreateTimeEntry, TIssue, TTimeEntryActivity } from "../types/redmine";
22
import instance from "./axios.config";
33

44
export const getAllMyOpenIssues = async (offset = 0, limit = 100): Promise<TIssue[]> => {
55
return instance.get(`/issues.json?offset=${offset}&limit=${limit}&status_id=open&assigned_to_id=me&sort=updated_on:desc`).then((res) => res.data.issues);
66
};
7+
8+
export const createTimeEntry = async (entry: TCreateTimeEntry) => {
9+
return instance
10+
.post("/time_entries.json", {
11+
time_entry: entry,
12+
})
13+
.then((res) => res.data);
14+
};
15+
16+
export const getTimeEntryActivities = async (): Promise<TTimeEntryActivity[]> => {
17+
return instance.get("/enumerations/time_entry_activities.json").then((res) => res.data.time_entry_activities);
18+
};

src/components/general/InputField.tsx

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,25 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
33
import clsx from "clsx";
44
import { useId } from "react";
55

6-
interface PropTypes extends React.ComponentProps<"input"> {
6+
interface PropTypes extends Omit<React.ComponentProps<"input">, "size"> {
7+
size?: "sm" | "md";
78
icon?: React.ReactNode;
89
error?: string;
910
}
1011

11-
const InputField = ({ icon, error, className, ...props }: PropTypes) => {
12+
const InputField = ({ size = "md", icon, error, className, ...props }: PropTypes) => {
1213
const id = useId();
1314

1415
return (
15-
<>
16+
<div>
1617
{props.title && (
17-
<label htmlFor={id} className="block text-sm font-medium text-gray-900 dark:text-white">
18+
<label
19+
htmlFor={id}
20+
className={clsx("block font-medium text-gray-900 dark:text-white mb-1", {
21+
"text-xs": size === "sm",
22+
"text-sm": size === "md",
23+
})}
24+
>
1825
{props.title}
1926
{props.required && <FontAwesomeIcon icon={faAsterisk} size="2xs" className="text-red-600 ml-1" />}
2027
</label>
@@ -26,19 +33,21 @@ const InputField = ({ icon, error, className, ...props }: PropTypes) => {
2633
id={id}
2734
required={false}
2835
className={clsx(
29-
"text-sm rounded-lg block w-full p-2.5",
36+
"text-sm rounded-lg block w-full",
3037
"bg-gray-50 border border-gray-300 text-gray-900 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white",
3138
"focus:ring-2 focus:ring-primary-300 focus:outline-none dark:focus:ring-primary-600",
3239
{
3340
"border-red-500 text-red-900 placeholder-red-700 dark:text-red-500 dark:placeholder-red-500 dark:border-red-500": error !== undefined,
3441
"pl-8": !!icon,
42+
"p-1.5": size === "sm",
43+
"p-2.5": size === "md",
3544
},
3645
className
3746
)}
3847
/>
3948
</div>
4049
{error && <p className="text-sm text-red-600 dark:text-red-500">{error}</p>}
41-
</>
50+
</div>
4251
);
4352
};
4453

src/components/general/Modal.tsx

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import useHotKey from "../../hooks/useHotkey";
2+
3+
type PropTypes = {
4+
title: string;
5+
children: React.ReactNode;
6+
onClose: () => void;
7+
};
8+
9+
const Modal = ({ title, children, onClose }: PropTypes) => {
10+
/**
11+
* On "Escape" => close
12+
*/
13+
useHotKey(onClose, { key: "Escape" });
14+
15+
return (
16+
<div tabIndex={-1} className="fixed inset-0 flex items-center z-40 w-full p-4 overflow-y-auto h-full bg-gray-800/50 dark:bg-gray-600/50 animate-in fade-in">
17+
<div className="relative w-full max-w-md max-h-full">
18+
<div className="relative rounded-lg drop-shadow-lg bg-white dark:bg-gray-800 animate-in fade-in zoom-in">
19+
<button
20+
type="button"
21+
className="absolute top-3 right-2.5 text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm p-1.5 ml-auto inline-flex items-center dark:hover:bg-gray-800 dark:hover:text-white"
22+
onClick={onClose}
23+
>
24+
<svg aria-hidden="true" className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
25+
<path
26+
fill-rule="evenodd"
27+
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
28+
clip-rule="evenodd"
29+
></path>
30+
</svg>
31+
<span className="sr-only">Close modal</span>
32+
</button>
33+
<div className="p-3">
34+
<h3 className="mb-4 text-xl font-medium text-gray-900 dark:text-white">{title}</h3>
35+
{children}
36+
</div>
37+
</div>
38+
</div>
39+
</div>
40+
);
41+
};
42+
43+
export default Modal;
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { faAsterisk } from "@fortawesome/free-solid-svg-icons";
2+
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
3+
import clsx from "clsx";
4+
import { useId } from "react";
5+
6+
interface PropTypes extends Omit<React.ComponentProps<"select">, "size"> {
7+
size?: "sm" | "md";
8+
error?: string;
9+
}
10+
11+
const SelectField = ({ error, children, size = "md", placeholder, className, ...props }: PropTypes) => {
12+
const id = useId();
13+
14+
return (
15+
<div>
16+
{props.title && (
17+
<label
18+
htmlFor={id}
19+
className={clsx("block font-medium text-gray-900 dark:text-white mb-1", {
20+
"text-xs": size === "sm",
21+
"text-sm": size === "md",
22+
})}
23+
>
24+
{props.title}
25+
{props.required && <FontAwesomeIcon icon={faAsterisk} size="2xs" className="text-red-600 ml-1" />}
26+
</label>
27+
)}
28+
<select
29+
{...props}
30+
id={id}
31+
className={clsx(
32+
"text-sm rounded-lg block w-full",
33+
"bg-gray-50 border border-gray-300 text-gray-900 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white ",
34+
"focus:ring-primary-300 focus:border-primary-300 dark:focus:ring-primary-600 dark:focus:border-primary-600",
35+
{
36+
"p-1.5": size === "sm",
37+
"p-2.5": size === "md",
38+
},
39+
className
40+
)}
41+
>
42+
{placeholder && (
43+
<option disabled selected>
44+
{placeholder}
45+
</option>
46+
)}
47+
{children}
48+
</select>
49+
{error && <p className="text-sm text-red-600 dark:text-red-500">{error}</p>}
50+
</div>
51+
);
52+
};
53+
54+
export default SelectField;
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { faAsterisk } from "@fortawesome/free-solid-svg-icons";
2+
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
3+
import clsx from "clsx";
4+
import { useId } from "react";
5+
6+
interface PropTypes extends Omit<React.ComponentProps<"textarea">, "size"> {
7+
size?: "sm" | "md";
8+
error?: string;
9+
}
10+
11+
const TextareaField = ({ size = "md", error, value, rows = 3, className, ...props }: PropTypes) => {
12+
const id = useId();
13+
14+
return (
15+
<div>
16+
{props.title && (
17+
<label
18+
htmlFor={id}
19+
className={clsx("block font-medium text-gray-900 dark:text-white mb-1", {
20+
"text-xs": size === "sm",
21+
"text-sm": size === "md",
22+
})}
23+
>
24+
{props.title}
25+
{props.required && <FontAwesomeIcon icon={faAsterisk} size="2xs" className="text-red-600 ml-1" />}
26+
</label>
27+
)}
28+
<textarea
29+
{...props}
30+
id={id}
31+
required={false}
32+
className={clsx(
33+
"text-sm rounded-lg block w-full",
34+
"bg-gray-50 border border-gray-300 text-gray-900 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white",
35+
"focus:ring-2 focus:ring-primary-300 focus:outline-none dark:focus:ring-primary-600",
36+
{
37+
"border-red-500 text-red-900 placeholder-red-700 dark:text-red-500 dark:placeholder-red-500 dark:border-red-500": error !== undefined,
38+
"p-1.5": size === "sm",
39+
"p-2.5": size === "md",
40+
},
41+
className
42+
)}
43+
rows={rows}
44+
>
45+
{value}
46+
</textarea>
47+
{error && <p className="text-sm text-red-600 dark:text-red-500">{error}</p>}
48+
</div>
49+
);
50+
};
51+
52+
export default TextareaField;

src/components/general/Toast.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ type PropTypes = {
99

1010
const Toast = ({ type, message, allowClose = true, onClose }: PropTypes) => {
1111
return (
12-
<div className="fixed bottom-0 left-0 p-2 w-full">
12+
<div className="fixed bottom-0 left-0 p-2 w-full z-50">
1313
<div className="flex items-center w-full p-1 text-gray-700 bg-gray-200 rounded-lg shadow dark:text-gray-300 dark:bg-gray-600" role="alert">
1414
<div
1515
className={clsx("inline-flex items-center justify-center flex-shrink-0 w-8 h-8", {
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
2+
import { AxiosError, isAxiosError } from "axios";
3+
import clsx from "clsx";
4+
import { Field, Form, Formik, FormikProps } from "formik";
5+
import { useEffect, useRef } from "react";
6+
import * as Yup from "yup";
7+
import { createTimeEntry, getTimeEntryActivities } from "../../api/redmine";
8+
import useSettings from "../../hooks/useSettings";
9+
import { TCreateTimeEntry, TIssue, TRedmineError } from "../../types/redmine";
10+
import InputField from "../general/InputField";
11+
import Modal from "../general/Modal";
12+
import SelectField from "../general/SelectField";
13+
import Toast from "../general/Toast";
14+
15+
type PropTypes = {
16+
issue: TIssue;
17+
time: number;
18+
onClose: () => void;
19+
onSuccess: () => void;
20+
};
21+
22+
const CreateTimeEntryModal = ({ issue, time, onClose, onSuccess }: PropTypes) => {
23+
const { settings } = useSettings();
24+
const queryClient = useQueryClient();
25+
const formik = useRef<FormikProps<TCreateTimeEntry>>(null);
26+
27+
const timeEntryActivitiesQuery = useQuery({
28+
queryKey: ["timeEntryActivities"],
29+
queryFn: getTimeEntryActivities,
30+
refetchOnWindowFocus: false,
31+
});
32+
33+
useEffect(() => {
34+
formik.current?.setFieldValue("activity_id", timeEntryActivitiesQuery.data?.find((entry) => entry.is_default)?.id ?? undefined);
35+
}, [timeEntryActivitiesQuery.data]);
36+
37+
const createTimeEntryMutation = useMutation({
38+
mutationFn: (entry: TCreateTimeEntry) => createTimeEntry(entry),
39+
onSuccess: () => {
40+
queryClient.invalidateQueries(["issues"]);
41+
onSuccess();
42+
},
43+
});
44+
45+
return (
46+
<>
47+
<Modal title="Add time spent" onClose={onClose}>
48+
<Formik
49+
innerRef={formik}
50+
initialValues={{
51+
issue_id: issue.id,
52+
activity_id: undefined,
53+
hours: time / 1000 / 60 / 60,
54+
comments: "",
55+
}}
56+
validationSchema={Yup.object({
57+
activity_id: Yup.number().required("Activity is required"),
58+
hours: Yup.number().required("Hours is required").min(0.01, "Must be greater than 0"),
59+
})}
60+
onSubmit={async (values, { setSubmitting }) => {
61+
//console.log("onSubmit", values);
62+
await createTimeEntryMutation.mutateAsync(values);
63+
setSubmitting(false);
64+
}}
65+
>
66+
{({ isSubmitting, touched, errors }) => (
67+
<>
68+
<Form>
69+
<div className="flex flex-col gap-y-2">
70+
<h1 className="mb-1 truncate">
71+
<a href={`${settings.redmineURL}/issues/${issue.id}`} target="_blank" className="text-blue-500 hover:underline" tabIndex={-1}>
72+
#{issue.id}
73+
</a>{" "}
74+
{issue.subject}
75+
</h1>
76+
<Field type="number" name="hours" title="Hours" placeholder="Hours" min="0" step="0.01" required as={InputField} size="sm" error={touched.hours && errors.hours} autoComplete="off" />
77+
<Field type="text" name="comments" title="Comments" placeholder="Comments" as={InputField} size="sm" error={touched.comments && errors.comments} autoFocus />
78+
<Field type="select" name="activity_id" title="Activity" placeholder="Activity" required as={SelectField} size="sm" error={touched.activity_id && errors.activity_id}>
79+
{timeEntryActivitiesQuery.data?.map((activity) => (
80+
<>
81+
<option key={activity.id} value={activity.id}>
82+
{activity.name}
83+
</option>
84+
</>
85+
))}
86+
</Field>
87+
88+
<button
89+
type="submit"
90+
className={clsx(
91+
"text-white bg-primary-700 hover:bg-primary-800 font-medium rounded-lg text-sm px-5 py-1.5 mt-2 dark:bg-primary-600 dark:hover:bg-primary-700",
92+
"focus:ring-4 focus:ring-primary-300 focus:outline-none dark:focus:ring-primary-800"
93+
)}
94+
disabled={isSubmitting}
95+
>
96+
Create
97+
</button>
98+
</div>
99+
</Form>
100+
</>
101+
)}
102+
</Formik>
103+
</Modal>
104+
{createTimeEntryMutation.isError && (
105+
<Toast
106+
type="error"
107+
allowClose={false}
108+
message={isAxiosError(createTimeEntryMutation.error) ? ((createTimeEntryMutation.error as AxiosError).response?.data as TRedmineError)?.errors.join(", ") : (createTimeEntryMutation.error as Error).message}
109+
/>
110+
)}
111+
</>
112+
);
113+
};
114+
115+
export default CreateTimeEntryModal;

0 commit comments

Comments
 (0)