63 Commits
1.16.0 ... main

Author SHA1 Message Date
Sebastien Castiel
d3b151e150 Upgrade dependencies (#479)
All checks were successful
CI / checks (push) Successful in 1m7s
Migrate to latest versions of Next.js, React, Radix, etc.
2025-12-06 12:50:01 -05:00
Ulrich Zorn
19c009f6b8 fix(db): Correct local db script volume mount for modern postgres images (minimal fix) (#460)
All checks were successful
CI / checks (push) Successful in 1m1s
The scripts/start-local-db.sh script was failing for modern PostgreSQL Docker images (version 18+) due to an incorrect volume mount point.

This commit provides a minimal fix by changing the volume mount from /var/lib/postgresql/data to /var/lib/postgresql, which is the correct path for modern postgres images.
2025-11-13 16:34:22 +01:00
Peter Smit
3b83a5f442 prettier
All checks were successful
CI / checks (push) Successful in 58s
2025-11-08 13:48:37 +01:00
Peter Smit
6b34b187f1 Limit height on dropdown menu to 80vh 2025-11-08 13:43:35 +01:00
Peter Smit
547adafcda Add (and reorder) languages added from Weblate 2025-11-08 13:30:39 +01:00
Weblate (bot)
8cc5689724 Translations update from Hosted Weblate (#415)
* Translated using Weblate (German)

Currently translated at 100.0% (303 of 303 strings)

Translation: Spliit/Spliit
Translate-URL: https://hosted.weblate.org/projects/spliit/spliit/de/

* Translated using Weblate (French)

Currently translated at 99.6% (302 of 303 strings)

Translation: Spliit/Spliit
Translate-URL: https://hosted.weblate.org/projects/spliit/spliit/fr/

* Translated using Weblate (Spanish)

Currently translated at 100.0% (303 of 303 strings)

Translation: Spliit/Spliit
Translate-URL: https://hosted.weblate.org/projects/spliit/spliit/es/

* Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 97.3% (295 of 303 strings)

Translation: Spliit/Spliit
Translate-URL: https://hosted.weblate.org/projects/spliit/spliit/zh_Hans/

* Added translation using Weblate (Korean)

* Translated using Weblate (Korean)

Currently translated at 82.8% (251 of 303 strings)

Translation: Spliit/Spliit
Translate-URL: https://hosted.weblate.org/projects/spliit/spliit/ko/

* Translated using Weblate (Catalan)

Currently translated at 100.0% (303 of 303 strings)

Translation: Spliit/Spliit
Translate-URL: https://hosted.weblate.org/projects/spliit/spliit/ca/

* Translated using Weblate (Russian)

Currently translated at 87.1% (264 of 303 strings)

Translation: Spliit/Spliit
Translate-URL: https://hosted.weblate.org/projects/spliit/spliit/ru/

* Added translation using Weblate (Basque)

* Translated using Weblate (Basque)

Currently translated at 48.5% (147 of 303 strings)

Translation: Spliit/Spliit
Translate-URL: https://hosted.weblate.org/projects/spliit/spliit/eu/

* Translated using Weblate (Basque)

Currently translated at 56.7% (172 of 303 strings)

Translation: Spliit/Spliit
Translate-URL: https://hosted.weblate.org/projects/spliit/spliit/eu/

* Translated using Weblate (Italian)

Currently translated at 90.7% (275 of 303 strings)

Translation: Spliit/Spliit
Translate-URL: https://hosted.weblate.org/projects/spliit/spliit/it/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 94.7% (287 of 303 strings)

Translation: Spliit/Spliit
Translate-URL: https://hosted.weblate.org/projects/spliit/spliit/pt_BR/

* Translated using Weblate (Basque)

Currently translated at 82.1% (249 of 303 strings)

Translation: Spliit/Spliit
Translate-URL: https://hosted.weblate.org/projects/spliit/spliit/eu/

* Translated using Weblate (Basque)

Currently translated at 100.0% (303 of 303 strings)

Translation: Spliit/Spliit
Translate-URL: https://hosted.weblate.org/projects/spliit/spliit/eu/

* Added translation using Weblate (Indonesian)

* Translated using Weblate (Indonesian)

Currently translated at 100.0% (303 of 303 strings)

Translation: Spliit/Spliit
Translate-URL: https://hosted.weblate.org/projects/spliit/spliit/id/

* Translated using Weblate (Japanese)

Currently translated at 100.0% (303 of 303 strings)

Translation: Spliit/Spliit
Translate-URL: https://hosted.weblate.org/projects/spliit/spliit/ja/

* Added translation using Weblate (Portuguese)

* Translated using Weblate (Portuguese)

Currently translated at 27.3% (83 of 303 strings)

Translation: Spliit/Spliit
Translate-URL: https://hosted.weblate.org/projects/spliit/spliit/pt/

* Added translation using Weblate (Hebrew)

* Translated using Weblate (Hebrew)

Currently translated at 100.0% (303 of 303 strings)

Translation: Spliit/Spliit
Translate-URL: https://hosted.weblate.org/projects/spliit/spliit/he/

* Translated using Weblate (Basque)

Currently translated at 100.0% (303 of 303 strings)

Translation: Spliit/Spliit
Translate-URL: https://hosted.weblate.org/projects/spliit/spliit/eu/

---------

Co-authored-by: Panoloo <chr.gee@t-online.de>
Co-authored-by: Meryl Street <e.kergrene+osm@gmail.com>
Co-authored-by: Aire Sur <andreser@gmail.com>
Co-authored-by: Leo Li <leo_li2001@outlook.com>
Co-authored-by: MinjiK <km.minjikim@gmail.com>
Co-authored-by: Jose <seriamente.fs@gmail.com>
Co-authored-by: Roman Miller <rmn.miller@googlemail.com>
Co-authored-by: Alexander Gabilondo <alexgabi@openmailbox.org>
Co-authored-by: Arthur Bonavita <arthur.bonavita@gmail.com>
Co-authored-by: Stefan Tanuwijaya <st.navybloo1@gmail.com>
Co-authored-by: susui <susui@hatsuyoake.com>
Co-authored-by: Pedro Gaspar <19785870+p-gaspar@users.noreply.github.com>
Co-authored-by: 12 123 <rohin.kaelin@moonfee.com>
2025-11-08 11:18:01 +01:00
Peter Smit
fc0feee736 Use decimal.js to validate uneven amounts
All checks were successful
CI / checks (push) Successful in 56s
2025-11-08 11:11:43 +01:00
Derek
548a8dc5ee Add cascading delete behavior to activity.group. (#448) 2025-11-08 09:49:40 +01:00
Derek
157ed4fd96 bugfix: Fix share values being incorrectly divided by 100 in expense form (#453)
* Update expense-form.tsx to handle shares as strings

Proposing fix to #424

The issue is in the data flow between the form and the schema transform function:

When editing existing expenses: Form loads shares by dividing database values by 100 (e.g., 200 / 100 = 2), but loads them as numbers
When users change values: Input fields return strings via enforceCurrencyPattern
Schema transform: Only multiplies by 100 for string values, not number values
Result: Modified shares (strings) get multiplied by 100, unmodified shares (numbers) stay as-is

Proposed fix: handle all shares consistently as strings throughout the form

* Add type assertions to fix TypeScript errors in expense form

Fix formatting.

---------

Co-authored-by: yllar <yllar.pajus@gmail.com>
2025-11-08 09:34:57 +01:00
Peter Smit
0f4c96fc46 Move padding from body to prevent jumping when toggling Selects
All checks were successful
CI / checks (push) Successful in 1m1s
2025-11-07 09:53:05 +01:00
Peter Smit
858114657c Merge branch 'dev' of github.com:CicadaCinema/spliit into CicadaCinema-dev
# Conflicts:
#	package-lock.json
2025-11-07 09:49:34 +01:00
Peter Smit
fca040a44d chore(deps): bump prisma to 6.18.0
All checks were successful
CI / checks (push) Successful in 1m3s
2025-11-03 20:19:05 +01:00
Derek
b2f9498142 Prevent date offset for expenses in local timezones (#433) 2025-11-03 19:46:24 +01:00
CicadaCinema
ed97deadd3 bump @radix-ui/react-select from 2.0.0 to 2.2.6 2025-11-01 09:25:58 +00:00
Axel Fahy
8875b98980 Add multi-platform build in workflow (#419)
All checks were successful
CI / checks (push) Successful in 52s
2025-09-20 15:41:02 +02:00
Weblate (bot)
eb78848601 Translations update from Hosted Weblate (#407)
All checks were successful
CI / checks (push) Successful in 50s
* Add currency and exchange rate with Frankfurter per expense

* Remove as unknown as

* Translated using Weblate (German)

Currently translated at 99.6% (273 of 274 strings)

Translation: Spliit/Spliit
Translate-URL: https://hosted.weblate.org/projects/spliit/spliit/de/

* Translated using Weblate (Dutch)

Currently translated at 100.0% (274 of 274 strings)

Translation: Spliit/Spliit
Translate-URL: https://hosted.weblate.org/projects/spliit/spliit/nl/

* Translated using Weblate (Dutch)

Currently translated at 100.0% (283 of 283 strings)

Translation: Spliit/Spliit
Translate-URL: https://hosted.weblate.org/projects/spliit/spliit/nl/

* Translated using Weblate (German)

Currently translated at 97.1% (275 of 283 strings)

Translation: Spliit/Spliit
Translate-URL: https://hosted.weblate.org/projects/spliit/spliit/de/

* Translated using Weblate (German)

Currently translated at 97.1% (275 of 283 strings)

Translation: Spliit/Spliit
Translate-URL: https://hosted.weblate.org/projects/spliit/spliit/de/

* Translated using Weblate (French)

Currently translated at 95.0% (269 of 283 strings)

Translation: Spliit/Spliit
Translate-URL: https://hosted.weblate.org/projects/spliit/spliit/fr/

* Translated using Weblate (German)

Currently translated at 100.0% (283 of 283 strings)

Translation: Spliit/Spliit
Translate-URL: https://hosted.weblate.org/projects/spliit/spliit/de/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (283 of 283 strings)

Translation: Spliit/Spliit
Translate-URL: https://hosted.weblate.org/projects/spliit/spliit/pt_BR/

* Translated using Weblate (French)

Currently translated at 96.4% (273 of 283 strings)

Translation: Spliit/Spliit
Translate-URL: https://hosted.weblate.org/projects/spliit/spliit/fr/

* Translated using Weblate (French)

Currently translated at 91.4% (277 of 303 strings)

Translation: Spliit/Spliit
Translate-URL: https://hosted.weblate.org/projects/spliit/spliit/fr/

* Translated using Weblate (Dutch)

Currently translated at 100.0% (303 of 303 strings)

Translation: Spliit/Spliit
Translate-URL: https://hosted.weblate.org/projects/spliit/spliit/nl/

---------

Co-authored-by: Steven Sengchanh <91092101+whimcomp@users.noreply.github.com>
Co-authored-by: Peter Smit <petersmit27@gmail.com>
Co-authored-by: Marcel Herhold <herhold.marcel@gmail.com>
Co-authored-by: Julian van Santen <julian@julianvansanten.nl>
Co-authored-by: Femke <femkeweijsenfeld2003@gmail.com>
Co-authored-by: Rico Stendel <rico@stendel.family>
Co-authored-by: renardyre <renardyre@gmail.com>
Co-authored-by: Antonin <atooo57@gmail.com>
2025-09-14 17:35:26 +02:00
Peter Smit
a9f008683f Remove as unknown as
All checks were successful
CI / checks (push) Successful in 55s
(cherry picked from commit 4e7733286a)
2025-09-13 17:20:55 +02:00
Peter Smit
52a2b552cb Merge branch 'currency-conversion' of github.com:whimcomp/spliit into whimcomp-currency-conversion
# Conflicts:
#	src/app/groups/[groupId]/expenses/expense-form.tsx
2025-09-13 17:20:39 +02:00
Peter Smit
0e77a666f4 Fix prettier issues
All checks were successful
CI / checks (push) Successful in 55s
2025-09-13 11:44:07 +02:00
Peter Smit
c49d0ea220 Always round minor units to an integer 2025-09-13 11:41:33 +02:00
Peter Smit
05a793ee39 Remove unneeded as unknown ases 2025-09-13 11:32:39 +02:00
Peter Smit
763c8c42e5 Remove ghrc pull example from README since it's not available yet
All checks were successful
CI / checks (push) Successful in 55s
2025-09-05 16:55:57 +02:00
Peter Smit
5fee0440c2 Add main currency code for groups feedback (#329)
Clarify group currency field description

Use default currency code instead of symbol

Hide currency symbol field when using a non-custom Currency

Run prettier

Update currency data

Update package-lock.json
2025-09-05 13:11:56 +02:00
Peter Smit
da8473406e Merge branch 'group-currency-code' of github.com:whimcomp/spliit into whimcomp-group-currency-code 2025-09-05 10:46:04 +02:00
Peter Smit
39d55d908a Fix prettier issues
All checks were successful
CI / checks (push) Successful in 54s
2025-09-05 09:56:50 +02:00
Julen Dixneuf
409784672c Add health check endpoint and resolve locale detection bug (#387)
* Add health check API endpoint with database connectivity

* Update locale handling to fallback to default language on invalid input

* Add health check endpoints for application readiness and liveness

- Introduced `/api/health/readiness` endpoint to check if the application can serve requests, including database connectivity.
- Introduced `/api/health/liveness` endpoint to verify if the application is running independently of external dependencies.
- Updated the health check logic to streamline database connectivity checks and response handling.

* Refactor health check logic

---------

Co-authored-by: Julen Dixneuf <julen.d@padoa-group.com>
2025-09-05 09:53:12 +02:00
Izzy Irvine
d27cbdba47 Added pipeline to buid container and push to ghcr.io (#332) 2025-09-05 08:59:09 +02:00
Peter Smit
048ac4da0a Disable provenance and sbom to hide unknown/unknown builds 2025-09-05 08:55:55 +02:00
Izzy Irvine
a86e92e414 Added pipeline to buid container and push to ghcr.io 2025-09-05 08:54:22 +02:00
Peter Smit
76c58a7f61 Use vertical weblate chart in README 2025-09-05 08:18:31 +02:00
Weblate (bot)
a59352d5da Translations update from Hosted Weblate (#405)
All checks were successful
CI / checks (push) Successful in 52s
* Add Discord and Weblate to README

* Translated using Weblate (German)

Currently translated at 98.9% (270 of 273 strings)

Translation: Spliit/Spliit
Translate-URL: https://hosted.weblate.org/projects/spliit/spliit/de/

* Translated using Weblate (French)

Currently translated at 98.1% (268 of 273 strings)

Translation: Spliit/Spliit
Translate-URL: https://hosted.weblate.org/projects/spliit/spliit/fr/

* Translated using Weblate (Italian)

Currently translated at 98.9% (270 of 273 strings)

Translation: Spliit/Spliit
Translate-URL: https://hosted.weblate.org/projects/spliit/spliit/it/

* Translated using Weblate (Czech)

Currently translated at 98.9% (270 of 273 strings)

Translation: Spliit/Spliit
Translate-URL: https://hosted.weblate.org/projects/spliit/spliit/cs/

* Translated using Weblate (Japanese)

Currently translated at 98.9% (270 of 273 strings)

Translation: Spliit/Spliit
Translate-URL: https://hosted.weblate.org/projects/spliit/spliit/ja/

* Translated using Weblate (Spanish)

Currently translated at 98.5% (269 of 273 strings)

Translation: Spliit/Spliit
Translate-URL: https://hosted.weblate.org/projects/spliit/spliit/es/

* Translated using Weblate (Polish)

Currently translated at 98.9% (270 of 273 strings)

Translation: Spliit/Spliit
Translate-URL: https://hosted.weblate.org/projects/spliit/spliit/pl/

* Translated using Weblate (Italian)

Currently translated at 100.0% (274 of 274 strings)

Translation: Spliit/Spliit
Translate-URL: https://hosted.weblate.org/projects/spliit/spliit/it/

---------

Co-authored-by: Eryk Michalak <gnu.ewm@protonmail.com>
Co-authored-by: albanobattistella <albano_battistella@hotmail.com>
2025-09-04 20:57:54 +02:00
Peter Smit
ef2062071a Add Discord and Weblate to README
All checks were successful
CI / checks (push) Successful in 59s
2025-09-04 20:36:24 +02:00
Peter Smit
86a20d6b23 Use "everyone" in the expense card if it was paid for all participants (#398) 2025-09-04 20:15:00 +02:00
Peter Smit
a21f0646b5 Merge branch 'ita-localization' of github.com:scollovati/spliit into scollovati-ita-localization
# Conflicts:
#	messages/de-DE.json
#	messages/fr-FR.json
#	messages/it-IT.json
#	messages/nl-NL.json
#	messages/pl-PL.json
2025-09-04 20:09:45 +02:00
Weblate (bot)
4c1a6b9e55 Sync Weblate translations (#404) 2025-09-04 19:46:35 +02:00
Peter Smit
ff42f0ab66 Filter out English strings from non-english translations 2025-09-04 19:21:14 +02:00
Peter Smit
6ea6cfac3e Use deepmerge function from existing dependency 2025-09-04 19:11:26 +02:00
Peter Smit
e3f70d0635 Merge branch 'bug-missing-translation' of github.com:Uli-Z/spliit-room into Uli-Z-bug-missing-translation 2025-09-04 18:54:16 +02:00
jannikac
5bf31e5b99 update de-DE translation for recurrenceRule (#339)
* update de-DE translation

* Update messages/de-DE.json

Co-authored-by: Benjamin Kästner <bkaestner@users.noreply.github.com>
2025-09-04 17:26:40 +02:00
Peter Smit
ed321f9880 Merge branch 'GuillemUJ-main'
# Conflicts:
#	src/i18n.ts
2025-09-04 17:15:19 +02:00
Peter Smit
070f6623b7 Add catalan to the list of locales 2025-09-04 17:13:45 +02:00
starforman
c556c18dc5 Add Czech translation (#358) 2025-09-04 16:59:46 +02:00
susui
65c9e01ad3 Add Japanese translation (#341) 2025-09-04 16:58:17 +02:00
Luca Bizzotto
4bcc9291a4 Update it-IT.json to latest features (#328) 2025-09-04 16:52:08 +02:00
Peter Smit
5fd3204990 Fix formatting of #382 2025-09-04 16:43:12 +02:00
Bernhard Bliem
436aff00d2 Fix missing translation string in de-DE (#382)
* Fix missing translation string in de-DE

* Make placeholder for "paid by" field translatable
2025-09-04 16:32:06 +02:00
Vick Airfull
2710afd560 Update es.json (#348) 2025-09-04 16:29:34 +02:00
Joachim Kołodziejski
4b69306f50 Fix polish translation (#364) 2025-09-04 16:27:18 +02:00
Vick Airfull
d6550e34c1 Update es.json (#349)
Minor update to translation:
"owes" translate to "debe a" in spanish
2025-09-04 16:26:06 +02:00
albanobattistella
de0cbb75ff Update it-IT.json (#392) 2025-09-04 16:24:40 +02:00
Peter Smit
a33b578027 Update Dutch translation (#399) 2025-09-04 16:24:20 +02:00
TO
de930cc9ad Fix french recurrenceRule translation (#402) 2025-09-04 16:08:43 +02:00
scollovati
a8ea05eae8 [localization] add new message notInvolved in ExpenseCard 2025-07-23 15:22:23 +02:00
scollovati
6c995ebd54 [localization] removing expense from the Recurrence field in order to make it suitable also for an income 2025-07-23 12:57:12 +02:00
scollovati
069554836b [localization] italian translation for recurrentRule 2025-07-23 12:55:08 +02:00
scollovati
c5726670e7 [localization] fixed recurrentRule json position 2025-07-23 12:54:52 +02:00
Uli-Z
81c42e5b8b Add English fallback for i18n messages
Introduced a mergeDeep helper to merge locale-specific messages with the English defaults. src/i18n.ts now loads both the en-US messages and the selected locale’s messages, merges them, and supplies the result so missing translations appear in English instead of as placeholders.
2025-06-24 11:55:30 +02:00
GuillemUJ
feec11f99c Traducció al català 2025-06-03 14:48:30 +02:00
Steven Sengchanh
d641540b65 Add currency and exchange rate with Frankfurter per expense 2025-04-21 01:31:14 +02:00
Steven Sengchanh
2814811aea Script to recreate currencies data 2025-04-21 00:49:43 +02:00
Steven Sengchanh
af4bfe3780 Add non-custom currencies per group 2025-04-21 00:49:43 +02:00
Sebastien Castiel
a11efc79c1 Fix Prettier issues
All checks were successful
CI / checks (push) Successful in 1m34s
2025-04-20 11:10:30 -04:00
Sebastien Castiel
e63f3aa68f Fix TypeScript issues
Some checks failed
CI / checks (push) Failing after 1m35s
2025-04-19 15:46:37 -04:00
79 changed files with 16419 additions and 3016 deletions

View File

@@ -1,4 +1,4 @@
POSTGRES_PRISMA_URL=postgresql://postgres:1234@localhost
POSTGRES_URL_NON_POOLING=postgresql://postgres:1234@localhost
NEXT_PUBLIC_DEFAULT_CURRENCY_SYMBOL=""
NEXT_PUBLIC_DEFAULT_CURRENCY_CODE=""

46
.github/workflows/cd.yml vendored Normal file
View File

@@ -0,0 +1,46 @@
name: Create and publish a Docker image
on:
push:
tags:
- '*'
jobs:
build-and-push-image:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Login to Github Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Downcase repo
run: |
echo "REPO=${GITHUB_REPOSITORY@L}" >> "${GITHUB_ENV}"
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: |
ghcr.io/${{ env.REPO }}:${{ github.ref_name }}
ghcr.io/${{ env.REPO }}:latest
provenance: false # Disable provenance to avoid unknown/unknown
sbom: false # Disable sbom to avoid unknown/unknown

View File

@@ -35,13 +35,24 @@ Spliit is a free and open source alternative to Splitwise. You can either use th
## Contribute
The project is open to contributions. Feel free to open an issue or even a pull-request!
The project is open to contributions. Feel free to open an issue or even a pull-request!
Join the discussion in [the Spliit Discord server](https://discord.gg/YSyVXbwvSY).
If you want to contribute financially and help us keep the application free and without ads, you can also:
- 💜 [Sponsor me (Sebastien)](https://github.com/sponsors/scastiel), or
- 💙 [Make a small one-time donation](https://donate.stripe.com/28o3eh96G7hH8k89Ba).
### Translation
The project's translations are managed using [our Weblate project](https://hosted.weblate.org/projects/spliit/spliit/).
You can easily add missing translations to the project or even add a new language!
Here is the current state of translation:
<a href="https://hosted.weblate.org/engage/spliit/">
<img src="https://hosted.weblate.org/widget/spliit/spliit/multi-auto.svg" alt="Translation status" />
</a>
## Run locally
1. Clone the repository (or fork it if you intend to contribute)
@@ -57,6 +68,13 @@ If you want to contribute financially and help us keep the application free and
3. Run `npm run start-container` to start the postgres and the spliit2 containers
4. You can access the app by browsing to http://localhost:3000
## Health check
The application has a health check endpoint that can be used to check if the application is running and if the database is accessible.
- `GET /api/health/readiness` or `GET /api/health` - Check if the application is ready to serve requests, including database connectivity.
- `GET /api/health/liveness` - Check if the application is running, but not necessarily ready to serve requests.
## Opt-in features
### Expense documents

21
eslint.config.mjs Normal file
View File

@@ -0,0 +1,21 @@
import nextVitals from 'eslint-config-next/core-web-vitals'
import { defineConfig, globalIgnores } from 'eslint/config'
const eslintConfig = defineConfig([
...nextVitals,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
'.next/**',
'out/**',
'build/**',
'next-env.d.ts',
]),
{
rules: {
'react-hooks/set-state-in-effect': 'off',
},
},
])
export default eslintConfig

459
messages/ca.json Normal file
View File

@@ -0,0 +1,459 @@
{
"Homepage": {
"title": "Comparteix <strong>despeses</strong> amb <strong>família i amics</strong>",
"description": "Benvinguda a <strong>Spliit</strong>!",
"button": {
"groups": "Veure els grups",
"github": "GitHub"
}
},
"Header": {
"groups": "Grups"
},
"Footer": {
"madeIn": "Fet a Montréal, Québec 🇨🇦",
"builtBy": "Desenvolupat per <author>Sebastien Castiel</author> i <source>col·laboradors</source>"
},
"Expenses": {
"title": "Despeses",
"description": "Aquí trobaràs les despeses que has creat al teu grup.",
"create": "Afegir despesa",
"createFirst": "Crea la primera",
"noExpenses": "El teu grup encara no té despeses.",
"exportJson": "Exportar a JSON",
"exportCsv": "Exportar a CSV",
"searchPlaceholder": "Cerca una despesa.",
"ActiveUserModal": {
"title": "Qui ets?",
"description": "Duige'ns quin participant ets, perquè puguem personalitzar com es mostra la informació.",
"nobody": "No vull seleccionar a ningú",
"save": "Desar canvis",
"footer": "Aquesta configuració pot modificar-se amb posterioritat, a la configuració del grup."
},
"Groups": {
"upcoming": "Pròximament",
"thisWeek": "Aquesta setmana",
"earlierThisMonth": "A inicis d'aquest mes",
"lastMonth": "El mes anterior",
"earlierThisYear": "A inicis d'aquest any",
"lastYear": "L'any passat",
"older": "Més antics"
},
"export": "Exportar"
},
"ExpenseCard": {
"paidBy": "Pagada per <strong>{paidBy}</strong> per a <paidFor></paidFor>",
"receivedBy": "Debuda per <strong>{paidBy}</strong> para <paidFor></paidFor>",
"yourBalance": "El teu balanç: balance:",
"everyone": "totes",
"notInvolved": "No ets implicat o implicada"
},
"Groups": {
"myGroups": "Els meus grups",
"create": "Crear",
"loadingRecent": "Arregant els grups recents…",
"NoRecent": {
"description": "No has format part de cap grup darrerament.",
"create": "Crea'n un",
"orAsk": "o demana a una amiga que et comparteixi l'enllaç a un grup ja existent"
},
"recent": "Grups recents",
"starred": "Grups preferits",
"archived": "Grups arxivats",
"archive": "Arxivar el grup",
"unarchive": "Desarxivar el grup",
"removeRecent": "Suprimeix dels grups recents",
"RecentRemovedToast": {
"title": "El grup ha estat eliminat",
"description": "El grup ha estat eliminat de la tebva llista de grups recents",
"undoAlt": "Reverteix la suoressió del grup",
"undo": "Desfer"
},
"AddByURL": {
"button": "Afegir mitjançant un enllaç",
"title": "Afegir un grup mitjançant un enllaç",
"description": "Si t'han compartit un grup, pots enganxar aqui el seu enllaç per afegir-lo a la teva llista",
"error": "Vatua lolla! No podem trobar el grup des de l'enllaç que has proporcionat..."
},
"NotFound": {
"text": "Aquest grup no existeix.",
"link": "Anar als darrers grups visitats."
}
},
"GroupForm": {
"title": "Informació del grup",
"NameField": {
"label": "Nom del grup",
"placeholder": "Calçotada amb la colla",
"description": "Afegeix un nom per al teu nou grup."
},
"InformationField": {
"label": "Informació del grupo",
"placeholder": "Quina informació és rellevant per les participants?"
},
"CurrencyField": {
"label": "Símbol de la divisa",
"placeholder": "$, €, £…",
"description": "Ho farem servir per mostrar el balanç."
},
"Participants": {
"title": "Participants",
"description": "Afegeix el nom de totes les participants.",
"protectedParticipant": "Aquestes participants ténen despeses al seu nom i no poden ser esborrades.",
"new": "Nou",
"add": "Afegir participants",
"John": "Ermessenda",
"Jane": "Guillem",
"Jack": "Jaume"
},
"Settings": {
"title": "Configuració local",
"description": "Aquesta configuració s'estableix per aquest diposotiu i s'utilitzarà per personalitzar la teva experiència.",
"ActiveUserField": {
"label": "Usuària activa",
"placeholder": "Selecciona una participant...",
"none": "Cap",
"description": "Usuària que paga les despeses de manera predeterminada."
},
"save": "Desar",
"saving": "Desant",
"create": "Crear",
"creating": "Creant",
"cancel": "Cancel·lar"
},
"CurrencyCodeField": {
"label": "Divisa principal",
"createDescription": "Totes les quantitats i els balanços es mostraràn en aquesta moneda.",
"editDescription": "Totes les quantitas i balanços es mostraràn en aquesta moneda. Canviar açò NO convertirà despeses ja enregistrades, excepte quan la moneda tinga \"unitats menors\" diferents que la actual (per exemple, canviar de Dólars estadounidencs a Yens japonesos)",
"customOption": "Personalitzat"
}
},
"ExpenseForm": {
"Income": {
"create": "Afegir despesa",
"edit": "Editar despesa",
"TitleField": {
"label": "Títol de la despesa",
"placeholder": "Berenar-sopar",
"description": "Introdueix una descripció per aquesta despesa."
},
"DateField": {
"label": "Data de la despesa",
"description": "Adegeix la data de la despesa."
},
"categoryFieldDescription": "Selecciona la categoria de la despesa.",
"paidByField": {
"label": "Pagat per",
"description": "Selecciona la participant que ha fet el pagament."
},
"recurrenceRule": {
"label": "Recurrència de la despesa",
"description": "Selecciona amb quina freqüència cal que es repeteixi la despesa.",
"none": "Puntual",
"daily": "Diària",
"weekly": "Setmanal",
"monthly": "Mensual"
},
"paidFor": {
"title": "Pagat per a",
"description": "Selecciona per a qui s'ha efectuat el pagament."
},
"splitModeDescription": "Selecciona com vols dividir la despesa.",
"attachDescription": "Veure i adjuntar tiquets a la despesa.",
"currencyField": {
"label": "Moneda del ingrés",
"description": "La moneda en la que l'ingrés va ser rebut."
}
},
"Expense": {
"create": "Crear despesa",
"edit": "Editar despesa",
"TitleField": {
"label": "Títol de la despesa",
"placeholder": "Llaunes del queviures",
"description": "Afegeix una descripció per la despesa."
},
"DateField": {
"label": "Data de la despesa",
"description": "Afegeix la data en que es va realitzar la despesa."
},
"categoryFieldDescription": "Selecciona la cateogria de la despesa.",
"paidByField": {
"label": "Pagat per",
"description": "Selecciona la participant que ha pagat la despesa.",
"placeholder": "Tria un membre"
},
"paidFor": {
"title": "Pagado per a",
"description": "Sleecciona per a qui s'ha pagat la despesa."
},
"splitModeDescription": "Selecciona com vols dividir la despesa.",
"attachDescription": "eure i adjuntar tiquets a la despesa.",
"currencyField": {
"label": "Moneda de la despesa",
"description": "La moneda en la que la despesa es va pagar."
},
"recurrenceRule": {
"label": "Despesa recurrent",
"description": "Tria la freqüència amb la que la despesa s'ha de repetir.",
"none": "Cap",
"daily": "Diàriament",
"weekly": "Setmanalment",
"monthly": "Mensualment"
}
},
"amountField": {
"label": "Quantitat"
},
"isReimbursementField": {
"label": "Això és un reemborsament"
},
"categoryField": {
"label": "Categoria"
},
"notesField": {
"label": "Notes"
},
"selectNone": "No en seleccionis cap",
"selectAll": "Selecciona'ls tots",
"shares": "parts",
"advancedOptions": "Opciones avanzadas",
"SplitModeField": {
"label": "Mode de divisió",
"evenly": "Uniforme",
"byShares": "Desigual: per parts",
"byPercentage": "Desigual: per percentatge",
"byAmount": "Desigual: Per quantitat",
"saveAsDefault": "Desa com a mode preferit"
},
"DeletePopup": {
"label": "Esborrar",
"title": "Esborrar la despesa?",
"description": "Segur que vols suprimir aquesta despesa? Aquesta acció és irreversible.",
"yes": "Sí",
"cancel": "Cancel·lar"
},
"attachDocuments": "Adjuntar documents",
"create": "Crear",
"creating": "Creant",
"save": "Desar",
"saving": "Desant",
"cancel": "Cancel·lar",
"reimbursement": "Reemborsament",
"conversionUnavailable": "Per a seleccionar una moneda diferent per despesa i convertir quantitats, tria una moneda no personalitzada per al grup.",
"originalAmountField": {
"label": "Quantitat a convertir"
},
"conversionRateField": {
"useApi": "Emprar les tases de Frankfurter",
"useCustom": "Utilitzar tasa personalitzada",
"label": "Tasa de conversió"
},
"conversionRateState": {
"loading": "Aconseguint tases d'intercanvi…",
"success": "Tases obtingudes:",
"error": "Ups, no hem pogut aconseguit les tases mes recents.",
"staleRate": "Utilizant la tasa:",
"noRate": "Introdueix una tasa personalitzada ací.",
"currencyNotFound": "Ups, Frankfurter no té la tasa per a la moneda triada aquest dia.",
"noDate": "Introdueix la data de ka despesa per a aconseguir la tasa de conversió.",
"dateMismatch": "Tases per a la data: {date}",
"refresh": "Refrescar",
"customRate": "Emprant tasa personalitzada"
}
},
"ExpenseDocumentsInput": {
"TooBigToast": {
"title": "L'arxiu és massa gran",
"description": "La mida màxima que pot tenir l'arxiu és de {maxSize}. El teu pesa {size}."
},
"ErrorToast": {
"title": "Error al carregar el document",
"description": "Hi ha hagut un error al carregar el document. Torna a intentar-ho més tard o selecciona un altre arxiu.",
"retry": "Tornar-ho a intentar"
}
},
"CreateFromReceipt": {
"Dialog": {
"triggerTitle": "Crear una despesa des d'un tiquet",
"title": "Crear des del tiquet",
"description": "Extreure la informació de despeses de la foto d'un tiquet.",
"body": "Puja la foto d'un tiquet i l'escanejarem per extreure'n, si podem, la informaició de la despesa.",
"selectImage": "Seleccionar la imatge…",
"titleLabel": "Títol:",
"categoryLabel": "Categoria:",
"amountLabel": "Quantitat:",
"dateLabel": "Data:",
"editNext": "A continuació, podràs editar la informació de les despeses.",
"continue": "Continuar"
},
"unknown": "Desconegut",
"TooBigToast": {
"title": "L'arxiu és massa gran",
"description": "La mida màxima que pot tenir l'arxiu és de {maxSize}. El teu pesa {size}."
},
"ErrorToast": {
"title": "Error al carregar el document",
"description": "Hi ha hagut un error al carregar el document. Torna a intentar-ho més tard o selecciona un altre arxiu.",
"retry": "Tornar-ho a intentar"
}
},
"Balances": {
"title": "Balanç",
"description": "L'import total que ha pagat o rebut cada participant.",
"Reimbursements": {
"title": "Proposta de reemborsaments",
"description": "Suggerència per optimitzar els reemborsaments entre participants.",
"noImbursements": "Sembla que el teu grup no necessita cap reemborsament 😁",
"owes": "<strong>{from}</strong> deu a <strong>{to}</strong>",
"markAsPaid": "Marcar com a pagat"
}
},
"Stats": {
"title": "Estadístiques",
"Totals": {
"title": "Totals",
"description": "Resum de les despeses de tot el grup.",
"groupSpendings": "Despeses totals del grup",
"groupEarnings": "Ingressos totals del grup",
"yourSpendings": "Suma de les teves despeses",
"yourEarnings": "Suma dels teus ingressos",
"yourShare": "El teu percentatge"
}
},
"Activity": {
"title": "Activitat",
"description": "Aquí trobaràs totes les activitats recents del grup.",
"noActivity": "No hi ha activitat recent a aquest grup.",
"someone": "Algú",
"settingsModified": "La configuració del grup ha estat modificada per <strong>{participant}</strong>.",
"expenseCreated": "Despesa <em>{expense}</em> creada per <strong>{participant}</strong>.",
"expenseUpdated": "Despesa <em>{expense}</em> actualitzada per <strong>{participant}</strong>.",
"expenseDeleted": "Despesa <em>{expense}</em> esborrada per <strong>{participant}</strong>.",
"Groups": {
"today": "Avui",
"yesterday": "Ahir",
"earlierThisWeek": "A inicis d'aquesta setmana",
"lastWeek": "La setmana passada",
"earlierThisMonth": "A inicis d'aquest mes",
"lastMonth": "El mes passat",
"earlierThisYear": "A inicis d'aquest any",
"lastYear": "El darrer any",
"older": "Més antigues"
}
},
"Information": {
"title": "Informació",
"description": "Utilitza aquest apartat per afegir informació rellevant per als participants del grup.",
"empty": "Encara no hi ha informació sobre el grup."
},
"Settings": {
"title": "Configuració"
},
"Share": {
"title": "Compartir",
"description": "Comparteix l'enllaç al grup amb altres participants perquè puguin veure el grup i afegir-hi despeses.",
"warning": "Alerta!",
"warningHelp": "Tothom qui tingui l'enllaç del grup podrà veure i editar les despeses. Comparteix-lo amb cautela!"
},
"SchemaErrors": {
"min1": "Introdueix com a mínim un caràcter.",
"min2": "Introdueix com a mínim dos caràcters.",
"max5": "Introdueix com a màxim cinc caràcters.",
"max50": "Introdueix com a màxim cinquanta caràcters.",
"duplicateParticipantName": "Una altra participant ja té el mateix nom",
"titleRequired": "Si us plau, afegeix un títol",
"invalidNumber": "Número invàlid",
"amountRequired": "Cal introduïr un import",
"amountNotZero": "L'import no por ser zero.",
"amountTenMillion": "L'import ha de ser inferior a 10.000.000.",
"paidByRequired": "Cal seleccionar una participant",
"paidForMin1": "La despesa ha de ser pagada com a mínim per a una participant",
"noZeroShares": "Totes les participacions han de ser superiors a 0",
"amountSum": "La suma dels imports ha de ser igual a la suma de la despesa",
"percentageSum": "La suma dels percentatges ha de ser igual a 100",
"ratePositive": "La tasa ha de ser estrictament major que zero."
},
"Categories": {
"search": "Cercar categoria...",
"noCategory": "Categoria no trobada!",
"Uncategorized": {
"heading": "Sense categoria",
"General": "General",
"Payment": "Pagament"
},
"Entertainment": {
"heading": "Oci",
"Entertainment": "Oci",
"Games": "Jocs",
"Movies": "Pel·lícules",
"Music": "Música",
"Sports": "Esport"
},
"Food and Drink": {
"heading": "Menjar i beure",
"Food and Drink": "Menjar i beure",
"Dining Out": "Menjar fora",
"Groceries": "Menjar",
"Liquor": "Licors"
},
"Home": {
"heading": "Casa",
"Home": "Casa",
"Electronics": "Elecrtònica",
"Furniture": "Mobles",
"Household Supplies": "Subministres",
"Maintenance": " Manteniment",
"Mortgage": "Hipoteca",
"Pets": "Mascotes",
"Rent": "Lloguer",
"Services": "Serveis"
},
"Life": {
"heading": "Vida",
"Childcare": "Cura de criatures",
"Clothing": "Roba",
"Education": "Ensenyament",
"Gifts": "Regals",
"Insurance": "Assegurança",
"Medical Expenses": "Despeses mèdiques",
"Taxes": "Impostos",
"Donation": "Donació"
},
"Transportation": {
"heading": "Transport",
"Transportation": "Transport",
"Bicycle": "Bicicleta",
"Bus/Train": "Autobús/Tren",
"Car": "Cotxe",
"Gas/Fuel": "Gasolina/Combustible",
"Hotel": "Hotel",
"Parking": "Aparcament",
"Plane": "Avió",
"Taxi": "Taxi"
},
"Utilities": {
"heading": "Utilitats",
"Utilities": "Utilitats",
"Cleaning": "Neteja",
"Electricity": "Electricitat",
"Heat/Gas": "Calefacció/Gas",
"Trash": "Ecombraries",
"TV/Phone/Internet": "TV/Telèfon/Internet",
"Water": "Aigua"
}
},
"Currencies": {
"search": "Buscar moneda...",
"noCurrency": "No s'han trobat monedes.",
"custom": {
"heading": "Personalitzat"
},
"common": {
"heading": "Més comunes"
},
"other": {
"heading": "Altres monedes"
}
}
}

399
messages/cs-CZ.json Normal file
View File

@@ -0,0 +1,399 @@
{
"Homepage": {
"title": "Sdílejte <strong>výdaje</strong> s <strong>přáteli a rodinou</strong>",
"description": "Vítejte ve své nové instanci <strong>Spliitu</strong>!",
"button": {
"groups": "Přejít na skupiny",
"github": "GitHub"
}
},
"Header": {
"groups": "Skupiny"
},
"Footer": {
"madeIn": "Vyrobeno v Montréalu, Québec 🇨🇦",
"builtBy": "Vytvořil <author>Sebastien Castiel</author> a <source>další přispěvatelé</source>"
},
"Expenses": {
"title": "Výdaje",
"description": "Zde jsou výdaje, které jste vytvořili pro svou skupinu.",
"create": "Vytvořit výdaj",
"createFirst": "Vytvořit první",
"noExpenses": "Vaše skupina zatím neobsahuje žádné výdaje.",
"export": "Exportovat",
"exportJson": "Exportovat do JSON",
"exportCsv": "Exportovat do CSV",
"searchPlaceholder": "Hledat výdaj…",
"ActiveUserModal": {
"title": "Kdo jste?",
"description": "Řekněte nám, který účastník jste, abychom mohli přizpůsobit zobrazení informací.",
"nobody": "Nechci nikoho vybírat",
"save": "Uložit změny",
"footer": "Toto nastavení můžete později změnit v nastavení skupiny."
},
"Groups": {
"upcoming": "Nadcházející",
"thisWeek": "Tento týden",
"earlierThisMonth": "Dříve tento měsíc",
"lastMonth": "Minulý měsíc",
"earlierThisYear": "Dříve tento rok",
"lastYear": "Minulý rok",
"older": "Starší"
}
},
"ExpenseCard": {
"paidBy": "Zaplatil/a <strong>{paidBy}</strong> za <paidFor></paidFor>",
"receivedBy": "Obdržel/a <strong>{paidBy}</strong> od <paidFor></paidFor>",
"yourBalance": "Váš zůstatek:"
},
"Groups": {
"myGroups": "Moje skupiny",
"create": "Vytvořit",
"loadingRecent": "Načítání nedávných skupin…",
"NoRecent": {
"description": "Nedávno jste nenavštívili žádnou skupinu.",
"create": "Vytvořit skupinu",
"orAsk": "nebo požádejte přítele, aby vám poslal odkaz na existující."
},
"recent": "Nedávné skupiny",
"starred": "Oblíbené skupiny",
"archived": "Archivované skupiny",
"archive": "Archivovat skupinu",
"unarchive": "Zrušit archivaci skupiny",
"removeRecent": "Odebrat z nedávných skupin",
"RecentRemovedToast": {
"title": "Skupina byla odebrána",
"description": "Skupina byla odebrána ze seznamu vašich nedávných skupin.",
"undoAlt": "Vrátit zpět odebrání skupiny",
"undo": "Vrátit zpět"
},
"AddByURL": {
"button": "Přidat pomocí URL",
"title": "Přidat skupinu pomocí URL",
"description": "Pokud s vámi byla skupina sdílena, můžete sem vložit její URL a přidat ji do svého seznamu.",
"error": "Bohužel se nám nepodařilo najít skupinu podle zadané URL…"
},
"NotFound": {
"text": "Tato skupina neexistuje.",
"link": "Přejít na nedávno navštívené skupiny"
}
},
"GroupForm": {
"title": "Informace o skupině",
"NameField": {
"label": "Název skupiny",
"placeholder": "Letní dovolená",
"description": "Zadejte název své skupiny."
},
"InformationField": {
"label": "Informace o skupině",
"placeholder": "Jaké informace jsou důležité pro účastníky skupiny?"
},
"CurrencyField": {
"label": "Symbol měny",
"placeholder": "CZK, Kč, $, €, £…",
"description": "Použijeme ho pro zobrazení částek."
},
"Participants": {
"title": "Účastníci",
"description": "Zadejte jméno každého účastníka.",
"protectedParticipant": "Tento účastník je součástí výdajů a nelze jej odebrat.",
"new": "Nový",
"add": "Přidat účastníka",
"John": "Jan",
"Jane": "Jana",
"Jack": "Jakub"
},
"Settings": {
"title": "Místní nastavení",
"description": "Tato nastavení jsou specifická pro toto zařízení a slouží k přizpůsobení vašeho zážitku.",
"ActiveUserField": {
"label": "Aktivní uživatel",
"placeholder": "Vyberte účastníka",
"none": "Žádný",
"description": "Uživatel použitý jako výchozí pro placení výdajů."
},
"save": "Uložit",
"saving": "Ukládání…",
"create": "Vytvořit",
"creating": "Vytváření…",
"cancel": "Zrušit"
}
},
"ExpenseForm": {
"Income": {
"create": "Vytvořit příjem",
"edit": "Upravit příjem",
"TitleField": {
"label": "Název příjmu",
"placeholder": "Pondělní večerní restaurace",
"description": "Zadejte popis příjmu."
},
"DateField": {
"label": "Datum příjmu",
"description": "Zadejte datum, kdy byl příjem přijat."
},
"categoryFieldDescription": "Vyberte kategorii příjmu.",
"paidByField": {
"label": "Obdržel/a",
"description": "Vyberte účastníka, který příjem obdržel."
},
"paidFor": {
"title": "Obdrženo pro",
"description": "Vyberte, pro koho byl příjem obdržen."
},
"splitModeDescription": "Vyberte, jak rozdělit příjem.",
"attachDescription": "Zobrazit a připojit účtenky k příjmu."
},
"Expense": {
"create": "Vytvořit výdaj",
"edit": "Upravit výdaj",
"TitleField": {
"label": "Název výdaje",
"placeholder": "Pondělní večerní restaurace",
"description": "Zadejte popis výdaje."
},
"DateField": {
"label": "Datum výdaje",
"description": "Zadejte datum, kdy byl výdaj zaplacen."
},
"categoryFieldDescription": "Vyberte kategorii výdaje.",
"paidByField": {
"label": "Zaplatil/a",
"description": "Vyberte účastníka, který výdaj zaplatil."
},
"recurrenceRule": {
"label": "Opakování výdaje",
"description": "Vyberte, jak často se má výdaj opakovat.",
"none": "Žádné",
"daily": "Denně",
"weekly": "Týdně",
"monthly": "Měsíčně"
},
"paidFor": {
"title": "Zaplaceno pro",
"description": "Vyberte, pro koho byl výdaj zaplacen."
},
"splitModeDescription": "Vyberte, jak rozdělit výdaj.",
"attachDescription": "Zobrazit a připojit účtenky k výdaji."
},
"amountField": {
"label": "Částka"
},
"isReimbursementField": {
"label": "Toto je proplacení"
},
"categoryField": {
"label": "Kategorie"
},
"notesField": {
"label": "Poznámky"
},
"selectNone": "Nevybrat nic",
"selectAll": "Vybrat vše",
"shares": "podíl(y)",
"advancedOptions": "Pokročilé možnosti rozdělení…",
"SplitModeField": {
"label": "Způsob rozdělení",
"evenly": "Rovnoměrně",
"byShares": "Nerovnoměrně podle podílů",
"byPercentage": "Nerovnoměrně podle procent",
"byAmount": "Nerovnoměrně podle částky",
"saveAsDefault": "Uložit jako výchozí možnosti rozdělení"
},
"DeletePopup": {
"label": "Smazat",
"title": "Smazat tento výdaj?",
"description": "Opravdu chcete tento výdaj smazat? Tato akce je nevratná.",
"yes": "Ano",
"cancel": "Zrušit"
},
"attachDocuments": "Připojit dokumenty",
"create": "Vytvořit",
"creating": "Vytváření…",
"save": "Uložit",
"saving": "Ukládání…",
"cancel": "Zrušit",
"reimbursement": "Proplacení"
},
"ExpenseDocumentsInput": {
"TooBigToast": {
"title": "Soubor je příliš velký",
"description": "Maximální velikost souboru, který můžete nahrát, je {maxSize}. Váš má {size}."
},
"ErrorToast": {
"title": "Chyba při nahrávání dokumentu",
"description": "Při nahrávání dokumentu došlo k chybě. Zkuste to prosím později nebo vyberte jiný soubor.",
"retry": "Zkusit znovu"
}
},
"CreateFromReceipt": {
"Dialog": {
"triggerTitle": "Vytvořit výdaj z účtenky",
"title": "Vytvořit z účtenky",
"description": "Získat informace o výdaji z fotografie účtenky.",
"body": "Nahrajte fotografii účtenky a my se pokusíme z ní získat informace o výdaji.",
"selectImage": "Vybrat obrázek…",
"titleLabel": "Název:",
"categoryLabel": "Kategorie:",
"amountLabel": "Částka:",
"dateLabel": "Datum:",
"editNext": "Informace o výdaji budete moci upravit v dalším kroku.",
"continue": "Pokračovat"
},
"unknown": "Neznámé",
"TooBigToast": {
"title": "Soubor je příliš velký",
"description": "Maximální velikost souboru, který můžete nahrát, je {maxSize}. Váš má {size}."
},
"ErrorToast": {
"title": "Chyba při nahrávání dokumentu",
"description": "Při nahrávání dokumentu došlo k chybě. Zkuste to prosím později nebo vyberte jiný soubor.",
"retry": "Zkusit znovu"
}
},
"Balances": {
"title": "Zůstatky",
"description": "Toto je částka, kterou každý účastník zaplatil nebo za kterou bylo zaplaceno.",
"Reimbursements": {
"title": "Navrhovaná proplacení",
"description": "Zde jsou návrhy na optimalizovaná proplacení mezi účastníky.",
"noImbursements": "Vypadá to, že vaše skupina nepotřebuje žádné proplacení 😁",
"owes": "<strong>{from}</strong> dluží <strong>{to}</strong>",
"markAsPaid": "Označit jako zaplaceno"
}
},
"Stats": {
"title": "Statistiky",
"Totals": {
"title": "Celkové součty",
"description": "Shrnutí výdajů celé skupiny.",
"groupSpendings": "Celkové výdaje skupiny",
"groupEarnings": "Celkové příjmy skupiny",
"yourSpendings": "Vaše celkové výdaje",
"yourEarnings": "Vaše celkové příjmy",
"yourShare": "Váš celkový podíl"
}
},
"Activity": {
"title": "Aktivita",
"description": "Přehled veškeré aktivity v této skupině.",
"noActivity": "Ve vaší skupině zatím není žádná aktivita.",
"someone": "Někdo",
"settingsModified": "Nastavení skupiny upravil <strong>{participant}</strong>.",
"expenseCreated": "Výdaj <em>{expense}</em> vytvořil <strong>{participant}</strong>.",
"expenseUpdated": "Výdaj <em>{expense}</em> upravil <strong>{participant}</strong>.",
"expenseDeleted": "Výdaj <em>{expense}</em> smazal <strong>{participant}</strong>.",
"Groups": {
"today": "Dnes",
"yesterday": "Včera",
"earlierThisWeek": "Dříve tento týden",
"lastWeek": "Minulý týden",
"earlierThisMonth": "Dříve tento měsíc",
"lastMonth": "Minulý měsíc",
"earlierThisYear": "Dříve tento rok",
"lastYear": "Minulý rok",
"older": "Starší"
}
},
"Information": {
"title": "Informace",
"description": "Použijte toto místo k přidání informací, které mohou být důležité pro účastníky skupiny.",
"empty": "Zatím žádné informace o skupině."
},
"Settings": {
"title": "Nastavení"
},
"Share": {
"title": "Sdílet",
"description": "Aby ostatní účastníci mohli vidět skupinu a přidávat výdaje, sdílejte s nimi její URL.",
"warning": "Varování!",
"warningHelp": "Každý, kdo má URL skupiny, bude moci zobrazit a upravovat výdaje. Sdílejte opatrně!"
},
"SchemaErrors": {
"min1": "Zadejte alespoň jeden znak.",
"min2": "Zadejte alespoň dva znaky.",
"max5": "Zadejte nejvýše pět znaků.",
"max50": "Zadejte nejvýše 50 znaků.",
"duplicateParticipantName": "Jiný účastník už toto jméno má.",
"titleRequired": "Zadejte prosím název.",
"invalidNumber": "Neplatné číslo.",
"amountRequired": "Musíte zadat částku.",
"amountNotZero": "Částka nesmí být nula.",
"amountTenMillion": "Částka musí být nižší než 10 000 000.",
"paidByRequired": "Musíte vybrat účastníka.",
"paidForMin1": "Výdaj musí být zaplacen alespoň za jednoho účastníka.",
"noZeroShares": "Všechny podíly musí být větší než 0.",
"amountSum": "Součet částek se musí rovnat částce výdaje.",
"percentageSum": "Součet procent musí být 100."
},
"Categories": {
"search": "Hledat kategorii...",
"noCategory": "Nebyla nalezena žádná kategorie.",
"Uncategorized": {
"heading": "Nezařazené",
"General": "Obecné",
"Payment": "Platba"
},
"Entertainment": {
"heading": "Zábava",
"Entertainment": "Zábava",
"Games": "Hry",
"Movies": "Filmy",
"Music": "Hudba",
"Sports": "Sport"
},
"Food and Drink": {
"heading": "Jídlo a pití",
"Food and Drink": "Jídlo a pití",
"Dining Out": "Restaurace",
"Groceries": "Potraviny",
"Liquor": "Alkohol"
},
"Home": {
"heading": "Domov",
"Home": "Domov",
"Electronics": "Elektronika",
"Furniture": "Nábytek",
"Household Supplies": "Domácí potřeby",
"Maintenance": "Údržba",
"Mortgage": "Hypotéka",
"Pets": "Domácí mazlíčci",
"Rent": "Nájem",
"Services": "Služby"
},
"Life": {
"heading": "Život",
"Childcare": "Péče o děti",
"Clothing": "Oblečení",
"Donation": "Dar",
"Education": "Vzdělání",
"Gifts": "Dárky",
"Insurance": "Pojištění",
"Medical Expenses": "Zdravotní výdaje",
"Taxes": "Daně"
},
"Transportation": {
"heading": "Doprava",
"Transportation": "Doprava",
"Bicycle": "Kolo",
"Bus/Train": "Autobus/Vlak",
"Car": "Auto",
"Gas/Fuel": "Benzín/Palivo",
"Hotel": "Hotel",
"Parking": "Parkování",
"Plane": "Letadlo",
"Taxi": "Taxi"
},
"Utilities": {
"heading": "Služby",
"Utilities": "Služby",
"Cleaning": "Úklid",
"Electricity": "Elektřina",
"Heat/Gas": "Topení/Plyn",
"Trash": "Odpad",
"TV/Phone/Internet": "TV/Telefon/Internet",
"Water": "Voda"
}
}
}

View File

@@ -38,12 +38,15 @@
"earlierThisYear": "Dieses Jahr",
"lastYear": "Letztes Jahr",
"older": "Älter"
}
},
"export": "Exportieren"
},
"ExpenseCard": {
"paidBy": "Gezahlt von <strong>{paidBy}</strong> für <paidFor></paidFor>",
"receivedBy": "Empfangen von <strong>{paidBy}</strong> für <paidFor></paidFor>",
"yourBalance": "Deine Bilanz:"
"yourBalance": "Deine Bilanz:",
"everyone": "jeder",
"notInvolved": "Du bist nicht involviert"
},
"Groups": {
"myGroups": "Meine Gruppen",
@@ -117,6 +120,12 @@
"create": "Erstellen",
"creating": "Erstellt…",
"cancel": "Abbrechen"
},
"CurrencyCodeField": {
"label": "Hauptwährung",
"createDescription": "Alle Beträge und Salden werden in dieser Währung angegeben.",
"customOption": "benutzerdefiniert",
"editDescription": "Alle Beträge und Salden werden in dieser Währung angegeben. Bei Änderung dieser, werden bereits eingegebene Ausgaben NICHT umgerechnet, es sei denn, die Währung hat andere \"kleinere Einheiten\" als die aktuelle (z. B. Wechsel von US-Dollar zu Japanischem Yen)"
}
},
"ExpenseForm": {
@@ -138,20 +147,23 @@
"description": "Wähle das Mitglied, das die Einnahme erhalten hat."
},
"recurrenceRule": {
"label": "Expense Recurrence",
"description": "Select how often the expense should repeat.",
"none": "None",
"daily": "Daily",
"weekly": "Weekly",
"monthly": "Monthly"
"label": "Wiederholung der Einnahme",
"description": "Wähle aus, wie oft die Einnahme wiederholt werden soll.",
"none": "Keine Wiederholung",
"daily": "Täglich",
"weekly": "Wöchentlich",
"monthly": "Monatlich"
},
"paidFor": {
"title": "Empfangen für",
"description": "Wähle für wen die Einnahme empfangen wurde."
},
"splitModeDescription": "Wähle, wie die Einnahme aufgeteilt werden soll.",
"attachDescription": "Füge der Einnahme einen Beleg hinzu."
"attachDescription": "Füge der Einnahme einen Beleg hinzu.",
"currencyField": {
"label": "Währung der Einnahme",
"description": "Die Währung, in der die Einnahmen erhalten wurden."
}
},
"Expense": {
"create": "Ausgabe erstellen",
@@ -168,14 +180,27 @@
"categoryFieldDescription": "Wähle eine Kategorie für die Ausgabe.",
"paidByField": {
"label": "Gezahlt von",
"placeholder": "Wähle ein Mitglied",
"description": "Wähle das Mitglied, das die Ausgabe bezahlt hat."
},
"recurrenceRule": {
"label": "Wiederholung der Ausgabe",
"description": "Wähle aus, wie oft die Ausgabe wiederholt werden soll.",
"none": "Keine Wiederholung",
"daily": "Täglich",
"weekly": "Wöchentlich",
"monthly": "Monatlich"
},
"paidFor": {
"title": "Gezahlt für",
"description": "Wähle für wen die Ausgabe gezahlt wurde."
},
"splitModeDescription": "Wähle, wie die Ausgabe aufgeteilt werden soll.",
"attachDescription": "Füge der Ausgabe einen Beleg hinzu."
"attachDescription": "Füge der Ausgabe einen Beleg hinzu.",
"currencyField": {
"label": "Währung der Ausgabe",
"description": "Die Währung, in der die Ausgabe bezahlt wurde."
}
},
"amountField": {
"label": "Betrag"
@@ -214,7 +239,28 @@
"save": "Speichern",
"saving": "Speichert…",
"cancel": "Abbrechen",
"reimbursement": "Rückzahlung"
"reimbursement": "Rückzahlung",
"conversionUnavailable": "Um für jede Ausgabe eine andere Währung festzulegen und Beträge umzurechnen, wählen Sie eine nicht benutzerdefinierte Währung für die Gruppe aus.",
"originalAmountField": {
"label": "Umzurechnender Betrag"
},
"conversionRateState": {
"customRate": "Benutzerdefinierte Rate verwenden",
"loading": "Wechselkurse abrufen…",
"success": "Erhaltene Zinssätze:",
"error": "Oops, wir konnten die aktuellsten Zinssätze nicht abrufen.",
"staleRate": "Verwendeter Zinssatz:",
"noRate": "Geben Sie unten einen benutzerdefinierten Zinssatz ein.",
"noDate": "Geben Sie das Ausgabedatum ein, um einen Umrechnungskurs zu erhalten.",
"dateMismatch": "Zinssätze von Datum: {date}",
"refresh": "Aktualisiere",
"currencyNotFound": "Oops, Frankfurter hat für diese Währung an diesem Tag keinen Zinssatz."
},
"conversionRateField": {
"useCustom": "Benutze individuelle Rate",
"label": "Wechselkurs",
"useApi": "Benutze Zinssätze von Frankfurter"
}
},
"ExpenseDocumentsInput": {
"TooBigToast": {
@@ -232,7 +278,7 @@
"triggerTitle": "Ausgabe von Rechnungsbeleg erstellen",
"title": "Von Rechnungsbeleg erstellen",
"description": "Ausgabeninformationen von einem Foto einer Rechnung lesen.",
"body": "Lade ein Foto der Rechnung hoch und wir versuchen die Ausgabeinformationen zu extrahieren",
"body": "Lade ein Foto der Rechnung hoch und wir versuchen die Ausgabeinformationen zu extrahieren.",
"selectImage": "Bild wählen…",
"titleLabel": "Titel:",
"categoryLabel": "Kategorie:",
@@ -287,7 +333,7 @@
"Groups": {
"today": "Heute",
"yesterday": "Gestern",
"earlierThisWeek": "Diese Woche",
"earlierThisWeek": "Anfang dieser Woche",
"lastWeek": "Letze Woche",
"earlierThisMonth": "Diesen Monat",
"lastMonth": "Letzen Monat",
@@ -320,12 +366,13 @@
"invalidNumber": "Zahl nicht valide.",
"amountRequired": "Du musst einen Betrag angeben.",
"amountNotZero": "Der Betrag darf nicht 0 sein.",
"amountTenMillion": "Der Betrag muss kleiner als 10.000.000 sein",
"amountTenMillion": "Der Betrag muss kleiner als 10.000.000 sein.",
"paidByRequired": "Du musst ein Mitglied auswählen.",
"paidForMin1": "Die Ausgabe muss mindestens für ein Mitglied bezahlt werden.",
"noZeroShares": "Alle Anteile müssen größer als 0 sein.",
"amountSum": "Die Summe der Beträge muss dem Betrag der Ausgabe entsprechen.",
"percentageSum": "Die Summe der prozentualen Anteile muss 100 ergeben."
"percentageSum": "Die Summe der prozentualen Anteile muss 100 ergeben.",
"ratePositive": "Der Zinssatz muss unbedingt größer als Null sein."
},
"Categories": {
"search": "Nach Kategorie suchen...",
@@ -395,5 +442,18 @@
"TV/Phone/Internet": "TV/Internet/Telefonie",
"Water": "Wasser"
}
},
"Currencies": {
"search": "Währung suchen...",
"noCurrency": "Keine Währungen gefunden.",
"other": {
"heading": "Andere Währungen"
},
"custom": {
"heading": "Benutzerdefinierte"
},
"common": {
"heading": "Geläufigste"
}
}
}
}

View File

@@ -43,8 +43,10 @@
},
"ExpenseCard": {
"paidBy": "Paid by <strong>{paidBy}</strong> for <paidFor></paidFor>",
"everyone": "everyone",
"receivedBy": "Received by <strong>{paidBy}</strong> for <paidFor></paidFor>",
"yourBalance": "Your balance:"
"yourBalance": "Your balance:",
"notInvolved": "You are not involved"
},
"Groups": {
"myGroups": "My groups",
@@ -94,6 +96,12 @@
"placeholder": "$, €, £…",
"description": "Well use it to display amounts."
},
"CurrencyCodeField": {
"label": "Main currency",
"createDescription": "All amounts and balances will be in this currency.",
"editDescription": "All amounts and balances will be in this currency. Changing this will NOT convert expenses already entered, except when the currency has different \"minor units\" than the current one (e.g. changing from US Dollar to Japanese Yen)",
"customOption": "Custom"
},
"Participants": {
"title": "Participants",
"description": "Enter the name for each participant.",
@@ -133,6 +141,10 @@
"label": "Income date",
"description": "Enter the date the income was received."
},
"currencyField": {
"label": "Currency of income",
"description": "The currency in which the income was received."
},
"categoryFieldDescription": "Select the income category.",
"paidByField": {
"label": "Received by",
@@ -157,9 +169,14 @@
"label": "Expense date",
"description": "Enter the date the expense was paid."
},
"currencyField": {
"label": "Currency of expense",
"description": "The currency in which the expense was paid."
},
"categoryFieldDescription": "Select the expense category.",
"paidByField": {
"label": "Paid by",
"placeholder": "Select a participant",
"description": "Select the participant who paid the expense."
},
"recurrenceRule": {
@@ -181,6 +198,27 @@
"amountField": {
"label": "Amount"
},
"conversionUnavailable": "To set a different currency per expense and convert amounts, select a non-custom currency for the group.",
"originalAmountField": {
"label": "Amount to convert"
},
"conversionRateField": {
"useApi": "Use rates from Frankfurter",
"useCustom": "Use custom rate",
"label": "Exchange rate"
},
"conversionRateState": {
"loading": "Getting exchange rates…",
"success": "Obtained rates:",
"error": "Oops, we could not get the most recent rates.",
"staleRate": "Using rate:",
"noRate": "Enter a custom rate below.",
"currencyNotFound": "Oops, Frankfurter does not have the rate for this currency at this day.",
"noDate": "Enter the expense date to get a conversion rate.",
"dateMismatch": "Rates from date: {date}",
"refresh": "Refresh",
"customRate": "Using custom rate"
},
"isReimbursementField": {
"label": "This is a reimbursement"
},
@@ -322,6 +360,7 @@
"amountRequired": "You must enter an amount.",
"amountNotZero": "The amount must not be zero.",
"amountTenMillion": "The amount must be lower than 10,000,000.",
"ratePositive": "The rate must be strictly greater than zero.",
"paidByRequired": "You must select a participant.",
"paidForMin1": "The expense must be paid for at least one participant.",
"noZeroShares": "All shares must be higher than 0.",
@@ -396,5 +435,18 @@
"TV/Phone/Internet": "TV/Phone/Internet",
"Water": "Water"
}
},
"Currencies": {
"search": "Search currency...",
"noCurrency": "No currencies found.",
"custom": {
"heading": "Custom"
},
"common": {
"heading": "Most common"
},
"other": {
"heading": "Other currencies"
}
}
}
}

View File

@@ -20,6 +20,7 @@
"create": "Crear gasto",
"createFirst": "Crea el primero",
"noExpenses": "Tu grupo aun no tiene gastos.",
"export": "Exportar",
"exportJson": "Exportar a JSON",
"exportCsv": "Exportar a CSV",
"searchPlaceholder": "Busca un gasto…",
@@ -37,13 +38,15 @@
"lastMonth": "El mes pasado",
"earlierThisYear": "A principios de este año",
"lastYear": "El año pasado",
"older": "Más antiguos"
"older": "Más antiguo"
}
},
"ExpenseCard": {
"paidBy": "Pagado por <strong>{paidBy}</strong> para <paidFor></paidFor>",
"receivedBy": "Recibido por <strong>{paidBy}</strong> para <paidFor></paidFor>",
"yourBalance": "Tu balance:"
"yourBalance": "Tu balance:",
"everyone": "todos",
"notInvolved": "No estás incluido"
},
"Groups": {
"myGroups": "Mis grupos",
@@ -52,16 +55,16 @@
"NoRecent": {
"description": "No has visitado ningun grupo recientemente.",
"create": "Crea uno",
"orAsk": "o pídele a un amigo que te envíe el enlace a uno ya existente.."
"orAsk": "o pídele a un amigo que te envíe el enlace a uno ya existente."
},
"recent": "Grupos recientes",
"starred": "Grupos favoritos",
"archived": "Grupos archivados",
"archive": "Archivar grupo",
"unarchive": "Desarchivar groupo",
"removeRecent": "Remove from recent groups",
"removeRecent": "Eliminar de grupos recientes",
"RecentRemovedToast": {
"title": "El grupo fue eliminado",
"title": "El grupo ha sido eliminado",
"description": "El grupo ha sido eliminado de tu lista de grupos recientes.",
"undoAlt": "Deshacer la eliminación del grupo",
"undo": "Deshacer"
@@ -69,29 +72,29 @@
"AddByURL": {
"button": "Añadir mediante url",
"title": "Añadir grupo mediante url",
"description": "Si te han compartido un grupo, puedes pegar aquí su URL para añadirlo a tu lista.",
"error": "Oops, no somos capaces de encontrar el grupo desde la URL que has proporcionado..."
"description": "Si un grupo ha sido compartido contigo, puedes pegar su URL aquí para añadirlo a tu lista.",
"error": "Ups, no pudimos encontrar el grupo a partir de la URL que proporcionaste…"
},
"NotFound": {
"text": "Este grupo no existe.",
"link": "Ir a los últimos grupos visitados"
"link": "Ir a los grupos visitados recientemente"
}
},
"GroupForm": {
"title": "Información del grupo",
"NameField": {
"label": "Nombre del grupo",
"placeholder": "Vacaciones en Barcelona",
"description": "Inserta un nombre para tu nuevo grupo."
"placeholder": "Vacaciones de verano",
"description": "Introduce un nombre para tu grupo."
},
"InformationField": {
"label": "Información del grupo",
"placeholder": "Qué información es relevante para los participantes del grupo?"
"placeholder": "¿Qué información es relevante para los participantes del grupo?"
},
"CurrencyField": {
"label": "Símbolo de divisa",
"placeholder": "$, €, £…",
"description": "Lo usaremos para mostrar balances."
"label": "Símbolo de la divisa",
"placeholder": "$, €, £, ₿…",
"description": "Lo utilizaremos para mostrar los montos."
},
"Participants": {
"title": "Participantes",
@@ -108,15 +111,21 @@
"description": "Estos ajustes se establecen por dispositivo y se utilizan para personalizar su experiencia.",
"ActiveUserField": {
"label": "Usuario activo",
"placeholder": "Selecciona un participante...",
"placeholder": "Selecciona un participante",
"none": "Ninguno",
"description": "Usuario que paga los gastos por defecto."
},
"save": "Guardar",
"saving": "Guardando",
"saving": "Guardando",
"create": "Crear",
"creating": "Creando",
"creating": "Creando",
"cancel": "Cancelar"
},
"CurrencyCodeField": {
"label": "Moneda principal",
"createDescription": "Todos los importes y saldos estarán en esta moneda.",
"editDescription": "Todos los importes y saldos estarán en esta moneda. Al cambiarla, NO se convertirán los gastos ya ingresados, excepto cuando la moneda tenga «unidades menores» diferentes a las actuales (por ejemplo, al cambiar de dólares estadounidenses a yenes japoneses)",
"customOption": "Personalizado"
}
},
"ExpenseForm": {
@@ -137,45 +146,53 @@
"label": "Recibido por",
"description": "Seleccione el participante que recibió los ingresos."
},
"recurrenceRule": {
"label": "Recurrencia del gasto",
"description": "Seleccione con qué frecuencia debe repetirse el gasto.",
"none": "Ninguno",
"daily": "Diario",
"weekly": "Semanal",
"monthly": "Mensual"
},
"paidFor": {
"title": "Recibido para for",
"description": "Seleccione para quién se recibió el ingreso."
},
"splitModeDescription": "Seleccione como quieres dividir el ingreso.",
"attachDescription": "Ver y adjuntar tickets para el ingreso."
"attachDescription": "Ver y adjuntar tickets para el ingreso.",
"currencyField": {
"label": "Moneda del ingreso",
"description": "La moneda en la que se recibieron los ingresos."
}
},
"Expense": {
"create": "Crear gasto",
"edit": "Editar gasto",
"TitleField": {
"label": "Título del gasto",
"placeholder": "Monday evening restaurant",
"description": "Enter a description for the expense."
"placeholder": "Restaurante de lunes por la noche",
"description": "Ingrese una descripción del gasto."
},
"DateField": {
"label": "Fecha del gasto",
"description": "Ingresa la fecha en que se recibio el gasto."
},
"categoryFieldDescription": "Select the expense category.",
"categoryFieldDescription": "Seleccione la categoría del gasto.",
"paidByField": {
"label": "Pagado por",
"description": "Seleccione el participante que pagó el gasto."
"description": "Seleccione el participante que pagó el gasto.",
"placeholder": "Seleccionar un participante"
},
"recurrenceRule": {
"label": "Gasto recurrente",
"description": "Seleccione con qué frecuencia debe repetirse el gasto.",
"none": "Ninguno",
"daily": "Diario",
"weekly": "Semanal",
"monthly": "Mensual"
},
"paidFor": {
"title": "Pagado para",
"description": "Seleccione para quién se pagó el gasto."
},
"splitModeDescription": "Seleccione como quieres dividir el gasto.",
"attachDescription": "Ver y adjuntar tickets para el gasto."
"attachDescription": "Ver y adjuntar tickets para el gasto.",
"currencyField": {
"label": "Moneda del gasto",
"description": "La moneda en la que se pagó el gasto."
}
},
"amountField": {
"label": "Cantidad"
@@ -192,7 +209,7 @@
"selectNone": "Seleccionar ninguno",
"selectAll": "Seleccionar todos",
"shares": "partes",
"advancedOptions": "Opciones avanzadas",
"advancedOptions": "Opciones avanzadas de división…",
"SplitModeField": {
"label": "Modo de división",
"evenly": "Uniformemente",
@@ -210,11 +227,32 @@
},
"attachDocuments": "Adjuntar documentos",
"create": "Crear",
"creating": "Creando",
"creating": "Creando",
"save": "Guardar",
"saving": "Guardando",
"saving": "Guardando",
"cancel": "Cancelar",
"reimbursement": "Reembolso"
"reimbursement": "Reembolso",
"conversionUnavailable": "Para establecer una moneda diferente por gasto y convertir los importes, seleccione una moneda no personalizada para el grupo.",
"originalAmountField": {
"label": "Monto a convertir"
},
"conversionRateField": {
"useApi": "Utilizar las tasas del Frankfurter",
"useCustom": "Utilizar tasa personalizada",
"label": "Tasa de cambio"
},
"conversionRateState": {
"loading": "Obteniendo tasas de cambio…",
"success": "Tasas obtenidas:",
"error": "Vaya, no hemos podido obtener las tasas más recientes.",
"staleRate": "Tasa utilizada:",
"noRate": "Ingrese una tasa personalizada.",
"currencyNotFound": "Vaya, Frankfurter no tiene el tipo de cambio para esta moneda en este día.",
"noDate": "Ingrese la fecha del gasto para obtener una tasa de cambio.",
"dateMismatch": "Tasas para la fecha: {date}",
"refresh": "Actualizar",
"customRate": "Utilizando tasa personalizada"
}
},
"ExpenseDocumentsInput": {
"TooBigToast": {
@@ -259,7 +297,7 @@
"title": "Reembolsos propuestos",
"description": "He aquí algunas sugerencias para optimizar los reembolsos entre los participantes.",
"noImbursements": "Parece que tu grupo no necesita ningún reembolso 😁",
"owes": "<strong>{from}</strong> debe <strong>{to}</strong>",
"owes": "<strong>{from}</strong> debe a <strong>{to}</strong>",
"markAsPaid": "Marcar como pagado"
}
},
@@ -315,21 +353,22 @@
"min2": "Introduzca al menos dos carácter.",
"max5": "Introduzca al menos cinco carácter.",
"max50": "Introduzca al menos treinta carácter.",
"duplicateParticipantName": "Otro participante ya tiene este nombre",
"titleRequired": "Por favor, introduzca un título",
"invalidNumber": "Número inválido",
"amountRequired": "Debe introducir un importe",
"duplicateParticipantName": "Ya hay otro participante con el mismo nombre.",
"titleRequired": "Por favor, introduzca un título.",
"invalidNumber": "Número inválido.",
"amountRequired": "Debe introducir un importe.",
"amountNotZero": "El importe no debe ser cero.",
"amountTenMillion": "El importe debe ser inferior a 10.000.000.",
"paidByRequired": "Debe seleccionar un participante",
"paidForMin1": "El gasto debe ser pagado por al menos un participante",
"noZeroShares": "Todas las participaciones deben ser superiores a 0",
"amountSum": "La suma de los importes debe ser igual al importe del gasto",
"percentageSum": "Suma de porcentajes debe ser igual a 100"
"paidByRequired": "Debe seleccionar un participante.",
"paidForMin1": "El gasto debe ser pagado por al menos un participante.",
"noZeroShares": "Todas las partes deben ser mayor que 0.",
"amountSum": "La suma de los importes debe ser igual al importe del gasto total.",
"percentageSum": "Suma de porcentajes debe ser igual a 100.",
"ratePositive": "La tasa debe ser mayor a cero."
},
"Categories": {
"search": "Buscar categoría...",
"noCategory": "Categoría no encontrada!",
"noCategory": "Categoría no encontrada.",
"Uncategorized": {
"heading": "Sin categoría",
"General": "General",
@@ -370,7 +409,8 @@
"Gifts": "Regalos",
"Insurance": "Seguro",
"Medical Expenses": "Gastos médicos",
"Taxes": "Impuestos"
"Taxes": "Impuestos",
"Donation": "Donación"
},
"Transportation": {
"heading": "Transporte",
@@ -394,5 +434,18 @@
"TV/Phone/Internet": "TV/Teléfono/Internet",
"Water": "Agua"
}
},
"Currencies": {
"search": "Buscar moneda...",
"noCurrency": "No se han podido encontrar monedas.",
"custom": {
"heading": "Personalizado"
},
"common": {
"heading": "Más común"
},
"other": {
"heading": "Otras monedas"
}
}
}
}

451
messages/eu.json Normal file
View File

@@ -0,0 +1,451 @@
{
"Homepage": {
"title": "Partekatu <strong>gastuak lagunekin eta familiarekin</strong>",
"description": "Ongi etorri zure <strong>Spliit</strong> intantzia berrira!",
"button": {
"groups": "Joan taldeetara",
"github": "GitHub"
}
},
"Header": {
"groups": "Taldeak"
},
"Footer": {
"madeIn": "Montrealen egina, Quebec 🇨🇦",
"builtBy": "<author>Sebastien Castiel</author> eta <source>laguntzaileek</source> egina"
},
"Expenses": {
"title": "Gastuak",
"description": "Hemen talde honetan sortutako gastuak aurkituko dituzu.",
"create": "Sortu gastua",
"createFirst": "Sortu lehenengoa",
"noExpenses": "Talde honek oraindik ez du gasturik.",
"export": "Esportatu",
"exportJson": "Esportatu JSONera",
"Groups": {
"earlierThisYear": "Urte hasieran",
"lastYear": "Iaz",
"older": "Zaharrena",
"upcoming": "Laster",
"thisWeek": "Aste honetan",
"earlierThisMonth": "Hilabete hasieran",
"lastMonth": "Aurreko hilabetean"
},
"exportCsv": "Esportatu CSVra",
"searchPlaceholder": "Bilatu gastu bat…",
"ActiveUserModal": {
"title": "Nor zara?",
"description": "Esan zein parte-hartzaile zaren, informazioa nola bistaratzen den pertsonalizatzeko.",
"nobody": "Ez dut inor aukeratu nahi",
"save": "Gorde aldaketak",
"footer": "Doikuntza hori geroago alda daiteke taldearen konfigurazioan."
}
},
"ExpenseCard": {
"paidBy": "<strong>{paidBy}</strong>-k ordaindua <paidFor></paidFor>-rentzat",
"everyone": "denak",
"receivedBy": "<strong>{paidBy}</strong>-k jasota <paidFor></paidFor>-rentzat",
"yourBalance": "Zure saldoa:",
"notInvolved": "Ez duzu parte hartzen"
},
"Groups": {
"myGroups": "Nire taldeak",
"create": "Sortu",
"loadingRecent": "Azken taldeak kargatzen…",
"NoRecent": {
"description": "Azken aldian ez duzu talderik bisitatu.",
"create": "Sortu bat",
"orAsk": "edo eskatu lagun bati lehendik dagoen baten esteka bidaltzeko."
},
"recent": "Azken taldeak",
"starred": "Gogoko taldeak",
"archived": "Gordetako taldeak",
"archive": "Gorde taldea",
"unarchive": "Kendu taldea artxibotik",
"removeRecent": "Kendu azken taldeetatik",
"RecentRemovedToast": {
"title": "Taldea ezabatu da",
"description": "Taldea azken taldeen zerrendatik ezabatu da.",
"undoAlt": "Desegin taldearen ezabatzea",
"undo": "Desegin"
},
"AddByURL": {
"button": "Gehitu URL bidez",
"title": "Gehitu talde bat URL bidez",
"description": "Talde bat zurekin partekatu bada, hemen itsatsi dezakezu bere URLa zure zerrendara gehitzeko.",
"error": "Oops, ezin dugu taldea aurkitu zuk emandako URLtik…"
},
"NotFound": {
"text": "Talde hori ez da existitzen.",
"link": "Joan bisitatutako azken taldeetara"
}
},
"GroupForm": {
"title": "Taldeko informazioa",
"NameField": {
"label": "Taldearen izena",
"placeholder": "Udako oporrak",
"description": "Eman izena zure taldeari."
},
"InformationField": {
"label": "Taldeko informazioa",
"placeholder": "Zer informazio da garrantzitsua taldeko parte-hartzaileentzat?"
},
"CurrencyField": {
"label": "Monetaren ikurra",
"placeholder": "$, €, £…",
"description": "Zenbatekoak erakusteko erabiliko dugu."
},
"CurrencyCodeField": {
"label": "Moneta nagusia",
"createDescription": "Zenbatekoak eta saldoak monetan honetan adieraziko dira.",
"editDescription": "Zenbateko eta saldo guztiak moneta honetan adieraziko dira. Hori aldatuz gero, EZ dira bihurtuko lehendik sartutako gastuak, dibisak egungoaren \"unitate txiki\" desberdinak dituenean izan ezik (adib. AEBko dolarretik Japoniako yenera aldatuz)",
"customOption": "Pertsonalizatua"
},
"Participants": {
"title": "Kideak",
"description": "Sartu kide bakoitzaren izena.",
"protectedParticipant": "Kide hauek gastuak dituzte eta ezin dira ezabatu.",
"new": "Berria",
"add": "Gehitu kidea",
"John": "Jon",
"Jane": "Jone",
"Jack": "Santi"
},
"Settings": {
"title": "Ezarpen lokalak",
"description": "Ezarpen hauek gailuari lotuta daude eta zure esperientzia pertsonalizatzeko erabiltzen dira.",
"ActiveUserField": {
"label": "Erabiltzaile aktiboa",
"placeholder": "Hautatu kide bat",
"none": "Bat ere ez",
"description": "Gastuak ordaintzeko lehenetsi gisa erabiltzen den erabiltzailea."
},
"save": "Gorde",
"saving": "Gordetzen…",
"create": "Sortu",
"creating": "Sortzen…",
"cancel": "Utzi"
}
},
"ExpenseForm": {
"Income": {
"create": "Sortu diru-sarrera",
"edit": "Editatu diru-sarrera",
"TitleField": {
"label": "Diru-sarreraren izena",
"placeholder": "Astelehenen arratseko jatetxea",
"description": "Sartu diru-sarreraren deskripzioa."
},
"DateField": {
"label": "Diru-sarreraren data",
"description": "Sartu diru-sarrera jaso den data."
},
"currencyField": {
"label": "Diru-sarreraren moneta",
"description": "Diru-sarrerak zein monetan jaso diren."
},
"categoryFieldDescription": "Hautatu diru-sarreraren kategoria.",
"paidByField": {
"label": "Zeinek jaso du",
"description": "Hautatu zein kidek jaso duen diru-sarrera."
},
"paidFor": {
"title": "Nork jaso du",
"description": "Hautatu norentzat jaso den diru-sarrera."
},
"splitModeDescription": "Hautatu nola zatitu diru-sarrera.",
"attachDescription": "Ordainagiriak ikusi eta diru-sarrerei erantsi."
},
"Expense": {
"create": "Sortu gastua",
"edit": "Editatu gastua",
"TitleField": {
"label": "Gastuaren izena",
"placeholder": "Astelehen arratseko jatetxea",
"description": "Sartu gastuaren deskripzioa."
},
"DateField": {
"label": "Gastuaren data",
"description": "Sartu gastua ordaindu deneko data."
},
"currencyField": {
"label": "Gastuaren moneta",
"description": "Gastua zein monetatan ordaindu den."
},
"categoryFieldDescription": "Hautatu gastuaren kategoria.",
"paidByField": {
"label": "Zeinek ordaindu du",
"placeholder": "Hautatu kide bat",
"description": "Hautatu gastua ordaindu duen kidea."
},
"recurrenceRule": {
"label": "Gastu errepikakorra",
"description": "Hautatu gastua zein maiztasunez errepikatu behar den.",
"none": "Bat ere ez",
"daily": "Egunero",
"weekly": "Astero",
"monthly": "Hilabetero"
},
"paidFor": {
"title": "Honentzat ordaindu da",
"description": "Hautatu gastua norentzat ordaindu den."
},
"splitModeDescription": "Hautatu nola zatitu gastua.",
"attachDescription": "Ikusi eta erantsi gastuaren ordainagiriak."
},
"amountField": {
"label": "Zenbatekoa"
},
"conversionUnavailable": "Gastu bakoitzeko moneta bat ezartzeko eta zenbatekoak bihurtzeko, hautatu taldearentzako moneta ez-pertsonalizatu bat.",
"originalAmountField": {
"label": "Bihurtzeko zenbatekoa"
},
"conversionRateField": {
"useApi": "Erabili Frankfurter-en tasak",
"useCustom": "Erabili tasa pertsonalizatua",
"label": "Kanbio-tasa"
},
"conversionRateState": {
"loading": "Kanbio-tasak eskuratzen…",
"success": "Eskuratutako tasak:",
"error": "Oops, ezin izan ditugu azken tasak lortu.",
"staleRate": "Erabilitako tasa:",
"noRate": "Sartu tarifa pertsonalizatua behean.",
"currencyNotFound": "Oops, Frankfurterrek ez du moneta honen tasa gaur egun.",
"noDate": "Sartu gastuaren data bihurketa-tasa lortzeko.",
"dateMismatch": "Tarifak datarako: {date}",
"refresh": "Eguneratu",
"customRate": "Tasa pertsonalizatua erabiltzen"
},
"isReimbursementField": {
"label": "Hau diru-itzultzea da"
},
"categoryField": {
"label": "Kategoria"
},
"notesField": {
"label": "Oharrak"
},
"selectNone": "Hautatu bat ere ez",
"selectAll": "Hautatu denak",
"shares": "partekatzea(k)",
"advancedOptions": "Zatitzeko aukera aurreratuak…",
"SplitModeField": {
"label": "Zatitzeko modua",
"evenly": "Zati berdinetan",
"byShares": "Zati ezberdinetan - Partekatzetan",
"byPercentage": "Zati ezberdinetan - ehunekoen bidez",
"byAmount": "Zati desberdinetan - zenbatekoen bidez",
"saveAsDefault": "Gorde gogoko zatitze modu gisa"
},
"DeletePopup": {
"label": "Ezabatu",
"title": "Ezabatu gastu hau?",
"description": "Benetan gastu hau ezabatu nahi duzu? Ekintzak ez dauka atzera bueltarik.",
"yes": "Bai",
"cancel": "Utzi"
},
"attachDocuments": "Erantsi dokumentuak",
"create": "Sortu",
"creating": "Sortzen…",
"save": "Gorde",
"saving": "Gordetzen…",
"cancel": "Utzi",
"reimbursement": "Diru-itzultzea"
},
"ExpenseDocumentsInput": {
"TooBigToast": {
"title": "Fitxategia handiegia da",
"description": "Igo dezakezun fitxategirik handiena {maxSize} da. Zureak {size} pisatzen du."
},
"ErrorToast": {
"title": "Errorea dokumentua igotzean",
"description": "Errore bat gertatu da dokumentua igotzean. Saiatu geroago edo hautatu beste fitxategi bat.",
"retry": "Saiatu berriro"
}
},
"CreateFromReceipt": {
"Dialog": {
"triggerTitle": "Sortu gastua ordainagiri batetik",
"title": "Sortu ordainagiritik",
"description": "Erauzi gastuaren informazioa ordainagiriren argazkitik.",
"body": "Igo ordainagiri baten argazkia eta eskaneatuko dugu gastuaren informazioa erauzteko, ahal badugu.",
"selectImage": "Hautatu irudia…",
"titleLabel": "Titulua:",
"categoryLabel": "Kategoria:",
"amountLabel": "Zenbatekoa:",
"dateLabel": "Data:",
"editNext": "Ondoren, gastuaren informazioa editatzeko aukera izango duzu.",
"continue": "Jarraitu"
},
"unknown": "Ezezaguna",
"TooBigToast": {
"title": "Fitxategia handiegia da",
"description": "Igo dezakezun fitxategiaren gehienezko tamaina {maxSize} da. Zureak {size} pisatzen du."
},
"ErrorToast": {
"title": "Errorea dokumentua igotzean",
"description": "Dokumentua igotzean errore bat gertatu da. Saiatu berriro beranduago edo hautatu beste fitxategi bat.",
"retry": "Saiatu berriro"
}
},
"Balances": {
"title": "Saldoak",
"description": "Hau da kide bakoitzak ordaindu edo jaso duen zenbatekoa.",
"Reimbursements": {
"title": "Proposatutako diru-itzulketak",
"description": "Hona hemen kideen arteko ditu-itzulketak optimizatzeko proposamenak.",
"noImbursements": "Antza denez zure taldeak ez du diru-itzulketarik behar 😁",
"owes": "<strong>{from}</strong> zor dio <strong>{to}</strong>-ri",
"markAsPaid": "Markatu ordainduta gisa"
}
},
"Stats": {
"title": "Estatistikak",
"Totals": {
"title": "Denetara",
"description": "Talde osoaren gastuen laburpena.",
"groupSpendings": "Talde osoaren gastuak",
"groupEarnings": "Talde osoaren diru-sarrerak",
"yourSpendings": "Zure gastuak denetara",
"yourEarnings": "Zure diru-sarrerak denetara",
"yourShare": "Zure parte osoa"
}
},
"Activity": {
"title": "Jarduera",
"description": "Talde honetako jarduera guztien ikuspegi orokorra.",
"noActivity": "Oraindik ez dago jarduerarik zure taldean.",
"someone": "Norbait",
"settingsModified": "<strong>{participant}</strong>-k taldearen ezarpenak aldatu ditu.",
"expenseCreated": "<em>{expense}</em>ko gastua sortu du <strong>{participant}</strong>-k.",
"expenseUpdated": "<em>{expense}</em>ko gastua <strong>{participant}</strong>-k sortua.",
"expenseDeleted": "<em>{expense}</em>ko gastua ezabatu du <strong>{participant}</strong>-k.",
"Groups": {
"today": "Gaur",
"yesterday": "Atzo",
"earlierThisWeek": "Aste honen hasieran",
"lastWeek": "Pasa den astean",
"earlierThisMonth": "Hilabete honen hasieran",
"lastMonth": "Pasa den hilabetean",
"earlierThisYear": "Urte honen hasieran",
"lastYear": "Iaz",
"older": "Zaharragoak"
}
},
"Information": {
"title": "Informazioa",
"description": "Erabili leku hau taldeko kideentzat garrantzitsua izan daitekeen edozein informazio gehitzeko.",
"empty": "Oraindik ez dago taldearen informaziorik."
},
"Settings": {
"title": "Ezarpenak"
},
"Share": {
"title": "Partekatu",
"description": "Beste kide batzuek taldea ikusteko eta gastuak gehitzeko, partekatu haiekin URLa.",
"warning": "Kontuz!",
"warningHelp": "Taldeko URLa duen edozein pertsonak gastuak ikusi eta editatu ahal izango ditu. Partekatu kontuz!"
},
"SchemaErrors": {
"min1": "Sartu gutxienez karaktere bat.",
"min2": "Sartu gutxienez bi karaktere.",
"max5": "Sartu gutxienez bost karaktere.",
"max50": "Sartu gutxienez 50 karaktere.",
"duplicateParticipantName": "Badago izen hori duen beste kide bat.",
"titleRequired": "Sartu titulu bat.",
"invalidNumber": "Zenbaki baliogabea.",
"amountRequired": "Zenbateko bat sartu behar duzu.",
"amountNotZero": "Zenbatekoa ezin da zero izan.",
"amountTenMillion": "Zenbatekoa 10.000.000 baino txikiagoa izan behar du.",
"ratePositive": "Tasa zero baino handiagoa izan behar du.",
"paidByRequired": "Kide bat hautatu behar duzu.",
"paidForMin1": "Gastua gutxienez kide batek ordaindu behar du.",
"noZeroShares": "Parte guztiek zero baino handiagoak izan behar dute.",
"amountSum": "Zenbatekoen baturak gastuaren zenbatekoa berdindu behar du.",
"percentageSum": "Ehunekoen baturak 100 izan behar du."
},
"Categories": {
"search": "Bilatu kategoria...",
"noCategory": "Ez da kategoriarik aurkitu.",
"Uncategorized": {
"heading": "Kategoriarik gabe",
"General": "Orokorra",
"Payment": "Ordainketa"
},
"Entertainment": {
"heading": "Aisia",
"Entertainment": "Aisia",
"Games": "Jolasak",
"Movies": "Filmak",
"Music": "Musika",
"Sports": "Kirolak"
},
"Food and Drink": {
"heading": "Janari-edariak",
"Food and Drink": "Janari-edariak",
"Dining Out": "Kanpoan afaltzea",
"Groceries": "Janariak",
"Liquor": "Likoreak"
},
"Home": {
"heading": "Etxea",
"Home": "Etxea",
"Electronics": "Elektronika",
"Furniture": "Altzariak",
"Household Supplies": "Etxeko hornidurak",
"Maintenance": "Mantenua",
"Mortgage": "Hipoteka",
"Pets": "Maskotak",
"Rent": "Etxeko errenta",
"Services": "Zerbitzuak"
},
"Life": {
"heading": "Bizitza",
"Childcare": "Umeen zaintza",
"Clothing": "Arropa",
"Donation": "Dohaintza",
"Education": "Hezkuntza",
"Gifts": "Opariak",
"Insurance": "Asegurua",
"Medical Expenses": "Osasun gastuak",
"Taxes": "Zergak"
},
"Transportation": {
"heading": "Garraioa",
"Transportation": "Garraioa",
"Bicycle": "Bizikleta",
"Bus/Train": "Autobusa/Trena",
"Car": "Autoa",
"Gas/Fuel": "Gasolina/Erregaia",
"Hotel": "Hotela",
"Parking": "Aparkalekua",
"Plane": "Hegazkina",
"Taxi": "Taxia"
},
"Utilities": {
"heading": "Erabilgarritasunak",
"Utilities": "Erabilgarritasunak",
"Cleaning": "Garbiketa",
"Electricity": "Elektrizitatea",
"Heat/Gas": "Berokuntza/Gasa",
"Trash": "Zaborra",
"TV/Phone/Internet": "TV/Telefonoa/Interneta",
"Water": "Ura"
}
},
"Currencies": {
"search": "Bilatu moneta...",
"noCurrency": "Ez da monetarik aurkitu.",
"custom": {
"heading": "Pertsonalizatua"
},
"common": {
"heading": "Ohikoena"
},
"other": {
"heading": "Beste monetak"
}
}
}

View File

@@ -137,15 +137,6 @@
"label": "Vastaanottaja",
"description": "Valitse kuka vastaanotti tulon."
},
"recurrenceRule": {
"label": "Expense Recurrence",
"description": "Select how often the expense should repeat.",
"none": "None",
"daily": "Daily",
"weekly": "Weekly",
"monthly": "Monthly"
},
"paidFor": {
"title": "Tulon jakaminen",
"description": "Valitse kenelle tulo jaetaan."
@@ -240,16 +231,6 @@
"dateLabel": "Päivä:",
"editNext": "Voit muokata kulun tietoja seuraavaksi.",
"continue": "Jatka"
},
"unknown": "Unknown",
"TooBigToast": {
"title": "The file is too big",
"description": "The maximum file size you can upload is {maxSize}. Yours is {size}."
},
"ErrorToast": {
"title": "Error while uploading document",
"description": "Something wrong happened when uploading the document. Please retry later or select a different file.",
"retry": "Retry"
}
},
"Balances": {

View File

@@ -1,6 +1,6 @@
{
"Homepage": {
"title": "Partagez <strong>vos dépenses</strong> avec <strong>vos amis</strong> & <strong>votre famille :)</strong>",
"title": "Partagez <strong>vos dépenses</strong> avec <strong>vos amis & votre famille</strong>",
"description": "Bienvenue sur votre instance <strong>Spliit</strong> !",
"button": {
"groups": "Accéder aux groupes",
@@ -38,12 +38,15 @@
"earlierThisYear": "Plus tôt cette année",
"lastYear": "L'année dernière",
"older": "Plus ancien"
}
},
"export": "Exporter"
},
"ExpenseCard": {
"paidBy": "Payé par <strong>{paidBy}</strong> pour <paidFor></paidFor>",
"receivedBy": "Reçu par <strong>{paidBy}</strong> pour <paidFor></paidFor>",
"yourBalance": "Votre solde :"
"yourBalance": "Votre solde :",
"everyone": "tout le monde",
"notInvolved": "Vous n'êtes pas concerné"
},
"Groups": {
"myGroups": "Mes groupes",
@@ -99,9 +102,9 @@
"protectedParticipant": "Ce participant fait partie des dépenses et ne peut pas être supprimé.",
"new": "Nouveau",
"add": "Ajouter un participant",
"John": "John",
"Jane": "Jane",
"Jack": "Jack"
"John": "Jean",
"Jane": "Jeanne",
"Jack": "Jacques"
},
"Settings": {
"title": "Paramètres locaux",
@@ -117,6 +120,12 @@
"create": "Créer",
"creating": "Création…",
"cancel": "Annuler"
},
"CurrencyCodeField": {
"label": "Devise principale",
"createDescription": "Tous les montants et soldes seront dans cette devise.",
"editDescription": "Tous les montants et soldes seront exprimés dans cette devise. La modification de cette option n'entraînera PAS la conversion des dépenses déjà saisies, sauf si la devise a des « unités mineures » différentes de celles de la devise actuelle (par exemple, passage du dollar américain au yen japonais)",
"customOption": "Personnalisée"
}
},
"ExpenseForm": {
@@ -138,20 +147,23 @@
"description": "Sélectionnez le participant qui a reçu le revenu."
},
"recurrenceRule": {
"label": "Expense Recurrence",
"description": "Select how often the expense should repeat.",
"none": "None",
"daily": "Daily",
"weekly": "Weekly",
"monthly": "Monthly"
"label": "Récurrence de la dépense",
"description": "Sélectionnez la fréquence de répétition de la dépense.",
"none": "Aucune",
"daily": "Quotidienne",
"weekly": "Hebdomadaire",
"monthly": "Mensuelle"
},
"paidFor": {
"title": "Reçu pour",
"description": "Sélectionnez pour qui le revenu a été reçu."
},
"splitModeDescription": "Sélectionnez comment diviser le revenu.",
"attachDescription": "Voir et joindre des reçus au revenu."
"attachDescription": "Voir et joindre des reçus au revenu.",
"currencyField": {
"label": "Devise de la recette",
"description": "La devise dans laquelle le revenu a été reçu."
}
},
"Expense": {
"create": "Créer une dépense",
@@ -168,14 +180,27 @@
"categoryFieldDescription": "Sélectionnez la catégorie de dépense.",
"paidByField": {
"label": "Payé par",
"description": "Sélectionnez le participant qui a réglé la dépense."
"description": "Sélectionnez le participant qui a réglé la dépense.",
"placeholder": "Sélectionner un participant"
},
"recurrenceRule": {
"label": "Récurrence de la dépense",
"description": "Sélectionnez la fréquence de répétition de la dépense.",
"none": "Aucune",
"daily": "Quotidienne",
"weekly": "Hebdomadaire",
"monthly": "Mensuelle"
},
"paidFor": {
"title": "Payé pour",
"description": "Sélectionnez les participants concernés"
"description": "Sélectionnez les participants concernés."
},
"splitModeDescription": "Sélectionnez comment diviser la dépense.",
"attachDescription": "Voir et joindre des reçus à la dépense."
"attachDescription": "Voir et joindre des reçus à la dépense.",
"currencyField": {
"label": "Devise de la dépense",
"description": "La devise dans laquelle la dépense a été payée."
}
},
"amountField": {
"label": "Montant"
@@ -214,7 +239,28 @@
"save": "Sauvegarder",
"saving": "Sauvegarde…",
"cancel": "Annuler",
"reimbursement": "Remboursement"
"reimbursement": "Remboursement",
"conversionUnavailable": "Pour définir une devise différente pour chaque dépense et convertir les montants, sélectionnez une devise non personnalisée pour le groupe.",
"originalAmountField": {
"label": "Montant à convertir"
},
"conversionRateField": {
"useCustom": "Utiliser un taux personnalisé",
"label": "Taux de change",
"useApi": "Utiliser les taux de change de Frankfurter"
},
"conversionRateState": {
"loading": "Obtention des taux de change…",
"success": "Taux obtenus :",
"error": "Oups, nous n'avons pas pu obtenir les taux de change les plus récents.",
"refresh": "Actualiser",
"staleRate": "Taux de change utilisé :",
"noRate": "Saisissez un taux de change personnalisé ci-dessous.",
"currencyNotFound": "Oups, Frankfurter na pas le taux de change pour cette devise à cette date.",
"noDate": "Saisissez la date de la dépense pour obtenir un taux de change.",
"dateMismatch": "Taux de change le {date}",
"customRate": "Utilisation dun taux personnalisé"
}
},
"ExpenseDocumentsInput": {
"TooBigToast": {
@@ -325,7 +371,8 @@
"paidForMin1": "La dépense doit concerner au moins un participant.",
"noZeroShares": "Toutes les parts doivent être supérieures à 0.",
"amountSum": "La somme des montants doit être égale au montant de la dépense.",
"percentageSum": "La somme des pourcentages doit être égale à 100."
"percentageSum": "La somme des pourcentages doit être égale à 100.",
"ratePositive": "Le taux de change doit être strictement supérieur à zéro."
},
"Categories": {
"search": "Rechercher une catégorie…",
@@ -341,7 +388,7 @@
"Games": "Jeux",
"Movies": "Films",
"Music": "Musique",
"Sports": "Sports"
"Sports": "Sport"
},
"Food and Drink": {
"heading": "Nourriture et boissons",
@@ -370,7 +417,8 @@
"Gifts": "Cadeaux",
"Insurance": "Assurance",
"Medical Expenses": "Dépenses médicales",
"Taxes": "Impôts"
"Taxes": "Impôts",
"Donation": "Don"
},
"Transportation": {
"heading": "Transport",
@@ -394,5 +442,18 @@
"TV/Phone/Internet": "TV/Téléphone/Internet",
"Water": "Eau"
}
},
"Currencies": {
"search": "Chercher une devise...",
"noCurrency": "Aucune devise trouvée.",
"custom": {
"heading": "Personnalisée"
},
"common": {
"heading": "Les plus courantes"
},
"other": {
"heading": "Autres devises"
}
}
}
}

451
messages/he.json Normal file
View File

@@ -0,0 +1,451 @@
{
"Homepage": {
"title": "שתף <strong>הוצאות</strong> עם <strong>חברים ומשפחה</strong>",
"description": "ברוך הבא למופע החדש שלך של <strong>Spliit</strong>!",
"button": {
"groups": "עבור לקבוצות",
"github": "GitHub"
}
},
"Header": {
"groups": "קבוצות"
},
"Footer": {
"madeIn": "נבנה במונטריאול, קוויבק 🇨🇦",
"builtBy": "נבנה על ידי <author>סבסטיאן קסטיאל</author> ו<source>תורמים</source>"
},
"Expenses": {
"title": "הוצאות",
"description": "הנה ההוצאות שיצרת עבור הקבוצה שלך.",
"createFirst": "צור את הראשונה",
"create": "צור הוצאה",
"noExpenses": "הקבוצה שלך עדיין לא מכילה הוצאות.",
"export": "ייצא",
"exportJson": "ייצא ל-JSON",
"exportCsv": "ייצא ל-CSV",
"searchPlaceholder": "חפש הוצאה…",
"ActiveUserModal": {
"title": "מי אתה?",
"description": "ספר לנו איזה משתתף אתה כדי לאפשר לנו להתאים אישית את אופן הצגת המידע.",
"nobody": "אני לא רוצה לבחור אף אחד",
"save": "שמור שינויים",
"footer": "הגדרה זו ניתנת לשינוי מאוחר יותר בהגדרות הקבוצה."
},
"Groups": {
"upcoming": "מתקרבות",
"thisWeek": "השבוע",
"earlierThisMonth": "מוקדם יותר החודש",
"lastMonth": "חודש שעבר",
"earlierThisYear": "מוקדם יותר השנה",
"lastYear": "שנה שעברה",
"older": "ישנות יותר"
}
},
"ExpenseCard": {
"paidBy": "שולם על ידי <strong>{paidBy}</strong> עבור <paidFor></paidFor>",
"everyone": "כולם",
"receivedBy": "התקבל על ידי <strong>{paidBy}</strong> עבור <paidFor></paidFor>",
"yourBalance": "היתרה שלך:",
"notInvolved": "אתה לא מעורב"
},
"Groups": {
"myGroups": "הקבוצות שלי",
"create": "צור",
"loadingRecent": "טוען קבוצות אחרונות…",
"NoRecent": {
"description": "לא ביקרת באף קבוצה לאחרונה.",
"create": "צור אחת",
"orAsk": "או בקש מחבר לשלוח לך את הקישור לקבוצה קיימת."
},
"recent": "קבוצות אחרונות",
"starred": "קבוצות מסומנות בכוכב",
"archived": "קבוצות בארכיון",
"archive": "העברת קבוצה לארכיון",
"unarchive": "הוצאת קבוצה מארכיון",
"removeRecent": "הסר מקבוצות אחרונות",
"RecentRemovedToast": {
"title": "הקבוצה הוסרה",
"description": "הקבוצה הוסרה מרשימת הקבוצות האחרונות שלך.",
"undoAlt": "בטל הסרת קבוצה",
"undo": "בטל"
},
"AddByURL": {
"button": "הוסף באמצעות קישור",
"title": "הוסף קבוצה באמצעות קישור",
"description": "אם שיתפו איתך קבוצה, תוכל להדביק את הקישור שלה כאן כדי להוסיף אותה לרשימה שלך.",
"error": "אופס, אנחנו לא מצליחים למצוא את הקבוצה מכתובת ה-URL שסיפקת…"
},
"NotFound": {
"text": "קבוצה זו אינה קיימת.",
"link": "עבור לקבוצות שביקרת בהן לאחרונה"
}
},
"GroupForm": {
"title": "מידע על הקבוצה",
"NameField": {
"label": "שם הקבוצה",
"placeholder": "חופשות קיץ",
"description": "הזן שם לקבוצה שלך."
},
"InformationField": {
"label": "מידע על הקבוצה",
"placeholder": "איזה מידע רלוונטי למשתתפי הקבוצה?"
},
"CurrencyField": {
"label": "סמל מטבע",
"placeholder": "$, €, ₪…",
"description": "נשתמש בו כדי להציג סכומים."
},
"CurrencyCodeField": {
"label": "מטבע ראשי",
"createDescription": "כל הסכומים והיתרות יהיו במטבע זה.",
"editDescription": "כל הסכומים והיתרות יהיו במטבע זה. שינוי של זה לא ימיר הוצאות שכבר הוזנו, אלא אם כן למטבע יש \"יחידות משנה\" שונות מהנוכחיות (למשל, שינוי מדולר אמריקאי לין יפני)",
"customOption": "מותאם אישית"
},
"Participants": {
"title": "משתתפים",
"description": "הזן את השם עבור כל משתתף.",
"protectedParticipant": "משתתף זה לקח חלק בהוצאות, ולא ניתן להסירו.",
"new": "חדש",
"add": "הוסף משתתף",
"John": "אבי",
"Jane": "ריקי",
"Jack": "ג'קי"
},
"Settings": {
"title": "הגדרות מקומיות",
"description": "הגדרות אלה מוגדרות לכל מכשיר בנפרד, ומשמשות להתאמה אישית של החוויה שלך.",
"ActiveUserField": {
"label": "משתמש פעיל",
"placeholder": "בחר משתתף",
"none": "אף אחד",
"description": "משתמש המשמש כברירת מחדל לתשלום הוצאות."
},
"save": "שמור",
"saving": "שומר…",
"create": "צור",
"creating": "יוצר…",
"cancel": "ביטול"
}
},
"ExpenseForm": {
"Income": {
"create": "צור הכנסה",
"edit": "ערוך הכנסה",
"TitleField": {
"label": "כותרת הכנסה",
"placeholder": "מסעדה בערב יום שני",
"description": "הזן תיאור עבור ההכנסה."
},
"DateField": {
"label": "תאריך ההכנסה",
"description": "הזן את התאריך שבו התקבלה ההכנסה."
},
"currencyField": {
"label": "מטבע ההכנסה",
"description": "המטבע שבו התקבלה ההכנסה."
},
"categoryFieldDescription": "בחר את קטגוריית ההכנסה.",
"paidByField": {
"label": "ניתן על ידי",
"description": "בחר את המשתתף שהעביר את ההכנסה."
},
"paidFor": {
"title": "התקבל עבור",
"description": "בחר עבור מי התקבלה ההכנסה."
},
"splitModeDescription": "בחר כיצד לפצל את ההכנסה.",
"attachDescription": "ראה וצרף קבלות להכנסה."
},
"Expense": {
"create": "צור הוצאה",
"edit": "ערוך הוצאה",
"TitleField": {
"label": "כותרת הוצאה",
"placeholder": "מסעדה בערב יום שני",
"description": "הזן תיאור עבור ההוצאה."
},
"DateField": {
"label": "תאריך הוצאה",
"description": "הזן את התאריך בו שולמה ההוצאה."
},
"currencyField": {
"label": "מטבע ההוצאה",
"description": "המטבע שבו שולמה ההוצאה."
},
"categoryFieldDescription": "בחר את קטגוריית ההוצאה.",
"paidByField": {
"label": "שולם על ידי",
"placeholder": "בחר משתתף",
"description": "בחר את המשתתף ששילם את ההוצאה."
},
"recurrenceRule": {
"label": "חזרתיות הוצאה",
"description": "בחר כמה פעמים ההוצאה צריכה לחזור.",
"none": "ללא",
"daily": "יומית",
"weekly": "שבועית",
"monthly": "חודשית"
},
"paidFor": {
"title": "שולם עבור",
"description": "בחר עבור מי שולמה ההוצאה."
},
"splitModeDescription": "בחר כיצד לפצל את ההוצאה.",
"attachDescription": "ראה וצרף קבלות להוצאה."
},
"amountField": {
"label": "סכום"
},
"conversionUnavailable": "כדי להגדיר מטבע שונה לכל הוצאה ולהמיר סכומים, בחר מטבע לא מותאם אישית לקבוצה.",
"originalAmountField": {
"label": "סכום להמרה"
},
"conversionRateField": {
"useApi": "השתמש בשערים מ-Frankfurter",
"useCustom": "השתמש בשער מותאם אישית",
"label": "שער חליפין"
},
"conversionRateState": {
"loading": "משיג שערי חליפין…",
"refresh": "רענן",
"customRate": "משתמש בשער מותאם אישית",
"success": "הושגו שערים:",
"error": "אופס, לא הצלחנו לקבל את השערים העדכניים ביותר.",
"staleRate": "משתמש בשער:",
"noRate": "הזן שער מותאם אישית למטה.",
"currencyNotFound": "אופס, ל-Frankfurter אין את השער עבור מטבע זה ביום הזה.",
"noDate": "הזן את תאריך ההוצאה כדי לקבל את שער החליפין.",
"dateMismatch": "שערים מתאריך: {date}"
},
"isReimbursementField": {
"label": "זהו החזר"
},
"categoryField": {
"label": "קטגוריה"
},
"notesField": {
"label": "הערות"
},
"selectNone": "הסר בחירה",
"selectAll": "בחר הכל",
"shares": "חלק(י) השתתפות",
"advancedOptions": "אפשרויות פיצול מתקדמות…",
"SplitModeField": {
"label": "מצב פיצול",
"evenly": "באופן שווה",
"byShares": "לא שווה לפי חלקי השתתפות",
"byPercentage": "לא שווה לפי אחוזים",
"byAmount": "לא שווה לפי סכום",
"saveAsDefault": "שמור כברירת מחדל עבור אפשרויות פיצול"
},
"DeletePopup": {
"label": "מחק",
"title": "למחוק הוצאה זו?",
"description": "האם אתה באמת רוצה למחוק הוצאה זו? פעולה זו בלתי הפיכה.",
"yes": "כן",
"cancel": "ביטול"
},
"attachDocuments": "צרף מסמכים",
"create": "צור",
"creating": "יוצר…",
"save": "שמור",
"saving": "שומר…",
"cancel": "ביטול",
"reimbursement": "החזר"
},
"ExpenseDocumentsInput": {
"TooBigToast": {
"title": "הקובץ גדול מדי",
"description": "גודל הקובץ המקסימלי שאתה יכול להעלות הוא {maxSize}. הקובץ שלך הוא {size}."
},
"ErrorToast": {
"title": "שגיאה בעת העלאת מסמך",
"description": "משהו השתבש בעת העלאת המסמך. נסה שוב מאוחר יותר או בחר קובץ אחר.",
"retry": "נסה שוב"
}
},
"CreateFromReceipt": {
"Dialog": {
"triggerTitle": "צור הוצאה מקבלה",
"title": "צור מקבלה",
"description": "חלץ את מידע ההוצאה מתמונת קבלה.",
"body": "העלה תמונה של קבלה, ואנחנו נסרוק אותה כדי לחלץ את מידע ההוצאה אם נוכל.",
"selectImage": "בחר תמונה…",
"titleLabel": "כותרת:",
"categoryLabel": "קטגוריה:",
"amountLabel": "סכום:",
"dateLabel": "תאריך:",
"editNext": "תוכל לערוך את מידע ההוצאה מאוחר יותר.",
"continue": "המשך"
},
"unknown": "לא ידוע",
"TooBigToast": {
"title": "הקובץ גדול מדי",
"description": "גודל הקובץ המקסימלי שאתה יכול להעלות הוא {maxSize}. הקובץ שלך הוא {size}."
},
"ErrorToast": {
"title": "שגיאה בעת העלאת מסמך",
"description": "משהו השתבש בעת העלאת המסמך. נסה שוב מאוחר יותר או בחר קובץ אחר.",
"retry": "נסה שוב"
}
},
"Balances": {
"title": "יתרות",
"description": "זה הסכום שכל משתתף שילם או קיבל.",
"Reimbursements": {
"title": "החזרים מוצעים",
"description": "הנה הצעות להחזרים אופטימליים בין משתתפים.",
"noImbursements": "נראה שהקבוצה שלך לא צריכה החזרים 😁",
"owes": "<strong>{from}</strong> חייב ל<strong>{to}</strong>",
"markAsPaid": "סמן כשולם"
}
},
"Stats": {
"title": "סטטיסטיקות",
"Totals": {
"title": "סיכומים",
"description": "סיכום הוצאות של כל הקבוצה.",
"groupSpendings": "סך הוצאות הקבוצה",
"groupEarnings": "סך הכנסות הקבוצה",
"yourSpendings": "סך ההוצאות שלך",
"yourEarnings": "סך ההכנסות שלך",
"yourShare": "סך החלק שלך"
}
},
"Activity": {
"title": "פעילות",
"description": "סקירה של כל הפעילות בקבוצה זו.",
"noActivity": "עדיין אין פעילות בקבוצה שלך.",
"someone": "מישהו",
"settingsModified": "הגדרות הקבוצה שונו על ידי <strong>{participant}</strong>.",
"expenseCreated": "הוצאה <em>{expense}</em> נוצרה על ידי <strong>{participant}</strong>.",
"expenseUpdated": "הוצאה <em>{expense}</em> עודכנה על ידי <strong>{participant}</strong>.",
"expenseDeleted": "הוצאה <em>{expense}</em> נמחקה על ידי <strong>{participant}</strong>.",
"Groups": {
"today": "היום",
"yesterday": "אתמול",
"earlierThisWeek": "מוקדם יותר השבוע",
"lastWeek": "שבוע שעבר",
"earlierThisMonth": "מוקדם יותר החודש",
"lastMonth": "חודש שעבר",
"earlierThisYear": "מוקדם יותר השנה",
"lastYear": "שנה שעברה",
"older": "ישנות יותר"
}
},
"Information": {
"title": "מידע",
"description": "השתמש במקום זה כדי להוסיף כל מידע שיכול להיות רלוונטי למשתתפי הקבוצה.",
"empty": "עדיין אין מידע על הקבוצה."
},
"Settings": {
"title": "הגדרות"
},
"Share": {
"title": "שתף",
"description": "כדי שמשתתפים אחרים יראו את הקבוצה ויוסיפו הוצאות, שתף איתם את הקישור שלה.",
"warning": "אזהרה!",
"warningHelp": "כל אדם עם קישור לקבוצה יוכל לראות ולערוך הוצאות. שתף בזהירות!"
},
"SchemaErrors": {
"min1": "הזן לפחות תו אחד.",
"min2": "הזן לפחות שני תווים.",
"max5": "הזן חמישה תווים לכל היותר.",
"max50": "הזן 50 תווים לכל היותר.",
"duplicateParticipantName": "קיים משתתף אחר בעל שם זהה.",
"titleRequired": "נא להזין כותרת.",
"invalidNumber": "מספר לא תקין.",
"amountRequired": "עליך להזין סכום.",
"amountNotZero": "הסכום לא יכול להיות אפס.",
"amountTenMillion": "הסכום חייב להיות נמוך מ-10,000,000.",
"ratePositive": "השער חייב להיות גדול מאפס באופן קפדני.",
"paidByRequired": "עליך לבחור משתתף.",
"paidForMin1": "ההוצאה חייבת להיות משולמת עבור לפחות משתתף אחד.",
"noZeroShares": "כל חלקי ההשתתפות חייבים להיות גבוהים מ-0.",
"amountSum": "סיכום הסכומים חייב להיות שווה לסכום ההוצאה.",
"percentageSum": "סיכום האחוזים חייב להיות שווה ל-100."
},
"Categories": {
"search": "חפש קטגוריה...",
"noCategory": "לא נמצאה קטגוריה.",
"Uncategorized": {
"heading": "לא מסווג",
"General": "כללי",
"Payment": "תשלום"
},
"Entertainment": {
"heading": "בידור",
"Entertainment": "בידור",
"Games": "משחקים",
"Movies": "סרטים",
"Music": "מוזיקה",
"Sports": "ספורט"
},
"Food and Drink": {
"heading": "אוכל ומשקאות",
"Food and Drink": "אוכל ומשקאות",
"Dining Out": "אכילה בחוץ",
"Groceries": "מצרכים",
"Liquor": "משקאות חריפים"
},
"Home": {
"heading": "בית",
"Home": "בית",
"Electronics": "אלקטרוניקה",
"Furniture": "ריהוט",
"Household Supplies": "ציוד ביתי",
"Maintenance": "תחזוקה",
"Mortgage": "משכנתא",
"Pets": "חיות מחמד",
"Rent": "שכירות",
"Services": "שירות(ים)"
},
"Life": {
"heading": "חיים",
"Childcare": "טיפול בילדים",
"Clothing": "ביגוד",
"Donation": "תרומה",
"Education": "חינוך",
"Gifts": "מתנות",
"Insurance": "ביטוח",
"Medical Expenses": "הוצאות רפואיות",
"Taxes": "מסים"
},
"Transportation": {
"heading": "תחבורה",
"Transportation": "תחבורה",
"Bicycle": "אופניים",
"Bus/Train": "אוטובוס/רכבת",
"Car": "רכב",
"Gas/Fuel": "דלק/בנזין",
"Hotel": "מלון",
"Parking": "חניה",
"Plane": "מטוס",
"Taxi": "מונית"
},
"Utilities": {
"heading": "שירותים",
"Utilities": "שירותים",
"Cleaning": "ניקיון",
"Electricity": "חשמל",
"Heat/Gas": "חימום/גז",
"Trash": "פסולת",
"TV/Phone/Internet": "תקשורת",
"Water": "מים"
}
},
"Currencies": {
"search": "חפש מטבע...",
"noCurrency": "לא נמצאו מטבעות.",
"custom": {
"heading": "מותאם אישית"
},
"common": {
"heading": "הנפוצים ביותר"
},
"other": {
"heading": "מטבעות אחרים"
}
}
}

451
messages/id.json Normal file
View File

@@ -0,0 +1,451 @@
{
"Homepage": {
"title": "Bagikan <strong>Expenses</strong> dengan <strong>Teman & Keluarga</strong>",
"description": "Selamat datang di <strong>Spliit</strong> instance yang baru !",
"button": {
"groups": "Ke grup",
"github": "GitHub"
}
},
"Header": {
"groups": "Grup"
},
"Footer": {
"madeIn": "Dibuat di Montréal, Québec 🇨🇦",
"builtBy": "Dibuat oleh <author>Sebastien Castiel</author> dan <source>kontributor lainnya</source>"
},
"Expenses": {
"title": "Pengeluaran",
"description": "Berikut adalah pengeluaran yang Anda buat untuk grup Anda.",
"create": "Buat pengeluaran",
"createFirst": "Buat yang pertama",
"noExpenses": "Grup Anda belum memiliki pengeluaran apa pun.",
"export": "Ekspor",
"exportJson": "Ekspor ke JSON",
"exportCsv": "Ekspor ke CSV",
"searchPlaceholder": "Cari pengeluaran…",
"ActiveUserModal": {
"title": "Siapa nama anda?",
"description": "Beritahu kami nama Anda agar kami dapat menyesuaikan cara informasi ditampilkan.",
"nobody": "Saya tidak ingin memilih siapa pun",
"save": "Simpan perubahan",
"footer": "Pengaturan ini dapat diubah nanti di pengaturan grup."
},
"Groups": {
"upcoming": "Mendatang",
"thisWeek": "Minggu ini",
"earlierThisMonth": "Awal bulan ini",
"lastMonth": "Bulan lalu",
"earlierThisYear": "Awal tahun ini",
"lastYear": "Tahun lalu",
"older": "Lebih tua"
}
},
"ExpenseCard": {
"paidBy": "Dibayar oleh <strong>{paidBy}</strong> untuk <paidFor></paidFor>",
"everyone": "semua orang",
"receivedBy": "Diterima oleh <strong>{paidBy}</strong> untuk <paidFor></paidFor>",
"yourBalance": "Saldo Anda:",
"notInvolved": "Anda tidak terlibat"
},
"Groups": {
"myGroups": "Grup saya",
"create": "Buat",
"loadingRecent": "Memuat grup terbaru…",
"NoRecent": {
"description": "Anda belum mengunjungi grup mana pun baru-baru ini.",
"create": "Buat",
"orAsk": "atau minta teman untuk mengirimi Anda tautan ke situs yang sudah ada."
},
"recent": "Grup terbaru",
"starred": "Grup berbintang",
"archived": "Grup terarsipkan",
"archive": "Arsipkan grup",
"unarchive": "Batalkan pengarsipan grup",
"removeRecent": "Hapus dari grup terbaru",
"RecentRemovedToast": {
"title": "Grup telah dihapus",
"description": "Grup ini telah dihapus dari daftar grup terbaru Anda.",
"undoAlt": "Urungkan penghapusan grup",
"undo": "Urungkan"
},
"AddByURL": {
"button": "Tambahkan melalui URL",
"title": "Tambahkan grup melalui URL",
"description": "Jika suatu grup dibagikan kepada Anda, Anda dapat menempelkan URL-nya di sini untuk menambahkannya ke daftar Anda.",
"error": "Ups, kami tidak dapat menemukan grup dari URL yang Anda berikan…"
},
"NotFound": {
"text": "Grup ini tidak dapat ditemukan.",
"link": "Buka grup yang baru saja dikunjungi"
}
},
"GroupForm": {
"title": "Informasi grup",
"NameField": {
"label": "Nama grup",
"placeholder": "Liburan sekolah",
"description": "Masukkan nama untuk grup Anda."
},
"InformationField": {
"label": "Informasi grup",
"placeholder": "Informasi apa yang relevan bagi peserta kelompok?"
},
"CurrencyField": {
"label": "Simbol mata uang",
"placeholder": "$, €, £…",
"description": "Kita akan menggunakannya untuk menampilkan jumlah."
},
"CurrencyCodeField": {
"label": "Mata uang utama",
"createDescription": "Semua jumlah dan saldo akan dalam mata uang ini.",
"editDescription": "Semua jumlah dan saldo akan menggunakan mata uang ini. Mengubah mata uang ini TIDAK akan mengonversi pengeluaran yang sudah dimasukkan, kecuali jika mata uang tersebut memiliki \"unit minor\" yang berbeda dari mata uang saat ini (misalnya, berubah dari Dolar AS ke Yen Jepang)",
"customOption": "Kustom"
},
"Participants": {
"title": "Peserta",
"description": "Masukkan nama untuk setiap peserta.",
"protectedParticipant": "Peserta ini adalah bagian dari pengeluaran, dan tidak dapat dihapus.",
"new": "Baru",
"add": "Tambahkan peserta",
"John": "John",
"Jane": "Janet",
"Jack": "Jacksen"
},
"Settings": {
"title": "Pengaturan lokal",
"description": "Pengaturan ini ditetapkan per perangkat, dan digunakan untuk menyesuaikan pengalaman Anda.",
"ActiveUserField": {
"label": "Pengguna aktif",
"placeholder": "Pilih peserta",
"none": "Tidak ada",
"description": "Pengguna digunakan sebagai default untuk membayar pengeluaran."
},
"save": "Simpan",
"saving": "Meyimpan…",
"create": "Buat",
"creating": "Membuat…",
"cancel": "Batalkan"
}
},
"ExpenseForm": {
"Income": {
"create": "Buat pemasukan",
"edit": "Ubah pemasukan",
"TitleField": {
"label": "Judul pemasukan",
"placeholder": "Restoran Senin malam",
"description": "Masukkan deskripsi untuk pemasukan."
},
"DateField": {
"label": "Tanggal pemasukan",
"description": "Masukkan tanggal penerimaan pemasukan."
},
"currencyField": {
"label": "Mata uang pemasukan",
"description": "Mata uang di mana pemasukan diterima."
},
"categoryFieldDescription": "Pilih kategori pemasukan.",
"paidByField": {
"label": "Diterima oleh",
"description": "Pilih peserta yang menerima pemasukan."
},
"paidFor": {
"title": "Diterima untuk",
"description": "Pilih untuk siapa pemasukan tersebut diterima."
},
"splitModeDescription": "Pilih cara membagi pemasukan.",
"attachDescription": "Lihat dan lampirkan tanda terima pada pemasukan."
},
"Expense": {
"create": "Buat pengeluaran",
"edit": "Ubah pengeluaran",
"TitleField": {
"label": "Judul pengeluaran",
"placeholder": "Restoran Senin malam",
"description": "Masukkan deskripsi untuk pengeluaran."
},
"DateField": {
"label": "Tanggal pengeluaran",
"description": "Masukkan tanggal pembayaran pengeluaran."
},
"currencyField": {
"label": "Mata uang pengeluaran",
"description": "Mata uang yang digunakan untuk membayar pengeluaran tersebut."
},
"categoryFieldDescription": "Pilih kategori pengeluaran.",
"paidByField": {
"label": "Dibayar oleh",
"placeholder": "Pilih peserta",
"description": "Pilih peserta yang membayar pengeluaran."
},
"recurrenceRule": {
"label": "Pengeluaran Berulang",
"description": "Pilih seberapa sering pengeluaran harus diulang.",
"none": "Tidak ada",
"daily": "Setiap hari",
"weekly": "Setiap minggu",
"monthly": "Setiap bulan"
},
"paidFor": {
"title": "Dibayar oleh",
"description": "Pilih untuk siapa biaya tersebut dibayarkan."
},
"splitModeDescription": "Pilih cara membagi pengeluaran.",
"attachDescription": "Lihat dan lampirkan tanda terima pada pengeluaran."
},
"amountField": {
"label": "Jumlah"
},
"conversionUnavailable": "Untuk menetapkan mata uang yang berbeda per pengeluaran dan mengonversi jumlahnya, pilih mata uang non-kustom untuk grup tersebut.",
"originalAmountField": {
"label": "Jumlah untuk dikonversi"
},
"conversionRateField": {
"useApi": "Gunakan kurs dari Frankfurter",
"useCustom": "Gunakan kurs kustom",
"label": "Kurs"
},
"conversionRateState": {
"loading": "Mendapatkan kurs…",
"success": "Kurs yang didapatkan:",
"error": "Ups, kami tidak bisa mendapatkan kurs terkini.",
"staleRate": "Menggunakan kurs:",
"noRate": "Masukkan kurs kustom di bawah ini.",
"currencyNotFound": "Ups, Frankfurter tidak memiliki kurs untuk mata uang ini pada hari ini.",
"noDate": "Masukkan tanggal pengeluaran untuk mendapatkan kurs.",
"dateMismatch": "Kurs pada tanggal: {date}",
"refresh": "Muat ulang",
"customRate": "Menggunakan kurs kustom"
},
"isReimbursementField": {
"label": "Ini adalah reimburse"
},
"categoryField": {
"label": "Kategori"
},
"notesField": {
"label": "Catatan"
},
"selectNone": "Pilih tidak ada",
"selectAll": "Pilih semua",
"shares": "porsi",
"advancedOptions": "Opsi lanjutan pembagian…",
"SplitModeField": {
"label": "Mode pembagian",
"evenly": "Bagi merata",
"byShares": "Tidak merata Berdasarkan porsi",
"byPercentage": "Tidak merata Berdasarkan persenan",
"byAmount": "Tidak merata Berdasarkan jumlah",
"saveAsDefault": "Simpan sebagai opsi pembagian default"
},
"DeletePopup": {
"label": "Hapus",
"title": "Hapus pengeluaran?",
"description": "Apakah Anda yakin ingin menghapus pengeluaran ini? Tindakan ini tidak dapat dibatalkan.",
"yes": "Iya",
"cancel": "Batalkan"
},
"attachDocuments": "Lampirkan dokumen",
"create": "Buat",
"creating": "Membuat…",
"save": "Simpan",
"saving": "Menyimpan…",
"cancel": "Batalkan",
"reimbursement": "Reimburse"
},
"Currencies": {
"custom": {
"heading": "Kustom"
},
"search": "Cari mata uang...",
"noCurrency": "Tidak ada mata uang yang ditemukan.",
"common": {
"heading": "Paling umum"
},
"other": {
"heading": "Mata uang lainnya"
}
},
"ExpenseDocumentsInput": {
"TooBigToast": {
"title": "Ukuran file terlalu besar",
"description": "Ukuran file maksimum yang dapat Anda unggah adalah {maxSize}. Ukuran file Anda adalah {size}."
},
"ErrorToast": {
"title": "Error saat mengunggah dokumen",
"description": "Terjadi kesalahan saat mengunggah dokumen. Silakan coba lagi nanti atau pilih file lain.",
"retry": "Ulangi"
}
},
"CreateFromReceipt": {
"Dialog": {
"triggerTitle": "Buat pengeluaran dari tanda terima",
"title": "Buat dari tanda terima",
"description": "Ekstrak informasi pengeluaran dari foto tanda terima.",
"body": "Unggah foto struk, dan kami akan memindainya untuk mengekstrak informasi pengeluaran jika memungkinkan.",
"selectImage": "Pilih gambar…",
"titleLabel": "Judul:",
"categoryLabel": "Kategori:",
"amountLabel": "Jumlah:",
"dateLabel": "Tanggal:",
"editNext": "Anda dapat mengedit informasi pengeluaran berikutnya.",
"continue": "Lanjutkan"
},
"unknown": "Tidak diketahui",
"TooBigToast": {
"title": "File terlalu besar",
"description": "Ukuran file maksimum yang dapat Anda unggah adalah {maxSize}. Ukuran file Anda adalah {size}."
},
"ErrorToast": {
"title": "Error saat mengunggah dokumen",
"description": "Terjadi kesalahan saat mengunggah dokumen. Silakan coba lagi nanti atau pilih file lain.",
"retry": "Ulangi"
}
},
"Balances": {
"title": "Saldo",
"description": "Ini adalah jumlah yang dibayarkan atau yang diterima oleh setiap peserta.",
"Reimbursements": {
"title": "Saran reimburse",
"description": "Berikut adalah saran untuk reimburse yang optimal antara peserta.",
"noImbursements": "Sepertinya grup Anda tidak memerlukan reimburse apa pun 😁",
"owes": "<strong>{from}</strong> berutang <strong>{to}</strong>",
"markAsPaid": "Tandai sebagai telah dibayar"
}
},
"Stats": {
"title": "Statistik",
"Totals": {
"title": "Total",
"description": "Ringkasan pengeluaran seluruh grup.",
"groupSpendings": "Total pengeluaran grup",
"groupEarnings": "Total pemasukan grup",
"yourSpendings": "Total pengeluaran Anda",
"yourEarnings": "Total pemasukan Anda",
"yourShare": "Total bagian Anda"
}
},
"Activity": {
"title": "Aktivitas",
"description": "Overview semua aktivitas dalam grup ini.",
"noActivity": "Belum ada aktivitas di grup Anda.",
"someone": "Seseorang",
"settingsModified": "Pengaturan grup diubah oleh <strong>{participant}</strong>.",
"expenseCreated": "Pengeluaran <em>{expense}</em> dibuat oleh <strong>{participant}</strong>.",
"expenseUpdated": "Pengeluaran <em>{expense}</em> diperbarui oleh <strong>{participant}</strong>.",
"expenseDeleted": "Pengeluaran <em>{expense}</em> dihapus oleh <strong>{participant}</strong>.",
"Groups": {
"today": "Hari ini",
"yesterday": "Kemarin",
"earlierThisWeek": "Awal minggu ini",
"lastWeek": "Minggu lalu",
"earlierThisMonth": "Awal bulan ini",
"lastMonth": "Bulan lalu",
"earlierThisYear": "Awal tahun ini",
"lastYear": "Tahun lalu",
"older": "Lebih lama"
}
},
"Information": {
"title": "Informasi",
"description": "Gunakan tempat ini untuk menambahkan informasi apa pun yang relevan bagi peserta grup.",
"empty": "Belum ada informasi grup."
},
"Settings": {
"title": "Pengaturan"
},
"Share": {
"title": "Bagikan",
"description": "Agar peserta lain dapat melihat grup dan menambahkan pengeluaran, bagikan URL-nya dengan mereka.",
"warning": "Peringatan!",
"warningHelp": "Setiap orang yang memiliki URL grup akan dapat melihat dan mengedit pengeluaran. Bagikan dengan hati-hati!"
},
"SchemaErrors": {
"min1": "Masukkan setidaknya satu karakter.",
"min2": "Masukkan setidaknya dua karakter.",
"max5": "Masukkan maksimal lima karakter.",
"max50": "Masukkan maksimal 50 karakter.",
"duplicateParticipantName": "Peserta lain sudah menggunakan nama ini.",
"titleRequired": "Silakan masukkan judul.",
"invalidNumber": "Nomor tidak valid.",
"amountRequired": "Anda harus memasukkan jumlah.",
"amountNotZero": "Jumlahnya tidak boleh nol.",
"amountTenMillion": "Jumlahnya harus kurang dari 10.000.000.",
"ratePositive": "Angkanya harus lebih besar dari nol.",
"paidByRequired": "Anda harus memilih peserta.",
"paidForMin1": "Pengeluaran harus dibayarkan untuk setidaknya satu peserta.",
"noZeroShares": "Semua porsi harus lebih tinggi dari 0.",
"amountSum": "Total harus sama dengan jumlah pengeluaran.",
"percentageSum": "Total persentase harus sama dengan 100."
},
"Categories": {
"search": "Cari kategori...",
"noCategory": "Tidak ada kategori yang ditemukan.",
"Uncategorized": {
"heading": "Tidak Berkategori",
"General": "Umum",
"Payment": "Pembayaran"
},
"Entertainment": {
"heading": "Hiburan",
"Entertainment": "Hiburan",
"Games": "Game",
"Movies": "Film",
"Music": "Musik",
"Sports": "Olah Raga"
},
"Food and Drink": {
"heading": "Makanan dan Minuman",
"Food and Drink": "Makanan dan Minuman",
"Dining Out": "Makan luar",
"Groceries": "Belanja",
"Liquor": "Minuman Keras"
},
"Home": {
"heading": "Rumah",
"Home": "Rumah",
"Electronics": "Elektronik",
"Furniture": "Furnitur",
"Household Supplies": "Kebutuhan rumah",
"Maintenance": "Perawatan rumah",
"Mortgage": "Cicilan",
"Pets": "Binatang peliharaan",
"Rent": "Sewa",
"Services": "Servis"
},
"Life": {
"heading": "Kehidupan",
"Childcare": "Anak-anak",
"Clothing": "Pakaian",
"Donation": "Donasi",
"Education": "Edukasi",
"Gifts": "Hadiah",
"Insurance": "Asuransi",
"Medical Expenses": "Kesehatan",
"Taxes": "Pajak"
},
"Transportation": {
"heading": "Transportasi",
"Transportation": "Transpotrasi",
"Bicycle": "Sepeda",
"Bus/Train": "Bis/Kereta",
"Car": "Mobil",
"Gas/Fuel": "Bensin",
"Hotel": "Hotel",
"Parking": "Parkir",
"Plane": "Pesawat",
"Taxi": "Taksi"
},
"Utilities": {
"heading": "Utilitas",
"Utilities": "Utilitas",
"Cleaning": "Kebersihan",
"Electricity": "Listrik",
"Heat/Gas": "Gas",
"Trash": "Sampah",
"TV/Phone/Internet": "TV/HP/Internet",
"Water": "Air"
}
}
}

View File

@@ -1,7 +1,7 @@
{
"Homepage": {
"title": "Condividi <strong>Spese</strong> con <strong>Amici & Familiari</strong>",
"description": "Benvenuto nella tua nuova instanza di <strong>Spliit</strong>!",
"description": "Benvenuto nella tua nuova installazione di <strong>Spliit</strong>!",
"button": {
"groups": "Vai ai gruppi",
"github": "GitHub"
@@ -12,7 +12,7 @@
},
"Footer": {
"madeIn": "Realizzato a Montréal, Québec 🇨🇦",
"builtBy": "Costruito da <author>Sebastien Castiel</author> e <source>contributori</source>"
"builtBy": "Sviluppato da <author>Sebastien Castiel</author> e <source>contributori</source>"
},
"Expenses": {
"title": "Spese",
@@ -38,12 +38,15 @@
"earlierThisYear": "All'inizio di quest'anno",
"lastYear": "Ultimo anno",
"older": "Più vecchio"
}
},
"export": "Esporta"
},
"ExpenseCard": {
"paidBy": "Pagato da <strong>{paidBy}</strong> per <paidFor></paidFor>",
"receivedBy": "Ricevuto da <strong>{paidBy}</strong> per <paidFor></paidFor>",
"yourBalance": "Il tuo bilancio:"
"yourBalance": "Il tuo saldo:",
"notInvolved": "Non sei coinvolto",
"everyone": "tutti"
},
"Groups": {
"myGroups": "I miei gruppi",
@@ -51,8 +54,8 @@
"loadingRecent": "Caricamento gruppi recenti…",
"NoRecent": {
"description": "Non hai visitato nessun gruppo di recente.",
"create": "Creane una",
"orAsk": "oppure chiedi a un amico di inviarti il collegamento a uno esistente."
"create": "Creane uno",
"orAsk": "oppure chiedi a un amico di inviarti il link a uno esistente."
},
"recent": "Gruppi recenti",
"starred": "Gruppi speciali",
@@ -70,7 +73,7 @@
"button": "Aggiungi tramite URL",
"title": "Aggiungi un gruppo tramite URL",
"description": "Se un gruppo è stato condiviso con te, puoi incollare qui il suo URL per aggiungerlo al tuo elenco.",
"error": "Spiacenti, non siamo in grado di trovare il gruppo dall'URL che hai fornito..."
"error": "Oops, non siamo riusciti a trovare il gruppo dall'URL che hai fornito"
},
"NotFound": {
"text": "Questo gruppo non esiste.",
@@ -132,50 +135,58 @@
"label": "Data entrata",
"description": "Inserisci la data in cui è stato ricevuta l'entrata."
},
"categoryFieldDescription": "Seleziona categoria entrata.",
"categoryFieldDescription": "Seleziona la categoria dell'entrata.",
"paidByField": {
"label": "Ricevuto da",
"description": "Seleziona partecipante che ha ricevuto l'entrata."
"label": "Ricevuta da",
"description": "Seleziona il partecipante che ha ricevuto l'entrata."
},
"recurrenceRule": {
"label": "Expense Recurrence",
"description": "Select how often the expense should repeat.",
"none": "None",
"daily": "Daily",
"weekly": "Weekly",
"monthly": "Monthly"
"label": "Spesa ricorrente",
"description": "Seleziona quanto spesso deve ripetersi.",
"none": "Mai",
"daily": "Giornaliera",
"weekly": "Settimanale",
"monthly": "Mensile"
},
"paidFor": {
"title": "Ricevuto per",
"description": "Seleziona per chi è stato ricevuta l'entrata."
"description": "Seleziona per chi è stato ricevuto il reddito."
},
"splitModeDescription": "Seleziona come dividere l'entrata.",
"attachDescription": "Vedi allegati entrata."
"attachDescription": "Vedi ed allega la ricevuta per l'entrata."
},
"Expense": {
"create": "Crea spesa",
"edit": "Modifica spesa",
"edit": "Edita spesa",
"TitleField": {
"label": "Titolo spesa",
"label": "Titolo Spesa",
"placeholder": "Ristorante del lunedì sera",
"description": "Inserisci una descrizione per l'uscita."
"description": "Inserisci una descrizione per la spesa."
},
"DateField": {
"label": "Data spesa",
"description": "Inserisci la data di quando è stata fatta la spesa"
"description": "Inserisci la data in cui si è svolta la spesa."
},
"categoryFieldDescription": "Seleziona una categoria per la spesa.",
"categoryFieldDescription": "Seleziona la categoria della spesa.",
"paidByField": {
"label": "Pagato da",
"description": "Seleziona il partecipante che ha pagato la spesa."
"description": "Seleziona il partecipante che ha pagato la spesa.",
"placeholder": "Seleziona un partecipante"
},
"recurrenceRule": {
"label": "Spesa ricorrente",
"description": "Seleziona quanto spesso deve ripetersi.",
"none": "Mai",
"daily": "Giornaliera",
"weekly": "Settimanale",
"monthly": "Mensile"
},
"paidFor": {
"title": "Pagato per",
"description": "Seleziona per chi è stata pagato."
"description": "Seleleziona per chi è stato pagato."
},
"splitModeDescription": "Seleziona come dividere la spesa.",
"attachDescription": "Vedi allegati spesa."
"attachDescription": "Vedi ed allega la ricevuta per la spesa."
},
"amountField": {
"label": "Importo"
@@ -214,7 +225,10 @@
"save": "Salva",
"saving": "Sto salvando…",
"cancel": "Annulla",
"reimbursement": "Rimborso"
"reimbursement": "Rimborso",
"conversionRateState": {
"refresh": "Aggiornare"
}
},
"ExpenseDocumentsInput": {
"TooBigToast": {
@@ -318,7 +332,7 @@
"duplicateParticipantName": "Un altro partecipante ha già questo nome.",
"titleRequired": "Inserisci un titolo.",
"invalidNumber": "Numero invalido.",
"amountRequired": "Devi inserire un importo",
"amountRequired": "Devi inserire un importo.",
"amountNotZero": "L'importo non deve essere zero.",
"amountTenMillion": "L'importo deve essere inferiore a 10.000.000.",
"paidByRequired": "È necessario selezionare un partecipante.",
@@ -351,11 +365,11 @@
"Liquor": "Liquori"
},
"Home": {
"heading": "Home",
"Home": "Home",
"Electronics": "Elettronica",
"Furniture": "Mobilia",
"Household Supplies": "Forniture per la casa",
"heading": "Casa",
"Home": "Casa",
"Electronics": "Elettronica di consumo",
"Furniture": "Mobili",
"Household Supplies": "Prodotti per la casa",
"Maintenance": "Manutenzione",
"Mortgage": "Mutuo",
"Pets": "Animali",
@@ -363,12 +377,13 @@
"Services": "Servizi"
},
"Life": {
"heading": "Life",
"Childcare": "Assistenza all'infanzia",
"Clothing": "Vestiti",
"heading": "Vita",
"Childcare": "Cura dei bambini",
"Clothing": "Abbigliamento",
"Donation": "Donazioni",
"Education": "Istruzione",
"Gifts": "Regali",
"Insurance": "Assicurazione",
"Insurance": "Assicurazioni",
"Medical Expenses": "Spese Mediche",
"Taxes": "Tasse"
},
@@ -385,14 +400,14 @@
"Taxi": "Taxi"
},
"Utilities": {
"heading": "Utilità",
"Utilities": "Utilità",
"Cleaning": "Pulizia",
"heading": "Utenze",
"Utilities": "Utenze",
"Cleaning": "Pulizie",
"Electricity": "Elettricità",
"Heat/Gas": "Riscaldamento/Gas",
"Trash": "Spazzatura",
"Trash": "Rifiuti",
"TV/Phone/Internet": "TV/Telefono/Internet",
"Water": "Acqua"
}
}
}
}

451
messages/ja-JP.json Normal file
View File

@@ -0,0 +1,451 @@
{
"Homepage": {
"title": "友達や家族と<strong>費用</strong>を<strong>分担</strong>しよう",
"description": "新しい<strong>Spliit</strong>インスタンスへようこそ!",
"button": {
"groups": "グループへ移動",
"github": "GitHub"
}
},
"Header": {
"groups": "グループ"
},
"Footer": {
"madeIn": "モントリオール、ケベック 🇨🇦 で製作",
"builtBy": "<author>Sebastien Castiel</author>と<source>貢献者</source>によって構築"
},
"Expenses": {
"title": "支出",
"description": "グループのために作成した支出はこちらです。",
"create": "支出を作成",
"createFirst": "最初の支出を作成",
"noExpenses": "グループにはまだ支出がありません。",
"export": "エクスポート",
"exportJson": "JSONにエクスポート",
"exportCsv": "CSVにエクスポート",
"searchPlaceholder": "支出を検索…",
"ActiveUserModal": {
"title": "あなたは誰ですか?",
"description": "情報の表示方法をカスタマイズするために、あなたがどの参加者かを教えてください。",
"nobody": "誰も選択したくありません",
"save": "変更を保存",
"footer": "この設定は後でグループ設定で変更できます。"
},
"Groups": {
"upcoming": "今後",
"thisWeek": "今週",
"earlierThisMonth": "今月初め",
"lastMonth": "先月",
"earlierThisYear": "今年初め",
"lastYear": "昨年",
"older": "それ以前"
}
},
"ExpenseCard": {
"paidBy": "<strong>{paidBy}</strong>が<paidFor></paidFor>のために支払いました",
"receivedBy": "<strong>{paidBy}</strong>が<paidFor></paidFor>のために受け取りました",
"yourBalance": "あなたの残高:",
"everyone": "全員",
"notInvolved": "あなたは関係していません"
},
"Groups": {
"myGroups": "マイグループ",
"create": "作成",
"loadingRecent": "最近のグループをロード中…",
"NoRecent": {
"description": "最近訪れたグループはありません。",
"create": "グループを作成する",
"orAsk": "または友達に既存のグループへのリンクを送ってもらいましょう。"
},
"recent": "最近のグループ",
"starred": "スター付きグループ",
"archived": "アーカイブ済みグループ",
"archive": "グループをアーカイブ",
"unarchive": "グループのアーカイブを解除",
"removeRecent": "最近のグループから削除",
"RecentRemovedToast": {
"title": "グループが削除されました",
"description": "グループは最近のグループリストから削除されました。",
"undoAlt": "削除を元に戻す",
"undo": "元に戻す"
},
"AddByURL": {
"button": "URLで追加",
"title": "URLでグループを追加",
"description": "グループが共有された場合、そのURLをここに貼り付けてリストに追加できます。",
"error": "あら、提供されたURLからグループを見つけることができませんでした…"
},
"NotFound": {
"text": "このグループは存在しません。",
"link": "最近訪れたグループへ移動"
}
},
"GroupForm": {
"title": "グループ情報",
"NameField": {
"label": "グループ名",
"placeholder": "夏休み",
"description": "グループの名前を入力してください。"
},
"InformationField": {
"label": "グループ情報",
"placeholder": "グループ参加者に関連する情報は何ですか?"
},
"CurrencyField": {
"label": "通貨記号",
"placeholder": "$, €, £…",
"description": "金額の表示に使用します。"
},
"Participants": {
"title": "参加者",
"description": "各参加者の名前を入力してください。",
"protectedParticipant": "この参加者は支出の一部であり、削除できません。",
"new": "新規",
"add": "参加者を追加",
"John": "一郎",
"Jane": "花子",
"Jack": "太郎"
},
"Settings": {
"title": "ローカル設定",
"description": "これらの設定はデバイスごとに設定され、あなたの体験をカスタマイズするために使用されます。",
"ActiveUserField": {
"label": "アクティブユーザー",
"placeholder": "参加者を選択",
"none": "なし",
"description": "支出の支払いのデフォルトとして使用されるユーザー。"
},
"save": "保存",
"saving": "保存中…",
"create": "作成",
"creating": "作成中…",
"cancel": "キャンセル"
},
"CurrencyCodeField": {
"label": "主な通貨",
"createDescription": "すべての金額および残高はこの通貨で表示されます。",
"editDescription": "すべての金額および残高はこの通貨で表示されます。これを変更しても、既に入力された支出は変換されません。ただし、現在の通貨と変更先の通貨で小数単位(例:米ドルから日本円へ)の扱いが異なる場合は例外です",
"customOption": "カスタム"
}
},
"ExpenseForm": {
"Income": {
"create": "収入を作成",
"edit": "収入を編集",
"TitleField": {
"label": "収入タイトル",
"placeholder": "月曜日の夕食レストラン",
"description": "収入の説明を入力してください。"
},
"DateField": {
"label": "収入日",
"description": "収入を受け取った日付を入力してください。"
},
"categoryFieldDescription": "収入カテゴリーを選択してください。",
"paidByField": {
"label": "受取人",
"description": "収入を受け取った参加者を選択してください。"
},
"paidFor": {
"title": "受け取り対象",
"description": "誰のために収入が受け取られたかを選択してください。"
},
"splitModeDescription": "収入の分割方法を選択してください。",
"attachDescription": "領収書を確認し、収入に添付してください。",
"currencyField": {
"label": "収入の通貨",
"description": "収入が受け取られた通貨。"
}
},
"Expense": {
"create": "支出を作成",
"edit": "支出を編集",
"TitleField": {
"label": "支出タイトル",
"placeholder": "月曜日の夕食レストラン",
"description": "支出の説明を入力してください。"
},
"DateField": {
"label": "支出日",
"description": "支出が支払われた日付を入力してください。"
},
"categoryFieldDescription": "支出カテゴリーを選択してください。",
"paidByField": {
"label": "支払者",
"description": "支出を支払った参加者を選択してください。",
"placeholder": "参加者を選択してください"
},
"recurrenceRule": {
"label": "支出の繰り返し",
"description": "支出の繰り返し頻度を選択してください。",
"none": "なし",
"daily": "毎日",
"weekly": "毎週",
"monthly": "毎月"
},
"paidFor": {
"title": "支払い対象",
"description": "誰のために支出が支払われたかを選択してください。"
},
"splitModeDescription": "支出の分割方法を選択してください。",
"attachDescription": "領収書を確認し、支出に添付してください。",
"currencyField": {
"label": "支出の通貨",
"description": "支出が支払われた通貨。"
}
},
"amountField": {
"label": "金額"
},
"isReimbursementField": {
"label": "これは払い戻しです"
},
"categoryField": {
"label": "カテゴリー"
},
"notesField": {
"label": "メモ"
},
"selectNone": "選択解除",
"selectAll": "すべて選択",
"shares": "シェア",
"advancedOptions": "詳細な分割オプション…",
"SplitModeField": {
"label": "分割モード",
"evenly": "均等に",
"byShares": "不均等に - シェアで",
"byPercentage": "不均等に - パーセンテージで",
"byAmount": "不均等に - 金額で",
"saveAsDefault": "デフォルトの分割オプションとして保存"
},
"DeletePopup": {
"label": "削除",
"title": "この支出を削除しますか?",
"description": "本当にこの支出を削除しますか?この操作は元に戻せません。",
"yes": "はい",
"cancel": "キャンセル"
},
"attachDocuments": "書類を添付",
"create": "作成",
"creating": "作成中…",
"save": "保存",
"saving": "保存中…",
"cancel": "キャンセル",
"reimbursement": "払い戻し",
"conversionUnavailable": "支出ごとに異なる通貨を設定して金額を換算するには、グループの通貨をカスタム以外の通貨に設定してください。",
"originalAmountField": {
"label": "換算する金額"
},
"conversionRateField": {
"useApi": "Frankfurterのレートを使用する",
"useCustom": "カスタムレートを使用する",
"label": "為替レート"
},
"conversionRateState": {
"loading": "為替レートを取得しています…",
"success": "取得したレート:",
"error": "おっと、最新のレートを取得できませんでした。",
"staleRate": "使用するレート:",
"noRate": "以下にカスタムレートを入力してください。",
"currencyNotFound": "おっと、Frankfurterにはその日のこの通貨のレートがありません。",
"noDate": "換算レートを取得するには支出日を入力してください。",
"refresh": "更新",
"customRate": "カスタムレートを使用中",
"dateMismatch": "適用開始日: {date}"
}
},
"ExpenseDocumentsInput": {
"TooBigToast": {
"title": "ファイルが大きすぎます",
"description": "アップロードできる最大ファイルサイズは{maxSize}です。あなたのファイルは{size}です。"
},
"ErrorToast": {
"title": "書類のアップロード中にエラーが発生しました",
"description": "書類のアップロード中に何か問題が発生しました。後で再試行するか、別のファイルを選択してください。",
"retry": "再試行"
}
},
"CreateFromReceipt": {
"Dialog": {
"triggerTitle": "領収書から支出を作成",
"title": "領収書から作成",
"description": "領収書の写真から支出情報を抽出します。",
"body": "領収書の写真をアップロードすると、可能であれば支出情報を抽出してスキャンします。",
"selectImage": "画像を選択…",
"titleLabel": "タイトル:",
"categoryLabel": "カテゴリー:",
"amountLabel": "金額:",
"dateLabel": "日付:",
"editNext": "次に支出情報を編集できます。",
"continue": "続ける"
},
"unknown": "不明",
"TooBigToast": {
"title": "ファイルが大きすぎます",
"description": "アップロードできる最大ファイルサイズは{maxSize}です。あなたのファイルは{size}です。"
},
"ErrorToast": {
"title": "書類のアップロード中にエラーが発生しました",
"description": "書類のアップロード中に何か問題が発生しました。後で再試行するか、別のファイルを選択してください。",
"retry": "再試行"
}
},
"Balances": {
"title": "残高",
"description": "これは各参加者が支払ったまたは支払われた金額です。",
"Reimbursements": {
"title": "提案される払い戻し",
"description": "参加者間の最適化された払い戻しの提案です。",
"noImbursements": "グループに払い戻しが必要ないようです 😁",
"owes": "<strong>{from}</strong>は<strong>{to}</strong>に借りがあります",
"markAsPaid": "支払い済みとしてマーク"
}
},
"Stats": {
"title": "統計",
"Totals": {
"title": "合計",
"description": "グループ全体の支出概要。",
"groupSpendings": "グループ総支出",
"groupEarnings": "グループ総収入",
"yourSpendings": "あなたの総支出",
"yourEarnings": "あなたの総収入",
"yourShare": "あなたの総シェア"
}
},
"Activity": {
"title": "アクティビティ",
"description": "このグループのすべてのアクティビティの概要。",
"noActivity": "グループにはまだアクティビティがありません。",
"someone": "誰か",
"settingsModified": "グループ設定が<strong>{participant}</strong>によって変更されました。",
"expenseCreated": "支出<em>{expense}</em>が<strong>{participant}</strong>によって作成されました。",
"expenseUpdated": "支出<em>{expense}</em>が<strong>{participant}</strong>によって更新されました。",
"expenseDeleted": "支出<em>{expense}</em>が<strong>{participant}</strong>によって削除されました。",
"Groups": {
"today": "今日",
"yesterday": "昨日",
"earlierThisWeek": "今週初め",
"lastWeek": "先週",
"earlierThisMonth": "今月初め",
"lastMonth": "先月",
"earlierThisYear": "今年初め",
"lastYear": "昨年",
"older": "それ以前"
}
},
"Information": {
"title": "情報",
"description": "グループ参加者に関連する情報を追加するためにこの場所を使用してください。",
"empty": "まだグループ情報がありません。"
},
"Settings": {
"title": "設定"
},
"Share": {
"title": "共有",
"description": "他の参加者がグループを見て支出を追加できるように、グループのURLを共有してください。",
"warning": "警告!",
"warningHelp": "グループURLを持つすべての人が支出を閲覧および編集できるようになります。注意して共有してください"
},
"SchemaErrors": {
"min1": "少なくとも1文字入力してください。",
"min2": "少なくとも2文字入力してください。",
"max5": "最大5文字まで入力してください。",
"max50": "最大50文字まで入力してください。",
"duplicateParticipantName": "別の参加者がすでにこの名前を使用しています。",
"titleRequired": "タイトルを入力してください。",
"invalidNumber": "無効な数字です。",
"amountRequired": "金額を入力する必要があります。",
"amountNotZero": "金額はゼロであってはなりません。",
"amountTenMillion": "金額は10,000,000未満である必要があります。",
"paidByRequired": "参加者を選択する必要があります。",
"paidForMin1": "支出は少なくとも1人の参加者のために支払われている必要があります。",
"noZeroShares": "すべてのシェアは0より大きくなければなりません。",
"amountSum": "金額の合計は支出金額と一致する必要があります。",
"percentageSum": "パーセンテージの合計は100である必要があります。",
"ratePositive": "レートは必ずゼロより大きくなければなりません。"
},
"Categories": {
"search": "カテゴリーを検索...",
"noCategory": "カテゴリーが見つかりません。",
"Uncategorized": {
"heading": "未分類",
"General": "一般",
"Payment": "支払い"
},
"Entertainment": {
"heading": "エンターテイメント",
"Entertainment": "エンターテイメント",
"Games": "ゲーム",
"Movies": "映画",
"Music": "音楽",
"Sports": "スポーツ"
},
"Food and Drink": {
"heading": "食事と飲み物",
"Food and Drink": "食事と飲み物",
"Dining Out": "外食",
"Groceries": "食料品",
"Liquor": "酒類"
},
"Home": {
"heading": "住まい",
"Home": "住まい",
"Electronics": "電子機器",
"Furniture": "家具",
"Household Supplies": "家庭用品",
"Maintenance": "メンテナンス",
"Mortgage": "住宅ローン",
"Pets": "ペット",
"Rent": "家賃",
"Services": "サービス"
},
"Life": {
"heading": "生活",
"Childcare": "育児",
"Clothing": "衣類",
"Donation": "寄付",
"Education": "教育",
"Gifts": "贈り物",
"Insurance": "保険",
"Medical Expenses": "医療費",
"Taxes": "税金"
},
"Transportation": {
"heading": "交通",
"Transportation": "交通",
"Bicycle": "自転車",
"Bus/Train": "バス/電車",
"Car": "車",
"Gas/Fuel": "ガソリン/燃料",
"Hotel": "ホテル",
"Parking": "駐車場",
"Plane": "飛行機",
"Taxi": "タクシー"
},
"Utilities": {
"heading": "公共料金",
"Utilities": "公共料金",
"Cleaning": "清掃",
"Electricity": "電気",
"Heat/Gas": "暖房/ガス",
"Trash": "ゴミ",
"TV/Phone/Internet": "テレビ/電話/インターネット",
"Water": "水道"
}
},
"Currencies": {
"search": "通貨を検索…",
"noCurrency": "通貨が見つかりませんでした。",
"custom": {
"heading": "カスタム"
},
"common": {
"heading": "一般的なもの"
},
"other": {
"heading": "その他の通貨"
}
}
}

388
messages/ko.json Normal file
View File

@@ -0,0 +1,388 @@
{
"Expenses": {
"exportJson": "JSON으로 내보내기",
"exportCsv": "CSV로 내보내기",
"export": "내보내기",
"ActiveUserModal": {
"save": "변경내용 저장하기",
"title": "당신은 누구인가요?",
"description": "당신이 누구인지 알려 주시면, 정보를 더 잘 보여드릴게요.",
"nobody": "아무도 선택하지 않을래요",
"footer": "이건 나중에 그룹 설정에서 바꿀 수 있어요."
},
"Groups": {
"thisWeek": "이번주",
"earlierThisMonth": "이번달 초",
"lastMonth": "지난달",
"lastYear": "작년",
"earlierThisYear": "올해 초",
"older": "이전"
},
"title": "지출",
"description": "그룹에서 만든 지출 항목들을 보여드려요.",
"create": "지출 추가하기",
"noExpenses": "그룹에 아직 지출 항목이 없네요.",
"searchPlaceholder": "지출 검색하기…",
"createFirst": "첫 지출 추가하기"
},
"Groups": {
"myGroups": "내 그룹",
"create": "만들기",
"loadingRecent": "최신 그룹 불러오는 중…",
"NoRecent": {
"description": "최근에 참여한 그룹이 없습니다.",
"orAsk": "또는 친구에게 기존 그룹 링크를 보내 달라고 요청하세요.",
"create": "그룹 만들기"
},
"recent": "최근 그룹",
"starred": "즐겨찾기 그룹",
"archived": "아카이브된 그룹",
"archive": "그룹 아카이브하기",
"RecentRemovedToast": {
"title": "그룹이 삭제되었습니다",
"description": "그룹이 최신 그룹 목록에서 삭제되었습니다.",
"undoAlt": "그룹 삭제 취소"
},
"AddByURL": {
"button": "URL로 불러오기",
"title": "URL로 그룹 불러오기",
"description": "이미 공유받은 그룹이 있다면, 여기에 URL을 붙여넣어 추가하세요.",
"error": "헉, 입력하신 URL에서 그룹을 찾을 수 없네요…"
},
"NotFound": {
"text": "그룹이 존재하지 않습니다.",
"link": "최근에 방문한 그룹 보기"
},
"unarchive": "그룹 다시 불러오기",
"removeRecent": "최근 그룹 목록에서 삭제하기"
},
"GroupForm": {
"title": "그룹 정보",
"NameField": {
"label": "그룹 이름",
"placeholder": "여름 휴가",
"description": "그룹 이름을 입력하세요."
},
"InformationField": {
"label": "그룹 정보",
"placeholder": "그룹 참가자들이 알아야 할 정보는 무엇인가요?"
},
"CurrencyField": {
"label": "통화 기호",
"placeholder": "$, €, £…",
"description": "금액 표시할 때 이걸 사용할 거예요."
},
"CurrencyCodeField": {
"label": "기본 통화",
"createDescription": "금액, 잔액 전부 이 통화로 표시돼요.",
"editDescription": "모든 금액이랑 잔액은 이 통화로 보여요. 통화를 바꿔도 이미 입력한 지출은 바뀌지 않아요. 단, (예: 미국 달러에서 일본 엔으로 바꿀 때처럼) 통화의 소수점 단위가 다를 경우에는 예외가 있을 수 있어요."
},
"Participants": {
"title": "참여자",
"description": "참가자 이름을 하나씩 입력해 주세요.",
"protectedParticipant": "이 참가자는 지출에 참여 중이라서 지울 수 없어요.",
"add": "참가자 추가하기",
"John": "민수",
"Jane": "수영",
"Jack": "길동"
},
"Settings": {
"ActiveUserField": {
"description": "사용자가 기본 지출 결제자로 사용돼요.",
"placeholder": "참가자를 선택해 주세요",
"label": "활성 사용자",
"none": "없음"
},
"save": "저장",
"saving": "저장중…",
"description": "이 설정들은 기기별로 저장되며, 사용자 경험을 맞춤화하는 데 사용돼요.",
"cancel": "취소",
"creating": "만드는 중…"
}
},
"ExpenseForm": {
"Expense": {
"create": "지출 추가하기",
"edit": "지출 수정하기",
"TitleField": {
"label": "지출 이름",
"placeholder": "월요일 저녁 식사",
"description": "지출 내용을 입력해 주세요."
},
"DateField": {
"label": "지출 날짜",
"description": "지출한 날짜를 입력해 주세요."
},
"currencyField": {
"label": "지출 통화",
"description": "지출이 결제된 통화예요."
},
"categoryFieldDescription": "지출 카테고리를 선택해 주세요.",
"paidByField": {
"label": "결제한 사람",
"placeholder": "참가자를 선택해 주세요",
"description": "지출을 결제한 사람을 선택해 주세요."
},
"recurrenceRule": {
"label": "지출 반복 설정",
"description": "지출 반복 주기를 선택해 주세요.",
"daily": "매일",
"weekly": "매주",
"monthly": "매달"
},
"paidFor": {
"title": "비용 부담자들",
"description": "비용 부담자들을 선택해 주세요."
},
"splitModeDescription": "지출을 어떻게 나눌지 선택해 주세요."
},
"amountField": {
"label": "금액"
},
"conversionRateField": {
"useApi": "Frankfurter의 환율을 사용해요",
"useCustom": "직접 설정한 환율 사용하기",
"label": "환율"
},
"conversionRateState": {
"loading": "환율 가져오는 중…",
"success": "가져온 환율:",
"error": "헉, 가장 최근 환율을 가져오지 못했어요.",
"staleRate": "적용 중인 환율:",
"noRate": "아래에 직접 환율을 입력해 주세요.",
"currencyNotFound": "헉, Frankfurter에서 이 날짜 환율을 찾을 수 없어요.",
"noDate": "환율을 가져오려면 지출한 날짜를 입력해 주세요.",
"dateMismatch": "기준 환율: {date}",
"refresh": "다시 불러오기",
"customRate": "직접 설정한 환율 사용 중"
},
"isReimbursementField": {
"label": "이건 비용 정산이에요"
},
"categoryField": {
"label": "카테고리"
},
"notesField": {
"label": "메모"
},
"SplitModeField": {
"label": "분할 방식",
"evenly": "똑같이 나누기",
"byShares": "균등하지 않게 지분별로 나누기",
"byPercentage": "균등하지 않게 비율로 나누기",
"byAmount": "균등하지 않게 금액별로 나누기",
"saveAsDefault": "기본 분할 방식으로 저장하기"
},
"DeletePopup": {
"description": "정말 이 지출을 삭제할까요? 한 번 삭제하면 되돌릴 수 없어요.",
"yes": "네",
"cancel": "취소"
},
"Income": {
"TitleField": {
"placeholder": "월요일 저녁 식사"
}
},
"reimbursement": "정산",
"cancel": "취소"
},
"Balances": {
"title": "잔액",
"Reimbursements": {
"title": "추천 정산 방법",
"description": "참가자 간에 가장 좋은 정산 방안을 알려드려요.",
"noImbursements": "그룹에선 따로 정산할 게 없네요 😁",
"markAsPaid": "지불 완료로 표시하기",
"owes": "<strong>{from}</strong>가 <strong>{to}</strong>에게 돈을 내야 해요"
}
},
"Stats": {
"title": "현황",
"Totals": {
"title": "전체 합계",
"description": "그룹 전체 지출 요약 입니다.",
"groupSpendings": "그룹 총 지출액",
"yourSpendings": "나의 총 지출",
"yourShare": "나의 총 부담금"
}
},
"Activity": {
"title": "활동 내역",
"description": "이 그룹의 모든 활동 개요예요.",
"noActivity": "아직 그룹에 활동 내역이 없어요.",
"Groups": {
"today": "오늘",
"yesterday": "어제",
"earlierThisWeek": "이번주 초",
"lastWeek": "지난주",
"earlierThisMonth": "이번달 초",
"lastMonth": "지난달",
"earlierThisYear": "올해 초",
"lastYear": "작년",
"older": "이전"
},
"settingsModified": "<strong>{participant}</strong>가 그룹 설정을 수정했어요.",
"expenseCreated": "<strong>{participant}</strong>가 <em>{expense}</em> 지출 내역을 추가했어요.",
"expenseUpdated": "<strong>{participant}</strong>가 <em>{expense}</em> 지출을 수정했어요.",
"expenseDeleted": "<strong>{participant}</strong>가 <em>{expense}</em> 지출을 삭제했어요."
},
"SchemaErrors": {
"duplicateParticipantName": "이미 같은 이름을 가진 참가자가 있어요.",
"invalidNumber": "잘못된 숫자입니다.",
"amountRequired": "금액을 입력해 주세요.",
"amountNotZero": "금액은 0이 될 수 없어요.",
"amountTenMillion": "금액은 10,000,000보다 작아야 해요.",
"ratePositive": "환율은 0보다 커야 해요.",
"paidByRequired": "참가자를 선택해 주세요.",
"paidForMin1": "",
"noZeroShares": "각 몫은 0보다 커야 합니다.",
"amountSum": "금액 합계가 지출 금액과 같아야 해요.",
"percentageSum": "퍼센트 합이 100이 되어야 합니다.",
"min1": "최소 한 글자 이상 입력해 주세요.",
"min2": "최소 두 글자 이상 입력해 주세요.",
"titleRequired": "제목을 입력해 주세요."
},
"Categories": {
"search": "카테고리 선택...",
"noCategory": "카테고리를 찾을 수 없어요.",
"Uncategorized": {
"heading": "카테고리 없음",
"General": "일반"
},
"Entertainment": {
"heading": "엔터테인먼트",
"Entertainment": "엔터테인먼트",
"Games": "게임",
"Movies": "영화",
"Music": "음악",
"Sports": "스포츠"
},
"Food and Drink": {
"heading": "음식 및 음료",
"Food and Drink": "음식 및 음료",
"Dining Out": "외식",
"Groceries": "생필품",
"Liquor": "주류"
},
"Home": {
"heading": "집 / 가정",
"Home": "집 / 가정",
"Electronics": "전자제품",
"Furniture": "가구",
"Household Supplies": "생활용품",
"Maintenance": "수리 / 관리",
"Mortgage": "주택 담보 대출",
"Pets": "반려동물",
"Rent": "임대료 / 월세"
},
"Life": {
"Childcare": "육아 / 보육",
"Clothing": "의류",
"Donation": "기부",
"Education": "교육비",
"Gifts": "선물",
"Insurance": "보험",
"Medical Expenses": "의료비",
"Taxes": "세금",
"heading": "생활"
},
"Transportation": {
"heading": "교통비",
"Transportation": "교통비",
"Bicycle": "자전거",
"Bus/Train": "버스/기차",
"Car": "자동차",
"Gas/Fuel": "주유비",
"Hotel": "호텔/숙박",
"Parking": "주차비",
"Plane": "비행기/항공",
"Taxi": "택시"
},
"Utilities": {
"heading": "공과금",
"Utilities": "공과금",
"Cleaning": "청소",
"Electricity": "전기료",
"Heat/Gas": "난방/가스",
"Trash": "쓰레기 처리비",
"TV/Phone/Internet": "TV/전화/인터넷 요금",
"Water": "수도 요금"
}
},
"Homepage": {
"button": {
"github": "깃허브",
"groups": "그룹 보기"
},
"title": "<strong>친구 & 가족</strong>과 <strong>지출</strong>을 함께 관리해요",
"description": "<strong>Spliit</strong>와 함께하는 새 시작을 환영해요!"
},
"Header": {
"groups": "그룹"
},
"Footer": {
"madeIn": "몬트리올, 퀘벡에서 제작 🇨🇦",
"builtBy": "<author>Sebastien Castiel</author>와 <source>공동 작업자들</source>이 함께 만들었어요"
},
"Settings": {
"title": "설정"
},
"Share": {
"warningHelp": "그룹 URL을 가진 사람은 누구나 지출 내역을 보고 수정할 수 있어요. 조심해서 공유하세요!",
"warning": "경고!",
"title": "공유하기",
"description": "다른 참가자들이 그룹을 보고 지출을 추가할 수 있도록 URL을 공유해 주세요."
},
"ExpenseCard": {
"paidBy": "<strong>{paidBy}</strong>가 <paidFor></paidFor>를 위해 결제함",
"everyone": "모두",
"yourBalance": "내 잔액:",
"notInvolved": "당신은 관련이 없어요"
},
"ExpenseDocumentsInput": {
"TooBigToast": {
"title": "파일이 너무 커요",
"description": "업로드 가능한 최대 파일 크기: {maxSize}, 현재 파일 크기: {size}입니다."
},
"ErrorToast": {
"title": "문서 업로드 중 오류가 발생했어요",
"description": "문서 업로드 중 오류가 발생했습니다. 나중에 다시 시도하거나 다른 파일을 선택해 주세요."
}
},
"CreateFromReceipt": {
"Dialog": {
"triggerTitle": "영수증으로 지출 추가하기",
"description": "영수증 사진으로 지출 내역을 자동으로 불러와요.",
"body": "영수증 사진을 업로드하면, 가능하면 지출 정보를 스캔해서 추출해 드려요.",
"selectImage": "사진 선택하기…",
"titleLabel": "제목:",
"categoryLabel": "카테고리:",
"amountLabel": "금액:",
"dateLabel": "날짜:",
"editNext": "다음 단계에서 지출 정보를 수정할 수 있습니다.",
"continue": "계속하기"
},
"TooBigToast": {
"title": "파일이 너무 커요",
"description": "업로드 가능한 최대 파일 크기: {maxSize}, 현재 파일 크기: {size}입니다."
},
"ErrorToast": {
"title": "문서 업로드 중 오류가 발생했어요",
"description": "문서 업로드 중 오류가 발생했습니다. 나중에 다시 시도하거나 다른 파일을 선택해 주세요."
}
},
"Information": {
"title": "정보",
"empty": "아직 그룹 정보가 없어요."
},
"Currencies": {
"search": "통화 선택하기...",
"noCurrency": "통화를 찾을 수 없어요.",
"common": {
"heading": "가장 많이 쓰이는"
},
"other": {
"heading": "그 외 통화"
}
}
}

View File

@@ -20,14 +20,15 @@
"create": "Maak uitgave",
"createFirst": "Maak de eerste",
"noExpenses": "Je groep heeft nog geen uitgaven.",
"exportJson": "Exporteer naar JSON",
"exportCsv": "Exporteer naar CSV",
"export": "Exporteren",
"exportJson": "Exporteren naar JSON",
"exportCsv": "Exporteren naar CSV",
"searchPlaceholder": "Zoek naar een uitgave…",
"ActiveUserModal": {
"title": "Wie ben jij?",
"description": "Zeg ons welke deelnemer je bent zodat wij persoonlijke informatie kunnen aantonen.",
"nobody": "Ik wil niemand selecteren",
"save": "Sla op",
"save": "Opslaan",
"footer": "Deze instelling kan later worden gewijzigd in de instellingen van de groep."
},
"Groups": {
@@ -42,8 +43,10 @@
},
"ExpenseCard": {
"paidBy": "Betaald door <strong>{paidBy}</strong> voor <paidFor></paidFor>",
"everyone": "iedereen",
"receivedBy": "Ontvangen door <strong>{paidBy}</strong> voor <paidFor></paidFor>",
"yourBalance": "Jouw balans:"
"yourBalance": "Jouw balans:",
"notInvolved": "Je bent hier niet bij betrokken"
},
"Groups": {
"myGroups": "Mijn groepen",
@@ -112,11 +115,17 @@
"none": "Geen",
"description": "De deelnemer die automatisch wordt geselecteerd als je een uitgave maakt."
},
"save": "Sla op",
"saving": "Opslaan…",
"create": "Maak groep",
"creating": "Maken…",
"cancel": "Annuleer"
"save": "Opslaan",
"saving": "Aan het opslaan…",
"create": "Groep maken",
"creating": "Aan het maken…",
"cancel": "Annuleren"
},
"CurrencyCodeField": {
"label": "Hoofdvaluta",
"createDescription": "Alle hoeveelheden en saldi worden in deze valuta weergegeven.",
"editDescription": "Alle bedragen en saldi worden in deze valuta weergegeven. Als je dit wijzigt, worden reeds ingevoerde uitgaven NIET omgerekend, behalve wanneer de valuta andere \"kleinste eenheden\" heeft dan de huidige (bijvoorbeeld bij een wijziging van Amerikaanse dollar naar Japanse yen)",
"customOption": "Aangepast"
}
},
"ExpenseForm": {
@@ -137,12 +146,24 @@
"label": "Ontvangen door",
"description": "Selecteer de deelnemer die het inkomen heeft ontvangen."
},
"recurrenceRule": {
"label": "Terugkerend inkomen",
"description": "Kies hoe vaak het inkomen herhaald wordt.",
"none": "Niet",
"daily": "Dagelijks",
"weekly": "Wekelijks",
"monthly": "Maandelijks"
},
"paidFor": {
"title": "Ontvangen voor",
"description": "Selecteer voor wie het inkomen is ontvangen."
},
"splitModeDescription": "Selecteer hoe het inkomen verdeeld moet worden.",
"attachDescription": "Bekijk en voeg bijlagen toe aan het inkomen."
"attachDescription": "Bekijk en voeg bijlagen toe aan het inkomen.",
"currencyField": {
"label": "Munteenheid van inkomen",
"description": "De munteenheid waar het inkomen in is ontvangen."
}
},
"Expense": {
"create": "Maak uitgave",
@@ -159,14 +180,27 @@
"categoryFieldDescription": "Selecteer de uitgavecategorie.",
"paidByField": {
"label": "Betaald door",
"description": "Selecteer de deelnemer die de uitgave heeft gedaan."
"description": "Selecteer de deelnemer die de uitgave heeft gedaan.",
"placeholder": "Selecteer een deelnemer"
},
"recurrenceRule": {
"label": "Terugkerende uitgave",
"description": "Kies hoe vaak de uitgave herhaald wordt.",
"none": "Niet",
"daily": "Dagelijks",
"weekly": "Wekelijks",
"monthly": "Maandelijks"
},
"paidFor": {
"title": "Betaald voor",
"description": "Selecteer voor wie de uitgave is gedaan."
},
"splitModeDescription": "Selecteer hoe de uitgave verdeeld moet worden.",
"attachDescription": "Bekijk en voeg bijlagen toe aan de uitgave."
"attachDescription": "Bekijk en voeg bijlagen toe aan de uitgave.",
"currencyField": {
"label": "Munteenheid van uitgave",
"description": "De munteenheid waar de uitgave in is betaald."
}
},
"amountField": {
"label": "Bedrag"
@@ -183,29 +217,50 @@
"selectNone": "Selecteer niemand",
"selectAll": "Selecteer iedereen",
"shares": "deel/delen",
"advancedOptions": "Geavanceerde split-opties",
"advancedOptions": "Andere split-opties",
"SplitModeField": {
"label": "Split soort",
"evenly": "Gelijk verdeeld",
"byShares": "Ongelijk Met delen",
"byPercentage": "Ongelijk Met percentage",
"byAmount": "Ongelijk Met bedrag",
"saveAsDefault": "Sla op als standaard-optie"
"saveAsDefault": "Opslaan als standaard-optie"
},
"DeletePopup": {
"label": "Verwijderen",
"title": "Deze uitgave verwijderen?",
"description": "Wil je deze uitgave echt verwijderen?",
"description": "Wil je deze uitgave echt verwijderen? Dit kan niet ongedaan worden.",
"yes": "Ja",
"cancel": "Annuleer"
},
"attachDocuments": "Voeg documenten toe",
"create": "Maak",
"creating": "Maken…",
"save": "Sla op",
"saving": "Opslaan…",
"cancel": "Annuleer",
"reimbursement": "Terugbetaling"
"create": "Maken",
"creating": "Aan het maken…",
"save": "Opslaan",
"saving": "Aan het opslaan…",
"cancel": "Annuleren",
"reimbursement": "Terugbetaling",
"conversionUnavailable": "Om een andere munteenheid in te stellen voor een uitgave en bedragen om te rekenen, kies een standaard munteenheid voor de groep.",
"originalAmountField": {
"label": "Om te rekenen bedrag"
},
"conversionRateField": {
"useApi": "Gebruik koersen van Frankfurter",
"useCustom": "Gebruik aangepaste koers",
"label": "Wisselkoers"
},
"conversionRateState": {
"loading": "Wisselkoers aan het ophalen…",
"success": "Verkregen wisselkoers:",
"error": "Oeps, we konden de nieuwste wisselkoersen niet verkrijgen.",
"staleRate": "Gebruikte wisselkoers:",
"noRate": "Voer hieronder een aangepaste koers in.",
"currencyNotFound": "Oeps, Frankfurter heeft geen koersen voor deze munteenheid op deze dag.",
"noDate": "Voer de datum van de uitgave in om een wisselkoers te krijgen.",
"dateMismatch": "Wisselkoers van: {date}",
"refresh": "Ververs",
"customRate": "Aangepaste wisselkoers wordt gebruikt"
}
},
"ExpenseDocumentsInput": {
"TooBigToast": {
@@ -316,7 +371,8 @@
"paidForMin1": "De uitgave moet voor ten minste één deelnemer zijn gedaan.",
"noZeroShares": "Een deel mag niet 0 zijn.",
"amountSum": "Het totaalbedrag moet gelijk zijn aan het uitgavebedrag.",
"percentageSum": "Het totaalpercentage moet gelijk zijn aan 100%."
"percentageSum": "Het totaalpercentage moet gelijk zijn aan 100%.",
"ratePositive": "De koers moet groter dan nul zijn."
},
"Categories": {
"search": "Categorie zoeken…",
@@ -357,6 +413,7 @@
"heading": "Leven",
"Childcare": "Kinderopvang",
"Clothing": "Kleding",
"Donation": "Donatie",
"Education": "Onderwijs",
"Gifts": "Cadeaus",
"Insurance": "Verzekering",
@@ -385,5 +442,18 @@
"TV/Phone/Internet": "Internet/TV/Telefoon",
"Water": "Water"
}
},
"Currencies": {
"noCurrency": "Geen valuta gevonden.",
"custom": {
"heading": "Aangepast"
},
"common": {
"heading": "Meest voorkomend"
},
"other": {
"heading": "Andere valuta"
},
"search": "Valuta zoeken..."
}
}

