diff --git a/app/models/timeEntry.server.ts b/app/models/timeEntry.server.ts index 98d1d8e..7b35bc6 100644 --- a/app/models/timeEntry.server.ts +++ b/app/models/timeEntry.server.ts @@ -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 & { +}: Pick & { 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 + 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: { diff --git a/app/routes/importexport.tsx b/app/routes/importexport.tsx index 405385b..758e6e9 100644 --- a/app/routes/importexport.tsx +++ b/app/routes/importexport.tsx @@ -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 }); } diff --git a/app/routes/importexport/export[.]csv.tsx b/app/routes/importexport/export[.]csv.tsx index e9f4d49..f98cb0d 100644 --- a/app/routes/importexport/export[.]csv.tsx +++ b/app/routes/importexport/export[.]csv.tsx @@ -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'; diff --git a/app/routes/projects/new.tsx b/app/routes/projects/new.tsx index 5366480..69ff325 100644 --- a/app/routes/projects/new.tsx +++ b/app/routes/projects/new.tsx @@ -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(); const actionData = useActionData(); @@ -148,7 +142,10 @@ export default function NewProjectPage() { const descriptionRef = React.useRef(null); const colorRef = React.useRef(null); - const [color, setColor] = React.useState(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={ setColor(randomColor())}> } - 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 }} /> + + + + + + + + + + + + Time per project + + + + + + + {reports.data && ( + + + + + + {costPerHour && } + + + + {( + Object.values(reports.data.timeByProject ?? {}) as { + projectId: string; + _sum: { duration: number }; + }[] + ).map((projectData) => ( + + + + {costPerHour && ( + + )} + + ))} + +
ProjectTimeBilling
+ + p.id === projectData.projectId + )?.color ?? '#000' + } + /> + {reports.data?.projects?.projects?.find( + (p) => p.id === projectData.projectId + )?.name ?? 'No project'} + + {projectData._sum.duration / 1000 / 60 / 60} h + {(projectData._sum.duration * costPerHour) / 1000 / 60 / 60}{' '} + € +
+ )} ); } diff --git a/app/routes/time-entries/$timeEntryId.tsx b/app/routes/time-entries/$timeEntryId.tsx index 1e16fbe..e4ccbb7 100644 --- a/app/routes/time-entries/$timeEntryId.tsx +++ b/app/routes/time-entries/$timeEntryId.tsx @@ -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 }); } diff --git a/app/routes/time-entries/new.tsx b/app/routes/time-entries/new.tsx index 1974f20..41d8810 100644 --- a/app/routes/time-entries/new.tsx +++ b/app/routes/time-entries/new.tsx @@ -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 }); diff --git a/app/utils.ts b/app/utils.ts index c766eee..269658f 100644 --- a/app/utils.ts +++ b/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 = { + 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 = '/'; /** diff --git a/prisma/migrations/20230219103742_add_timeentry_duration/migration.sql b/prisma/migrations/20230219103742_add_timeentry_duration/migration.sql new file mode 100644 index 0000000..92e7fcc --- /dev/null +++ b/prisma/migrations/20230219103742_add_timeentry_duration/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "TimeEntry" ADD COLUMN "duration" DECIMAL(65,30); diff --git a/prisma/migrations/20230219104008_fix_timeentry_duration/migration.sql b/prisma/migrations/20230219104008_fix_timeentry_duration/migration.sql new file mode 100644 index 0000000..dbe783c --- /dev/null +++ b/prisma/migrations/20230219104008_fix_timeentry_duration/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 0a14aba..6482292 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -31,6 +31,7 @@ model TimeEntry { description String startTime DateTime @default(now()) endTime DateTime? + duration Float? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt