2023-02-18 23:16:14 +01:00
|
|
|
import { useEffect, useMemo, useState } from 'react';
|
2023-02-18 21:42:16 +01:00
|
|
|
import {
|
|
|
|
|
Button,
|
|
|
|
|
Text,
|
|
|
|
|
Alert,
|
2023-02-18 23:16:14 +01:00
|
|
|
Tabs,
|
|
|
|
|
FileInput,
|
|
|
|
|
Loader,
|
2023-02-19 14:07:24 +01:00
|
|
|
Flex,
|
|
|
|
|
useMantineTheme
|
2023-02-18 21:42:16 +01:00
|
|
|
} from '@mantine/core';
|
|
|
|
|
import {
|
|
|
|
|
ActionArgs,
|
|
|
|
|
json,
|
|
|
|
|
LoaderArgs,
|
|
|
|
|
MetaFunction,
|
|
|
|
|
redirect
|
|
|
|
|
} from '@remix-run/node';
|
2023-02-18 23:16:14 +01:00
|
|
|
import { Form, Link, useActionData, useSearchParams } from '@remix-run/react';
|
|
|
|
|
import { CheckCircle, Download, Upload, XCircle } from 'react-feather';
|
2023-02-18 21:42:16 +01:00
|
|
|
import { requireUserId } from '~/session.server';
|
2023-02-18 23:16:14 +01:00
|
|
|
import { createProject, getProjectByName } from '~/models/project.server';
|
|
|
|
|
import { createTimeEntry } from '~/models/timeEntry.server';
|
2023-02-18 21:42:16 +01:00
|
|
|
import papaparse from 'papaparse';
|
2023-02-19 14:07:24 +01:00
|
|
|
import { randomColorName } from '~/utils';
|
2023-02-18 23:16:14 +01:00
|
|
|
|
2023-02-18 21:42:16 +01:00
|
|
|
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();
|
|
|
|
|
|
2023-02-18 23:16:14 +01:00
|
|
|
const fileData = formData.get('fileData');
|
2023-02-18 21:42:16 +01:00
|
|
|
|
2023-02-18 23:16:14 +01:00
|
|
|
if (typeof fileData !== 'string' || !fileData?.length) {
|
2023-02-18 21:42:16 +01:00
|
|
|
return json(
|
2023-02-18 23:16:14 +01:00
|
|
|
{ errors: { fileData: 'No file data' }, success: false },
|
|
|
|
|
{ status: 400 }
|
2023-02-18 21:42:16 +01:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2023-02-18 23:16:14 +01:00
|
|
|
const parsed = papaparse.parse(fileData, {
|
|
|
|
|
header: true,
|
|
|
|
|
skipEmptyLines: true
|
|
|
|
|
});
|
2023-02-18 21:42:16 +01:00
|
|
|
|
2023-02-18 23:16:14 +01:00
|
|
|
if (parsed.errors.length) {
|
|
|
|
|
return json(
|
|
|
|
|
{
|
|
|
|
|
errors: { fileData: parsed.errors[0].message },
|
|
|
|
|
success: false,
|
|
|
|
|
imported: 0
|
|
|
|
|
},
|
|
|
|
|
{ status: 400 }
|
|
|
|
|
);
|
|
|
|
|
}
|
2023-02-18 21:42:16 +01:00
|
|
|
|
2023-02-18 23:16:14 +01:00
|
|
|
const headers = parsed.meta.fields;
|
|
|
|
|
if (!headers?.includes('description')) {
|
2023-02-18 21:42:16 +01:00
|
|
|
return json(
|
|
|
|
|
{
|
2023-02-18 23:16:14 +01:00
|
|
|
errors: { fileData: 'Missing description column' },
|
|
|
|
|
success: false,
|
|
|
|
|
imported: 0
|
2023-02-18 21:42:16 +01:00
|
|
|
},
|
2023-02-18 23:16:14 +01:00
|
|
|
{ status: 400 }
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
if (!headers?.includes('startTime')) {
|
|
|
|
|
return json(
|
|
|
|
|
{
|
|
|
|
|
errors: { fileData: 'Missing startTime column' },
|
|
|
|
|
success: false,
|
|
|
|
|
imported: 0
|
|
|
|
|
},
|
|
|
|
|
{ status: 400 }
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
if (!headers?.includes('endTime')) {
|
|
|
|
|
return json(
|
2023-02-18 21:42:16 +01:00
|
|
|
{
|
2023-02-18 23:16:14 +01:00
|
|
|
errors: { fileData: 'Missing endTime column' },
|
|
|
|
|
success: false,
|
|
|
|
|
imported: 0
|
|
|
|
|
},
|
|
|
|
|
{ status: 400 }
|
2023-02-18 21:42:16 +01:00
|
|
|
);
|
|
|
|
|
}
|
2023-02-18 23:16:14 +01:00
|
|
|
if (!headers?.includes('project')) {
|
|
|
|
|
return json(
|
|
|
|
|
{
|
|
|
|
|
errors: { fileData: 'Missing project column' },
|
|
|
|
|
success: false,
|
|
|
|
|
imported: 0
|
|
|
|
|
},
|
|
|
|
|
{ status: 400 }
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const timeEntries = parsed.data.map<{
|
|
|
|
|
description: string;
|
|
|
|
|
startTime: string;
|
|
|
|
|
endTime?: string;
|
2023-02-19 14:07:24 +01:00
|
|
|
duration?: number;
|
2023-02-18 23:16:14 +01:00
|
|
|
projectId?: string;
|
|
|
|
|
projectName: string;
|
|
|
|
|
}>((row: any) => ({
|
|
|
|
|
description: row.description,
|
|
|
|
|
startTime: row.startTime,
|
|
|
|
|
endTime: row.endTime,
|
2023-02-19 14:07:24 +01:00
|
|
|
duration: row.duration,
|
2023-02-18 23:16:14 +01:00
|
|
|
projectId: undefined,
|
|
|
|
|
projectName: row.project
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
for (const timeEntry of timeEntries) {
|
|
|
|
|
const project = await getProjectByName({
|
|
|
|
|
userId,
|
|
|
|
|
name: timeEntry.projectName
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!project) {
|
|
|
|
|
const project = await createProject({
|
|
|
|
|
userId,
|
|
|
|
|
name: timeEntry.projectName,
|
|
|
|
|
description: null,
|
2023-02-19 14:07:24 +01:00
|
|
|
color: randomColorName()
|
2023-02-18 23:16:14 +01:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
timeEntry.projectId = project.id;
|
|
|
|
|
} else {
|
|
|
|
|
timeEntry.projectId = project.id;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await createTimeEntry({
|
|
|
|
|
userId,
|
|
|
|
|
projectId: timeEntry.projectId,
|
|
|
|
|
description: timeEntry.description,
|
|
|
|
|
startTime: new Date(timeEntry.startTime),
|
2023-02-19 14:07:24 +01:00
|
|
|
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
|
2023-02-18 23:16:14 +01:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return json(
|
|
|
|
|
{
|
|
|
|
|
errors: {
|
|
|
|
|
fileData: null
|
|
|
|
|
},
|
|
|
|
|
success: true,
|
|
|
|
|
imported: timeEntries.length
|
|
|
|
|
},
|
|
|
|
|
{ status: 200 }
|
|
|
|
|
);
|
2023-02-18 21:42:16 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 [searchParams, setSearchParams] = useSearchParams();
|
|
|
|
|
|
|
|
|
|
const tab = useMemo(() => {
|
|
|
|
|
return searchParams.get('tab') || 'import';
|
|
|
|
|
}, [searchParams]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
setSearchParams({ tab });
|
|
|
|
|
}, []);
|
|
|
|
|
|
2023-02-18 23:16:14 +01:00
|
|
|
const [csvData, setCsvData] = useState<string>();
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (actionData?.success) {
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
window.location.pathname = '/time-entries';
|
|
|
|
|
}, 3000);
|
|
|
|
|
}
|
|
|
|
|
}, [actionData?.success]);
|
|
|
|
|
|
|
|
|
|
const handleChangeFile = (file: File) => {
|
|
|
|
|
let reader: FileReader = new FileReader();
|
|
|
|
|
|
|
|
|
|
reader.onload = (_event: Event) => {
|
|
|
|
|
setCsvData(reader.result as string);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
reader.readAsText(file, 'UTF-8');
|
|
|
|
|
};
|
|
|
|
|
|
2023-02-18 21:42:16 +01:00
|
|
|
return (
|
|
|
|
|
<div>
|
2023-02-18 23:45:47 +01:00
|
|
|
<h1
|
|
|
|
|
style={{
|
|
|
|
|
position: 'absolute',
|
|
|
|
|
width: '1px',
|
|
|
|
|
height: '1px',
|
|
|
|
|
padding: 0,
|
|
|
|
|
margin: '-1px',
|
|
|
|
|
overflow: 'hidden',
|
|
|
|
|
clip: 'rect(0,0,0,0)',
|
|
|
|
|
whiteSpace: 'nowrap',
|
|
|
|
|
border: 0
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
Import/Export
|
|
|
|
|
</h1>
|
2023-02-18 21:42:16 +01:00
|
|
|
<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">
|
2023-02-18 23:16:14 +01:00
|
|
|
<h2>Import</h2>
|
|
|
|
|
<p>
|
|
|
|
|
Select a CSV file with the same format as the one you can download
|
|
|
|
|
from <Link to="?tab=export">Export</Link>
|
|
|
|
|
</p>
|
|
|
|
|
|
|
|
|
|
<FileInput
|
|
|
|
|
label="Upload CSV file"
|
|
|
|
|
placeholder="Upload CSV file"
|
|
|
|
|
accept=".csv, text/csv"
|
|
|
|
|
icon={<Upload size={14} />}
|
|
|
|
|
onChange={handleChangeFile}
|
|
|
|
|
aria-invalid={actionData?.errors?.fileData ? true : undefined}
|
|
|
|
|
error={actionData?.errors?.fileData}
|
|
|
|
|
errorProps={{ children: actionData?.errors?.fileData }}
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
<Form method="post" noValidate>
|
|
|
|
|
<input type="hidden" name="fileData" value={csvData} />
|
|
|
|
|
<Button
|
|
|
|
|
type="submit"
|
|
|
|
|
mt="md"
|
|
|
|
|
disabled={!csvData?.length || !!actionData?.success}
|
|
|
|
|
>
|
|
|
|
|
Import
|
|
|
|
|
</Button>
|
|
|
|
|
</Form>
|
|
|
|
|
|
|
|
|
|
{!!actionData?.success && (
|
|
|
|
|
<Alert
|
|
|
|
|
icon={<CheckCircle size={16} />}
|
|
|
|
|
title="Import successful"
|
|
|
|
|
color="green"
|
|
|
|
|
radius="md"
|
|
|
|
|
variant="light"
|
|
|
|
|
mt="md"
|
|
|
|
|
withCloseButton
|
|
|
|
|
closeButtonLabel="Close results"
|
|
|
|
|
>
|
|
|
|
|
Successfully imported time entries
|
|
|
|
|
<Flex mt="md" align="center">
|
|
|
|
|
<Loader size={16} />
|
|
|
|
|
<Text ml="sm">Redirecting to time entries...</Text>
|
|
|
|
|
</Flex>
|
|
|
|
|
</Alert>
|
|
|
|
|
)}
|
|
|
|
|
{!!actionData?.errors?.fileData && (
|
|
|
|
|
<Alert
|
|
|
|
|
icon={<XCircle size={16} />}
|
|
|
|
|
title="Error"
|
|
|
|
|
color="red"
|
|
|
|
|
radius="md"
|
|
|
|
|
variant="light"
|
|
|
|
|
mt="md"
|
|
|
|
|
withCloseButton
|
|
|
|
|
closeButtonLabel="Close results"
|
|
|
|
|
>
|
|
|
|
|
{actionData?.errors?.fileData}
|
|
|
|
|
</Alert>
|
|
|
|
|
)}
|
2023-02-18 21:42:16 +01:00
|
|
|
</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>
|
|
|
|
|
);
|
|
|
|
|
}
|