diff options
| author | Sebastien Castiel <sebastien@castiel.me> | 2024-01-28 18:51:29 -0500 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2024-01-28 18:51:29 -0500 |
| commit | d43e731fe191d7e35d96847fe095c94555dd8ca6 (patch) | |
| tree | b8bc3f99770a47ec0343f642ca04e727f7abd716 /src | |
| parent | 11d2e298e8cc5cac07aa2c60e194bf4460cefb21 (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.ts | 8 | ||||
| -rw-r--r-- | src/components/expense-documents-input.tsx | 145 | ||||
| -rw-r--r-- | src/components/expense-form.tsx | 29 | ||||
| -rw-r--r-- | src/lib/api.ts | 28 | ||||
| -rw-r--r-- | src/lib/env.ts | 47 | ||||
| -rw-r--r-- | src/lib/schemas.ts | 10 |
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 |
