67 Commits

Author SHA1 Message Date
Sebastien Castiel
7ff1211e66 Improve Next.js caching for some routes 2024-01-29 23:19:31 -05:00
Sebastien Castiel
3847a67a19 Sort expenses by expense date, then by creation date (partial workaround for #67) 2024-01-29 15:14:51 -05:00
Sebastien Castiel
7695ffd62d Fix uploaded image names 2024-01-29 10:39:49 -05:00
Sebastien Castiel
091cd02c06 Use carousel to display images (fix dimensions) 2024-01-28 23:41:18 -05:00
Sebastien Castiel
9876d7045f Use carousel to display images 2024-01-28 23:28:44 -05:00
Lauri Vuorela
9759f61e0e Production target for Dockerfile (#57)
* add production build

* add back updates and use slim image

* udpate command

* ignore scripts

* add workdir

* fix workdirs

* docker image improvements

* use .example instead

* use dummy data instead

* remove unused env var and add comment

* fix entrypoints

* change name of script and add possibility for different commands

* change to safer default for volume

* add instructions for the dev docker container

* update copy

* add empty lines under topics to keep uniformity

* most RUN's in a single command

* add comment about volumes for dev target

* remove dev workflow

* remove dev workflow from readme

* Prettify README

---------

Co-authored-by: Sebastien Castiel <sebastien@castiel.me>
2024-01-28 19:30:26 -05:00
Sebastien Castiel
d43e731fe1 Attach documents to expenses (#64)
* Upload documents to receipts

* Improve documents

* Make the feature opt-in

* Fix file name issue
2024-01-28 18:51:29 -05:00
Sebastien Castiel
11d2e298e8 Improve recent groups design 2024-01-26 16:11:51 -05:00
Sebastien Castiel
0647000a77 Disable prefetch on export link 2024-01-26 15:50:32 -05:00
Vid Čufar
2228415323 Fix search functionality (#62)
* Improve README instructions for local setup

* Fix search functionality #61
- use 'includes' for expense filtering

* Ensure expense groups with no matching expenses are hidden after filtering

* Improve README instructions for local setup
2024-01-26 10:27:34 -05:00
Mert Demir
58ee685e22 paid for all, split evenly (#59) 2024-01-26 10:26:58 -05:00
Sebastien Castiel
545cf75e99 Join group by URL (Closes #55) 2024-01-24 11:12:55 -05:00
Sebastien Castiel
7956156d70 Upgrade Next.js to 14.1.0 2024-01-24 09:50:37 -05:00
Sebastien Castiel
2f58e466da Fix image size to prevent warning in the console 2024-01-23 22:27:07 -05:00
Sebastien Castiel
89ee5ae247 Add date and bring back group name in exported filename (#54) 2024-01-23 16:41:07 -05:00
Sebastien Castiel
1bd3f99d38 Fix export file name (Fix #54) 2024-01-22 13:05:35 -05:00
Sebastien Castiel
e32a12ce41 Update FUNDING.yml 2024-01-19 23:39:01 -05:00
Sebastien Castiel
49218e8e9d Update README.md 2024-01-19 23:37:12 -05:00
Sebastien Castiel
23eedcb619 Update FUNDING.yml 2024-01-19 23:33:28 -05:00
Sebastien Castiel
ba4107e440 Update README.md 2024-01-19 16:43:31 -05:00
Ankit Bahl
ae7cb2ccc8 Added search bar for expense list page (#52)
* Added search bar for expense list page

* Change search input styling

---------

Co-authored-by: Sebastien Castiel <sebastien@castiel.me>
2024-01-19 16:38:18 -05:00
Sebastien Castiel
3735509fea Update GitHub repository URL 2024-01-19 16:13:02 -05:00
Sebastien Castiel
1b1ebf015e Bring back NEXT_PUBLIC_BASE_URL (continued) 2024-01-19 12:49:22 -05:00
Sebastien Castiel
c138afadb9 Bring back NEXT_PUBLIC_BASE_URL 2024-01-19 12:43:17 -05:00
Sebastien Castiel
18ac2142a8 Update README.md 2024-01-19 12:10:19 -05:00
Sebastien Castiel
875b9787d0 Add deploy with Vercel button 2024-01-19 12:04:48 -05:00
Sebastien Castiel
4d86c8c727 Remove varianle NEXT_PUBLIC_BASE_URL 2024-01-19 12:03:16 -05:00
Sebastien Castiel
23524cb943 Clean project from marketing content (#50)
* Clean project from marketing content

* Remove some dependencies
2024-01-19 11:28:25 -05:00
Sebastien Castiel
f9040f8bed Merge feedback and support dialogs 2024-01-18 15:48:45 -05:00
Sebastien Castiel
395c86666c Fix mobile keyboard on shares field (Fix #49) 2024-01-18 09:07:47 -05:00
Sebastien Castiel
2728f24989 Remove unused code 2024-01-18 09:02:53 -05:00
Sebastien Castiel
314eba284b Responsive category selector with drawer 2024-01-17 12:30:56 -05:00
Sebastien Castiel
92156b29cb Use combobox for category selector 2024-01-17 12:07:03 -05:00
Sebastien Castiel
c4de3f605c Improve UI of expense list 2024-01-17 10:22:49 -05:00
Brandon Eng
ff6b84ff88 Group expenses (#48)
* Group expenses my date

* Group expenses my date

* typescript errors

* prettier

* getExpenseGroup

* update logic to use dayjs

* clean up
2024-01-17 09:42:00 -05:00
Sebastien Castiel
6b6d58e95e Add GitHub actions 2024-01-16 13:55:34 -05:00
Sebastien Castiel
d809e10d19 Update contributors 2024-01-16 10:43:04 -05:00
Brandon Eng
36cc4f1ef7 Ability to archive groups when they’re settled up (#45)
* Settled up icon on group card

* remove logs

* archived groups

* remove settled up

* remove more settled up

* recent-group-list-card

* sortGroups

* archiveGroup

* unarchiveGroup

* clean up

* more clean up

* Prettier, fix TS errors, add titles

---------

Co-authored-by: Sebastien Castiel <sebastien@castiel.me>
2024-01-16 10:41:46 -05:00
Sebastien Castiel
1141501edb Create FUNDING.yml 2024-01-15 18:24:48 -05:00
Sebastien Castiel
28902ad0ea Export group expenses as JSON (Closes #42) 2024-01-15 11:44:49 -05:00
Sebastien Castiel
8abdcb7d6f Fix donation modal with dark mode (Closes #46) 2024-01-15 09:19:53 -05:00
Sebastien Castiel
43f7ca700b Fix client-side error when editing date with keyboard (Closes #43) 2024-01-14 12:47:52 -05:00
Sebastien Castiel
beae336666 Add donation button (closes #40) 2024-01-14 11:43:48 -05:00
Sebastien Castiel
2dcb80f954 Update home page & README 2024-01-11 17:32:52 -05:00
Sebastien Castiel
c7fb810f80 Add category icons 2024-01-11 17:12:21 -05:00
Chris Johnston
45ee9cdba4 Assign categories to expenses (#28)
* add expense categories

* set category to Payment for reimbursements

* Insert categories as part of the migration

* Display category groups

---------

Co-authored-by: Sebastien Castiel <sebastien@castiel.me>
2024-01-11 16:38:30 -05:00
Sebastien Castiel
057f3e9c53 Update contributors 2024-01-11 15:30:50 -05:00
Max
76427c9f13 Docker container version (#39)
* + Dockerfile and compose file
+ Scripts dir and startup script
+ Build image npm script

* * Moves env to file

* + Tags image with info from package.json
* Moves image creation to script
* Updates README

* Update README.md

Co-authored-by: Sebastien Castiel <sebastien@castiel.me>

---------

Co-authored-by: Maxime Jacob <mjacob-no-reply@proton.me>
Co-authored-by: Sebastien Castiel <sebastien@castiel.me>
2024-01-11 15:25:08 -05:00
Sebastien Castiel
ddce4d0bdb Add contributors link 2024-01-11 11:44:48 -05:00
Sebastien Castiel
cf41048aea Remove group (Fix #38) 2024-01-11 10:12:58 -05:00
Sebastien Castiel
f20ebd5bdd Improve design for expense list 2024-01-10 08:21:12 -05:00
Sebastien Castiel
9c728530c9 Fix font size in inputs 2024-01-09 15:38:08 -05:00
Sebastien Castiel
323b0ea128 Feedback button 2024-01-09 15:32:19 -05:00
Sebastien Castiel
5ce96aef30 Add contributors on home page 2024-01-09 11:25:42 -05:00
Sebastien Castiel
a258e85fae Update README.md 2024-01-09 09:15:38 -05:00
Sebastien Castiel
1b9e624004 Ask the user who they are when opening a group for the first time (#7) 2024-01-09 08:53:51 -05:00
Ankit Bahl
6bd3299331 Add activeUser for default payer per group (#16)
* Add activeUser for default payer per group

* Prettier, change labels, use useEffect

---------

Co-authored-by: Sebastien Castiel <sebastien@castiel.me>
2024-01-09 08:08:17 -05:00
Sebastien Castiel
a942369193 Fix how dates are displayed (Fix #33) 2024-01-09 07:45:28 -05:00
Chris Johnston
e891d259a5 add shares to paidFor in reimbursement (#32) 2024-01-08 16:22:30 -05:00
Sebastien Castiel
d9aeb45c83 Update README.md 2024-01-08 16:21:46 -05:00
Chris Johnston
76befff481 Fix UI bug when clicking reimbursement link (#31) 2024-01-08 15:56:54 -05:00
Sebastien Castiel
55883ce414 Mark a group as favorite (fixes #29) 2024-01-08 15:49:07 -05:00
Chris Johnston
bec1dd270a Add Expense Date (#26)
* add expense date

* Improve date formatting

* Prettier

* Change field description

---------

Co-authored-by: Sebastien Castiel <sebastien@castiel.me>
2024-01-08 14:26:44 -05:00
Sebastien Castiel
4566900f9c Fix items alignment 2024-01-08 12:29:20 -05:00
Sebastien Castiel
0a8e56f800 Add splitmode and shares to expenses (#11)
* Add splitmode and shares to expenses

* Update balances based on shares

* Change field size

* Form validation

* Redesign expense form

* Split unevenly by amount
2024-01-08 12:11:11 -05:00
Sebastien Castiel
0fb0c42ff5 Fix index 2023-12-26 12:24:42 +01:00
Sebastien Castiel
f881aff5f9 Revert "Use modal dialogs for expense creation & edition (#10)"
This reverts commit 1e66efe516.
2023-12-19 09:44:09 -05:00
80 changed files with 6854 additions and 960 deletions

View File

@@ -1,3 +1,2 @@
POSTGRES_PRISMA_URL=postgresql://postgres:1234@localhost
POSTGRES_URL_NON_POOLING=postgresql://postgres:1234@localhost
NEXT_PUBLIC_BASE_URL=http://localhost:3000
POSTGRES_URL_NON_POOLING=postgresql://postgres:1234@localhost

13
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,13 @@
# These are supported funding model platforms
github: ['scastiel']
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
otechie: # Replace with a single Otechie username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
custom: ['https://donate.stripe.com/28o3eh96G7hH8k89Ba']

36
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,36 @@
name: CI
on:
push:
branches: ['main']
pull_request:
branches: ['main']
jobs:
checks:
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v3
- name: Set up Node.js
uses: actions/setup-node@v3
with:
node-version: 18
cache: 'npm'
- name: Install dependencies
run: npm ci --ignore-scripts
- name: Generate Prisma client
run: npx prisma generate
- name: Check TypeScript types
run: npm run check-types
- name: Check ESLint
run: npm run lint
- name: Check Prettier formatting
run: npm run check-formatting

2
.gitignore vendored
View File

@@ -27,7 +27,7 @@ yarn-error.log*
# local env files
.env*.local
.env
*.env
# vercel
.vercel

1
.prettierignore Normal file
View File

@@ -0,0 +1 @@
src/components/ui

22
Dockerfile Normal file
View File

@@ -0,0 +1,22 @@
FROM node:21-slim as base
EXPOSE 3000/tcp
WORKDIR /usr/app
COPY ./ ./
RUN apt update && \
apt install openssl -y && \
apt clean && \
apt autoclean && \
apt autoremove && \
npm ci --ignore-scripts && \
npm install -g prisma && \
prisma generate
# env vars needed for build not to fail
ARG POSTGRES_PRISMA_URL
ARG POSTGRES_URL_NON_POOLING
RUN npm run build
ENTRYPOINT ["/usr/app/scripts/container-entrypoint.sh"]

View File

@@ -1,6 +1,8 @@
[<img alt="Spliit" height="60" src="https://github.com/scastiel/spliit2/blob/main/public/logo-with-text.png?raw=true" />](https://spliit.app)
[<img alt="Spliit" height="60" src="https://github.com/spliit-app/spliit/blob/main/public/logo-with-text.png?raw=true" />](https://spliit.app)
Spliit is a free and open source alternative to Splitwise. I created it back in 2022 as a side project to learn the Go language, but rewrote it with Next.js since.
Spliit is a free and open source alternative to Splitwise. You can either use the official instance at [Spliit.app](https://spliit.app), or deploy your own instance:
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fspliit-app%2Fspliit&project-name=my-spliit-instance&repository-name=my-spliit-instance&stores=%5B%7B%22type%22%3A%22postgres%22%7D%5D&)
## Features
@@ -10,12 +12,17 @@ Spliit is a free and open source alternative to Splitwise. I created it back in
- [x] Create reimbursement expenses
- [x] Progressive Web App
- [x] Select all/no participant for expenses
- [x] Split expenses unevenly [(#6)](https://github.com/spliit-app/spliit/issues/6)
- [x] Mark a group as favorite [(#29)](https://github.com/spliit-app/spliit/issues/29)
- [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
- [ ] Tell the application who you are when opening a group [(#7)](https://github.com/scastiel/spliit2/issues/7)
- [ ] Ability to create recurring expenses [(#5)](https://github.com/scastiel/spliit2/issues/5)
- [ ] Ability to split expenses unevenly [(#6)](https://github.com/scastiel/spliit2/issues/6)
- [ ] Ability to create recurring expenses [(#5)](https://github.com/spliit-app/spliit/issues/5)
- [ ] Import expenses from Splitwise [(#22)](https://github.com/spliit-app/spliit/issues/22)
## Stack
@@ -29,13 +36,42 @@ Spliit is a free and open source alternative to Splitwise. I created it back in
The project is open to contributions. Feel free to open an issue or even a pull-request!
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).
## Run locally
1. Clone the repository (or fork it if you intend to contribute)
2. `npm install`
3. Start a PostgreSQL server. You can run `./start-local-db.sh` if you dont have a server already.
4. Copy the file `.env.example` as `.env`
5. `npm run dev`
2. Start a PostgreSQL server. You can run `./scripts/start-local-db.sh` if you dont have a server already.
3. Copy the file `.env.example` as `.env`
4. Run `npm install` to install dependencies. This will also apply database migrations and update Prisma Client.
5. Run `npm run dev` to start the development server
## Run in a container
1. Run `npm run build-image` to build the docker image from the Dockerfile
2. Copy the file `container.env.example` as `container.env`
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

View File

@@ -13,4 +13,4 @@
"components": "@/components",
"utils": "@/lib/utils"
}
}
}

24
compose.yaml Normal file
View File

@@ -0,0 +1,24 @@
services:
app:
image: spliit2:latest
ports:
- 3000:3000
env_file:
- container.env
depends_on:
db:
condition: service_healthy
db:
image: postgres:latest
ports:
- 5432:5432
env_file:
- container.env
volumes:
- ./postgres-data:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U postgres']
interval: 5s
timeout: 5s
retries: 5

6
container.env.example Normal file
View File

@@ -0,0 +1,6 @@
# db
POSTGRES_PASSWORD=1234
# app
POSTGRES_PRISMA_URL=postgresql://postgres:${POSTGRES_PASSWORD}@db
POSTGRES_URL_NON_POOLING=postgresql://postgres:${POSTGRES_PASSWORD}@db

View File

@@ -1,5 +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`,
},
]
: [],
},
}
const { withPlausibleProxy } = require('next-plausible')
module.exports = withPlausibleProxy()(nextConfig)
module.exports = nextConfig

3142
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,50 +7,65 @@
"build": "next build",
"start": "next start",
"lint": "next lint",
"postinstall": "prisma migrate deploy && prisma generate"
"check-types": "tsc --noEmit",
"check-formatting": "prettier -c src",
"postinstall": "prisma migrate deploy && prisma generate",
"build-image": "./scripts/build-image.sh",
"start-container": "docker compose --env-file container.env up"
},
"dependencies": {
"@hookform/resolvers": "^3.3.2",
"@prisma/client": "5.6.0",
"@prisma/client": "^5.6.0",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-collapsible": "^1.0.3",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-hover-card": "^1.0.7",
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-radio-group": "^1.1.3",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-toast": "^1.1.5",
"@tailwindcss/typography": "^0.5.10",
"class-variance-authority": "^0.7.0",
"clsx": "^2.0.0",
"cmdk": "^0.2.0",
"content-disposition": "^0.5.4",
"dayjs": "^1.11.10",
"embla-carousel-react": "^8.0.0-rc21",
"lucide-react": "^0.290.0",
"nanoid": "^5.0.4",
"next": "14.0.1",
"next-plausible": "^3.12.0",
"next": "^14.1.0",
"next-s3-upload": "^0.3.4",
"next-themes": "^0.2.1",
"next13-progressbar": "^1.1.1",
"pg": "^8.11.3",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.47.0",
"sharp": "^0.33.2",
"tailwind-merge": "^1.14.0",
"tailwindcss-animate": "^1.0.7",
"ts-pattern": "^5.0.6",
"uuid": "^9.0.1",
"vaul": "^0.7.9",
"vaul": "^0.8.0",
"zod": "^3.22.4"
},
"devDependencies": {
"@total-typescript/ts-reset": "^0.5.1",
"@types/content-disposition": "^0.5.8",
"@types/node": "^20",
"@types/pg": "^8.10.9",
"@types/react": "^18",
"@types/react-dom": "^18",
"@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18",
"@types/uuid": "^9.0.6",
"autoprefixer": "^10",
"dotenv": "^16.3.1",
"eslint": "^8",
"eslint-config-next": "^14.0.4",
"eslint-config-next": "^14.1.0",
"postcss": "^8",
"prettier": "^3.0.3",
"prettier-plugin-organize-imports": "^3.2.3",

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "ExpensePaidFor" ADD COLUMN "shares" INTEGER NOT NULL DEFAULT 1;

View File

@@ -0,0 +1,5 @@
-- CreateEnum
CREATE TYPE "SplitMode" AS ENUM ('EVENLY', 'BY_SHARES', 'BY_PERCENTAGE', 'BY_AMOUNT');
-- AlterTable
ALTER TABLE "Expense" ADD COLUMN "splitMode" "SplitMode" NOT NULL DEFAULT 'EVENLY';

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Expense" ADD COLUMN "expenseDate" DATE NOT NULL DEFAULT CURRENT_DATE;

View File

@@ -0,0 +1,65 @@
/*
Warnings:
- Added the required column `categoryId` to the `Expense` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "Expense" ADD COLUMN "categoryId" INTEGER NOT NULL DEFAULT 0;
-- CreateTable
CREATE TABLE "Category" (
"id" SERIAL NOT NULL,
"grouping" TEXT NOT NULL,
"name" TEXT NOT NULL,
CONSTRAINT "Category_pkey" PRIMARY KEY ("id")
);
-- Insert categories
INSERT INTO "Category" ("id", "grouping", "name") VALUES (0, 'Uncategorized', 'General');
INSERT INTO "Category" ("id", "grouping", "name") VALUES (1, 'Uncategorized', 'Payment');
INSERT INTO "Category" ("id", "grouping", "name") VALUES (2, 'Entertainment', 'Entertainment');
INSERT INTO "Category" ("id", "grouping", "name") VALUES (3, 'Entertainment', 'Games');
INSERT INTO "Category" ("id", "grouping", "name") VALUES (4, 'Entertainment', 'Movies');
INSERT INTO "Category" ("id", "grouping", "name") VALUES (5, 'Entertainment', 'Music');
INSERT INTO "Category" ("id", "grouping", "name") VALUES (6, 'Entertainment', 'Sports');
INSERT INTO "Category" ("id", "grouping", "name") VALUES (7, 'Food and Drink', 'Food and Drink');
INSERT INTO "Category" ("id", "grouping", "name") VALUES (8, 'Food and Drink', 'Dining Out');
INSERT INTO "Category" ("id", "grouping", "name") VALUES (9, 'Food and Drink', 'Groceries');
INSERT INTO "Category" ("id", "grouping", "name") VALUES (10, 'Food and Drink', 'Liquor');
INSERT INTO "Category" ("id", "grouping", "name") VALUES (11, 'Home', 'Home');
INSERT INTO "Category" ("id", "grouping", "name") VALUES (12, 'Home', 'Electronics');
INSERT INTO "Category" ("id", "grouping", "name") VALUES (13, 'Home', 'Furniture');
INSERT INTO "Category" ("id", "grouping", "name") VALUES (14, 'Home', 'Household Supplies');
INSERT INTO "Category" ("id", "grouping", "name") VALUES (15, 'Home', 'Maintenance');
INSERT INTO "Category" ("id", "grouping", "name") VALUES (16, 'Home', 'Mortgage');
INSERT INTO "Category" ("id", "grouping", "name") VALUES (17, 'Home', 'Pets');
INSERT INTO "Category" ("id", "grouping", "name") VALUES (18, 'Home', 'Rent');
INSERT INTO "Category" ("id", "grouping", "name") VALUES (19, 'Home', 'Services');
INSERT INTO "Category" ("id", "grouping", "name") VALUES (20, 'Life', 'Childcare');
INSERT INTO "Category" ("id", "grouping", "name") VALUES (21, 'Life', 'Clothing');
INSERT INTO "Category" ("id", "grouping", "name") VALUES (22, 'Life', 'Education');
INSERT INTO "Category" ("id", "grouping", "name") VALUES (23, 'Life', 'Gifts');
INSERT INTO "Category" ("id", "grouping", "name") VALUES (24, 'Life', 'Insurance');
INSERT INTO "Category" ("id", "grouping", "name") VALUES (25, 'Life', 'Medical Expenses');
INSERT INTO "Category" ("id", "grouping", "name") VALUES (26, 'Life', 'Taxes');
INSERT INTO "Category" ("id", "grouping", "name") VALUES (27, 'Transportation', 'Transportation');
INSERT INTO "Category" ("id", "grouping", "name") VALUES (28, 'Transportation', 'Bicycle');
INSERT INTO "Category" ("id", "grouping", "name") VALUES (29, 'Transportation', 'Bus/Train');
INSERT INTO "Category" ("id", "grouping", "name") VALUES (30, 'Transportation', 'Car');
INSERT INTO "Category" ("id", "grouping", "name") VALUES (31, 'Transportation', 'Gas/Fuel');
INSERT INTO "Category" ("id", "grouping", "name") VALUES (32, 'Transportation', 'Hotel');
INSERT INTO "Category" ("id", "grouping", "name") VALUES (33, 'Transportation', 'Parking');
INSERT INTO "Category" ("id", "grouping", "name") VALUES (34, 'Transportation', 'Plane');
INSERT INTO "Category" ("id", "grouping", "name") VALUES (35, 'Transportation', 'Taxi');
INSERT INTO "Category" ("id", "grouping", "name") VALUES (36, 'Utilities', 'Utilities');
INSERT INTO "Category" ("id", "grouping", "name") VALUES (37, 'Utilities', 'Cleaning');
INSERT INTO "Category" ("id", "grouping", "name") VALUES (38, 'Utilities', 'Electricity');
INSERT INTO "Category" ("id", "grouping", "name") VALUES (39, 'Utilities', 'Heat/Gas');
INSERT INTO "Category" ("id", "grouping", "name") VALUES (40, 'Utilities', 'Trash');
INSERT INTO "Category" ("id", "grouping", "name") VALUES (41, 'Utilities', 'TV/Phone/Internet');
INSERT INTO "Category" ("id", "grouping", "name") VALUES (42, 'Utilities', 'Water');
-- AddForeignKey
ALTER TABLE "Expense" ADD CONSTRAINT "Expense_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Expense" ADD COLUMN "documentUrls" TEXT[] DEFAULT ARRAY[]::TEXT[];

View File

@@ -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[];

View File

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

View 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;

View File

@@ -29,17 +29,45 @@ model Participant {
expensesPaidFor ExpensePaidFor[]
}
model Category {
id Int @id @default(autoincrement())
grouping String
name String
Expense Expense[]
}
model Expense {
id String @id
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
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)
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)
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 {
EVENLY
BY_SHARES
BY_PERCENTAGE
BY_AMOUNT
}
model ExpensePaidFor {
@@ -47,6 +75,7 @@ model ExpensePaidFor {
participant Participant @relation(fields: [participantId], references: [id], onDelete: Cascade)
expenseId String
participantId String
shares Int @default(1)
@@id([expenseId, participantId])
}

15
scripts/build-image.sh Executable file
View File

@@ -0,0 +1,15 @@
#!/bin/bash
SPLIIT_APP_NAME=$(node -p -e "require('./package.json').name")
SPLIIT_VERSION=$(node -p -e "require('./package.json').version")
# we need to set dummy data for POSTGRES env vars in order for build not to fail
docker buildx build \
--no-cache \
--build-arg POSTGRES_PRISMA_URL=postgresql://build:@db \
--build-arg POSTGRES_URL_NON_POOLING=postgresql://build:@db \
-t ${SPLIIT_APP_NAME}:${SPLIIT_VERSION} \
-t ${SPLIIT_APP_NAME}:latest \
.
docker image prune -f

View File

@@ -0,0 +1,3 @@
#!/bin/bash
prisma migrate deploy
npm run start

View File

@@ -0,0 +1,11 @@
import { randomId } from '@/lib/api'
import { POST as route } from 'next-s3-upload/route'
export const POST = route.configure({
key(req, filename) {
const [, extension] = filename.match(/(\.[^\.]*)$/) ?? [null, '']
const timestamp = new Date().toISOString()
const random = randomId()
return `document-${timestamp}-${random}${extension.toLowerCase()}`
},
})

View File

@@ -1,3 +0,0 @@
export default function Default() {
return null
}

View File

@@ -1,83 +0,0 @@
'use client'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { useRouter } from 'next/navigation'
import { ReactNode, useEffect, useState } from 'react'
import { Drawer } from 'vaul'
type Props = {
children: ReactNode
title: ReactNode
}
export function ExpenseModal(props: Props) {
const size = useTailwindBreakpoint()
if (size === 'xs') {
return <ExpenseVaul {...props} />
} else {
return <ExpenseDialog {...props} />
}
}
export function ExpenseDialog({ children, title }: Props) {
const router = useRouter()
return (
<Dialog open onOpenChange={() => router.back()}>
<DialogContent className="w-full max-w-screen-sm">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
{children}
</DialogContent>
</Dialog>
)
}
export function ExpenseVaul({ children, title }: Props) {
const router = useRouter()
return (
<Drawer.Root open onClose={() => router.back()}>
<Drawer.Portal>
<Drawer.Title>{title}</Drawer.Title>
<Drawer.Overlay className="fixed inset-0 bg-background/80 backdrop-blur-sm" />
<Drawer.Content className="bg-background border flex flex-col rounded-t-[10px] max-h-[90dvh] mt-24 fixed bottom-0 left-0 right-0 z-50">
<div className="mx-auto w-12 h-1.5 flex-shrink-0 rounded-full bg-gray-300 dark:bg-gray-700 mt-4"></div>
<div className="text-xl font-bold p-4">{title}</div>
<div className="flex-1 overflow-y-auto p-4 pt-0">{children}</div>
</Drawer.Content>
</Drawer.Portal>
</Drawer.Root>
)
}
export function useTailwindBreakpoint() {
const [size, setSize] = useState<'xs' | 'sm' | 'md' | 'lg'>('xs')
useEffect(() => {
const handleBreakpointChange = () => {
if (window.innerWidth >= 1200) {
setSize('lg')
} else if (window.innerWidth >= 768) {
setSize('md')
} else if (window.innerWidth >= 640) {
setSize('sm')
} else {
setSize('xs')
}
}
window.addEventListener('resize', handleBreakpointChange)
handleBreakpointChange()
return () => {
window.removeEventListener('resize', handleBreakpointChange)
}
}, [])
return size
}

View File

@@ -1,26 +0,0 @@
import { ExpenseModal } from '@/app/groups/[groupId]/@modal/expense-modal'
import { ExpenseForm } from '@/components/expense-form'
import { getExpense, getGroup } from '@/lib/api'
import { Metadata } from 'next'
import { notFound } from 'next/navigation'
export const metadata: Metadata = {
title: 'Edit expense',
}
export default async function EditExpensePage({
params: { groupId, expenseId },
}: {
params: { groupId: string; expenseId: string }
}) {
const group = await getGroup(groupId)
if (!group) notFound()
const expense = await getExpense(groupId, expenseId)
if (!expense) notFound()
return (
<ExpenseModal title="Edit expense">
<ExpenseForm group={group} expense={expense} />
</ExpenseModal>
)
}

View File

@@ -1,24 +0,0 @@
import { ExpenseModal } from '@/app/groups/[groupId]/@modal/expense-modal'
import { ExpenseForm } from '@/components/expense-form'
import { getGroup } from '@/lib/api'
import { Metadata } from 'next'
import { notFound } from 'next/navigation'
export const metadata: Metadata = {
title: 'Create expense',
}
export default async function ExpensePage({
params: { groupId },
}: {
params: { groupId: string }
}) {
const group = await getGroup(groupId)
if (!group) notFound()
return (
<ExpenseModal title="Create expense">
<ExpenseForm group={group} />
</ExpenseModal>
)
}

View File

@@ -12,6 +12,8 @@ import { getBalances, getSuggestedReimbursements } from '@/lib/balances'
import { Metadata } from 'next'
import { notFound } from 'next/navigation'
export const dynamic = 'force-static'
export const metadata: Metadata = {
title: 'Balances',
}

View File

@@ -2,8 +2,11 @@ import { GroupForm } from '@/components/group-form'
import { getGroup, getGroupExpensesParticipants, updateGroup } from '@/lib/api'
import { groupFormSchema } from '@/lib/schemas'
import { Metadata } from 'next'
import { revalidatePath } from 'next/cache'
import { notFound, redirect } from 'next/navigation'
export const dynamic = 'force-static'
export const metadata: Metadata = {
title: 'Settings',
}
@@ -20,7 +23,11 @@ export default async function EditGroupPage({
'use server'
const groupFormValues = groupFormSchema.parse(values)
const group = await updateGroup(groupId, groupFormValues)
redirect(`/groups/${group.id}`)
revalidatePath(`/groups/${group.id}/expenses`)
revalidatePath(`/groups/${group.id}/expenses/create`)
revalidatePath(`/groups/${group.id}/balances`)
revalidatePath(`/groups/${group.id}/edit`)
redirect(`/groups/${group.id}/expenses`)
}
const protectedParticipantIds = await getGroupExpensesParticipants(groupId)

View File

@@ -0,0 +1,56 @@
import { ExpenseForm } from '@/components/expense-form'
import {
deleteExpense,
getCategories,
getExpense,
getGroup,
updateExpense,
} from '@/lib/api'
import { expenseFormSchema } from '@/lib/schemas'
import { Metadata } from 'next'
import { revalidatePath } from 'next/cache'
import { notFound, redirect } from 'next/navigation'
import { Suspense } from 'react'
export const metadata: Metadata = {
title: 'Edit expense',
}
export default async function EditExpensePage({
params: { groupId, expenseId },
}: {
params: { groupId: string; expenseId: string }
}) {
const categories = await getCategories()
const group = await getGroup(groupId)
if (!group) notFound()
const expense = await getExpense(groupId, expenseId)
if (!expense) notFound()
async function updateExpenseAction(values: unknown) {
'use server'
const expenseFormValues = expenseFormSchema.parse(values)
await updateExpense(groupId, expenseId, expenseFormValues)
revalidatePath(`/groups/${groupId}/expenses`)
revalidatePath(`/groups/${groupId}/balances`)
redirect(`/groups/${groupId}/expenses`)
}
async function deleteExpenseAction() {
'use server'
await deleteExpense(expenseId)
redirect(`/groups/${groupId}`)
}
return (
<Suspense>
<ExpenseForm
group={group}
expense={expense}
categories={categories}
onSubmit={updateExpenseAction}
onDelete={deleteExpenseAction}
/>
</Suspense>
)
}

View File

@@ -1,28 +0,0 @@
'use server'
import { createExpense, deleteExpense, updateExpense } from '@/lib/api'
import { expenseFormSchema } from '@/lib/schemas'
import { revalidatePath } from 'next/cache'
export async function createExpenseAction(groupId: string, values: unknown) {
'use server'
const expenseFormValues = expenseFormSchema.parse(values)
await createExpense(expenseFormValues, groupId)
revalidatePath(`/groups/${groupId}`, 'layout')
}
export async function updateExpenseAction(
groupId: string,
expenseId: string,
values: unknown,
) {
'use server'
const expenseFormValues = expenseFormSchema.parse(values)
await updateExpense(groupId, expenseId, expenseFormValues)
revalidatePath(`/groups/${groupId}`, 'layout')
}
export async function deleteExpenseAction(groupId: string, expenseId: string) {
'use server'
await deleteExpense(expenseId)
revalidatePath(`/groups/${groupId}`, 'layout')
}

View File

@@ -0,0 +1,134 @@
'use client'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
} from '@/components/ui/drawer'
import { Label } from '@/components/ui/label'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { getGroup } from '@/lib/api'
import { useMediaQuery } from '@/lib/hooks'
import { cn } from '@/lib/utils'
import { ComponentProps, useEffect, useState } from 'react'
type Props = {
group: NonNullable<Awaited<ReturnType<typeof getGroup>>>
}
export function ActiveUserModal({ group }: Props) {
const [open, setOpen] = useState(false)
const isDesktop = useMediaQuery('(min-width: 768px)')
useEffect(() => {
const tempUser = localStorage.getItem(`newGroup-activeUser`)
const activeUser = localStorage.getItem(`${group.id}-activeUser`)
if (!tempUser && !activeUser) {
setOpen(true)
}
}, [group])
function updateOpen(open: boolean) {
if (!open && !localStorage.getItem(`${group.id}-activeUser`)) {
localStorage.setItem(`${group.id}-activeUser`, 'None')
}
setOpen(open)
}
if (isDesktop) {
return (
<Dialog open={open} onOpenChange={updateOpen}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Who are you?</DialogTitle>
<DialogDescription>
Tell us which participant you are to let us customize how the
information is displayed.
</DialogDescription>
</DialogHeader>
<ActiveUserForm group={group} close={() => setOpen(false)} />
<DialogFooter className="sm:justify-center">
<p className="text-sm text-center text-muted-foreground">
This setting can be changed later in the group settings.
</p>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
return (
<Drawer open={open} onOpenChange={updateOpen}>
<DrawerContent>
<DrawerHeader className="text-left">
<DrawerTitle>Who are you?</DrawerTitle>
<DrawerDescription>
Tell us which participant you are to let us customize how the
information is displayed.
</DrawerDescription>
</DrawerHeader>
<ActiveUserForm
className="px-4"
group={group}
close={() => setOpen(false)}
/>
<DrawerFooter className="pt-2">
<p className="text-sm text-center text-muted-foreground">
This setting can be changed later in the group settings.
</p>
</DrawerFooter>
</DrawerContent>
</Drawer>
)
}
function ActiveUserForm({
group,
close,
className,
}: ComponentProps<'form'> & { group: Props['group']; close: () => void }) {
const [selected, setSelected] = useState('None')
return (
<form
className={cn('grid items-start gap-4', className)}
onSubmit={(event) => {
event.preventDefault()
localStorage.setItem(`${group.id}-activeUser`, selected)
close()
}}
>
<RadioGroup defaultValue="none" onValueChange={setSelected}>
<div className="flex flex-col gap-4 my-4">
<div className="flex items-center space-x-2">
<RadioGroupItem value="none" id="none" />
<Label htmlFor="none" className="italic font-normal flex-1">
I dont want to select anyone
</Label>
</div>
{group.participants.map((participant) => (
<div key={participant.id} className="flex items-center space-x-2">
<RadioGroupItem value={participant.id} id={participant.id} />
<Label htmlFor={participant.id} className="flex-1">
{participant.name}
</Label>
</div>
))}
</div>
</RadioGroup>
<Button type="submit">Save changes</Button>
</form>
)
}

View File

@@ -0,0 +1,144 @@
import { Category } from '@prisma/client'
import {
Armchair,
Baby,
Banknote,
Bike,
Bus,
Car,
CarTaxiFront,
Cat,
Clapperboard,
CupSoda,
Dices,
Dumbbell,
Eraser,
FerrisWheel,
Fuel,
Gift,
Home,
Hotel,
Lamp,
Landmark,
LibraryBig,
LucideIcon,
LucideProps,
Martini,
Music,
ParkingMeter,
Phone,
PiggyBank,
Plane,
Plug,
PlugZap,
Shirt,
ShoppingCart,
Stethoscope,
ThermometerSun,
Train,
Trash,
Utensils,
Wine,
Wrench,
} from 'lucide-react'
export function CategoryIcon({
category,
...props
}: { category: Category | null } & LucideProps) {
const Icon = getCategoryIcon(`${category?.grouping}/${category?.name}`)
return <Icon {...props} />
}
function getCategoryIcon(category: string): LucideIcon {
switch (category) {
case 'Uncategorized/General':
return Banknote
case 'Uncategorized/Payment':
return Banknote
case 'Entertainment/Entertainment':
return FerrisWheel
case 'Entertainment/Games':
return Dices
case 'Entertainment/Movies':
return Clapperboard
case 'Entertainment/Music':
return Music
case 'Entertainment/Sports':
return Dumbbell
case 'Food and Drink/Food and Drink':
return Utensils
case 'Food and Drink/Dining Out':
return Martini
case 'Food and Drink/Groceries':
return ShoppingCart
case 'Food and Drink/Liquor':
return Wine
case 'Home/Home':
return Home
case 'Home/Electronics':
return Plug
case 'Home/Furniture':
return Armchair
case 'Home/Household Supplies':
return Lamp
case 'Home/Maintenance':
return Wrench
case 'Home/Mortgage':
return Landmark
case 'Home/Pets':
return Cat
case 'Home/Rent':
return PiggyBank
case 'Home/Services':
return Wrench
case 'Life/Childcare':
return Baby
case 'Life/Clothing':
return Shirt
case 'Life/Education':
return LibraryBig
case 'Life/Gifts':
return Gift
case 'Life/Insurance':
return Landmark
case 'Life/Medical Expenses':
return Stethoscope
case 'Life/Taxes':
return Banknote
case 'Transportation/Transportation':
return Bus
case 'Transportation/Bicycle':
return Bike
case 'Transportation/Bus/Train':
return Train
case 'Transportation/Car':
return Car
case 'Transportation/Gas/Fuel':
return Fuel
case 'Transportation/Hotel':
return Hotel
case 'Transportation/Parking':
return ParkingMeter
case 'Transportation/Plane':
return Plane
case 'Transportation/Taxi':
return CarTaxiFront
case 'Utilities/Utilities':
return Banknote
case 'Utilities/Cleaning':
return Eraser
case 'Utilities/Electricity':
return PlugZap
case 'Utilities/Heat/Gas':
return ThermometerSun
case 'Utilities/Trash':
return Trash
case 'Utilities/TV/Phone/Internet':
return Phone
case 'Utilities/Water':
return CupSoda
default:
return Banknote
}
}

View File

@@ -0,0 +1,39 @@
import { ExpenseForm } from '@/components/expense-form'
import { createExpense, getCategories, getGroup } from '@/lib/api'
import { expenseFormSchema } from '@/lib/schemas'
import { Metadata } from 'next'
import { notFound, redirect } from 'next/navigation'
import { Suspense } from 'react'
export const dynamic = 'force-static'
export const metadata: Metadata = {
title: 'Create expense',
}
export default async function ExpensePage({
params: { groupId },
}: {
params: { groupId: string }
}) {
const categories = await getCategories()
const group = await getGroup(groupId)
if (!group) notFound()
async function createExpenseAction(values: unknown) {
'use server'
const expenseFormValues = expenseFormSchema.parse(values)
await createExpense(expenseFormValues, groupId)
redirect(`/groups/${groupId}`)
}
return (
<Suspense>
<ExpenseForm
group={group}
categories={categories}
onSubmit={createExpenseAction}
/>
</Suspense>
)
}

View File

@@ -1,12 +1,15 @@
'use client'
import { CategoryIcon } from '@/app/groups/[groupId]/expenses/category-icon'
import { Button } from '@/components/ui/button'
import { SearchBar } from '@/components/ui/search-bar'
import { getGroupExpenses } from '@/lib/api'
import { cn } from '@/lib/utils'
import { Participant } from '@prisma/client'
import { Expense, Participant } from '@prisma/client'
import dayjs, { type Dayjs } from 'dayjs'
import { ChevronRight } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { Fragment } from 'react'
import { Fragment, useEffect, useState } from 'react'
type Props = {
expenses: Awaited<ReturnType<typeof getGroupExpenses>>
@@ -15,69 +18,166 @@ type Props = {
groupId: string
}
const EXPENSE_GROUPS = {
THIS_WEEK: 'This week',
EARLIER_THIS_MONTH: 'Earlier this month',
LAST_MONTH: 'Last month',
EARLIER_THIS_YEAR: 'Earlier this year',
LAST_YEAR: 'Last year',
OLDER: 'Older',
}
function getExpenseGroup(date: Dayjs, today: Dayjs) {
if (today.isSame(date, 'week')) {
return EXPENSE_GROUPS.THIS_WEEK
} else if (today.isSame(date, 'month')) {
return EXPENSE_GROUPS.EARLIER_THIS_MONTH
} else if (today.subtract(1, 'month').isSame(date, 'month')) {
return EXPENSE_GROUPS.LAST_MONTH
} else if (today.isSame(date, 'year')) {
return EXPENSE_GROUPS.EARLIER_THIS_YEAR
} else if (today.subtract(1, 'year').isSame(date, 'year')) {
return EXPENSE_GROUPS.LAST_YEAR
} else {
return EXPENSE_GROUPS.OLDER
}
}
function getGroupedExpensesByDate(
expenses: Awaited<ReturnType<typeof getGroupExpenses>>,
) {
const today = dayjs()
return expenses.reduce(
(result: { [key: string]: Expense[] }, expense: Expense) => {
const expenseGroup = getExpenseGroup(dayjs(expense.expenseDate), today)
result[expenseGroup] = result[expenseGroup] ?? []
result[expenseGroup].push(expense)
return result
},
{},
)
}
export function ExpenseList({
expenses,
currency,
participants,
groupId,
}: Props) {
const [searchText, setSearchText] = useState('')
useEffect(() => {
const activeUser = localStorage.getItem('newGroup-activeUser')
const newUser = localStorage.getItem(`${groupId}-newUser`)
if (activeUser || newUser) {
localStorage.removeItem('newGroup-activeUser')
localStorage.removeItem(`${groupId}-newUser`)
if (activeUser === 'None') {
localStorage.setItem(`${groupId}-activeUser`, 'None')
} else {
const userId = participants.find(
(p) => p.name === (activeUser || newUser),
)?.id
if (userId) {
localStorage.setItem(`${groupId}-activeUser`, userId)
}
}
}
}, [groupId, participants])
const getParticipant = (id: string) => participants.find((p) => p.id === id)
const router = useRouter()
const groupedExpensesByDate = getGroupedExpensesByDate(expenses)
return expenses.length > 0 ? (
expenses.map((expense) => (
<div
key={expense.id}
className={cn(
'border-t flex justify-between pl-6 pr-2 py-4 text-sm cursor-pointer hover:bg-accent',
expense.isReimbursement && 'italic',
)}
onClick={() => {
router.push(`/groups/${groupId}/expenses/${expense.id}/edit`, {
scroll: false,
})
}}
>
<div>
<div className={cn('mb-1', expense.isReimbursement && 'italic')}>
{expense.title}
</div>
<div className="text-xs text-muted-foreground">
Paid by <strong>{getParticipant(expense.paidById)?.name}</strong>{' '}
for{' '}
{expense.paidFor.map((paidFor, index) => (
<Fragment key={index}>
{index !== 0 && <>, </>}
<strong>
{
participants.find((p) => p.id === paidFor.participantId)
?.name
}
</strong>
</Fragment>
<>
<SearchBar onChange={(e) => setSearchText(e.target.value)} />
{Object.values(EXPENSE_GROUPS).map((expenseGroup: string) => {
let groupExpenses = groupedExpensesByDate[expenseGroup]
if (!groupExpenses) return null
groupExpenses = groupExpenses.filter(({ title }) =>
title.toLowerCase().includes(searchText.toLowerCase()),
)
if (groupExpenses.length === 0) return null
return (
<div key={expenseGroup}>
<div
className={
'text-muted-foreground text-xs pl-4 sm:pl-6 py-1 font-semibold sticky top-16 bg-white dark:bg-[#1b1917]'
}
>
{expenseGroup}
</div>
{groupExpenses.map((expense: any) => (
<div
key={expense.id}
className={cn(
'flex justify-between sm:mx-6 px-4 sm:rounded-lg sm:pr-2 sm:pl-4 py-4 text-sm cursor-pointer hover:bg-accent gap-1 items-stretch',
expense.isReimbursement && 'italic',
)}
onClick={() => {
router.push(`/groups/${groupId}/expenses/${expense.id}/edit`)
}}
>
<CategoryIcon
category={expense.category}
className="w-4 h-4 mr-2 mt-0.5 text-muted-foreground"
/>
<div className="flex-1">
<div
className={cn('mb-1', expense.isReimbursement && 'italic')}
>
{expense.title}
</div>
<div className="text-xs text-muted-foreground">
Paid by{' '}
<strong>{getParticipant(expense.paidById)?.name}</strong>{' '}
for{' '}
{expense.paidFor.map((paidFor: any, index: number) => (
<Fragment key={index}>
{index !== 0 && <>, </>}
<strong>
{
participants.find(
(p) => p.id === paidFor.participantId,
)?.name
}
</strong>
</Fragment>
))}
</div>
</div>
<div className="flex flex-col justify-between items-end">
<div
className={cn(
'tabular-nums whitespace-nowrap',
expense.isReimbursement ? 'italic' : 'font-bold',
)}
>
{currency} {(expense.amount / 100).toFixed(2)}
</div>
<div className="text-xs text-muted-foreground">
{formatDate(expense.expenseDate)}
</div>
</div>
<Button
size="icon"
variant="link"
className="self-center hidden sm:flex"
asChild
>
<Link href={`/groups/${groupId}/expenses/${expense.id}/edit`}>
<ChevronRight className="w-4 h-4" />
</Link>
</Button>
</div>
))}
</div>
</div>
<div className="flex items-center">
<div
className={cn(
'tabular-nums whitespace-nowrap',
expense.isReimbursement ? 'italic' : 'font-bold',
)}
>
{currency} {(expense.amount / 100).toFixed(2)}
</div>
<Button size="icon" variant="link" className="-my-2" asChild>
<Link
href={`/groups/${groupId}/expenses/${expense.id}/edit`}
scroll={false}
>
<ChevronRight className="w-4 h-4" />
</Link>
</Button>
</div>
</div>
))
)
})}
</>
) : (
<p className="px-6 text-sm py-6">
Your group doesnt contain any expense yet.{' '}
@@ -89,3 +189,10 @@ export function ExpenseList({
</p>
)
}
function formatDate(date: Date) {
return date.toLocaleDateString('en-US', {
dateStyle: 'medium',
timeZone: 'UTC',
})
}

View File

@@ -1,19 +0,0 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { ReactNode } from 'react'
export function ExpensePage({
children,
title,
}: {
children: ReactNode
title: ReactNode
}) {
return (
<Card>
<CardHeader>
<CardTitle>{title}</CardTitle>
</CardHeader>
<CardContent>{children}</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,42 @@
import { getPrisma } from '@/lib/prisma'
import contentDisposition from 'content-disposition'
import { NextResponse } from 'next/server'
export async function GET(
req: Request,
{ params: { groupId } }: { params: { groupId: string } },
) {
const prisma = await getPrisma()
const group = await prisma.group.findUnique({
where: { id: groupId },
select: {
id: true,
name: true,
currency: true,
expenses: {
select: {
expenseDate: true,
title: true,
category: { select: { grouping: true, name: true } },
amount: true,
paidById: true,
paidFor: { select: { participantId: true, shares: true } },
isReimbursement: true,
splitMode: true,
},
},
participants: { select: { id: true, name: true } },
},
})
if (!group)
return NextResponse.json({ error: 'Invalid group ID' }, { status: 404 })
const date = new Date().toISOString().split('T')[0]
const filename = `Spliit Export - ${group.name} - ${date}`
return NextResponse.json(group, {
headers: {
'content-type': 'application/json',
'content-disposition': contentDisposition(`${filename}.json`),
},
})
}

View File

@@ -1,9 +0,0 @@
import { ReactNode } from 'react'
export default function GroupExpensesLayout({
children,
}: {
children: ReactNode
}) {
return <>{children}</>
}

View File

@@ -1,3 +1,4 @@
import { ActiveUserModal } from '@/app/groups/[groupId]/expenses/active-user-modal'
import { ExpenseList } from '@/app/groups/[groupId]/expenses/expense-list'
import { Button } from '@/components/ui/button'
import {
@@ -9,12 +10,14 @@ import {
} from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { getGroup, getGroupExpenses } from '@/lib/api'
import { Plus } from 'lucide-react'
import { Download, Plus } from 'lucide-react'
import { Metadata } from 'next'
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { Suspense } from 'react'
export const dynamic = 'force-static'
export const metadata: Metadata = {
title: 'Expenses',
}
@@ -24,45 +27,61 @@ export default async function GroupExpensesPage({
}: {
params: { groupId: string }
}) {
return (
<Card className="mb-4">
<div className="flex flex-1">
<CardHeader className="flex-1">
<CardTitle>Expenses</CardTitle>
<CardDescription>
Here are the expenses that you created for your group.
</CardDescription>
</CardHeader>
<CardHeader>
<Button asChild size="icon">
<Link href={`/groups/${groupId}/expenses/create`} scroll={false}>
<Plus />
</Link>
</Button>
</CardHeader>
</div>
const group = await getGroup(groupId)
if (!group) notFound()
<CardContent className="p-0">
<Suspense
fallback={[0, 1, 2].map((i) => (
<div
key={i}
className="border-t flex justify-between items-center px-6 py-4 text-sm"
>
<div className="flex flex-col gap-2">
<Skeleton className="h-4 w-16 rounded-full" />
<Skeleton className="h-4 w-32 rounded-full" />
return (
<>
<Card className="mb-4 rounded-none -mx-4 border-x-0 sm:border-x sm:rounded-lg sm:mx-0">
<div className="flex flex-1">
<CardHeader className="flex-1 p-4 sm:p-6">
<CardTitle>Expenses</CardTitle>
<CardDescription>
Here are the expenses that you created for your group.
</CardDescription>
</CardHeader>
<CardHeader className="p-4 sm:p-6 flex flex-row space-y-0 gap-2">
<Button variant="secondary" size="icon" asChild>
<Link
prefetch={false}
href={`/groups/${groupId}/expenses/export/json`}
target="_blank"
>
<Download className="w-4 h-4" />
</Link>
</Button>
<Button asChild size="icon">
<Link href={`/groups/${groupId}/expenses/create`}>
<Plus className="w-4 h-4" />
</Link>
</Button>
</CardHeader>
</div>
<CardContent className="p-0 pt-2 pb-4 sm:pb-6 flex flex-col gap-4 relative">
<Suspense
fallback={[0, 1, 2].map((i) => (
<div
key={i}
className="border-t flex justify-between items-center px-6 py-4 text-sm"
>
<div className="flex flex-col gap-2">
<Skeleton className="h-4 w-16 rounded-full" />
<Skeleton className="h-4 w-32 rounded-full" />
</div>
<div>
<Skeleton className="h-4 w-16 rounded-full" />
</div>
</div>
<div>
<Skeleton className="h-4 w-16 rounded-full" />
</div>
</div>
))}
>
<Expenses groupId={groupId} />
</Suspense>
</CardContent>
</Card>
))}
>
<Expenses groupId={groupId} />
</Suspense>
</CardContent>
</Card>
<ActiveUserModal group={group} />
</>
)
}

View File

@@ -5,13 +5,12 @@ import { getGroup } from '@/lib/api'
import { Metadata } from 'next'
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { PropsWithChildren, ReactNode } from 'react'
import { PropsWithChildren, Suspense } from 'react'
type Props = {
params: {
groupId: string
}
modal: ReactNode
}
export async function generateMetadata({
@@ -29,7 +28,6 @@ export async function generateMetadata({
export default async function GroupLayout({
children,
modal,
params: { groupId },
}: PropsWithChildren<Props>) {
const group = await getGroup(groupId)
@@ -43,13 +41,14 @@ export default async function GroupLayout({
</h1>
<div className="flex gap-2 justify-between">
<GroupTabs groupId={groupId} />
<Suspense>
<GroupTabs groupId={groupId} />
</Suspense>
<ShareButton group={group} />
</div>
</div>
{children}
{modal}
<SaveGroupLocally group={{ id: group.id, name: group.name }} />
</>

View File

@@ -1,5 +0,0 @@
'use client'
export default function NotFound() {
return null
}

View File

@@ -1,5 +1,7 @@
import { redirect } from 'next/navigation'
export const dynamic = 'force-static'
export default async function GroupPage({
params: { groupId },
}: {

View File

@@ -37,7 +37,6 @@ export function ReimbursementList({
<Button variant="link" asChild className="-mx-4 -my-3">
<Link
href={`/groups/${groupId}/expenses/create?reimbursement=yes&from=${reimbursement.from}&to=${reimbursement.to}&amount=${reimbursement.amount}`}
scroll={false}
>
Mark as paid
</Link>

View File

@@ -1,3 +1,4 @@
'use client'
import { CopyButton } from '@/components/copy-button'
import { ShareUrlButton } from '@/components/share-url-button'
import { Button } from '@/components/ui/button'
@@ -7,7 +8,7 @@ import {
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { env } from '@/lib/env'
import { useBaseUrl } from '@/lib/hooks'
import { Group } from '@prisma/client'
import { Share } from 'lucide-react'
@@ -16,7 +17,8 @@ type Props = {
}
export function ShareButton({ group }: Props) {
const url = `${env.NEXT_PUBLIC_BASE_URL}/groups/${group.id}/expenses?ref=share`
const baseUrl = useBaseUrl()
const url = baseUrl && `${baseUrl}/groups/${group.id}/expenses?ref=share`
return (
<Popover>
@@ -30,14 +32,16 @@ export function ShareButton({ group }: Props) {
For other participants to see the group and add expenses, share its
URL with them.
</p>
<div className="flex gap-2">
<Input className="flex-1" defaultValue={url} readOnly />
<CopyButton text={url} />
<ShareUrlButton
text={`Join my group ${group.name} on Spliit`}
url={url}
/>
</div>
{url && (
<div className="flex gap-2">
<Input className="flex-1" defaultValue={url} readOnly />
<CopyButton text={url} />
<ShareUrlButton
text={`Join my group ${group.name} on Spliit`}
url={url}
/>
</div>
)}
<p>
<strong>Warning!</strong> Every person with the group URL will be able
to see and edit expenses. Share with caution!

View File

@@ -1,5 +1,5 @@
'use server'
import { getGroups } from "@/lib/api"
import { getGroups } from '@/lib/api'
export async function getGroupsAction(groupIds: string[]) {
'use server'

View File

@@ -0,0 +1,8 @@
'use server'
import { getGroup } from '@/lib/api'
export async function getGroupInfoAction(groupId: string) {
'use server'
return getGroup(groupId)
}

View File

@@ -0,0 +1,91 @@
import { getGroupInfoAction } from '@/app/groups/add-group-by-url-button-actions'
import { saveRecentGroup } from '@/app/groups/recent-groups-helpers'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { useMediaQuery } from '@/lib/hooks'
import { Loader2, Plus } from 'lucide-react'
import { useState } from 'react'
type Props = {
reload: () => void
}
export function AddGroupByUrlButton({ reload }: Props) {
const isDesktop = useMediaQuery('(min-width: 640px)')
const [url, setUrl] = useState('')
const [error, setError] = useState(false)
const [open, setOpen] = useState(false)
const [pending, setPending] = useState(false)
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="secondary">
{/* <Plus className="w-4 h-4 mr-2" /> */}
<>Add by URL</>
</Button>
</PopoverTrigger>
<PopoverContent
align={isDesktop ? 'end' : 'start'}
className="[&_p]:text-sm flex flex-col gap-3"
>
<h3 className="font-bold">Add a group by URL</h3>
<p>
If a group was shared with you, you can paste its URL here to add it
to your list.
</p>
<form
className="flex gap-2"
onSubmit={async (event) => {
event.preventDefault()
const [, groupId] =
url.match(
new RegExp(`${window.location.origin}/groups/([^/]+)`),
) ?? []
setPending(true)
const group = groupId ? await getGroupInfoAction(groupId) : null
setPending(false)
if (!group) {
setError(true)
} else {
saveRecentGroup({ id: group.id, name: group.name })
reload()
setUrl('')
setOpen(false)
}
}}
>
<Input
type="url"
required
placeholder="https://spliit.app/..."
className="flex-1 text-base"
value={url}
disabled={pending}
onChange={(event) => {
setUrl(event.target.value)
setError(false)
}}
/>
<Button size="icon" type="submit" disabled={pending}>
{pending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Plus className="w-4 h-4" />
)}
</Button>
</form>
{error && (
<p className="text-destructive">
Oops, we are not able to find the group from the URL you provided
</p>
)}
</PopoverContent>
</Popover>
)
}

View File

@@ -1,9 +1,11 @@
import { PropsWithChildren } from 'react'
import { PropsWithChildren, Suspense } from 'react'
export default function GroupsLayout({ children }: PropsWithChildren<{}>) {
return (
<main className="flex-1 max-w-screen-md w-full mx-auto px-4 py-6 flex flex-col gap-6">
{children}
</main>
<Suspense>
<main className="flex-1 max-w-screen-md w-full mx-auto px-4 py-6 flex flex-col gap-6">
{children}
</main>
</Suspense>
)
}

View File

@@ -1,28 +1,10 @@
import { RecentGroupList } from '@/app/groups/recent-group-list'
import { Button } from '@/components/ui/button'
import { Plus } from 'lucide-react'
import { Metadata } from 'next'
import Link from 'next/link'
export const metadata: Metadata = {
title: 'Recently visited groups',
}
export default async function GroupsPage() {
return (
<>
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 items-start">
<h1 className="font-bold text-2xl">
<Link href="/groups">Recently visited groups</Link>
</h1>
<Button asChild>
<Link href="/groups/create">
<Plus className="w-4 h-4 mr-2" />
Create group
</Link>
</Button>
</div>
<RecentGroupList />
</>
)
return <RecentGroupList />
}

View File

@@ -0,0 +1,189 @@
'use client'
import { RecentGroupsState } from '@/app/groups/recent-group-list'
import {
RecentGroup,
archiveGroup,
deleteRecentGroup,
getArchivedGroups,
getStarredGroups,
saveRecentGroup,
starGroup,
unarchiveGroup,
unstarGroup,
} from '@/app/groups/recent-groups-helpers'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Skeleton } from '@/components/ui/skeleton'
import { ToastAction } from '@/components/ui/toast'
import { useToast } from '@/components/ui/use-toast'
import { StarFilledIcon } from '@radix-ui/react-icons'
import { Calendar, MoreHorizontal, Star, Users } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { SetStateAction } from 'react'
export function RecentGroupListCard({
group,
state,
setState,
}: {
group: RecentGroup
state: RecentGroupsState
setState: (state: SetStateAction<RecentGroupsState>) => void
}) {
const router = useRouter()
const toast = useToast()
const details =
state.status === 'complete'
? state.groupsDetails.find((d) => d.id === group.id)
: null
if (state.status === 'pending') return null
const refreshGroupsFromStorage = () =>
setState({
...state,
starredGroups: getStarredGroups(),
archivedGroups: getArchivedGroups(),
})
const isStarred = state.starredGroups.includes(group.id)
const isArchived = state.archivedGroups.includes(group.id)
return (
<li key={group.id}>
<Button
variant="secondary"
className="h-fit w-full py-3 rounded-lg border bg-card shadow-sm"
asChild
>
<div
className="text-base"
onClick={() => router.push(`/groups/${group.id}`)}
>
<div className="w-full flex flex-col gap-1">
<div className="text-base flex gap-2 justify-between">
<Link
href={`/groups/${group.id}`}
className="flex-1 overflow-hidden text-ellipsis"
>
{group.name}
</Link>
<span className="flex-shrink-0">
<Button
size="icon"
variant="ghost"
className="-my-3 -ml-3 -mr-1.5"
onClick={(event) => {
event.stopPropagation()
if (isStarred) {
unstarGroup(group.id)
} else {
starGroup(group.id)
unarchiveGroup(group.id)
}
refreshGroupsFromStorage()
}}
>
{isStarred ? (
<StarFilledIcon className="w-4 h-4 text-orange-400" />
) : (
<Star className="w-4 h-4 text-muted-foreground" />
)}
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="ghost"
className="-my-3 -mr-3 -ml-1.5"
>
<MoreHorizontal className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
className="text-destructive"
onClick={(event) => {
event.stopPropagation()
deleteRecentGroup(group)
setState({
...state,
groups: state.groups.filter((g) => g.id !== group.id),
})
toast.toast({
title: 'Group has been removed',
description:
'The group was removed from your recent groups list.',
action: (
<ToastAction
altText="Undo group removal"
onClick={() => {
saveRecentGroup(group)
setState({
...state,
groups: state.groups,
})
}}
>
Undo
</ToastAction>
),
})
}}
>
Remove from recent groups
</DropdownMenuItem>
<DropdownMenuItem
onClick={(event) => {
event.stopPropagation()
if (isArchived) {
unarchiveGroup(group.id)
} else {
archiveGroup(group.id)
unstarGroup(group.id)
}
refreshGroupsFromStorage()
}}
>
{isArchived ? <>Unarchive group</> : <>Archive group</>}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</span>
</div>
<div className="text-muted-foreground font-normal text-xs">
{details ? (
<div className="w-full flex items-center justify-between">
<div className="flex items-center">
<Users className="w-3 h-3 inline mr-1" />
<span>{details._count.participants}</span>
</div>
<div className="flex items-center">
<Calendar className="w-3 h-3 inline mx-1" />
<span>
{new Date(details.createdAt).toLocaleDateString('en-US', {
dateStyle: 'medium',
})}
</span>
</div>
</div>
) : (
<div className="flex justify-between">
<Skeleton className="h-4 w-6 rounded-full" />
<Skeleton className="h-4 w-24 rounded-full" />
</div>
)}
</div>
</div>
</div>
</Button>
</li>
)
}

View File

@@ -1,114 +1,194 @@
'use client'
import { getGroupsAction } from '@/app/groups/actions'
import { getRecentGroups } from '@/app/groups/recent-groups-helpers'
import { AddGroupByUrlButton } from '@/app/groups/add-group-by-url-button'
import {
RecentGroups,
getArchivedGroups,
getRecentGroups,
getStarredGroups,
} from '@/app/groups/recent-groups-helpers'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { getGroups } from '@/lib/api'
import { Calendar, Loader2, Users } from 'lucide-react'
import { Loader2 } from 'lucide-react'
import Link from 'next/link'
import { useEffect, useState } from 'react'
import { z } from 'zod'
import { PropsWithChildren, SetStateAction, useEffect, useState } from 'react'
import { RecentGroupListCard } from './recent-group-list-card'
const recentGroupsSchema = z.array(
z.object({
id: z.string().min(1),
name: z.string(),
}),
)
type RecentGroups = z.infer<typeof recentGroupsSchema>
type State =
export type RecentGroupsState =
| { status: 'pending' }
| { status: 'partial'; groups: RecentGroups }
| {
status: 'partial'
groups: RecentGroups
starredGroups: string[]
archivedGroups: string[]
}
| {
status: 'complete'
groups: RecentGroups
groupsDetails: Awaited<ReturnType<typeof getGroups>>
starredGroups: string[]
archivedGroups: string[]
}
type Props = {
getGroupsAction: (groupIds: string[]) => ReturnType<typeof getGroups>
function sortGroups(
state: RecentGroupsState & { status: 'complete' | 'partial' },
) {
const starredGroupInfo = []
const groupInfo = []
const archivedGroupInfo = []
for (const group of state.groups) {
if (state.starredGroups.includes(group.id)) {
starredGroupInfo.push(group)
} else if (state.archivedGroups.includes(group.id)) {
archivedGroupInfo.push(group)
} else {
groupInfo.push(group)
}
}
return {
starredGroupInfo,
groupInfo,
archivedGroupInfo,
}
}
export function RecentGroupList() {
const [state, setState] = useState<State>({ status: 'pending' })
const [state, setState] = useState<RecentGroupsState>({ status: 'pending' })
function loadGroups() {
const groupsInStorage = getRecentGroups()
const starredGroups = getStarredGroups()
const archivedGroups = getArchivedGroups()
setState({
status: 'partial',
groups: groupsInStorage,
starredGroups,
archivedGroups,
})
getGroupsAction(groupsInStorage.map((g) => g.id)).then((groupsDetails) => {
setState({
status: 'complete',
groups: groupsInStorage,
groupsDetails,
starredGroups,
archivedGroups,
})
})
}
useEffect(() => {
const groupsInStorage = getRecentGroups()
setState({ status: 'partial', groups: groupsInStorage })
getGroupsAction(groupsInStorage.map((g) => g.id)).then((groupsDetails) => {
setState({ status: 'complete', groups: groupsInStorage, groupsDetails })
})
loadGroups()
}, [])
if (state.status === 'pending') {
return (
<p>
<Loader2 className="w-4 m-4 mr-2 inline animate-spin" /> Loading recent
groups
</p>
<GroupsPage reload={loadGroups}>
<p>
<Loader2 className="w-4 m-4 mr-2 inline animate-spin" /> Loading
recent groups
</p>
</GroupsPage>
)
}
if (state.groups.length === 0) {
return (
<div className="text-sm space-y-2">
<p>You have not visited any group recently.</p>
<p>
<Button variant="link" asChild className="-m-4">
<Link href={`/groups/create`}>Create one</Link>
</Button>{' '}
or ask a friend to send you the link to an existing one.
</p>
</div>
<GroupsPage reload={loadGroups}>
<div className="text-sm space-y-2">
<p>You have not visited any group recently.</p>
<p>
<Button variant="link" asChild className="-m-4">
<Link href={`/groups/create`}>Create one</Link>
</Button>{' '}
or ask a friend to send you the link to an existing one.
</p>
</div>
</GroupsPage>
)
}
const { starredGroupInfo, groupInfo, archivedGroupInfo } = sortGroups(state)
return (
<ul className="grid grid-cols-1 gap-2 sm:grid-cols-3">
{state.groups.map((group) => {
const details =
state.status === 'complete'
? state.groupsDetails.find((d) => d.id === group.id)
: null
return (
<li key={group.id}>
<Button variant="outline" className="h-fit w-full py-3" asChild>
<Link href={`/groups/${group.id}`} className="text-base">
<div className="w-full flex flex-col gap-1">
<div className="text-base">{group.name}</div>
<div className="text-muted-foreground font-normal text-xs">
{details ? (
<div className="w-full flex items-center justify-between">
<div className="flex items-center">
<Users className="w-3 h-3 inline mr-1" />
<span>{details._count.participants}</span>
</div>
<div className="flex items-center">
<Calendar className="w-3 h-3 inline mx-1" />
<span>
{new Date(details.createdAt).toLocaleDateString(
'en-US',
{
dateStyle: 'medium',
},
)}
</span>
</div>
</div>
) : (
<div className="flex justify-between">
<Skeleton className="h-4 w-6 rounded-full" />
<Skeleton className="h-4 w-24 rounded-full" />
</div>
)}
</div>
</div>
</Link>
</Button>
</li>
)
})}
<GroupsPage reload={loadGroups}>
{starredGroupInfo.length > 0 && (
<>
<h2 className="mb-2">Starred groups</h2>
<GroupList
groups={starredGroupInfo}
state={state}
setState={setState}
/>
</>
)}
{groupInfo.length > 0 && (
<>
<h2 className="mt-6 mb-2">Recent groups</h2>
<GroupList groups={groupInfo} state={state} setState={setState} />
</>
)}
{archivedGroupInfo.length > 0 && (
<>
<h2 className="mt-6 mb-2 opacity-50">Archived groups</h2>
<div className="opacity-50">
<GroupList
groups={archivedGroupInfo}
state={state}
setState={setState}
/>
</div>
</>
)}
</GroupsPage>
)
}
function GroupList({
groups,
state,
setState,
}: {
groups: RecentGroups
state: RecentGroupsState
setState: (state: SetStateAction<RecentGroupsState>) => void
}) {
return (
<ul className="grid gap-2 sm:grid-cols-2">
{groups.map((group) => (
<RecentGroupListCard
key={group.id}
group={group}
state={state}
setState={setState}
/>
))}
</ul>
)
}
function GroupsPage({
children,
reload,
}: PropsWithChildren<{ reload: () => void }>) {
return (
<>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
<h1 className="font-bold text-2xl flex-1">
<Link href="/groups">My groups</Link>
</h1>
<div className="flex gap-2">
<AddGroupByUrlButton reload={reload} />
<Button asChild>
<Link href="/groups/create">
{/* <Plus className="w-4 h-4 mr-2" /> */}
<>Create</>
</Link>
</Button>
</div>
</div>
<div>{children}</div>
</>
)
}

View File

@@ -7,10 +7,15 @@ export const recentGroupsSchema = z.array(
}),
)
export const starredGroupsSchema = z.array(z.string())
export const archivedGroupsSchema = z.array(z.string())
export type RecentGroups = z.infer<typeof recentGroupsSchema>
export type RecentGroup = RecentGroups[number]
const STORAGE_KEY = 'recentGroups'
const STARRED_GROUPS_STORAGE_KEY = 'starredGroups'
const ARCHIVED_GROUPS_STORAGE_KEY = 'archivedGroups'
export function getRecentGroups() {
const groupsInStorageJson = localStorage.getItem(STORAGE_KEY)
@@ -28,3 +33,61 @@ export function saveRecentGroup(group: RecentGroup) {
JSON.stringify([group, ...recentGroups.filter((rg) => rg.id !== group.id)]),
)
}
export function deleteRecentGroup(group: RecentGroup) {
const recentGroups = getRecentGroups()
localStorage.setItem(
STORAGE_KEY,
JSON.stringify(recentGroups.filter((rg) => rg.id !== group.id)),
)
}
export function getStarredGroups() {
const starredGroupsJson = localStorage.getItem(STARRED_GROUPS_STORAGE_KEY)
const starredGroupsRaw = starredGroupsJson
? JSON.parse(starredGroupsJson)
: []
const parseResult = starredGroupsSchema.safeParse(starredGroupsRaw)
return parseResult.success ? parseResult.data : []
}
export function starGroup(groupId: string) {
const starredGroups = getStarredGroups()
localStorage.setItem(
STARRED_GROUPS_STORAGE_KEY,
JSON.stringify([...starredGroups, groupId]),
)
}
export function unstarGroup(groupId: string) {
const starredGroups = getStarredGroups()
localStorage.setItem(
STARRED_GROUPS_STORAGE_KEY,
JSON.stringify(starredGroups.filter((g) => g !== groupId)),
)
}
export function getArchivedGroups() {
const archivedGroupsJson = localStorage.getItem(ARCHIVED_GROUPS_STORAGE_KEY)
const archivedGroupsRaw = archivedGroupsJson
? JSON.parse(archivedGroupsJson)
: []
const parseResult = archivedGroupsSchema.safeParse(archivedGroupsRaw)
return parseResult.success ? parseResult.data : []
}
export function archiveGroup(groupId: string) {
const archivedGroups = getArchivedGroups()
localStorage.setItem(
ARCHIVED_GROUPS_STORAGE_KEY,
JSON.stringify([...archivedGroups, groupId]),
)
}
export function unarchiveGroup(groupId: string) {
const archivedGroups = getArchivedGroups()
localStorage.setItem(
ARCHIVED_GROUPS_STORAGE_KEY,
JSON.stringify(archivedGroups.filter((g) => g !== groupId)),
)
}

View File

@@ -0,0 +1,29 @@
'use client'
import { AddGroupByUrlButton } from '@/app/groups/add-group-by-url-button'
import { RecentGroupList } from '@/app/groups/recent-group-list'
import { Button } from '@/components/ui/button'
import Link from 'next/link'
export function RecentGroupsPage() {
return (
<>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
<h1 className="font-bold text-2xl flex-1">
<Link href="/groups">My groups</Link>
</h1>
<div className="flex gap-2">
<AddGroupByUrlButton reload={() => {}} />
<Button asChild>
<Link href="/groups/create">
{/* <Plus className="w-4 h-4 mr-2" /> */}
<>Create</>
</Link>
</Button>
</div>
</div>
<div>
<RecentGroupList />
</div>
</>
)
}

View File

@@ -2,11 +2,12 @@ import { ProgressBar } from '@/components/progress-bar'
import { ThemeProvider } from '@/components/theme-provider'
import { ThemeToggle } from '@/components/theme-toggle'
import { Button } from '@/components/ui/button'
import { Toaster } from '@/components/ui/toaster'
import { env } from '@/lib/env'
import type { Metadata, Viewport } from 'next'
import PlausibleProvider from 'next-plausible'
import Image from 'next/image'
import Link from 'next/link'
import { Suspense } from 'react'
import './globals.css'
export const metadata: Metadata = {
@@ -64,9 +65,6 @@ export default function RootLayout({
}) {
return (
<html lang="en" suppressHydrationWarning>
{env.PLAUSIBLE_DOMAIN && (
<PlausibleProvider domain={env.PLAUSIBLE_DOMAIN} trackOutboundLinks />
)}
<body className="pt-16 min-h-[100dvh] flex flex-col items-stretch bg-slate-50 bg-opacity-30 dark:bg-background">
<ThemeProvider
attribute="class"
@@ -74,8 +72,10 @@ export default function RootLayout({
enableSystem
disableTransitionOnChange
>
<ProgressBar />
<header className="fixed top-0 left-0 right-0 h-16 flex justify-between bg-white dark:bg-gray-950 bg-opacity-50 dark:bg-opacity-50 p-2 border-b backdrop-blur-sm">
<Suspense>
<ProgressBar />
</Suspense>
<header className="fixed top-0 left-0 right-0 h-16 flex justify-between bg-white dark:bg-gray-950 bg-opacity-50 dark:bg-opacity-50 p-2 border-b backdrop-blur-sm z-50">
<Link
className="flex items-center gap-2 hover:scale-105 transition-transform"
href="/"
@@ -83,7 +83,7 @@ export default function RootLayout({
<h1>
<Image
src="/logo-with-text.png"
className="m-1 h-auto"
className="m-1 h-auto w-auto"
width={(35 * 522) / 180}
height={35}
alt="Spliit"
@@ -108,30 +108,41 @@ export default function RootLayout({
</div>
</header>
{children}
<div className="flex-1 flex flex-col">{children}</div>
<footer className="sm:p-8 md:p-16 sm:mt-16 sm:text-sm md:text-base md:mt-32 bg-slate-50 dark:bg-card border-t p-6 mt-8 flex flex-col space-y-2 text-xs [&_a]:underline">
<div className="sm:text-lg font-semibold text-base flex space-x-2 items-center">
<Link className="flex items-center gap-2" href="/">
<Image
src="/logo-with-text.png"
className="m-1 h-auto"
width={(35 * 522) / 180}
height={35}
alt="Spliit"
/>
</Link>
</div>
<div className="flex flex-col space-y a--no-underline-text-white">
<span>Made in Montréal, Québec 🇨🇦</span>
<span>
Built by{' '}
<a href="https://scastiel.dev" target="_blank" rel="noopener">
Sebastien Castiel
</a>
</span>
<footer className="sm:p-8 md:p-16 sm:mt-16 sm:text-sm md:text-base md:mt-32 bg-slate-50 dark:bg-card border-t p-6 mt-8 flex flex-col sm:flex-row sm:justify-between gap-4 text-xs [&_a]:underline">
<div className="flex flex-col space-y-2">
<div className="sm:text-lg font-semibold text-base flex space-x-2 items-center">
<Link className="flex items-center gap-2" href="/">
<Image
src="/logo-with-text.png"
className="m-1 h-auto w-auto"
width={(35 * 522) / 180}
height={35}
alt="Spliit"
/>
</Link>
</div>
<div className="flex flex-col space-y a--no-underline-text-white">
<span>Made in Montréal, Québec 🇨🇦</span>
<span>
Built by{' '}
<a href="https://scastiel.dev" target="_blank" rel="noopener">
Sebastien Castiel
</a>{' '}
and{' '}
<a
href="https://github.com/spliit-app/spliit/graphs/contributors"
target="_blank"
rel="noopener"
>
contributors
</a>
</span>
</div>
</div>
</footer>
<Toaster />
</ThemeProvider>
</body>
</html>

View File

@@ -1,16 +1,9 @@
import { Button } from '@/components/ui/button'
import {
BarChartHorizontalBig,
CircleDollarSign,
Github,
List,
LucideIcon,
Share,
ShieldX,
Users,
} from 'lucide-react'
import { Github } from 'lucide-react'
import Link from 'next/link'
import { ReactNode } from 'react'
// FIX for https://github.com/vercel/next.js/issues/58615
// export const dynamic = 'force-dynamic'
export default function HomePage() {
return (
@@ -22,91 +15,18 @@ export default function HomePage() {
& <strong>Family</strong>
</h1>
<p className="max-w-[42rem] leading-normal text-muted-foreground sm:text-xl sm:leading-8">
No ads. No account. <br className="sm:hidden" /> Open Source.
Forever Free.
Welcome to your new <strong>Spliit</strong> instance! <br />
Customize this page by editing <em>src/app/page.tsx</em>.
</p>
<div className="flex gap-2">
<Button asChild size="lg">
<Link
className="inline-flex items-center justify-center text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none ring-offset-background bg-primary text-primary-foreground hover:bg-primary/90 h-11 px-8 rounded-md"
href="/groups/create"
>
Create a group
</Link>
<Button asChild>
<Link href="/groups">Go to groups</Link>
</Button>
</div>
</div>
</section>
<section className="bg-slate-50 dark:bg-card py-16 md:py-24 lg:py-32">
<div className="p-4 flex mx-auto max-w-screen-md flex-col items-center text-center">
<h2 className="font-bold text-3xl leading-[1.1] sm:text-3xl md:text-6xl">
Features
</h2>
<p
className="mt-2 md:mt-3 leading-normal text-muted-foreground sm:text-lg sm:leading-7"
style={{ textWrap: 'balance' } as any}
>
Spliit is a minimalist application to track and share expenses with
your friends and family.
</p>
<div className="mt-8 md:mt-6 w-full grid grid-cols-2 sm:grid-cols-3 gap-2 sm:gap-4 text-left">
<Feature
Icon={Users}
name="Groups"
description="Create a group for a travel, an event, a gift…"
/>
<Feature
Icon={List}
name="Expenses"
description="Create and list expenses in your group."
/>
<Feature
Icon={Share}
name="Share"
description="Send the group link to participants."
/>
<Feature
Icon={BarChartHorizontalBig}
name="Balances"
description="Visualize how much each participant spent."
/>
<Feature
Icon={CircleDollarSign}
name="Reimbursements"
description="Optimize money transfers between participants."
/>
<Feature
Icon={ShieldX}
name="No ads"
description="No account. No limitation. No problem."
/>
</div>
</div>
</section>
<section className="py-16 md:py-24 lg:py-32">
<div className="container flex max-w-screen-md flex-col items-center text-center">
<h2 className="font-bold text-3xl leading-[1.1] sm:text-3xl md:text-6xl">
Proudly Open Source
</h2>
<p
className="mt-2 leading-normal text-muted-foreground sm:text-lg sm:leading-7"
style={{ textWrap: 'balance' } as any}
>
Spliit is open source and powered by open source software. Feel free
to contribute!
</p>
<div className="mt-4 md:mt-6">
<Button asChild variant="secondary" size="lg">
<a
target="_blank"
rel="noreferrer"
href="https://github.com/scastiel/spliit2"
>
<Button asChild variant="secondary">
<Link href="https://github.com/spliit-app/spliit">
<Github className="w-4 h-4 mr-2" />
GitHub
</a>
</Link>
</Button>
</div>
</div>
@@ -114,28 +34,3 @@ export default function HomePage() {
</main>
)
}
function Feature({
name,
Icon,
description,
}: {
name: ReactNode
Icon: LucideIcon
description: ReactNode
}) {
return (
<div className="bg-card border rounded-md p-4 flex flex-col gap-2">
<Icon className="w-8 h-8" />
<div>
<strong>{name}</strong>
</div>
<div
className="text-sm text-muted-foreground"
style={{ textWrap: 'balance' } as any}
>
{description}
</div>
</div>
)
}

View File

@@ -0,0 +1,152 @@
import { ChevronDown } from 'lucide-react'
import { CategoryIcon } from '@/app/groups/[groupId]/expenses/category-icon'
import { Button, ButtonProps } from '@/components/ui/button'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
} from '@/components/ui/command'
import { Drawer, DrawerContent, DrawerTrigger } from '@/components/ui/drawer'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { useMediaQuery } from '@/lib/hooks'
import { Category } from '@prisma/client'
import { forwardRef, useState } from 'react'
type Props = {
categories: Category[]
onValueChange: (categoryId: Category['id']) => void
defaultValue: Category['id']
}
export function CategorySelector({
categories,
onValueChange,
defaultValue,
}: Props) {
const [open, setOpen] = useState(false)
const [value, setValue] = useState<number>(defaultValue)
const isDesktop = useMediaQuery('(min-width: 768px)')
const selectedCategory =
categories.find((category) => category.id === value) ?? categories[0]
if (isDesktop) {
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<CategoryButton category={selectedCategory} open={open} />
</PopoverTrigger>
<PopoverContent className="p-0" align="start">
<CategoryCommand
categories={categories}
onValueChange={(id) => {
setValue(id)
onValueChange(id)
setOpen(false)
}}
/>
</PopoverContent>
</Popover>
)
}
return (
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>
<CategoryButton category={selectedCategory} open={open} />
</DrawerTrigger>
<DrawerContent className="p-0">
<CategoryCommand
categories={categories}
onValueChange={(id) => {
setValue(id)
onValueChange(id)
setOpen(false)
}}
/>
</DrawerContent>
</Drawer>
)
}
function CategoryCommand({
categories,
onValueChange,
}: {
categories: Category[]
onValueChange: (categoryId: Category['id']) => void
}) {
const categoriesByGroup = categories.reduce<Record<string, Category[]>>(
(acc, category) => ({
...acc,
[category.grouping]: [...(acc[category.grouping] ?? []), category],
}),
{},
)
return (
<Command>
<CommandInput placeholder="Search category..." className="text-base" />
<CommandEmpty>No category found.</CommandEmpty>
<div className="w-full max-h-[300px] overflow-y-auto">
{Object.entries(categoriesByGroup).map(
([group, groupCategories], index) => (
<CommandGroup key={index} heading={group}>
{groupCategories.map((category) => (
<CommandItem
key={category.id}
value={`${category.id} ${category.grouping} ${category.name}`}
onSelect={(currentValue) => {
const id = Number(currentValue.split(' ')[0])
onValueChange(id)
}}
>
<CategoryLabel category={category} />
</CommandItem>
))}
</CommandGroup>
),
)}
</div>
</Command>
)
}
type CategoryButtonProps = {
category: Category
open: boolean
}
const CategoryButton = forwardRef<HTMLButtonElement, CategoryButtonProps>(
({ category, open, ...props }: ButtonProps & CategoryButtonProps, ref) => {
return (
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="flex w-full justify-between"
ref={ref}
{...props}
>
<CategoryLabel category={category} />
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
)
},
)
CategoryButton.displayName = 'CategoryButton'
function CategoryLabel({ category }: { category: Category }) {
return (
<div className="flex items-center gap-3">
<CategoryIcon category={category} className="w-4 h-4" />
{category.name}
</div>
)
}

View File

@@ -0,0 +1,190 @@
import { Button } from '@/components/ui/button'
import {
Carousel,
CarouselApi,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
} from '@/components/ui/carousel'
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 { useEffect, 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}
documents={documents}
deleteDocument={(document) => {
updateDocuments(documents.filter((d) => d.id !== document.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,
documents,
deleteDocument,
}: {
document: ExpenseFormValues['documents'][number]
documents: ExpenseFormValues['documents']
deleteDocument: (document: ExpenseFormValues['documents'][number]) => void
}) {
const [open, setOpen] = useState(false)
const [api, setApi] = useState<CarouselApi>()
const [currentDocument, setCurrentDocument] = useState<number | null>(null)
useEffect(() => {
if (!api) return
api.on('slidesInView', () => {
const index = api.slidesInView()[0]
if (index !== undefined) {
setCurrentDocument(index)
}
})
}, [api])
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-[100vw] max-w-[100vw] h-[100dvh] max-h-[100dvh] sm:max-w-[calc(100vw-32px)] sm:max-h-[calc(100dvh-32px)] [&>:last-child]:hidden">
<div className="flex flex-col gap-4">
<div className="flex justify-end">
<Button
variant="ghost"
className="text-destructive"
onClick={() => {
if (currentDocument !== null) {
deleteDocument(documents[currentDocument])
}
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>
<Carousel
opts={{
startIndex: documents.indexOf(document),
loop: true,
align: 'center',
}}
setApi={setApi}
>
<CarouselContent>
{documents.map((document, index) => (
<CarouselItem key={index}>
<Image
className="object-contain w-[calc(100vw-32px)] h-[calc(100dvh-32px-40px-16px-48px)] sm:w-[calc(100vw-32px-32px)] sm:h-[calc(100dvh-32px-40px-16px-32px-48px)]"
src={document.url}
width={document.width}
height={document.height}
alt=""
/>
</CarouselItem>
))}
</CarouselContent>
<CarouselPrevious className="left-0 top-auto -bottom-16" />
<CarouselNext className="right-0 top-auto -bottom-16" />
</Carousel>
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,13 +1,22 @@
'use client'
import {
createExpenseAction,
deleteExpenseAction,
updateExpenseAction,
} from '@/app/groups/[groupId]/expenses/actions'
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 {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Checkbox } from '@/components/ui/checkbox'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import {
Form,
FormControl,
@@ -25,242 +34,522 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { getExpense, getGroup } from '@/lib/api'
import { getCategories, getExpense, getGroup } from '@/lib/api'
import { ExpenseFormValues, expenseFormSchema } from '@/lib/schemas'
import { cn } from '@/lib/utils'
import { zodResolver } from '@hookform/resolvers/zod'
import { useRouter, useSearchParams } from 'next/navigation'
import { Save, Trash2 } from 'lucide-react'
import { useSearchParams } from 'next/navigation'
import { useForm } from 'react-hook-form'
import { match } from 'ts-pattern'
export type Props = {
group: NonNullable<Awaited<ReturnType<typeof getGroup>>>
expense?: NonNullable<Awaited<ReturnType<typeof getExpense>>>
categories: NonNullable<Awaited<ReturnType<typeof getCategories>>>
onSubmit: (values: ExpenseFormValues) => Promise<void>
onDelete?: () => Promise<void>
}
export function ExpenseForm({ group, expense }: Props) {
export function ExpenseForm({
group,
expense,
categories,
onSubmit,
onDelete,
}: Props) {
const isCreate = expense === undefined
const searchParams = useSearchParams()
const getSelectedPayer = (field?: { value: string }) => {
if (isCreate && typeof window !== 'undefined') {
const activeUser = localStorage.getItem(`${group.id}-activeUser`)
if (activeUser && activeUser !== 'None') {
return activeUser
}
}
return field?.value
}
const form = useForm<ExpenseFormValues>({
resolver: zodResolver(expenseFormSchema),
defaultValues: expense
? {
title: expense.title,
expenseDate: expense.expenseDate ?? new Date(),
amount: String(expense.amount / 100) as unknown as number, // hack
category: expense.categoryId,
paidBy: expense.paidById,
paidFor: expense.paidFor.map(({ participantId }) => participantId),
paidFor: expense.paidFor.map(({ participantId, shares }) => ({
participant: participantId,
shares: String(shares / 100) as unknown as number,
})),
splitMode: expense.splitMode,
isReimbursement: expense.isReimbursement,
documents: expense.documents,
}
: searchParams.get('reimbursement')
? {
title: 'Reimbursement',
expenseDate: new Date(),
amount: String(
(Number(searchParams.get('amount')) || 0) / 100,
) as unknown as number, // hack
category: 1, // category with Id 1 is Payment
paidBy: searchParams.get('from') ?? undefined,
paidFor: [searchParams.get('to') ?? undefined],
paidFor: [
searchParams.get('to')
? { participant: searchParams.get('to')!, shares: 1 }
: undefined,
],
isReimbursement: true,
splitMode: 'EVENLY',
documents: [],
}
: { title: '', amount: 0, paidFor: [], isReimbursement: false },
: {
title: '',
expenseDate: new Date(),
amount: 0,
category: 0, // category with Id 0 is General
// paid for all, split evenly
paidFor: group.participants.map(({ id }) => ({
participant: id,
shares: 1,
})),
paidBy: getSelectedPayer(),
isReimbursement: false,
splitMode: 'EVENLY',
documents: [],
},
})
const router = useRouter()
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(async (values) => {
if (expense) {
await updateExpenseAction(group.id, expense.id, values)
} else {
await createExpenseAction(group.id, values)
}
router.back()
})}
>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem className="order-1">
<FormLabel>Expense title</FormLabel>
<FormControl>
<Input
placeholder="Monday evening restaurant"
className="text-base"
{...field}
/>
</FormControl>
<FormDescription>
Enter a description for the expense.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="paidBy"
render={({ field }) => (
<FormItem className="order-3 sm:order-2">
<FormLabel>Paid by</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<SelectTrigger>
<SelectValue placeholder="Select a participant" />
</SelectTrigger>
<SelectContent>
{group.participants.map(({ id, name }) => (
<SelectItem key={id} value={id}>
{name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>
Select the participant who paid the expense.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="amount"
render={({ field }) => (
<FormItem className="order-2 sm:order-3">
<FormLabel>Amount</FormLabel>
<div className="flex items-baseline gap-2">
<span>{group.currency}</span>
<form onSubmit={form.handleSubmit((values) => onSubmit(values))}>
<Card>
<CardHeader>
<CardTitle>
{isCreate ? <>Create expense</> : <>Edit expense</>}
</CardTitle>
</CardHeader>
<CardContent className="grid sm:grid-cols-2 gap-6">
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem className="">
<FormLabel>Expense title</FormLabel>
<FormControl>
<Input
className="text-base max-w-[120px]"
type="number"
inputMode="decimal"
step={0.01}
placeholder="0.00"
placeholder="Monday evening restaurant"
className="text-base"
{...field}
/>
</FormControl>
</div>
<FormMessage />
<FormField
control={form.control}
name="isReimbursement"
render={({ field }) => (
<FormItem className="flex flex-row gap-2 items-center space-y-0 pt-2">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div>
<FormLabel>This is a reimbursement</FormLabel>
</div>
</FormItem>
)}
/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="paidFor"
render={() => (
<FormItem className="order-5">
<div className="mb-4">
<FormLabel>
Paid for
<Button
variant="link"
type="button"
className="-m-2"
onClick={() => {
const paidFor = form.getValues().paidFor
const allSelected =
paidFor.length === group.participants.length
const newPairFor = allSelected
? []
: group.participants.map((p) => p.id)
form.setValue('paidFor', newPairFor, {
shouldDirty: true,
shouldTouch: true,
shouldValidate: true,
})
}}
>
{form.getValues().paidFor.length ===
group.participants.length ? (
<>Select none</>
) : (
<>Select all</>
)}
</Button>
</FormLabel>
<FormDescription>
Select who the expense was paid for.
Enter a description for the expense.
</FormDescription>
</div>
{group.participants.map(({ id, name }) => (
<FormField
key={id}
control={form.control}
name="paidFor"
render={({ field }) => {
return (
<FormItem
key={id}
className="flex flex-row items-start space-x-3 space-y-0"
>
<FormControl>
<Checkbox
checked={field.value?.includes(id)}
onCheckedChange={(checked) => {
return checked
? field.onChange([...field.value, id])
: field.onChange(
field.value?.filter(
(value) => value !== id,
),
)
}}
/>
</FormControl>
<FormLabel className="text-sm font-normal">
{name}
</FormLabel>
</FormItem>
)
}}
/>
))}
<FormMessage />
</FormItem>
)}
/>
</div>
<FormMessage />
</FormItem>
)}
/>
<div className="mt-6 flex gap-2">
<FormField
control={form.control}
name="expenseDate"
render={({ field }) => (
<FormItem className="sm:order-1">
<FormLabel>Expense date</FormLabel>
<FormControl>
<Input
className="date-base"
type="date"
defaultValue={formatDate(field.value)}
onChange={(event) => {
return field.onChange(new Date(event.target.value))
}}
/>
</FormControl>
<FormDescription>
Enter the date the expense was made.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="amount"
render={({ field }) => (
<FormItem className="sm:order-3">
<FormLabel>Amount</FormLabel>
<div className="flex items-baseline gap-2">
<span>{group.currency}</span>
<FormControl>
<Input
className="text-base max-w-[120px]"
type="number"
inputMode="decimal"
step={0.01}
placeholder="0.00"
{...field}
/>
</FormControl>
</div>
<FormMessage />
<FormField
control={form.control}
name="isReimbursement"
render={({ field }) => (
<FormItem className="flex flex-row gap-2 items-center space-y-0 pt-2">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div>
<FormLabel>This is a reimbursement</FormLabel>
</div>
</FormItem>
)}
/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="category"
render={({ field }) => (
<FormItem className="order-3 sm:order-2">
<FormLabel>Category</FormLabel>
<CategorySelector
categories={categories}
defaultValue={field.value}
onValueChange={field.onChange}
/>
<FormDescription>
Select the expense category.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="paidBy"
render={({ field }) => (
<FormItem className="sm:order-5">
<FormLabel>Paid by</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={getSelectedPayer(field)}
>
<SelectTrigger>
<SelectValue placeholder="Select a participant" />
</SelectTrigger>
<SelectContent>
{group.participants.map(({ id, name }) => (
<SelectItem key={id} value={id}>
{name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>
Select the participant who paid the expense.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
<Card className="mt-4">
<CardHeader>
<CardTitle className="flex justify-between">
<span>Paid for</span>
<Button
variant="link"
type="button"
className="-my-2 -mx-4"
onClick={() => {
const paidFor = form.getValues().paidFor
const allSelected =
paidFor.length === group.participants.length
const newPaidFor = allSelected
? []
: group.participants.map((p) => ({
participant: p.id,
shares:
paidFor.find((pfor) => pfor.participant === p.id)
?.shares ?? ('1' as unknown as number),
}))
form.setValue('paidFor', newPaidFor, {
shouldDirty: true,
shouldTouch: true,
shouldValidate: true,
})
}}
>
{form.getValues().paidFor.length ===
group.participants.length ? (
<>Select none</>
) : (
<>Select all</>
)}
</Button>
</CardTitle>
<CardDescription>
Select who the expense was paid for.
</CardDescription>
</CardHeader>
<CardContent>
<FormField
control={form.control}
name="paidFor"
render={() => (
<FormItem className="sm:order-4 row-span-2 space-y-0">
{group.participants.map(({ id, name }) => (
<FormField
key={id}
control={form.control}
name="paidFor"
render={({ field }) => {
return (
<div
data-id={`${id}/${form.getValues().splitMode}/${
group.currency
}`}
className="flex items-center border-t last-of-type:border-b last-of-type:!mb-4 -mx-6 px-6 py-3"
>
<FormItem className="flex-1 flex flex-row items-start space-x-3 space-y-0">
<FormControl>
<Checkbox
checked={field.value?.some(
({ participant }) => participant === id,
)}
onCheckedChange={(checked) => {
return checked
? field.onChange([
...field.value,
{
participant: id,
shares: '1',
},
])
: field.onChange(
field.value?.filter(
(value) => value.participant !== id,
),
)
}}
/>
</FormControl>
<FormLabel className="text-sm font-normal flex-1">
{name}
</FormLabel>
</FormItem>
{form.getValues().splitMode !== 'EVENLY' && (
<FormField
name={`paidFor[${field.value.findIndex(
({ participant }) => participant === id,
)}].shares`}
render={() => {
const sharesLabel = (
<span
className={cn('text-sm', {
'text-muted': !field.value?.some(
({ participant }) =>
participant === id,
),
})}
>
{match(form.getValues().splitMode)
.with('BY_SHARES', () => <>share(s)</>)
.with('BY_PERCENTAGE', () => <>%</>)
.with('BY_AMOUNT', () => (
<>{group.currency}</>
))
.otherwise(() => (
<></>
))}
</span>
)
return (
<div>
<div className="flex gap-1 items-center">
{form.getValues().splitMode ===
'BY_AMOUNT' && sharesLabel}
<FormControl>
<Input
key={String(
!field.value?.some(
({ participant }) =>
participant === id,
),
)}
className="text-base w-[80px] -my-2"
type="number"
disabled={
!field.value?.some(
({ participant }) =>
participant === id,
)
}
value={
field.value?.find(
({ participant }) =>
participant === id,
)?.shares
}
onChange={(event) =>
field.onChange(
field.value.map((p) =>
p.participant === id
? {
participant: id,
shares:
event.target.value,
}
: p,
),
)
}
inputMode={
form.getValues().splitMode ===
'BY_AMOUNT'
? 'decimal'
: 'numeric'
}
step={
form.getValues().splitMode ===
'BY_AMOUNT'
? 0.01
: 1
}
/>
</FormControl>
{[
'BY_SHARES',
'BY_PERCENTAGE',
].includes(
form.getValues().splitMode,
) && sharesLabel}
</div>
<FormMessage className="float-right" />
</div>
)
}}
/>
)}
</div>
)
}}
/>
))}
<FormMessage />
</FormItem>
)}
/>
<Collapsible className="mt-5">
<CollapsibleTrigger asChild>
<Button variant="link" className="-mx-4">
Advanced splitting options
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="grid sm:grid-cols-2 gap-6 pt-3">
<FormField
control={form.control}
name="splitMode"
render={({ field }) => (
<FormItem className="sm:order-2">
<FormLabel>Split mode</FormLabel>
<FormControl>
<Select
onValueChange={(value) => {
form.setValue('splitMode', value as any, {
shouldDirty: true,
shouldTouch: true,
shouldValidate: true,
})
}}
defaultValue={field.value}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="EVENLY">Evenly</SelectItem>
<SelectItem value="BY_SHARES">
Unevenly By shares
</SelectItem>
<SelectItem value="BY_PERCENTAGE">
Unevenly By percentage
</SelectItem>
<SelectItem value="BY_AMOUNT">
Unevenly By amount
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormDescription>
Select how to split the expense.
</FormDescription>
</FormItem>
)}
/>
</div>
</CollapsibleContent>
</Collapsible>
</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</>}
>
<Save className="w-4 h-4 mr-2" />
{isCreate ? <>Create</> : <>Save</>}
</SubmitButton>
{!isCreate && (
{!isCreate && onDelete && (
<AsyncButton
type="button"
variant="destructive"
loadingContent="Deleting…"
action={async () => {
await deleteExpenseAction(group.id, expense.id)
router.back()
}}
action={onDelete}
>
<Trash2 className="w-4 h-4 mr-2" />
Delete
</AsyncButton>
)}
@@ -269,3 +558,8 @@ export function ExpenseForm({ group, expense }: Props) {
</Form>
)
}
function formatDate(date?: Date) {
if (!date || isNaN(date as any)) date = new Date()
return date.toISOString().substring(0, 10)
}

View File

@@ -24,10 +24,18 @@ import {
HoverCardTrigger,
} from '@/components/ui/hover-card'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { getGroup } from '@/lib/api'
import { GroupFormValues, groupFormSchema } from '@/lib/schemas'
import { zodResolver } from '@hookform/resolvers/zod'
import { Save, Trash2 } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useFieldArray, useForm } from 'react-hook-form'
export type Props = {
@@ -61,6 +69,31 @@ export function GroupForm({
keyName: 'key',
})
const [activeUser, setActiveUser] = useState<string | null>(null)
useEffect(() => {
if (activeUser === null) {
const currentActiveUser =
fields.find(
(f) => f.id === localStorage.getItem(`${group?.id}-activeUser`),
)?.name || 'None'
setActiveUser(currentActiveUser)
}
}, [activeUser, fields, group?.id])
const updateActiveUser = () => {
if (!activeUser) return
if (group?.id) {
const participant = group.participants.find((p) => p.name === activeUser)
if (participant?.id) {
localStorage.setItem(`${group.id}-activeUser`, participant.id)
} else {
localStorage.setItem(`${group.id}-activeUser`, activeUser)
}
} else {
localStorage.setItem('newGroup-activeUser', activeUser)
}
}
return (
<Form {...form}>
<form
@@ -196,9 +229,53 @@ export function GroupForm({
</CardFooter>
</Card>
<Card className="mb-4">
<CardHeader>
<CardTitle>Local settings</CardTitle>
<CardDescription>
These settings are set per-device, and are used to customize your
experience.
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid sm:grid-cols-2 gap-4">
{activeUser !== null && (
<FormItem>
<FormLabel>Active user</FormLabel>
<FormControl>
<Select
onValueChange={(value) => {
setActiveUser(value)
}}
defaultValue={activeUser}
>
<SelectTrigger>
<SelectValue placeholder="Select a participant" />
</SelectTrigger>
<SelectContent>
{[{ name: 'None' }, ...form.watch('participants')]
.filter((item) => item.name.length > 0)
.map(({ name }) => (
<SelectItem key={name} value={name}>
{name}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormDescription>
User used as default for paying expenses.
</FormDescription>
</FormItem>
)}
</div>
</CardContent>
</Card>
<SubmitButton
size="lg"
loadingContent={group ? 'Saving…' : 'Creating…'}
onClick={updateActiveUser}
>
<Save className="w-4 h-4 mr-2" /> {group ? <>Save</> : <> Create</>}
</SubmitButton>

View File

@@ -0,0 +1,262 @@
"use client"
import * as React from "react"
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react"
import { ArrowLeft, ArrowRight } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
type CarouselApi = UseEmblaCarouselType[1]
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
type CarouselOptions = UseCarouselParameters[0]
type CarouselPlugin = UseCarouselParameters[1]
type CarouselProps = {
opts?: CarouselOptions
plugins?: CarouselPlugin
orientation?: "horizontal" | "vertical"
setApi?: (api: CarouselApi) => void
}
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
api: ReturnType<typeof useEmblaCarousel>[1]
scrollPrev: () => void
scrollNext: () => void
canScrollPrev: boolean
canScrollNext: boolean
} & CarouselProps
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
function useCarousel() {
const context = React.useContext(CarouselContext)
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />")
}
return context
}
const Carousel = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & CarouselProps
>(
(
{
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
},
ref
) => {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins
)
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
const [canScrollNext, setCanScrollNext] = React.useState(false)
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) {
return
}
setCanScrollPrev(api.canScrollPrev())
setCanScrollNext(api.canScrollNext())
}, [])
const scrollPrev = React.useCallback(() => {
api?.scrollPrev()
}, [api])
const scrollNext = React.useCallback(() => {
api?.scrollNext()
}, [api])
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault()
scrollPrev()
} else if (event.key === "ArrowRight") {
event.preventDefault()
scrollNext()
}
},
[scrollPrev, scrollNext]
)
React.useEffect(() => {
if (!api || !setApi) {
return
}
setApi(api)
}, [api, setApi])
React.useEffect(() => {
if (!api) {
return
}
onSelect(api)
api.on("reInit", onSelect)
api.on("select", onSelect)
return () => {
api?.off("select", onSelect)
}
}, [api, onSelect])
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
ref={ref}
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
)
}
)
Carousel.displayName = "Carousel"
const CarouselContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { carouselRef, orientation } = useCarousel()
return (
<div ref={carouselRef} className="overflow-hidden">
<div
ref={ref}
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className
)}
{...props}
/>
</div>
)
})
CarouselContent.displayName = "CarouselContent"
const CarouselItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { orientation } = useCarousel()
return (
<div
ref={ref}
role="group"
aria-roledescription="slide"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className
)}
{...props}
/>
)
})
CarouselItem.displayName = "CarouselItem"
const CarouselPrevious = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-left-12 top-1/2 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft className="h-4 w-4" />
<span className="sr-only">Previous slide</span>
</Button>
)
})
CarouselPrevious.displayName = "CarouselPrevious"
const CarouselNext = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollNext, canScrollNext } = useCarousel()
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-right-12 top-1/2 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight className="h-4 w-4" />
<span className="sr-only">Next slide</span>
</Button>
)
})
CarouselNext.displayName = "CarouselNext"
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
}

View File

@@ -0,0 +1,11 @@
"use client"
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
const Collapsible = CollapsiblePrimitive.Root
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

View File

@@ -0,0 +1,155 @@
"use client"
import * as React from "react"
import { type DialogProps } from "@radix-ui/react-dialog"
import { Command as CommandPrimitive } from "cmdk"
import { Search } from "lucide-react"
import { cn } from "@/lib/utils"
import { Dialog, DialogContent } from "@/components/ui/dialog"
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className
)}
{...props}
/>
))
Command.displayName = CommandPrimitive.displayName
interface CommandDialogProps extends DialogProps {}
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0 shadow-lg">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
))
CommandInput.displayName = CommandPrimitive.Input.displayName
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
))
CommandList.displayName = CommandPrimitive.List.displayName
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-sm"
{...props}
/>
))
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className
)}
{...props}
/>
))
CommandGroup.displayName = CommandPrimitive.Group.displayName
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
))
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
/>
))
CommandItem.displayName = CommandPrimitive.Item.displayName
const CommandShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
CommandShortcut.displayName = "CommandShortcut"
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}

View File

@@ -21,7 +21,7 @@ const DialogOverlay = React.forwardRef<
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}

View File

@@ -0,0 +1,118 @@
"use client"
import * as React from "react"
import { Drawer as DrawerPrimitive } from "vaul"
import { cn } from "@/lib/utils"
const Drawer = ({
shouldScaleBackground = true,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
<DrawerPrimitive.Root
shouldScaleBackground={shouldScaleBackground}
{...props}
/>
)
Drawer.displayName = "Drawer"
const DrawerTrigger = DrawerPrimitive.Trigger
const DrawerPortal = DrawerPrimitive.Portal
const DrawerClose = DrawerPrimitive.Close
const DrawerOverlay = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Overlay
ref={ref}
className={cn("fixed inset-0 z-50 bg-black/80", className)}
{...props}
/>
))
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName
const DrawerContent = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DrawerPortal>
<DrawerOverlay />
<DrawerPrimitive.Content
ref={ref}
className={cn(
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
className
)}
{...props}
>
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
))
DrawerContent.displayName = "DrawerContent"
const DrawerHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)}
{...props}
/>
)
DrawerHeader.displayName = "DrawerHeader"
const DrawerFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
DrawerFooter.displayName = "DrawerFooter"
const DrawerTitle = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DrawerTitle.displayName = DrawerPrimitive.Title.displayName
const DrawerDescription = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DrawerDescription.displayName = DrawerPrimitive.Description.displayName
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
}

View File

@@ -0,0 +1,44 @@
"use client"
import * as React from "react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import { Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const RadioGroup = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Root
className={cn("grid gap-2", className)}
{...props}
ref={ref}
/>
)
})
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
const RadioGroupItem = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
<Circle className="h-2.5 w-2.5 fill-current text-current" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
})
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
export { RadioGroup, RadioGroupItem }

View File

@@ -0,0 +1,33 @@
import * as React from "react"
import {Input} from "@/components/ui/input";
import {cn} from "@/lib/utils";
import {
Search
} from 'lucide-react'
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const SearchBar = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<div className="mx-4 sm:mx-6 flex relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
type={type}
className={cn(
"pl-10 text-sm focus:text-base bg-muted border-none text-muted-foreground",
className
)}
ref={ref}
placeholder="Search for an expense…"
{...props}
/>
</div>
)
}
)
SearchBar.displayName = "SearchBar"
export { SearchBar }

View File

@@ -0,0 +1,24 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Textarea.displayName = "Textarea"
export { Textarea }

127
src/components/ui/toast.tsx Normal file
View File

@@ -0,0 +1,127 @@
import * as React from "react"
import * as ToastPrimitives from "@radix-ui/react-toast"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const ToastProvider = ToastPrimitives.Provider
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className
)}
{...props}
/>
))
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
{
variants: {
variant: {
default: "border bg-background text-foreground",
destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
)
})
Toast.displayName = ToastPrimitives.Root.displayName
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
className
)}
{...props}
/>
))
ToastAction.displayName = ToastPrimitives.Action.displayName
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
))
ToastClose.displayName = ToastPrimitives.Close.displayName
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn("text-sm font-semibold", className)}
{...props}
/>
))
ToastTitle.displayName = ToastPrimitives.Title.displayName
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn("text-sm opacity-90", className)}
{...props}
/>
))
ToastDescription.displayName = ToastPrimitives.Description.displayName
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
type ToastActionElement = React.ReactElement<typeof ToastAction>
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
}

View File

@@ -0,0 +1,35 @@
"use client"
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from "@/components/ui/toast"
import { useToast } from "@/components/ui/use-toast"
export function Toaster() {
const { toasts } = useToast()
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>{description}</ToastDescription>
)}
</div>
{action}
<ToastClose />
</Toast>
)
})}
<ToastViewport />
</ToastProvider>
)
}

View File

@@ -0,0 +1,192 @@
// Inspired by react-hot-toast library
import * as React from "react"
import type {
ToastActionElement,
ToastProps,
} from "@/components/ui/toast"
const TOAST_LIMIT = 1
const TOAST_REMOVE_DELAY = 1000000
type ToasterToast = ToastProps & {
id: string
title?: React.ReactNode
description?: React.ReactNode
action?: ToastActionElement
}
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const
let count = 0
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER
return count.toString()
}
type ActionType = typeof actionTypes
type Action =
| {
type: ActionType["ADD_TOAST"]
toast: ToasterToast
}
| {
type: ActionType["UPDATE_TOAST"]
toast: Partial<ToasterToast>
}
| {
type: ActionType["DISMISS_TOAST"]
toastId?: ToasterToast["id"]
}
| {
type: ActionType["REMOVE_TOAST"]
toastId?: ToasterToast["id"]
}
interface State {
toasts: ToasterToast[]
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId)
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
})
}, TOAST_REMOVE_DELAY)
toastTimeouts.set(toastId, timeout)
}
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
}
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t
),
}
case "DISMISS_TOAST": {
const { toastId } = action
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId)
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id)
})
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t
),
}
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
}
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
}
}
}
const listeners: Array<(state: State) => void> = []
let memoryState: State = { toasts: [] }
function dispatch(action: Action) {
memoryState = reducer(memoryState, action)
listeners.forEach((listener) => {
listener(memoryState)
})
}
type Toast = Omit<ToasterToast, "id">
function toast({ ...props }: Toast) {
const id = genId()
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
})
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss()
},
},
})
return {
id: id,
dismiss,
update,
}
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState)
React.useEffect(() => {
listeners.push(setState)
return () => {
const index = listeners.indexOf(setState)
if (index > -1) {
listeners.splice(index, 1)
}
}
}, [state])
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
}
}
export { useToast, toast }

View File

@@ -36,7 +36,7 @@ export async function createExpense(
for (const participant of [
expenseFormValues.paidBy,
...expenseFormValues.paidFor,
...expenseFormValues.paidFor.map((p) => p.participant),
]) {
if (!group.participants.some((p) => p.id === participant))
throw new Error(`Invalid participant ID: ${participant}`)
@@ -47,17 +47,31 @@ export async function createExpense(
data: {
id: randomId(),
groupId,
expenseDate: expenseFormValues.expenseDate,
categoryId: expenseFormValues.category,
amount: expenseFormValues.amount,
title: expenseFormValues.title,
paidById: expenseFormValues.paidBy,
splitMode: expenseFormValues.splitMode,
paidFor: {
createMany: {
data: expenseFormValues.paidFor.map((paidFor) => ({
participantId: paidFor,
participantId: paidFor.participant,
shares: paidFor.shares,
})),
},
},
isReimbursement: expenseFormValues.isReimbursement,
documents: {
createMany: {
data: expenseFormValues.documents.map((doc) => ({
id: randomId(),
url: doc.url,
width: doc.width,
height: doc.height,
})),
},
},
},
})
}
@@ -84,12 +98,14 @@ export async function getGroupExpensesParticipants(groupId: string) {
export async function getGroups(groupIds: string[]) {
const prisma = await getPrisma()
return (await prisma.group.findMany({
where: { id: { in: groupIds } },
include: { _count: { select: { participants: true } } },
})).map(group => ({
return (
await prisma.group.findMany({
where: { id: { in: groupIds } },
include: { _count: { select: { participants: true } } },
})
).map((group) => ({
...group,
createdAt: group.createdAt.toISOString()
createdAt: group.createdAt.toISOString(),
}))
}
@@ -106,7 +122,7 @@ export async function updateExpense(
for (const participant of [
expenseFormValues.paidBy,
...expenseFormValues.paidFor,
...expenseFormValues.paidFor.map((p) => p.participant),
]) {
if (!group.participants.some((p) => p.id === participant))
throw new Error(`Invalid participant ID: ${participant}`)
@@ -116,24 +132,59 @@ export async function updateExpense(
return prisma.expense.update({
where: { id: expenseId },
data: {
expenseDate: expenseFormValues.expenseDate,
amount: expenseFormValues.amount,
title: expenseFormValues.title,
categoryId: expenseFormValues.category,
paidById: expenseFormValues.paidBy,
splitMode: expenseFormValues.splitMode,
paidFor: {
connectOrCreate: expenseFormValues.paidFor.map((paidFor) => ({
create: expenseFormValues.paidFor
.filter(
(p) =>
!existingExpense.paidFor.some(
(pp) => pp.participantId === p.participant,
),
)
.map((paidFor) => ({
participantId: paidFor.participant,
shares: paidFor.shares,
})),
update: expenseFormValues.paidFor.map((paidFor) => ({
where: {
expenseId_participantId: { expenseId, participantId: paidFor },
expenseId_participantId: {
expenseId,
participantId: paidFor.participant,
},
},
data: {
shares: paidFor.shares,
},
create: { participantId: paidFor },
})),
deleteMany: existingExpense.paidFor.filter(
(paidFor) =>
!expenseFormValues.paidFor.some(
(pf) => pf === paidFor.participantId,
(pf) => pf.participant === paidFor.participantId,
),
),
},
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,
})),
},
},
})
}
@@ -184,12 +235,21 @@ export async function getGroup(groupId: string) {
})
}
export async function getCategories() {
const prisma = await getPrisma()
return prisma.category.findMany()
}
export async function getGroupExpenses(groupId: string) {
const prisma = await getPrisma()
return prisma.expense.findMany({
where: { groupId },
include: { paidFor: { include: { participant: true } }, paidBy: true },
orderBy: { createdAt: 'desc' },
include: {
paidFor: { include: { participant: true } },
paidBy: true,
category: true,
},
orderBy: [{ expenseDate: 'desc' }, { createdAt: 'desc' }],
})
}
@@ -197,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 },
include: { paidBy: true, paidFor: true, category: true, documents: true },
})
}

View File

@@ -1,5 +1,6 @@
import { getGroupExpenses } from '@/lib/api'
import { Participant } from '@prisma/client'
import { match } from 'ts-pattern'
export type Balances = Record<
Participant['id'],
@@ -19,34 +20,42 @@ export function getBalances(
for (const expense of expenses) {
const paidBy = expense.paidById
const paidFors = expense.paidFor.map((p) => p.participantId)
const paidFors = expense.paidFor
if (!balances[paidBy]) balances[paidBy] = { paid: 0, paidFor: 0, total: 0 }
balances[paidBy].paid += expense.amount
balances[paidBy].total += expense.amount
paidFors.forEach((paidFor, index) => {
if (!balances[paidFor])
balances[paidFor] = { paid: 0, paidFor: 0, total: 0 }
const dividedAmount = divide(
expense.amount,
paidFors.length,
index === paidFors.length - 1,
)
balances[paidFor].paidFor += dividedAmount
balances[paidFor].total -= dividedAmount
const totalPaidForShares = paidFors.reduce(
(sum, paidFor) => sum + paidFor.shares,
0,
)
let remaining = expense.amount
paidFors.forEach((paidFor, index) => {
if (!balances[paidFor.participantId])
balances[paidFor.participantId] = { paid: 0, paidFor: 0, total: 0 }
const isLast = index === paidFors.length - 1
const [shares, totalShares] = match(expense.splitMode)
.with('EVENLY', () => [1, paidFors.length])
.with('BY_SHARES', () => [paidFor.shares, totalPaidForShares])
.with('BY_PERCENTAGE', () => [paidFor.shares, totalPaidForShares])
.with('BY_AMOUNT', () => [paidFor.shares, totalPaidForShares])
.exhaustive()
const dividedAmount = isLast
? remaining
: Math.floor((expense.amount * shares) / totalShares)
remaining -= dividedAmount
balances[paidFor.participantId].paidFor += dividedAmount
balances[paidFor.participantId].total -= dividedAmount
})
}
return balances
}
function divide(total: number, count: number, isLast: boolean): number {
if (!isLast) return Math.floor(total / count)
return total - divide(total, count, false) * (count - 1)
}
export function getSuggestedReimbursements(
balances: Balances,
): Reimbursement[] {

View File

@@ -1,10 +1,37 @@
import { z } from 'zod'
import { ZodIssueCode, z } from 'zod'
const envSchema = z.object({
NEXT_PUBLIC_BASE_URL: z.string().url(),
POSTGRES_URL_NON_POOLING: z.string().url(),
POSTGRES_PRISMA_URL: z.string().url(),
PLAUSIBLE_DOMAIN: z.string().optional(),
})
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)

50
src/lib/hooks.ts Normal file
View File

@@ -0,0 +1,50 @@
import { useEffect, useState } from 'react'
export function useMediaQuery(query: string): boolean {
const getMatches = (query: string): boolean => {
// Prevents SSR issues
if (typeof window !== 'undefined') {
return window.matchMedia(query).matches
}
return false
}
const [matches, setMatches] = useState<boolean>(getMatches(query))
function handleChange() {
setMatches(getMatches(query))
}
useEffect(() => {
const matchMedia = window.matchMedia(query)
// Triggered at the first client-side load and if query changes
handleChange()
// Listen matchMedia
if (matchMedia.addListener) {
matchMedia.addListener(handleChange)
} else {
matchMedia.addEventListener('change', handleChange)
}
return () => {
if (matchMedia.removeListener) {
matchMedia.removeListener(handleChange)
} else {
matchMedia.removeEventListener('change', handleChange)
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [query])
return matches
}
export function useBaseUrl() {
const [baseUrl, setBaseUrl] = useState<string | null>(null)
useEffect(() => {
setBaseUrl(window.location.origin)
}, [])
return baseUrl
}

View File

@@ -1,3 +1,4 @@
import { SplitMode } from '@prisma/client'
import * as z from 'zod'
export const groupFormSchema = z
@@ -38,36 +39,123 @@ export const groupFormSchema = z
export type GroupFormValues = z.infer<typeof groupFormSchema>
export const expenseFormSchema = z.object({
title: z
.string({ required_error: 'Please enter a title.' })
.min(2, 'Enter at least two characters.'),
amount: z
.union(
[
z.number(),
z.string().transform((value, ctx) => {
const valueAsNumber = Number(value)
if (Number.isNaN(valueAsNumber))
export const expenseFormSchema = z
.object({
expenseDate: z.coerce.date(),
title: z
.string({ required_error: 'Please enter a title.' })
.min(2, 'Enter at least two characters.'),
category: z.coerce.number().default(0),
amount: z
.union(
[
z.number(),
z.string().transform((value, ctx) => {
const valueAsNumber = Number(value)
if (Number.isNaN(valueAsNumber))
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Invalid number.',
})
return Math.round(valueAsNumber * 100)
}),
],
{ required_error: 'You must enter an amount.' },
)
.refine((amount) => amount >= 1, 'The amount must be higher than 0.01.')
.refine(
(amount) => amount <= 10_000_000_00,
'The amount must be lower than 10,000,000.',
),
paidBy: z.string({ required_error: 'You must select a participant.' }),
paidFor: z
.array(
z.object({
participant: z.string(),
shares: z.union([
z.number(),
z.string().transform((value, ctx) => {
const valueAsNumber = Number(value)
if (Number.isNaN(valueAsNumber))
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Invalid number.',
})
return Math.round(valueAsNumber * 100)
}),
]),
}),
)
.min(1, 'The expense must be paid for at least one participant.')
.superRefine((paidFor, ctx) => {
let sum = 0
for (const { shares } of paidFor) {
sum += shares
if (shares < 1) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Invalid number.',
message: 'All shares must be higher than 0.',
})
return Math.round(valueAsNumber * 100)
}
}
}),
splitMode: z
.enum<SplitMode, [SplitMode, ...SplitMode[]]>(
Object.values(SplitMode) as any,
)
.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),
}),
],
{ required_error: 'You must enter an amount.' },
)
.refine((amount) => amount >= 1, 'The amount must be higher than 0.01.')
.refine(
(amount) => amount <= 10_000_000_00,
'The amount must be lower than 10,000,000.',
),
paidBy: z.string({ required_error: 'You must select a participant.' }),
paidFor: z
.array(z.string())
.min(1, 'The expense must be paid for at least one participant.'),
isReimbursement: z.boolean(),
})
)
.default([]),
})
.superRefine((expense, ctx) => {
let sum = 0
for (const { shares } of expense.paidFor) {
sum +=
typeof shares === 'number' ? shares : Math.round(Number(shares) * 100)
}
switch (expense.splitMode) {
case 'EVENLY':
break // noop
case 'BY_SHARES':
break // noop
case 'BY_AMOUNT': {
if (sum !== expense.amount) {
const detail =
sum < expense.amount
? `${((expense.amount - sum) / 100).toFixed(2)} missing`
: `${((sum - expense.amount) / 100).toFixed(2)} surplus`
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Sum of amounts must equal the expense amount (${detail}).`,
path: ['paidFor'],
})
}
break
}
case 'BY_PERCENTAGE': {
if (sum !== 10000) {
const detail =
sum < 10000
? `${((10000 - sum) / 100).toFixed(0)}% missing`
: `${((sum - 10000) / 100).toFixed(0)}% surplus`
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Sum of percentages must equal 100 (${detail})`,
path: ['paidFor'],
})
}
break
}
}
})
export type ExpenseFormValues = z.infer<typeof expenseFormSchema>

View File

@@ -80,6 +80,8 @@ async function main() {
amount: Math.round(expenseRow.amount * 100),
groupId: groupRow.id,
title: expenseRow.description,
categoryId: 1,
expenseDate: new Date(expenseRow.created_at.toDateString()),
createdAt: expenseRow.created_at,
isReimbursement: expenseRow.is_reimbursement === true,
paidById: participantIdsMapping[expenseRow.paid_by_participant_id],

View File

@@ -89,5 +89,5 @@ module.exports = {
},
},
},
plugins: [require('tailwindcss-animate')],
plugins: [require('tailwindcss-animate'), require('@tailwindcss/typography')],
}