Skip to content

Commit b6a0172

Browse files
committed
Merge remote-tracking branch 'base/main'
2 parents 0009b02 + 4a9bf57 commit b6a0172

9 files changed

+666
-165
lines changed

package-lock.json

+290-149
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

+1
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
"next-themes": "^0.2.1",
5050
"next13-progressbar": "^1.1.1",
5151
"octokit": "^3.1.2",
52+
"openai": "^4.25.0",
5253
"pg": "^8.11.3",
5354
"react": "^18.2.0",
5455
"react-dom": "^18.2.0",
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
'use server'
2+
import { getCategories } from '@/lib/api'
3+
import { env } from '@/lib/env'
4+
import OpenAI from 'openai'
5+
6+
const openai = new OpenAI({ apiKey: env.OPENAI_API_KEY })
7+
8+
export async function extractExpenseInformationFromImage(imageUrl: string) {
9+
'use server'
10+
const categories = await getCategories()
11+
12+
const body = {
13+
model: 'gpt-4-vision-preview',
14+
messages: [
15+
{
16+
role: 'user',
17+
content: [
18+
{
19+
type: 'text',
20+
text: `
21+
This image contains a receipt.
22+
Read the total amount and store it as a non-formatted number without any other text or currency.
23+
Then guess the category for this receipt amoung the following categories and store its ID: ${categories.map(
24+
({ id, grouping, name }) => `"${grouping}/${name}" (ID: ${id})`,
25+
)}.
26+
Guess the expense’s date and store it as yyyy-mm-dd.
27+
Guess a title for the expense.
28+
Return the amount, the category, the date and the title with just a comma between them, without anything else.`,
29+
},
30+
],
31+
},
32+
{
33+
role: 'user',
34+
content: [{ type: 'image_url', image_url: { url: imageUrl } }],
35+
},
36+
],
37+
}
38+
const completion = await openai.chat.completions.create(body as any)
39+
40+
const [amountString, categoryId, date, title] = completion.choices
41+
.at(0)
42+
?.message.content?.split(',') ?? [null, null, null, null]
43+
return { amount: Number(amountString), categoryId, date, title }
44+
}
45+
46+
export type ReceiptExtractedInfo = Awaited<
47+
ReturnType<typeof extractExpenseInformationFromImage>
48+
>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
1+
'use client'
2+
3+
import { CategoryIcon } from '@/app/groups/[groupId]/expenses/category-icon'
4+
import {
5+
ReceiptExtractedInfo,
6+
extractExpenseInformationFromImage,
7+
} from '@/app/groups/[groupId]/expenses/create-from-receipt-button-actions'
8+
import { Badge } from '@/components/ui/badge'
9+
import { Button } from '@/components/ui/button'
10+
import {
11+
Dialog,
12+
DialogContent,
13+
DialogDescription,
14+
DialogHeader,
15+
DialogTitle,
16+
DialogTrigger,
17+
} from '@/components/ui/dialog'
18+
import {
19+
Drawer,
20+
DrawerContent,
21+
DrawerDescription,
22+
DrawerHeader,
23+
DrawerTitle,
24+
DrawerTrigger,
25+
} from '@/components/ui/drawer'
26+
import { ToastAction } from '@/components/ui/toast'
27+
import { useToast } from '@/components/ui/use-toast'
28+
import { useMediaQuery } from '@/lib/hooks'
29+
import { formatExpenseDate } from '@/lib/utils'
30+
import { Category } from '@prisma/client'
31+
import { ChevronRight, Loader2, Receipt } from 'lucide-react'
32+
import { getImageData, useS3Upload } from 'next-s3-upload'
33+
import Image from 'next/image'
34+
import { useRouter } from 'next/navigation'
35+
import { PropsWithChildren, ReactNode, useState } from 'react'
36+
37+
type Props = {
38+
groupId: string
39+
groupCurrency: string
40+
categories: Category[]
41+
}
42+
43+
export function CreateFromReceiptButton({
44+
groupId,
45+
groupCurrency,
46+
categories,
47+
}: Props) {
48+
const [pending, setPending] = useState(false)
49+
const { uploadToS3, FileInput, openFileDialog } = useS3Upload()
50+
const { toast } = useToast()
51+
const router = useRouter()
52+
const [receiptInfo, setReceiptInfo] = useState<
53+
| null
54+
| (ReceiptExtractedInfo & { url: string; width?: number; height?: number })
55+
>(null)
56+
const isDesktop = useMediaQuery('(min-width: 640px)')
57+
58+
const handleFileChange = async (file: File) => {
59+
const upload = async () => {
60+
try {
61+
setPending(true)
62+
console.log('Uploading image…')
63+
let { url } = await uploadToS3(file)
64+
console.log('Extracting information from receipt…')
65+
const { amount, categoryId, date, title } =
66+
await extractExpenseInformationFromImage(url)
67+
const { width, height } = await getImageData(file)
68+
setReceiptInfo({ amount, categoryId, date, title, url, width, height })
69+
} catch (err) {
70+
console.error(err)
71+
toast({
72+
title: 'Error while uploading document',
73+
description:
74+
'Something wrong happened when uploading the document. Please retry later or select a different file.',
75+
variant: 'destructive',
76+
action: (
77+
<ToastAction altText="Retry" onClick={() => upload()}>
78+
Retry
79+
</ToastAction>
80+
),
81+
})
82+
} finally {
83+
setPending(false)
84+
}
85+
}
86+
upload()
87+
}
88+
89+
const receiptInfoCategory =
90+
(receiptInfo?.categoryId &&
91+
categories.find((c) => String(c.id) === receiptInfo.categoryId)) ||
92+
null
93+
94+
const DialogOrDrawer = isDesktop
95+
? CreateFromReceiptDialog
96+
: CreateFromReceiptDrawer
97+
98+
return (
99+
<DialogOrDrawer
100+
trigger={
101+
<Button
102+
size="icon"
103+
variant="secondary"
104+
title="Create expense from receipt"
105+
>
106+
<Receipt className="w-4 h-4" />
107+
</Button>
108+
}
109+
title={
110+
<>
111+
<span>Create from receipt</span>
112+
<Badge className="bg-pink-700 hover:bg-pink-600 dark:bg-pink-500 dark:hover:bg-pink-600">
113+
Beta
114+
</Badge>
115+
</>
116+
}
117+
description={<>Extract the expense information from a receipt photo.</>}
118+
>
119+
<div className="prose prose-sm dark:prose-invert">
120+
<p>
121+
Upload the photo of a receipt, and we’ll scan it to extract the
122+
expense information if we can.
123+
</p>
124+
<div>
125+
<FileInput
126+
onChange={handleFileChange}
127+
accept="image/jpeg,image/png"
128+
/>
129+
<div className="grid gap-x-4 gap-y-2 grid-cols-3">
130+
<Button
131+
variant="secondary"
132+
className="row-span-3 w-full h-full relative"
133+
title="Create expense from receipt"
134+
onClick={openFileDialog}
135+
disabled={pending}
136+
>
137+
{pending ? (
138+
<Loader2 className="w-8 h-8 animate-spin" />
139+
) : receiptInfo ? (
140+
<div className="absolute top-2 left-2 bottom-2 right-2">
141+
<Image
142+
src={receiptInfo.url}
143+
width={receiptInfo.width}
144+
height={receiptInfo.height}
145+
className="w-full h-full m-0 object-contain drop-shadow-lg"
146+
alt="Scanned receipt"
147+
/>
148+
</div>
149+
) : (
150+
<span className="text-xs sm:text-sm text-muted-foreground">
151+
Select image…
152+
</span>
153+
)}
154+
</Button>
155+
<div className="col-span-2">
156+
<strong>Title:</strong>
157+
<div>{receiptInfo?.title ?? '…'}</div>
158+
</div>
159+
<div className="col-span-2">
160+
<strong>Category:</strong>
161+
<div>
162+
{receiptInfoCategory ? (
163+
<div className="flex items-center">
164+
<CategoryIcon
165+
category={receiptInfoCategory}
166+
className="inline w-4 h-4 mr-2"
167+
/>
168+
<span className="mr-1">{receiptInfoCategory.grouping}</span>
169+
<ChevronRight className="inline w-3 h-3 mr-1" />
170+
<span>{receiptInfoCategory.name}</span>
171+
</div>
172+
) : (
173+
'' || '…'
174+
)}
175+
</div>
176+
</div>
177+
<div>
178+
<strong>Amount:</strong>
179+
<div>
180+
{receiptInfo?.amount ? (
181+
<>
182+
{groupCurrency} {receiptInfo.amount.toFixed(2)}
183+
</>
184+
) : (
185+
'…'
186+
)}
187+
</div>
188+
</div>
189+
<div>
190+
<strong>Date:</strong>
191+
<div>
192+
{receiptInfo?.date
193+
? formatExpenseDate(
194+
new Date(`${receiptInfo?.date}T12:00:00.000Z`),
195+
)
196+
: '…'}
197+
</div>
198+
</div>
199+
</div>
200+
</div>
201+
<p>You’ll be able to edit the expense information after creating it.</p>
202+
<div className="text-center">
203+
<Button
204+
disabled={pending || !receiptInfo}
205+
onClick={() => {
206+
if (!receiptInfo) return
207+
router.push(
208+
`/groups/${groupId}/expenses/create?amount=${
209+
receiptInfo.amount
210+
}&categoryId=${receiptInfo.categoryId}&date=${
211+
receiptInfo.date
212+
}&title=${encodeURIComponent(
213+
receiptInfo.title ?? '',
214+
)}&imageUrl=${encodeURIComponent(receiptInfo.url)}&imageWidth=${
215+
receiptInfo.width
216+
}&imageHeight=${receiptInfo.height}`,
217+
)
218+
}}
219+
>
220+
Create expense
221+
</Button>
222+
</div>
223+
</div>
224+
</DialogOrDrawer>
225+
)
226+
}
227+
228+
function CreateFromReceiptDialog({
229+
trigger,
230+
title,
231+
description,
232+
children,
233+
}: PropsWithChildren<{
234+
trigger: ReactNode
235+
title: ReactNode
236+
description: ReactNode
237+
}>) {
238+
return (
239+
<Dialog>
240+
<DialogTrigger asChild>{trigger}</DialogTrigger>
241+
<DialogContent>
242+
<DialogHeader>
243+
<DialogTitle className="flex items-center gap-2">{title}</DialogTitle>
244+
<DialogDescription className="text-left">
245+
{description}
246+
</DialogDescription>
247+
</DialogHeader>
248+
{children}
249+
</DialogContent>
250+
</Dialog>
251+
)
252+
}
253+
254+
function CreateFromReceiptDrawer({
255+
trigger,
256+
title,
257+
description,
258+
children,
259+
}: PropsWithChildren<{
260+
trigger: ReactNode
261+
title: ReactNode
262+
description: ReactNode
263+
}>) {
264+
return (
265+
<Drawer>
266+
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
267+
<DrawerContent>
268+
<DrawerHeader>
269+
<DrawerTitle className="flex items-center gap-2">{title}</DrawerTitle>
270+
<DrawerDescription className="text-left">
271+
{description}
272+
</DrawerDescription>
273+
</DrawerHeader>
274+
<div className="px-4 pb-4">{children}</div>
275+
</DrawerContent>
276+
</Drawer>
277+
)
278+
}

