feat: add importexport page with csv export
This commit is contained in:
parent
35adc7db03
commit
72df192ebc
|
|
@ -161,3 +161,28 @@ export function deleteTimeEntry({
|
|||
where: { id, userId }
|
||||
});
|
||||
}
|
||||
|
||||
export async function exportTimeEntries({ userId }: { userId: User['id'] }) {
|
||||
const entries = await prisma.timeEntry.findMany({
|
||||
where: {
|
||||
userId
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
description: true,
|
||||
startTime: true,
|
||||
endTime: true,
|
||||
createdAt: true,
|
||||
project: {
|
||||
select: {
|
||||
name: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return entries.map((entry) => ({
|
||||
...entry,
|
||||
project: entry.project?.name
|
||||
}));
|
||||
}
|
||||
|
|
|
|||
161
app/routes/importexport.tsx
Normal file
161
app/routes/importexport.tsx
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
import { useEffect, useMemo } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Paper,
|
||||
Text,
|
||||
Menu,
|
||||
ActionIcon,
|
||||
Pagination,
|
||||
NativeSelect,
|
||||
Group,
|
||||
useMantineTheme,
|
||||
Alert,
|
||||
ColorSwatch,
|
||||
Tabs
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
ActionArgs,
|
||||
json,
|
||||
LoaderArgs,
|
||||
MetaFunction,
|
||||
redirect
|
||||
} from '@remix-run/node';
|
||||
import {
|
||||
Form,
|
||||
Link,
|
||||
Outlet,
|
||||
useActionData,
|
||||
useCatch,
|
||||
useLoaderData,
|
||||
useSearchParams
|
||||
} from '@remix-run/react';
|
||||
import {
|
||||
AlertTriangle,
|
||||
Download,
|
||||
Edit3,
|
||||
Plus,
|
||||
Settings,
|
||||
Trash,
|
||||
Upload
|
||||
} from 'react-feather';
|
||||
import { requireUserId } from '~/session.server';
|
||||
import { getProjects } from '~/models/project.server';
|
||||
import { exportTimeEntries, getTimeEntries } from '~/models/timeEntry.server';
|
||||
import papaparse from 'papaparse';
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return {
|
||||
title: 'Import/Export | WorkTimer',
|
||||
description: 'Manage your projects. You must be logged in to do this.'
|
||||
};
|
||||
};
|
||||
|
||||
export async function action({ request, params }: ActionArgs) {
|
||||
const userId = await requireUserId(request);
|
||||
const formData = await request.formData();
|
||||
|
||||
const actionType = formData.get('type');
|
||||
|
||||
if (
|
||||
typeof actionType !== 'string' ||
|
||||
!['import', 'export'].includes(actionType)
|
||||
) {
|
||||
return json(
|
||||
{
|
||||
errors: {
|
||||
type: 'Invalid action type',
|
||||
data: null
|
||||
}
|
||||
},
|
||||
{
|
||||
status: 400
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (actionType === 'import') {
|
||||
const file = formData.get('file');
|
||||
|
||||
return json({}, { status: 200 });
|
||||
} else if (actionType === 'export') {
|
||||
const timeEntries = await exportTimeEntries({ userId });
|
||||
const csv = papaparse.unparse(timeEntries, {
|
||||
header: true
|
||||
});
|
||||
|
||||
return json(
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'text/csv',
|
||||
'Content-Disposition': 'attachment; filename="export.csv"'
|
||||
},
|
||||
body: 'hello world',
|
||||
errors: {}
|
||||
},
|
||||
{
|
||||
status: 200
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function loader({ request }: LoaderArgs) {
|
||||
const userId = await requireUserId(request);
|
||||
if (!userId) return redirect('/login');
|
||||
|
||||
return json({});
|
||||
}
|
||||
|
||||
export default function ImportExportPage() {
|
||||
const actionData = useActionData<typeof action>();
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const theme = useMantineTheme();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const tab = useMemo(() => {
|
||||
return searchParams.get('tab') || 'import';
|
||||
}, [searchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
setSearchParams({ tab });
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Tabs
|
||||
radius="sm"
|
||||
defaultValue="import"
|
||||
value={tab}
|
||||
onTabChange={(tab) => {
|
||||
setSearchParams({ tab: tab as string });
|
||||
}}
|
||||
>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="import" icon={<Upload size={14} />}>
|
||||
Import
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="export" icon={<Download size={14} />}>
|
||||
Export
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="import" pt="xs">
|
||||
<h2>Import CSV</h2>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="export" pt="xs">
|
||||
<h2>Export</h2>
|
||||
<p>Export all your time entries as CSV file</p>
|
||||
<Button
|
||||
component="a"
|
||||
href="/importexport/export.csv"
|
||||
download="work-timer-export.csv"
|
||||
leftIcon={<Download size={14} />}
|
||||
>
|
||||
Download CSV
|
||||
</Button>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
21
app/routes/importexport/export[.]csv.tsx
Normal file
21
app/routes/importexport/export[.]csv.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { json, LoaderArgs } from '@remix-run/node';
|
||||
import { requireUserId } from '~/session.server';
|
||||
import { exportTimeEntries } from '~/models/timeEntry.server';
|
||||
import papaparse from 'papaparse';
|
||||
|
||||
export async function loader({ request }: LoaderArgs) {
|
||||
const userId = await requireUserId(request);
|
||||
|
||||
const timeEntries = await exportTimeEntries({ userId });
|
||||
const csv = papaparse.unparse(timeEntries, {
|
||||
header: true
|
||||
});
|
||||
|
||||
return new Response(csv, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'text/csv',
|
||||
'Content-Disposition': 'attachment; filename="export.csv"'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -28,6 +28,7 @@
|
|||
"dayjs": "1.11.7",
|
||||
"isbot": "^3.6.5",
|
||||
"nprogress": "0.2.0",
|
||||
"papaparse": "5.3.2",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-feather": "2.0.10",
|
||||
|
|
@ -43,6 +44,7 @@
|
|||
"@types/bcryptjs": "^2.4.2",
|
||||
"@types/node": "^18.11.18",
|
||||
"@types/nprogress": "0.2.0",
|
||||
"@types/papaparse": "5.3.7",
|
||||
"@types/react": "^18.0.25",
|
||||
"@types/react-dom": "^18.0.8",
|
||||
"eslint": "^8.27.0",
|
||||
|
|
|
|||
12
yarn.lock
12
yarn.lock
|
|
@ -2390,6 +2390,13 @@
|
|||
resolved "https://registry.yarnpkg.com/@types/nprogress/-/nprogress-0.2.0.tgz#86c593682d4199212a0509cc3c4d562bbbd6e45f"
|
||||
integrity sha512-1cYJrqq9GezNFPsWTZpFut/d4CjpZqA0vhqDUPFWYKF1oIyBz5qnoYMzR+0C/T96t3ebLAC1SSnwrVOm5/j74A==
|
||||
|
||||
"@types/papaparse@5.3.7":
|
||||
version "5.3.7"
|
||||
resolved "https://registry.yarnpkg.com/@types/papaparse/-/papaparse-5.3.7.tgz#8d3bf9e62ac2897df596f49d9ca59a15451aa247"
|
||||
integrity sha512-f2HKmlnPdCvS0WI33WtCs5GD7X1cxzzS/aduaxSu3I7TbhWlENjSPs6z5TaB9K0J+BH1jbmqTaM+ja5puis4wg==
|
||||
dependencies:
|
||||
"@types/node" "*"
|
||||
|
||||
"@types/parse-json@^4.0.0":
|
||||
version "4.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0"
|
||||
|
|
@ -7528,6 +7535,11 @@ pako@~0.2.0:
|
|||
resolved "https://registry.yarnpkg.com/pako/-/pako-0.2.9.tgz#f3f7522f4ef782348da8161bad9ecfd51bf83a75"
|
||||
integrity sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==
|
||||
|
||||
papaparse@5.3.2:
|
||||
version "5.3.2"
|
||||
resolved "https://registry.yarnpkg.com/papaparse/-/papaparse-5.3.2.tgz#d1abed498a0ee299f103130a6109720404fbd467"
|
||||
integrity sha512-6dNZu0Ki+gyV0eBsFKJhYr+MdQYAzFUGlBMNj3GNrmHxmz1lfRa24CjFObPXtjcetlOv5Ad299MhIK0znp3afw==
|
||||
|
||||
parent-module@^1.0.0:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2"
|
||||
|
|
|
|||
Loading…
Reference in a new issue