From d43e731fe191d7e35d96847fe095c94555dd8ca6 Mon Sep 17 00:00:00 2001 From: Sebastien Castiel Date: Sun, 28 Jan 2024 18:51:29 -0500 Subject: Attach documents to expenses (#64) * Upload documents to receipts * Improve documents * Make the feature opt-in * Fix file name issue --- src/components/expense-documents-input.tsx | 145 +++++++++++++++++++++++++++++ src/components/expense-form.tsx | 29 ++++++ 2 files changed, 174 insertions(+) create mode 100644 src/components/expense-documents-input.tsx (limited to 'src/components') 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: ( + upload()}> + Retry + + ), + }) + } finally { + setPending(false) + } + } + upload() + } + + return ( +
+ + +
+ {documents.map((doc) => ( + { + updateDocuments(documents.filter((d) => d.id !== doc.id)) + }} + /> + ))} + +
+ +
+
+
+ ) +} + +export function DocumentThumbnail({ + document, + deleteDocument, +}: { + document: ExpenseFormValues['documents'][number] + deleteDocument: () => void +}) { + const [open, setOpen] = useState(false) + + return ( + + + + + +
+ + + + +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + +
+
+ ) +} 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({ + {process.env.NEXT_PUBLIC_ENABLE_EXPENSE_DOCUMENTS && ( + + + + Attach documents + + + See and attach receipts to the expense. + + + + ( + + )} + /> + + + )} +
Creating… : <>Saving…} -- cgit v1.3