feat: add new + edit/delete time-entries
This commit is contained in:
parent
81273a8a9a
commit
9b16fe617c
|
|
@ -1,60 +1,496 @@
|
|||
import type { ActionArgs, LoaderArgs } from '@remix-run/node'
|
||||
import { json, redirect } from '@remix-run/node'
|
||||
import { Form, useCatch, useLoaderData } from '@remix-run/react'
|
||||
import invariant from 'tiny-invariant'
|
||||
import type { ActionArgs, LoaderArgs, MetaFunction } from '@remix-run/node';
|
||||
import { json, redirect } from '@remix-run/node';
|
||||
import {
|
||||
Form,
|
||||
useActionData,
|
||||
useCatch,
|
||||
useLoaderData,
|
||||
useNavigate
|
||||
} from '@remix-run/react';
|
||||
import * as React from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Drawer,
|
||||
TextInput,
|
||||
Text,
|
||||
useMantineTheme,
|
||||
Group,
|
||||
Button,
|
||||
Textarea,
|
||||
Stack,
|
||||
Select,
|
||||
ColorSwatch,
|
||||
ActionIcon
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
AlertTriangle,
|
||||
Delete,
|
||||
Play,
|
||||
Save,
|
||||
Square,
|
||||
Trash
|
||||
} from 'react-feather';
|
||||
import invariant from 'tiny-invariant';
|
||||
|
||||
import { deleteNote, getNote } from '~/models/note.server'
|
||||
import { requireUserId } from '~/session.server'
|
||||
import {
|
||||
deleteTimeEntry,
|
||||
getTimeEntry,
|
||||
updateTimeEntry
|
||||
} from '~/models/timeEntry.server';
|
||||
import { requireUserId } from '~/session.server';
|
||||
import { getProjects } from '~/models/project.server';
|
||||
import { DatePicker, TimeInput } from '@mantine/dates';
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return {
|
||||
title: 'Edit Time Entry | WorkTimer',
|
||||
description: 'Edit a time entry. You must be logged in to do this.'
|
||||
};
|
||||
};
|
||||
|
||||
export async function loader({ request, params }: LoaderArgs) {
|
||||
const userId = await requireUserId(request)
|
||||
invariant(params.noteId, 'noteId not found')
|
||||
const userId = await requireUserId(request);
|
||||
invariant(params.timeEntryId, 'timeEntryId not found');
|
||||
|
||||
const note = await getNote({ userId, id: params.timeEntryId })
|
||||
if (!note) {
|
||||
throw new Response('Not Found', { status: 404 })
|
||||
const timeEntry = await getTimeEntry({ userId, id: params.timeEntryId });
|
||||
if (!timeEntry) {
|
||||
throw new Response('Not Found', { status: 404 });
|
||||
}
|
||||
return json({ note })
|
||||
|
||||
const projects = await getProjects({ userId });
|
||||
|
||||
return json({ timeEntry, projects });
|
||||
}
|
||||
|
||||
export async function action({ request, params }: ActionArgs) {
|
||||
const userId = await requireUserId(request)
|
||||
invariant(params.timeEntryId, 'timeEntryId not found')
|
||||
const userId = await requireUserId(request);
|
||||
invariant(params.timeEntryId, 'timeEntryId not found');
|
||||
|
||||
await deleteNote({ userId, id: params.timeEntryId })
|
||||
const timeEntry = await getTimeEntry({ userId, id: params.timeEntryId });
|
||||
if (!timeEntry) {
|
||||
throw new Response('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
return redirect('/notes')
|
||||
if (request.method === 'DELETE') {
|
||||
await deleteTimeEntry({ userId, id: params.timeEntryId });
|
||||
} else if (request.method === 'PATCH') {
|
||||
const formData = await request.formData();
|
||||
|
||||
const description = (formData.get('description') || undefined) as
|
||||
| string
|
||||
| undefined;
|
||||
const projectId = (formData.get('projectId') || undefined) as
|
||||
| string
|
||||
| undefined;
|
||||
let startTime = (formData.get('startTime') || undefined) as
|
||||
| string
|
||||
| undefined;
|
||||
let endTime = (formData.get('endTime') || undefined) as string | undefined;
|
||||
|
||||
if (
|
||||
startTime &&
|
||||
typeof startTime === 'string' &&
|
||||
Number.isNaN(Date.parse(startTime))
|
||||
) {
|
||||
return json(
|
||||
{
|
||||
errors: {
|
||||
projectId: null,
|
||||
description: null,
|
||||
startTime: 'startTime is invalid',
|
||||
endTime: null
|
||||
}
|
||||
},
|
||||
{ status: 422 }
|
||||
);
|
||||
}
|
||||
if (
|
||||
endTime &&
|
||||
typeof endTime === 'string' &&
|
||||
Number.isNaN(Date.parse(endTime))
|
||||
) {
|
||||
return json(
|
||||
{
|
||||
errors: {
|
||||
projectId: null,
|
||||
description: null,
|
||||
startTime: null,
|
||||
endTime: 'endTime is invalid'
|
||||
}
|
||||
},
|
||||
{ status: 422 }
|
||||
);
|
||||
}
|
||||
if (
|
||||
startTime &&
|
||||
endTime &&
|
||||
typeof startTime === 'string' &&
|
||||
typeof endTime === 'string' &&
|
||||
new Date(startTime) > new Date(endTime)
|
||||
) {
|
||||
return json(
|
||||
{
|
||||
errors: {
|
||||
projectId: null,
|
||||
description: null,
|
||||
startTime: 'startTime must be before endTime',
|
||||
endTime: 'startTime must be before endTime'
|
||||
}
|
||||
},
|
||||
{ status: 422 }
|
||||
);
|
||||
}
|
||||
|
||||
await updateTimeEntry({
|
||||
timeEntryId: params.timeEntryId,
|
||||
description,
|
||||
projectId,
|
||||
startTime: startTime ? new Date(startTime) : undefined,
|
||||
endTime: endTime ? new Date(endTime) : undefined
|
||||
});
|
||||
}
|
||||
|
||||
return redirect('/time-entries');
|
||||
}
|
||||
|
||||
export default function NoteDetailsPage() {
|
||||
const data = useLoaderData<typeof loader>()
|
||||
interface ItemProps extends React.ComponentPropsWithoutRef<'div'> {
|
||||
id: string;
|
||||
label: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-2xl font-bold">{data.note.title}</h3>
|
||||
<p className="py-6">{data.note.body}</p>
|
||||
<hr className="my-4" />
|
||||
<Form method="post">
|
||||
<button type="submit" className="rounded bg-blue-500 py-2 px-4 text-white hover:bg-blue-600 focus:bg-blue-400">
|
||||
Delete
|
||||
</button>
|
||||
</Form>
|
||||
const SelectItem = React.forwardRef<HTMLDivElement, ItemProps>(
|
||||
({ label, color, id, ...others }: ItemProps, ref) => (
|
||||
<div key={id} ref={ref} {...others}>
|
||||
<Group noWrap>
|
||||
<ColorSwatch color={color} />
|
||||
<Text size="sm">{label}</Text>
|
||||
</Group>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
const LayoutWrapper = ({ children }: React.PropsWithChildren<{}>) => {
|
||||
const theme = useMantineTheme();
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
opened
|
||||
position="right"
|
||||
title="Edit Time Entry"
|
||||
padding="xl"
|
||||
size="xl"
|
||||
overlayColor={
|
||||
theme.colorScheme === 'dark'
|
||||
? theme.colors.dark[9]
|
||||
: theme.colors.gray[2]
|
||||
}
|
||||
overlayOpacity={0.55}
|
||||
overlayBlur={3}
|
||||
onClose={() => {
|
||||
navigate('/time-entries');
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default function TimeEntryDetailsPage() {
|
||||
const actionData = useActionData<typeof action>();
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const theme = useMantineTheme();
|
||||
|
||||
const descriptionRef = React.useRef<HTMLTextAreaElement>(null);
|
||||
const startDateRef = React.useRef<HTMLInputElement>(null);
|
||||
const endDateRef = React.useRef<HTMLInputElement>(null);
|
||||
const projectRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const [start, setStart] = React.useState<Date>(
|
||||
new Date(data.timeEntry.startTime || Date.now())
|
||||
);
|
||||
const [end, setEnd] = React.useState<Date | undefined>(
|
||||
data.timeEntry.endTime ? new Date(data.timeEntry.endTime) : undefined
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (actionData?.errors?.description) {
|
||||
descriptionRef.current?.focus();
|
||||
} else if (actionData?.errors?.startTime) {
|
||||
startDateRef.current?.focus();
|
||||
} else if (actionData?.errors?.endTime) {
|
||||
endDateRef.current?.focus();
|
||||
} else if (actionData?.errors?.projectId) {
|
||||
projectRef.current?.focus();
|
||||
}
|
||||
}, [actionData]);
|
||||
|
||||
return (
|
||||
<LayoutWrapper>
|
||||
<Form
|
||||
method="post"
|
||||
noValidate
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 8,
|
||||
width: '100%'
|
||||
}}
|
||||
>
|
||||
<Textarea
|
||||
mb={12}
|
||||
withAsterisk
|
||||
label="Description"
|
||||
placeholder="What are you working on?"
|
||||
id="new-description"
|
||||
ref={descriptionRef}
|
||||
defaultValue={data.timeEntry.description}
|
||||
required
|
||||
autoFocus={true}
|
||||
name="description"
|
||||
aria-invalid={actionData?.errors?.description ? true : undefined}
|
||||
error={actionData?.errors?.description}
|
||||
errorProps={{ children: actionData?.errors?.description }}
|
||||
/>
|
||||
|
||||
<Select
|
||||
id="new-project"
|
||||
ref={projectRef}
|
||||
name="projectId"
|
||||
mb={12}
|
||||
label="Project"
|
||||
defaultValue={data.timeEntry.projectId}
|
||||
placeholder="Select project"
|
||||
searchable
|
||||
nothingFound="No options"
|
||||
required
|
||||
withAsterisk
|
||||
maxDropdownHeight={400}
|
||||
data={data.projects.projects.map((project) => ({
|
||||
label: project.name,
|
||||
value: project.id,
|
||||
color: project.color
|
||||
}))}
|
||||
itemComponent={SelectItem}
|
||||
filter={(value, item) =>
|
||||
item.label?.toLowerCase().includes(value.toLowerCase().trim()) ||
|
||||
item.value.toLowerCase().includes(value.toLowerCase().trim())
|
||||
}
|
||||
aria-invalid={actionData?.errors?.projectId ? true : undefined}
|
||||
error={actionData?.errors?.projectId}
|
||||
errorProps={{ children: actionData?.errors?.projectId }}
|
||||
/>
|
||||
|
||||
<Stack>
|
||||
<label
|
||||
htmlFor="new-startTime"
|
||||
style={{
|
||||
margin: '1rem 0 -0.5rem 0'
|
||||
}}
|
||||
>
|
||||
Start
|
||||
</label>
|
||||
<Group align="flex-end">
|
||||
<DatePicker
|
||||
id="new-startTime-date"
|
||||
ref={startDateRef}
|
||||
name="startTime-date"
|
||||
allowFreeInput
|
||||
withAsterisk
|
||||
clearable={false}
|
||||
inputFormat="DD/MM/YYYY"
|
||||
labelFormat="MM/YYYY"
|
||||
aria-labelledby="new-startTime-label"
|
||||
locale="it"
|
||||
placeholder="Start date"
|
||||
label="Start date"
|
||||
aria-invalid={actionData?.errors?.startTime ? true : undefined}
|
||||
error={actionData?.errors?.startTime}
|
||||
errorProps={{ children: actionData?.errors?.startTime }}
|
||||
value={start}
|
||||
onChange={(date) => {
|
||||
if (!date) return;
|
||||
let newDate = new Date(start);
|
||||
newDate.setFullYear(date.getFullYear());
|
||||
newDate.setMonth(date.getMonth());
|
||||
newDate.setDate(date.getDate());
|
||||
setStart(newDate);
|
||||
}}
|
||||
/>
|
||||
<TimeInput
|
||||
id="new-startTime-time"
|
||||
ref={startDateRef}
|
||||
name="startTime-time"
|
||||
withAsterisk
|
||||
withSeconds
|
||||
clearable={false}
|
||||
aria-labelledby="new-startTime-label"
|
||||
label="Start time"
|
||||
value={start}
|
||||
onChange={(date) => {
|
||||
let newDate = new Date(start);
|
||||
newDate.setHours(date.getHours());
|
||||
newDate.setMinutes(date.getMinutes());
|
||||
setStart(newDate);
|
||||
}}
|
||||
aria-invalid={actionData?.errors?.startTime ? true : undefined}
|
||||
error={actionData?.errors?.startTime}
|
||||
errorProps={{ children: actionData?.errors?.startTime }}
|
||||
/>
|
||||
</Group>
|
||||
<input type="hidden" name="startTime" value={start.toISOString()} />
|
||||
</Stack>
|
||||
|
||||
<Stack>
|
||||
<label
|
||||
htmlFor="new-endTime"
|
||||
style={{
|
||||
margin: '1rem 0 -0.5rem 0'
|
||||
}}
|
||||
>
|
||||
End
|
||||
</label>
|
||||
<Group align="flex-end">
|
||||
<DatePicker
|
||||
id="new-endTime-date"
|
||||
ref={endDateRef}
|
||||
name="endTime-date"
|
||||
allowFreeInput
|
||||
clearable={true}
|
||||
inputFormat="DD/MM/YYYY"
|
||||
labelFormat="MM/YYYY"
|
||||
aria-labelledby="new-endTime-label"
|
||||
locale="it"
|
||||
placeholder="End date"
|
||||
label="End date"
|
||||
aria-invalid={actionData?.errors?.endTime ? true : undefined}
|
||||
error={actionData?.errors?.endTime}
|
||||
errorProps={{ children: actionData?.errors?.endTime }}
|
||||
value={end}
|
||||
onChange={(date) => {
|
||||
if (!date) return;
|
||||
let newDate = new Date(end || start);
|
||||
newDate.setFullYear(date.getFullYear());
|
||||
newDate.setMonth(date.getMonth());
|
||||
newDate.setDate(date.getDate());
|
||||
setEnd(newDate);
|
||||
}}
|
||||
/>
|
||||
<TimeInput
|
||||
id="new-endTime-time"
|
||||
ref={endDateRef}
|
||||
name="endTime-time"
|
||||
withAsterisk
|
||||
withSeconds
|
||||
clearable={false}
|
||||
aria-labelledby="new-endTime-label"
|
||||
label="end time"
|
||||
value={end}
|
||||
onChange={(date) => {
|
||||
let newDate = new Date(end || start);
|
||||
newDate.setHours(date.getHours());
|
||||
newDate.setMinutes(date.getMinutes());
|
||||
setEnd(newDate);
|
||||
}}
|
||||
aria-invalid={actionData?.errors?.endTime ? true : undefined}
|
||||
error={actionData?.errors?.endTime}
|
||||
errorProps={{ children: actionData?.errors?.endTime }}
|
||||
/>
|
||||
</Group>
|
||||
{end && (
|
||||
<input type="hidden" name="endTime" value={end.toISOString()} />
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Group position="left" mt="lg">
|
||||
<Button type="submit" leftIcon={<Save />} radius={theme.radius.md}>
|
||||
Save
|
||||
</Button>
|
||||
</Group>
|
||||
</Form>
|
||||
|
||||
<section style={{ marginTop: '1rem', marginBottom: '1rem' }}>
|
||||
<Form method="delete">
|
||||
<input
|
||||
type="hidden"
|
||||
name="endTime"
|
||||
value={new Date().toISOString()}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
color="red"
|
||||
leftIcon={<Trash />}
|
||||
radius={theme.radius.md}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</Form>
|
||||
</section>
|
||||
|
||||
{!end && (
|
||||
<section style={{ marginTop: '1rem', marginBottom: '1rem' }}>
|
||||
<Form method="patch">
|
||||
<input
|
||||
type="hidden"
|
||||
name="endTime"
|
||||
value={new Date().toISOString()}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="subtle"
|
||||
radius={theme.radius.md}
|
||||
leftIcon={
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: theme.colors.red[7],
|
||||
color: 'white',
|
||||
width: '1.75rem',
|
||||
height: '1.75rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: '50%'
|
||||
}}
|
||||
>
|
||||
<Square size={12} fill="currentColor" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
Stop running time entry
|
||||
</Button>
|
||||
</Form>
|
||||
</section>
|
||||
)}
|
||||
</LayoutWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export function ErrorBoundary({ error }: { error: Error }) {
|
||||
console.error(error)
|
||||
console.error(error);
|
||||
|
||||
return <div>An unexpected error occurred: {error.message}</div>
|
||||
return (
|
||||
<LayoutWrapper>
|
||||
<Alert icon={<AlertTriangle size={14} />} title="Error" color="red">
|
||||
An unexpected error occurred: {error.message}
|
||||
</Alert>
|
||||
</LayoutWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export function CatchBoundary() {
|
||||
const caught = useCatch()
|
||||
const caught = useCatch();
|
||||
|
||||
if (caught.status === 404) {
|
||||
return <div>Note not found</div>
|
||||
return (
|
||||
<LayoutWrapper>
|
||||
<Alert icon={<AlertTriangle size={14} />} title="Error" color="red">
|
||||
Not found
|
||||
</Alert>
|
||||
</LayoutWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected caught response with status: ${caught.status}`)
|
||||
throw new Error(`Unexpected caught response with status: ${caught.status}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +0,0 @@
|
|||
import { Link } from '@remix-run/react'
|
||||
|
||||
export default function NoteIndexPage() {
|
||||
return (
|
||||
<p>
|
||||
No note selected. Select a note on the left, or{' '}
|
||||
<Link to="new" className="text-blue-500 underline">
|
||||
create a new note.
|
||||
</Link>
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,36 +1,132 @@
|
|||
import type { ActionArgs } from '@remix-run/node'
|
||||
import { json, redirect } from '@remix-run/node'
|
||||
import { Form, useActionData } from '@remix-run/react'
|
||||
import * as React from 'react'
|
||||
import z from 'zod'
|
||||
import {
|
||||
Alert,
|
||||
Drawer,
|
||||
TextInput,
|
||||
Text,
|
||||
useMantineTheme,
|
||||
Group,
|
||||
Button,
|
||||
Textarea,
|
||||
Stack,
|
||||
Select,
|
||||
ColorSwatch
|
||||
} from '@mantine/core';
|
||||
import type { ActionArgs, LoaderArgs, MetaFunction } from '@remix-run/node';
|
||||
import { json, redirect } from '@remix-run/node';
|
||||
import {
|
||||
Form,
|
||||
useActionData,
|
||||
useCatch,
|
||||
useLoaderData,
|
||||
useNavigate
|
||||
} from '@remix-run/react';
|
||||
import * as React from 'react';
|
||||
import { AlertTriangle, Play } from 'react-feather';
|
||||
import { getProjects } from '~/models/project.server';
|
||||
import { createTimeEntry, stopAllTimeEntries } from '~/models/timeEntry.server';
|
||||
import { requireUserId } from '~/session.server';
|
||||
import { DatePicker, TimeInput } from '@mantine/dates';
|
||||
import { forwardRef } from 'react';
|
||||
|
||||
import { createTimeEntry } from '~/models/timeEntry.server'
|
||||
import { requireUserId } from '~/session.server'
|
||||
import 'dayjs/locale/it';
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return {
|
||||
title: 'New Time Entry | WorkTimer',
|
||||
description: 'Create a new time entry. You must be logged in to do this.'
|
||||
};
|
||||
};
|
||||
|
||||
export async function loader({ request }: LoaderArgs) {
|
||||
const userId = await requireUserId(request);
|
||||
|
||||
return json({
|
||||
...(await getProjects({ userId }))
|
||||
});
|
||||
}
|
||||
|
||||
export async function action({ request }: ActionArgs) {
|
||||
const userId = await requireUserId(request)
|
||||
const userId = await requireUserId(request);
|
||||
|
||||
const formData = await request.formData()
|
||||
const description = formData.get('description')
|
||||
const projectId = formData.get('projectId')
|
||||
let startTime = formData.get('startTime')
|
||||
let endTime = formData.get('endTime')
|
||||
const formData = await request.formData();
|
||||
const description = formData.get('description');
|
||||
const projectId = formData.get('projectId');
|
||||
let startTime = formData.get('startTime');
|
||||
let endTime = formData.get('endTime');
|
||||
|
||||
if (typeof description !== 'string' || description.length === 0) {
|
||||
return json({ errors: { description: 'Description is required' } }, { status: 400 })
|
||||
return json(
|
||||
{
|
||||
errors: {
|
||||
projectId: null,
|
||||
description: 'Description is required',
|
||||
startTime: null,
|
||||
endTime: null
|
||||
}
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
if (typeof projectId !== 'string' || projectId.length === 0) {
|
||||
return json({ errors: { projectId: 'projectId is required' } }, { status: 400 })
|
||||
return json(
|
||||
{
|
||||
errors: {
|
||||
projectId: 'projectId is required',
|
||||
description: null,
|
||||
startTime: null,
|
||||
endTime: null
|
||||
}
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
if (typeof startTime !== 'string' || startTime.length === 0) {
|
||||
return json({ errors: { startTime: 'startTime is required' } }, { status: 400 })
|
||||
return json(
|
||||
{
|
||||
errors: {
|
||||
projectId: null,
|
||||
description: null,
|
||||
startTime: 'startTime is required',
|
||||
endTime: null
|
||||
}
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (startTime && typeof startTime === 'string' && Number.isNaN(Date.parse(startTime))) {
|
||||
return json({ errors: { startTime: 'startTime is invalid' } }, { status: 422 })
|
||||
if (
|
||||
startTime &&
|
||||
typeof startTime === 'string' &&
|
||||
Number.isNaN(Date.parse(startTime))
|
||||
) {
|
||||
return json(
|
||||
{
|
||||
errors: {
|
||||
projectId: null,
|
||||
description: null,
|
||||
startTime: 'startTime is invalid',
|
||||
endTime: null
|
||||
}
|
||||
if (endTime && typeof endTime === 'string' && Number.isNaN(Date.parse(endTime))) {
|
||||
return json({ errors: { endTime: 'endTime is invalid' } }, { status: 422 })
|
||||
},
|
||||
{ status: 422 }
|
||||
);
|
||||
}
|
||||
if (
|
||||
endTime &&
|
||||
typeof endTime === 'string' &&
|
||||
Number.isNaN(Date.parse(endTime))
|
||||
) {
|
||||
return json(
|
||||
{
|
||||
errors: {
|
||||
projectId: null,
|
||||
description: null,
|
||||
startTime: null,
|
||||
endTime: 'endTime is invalid'
|
||||
}
|
||||
},
|
||||
{ status: 422 }
|
||||
);
|
||||
}
|
||||
if (
|
||||
startTime &&
|
||||
|
|
@ -39,8 +135,20 @@ export async function action({ request }: ActionArgs) {
|
|||
typeof endTime === 'string' &&
|
||||
new Date(startTime) > new Date(endTime)
|
||||
) {
|
||||
return json({ errors: { endTime: 'startTime must be before endTime' } }, { status: 422 })
|
||||
return json(
|
||||
{
|
||||
errors: {
|
||||
projectId: null,
|
||||
description: null,
|
||||
startTime: 'startTime must be before endTime',
|
||||
endTime: 'startTime must be before endTime'
|
||||
}
|
||||
},
|
||||
{ status: 422 }
|
||||
);
|
||||
}
|
||||
|
||||
await stopAllTimeEntries(userId);
|
||||
|
||||
const timeEntry = await createTimeEntry({
|
||||
description,
|
||||
|
|
@ -48,27 +156,86 @@ export async function action({ request }: ActionArgs) {
|
|||
endTime: typeof endTime === 'string' ? new Date(endTime) : null,
|
||||
userId,
|
||||
projectId
|
||||
})
|
||||
});
|
||||
|
||||
return redirect(`/time-entries/${timeEntry.id}`)
|
||||
return redirect(`/time-entries`);
|
||||
}
|
||||
|
||||
export default function NewNotePage() {
|
||||
const actionData = useActionData<typeof action>()
|
||||
const titleRef = React.useRef<HTMLInputElement>(null)
|
||||
const bodyRef = React.useRef<HTMLTextAreaElement>(null)
|
||||
interface ItemProps extends React.ComponentPropsWithoutRef<'div'> {
|
||||
id: string;
|
||||
label: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
if (actionData?.errors?.title) {
|
||||
titleRef.current?.focus()
|
||||
} else if (actionData?.errors?.body) {
|
||||
bodyRef.current?.focus()
|
||||
}
|
||||
}, [actionData])
|
||||
const SelectItem = forwardRef<HTMLDivElement, ItemProps>(
|
||||
({ label, color, id, ...others }: ItemProps, ref) => (
|
||||
<div key={id} ref={ref} {...others}>
|
||||
<Group noWrap>
|
||||
<ColorSwatch color={color} />
|
||||
<Text size="sm">{label}</Text>
|
||||
</Group>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
const LayoutWrapper = ({ children }: React.PropsWithChildren<{}>) => {
|
||||
const theme = useMantineTheme();
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
opened
|
||||
position="right"
|
||||
title="New Time Entry"
|
||||
padding="xl"
|
||||
size="xl"
|
||||
overlayColor={
|
||||
theme.colorScheme === 'dark'
|
||||
? theme.colors.dark[9]
|
||||
: theme.colors.gray[2]
|
||||
}
|
||||
overlayOpacity={0.55}
|
||||
overlayBlur={3}
|
||||
onClose={() => {
|
||||
navigate('/time-entries');
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default function NewTimeEntryPage() {
|
||||
const actionData = useActionData<typeof action>();
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
||||
const theme = useMantineTheme();
|
||||
|
||||
const descriptionRef = React.useRef<HTMLTextAreaElement>(null);
|
||||
const startDateRef = React.useRef<HTMLInputElement>(null);
|
||||
const endDateRef = React.useRef<HTMLInputElement>(null);
|
||||
const projectRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const [start, setStart] = React.useState(new Date(Date.now()));
|
||||
const [end, setEnd] = React.useState<Date | undefined>();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (actionData?.errors?.description) {
|
||||
descriptionRef.current?.focus();
|
||||
} else if (actionData?.errors?.startTime) {
|
||||
startDateRef.current?.focus();
|
||||
} else if (actionData?.errors?.endTime) {
|
||||
endDateRef.current?.focus();
|
||||
} else if (actionData?.errors?.projectId) {
|
||||
projectRef.current?.focus();
|
||||
}
|
||||
}, [actionData]);
|
||||
|
||||
return (
|
||||
<LayoutWrapper>
|
||||
<Form
|
||||
method="post"
|
||||
noValidate
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
|
|
@ -76,48 +243,203 @@ export default function NewNotePage() {
|
|||
width: '100%'
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<label className="flex w-full flex-col gap-1">
|
||||
<span>Title: </span>
|
||||
<input
|
||||
ref={titleRef}
|
||||
name="title"
|
||||
className="flex-1 rounded-md border-2 border-blue-500 px-3 text-lg leading-loose"
|
||||
aria-invalid={actionData?.errors?.title ? true : undefined}
|
||||
aria-errormessage={actionData?.errors?.title ? 'title-error' : undefined}
|
||||
<Textarea
|
||||
mb={12}
|
||||
withAsterisk
|
||||
label="Description"
|
||||
placeholder="What are you working on?"
|
||||
id="new-description"
|
||||
ref={descriptionRef}
|
||||
required
|
||||
autoFocus={true}
|
||||
name="description"
|
||||
aria-invalid={actionData?.errors?.description ? true : undefined}
|
||||
error={actionData?.errors?.description}
|
||||
errorProps={{ children: actionData?.errors?.description }}
|
||||
/>
|
||||
</label>
|
||||
{actionData?.errors?.title && (
|
||||
<div className="pt-1 text-red-700" id="title-error">
|
||||
{actionData.errors.title}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex w-full flex-col gap-1">
|
||||
<span>Body: </span>
|
||||
<textarea
|
||||
ref={bodyRef}
|
||||
name="body"
|
||||
rows={8}
|
||||
className="w-full flex-1 rounded-md border-2 border-blue-500 py-2 px-3 text-lg leading-6"
|
||||
aria-invalid={actionData?.errors?.body ? true : undefined}
|
||||
aria-errormessage={actionData?.errors?.body ? 'body-error' : undefined}
|
||||
<Select
|
||||
id="new-project"
|
||||
ref={projectRef}
|
||||
name="projectId"
|
||||
mb={12}
|
||||
label="Project"
|
||||
placeholder="Select project"
|
||||
searchable
|
||||
nothingFound="No options"
|
||||
required
|
||||
withAsterisk
|
||||
maxDropdownHeight={400}
|
||||
data={data.projects.map((project) => ({
|
||||
label: project.name,
|
||||
value: project.id,
|
||||
color: project.color
|
||||
}))}
|
||||
itemComponent={SelectItem}
|
||||
filter={(value, item) =>
|
||||
item.label?.toLowerCase().includes(value.toLowerCase().trim()) ||
|
||||
item.value.toLowerCase().includes(value.toLowerCase().trim())
|
||||
}
|
||||
aria-invalid={actionData?.errors?.projectId ? true : undefined}
|
||||
error={actionData?.errors?.projectId}
|
||||
errorProps={{ children: actionData?.errors?.projectId }}
|
||||
/>
|
||||
</label>
|
||||
{actionData?.errors?.body && (
|
||||
<div className="pt-1 text-red-700" id="body-error">
|
||||
{actionData.errors.body}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-right">
|
||||
<button type="submit" className="rounded bg-blue-500 py-2 px-4 text-white hover:bg-blue-600 focus:bg-blue-400">
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
<Stack>
|
||||
<label
|
||||
htmlFor="new-startTime"
|
||||
style={{
|
||||
margin: '1rem 0 -0.5rem 0'
|
||||
}}
|
||||
>
|
||||
Start
|
||||
</label>
|
||||
<Group align="flex-end">
|
||||
<DatePicker
|
||||
id="new-startTime-date"
|
||||
ref={startDateRef}
|
||||
name="startTime-date"
|
||||
allowFreeInput
|
||||
withAsterisk
|
||||
clearable={false}
|
||||
inputFormat="DD/MM/YYYY"
|
||||
labelFormat="MM/YYYY"
|
||||
aria-labelledby="new-startTime-label"
|
||||
locale="it"
|
||||
placeholder="Start date"
|
||||
label="Start date"
|
||||
aria-invalid={actionData?.errors?.startTime ? true : undefined}
|
||||
error={actionData?.errors?.startTime}
|
||||
errorProps={{ children: actionData?.errors?.startTime }}
|
||||
value={start}
|
||||
onChange={(date) => {
|
||||
if (!date) return;
|
||||
let newDate = new Date(start);
|
||||
newDate.setFullYear(date.getFullYear());
|
||||
newDate.setMonth(date.getMonth());
|
||||
newDate.setDate(date.getDate());
|
||||
setStart(newDate);
|
||||
}}
|
||||
/>
|
||||
<TimeInput
|
||||
id="new-startTime-time"
|
||||
ref={startDateRef}
|
||||
name="startTime-time"
|
||||
withAsterisk
|
||||
withSeconds
|
||||
clearable={false}
|
||||
aria-labelledby="new-startTime-label"
|
||||
label="Start time"
|
||||
value={start}
|
||||
onChange={(date) => {
|
||||
let newDate = new Date(start);
|
||||
newDate.setHours(date.getHours());
|
||||
newDate.setMinutes(date.getMinutes());
|
||||
setStart(newDate);
|
||||
}}
|
||||
aria-invalid={actionData?.errors?.startTime ? true : undefined}
|
||||
error={actionData?.errors?.startTime}
|
||||
errorProps={{ children: actionData?.errors?.startTime }}
|
||||
/>
|
||||
</Group>
|
||||
<input type="hidden" name="startTime" value={start.toISOString()} />
|
||||
</Stack>
|
||||
|
||||
<Stack>
|
||||
<label
|
||||
htmlFor="new-endTime"
|
||||
style={{
|
||||
margin: '1rem 0 -0.5rem 0'
|
||||
}}
|
||||
>
|
||||
End
|
||||
</label>
|
||||
<Group align="flex-end">
|
||||
<DatePicker
|
||||
id="new-endTime-date"
|
||||
ref={endDateRef}
|
||||
name="endTime-date"
|
||||
allowFreeInput
|
||||
clearable={true}
|
||||
inputFormat="DD/MM/YYYY"
|
||||
labelFormat="MM/YYYY"
|
||||
aria-labelledby="new-endTime-label"
|
||||
locale="it"
|
||||
placeholder="End date"
|
||||
label="End date"
|
||||
aria-invalid={actionData?.errors?.endTime ? true : undefined}
|
||||
error={actionData?.errors?.endTime}
|
||||
errorProps={{ children: actionData?.errors?.endTime }}
|
||||
value={end}
|
||||
onChange={(date) => {
|
||||
if (!date) return;
|
||||
let newDate = new Date(end || start);
|
||||
newDate.setFullYear(date.getFullYear());
|
||||
newDate.setMonth(date.getMonth());
|
||||
newDate.setDate(date.getDate());
|
||||
setEnd(newDate);
|
||||
}}
|
||||
/>
|
||||
<TimeInput
|
||||
id="new-endTime-time"
|
||||
ref={endDateRef}
|
||||
name="endTime-time"
|
||||
withAsterisk
|
||||
withSeconds
|
||||
clearable={false}
|
||||
aria-labelledby="new-endTime-label"
|
||||
label="end time"
|
||||
value={end}
|
||||
onChange={(date) => {
|
||||
let newDate = new Date(end || start);
|
||||
newDate.setHours(date.getHours());
|
||||
newDate.setMinutes(date.getMinutes());
|
||||
setEnd(newDate);
|
||||
}}
|
||||
aria-invalid={actionData?.errors?.endTime ? true : undefined}
|
||||
error={actionData?.errors?.endTime}
|
||||
errorProps={{ children: actionData?.errors?.endTime }}
|
||||
/>
|
||||
</Group>
|
||||
{end && (
|
||||
<input type="hidden" name="endTime" value={end.toISOString()} />
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Group position="left" mt="lg">
|
||||
<Button type="submit" leftIcon={<Play />} radius={theme.radius.md}>
|
||||
Start
|
||||
</Button>
|
||||
</Group>
|
||||
</Form>
|
||||
)
|
||||
</LayoutWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export function ErrorBoundary({ error }: { error: Error }) {
|
||||
console.error(error);
|
||||
|
||||
return (
|
||||
<LayoutWrapper>
|
||||
<Alert icon={<AlertTriangle size={14} />} title="Error" color="red">
|
||||
An unexpected error occurred: {error.message}
|
||||
</Alert>
|
||||
</LayoutWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export function CatchBoundary() {
|
||||
const caught = useCatch();
|
||||
|
||||
if (caught.status === 404) {
|
||||
return (
|
||||
<LayoutWrapper>
|
||||
<Alert icon={<AlertTriangle size={14} />} title="Error" color="red">
|
||||
Not found
|
||||
</Alert>
|
||||
</LayoutWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected caught response with status: ${caught.status}`);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue