mirror of
https://github.com/spliit-app/spliit.git
synced 2026-02-11 10:06:13 +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
22
README.md
22
README.md
@@ -17,6 +17,7 @@ Spliit is a free and open source alternative to Splitwise. You can either use th
|
||||
- [x] Tell the application who you are when opening a group [(#7)](https://github.com/spliit-app/spliit/issues/7)
|
||||
- [x] Assign a category to expenses [(#35)](https://github.com/spliit-app/spliit/issues/35)
|
||||
- [x] Search for expenses in a group [(#51)](https://github.com/spliit-app/spliit/issues/51)
|
||||
- [x] Upload and attach images to expenses [(#63)](https://github.com/spliit-app/spliit/issues/63)
|
||||
|
||||
### Possible incoming features
|
||||
|
||||
@@ -37,8 +38,8 @@ The project is open to contributions. Feel free to open an issue or even a pull-
|
||||
|
||||
If you want to contribute financially and help us keep the application free and without ads, you can also:
|
||||
|
||||
* 💜 [Sponsor me (Sebastien)](https://github.com/sponsors/scastiel), or
|
||||
* 💙 [Make a small one-time donation](https://donate.stripe.com/28o3eh96G7hH8k89Ba).
|
||||
- 💜 [Sponsor me (Sebastien)](https://github.com/sponsors/scastiel), or
|
||||
- 💙 [Make a small one-time donation](https://donate.stripe.com/28o3eh96G7hH8k89Ba).
|
||||
|
||||
## Run locally
|
||||
|
||||
@@ -55,6 +56,23 @@ If you want to contribute financially and help us keep the application free and
|
||||
3. Run `npm run start-container` to start the postgres and the spliit2 containers
|
||||
4. You can access the app by browsing to http://localhost:3000
|
||||
|
||||
## Opt-in features
|
||||
|
||||
### Expense documents
|
||||
|
||||
Spliit offers users to upload images (to an AWS S3 bucket) and attach them to expenses. To enable this feature:
|
||||
|
||||
- Follow the instructions in the _S3 bucket_ and _IAM user_ sections of [next-s3-upload](https://next-s3-upload.codingvalue.com/setup#s3-bucket) to create and set up an S3 bucket where images will be stored.
|
||||
- Update your environments variables with appropriate values:
|
||||
|
||||
```.env
|
||||
NEXT_PUBLIC_ENABLE_EXPENSE_DOCUMENTS=true
|
||||
S3_UPLOAD_KEY=AAAAAAAAAAAAAAAAAAAA
|
||||
S3_UPLOAD_SECRET=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
|
||||
S3_UPLOAD_BUCKET=name-of-s3-bucket
|
||||
S3_UPLOAD_REGION=us-east-1
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT, see [LICENSE](./LICENSE).
|
||||
|
||||
@@ -1,4 +1,15 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {}
|
||||
const nextConfig = {
|
||||
images: {
|
||||
remotePatterns:
|
||||
process.env.S3_UPLOAD_BUCKET && process.env.S3_UPLOAD_REGION
|
||||
? [
|
||||
{
|
||||
hostname: `${process.env.S3_UPLOAD_BUCKET}.s3.${process.env.S3_UPLOAD_REGION}.amazonaws.com`,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
},
|
||||
}
|
||||
|
||||
module.exports = nextConfig
|
||||
|
||||
1596
package-lock.json
generated
1596
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -38,6 +38,7 @@
|
||||
"lucide-react": "^0.290.0",
|
||||
"nanoid": "^5.0.4",
|
||||
"next": "^14.1.0",
|
||||
"next-s3-upload": "^0.3.4",
|
||||
"next-themes": "^0.2.1",
|
||||
"next13-progressbar": "^1.1.1",
|
||||
"pg": "^8.11.3",
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Expense" ADD COLUMN "documentUrls" TEXT[] DEFAULT ARRAY[]::TEXT[];
|
||||
@@ -0,0 +1,9 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- The `documentUrls` column on the `Expense` table would be dropped and recreated. This will lead to data loss if there is data in the column.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "Expense" DROP COLUMN "documentUrls",
|
||||
ADD COLUMN "documentUrls" JSONB[] DEFAULT ARRAY[]::JSONB[];
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `documentUrls` on the `Expense` table. All the data in the column will be lost.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "Expense" DROP COLUMN "documentUrls";
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ExpenseDocument" (
|
||||
"id" TEXT NOT NULL,
|
||||
"url" TEXT NOT NULL,
|
||||
"expenseId" TEXT,
|
||||
|
||||
CONSTRAINT "ExpenseDocument_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ExpenseDocument" ADD CONSTRAINT "ExpenseDocument_expenseId_fkey" FOREIGN KEY ("expenseId") REFERENCES "Expense"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
10
prisma/migrations/20240128202400_add_doc_info/migration.sql
Normal file
10
prisma/migrations/20240128202400_add_doc_info/migration.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- Added the required column `height` to the `ExpenseDocument` table without a default value. This is not possible if the table is not empty.
|
||||
- Added the required column `width` to the `ExpenseDocument` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "ExpenseDocument" ADD COLUMN "height" INTEGER NOT NULL,
|
||||
ADD COLUMN "width" INTEGER NOT NULL;
|
||||
@@ -37,20 +37,30 @@ model Category {
|
||||
}
|
||||
|
||||
model Expense {
|
||||
id String @id
|
||||
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
||||
expenseDate DateTime @default(dbgenerated("CURRENT_DATE")) @db.Date
|
||||
id String @id
|
||||
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
||||
expenseDate DateTime @default(dbgenerated("CURRENT_DATE")) @db.Date
|
||||
title String
|
||||
category Category? @relation(fields: [categoryId], references: [id])
|
||||
categoryId Int @default(0)
|
||||
category Category? @relation(fields: [categoryId], references: [id])
|
||||
categoryId Int @default(0)
|
||||
amount Int
|
||||
paidBy Participant @relation(fields: [paidById], references: [id], onDelete: Cascade)
|
||||
paidBy Participant @relation(fields: [paidById], references: [id], onDelete: Cascade)
|
||||
paidById String
|
||||
paidFor ExpensePaidFor[]
|
||||
groupId String
|
||||
isReimbursement Boolean @default(false)
|
||||
splitMode SplitMode @default(EVENLY)
|
||||
createdAt DateTime @default(now())
|
||||
isReimbursement Boolean @default(false)
|
||||
splitMode SplitMode @default(EVENLY)
|
||||
createdAt DateTime @default(now())
|
||||
documents ExpenseDocument[]
|
||||
}
|
||||
|
||||
model ExpenseDocument {
|
||||
id String @id
|
||||
url String
|
||||
width Int
|
||||
height Int
|
||||
Expense Expense? @relation(fields: [expenseId], references: [id])
|
||||
expenseId String?
|
||||
}
|
||||
|
||||
enum SplitMode {
|
||||
|
||||
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