Attach documents to expenses (#64)

* Upload documents to receipts

* Improve documents

* Make the feature opt-in

* Fix file name issue
This commit is contained in:
Sebastien Castiel
2024-01-28 18:51:29 -05:00
committed by GitHub
parent 11d2e298e8
commit d43e731fe1
15 changed files with 1942 additions and 26 deletions

View File

@@ -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()
},
})

View File

@@ -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>
)
}

View File

@@ -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</>}

View File

@@ -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 },
})
}

View File

@@ -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)

View File

@@ -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