401 lines
12 KiB
TypeScript
401 lines
12 KiB
TypeScript
import { useState, useEffect, useMemo } from 'react';
|
|
import {
|
|
Button,
|
|
Paper,
|
|
Text,
|
|
Menu,
|
|
ActionIcon,
|
|
Textarea,
|
|
Pagination,
|
|
NativeSelect,
|
|
Group,
|
|
Divider,
|
|
useMantineTheme,
|
|
Progress,
|
|
Badge,
|
|
ThemeIcon,
|
|
Alert
|
|
} from '@mantine/core';
|
|
import { json, LoaderArgs, MetaFunction, redirect } from '@remix-run/node';
|
|
import {
|
|
Form,
|
|
Link,
|
|
Outlet,
|
|
useCatch,
|
|
useLoaderData,
|
|
useSearchParams
|
|
} from '@remix-run/react';
|
|
import {
|
|
AlertTriangle,
|
|
Edit,
|
|
Edit3,
|
|
Play,
|
|
Power,
|
|
Settings,
|
|
Square,
|
|
Trash
|
|
} from 'react-feather';
|
|
import { requireUserId } from '~/session.server';
|
|
import { getTimeEntries, TimeEntry } from '~/models/timeEntry.server';
|
|
import TimeElapsed from '~/components/TimeElapsed';
|
|
import SectionTimeElapsed from '~/components/SectionTimeElapsed';
|
|
|
|
export const meta: MetaFunction = () => {
|
|
return {
|
|
title: 'Time entries | WorkTimer',
|
|
description: 'Manage your time entries. You must be logged in to do this.'
|
|
};
|
|
};
|
|
|
|
export async function loader({ request }: LoaderArgs) {
|
|
const userId = await requireUserId(request);
|
|
if (!userId) return redirect('/login');
|
|
|
|
const url = new URL(request.url);
|
|
const page = url.searchParams.get('page')
|
|
? parseInt(url.searchParams.get('page')!, 10)
|
|
: 1;
|
|
const size = url.searchParams.get('size')
|
|
? parseInt(url.searchParams.get('size')!, 10)
|
|
: 25;
|
|
const orderBy = url.searchParams.get('orderBy') || 'createdAt';
|
|
const order = url.searchParams.get('order') || 'desc';
|
|
|
|
return json({
|
|
...(await getTimeEntries({
|
|
page,
|
|
size,
|
|
userId,
|
|
orderBy,
|
|
order: order === 'asc' ? 'asc' : 'desc'
|
|
}))
|
|
});
|
|
}
|
|
|
|
export default function TimeEntriesPage() {
|
|
const data = useLoaderData<typeof loader>();
|
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
const theme = useMantineTheme();
|
|
|
|
const pageSize = useMemo(() => {
|
|
return parseInt(searchParams.get('size') || '25', 10);
|
|
}, [searchParams]);
|
|
const page = useMemo(() => {
|
|
return parseInt(searchParams.get('page') || '1', 10);
|
|
}, [searchParams]);
|
|
|
|
const timeEntriesPerDay = useMemo(() => {
|
|
const timeEntriesPerDay: Record<
|
|
string,
|
|
{ entries: typeof data.timeEntries; total: number }
|
|
> = {};
|
|
data.timeEntries.forEach((timeEntry) => {
|
|
const date = Intl.DateTimeFormat('it-IT', {
|
|
year: 'numeric',
|
|
month: '2-digit',
|
|
day: '2-digit'
|
|
}).format(new Date(timeEntry.startTime));
|
|
|
|
if (!timeEntriesPerDay[date])
|
|
timeEntriesPerDay[date] = { entries: [], total: 0 };
|
|
timeEntriesPerDay[date].total +=
|
|
(timeEntry.endTime
|
|
? new Date(timeEntry.endTime).getTime() -
|
|
new Date(timeEntry.startTime).getTime()
|
|
: Date.now() - new Date(timeEntry.startTime).getTime()) / 1000;
|
|
timeEntriesPerDay[date].entries.push(timeEntry);
|
|
});
|
|
return timeEntriesPerDay;
|
|
}, [data.timeEntries]);
|
|
|
|
return (
|
|
<div>
|
|
<Paper
|
|
component="fieldset"
|
|
aria-controls="time-entries"
|
|
p="sm"
|
|
shadow="sm"
|
|
radius="md"
|
|
withBorder
|
|
style={{
|
|
display: 'flex',
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
flexWrap: 'wrap'
|
|
}}
|
|
>
|
|
<Button
|
|
component={Link}
|
|
to="/time-entries/new"
|
|
variant="light"
|
|
radius={theme.radius.md}
|
|
leftIcon={<Play />}
|
|
>
|
|
Start
|
|
</Button>
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'flex-end',
|
|
justifyContent: 'space-between'
|
|
}}
|
|
>
|
|
<NativeSelect
|
|
data={[
|
|
{ label: '25 / page', value: '25' },
|
|
{ label: '50 / page', value: '50' },
|
|
{ label: '100 / page', value: '100' }
|
|
]}
|
|
value={pageSize}
|
|
onChange={(event) => {
|
|
setSearchParams({
|
|
page: page.toString(),
|
|
size: event.currentTarget.value
|
|
});
|
|
}}
|
|
/>
|
|
{data.total / pageSize > 1 && (
|
|
<Pagination
|
|
style={{ marginLeft: 10 }}
|
|
page={page}
|
|
total={Math.ceil(data.total / pageSize)}
|
|
onChange={(page) => {
|
|
setSearchParams({
|
|
page: page.toString(),
|
|
size: pageSize.toString()
|
|
});
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
</Paper>
|
|
<Group
|
|
mt="lg"
|
|
mb="md"
|
|
mx="auto"
|
|
maw={500}
|
|
display="flex"
|
|
style={{
|
|
justifyContent: 'space-between'
|
|
}}
|
|
>
|
|
<Text size="sm" color="darkgray">
|
|
{data.total} entries
|
|
</Text>
|
|
<Divider orientation="vertical" />
|
|
<SectionTimeElapsed
|
|
timeEntries={
|
|
data.timeEntries.filter(
|
|
(t) =>
|
|
new Date(t.startTime) >=
|
|
new Date(
|
|
new Date().getFullYear(),
|
|
new Date().getMonth(),
|
|
new Date().getDate(),
|
|
0,
|
|
0,
|
|
0
|
|
)
|
|
) as any as TimeEntry[]
|
|
}
|
|
size="sm"
|
|
additionalLabel="today"
|
|
/>
|
|
<Divider orientation="vertical" />
|
|
<SectionTimeElapsed
|
|
timeEntries={
|
|
data.timeEntries.filter(
|
|
(t) =>
|
|
new Date(t.startTime) >=
|
|
new Date(new Date().getFullYear(), new Date().getMonth(), 1)
|
|
) as any as TimeEntry[]
|
|
}
|
|
size="sm"
|
|
additionalLabel="this month"
|
|
/>
|
|
</Group>
|
|
|
|
<div role="region" id="time-entries">
|
|
{Object.entries(timeEntriesPerDay).map(([date, timeEntries]) => (
|
|
<section key={date}>
|
|
<header
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center'
|
|
}}
|
|
>
|
|
<h2
|
|
style={{
|
|
marginRight: 'auto'
|
|
}}
|
|
>
|
|
{date}
|
|
</h2>
|
|
|
|
<SectionTimeElapsed
|
|
timeEntries={timeEntries.entries as any as TimeEntry[]}
|
|
size="md"
|
|
total={timeEntries.total}
|
|
/>
|
|
</header>
|
|
|
|
{timeEntries.entries.map((timeEntry) => (
|
|
<Paper
|
|
key={timeEntry.id}
|
|
shadow="sm"
|
|
p="md"
|
|
radius="md"
|
|
mb="sm"
|
|
display="flex"
|
|
style={{
|
|
alignItems: 'center',
|
|
flexDirection: 'column'
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
width: '100%'
|
|
}}
|
|
>
|
|
<span
|
|
style={{
|
|
marginRight: 'auto'
|
|
}}
|
|
>
|
|
{timeEntry.description}
|
|
</span>
|
|
{timeEntry.projectId && timeEntry.project && (
|
|
<Badge color={timeEntry.project.color}>
|
|
{timeEntry.project.name}
|
|
</Badge>
|
|
)}
|
|
<TimeElapsed
|
|
startTime={timeEntry.startTime}
|
|
endTime={timeEntry.endTime}
|
|
/>
|
|
<Menu shadow="md" width={200}>
|
|
<Menu.Target>
|
|
<ActionIcon title="Edit" mr="xs">
|
|
<Settings size={14} />
|
|
</ActionIcon>
|
|
</Menu.Target>
|
|
|
|
<Menu.Dropdown>
|
|
<Menu.Label>Edit time entry</Menu.Label>
|
|
<Menu.Item
|
|
component={Link}
|
|
to={`/time-entries/${timeEntry.id}`}
|
|
icon={
|
|
<Edit3 size={14} color={theme.colors.yellow[8]} />
|
|
}
|
|
>
|
|
Edit
|
|
</Menu.Item>
|
|
<Form
|
|
method="delete"
|
|
action={`/time-entries/${timeEntry.id}`}
|
|
>
|
|
<Menu.Item
|
|
component="button"
|
|
type="submit"
|
|
icon={<Trash size={14} color={theme.colors.red[8]} />}
|
|
>
|
|
Delete
|
|
</Menu.Item>
|
|
</Form>
|
|
</Menu.Dropdown>
|
|
</Menu>
|
|
{timeEntry.endTime ? (
|
|
<Form method="post" action="/time-entries/new">
|
|
<input
|
|
type="hidden"
|
|
name="startTime"
|
|
value={new Date(Date.now()).toISOString()}
|
|
/>
|
|
<input
|
|
type="hidden"
|
|
name="description"
|
|
value={timeEntry.description}
|
|
/>
|
|
<input
|
|
type="hidden"
|
|
name="projectId"
|
|
value={timeEntry.projectId}
|
|
/>
|
|
<input
|
|
type="hidden"
|
|
name="userId"
|
|
value={timeEntry.userId}
|
|
/>
|
|
<ActionIcon
|
|
type="submit"
|
|
title="Start new entry with same description"
|
|
>
|
|
<ThemeIcon variant="light">
|
|
<Play size={14} color={theme.colors.blue[7]} />
|
|
</ThemeIcon>
|
|
</ActionIcon>
|
|
</Form>
|
|
) : (
|
|
<Form
|
|
method="patch"
|
|
action={`/time-entries/${timeEntry.id}`}
|
|
>
|
|
<input
|
|
type="hidden"
|
|
name="endTime"
|
|
value={new Date().toISOString()}
|
|
/>
|
|
<ActionIcon
|
|
type="submit"
|
|
variant="filled"
|
|
title="Stop"
|
|
style={{
|
|
backgroundColor: theme.colors.red[7],
|
|
color: 'white',
|
|
borderRadius: '50%'
|
|
}}
|
|
>
|
|
<Square size={12} fill="currentColor" />
|
|
</ActionIcon>
|
|
</Form>
|
|
)}
|
|
</div>
|
|
</Paper>
|
|
))}
|
|
</section>
|
|
))}
|
|
</div>
|
|
<Outlet />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function ErrorBoundary({ error }: { error: Error }) {
|
|
console.error(error);
|
|
|
|
return (
|
|
<Alert icon={<AlertTriangle size={14} />} title="Error" color="red">
|
|
An unexpected error occurred: {error.message}
|
|
</Alert>
|
|
);
|
|
}
|
|
|
|
export function CatchBoundary() {
|
|
const caught = useCatch();
|
|
|
|
if (caught.status === 404) {
|
|
return (
|
|
<Alert icon={<AlertTriangle size={14} />} title="Error" color="red">
|
|
Not found
|
|
</Alert>
|
|
);
|
|
}
|
|
|
|
throw new Error(`Unexpected caught response with status: ${caught.status}`);
|
|
}
|