mirror of
https://github.com/spliit-app/spliit.git
synced 2026-02-19 05:56:14 +01:00
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:
committed by
GitHub
parent
11d2e298e8
commit
d43e731fe1
8
src/app/api/s3-upload/route.ts
Normal file
8
src/app/api/s3-upload/route.ts
Normal 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()
|
||||
},
|
||||
})
|
||||
145
src/components/expense-documents-input.tsx
Normal file
145
src/components/expense-documents-input.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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…</>}
|
||||
|
||||
@@ -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 },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user