feat: add currency + hourly rate to user, fixes for import and reports

This commit is contained in:
Nicola Zambello 2023-03-01 02:27:43 +01:00
parent 1a57580b6e
commit 01ec528ee4
Signed by: nzambello
GPG key ID: 56E4A92C2C1E50BA
6 changed files with 98 additions and 37 deletions

View file

@ -21,7 +21,8 @@ import {
Progress, Progress,
Modal, Modal,
Badge, Badge,
Select Select,
NumberInput
} from '@mantine/core'; } from '@mantine/core';
import { AtSign, Check, Lock, Save, Trash, X } from 'react-feather'; import { AtSign, Check, Lock, Save, Trash, X } from 'react-feather';
import { requireUser } from '~/session.server'; import { requireUser } from '~/session.server';
@ -42,6 +43,12 @@ export async function action({ request }: ActionArgs) {
const dateFormat = (formData.get('dateFormat') || undefined) as const dateFormat = (formData.get('dateFormat') || undefined) as
| string | string
| undefined; | undefined;
const currency = (formData.get('currency') || undefined) as
| string
| undefined;
const defaultHourlyRate = (formData.get('defaultHourlyRate') || undefined) as
| string
| undefined;
if (email && !validateEmail(email)) { if (email && !validateEmail(email)) {
return json( return json(
@ -59,8 +66,19 @@ export async function action({ request }: ActionArgs) {
await updateUserEmail(user.id, email); await updateUserEmail(user.id, email);
} }
if (dateFormat && dateFormat !== user.dateFormat) { const prefs = {
await updateUserPrefs(user.id, { dateFormat }); 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'); return redirect('/account/updatesuccess');
@ -90,6 +108,11 @@ export default function Account() {
loaderData.user.dateFormat loaderData.user.dateFormat
); );
const [isHydrated, setIsHydrated] = React.useState(false);
React.useEffect(() => {
setIsHydrated(true);
}, []);
return ( return (
<Box sx={{ maxWidth: 300 }} mx="auto"> <Box sx={{ maxWidth: 300 }} mx="auto">
<Title order={2} my="lg"> <Title order={2} my="lg">
@ -188,23 +211,35 @@ export default function Account() {
])} ])}
/> />
<p>Example:</p> {isHydrated && (
<blockquote> <p>
{Intl.DateTimeFormat(dateFormat, { Example:{' '}
dateStyle: 'full', {Intl.DateTimeFormat(dateFormat, {
timeZone: 'UTC' dateStyle: 'full',
}).format(new Date(Date.now()))} timeStyle: 'short',
<br /> timeZone: 'UTC'
{Intl.DateTimeFormat(dateFormat, { }).format(new Date(Date.now()))}
day: '2-digit', </p>
month: '2-digit', )}
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
}).format(new Date(Date.now()))}
</blockquote>
<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} />}> <Button type="submit" leftIcon={<Save size={14} />}>
Save Save
</Button> </Button>

View file

