Skip to content

Commit 619c465

Browse files
feat: brush up UI and add loading states
1 parent 5b294f0 commit 619c465

File tree

8 files changed

+423
-187
lines changed

8 files changed

+423
-187
lines changed

Diff for: bun.lockb

2.42 KB
Binary file not shown.

Diff for: package.json

+1
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
"@instructor-ai/instructor": "^1.3.0",
1414
"@radix-ui/react-label": "^2.0.2",
1515
"@radix-ui/react-slot": "^1.0.2",
16+
"@radix-ui/react-tabs": "^1.0.4",
1617
"@radix-ui/react-toast": "^1.1.5",
1718
"@tanstack/react-query": "^5.37.1",
1819
"@types/lodash": "^4.17.4",

Diff for: scripts/test.ts

+39
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
// import Instructor from "@instructor-ai/instructor";
2+
// import "dotenv/config";
3+
// import OpenAI from "openai";
4+
// import { z } from "zod";
5+
6+
import { z } from "zod";
7+
8+
// const oai = new OpenAI({
9+
// apiKey: process.env.OPENAI_API_KEY ?? undefined,
10+
// });
11+
12+
// const client = Instructor({
13+
// client: oai,
14+
// mode: "TOOLS",
15+
// });
16+
17+
// const UserSchema = z.object({
18+
// // Description will be used in the prompt
19+
// age: z.number().describe("The age of the user"),
20+
// name: z.string(),
21+
// });
22+
23+
// (async () => {
24+
// // User will be of type z.infer<typeof UserSchema>
25+
// const user = await client.chat.completions.create({
26+
// messages: [{ role: "user", content: "Jason Liu is 30 years old" }],
27+
// model: "gpt-3.5-turbo",
28+
// response_model: {
29+
// schema: UserSchema,
30+
// name: "User",
31+
// },
32+
// });
33+
// console.log(user);
34+
// })();
35+
36+
const myType = z.coerce.date();
37+
38+
console.log(myType.safeParse("2023-01-01"));
39+
console.log(myType.safeParse("2024-05-01T04:08:45"));

Diff for: src/app/layout.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ const inter = Inter({
1212
});
1313

1414
export const metadata: Metadata = {
15-
title: "Upload invoice or receipt",
15+
title: "Accountant GPT",
1616
description: "Bookkeeps your invoices and receipts",
1717
};
1818

Diff for: src/app/page.tsx

+152-111
Original file line numberDiff line numberDiff line change
@@ -1,149 +1,190 @@
11
"use client";
2-
import AccountingVoucher from "@/components/accounting-voucher";
2+
import { AccountingVoucher } from "@/components/accounting-voucher";
33
import { Button } from "@/components/ui/button";
4-
import { Input } from "@/components/ui/input";
4+
import { Card, CardContent, CardHeader } from "@/components/ui/card";
55
import { Label } from "@/components/ui/label";
6+
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
7+
import { UploadImageForm } from "@/components/upload-image-form";
68
import { useStream } from "@/components/use-stream";
7-
import { convertPageToPng } from "@/lib/pdf/convertPageToPng";
89
import { Base64Image, partialInvoiceVoucherSchema } from "@/lib/schemas";
910
import { cn } from "@/lib/utils";
1011
import { LucideLoader, LucideSparkles } from "lucide-react";
1112
import Image from "next/image";
12-
import * as pdfjsLib from "pdfjs-dist";
1313
import { useRef, useState } from "react";
1414

