feat: refactor project colors, add duration in time entries, add reports page
This commit is contained in:
parent
130698a317
commit
74acc65dde
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
</Paper>
|
||||
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
|
|
|
|||
42
app/utils.ts
42
app/utils.ts
|
|
@ -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 = '/';
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "TimeEntry" ADD COLUMN "duration" DECIMAL(65,30);
|
||||
|
|
@ -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;
|
||||
|
|
@ -31,6 +31,7 @@ model TimeEntry {
|
|||
description String
|
||||
startTime DateTime @default(now())
|
||||
endTime DateTime?
|
||||
duration Float?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
|
|
|||
Loading…
Reference in a new issue