feat: add currency + hourly rate to user, fixes for import and reports
This commit is contained in:
parent
1a57580b6e
commit
01ec528ee4
|
|
@ -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>
|
||||||
|
Example:{' '}
|
||||||
{Intl.DateTimeFormat(dateFormat, {
|
{Intl.DateTimeFormat(dateFormat, {
|
||||||
dateStyle: 'full',
|
dateStyle: 'full',
|
||||||
|
timeStyle: 'short',
|
||||||
timeZone: 'UTC'
|
timeZone: 'UTC'
|
||||||
}).format(new Date(Date.now()))}
|
}).format(new Date(Date.now()))}
|
||||||
<br />
|
</p>
|
||||||
{Intl.DateTimeFormat(dateFormat, {
|
)}
|
||||||
day: '2-digit',
|
|
||||||
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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "User" ADD COLUMN "defaultCurrency" TEXT NOT NULL DEFAULT '€',
|
||||||
|
ADD COLUMN "defaultHourlyRate" DOUBLE PRECISION;
|
||||||
|
|
@ -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 '€';
|
||||||
|
|
@ -13,6 +13,8 @@ model User {
|
||||||
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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue