Skip to content

Commit 207962f

Browse files
committed
Added importing from CSV file
1 parent b3d8317 commit 207962f

12 files changed

+738
-134
lines changed

app/components/csv-drop-zone.tsx

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/* eslint-disable @typescript-eslint/no-explicit-any */
2+
import { useState } from "react";
3+
import { useCSVReader } from "react-papaparse";
4+
5+
import { Import } from "lucide-react";
6+
import { Button } from "~/components/ui/button";
7+
8+
export function CSVDropZone({ onData }: { onData: any }) {
9+
const { CSVReader } = useCSVReader();
10+
const [zoneHover, setZoneHover] = useState(false);
11+
12+
// @todo investigate if the onClick from getRemoveFileProps can be propagated upwards to reset the import table
13+
14+
return (
15+
<CSVReader
16+
config={{
17+
header: true,
18+
}}
19+
onUploadAccepted={(results: any) => {
20+
// @todo lower-case (or normalize) column headers (property names)
21+
onData(results.data);
22+
setZoneHover(false);
23+
}}
24+
onDragOver={(event: DragEvent) => {
25+
event.preventDefault();
26+
setZoneHover(true);
27+
}}
28+
onDragLeave={(event: DragEvent) => {
29+
event.preventDefault();
30+
setZoneHover(false);
31+
}}
32+
>
33+
{({ getRootProps, acceptedFile, getRemoveFileProps }: any) => (
34+
<div
35+
{...getRootProps()}
36+
className={`flex border ${zoneHover ? "border-black border-double" : "border-dashed"} p-8 gap-2 rounded-lg bg-card text-card-foreground items-center justify-center`}
37+
>
38+
{acceptedFile ? (
39+
<>
40+
{acceptedFile.name}{" "}
41+
<Button {...getRemoveFileProps()} size="sm" variant="outline">
42+
Remove
43+
</Button>
44+
</>
45+
) : (
46+
<>
47+
<Import /> Drag and drop a CSV file here or click to select
48+
</>
49+
)}
50+
</div>
51+
)}
52+
</CSVReader>
53+
);
54+
}

app/components/task-runner.tsx

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
/* eslint-disable @typescript-eslint/no-explicit-any */
2+
import { useState, useEffect } from "react";
3+
import { Play, Pause, ListRestart } from "lucide-react";
4+
import { Button } from "~/components/ui/button";
5+
6+
type TaskRunnerProps = {
7+
items: Array<unknown>;
8+
itemLabel: string;
9+
startLabel: string;
10+
confirmLabel: string;
11+
onRunTask: (item: any, index: number) => Promise<unknown>;
12+
onFinish?: () => void;
13+
onReset?: () => void;
14+
onError?: (error: Error) => void;
15+
//tooltip?: unknown;
16+
parallel?: number;
17+
};
18+
19+
export function TaskRunner({
20+
items,
21+
itemLabel,
22+
startLabel,
23+
confirmLabel,
24+
onRunTask,
25+
onFinish = () => {},
26+
onReset = () => {},
27+
onError = () => {},
28+
//tooltip = null,
29+
parallel = 4,
30+
}: TaskRunnerProps) {
31+
const [current, setCurrent] = useState(0);
32+
const [done, setDone] = useState(0);
33+
const [pending, setPending] = useState(0);
34+
const [isRunning, setIsRunning] = useState(false);
35+
36+
const onPlayPause = () => {
37+
if (!isRunning) {
38+
// @todo replace window.confirm with a nice UI or modal
39+
if (window.confirm(confirmLabel)) {
40+
setIsRunning(true);
41+
}
42+
} else {
43+
setIsRunning(false);
44+
}
45+
};
46+
47+
const handleReset = () => {
48+
setIsRunning(false);
49+
setCurrent(0);
50+
setDone(0);
51+
setPending(0);
52+
if (onReset) onReset();
53+
};
54+
55+
useEffect(() => {
56+
if (isRunning && current < items.length && pending < parallel) {
57+
onRunTask(items[current], current)
58+
.then(() => {
59+
setPending((pending) => (pending > 0 ? pending - 1 : 0));
60+
setDone((done) => done + 1);
61+
})
62+
.catch((error: Error) => {
63+
setIsRunning(false);
64+
if (onError) {
65+
onError(error);
66+
} else {
67+
console.error(error);
68+
}
69+
});
70+
71+
setPending((pending) => pending + 1);
72+
setCurrent((current) => current + 1);
73+
}
74+
75+
if (done === items.length) {
76+
setIsRunning(false);
77+
if (onFinish) onFinish();
78+
}
79+
}, [
80+
isRunning,
81+
onRunTask,
82+
current,
83+
items,
84+
pending,
85+
parallel,
86+
done,
87+
onError,
88+
onFinish,
89+
]);
90+
91+
return (
92+
<div className="flex gap-2 items-center">
93+
<Button onClick={onPlayPause} disabled={items.length === 0}>
94+
{isRunning ? (
95+
<>
96+
<Pause className="mr-2 h-4 w-4" />
97+
{startLabel ?? "Pause"}
98+
</>
99+
) : (
100+
<>
101+
<Play className="mr-2 h-4 w-4" />
102+
{startLabel ?? "Start"}
103+
</>
104+
)}
105+
</Button>
106+
<Button onClick={handleReset} variant="outline">
107+
<ListRestart className="mr-2 h-4 w-4" /> Reset
108+
</Button>
109+
<div className="grow"></div>
110+
<div className="text-sm">
111+
{items && `${done} of ${items.length} ${itemLabel} done`}{" "}
112+
</div>
113+
</div>
114+
);
115+
}

app/components/ui/badge.tsx

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import * as React from "react"
2+
import { cva, type VariantProps } from "class-variance-authority"
3+
4+
import { cn } from "~/lib/utils"
5+
6+
const badgeVariants = cva(
7+
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
8+
{
9+
variants: {
10+
variant: {
11+
default:
12+
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
13+
secondary:
14+
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
15+
destructive:
16+
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
17+
outline: "text-foreground font-light",
18+
},
19+
},
20+
defaultVariants: {
21+
variant: "default",
22+
},
23+
}
24+
)
25+
26+
export interface BadgeProps
27+
extends React.HTMLAttributes<HTMLDivElement>,
28+
VariantProps<typeof badgeVariants> {}
29+
30+
function Badge({ className, variant, ...props }: BadgeProps) {
31+
return (
32+
<div className={cn(badgeVariants({ variant }), className)} {...props} />
33+
)
34+
}
35+
36+
export { Badge, badgeVariants }

app/components/ui/table.tsx

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
/* eslint-disable react/prop-types */
2+
import * as React from "react"
3+
4+
import { cn } from "~/lib/utils"
5+
6+
const Table = React.forwardRef<
7+
HTMLTableElement,
8+
React.HTMLAttributes<HTMLTableElement>
9+
>(({ className, ...props }, ref) => (
10+
<div className="relative w-full overflow-auto">
11+
<table
12+
ref={ref}
13+
className={cn("w-full caption-bottom text-sm", className)}
14+
{...props}
15+
/>
16+
</div>
17+
))
18+
Table.displayName = "Table"
19+
20+
const TableHeader = React.forwardRef<
21+
HTMLTableSectionElement,
22+
React.HTMLAttributes<HTMLTableSectionElement>
23+
>(({ className, ...props }, ref) => (
24+
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
25+
))
26+
TableHeader.displayName = "TableHeader"
27+
28+
const TableBody = React.forwardRef<
29+
HTMLTableSectionElement,
30+
React.HTMLAttributes<HTMLTableSectionElement>
31+
>(({ className, ...props }, ref) => (
32+
<tbody
33+
ref={ref}
34+
className={cn("[&_tr:last-child]:border-0", className)}
35+
{...props}
36+
/>
37+
))
38+
TableBody.displayName = "TableBody"
39+
40+
const TableFooter = React.forwardRef<
41+
HTMLTableSectionElement,
42+
React.HTMLAttributes<HTMLTableSectionElement>
43+
>(({ className, ...props }, ref) => (
44+
<tfoot
45+
ref={ref}
46+
className={cn(
47+
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
48+
className
49+
)}
50+
{...props}
51+
/>
52+
))
53+
TableFooter.displayName = "TableFooter"
54+
55+
const TableRow = React.forwardRef<
56+
HTMLTableRowElement,
57+
React.HTMLAttributes<HTMLTableRowElement>
58+
>(({ className, ...props }, ref) => (
59+
<tr
60+
ref={ref}
61+
className={cn(
62+
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
63+
className
64+
)}
65+
{...props}
66+
/>
67+
))
68+
TableRow.displayName = "TableRow"
69+
70+
const TableHead = React.forwardRef<
71+
HTMLTableCellElement,
72+
React.ThHTMLAttributes<HTMLTableCellElement>
73+
>(({ className, ...props }, ref) => (
74+
<th
75+
ref={ref}
76+
className={cn(
77+
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
78+
className
79+
)}
80+
{...props}
81+
/>
82+
))
83+
TableHead.displayName = "TableHead"
84+
85+
const TableCell = React.forwardRef<
86+
HTMLTableCellElement,
87+
React.TdHTMLAttributes<HTMLTableCellElement>
88+
>(({ className, ...props }, ref) => (
89+
<td
90+
ref={ref}
91+
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
92+
{...props}
93+
/>
94+
))
95+
TableCell.displayName = "TableCell"
96+
97+
const TableCaption = React.forwardRef<
98+
HTMLTableCaptionElement,
99+
React.HTMLAttributes<HTMLTableCaptionElement>
100+
>(({ className, ...props }, ref) => (
101+
<caption
102+
ref={ref}
103+
className={cn("mt-4 text-sm text-muted-foreground", className)}
104+
{...props}
105+
/>
106+
))
107+
TableCaption.displayName = "TableCaption"
108+
109+
export {
110+
Table,
111+
TableHeader,
112+
TableBody,
113+
TableFooter,
114+
TableHead,
115+
TableRow,
116+
TableCell,
117+
TableCaption,
118+
}

app/routes/api.import.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import type { ActionFunction } from "@remix-run/node";
2+
import { json } from "@remix-run/node";
3+
4+
import { requireUserId } from "~/lib/auth.server";
5+
import { prisma } from "~/lib/prisma.server";
6+
7+
export const action: ActionFunction = async ({ request }) => {
8+
// @todo require admin user
9+
await requireUserId(request);
10+
11+
const formData = await request.formData();
12+
const inputs = Object.fromEntries(formData);
13+
14+
// @todo validate inputs? (or rely on Prisma internal validation?)
15+
16+
// If this email exists already for this batch, update instead of create
17+
const certificate = await prisma.certificate.upsert({
18+
where: {
19+
certId: {
20+
email: inputs.email,
21+
batchId: Number(inputs.batchId),
22+
},
23+
},
24+
update: {
25+
firstName: inputs.firstName,
26+
lastName: inputs.lastName,
27+
// @todo team, track
28+
},
29+
create: {
30+
firstName: inputs.firstName,
31+
lastName: inputs.lastName,
32+
email: inputs.email,
33+
batch: {
34+
connect: { id: Number(inputs.batchId) },
35+
},
36+
},
37+
});
38+
39+
// @todo error handling for Prisma create
40+
41+
console.log("Created", certificate);
42+
43+
return json({ certificate });
44+
};

app/routes/org._index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ export const loader: LoaderFunction = async ({ request }) => {
2626
return json({ programs });
2727
};
2828

29-
export default function Index() {
29+
export default function OrgIndex() {
3030
const { programs } = useLoaderData<typeof loader>();
3131

3232
return (

0 commit comments

Comments
 (0)