Skip to content

Commit 76665ce

Browse files
committed
feat: implement CSV import
1 parent 446b91a commit 76665ce

5 files changed

Lines changed: 223 additions & 11 deletions

File tree

collection/app/(app)/CSVImport.tsx

Lines changed: 52 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
"use client";
22

3+
import { CSVFormValues, importCsv } from "@/lib/crud/importCsv";
4+
import { StatusReturn } from "@/lib/types";
5+
import { isValidAcademicYear } from "@docsoc/eactivities";
36
import {
47
Alert,
58
Badge,
@@ -19,8 +22,9 @@ import "@mantine/dropzone/styles.css";
1922
import { useForm } from "@mantine/form";
2023
import { useDisclosure } from "@mantine/hooks";
2124
import { RootItem } from "@prisma/client";
22-
import React from "react";
25+
import React, { useState, useTransition } from "react";
2326
import {
27+
FaCircleCheck,
2428
FaCircleXmark,
2529
FaDownload,
2630
FaTable,
@@ -29,22 +33,21 @@ import {
2933
} from "react-icons/fa6";
3034
import useSWR from "swr";
3135

32-
interface FormValues {
33-
csv: File[];
34-
productId: string;
35-
academicYear: string;
36-
}
37-
3836
interface CSVImportFormProp {
3937
/** Undefiend treated as loading UI */
4038
productsByAcademicYear: Record<string, RootItem[]>;
4139
}
4240

4341
const CSVImportForm: React.FC<CSVImportFormProp> = ({ productsByAcademicYear }) => {
44-
const form = useForm<FormValues>({
42+
const [isPending, startTransition] = useTransition();
43+
const [formState, setFormState] = useState<StatusReturn>({
44+
status: "pending",
45+
});
46+
const form = useForm<CSVFormValues>({
4547
mode: "controlled",
4648
initialValues: {
47-
productId: "",
49+
productId:
50+
productsByAcademicYear[Object.keys(productsByAcademicYear)[0]][0].id.toString(10),
4851
csv: [],
4952
academicYear: Object.keys(productsByAcademicYear)[0],
5053
},
@@ -76,9 +79,42 @@ const CSVImportForm: React.FC<CSVImportFormProp> = ({ productsByAcademicYear })
7679
</Badge>
7780
));
7881

82+
const formHandler = (values: CSVFormValues) => {
83+
const productId = parseInt(values.productId, 10);
84+
const academicYear = values.academicYear;
85+
if (!productId || !academicYear) {
86+
return;
87+
}
88+
89+
if (values.csv.length === 0) {
90+
form.setFieldError("csv", "Please select a CSV file to import.");
91+
return;
92+
}
93+
94+
if (!isValidAcademicYear(academicYear)) {
95+
form.setFieldError("academicYear", "Invalid academic year.");
96+
return;
97+
}
98+
99+
startTransition(async () => {
100+
const csvString = await values.csv[0].text();
101+
const res = await importCsv(csvString, productId, academicYear);
102+
setFormState(res);
103+
});
104+
};
105+
79106
return (
80-
<form>
107+
<form onSubmit={form.onSubmit(formHandler)}>
81108
<Stack gap="md">
109+
{formState.status === "success" ? (
110+
<Alert color="green" icon={<FaCircleCheck />} title="Success">
111+
{formState.message ?? "Data imported successfully"}
112+
</Alert>
113+
) : formState.status === "error" ? (
114+
<Alert color="red" icon={<FaCircleXmark />} title="Error">
115+
{formState.error}
116+
</Alert>
117+
) : null}
82118
<NativeSelect
83119
label="Academic year"
84120
name="academicYear"
@@ -165,7 +201,12 @@ const CSVImportForm: React.FC<CSVImportFormProp> = ({ productsByAcademicYear })
165201
{selectedFiles}
166202
</Stack>
167203
)}
168-
<Button type="submit" color="green" leftSection={<FaDownload />}>
204+
<Button
205+
type="submit"
206+
color="green"
207+
leftSection={<FaDownload />}
208+
loading={isPending}
209+
>
169210
Import data
170211
</Button>
171212
</Stack>

collection/lib/crud/importCsv.ts

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
"use server";
2+
3+
import { AcademicYear } from "@docsoc/eactivities";
4+
import { createLogger } from "@docsoc/util";
5+
import { parse } from "csv-parse";
6+
import { revalidatePath, revalidateTag } from "next/cache";
7+
8+
import prisma from "../db";
9+
import { StatusReturn } from "../types";
10+
11+
const logger = createLogger("collection.importCsv");
12+
13+
async function importFile(fileContents: string, itemId: number, academicYear: AcademicYear) {
14+
logger.info("Importing file for: ", itemId);
15+
16+
const iter = parse(fileContents, {
17+
columns: true,
18+
});
19+
20+
// 0: Prep academic year
21+
const academicYearDB = await prisma.academicYear.upsert({
22+
where: {
23+
year: academicYear,
24+
},
25+
update: {},
26+
create: {
27+
year: academicYear,
28+
},
29+
});
30+
31+
const academicYearReference = academicYearDB.year;
32+
33+
for await (const record of iter) {
34+
logger.debug(`Record: ${JSON.stringify(record)}`);
35+
36+
// 1: If user exists, get user
37+
const user = await prisma.imperialStudent.upsert({
38+
where: {
39+
shortcode: record["Login"],
40+
},
41+
update: {
42+
cid: record["CID/Card Number"],
43+
firstName: record["First Name"],
44+
lastName: record["Surname"],
45+
email: record["Email"],
46+
},
47+
create: {
48+
cid: record["CID/Card Number"],
49+
shortcode: record["Login"],
50+
firstName: record["First Name"],
51+
lastName: record["Surname"],
52+
email: record["Email"],
53+
},
54+
});
55+
56+
// 2: Create order
57+
// If already exists, skip
58+
const orderNo = parseInt(record["Order No"]);
59+
const dateString = record["Date"];
60+
const [day, month, year] = dateString.split("/");
61+
const isoDateString = `${year}-${month}-${day}`;
62+
const date = new Date(isoDateString);
63+
const order = await prisma.order.upsert({
64+
where: {
65+
orderNo: orderNo,
66+
},
67+
update: {},
68+
create: {
69+
orderDate: date,
70+
orderNo: orderNo,
71+
academicYearReference: {
72+
connect: {
73+
year: academicYearReference,
74+
},
75+
},
76+
ImperialStudent: {
77+
connect: {
78+
id: user.id,
79+
},
80+
},
81+
},
82+
});
83+
84+
// 3: Create variant if needed
85+
const variant = record["Product Name"];
86+
const quantity = parseInt(record["Quantity"]);
87+
88+
const varientDB = await prisma.variant.upsert({
89+
where: {
90+
variantName_rootItemId: {
91+
rootItemId: itemId,
92+
variantName: variant,
93+
},
94+
},
95+
update: {},
96+
create: {
97+
variantName: variant,
98+
RootItem: {
99+
connect: {
100+
id: itemId,
101+
},
102+
},
103+
},
104+
});
105+
106+
// Add order item
107+
// kip if this orderId already exists for this user & variant
108+
if (
109+
await prisma.orderItem.findFirst({
110+
where: {
111+
orderId: order.id,
112+
variantId: varientDB.id,
113+
},
114+
})
115+
) {
116+
continue;
117+
}
118+
119+
// Else, create order item
120+
await prisma.orderItem.create({
121+
data: {
122+
orderId: order.id,
123+
variantId: varientDB.id,
124+
quantity: quantity,
125+
collected: false,
126+
},
127+
});
128+
}
129+
}
130+
export interface CSVFormValues {
131+
csv: File[];
132+
productId: string;
133+
academicYear: string;
134+
}
135+
136+
export async function importCsv(
137+
fileContents: string,
138+
productId: number,
139+
academicYear: AcademicYear,
140+
): Promise<StatusReturn> {
141+
const product = await prisma.rootItem.findUnique({
142+
where: {
143+
id: productId,
144+
},
145+
});
146+
147+
try {
148+
await importFile(fileContents, productId, academicYear);
149+
} catch (e: any) {
150+
return {
151+
status: "error",
152+
error: e.message ?? e.toString(),
153+
};
154+
}
155+
156+
revalidatePath("/");
157+
revalidateTag("purchases:*");
158+
159+
return {
160+
status: "success",
161+
message: `Data imported for ${product?.name} in ${academicYear}`,
162+
};
163+
}

collection/lib/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
export type StatusReturn =
22
| {
33
status: "success";
4+
message?: string;
45
}
56
| {
67
status: "error";
78
error: string;
9+
}
10+
| {
11+
status: "pending";
812
};

package-lock.json

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,13 +71,15 @@
7171
],
7272
"dependencies": {
7373
"@docsoc/eactivities": "^1.2.1",
74+
"@docsoc/util": "^1.2.0",
7475
"@mantine/core": "^7.12.0",
7576
"@mantine/dropzone": "^7.12.1",
7677
"@mantine/form": "^7.12.0",
7778
"@mantine/hooks": "^7.12.0",
7879
"@prisma/client": "^5.18.0",
7980
"@tanstack/react-table": "^8.20.1",
8081
"axios": "^1.6.0",
82+
"csv-parse": "^5.5.6",
8183
"mkdirp": "^3.0.1",
8284
"next": "^14.2.5",
8385
"next-auth": "^5.0.0-beta.20",

0 commit comments

Comments
 (0)