aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorSebastien Castiel <sebastien@castiel.me>2024-01-28 18:51:29 -0500
committerGitHub <noreply@github.com>2024-01-28 18:51:29 -0500
commitd43e731fe191d7e35d96847fe095c94555dd8ca6 (patch)
treeb8bc3f99770a47ec0343f642ca04e727f7abd716 /src
parent11d2e298e8cc5cac07aa2c60e194bf4460cefb21 (diff)
Attach documents to expenses (#64)
* Upload documents to receipts * Improve documents * Make the feature opt-in * Fix file name issue
Diffstat (limited to 'src')
-rw-r--r--src/app/api/s3-upload/route.ts8
-rw-r--r--src/components/expense-documents-input.tsx145
-rw-r--r--src/components/expense-form.tsx29
-rw-r--r--src/lib/api.ts28
-rw-r--r--src/lib/env.ts47
-rw-r--r--src/lib/schemas.ts10
6 files changed, 253 insertions, 14 deletions
diff --git a/src/app/api/s3-upload/route.ts b/src/app/api/s3-upload/route.ts
new file mode 100644
index 0000000..042d357
--- /dev/null
+++ b/src/app/api/s3-upload/route.ts
@@ -0,0 +1,8 @@
+import { sanitizeKey } from 'next-s3-upload'
+import { POST as route } from 'next-s3-upload/route'
+
+export const POST = route.configure({
+ key(req, filename) {
+ return sanitizeKey(filename).toLowerCase()
+ },
+})
diff --git a/src/components/expense-documents-input.tsx b/src/components/expense-documents-input.tsx
new file mode 100644
index 0000000..11e5708
--- /dev/null
+++ b/src/components/expense-documents-input.tsx
@@ -0,0 +1,145 @@
+import { Button } from '@/components/ui/button'
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogTrigger,
+} from '@/components/ui/dialog'
+import { ToastAction } from '@/components/ui/toast'
+import { useToast } from '@/components/ui/use-toast'
+import { randomId } from '@/lib/api'
+import { ExpenseFormValues } from '@/lib/schemas'
+import { Loader2, Plus, Trash, X } from 'lucide-react'
+import { getImageData, useS3Upload } from 'next-s3-upload'
+import Image from 'next/image'
+import { useState } from 'react'
+
+type Props = {
+ documents: ExpenseFormValues['documents']
+ updateDocuments: (documents: ExpenseFormValues['documents']) => void
+}
+
+export function ExpenseDocumentsInput({ documents, updateDocuments }: Props) {
+ const [pending, setPending] = useState(false)
+ const { FileInput, openFileDialog, uploadToS3 } = useS3Upload()
+ const { toast } = useToast()
+
+ const handleFileChange = async (file: File) => {
+ const upload = async () => {
+ try {
+ setPending(true)
+ const { width, height } = await getImageData(file)
+ if (!width || !height) throw new Error('Cannot get image dimensions')
+ const { url } = await uploadToS3(file)
+ updateDocuments([...documents, { id: randomId(), url, width, height }])
+ } catch (err) {
+ console.error(err)
+ toast({
+ title: 'Error while uploading document',
+ description:
+ 'Something wrong happened when uploading the document. Please retry later or select a different file.',
+ variant: 'destructive',
+ action: (
+ <ToastAction altText="Retry" onClick={() => upload()}>
+ Retry
+ </ToastAction>
+ ),
+ })
+ } finally {
+ setPending(false)
+ }
+ }
+ upload()
+ }
+
+ return (
+ <div>
+ <FileInput onChange={handleFileChange} accept="image/jpeg,image/png" />
+
+ <div className="grid grid-cols-2 sm:grid-cols-3 gap-4 [&_*]:aspect-square">
+ {documents.map((doc) => (
+ <DocumentThumbnail
+ key={doc.id}
+ document={doc}
+ deleteDocument={() => {
+ updateDocuments(documents.filter((d) => d.id !== doc.id))
+ }}
+ />
+ ))}
+
+ <div>
+ <Button
+ variant="secondary"
+ type="button"
+ onClick={openFileDialog}
+ className="w-full h-full"
+ disabled={pending}
+ >
+ {pending ? (
+ <Loader2 className="w-8 h-8 animate-spin" />
+ ) : (
+ <Plus className="w-8 h-8" />
+ )}
+ </Button>
+ </div>
+ </div>
+ </div>
+ )
+}
+
+export function DocumentThumbnail({
+ document,
+ deleteDocument,
+}: {
+ document: ExpenseFormValues['documents'][number]
+ deleteDocument: () => void
+}) {
+ const [open, setOpen] = useState(false)
+
+ return (
+ <Dialog open={open} onOpenChange={setOpen}>
+ <DialogTrigger asChild>
+ <Button
+ variant="secondary"
+ className="w-full h-full border overflow-hidden rounded shadow-inner"
+ >
+ <Image
+ width={300}
+ height={300}
+ className="object-contain"
+ src={document.url}
+ alt=""
+ />
+ </Button>
+ </DialogTrigger>
+ <DialogContent className="p-4 w-fit min-w-[300px] min-h-[300px] max-w-full [&>:last-child]:hidden">
+ <div className="flex justify-end">
+ <Button
+ variant="ghost"
+ className="text-destructive"
+ onClick={() => {
+ deleteDocument()
+ setOpen(false)
+ }}
+ >
+ <Trash className="w-4 h-4 mr-2" />
+ Delete document
+ </Button>
+ <DialogClose asChild>
+ <Button variant="ghost">
+ <X className="w-4 h-4 mr-2" /> Close
+ </Button>
+ </DialogClose>
+ </div>
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+ <Image
+ className="object-contain w-[100vw] h-[100dvh] max-w-[calc(100vw-32px)] max-h-[calc(100dvh-32px-40px-16px)] sm:w-fit sm:h-fit sm:max-w-[calc(100vw-32px-32px)] sm:max-h-[calc(100dvh-32px-40px-32px)]"
+ src={document.url}
+ width={document.width}
+ height={document.height}
+ alt=""
+ />
+ </DialogContent>
+ </Dialog>
+ )
+}
diff --git a/src/components/expense-form.tsx b/src/components/expense-form.tsx
index 92e24d0..a847949 100644
--- a/src/components/expense-form.tsx
+++ b/src/components/expense-form.tsx
@@ -1,6 +1,7 @@
'use client'
import { AsyncButton } from '@/components/async-button'
import { CategorySelector } from '@/components/category-selector'
+import { ExpenseDocumentsInput } from '@/components/expense-documents-input'
import { SubmitButton } from '@/components/submit-button'
import { Button } from '@/components/ui/button'
import {
@@ -83,6 +84,7 @@ export function ExpenseForm({
})),
splitMode: expense.splitMode,
isReimbursement: expense.isReimbursement,
+ documents: expense.documents,
}
: searchParams.get('reimbursement')
? {
@@ -100,6 +102,7 @@ export function ExpenseForm({
],
isReimbursement: true,
splitMode: 'EVENLY',
+ documents: [],
}
: {
title: '',
@@ -114,6 +117,7 @@ export function ExpenseForm({
paidBy: getSelectedPayer(),
isReimbursement: false,
splitMode: 'EVENLY',
+ documents: [],
},
})
@@ -506,6 +510,31 @@ export function ExpenseForm({
</CardContent>
</Card>
+ {process.env.NEXT_PUBLIC_ENABLE_EXPENSE_DOCUMENTS && (
+ <Card className="mt-4">
+ <CardHeader>
+ <CardTitle className="flex justify-between">
+ <span>Attach documents</span>
+ </CardTitle>
+ <CardDescription>
+ See and attach receipts to the expense.
+ </CardDescription>
+ </CardHeader>
+ <CardContent>
+ <FormField
+ control={form.control}
+ name="documents"
+ render={({ field }) => (
+ <ExpenseDocumentsInput
+ documents={field.value}
+ updateDocuments={field.onChange}
+ />
+ )}
+ />
+ </CardContent>
+ </Card>
+ )}
+
<div className="flex mt-4 gap-2">
<SubmitButton
loadingContent={isCreate ? <>Creating…</> : <>Saving…</>}
diff --git a/src/lib/api.ts b/src/lib/api.ts
index c27e584..dfa9651 100644
--- a/src/lib/api.ts
+++ b/src/lib/api.ts
@@ -62,6 +62,16 @@ export async function createExpense(
},
},
isReimbursement: expenseFormValues.isReimbursement,
+ documents: {
+ createMany: {
+ data: expenseFormValues.documents.map((doc) => ({
+ id: randomId(),
+ url: doc.url,
+ width: doc.width,
+ height: doc.height,
+ })),
+ },
+ },
},
})
}
@@ -159,6 +169,22 @@ export async function updateExpense(
),
},
isReimbursement: expenseFormValues.isReimbursement,
+ documents: {
+ connectOrCreate: expenseFormValues.documents.map((doc) => ({
+ create: doc,
+ where: { id: doc.id },
+ })),
+ deleteMany: existingExpense.documents
+ .filter(
+ (existingDoc) =>
+ !expenseFormValues.documents.some(
+ (doc) => doc.id === existingDoc.id,
+ ),
+ )
+ .map((doc) => ({
+ id: doc.id,
+ })),
+ },
},
})
}
@@ -231,6 +257,6 @@ export async function getExpense(groupId: string, expenseId: string) {
const prisma = await getPrisma()
return prisma.expense.findUnique({
where: { id: expenseId },
- include: { paidBy: true, paidFor: true, category: true },
+ include: { paidBy: true, paidFor: true, category: true, documents: true },
})
}
diff --git a/src/lib/env.ts b/src/lib/env.ts
index 3db102e..9912945 100644
--- a/src/lib/env.ts
+++ b/src/lib/env.ts
@@ -1,16 +1,37 @@
-import { z } from 'zod'
+import { ZodIssueCode, z } from 'zod'
-const envSchema = z.object({
- POSTGRES_URL_NON_POOLING: z.string().url(),
- POSTGRES_PRISMA_URL: z.string().url(),
- NEXT_PUBLIC_BASE_URL: z
- .string()
- .optional()
- .default(
- process.env.VERCEL_URL
- ? `https://${process.env.VERCEL_URL}`
- : 'http://localhost:3000',
- ),
-})
+const envSchema = z
+ .object({
+ POSTGRES_URL_NON_POOLING: z.string().url(),
+ POSTGRES_PRISMA_URL: z.string().url(),
+ NEXT_PUBLIC_BASE_URL: z
+ .string()
+ .optional()
+ .default(
+ process.env.VERCEL_URL
+ ? `https://${process.env.VERCEL_URL}`
+ : 'http://localhost:3000',
+ ),
+ NEXT_PUBLIC_ENABLE_EXPENSE_DOCUMENTS: z.coerce.boolean().default(false),
+ S3_UPLOAD_KEY: z.string().optional(),
+ S3_UPLOAD_SECRET: z.string().optional(),
+ S3_UPLOAD_BUCKET: z.string().optional(),
+ S3_UPLOAD_REGION: z.string().optional(),
+ })
+ .superRefine((env, ctx) => {
+ if (
+ env.NEXT_PUBLIC_ENABLE_EXPENSE_DOCUMENTS &&
+ (!env.S3_UPLOAD_BUCKET ||
+ !env.S3_UPLOAD_KEY ||
+ !env.S3_UPLOAD_REGION ||
+ !env.S3_UPLOAD_SECRET)
+ ) {
+ ctx.addIssue({
+ code: ZodIssueCode.custom,
+ message:
+ 'If NEXT_PUBLIC_ENABLE_EXPENSE_DOCUMENTS is specified, then S3_* must be specified too',
+ })
+ }
+ })
export const env = envSchema.parse(process.env)
diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts
index be90481..981cea3 100644
--- a/src/lib/schemas.ts
+++ b/src/lib/schemas.ts
@@ -105,6 +105,16 @@ export const expenseFormSchema = z
)
.default('EVENLY'),
isReimbursement: z.boolean(),
+ documents: z
+ .array(
+ z.object({
+ id: z.string(),
+ url: z.string().url(),
+ width: z.number().int().min(1),
+ height: z.number().int().min(1),
+ }),
+ )
+ .default([]),
})
.superRefine((expense, ctx) => {
let sum = 0