View File

@@ -1,7 +1,7 @@
{
"Homepage": {
"title": "Podziel <strong>Wydatki</strong> z <strong>Rodziną i Przyjaciółmi</strong>",
"description": "Witaj na twojej nowej instancji <strong>Spliita</strong> !",
"title": "Dziel <strong>Wydatki</strong> z <strong>Rodziną i Przyjaciółmi</strong>",
"description": "Witaj na Twojej nowej instancji <strong>Spliit</strong> !",
"button": {
"groups": "Przejdź do grup",
"github": "GitHub"
@@ -11,18 +11,19 @@
"groups": "Grupy"
},
"Footer": {
"madeIn": "Stworzone Montréalu, Québec 🇨🇦",
"madeIn": "Stworzone w Montréalu, Québec 🇨🇦",
"builtBy": "Napisane przez <author>Sebastien Castiela</author> i <source>kontrybutorów</source>"
},
"Expenses": {
"title": "Wydatki",
"description": "Tutaj są wydatki, które utworzyłeś dla twojej grupy.",
"description": "Tutaj są wydatki, które utworzyłeś dla Twojej grupy.",
"create": "Dodaj wydatek",
"createFirst": "Stwórz swój pierwszy",
"noExpenses": "Twoja grupa nie ma jeszcze żadnych wydatków.",
"exportJson": "Eksportuj do JSONa",
"exportCsv": "Eksportuj do CSVa",
"searchPlaceholder": "Szukaj wydatku...",
"export": "Eksportuj",
"exportJson": "Eksportuj jako JSON",
"exportCsv": "Eksportuj jako CSV",
"searchPlaceholder": "Szukaj wydatku…",
"ActiveUserModal": {
"title": "Kim jesteś?",
"description": "Podaj, którym uczestnikiem jesteś aby pozwolić nam określić jakie informacje mają być wyświetlane.",
@@ -48,17 +49,17 @@
"Groups": {
"myGroups": "Moje grupy",
"create": "Stwórz",
"loadingRecent": "Wczytywanie ostatnich grup...",
"loadingRecent": "Wczytywanie ostatnich grup",
"NoRecent": {
"description": "Nie odwiedzałeś ostatnio żadnych grup.",
"create": "Stwórz",
"orAsk": "albo poproś przyjaciela, aby ci wysłał link do już istniejącej."
"orAsk": "albo poproś przyjaciela, aby wysłał Ci link do już istniejącej."
},
"recent": "Ostatnie grupy",
"starred": "Ogwiazdkowane grupy",
"starred": "Ulubione grupy",
"archived": "Zarchiwizowane grupy",
"archive": "Zarchiwizuj grupę",
"unarchive": "Odarchwiruj grupę",
"unarchive": "Cofnij archiwizację grupy",
"removeRecent": "Usuń z ostatnich grup",
"RecentRemovedToast": {
"title": "Grupa została usunięta",
@@ -67,10 +68,10 @@
"undo": "Cofnij"
},
"AddByURL": {
"button": "Dodaj poprzez link URL",
"title": "Dodaj grupę poprzez link URL",
"description": "Jeśli grupa została ci udostępniona możesz wkleić jej link tutaj, aby dodać ją do twojej listy.",
"error": "Ups, nie możemy znaleźć grupy z podanego linka..."
"button": "Dodaj poprzez adres URL",
"title": "Dodaj grupę poprzez adres URL",
"description": "Jeśli grupa została Ci udostępniona, możesz wkleić jej adres URL tutaj, aby dodać ją do Twojej listy.",
"error": "Ups, nie możemy znaleźć grupy o podanym adresie URL…"
},
"NotFound": {
"text": "Ta grupa nie istnieje.",
@@ -105,7 +106,7 @@
},
"Settings": {
"title": "Ustawienia lokalne",
"description": "Te ustawienia są zapisywane dla tego urządzenia i służą do dostosowania twoich doświadczeń z aplikacją.",
"description": "Te ustawienia są zapisywane na tym urządzeniu i służą do dostosowania Twoich doświadczeń z aplikacją.",
"ActiveUserField": {
"label": "Aktywny użytkownik",
"placeholder": "Wybierz użytkownika",
@@ -132,20 +133,11 @@
"label": "Data wpływu",
"description": "Podaj datę otrzymania wpływu."
},
"categoryFieldDescription": "Wybierz typ wpływu.",
"categoryFieldDescription": "Wybierz kategorię wpływu.",
"paidByField": {
"label": "Otrzymane przez",
"description": "Wybierz członka, który otrzymał wpływ."
},
"recurrenceRule": {
"label": "Expense Recurrence",
"description": "Select how often the expense should repeat.",
"none": "None",
"daily": "Daily",
"weekly": "Weekly",
"monthly": "Monthly"
},
"paidFor": {
"title": "Otrzymany dla",
"description": "Podaj dla kogo wpływ był przeznaczony."
@@ -170,6 +162,14 @@
"label": "Opłacone przez",
"description": "Wybierz członka, który zapłacił."
},
"recurrenceRule": {
"label": "Powtarzalnośc wydatku",
"description": "Wybierz jak często wydatek ma się powtarzać.",
"none": "Jednorazowo",
"daily": "Codziennie",
"weekly": "Co tydzień",
"monthly": "Co miesiąc"
},
"paidFor": {
"title": "Opłacone dla",
"description": "Wybierz kogo dotyczył wydatek."
@@ -192,7 +192,7 @@
"selectNone": "Nie wybieraj nikogo",
"selectAll": "Wybierz wszystkich",
"shares": "udział(y)",
"advancedOptions": "Zaawansowane opcje podziału...",
"advancedOptions": "Zaawansowane opcje podziału",
"SplitModeField": {
"label": "Typ podziału",
"evenly": "Równy",
@@ -219,7 +219,7 @@
"ExpenseDocumentsInput": {
"TooBigToast": {
"title": "Ten plik jest zbyt duży",
"description": "Maksymalny rozmiar pliku to: {maxSize}. Twój plik ma: {size}."
"description": "Maksymalny rozmiar pliku to {maxSize}. Twój plik ma {size}."
},
"ErrorToast": {
"title": "Błąd podczas wysyłania dokumentu",
@@ -231,9 +231,9 @@
"Dialog": {
"triggerTitle": "Utwórz wydatek z paragonu",
"title": "Utwórz z paragonu",
"description": "Wyodrębnianie informacji o wydatkach ze zdjęcia paragonu.",
"description": "Wyodrębnij informacje o wydatkach ze zdjęcia paragonu.",
"body": "Prześlij zdjęcie paragonu, a my zeskanujemy je, aby wyodrębnić informacje o wydatkach, jeśli to możliwe.",
"selectImage": "Wybierz obraz...",
"selectImage": "Wybierz obraz",
"titleLabel": "Tytuł:",
"categoryLabel": "Kategoria:",
"amountLabel": "Suma:",
@@ -244,7 +244,7 @@
"unknown": "Nieznany",
"TooBigToast": {
"title": "Ten plik jest zbyt duży",
"description": "Maksymalny rozmiar pliku to: {maxSize}. Twój plik ma: {size}."
"description": "Maksymalny rozmiar pliku to {maxSize}. Twój plik ma {size}."
},
"ErrorToast": {
"title": "Błąd podczas wysyłania dokumentu",
@@ -254,7 +254,7 @@
},
"Balances": {
"title": "Salda",
"description": "Jest to kwota, którą każdy członek zapłacił lub za którą otrzymał zapłatę.",
"description": "Jest to kwota, którą każdy członek zapłacił lub otrzymał.",
"Reimbursements": {
"title": "Sugerowane zwroty",
"description": "Oto sugestie dotyczące optymalizacji zwrotów między uczestnikami.",
@@ -298,7 +298,7 @@
},
"Information": {
"title": "Informacje",
"description": "Użyj tego miejsca, aby dodać wszelkie informacje, które mogą być istotne dla uczestników grupy..",
"description": "Użyj tego miejsca, aby dodać wszelkie informacje, które mogą być istotne dla uczestników grupy.",
"empty": "Jeszcze nic tu nie ma."
},
"Settings": {
@@ -320,7 +320,7 @@
"invalidNumber": "Niewłaściwa liczba.",
"amountRequired": "Należy wprowadzić kwotę.",
"amountNotZero": "Kwota nie może być zerem.",
"amountTenMillion": "Kwota musi być niższa niż 10,000,000.",
"amountTenMillion": "Kwota musi być niższa niż 10 000 000.",
"paidByRequired": "Musisz wybrać członka.",
"paidForMin1": "Wydatek musi zostać opłacony za co najmniej jednego uczestnika.",
"noZeroShares": "Wszystkie udziały muszą być większe niż 0.",
@@ -328,7 +328,7 @@
"percentageSum": "Suma procentów musi być równa 100."
},
"Categories": {
"search": "Szukaj kategorii...",
"search": "Szukaj kategorii",
"noCategory": "Nie znaleziono kategorii.",
"Uncategorized": {
"heading": "Bez kategorii",
@@ -341,7 +341,7 @@
"Games": "Gry",
"Movies": "Filmy",
"Music": "Muzyka",
"Sports": "Sporty"
"Sports": "Sport"
},
"Food and Drink": {
"heading": "Jedzenie i Napoje",
@@ -357,7 +357,7 @@
"Furniture": "Meble",
"Household Supplies": "Artykuły gospodarstwa domowego",
"Maintenance": "Utrzymanie",
"Mortgage": "Czynsz",
"Mortgage": "Kredyt",
"Pets": "Zwierzaki",
"Rent": "Czynsz",
"Services": "Usługi"
@@ -366,6 +366,7 @@
"heading": "Życie",
"Childcare": "Opieka nad dzieckiem",
"Clothing": "Ubrania",
"Donation": "Darowizna",
"Education": "Edukacja",
"Gifts": "Prezenty",
"Insurance": "Ubezpieczenie",
@@ -376,20 +377,20 @@
"heading": "Transport",
"Transportation": "Transport",
"Bicycle": "Rower",
"Bus/Train": "Bus/Pociąg",
"Bus/Train": "Autobus/Pociąg",
"Car": "Samochód",
"Gas/Fuel": "Paliwo",
"Hotel": "Hotel",
"Parking": "Parking",
"Plane": "Pociąg",
"Taxi": "Taxi"
"Plane": "Samolot",
"Taxi": "Taksówka"
},
"Utilities": {
"heading": "Media",
"Utilities": "Media",
"Cleaning": "Sprzątanie",
"Electricity": "Prąg",
"Heat/Gas": "Ogrzewanie",
"Electricity": "Prąd",
"Heat/Gas": "Ogrzewanie/Gaz",
"Trash": "Śmieci",
"TV/Phone/Internet": "TV/Telefon/Internet",
"Water": "Woda"

View File

@@ -1,6 +1,6 @@
{
"Homepage": {
"title": "Compartilhe <strong>Despesas</strong> com <strong>Amigos e Família</strong>",
"title": "Compartilhe <strong>Despesas</strong> com <strong>Amigos e Família</strong>",
"description": "Bem-vindo à sua nova instalação do <strong>Spliit</strong>!",
"button": {
"groups": "Ir para grupos",
@@ -38,12 +38,15 @@
"earlierThisYear": "Anteriores neste ano",
"lastYear": "Ano passado",
"older": "Mais antigas"
}
},
"export": "Exportar"
},
"ExpenseCard": {
"paidBy": "Pago por <strong>{paidBy}</strong> para <paidFor></paidFor>",
"receivedBy": "Recebido por <strong>{paidBy}</strong> para <paidFor></paidFor>",
"yourBalance": "Seu saldo:"
"yourBalance": "Seu saldo:",
"everyone": "Todos",
"notInvolved": "Você não está envolvido"
},
"Groups": {
"myGroups": "Meus grupos",
@@ -117,6 +120,12 @@
"create": "Criar",
"creating": "Criando…",
"cancel": "Cancelar"
},
"CurrencyCodeField": {
"label": "Moeda principal",
"createDescription": "Todos os valores e saldos estarão nesta moeda.",
"editDescription": "Todos os valores e saldos estarão nesta moeda. A sua alteração NÃO irá converter despesas já registradas, exceto quando a moeda possuir \"unidades menores\" que a atual (ex. Alterar de Dólar Americano para Yen Japonês)",
"customOption": "Customizado"
}
},
"ExpenseForm": {
@@ -159,14 +168,23 @@
"categoryFieldDescription": "Selecione a categoria da despesa.",
"paidByField": {
"label": "Pago por",
"description": "Selecione o participante que pagou a despesa."
"description": "Selecione o participante que pagou a despesa.",
"placeholder": "Selecione um participante"
},
"paidFor": {
"title": "Pago para",
"description": "Selecione para quem a despesa foi paga."
},
"splitModeDescription": "Selecione como dividir a despesa.",
"attachDescription": "Veja e anexe recibos à despesa."
"attachDescription": "Veja e anexe recibos à despesa.",
"recurrenceRule": {
"label": "Recorrência da Despesa",
"description": "Selecione a frequência de recorrência da despesa.",
"none": "Nenhuma",
"daily": "Diariamente",
"weekly": "Semanalmente",
"monthly": "Mensalmente"
}
},
"amountField": {
"label": "Valor"
@@ -205,7 +223,15 @@
"save": "Salvar",
"saving": "Salvando…",
"cancel": "Cancelar",
"reimbursement": "Reembolso"
"reimbursement": "Reembolso",
"conversionRateField": {
"label": "Taxa de câmbio"
},
"conversionRateState": {
"success": "Taxas obtidas:",
"error": "Opa, não conseguimos obter as taxas mais recentes.",
"refresh": "Atualizar"
}
},
"ExpenseDocumentsInput": {
"TooBigToast": {
@@ -361,7 +387,8 @@
"Gifts": "Presentes",
"Insurance": "Seguro",
"Medical Expenses": "Despesas médicas",
"Taxes": "Impostos"
"Taxes": "Impostos",
"Donation": "Doação"
},
"Transportation": {
"heading": "Transporte",
@@ -385,5 +412,18 @@
"TV/Phone/Internet": "TV/Telefone/Internet",
"Water": "Água"
}
},
"Currencies": {
"search": "Pesquisar moeda...",
"noCurrency": "Nenhuma moeda encontrada.",
"custom": {
"heading": "Customizado"
},
"common": {
"heading": "Mais comum"
},
"other": {
"heading": "Outras moedas"
}
}
}

127
messages/pt.json Normal file
View File

@@ -0,0 +1,127 @@
{
"Homepage": {
"title": "Partilha <strong>Despesas</strong> com <strong>Amigos e Família</strong>",
"description": "Bem-vindo/a à tua nova instância <strong>Spliit</strong> !",
"button": {
"groups": "Ir para grupos",
"github": "GitHub"
}
},
"Header": {
"groups": "Grupos"
},
"Footer": {
"madeIn": "Feito em Montréal, Québec 🇨🇦",
"builtBy": "Construído por <author>Sebastien Castiel</author> e <source>contribuidores</source>"
},
"Expenses": {
"title": "Despesas",
"description": "Aqui estão as despesas que criaste para o teu grupo.",
"create": "Criar despesa",
"createFirst": "Criar a primeira",
"noExpenses": "O teu grupo ainda não tem nenhuma despesa.",
"export": "Exportar",
"exportJson": "Exportar para JSON",
"exportCsv": "Exportar para CSV",
"searchPlaceholder": "Pesquisar uma despesa…",
"ActiveUserModal": {
"title": "Quem és?",
"description": "Diz-nos que participante és para nos permitir customizar como a informação é apresentada.",
"nobody": "Não quero selecionar ninguém",
"save": "Guardar alterações",
"footer": "Esta definição pode ser alterada posteriormente nas definições do grupo."
},
"Groups": {
"upcoming": "Em breve",
"thisWeek": "Esta semana",
"earlierThisMonth": "No início deste mês",
"lastMonth": "Mês passado",
"earlierThisYear": "No início deste ano",
"lastYear": "Ano passado",
"older": "Mais antigos"
}
},
"ExpenseCard": {
"paidBy": "Pago por <strong>{paidBy}</strong> para <paidFor></paidFor>",
"everyone": "todos",
"receivedBy": "Recebido por <strong>{paidBy}</strong> para <paidFor></paidFor>",
"yourBalance": "O teu saldo:",
"notInvolved": "Não participaste"
},
"Groups": {
"myGroups": "Os meus grupos",
"create": "Criar",
"loadingRecent": "A carregar grupos recentes…",
"NoRecent": {
"description": "Não visitaste nenhum grupo recentemente.",
"create": "Criar um",
"orAsk": "ou pergunta a um amigo para te mandar o link de um já existente."
},
"recent": "Grupos recentes",
"starred": "Grupos favoritos",
"archived": "Grupos arquivados",
"archive": "Arquivar grupo",
"unarchive": "Desarquivar grupo",
"removeRecent": "Remover dos grupos recentes",
"RecentRemovedToast": {
"title": "O grupo foi removido",
"description": "O grupo foi removido da tua lista de grupos recentes.",
"undoAlt": "Desfazer remoção do grupo",
"undo": "Desfazer"
},
"AddByURL": {
"button": "Adicionar por URL",
"title": "Adicionar um grupo por URL",
"description": "Se um grupo foi partilhado contigo, podes colar o URL aqui para o adicionar à tua lista.",
"error": "Oops, não conseguimos encontrar o grupo pelo URL que indicaste…"
},
"NotFound": {
"text": "Este grupo não existe.",
"link": "Ir para os grupos visitados recentemente"
}
},
"GroupForm": {
"title": "Informações do grupo",
"NameField": {
"label": "Nome do grupo",
"placeholder": "Férias de verão",
"description": "Introduz um nome para o teu grupo."
},
"InformationField": {
"label": "Informações do grupo",
"placeholder": "Que informações são relevantes para os participantes do grupo?"
},
"CurrencyField": {
"label": "Símbolo da moeda",
"placeholder": "$, €, £…",
"description": "Utilizá-lo-emos para mostrar montantes."
},
"CurrencyCodeField": {
"label": "Moeda principal",
"createDescription": "Todos os montantes e saldos vão estar nesta moeda.",
"editDescription": "Todos os montantes e saldos vão estar nesta moeda. Ao alterares isto NÃO vão ser convertidas despesas já inseridas, excepto quando a moeda tem \"unidades monetárias menores\" diferentes da atual (ex. alterar de Dólar Americano para Yen Japonês)",
"customOption": "Customizado"
},
"Participants": {
"title": "Participantes",
"description": "Introduz o nome de cada participante.",
"protectedParticipant": "Este participante faz parte de despesas e não pode ser removido.",
"new": "Novo",
"add": "Adicionar participante",
"John": "João",
"Jane": "Joana",
"Jack": "Joaquim"
},
"Settings": {
"title": "Definições locais",
"description": "Estas definições são definidas por dispositivo, e são utilizadas para customizar a tua experiência.",
"ActiveUserField": {
"label": "Utilizador ativo",
"placeholder": "Selecionar um participante",
"none": "Nenhum",
"description": "Utilizador usado por defeito para pagar despesas."
},
"save": "Gravar"
}
}
}

View File

@@ -128,15 +128,6 @@
"placeholder": "Cina de luni seară",
"description": "Adaugă o descriere pentru venit."
},
"recurrenceRule": {
"label": "Expense Recurrence",
"description": "Select how often the expense should repeat.",
"none": "None",
"daily": "Daily",
"weekly": "Weekly",
"monthly": "Monthly"
},
"DateField": {
"label": "Data venitului",
"description": "Adaugă data la care venitul a fost primit."

View File

@@ -38,7 +38,8 @@
"earlierThisYear": "Ранее в этом году",
"lastYear": "В прошлом году",
"older": "Очень давно"
}
},
"export": "Экспортировать"
},
"ExpenseCard": {
"paidBy": "Потратил <strong>{paidBy}</strong> за <paidFor></paidFor>",
@@ -137,15 +138,6 @@
"label": "Получивший",
"description": "Выберите участника, который получил этот доход."
},
"recurrenceRule": {
"label": "Expense Recurrence",
"description": "Select how often the expense should repeat.",
"none": "None",
"daily": "Daily",
"weekly": "Weekly",
"monthly": "Monthly"
},
"paidFor": {
"title": "Участники",
"description": "Выберите тех, между кем этот доход будет распределен."
@@ -395,4 +387,4 @@
"Water": "Вода"
}
}
}
}

View File

@@ -137,15 +137,6 @@
"label": "Отримав",
"description": "Оберіть учасника, який отримав дохід"
},
"recurrenceRule": {
"label": "Expense Recurrence",
"description": "Select how often the expense should repeat.",
"none": "None",
"daily": "Daily",
"weekly": "Weekly",
"monthly": "Monthly"
},
"paidFor": {
"title": "Учасники",
"description": "Виберіть тих, між ким цей дохід буде розподілено"

View File

@@ -38,12 +38,15 @@
"earlierThisYear": "本年早些时候",
"lastYear": "去年",
"older": "更早"
}
},
"export": "导出"
},
"ExpenseCard": {
"paidBy": "<strong>{paidBy}</strong> 为 <paidFor></paidFor> 支付。",
"receivedBy": "<strong>{paidBy}</strong> 为 <paidFor></paidFor> 接收。",
"yourBalance": "你的余额:"
"yourBalance": "你的余额:",
"everyone": "所有人",
"notInvolved": "您无需支付"
},
"Groups": {
"myGroups": "我的群组",
@@ -117,6 +120,11 @@
"create": "创建",
"creating": "创建中",
"cancel": "取消"
},
"CurrencyCodeField": {
"label": "首选货币",
"createDescription": "所有的交易将使用此币种。",
"customOption": "自定义"
}
},
"ExpenseForm": {
@@ -137,21 +145,15 @@
"label": "接收到",
"description": "选择接收到这笔收入的群组成员。"
},
"recurrenceRule": {
"label": "Expense Recurrence",
"description": "Select how often the expense should repeat.",
"none": "None",
"daily": "Daily",
"weekly": "Weekly",
"monthly": "Monthly"
},
"paidFor": {
"title": "接收给",
"description": "选择收入是为谁而收。"
},
"splitModeDescription": "选择如何划分这笔收入。",
"attachDescription": "查看并为这笔收入附加收据。"
"attachDescription": "查看并为这笔收入附加收据。",
"currencyField": {
"label": "收入币种"
}
},
"Expense": {
"create": "创建消费",
@@ -168,14 +170,26 @@
"categoryFieldDescription": "选择消费类别。",
"paidByField": {
"label": "支付自",
"description": "选择支付这笔消费的群组成员。"
"description": "选择支付这笔消费的群组成员。",
"placeholder": "选择一个参与人"
},
"paidFor": {
"title": "支付给",
"description": "选择消费是为谁而支出。"
},
"splitModeDescription": "选择如何划分这笔消费。",
"attachDescription": "查看并为这笔消费附加收据。"
"attachDescription": "查看并为这笔消费附加收据。",
"currencyField": {
"label": "支出币种"
},
"recurrenceRule": {
"label": "订阅式支出",
"description": "请选择这笔开销发生的频率。",
"none": "无",
"daily": "每天",
"weekly": "每周",
"monthly": "每月"
}
},
"amountField": {
"label": "金额"
@@ -214,7 +228,23 @@
"save": "保存",
"saving": "保存中……",
"cancel": "取消",
"reimbursement": "报销"
"reimbursement": "报销",
"originalAmountField": {
"label": "需要转换的金额"
},
"conversionRateField": {
"useApi": "使用Frankfurter提供的汇率",
"useCustom": "使用自定义汇率",
"label": "汇率"
},
"conversionRateState": {
"error": "抱歉,我们无法获取最新的汇率信息。",
"noRate": "请在下方输入自定义汇率。",
"currencyNotFound": "抱歉Frankfurter无法为此货币提供此日期的汇率。",
"noDate": "请输入交易发生日期来获取当天的汇率。",
"refresh": "刷新",
"customRate": "使用自定义汇率"
}
},
"ExpenseDocumentsInput": {
"TooBigToast": {
@@ -325,7 +355,8 @@
"paidForMin1": "这项消费必须支付给至少1名群组成员。",
"noZeroShares": "所有份额必须大于0。",
"amountSum": "金额之和必须等于消费的金额。",
"percentageSum": "百分比之和必须等于100。"
"percentageSum": "百分比之和必须等于100。",
"ratePositive": "汇率必须为正数大于0。"
},
"Categories": {
"search": "搜寻类别……",
@@ -370,7 +401,8 @@
"Gifts": "礼物",
"Insurance": "保险",
"Medical Expenses": "医疗支出",
"Taxes": "税"
"Taxes": "税",
"Donation": "捐赠"
},
"Transportation": {
"heading": "交通",
@@ -394,5 +426,18 @@
"TV/Phone/Internet": "电视/手机/互联网",
"Water": "水"
}
},
"Currencies": {
"search": "搜索币种...",
"noCurrency": "无法找到此货币。",
"custom": {
"heading": "自定义"
},
"common": {
"heading": "最常用"
},
"other": {
"heading": "其他币种"
}
}
}
}

6831
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,33 +6,34 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"lint": "eslint .",
"check-types": "tsc --noEmit",
"check-formatting": "prettier -c src",
"prettier": "prettier -w src",
"postinstall": "prisma migrate deploy && prisma generate",
"build-image": "./scripts/build-image.sh",
"start-container": "docker compose --env-file container.env up",
"test": "jest"
"test": "jest",
"generate-currency-data": "ts-node -T ./src/scripts/generateCurrencyData.ts"
},
"dependencies": {
"@formatjs/intl-localematcher": "^0.5.4",
"@hookform/resolvers": "^3.3.2",
"@json2csv/plainjs": "^7.0.6",
"@prisma/client": "^5.6.0",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-collapsible": "^1.0.3",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-hover-card": "^1.0.7",
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-radio-group": "^1.1.3",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-toast": "^1.1.5",
"@prisma/client": "^6.18.0",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toast": "^1.2.15",
"@tailwindcss/typography": "^0.5.10",
"@tanstack/react-query": "^5.59.15",
"@trpc/client": "^11.0.0-rc.586",
@@ -41,40 +42,41 @@
"class-variance-authority": "^0.7.0",
"client-only": "^0.0.1",
"clsx": "^2.0.0",
"cmdk": "^0.2.0",
"cmdk": "^1.1.1",
"content-disposition": "^0.5.4",
"dayjs": "^1.11.10",
"embla-carousel-react": "^8.0.0-rc21",
"embla-carousel-react": "^8.6.0",
"lucide-react": "^0.501.0",
"nanoid": "^5.0.4",
"negotiator": "^0.6.3",
"next": "^14.2.5",
"next-intl": "^3.17.2",
"next": "^16.0.7",
"next-intl": "^4.5.8",
"next-s3-upload": "^0.3.4",
"next-themes": "^0.2.1",
"next13-progressbar": "^1.1.1",
"openai": "^4.25.0",
"pg": "^8.11.3",
"prisma": "^5.7.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.47.0",
"react-intersection-observer": "^9.8.0",
"prisma": "^6.18.0",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"react-hook-form": "^7.68.0",
"react-intersection-observer": "^10.0.0",
"server-only": "^0.0.1",
"sharp": "^0.33.2",
"superjson": "^2.2.1",
"swr": "^2.3.3",
"tailwind-merge": "^1.14.0",
"tailwindcss-animate": "^1.0.7",
"ts-pattern": "^5.0.6",
"use-debounce": "^10.0.4",
"uuid": "^9.0.1",
"vaul": "^0.8.0",
"vaul": "^1.1.2",
"zod": "^3.23.8"
},
"devDependencies": {
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.4.8",
"@testing-library/react": "^16.0.0",
"@testing-library/react": "^16.3.0",
"@total-typescript/ts-reset": "^0.5.1",
"@types/content-disposition": "^0.5.8",
"@types/jest": "^29.5.12",
@@ -85,9 +87,10 @@
"@types/react-dom": "^18.2.18",
"@types/uuid": "^9.0.6",
"autoprefixer": "^10",
"currency-list": "^1.0.8",
"dotenv": "^16.3.1",
"eslint": "^8",
"eslint-config-next": "^14.1.0",
"eslint": "^9.39.1",
"eslint-config-next": "^16.0.7",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"postcss": "^8",

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Group" ADD COLUMN "currencyCode" TEXT;

View File

@@ -0,0 +1,4 @@
-- AlterTable
ALTER TABLE "Expense" ADD COLUMN "conversionRate" DECIMAL(65,30),
ADD COLUMN "originalAmount" INTEGER,
ADD COLUMN "originalCurrency" TEXT;

View File

@@ -0,0 +1,5 @@
-- DropForeignKey
ALTER TABLE "Activity" DROP CONSTRAINT "Activity_groupId_fkey";
-- AddForeignKey
ALTER TABLE "Activity" ADD CONSTRAINT "Activity_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "Group"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -16,6 +16,7 @@ model Group {
name String
information String? @db.Text
currency String @default("$")
currencyCode String?
participants Participant[]
expenses Expense[]
activities Activity[]
@@ -39,25 +40,28 @@ model Category {
}
model Expense {
id String @id
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
expenseDate DateTime @default(dbgenerated("CURRENT_DATE")) @db.Date
title String
category Category? @relation(fields: [categoryId], references: [id])
categoryId Int @default(0)
amount Int
paidBy Participant @relation(fields: [paidById], references: [id], onDelete: Cascade)
paidById String
paidFor ExpensePaidFor[]
groupId String
isReimbursement Boolean @default(false)
splitMode SplitMode @default(EVENLY)
createdAt DateTime @default(now())
documents ExpenseDocument[]
notes String?
id String @id
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
expenseDate DateTime @default(dbgenerated("CURRENT_DATE")) @db.Date
title String
category Category? @relation(fields: [categoryId], references: [id])
categoryId Int @default(0)
amount Int
originalAmount Int?
originalCurrency String?
conversionRate Decimal?
paidBy Participant @relation(fields: [paidById], references: [id], onDelete: Cascade)
paidById String
paidFor ExpensePaidFor[]
groupId String
isReimbursement Boolean @default(false)
splitMode SplitMode @default(EVENLY)
createdAt DateTime @default(now())
documents ExpenseDocument[]
notes String?
recurrenceRule RecurrenceRule? @default(NONE)
recurringExpenseLink RecurringExpenseLink?
recurrenceRule RecurrenceRule? @default(NONE)
recurringExpenseLink RecurringExpenseLink?
recurringExpenseLinkId String?
}
@@ -78,16 +82,16 @@ enum SplitMode {
}
model RecurringExpenseLink {
id String @id
groupId String
currentFrameExpense Expense @relation(fields: [currentFrameExpenseId], references: [id], onDelete: Cascade)
currentFrameExpenseId String @unique
id String @id
groupId String
currentFrameExpense Expense @relation(fields: [currentFrameExpenseId], references: [id], onDelete: Cascade)
currentFrameExpenseId String @unique
// Note: We do not want to link to the next expense because once it is created, it should be
// treated as it's own independent entity. This means that if a user wants to delete an Expense
// and any prior related recurring expenses, they'll need to delete them one by one.
nextExpenseCreatedAt DateTime?
nextExpenseDate DateTime
nextExpenseDate DateTime
@@index([groupId])
@@index([groupId, nextExpenseCreatedAt, nextExpenseDate(sort: Desc)])
@@ -112,7 +116,7 @@ model ExpensePaidFor {
model Activity {
id String @id
group Group @relation(fields: [groupId], references: [id])
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
groupId String
time DateTime @default(now())
activityType ActivityType

View File

@@ -6,6 +6,6 @@ else
echo "postgres is not running, starting it"
docker rm postgres --force
mkdir -p postgres-data
docker run --name spliit-db -d -p 5432:5432 -e POSTGRES_PASSWORD=1234 -v "/$(pwd)/postgres-data:/var/lib/postgresql/data" postgres
docker run --name spliit-db -d -p 5432:5432 -e POSTGRES_PASSWORD=1234 -v "/$(pwd)/postgres-data:/var/lib/postgresql" postgres
sleep 5 # Wait for postgres to start
fi
fi

View File

@@ -0,0 +1,7 @@
import { checkLiveness } from '@/lib/health'
// Liveness: Is the app itself healthy? (no external dependencies)
// If this fails, Kubernetes should restart the pod
export async function GET() {
return checkLiveness()
}

View File

@@ -0,0 +1,7 @@
import { checkReadiness } from '@/lib/health'
// Readiness: Can the app serve requests? (includes all external dependencies)
// If this fails, Kubernetes should stop sending traffic but not restart
export async function GET() {
return checkReadiness()
}

View File

@@ -0,0 +1,7 @@
import { checkReadiness } from '@/lib/health'
// Default health check - same as readiness (includes database check)
// This is readiness-focused for monitoring tools like uptime-kuma
export async function GET() {
return checkReadiness()
}

View File

@@ -1,4 +1,5 @@
import { Balances } from '@/lib/balances'
import { Currency } from '@/lib/currency'
import { cn, formatCurrency } from '@/lib/utils'
import { Participant } from '@prisma/client'
import { useLocale } from 'next-intl'
@@ -6,7 +7,7 @@ import { useLocale } from 'next-intl'
type Props = {
balances: Balances
participants: Participant[]
currency: string
currency: Currency
}
export function BalancesList({ balances, participants, currency }: Props) {

View File

@@ -10,6 +10,7 @@ import {
CardTitle,
} from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { getCurrencyFromGroup } from '@/lib/utils'
import { trpc } from '@/trpc/client'
import { useTranslations } from 'next-intl'
import { Fragment, useEffect } from 'react'
@@ -47,7 +48,7 @@ export default function BalancesAndReimbursements() {
<BalancesList
balances={balancesData.balances}
participants={group?.participants}
currency={group?.currency}
currency={getCurrencyFromGroup(group)}
/>
)}
</CardContent>
@@ -66,7 +67,7 @@ export default function BalancesAndReimbursements() {
<ReimbursementList
reimbursements={balancesData.reimbursements}
participants={group?.participants}
currency={group?.currency}
currency={getCurrencyFromGroup(group)}
groupId={groupId}
/>
)}

View File

@@ -7,10 +7,11 @@ export const metadata: Metadata = {
}
export default async function EditExpensePage({
params: { groupId, expenseId },
params,
}: {
params: { groupId: string; expenseId: string }
params: Promise<{ groupId: string; expenseId: string }>
}) {
const { groupId, expenseId } = await params
return (
<EditExpenseForm
groupId={groupId}

View File

@@ -1,12 +1,13 @@
'use client'
import { Money } from '@/components/money'
import { getBalances } from '@/lib/balances'
import { Currency } from '@/lib/currency'
import { useActiveUser } from '@/lib/hooks'
import { useTranslations } from 'next-intl'
type Props = {
groupId: string
currency: string
currency: Currency
expense: Parameters<typeof getBalances>[0][number]
}
@@ -18,7 +19,7 @@ export function ActiveUserBalance({ groupId, currency, expense }: Props) {
}
const balances = getBalances([expense])
let fmtBalance = <>You are not involved</>
let fmtBalance = <>{t('notInvolved')}</>
if (Object.hasOwn(balances, activeUserId)) {
const balance = balances[activeUserId]
let balanceDetail = <></>

View File

@@ -48,6 +48,7 @@ export function CategoryIcon({
...props
}: { category: Category | null } & LucideProps) {
const Icon = getCategoryIcon(`${category?.grouping}/${category?.name}`)
// eslint-disable-next-line react-hooks/static-components
return <Icon {...props} />
}

View File

@@ -12,7 +12,7 @@ export async function extractExpenseInformationFromImage(imageUrl: string) {
const categories = await getCategories()
const body: ChatCompletionCreateParamsNonStreaming = {
model: 'gpt-4-turbo',
model: 'gpt-5-nano',
messages: [
{
role: 'user',

View File

@@ -26,7 +26,12 @@ import {
import { ToastAction } from '@/components/ui/toast'
import { useToast } from '@/components/ui/use-toast'
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 { ChevronRight, FileQuestion, Loader2, Receipt } from 'lucide-react'
import { useLocale, useTranslations } from 'next-intl'
@@ -191,7 +196,7 @@ function ReceiptDialogContent() {
<Unknown />
)
) : (
'' || '…'
''
)}
</div>
</div>
@@ -202,7 +207,7 @@ function ReceiptDialogContent() {
receiptInfo.amount ? (
<>
{formatCurrency(
group.currency,
getCurrencyFromGroup(group),
receiptInfo.amount,
locale,
true,

View File

@@ -7,10 +7,11 @@ export const metadata: Metadata = {
}
export default async function ExpensePage({
params: { groupId },
params,
}: {
params: { groupId: string }
params: Promise<{ groupId: string }>
}) {
const { groupId } = await params
return (
<CreateExpenseForm
groupId={groupId}

View File

@@ -4,7 +4,8 @@ import { CategoryIcon } from '@/app/groups/[groupId]/expenses/category-icon'
import { DocumentsCount } from '@/app/groups/[groupId]/expenses/documents-count'
import { Button } from '@/components/ui/button'
import { getGroupExpenses } from '@/lib/api'
import { cn, formatCurrency, formatDate } from '@/lib/utils'
import { Currency } from '@/lib/currency'
import { cn, formatCurrency, formatDateOnly } from '@/lib/utils'
import { ChevronRight } from 'lucide-react'
import { useLocale, useTranslations } from 'next-intl'
import Link from 'next/link'
@@ -13,15 +14,27 @@ import { Fragment } from 'react'
type Expense = Awaited<ReturnType<typeof getGroupExpenses>>[number]
function Participants({ expense }: { expense: Expense }) {
function Participants({
expense,
participantCount,
}: {
expense: Expense
participantCount: number
}) {
const t = useTranslations('ExpenseCard')
const key = expense.amount > 0 ? 'paidBy' : 'receivedBy'
const paidFor = expense.paidFor.map((paidFor, index) => (
<Fragment key={index}>
{index !== 0 && <>, </>}
<strong>{paidFor.participant.name}</strong>
</Fragment>
))
const paidFor =
expense.paidFor.length == participantCount && participantCount >= 4 ? (
<strong>{t('everyone')}</strong>
) : (
expense.paidFor.map((paidFor, index) => (
<Fragment key={index}>
{index !== 0 && <>, </>}
<strong>{paidFor.participant.name}</strong>
</Fragment>
))
)
const participants = t.rich(key, {
strong: (chunks) => <strong>{chunks}</strong>,
paidBy: expense.paidBy.name,
@@ -33,11 +46,17 @@ function Participants({ expense }: { expense: Expense }) {
type Props = {
expense: Expense
currency: string
currency: Currency
groupId: string
participantCount: number
}
export function ExpenseCard({ expense, currency, groupId }: Props) {
export function ExpenseCard({
expense,
currency,
groupId,
participantCount,
}: Props) {
const router = useRouter()
const locale = useLocale()
@@ -61,7 +80,7 @@ export function ExpenseCard({ expense, currency, groupId }: Props) {
{expense.title}
</div>
<div className="text-xs text-muted-foreground">
<Participants expense={expense} />
<Participants expense={expense} participantCount={participantCount} />
</div>
<div className="text-xs text-muted-foreground">
<ActiveUserBalance {...{ groupId, currency, expense }} />
@@ -80,7 +99,7 @@ export function ExpenseCard({ expense, currency, groupId }: Props) {
<DocumentsCount count={expense._count.documents} />
</div>
<div className="text-xs text-muted-foreground">
{formatDate(expense.expenseDate, locale, { dateStyle: 'medium' })}
{formatDateOnly(expense.expenseDate, locale, { dateStyle: 'medium' })}
</div>
</div>
<Button

View File

@@ -1,4 +1,5 @@
import { CategorySelector } from '@/components/category-selector'
import { CurrencySelector } from '@/components/currency-selector'
import { ExpenseDocumentsInput } from '@/components/expense-documents-input'
import { SubmitButton } from '@/components/submit-button'
import { Button } from '@/components/ui/button'
@@ -32,20 +33,29 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Locale } from '@/i18n/request'
import { randomId } from '@/lib/api'
import { defaultCurrencyList, getCurrency } from '@/lib/currency'
import { RuntimeFeatureFlags } from '@/lib/featureFlags'
import { useActiveUser } from '@/lib/hooks'
import { useActiveUser, useCurrencyRate } from '@/lib/hooks'
import {
ExpenseFormValues,
SplittingOptions,
expenseFormSchema,
} from '@/lib/schemas'
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 { zodResolver } from '@hookform/resolvers/zod'
import { Save } from 'lucide-react'
import { useTranslations } from 'next-intl'
import { RecurrenceRule } from '@prisma/client'
import { ChevronRight, Save } from 'lucide-react'
import { useLocale, useTranslations } from 'next-intl'
import Link from 'next/link'
import { useSearchParams } from 'next/navigation'
import { useEffect, useState } from 'react'
@@ -54,7 +64,6 @@ import { match } from 'ts-pattern'
import { DeletePopup } from '../../../../components/delete-popup'
import { extractCategoryFromTitle } from '../../../../components/expense-form-actions'
import { Textarea } from '../../../../components/ui/textarea'
import { RecurrenceRule } from '@prisma/client'
const enforceCurrencyPattern = (value: string) =>
value
@@ -72,7 +81,7 @@ const getDefaultSplittingOptions = (
splitMode: 'EVENLY' as const,
paidFor: group.participants.map(({ id }) => ({
participant: id,
shares: '1' as unknown as number,
shares: '1' as any, // Use string to ensure consistent schema handling
})),
}
@@ -104,7 +113,7 @@ const getDefaultSplittingOptions = (
splitMode: parsedDefaultSplitMode.splitMode,
paidFor: parsedDefaultSplitMode.paidFor.map((paidFor) => ({
participant: paidFor.participant,
shares: String(paidFor.shares / 100) as unknown as number,
shares: (paidFor.shares / 100).toString() as any, // Convert to string for consistent schema handling
})),
}
}
@@ -118,7 +127,7 @@ async function persistDefaultSplittingOptions(
if (expenseFormValues.splitMode === 'EVENLY') {
return expenseFormValues.paidFor.map(({ participant }) => ({
participant,
shares: '100' as unknown as number,
shares: 100,
}))
} else if (expenseFormValues.splitMode === 'BY_AMOUNT') {
return null
@@ -155,6 +164,7 @@ export function ExpenseForm({
runtimeFeatureFlags: RuntimeFeatureFlags
}) {
const t = useTranslations('ExpenseForm')
const locale = useLocale() as Locale
const isCreate = expense === undefined
const searchParams = useSearchParams()
@@ -172,40 +182,50 @@ export function ExpenseForm({
return field?.value as RecurrenceRule
}
const defaultSplittingOptions = getDefaultSplittingOptions(group)
const groupCurrency = getCurrencyFromGroup(group)
const form = useForm<ExpenseFormValues>({
resolver: zodResolver(expenseFormSchema),
defaultValues: expense
? {
title: expense.title,
expenseDate: expense.expenseDate ?? new Date(),
amount: String(expense.amount / 100) as unknown as number, // hack
amount: amountAsDecimal(expense.amount, groupCurrency),
originalCurrency: expense.originalCurrency ?? group.currencyCode,
originalAmount: expense.originalAmount ?? undefined,
conversionRate: expense.conversionRate?.toNumber(),
category: expense.categoryId,
paidBy: expense.paidById,
paidFor: expense.paidFor.map(({ participantId, shares }) => ({
participant: participantId,
shares: String(shares / 100) as unknown as number,
shares: (expense.splitMode === 'BY_AMOUNT'
? amountAsDecimal(shares, groupCurrency)
: (shares / 100).toString()) as any, // Convert to string to ensure consistent handling
})),
splitMode: expense.splitMode,
saveDefaultSplittingOptions: false,
isReimbursement: expense.isReimbursement,
documents: expense.documents,
notes: expense.notes ?? '',
recurrenceRule: expense.recurrenceRule,
recurrenceRule: expense.recurrenceRule ?? undefined,
}
: searchParams.get('reimbursement')
? {
title: t('reimbursement'),
expenseDate: new Date(),
amount: String(
(Number(searchParams.get('amount')) || 0) / 100,
) as unknown as number, // hack
amount: amountAsDecimal(
Number(searchParams.get('amount')) || 0,
groupCurrency,
),
originalCurrency: group.currencyCode,
originalAmount: undefined,
conversionRate: undefined,
category: 1, // category with Id 1 is Payment
paidBy: searchParams.get('from') ?? undefined,
paidFor: [
searchParams.get('to')
? {
participant: searchParams.get('to')!,
shares: '1' as unknown as number,
shares: '1' as any, // String for consistent form handling
}
: undefined,
],
@@ -221,7 +241,10 @@ export function ExpenseForm({
expenseDate: searchParams.get('date')
? new Date(searchParams.get('date') as string)
: new Date(),
amount: (searchParams.get('amount') || 0) as unknown as number, // hack,
amount: Number(searchParams.get('amount')) || 0,
originalCurrency: group.currencyCode ?? undefined,
originalAmount: undefined,
conversionRate: undefined,
category: searchParams.get('categoryId')
? Number(searchParams.get('categoryId'))
: 0, // category with Id 0 is General
@@ -250,6 +273,22 @@ export function ExpenseForm({
const submit = async (values: ExpenseFormValues) => {
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,
}))
// Currency should be blank if same as group currency
if (!conversionRequired) {
delete values.originalAmount
delete values.originalCurrency
}
return onSubmit(values, activeUserId ?? undefined)
}
@@ -260,6 +299,23 @@ export function ExpenseForm({
const sExpense = isIncome ? 'Income' : 'Expense'
const originalCurrency = getCurrency(
form.getValues('originalCurrency'),
locale,
'Custom',
)
const exchangeRate = useCurrencyRate(
form.watch('expenseDate'),
form.watch('originalCurrency') ?? '',
groupCurrency.code,
)
const conversionRequired =
group.currencyCode &&
group.currencyCode.length &&
originalCurrency.code.length &&
originalCurrency.code !== group.currencyCode
useEffect(() => {
setManuallyEditedParticipants(new Set())
}, [form.watch('splitMode'), form.watch('amount')])
@@ -302,9 +358,9 @@ export function ExpenseForm({
if (!editedParticipants.includes(participant.participant)) {
return {
...participant,
shares: String(
Number(amountPerRemaining.toFixed(2)),
) as unknown as number,
shares: amountPerRemaining.toFixed(
groupCurrency.decimal_digits,
) as any, // Keep as string for consistent schema handling
}
}
return participant
@@ -318,6 +374,71 @@ export function ExpenseForm({
form.watch('splitMode'),
])
const [usingCustomConversionRate, setUsingCustomConversionRate] = useState(
!!form.formState.defaultValues?.conversionRate,
)
useEffect(() => {
if (!usingCustomConversionRate && exchangeRate.data) {
form.setValue('conversionRate', exchangeRate.data)
}
}, [exchangeRate.data, usingCustomConversionRate])
useEffect(() => {
if (!form.getFieldState('originalAmount').isTouched) return
const originalAmount = form.getValues('originalAmount') ?? 0
const conversionRate = form.getValues('conversionRate')
if (conversionRate && originalAmount) {
const rate = Number(conversionRate)
const convertedAmount = originalAmount * rate
if (!Number.isNaN(convertedAmount)) {
const v = enforceCurrencyPattern(
convertedAmount.toFixed(groupCurrency.decimal_digits),
)
const income = Number(v) < 0
setIsIncome(income)
if (income) form.setValue('isReimbursement', false)
form.setValue('amount', Number(v))
}
}
}, [
form.watch('originalAmount'),
form.watch('conversionRate'),
form.getFieldState('originalAmount').isTouched,
])
let conversionRateMessage = ''
if (exchangeRate.isLoading) {
conversionRateMessage = t('conversionRateState.loading')
} else {
let ratesDisplay = ''
if (exchangeRate.data) {
// non breaking spaces so the rate text is not split with line feeds
ratesDisplay = `${form.getValues('originalCurrency')}\xa01\xa0=\xa0${
group.currencyCode
}\xa0${exchangeRate.data}`
}
if (exchangeRate.error) {
if (exchangeRate.error instanceof RangeError && exchangeRate.data)
conversionRateMessage = t('conversionRateState.dateMismatch', {
date: exchangeRate.error.message,
})
else {
conversionRateMessage = t('conversionRateState.error')
}
conversionRateMessage +=
' ' +
(ratesDisplay.length
? `${t('conversionRateState.staleRate')} ${ratesDisplay}`
: t('conversionRateState.noRate'))
} else {
conversionRateMessage = ratesDisplay.length
? `${t('conversionRateState.success')} ${ratesDisplay}`
: t('conversionRateState.currencyNotFound')
}
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(submit)}>
@@ -384,11 +505,175 @@ export function ExpenseForm({
)}
/>
<FormField
name="originalCurrency"
render={({ field: { onChange, ...field } }) => (
<FormItem className="sm:order-3">
<FormLabel>{t(`${sExpense}.currencyField.label`)}</FormLabel>
<FormControl>
{group.currencyCode ? (
<CurrencySelector
currencies={defaultCurrencyList(locale, '')}
defaultValue={form.watch(field.name) ?? ''}
isLoading={false}
onValueChange={(v) => onChange(v)}
/>
) : (
<Input
className="text-base"
disabled={true}
{...field}
placeholder={group.currency}
/>
)}
</FormControl>
<FormDescription>
{t(`${sExpense}.currencyField.description`)}{' '}
{!group.currencyCode && t('conversionUnavailable')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div
className={`sm:order-4 ${
!conversionRequired ? 'max-sm:hidden sm:invisible' : ''
} col-span-2 md:col-span-1 space-y-2`}
>
<FormField
control={form.control}
name="originalAmount"
render={({ field: { onChange, ...field } }) => (
<FormItem>
<FormLabel>{t('originalAmountField.label')}</FormLabel>
<div className="flex items-baseline gap-2">
<span>{originalCurrency.symbol}</span>
<FormControl>
<Input
className="text-base max-w-[120px]"
type="text"
inputMode="decimal"
placeholder="0.00"
onChange={(event) => {
const v = enforceCurrencyPattern(event.target.value)
onChange(v)
}}
{...field}
onFocus={(e) => {
const target = e.currentTarget
setTimeout(() => target.select(), 1)
}}
/>
</FormControl>
</div>
<FormDescription>
{isNaN(form.getValues('expenseDate').getTime()) ? (
t('conversionRateState.noDate')
) : form.getValues('expenseDate') &&
!usingCustomConversionRate ? (
<>
{conversionRateMessage}
{!exchangeRate.isLoading && (
<Button
className="h-auto py-0"
variant="link"
onClick={() => exchangeRate.refresh()}
>
{t('conversionRateState.refresh')}
</Button>
)}
</>
) : (
t('conversionRateState.customRate')
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Collapsible
open={usingCustomConversionRate}
onOpenChange={setUsingCustomConversionRate}
>
<CollapsibleTrigger asChild>
<Button variant="link" className="-mx-4">
{usingCustomConversionRate
? t('conversionRateField.useApi')
: t('conversionRateField.useCustom')}
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<FormField
control={form.control}
name="conversionRate"
render={({ field: { onChange, ...field } }) => (
<FormItem
className={`sm:order-4 ${
!conversionRequired
? 'max-sm:hidden sm:invisible'
: ''
}`}
>
<FormLabel>{t('conversionRateField.label')}</FormLabel>
<div className="flex items-baseline gap-2">
<span>
{originalCurrency.symbol} 1 = {group.currency}
</span>
<FormControl>
<Input
className="text-base max-w-[120px]"
type="text"
inputMode="decimal"
placeholder="0.00"
onChange={(event) => {
const v = enforceCurrencyPattern(
event.target.value,
)
onChange(v)
}}
{...field}
onFocus={(e) => {
const target = e.currentTarget
setTimeout(() => target.select(), 1)
}}
/>
</FormControl>
</div>
<FormMessage />
</FormItem>
)}
/>
</CollapsibleContent>
</Collapsible>
</div>
<FormField
control={form.control}
name="category"
render={({ field }) => (
<FormItem className="order-3 sm:order-2">
<FormLabel>{t('categoryField.label')}</FormLabel>
<CategorySelector
categories={categories}
defaultValue={
form.watch(field.name) // may be overwritten externally
}
onValueChange={field.onChange}
isLoading={isCategoryLoading}
/>
<FormDescription>
{t(`${sExpense}.categoryFieldDescription`)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="amount"
render={({ field: { onChange, ...field } }) => (
<FormItem className="sm:order-3">
<FormItem className="sm:order-5">
<FormLabel>{t('amountField.label')}</FormLabel>
<div className="flex items-baseline gap-2">
<span>{group.currency}</span>
@@ -441,28 +726,6 @@ export function ExpenseForm({
)}
/>
<FormField
control={form.control}
name="category"
render={({ field }) => (
<FormItem className="order-3 sm:order-2">
<FormLabel>{t('categoryField.label')}</FormLabel>
<CategorySelector
categories={categories}
defaultValue={
form.watch(field.name) // may be overwritten externally
}
onValueChange={field.onChange}
isLoading={isCategoryLoading}
/>
<FormDescription>
{t(`${sExpense}.categoryFieldDescription`)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="paidBy"
@@ -474,7 +737,9 @@ export function ExpenseForm({
defaultValue={getSelectedPayer(field)}
>
<SelectTrigger>
<SelectValue placeholder="Select a participant" />
<SelectValue
placeholder={t(`${sExpense}.paidByField.placeholder`)}
/>
</SelectTrigger>
<SelectContent>
{group.participants.map(({ id, name }) => (
@@ -516,7 +781,7 @@ export function ExpenseForm({
defaultValue={getSelectedRecurrenceRule(field)}
>
<SelectTrigger>
<SelectValue placeholder="NONE"/>
<SelectValue placeholder="NONE" />
</SelectTrigger>
<SelectContent>
<SelectItem value="NONE">
@@ -559,11 +824,11 @@ export function ExpenseForm({
? []
: group.participants.map((p) => ({
participant: p.id,
shares:
paidFor.find((pfor) => pfor.participant === p.id)
?.shares ?? ('1' as unknown as number),
shares: (paidFor.find(
(pfor) => pfor.participant === p.id,
)?.shares ?? '1') as any, // Use string to ensure consistent schema handling
}))
form.setValue('paidFor', newPaidFor, {
form.setValue('paidFor', newPaidFor as any, {
shouldDirty: true,
shouldTouch: true,
shouldValidate: true,
@@ -599,7 +864,7 @@ export function ExpenseForm({
data-id={`${id}/${form.getValues().splitMode}/${
group.currency
}`}
className="flex items-center border-t last-of-type:border-b last-of-type:!mb-4 -mx-6 px-6 py-3"
className="flex flex-wrap gap-y-4 items-center border-t last-of-type:border-b last-of-type:!mb-4 -mx-6 px-6 py-3"
>
<FormItem className="flex-1 flex flex-row items-start space-x-3 space-y-0">
<FormControl>
@@ -620,9 +885,9 @@ export function ExpenseForm({
...field.value,
{
participant: id,
shares: '1' as unknown as number,
shares: '1', // Use string to ensure consistent schema handling
},
],
] as any,
options,
)
: form.setValue(
@@ -642,11 +907,14 @@ export function ExpenseForm({
) &&
!form.watch('isReimbursement') && (
<span className="text-muted-foreground ml-2">
({group.currency}{' '}
{(
(
{formatCurrency(
groupCurrency,
calculateShare(id, {
amount:
Number(form.watch('amount')) * 100, // Convert to cents
amount: amountAsMinorUnits(
Number(form.watch('amount')),
groupCurrency,
), // Convert to cents
paidFor: field.value.map(
({ participant, shares }) => ({
participant: {
@@ -656,10 +924,14 @@ export function ExpenseForm({
},
shares:
form.watch('splitMode') ===
'BY_PERCENTAGE' ||
form.watch('splitMode') ===
'BY_AMOUNT'
'BY_PERCENTAGE'
? Number(shares) * 100 // Convert percentage to basis points (e.g., 50% -> 5000)
: form.watch('splitMode') ===
'BY_AMOUNT'
? amountAsMinorUnits(
shares,
groupCurrency,
)
: shares,
expenseId: '',
participantId: '',
@@ -668,113 +940,217 @@ export function ExpenseForm({
splitMode: form.watch('splitMode'),
isReimbursement:
form.watch('isReimbursement'),
}) / 100
).toFixed(2)}
}),
locale,
)}
)
</span>
)}
</FormLabel>
</FormItem>
{form.getValues().splitMode !== 'EVENLY' && (
<FormField
name={`paidFor[${field.value.findIndex(
({ participant }) => participant === id,
)}].shares`}
render={() => {
const sharesLabel = (
<span
className={cn('text-sm', {
'text-muted': !field.value?.some(
({ participant }) =>
participant === id,
),
})}
>
{match(form.getValues().splitMode)
.with('BY_SHARES', () => (
<>{t('shares')}</>
))
.with('BY_PERCENTAGE', () => <>%</>)
.with('BY_AMOUNT', () => (
<>{group.currency}</>
))
.otherwise(() => (
<></>
))}
</span>
)
return (
<div>
<div className="flex gap-1 items-center">
{form.getValues().splitMode ===
'BY_AMOUNT' && sharesLabel}
<FormControl>
<Input
key={String(
!field.value?.some(
({ participant }) =>
participant === id,
),
)}
className="text-base w-[80px] -my-2"
type="text"
disabled={
!field.value?.some(
({ participant }) =>
participant === id,
)
}
value={
field.value?.find(
({ participant }) =>
participant === id,
)?.shares
}
onChange={(event) => {
field.onChange(
field.value.map((p) =>
p.participant === id
? {
participant: id,
shares:
enforceCurrencyPattern(
event.target.value,
),
}
: p,
<div className="flex">
{form.getValues().splitMode === 'BY_AMOUNT' &&
!!conversionRequired && (
<FormField
name={`paidFor[${field.value.findIndex(
({ participant }) => participant === id,
)}].originalAmount`}
render={() => {
const sharesLabel = (
<span
className={cn('text-sm', {
'text-muted': !field.value?.some(
({ participant }) =>
participant === id,
),
})}
>
{originalCurrency.symbol}
</span>
)
return (
<div>
<div className="flex gap-1 items-center">
{sharesLabel}
<FormControl>
<Input
key={String(
!field.value?.some(
({ participant }) =>
participant === id,
),
)}
className="text-base w-[80px] -my-2"
type="text"
inputMode="decimal"
disabled={
!field.value?.some(
({ participant }) =>
participant === id,
)
}
value={
field.value.find(
({ participant }) =>
participant === id,
)?.originalAmount ?? ''
}
onChange={(event) => {
const originalAmount = Number(
event.target.value,
)
let convertedAmount = ''
if (
!Number.isNaN(
originalAmount,
) &&
exchangeRate.data
) {
convertedAmount = (
originalAmount *
exchangeRate.data
).toFixed(
groupCurrency.decimal_digits,
)
}
field.onChange(
field.value.map((p) =>
p.participant === id
? {
participant: id,
originalAmount:
event.target
.value,
shares:
enforceCurrencyPattern(
convertedAmount,
),
}
: p,
),
)
setManuallyEditedParticipants(
(prev) =>
new Set(prev).add(id),
)
}}
step={
10 **
-originalCurrency.decimal_digits
}
/>
</FormControl>
<ChevronRight className="h-4 w-4 mx-1 opacity-50" />
</div>
</div>
)
}}
/>
)}
{form.getValues().splitMode !== 'EVENLY' && (
<FormField
name={`paidFor[${field.value.findIndex(
({ participant }) => participant === id,
)}].shares`}
render={() => {
const sharesLabel = (
<span
className={cn('text-sm', {
'text-muted': !field.value?.some(
({ participant }) =>
participant === id,
),
})}
>
{match(form.getValues().splitMode)
.with('BY_SHARES', () => (
<>{t('shares')}</>
))
.with('BY_PERCENTAGE', () => <>%</>)
.with('BY_AMOUNT', () => (
<>{group.currency}</>
))
.otherwise(() => (
<></>
))}
</span>
)
return (
<div>
<div className="flex gap-1 items-center">
{form.getValues().splitMode ===
'BY_AMOUNT' && sharesLabel}
<FormControl>
<Input
key={String(
!field.value?.some(
({ participant }) =>
participant === id,
),
)
setManuallyEditedParticipants(
(prev) => new Set(prev).add(id),
)
}}
inputMode={
form.getValues().splitMode ===
'BY_AMOUNT'
? 'decimal'
: 'numeric'
}
step={
form.getValues().splitMode ===
'BY_AMOUNT'
? 0.01
: 1
}
/>
</FormControl>
{[
'BY_SHARES',
'BY_PERCENTAGE',
].includes(
form.getValues().splitMode,
) && sharesLabel}
)}
className="text-base w-[80px] -my-2"
type="text"
disabled={
!field.value?.some(
({ participant }) =>
participant === id,
)
}
value={
field.value?.find(
({ participant }) =>
participant === id,
)?.shares
}
onChange={(event) => {
field.onChange(
field.value.map((p) =>
p.participant === id
? {
participant: id,
shares:
enforceCurrencyPattern(
event.target
.value,
),
}
: p,
),
)
setManuallyEditedParticipants(
(prev) =>
new Set(prev).add(id),
)
}}
inputMode={
form.getValues().splitMode ===
'BY_AMOUNT'
? 'decimal'
: 'numeric'
}
step={
form.getValues().splitMode ===
'BY_AMOUNT'
? 10 **
-groupCurrency.decimal_digits
: 1
}
/>
</FormControl>
{[
'BY_SHARES',
'BY_PERCENTAGE',
].includes(
form.getValues().splitMode,
) && sharesLabel}
</div>
<FormMessage className="float-right" />
</div>
<FormMessage className="float-right" />
</div>
)
}}
/>
)}
)
}}
/>
)}
</div>
</div>
)
}}

View File

@@ -4,6 +4,7 @@ import { getGroupExpensesAction } from '@/app/groups/[groupId]/expenses/expense-
import { Button } from '@/components/ui/button'
import { SearchBar } from '@/components/ui/search-bar'
import { Skeleton } from '@/components/ui/skeleton'
import { getCurrencyFromGroup } from '@/lib/utils'
import { trpc } from '@/trpc/client'
import dayjs, { type Dayjs } from 'dayjs'
import { useTranslations } from 'next-intl'
@@ -170,8 +171,9 @@ const ExpenseListForSearch = ({
<ExpenseCard
key={expense.id}
expense={expense}
currency={group.currency}
currency={getCurrencyFromGroup(group)}
groupId={groupId}
participantCount={group.participants.length}
/>
))}
</div>

View File

@@ -1,3 +1,5 @@
import { getCurrency } from '@/lib/currency'
import { formatAmountAsDecimal, getCurrencyFromGroup } from '@/lib/utils'
import { Parser } from '@json2csv/plainjs'
import { PrismaClient } from '@prisma/client'
import contentDisposition from 'content-disposition'
@@ -22,20 +24,25 @@ const prisma = new PrismaClient()
export async function GET(
req: Request,
{ params: { groupId } }: { params: { groupId: string } },
{ params }: { params: Promise<{ groupId: string }> },
) {
const { groupId } = await params
const group = await prisma.group.findUnique({
where: { id: groupId },
select: {
id: true,
name: true,
currency: true,
currencyCode: true,
expenses: {
select: {
expenseDate: true,
title: true,
category: { select: { name: true } },
amount: true,
originalAmount: true,
originalCurrency: true,
conversionRate: true,
paidById: true,
paidFor: { select: { participantId: true, shares: true } },
isReimbursement: true,
@@ -52,30 +59,29 @@ export async function GET(
/*
CSV Structure:
--------------------------------------------------------------
| Date | Description | Category | Currency | Cost
--------------------------------------------------------------
| Is Reimbursement | Split mode | UserA | UserB
--------------------------------------------------------------
Columns:
CSV Columns:
- Date: The date of the expense.
- Description: A brief description of the expense.
- Category: The category of the expense (e.g., Food, Travel, etc.).
- Currency: The currency in which the expense is recorded.
- Cost: The amount spent.
- Original cost: The amount spent in the original currency.
- Original currency: The currency the amount was originally spent in.
- Conversion rate: The rate used to convert the amount.
- Is Reimbursement: Whether the expense is a reimbursement or not.
- Split mode: The method used to split the expense (e.g., Evenly, By shares, By percentage, By amount).
- UserA, UserB: User-specific data or balances (e.g., amount owed or contributed by each user).
Example Row:
------------------------------------------------------------------------------------------
| 2025-01-06 | Dinner with team | Food | ₹ | 5000 | No | Evenly | John | Jane
------------------------------------------------------------------------------------------
Example Table:
+------------+------------------+----------+----------+----------+---------------+-------------------+-----------------+------------------+----------------------+--------+-----------+
| Date | Description | Category | Currency | Cost | Original cost | Original currency | Conversion rate | Is reinbursement | Split mode | User A | User B |
+------------+------------------+----------+----------+----------+---------------+-------------------+-----------------+------------------+----------------------+--------+-----------+
| 2025-01-06 | Dinner with team | Food | INR | 5000 | | | | No | Evenly | 2500 | -2500 |
+------------+------------------+----------+----------+----------+---------------+-------------------+-----------------+------------------+----------------------+--------+-----------+
| 2025-02-07 | Plane tickets | Travel | INR | 97264.09 | 1000 | EUR | 97.2641 | No | Unevenly - By amount | -80000 | -17264.09 |
+------------+------------------+----------+----------+----------+---------------+-------------------+-----------------+------------------+----------------------+--------+-----------+
*/
*/
const fields = [
{ label: 'Date', value: 'date' },
@@ -83,6 +89,9 @@ export async function GET(
{ label: 'Category', value: 'categoryName' },
{ label: 'Currency', value: 'currency' },
{ label: 'Cost', value: 'amount' },
{ label: 'Original cost', value: 'originalAmount' },
{ label: 'Original currency', value: 'originalCurrency' },
{ label: 'Conversion rate', value: 'conversionRate' },
{ label: 'Is Reimbursement', value: 'isReimbursement' },
{ label: 'Split mode', value: 'splitMode' },
...group.participants.map((participant) => ({
@@ -91,12 +100,24 @@ export async function GET(
})),
]
const currency = getCurrencyFromGroup(group)
const expenses = group.expenses.map((expense) => ({
date: formatDate(expense.expenseDate),
title: expense.title,
categoryName: expense.category?.name || '',
currency: group.currency,
amount: (expense.amount / 100).toFixed(2),
currency: group.currencyCode ?? group.currency,
amount: formatAmountAsDecimal(expense.amount, currency),
originalAmount: expense.originalAmount
? formatAmountAsDecimal(
expense.originalAmount,
getCurrency(expense.originalCurrency),
)
: null,
originalCurrency: expense.originalCurrency,
conversionRate: expense.conversionRate
? expense.conversionRate.toString()
: null,
isReimbursement: expense.isReimbursement ? 'Yes' : 'No',
splitMode: splitModeLabel[expense.splitMode],
...Object.fromEntries(
@@ -113,10 +134,10 @@ export async function GET(
)
const isPaidByParticipant = expense.paidById === participant.id
const participantAmountShare = +(
((expense.amount / totalShares) * participantShare) /
100
).toFixed(2)
const participantAmountShare = +formatAmountAsDecimal(
(expense.amount / totalShares) * participantShare,
currency,
)
return [
participant.name,

View File

@@ -4,14 +4,16 @@ import { NextResponse } from 'next/server'
export async function GET(
req: Request,
{ params: { groupId } }: { params: { groupId: string } },
{ params }: { params: Promise<{ groupId: string }> },
) {
const { groupId } = await params
const group = await prisma.group.findUnique({
where: { id: groupId },
select: {
id: true,
name: true,
currency: true,
currencyCode: true,
expenses: {
select: {
createdAt: true,
@@ -19,6 +21,9 @@ export async function GET(
title: true,
category: { select: { grouping: true, name: true } },
amount: true,
originalAmount: true,
originalCurrency: true,
conversionRate: true,
paidById: true,
paidFor: { select: { participantId: true, shares: true } },
isReimbursement: true,

View File

@@ -5,10 +5,11 @@ export const metadata: Metadata = {
title: 'Group Information',
}
export default function InformationPage({
params: { groupId },
export default async function InformationPage({
params,
}: {
params: { groupId: string }
params: Promise<{ groupId: string }>
}) {
const { groupId } = await params
return <GroupInformation groupId={groupId} />
}

View File

@@ -4,14 +4,13 @@ import { PropsWithChildren } from 'react'
import { GroupLayoutClient } from './layout.client'
type Props = {
params: {
params: Promise<{
groupId: string
}
}>
}
export async function generateMetadata({
params: { groupId },
}: Props): Promise<Metadata> {
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { groupId } = await params
const group = await cached.getGroup(groupId)
return {
@@ -22,9 +21,10 @@ export async function generateMetadata({
}
}
export default function GroupLayout({
export default async function GroupLayout({
children,
params: { groupId },
params,
}: PropsWithChildren<Props>) {
const { groupId } = await params
return <GroupLayoutClient groupId={groupId}>{children}</GroupLayoutClient>
}

View File

@@ -1,9 +1,10 @@
import { redirect } from 'next/navigation'
export default async function GroupPage({
params: { groupId },
params,
}: {
params: { groupId: string }
params: Promise<{ groupId: string }>
}) {
const { groupId } = await params
redirect(`/groups/${groupId}/expenses`)
}

View File

@@ -1,5 +1,6 @@
import { Button } from '@/components/ui/button'
import { Reimbursement } from '@/lib/balances'
import { Currency } from '@/lib/currency'
import { formatCurrency } from '@/lib/utils'
import { Participant } from '@prisma/client'
import { useLocale, useTranslations } from 'next-intl'
@@ -8,7 +9,7 @@ import Link from 'next/link'
type Props = {
reimbursements: Reimbursement[]
participants: Participant[]
currency: string
currency: Currency
groupId: string
}
@@ -32,8 +33,8 @@ export function ReimbursementList({
<div className="flex flex-col gap-1 items-start sm:flex-row sm:items-baseline sm:gap-4">
<div>
{t.rich('owes', {
from: getParticipant(reimbursement.from)?.name,
to: getParticipant(reimbursement.to)?.name,
from: getParticipant(reimbursement.from)?.name ?? '',
to: getParticipant(reimbursement.to)?.name ?? '',
strong: (chunks) => <strong>{chunks}</strong>,
})}
</div>

View File

@@ -1,9 +1,10 @@
import { Currency } from '@/lib/currency'
import { formatCurrency } from '@/lib/utils'
import { useLocale, useTranslations } from 'next-intl'
type Props = {
totalGroupSpendings: number
currency: string
currency: Currency
}
export function TotalsGroupSpending({ totalGroupSpendings, currency }: Props) {

View File

@@ -1,4 +1,5 @@
'use client'
import { Currency } from '@/lib/currency'
import { cn, formatCurrency } from '@/lib/utils'
import { useLocale, useTranslations } from 'next-intl'
@@ -7,7 +8,7 @@ export function TotalsYourShare({
currency,
}: {
totalParticipantShare?: number
currency: string
currency: Currency
}) {
const locale = useLocale()
const t = useTranslations('Stats.Totals')

View File

@@ -1,4 +1,5 @@
'use client'
import { Currency } from '@/lib/currency'
import { cn, formatCurrency } from '@/lib/utils'
import { useLocale, useTranslations } from 'next-intl'
@@ -7,7 +8,7 @@ export function TotalsYourSpendings({
currency,
}: {
totalParticipantSpendings?: number
currency: string
currency: Currency
}) {
const locale = useLocale()
const t = useTranslations('Stats.Totals')

View File

@@ -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 { Skeleton } from '@/components/ui/skeleton'
import { useActiveUser } from '@/lib/hooks'
import { getCurrencyFromGroup } from '@/lib/utils'
import { trpc } from '@/trpc/client'
import { useCurrentGroup } from '../current-group-context'
@@ -33,21 +34,23 @@ export function Totals() {
totalParticipantSpendings,
} = data
const currency = getCurrencyFromGroup(group)
return (
<>
<TotalsGroupSpending
totalGroupSpendings={totalGroupSpendings}
currency={group.currency}
currency={currency}
/>
{participantId && (
<>
<TotalsYourSpendings
totalParticipantSpendings={totalParticipantSpendings}
currency={group.currency}
currency={currency}
/>
<TotalsYourShare
totalParticipantShare={totalParticipantShare}
currency={group.currency}
currency={currency}
/>
</>
)}

View File

@@ -104,7 +104,7 @@ function Content({ children }: { children: React.ReactNode }) {
</div>
</header>
<div className="flex-1 flex flex-col">{children}</div>
<div className="pt-16 flex-1 flex flex-col">{children}</div>
<footer className="sm:p-8 md:p-16 sm:mt-16 sm:text-sm md:text-base md:mt-32 bg-slate-50 dark:bg-card border-t p-6 mt-8 flex flex-col sm:flex-row sm:justify-between gap-4 text-xs [&_a]:underline">
<div className="flex flex-col space-y-2">
@@ -157,7 +157,7 @@ export default async function RootLayout({
return (
<html lang={locale} suppressHydrationWarning>
<ApplePwaSplash icon="/logo-with-text.png" color="#027756" />
<body className="pt-16 min-h-[100dvh] flex flex-col items-stretch bg-slate-50 bg-opacity-30 dark:bg-background">
<body className="min-h-[100dvh] flex flex-col items-stretch bg-slate-50 bg-opacity-30 dark:bg-background">
<NextIntlClientProvider messages={messages}>
<ThemeProvider
attribute="class"

View 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>
)
}

View File

@@ -11,6 +11,8 @@ import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { ToastAction } from '@/components/ui/toast'
@@ -157,6 +159,8 @@ export function DocumentThumbnail({
</Button>
</DialogTrigger>
<DialogContent className="p-4 w-[100vw] max-w-[100vw] h-[100dvh] max-h-[100dvh] sm:max-w-[calc(100vw-32px)] sm:max-h-[calc(100dvh-32px)] [&>:last-child]:hidden">
<DialogTitle className="sr-only">Document</DialogTitle>
<DialogDescription className="sr-only"></DialogDescription>
<div className="flex flex-col gap-4">
<div className="flex justify-end">
<Button

View File

@@ -30,14 +30,17 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Locale } from '@/i18n/request'
import { getGroup } from '@/lib/api'
import { defaultCurrencyList, getCurrency } from '@/lib/currency'
import { GroupFormValues, groupFormSchema } from '@/lib/schemas'
import { zodResolver } from '@hookform/resolvers/zod'
import { Save, Trash2 } from 'lucide-react'
import { useTranslations } from 'next-intl'
import { useLocale, useTranslations } from 'next-intl'
import Link from 'next/link'
import { useEffect, useState } from 'react'
import { useFieldArray, useForm } from 'react-hook-form'
import { CurrencySelector } from './currency-selector'
import { Textarea } from './ui/textarea'
export type Props = {
@@ -54,6 +57,7 @@ export function GroupForm({
onSubmit,
protectedParticipantIds = [],
}: Props) {
const locale = useLocale()
const t = useTranslations('GroupForm')
const form = useForm<GroupFormValues>({
resolver: zodResolver(groupFormSchema),
@@ -61,13 +65,15 @@ export function GroupForm({
? {
name: group.name,
information: group.information ?? '',
currency: group.currency,
currency: group.currency ?? '',
currencyCode: group.currencyCode ?? '',
participants: group.participants,
}
: {
name: '',
information: '',
currency: process.env.NEXT_PUBLIC_DEFAULT_CURRENCY_SYMBOL || '',
currency: '',
currencyCode: process.env.NEXT_PUBLIC_DEFAULT_CURRENCY_CODE || 'USD', // TODO: If NEXT_PUBLIC_DEFAULT_CURRENCY_CODE, is not set, determine the default currency code based on locale
participants: [
{ name: t('Participants.John') },
{ name: t('Participants.Jane') },
@@ -145,9 +151,48 @@ export function GroupForm({
<FormField
control={form.control}
name="currency"
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(
group
? 'CurrencyCodeField.editDescription'
: 'CurrencyCodeField.createDescription',
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="currency"
render={({ field }) => (
<FormItem hidden={!!form.watch('currencyCode')?.length}>
<FormLabel>{t('CurrencyField.label')}</FormLabel>
<FormControl>
<Input

View File

@@ -7,7 +7,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Locale, localeLabels } from '@/i18n'
import { Locale, localeLabels } from '@/i18n/request'
import { setUserLocale } from '@/lib/locale'
import { useLocale } from 'next-intl'

View File

@@ -1,9 +1,10 @@
'use client'
import { Currency } from '@/lib/currency'
import { cn, formatCurrency } from '@/lib/utils'
import { useLocale } from 'next-intl'
type Props = {
currency: string
currency: Currency
amount: number
bold?: boolean
colored?: boolean

View File

@@ -117,7 +117,7 @@ const CommandItem = React.forwardRef<
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50",
className
)}
{...props}

View File

@@ -48,6 +48,7 @@ const DropdownMenuSubContent = React.forwardRef<
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
"max-h-[80vh] overflow-y-scroll",
className
)}
{...props}
@@ -66,6 +67,7 @@ const DropdownMenuContent = React.forwardRef<
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
"max-h-[80vh] overflow-y-scroll",
className
)}
{...props}

View File

@@ -1,22 +1,31 @@
import deepmerge from 'deepmerge'
import { getRequestConfig } from 'next-intl/server'
import { getUserLocale } from './lib/locale'
import { getUserLocale } from '../lib/locale'
export const localeLabels = {
'en-US': 'English',
fi: 'Suomi',
'fr-FR': 'Français',
es: 'Español',
id: 'Bahasa Indonesia',
ca: 'Català',
'cs-CZ': 'Česky',
'de-DE': 'Deutsch',
'en-US': 'English',
es: 'Español',
eu: 'Euskera',
'fr-FR': 'Français',
'it-IT': 'Italiano',
'nl-NL': 'Nederlands',
'pl-PL': 'Polski',
pt: 'Português',
'pt-BR': 'Português Brasileiro',
ro: 'Română',
fi: 'Suomi',
'tr-TR': 'Türkçe',
'ru-RU': 'Русский',
'uk-UA': 'Українська',
he: 'עברית',
ko: '한국어',
'ja-JP': '日本語',
'zh-CN': '简体中文',
'zh-TW': '正體中文',
'pl-PL': 'Polski',
'ru-RU': 'Русский',
'it-IT': 'Italiano',
'ua-UA': 'Українська',
ro: 'Română',
'tr-TR': 'Türkçe',
'pt-BR': 'Português Brasileiro',
'nl-NL': 'Nederlands',
} as const
export const locales: (keyof typeof localeLabels)[] = Object.keys(
@@ -28,9 +37,20 @@ export const defaultLocale: Locale = 'en-US'
export default getRequestConfig(async () => {
const locale = await getUserLocale()
const localeMessages = (await import(`../../messages/${locale}.json`)).default
let messages: any
if (locale === defaultLocale) {
messages = localeMessages
} else {
messages = deepmerge(
(await import(`../../messages/${defaultLocale}.json`)).default,
localeMessages,
) as any
}
return {
locale,
messages: (await import(`../messages/${locale}.json`)).default,
messages,
}
})

View File

@@ -1,6 +1,11 @@
import { prisma } from '@/lib/prisma'
import { ExpenseFormValues, GroupFormValues } from '@/lib/schemas'
import { ActivityType, Expense, RecurrenceRule, RecurringExpenseLink } from '@prisma/client'
import {
ActivityType,
Expense,
RecurrenceRule,
RecurringExpenseLink,
} from '@prisma/client'
import { nanoid } from 'nanoid'
export function randomId() {
@@ -14,6 +19,7 @@ export async function createGroup(groupFormValues: GroupFormValues) {
name: groupFormValues.name,
information: groupFormValues.information,
currency: groupFormValues.currency,
currencyCode: groupFormValues.currencyCode,
participants: {
createMany: {
data: groupFormValues.participants.map(({ name }) => ({
@@ -50,11 +56,12 @@ export async function createExpense(
data: expenseFormValues.title,
})
const isCreateRecurrence = expenseFormValues.recurrenceRule !== RecurrenceRule.NONE
const isCreateRecurrence =
expenseFormValues.recurrenceRule !== RecurrenceRule.NONE
const recurringExpenseLinkPayload = createPayloadForNewRecurringExpenseLink(
expenseFormValues.recurrenceRule as RecurrenceRule,
expenseFormValues.expenseDate,
groupId
groupId,
)
return prisma.expense.create({
@@ -64,6 +71,9 @@ export async function createExpense(
expenseDate: expenseFormValues.expenseDate,
categoryId: expenseFormValues.category,
amount: expenseFormValues.amount,
originalAmount: expenseFormValues.originalAmount,
originalCurrency: expenseFormValues.originalCurrency,
conversionRate: expenseFormValues.conversionRate,
title: expenseFormValues.title,
paidById: expenseFormValues.paidBy,
splitMode: expenseFormValues.splitMode,
@@ -71,11 +81,9 @@ export async function createExpense(
recurringExpenseLink: {
...(isCreateRecurrence
? {
create: recurringExpenseLinkPayload
create: recurringExpenseLinkPayload,
}
: {}
),
: {}),
},
paidFor: {
createMany: {
@@ -169,30 +177,31 @@ export async function updateExpense(
data: expenseFormValues.title,
})
const isDeleteRecurrenceExpenseLink =
existingExpense.recurrenceRule !== RecurrenceRule.NONE &&
const isDeleteRecurrenceExpenseLink =
existingExpense.recurrenceRule !== RecurrenceRule.NONE &&
expenseFormValues.recurrenceRule === RecurrenceRule.NONE &&
// Delete the existing RecurrenceExpenseLink only if it has not been acted upon yet
existingExpense.recurringExpenseLink?.nextExpenseCreatedAt === null
const isUpdateRecurrenceExpenseLink = existingExpense.recurrenceRule !== expenseFormValues.recurrenceRule &&
const isUpdateRecurrenceExpenseLink =
existingExpense.recurrenceRule !== expenseFormValues.recurrenceRule &&
// Update the exisiting RecurrenceExpenseLink only if it has not been acted upon yet
existingExpense.recurringExpenseLink?.nextExpenseCreatedAt === null
const isCreateRecurrenceExpenseLink =
existingExpense.recurrenceRule === RecurrenceRule.NONE &&
const isCreateRecurrenceExpenseLink =
existingExpense.recurrenceRule === RecurrenceRule.NONE &&
expenseFormValues.recurrenceRule !== RecurrenceRule.NONE &&
// Create a new RecurrenceExpenseLink only if one does not already exist for the expense
existingExpense.recurringExpenseLink === null
const newRecurringExpenseLink = createPayloadForNewRecurringExpenseLink(
expenseFormValues.recurrenceRule as RecurrenceRule,
expenseFormValues.expenseDate,
groupId
groupId,
)
const updatedRecurrenceExpenseLinkNextExpenseDate = calculateNextDate(
expenseFormValues.recurrenceRule as RecurrenceRule,
existingExpense.expenseDate
existingExpense.expenseDate,
)
return prisma.expense.update({
@@ -200,6 +209,9 @@ export async function updateExpense(
data: {
expenseDate: expenseFormValues.expenseDate,
amount: expenseFormValues.amount,
originalAmount: expenseFormValues.originalAmount,
originalCurrency: expenseFormValues.originalCurrency,
conversionRate: expenseFormValues.conversionRate,
title: expenseFormValues.title,
categoryId: expenseFormValues.category,
paidById: expenseFormValues.paidBy,
@@ -238,18 +250,16 @@ export async function updateExpense(
recurringExpenseLink: {
...(isCreateRecurrenceExpenseLink
? {
create: newRecurringExpenseLink
create: newRecurringExpenseLink,
}
: {}
),
: {}),
...(isUpdateRecurrenceExpenseLink
? {
update: {
nextExpenseDate: updatedRecurrenceExpenseLinkNextExpenseDate
update: {
nextExpenseDate: updatedRecurrenceExpenseLinkNextExpenseDate,
},
}
}
: {}
),
: {}),
delete: isDeleteRecurrenceExpenseLink,
},
isReimbursement: expenseFormValues.isReimbursement,
@@ -290,6 +300,7 @@ export async function updateGroup(
name: groupFormValues.name,
information: groupFormValues.information,
currency: groupFormValues.currency,
currencyCode: groupFormValues.currencyCode,
participants: {
deleteMany: existingGroup.participants.filter(
(p) => !groupFormValues.participants.some((p2) => p2.id === p.id),
@@ -371,7 +382,13 @@ export async function getGroupExpenseCount(groupId: string) {
export async function getExpense(groupId: string, expenseId: string) {
return prisma.expense.findUnique({
where: { id: expenseId },
include: { paidBy: true, paidFor: true, category: true, documents: true, recurringExpenseLink: true },
include: {
paidBy: true,
paidFor: true,
category: true,
documents: true,
recurringExpenseLink: true,
},
})
}
@@ -420,35 +437,38 @@ export async function logActivity(
})
}
async function createRecurringExpenses(){
const localDate = new Date(); // Current local date
const utcDateFromLocal = new Date(Date.UTC(
async function createRecurringExpenses() {
const localDate = new Date() // Current local date
const utcDateFromLocal = new Date(
Date.UTC(
localDate.getUTCFullYear(),
localDate.getUTCMonth(),
localDate.getUTCDate(),
// More precision beyond date is required to ensure that recurring Expenses are created within <most precises unit> of when expected
localDate.getUTCHours(),
localDate.getUTCMinutes(),
));
),
)
const recurringExpenseLinksWithExpensesToCreate = await prisma.recurringExpenseLink.findMany({
where: {
nextExpenseCreatedAt: null,
nextExpenseDate: {
lte: utcDateFromLocal
}
},
include: {
currentFrameExpense: {
include: {
paidBy: true,
paidFor: true,
category: true,
documents: true
const recurringExpenseLinksWithExpensesToCreate =
await prisma.recurringExpenseLink.findMany({
where: {
nextExpenseCreatedAt: null,
nextExpenseDate: {
lte: utcDateFromLocal,
},
}
}
})
},
include: {
currentFrameExpense: {
include: {
paidBy: true,
paidFor: true,
category: true,
documents: true,
},
},
},
})
for (const recurringExpenseLink of recurringExpenseLinksWithExpensesToCreate) {
let newExpenseDate = recurringExpenseLink.nextExpenseDate
@@ -459,74 +479,84 @@ async function createRecurringExpenses(){
while (newExpenseDate < utcDateFromLocal) {
const newExpenseId = randomId()
const newRecurringExpenseLinkId = randomId()
const newRecurringExpenseNextExpenseDate = calculateNextDate(
currentExpenseRecord.recurrenceRule as RecurrenceRule,
newExpenseDate
currentExpenseRecord.recurrenceRule as RecurrenceRule,
newExpenseDate,
)
const {
category, paidBy, paidFor, documents,
category,
paidBy,
paidFor,
documents,
...destructeredCurrentExpenseRecord
} = currentExpenseRecord
// Use a transacton to ensure that the only one expense is created for the RecurringExpenseLink
// just in case two clients are processing the same RecurringExpenseLink at the same time
const newExpense = await prisma.$transaction(async (transaction) => {
const newExpense = await transaction.expense.create({
data: {
...destructeredCurrentExpenseRecord,
categoryId: currentExpenseRecord.categoryId,
paidById: currentExpenseRecord.paidById,
paidFor: {
createMany: {
data: currentExpenseRecord.paidFor.map((paidFor) => ({
participantId: paidFor.participantId,
shares: paidFor.shares,
})),
const newExpense = await prisma
.$transaction(async (transaction) => {
const newExpense = await transaction.expense.create({
data: {
...destructeredCurrentExpenseRecord,
categoryId: currentExpenseRecord.categoryId,
paidById: currentExpenseRecord.paidById,
paidFor: {
createMany: {
data: currentExpenseRecord.paidFor.map((paidFor) => ({
participantId: paidFor.participantId,
shares: paidFor.shares,
})),
},
},
documents: {
connect: currentExpenseRecord.documents.map(
(documentRecord) => ({
id: documentRecord.id,
}),
),
},
id: newExpenseId,
expenseDate: newExpenseDate,
recurringExpenseLink: {
create: {
groupId: currentExpenseRecord.groupId,
id: newRecurringExpenseLinkId,
nextExpenseDate: newRecurringExpenseNextExpenseDate,
},
},
},
documents: {
connect: currentExpenseRecord.documents.map((documentRecord) => ({
id: documentRecord.id
})),
// Ensure that the same information is available on the returned record that was created
include: {
paidFor: true,
documents: true,
category: true,
paidBy: true,
},
id: newExpenseId,
expenseDate: newExpenseDate,
recurringExpenseLink: {
create: {
groupId: currentExpenseRecord.groupId,
id: newRecurringExpenseLinkId,
nextExpenseDate: newRecurringExpenseNextExpenseDate
}
}
},
// Ensure that the same information is available on the returned record that was created
include: {
paidFor: true,
documents: true,
category: true,
paidBy: true
}
})
})
// Mark the RecurringExpenseLink as being "completed" since the new Expense was created
// if an expense hasn't been created for this RecurringExpenseLink yet
await transaction.recurringExpenseLink.update({
where: {
id: currentReccuringExpenseLinkId,
nextExpenseCreatedAt: null,
},
data: {
nextExpenseCreatedAt: newExpense.createdAt
},
})
// Mark the RecurringExpenseLink as being "completed" since the new Expense was created
// if an expense hasn't been created for this RecurringExpenseLink yet
await transaction.recurringExpenseLink.update({
where: {
id: currentReccuringExpenseLinkId,
nextExpenseCreatedAt: null,
},
data: {
nextExpenseCreatedAt: newExpense.createdAt,
},
})
return newExpense
}).catch(() => {
console.error("Failed to created recurringExpense for expenseId: %s", currentExpenseRecord.id)
return null
})
return newExpense
})
.catch(() => {
console.error(
'Failed to created recurringExpense for expenseId: %s',
currentExpenseRecord.id,
)
return null
})
// If the new expense failed to be created, break out of the while-loop
if (newExpense === null) break
@@ -545,15 +575,15 @@ function createPayloadForNewRecurringExpenseLink(
groupId: String,
): RecurringExpenseLink {
const nextExpenseDate = calculateNextDate(
recurrenceRule,
priorDateToNextRecurrence
recurrenceRule,
priorDateToNextRecurrence,
)
const recurringExpenseLinkId = randomId()
const recurringExpenseLinkPayload = {
id: recurringExpenseLinkId,
groupId: groupId,
nextExpenseDate: nextExpenseDate
nextExpenseDate: nextExpenseDate,
}
return recurringExpenseLinkPayload as RecurringExpenseLink
@@ -567,10 +597,10 @@ function createPayloadForNewRecurringExpenseLink(
// will be created for Feb 28th, March 28, etc. until it is cancelled or fixed
function calculateNextDate(
recurrenceRule: RecurrenceRule,
priorDateToNextRecurrence: Date
priorDateToNextRecurrence: Date,
): Date {
const nextDate = new Date(priorDateToNextRecurrence)
switch(recurrenceRule) {
switch (recurrenceRule) {
case RecurrenceRule.DAILY:
nextDate.setUTCDate(nextDate.getUTCDate() + 1)
break
@@ -578,7 +608,7 @@ function calculateNextDate(
nextDate.setUTCDate(nextDate.getUTCDate() + 7)
break
case RecurrenceRule.MONTHLY:
const nextYear = nextDate.getUTCFullYear()
const nextYear = nextDate.getUTCFullYear()
const nextMonth = nextDate.getUTCMonth() + 1
let nextDay = nextDate.getUTCDate()
@@ -596,15 +626,12 @@ function calculateNextDate(
function isDateInNextMonth(
utcYear: number,
utcMonth: number,
utcDate: number
utcDate: number,
): Boolean {
const testDate = new Date(Date.UTC(
utcYear, utcMonth, utcDate
))
const testDate = new Date(Date.UTC(utcYear, utcMonth, utcDate))
// We're not concerned if the year or month changes. We only want to make sure that the date is our target date
if (testDate.getUTCDate() !== utcDate
) {
if (testDate.getUTCDate() !== utcDate) {
return false
}

6258
src/lib/currency-data.json Normal file

File diff suppressed because it is too large Load Diff

89
src/lib/currency.ts Normal file
View File

@@ -0,0 +1,89 @@
import { Locale } from '@/i18n/request'
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
)
}

View File

@@ -21,7 +21,7 @@ const envSchema = z
interpretEnvVarAsBool,
z.boolean().default(false),
),
NEXT_PUBLIC_DEFAULT_CURRENCY_SYMBOL: z.string().optional(),
NEXT_PUBLIC_DEFAULT_CURRENCY_CODE: z.string().optional(),
S3_UPLOAD_KEY: z.string().optional(),
S3_UPLOAD_SECRET: z.string().optional(),
S3_UPLOAD_BUCKET: z.string().optional(),

96
src/lib/health.ts Normal file
View File

@@ -0,0 +1,96 @@
import { prisma } from '@/lib/prisma'
export interface HealthCheckStatus {
status: 'healthy' | 'unhealthy'
services?: {
database?: {
status: 'healthy' | 'unhealthy'
error?: string
}
}
}
async function checkDatabase(): Promise<{
status: 'healthy' | 'unhealthy'
error?: string
}> {
try {
// Simple query to test database connectivity
await prisma.$queryRaw`SELECT 1`
return {
status: 'healthy',
}
} catch (error) {
return {
status: 'unhealthy',
error:
error instanceof Error ? error.message : 'Database connection failed',
}
}
}
function createHealthResponse(
data: HealthCheckStatus,
isHealthy: boolean,
): Response {
return new Response(JSON.stringify(data), {
status: isHealthy ? 200 : 503,
headers: {
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Content-Type': 'application/json',
},
})
}
export async function checkReadiness(): Promise<Response> {
try {
const databaseStatus = await checkDatabase()
const services: HealthCheckStatus['services'] = {
database: databaseStatus,
}
// For readiness: healthy only if all services are healthy
const isHealthy = databaseStatus.status === 'healthy'
const healthStatus: HealthCheckStatus = {
status: isHealthy ? 'healthy' : 'unhealthy',
services,
}
return createHealthResponse(healthStatus, isHealthy)
} catch (error) {
const errorStatus: HealthCheckStatus = {
status: 'unhealthy',
services: {
database: {
status: 'unhealthy',
error:
error instanceof Error ? error.message : 'Readiness check failed',
},
},
}
return createHealthResponse(errorStatus, false)
}
}
export async function checkLiveness(): Promise<Response> {
try {
// Liveness: Only check if the app process is alive
// No database or external service checks - restarting won't fix those
const healthStatus: HealthCheckStatus = {
status: 'healthy',
// No services reported - we don't check them for liveness
}
return createHealthResponse(healthStatus, true) // Always 200 for liveness
} catch (error) {
// This should rarely happen, but if it does, the app needs restart
const errorStatus: HealthCheckStatus = {
status: 'unhealthy',
}
return createHealthResponse(errorStatus, false)
}
}

View File

@@ -1,4 +1,6 @@
import dayjs from 'dayjs'
import { useEffect, useState } from 'react'
import useSWR, { Fetcher } from 'swr'
export function useMediaQuery(query: string): boolean {
const getMatches = (query: string): boolean => {
@@ -64,3 +66,62 @@ export function useActiveUser(groupId?: string) {
return activeUser
}
interface FrankfurterAPIResponse {
base: string
date: string
rates: Record<string, number>
}
const fetcher: Fetcher<FrankfurterAPIResponse> = (url: string) =>
fetch(url).then(async (res) => {
if (!res.ok)
throw new TypeError('Unsuccessful response from API', { cause: res })
return res.json() as Promise<FrankfurterAPIResponse>
})
export function useCurrencyRate(
date: Date,
baseCurrency: string,
targetCurrency: string,
) {
const dateString = dayjs(date).format('YYYY-MM-DD')
// Only send request if both currency codes are given and not the same
const url =
!isNaN(date.getTime()) &&
!!baseCurrency.length &&
!!targetCurrency.length &&
baseCurrency !== targetCurrency &&
`https://api.frankfurter.app/${dateString}?base=${baseCurrency}`
const { data, error, isLoading, mutate } = useSWR<FrankfurterAPIResponse>(
url,
fetcher,
{ shouldRetryOnError: false, revalidateOnFocus: false },
)
if (data) {
let exchangeRate = undefined
let sentError = error
if (!error && data.date !== dateString) {
// this happens if for example, the requested date is in the future.
sentError = new RangeError(data.date)
}
if (data.rates[targetCurrency]) {
exchangeRate = data.rates[targetCurrency]
}
return {
data: exchangeRate,
error: sentError,
isLoading,
refresh: mutate,
}
}
return {
data,
error,
isLoading,
refresh: mutate,
}
}

View File

@@ -1,6 +1,6 @@
'use server'
import { Locale, Locales, defaultLocale, locales } from '@/i18n'
import { Locale, Locales, defaultLocale, locales } from '@/i18n/request'
import { match } from '@formatjs/intl-localematcher'
import Negotiator from 'negotiator'
import { cookies, headers } from 'next/headers'
@@ -17,7 +17,8 @@ function getAcceptLanguageLocale(requestHeaders: Headers, locales: Locales) {
try {
locale = match(languages, locales, defaultLocale)
} catch (e) {
// invalid language
// invalid language - fallback to default
locale = defaultLocale
}
return locale
}
@@ -26,17 +27,17 @@ export async function getUserLocale() {
let locale
// Prio 1: use existing cookie
locale = cookies().get(COOKIE_NAME)?.value
locale = (await cookies()).get(COOKIE_NAME)?.value
// Prio 2: use `accept-language` header
// Prio 3: use default locale
if (!locale) {
locale = getAcceptLanguageLocale(headers(), locales)
locale = getAcceptLanguageLocale(await headers(), locales)
}
return locale
}
export async function setUserLocale(locale: Locale) {
cookies().set(COOKIE_NAME, locale)
;(await cookies()).set(COOKIE_NAME, locale)
}

View File

@@ -1,4 +1,6 @@
import { SplitMode, RecurrenceRule } from '@prisma/client'
import { RecurrenceRule, SplitMode } from '@prisma/client'
import Decimal from 'decimal.js'
import * as z from 'zod'
export const groupFormSchema = z
@@ -6,6 +8,7 @@ export const groupFormSchema = z
name: z.string().min(2, 'min2').max(50, 'max50'),
information: z.string().optional(),
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
.array(
z.object({
@@ -31,6 +34,19 @@ export const groupFormSchema = z
export type GroupFormValues = z.infer<typeof groupFormSchema>
const inputCoercedToNumber = z.union([
z.number(),
z.string().transform((value, ctx) => {
const valueAsNumber = Number(value)
if (Number.isNaN(valueAsNumber))
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'invalidNumber',
})
return valueAsNumber
}),
])
export const expenseFormSchema = z
.object({
expenseDate: z.coerce.date(),
@@ -47,18 +63,34 @@ export const expenseFormSchema = z
code: z.ZodIssueCode.custom,
message: 'invalidNumber',
})
return Math.round(valueAsNumber * 100)
return valueAsNumber
}),
],
{ required_error: 'amountRequired' },
)
.refine((amount) => amount != 0, 'amountNotZero')
.refine((amount) => amount <= 10_000_000_00, 'amountTenMillion'),
originalAmount: z
.union([
z.literal('').transform(() => undefined),
inputCoercedToNumber
.refine((amount) => amount != 0, 'amountNotZero')
.refine((amount) => amount <= 10_000_000_00, 'amountTenMillion'),
])
.optional(),
originalCurrency: z.union([z.string().length(3).nullish(), z.literal('')]),
conversionRate: z
.union([
z.literal('').transform(() => undefined),
inputCoercedToNumber.refine((amount) => amount > 0, 'ratePositive'),
])
.optional(),
paidBy: z.string({ required_error: 'paidByRequired' }),
paidFor: z
.array(
z.object({
participant: z.string(),
originalAmount: z.string().optional(), // For converting shares by amounts in original currency, not saved.
shares: z.union([
z.number(),
z.string().transform((value, ctx) => {
@@ -69,17 +101,16 @@ export const expenseFormSchema = z
code: z.ZodIssueCode.custom,
message: 'invalidNumber',
})
return Math.round(valueAsNumber * 100)
return value
}),
]),
}),
)
.min(1, 'paidForMin1')
.superRefine((paidFor, ctx) => {
let sum = 0
for (const { shares } of paidFor) {
sum += shares
if (shares < 1) {
const shareNumber = Number(shares)
if (shareNumber <= 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'noZeroShares',
@@ -105,29 +136,28 @@ export const expenseFormSchema = z
)
.default([]),
notes: z.string().optional(),
recurrenceRule:z
recurrenceRule: z
.enum<RecurrenceRule, [RecurrenceRule, ...RecurrenceRule[]]>(
Object.values(RecurrenceRule) as any
Object.values(RecurrenceRule) as any,
)
.default('NONE'),
})
.superRefine((expense, ctx) => {
let sum = 0
for (const { shares } of expense.paidFor) {
sum +=
typeof shares === 'number' ? shares : Math.round(Number(shares) * 100)
}
switch (expense.splitMode) {
case 'EVENLY':
break // noop
case 'BY_SHARES':
break // noop
case 'BY_AMOUNT': {
if (sum !== expense.amount) {
const detail =
sum < expense.amount
? `${((expense.amount - sum) / 100).toFixed(2)} missing`
: `${((sum - expense.amount) / 100).toFixed(2)} surplus`
const sum = expense.paidFor.reduce(
(sum, { shares }) => new Decimal(shares).add(sum),
new Decimal(0),
)
if (!sum.equals(new Decimal(expense.amount))) {
// const detail =
// sum < expense.amount
// ? `${((expense.amount - sum) / 100).toFixed(2)} missing`
// : `${((sum - expense.amount) / 100).toFixed(2)} surplus`
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'amountSum',
@@ -137,6 +167,14 @@ export const expenseFormSchema = z
break
}
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) {
const detail =
sum < 10000
@@ -152,6 +190,27 @@ export const expenseFormSchema = z
}
}
})
.transform((expense) => {
// Format the share split as a number (if from form submission)
return {
...expense,
paidFor: expense.paidFor.map((paidFor) => {
const shares = paidFor.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 {
...paidFor,
shares: Math.round(Number(shares) * 100),
}
}
// Otherwise, no need as the number will have been formatted according to currency.
return {
...paidFor,
shares: Number(shares),
}
}),
}
})
export type ExpenseFormValues = z.infer<typeof expenseFormSchema>

View File

@@ -1,7 +1,16 @@
import { Currency } from './currency'
import { formatCurrency } from './utils'
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 */
const partialAmount = 1.23
/** For testing small full amounts */
@@ -27,32 +36,32 @@ describe('formatCurrency', () => {
{
amount: partialAmount,
locale: `en-US`,
result: `${currency}1.23`,
result: `${currency.symbol}1.23`,
},
{
amount: smallAmount,
locale: `en-US`,
result: `${currency}1.00`,
result: `${currency.symbol}1.00`,
},
{
amount: largeAmount,
locale: `en-US`,
result: `${currency}10,000.00`,
result: `${currency.symbol}10,000.00`,
},
{
amount: partialAmount,
locale: `de-DE`,
result: `1,23${nbsp}${currency}`,
result: `1,23${nbsp}${currency.symbol}`,
},
{
amount: smallAmount,
locale: `de-DE`,
result: `1,00${nbsp}${currency}`,
result: `1,00${nbsp}${currency.symbol}`,
},
{
amount: largeAmount,
locale: `de-DE`,
result: `10.000,00${nbsp}${currency}`,
result: `10.000,00${nbsp}${currency.symbol}`,
},
]

View File

@@ -1,6 +1,7 @@
import { Category } from '@prisma/client'
import { Category, Group } from '@prisma/client'
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
import { Currency, getCurrency } from './currency'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
@@ -23,6 +24,34 @@ export function formatDate(
})
}
/**
* Formats a date-only field (without time) for display.
* Extracts UTC date components to avoid timezone shifts that can cause off-by-one day errors.
* Use this for dates stored as DATE type in the database (e.g., expenseDate).
*
* @param date - The date to format (typically from a database DATE field, e.g., 2025-10-17T00:00:00.000Z)
* @param locale - The locale string (e.g., 'en-US', 'fr-FR')
* @param options - Formatting options (dateStyle, timeStyle)
* @returns Formatted date string in the specified locale
*/
export function formatDateOnly(
date: Date,
locale: string,
options: { dateStyle?: DateTimeStyle; timeStyle?: DateTimeStyle } = {},
) {
// Extract UTC date components to avoid timezone shifts
const year = date.getUTCFullYear()
const month = date.getUTCMonth()
const day = date.getUTCDate()
// Create a new date in the user's local timezone with these components
const localDate = new Date(year, month, day)
return localDate.toLocaleString(locale, {
...options,
})
}
export function formatCategoryForAIPrompt(category: Category) {
return `"${category.grouping}/${category.name}" (ID: ${category.id})`
}
@@ -33,20 +62,85 @@ 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).
*/
export function formatCurrency(
currency: string,
currency: Currency,
amount: number,
locale: string,
fractions?: boolean,
) {
const format = new Intl.NumberFormat(locale, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
minimumFractionDigits: currency.decimal_digits,
maximumFractionDigits: currency.decimal_digits,
style: 'currency',
// '€' will be placed in correct position
currency: 'EUR',
currency: currency.code.length ? currency.code : 'EUR',
})
const formattedAmount = format.format(fractions ? amount : amount / 100)
return formattedAmount.replace('€', currency)
const formatted = format.format(
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 (always an integer)
*/
export function amountAsMinorUnits(amount: number, currency: Currency) {
return Math.round(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) {

View File

@@ -0,0 +1,40 @@
// @ts-nocheck
import { Locale, locales } from '@/i18n/request'
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))

View File

@@ -1,4 +1,5 @@
'use client' // <-- to make sure we can mount the Provider from a server component
import { Prisma } from '@prisma/client'
import type { QueryClient } from '@tanstack/react-query'
import { QueryClientProvider } from '@tanstack/react-query'
import { httpBatchLink } from '@trpc/client'
@@ -8,6 +9,15 @@ import superjson from 'superjson'
import { makeQueryClient } from './query-client'
import type { AppRouter } from './routers/_app'
superjson.registerCustom<Prisma.Decimal, string>(
{
isApplicable: (v): v is Prisma.Decimal => Prisma.Decimal.isDecimal(v),
serialize: (v) => v.toJSON(),
deserialize: (v) => new Prisma.Decimal(v),
},
'decimal.js',
)
export const trpc = createTRPCReact<AppRouter>()
let clientQueryClientSingleton: QueryClient

View File

@@ -1,7 +1,17 @@
import { Prisma } from '@prisma/client'
import { initTRPC } from '@trpc/server'
import { cache } from 'react'
import superjson from 'superjson'
superjson.registerCustom<Prisma.Decimal, string>(
{
isApplicable: (v): v is Prisma.Decimal => Prisma.Decimal.isDecimal(v),
serialize: (v) => v.toJSON(),
deserialize: (v) => new Prisma.Decimal(v),
},
'decimal.js',
)
export const createTRPCContext = cache(async () => {
/**
* @see: https://trpc.io/docs/server/context

View File

@@ -1,7 +1,11 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
@@ -11,7 +15,7 @@
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
@@ -19,16 +23,30 @@
}
],
"paths": {
"@/*": ["./src/*"]
"@/*": [
"./src/*"
]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"],
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
],
"ts-node": {
"require": ["tsconfig-paths/register", "dotenv/config"],
"require": [
"tsconfig-paths/register",
"dotenv/config"
],
"compilerOptions": {
"isolatedModules": false,
"module": "CommonJS"
"moduleResolution": "nodenext",
"module": "nodenext"
}
}
}