@ -46,7 +46,8 @@ export async function action({ request, params }: ActionArgs) {
const parsed = papaparse.parse(fileData, { const parsed = papaparse.parse(fileData, {
header: true, header: true,
skipEmptyLines: true skipEmptyLines: true,
dynamicTyping: true
}); });
if (parsed.errors.length) { if (parsed.errors.length) {
@ -145,7 +146,7 @@ export async function action({ request, params }: ActionArgs) {
endTime: timeEntry.endTime ? new Date(timeEntry.endTime) : null, endTime: timeEntry.endTime ? new Date(timeEntry.endTime) : null,
duration: timeEntry.duration duration: timeEntry.duration
? timeEntry.duration ? timeEntry.duration
: timeEntry.endTime : timeEntry.endTime && timeEntry.startTime
? new Date(timeEntry.endTime).getTime() - ? new Date(timeEntry.endTime).getTime() -
new Date(timeEntry.startTime).getTime() new Date(timeEntry.startTime).getTime()
: null : null

View file

@ -17,7 +17,7 @@ import {
updateDuration updateDuration
} from '~/models/timeEntry.server'; } from '~/models/timeEntry.server';
import { getProjects, Project } from '~/models/project.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 { DateRangePicker, DateRangePickerValue } from '@mantine/dates';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
@ -35,8 +35,8 @@ export const meta: MetaFunction = () => {
}; };
export async function loader({ request }: LoaderArgs) { export async function loader({ request }: LoaderArgs) {
const userId = await requireUserId(request); const user = await requireUser(request);
if (!userId) return redirect('/login'); if (!user) return redirect('/login');
const url = new URL(request.url); const url = new URL(request.url);
const dateFrom = url.searchParams.get('dateFrom') 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(url.searchParams.get('dateTo')).endOf('day').toDate()
: dayjs().endOf('month').endOf('day').toDate(); : dayjs().endOf('month').endOf('day').toDate();
await updateDuration(userId); await updateDuration(user.id);
return json({ return json({
user,
timeByProject: await getTimeEntriesByDateAndProject({ timeByProject: await getTimeEntriesByDateAndProject({
userId, userId: user.id,
dateFrom, dateFrom,
dateTo dateTo
}), }),
projects: await getProjects({ userId }) projects: await getProjects({ userId: user.id })
}); });
} }
export default function ReportPage() { export default function ReportPage() {
const data = useLoaderData<typeof loader>();
const reports = useFetcher<typeof loader>(); const reports = useFetcher<typeof loader>();
const [dateRange, setDateRange] = useState<DateRangePickerValue>([ const [dateRange, setDateRange] = useState<DateRangePickerValue>([
@ -76,7 +78,9 @@ export default function ReportPage() {
} }
}, [dateRange]); }, [dateRange]);
const [costPerHour, setCostPerHour] = useState<number>(); const [hourlyRate, setHourlyRate] = useState<number | undefined>(
data.user.defaultHourlyRate || undefined
);
const mobile = useMediaQuery('(max-width: 600px)'); const mobile = useMediaQuery('(max-width: 600px)');
@ -99,7 +103,14 @@ export default function ReportPage() {
</h1> </h1>
<reports.Form action="/reports" method="get"> <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 <DateRangePicker
label="Select date range" label="Select date range"
placeholder="Pick dates range" placeholder="Pick dates range"
@ -206,9 +217,9 @@ export default function ReportPage() {
<Box mt="md" maw={300}> <Box mt="md" maw={300}>
<NumberInput <NumberInput
label="Cost per hour" label="Hourly rate"
value={costPerHour} value={hourlyRate || data.user.defaultHourlyRate || undefined}
onChange={setCostPerHour} onChange={setHourlyRate}
/> />
</Box> </Box>
@ -218,7 +229,7 @@ export default function ReportPage() {
<tr> <tr>
<th>Project</th> <th>Project</th>
<th>Time</th> <th>Time</th>
{costPerHour && <th>Billing</th>} {hourlyRate && <th>Billing</th>}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -247,15 +258,15 @@ export default function ReportPage() {
<td> <td>
{(projectData._sum.duration / 1000 / 60 / 60).toFixed(2)} h {(projectData._sum.duration / 1000 / 60 / 60).toFixed(2)} h
</td> </td>
{costPerHour && ( {hourlyRate && (
<td> <td>
{( {(
(projectData._sum.duration * costPerHour) / (projectData._sum.duration * hourlyRate) /
1000 / 1000 /
60 / 60 /
60 60
).toFixed(2)}{' '} ).toFixed(2)}{' '}
{reports.data?.user?.currency ?? '€'}
</td> </td>
)} )}
</tr> </tr>

View file

@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "defaultCurrency" TEXT NOT NULL DEFAULT '',
ADD COLUMN "defaultHourlyRate" DOUBLE PRECISION;

View file

@ -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 '';

View file

@ -12,7 +12,9 @@ model User {
email String @unique email String @unique
admin Boolean @default(false) admin Boolean @default(false)
dateFormat String @default("en-GB") dateFormat String @default("en-GB")
currency String @default("€")
defaultHourlyRate Float?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt