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({
|
export function createTimeEntry({
|
||||||
description,
|
description,
|
||||||
startTime,
|
startTime,
|
||||||
endTime,
|
endTime,
|
||||||
|
duration,
|
||||||
userId,
|
userId,
|
||||||
projectId
|
projectId
|
||||||
}: Pick<TimeEntry, 'description' | 'startTime' | 'endTime'> & {
|
}: Pick<TimeEntry, 'description' | 'startTime' | 'endTime' | 'duration'> & {
|
||||||
userId: User['id'];
|
userId: User['id'];
|
||||||
projectId: Project['id'];
|
projectId: Project['id'];
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -116,6 +138,7 @@ export function createTimeEntry({
|
||||||
description,
|
description,
|
||||||
startTime,
|
startTime,
|
||||||
endTime,
|
endTime,
|
||||||
|
duration,
|
||||||
projectId,
|
projectId,
|
||||||
userId
|
userId
|
||||||
}
|
}
|
||||||
|
|
@ -134,9 +157,13 @@ export function updateTimeEntry({
|
||||||
description,
|
description,
|
||||||
startTime,
|
startTime,
|
||||||
endTime,
|
endTime,
|
||||||
|
duration,
|
||||||
projectId
|
projectId
|
||||||
}: Partial<
|
}: Partial<
|
||||||
Pick<TimeEntry, 'description' | 'startTime' | 'endTime' | 'projectId'>
|
Pick<
|
||||||
|
TimeEntry,
|
||||||
|
'description' | 'startTime' | 'endTime' | 'duration' | 'projectId'
|
||||||
|
>
|
||||||
> & {
|
> & {
|
||||||
timeEntryId: TimeEntry['id'];
|
timeEntryId: TimeEntry['id'];
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -145,6 +172,7 @@ export function updateTimeEntry({
|
||||||
description,
|
description,
|
||||||
startTime,
|
startTime,
|
||||||
endTime,
|
endTime,
|
||||||
|
duration,
|
||||||
projectId
|
projectId
|
||||||
},
|
},
|
||||||
where: {
|
where: {
|
||||||
|
|
@ -172,6 +200,7 @@ export async function exportTimeEntries({ userId }: { userId: User['id'] }) {
|
||||||
description: true,
|
description: true,
|
||||||
startTime: true,
|
startTime: true,
|
||||||
endTime: true,
|
endTime: true,
|
||||||
|
duration: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
project: {
|
project: {
|
||||||
select: {
|
select: {
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,8 @@ import {
|
||||||
Tabs,
|
Tabs,
|
||||||
FileInput,
|
FileInput,
|
||||||
Loader,
|
Loader,
|
||||||
Flex
|
Flex,
|
||||||
|
useMantineTheme
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import {
|
import {
|
||||||
ActionArgs,
|
ActionArgs,
|
||||||
|
|
@ -21,9 +22,7 @@ import { requireUserId } from '~/session.server';
|
||||||
import { createProject, getProjectByName } from '~/models/project.server';
|
import { createProject, getProjectByName } from '~/models/project.server';
|
||||||
import { createTimeEntry } from '~/models/timeEntry.server';
|
import { createTimeEntry } from '~/models/timeEntry.server';
|
||||||
import papaparse from 'papaparse';
|
import papaparse from 'papaparse';
|
||||||
|
import { randomColorName } from '~/utils';
|
||||||
const randomColor = () =>
|
|
||||||
`#${Math.floor(Math.random() * 16777215).toString(16)}`;
|
|
||||||
|
|
||||||
export const meta: MetaFunction = () => {
|
export const meta: MetaFunction = () => {
|
||||||
return {
|
return {
|
||||||
|
|
@ -107,12 +106,14 @@ export async function action({ request, params }: ActionArgs) {
|
||||||
description: string;
|
description: string;
|
||||||
startTime: string;
|
startTime: string;
|
||||||
endTime?: string;
|
endTime?: string;
|
||||||
|
duration?: number;
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
projectName: string;
|
projectName: string;
|
||||||
}>((row: any) => ({
|
}>((row: any) => ({
|
||||||
description: row.description,
|
description: row.description,
|
||||||
startTime: row.startTime,
|
startTime: row.startTime,
|
||||||
endTime: row.endTime,
|
endTime: row.endTime,
|
||||||
|
duration: row.duration,
|
||||||
projectId: undefined,
|
projectId: undefined,
|
||||||
projectName: row.project
|
projectName: row.project
|
||||||
}));
|
}));
|
||||||
|
|
@ -128,7 +129,7 @@ export async function action({ request, params }: ActionArgs) {
|
||||||
userId,
|
userId,
|
||||||
name: timeEntry.projectName,
|
name: timeEntry.projectName,
|
||||||
description: null,
|
description: null,
|
||||||
color: randomColor()
|
color: randomColorName()
|
||||||
});
|
});
|
||||||
|
|
||||||
timeEntry.projectId = project.id;
|
timeEntry.projectId = project.id;
|
||||||
|
|
@ -141,7 +142,13 @@ export async function action({ request, params }: ActionArgs) {
|
||||||
projectId: timeEntry.projectId,
|
projectId: timeEntry.projectId,
|
||||||
description: timeEntry.description,
|
description: timeEntry.description,
|
||||||
startTime: new Date(timeEntry.startTime),
|
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 { requireUserId } from '~/session.server';
|
||||||
import { exportTimeEntries } from '~/models/timeEntry.server';
|
import { exportTimeEntries } from '~/models/timeEntry.server';
|
||||||
import papaparse from 'papaparse';
|
import papaparse from 'papaparse';
|
||||||
|
|
|
||||||
|
|
@ -20,11 +20,8 @@ import {
|
||||||
} from '@remix-run/react';
|
} from '@remix-run/react';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { AlertTriangle, RefreshCcw, Save } from 'react-feather';
|
import { AlertTriangle, RefreshCcw, Save } from 'react-feather';
|
||||||
import {
|
import { COLORS_MAP, randomColor } from '~/utils';
|
||||||
createProject,
|
import { createProject, getProjectByName } from '~/models/project.server';
|
||||||
getProjectByName,
|
|
||||||
Project
|
|
||||||
} from '~/models/project.server';
|
|
||||||
import { requireUserId } from '~/session.server';
|
import { requireUserId } from '~/session.server';
|
||||||
|
|
||||||
export const meta: MetaFunction = () => {
|
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() {
|
export default function NewProjectPage() {
|
||||||
const data = useLoaderData<typeof loader>();
|
const data = useLoaderData<typeof loader>();
|
||||||
const actionData = useActionData<typeof action>();
|
const actionData = useActionData<typeof action>();
|
||||||
|
|
@ -148,7 +142,10 @@ export default function NewProjectPage() {
|
||||||
const descriptionRef = React.useRef<HTMLTextAreaElement>(null);
|
const descriptionRef = React.useRef<HTMLTextAreaElement>(null);
|
||||||
const colorRef = React.useRef<HTMLInputElement>(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(() => {
|
React.useEffect(() => {
|
||||||
if (actionData?.errors?.name) {
|
if (actionData?.errors?.name) {
|
||||||
|
|
@ -203,22 +200,28 @@ export default function NewProjectPage() {
|
||||||
label="Color"
|
label="Color"
|
||||||
placeholder="The color of your project"
|
placeholder="The color of your project"
|
||||||
id="new-color"
|
id="new-color"
|
||||||
name="color"
|
|
||||||
ref={colorRef}
|
ref={colorRef}
|
||||||
withPicker={false}
|
withPicker={false}
|
||||||
withEyeDropper
|
disallowInput
|
||||||
withAsterisk
|
withAsterisk
|
||||||
swatchesPerRow={6}
|
swatchesPerRow={6}
|
||||||
swatches={Object.keys(theme.colors).map(
|
swatches={Object.values(COLORS_MAP)}
|
||||||
(color) => theme.colors[color][6]
|
|
||||||
)}
|
|
||||||
rightSection={
|
rightSection={
|
||||||
<ActionIcon onClick={() => setColor(randomColor())}>
|
<ActionIcon onClick={() => setColor(randomColor())}>
|
||||||
<RefreshCcw size={16} />
|
<RefreshCcw size={16} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
}
|
}
|
||||||
value={color}
|
value={color.hex}
|
||||||
onChange={setColor}
|
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
|
closeOnColorSwatchClick
|
||||||
format="hex"
|
format="hex"
|
||||||
required
|
required
|
||||||
|
|
@ -226,6 +229,7 @@ export default function NewProjectPage() {
|
||||||
error={actionData?.errors?.color}
|
error={actionData?.errors?.color}
|
||||||
errorProps={{ children: actionData?.errors?.color }}
|
errorProps={{ children: actionData?.errors?.color }}
|
||||||
/>
|
/>
|
||||||
|
<input type="hidden" name="color" value={color.name} />
|
||||||
|
|
||||||
<Group position="left" mt="lg">
|
<Group position="left" mt="lg">
|
||||||
<Button type="submit" leftIcon={<Save />} radius={theme.radius.md}>
|
<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 { MetaFunction, LoaderArgs, redirect, json } from '@remix-run/node';
|
||||||
import { useLoaderData } from '@remix-run/react';
|
import { Link, useFetcher, useLoaderData, useNavigate } from '@remix-run/react';
|
||||||
import { getTimeEntries } from '~/models/timeEntry.server';
|
import { getTimeEntriesByDateAndProject } from '~/models/timeEntry.server';
|
||||||
|
import { getProjects, Project } from '~/models/project.server';
|
||||||
import { requireUserId } from '~/session.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 = () => {
|
export const meta: MetaFunction = () => {
|
||||||
return {
|
return {
|
||||||
|
|
@ -16,15 +35,45 @@ export async function loader({ request }: LoaderArgs) {
|
||||||
const userId = await requireUserId(request);
|
const userId = await requireUserId(request);
|
||||||
if (!userId) return redirect('/login');
|
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({
|
return json({
|
||||||
...(await getTimeEntries({
|
timeByProject: await getTimeEntriesByDateAndProject({
|
||||||
userId
|
userId,
|
||||||
}))
|
dateFrom,
|
||||||
|
dateTo
|
||||||
|
}),
|
||||||
|
projects: await getProjects({ userId })
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ReportPage() {
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -43,9 +92,165 @@ export default function ReportPage() {
|
||||||
>
|
>
|
||||||
Reports
|
Reports
|
||||||
</h1>
|
</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>
|
</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,
|
description,
|
||||||
projectId,
|
projectId,
|
||||||
startTime: startTime ? new Date(startTime) : undefined,
|
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,
|
description,
|
||||||
startTime: new Date(startTime),
|
startTime: new Date(startTime),
|
||||||
endTime: typeof endTime === 'string' ? new Date(endTime) : null,
|
endTime: typeof endTime === 'string' ? new Date(endTime) : null,
|
||||||
|
duration:
|
||||||
|
typeof endTime === 'string'
|
||||||
|
? new Date(endTime).getTime() - new Date(startTime).getTime()
|
||||||
|
: null,
|
||||||
userId,
|
userId,
|
||||||
projectId
|
projectId
|
||||||
});
|
});
|
||||||
|
|
|
||||||
42
app/utils.ts
42
app/utils.ts
|
|
@ -3,6 +3,48 @@ import { useMemo } from 'react';
|
||||||
|
|
||||||
import type { User } from '~/models/user.server';
|
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 = '/';
|
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
|
description String
|
||||||
startTime DateTime @default(now())
|
startTime DateTime @default(now())
|
||||||
endTime DateTime?
|
endTime DateTime?
|
||||||
|
duration Float?
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue