mirror of
https://github.com/spliit-app/spliit.git
synced 2026-03-01 19:06:12 +01:00
Merge branch 'group-currency-code' of github.com:whimcomp/spliit into whimcomp-group-currency-code
This commit is contained in:
@@ -96,6 +96,11 @@
|
|||||||
"placeholder": "$, €, £…",
|
"placeholder": "$, €, £…",
|
||||||
"description": "We’ll use it to display amounts."
|
"description": "We’ll use it to display amounts."
|
||||||
},
|
},
|
||||||
|
"CurrencyCodeField": {
|
||||||
|
"label": "Main currency",
|
||||||
|
"description": "All amounts and balances will be in this currency. Changing this will NOT convert expenses already entered.",
|
||||||
|
"customOption": "Custom"
|
||||||
|
},
|
||||||
"Participants": {
|
"Participants": {
|
||||||
"title": "Participants",
|
"title": "Participants",
|
||||||
"description": "Enter the name for each participant.",
|
"description": "Enter the name for each participant.",
|
||||||
@@ -399,5 +404,18 @@
|
|||||||
"TV/Phone/Internet": "TV/Phone/Internet",
|
"TV/Phone/Internet": "TV/Phone/Internet",
|
||||||
"Water": "Water"
|
"Water": "Water"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Currencies": {
|
||||||
|
"search": "Search currency...",
|
||||||
|
"noCurrency": "No currencies found.",
|
||||||
|
"custom": {
|
||||||
|
"heading": "Custom"
|
||||||
|
},
|
||||||
|
"common": {
|
||||||
|
"heading": "Most common"
|
||||||
|
},
|
||||||
|
"other": {
|
||||||
|
"heading": "Other currencies"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
195
package-lock.json
generated
195
package-lock.json
generated
@@ -78,6 +78,7 @@
|
|||||||
"@types/react-dom": "^18.2.18",
|
"@types/react-dom": "^18.2.18",
|
||||||
"@types/uuid": "^9.0.6",
|
"@types/uuid": "^9.0.6",
|
||||||
"autoprefixer": "^10",
|
"autoprefixer": "^10",
|
||||||
|
"currency-list": "^1.0.8",
|
||||||
"dotenv": "^16.3.1",
|
"dotenv": "^16.3.1",
|
||||||
"eslint": "^8",
|
"eslint": "^8",
|
||||||
"eslint-config-next": "^14.1.0",
|
"eslint-config-next": "^14.1.0",
|
||||||
@@ -4689,52 +4690,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-3-Clause"
|
"license": "BSD-3-Clause"
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-darwin-x64": {
|
|
||||||
"version": "0.33.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.2.tgz",
|
|
||||||
"integrity": "sha512-/rK/69Rrp9x5kaWBjVN07KixZanRr+W1OiyKdXcbjQD6KbW+obaTeBBtLUAtbBsnlTTmWthw99xqoOS7SsySDg==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"glibc": ">=2.26",
|
|
||||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0",
|
|
||||||
"npm": ">=9.6.5",
|
|
||||||
"pnpm": ">=7.1.0",
|
|
||||||
"yarn": ">=3.2.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@img/sharp-libvips-darwin-x64": "1.0.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@img/sharp-libvips-darwin-x64": {
|
|
||||||
"version": "1.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.1.tgz",
|
|
||||||
"integrity": "sha512-eVU/JYLPVjhhrd8Tk6gosl5pVlvsqiFlt50wotCvdkFGf+mDNBJxMh+bvav+Wt3EBnNZWq8Sp2I7XfSjm8siog==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"macos": ">=10.13",
|
|
||||||
"npm": ">=9.6.5",
|
|
||||||
"pnpm": ">=7.1.0",
|
|
||||||
"yarn": ">=3.2.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@isaacs/cliui": {
|
"node_modules/@isaacs/cliui": {
|
||||||
"version": "8.0.2",
|
"version": "8.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
||||||
@@ -5319,134 +5274,6 @@
|
|||||||
"url": "https://github.com/sponsors/isaacs"
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-darwin-arm64": {
|
|
||||||
"version": "14.2.28",
|
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.28.tgz",
|
|
||||||
"integrity": "sha512-kzGChl9setxYWpk3H6fTZXXPFFjg7urptLq5o5ZgYezCrqlemKttwMT5iFyx/p1e/JeglTwDFRtb923gTJ3R1w==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@next/swc-darwin-x64": {
|
|
||||||
"version": "14.2.28",
|
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.28.tgz",
|
|
||||||
"integrity": "sha512-z6FXYHDJlFOzVEOiiJ/4NG8aLCeayZdcRSMjPDysW297Up6r22xw6Ea9AOwQqbNsth8JNgIK8EkWz2IDwaLQcw==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@next/swc-linux-arm64-gnu": {
|
|
||||||
"version": "14.2.28",
|
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.28.tgz",
|
|
||||||
"integrity": "sha512-9ARHLEQXhAilNJ7rgQX8xs9aH3yJSj888ssSjJLeldiZKR4D7N08MfMqljk77fAwZsWwsrp8ohHsMvurvv9liQ==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@next/swc-linux-arm64-musl": {
|
|
||||||
"version": "14.2.28",
|
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.28.tgz",
|
|
||||||
"integrity": "sha512-p6gvatI1nX41KCizEe6JkF0FS/cEEF0u23vKDpl+WhPe/fCTBeGkEBh7iW2cUM0rvquPVwPWdiUR6Ebr/kQWxQ==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@next/swc-linux-x64-gnu": {
|
|
||||||
"version": "14.2.28",
|
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.28.tgz",
|
|
||||||
"integrity": "sha512-nsiSnz2wO6GwMAX2o0iucONlVL7dNgKUqt/mDTATGO2NY59EO/ZKnKEr80BJFhuA5UC1KZOMblJHWZoqIJddpA==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@next/swc-linux-x64-musl": {
|
|
||||||
"version": "14.2.28",
|
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.28.tgz",
|
|
||||||
"integrity": "sha512-+IuGQKoI3abrXFqx7GtlvNOpeExUH1mTIqCrh1LGFf8DnlUcTmOOCApEnPJUSLrSbzOdsF2ho2KhnQoO0I1RDw==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@next/swc-win32-arm64-msvc": {
|
|
||||||
"version": "14.2.28",
|
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.28.tgz",
|
|
||||||
"integrity": "sha512-l61WZ3nevt4BAnGksUVFKy2uJP5DPz2E0Ma/Oklvo3sGj9sw3q7vBWONFRgz+ICiHpW5mV+mBrkB3XEubMrKaA==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@next/swc-win32-ia32-msvc": {
|
|
||||||
"version": "14.2.28",
|
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.28.tgz",
|
|
||||||
"integrity": "sha512-+Kcp1T3jHZnJ9v9VTJ/yf1t/xmtFAc/Sge4v7mVc1z+NYfYzisi8kJ9AsY8itbgq+WgEwMtOpiLLJsUy2qnXZw==",
|
|
||||||
"cpu": [
|
|
||||||
"ia32"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@next/swc-win32-x64-msvc": {
|
"node_modules/@next/swc-win32-x64-msvc": {
|
||||||
"version": "14.2.28",
|
"version": "14.2.28",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.28.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.28.tgz",
|
||||||
@@ -10927,6 +10754,13 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/currency-list": {
|
||||||
|
"version": "1.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/currency-list/-/currency-list-1.0.8.tgz",
|
||||||
|
"integrity": "sha512-KBUtf8AzoP2WYeAKUYFhhNdHRx8Xw2UoOUnBNVir43RxL96T+iSHMtThtT1DFNtYbbVVyD08ETe3aamn+JX7RA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/damerau-levenshtein": {
|
"node_modules/damerau-levenshtein": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
||||||
@@ -12243,19 +12077,6 @@
|
|||||||
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
|
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/fsevents": {
|
|
||||||
"version": "2.3.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
|
||||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/function-bind": {
|
"node_modules/function-bind": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||||
|
|||||||
@@ -13,7 +13,8 @@
|
|||||||
"postinstall": "prisma migrate deploy && prisma generate",
|
"postinstall": "prisma migrate deploy && prisma generate",
|
||||||
"build-image": "./scripts/build-image.sh",
|
"build-image": "./scripts/build-image.sh",
|
||||||
"start-container": "docker compose --env-file container.env up",
|
"start-container": "docker compose --env-file container.env up",
|
||||||
"test": "jest"
|
"test": "jest",
|
||||||
|
"generate-currency-data": "ts-node -T ./src/scripts/generateCurrencyData.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@formatjs/intl-localematcher": "^0.5.4",
|
"@formatjs/intl-localematcher": "^0.5.4",
|
||||||
@@ -85,6 +86,7 @@
|
|||||||
"@types/react-dom": "^18.2.18",
|
"@types/react-dom": "^18.2.18",
|
||||||
"@types/uuid": "^9.0.6",
|
"@types/uuid": "^9.0.6",
|
||||||
"autoprefixer": "^10",
|
"autoprefixer": "^10",
|
||||||
|
"currency-list": "^1.0.8",
|
||||||
"dotenv": "^16.3.1",
|
"dotenv": "^16.3.1",
|
||||||
"eslint": "^8",
|
"eslint": "^8",
|
||||||
"eslint-config-next": "^14.1.0",
|
"eslint-config-next": "^14.1.0",
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Group" ADD COLUMN "currencyCode" TEXT;
|
||||||
@@ -16,6 +16,7 @@ model Group {
|
|||||||
name String
|
name String
|
||||||
information String? @db.Text
|
information String? @db.Text
|
||||||
currency String @default("$")
|
currency String @default("$")
|
||||||
|
currencyCode String?
|
||||||
participants Participant[]
|
participants Participant[]
|
||||||
expenses Expense[]
|
expenses Expense[]
|
||||||
activities Activity[]
|
activities Activity[]
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Balances } from '@/lib/balances'
|
import { Balances } from '@/lib/balances'
|
||||||
|
import { Currency } from '@/lib/currency'
|
||||||
import { cn, formatCurrency } from '@/lib/utils'
|
import { cn, formatCurrency } from '@/lib/utils'
|
||||||
import { Participant } from '@prisma/client'
|
import { Participant } from '@prisma/client'
|
||||||
import { useLocale } from 'next-intl'
|
import { useLocale } from 'next-intl'
|
||||||
@@ -6,7 +7,7 @@ import { useLocale } from 'next-intl'
|
|||||||
type Props = {
|
type Props = {
|
||||||
balances: Balances
|
balances: Balances
|
||||||
participants: Participant[]
|
participants: Participant[]
|
||||||
currency: string
|
currency: Currency
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BalancesList({ balances, participants, currency }: Props) {
|
export function BalancesList({ balances, participants, currency }: Props) {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
} from '@/components/ui/card'
|
} from '@/components/ui/card'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { getCurrencyFromGroup } from '@/lib/utils'
|
||||||
import { trpc } from '@/trpc/client'
|
import { trpc } from '@/trpc/client'
|
||||||
import { useTranslations } from 'next-intl'
|
import { useTranslations } from 'next-intl'
|
||||||
import { Fragment, useEffect } from 'react'
|
import { Fragment, useEffect } from 'react'
|
||||||
@@ -47,7 +48,7 @@ export default function BalancesAndReimbursements() {
|
|||||||
<BalancesList
|
<BalancesList
|
||||||
balances={balancesData.balances}
|
balances={balancesData.balances}
|
||||||
participants={group?.participants}
|
participants={group?.participants}
|
||||||
currency={group?.currency}
|
currency={getCurrencyFromGroup(group)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -66,7 +67,7 @@ export default function BalancesAndReimbursements() {
|
|||||||
<ReimbursementList
|
<ReimbursementList
|
||||||
reimbursements={balancesData.reimbursements}
|
reimbursements={balancesData.reimbursements}
|
||||||
participants={group?.participants}
|
participants={group?.participants}
|
||||||
currency={group?.currency}
|
currency={getCurrencyFromGroup(group)}
|
||||||
groupId={groupId}
|
groupId={groupId}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import { Money } from '@/components/money'
|
import { Money } from '@/components/money'
|
||||||
import { getBalances } from '@/lib/balances'
|
import { getBalances } from '@/lib/balances'
|
||||||
|
import { Currency } from '@/lib/currency'
|
||||||
import { useActiveUser } from '@/lib/hooks'
|
import { useActiveUser } from '@/lib/hooks'
|
||||||
import { useTranslations } from 'next-intl'
|
import { useTranslations } from 'next-intl'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
groupId: string
|
groupId: string
|
||||||
currency: string
|
currency: Currency
|
||||||
expense: Parameters<typeof getBalances>[0][number]
|
expense: Parameters<typeof getBalances>[0][number]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import {
|
|||||||
import { ToastAction } from '@/components/ui/toast'
|
import { ToastAction } from '@/components/ui/toast'
|
||||||
import { useToast } from '@/components/ui/use-toast'
|
import { useToast } from '@/components/ui/use-toast'
|
||||||
import { useMediaQuery } from '@/lib/hooks'
|
import { useMediaQuery } from '@/lib/hooks'
|
||||||
import { formatCurrency, formatDate, formatFileSize } from '@/lib/utils'
|
import { formatCurrency, formatDate, formatFileSize, getCurrencyFromGroup } from '@/lib/utils'
|
||||||
import { trpc } from '@/trpc/client'
|
import { trpc } from '@/trpc/client'
|
||||||
import { ChevronRight, FileQuestion, Loader2, Receipt } from 'lucide-react'
|
import { ChevronRight, FileQuestion, Loader2, Receipt } from 'lucide-react'
|
||||||
import { useLocale, useTranslations } from 'next-intl'
|
import { useLocale, useTranslations } from 'next-intl'
|
||||||
@@ -202,7 +202,7 @@ function ReceiptDialogContent() {
|
|||||||
receiptInfo.amount ? (
|
receiptInfo.amount ? (
|
||||||
<>
|
<>
|
||||||
{formatCurrency(
|
{formatCurrency(
|
||||||
group.currency,
|
getCurrencyFromGroup(group),
|
||||||
receiptInfo.amount,
|
receiptInfo.amount,
|
||||||
locale,
|
locale,
|
||||||
true,
|
true,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { CategoryIcon } from '@/app/groups/[groupId]/expenses/category-icon'
|
|||||||
import { DocumentsCount } from '@/app/groups/[groupId]/expenses/documents-count'
|
import { DocumentsCount } from '@/app/groups/[groupId]/expenses/documents-count'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { getGroupExpenses } from '@/lib/api'
|
import { getGroupExpenses } from '@/lib/api'
|
||||||
|
import { Currency } from '@/lib/currency'
|
||||||
import { cn, formatCurrency, formatDate } from '@/lib/utils'
|
import { cn, formatCurrency, formatDate } from '@/lib/utils'
|
||||||
import { ChevronRight } from 'lucide-react'
|
import { ChevronRight } from 'lucide-react'
|
||||||
import { useLocale, useTranslations } from 'next-intl'
|
import { useLocale, useTranslations } from 'next-intl'
|
||||||
@@ -45,7 +46,7 @@ function Participants({
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
expense: Expense
|
expense: Expense
|
||||||
currency: string
|
currency: Currency
|
||||||
groupId: string
|
groupId: string
|
||||||
participantCount: number
|
participantCount: number
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,12 +41,18 @@ import {
|
|||||||
expenseFormSchema,
|
expenseFormSchema,
|
||||||
} from '@/lib/schemas'
|
} from '@/lib/schemas'
|
||||||
import { calculateShare } from '@/lib/totals'
|
import { calculateShare } from '@/lib/totals'
|
||||||
import { cn } from '@/lib/utils'
|
import {
|
||||||
|
amountAsDecimal,
|
||||||
|
amountAsMinorUnits,
|
||||||
|
cn,
|
||||||
|
formatCurrency,
|
||||||
|
getCurrencyFromGroup,
|
||||||
|
} from '@/lib/utils'
|
||||||
import { AppRouterOutput } from '@/trpc/routers/_app'
|
import { AppRouterOutput } from '@/trpc/routers/_app'
|
||||||
import { zodResolver } from '@hookform/resolvers/zod'
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
import { RecurrenceRule } from '@prisma/client'
|
import { RecurrenceRule } from '@prisma/client'
|
||||||
import { Save } from 'lucide-react'
|
import { Save } from 'lucide-react'
|
||||||
import { useTranslations } from 'next-intl'
|
import { useLocale, useTranslations } from 'next-intl'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useSearchParams } from 'next/navigation'
|
import { useSearchParams } from 'next/navigation'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
@@ -155,6 +161,7 @@ export function ExpenseForm({
|
|||||||
runtimeFeatureFlags: RuntimeFeatureFlags
|
runtimeFeatureFlags: RuntimeFeatureFlags
|
||||||
}) {
|
}) {
|
||||||
const t = useTranslations('ExpenseForm')
|
const t = useTranslations('ExpenseForm')
|
||||||
|
const locale = useLocale()
|
||||||
const isCreate = expense === undefined
|
const isCreate = expense === undefined
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
|
|
||||||
@@ -172,18 +179,25 @@ export function ExpenseForm({
|
|||||||
return field?.value as RecurrenceRule
|
return field?.value as RecurrenceRule
|
||||||
}
|
}
|
||||||
const defaultSplittingOptions = getDefaultSplittingOptions(group)
|
const defaultSplittingOptions = getDefaultSplittingOptions(group)
|
||||||
|
const groupCurrency = getCurrencyFromGroup(group)
|
||||||
const form = useForm<ExpenseFormValues>({
|
const form = useForm<ExpenseFormValues>({
|
||||||
resolver: zodResolver(expenseFormSchema),
|
resolver: zodResolver(expenseFormSchema),
|
||||||
defaultValues: expense
|
defaultValues: expense
|
||||||
? {
|
? {
|
||||||
title: expense.title,
|
title: expense.title,
|
||||||
expenseDate: expense.expenseDate ?? new Date(),
|
expenseDate: expense.expenseDate ?? new Date(),
|
||||||
amount: String(expense.amount / 100) as unknown as number, // hack
|
amount: String(
|
||||||
|
amountAsDecimal(expense.amount, groupCurrency),
|
||||||
|
) as unknown as number, // hack
|
||||||
category: expense.categoryId,
|
category: expense.categoryId,
|
||||||
paidBy: expense.paidById,
|
paidBy: expense.paidById,
|
||||||
paidFor: expense.paidFor.map(({ participantId, shares }) => ({
|
paidFor: expense.paidFor.map(({ participantId, shares }) => ({
|
||||||
participant: participantId,
|
participant: participantId,
|
||||||
shares: String(shares / 100) as unknown as number,
|
shares: String(
|
||||||
|
expense.splitMode === 'BY_AMOUNT'
|
||||||
|
? amountAsDecimal(shares, groupCurrency)
|
||||||
|
: shares / 100,
|
||||||
|
) as unknown as number,
|
||||||
})),
|
})),
|
||||||
splitMode: expense.splitMode,
|
splitMode: expense.splitMode,
|
||||||
saveDefaultSplittingOptions: false,
|
saveDefaultSplittingOptions: false,
|
||||||
@@ -197,7 +211,10 @@ export function ExpenseForm({
|
|||||||
title: t('reimbursement'),
|
title: t('reimbursement'),
|
||||||
expenseDate: new Date(),
|
expenseDate: new Date(),
|
||||||
amount: String(
|
amount: String(
|
||||||
(Number(searchParams.get('amount')) || 0) / 100,
|
amountAsDecimal(
|
||||||
|
Number(searchParams.get('amount')) || 0,
|
||||||
|
groupCurrency,
|
||||||
|
),
|
||||||
) as unknown as number, // hack
|
) as unknown as number, // hack
|
||||||
category: 1, // category with Id 1 is Payment
|
category: 1, // category with Id 1 is Payment
|
||||||
paidBy: searchParams.get('from') ?? undefined,
|
paidBy: searchParams.get('from') ?? undefined,
|
||||||
@@ -250,6 +267,16 @@ export function ExpenseForm({
|
|||||||
|
|
||||||
const submit = async (values: ExpenseFormValues) => {
|
const submit = async (values: ExpenseFormValues) => {
|
||||||
await persistDefaultSplittingOptions(group.id, values)
|
await persistDefaultSplittingOptions(group.id, values)
|
||||||
|
|
||||||
|
// Store monetary amounts in minor units (cents)
|
||||||
|
values.amount = amountAsMinorUnits(values.amount, groupCurrency)
|
||||||
|
values.paidFor = values.paidFor.map(({ participant, shares }) => ({
|
||||||
|
participant,
|
||||||
|
shares:
|
||||||
|
values.splitMode === 'BY_AMOUNT'
|
||||||
|
? amountAsMinorUnits(shares, groupCurrency)
|
||||||
|
: shares,
|
||||||
|
}))
|
||||||
return onSubmit(values, activeUserId ?? undefined)
|
return onSubmit(values, activeUserId ?? undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,7 +330,9 @@ export function ExpenseForm({
|
|||||||
return {
|
return {
|
||||||
...participant,
|
...participant,
|
||||||
shares: String(
|
shares: String(
|
||||||
Number(amountPerRemaining.toFixed(2)),
|
Number(
|
||||||
|
amountPerRemaining.toFixed(groupCurrency.decimal_digits),
|
||||||
|
),
|
||||||
) as unknown as number,
|
) as unknown as number,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -644,11 +673,14 @@ export function ExpenseForm({
|
|||||||
) &&
|
) &&
|
||||||
!form.watch('isReimbursement') && (
|
!form.watch('isReimbursement') && (
|
||||||
<span className="text-muted-foreground ml-2">
|
<span className="text-muted-foreground ml-2">
|
||||||
({group.currency}{' '}
|
(
|
||||||
{(
|
{formatCurrency(
|
||||||
|
groupCurrency,
|
||||||
calculateShare(id, {
|
calculateShare(id, {
|
||||||
amount:
|
amount: amountAsMinorUnits(
|
||||||
Number(form.watch('amount')) * 100, // Convert to cents
|
Number(form.watch('amount')),
|
||||||
|
groupCurrency,
|
||||||
|
), // Convert to cents
|
||||||
paidFor: field.value.map(
|
paidFor: field.value.map(
|
||||||
({ participant, shares }) => ({
|
({ participant, shares }) => ({
|
||||||
participant: {
|
participant: {
|
||||||
@@ -658,10 +690,14 @@ export function ExpenseForm({
|
|||||||
},
|
},
|
||||||
shares:
|
shares:
|
||||||
form.watch('splitMode') ===
|
form.watch('splitMode') ===
|
||||||
'BY_PERCENTAGE' ||
|
'BY_PERCENTAGE'
|
||||||
form.watch('splitMode') ===
|
|
||||||
'BY_AMOUNT'
|
|
||||||
? Number(shares) * 100 // Convert percentage to basis points (e.g., 50% -> 5000)
|
? Number(shares) * 100 // Convert percentage to basis points (e.g., 50% -> 5000)
|
||||||
|
: form.watch('splitMode') ===
|
||||||
|
'BY_AMOUNT'
|
||||||
|
? amountAsMinorUnits(
|
||||||
|
shares,
|
||||||
|
groupCurrency,
|
||||||
|
)
|
||||||
: shares,
|
: shares,
|
||||||
expenseId: '',
|
expenseId: '',
|
||||||
participantId: '',
|
participantId: '',
|
||||||
@@ -670,8 +706,9 @@ export function ExpenseForm({
|
|||||||
splitMode: form.watch('splitMode'),
|
splitMode: form.watch('splitMode'),
|
||||||
isReimbursement:
|
isReimbursement:
|
||||||
form.watch('isReimbursement'),
|
form.watch('isReimbursement'),
|
||||||
}) / 100
|
}),
|
||||||
).toFixed(2)}
|
locale,
|
||||||
|
)}
|
||||||
)
|
)
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ 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 { getCurrencyFromGroup } from '@/lib/utils'
|
||||||
import { trpc } from '@/trpc/client'
|
import { trpc } from '@/trpc/client'
|
||||||
import dayjs, { type Dayjs } from 'dayjs'
|
import dayjs, { type Dayjs } from 'dayjs'
|
||||||
import { useTranslations } from 'next-intl'
|
import { useTranslations } from 'next-intl'
|
||||||
@@ -170,7 +171,7 @@ const ExpenseListForSearch = ({
|
|||||||
<ExpenseCard
|
<ExpenseCard
|
||||||
key={expense.id}
|
key={expense.id}
|
||||||
expense={expense}
|
expense={expense}
|
||||||
currency={group.currency}
|
currency={getCurrencyFromGroup(group)}
|
||||||
groupId={groupId}
|
groupId={groupId}
|
||||||
participantCount={group.participants.length}
|
participantCount={group.participants.length}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { formatAmountAsDecimal, getCurrencyFromGroup } from '@/lib/utils'
|
||||||
import { Parser } from '@json2csv/plainjs'
|
import { Parser } from '@json2csv/plainjs'
|
||||||
import { PrismaClient } from '@prisma/client'
|
import { PrismaClient } from '@prisma/client'
|
||||||
import contentDisposition from 'content-disposition'
|
import contentDisposition from 'content-disposition'
|
||||||
@@ -30,6 +31,7 @@ export async function GET(
|
|||||||
id: true,
|
id: true,
|
||||||
name: true,
|
name: true,
|
||||||
currency: true,
|
currency: true,
|
||||||
|
currencyCode: true,
|
||||||
expenses: {
|
expenses: {
|
||||||
select: {
|
select: {
|
||||||
expenseDate: true,
|
expenseDate: true,
|
||||||
@@ -91,12 +93,14 @@ export async function GET(
|
|||||||
})),
|
})),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const currency = getCurrencyFromGroup(group)
|
||||||
|
|
||||||
const expenses = group.expenses.map((expense) => ({
|
const expenses = group.expenses.map((expense) => ({
|
||||||
date: formatDate(expense.expenseDate),
|
date: formatDate(expense.expenseDate),
|
||||||
title: expense.title,
|
title: expense.title,
|
||||||
categoryName: expense.category?.name || '',
|
categoryName: expense.category?.name || '',
|
||||||
currency: group.currency,
|
currency: group.currencyCode ?? group.currency,
|
||||||
amount: (expense.amount / 100).toFixed(2),
|
amount: formatAmountAsDecimal(expense.amount, currency),
|
||||||
isReimbursement: expense.isReimbursement ? 'Yes' : 'No',
|
isReimbursement: expense.isReimbursement ? 'Yes' : 'No',
|
||||||
splitMode: splitModeLabel[expense.splitMode],
|
splitMode: splitModeLabel[expense.splitMode],
|
||||||
...Object.fromEntries(
|
...Object.fromEntries(
|
||||||
@@ -113,10 +117,10 @@ export async function GET(
|
|||||||
)
|
)
|
||||||
|
|
||||||
const isPaidByParticipant = expense.paidById === participant.id
|
const isPaidByParticipant = expense.paidById === participant.id
|
||||||
const participantAmountShare = +(
|
const participantAmountShare = +formatAmountAsDecimal(
|
||||||
((expense.amount / totalShares) * participantShare) /
|
(expense.amount / totalShares) * participantShare,
|
||||||
100
|
currency,
|
||||||
).toFixed(2)
|
)
|
||||||
|
|
||||||
return [
|
return [
|
||||||
participant.name,
|
participant.name,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export async function GET(
|
|||||||
id: true,
|
id: true,
|
||||||
name: true,
|
name: true,
|
||||||
currency: true,
|
currency: true,
|
||||||
|
currencyCode: true,
|
||||||
expenses: {
|
expenses: {
|
||||||
select: {
|
select: {
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Reimbursement } from '@/lib/balances'
|
import { Reimbursement } from '@/lib/balances'
|
||||||
|
import { Currency } from '@/lib/currency'
|
||||||
import { formatCurrency } from '@/lib/utils'
|
import { formatCurrency } from '@/lib/utils'
|
||||||
import { Participant } from '@prisma/client'
|
import { Participant } from '@prisma/client'
|
||||||
import { useLocale, useTranslations } from 'next-intl'
|
import { useLocale, useTranslations } from 'next-intl'
|
||||||
@@ -8,7 +9,7 @@ import Link from 'next/link'
|
|||||||
type Props = {
|
type Props = {
|
||||||
reimbursements: Reimbursement[]
|
reimbursements: Reimbursement[]
|
||||||
participants: Participant[]
|
participants: Participant[]
|
||||||
currency: string
|
currency: Currency
|
||||||
groupId: string
|
groupId: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
|
import { Currency } from '@/lib/currency'
|
||||||
import { formatCurrency } from '@/lib/utils'
|
import { formatCurrency } from '@/lib/utils'
|
||||||
import { useLocale, useTranslations } from 'next-intl'
|
import { useLocale, useTranslations } from 'next-intl'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
totalGroupSpendings: number
|
totalGroupSpendings: number
|
||||||
currency: string
|
currency: Currency
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TotalsGroupSpending({ totalGroupSpendings, currency }: Props) {
|
export function TotalsGroupSpending({ totalGroupSpendings, currency }: Props) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
import { Currency } from '@/lib/currency'
|
||||||
import { cn, formatCurrency } from '@/lib/utils'
|
import { cn, formatCurrency } from '@/lib/utils'
|
||||||
import { useLocale, useTranslations } from 'next-intl'
|
import { useLocale, useTranslations } from 'next-intl'
|
||||||
|
|
||||||
@@ -7,7 +8,7 @@ export function TotalsYourShare({
|
|||||||
currency,
|
currency,
|
||||||
}: {
|
}: {
|
||||||
totalParticipantShare?: number
|
totalParticipantShare?: number
|
||||||
currency: string
|
currency: Currency
|
||||||
}) {
|
}) {
|
||||||
const locale = useLocale()
|
const locale = useLocale()
|
||||||
const t = useTranslations('Stats.Totals')
|
const t = useTranslations('Stats.Totals')
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
import { Currency } from '@/lib/currency'
|
||||||
import { cn, formatCurrency } from '@/lib/utils'
|
import { cn, formatCurrency } from '@/lib/utils'
|
||||||
import { useLocale, useTranslations } from 'next-intl'
|
import { useLocale, useTranslations } from 'next-intl'
|
||||||
|
|
||||||
@@ -7,7 +8,7 @@ export function TotalsYourSpendings({
|
|||||||
currency,
|
currency,
|
||||||
}: {
|
}: {
|
||||||
totalParticipantSpendings?: number
|
totalParticipantSpendings?: number
|
||||||
currency: string
|
currency: Currency
|
||||||
}) {
|
}) {
|
||||||
const locale = useLocale()
|
const locale = useLocale()
|
||||||
const t = useTranslations('Stats.Totals')
|
const t = useTranslations('Stats.Totals')
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { TotalsYourShare } from '@/app/groups/[groupId]/stats/totals-your-share'
|
|||||||
import { TotalsYourSpendings } from '@/app/groups/[groupId]/stats/totals-your-spending'
|
import { TotalsYourSpendings } from '@/app/groups/[groupId]/stats/totals-your-spending'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { useActiveUser } from '@/lib/hooks'
|
import { useActiveUser } from '@/lib/hooks'
|
||||||
|
import { getCurrencyFromGroup } from '@/lib/utils'
|
||||||
import { trpc } from '@/trpc/client'
|
import { trpc } from '@/trpc/client'
|
||||||
import { useCurrentGroup } from '../current-group-context'
|
import { useCurrentGroup } from '../current-group-context'
|
||||||
|
|
||||||
@@ -33,21 +34,23 @@ export function Totals() {
|
|||||||
totalParticipantSpendings,
|
totalParticipantSpendings,
|
||||||
} = data
|
} = data
|
||||||
|
|
||||||
|
const currency = getCurrencyFromGroup(group)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<TotalsGroupSpending
|
<TotalsGroupSpending
|
||||||
totalGroupSpendings={totalGroupSpendings}
|
totalGroupSpendings={totalGroupSpendings}
|
||||||
currency={group.currency}
|
currency={currency}
|
||||||
/>
|
/>
|
||||||
{participantId && (
|
{participantId && (
|
||||||
<>
|
<>
|
||||||
<TotalsYourSpendings
|
<TotalsYourSpendings
|
||||||
totalParticipantSpendings={totalParticipantSpendings}
|
totalParticipantSpendings={totalParticipantSpendings}
|
||||||
currency={group.currency}
|
currency={currency}
|
||||||
/>
|
/>
|
||||||
<TotalsYourShare
|
<TotalsYourShare
|
||||||
totalParticipantShare={totalParticipantShare}
|
totalParticipantShare={totalParticipantShare}
|
||||||
currency={group.currency}
|
currency={currency}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
198
src/components/currency-selector.tsx
Normal file
198
src/components/currency-selector.tsx
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
import { ChevronDown, Loader2 } from 'lucide-react'
|
||||||
|
|
||||||
|
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 { Currency } from '@/lib/currency'
|
||||||
|
import { useMediaQuery } from '@/lib/hooks'
|
||||||
|
import { useTranslations } from 'next-intl'
|
||||||
|
import { forwardRef, useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
currencies: Currency[]
|
||||||
|
onValueChange: (currencyCode: Currency['code']) => void
|
||||||
|
/** Currency code to be selected by default. Overwriting this value will update current selection, too. */
|
||||||
|
defaultValue: Currency['code']
|
||||||
|
isLoading: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CurrencySelector({
|
||||||
|
currencies,
|
||||||
|
onValueChange,
|
||||||
|
defaultValue,
|
||||||
|
isLoading,
|
||||||
|
}: Props) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [value, setValue] = useState<string>(defaultValue)
|
||||||
|
const isDesktop = useMediaQuery('(min-width: 768px)')
|
||||||
|
|
||||||
|
// allow overwriting currently selected currency from outside
|
||||||
|
useEffect(() => {
|
||||||
|
setValue(defaultValue)
|
||||||
|
onValueChange(defaultValue)
|
||||||
|
}, [defaultValue])
|
||||||
|
|
||||||
|
const selectedCurrency =
|
||||||
|
currencies.find((currency) => (currency.code ?? '') === value) ??
|
||||||
|
currencies[0]
|
||||||
|
|
||||||
|
if (isDesktop) {
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<CurrencyButton
|
||||||
|
currency={selectedCurrency}
|
||||||
|
open={open}
|
||||||
|
isLoading={isLoading}
|
||||||
|
/>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="p-0" align="start">
|
||||||
|
<CurrencyCommand
|
||||||
|
currencies={currencies}
|
||||||
|
onValueChange={(code) => {
|
||||||
|
setValue(code)
|
||||||
|
onValueChange(code)
|
||||||
|
setOpen(false)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer open={open} onOpenChange={setOpen}>
|
||||||
|
<DrawerTrigger asChild>
|
||||||
|
<CurrencyButton
|
||||||
|
currency={selectedCurrency}
|
||||||
|
open={open}
|
||||||
|
isLoading={isLoading}
|
||||||
|
/>
|
||||||
|
</DrawerTrigger>
|
||||||
|
<DrawerContent className="p-0">
|
||||||
|
<CurrencyCommand
|
||||||
|
currencies={currencies}
|
||||||
|
onValueChange={(id) => {
|
||||||
|
setValue(id)
|
||||||
|
onValueChange(id)
|
||||||
|
setOpen(false)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CurrencyCommand({
|
||||||
|
currencies,
|
||||||
|
onValueChange,
|
||||||
|
}: {
|
||||||
|
currencies: Currency[]
|
||||||
|
onValueChange: (currencyId: Currency['code']) => void
|
||||||
|
}) {
|
||||||
|
const currencyGroup = (currency: Currency) => {
|
||||||
|
switch (currency.code) {
|
||||||
|
case 'USD':
|
||||||
|
case 'EUR':
|
||||||
|
case 'JPY':
|
||||||
|
case 'GBP':
|
||||||
|
case 'CNY':
|
||||||
|
return 'common'
|
||||||
|
default:
|
||||||
|
if (currency.code === '') return 'custom'
|
||||||
|
return 'other'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const t = useTranslations('Currencies')
|
||||||
|
const currenciesByGroup = currencies.reduce<Record<string, Currency[]>>(
|
||||||
|
(acc, currency) => ({
|
||||||
|
...acc,
|
||||||
|
[currencyGroup(currency)]: (acc[currencyGroup(currency)] ?? []).concat([
|
||||||
|
currency,
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder={t('search')} className="text-base" />
|
||||||
|
<CommandEmpty>{t('noCurrency')}</CommandEmpty>
|
||||||
|
<div className="w-full max-h-[300px] overflow-y-auto">
|
||||||
|
{Object.entries(currenciesByGroup).map(
|
||||||
|
([group, groupCurrencies], index) => (
|
||||||
|
<CommandGroup key={index} heading={t(`${group}.heading`)}>
|
||||||
|
{groupCurrencies.map((currency) => (
|
||||||
|
<CommandItem
|
||||||
|
key={currency.code}
|
||||||
|
value={`${currency.code} ${currency.name} ${currency.symbol}`}
|
||||||
|
onSelect={(currentValue) => {
|
||||||
|
onValueChange(currency.code)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CurrencyLabel currency={currency} />
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Command>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type CurrencyButtonProps = {
|
||||||
|
currency: Currency
|
||||||
|
open: boolean
|
||||||
|
isLoading: boolean
|
||||||
|
}
|
||||||
|
const CurrencyButton = forwardRef<HTMLButtonElement, CurrencyButtonProps>(
|
||||||
|
(
|
||||||
|
{ currency, open, isLoading, ...props }: ButtonProps & CurrencyButtonProps,
|
||||||
|
ref,
|
||||||
|
) => {
|
||||||
|
const iconClassName = 'ml-2 h-4 w-4 shrink-0 opacity-50'
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={open}
|
||||||
|
className="flex w-full justify-between"
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<CurrencyLabel currency={currency} />
|
||||||
|
{isLoading ? (
|
||||||
|
<Loader2 className={`animate-spin ${iconClassName}`} />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className={iconClassName} />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
CurrencyButton.displayName = 'CurrencyButton'
|
||||||
|
|
||||||
|
function CurrencyLabel({ currency }: { currency: Currency }) {
|
||||||
|
const flagUrl = `https://flagcdn.com/h24/${
|
||||||
|
currency?.code.length ? currency.code.slice(0, 2).toLowerCase() : 'un'
|
||||||
|
}.png`
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<img src={flagUrl} className="w-4" alt="" />
|
||||||
|
{currency.name}
|
||||||
|
{currency.code ? ` (${currency.code})` : ''}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -30,14 +30,17 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
|
import { Locale } from '@/i18n'
|
||||||
import { getGroup } from '@/lib/api'
|
import { getGroup } from '@/lib/api'
|
||||||
|
import { defaultCurrencyList, getCurrency } from '@/lib/currency'
|
||||||
import { GroupFormValues, groupFormSchema } from '@/lib/schemas'
|
import { GroupFormValues, groupFormSchema } from '@/lib/schemas'
|
||||||
import { zodResolver } from '@hookform/resolvers/zod'
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
import { Save, Trash2 } from 'lucide-react'
|
import { Save, Trash2 } from 'lucide-react'
|
||||||
import { useTranslations } from 'next-intl'
|
import { useLocale, useTranslations } from 'next-intl'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useFieldArray, useForm } from 'react-hook-form'
|
import { useFieldArray, useForm } from 'react-hook-form'
|
||||||
|
import { CurrencySelector } from './currency-selector'
|
||||||
import { Textarea } from './ui/textarea'
|
import { Textarea } from './ui/textarea'
|
||||||
|
|
||||||
export type Props = {
|
export type Props = {
|
||||||
@@ -54,6 +57,7 @@ export function GroupForm({
|
|||||||
onSubmit,
|
onSubmit,
|
||||||
protectedParticipantIds = [],
|
protectedParticipantIds = [],
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const locale = useLocale()
|
||||||
const t = useTranslations('GroupForm')
|
const t = useTranslations('GroupForm')
|
||||||
const form = useForm<GroupFormValues>({
|
const form = useForm<GroupFormValues>({
|
||||||
resolver: zodResolver(groupFormSchema),
|
resolver: zodResolver(groupFormSchema),
|
||||||
@@ -62,6 +66,7 @@ export function GroupForm({
|
|||||||
name: group.name,
|
name: group.name,
|
||||||
information: group.information ?? '',
|
information: group.information ?? '',
|
||||||
currency: group.currency,
|
currency: group.currency,
|
||||||
|
currencyCode: group.currencyCode,
|
||||||
participants: group.participants,
|
participants: group.participants,
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
@@ -143,6 +148,41 @@ export function GroupForm({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="currencyCode"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t('CurrencyCodeField.label')}</FormLabel>
|
||||||
|
<CurrencySelector
|
||||||
|
currencies={defaultCurrencyList(
|
||||||
|
locale as Locale,
|
||||||
|
t('CurrencyCodeField.customOption'),
|
||||||
|
)}
|
||||||
|
defaultValue={form.watch(field.name) ?? ''}
|
||||||
|
onValueChange={(newCurrency) => {
|
||||||
|
field.onChange(newCurrency)
|
||||||
|
const currency = getCurrency(newCurrency)
|
||||||
|
if (
|
||||||
|
currency.code.length ||
|
||||||
|
form.getFieldState('currency').isTouched
|
||||||
|
)
|
||||||
|
form.setValue('currency', currency.symbol, {
|
||||||
|
shouldValidate: true,
|
||||||
|
shouldTouch: true,
|
||||||
|
shouldDirty: true,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
isLoading={false}
|
||||||
|
/>
|
||||||
|
<FormDescription>
|
||||||
|
{t('CurrencyCodeField.description')}
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="currency"
|
name="currency"
|
||||||
@@ -153,6 +193,7 @@ export function GroupForm({
|
|||||||
<Input
|
<Input
|
||||||
className="text-base"
|
className="text-base"
|
||||||
placeholder={t('CurrencyField.placeholder')}
|
placeholder={t('CurrencyField.placeholder')}
|
||||||
|
disabled={!!form.watch('currencyCode')?.length}
|
||||||
max={5}
|
max={5}
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
import { Currency } from '@/lib/currency'
|
||||||
import { cn, formatCurrency } from '@/lib/utils'
|
import { cn, formatCurrency } from '@/lib/utils'
|
||||||
import { useLocale } from 'next-intl'
|
import { useLocale } from 'next-intl'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
currency: string
|
currency: Currency
|
||||||
amount: number
|
amount: number
|
||||||
bold?: boolean
|
bold?: boolean
|
||||||
colored?: boolean
|
colored?: boolean
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export async function createGroup(groupFormValues: GroupFormValues) {
|
|||||||
name: groupFormValues.name,
|
name: groupFormValues.name,
|
||||||
information: groupFormValues.information,
|
information: groupFormValues.information,
|
||||||
currency: groupFormValues.currency,
|
currency: groupFormValues.currency,
|
||||||
|
currencyCode: groupFormValues.currencyCode,
|
||||||
participants: {
|
participants: {
|
||||||
createMany: {
|
createMany: {
|
||||||
data: groupFormValues.participants.map(({ name }) => ({
|
data: groupFormValues.participants.map(({ name }) => ({
|
||||||
@@ -293,6 +294,7 @@ export async function updateGroup(
|
|||||||
name: groupFormValues.name,
|
name: groupFormValues.name,
|
||||||
information: groupFormValues.information,
|
information: groupFormValues.information,
|
||||||
currency: groupFormValues.currency,
|
currency: groupFormValues.currency,
|
||||||
|
currencyCode: groupFormValues.currencyCode,
|
||||||
participants: {
|
participants: {
|
||||||
deleteMany: existingGroup.participants.filter(
|
deleteMany: existingGroup.participants.filter(
|
||||||
(p) => !groupFormValues.participants.some((p2) => p2.id === p.id),
|
(p) => !groupFormValues.participants.some((p2) => p2.id === p.id),
|
||||||
|
|||||||
4082
src/lib/currency-data.json
Normal file
4082
src/lib/currency-data.json
Normal file
File diff suppressed because it is too large
Load Diff
89
src/lib/currency.ts
Normal file
89
src/lib/currency.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { Locale } from '@/i18n'
|
||||||
|
import currencyList from './currency-data.json'
|
||||||
|
|
||||||
|
export type Currency = {
|
||||||
|
name: string
|
||||||
|
symbol_native: string
|
||||||
|
symbol: string
|
||||||
|
code: string
|
||||||
|
name_plural: string
|
||||||
|
rounding: number
|
||||||
|
decimal_digits: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const supportedCurrencyCodes = [
|
||||||
|
'USD',
|
||||||
|
'EUR',
|
||||||
|
'JPY',
|
||||||
|
'BGN',
|
||||||
|
'CZK',
|
||||||
|
'DKK',
|
||||||
|
'GBP',
|
||||||
|
'HUF',
|
||||||
|
'PLN',
|
||||||
|
'RON',
|
||||||
|
'SEK',
|
||||||
|
'CHF',
|
||||||
|
'ISK',
|
||||||
|
'NOK',
|
||||||
|
'TRY',
|
||||||
|
'AUD',
|
||||||
|
'BRL',
|
||||||
|
'CAD',
|
||||||
|
'CNY',
|
||||||
|
'HKD',
|
||||||
|
'IDR',
|
||||||
|
'ILS',
|
||||||
|
'INR',
|
||||||
|
'KRW',
|
||||||
|
'MXN',
|
||||||
|
'NZD',
|
||||||
|
'PHP',
|
||||||
|
'SGD',
|
||||||
|
'THB',
|
||||||
|
'ZAR',
|
||||||
|
] as const
|
||||||
|
export type supportedCurrencyCodeType = (typeof supportedCurrencyCodes)[number]
|
||||||
|
|
||||||
|
export function defaultCurrencyList(
|
||||||
|
locale: Locale = 'en-US',
|
||||||
|
customChoice: string | null = null,
|
||||||
|
) {
|
||||||
|
const currencies = customChoice
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
name: customChoice,
|
||||||
|
symbol_native: '',
|
||||||
|
symbol: '',
|
||||||
|
code: '',
|
||||||
|
name_plural: customChoice,
|
||||||
|
rounding: 0,
|
||||||
|
decimal_digits: 2,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []
|
||||||
|
const allCurrencies = currencyList[locale]
|
||||||
|
return currencies.concat(Object.values(allCurrencies))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCurrency(
|
||||||
|
currencyCode: string | undefined | null,
|
||||||
|
locale: Locale = 'en-US',
|
||||||
|
customChoice = 'Custom',
|
||||||
|
): Currency {
|
||||||
|
const defaultCurrency = {
|
||||||
|
name: customChoice,
|
||||||
|
symbol_native: '',
|
||||||
|
symbol: '',
|
||||||
|
code: '',
|
||||||
|
name_plural: customChoice,
|
||||||
|
rounding: 0,
|
||||||
|
decimal_digits: 2,
|
||||||
|
}
|
||||||
|
if (!currencyCode || currencyCode === '') return defaultCurrency
|
||||||
|
const currencyListInLocale = currencyList[locale] ?? currencyList['en-US']
|
||||||
|
return (
|
||||||
|
currencyListInLocale[currencyCode as supportedCurrencyCodeType] ??
|
||||||
|
defaultCurrency
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ export const groupFormSchema = z
|
|||||||
name: z.string().min(2, 'min2').max(50, 'max50'),
|
name: z.string().min(2, 'min2').max(50, 'max50'),
|
||||||
information: z.string().optional(),
|
information: z.string().optional(),
|
||||||
currency: z.string().min(1, 'min1').max(5, 'max5'),
|
currency: z.string().min(1, 'min1').max(5, 'max5'),
|
||||||
|
currencyCode: z.union([z.string().length(3).nullish(), z.literal('')]), // ISO-4217 currency code
|
||||||
participants: z
|
participants: z
|
||||||
.array(
|
.array(
|
||||||
z.object({
|
z.object({
|
||||||
@@ -47,7 +48,7 @@ export const expenseFormSchema = z
|
|||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
message: 'invalidNumber',
|
message: 'invalidNumber',
|
||||||
})
|
})
|
||||||
return Math.round(valueAsNumber * 100)
|
return valueAsNumber
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
{ required_error: 'amountRequired' },
|
{ required_error: 'amountRequired' },
|
||||||
@@ -69,17 +70,16 @@ export const expenseFormSchema = z
|
|||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
message: 'invalidNumber',
|
message: 'invalidNumber',
|
||||||
})
|
})
|
||||||
return Math.round(valueAsNumber * 100)
|
return value
|
||||||
}),
|
}),
|
||||||
]),
|
]),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.min(1, 'paidForMin1')
|
.min(1, 'paidForMin1')
|
||||||
.superRefine((paidFor, ctx) => {
|
.superRefine((paidFor, ctx) => {
|
||||||
let sum = 0
|
|
||||||
for (const { shares } of paidFor) {
|
for (const { shares } of paidFor) {
|
||||||
sum += shares
|
const shareNumber = Number(shares)
|
||||||
if (shares < 1) {
|
if (shareNumber <= 0) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
message: 'noZeroShares',
|
message: 'noZeroShares',
|
||||||
@@ -112,17 +112,16 @@ export const expenseFormSchema = z
|
|||||||
.default('NONE'),
|
.default('NONE'),
|
||||||
})
|
})
|
||||||
.superRefine((expense, ctx) => {
|
.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) {
|
switch (expense.splitMode) {
|
||||||
case 'EVENLY':
|
case 'EVENLY':
|
||||||
break // noop
|
break // noop
|
||||||
case 'BY_SHARES':
|
case 'BY_SHARES':
|
||||||
break // noop
|
break // noop
|
||||||
case 'BY_AMOUNT': {
|
case 'BY_AMOUNT': {
|
||||||
|
const sum = expense.paidFor.reduce(
|
||||||
|
(sum, { shares }) => sum + Number(shares),
|
||||||
|
0,
|
||||||
|
)
|
||||||
if (sum !== expense.amount) {
|
if (sum !== expense.amount) {
|
||||||
const detail =
|
const detail =
|
||||||
sum < expense.amount
|
sum < expense.amount
|
||||||
@@ -137,6 +136,14 @@ export const expenseFormSchema = z
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 'BY_PERCENTAGE': {
|
case 'BY_PERCENTAGE': {
|
||||||
|
const sum = expense.paidFor.reduce(
|
||||||
|
(sum, { shares }) =>
|
||||||
|
sum +
|
||||||
|
(typeof shares === 'string'
|
||||||
|
? Math.round(Number(shares) * 100)
|
||||||
|
: Number(shares)),
|
||||||
|
0,
|
||||||
|
)
|
||||||
if (sum !== 10000) {
|
if (sum !== 10000) {
|
||||||
const detail =
|
const detail =
|
||||||
sum < 10000
|
sum < 10000
|
||||||
@@ -152,6 +159,26 @@ export const expenseFormSchema = z
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
.transform((expense) => {
|
||||||
|
// Format the share split as a number (if from form submission)
|
||||||
|
return {
|
||||||
|
...expense,
|
||||||
|
paidFor: expense.paidFor.map(({ participant, shares }) => {
|
||||||
|
if (typeof shares === 'string' && expense.splitMode !== 'BY_AMOUNT') {
|
||||||
|
// For splitting not by amount, preserve the previous behaviour of multiplying the share by 100
|
||||||
|
return {
|
||||||
|
participant,
|
||||||
|
shares: Math.round(Number(shares) * 100),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Otherwise, no need as the number will have been formatted according to currency.
|
||||||
|
return {
|
||||||
|
participant,
|
||||||
|
shares: Number(shares),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
export type ExpenseFormValues = z.infer<typeof expenseFormSchema>
|
export type ExpenseFormValues = z.infer<typeof expenseFormSchema>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
|
import { Currency } from './currency'
|
||||||
import { formatCurrency } from './utils'
|
import { formatCurrency } from './utils'
|
||||||
|
|
||||||
describe('formatCurrency', () => {
|
describe('formatCurrency', () => {
|
||||||
const currency = 'CUR'
|
const currency: Currency = {
|
||||||
|
name: 'Test',
|
||||||
|
symbol_native: '',
|
||||||
|
symbol: 'CUR',
|
||||||
|
code: '',
|
||||||
|
name_plural: '',
|
||||||
|
rounding: 0,
|
||||||
|
decimal_digits: 2,
|
||||||
|
}
|
||||||
/** For testing decimals */
|
/** For testing decimals */
|
||||||
const partialAmount = 1.23
|
const partialAmount = 1.23
|
||||||
/** For testing small full amounts */
|
/** For testing small full amounts */
|
||||||
@@ -27,32 +36,32 @@ describe('formatCurrency', () => {
|
|||||||
{
|
{
|
||||||
amount: partialAmount,
|
amount: partialAmount,
|
||||||
locale: `en-US`,
|
locale: `en-US`,
|
||||||
result: `${currency}1.23`,
|
result: `${currency.symbol}1.23`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
amount: smallAmount,
|
amount: smallAmount,
|
||||||
locale: `en-US`,
|
locale: `en-US`,
|
||||||
result: `${currency}1.00`,
|
result: `${currency.symbol}1.00`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
amount: largeAmount,
|
amount: largeAmount,
|
||||||
locale: `en-US`,
|
locale: `en-US`,
|
||||||
result: `${currency}10,000.00`,
|
result: `${currency.symbol}10,000.00`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
amount: partialAmount,
|
amount: partialAmount,
|
||||||
locale: `de-DE`,
|
locale: `de-DE`,
|
||||||
result: `1,23${nbsp}${currency}`,
|
result: `1,23${nbsp}${currency.symbol}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
amount: smallAmount,
|
amount: smallAmount,
|
||||||
locale: `de-DE`,
|
locale: `de-DE`,
|
||||||
result: `1,00${nbsp}${currency}`,
|
result: `1,00${nbsp}${currency.symbol}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
amount: largeAmount,
|
amount: largeAmount,
|
||||||
locale: `de-DE`,
|
locale: `de-DE`,
|
||||||
result: `10.000,00${nbsp}${currency}`,
|
result: `10.000,00${nbsp}${currency.symbol}`,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Category } from '@prisma/client'
|
import { Category, Group } from '@prisma/client'
|
||||||
import { clsx, type ClassValue } from 'clsx'
|
import { clsx, type ClassValue } from 'clsx'
|
||||||
import { twMerge } from 'tailwind-merge'
|
import { twMerge } from 'tailwind-merge'
|
||||||
|
import { Currency, getCurrency } from './currency'
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs))
|
return twMerge(clsx(inputs))
|
||||||
@@ -33,20 +34,81 @@ export function formatCategoryForAIPrompt(category: Category) {
|
|||||||
* Set this to `true` if you need to pass a value with decimal fractions instead (e.g. 1.00 for USD 1.00).
|
* Set this to `true` if you need to pass a value with decimal fractions instead (e.g. 1.00 for USD 1.00).
|
||||||
*/
|
*/
|
||||||
export function formatCurrency(
|
export function formatCurrency(
|
||||||
currency: string,
|
currency: Currency,
|
||||||
amount: number,
|
amount: number,
|
||||||
locale: string,
|
locale: string,
|
||||||
fractions?: boolean,
|
fractions?: boolean,
|
||||||
) {
|
) {
|
||||||
const format = new Intl.NumberFormat(locale, {
|
const format = new Intl.NumberFormat(locale, {
|
||||||
minimumFractionDigits: 2,
|
minimumFractionDigits: currency.decimal_digits,
|
||||||
maximumFractionDigits: 2,
|
maximumFractionDigits: currency.decimal_digits,
|
||||||
style: 'currency',
|
style: 'currency',
|
||||||
// '€' will be placed in correct position
|
// '€' will be placed in correct position
|
||||||
currency: 'EUR',
|
currency: currency.code.length ? currency.code : 'EUR',
|
||||||
})
|
})
|
||||||
const formattedAmount = format.format(fractions ? amount : amount / 100)
|
const formatted = format.format(
|
||||||
return formattedAmount.replace('€', currency)
|
fractions ? amount : amountAsDecimal(amount, currency),
|
||||||
|
)
|
||||||
|
if (currency.code.length) {
|
||||||
|
return formatted
|
||||||
|
}
|
||||||
|
return formatted.replace('€', currency.symbol)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCurrencyFromGroup(
|
||||||
|
group: Pick<Group, 'currency' | 'currencyCode'>,
|
||||||
|
): Currency {
|
||||||
|
if (!group.currencyCode) {
|
||||||
|
return {
|
||||||
|
name: 'Custom',
|
||||||
|
symbol_native: group.currency,
|
||||||
|
symbol: group.currency,
|
||||||
|
code: '',
|
||||||
|
name_plural: '',
|
||||||
|
rounding: 0,
|
||||||
|
decimal_digits: 2,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return getCurrency(group.currencyCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts monetary amounts in minor units to the corresponding amount in major units in the given currency.
|
||||||
|
* e.g.
|
||||||
|
* - 150 "minor units" of euros = 1.5
|
||||||
|
* - 1000 "minor units" of yen = 1000 (the yen does not have minor units in practice)
|
||||||
|
*
|
||||||
|
* @param amount The amount, as the number of minor units of currency (cents for most currencies)
|
||||||
|
* @param round Whether to round the amount to the nearest minor unit (e.g.: 1.5612 € => 1.56 €)
|
||||||
|
*/
|
||||||
|
export function amountAsDecimal(amount: number, currency: Currency, round = false) {
|
||||||
|
const decimal = amount / 10 ** currency.decimal_digits
|
||||||
|
if (round) {
|
||||||
|
return Number(decimal.toFixed(currency.decimal_digits))
|
||||||
|
}
|
||||||
|
return decimal
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts decimal monetary amounts in major units to the amount in minor units in the given currency.
|
||||||
|
* e.g.
|
||||||
|
* - €1.5 = 150 "minor units" of euros (cents)
|
||||||
|
* - JPY 1000 = 1000 "minor units" of yen (the yen does not have minor units in practice)
|
||||||
|
*
|
||||||
|
* @param amount The amount in decimal major units
|
||||||
|
*/
|
||||||
|
export function amountAsMinorUnits(amount: number, currency: Currency) {
|
||||||
|
return amount * 10 ** currency.decimal_digits
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats monetary amounts in minor units to the corresponding amount in major units in the given currency,
|
||||||
|
* as a string, with correct rounding.
|
||||||
|
*
|
||||||
|
* @param amount The amount, as the number of minor units of currency (cents for most currencies)
|
||||||
|
*/
|
||||||
|
export function formatAmountAsDecimal(amount: number, currency: Currency) {
|
||||||
|
return amountAsDecimal(amount, currency).toFixed(currency.decimal_digits)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatFileSize(size: number, locale: string) {
|
export function formatFileSize(size: number, locale: string) {
|
||||||
|
|||||||
40
src/scripts/generateCurrencyData.ts
Normal file
40
src/scripts/generateCurrencyData.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
import { Locale, locales } from '@/i18n'
|
||||||
|
import {
|
||||||
|
Currency,
|
||||||
|
supportedCurrencyCodeType,
|
||||||
|
supportedCurrencyCodes,
|
||||||
|
} from '@/lib/currency'
|
||||||
|
import CurrencyList from 'currency-list'
|
||||||
|
|
||||||
|
import fs from 'node:fs'
|
||||||
|
|
||||||
|
const currencyList = locales.reduce((curList, locale) => {
|
||||||
|
const currencyData = supportedCurrencyCodes.reduce(
|
||||||
|
(curData, currencyCode) => {
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
...curData,
|
||||||
|
[currencyCode]: CurrencyList.get(
|
||||||
|
currencyCode,
|
||||||
|
locale.replaceAll('-', '_'),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// For currency translations which are not found in the library (e.g. ua), use English.
|
||||||
|
return {
|
||||||
|
...curData,
|
||||||
|
[currencyCode]: CurrencyList.get(currencyCode, 'en_US'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
return { ...curList, [locale]: currencyData }
|
||||||
|
}, {}) as {
|
||||||
|
[K in Locale]: {
|
||||||
|
[K in supportedCurrencyCodeType]: Currency
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync('src/lib/currency-data.json', JSON.stringify(currencyList))
|
||||||
@@ -28,7 +28,8 @@
|
|||||||
"require": ["tsconfig-paths/register", "dotenv/config"],
|
"require": ["tsconfig-paths/register", "dotenv/config"],
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"isolatedModules": false,
|
"isolatedModules": false,
|
||||||
"module": "CommonJS"
|
"moduleResolution": "nodenext",
|
||||||
|
"module": "nodenext"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user