15-
pdfjsLib.GlobalWorkerOptions.workerSrc = `//cdnjs.cloudflare.com/ajax/libs/pdf.js/${pdfjsLib.version}/pdf.worker.min.js`;
16-
1715
export default function Home() {
18-
const fileInputRef = useRef<HTMLInputElement>(null);
16+
const [step, setStep] = useState<"step1" | "step2" | "step3">("step1");
1917
const [images, setImages] = useState<Array<Base64Image>>([]);
18+
const formRef = useRef<HTMLFormElement>(null);
2019

2120
const {
2221
retrieve,
2322
result: inoviceVoucher,
2423
isPending,
2524
error,
2625
reset,
27-
} = useStream(partialInvoiceVoucherSchema);
28-
29-
const handleFileChange = async (
30-
event: React.ChangeEvent<HTMLInputElement>
31-
) => {
32-
const currentFile = event.target.files?.[0] ?? "";
33-
34-
if (!currentFile) {
35-
return;
36-
}
37-
38-
const [filename] = currentFile.name.split(".");
39-
40-
if (currentFile.type === "application/pdf") {
41-
const arrayBuffer = await currentFile.arrayBuffer();
42-
const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
26+
} = useStream({
27+
streamFn: async (images: Array<Base64Image>, signal) => {
28+
const response = await fetch("/api/bookkeep", {
29+
method: "POST",
30+
body: JSON.stringify({
31+
images,
32+
}),
33+
headers: {
34+
"Content-Type": "application/json",
35+
},
36+
signal,
37+
});
4338

44-
const convertedImages = await Promise.all(
45-
Array.from({ length: pdf.numPages }, async (_, i) => {
46-
const base64Url = await convertPageToPng(pdf, i + 1);
47-
const [type, base64] = base64Url.split(",");
39+
if (!response.body) {
40+
throw new Error("Failed to generate prediction");
41+
}
4842

49-
return {
50-
name: `${filename}-page-${i + 1}.png`,
51-
base64,
52-
type,
53-
};
54-
})
55-
);
43+
if (!response.ok) {
44+
throw new Error(response.statusText);
45+
}
5646

57-
setImages(convertedImages);
58-
reset();
59-
} else {
60-
const reader = new FileReader();
61-
reader.onloadend = () => {
62-
const base64Url = reader.result as string;
63-
const [type, base64] = base64Url.split(",");
47+
return response.body.getReader();
48+
},
49+
resultSchema: partialInvoiceVoucherSchema,
50+
onSuccess: () => {
51+
console.log("Success");
52+
if (formRef.current) {
53+
formRef.current.reset();
54+
}
55+
setImages([]);
56+
},
57+
});
6458

65-
setImages([
66-
{
67-
name: currentFile.name,
68-
base64,
69-
type,
70-
},
71-
]);
72-
};
73-
reader.readAsDataURL(currentFile);
74-
}
75-
};
59+
const accountVoucherReady = Object.keys(inoviceVoucher).length > 0;
60+
const imagesUploaded = images.length > 0;
7661

77-
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
78-
e.preventDefault();
62+
function handleImagesUploaded(images: Array<Base64Image>) {
63+
setImages(images);
64+
reset();
65+
}
7966

67+
function handleSubmit() {
8068
if (images.length < 1) {
8169
return;
8270
}
8371

8472
retrieve(images);
85-
};
73+
setStep("step2");
74+
}
8675

