feat: refactor project colors, add duration in time entries, add reports page

This commit is contained in:
Nicola Zambello 2023-02-19 14:07:24 +01:00
parent 130698a317
commit 74acc65dde
Signed by: nzambello
GPG key ID: 56E4A92C2C1E50BA
11 changed files with 342 additions and 36 deletions

View file

@ -101,13 +101,35 @@ export async function getTimeEntries({
};
}
export function getTimeEntriesByDateAndProject({
userId,
dateFrom,
dateTo
}: {
userId: User['id'];
dateFrom: Date;
dateTo: Date;
}) {
return prisma.timeEntry.groupBy({
by: ['projectId'],
_sum: {
duration: true
},
where: {
userId,
startTime: { gte: dateFrom, lte: dateTo }
}
});
}
export function createTimeEntry({
description,
startTime,
endTime,
duration,
userId,
projectId
}: Pick<TimeEntry, 'description' | 'startTime' | 'endTime'> & {
}: Pick<TimeEntry, 'description' | 'startTime' | 'endTime' | 'duration'> & {
userId: User['id'];
projectId: Project['id'];
}) {
@ -116,6 +138,7 @@ export function createTimeEntry({
description,
startTime,
endTime,
duration,
projectId,
userId
}
@ -134,9 +157,13 @@ export function updateTimeEntry({
description,
startTime,
endTime,
duration,
projectId
}: Partial<
Pick<TimeEntry, 'description' | 'startTime' | 'endTime' | 'projectId'>
Pick<
TimeEntry,
'description' | 'startTime' | 'endTime' | 'duration' | 'projectId'
>
> & {
timeEntryId: TimeEntry['id'];
}) {
@ -145,6 +172,7 @@ export function updateTimeEntry({
description,
startTime,
endTime,
duration,
projectId
},
where: {
@ -172,6 +200,7 @@ export async function exportTimeEntries({ userId }: { userId: User['id'] }) {
description: true,
startTime: true,
endTime: true,
duration: true,
createdAt: true,
project: {
select: {

View file

@ -6,7 +6,8 @@ import {
Tabs,
FileInput,
Loader,
Flex
Flex,
useMantineTheme
} from '@mantine/core';
import {
ActionArgs,
@ -21,9 +22,7 @@ import { requireUserId } from '~/session.server';
import { createProject, getProjectByName } from '~/models/project.server';
import { createTimeEntry } from '~/models/timeEntry.server';
import papaparse from 'papaparse';
const randomColor = () =>
`#${Math.floor(Math.random() * 16777215).toString(16)}`;
import { randomColorName } from '~/utils';
export const meta: MetaFunction = () => {
return {
@ -107,12 +106,14 @@ export async function action({ request, params }: ActionArgs) {
description: string;
startTime: string;
endTime?: string;
duration?: number;
projectId?: string;
projectName: string;
}>((row: any) => ({
description: row.description,
startTime: row.startTime,
endTime: row.endTime,
duration: row.duration,
projectId: undefined,
projectName: row.project
}));
@ -128,7 +129,7 @@ export async function action({ request, params }: ActionArgs) {
userId,
name: timeEntry.projectName,
description: null,
color: randomColor()
color: randomColorName()
});
timeEntry.projectId = project.id;
@ -141,7 +142,13 @@ export async function action({ request, params }: ActionArgs) {
projectId: timeEntry.projectId,
description: timeEntry.description,
startTime: new Date(timeEntry.startTime),
endTime: timeEntry.endTime ? new Date(timeEntry.endTime) : null
endTime: timeEntry.endTime ? new Date(timeEntry.endTime) : null,
duration: timeEntry.duration
? timeEntry.duration
: timeEntry.endTime
? new Date(timeEntry.endTime).getTime() -
new Date(timeEntry.startTime).getTime()
: null
});
}

View file

@ -1,4 +1,4 @@
import { json, LoaderArgs } from '@remix-run/node';
import { LoaderArgs } from '@remix-run/node';
import { requireUserId } from '~/session.server';
import { exportTimeEntries } from '~/models/timeEntry.server';
import papaparse from 'papaparse';

View file

@ -20,11 +20,8 @@ import {
} from '@remix-run/react';
import * as React from 'react';
import { AlertTriangle, RefreshCcw, Save } from 'react-feather';
import {
createProject,
getProjectByName,
Project
} from '~/models/project.server';
import { COLORS_MAP, randomColor } from '~/utils';
import { createProject, getProjectByName } from '~/models/project.server';
import { requireUserId } from '~/session.server';
export const meta: MetaFunction = () => {
@ -136,9 +133,6 @@ const LayoutWrapper = ({ children }: React.PropsWithChildren<{}>) => {
);
};
const randomColor = () =>
`#${Math.floor(Math.random() * 16777215).toString(16)}`;
export default function NewProjectPage() {
const data = useLoaderData<typeof loader>();
const actionData = useActionData<typeof action>();
@ -148,7 +142,10 @@ export default function NewProjectPage() {
const descriptionRef = React.useRef<HTMLTextAreaElement>(null);
const colorRef = React.useRef<HTMLInputElement>(null);
const [color, setColor] = React.useState<Project['color']>(randomColor());
const [color, setColor] = React.useState<{
name: string;
hex: string;
}>(randomColor());
React.useEffect(() => {
if (actionData?.errors?.name) {
@ -203,22 +200,28 @@ export default function NewProjectPage() {
label="Color"
placeholder="The color of your project"
id="new-color"
name="color"
ref={colorRef}
withPicker={false}
withEyeDropper
disallowInput
withAsterisk
swatchesPerRow={6}
swatches={Object.keys(theme.colors).map(
(color) => theme.colors[color][6]
)}
swatches={Object.values(COLORS_MAP)}
rightSection={
<ActionIcon onClick={() => setColor(randomColor())}>
<RefreshCcw size={16} />
</ActionIcon>
}
value={color}
onChange={setColor}
value={color.hex}
onChange={(value) => {
const color = Object.entries(COLORS_MAP).find(
([, hex]) => hex === value
);
if (color) {
setColor({ name: color[0], hex: color[1] });
} else {
setColor(randomColor());
}
}}
closeOnColorSwatchClick
format="hex"
required
@ -226,6 +229,7 @@ export default function NewProjectPage() {
error={actionData?.errors?.color}
errorProps={{ children: actionData?.errors?.color }}
/>
<input type="hidden" name="color" value={color.name} />
<Group position="left" mt="lg">
<Button type="submit" leftIcon={<Save />} radius={theme.radius.md}>

View file

@ -1,8 +1,27 @@
import { Box, Paper, Title } from '@mantine/core';
import {
Alert,
Box,
Button,
ColorSwatch,
Flex,
Group,
NumberInput,
Paper,
Table,
Title
} from '@mantine/core';
import { MetaFunction, LoaderArgs, redirect, json } from '@remix-run/node';
import { useLoaderData } from '@remix-run/react';
import { getTimeEntries } from '~/models/timeEntry.server';
import { Link, useFetcher, useLoaderData, useNavigate } from '@remix-run/react';
import { getTimeEntriesByDateAndProject } from '~/models/timeEntry.server';
import { getProjects, Project } from '~/models/project.server';
import { requireUserId } from '~/session.server';
import { DateRangePicker, DateRangePickerValue } from '@mantine/dates';
import { useEffect, useState } from 'react';
import dayjs from 'dayjs';
import { Calendar } from 'react-feather';
import { useMediaQuery } from '@mantine/hooks';
import 'dayjs/locale/it';
export const meta: MetaFunction = () => {
return {
@ -16,15 +35,45 @@ export async function loader({ request }: LoaderArgs) {
const userId = await requireUserId(request);
if (!userId) return redirect('/login');
const url = new URL(request.url);
const dateFrom = url.searchParams.get('dateFrom')
? dayjs(url.searchParams.get('dateFrom')).toDate()
: dayjs().startOf('month').toDate();
const dateTo = url.searchParams.get('dateTo')
? dayjs(url.searchParams.get('dateTo')).toDate()
: dayjs().endOf('month').toDate();
return json({
...(await getTimeEntries({
userId
}))
timeByProject: await getTimeEntriesByDateAndProject({
userId,
dateFrom,
dateTo
}),
projects: await getProjects({ userId })
});
}
export default function ReportPage() {
const data = useLoaderData();
const reports = useFetcher<typeof loader>();
const [dateRange, setDateRange] = useState<DateRangePickerValue>([
dayjs().startOf('month').toDate(),
dayjs().endOf('month').toDate()
]);
useEffect(() => {
if (dateRange[0] && dateRange[1]) {
reports.load(
`/reports?dateFrom=${dayjs(dateRange[0]).format(
'YYYY-MM-DD'
)}&dateTo=${dayjs(dateRange[1]).format('YYYY-MM-DD')}`
);
}
}, [dateRange]);
const [costPerHour, setCostPerHour] = useState<number>();
const mobile = useMediaQuery('(max-width: 600px)');
return (
<>
@ -43,9 +92,165 @@ export default function ReportPage() {
>
Reports
</h1>
<Paper p="lg" radius="md">
Coming soon
<reports.Form action="/reports" method="get">
<Paper p="sm" shadow="sm" radius="md" component="fieldset">
<DateRangePicker
label="Select date range"
placeholder="Pick dates range"
value={dateRange}
onChange={setDateRange}
inputFormat="DD/MM/YYYY"
labelFormat="MM/YYYY"
firstDayOfWeek="monday"
amountOfMonths={mobile ? 1 : 2}
icon={<Calendar size={16} />}
mb="md"
/>
<input
type="hidden"
name="dateFrom"
value={dayjs(dateRange[0]).format('YYYY-MM-DD')}
/>
<input
type="hidden"
name="dateTo"
value={dayjs(dateRange[1]).format('YYYY-MM-DD')}
/>
<Group>
<Button
variant="outline"
size="xs"
onClick={() =>
setDateRange([
dayjs().startOf('week').toDate(),
dayjs().endOf('week').toDate()
])
}
>
This week
</Button>
<Button
variant="outline"
size="xs"
onClick={() =>
setDateRange([
dayjs().subtract(1, 'week').startOf('week').toDate(),
dayjs().subtract(1, 'week').endOf('week').toDate()
])
}
>
Last week
</Button>
<Button
variant="outline"
size="xs"
onClick={() =>
setDateRange([
dayjs().startOf('month').toDate(),
dayjs().endOf('month').toDate()
])
}
>
This month
</Button>
<Button
variant="outline"
size="xs"
onClick={() =>
setDateRange([
dayjs().subtract(1, 'month').startOf('month').toDate(),
dayjs().subtract(1, 'month').endOf('month').toDate()
])
}
>
Last month
</Button>
<Button
variant="outline"
size="xs"
onClick={() =>
setDateRange([
dayjs().startOf('year').toDate(),
dayjs().endOf('year').toDate()
])
}
>
This year
</Button>
<Button
variant="outline"
size="xs"
onClick={() =>
setDateRange([
dayjs().subtract(1, 'year').startOf('year').toDate(),
dayjs().subtract(1, 'year').endOf('year').toDate()
])
}
>
Last year
</Button>
</Group>
</Paper>
</reports.Form>
<Box mt="xl">
<Title order={2}>Time per project</Title>
</Box>
<Box mt="md" maw={300}>
<NumberInput
label="Cost per hour"
value={costPerHour}
onChange={setCostPerHour}
/>
</Box>
{reports.data && (
<Table mt="md">
<thead>
<tr>
<th>Project</th>
<th>Time</th>
{costPerHour && <th>Billing</th>}
</tr>
</thead>
<tbody>
{(
Object.values(reports.data.timeByProject ?? {}) as {
projectId: string;
_sum: { duration: number };
}[]
).map((projectData) => (
<tr key={projectData.projectId}>
<td>
<Flex align="center">
<ColorSwatch
mr="sm"
color={
reports.data?.projects?.projects?.find(
(p) => p.id === projectData.projectId
)?.color ?? '#000'
}
/>
{reports.data?.projects?.projects?.find(
(p) => p.id === projectData.projectId
)?.name ?? 'No project'}
</Flex>
</td>
<td>{projectData._sum.duration / 1000 / 60 / 60} h</td>
{costPerHour && (
<td>
{(projectData._sum.duration * costPerHour) / 1000 / 60 / 60}{' '}
</td>
)}
</tr>
))}
</tbody>
</Table>
)}
</>
);
}

View file

@ -146,7 +146,11 @@ export async function action({ request, params }: ActionArgs) {
description,
projectId,
startTime: startTime ? new Date(startTime) : undefined,
endTime: endTime ? new Date(endTime) : undefined
endTime: endTime ? new Date(endTime) : undefined,
duration:
endTime && startTime
? new Date(endTime).getTime() - new Date(startTime).getTime()
: undefined
});
}

View file

@ -154,6 +154,10 @@ export async function action({ request }: ActionArgs) {
description,
startTime: new Date(startTime),
endTime: typeof endTime === 'string' ? new Date(endTime) : null,
duration:
typeof endTime === 'string'
? new Date(endTime).getTime() - new Date(startTime).getTime()
: null,
userId,
projectId
});

View file

@ -3,6 +3,48 @@ import { useMemo } from 'react';
import type { User } from '~/models/user.server';
export const DEFAULT_COLORS = [
'dark',
'gray',
'red',
'pink',
'grape',
'violet',
'indigo',
'blue',
'cyan',
'green',
'lime',
'yellow',
'orange',
'teal'
];
export const COLORS_MAP: Record<string, string> = {
dark: '#25262b',
gray: '#868e96',
red: '#fa5252',
pink: '#e64980',
grape: '#be4bdb',
violet: '#7950f2',
indigo: '#4c6ef5',
blue: '#228be6',
cyan: '#15aabf',
green: '#12b886',
lime: '#40c057',
yellow: '#82c91e',
orange: '#fab005',
teal: '#fd7e14'
};
export const randomColorName = () =>
DEFAULT_COLORS[Math.floor(Math.random() * DEFAULT_COLORS.length)];
export const randomColor = () => {
const colorName = randomColorName();
return {
name: colorName,
hex: COLORS_MAP[colorName]
};
};
const DEFAULT_REDIRECT = '/';
/**

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "TimeEntry" ADD COLUMN "duration" DECIMAL(65,30);

View file

@ -0,0 +1,8 @@
/*
Warnings:
- You are about to alter the column `duration` on the `TimeEntry` table. The data in that column could be lost. The data in that column will be cast from `Decimal(65,30)` to `DoublePrecision`.
*/
-- AlterTable
ALTER TABLE "TimeEntry" ALTER COLUMN "duration" SET DATA TYPE DOUBLE PRECISION;

View file

@ -31,6 +31,7 @@ model TimeEntry {
description String
startTime DateTime @default(now())
endTime DateTime?
duration Float?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt