From 01ec528ee4c5ffc0c11f38039469db57c1cb2d2b Mon Sep 17 00:00:00 2001 From: nzambello Date: Wed, 1 Mar 2023 02:27:43 +0100 Subject: [PATCH] feat: add currency + hourly rate to user, fixes for import and reports --- app/routes/account.tsx | 73 ++++++++++++++----- app/routes/importexport.tsx | 5 +- app/routes/reports.tsx | 41 +++++++---- .../migration.sql | 3 + .../migration.sql | 9 +++ prisma/schema.prisma | 4 +- 6 files changed, 98 insertions(+), 37 deletions(-) create mode 100644 prisma/migrations/20230301010453_add_userprefs_currency_hourlyrate/migration.sql create mode 100644 prisma/migrations/20230301010640_rename_currency_userpref/migration.sql diff --git a/app/routes/account.tsx b/app/routes/account.tsx index a9b2c57..d0e49ef 100644 --- a/app/routes/account.tsx +++ b/app/routes/account.tsx @@ -21,7 +21,8 @@ import { Progress, Modal, Badge, - Select + Select, + NumberInput } from '@mantine/core'; import { AtSign, Check, Lock, Save, Trash, X } from 'react-feather'; import { requireUser } from '~/session.server'; @@ -42,6 +43,12 @@ export async function action({ request }: ActionArgs) { const dateFormat = (formData.get('dateFormat') || undefined) as | string | undefined; + const currency = (formData.get('currency') || undefined) as + | string + | undefined; + const defaultHourlyRate = (formData.get('defaultHourlyRate') || undefined) as + | string + | undefined; if (email && !validateEmail(email)) { return json( @@ -59,8 +66,19 @@ export async function action({ request }: ActionArgs) { await updateUserEmail(user.id, email); } - if (dateFormat && dateFormat !== user.dateFormat) { - await updateUserPrefs(user.id, { dateFormat }); + const prefs = { + dateFormat: + dateFormat && dateFormat !== user.dateFormat ? dateFormat : undefined, + currency: currency && currency !== user.currency ? currency : undefined, + defaultHourlyRate: + defaultHourlyRate && + parseInt(defaultHourlyRate || '-1', 10) !== user.defaultHourlyRate + ? parseInt(defaultHourlyRate, 10) + : undefined + }; + + if (Object.values(prefs).some((v) => v !== undefined)) { + await updateUserPrefs(user.id, prefs); } return redirect('/account/updatesuccess'); @@ -90,6 +108,11 @@ export default function Account() { loaderData.user.dateFormat ); + const [isHydrated, setIsHydrated] = React.useState(false); + React.useEffect(() => { + setIsHydrated(true); + }, []); + return ( @@ -188,23 +211,35 @@ export default function Account() { ])} /> - <p>Example:</p> - <blockquote> - {Intl.DateTimeFormat(dateFormat, { - dateStyle: 'full', - timeZone: 'UTC' - }).format(new Date(Date.now()))} - <br /> - {Intl.DateTimeFormat(dateFormat, { - day: '2-digit', - month: '2-digit', - year: 'numeric', - hour: '2-digit', - minute: '2-digit' - }).format(new Date(Date.now()))} - </blockquote> + {isHydrated && ( + <p> + Example:{' '} + {Intl.DateTimeFormat(dateFormat, { + dateStyle: 'full', + timeStyle: 'short', + timeZone: 'UTC' + }).format(new Date(Date.now()))} + </p> + )} - <Group position="center" mt="sm"> + <TextInput + name="currency" + label="Currency" + placeholder="Select your currency" + defaultValue={loaderData.user.currency} + mb="lg" + /> + + <NumberInput + name="defaultHourlyRate" + label="Hourly rate" + placeholder="Enter your hourly rate" + defaultValue={loaderData.user.defaultHourlyRate || undefined} + min={0} + mb="lg" + /> + + <Group position="center" mt="lg"> <Button type="submit" leftIcon={<Save size={14} />}> Save </Button> diff --git a/app/routes/importexport.tsx b/app/routes/importexport.tsx index 758e6e9..dd8f6de 100644 --- a/app/routes/importexport.tsx +++ b/app/routes/importexport.tsx @@ -46,7 +46,8 @@ export async function action({ request, params }: ActionArgs) { const parsed = papaparse.parse(fileData, { header: true, - skipEmptyLines: true + skipEmptyLines: true, + dynamicTyping: true }); if (parsed.errors.length) { @@ -145,7 +146,7 @@ export async function action({ request, params }: ActionArgs) { endTime: timeEntry.endTime ? new Date(timeEntry.endTime) : null, duration: timeEntry.duration ? timeEntry.duration - : timeEntry.endTime + : timeEntry.endTime && timeEntry.startTime ? new Date(timeEntry.endTime).getTime() - new Date(timeEntry.startTime).getTime() : null diff --git a/app/routes/reports.tsx b/app/routes/reports.tsx index 5163f92..18e69c8 100644 --- a/app/routes/reports.tsx +++ b/app/routes/reports.tsx @@ -17,7 +17,7 @@ import { updateDuration } from '~/models/timeEntry.server'; import { getProjects, Project } from '~/models/project.server'; -import { requireUserId } from '~/session.server'; +import { requireUser } from '~/session.server'; import { DateRangePicker, DateRangePickerValue } from '@mantine/dates'; import { useEffect, useState } from 'react'; import dayjs from 'dayjs'; @@ -35,8 +35,8 @@ export const meta: MetaFunction = () => { }; export async function loader({ request }: LoaderArgs) { - const userId = await requireUserId(request); - if (!userId) return redirect('/login'); + const user = await requireUser(request); + if (!user) return redirect('/login'); const url = new URL(request.url); const dateFrom = url.searchParams.get('dateFrom') @@ -46,19 +46,21 @@ export async function loader({ request }: LoaderArgs) { ? dayjs(url.searchParams.get('dateTo')).endOf('day').toDate() : dayjs().endOf('month').endOf('day').toDate(); - await updateDuration(userId); + await updateDuration(user.id); return json({ + user, timeByProject: await getTimeEntriesByDateAndProject({ - userId, + userId: user.id, dateFrom, dateTo }), - projects: await getProjects({ userId }) + projects: await getProjects({ userId: user.id }) }); } export default function ReportPage() { + const data = useLoaderData<typeof loader>(); const reports = useFetcher<typeof loader>(); const [dateRange, setDateRange] = useState<DateRangePickerValue>([ @@ -76,7 +78,9 @@ export default function ReportPage() { } }, [dateRange]); - const [costPerHour, setCostPerHour] = useState<number>(); + const [hourlyRate, setHourlyRate] = useState<number | undefined>( + data.user.defaultHourlyRate || undefined + ); const mobile = useMediaQuery('(max-width: 600px)'); @@ -99,7 +103,14 @@ export default function ReportPage() { </h1> <reports.Form action="/reports" method="get"> - <Paper p="sm" shadow="sm" radius="md" component="fieldset"> + <Paper + p="sm" + aria-controls="time-entries" + shadow="sm" + radius="md" + withBorder + component="fieldset" + > <DateRangePicker label="Select date range" placeholder="Pick dates range" @@ -206,9 +217,9 @@ export default function ReportPage() { <Box mt="md" maw={300}> <NumberInput - label="Cost per hour" - value={costPerHour} - onChange={setCostPerHour} + label="Hourly rate" + value={hourlyRate || data.user.defaultHourlyRate || undefined} + onChange={setHourlyRate} /> </Box> @@ -218,7 +229,7 @@ export default function ReportPage() { <tr> <th>Project</th> <th>Time</th> - {costPerHour && <th>Billing</th>} + {hourlyRate && <th>Billing</th>} </tr> </thead> <tbody> @@ -247,15 +258,15 @@ export default function ReportPage() { <td> {(projectData._sum.duration / 1000 / 60 / 60).toFixed(2)} h </td> - {costPerHour && ( + {hourlyRate && ( <td> {( - (projectData._sum.duration * costPerHour) / + (projectData._sum.duration * hourlyRate) / 1000 / 60 / 60 ).toFixed(2)}{' '} - € + {reports.data?.user?.currency ?? '€'} </td> )} </tr> diff --git a/prisma/migrations/20230301010453_add_userprefs_currency_hourlyrate/migration.sql b/prisma/migrations/20230301010453_add_userprefs_currency_hourlyrate/migration.sql new file mode 100644 index 0000000..c9261eb --- /dev/null +++ b/prisma/migrations/20230301010453_add_userprefs_currency_hourlyrate/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "defaultCurrency" TEXT NOT NULL DEFAULT '€', +ADD COLUMN "defaultHourlyRate" DOUBLE PRECISION; diff --git a/prisma/migrations/20230301010640_rename_currency_userpref/migration.sql b/prisma/migrations/20230301010640_rename_currency_userpref/migration.sql new file mode 100644 index 0000000..1fce1c7 --- /dev/null +++ b/prisma/migrations/20230301010640_rename_currency_userpref/migration.sql @@ -0,0 +1,9 @@ +/* + Warnings: + + - You are about to drop the column `defaultCurrency` on the `User` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "User" DROP COLUMN "defaultCurrency", +ADD COLUMN "currency" TEXT NOT NULL DEFAULT '€'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index dbd4e0c..891e4fa 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -12,7 +12,9 @@ model User { email String @unique admin Boolean @default(false) - dateFormat String @default("en-GB") + dateFormat String @default("en-GB") + currency String @default("€") + defaultHourlyRate Float? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt