mirror of
https://github.com/spliit-app/spliit.git
synced 2026-03-04 20:06:11 +01:00
Add tRPC, use it for group expenses, balances and information page (#246)
* Add tRPC, use it for group expense list * Use tRPC for balances * Use tRPC in group information + better loading states
This commit is contained in:
committed by
GitHub
parent
727803ea5c
commit
66e15e419e
136
package-lock.json
generated
136
package-lock.json
generated
@@ -26,7 +26,12 @@
|
|||||||
"@radix-ui/react-tabs": "^1.0.4",
|
"@radix-ui/react-tabs": "^1.0.4",
|
||||||
"@radix-ui/react-toast": "^1.1.5",
|
"@radix-ui/react-toast": "^1.1.5",
|
||||||
"@tailwindcss/typography": "^0.5.10",
|
"@tailwindcss/typography": "^0.5.10",
|
||||||
|
"@tanstack/react-query": "^5.59.15",
|
||||||
|
"@trpc/client": "^11.0.0-rc.586",
|
||||||
|
"@trpc/react-query": "^11.0.0-rc.586",
|
||||||
|
"@trpc/server": "^11.0.0-rc.586",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
|
"client-only": "^0.0.1",
|
||||||
"clsx": "^2.0.0",
|
"clsx": "^2.0.0",
|
||||||
"cmdk": "^0.2.0",
|
"cmdk": "^0.2.0",
|
||||||
"content-disposition": "^0.5.4",
|
"content-disposition": "^0.5.4",
|
||||||
@@ -47,13 +52,16 @@
|
|||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-hook-form": "^7.47.0",
|
"react-hook-form": "^7.47.0",
|
||||||
"react-intersection-observer": "^9.8.0",
|
"react-intersection-observer": "^9.8.0",
|
||||||
|
"server-only": "^0.0.1",
|
||||||
"sharp": "^0.33.2",
|
"sharp": "^0.33.2",
|
||||||
|
"superjson": "^2.2.1",
|
||||||
"tailwind-merge": "^1.14.0",
|
"tailwind-merge": "^1.14.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"ts-pattern": "^5.0.6",
|
"ts-pattern": "^5.0.6",
|
||||||
|
"use-debounce": "^10.0.4",
|
||||||
"uuid": "^9.0.1",
|
"uuid": "^9.0.1",
|
||||||
"vaul": "^0.8.0",
|
"vaul": "^0.8.0",
|
||||||
"zod": "^3.22.4"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@testing-library/dom": "^10.4.0",
|
"@testing-library/dom": "^10.4.0",
|
||||||
@@ -8962,6 +8970,32 @@
|
|||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tanstack/query-core": {
|
||||||
|
"version": "5.59.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.59.13.tgz",
|
||||||
|
"integrity": "sha512-Oou0bBu/P8+oYjXsJQ11j+gcpLAMpqW42UlokQYEz4dE7+hOtVO9rVuolJKgEccqzvyFzqX4/zZWY+R/v1wVsQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tanstack/react-query": {
|
||||||
|
"version": "5.59.15",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.59.15.tgz",
|
||||||
|
"integrity": "sha512-QbVlAkTI78wB4Mqgf2RDmgC0AOiJqer2c5k9STOOSXGv1S6ZkY37r/6UpE8DbQ2Du0ohsdoXgFNEyv+4eDoPEw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/query-core": "5.59.13"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18 || ^19"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@testing-library/dom": {
|
"node_modules/@testing-library/dom": {
|
||||||
"version": "10.4.0",
|
"version": "10.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz",
|
||||||
@@ -9063,6 +9097,43 @@
|
|||||||
"integrity": "sha512-AqlrT8YA1o7Ff5wPfMOL0pvL+1X+sw60NN6CcOCqs658emD6RfiXhF7Gu9QcfKBH7ELY2nInLhKSCWVoNL70MQ==",
|
"integrity": "sha512-AqlrT8YA1o7Ff5wPfMOL0pvL+1X+sw60NN6CcOCqs658emD6RfiXhF7Gu9QcfKBH7ELY2nInLhKSCWVoNL70MQ==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@trpc/client": {
|
||||||
|
"version": "11.0.0-rc.586",
|
||||||
|
"resolved": "https://registry.npmjs.org/@trpc/client/-/client-11.0.0-rc.586.tgz",
|
||||||
|
"integrity": "sha512-shCIpBzT+SzEbVXbCdpbSrPogG4c9J6hXh+xh5pidY1MTYcBHkeZVBLjy/fVSX+fB9wRoZXNaaoXO+ijYAZBcQ==",
|
||||||
|
"funding": [
|
||||||
|
"https://trpc.io/sponsor"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@trpc/server": "11.0.0-rc.586+3388c9691"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@trpc/react-query": {
|
||||||
|
"version": "11.0.0-rc.586",
|
||||||
|
"resolved": "https://registry.npmjs.org/@trpc/react-query/-/react-query-11.0.0-rc.586.tgz",
|
||||||
|
"integrity": "sha512-fYIo9Y9lM2tqTBY9NBT5ZPX4R++SaauOl6qjvnSwmIBupboiueLMMWfMh+cmJiAVim1Hg0OvgoS6WRFIYMlFYg==",
|
||||||
|
"funding": [
|
||||||
|
"https://trpc.io/sponsor"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tanstack/react-query": "^5.59.15",
|
||||||
|
"@trpc/client": "11.0.0-rc.586+3388c9691",
|
||||||
|
"@trpc/server": "11.0.0-rc.586+3388c9691",
|
||||||
|
"react": ">=18.2.0",
|
||||||
|
"react-dom": ">=18.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@trpc/server": {
|
||||||
|
"version": "11.0.0-rc.586",
|
||||||
|
"resolved": "https://registry.npmjs.org/@trpc/server/-/server-11.0.0-rc.586.tgz",
|
||||||
|
"integrity": "sha512-G0713HRFYyBLjN58DYq88hTH4kfKNZt9GXR0/TkVD7rENpOUBk6LKorqSDQ0y0/8aqu11HdDHsn6vBTWK3D44Q==",
|
||||||
|
"funding": [
|
||||||
|
"https://trpc.io/sponsor"
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@tsconfig/node10": {
|
"node_modules/@tsconfig/node10": {
|
||||||
"version": "1.0.11",
|
"version": "1.0.11",
|
||||||
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz",
|
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz",
|
||||||
@@ -10788,6 +10859,21 @@
|
|||||||
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/copy-anything": {
|
||||||
|
"version": "3.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-3.0.5.tgz",
|
||||||
|
"integrity": "sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"is-what": "^4.1.8"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.13"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/mesqueeb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/create-jest": {
|
"node_modules/create-jest": {
|
||||||
"version": "29.7.0",
|
"version": "29.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz",
|
||||||
@@ -13155,6 +13241,18 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-what": {
|
||||||
|
"version": "4.1.16",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-what/-/is-what-4.1.16.tgz",
|
||||||
|
"integrity": "sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.13"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/mesqueeb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/isarray": {
|
"node_modules/isarray": {
|
||||||
"version": "2.0.5",
|
"version": "2.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
|
||||||
@@ -16298,6 +16396,12 @@
|
|||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/server-only": {
|
||||||
|
"version": "0.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/server-only/-/server-only-0.0.1.tgz",
|
||||||
|
"integrity": "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/set-function-length": {
|
"node_modules/set-function-length": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.1.1.tgz",
|
||||||
@@ -16811,6 +16915,18 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/superjson": {
|
||||||
|
"version": "2.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.1.tgz",
|
||||||
|
"integrity": "sha512-8iGv75BYOa0xRJHK5vRLEjE2H/i4lulTjzpUXic3Eg8akftYjkmQDa8JARQ42rlczXyFR3IeRoeFCc7RxHsYZA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"copy-anything": "^3.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/supports-color": {
|
"node_modules/supports-color": {
|
||||||
"version": "7.2.0",
|
"version": "7.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||||
@@ -17319,6 +17435,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/use-debounce": {
|
||||||
|
"version": "10.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/use-debounce/-/use-debounce-10.0.4.tgz",
|
||||||
|
"integrity": "sha512-6Cf7Yr7Wk7Kdv77nnJMf6de4HuDE4dTxKij+RqE9rufDsI6zsbjyAxcH5y2ueJCQAnfgKbzXbZHYlkFwmBlWkw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/use-intl": {
|
"node_modules/use-intl": {
|
||||||
"version": "3.17.2",
|
"version": "3.17.2",
|
||||||
"resolved": "https://registry.npmjs.org/use-intl/-/use-intl-3.17.2.tgz",
|
"resolved": "https://registry.npmjs.org/use-intl/-/use-intl-3.17.2.tgz",
|
||||||
@@ -17819,9 +17947,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/zod": {
|
"node_modules/zod": {
|
||||||
"version": "3.22.4",
|
"version": "3.23.8",
|
||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.22.4.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz",
|
||||||
"integrity": "sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==",
|
"integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
|
|||||||
10
package.json
10
package.json
@@ -33,7 +33,12 @@
|
|||||||
"@radix-ui/react-tabs": "^1.0.4",
|
"@radix-ui/react-tabs": "^1.0.4",
|
||||||
"@radix-ui/react-toast": "^1.1.5",
|
"@radix-ui/react-toast": "^1.1.5",
|
||||||
"@tailwindcss/typography": "^0.5.10",
|
"@tailwindcss/typography": "^0.5.10",
|
||||||
|
"@tanstack/react-query": "^5.59.15",
|
||||||
|
"@trpc/client": "^11.0.0-rc.586",
|
||||||
|
"@trpc/react-query": "^11.0.0-rc.586",
|
||||||
|
"@trpc/server": "^11.0.0-rc.586",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
|
"client-only": "^0.0.1",
|
||||||
"clsx": "^2.0.0",
|
"clsx": "^2.0.0",
|
||||||
"cmdk": "^0.2.0",
|
"cmdk": "^0.2.0",
|
||||||
"content-disposition": "^0.5.4",
|
"content-disposition": "^0.5.4",
|
||||||
@@ -54,13 +59,16 @@
|
|||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-hook-form": "^7.47.0",
|
"react-hook-form": "^7.47.0",
|
||||||
"react-intersection-observer": "^9.8.0",
|
"react-intersection-observer": "^9.8.0",
|
||||||
|
"server-only": "^0.0.1",
|
||||||
"sharp": "^0.33.2",
|
"sharp": "^0.33.2",
|
||||||
|
"superjson": "^2.2.1",
|
||||||
"tailwind-merge": "^1.14.0",
|
"tailwind-merge": "^1.14.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"ts-pattern": "^5.0.6",
|
"ts-pattern": "^5.0.6",
|
||||||
|
"use-debounce": "^10.0.4",
|
||||||
"uuid": "^9.0.1",
|
"uuid": "^9.0.1",
|
||||||
"vaul": "^0.8.0",
|
"vaul": "^0.8.0",
|
||||||
"zod": "^3.22.4"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@testing-library/dom": "^10.4.0",
|
"@testing-library/dom": "^10.4.0",
|
||||||
|
|||||||
13
src/app/api/trpc/[trpc]/route.ts
Normal file
13
src/app/api/trpc/[trpc]/route.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { createTRPCContext } from '@/trpc/init'
|
||||||
|
import { appRouter } from '@/trpc/routers/_app'
|
||||||
|
import { fetchRequestHandler } from '@trpc/server/adapters/fetch'
|
||||||
|
|
||||||
|
const handler = (req: Request) =>
|
||||||
|
fetchRequestHandler({
|
||||||
|
endpoint: '/api/trpc',
|
||||||
|
req,
|
||||||
|
router: appRouter,
|
||||||
|
createContext: createTRPCContext,
|
||||||
|
})
|
||||||
|
|
||||||
|
export { handler as GET, handler as POST }
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { BalancesList } from '@/app/groups/[groupId]/balances-list'
|
||||||
|
import { ReimbursementList } from '@/app/groups/[groupId]/reimbursement-list'
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { getGroup } from '@/lib/api'
|
||||||
|
import { trpc } from '@/trpc/client'
|
||||||
|
import { useTranslations } from 'next-intl'
|
||||||
|
import { Fragment, useEffect } from 'react'
|
||||||
|
|
||||||
|
export default function BalancesAndReimbursements({
|
||||||
|
group,
|
||||||
|
}: {
|
||||||
|
group: NonNullable<Awaited<ReturnType<typeof getGroup>>>
|
||||||
|
}) {
|
||||||
|
const utils = trpc.useUtils()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Until we use tRPC more widely and can invalidate the cache on expense
|
||||||
|
// update, it's easier and safer to invalidate the cache on page load.
|
||||||
|
utils.groups.balances.invalidate()
|
||||||
|
}, [utils])
|
||||||
|
|
||||||
|
const t = useTranslations('Balances')
|
||||||
|
|
||||||
|
const { data, isLoading } = trpc.groups.balances.list.useQuery({
|
||||||
|
groupId: group.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card className="mb-4">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t('title')}</CardTitle>
|
||||||
|
<CardDescription>{t('description')}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{isLoading || !data ? (
|
||||||
|
<BalancesLoading participantCount={group.participants.length} />
|
||||||
|
) : (
|
||||||
|
<BalancesList
|
||||||
|
balances={data.balances}
|
||||||
|
participants={group.participants}
|
||||||
|
currency={group.currency}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="mb-4">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t('Reimbursements.title')}</CardTitle>
|
||||||
|
<CardDescription>{t('Reimbursements.description')}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{isLoading || !data ? (
|
||||||
|
<ReimbursementsLoading
|
||||||
|
participantCount={group.participants.length}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ReimbursementList
|
||||||
|
reimbursements={data.reimbursements}
|
||||||
|
participants={group.participants}
|
||||||
|
currency={group.currency}
|
||||||
|
groupId={group.id}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ReimbursementsLoading = ({
|
||||||
|
participantCount,
|
||||||
|
}: {
|
||||||
|
participantCount: number
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
{Array(participantCount - 1)
|
||||||
|
.fill(undefined)
|
||||||
|
.map((_, index) => (
|
||||||
|
<div key={index} className="flex justify-between py-5">
|
||||||
|
<div className="flex flex-col sm:flex-row gap-3 sm:gap-4">
|
||||||
|
<Skeleton className="h-3 w-32" />
|
||||||
|
<Skeleton className="h-3 w-24" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-3 w-16" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const BalancesLoading = ({
|
||||||
|
participantCount,
|
||||||
|
}: {
|
||||||
|
participantCount: number
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 py-1 gap-y-2">
|
||||||
|
{Array(participantCount)
|
||||||
|
.fill(undefined)
|
||||||
|
.map((_, index) =>
|
||||||
|
index % 2 === 0 ? (
|
||||||
|
<Fragment key={index}>
|
||||||
|
<div className="flex items-center justify-end pr-2">
|
||||||
|
<Skeleton className="h-3 w-16" />
|
||||||
|
</div>
|
||||||
|
<div className="self-start">
|
||||||
|
<Skeleton
|
||||||
|
className={`h-7 w-${(index % 3) + 1}/3 rounded-l-none`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Fragment>
|
||||||
|
) : (
|
||||||
|
<Fragment key={index}>
|
||||||
|
<div className="flex items-center justify-end">
|
||||||
|
<Skeleton
|
||||||
|
className={`h-7 w-${(index % 3) + 1}/3 rounded-r-none`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center pl-2">
|
||||||
|
<Skeleton className="h-3 w-16" />
|
||||||
|
</div>
|
||||||
|
</Fragment>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,21 +1,6 @@
|
|||||||
import { cached } from '@/app/cached-functions'
|
import { cached } from '@/app/cached-functions'
|
||||||
import { BalancesList } from '@/app/groups/[groupId]/balances-list'
|
import BalancesAndReimbursements from '@/app/groups/[groupId]/balances/balances-and-reimbursements'
|
||||||
import { ReimbursementList } from '@/app/groups/[groupId]/reimbursement-list'
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from '@/components/ui/card'
|
|
||||||
import { getGroupExpenses } from '@/lib/api'
|
|
||||||
import {
|
|
||||||
getBalances,
|
|
||||||
getPublicBalances,
|
|
||||||
getSuggestedReimbursements,
|
|
||||||
} from '@/lib/balances'
|
|
||||||
import { Metadata } from 'next'
|
import { Metadata } from 'next'
|
||||||
import { getTranslations } from 'next-intl/server'
|
|
||||||
import { notFound } from 'next/navigation'
|
import { notFound } from 'next/navigation'
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@@ -27,44 +12,8 @@ export default async function GroupPage({
|
|||||||
}: {
|
}: {
|
||||||
params: { groupId: string }
|
params: { groupId: string }
|
||||||
}) {
|
}) {
|
||||||
const t = await getTranslations('Balances')
|
|
||||||
const group = await cached.getGroup(groupId)
|
const group = await cached.getGroup(groupId)
|
||||||
if (!group) notFound()
|
if (!group) notFound()
|
||||||
|
|
||||||
const expenses = await getGroupExpenses(groupId)
|
return <BalancesAndReimbursements group={group} />
|
||||||
const balances = getBalances(expenses)
|
|
||||||
const reimbursements = getSuggestedReimbursements(balances)
|
|
||||||
const publicBalances = getPublicBalances(reimbursements)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Card className="mb-4">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>{t('title')}</CardTitle>
|
|
||||||
<CardDescription>{t('description')}</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<BalancesList
|
|
||||||
balances={publicBalances}
|
|
||||||
participants={group.participants}
|
|
||||||
currency={group.currency}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card className="mb-4">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>{t('Reimbursements.title')}</CardTitle>
|
|
||||||
<CardDescription>{t('Reimbursements.description')}</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="p-0">
|
|
||||||
<ReimbursementList
|
|
||||||
reimbursements={reimbursements}
|
|
||||||
participants={group.participants}
|
|
||||||
currency={group.currency}
|
|
||||||
groupId={groupId}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,21 +4,22 @@ import { getGroupExpensesAction } from '@/app/groups/[groupId]/expenses/expense-
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { SearchBar } from '@/components/ui/search-bar'
|
import { SearchBar } from '@/components/ui/search-bar'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { normalizeString } from '@/lib/utils'
|
import { trpc } from '@/trpc/client'
|
||||||
import { Participant } from '@prisma/client'
|
import { Participant } from '@prisma/client'
|
||||||
import dayjs, { type Dayjs } from 'dayjs'
|
import dayjs, { type Dayjs } from 'dayjs'
|
||||||
import { useTranslations } from 'next-intl'
|
import { useTranslations } from 'next-intl'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { forwardRef, useEffect, useMemo, useState } from 'react'
|
||||||
import { useInView } from 'react-intersection-observer'
|
import { useInView } from 'react-intersection-observer'
|
||||||
|
import { useDebounce } from 'use-debounce'
|
||||||
|
|
||||||
|
const PAGE_SIZE = 200
|
||||||
|
|
||||||
type ExpensesType = NonNullable<
|
type ExpensesType = NonNullable<
|
||||||
Awaited<ReturnType<typeof getGroupExpensesAction>>
|
Awaited<ReturnType<typeof getGroupExpensesAction>>
|
||||||
>
|
>
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
expensesFirstPage: ExpensesType
|
|
||||||
expenseCount: number
|
|
||||||
participants: Participant[]
|
participants: Participant[]
|
||||||
currency: string
|
currency: string
|
||||||
groupId: string
|
groupId: string
|
||||||
@@ -62,22 +63,9 @@ function getGroupedExpensesByDate(expenses: ExpensesType) {
|
|||||||
}, {})
|
}, {})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ExpenseList({
|
export function ExpenseList({ currency, participants, groupId }: Props) {
|
||||||
expensesFirstPage,
|
|
||||||
expenseCount,
|
|
||||||
currency,
|
|
||||||
participants,
|
|
||||||
groupId,
|
|
||||||
}: Props) {
|
|
||||||
const firstLen = expensesFirstPage.length
|
|
||||||
const [searchText, setSearchText] = useState('')
|
const [searchText, setSearchText] = useState('')
|
||||||
const [dataIndex, setDataIndex] = useState(firstLen)
|
const [debouncedSearchText] = useDebounce(searchText, 300)
|
||||||
const [dataLen, setDataLen] = useState(firstLen)
|
|
||||||
const [hasMoreData, setHasMoreData] = useState(expenseCount > firstLen)
|
|
||||||
const [isFetching, setIsFetching] = useState(false)
|
|
||||||
const [expenses, setExpenses] = useState(expensesFirstPage)
|
|
||||||
const { ref, inView } = useInView()
|
|
||||||
const t = useTranslations('Expenses')
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const activeUser = localStorage.getItem('newGroup-activeUser')
|
const activeUser = localStorage.getItem('newGroup-activeUser')
|
||||||
@@ -98,57 +86,74 @@ export function ExpenseList({
|
|||||||
}
|
}
|
||||||
}, [groupId, participants])
|
}, [groupId, participants])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SearchBar onValueChange={(value) => setSearchText(value)} />
|
||||||
|
<ExpenseListForSearch
|
||||||
|
groupId={groupId}
|
||||||
|
currency={currency}
|
||||||
|
searchText={debouncedSearchText}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ExpenseListForSearch = ({
|
||||||
|
currency,
|
||||||
|
groupId,
|
||||||
|
searchText,
|
||||||
|
}: {
|
||||||
|
currency: string
|
||||||
|
groupId: string
|
||||||
|
searchText: string
|
||||||
|
}) => {
|
||||||
|
const utils = trpc.useUtils()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchNextPage = async () => {
|
// Until we use tRPC more widely and can invalidate the cache on expense
|
||||||
setIsFetching(true)
|
// update, it's easier and safer to invalidate the cache on page load.
|
||||||
|
utils.groups.expenses.invalidate()
|
||||||
|
}, [utils])
|
||||||
|
|
||||||
const newExpenses = await getGroupExpensesAction(groupId, {
|
const t = useTranslations('Expenses')
|
||||||
offset: dataIndex,
|
const { ref: loadingRef, inView } = useInView()
|
||||||
length: dataLen,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (newExpenses !== null) {
|
const { data, isLoading, isError, fetchNextPage } =
|
||||||
const exp = expenses.concat(newExpenses)
|
trpc.groups.expenses.list.useInfiniteQuery(
|
||||||
setExpenses(exp)
|
{ groupId, limit: PAGE_SIZE, filter: searchText },
|
||||||
setHasMoreData(exp.length < expenseCount)
|
{ getNextPageParam: ({ nextCursor }) => nextCursor },
|
||||||
setDataIndex(dataIndex + dataLen)
|
)
|
||||||
setDataLen(Math.ceil(1.5 * dataLen))
|
const expenses = data?.pages.flatMap((page) => page.expenses)
|
||||||
}
|
const hasMore = data?.pages.at(-1)?.hasMore ?? false
|
||||||
|
|
||||||
setTimeout(() => setIsFetching(false), 500)
|
useEffect(() => {
|
||||||
}
|
if (inView && hasMore && !isLoading) fetchNextPage()
|
||||||
|
}, [fetchNextPage, hasMore, inView, isLoading])
|
||||||
if (inView && hasMoreData && !isFetching) fetchNextPage()
|
|
||||||
}, [
|
|
||||||
dataIndex,
|
|
||||||
dataLen,
|
|
||||||
expenseCount,
|
|
||||||
expenses,
|
|
||||||
groupId,
|
|
||||||
hasMoreData,
|
|
||||||
inView,
|
|
||||||
isFetching,
|
|
||||||
])
|
|
||||||
|
|
||||||
const groupedExpensesByDate = useMemo(
|
const groupedExpensesByDate = useMemo(
|
||||||
() => getGroupedExpensesByDate(expenses),
|
() => (expenses ? getGroupedExpensesByDate(expenses) : {}),
|
||||||
[expenses],
|
[expenses],
|
||||||
)
|
)
|
||||||
|
|
||||||
return expenses.length > 0 ? (
|
if (isLoading) return <ExpensesLoading />
|
||||||
|
|
||||||
|
if (!expenses || expenses?.length === 0)
|
||||||
|
return (
|
||||||
|
<p className="px-6 text-sm py-6">
|
||||||
|
{t('noExpenses')}{' '}
|
||||||
|
<Button variant="link" asChild className="-m-4">
|
||||||
|
<Link href={`/groups/${groupId}/expenses/create`}>
|
||||||
|
{t('createFirst')}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
<>
|
<>
|
||||||
<SearchBar
|
|
||||||
onValueChange={(value) => setSearchText(normalizeString(value))}
|
|
||||||
/>
|
|
||||||
{Object.values(EXPENSE_GROUPS).map((expenseGroup: string) => {
|
{Object.values(EXPENSE_GROUPS).map((expenseGroup: string) => {
|
||||||
let groupExpenses = groupedExpensesByDate[expenseGroup]
|
let groupExpenses = groupedExpensesByDate[expenseGroup]
|
||||||
if (!groupExpenses) return null
|
if (!groupExpenses || groupExpenses.length === 0) return null
|
||||||
|
|
||||||
groupExpenses = groupExpenses.filter(({ title }) =>
|
|
||||||
normalizeString(title).includes(searchText),
|
|
||||||
)
|
|
||||||
|
|
||||||
if (groupExpenses.length === 0) return null
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={expenseGroup}>
|
<div key={expenseGroup}>
|
||||||
@@ -170,31 +175,34 @@ export function ExpenseList({
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
{expenses.length < expenseCount &&
|
{hasMore && <ExpensesLoading ref={loadingRef} />}
|
||||||
[0, 1, 2].map((i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
className="border-t flex justify-between items-center px-6 py-4 text-sm"
|
|
||||||
ref={i === 0 ? ref : undefined}
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
))}
|
|
||||||
</>
|
</>
|
||||||
) : (
|
|
||||||
<p className="px-6 text-sm py-6">
|
|
||||||
{t('noExpenses')}{' '}
|
|
||||||
<Button variant="link" asChild className="-m-4">
|
|
||||||
<Link href={`/groups/${groupId}/expenses/create`}>
|
|
||||||
{t('createFirst')}
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</p>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ExpensesLoading = forwardRef<HTMLDivElement>((_, ref) => {
|
||||||
|
return (
|
||||||
|
<div ref={ref}>
|
||||||
|
<Skeleton className="mx-4 sm:mx-6 mb-2 h-4 w-32 rounded-full" />
|
||||||
|
{[0, 1, 2].map((i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex justify-between items-start px-2 sm:px-6 py-4 text-sm gap-2"
|
||||||
|
>
|
||||||
|
<div className="flex-0 pl-2 pr-1">
|
||||||
|
<Skeleton className="h-4 w-4 rounded-full" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 flex flex-col gap-2">
|
||||||
|
<Skeleton className="h-4 w-16 rounded-full" />
|
||||||
|
<Skeleton className="h-4 w-32 rounded-full" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-0 flex flex-col gap-2 items-end mr-2 sm:mr-12">
|
||||||
|
<Skeleton className="h-4 w-16 rounded-full" />
|
||||||
|
<Skeleton className="h-4 w-20 rounded-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
ExpensesLoading.displayName = 'ExpensesLoading'
|
||||||
|
|||||||
@@ -10,19 +10,13 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from '@/components/ui/card'
|
} from '@/components/ui/card'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { getCategories } from '@/lib/api'
|
||||||
import {
|
|
||||||
getCategories,
|
|
||||||
getGroupExpenseCount,
|
|
||||||
getGroupExpenses,
|
|
||||||
} from '@/lib/api'
|
|
||||||
import { env } from '@/lib/env'
|
import { env } from '@/lib/env'
|
||||||
import { Download, Plus } from 'lucide-react'
|
import { Download, Plus } from 'lucide-react'
|
||||||
import { Metadata } from 'next'
|
import { Metadata } from 'next'
|
||||||
import { getTranslations } from 'next-intl/server'
|
import { getTranslations } from 'next-intl/server'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { notFound } from 'next/navigation'
|
import { notFound } from 'next/navigation'
|
||||||
import { Suspense } from 'react'
|
|
||||||
|
|
||||||
export const revalidate = 3600
|
export const revalidate = 3600
|
||||||
|
|
||||||
@@ -79,24 +73,11 @@ export default async function GroupExpensesPage({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CardContent className="p-0 pt-2 pb-4 sm:pb-6 flex flex-col gap-4 relative">
|
<CardContent className="p-0 pt-2 pb-4 sm:pb-6 flex flex-col gap-4 relative">
|
||||||
<Suspense
|
<ExpenseList
|
||||||
fallback={[0, 1, 2].map((i) => (
|
groupId={group.id}
|
||||||
<div
|
currency={group.currency}
|
||||||
key={i}
|
participants={group.participants}
|
||||||
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>
|
|
||||||
))}
|
|
||||||
>
|
|
||||||
<Expenses group={group} />
|
|
||||||
</Suspense>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -104,26 +85,3 @@ export default async function GroupExpensesPage({
|
|||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
type Props = {
|
|
||||||
group: NonNullable<Awaited<ReturnType<typeof cached.getGroup>>>
|
|
||||||
}
|
|
||||||
|
|
||||||
async function Expenses({ group }: Props) {
|
|
||||||
const expenseCount = await getGroupExpenseCount(group.id)
|
|
||||||
|
|
||||||
const expenses = await getGroupExpenses(group.id, {
|
|
||||||
offset: 0,
|
|
||||||
length: 200,
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ExpenseList
|
|
||||||
expensesFirstPage={expenses}
|
|
||||||
expenseCount={expenseCount}
|
|
||||||
groupId={group.id}
|
|
||||||
currency={group.currency}
|
|
||||||
participants={group.participants}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
52
src/app/groups/[groupId]/information/group-information.tsx
Normal file
52
src/app/groups/[groupId]/information/group-information.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { trpc } from '@/trpc/client'
|
||||||
|
import { Pencil } from 'lucide-react'
|
||||||
|
import { useTranslations } from 'next-intl'
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
|
export default function GroupInformation({ groupId }: { groupId: string }) {
|
||||||
|
const t = useTranslations('Information')
|
||||||
|
const { data, isLoading } = trpc.groups.information.get.useQuery({ groupId })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card className="mb-4">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex justify-between">
|
||||||
|
<span>{t('title')}</span>
|
||||||
|
<Button size="icon" asChild className="-mb-12">
|
||||||
|
<Link href={`/groups/${groupId}/edit`}>
|
||||||
|
<Pencil className="w-4 h-4" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="mr-12">
|
||||||
|
{t('description')}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="prose prose-sm sm:prose-base max-w-full whitespace-break-spaces">
|
||||||
|
{isLoading || !data ? (
|
||||||
|
<div className="py-1 flex flex-col gap-2">
|
||||||
|
<Skeleton className="h-3 w-3/4" />
|
||||||
|
<Skeleton className="h-3 w-1/2" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
data.information || (
|
||||||
|
<p className="text-muted-foreground text-sm">{t('empty')}</p>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,54 +1,14 @@
|
|||||||
import { cached } from '@/app/cached-functions'
|
import GroupInformation from '@/app/groups/[groupId]/information/group-information'
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from '@/components/ui/card'
|
|
||||||
import { Pencil } from 'lucide-react'
|
|
||||||
import { Metadata } from 'next'
|
import { Metadata } from 'next'
|
||||||
import { getTranslations } from 'next-intl/server'
|
|
||||||
import Link from 'next/link'
|
|
||||||
import { notFound } from 'next/navigation'
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Totals',
|
title: 'Group Information',
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function InformationPage({
|
export default function InformationPage({
|
||||||
params: { groupId },
|
params: { groupId },
|
||||||
}: {
|
}: {
|
||||||
params: { groupId: string }
|
params: { groupId: string }
|
||||||
}) {
|
}) {
|
||||||
const group = await cached.getGroup(groupId)
|
return <GroupInformation groupId={groupId} />
|
||||||
if (!group) notFound()
|
|
||||||
|
|
||||||
const t = await getTranslations('Information')
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Card className="mb-4">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex justify-between">
|
|
||||||
<span>{t('title')}</span>
|
|
||||||
<Button size="icon" asChild className="-mb-12">
|
|
||||||
<Link href={`/groups/${groupId}/edit`}>
|
|
||||||
<Pencil className="w-4 h-4" />
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription className="mr-12">
|
|
||||||
{t('description')}
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="prose prose-sm sm:prose-base max-w-full whitespace-break-spaces">
|
|
||||||
{group.information || (
|
|
||||||
<p className="text-muted-foreground italic">{t('empty')}</p>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export function ReimbursementList({
|
|||||||
return (
|
return (
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
{reimbursements.map((reimbursement, index) => (
|
{reimbursements.map((reimbursement, index) => (
|
||||||
<div className="border-t px-6 py-4 flex justify-between" key={index}>
|
<div className="py-4 flex justify-between" key={index}>
|
||||||
<div className="flex flex-col gap-1 items-start sm:flex-row sm:items-baseline sm:gap-4">
|
<div className="flex flex-col gap-1 items-start sm:flex-row sm:items-baseline sm:gap-4">
|
||||||
<div>
|
<div>
|
||||||
{t.rich('owes', {
|
{t.rich('owes', {
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ export function Totals({
|
|||||||
totalGroupSpendings: number
|
totalGroupSpendings: number
|
||||||
}) {
|
}) {
|
||||||
const activeUser = useActiveUser(group.id)
|
const activeUser = useActiveUser(group.id)
|
||||||
console.log('activeUser', activeUser)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { ThemeToggle } from '@/components/theme-toggle'
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Toaster } from '@/components/ui/toaster'
|
import { Toaster } from '@/components/ui/toaster'
|
||||||
import { env } from '@/lib/env'
|
import { env } from '@/lib/env'
|
||||||
|
import { TRPCProvider } from '@/trpc/client'
|
||||||
import type { Metadata, Viewport } from 'next'
|
import type { Metadata, Viewport } from 'next'
|
||||||
import { NextIntlClientProvider, useTranslations } from 'next-intl'
|
import { NextIntlClientProvider, useTranslations } from 'next-intl'
|
||||||
import { getLocale, getMessages } from 'next-intl/server'
|
import { getLocale, getMessages } from 'next-intl/server'
|
||||||
@@ -65,7 +66,7 @@ export const viewport: Viewport = {
|
|||||||
function Content({ children }: { children: React.ReactNode }) {
|
function Content({ children }: { children: React.ReactNode }) {
|
||||||
const t = useTranslations()
|
const t = useTranslations()
|
||||||
return (
|
return (
|
||||||
<>
|
<TRPCProvider>
|
||||||
<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">
|
<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
|
<Link
|
||||||
className="flex items-center gap-2 hover:scale-105 transition-transform"
|
className="flex items-center gap-2 hover:scale-105 transition-transform"
|
||||||
@@ -142,7 +143,7 @@ function Content({ children }: { children: React.ReactNode }) {
|
|||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</>
|
</TRPCProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -267,7 +267,7 @@ export async function getCategories() {
|
|||||||
|
|
||||||
export async function getGroupExpenses(
|
export async function getGroupExpenses(
|
||||||
groupId: string,
|
groupId: string,
|
||||||
options?: { offset: number; length: number },
|
options?: { offset?: number; length?: number; filter?: string },
|
||||||
) {
|
) {
|
||||||
return prisma.expense.findMany({
|
return prisma.expense.findMany({
|
||||||
select: {
|
select: {
|
||||||
@@ -287,7 +287,12 @@ export async function getGroupExpenses(
|
|||||||
splitMode: true,
|
splitMode: true,
|
||||||
title: true,
|
title: true,
|
||||||
},
|
},
|
||||||
where: { groupId },
|
where: {
|
||||||
|
groupId,
|
||||||
|
title: options?.filter
|
||||||
|
? { contains: options.filter, mode: 'insensitive' }
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
orderBy: [{ expenseDate: 'desc' }, { createdAt: 'desc' }],
|
orderBy: [{ expenseDate: 'desc' }, { createdAt: 'desc' }],
|
||||||
skip: options && options.offset,
|
skip: options && options.offset,
|
||||||
take: options && options.length,
|
take: options && options.length,
|
||||||
|
|||||||
60
src/trpc/client.tsx
Normal file
60
src/trpc/client.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
'use client' // <-- to make sure we can mount the Provider from a server component
|
||||||
|
import type { QueryClient } from '@tanstack/react-query'
|
||||||
|
import { QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import { httpBatchLink } from '@trpc/client'
|
||||||
|
import { createTRPCReact } from '@trpc/react-query'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import superjson from 'superjson'
|
||||||
|
import { makeQueryClient } from './query-client'
|
||||||
|
import type { AppRouter } from './routers/_app'
|
||||||
|
|
||||||
|
export const trpc = createTRPCReact<AppRouter>()
|
||||||
|
|
||||||
|
let clientQueryClientSingleton: QueryClient
|
||||||
|
|
||||||
|
function getQueryClient() {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
// Server: always make a new query client
|
||||||
|
return makeQueryClient()
|
||||||
|
}
|
||||||
|
// Browser: use singleton pattern to keep the same query client
|
||||||
|
return (clientQueryClientSingleton ??= makeQueryClient())
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUrl() {
|
||||||
|
const base = (() => {
|
||||||
|
if (typeof window !== 'undefined') return ''
|
||||||
|
if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`
|
||||||
|
return 'http://localhost:3000'
|
||||||
|
})()
|
||||||
|
return `${base}/api/trpc`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TRPCProvider(
|
||||||
|
props: Readonly<{
|
||||||
|
children: React.ReactNode
|
||||||
|
}>,
|
||||||
|
) {
|
||||||
|
// NOTE: Avoid useState when initializing the query client if you don't
|
||||||
|
// have a suspense boundary between this and the code that may
|
||||||
|
// suspend because React will throw away the client on the initial
|
||||||
|
// render if it suspends and there is no boundary
|
||||||
|
const queryClient = getQueryClient()
|
||||||
|
const [trpcClient] = useState(() =>
|
||||||
|
trpc.createClient({
|
||||||
|
links: [
|
||||||
|
httpBatchLink({
|
||||||
|
transformer: superjson,
|
||||||
|
url: getUrl(),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<trpc.Provider client={trpcClient} queryClient={queryClient}>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
{props.children}
|
||||||
|
</QueryClientProvider>
|
||||||
|
</trpc.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
25
src/trpc/init.ts
Normal file
25
src/trpc/init.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { initTRPC } from '@trpc/server'
|
||||||
|
import { cache } from 'react'
|
||||||
|
import superjson from 'superjson'
|
||||||
|
|
||||||
|
export const createTRPCContext = cache(async () => {
|
||||||
|
/**
|
||||||
|
* @see: https://trpc.io/docs/server/context
|
||||||
|
*/
|
||||||
|
return {}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Avoid exporting the entire t-object
|
||||||
|
// since it's not very descriptive.
|
||||||
|
// For instance, the use of a t variable
|
||||||
|
// is common in i18n libraries.
|
||||||
|
const t = initTRPC.create({
|
||||||
|
/**
|
||||||
|
* @see https://trpc.io/docs/server/data-transformers
|
||||||
|
*/
|
||||||
|
transformer: superjson,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Base router and procedure helpers
|
||||||
|
export const createTRPCRouter = t.router
|
||||||
|
export const baseProcedure = t.procedure
|
||||||
21
src/trpc/query-client.ts
Normal file
21
src/trpc/query-client.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { defaultShouldDehydrateQuery, QueryClient } from '@tanstack/react-query'
|
||||||
|
import superjson from 'superjson'
|
||||||
|
|
||||||
|
export function makeQueryClient() {
|
||||||
|
return new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 30 * 1000,
|
||||||
|
},
|
||||||
|
dehydrate: {
|
||||||
|
serializeData: superjson.serialize,
|
||||||
|
shouldDehydrateQuery: (query) =>
|
||||||
|
defaultShouldDehydrateQuery(query) ||
|
||||||
|
query.state.status === 'pending',
|
||||||
|
},
|
||||||
|
hydrate: {
|
||||||
|
deserializeData: superjson.deserialize,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
10
src/trpc/routers/_app.ts
Normal file
10
src/trpc/routers/_app.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { groupsRouter } from '@/trpc/routers/groups'
|
||||||
|
import { inferRouterOutputs } from '@trpc/server'
|
||||||
|
import { createTRPCRouter } from '../init'
|
||||||
|
|
||||||
|
export const appRouter = createTRPCRouter({
|
||||||
|
groups: groupsRouter,
|
||||||
|
})
|
||||||
|
|
||||||
|
export type AppRouter = typeof appRouter
|
||||||
|
export type AppRouterOutput = inferRouterOutputs<AppRouter>
|
||||||
6
src/trpc/routers/groups/balances/index.ts
Normal file
6
src/trpc/routers/groups/balances/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { createTRPCRouter } from '@/trpc/init'
|
||||||
|
import { listGroupBalancesProcedure } from '@/trpc/routers/groups/balances/list.procedure'
|
||||||
|
|
||||||
|
export const groupBalancesRouter = createTRPCRouter({
|
||||||
|
list: listGroupBalancesProcedure,
|
||||||
|
})
|
||||||
19
src/trpc/routers/groups/balances/list.procedure.ts
Normal file
19
src/trpc/routers/groups/balances/list.procedure.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { getGroupExpenses } from '@/lib/api'
|
||||||
|
import {
|
||||||
|
getBalances,
|
||||||
|
getPublicBalances,
|
||||||
|
getSuggestedReimbursements,
|
||||||
|
} from '@/lib/balances'
|
||||||
|
import { baseProcedure } from '@/trpc/init'
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
export const listGroupBalancesProcedure = baseProcedure
|
||||||
|
.input(z.object({ groupId: z.string().min(1) }))
|
||||||
|
.query(async ({ input: { groupId } }) => {
|
||||||
|
const expenses = await getGroupExpenses(groupId)
|
||||||
|
const balances = getBalances(expenses)
|
||||||
|
const reimbursements = getSuggestedReimbursements(balances)
|
||||||
|
const publicBalances = getPublicBalances(reimbursements)
|
||||||
|
|
||||||
|
return { balances: publicBalances, reimbursements }
|
||||||
|
})
|
||||||
6
src/trpc/routers/groups/expenses/index.ts
Normal file
6
src/trpc/routers/groups/expenses/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { createTRPCRouter } from '@/trpc/init'
|
||||||
|
import { listGroupExpensesProcedure } from '@/trpc/routers/groups/expenses/list.procedure'
|
||||||
|
|
||||||
|
export const groupExpensesRouter = createTRPCRouter({
|
||||||
|
list: listGroupExpensesProcedure,
|
||||||
|
})
|
||||||
29
src/trpc/routers/groups/expenses/list.procedure.ts
Normal file
29
src/trpc/routers/groups/expenses/list.procedure.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { getGroupExpenses } from '@/lib/api'
|
||||||
|
import { baseProcedure } from '@/trpc/init'
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
export const listGroupExpensesProcedure = baseProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
groupId: z.string().min(1),
|
||||||
|
cursor: z.number().optional(),
|
||||||
|
limit: z.number().optional(),
|
||||||
|
filter: z.string().optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.query(async ({ input: { groupId, cursor = 0, limit = 10, filter } }) => {
|
||||||
|
const expenses = await getGroupExpenses(groupId, {
|
||||||
|
offset: cursor,
|
||||||
|
length: limit + 1,
|
||||||
|
filter,
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
expenses: expenses.slice(0, limit).map((expense) => ({
|
||||||
|
...expense,
|
||||||
|
createdAt: new Date(expense.createdAt),
|
||||||
|
expenseDate: new Date(expense.expenseDate),
|
||||||
|
})),
|
||||||
|
hasMore: !!expenses[limit],
|
||||||
|
nextCursor: cursor + limit,
|
||||||
|
}
|
||||||
|
})
|
||||||
10
src/trpc/routers/groups/index.ts
Normal file
10
src/trpc/routers/groups/index.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { createTRPCRouter } from '@/trpc/init'
|
||||||
|
import { groupBalancesRouter } from '@/trpc/routers/groups/balances'
|
||||||
|
import { groupExpensesRouter } from '@/trpc/routers/groups/expenses'
|
||||||
|
import { groupInformationRouter } from '@/trpc/routers/groups/information'
|
||||||
|
|
||||||
|
export const groupsRouter = createTRPCRouter({
|
||||||
|
expenses: groupExpensesRouter,
|
||||||
|
balances: groupBalancesRouter,
|
||||||
|
information: groupInformationRouter,
|
||||||
|
})
|
||||||
21
src/trpc/routers/groups/information/get.procedure.ts
Normal file
21
src/trpc/routers/groups/information/get.procedure.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { getGroup } from '@/lib/api'
|
||||||
|
import { baseProcedure } from '@/trpc/init'
|
||||||
|
import { TRPCError } from '@trpc/server'
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
export const getGroupInformationProcedure = baseProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
groupId: z.string().min(1),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.query(async ({ input: { groupId } }) => {
|
||||||
|
const group = await getGroup(groupId)
|
||||||
|
if (!group) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'NOT_FOUND',
|
||||||
|
message: 'Group not found.',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return { information: group.information ?? '' }
|
||||||
|
})
|
||||||
6
src/trpc/routers/groups/information/index.ts
Normal file
6
src/trpc/routers/groups/information/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { createTRPCRouter } from '@/trpc/init'
|
||||||
|
import { getGroupInformationProcedure } from '@/trpc/routers/groups/information/get.procedure'
|
||||||
|
|
||||||
|
export const groupInformationRouter = createTRPCRouter({
|
||||||
|
get: getGroupInformationProcedure,
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user