8776
return (
88-
<main className="flex min-h-screen flex-col items-center justify-center p-8 gap-4">
89-
<h1 className="text-2xl">How do I account for my invoice or receipt?</h1>
90-
{Object.keys(inoviceVoucher).length > 0 && (
91-
<AccountingVoucher {...inoviceVoucher} />
92-
)}
93-
<form
94-
onSubmit={handleSubmit}
95-
className="flex flex-col items-center gap-4"
77+
<main className="flex flex-col justify-center items-center p-8 w-full min-h-screen">
78+
<Tabs
79+
value={step}
80+
onValueChange={(value) => setStep(value as "step1" | "step2" | "step3")}
81+
className="max-w-2xl justify-center items-center"
9682
>
97-
<div className="grid w-full max-w-sm items-center gap-2">
98-
<Label htmlFor="inoviceFile">
99-
Upload invoice or Receipt (PDF or Image)
100-
</Label>
101-
<Input
102-
name="file"
103-
id="file"
104-
type="file"
105-
accept="image/*, .pdf"
106-
ref={fileInputRef}
107-
onChange={handleFileChange}
108-
/>
109-
</div>
110-
{images.length > 0 && (
111-
<div
112-
className={cn(
113-
"grid",
114-
"gap-4",
115-
images.length > 1 ? "grid-cols-2" : "grid-cols-1"
116-
)}
83+
<TabsList className="flex items-center justify-center gap-4 bg-transparent">
84+
<TabsTrigger
85+
value="step1"
86+
className="data-[state=active]:bg-transparent"
11787
>
118-
{images.map((image) => (
119-
<div
120-
key={image.name}
121-
className="flex flex-col items-center justify-center aspect-a4"
122-
>
123-
<Image
124-
src={`${image.type},${image.base64}`}
125-
alt={image.name}
126-
width={200}
127-
height={200}
128-
/>
129-
<Label className="mt-2">{image.name}</Label>
88+
<div className="flex items-center gap-2">
89+
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-gray-900 text-sm font-medium text-white dark:bg-gray-50 dark:text-gray-900">
90+
1
91+
</div>
92+
<span className="text-sm font-medium text-gray-500 dark:text-gray-400">
93+
Upload invoice/receipt
94+
</span>
95+
</div>
96+
</TabsTrigger>
97+
<div className="h-px w-12 bg-gray-200 dark:bg-gray-700" />
98+
<TabsTrigger
99+
value="step2"
100+
className="data-[state=active]:bg-transparent"
101+
disabled={!imagesUploaded}
102+
>
103+
<div className="flex items-center gap-2">
104+
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-gray-900 text-sm font-medium text-white dark:bg-gray-50 dark:text-gray-900">
105+
2
130106
</div>
131-
))}
132-
</div>
133-
)}
134-
<Button
135-
type="submit"
136-
className="w-full"
137-
disabled={images.length < 1 || isPending}
138-
>
139-
{isPending ? (
140-
<LucideLoader className="animate-spin w-4 h-4" />
141-
) : (
142-
<LucideSparkles className="w-4 h-4" />
143-
)}
144-
<span className="ml-2">Record suggestion</span>
145-
</Button>
146-
</form>
107+
<span className="text-sm font-medium text-gray-500 dark:text-gray-400">
108+
Suggestion
109+
</span>
110+
</div>
111+
</TabsTrigger>
112+
<div className="h-px w-12 bg-gray-200 dark:bg-gray-700" />
113+
<TabsTrigger
114+
value="step3"
115+
className="data-[state=active]:bg-transparent"
116+
disabled={!accountVoucherReady}
117+
>
118+
<div className="flex items-center gap-2">
119+
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-gray-900 text-sm font-medium text-white dark:bg-gray-50 dark:text-gray-900">
120+
3
121+
</div>
122+
<span className="text-sm font-medium text-gray-500 dark:text-gray-400">
123+
Record
124+
</span>
125+
</div>
126+
</TabsTrigger>
127+
</TabsList>
128+
<TabsContent value="step1">
129+
<Card className="w-full">
130+
<CardHeader>
131+
<h1 className="text-lg font-medium text-center">
132+
How do I account for my invoice or receipt?
133+
</h1>
134+
</CardHeader>
135+
<CardContent>
136+
<UploadImageForm
137+
onImagesUploaded={handleImagesUploaded}
138+
onSubmit={handleSubmit}
139+
ref={formRef}
140+
>
141+
<Button
142+
type="submit"
143+
className="w-full"
144+
disabled={images.length < 1 || isPending}
145+
>
146+
{isPending ? (
147+
<LucideLoader className="animate-spin w-4 h-4" />
148+
) : (
149+
<LucideSparkles className="w-4 h-4" />
150+
)}
151+
<span className="ml-2">Record suggestion</span>
152+
</Button>
153+
</UploadImageForm>
154+
{images.length > 0 && (
155+
<div
156+
className={cn(
157+
"grid",
158+
"gap-4",
159+
"py-4",
160+
images.length > 1 ? "grid-cols-2" : "grid-cols-1"
161+
)}
162+
>
163+
{images.map((image) => (
164+
<div
165+
key={image.name}
166+
className="flex flex-col items-center justify-center"
167+
>
168+
<Image
169+
src={`${image.type},${image.base64}`}
170+
alt={image.name}
171+
width={200}
172+
height={200}
173+
className="aspect-a4"
174+
/>
175+
<Label className="mt-2">{image.name}</Label>
176+
</div>
177+
))}
178+
</div>
179+
)}
180+
</CardContent>
181+
</Card>
182+
</TabsContent>
183+
<TabsContent value="step2">
184+
{error && <div className="text-red-500">{error.message}</div>}
185+
{<AccountingVoucher data={inoviceVoucher} isPending={isPending} />}
186+
</TabsContent>
187+
</Tabs>
147188
</main>
148189
);
149190
}

0 commit comments

Comments
 (0)