mirror of
https://github.com/spliit-app/spliit.git
synced 2026-02-18 13:36:12 +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
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…</>}
|
||||
|
||||
Reference in New Issue
Block a user