src/app/groups/[groupId]/expenses/expense-list.tsx

+2-9
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { CategoryIcon } from '@/app/groups/[groupId]/expenses/category-icon'
33
import { Button } from '@/components/ui/button'
44
import { SearchBar } from '@/components/ui/search-bar'
55
import { getGroupExpenses } from '@/lib/api'
6-
import { cn } from '@/lib/utils'
6+
import { cn, formatExpenseDate } from '@/lib/utils'
77
import { Expense, Participant } from '@prisma/client'
88
import dayjs, { type Dayjs } from 'dayjs'
99
import { ChevronRight } from 'lucide-react'
@@ -159,7 +159,7 @@ export function ExpenseList({
159159
{currency} {(expense.amount / 100).toFixed(2)}
160160
</div>
161161
<div className="text-xs text-muted-foreground">
162-
{formatDate(expense.expenseDate)}
162+
{formatExpenseDate(expense.expenseDate)}
163163
</div>
164164
</div>
165165
<Button
@@ -189,10 +189,3 @@ export function ExpenseList({
189189
</p>
190190
)
191191
}
192-
193-
function formatDate(date: Date) {
194-
return date.toLocaleDateString('en-US', {
195-
dateStyle: 'medium',
196-
timeZone: 'UTC',
197-
})
198-
}

0 commit comments

Comments
 (0)