Compare commits
80 Commits
1.14.0
...
414-fix-de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df0a32617d | ||
|
|
eb78848601 | ||
|
|
a9f008683f | ||
|
|
52a2b552cb | ||
|
|
0e77a666f4 | ||
|
|
c49d0ea220 | ||
|
|
05a793ee39 | ||
|
|
763c8c42e5 | ||
|
|
5fee0440c2 | ||
|
|
da8473406e | ||
|
|
39d55d908a | ||
|
|
409784672c | ||
|
|
d27cbdba47 | ||
|
|
048ac4da0a | ||
|
|
a86e92e414 | ||
|
|
76c58a7f61 | ||
|
|
a59352d5da | ||
|
|
ef2062071a | ||
|
|
86a20d6b23 | ||
|
|
a21f0646b5 | ||
|
|
4c1a6b9e55 | ||
|
|
ff42f0ab66 | ||
|
|
6ea6cfac3e | ||
|
|
e3f70d0635 | ||
|
|
5bf31e5b99 | ||
|
|
ed321f9880 | ||
|
|
070f6623b7 | ||
|
|
c556c18dc5 | ||
|
|
65c9e01ad3 | ||
|
|
4bcc9291a4 | ||
|
|
5fd3204990 | ||
|
|
436aff00d2 | ||
|
|
2710afd560 | ||
|
|
4b69306f50 | ||
|
|
d6550e34c1 | ||
|
|
de0cbb75ff | ||
|
|
a33b578027 | ||
|
|
de930cc9ad | ||
|
|
a8ea05eae8 | ||
|
|
6c995ebd54 | ||
|
|
069554836b | ||
|
|
c5726670e7 | ||
|
|
81c42e5b8b | ||
|
|
feec11f99c | ||
|
|
d641540b65 | ||
|
|
2814811aea | ||
|
|
af4bfe3780 | ||
|
|
a11efc79c1 | ||
|
|
e63f3aa68f | ||
|
|
d77411c21e | ||
|
|
94c101cf7b | ||
|
|
2bced00f82 | ||
|
|
233b338bc5 | ||
|
|
728e072376 | ||
|
|
9fec8f9eaa | ||
|
|
6346fc8ec5 | ||
|
|
1c83ebd6f9 | ||
|
|
a65c3c9dfe | ||
|
|
86c084da6f | ||
|
|
03712f1503 | ||
|
|
ffbcb6b74d | ||
|
|
0a16a4ad38 | ||
|
|
75747157f0 | ||
|
|
2c4b4f1594 | ||
|
|
2fda3e453c | ||
|
|
c14c854a79 | ||
|
|
0c3368fd35 | ||
|
|
92909ce27f | ||
|
|
ff6c48a0c8 | ||
|
|
6c5c9d5bed | ||
|
|
f9307fd22d | ||
|
|
9302a32f4c | ||
|
|
98e2345bb9 | ||
|
|
5732f78e80 | ||
|
|
72ad0a4c90 | ||
|
|
2c973f976f | ||
|
|
5374d9e9c7 | ||
|
|
5111f3574f | ||
|
|
4db788680e | ||
|
|
39c1a2ffc6 |
@@ -1,2 +1,4 @@
|
||||
POSTGRES_PRISMA_URL=postgresql://postgres:1234@localhost
|
||||
POSTGRES_URL_NON_POOLING=postgresql://postgres:1234@localhost
|
||||
POSTGRES_URL_NON_POOLING=postgresql://postgres:1234@localhost
|
||||
|
||||
NEXT_PUBLIC_DEFAULT_CURRENCY_CODE=""
|
||||
45
.github/workflows/cd.yml
vendored
Normal file
@@ -0,0 +1,45 @@
|
||||
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: .
|
||||
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
|
||||
20
README.md
@@ -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
|
||||
|
||||
15
compose.yaml
@@ -1,6 +1,7 @@
|
||||
services:
|
||||
app:
|
||||
image: spliit2:latest
|
||||
build: .
|
||||
image: spliit:latest
|
||||
ports:
|
||||
- 3000:3000
|
||||
env_file:
|
||||
@@ -8,11 +9,13 @@ services:
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- spliit_network
|
||||
|
||||
db:
|
||||
image: postgres:latest
|
||||
ports:
|
||||
- 5432:5432
|
||||
expose:
|
||||
- 5432
|
||||
env_file:
|
||||
- container.env
|
||||
volumes:
|
||||
@@ -22,3 +25,9 @@ services:
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- spliit_network
|
||||
|
||||
networks:
|
||||
spliit_network:
|
||||
driver: bridge
|
||||
|
||||
398
messages/ca.json
Normal file
@@ -0,0 +1,398 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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:"
|
||||
},
|
||||
"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 l’olla! 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"
|
||||
}
|
||||
},
|
||||
"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."
|
||||
},
|
||||
"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."
|
||||
},
|
||||
"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."
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
399
messages/cs-CZ.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@
|
||||
"createFirst": "Erstelle die Erste",
|
||||
"noExpenses": "Deine Gruppe hat noch keine Ausgaben.",
|
||||
"exportJson": "Als JSON exportieren",
|
||||
"exportCsv": "Als CSV exportieren",
|
||||
"searchPlaceholder": "Suche nach einer Ausgabe…",
|
||||
"ActiveUserModal": {
|
||||
"title": "Wer bist du?",
|
||||
@@ -35,14 +36,17 @@
|
||||
"earlierThisMonth": "Diesen Monat",
|
||||
"lastMonth": "Letzten Monat",
|
||||
"earlierThisYear": "Dieses Jahr",
|
||||
"lastYera": "Letztes 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",
|
||||
@@ -116,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": {
|
||||
@@ -136,6 +146,14 @@
|
||||
"label": "Empfangen von",
|
||||
"description": "Wähle das Mitglied, das die Einnahme erhalten hat."
|
||||
},
|
||||
"recurrenceRule": {
|
||||
"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."
|
||||
@@ -144,7 +162,7 @@
|
||||
"attachDescription": "Füge der Einnahme einen Beleg hinzu."
|
||||
},
|
||||
"Expense": {
|
||||
"create": "Augabe erstellen",
|
||||
"create": "Ausgabe erstellen",
|
||||
"edit": "Ausgabe bearbeiten",
|
||||
"TitleField": {
|
||||
"label": "Titel der Ausgabe",
|
||||
@@ -158,8 +176,17 @@
|
||||
"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."
|
||||
@@ -209,7 +236,7 @@
|
||||
"ExpenseDocumentsInput": {
|
||||
"TooBigToast": {
|
||||
"title": "Die Datei ist zu groß",
|
||||
"description": "Die maximale Dateigröße ist {maxSize}. Deine ist ${size}."
|
||||
"description": "Die maximale Dateigröße ist {maxSize}. Deine ist {size}."
|
||||
},
|
||||
"ErrorToast": {
|
||||
"title": "Fehler beim Hochladen der Datei",
|
||||
@@ -222,7 +249,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:",
|
||||
@@ -234,7 +261,7 @@
|
||||
"unknown": "Unbekannt",
|
||||
"TooBigToast": {
|
||||
"title": "Die Datei ist zu groß",
|
||||
"description": "Die maximale Dateigröße ist {maxSize}. Deine ist ${size}."
|
||||
"description": "Die maximale Dateigröße ist {maxSize}. Deine ist {size}."
|
||||
},
|
||||
"ErrorToast": {
|
||||
"title": "Fehler beim Hochladen der Datei",
|
||||
@@ -271,13 +298,13 @@
|
||||
"noActivity": "Es gab noch keine Aktivität in dieser Gruppe.",
|
||||
"someone": "Jemand",
|
||||
"settingsModified": "Die Gruppeneinstellungen wurden von <strong>{participant}</strong> verändert.",
|
||||
"expenseCreated": "Augabe <em>{expense}</em> wurde von <strong>{participant}</strong> erstellt.",
|
||||
"expenseCreated": "Ausgabe <em>{expense}</em> wurde von <strong>{participant}</strong> erstellt.",
|
||||
"expenseUpdated": "Ausgabe <em>{expense}</em> wurde von <strong>{participant}</strong> aktualisiert.",
|
||||
"expenseDeleted": "Ausgabe <em>{expense}</em> wurde von <strong>{participant}</strong> gelöscht.",
|
||||
"Groups": {
|
||||
"today": "Heute",
|
||||
"yesterday": "Gestern",
|
||||
"earlierThisWeek": "Diese Woche",
|
||||
"earlierThisWeek": "Anfang dieser Woche",
|
||||
"lastWeek": "Letze Woche",
|
||||
"earlierThisMonth": "Diesen Monat",
|
||||
"lastMonth": "Letzen Monat",
|
||||
@@ -294,24 +321,11 @@
|
||||
"Settings": {
|
||||
"title": "Einstellungen"
|
||||
},
|
||||
"Locale": {
|
||||
"en-US": "English",
|
||||
"fi": "Suomi",
|
||||
"fr-FR": "Français",
|
||||
"es": "Español",
|
||||
"de-DE": "Deutsch",
|
||||
"zh-CN": "Chinese (Simplified)",
|
||||
"pl-PL": "Polski",
|
||||
"ru-RU": "Русский",
|
||||
"it-IT": "Italiano",
|
||||
"ua-UA": "Українська",
|
||||
"ro": "Română"
|
||||
},
|
||||
"Share": {
|
||||
"title": "Teilen",
|
||||
"description": "Teile die URL, damit andere Mitglieder die Gruppe sehen und Ausgaben hinzufügen können.",
|
||||
"warning": "Achtung!",
|
||||
"warningHelp": "Jede person mit der Gruppen-URL kann Ausgaben sehen und editieren. Teile den Link mit Bedacht!"
|
||||
"warningHelp": "Jede Person mit der Gruppen-URL kann Ausgaben sehen und editieren. Teile den Link mit Bedacht!"
|
||||
},
|
||||
"SchemaErrors": {
|
||||
"min1": "Gib mindestens ein Zeichen ein.",
|
||||
@@ -323,7 +337,7 @@
|
||||
"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.",
|
||||
@@ -369,6 +383,7 @@
|
||||
"heading": "Leben",
|
||||
"Childcare": "Kinderversorgung",
|
||||
"Clothing": "Kleidung",
|
||||
"Donation": "Spende",
|
||||
"Education": "Bildung",
|
||||
"Gifts": "Geschenke",
|
||||
"Insurance": "Versicherung",
|
||||
@@ -397,5 +412,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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,9 @@
|
||||
"create": "Create expense",
|
||||
"createFirst": "Create the first one",
|
||||
"noExpenses": "Your group doesn’t contain any expense yet.",
|
||||
"export": "Export",
|
||||
"exportJson": "Export to JSON",
|
||||
"exportCsv": "Export to CSV",
|
||||
"searchPlaceholder": "Search for an expense…",
|
||||
"ActiveUserModal": {
|
||||
"title": "Who are you?",
|
||||
@@ -35,14 +37,16 @@
|
||||
"earlierThisMonth": "Earlier this month",
|
||||
"lastMonth": "Last month",
|
||||
"earlierThisYear": "Earlier this year",
|
||||
"lastYera": "Last year",
|
||||
"lastYear": "Last year",
|
||||
"older": "Older"
|
||||
}
|
||||
},
|
||||
"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",
|
||||
@@ -92,6 +96,12 @@
|
||||
"placeholder": "$, €, £…",
|
||||
"description": "We’ll 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.",
|
||||
@@ -131,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",
|
||||
@@ -155,11 +169,25 @@
|
||||
"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": {
|
||||
"label": "Expense Recurrence",
|
||||
"description": "Select how often the expense should repeat.",
|
||||
|
||||
"none": "None",
|
||||
"daily": "Daily",
|
||||
"weekly": "Weekly",
|
||||
"monthly": "Monthly"
|
||||
},
|
||||
"paidFor": {
|
||||
"title": "Paid for",
|
||||
"description": "Select who the expense was paid for."
|
||||
@@ -170,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"
|
||||
},
|
||||
@@ -209,7 +258,7 @@
|
||||
"ExpenseDocumentsInput": {
|
||||
"TooBigToast": {
|
||||
"title": "The file is too big",
|
||||
"description": "The maximum file size you can upload is {maxSize}. Yours is ${size}."
|
||||
"description": "The maximum file size you can upload is {maxSize}. Yours is {size}."
|
||||
},
|
||||
"ErrorToast": {
|
||||
"title": "Error while uploading document",
|
||||
@@ -234,7 +283,7 @@
|
||||
"unknown": "Unknown",
|
||||
"TooBigToast": {
|
||||
"title": "The file is too big",
|
||||
"description": "The maximum file size you can upload is {maxSize}. Yours is ${size}."
|
||||
"description": "The maximum file size you can upload is {maxSize}. Yours is {size}."
|
||||
},
|
||||
"ErrorToast": {
|
||||
"title": "Error while uploading document",
|
||||
@@ -294,19 +343,6 @@
|
||||
"Settings": {
|
||||
"title": "Settings"
|
||||
},
|
||||
"Locale": {
|
||||
"en-US": "English",
|
||||
"fi": "Suomi",
|
||||
"fr-FR": "Français",
|
||||
"es": "Español",
|
||||
"de-DE": "Deutsch",
|
||||
"zh-CN": "Chinese (Simplified)",
|
||||
"pl-PL": "Polski",
|
||||
"ru-RU": "Русский",
|
||||
"it-IT": "Italiano",
|
||||
"ua-UA": "Українська",
|
||||
"ro": "Română"
|
||||
},
|
||||
"Share": {
|
||||
"title": "Share",
|
||||
"description": "For other participants to see the group and add expenses, share its URL with them.",
|
||||
@@ -324,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.",
|
||||
@@ -369,6 +406,7 @@
|
||||
"heading": "Life",
|
||||
"Childcare": "Childcare",
|
||||
"Clothing": "Clothing",
|
||||
"Donation": "Donation",
|
||||
"Education": "Education",
|
||||
"Gifts": "Gifts",
|
||||
"Insurance": "Insurance",
|
||||
@@ -397,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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,9 @@
|
||||
"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…",
|
||||
"ActiveUserModal": {
|
||||
"title": "¿Quién es usted?",
|
||||
@@ -35,7 +37,7 @@
|
||||
"earlierThisMonth": "A principios de este mes",
|
||||
"lastMonth": "El mes pasado",
|
||||
"earlierThisYear": "A principios de este año",
|
||||
"lastYera": "El año pasado",
|
||||
"lastYear": "El año pasado",
|
||||
"older": "Más antiguos"
|
||||
}
|
||||
},
|
||||
@@ -160,6 +162,14 @@
|
||||
"label": "Pagado por",
|
||||
"description": "Seleccione el participante que pagó el gasto."
|
||||
},
|
||||
"recurrenceRule": {
|
||||
"label": "Recurrencia",
|
||||
"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."
|
||||
@@ -209,7 +219,7 @@
|
||||
"ExpenseDocumentsInput": {
|
||||
"TooBigToast": {
|
||||
"title": "El archivo es demasiado grande",
|
||||
"description": "El tamaño máximo de archivo que puede cargar es {maxSize}. El tuyo pesa ${size}."
|
||||
"description": "El tamaño máximo de archivo que puede cargar es {maxSize}. El tuyo pesa {size}."
|
||||
},
|
||||
"ErrorToast": {
|
||||
"title": "Error al cargar el documento",
|
||||
@@ -234,7 +244,7 @@
|
||||
"unknown": "Desconocido",
|
||||
"TooBigToast": {
|
||||
"title": "El archivo es demasiado grande",
|
||||
"description": "El tamaño máximo de archivo que puede cargar es {maxSize}. El tuyo pesa ${size}."
|
||||
"description": "El tamaño máximo de archivo que puede cargar es {maxSize}. El tuyo pesa {size}."
|
||||
},
|
||||
"ErrorToast": {
|
||||
"title": "Error al cargar el documento",
|
||||
@@ -249,7 +259,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"
|
||||
}
|
||||
},
|
||||
@@ -294,19 +304,6 @@
|
||||
"Settings": {
|
||||
"title": "Ajustes"
|
||||
},
|
||||
"Locale": {
|
||||
"en-US": "English",
|
||||
"fi": "Suomi",
|
||||
"fr-FR": "Français",
|
||||
"es": "Español",
|
||||
"de-DE": "Deutsch",
|
||||
"zh-CN": "Chinese (Simplified)",
|
||||
"pl-PL": "Polski",
|
||||
"ru-RU": "Русский",
|
||||
"it-IT": "Italiano",
|
||||
"ua-UA": "Українська",
|
||||
"ro": "Română"
|
||||
},
|
||||
"Share": {
|
||||
"title": "Compartir",
|
||||
"description": "Para que otros participantes puedan ver el grupo y añadir gastos, compárteles su URL.",
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
"createFirst": "Lisää ensimmäinen kulu",
|
||||
"noExpenses": "Ryhmälläsi ei ole vielä yhtään kulua.",
|
||||
"exportJson": "Vie JSON-tiedostoon",
|
||||
"exportCsv": "Vie CSV-tiedostoon",
|
||||
"searchPlaceholder": "Etsi kulua…",
|
||||
"ActiveUserModal": {
|
||||
"title": "Kuka olet?",
|
||||
@@ -209,7 +210,7 @@
|
||||
"ExpenseDocumentsInput": {
|
||||
"TooBigToast": {
|
||||
"title": "Tiedosto on liian suuri",
|
||||
"description": "Maksimikoko ladattavalle tiedostolle on {maxSize}. Tiedostosi on ${size}."
|
||||
"description": "Maksimikoko ladattavalle tiedostolle on {maxSize}. Tiedostosi on {size}."
|
||||
},
|
||||
"ErrorToast": {
|
||||
"title": "Virhe tiedostoa ladattaessa",
|
||||
@@ -230,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": {
|
||||
@@ -294,19 +285,6 @@
|
||||
"Settings": {
|
||||
"title": "Asetukset"
|
||||
},
|
||||
"Locale": {
|
||||
"en-US": "English",
|
||||
"fi": "Suomi",
|
||||
"fr-FR": "Français",
|
||||
"es": "Español",
|
||||
"de-DE": "Deutsch",
|
||||
"zh-CN": "Chinese (Simplified)",
|
||||
"pl-PL": "Polski",
|
||||
"ru-RU": "Русский",
|
||||
"it-IT": "Italiano",
|
||||
"ua-UA": "Українська",
|
||||
"ro": "Română"
|
||||
},
|
||||
"Share": {
|
||||
"title": "Jaa",
|
||||
"description": "Jaa ryhmän URL muille jäsenille, jotta he voivat nähdä sen ja lisätä kuluja.",
|
||||
|
||||
@@ -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",
|
||||
@@ -21,6 +21,7 @@
|
||||
"createFirst": "Créer la première :)",
|
||||
"noExpenses": "Votre groupe n'a effectué aucune dépense pour le moment.",
|
||||
"exportJson": "Exporter en JSON",
|
||||
"exportCsv": "Exporter en CSV",
|
||||
"searchPlaceholder": "Rechercher une dépense…",
|
||||
"ActiveUserModal": {
|
||||
"title": "Qui êtes-vous ?",
|
||||
@@ -35,14 +36,17 @@
|
||||
"earlierThisMonth": "Plus tôt ce mois-ci",
|
||||
"lastMonth": "Le mois dernier",
|
||||
"earlierThisYear": "Plus tôt cette année",
|
||||
"lastYera": "L'année dernière",
|
||||
"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",
|
||||
@@ -98,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",
|
||||
@@ -116,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": "Personalisée"
|
||||
}
|
||||
},
|
||||
"ExpenseForm": {
|
||||
@@ -136,12 +146,24 @@
|
||||
"label": "Reçu par",
|
||||
"description": "Sélectionnez le participant qui a reçu le revenu."
|
||||
},
|
||||
"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": "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 bénéfice a été perçu."
|
||||
}
|
||||
},
|
||||
"Expense": {
|
||||
"create": "Créer une dépense",
|
||||
@@ -158,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"
|
||||
},
|
||||
"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"
|
||||
@@ -204,12 +239,26 @@
|
||||
"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"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"ExpenseDocumentsInput": {
|
||||
"TooBigToast": {
|
||||
"title": "Le fichier est trop grand",
|
||||
"description": "La taille maximale du fichier que vous pouvez télécharger est {maxSize}. La vôtre est ${size}."
|
||||
"description": "La taille maximale du fichier que vous pouvez télécharger est {maxSize}. La vôtre est {size}."
|
||||
},
|
||||
"ErrorToast": {
|
||||
"title": "Erreur lors du téléchargement du document",
|
||||
@@ -234,7 +283,7 @@
|
||||
"unknown": "Inconnu",
|
||||
"TooBigToast": {
|
||||
"title": "Le fichier est trop grand",
|
||||
"description": "La taille maximale du fichier que vous pouvez télécharger est {maxSize}. La vôtre est ${size}."
|
||||
"description": "La taille maximale du fichier que vous pouvez télécharger est {maxSize}. La vôtre est {size}."
|
||||
},
|
||||
"ErrorToast": {
|
||||
"title": "Erreur lors du téléchargement du document",
|
||||
@@ -294,19 +343,6 @@
|
||||
"Settings": {
|
||||
"title": "Paramètres"
|
||||
},
|
||||
"Locale": {
|
||||
"en-US": "English",
|
||||
"fi": "Suomi",
|
||||
"fr-FR": "Français",
|
||||
"es": "Español",
|
||||
"de-DE": "Deutsch",
|
||||
"zh-CN": "Chinese (Simplified)",
|
||||
"pl-PL": "Polski",
|
||||
"ru-RU": "Русский",
|
||||
"it-IT": "Italiano",
|
||||
"ua-UA": "Українська",
|
||||
"ro": "Română"
|
||||
},
|
||||
"Share": {
|
||||
"title": "Partager",
|
||||
"description": "Pour que d'autres participants puissent voir le groupe et ajouter des dépenses, partagez son URL avec eux.",
|
||||
@@ -344,7 +380,7 @@
|
||||
"Games": "Jeux",
|
||||
"Movies": "Films",
|
||||
"Music": "Musique",
|
||||
"Sports": "Sports"
|
||||
"Sports": "Sport"
|
||||
},
|
||||
"Food and Drink": {
|
||||
"heading": "Nourriture et boissons",
|
||||
@@ -373,7 +409,8 @@
|
||||
"Gifts": "Cadeaux",
|
||||
"Insurance": "Assurance",
|
||||
"Medical Expenses": "Dépenses médicales",
|
||||
"Taxes": "Impôts"
|
||||
"Taxes": "Impôts",
|
||||
"Donation": "Don"
|
||||
},
|
||||
"Transportation": {
|
||||
"heading": "Transport",
|
||||
@@ -397,5 +434,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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
@@ -11,8 +11,8 @@
|
||||
"groups": "Gruppi"
|
||||
},
|
||||
"Footer": {
|
||||
"madeIn": "Made in Montréal, Québec 🇨🇦",
|
||||
"builtBy": "Built by <author>Sebastien Castiel</author> and <source>contributors</source>"
|
||||
"madeIn": "Realizzato a Montréal, Québec 🇨🇦",
|
||||
"builtBy": "Sviluppato da <author>Sebastien Castiel</author> e <source>contributori</source>"
|
||||
},
|
||||
"Expenses": {
|
||||
"title": "Spese",
|
||||
@@ -21,6 +21,7 @@
|
||||
"createFirst": "Crea la prima",
|
||||
"noExpenses": "Il tuo gruppo non contiene ancora spese.",
|
||||
"exportJson": "Esporta file JSON",
|
||||
"exportCsv": "Esporta file CSV",
|
||||
"searchPlaceholder": "Cerca una spesa…",
|
||||
"ActiveUserModal": {
|
||||
"title": "Chi sei?",
|
||||
@@ -35,14 +36,17 @@
|
||||
"earlierThisMonth": "All'inizio di questo mese",
|
||||
"lastMonth": "Ultimo mese",
|
||||
"earlierThisYear": "All'inizio di quest'anno",
|
||||
"lastYera": "Ultimo 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",
|
||||
@@ -50,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",
|
||||
@@ -69,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.",
|
||||
@@ -98,9 +102,9 @@
|
||||
"protectedParticipant": "Questo partecipante fa parte delle spese e non può essere rimosso.",
|
||||
"new": "Nuovo",
|
||||
"add": "Aggiungi partecipante",
|
||||
"John": "John",
|
||||
"Jane": "Jane",
|
||||
"Jack": "Jack"
|
||||
"John": "Fabio",
|
||||
"Jane": "Kaneda",
|
||||
"Jack": "Albano"
|
||||
},
|
||||
"Settings": {
|
||||
"title": "Impostazioni locali",
|
||||
@@ -131,41 +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": "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"
|
||||
@@ -209,7 +230,7 @@
|
||||
"ExpenseDocumentsInput": {
|
||||
"TooBigToast": {
|
||||
"title": "Il file è troppo grande",
|
||||
"description": "La dimensione massima del file che puoi caricare è {maxSize}. Il tuo è ${size}."
|
||||
"description": "La dimensione massima del file che puoi caricare è {maxSize}. Il tuo è {size}."
|
||||
},
|
||||
"ErrorToast": {
|
||||
"title": "Errore durante il caricamento del documento",
|
||||
@@ -231,10 +252,10 @@
|
||||
"editNext": "Successivamente potrai modificare le informazioni sulle spese.",
|
||||
"continue": "Continua"
|
||||
},
|
||||
"unknown": "Unknown",
|
||||
"unknown": "Sconosciuto",
|
||||
"TooBigToast": {
|
||||
"title": "Il file è troppo grande",
|
||||
"description": "La dimensione massima del file che puoi caricare è {maxSize}. Il tuo è ${size}."
|
||||
"description": "La dimensione massima del file che puoi caricare è {maxSize}. Il tuo è {size}."
|
||||
},
|
||||
"ErrorToast": {
|
||||
"title": "Errore durante il caricamento del documento",
|
||||
@@ -294,19 +315,6 @@
|
||||
"Settings": {
|
||||
"title": "Impostazioni"
|
||||
},
|
||||
"Locale": {
|
||||
"en-US": "English",
|
||||
"fi": "Suomi",
|
||||
"fr-FR": "Français",
|
||||
"es": "Español",
|
||||
"de-DE": "Deutsch",
|
||||
"zh-CN": "Chinese (Simplified)",
|
||||
"pl-PL": "Polski",
|
||||
"ru-RU": "Русский",
|
||||
"it-IT": "Italiano",
|
||||
"ua-UA": "Українська",
|
||||
"ro": "Română"
|
||||
},
|
||||
"Share": {
|
||||
"title": "Condividi",
|
||||
"description": "Per consentire agli altri partecipanti di vedere il gruppo e aggiungere spese, condividi il suo URL con loro.",
|
||||
@@ -321,7 +329,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.",
|
||||
@@ -341,10 +349,10 @@
|
||||
"Entertainment": {
|
||||
"heading": "Intrattenimento",
|
||||
"Entertainment": "Intrattenimento",
|
||||
"Games": "Games",
|
||||
"Games": "Giochi",
|
||||
"Movies": "Film",
|
||||
"Music": "Musica",
|
||||
"Sports": "Sports"
|
||||
"Sports": "Sport"
|
||||
},
|
||||
"Food and Drink": {
|
||||
"heading": "Cibo e Bevande",
|
||||
@@ -354,11 +362,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",
|
||||
@@ -366,12 +374,13 @@
|
||||
"Services": "Servizi"
|
||||
},
|
||||
"Life": {
|
||||
"heading": "Life",
|
||||
"Childcare": "Assistenza all'infanzia",
|
||||
"Clothing": "Vestiti",
|
||||
"Education": "Educazione",
|
||||
"heading": "Vita",
|
||||
"Childcare": "Cura dei bambini",
|
||||
"Clothing": "Abbigliamento",
|
||||
"Donation": "Donazioni",
|
||||
"Education": "Istruzione",
|
||||
"Gifts": "Regali",
|
||||
"Insurance": "Assicurazione",
|
||||
"Insurance": "Assicurazioni",
|
||||
"Medical Expenses": "Spese Mediche",
|
||||
"Taxes": "Tasse"
|
||||
},
|
||||
@@ -388,12 +397,12 @@
|
||||
"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"
|
||||
}
|
||||
|
||||
399
messages/ja-JP.json
Normal file
@@ -0,0 +1,399 @@
|
||||
{
|
||||
"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": "あなたの残高:"
|
||||
},
|
||||
"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": "John",
|
||||
"Jane": "Jane",
|
||||
"Jack": "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": "収入を受け取った日付を入力してください。"
|
||||
},
|
||||
"categoryFieldDescription": "収入カテゴリーを選択してください。",
|
||||
"paidByField": {
|
||||
"label": "受取人",
|
||||
"description": "収入を受け取った参加者を選択してください。"
|
||||
},
|
||||
"paidFor": {
|
||||
"title": "受け取り対象",
|
||||
"description": "誰のために収入が受け取られたかを選択してください。"
|
||||
},
|
||||
"splitModeDescription": "収入の分割方法を選択してください。",
|
||||
"attachDescription": "領収書を確認し、収入に添付してください。"
|
||||
},
|
||||
"Expense": {
|
||||
"create": "支出を作成",
|
||||
"edit": "支出を編集",
|
||||
"TitleField": {
|
||||
"label": "支出タイトル",
|
||||
"placeholder": "月曜日の夕食レストラン",
|
||||
"description": "支出の説明を入力してください。"
|
||||
},
|
||||
"DateField": {
|
||||
"label": "支出日",
|
||||
"description": "支出が支払われた日付を入力してください。"
|
||||
},
|
||||
"categoryFieldDescription": "支出カテゴリーを選択してください。",
|
||||
"paidByField": {
|
||||
"label": "支払者",
|
||||
"description": "支出を支払った参加者を選択してください。"
|
||||
},
|
||||
"recurrenceRule": {
|
||||
"label": "支出の繰り返し",
|
||||
"description": "支出の繰り返し頻度を選択してください。",
|
||||
"none": "なし",
|
||||
"daily": "毎日",
|
||||
"weekly": "毎週",
|
||||
"monthly": "毎月"
|
||||
},
|
||||
"paidFor": {
|
||||
"title": "支払い対象",
|
||||
"description": "誰のために支出が支払われたかを選択してください。"
|
||||
},
|
||||
"splitModeDescription": "支出の分割方法を選択してください。",
|
||||
"attachDescription": "領収書を確認し、支出に添付してください。"
|
||||
},
|
||||
"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": "払い戻し"
|
||||
},
|
||||
"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である必要があります。"
|
||||
},
|
||||
"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": "水道"
|
||||
}
|
||||
}
|
||||
}
|
||||
459
messages/nl-NL.json
Normal file
@@ -0,0 +1,459 @@
|
||||
{
|
||||
"Homepage": {
|
||||
"title": "Deel <strong>Uitgaven</strong> met <strong>Vrienden & Familie</strong>",
|
||||
"description": "Welkom op je nieuwe <strong>Spliit</strong>-instantie!",
|
||||
"button": {
|
||||
"groups": "Ga naar groepen",
|
||||
"github": "GitHub"
|
||||
}
|
||||
},
|
||||
"Header": {
|
||||
"groups": "Groepen"
|
||||
},
|
||||
"Footer": {
|
||||
"madeIn": "Gemaakt in Montréal, Québec 🇨🇦",
|
||||
"builtBy": "Geschreven door <author>Sebastien Castiel</author> en <source>bijdragers</source>"
|
||||
},
|
||||
"Expenses": {
|
||||
"title": "Uitgaven",
|
||||
"description": "Dit zijn de uitgaven die je gemaakt hebt voor je groep.",
|
||||
"create": "Maak uitgave",
|
||||
"createFirst": "Maak de eerste",
|
||||
"noExpenses": "Je groep heeft nog geen uitgaven.",
|
||||
"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": "Opslaan",
|
||||
"footer": "Deze instelling kan later worden gewijzigd in de instellingen van de groep."
|
||||
},
|
||||
"Groups": {
|
||||
"upcoming": "Aankomend",
|
||||
"thisWeek": "Deze week",
|
||||
"earlierThisMonth": "Eerder deze maand",
|
||||
"lastMonth": "Vorige maand",
|
||||
"earlierThisYear": "Eerder dit jaar",
|
||||
"lastYear": "Vorig jaar",
|
||||
"older": "Ouder"
|
||||
}
|
||||
},
|
||||
"ExpenseCard": {
|
||||
"paidBy": "Betaald door <strong>{paidBy}</strong> voor <paidFor></paidFor>",
|
||||
"everyone": "iedereen",
|
||||
"receivedBy": "Ontvangen door <strong>{paidBy}</strong> voor <paidFor></paidFor>",
|
||||
"yourBalance": "Jouw balans:",
|
||||
"notInvolved": "Je bent hier niet bij betrokken"
|
||||
},
|
||||
"Groups": {
|
||||
"myGroups": "Mijn groepen",
|
||||
"create": "Maak",
|
||||
"loadingRecent": "Recente groepen laden…",
|
||||
"NoRecent": {
|
||||
"description": "Je hebt de laatste tijd geen groepen bezocht.",
|
||||
"create": "Maak er één",
|
||||
"orAsk": "of vraag een vriend om je de link naar een bestaande groep te sturen."
|
||||
},
|
||||
"recent": "Recente groepen",
|
||||
"starred": "Favoriete groepen",
|
||||
"archived": "Gearchiveerde groepen",
|
||||
"archive": "Archiveer groep",
|
||||
"unarchive": "Herstel groep",
|
||||
"removeRecent": "Verwijder uit recente groepen",
|
||||
"RecentRemovedToast": {
|
||||
"title": "Groep verwijderd",
|
||||
"description": "Deze groep is verwijderd uit je recente groepen.",
|
||||
"undoAlt": "Maak het verwijderen van de groep ongedaan",
|
||||
"undo": "Ongedaan maken"
|
||||
},
|
||||
"AddByURL": {
|
||||
"button": "Voeg toe met URL",
|
||||
"title": "Voeg een groep toe met een URL",
|
||||
"description": "Als een groep met je gedeeld is, kun je de URL hier plakken om deze aan je lijst toe te voegen.",
|
||||
"error": "Oeps, we kunnen de groep niet vinden met de URL die je hebt opgegeven…"
|
||||
},
|
||||
"NotFound": {
|
||||
"text": "Deze groep bestaat niet.",
|
||||
"link": "Ga naar je recente groepen"
|
||||
}
|
||||
},
|
||||
"GroupForm": {
|
||||
"title": "Groepsinformatie",
|
||||
"NameField": {
|
||||
"label": "Groepsnaam",
|
||||
"placeholder": "Zomervakantie",
|
||||
"description": "Geef je groep een naam."
|
||||
},
|
||||
"InformationField": {
|
||||
"label": "Groepsinformatie",
|
||||
"placeholder": "Welke informatie is relevant voor de groep?"
|
||||
},
|
||||
"CurrencyField": {
|
||||
"label": "Symbool van de valuta",
|
||||
"placeholder": "€, $, £…",
|
||||
"description": "Die gebruiken we om de bedragen in de groep aan te geven."
|
||||
},
|
||||
"Participants": {
|
||||
"title": "Deelnemers",
|
||||
"description": "Voer de naam in van de deelnemers in de groep.",
|
||||
"protectedParticipant": "Deze deelnemer maakt deel uit van de uitgaven en kan niet worden verwijderd.",
|
||||
"new": "Nieuwe deelnemer",
|
||||
"add": "Voeg deelnemer toe",
|
||||
"John": "Jan",
|
||||
"Jane": "Julia",
|
||||
"Jack": "Jacob"
|
||||
},
|
||||
"Settings": {
|
||||
"title": "Lokale instellingen",
|
||||
"description": "Deze instellingen worden per apparaat ingesteld en worden gebruikt om je ervaring aan te passen.",
|
||||
"ActiveUserField": {
|
||||
"label": "Huidige gebruiker",
|
||||
"placeholder": "Selecteer een deelnemer",
|
||||
"none": "Geen",
|
||||
"description": "De deelnemer die automatisch wordt geselecteerd als je een uitgave maakt."
|
||||
},
|
||||
"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": {
|
||||
"Income": {
|
||||
"create": "Maak inkomen",
|
||||
"edit": "Bewerk inkomen",
|
||||
"TitleField": {
|
||||
"label": "Titel inkomen",
|
||||
"placeholder": "Restaurant maandagavond",
|
||||
"description": "Voer een beschrijving in voor het inkomen."
|
||||
},
|
||||
"DateField": {
|
||||
"label": "Datum inkomen",
|
||||
"description": "Voer de datum in waarop het inkomen is ontvangen."
|
||||
},
|
||||
"categoryFieldDescription": "Selecteer de inkomencategorie.",
|
||||
"paidByField": {
|
||||
"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.",
|
||||
"currencyField": {
|
||||
"label": "Munteenheid van inkomen",
|
||||
"description": "De munteenheid waar het inkomen in is ontvangen."
|
||||
}
|
||||
},
|
||||
"Expense": {
|
||||
"create": "Maak uitgave",
|
||||
"edit": "Bewerk uitgave",
|
||||
"TitleField": {
|
||||
"label": "Titel uitgave",
|
||||
"placeholder": "Restaurant maandagavond",
|
||||
"description": "Voer een beschrijving in voor de uitgave."
|
||||
},
|
||||
"DateField": {
|
||||
"label": "Datum uitgave",
|
||||
"description": "Voer de datum in waarop de uitgave is gedaan."
|
||||
},
|
||||
"categoryFieldDescription": "Selecteer de uitgavecategorie.",
|
||||
"paidByField": {
|
||||
"label": "Betaald door",
|
||||
"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.",
|
||||
"currencyField": {
|
||||
"label": "Munteenheid van uitgave",
|
||||
"description": "De munteenheid waar de uitgave in is betaald."
|
||||
}
|
||||
},
|
||||
"amountField": {
|
||||
"label": "Bedrag"
|
||||
},
|
||||
"isReimbursementField": {
|
||||
"label": "Dit is een terugbetaling"
|
||||
},
|
||||
"categoryField": {
|
||||
"label": "Categorie"
|
||||
},
|
||||
"notesField": {
|
||||
"label": "Notities"
|
||||
},
|
||||
"selectNone": "Selecteer niemand",
|
||||
"selectAll": "Selecteer iedereen",
|
||||
"shares": "deel/delen",
|
||||
"advancedOptions": "Andere split-opties…",
|
||||
"SplitModeField": {
|
||||
"label": "Split soort",
|
||||
"evenly": "Gelijk verdeeld",
|
||||
"byShares": "Ongelijk – Met delen",
|
||||
"byPercentage": "Ongelijk – Met percentage",
|
||||
"byAmount": "Ongelijk – Met bedrag",
|
||||
"saveAsDefault": "Opslaan als standaard-optie"
|
||||
},
|
||||
"DeletePopup": {
|
||||
"label": "Verwijderen",
|
||||
"title": "Deze uitgave verwijderen?",
|
||||
"description": "Wil je deze uitgave echt verwijderen? Dit kan niet ongedaan worden.",
|
||||
"yes": "Ja",
|
||||
"cancel": "Annuleer"
|
||||
},
|
||||
"attachDocuments": "Voeg documenten toe",
|
||||
"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": {
|
||||
"title": "Het bestand is te groot",
|
||||
"description": "De maximum bestandsgrootte {maxSize}. Jouw bestand is {size}."
|
||||
},
|
||||
"ErrorToast": {
|
||||
"title": "Fout bij het uploaden van document",
|
||||
"description": "Er is iets mis gegaan bij het uploaden van het document. Probeer het later opnieuw of kies een ander bestand.",
|
||||
"retry": "Probeer opnieuw"
|
||||
}
|
||||
},
|
||||
"CreateFromReceipt": {
|
||||
"Dialog": {
|
||||
"triggerTitle": "Uitgave maken van foto",
|
||||
"title": "Maak uitgave van foto",
|
||||
"description": "Uitgave-informatie van een foto van een bon lezen.",
|
||||
"body": "Upload de foto van een bon, en we lezen de uitgave-informatie eruit.",
|
||||
"selectImage": "Selecteer foto…",
|
||||
"titleLabel": "Titel:",
|
||||
"categoryLabel": "Categorie:",
|
||||
"amountLabel": "Bedrag:",
|
||||
"dateLabel": "Datum:",
|
||||
"editNext": "Hierna kun je de uitgave-informatie bewerken.",
|
||||
"continue": "Doorgaan"
|
||||
},
|
||||
"unknown": "Onbekend",
|
||||
"TooBigToast": {
|
||||
"title": "Het bestand is te groot",
|
||||
"description": "De maximum bestandsgrootte {maxSize}. Jouw bestand is {size}."
|
||||
},
|
||||
"ErrorToast": {
|
||||
"title": "Fout bij het uploaden van document",
|
||||
"description": "Er is iets mis gegaan bij het uploaden van het document. Probeer het later opnieuw of kies een ander bestand.",
|
||||
"retry": "Probeer opnieuw"
|
||||
}
|
||||
},
|
||||
"Balances": {
|
||||
"title": "Balans",
|
||||
"description": "Dit zijn de bedragen die elke deelnemer heeft betaald of waarvoor is betaald.",
|
||||
"Reimbursements": {
|
||||
"title": "Voorgestelde terugbetalingen",
|
||||
"description": "Dit zijn de voorgestelde terugbetalingen tussen deelnemers.",
|
||||
"noImbursements": "Lijkt erop dat je groep geen terugbetalingen nodig heeft 😁",
|
||||
"owes": "<strong>{from}</strong> betaalt aan <strong>{to}</strong>",
|
||||
"markAsPaid": "Markeer als betaald"
|
||||
}
|
||||
},
|
||||
"Stats": {
|
||||
"title": "Statistieken",
|
||||
"Totals": {
|
||||
"title": "Totaaluitgaven",
|
||||
"description": "Uitgavenoverzicht van de hele groep.",
|
||||
"groupSpendings": "Totale uitgaven van de groep",
|
||||
"groupEarnings": "Totale inkomsten van de groep",
|
||||
"yourSpendings": "Jouw totale uitgaven",
|
||||
"yourEarnings": "Jouw totale inkomsten",
|
||||
"yourShare": "Jouw totale aandeel"
|
||||
}
|
||||
},
|
||||
"Activity": {
|
||||
"title": "Gebeurtenissen",
|
||||
"description": "Overzicht van de gebeurtenissen in je groep.",
|
||||
"noActivity": "Er zijn geen gebeurtenissen in deze groep.",
|
||||
"someone": "Iemand",
|
||||
"settingsModified": "Groepsinstellingen zijn aangepast door <strong>{participant}</strong>.",
|
||||
"expenseCreated": "Uitgave <em>{expense}</em> gemaakt door <strong>{participant}</strong>.",
|
||||
"expenseUpdated": "Uitgave <em>{expense}</em> bewerkt door <strong>{participant}</strong>.",
|
||||
"expenseDeleted": "Uitgave <em>{expense}</em> verwijderd door <strong>{participant}</strong>.",
|
||||
"Groups": {
|
||||
"today": "Vandaag",
|
||||
"yesterday": "Gisteren",
|
||||
"earlierThisWeek": "Eerder deze week",
|
||||
"lastWeek": "Vorige week",
|
||||
"earlierThisMonth": "Eerder deze maand",
|
||||
"lastMonth": "Vorige maand",
|
||||
"earlierThisYear": "Eerder dit jaar",
|
||||
"lastYear": "Vorig jaar",
|
||||
"older": "Ouder"
|
||||
}
|
||||
},
|
||||
"Information": {
|
||||
"title": "Informatie",
|
||||
"description": "Gebruike deze plek om informatie toe te voegen die relevant kan zijn voor de groepsleden.",
|
||||
"empty": "Nog geen informatie toegevoegd."
|
||||
},
|
||||
"Settings": {
|
||||
"title": "Instellingen"
|
||||
},
|
||||
"Share": {
|
||||
"title": "Delen",
|
||||
"description": "Om andere deelnemers de groep te laten zien en uitgaven toe te voegen, deel je de URL met hen.",
|
||||
"warning": "Waarschuwing!",
|
||||
"warningHelp": "Iedereen met de groeps-URL kan de uitgaven zien en bewerken. Deel voorzichtig!"
|
||||
},
|
||||
"SchemaErrors": {
|
||||
"min1": "Vul ten minste één karakter in.",
|
||||
"min2": "Vul ten minste twee karakters in.",
|
||||
"max5": "Vul maximaal vijf karakters in.",
|
||||
"max50": "Vul maximaal 50 karakters in.",
|
||||
"duplicateParticipantName": "Er is al een deelnemer met deze naam.",
|
||||
"titleRequired": "Vul een titel in.",
|
||||
"invalidNumber": "Ongeldig getal.",
|
||||
"amountRequired": "Vul een bedrag in.",
|
||||
"amountNotZero": "Het bedrag moet hoger zijn dan 0.",
|
||||
"amountTenMillion": "Het bedrag mag niet hoger zijn dan 10,000,000.",
|
||||
"paidByRequired": "Selecteer een deelnemer die de uitgave heeft gedaan.",
|
||||
"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%.",
|
||||
"ratePositive": "De koers moet groter dan nul zijn."
|
||||
},
|
||||
"Categories": {
|
||||
"search": "Categorie zoeken…",
|
||||
"noCategory": "Geen categorieën gevonden.",
|
||||
"Uncategorized": {
|
||||
"heading": "Geen categorie",
|
||||
"General": "Algemeen",
|
||||
"Payment": "Betaling"
|
||||
},
|
||||
"Entertainment": {
|
||||
"heading": "Vermaak",
|
||||
"Entertainment": "Vermaak",
|
||||
"Games": "Games",
|
||||
"Movies": "Film",
|
||||
"Music": "Muziek",
|
||||
"Sports": "Sport"
|
||||
},
|
||||
"Food and Drink": {
|
||||
"heading": "Eten en Drinken",
|
||||
"Food and Drink": "Eten en Drinken",
|
||||
"Dining Out": "Uit eten",
|
||||
"Groceries": "Boodschappen",
|
||||
"Liquor": "Drank"
|
||||
},
|
||||
"Home": {
|
||||
"heading": "Thuis",
|
||||
"Home": "Thuis",
|
||||
"Electronics": "Elektronica",
|
||||
"Furniture": "Meubels",
|
||||
"Household Supplies": "Huishoudelijke artikelen",
|
||||
"Maintenance": "Onderhoud",
|
||||
"Mortgage": "Hypotheek",
|
||||
"Pets": "Huisdieren",
|
||||
"Rent": "Huur",
|
||||
"Services": "Diensten"
|
||||
},
|
||||
"Life": {
|
||||
"heading": "Leven",
|
||||
"Childcare": "Kinderopvang",
|
||||
"Clothing": "Kleding",
|
||||
"Donation": "Donatie",
|
||||
"Education": "Onderwijs",
|
||||
"Gifts": "Cadeaus",
|
||||
"Insurance": "Verzekering",
|
||||
"Medical Expenses": "Medische kosten",
|
||||
"Taxes": "Belastingen"
|
||||
},
|
||||
"Transportation": {
|
||||
"heading": "Vervoer",
|
||||
"Transportation": "Vervoer",
|
||||
"Bicycle": "Fiets",
|
||||
"Bus/Train": "Bus/Trein",
|
||||
"Car": "Auto",
|
||||
"Gas/Fuel": "Tanken",
|
||||
"Hotel": "Hotel",
|
||||
"Parking": "Parkeren",
|
||||
"Plane": "Vliegtuig",
|
||||
"Taxi": "Taxi"
|
||||
},
|
||||
"Utilities": {
|
||||
"heading": "Nutsvoorzieningen",
|
||||
"Utilities": "Nutsvoorzieningen",
|
||||
"Cleaning": "Schoonmaak",
|
||||
"Electricity": "Elektriciteit",
|
||||
"Heat/Gas": "Verwarming/Gas",
|
||||
"Trash": "Afval",
|
||||
"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..."
|
||||
}
|
||||
}
|
||||
@@ -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,17 +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",
|
||||
"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.",
|
||||
@@ -35,7 +37,7 @@
|
||||
"earlierThisMonth": "Wcześniej w tym miesiącu",
|
||||
"lastMonth": "Ostatni miesiąc",
|
||||
"earlierThisYear": "Wcześniej w tym roku",
|
||||
"lastYera": "Poprzedni rok",
|
||||
"lastYear": "Poprzedni rok",
|
||||
"older": "Starsze"
|
||||
}
|
||||
},
|
||||
@@ -47,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",
|
||||
@@ -66,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.",
|
||||
@@ -81,7 +83,7 @@
|
||||
"NameField": {
|
||||
"label": "Nazwa grupy",
|
||||
"placeholder": "Letni wyjazd",
|
||||
"description": "Podaj nazwę dla twojej grupy."
|
||||
"description": "Podaj nazwę dla grupy."
|
||||
},
|
||||
"InformationField": {
|
||||
"label": "Informacje o grupie",
|
||||
@@ -90,7 +92,7 @@
|
||||
"CurrencyField": {
|
||||
"label": "Symbol waluty",
|
||||
"placeholder": "PLN, zł, $, €, £…",
|
||||
"description": "Użyjemy go do wyświetlania ilości."
|
||||
"description": "Użyjemy go do wyświetlania kwot."
|
||||
},
|
||||
"Participants": {
|
||||
"title": "Członkowie",
|
||||
@@ -104,10 +106,10 @@
|
||||
},
|
||||
"Settings": {
|
||||
"title": "Ustawienia lokalne",
|
||||
"description": "Te ustawienia są ustawiane dla konkretnego 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 członka",
|
||||
"placeholder": "Wybierz użytkownika",
|
||||
"none": "Brak",
|
||||
"description": "Użytkownik używany domyślnie do wprowadzania wydatków."
|
||||
},
|
||||
@@ -131,7 +133,7 @@
|
||||
"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."
|
||||
@@ -153,13 +155,21 @@
|
||||
},
|
||||
"DateField": {
|
||||
"label": "Data wydatku",
|
||||
"description": "Podaj datę opłacenia wydatku."
|
||||
"description": "Podaj datę wydatku."
|
||||
},
|
||||
"categoryFieldDescription": "Podaj kategorię wydatku.",
|
||||
"paidByField": {
|
||||
"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."
|
||||
@@ -168,10 +178,10 @@
|
||||
"attachDescription": "Zobacz i załącz rachunki do wydatku."
|
||||
},
|
||||
"amountField": {
|
||||
"label": "Ilość"
|
||||
"label": "Suma"
|
||||
},
|
||||
"isReimbursementField": {
|
||||
"label": "To jest zwrot kosztów"
|
||||
"label": "Oznacz jako zwrot kosztów"
|
||||
},
|
||||
"categoryField": {
|
||||
"label": "Kategoria"
|
||||
@@ -179,10 +189,10 @@
|
||||
"notesField": {
|
||||
"label": "Notatki"
|
||||
},
|
||||
"selectNone": "Nie wybieraj żadnego",
|
||||
"selectAll": "Wybierz wszystkie",
|
||||
"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",
|
||||
@@ -203,12 +213,13 @@
|
||||
"creating": "Tworzenie…",
|
||||
"save": "Zapisz",
|
||||
"saving": "Zapisywanie…",
|
||||
"cancel": "Anuluj"
|
||||
"cancel": "Anuluj",
|
||||
"reimbursement": "Zwrot środków"
|
||||
},
|
||||
"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",
|
||||
@@ -220,12 +231,12 @@
|
||||
"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": "Ilość:",
|
||||
"amountLabel": "Suma:",
|
||||
"dateLabel": "Data:",
|
||||
"editNext": "Następnie będziesz mógł edytować informacje o wydatkach.",
|
||||
"continue": "Kontynuuj"
|
||||
@@ -233,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",
|
||||
@@ -243,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.",
|
||||
@@ -281,31 +292,18 @@
|
||||
"earlierThisMonth": "Wcześniej w tym miesiącu",
|
||||
"lastMonth": "Ostatni miesiąc",
|
||||
"earlierThisYear": "Wcześniej w tym roku",
|
||||
"lastYera": "Poprzedni rok",
|
||||
"lastYear": "Poprzedni rok",
|
||||
"older": "Starsze"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"title": "Ustawienia"
|
||||
},
|
||||
"Locale": {
|
||||
"en-US": "English",
|
||||
"fi": "Suomi",
|
||||
"fr-FR": "Français",
|
||||
"es": "Español",
|
||||
"de-DE": "Deutsch",
|
||||
"zh-CN": "Chinese (Simplified)",
|
||||
"pl-PL": "Polski",
|
||||
"ru-RU": "Русский",
|
||||
"it-IT": "Italiano",
|
||||
"ua-UA": "Українська",
|
||||
"ro": "Română"
|
||||
},
|
||||
"Share": {
|
||||
"title": "Udostępnij",
|
||||
"description": "Aby inni uczestnicy mogli zobaczyć grupę i dodać wydatki, udostępnij im jej adres URL.",
|
||||
@@ -322,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.",
|
||||
@@ -330,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",
|
||||
@@ -343,7 +341,7 @@
|
||||
"Games": "Gry",
|
||||
"Movies": "Filmy",
|
||||
"Music": "Muzyka",
|
||||
"Sports": "Sporty"
|
||||
"Sports": "Sport"
|
||||
},
|
||||
"Food and Drink": {
|
||||
"heading": "Jedzenie i Napoje",
|
||||
@@ -359,7 +357,7 @@
|
||||
"Furniture": "Meble",
|
||||
"Household Supplies": "Artykuły gospodarstwa domowego",
|
||||
"Maintenance": "Utrzymanie",
|
||||
"Mortgage": "Czynsz",
|
||||
"Mortgage": "Kredyt",
|
||||
"Pets": "Zwierzaki",
|
||||
"Rent": "Czynsz",
|
||||
"Services": "Usługi"
|
||||
@@ -368,6 +366,7 @@
|
||||
"heading": "Życie",
|
||||
"Childcare": "Opieka nad dzieckiem",
|
||||
"Clothing": "Ubrania",
|
||||
"Donation": "Darowizna",
|
||||
"Education": "Edukacja",
|
||||
"Gifts": "Prezenty",
|
||||
"Insurance": "Ubezpieczenie",
|
||||
@@ -378,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"
|
||||
|
||||
421
messages/pt-BR.json
Normal file
@@ -0,0 +1,421 @@
|
||||
{
|
||||
"Homepage": {
|
||||
"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",
|
||||
"github": "GitHub"
|
||||
}
|
||||
},
|
||||
"Header": {
|
||||
"groups": "Grupos"
|
||||
},
|
||||
"Footer": {
|
||||
"madeIn": "Feito em Montréal, Québec 🇨🇦",
|
||||
"builtBy": "Desenvolvido por <author>Sebastien Castiel</author> e <source>contribuidores</source>"
|
||||
},
|
||||
"Expenses": {
|
||||
"title": "Despesas",
|
||||
"description": "Aqui estão as despesas que você criou para o seu grupo.",
|
||||
"create": "Criar despesa",
|
||||
"createFirst": "Crie a primeira",
|
||||
"noExpenses": "Seu grupo ainda não contém nenhuma despesa.",
|
||||
"exportJson": "Exportar para JSON",
|
||||
"exportCsv": "Exportar para CSV",
|
||||
"searchPlaceholder": "Pesquisar por uma despesa…",
|
||||
"ActiveUserModal": {
|
||||
"title": "Quem é você?",
|
||||
"description": "Informe qual participante você é para personalizarmos a exibição das informações.",
|
||||
"nobody": "Não quero selecionar ninguém",
|
||||
"save": "Salvar alterações",
|
||||
"footer": "Essa configuração pode ser alterada posteriormente nas configurações do grupo."
|
||||
},
|
||||
"Groups": {
|
||||
"upcoming": "Próximas",
|
||||
"thisWeek": "Esta semana",
|
||||
"earlierThisMonth": "Anteriores neste mês",
|
||||
"lastMonth": "Mês passado",
|
||||
"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:",
|
||||
"everyone": "Todos",
|
||||
"notInvolved": "Você não está envolvido"
|
||||
},
|
||||
"Groups": {
|
||||
"myGroups": "Meus grupos",
|
||||
"create": "Criar",
|
||||
"loadingRecent": "Carregando grupos recentes…",
|
||||
"NoRecent": {
|
||||
"description": "Você não visitou nenhum grupo recentemente.",
|
||||
"create": "Crie um",
|
||||
"orAsk": "ou peça a um amigo para enviar o link de um existente."
|
||||
},
|
||||
"recent": "Grupos recentes",
|
||||
"starred": "Grupos favoritos",
|
||||
"archived": "Grupos arquivados",
|
||||
"archive": "Arquivar grupo",
|
||||
"unarchive": "Desarquivar grupo",
|
||||
"removeRecent": "Remover dos grupos recentes",
|
||||
"RecentRemovedToast": {
|
||||
"title": "Grupo removido",
|
||||
"description": "O grupo foi removido da sua 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 compartilhado com você, você pode colar sua URL aqui para adicioná-lo à sua lista.",
|
||||
"error": "Ops, não conseguimos encontrar o grupo a partir da URL fornecida…"
|
||||
},
|
||||
"NotFound": {
|
||||
"text": "Este grupo não existe.",
|
||||
"link": "Ir para grupos visitados recentemente"
|
||||
}
|
||||
},
|
||||
"GroupForm": {
|
||||
"title": "Informações do grupo",
|
||||
"NameField": {
|
||||
"label": "Nome do grupo",
|
||||
"placeholder": "Férias de verão",
|
||||
"description": "Insira um nome para o seu grupo."
|
||||
},
|
||||
"InformationField": {
|
||||
"label": "Informações do grupo",
|
||||
"placeholder": "Quais informações são relevantes para os participantes do grupo?"
|
||||
},
|
||||
"CurrencyField": {
|
||||
"label": "Símbolo da moeda",
|
||||
"placeholder": "$, €, £, R$…",
|
||||
"description": "Vamos usá-lo para exibir valores."
|
||||
},
|
||||
"Participants": {
|
||||
"title": "Participantes",
|
||||
"description": "Insira o nome de cada participante.",
|
||||
"protectedParticipant": "Este participante faz parte das despesas e não pode ser removido.",
|
||||
"new": "Novo",
|
||||
"add": "Adicionar participante",
|
||||
"John": "João",
|
||||
"Jane": "Maria",
|
||||
"Jack": "José"
|
||||
},
|
||||
"Settings": {
|
||||
"title": "Configurações locais",
|
||||
"description": "Essas configurações são definidas por dispositivo e são usadas para personalizar sua experiência.",
|
||||
"ActiveUserField": {
|
||||
"label": "Usuário ativo",
|
||||
"placeholder": "Selecione um participante",
|
||||
"none": "Nenhum",
|
||||
"description": "Usuário usado como padrão para pagar despesas."
|
||||
},
|
||||
"save": "Salvar",
|
||||
"saving": "Salvando…",
|
||||
"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": {
|
||||
"Income": {
|
||||
"create": "Criar receita",
|
||||
"edit": "Editar receita",
|
||||
"TitleField": {
|
||||
"label": "Título da receita",
|
||||
"placeholder": "Restaurante na segunda à noite",
|
||||
"description": "Insira uma descrição para a receita."
|
||||
},
|
||||
"DateField": {
|
||||
"label": "Data da receita",
|
||||
"description": "Insira a data em que a receita foi recebida."
|
||||
},
|
||||
"categoryFieldDescription": "Selecione a categoria da receita.",
|
||||
"paidByField": {
|
||||
"label": "Recebido por",
|
||||
"description": "Selecione o participante que recebeu a receita."
|
||||
},
|
||||
"paidFor": {
|
||||
"title": "Recebido para",
|
||||
"description": "Selecione para quem a receita foi recebida."
|
||||
},
|
||||
"splitModeDescription": "Selecione como dividir a receita.",
|
||||
"attachDescription": "Veja e anexe recibos à receita."
|
||||
},
|
||||
"Expense": {
|
||||
"create": "Criar despesa",
|
||||
"edit": "Editar despesa",
|
||||
"TitleField": {
|
||||
"label": "Título da despesa",
|
||||
"placeholder": "Restaurante na segunda à noite",
|
||||
"description": "Insira uma descrição para a despesa."
|
||||
},
|
||||
"DateField": {
|
||||
"label": "Data da despesa",
|
||||
"description": "Insira a data em que a despesa foi paga."
|
||||
},
|
||||
"categoryFieldDescription": "Selecione a categoria da despesa.",
|
||||
"paidByField": {
|
||||
"label": "Pago por",
|
||||
"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.",
|
||||
"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"
|
||||
},
|
||||
"isReimbursementField": {
|
||||
"label": "Isso é um reembolso"
|
||||
},
|
||||
"categoryField": {
|
||||
"label": "Categoria"
|
||||
},
|
||||
"notesField": {
|
||||
"label": "Notas"
|
||||
},
|
||||
"selectNone": "Remover seleção",
|
||||
"selectAll": "Selecionar todos(as)",
|
||||
"shares": "parte(s)",
|
||||
"advancedOptions": "Opções avançadas de divisão…",
|
||||
"SplitModeField": {
|
||||
"label": "Modo de divisão",
|
||||
"evenly": "Igualmente",
|
||||
"byShares": "Desigualmente - Por partes",
|
||||
"byPercentage": "Desigualmente - Por porcentagem",
|
||||
"byAmount": "Desigualmente - Por valor",
|
||||
"saveAsDefault": "Salvar como opções de divisão padrão"
|
||||
},
|
||||
"DeletePopup": {
|
||||
"label": "Excluir",
|
||||
"title": "Excluir esta despesa?",
|
||||
"description": "Você realmente deseja excluir esta despesa? Esta ação é irreversível.",
|
||||
"yes": "Sim",
|
||||
"cancel": "Cancelar"
|
||||
},
|
||||
"attachDocuments": "Anexar documentos",
|
||||
"create": "Criar",
|
||||
"creating": "Criando…",
|
||||
"save": "Salvar",
|
||||
"saving": "Salvando…",
|
||||
"cancel": "Cancelar",
|
||||
"reimbursement": "Reembolso"
|
||||
},
|
||||
"ExpenseDocumentsInput": {
|
||||
"TooBigToast": {
|
||||
"title": "O arquivo é muito grande",
|
||||
"description": "O tamanho máximo de arquivo que você pode enviar é {maxSize}. O seu é ${size}."
|
||||
},
|
||||
"ErrorToast": {
|
||||
"title": "Erro ao enviar documento",
|
||||
"description": "Algo deu errado ao enviar o documento. Por favor, tente novamente mais tarde ou selecione um arquivo diferente.",
|
||||
"retry": "Tentar novamente"
|
||||
}
|
||||
},
|
||||
"CreateFromReceipt": {
|
||||
"Dialog": {
|
||||
"triggerTitle": "Criar despesa a partir de recibo",
|
||||
"title": "Criar a partir de recibo",
|
||||
"description": "Extraia as informações da despesa a partir de uma foto de recibo.",
|
||||
"body": "Faça upload da foto de um recibo, e vamos escaneá-la para extrair as informações da despesa, se possível.",
|
||||
"selectImage": "Selecionar imagem…",
|
||||
"titleLabel": "Título:",
|
||||
"categoryLabel": "Categoria:",
|
||||
"amountLabel": "Valor:",
|
||||
"dateLabel": "Data:",
|
||||
"editNext": "Você poderá editar as informações da despesa a seguir.",
|
||||
"continue": "Continuar"
|
||||
},
|
||||
"unknown": "Desconhecido",
|
||||
"TooBigToast": {
|
||||
"title": "O arquivo é muito grande",
|
||||
"description": "O tamanho máximo de arquivo que você pode enviar é {maxSize}. O seu é ${size}."
|
||||
},
|
||||
"ErrorToast": {
|
||||
"title": "Erro ao enviar documento",
|
||||
"description": "Algo deu errado ao enviar o documento. Por favor, tente novamente mais tarde ou selecione um arquivo diferente.",
|
||||
"retry": "Tentar novamente"
|
||||
}
|
||||
},
|
||||
"Balances": {
|
||||
"title": "Saldos",
|
||||
"description": "Este é o valor que cada participante pagou ou recebeu.",
|
||||
"Reimbursements": {
|
||||
"title": "Reembolsos sugeridos",
|
||||
"description": "Aqui estão sugestões para reembolsos otimizados entre os participantes.",
|
||||
"noImbursements": "Parece que seu grupo não precisa de nenhum reembolso 😁",
|
||||
"owes": "<strong>{from}</strong> deve <strong>{to}</strong>",
|
||||
"markAsPaid": "Marcar como pago"
|
||||
}
|
||||
},
|
||||
"Stats": {
|
||||
"title": "Estatísticas",
|
||||
"Totals": {
|
||||
"title": "Totais",
|
||||
"description": "Resumo de gastos de todo o grupo.",
|
||||
"groupSpendings": "Total de gastos do grupo",
|
||||
"groupEarnings": "Total de receitas do grupo",
|
||||
"yourSpendings": "Seus gastos totais",
|
||||
"yourEarnings": "Suas receitas totais",
|
||||
"yourShare": "Sua participação total"
|
||||
}
|
||||
},
|
||||
"Activity": {
|
||||
"title": "Atividade",
|
||||
"description": "Visão geral de toda a atividade neste grupo.",
|
||||
"noActivity": "Ainda não há atividades no seu grupo.",
|
||||
"someone": "Alguém",
|
||||
"settingsModified": "As configurações do grupo foram modificadas por <strong>{participant}</strong>.",
|
||||
"expenseCreated": "Despesa {expense} criada por <strong>{participant}</strong>.",
|
||||
"expenseUpdated": "Despesa {expense} atualizada por <strong>{participant}</strong>.",
|
||||
"expenseDeleted": "Despesa {expense} excluída por <strong>{participant}</strong>.",
|
||||
"Groups": {
|
||||
"today": "Hoje",
|
||||
"yesterday": "Ontem",
|
||||
"earlierThisWeek": "Anteriormente nesta semana",
|
||||
"lastWeek": "Semana passada",
|
||||
"earlierThisMonth": "Anteriormente neste mês",
|
||||
"lastMonth": "Mês passado",
|
||||
"earlierThisYear": "Anteriormente neste ano",
|
||||
"lastYear": "Ano passado",
|
||||
"older": "Mais antigas"
|
||||
}
|
||||
},
|
||||
"Information": {
|
||||
"title": "Informação",
|
||||
"description": "Use este espaço para adicionar qualquer informação que possa ser relevante para os participantes do grupo.",
|
||||
"empty": "Nenhuma informação do grupo ainda."
|
||||
},
|
||||
"Settings": {
|
||||
"title": "Configurações"
|
||||
},
|
||||
"Share": {
|
||||
"title": "Compartilhar",
|
||||
"description": "Para que outros participantes vejam o grupo e adicionem despesas, compartilhe o link com eles.",
|
||||
"warning": "Aviso!",
|
||||
"warningHelp": "Toda pessoa com o link do grupo poderá ver e editar despesas. Compartilhe com cautela!"
|
||||
},
|
||||
"SchemaErrors": {
|
||||
"min1": "Digite pelo menos um caractere.",
|
||||
"min2": "Digite pelo menos dois caracteres.",
|
||||
"max5": "Digite no máximo cinco caracteres.",
|
||||
"max50": "Digite no máximo 50 caracteres.",
|
||||
"duplicateParticipantName": "Outro participante já tem este nome.",
|
||||
"titleRequired": "Por favor, insira um título.",
|
||||
"invalidNumber": "Número inválido.",
|
||||
"amountRequired": "Você deve inserir um valor.",
|
||||
"amountNotZero": "O valor não deve ser zero.",
|
||||
"amountTenMillion": "O valor deve ser inferior a 10.000.000.",
|
||||
"paidByRequired": "Você deve selecionar um participante.",
|
||||
"paidForMin1": "A despesa deve ser paga para pelo menos um participante.",
|
||||
"noZeroShares": "Todas as partes devem ser maiores que 0.",
|
||||
"amountSum": "A soma dos valores deve ser igual ao valor da despesa.",
|
||||
"percentageSum": "A soma das porcentagens deve ser igual a 100."
|
||||
},
|
||||
"Categories": {
|
||||
"search": "Pesquisar categoria...",
|
||||
"noCategory": "Nenhuma categoria encontrada.",
|
||||
"Uncategorized": {
|
||||
"heading": "Sem categoria",
|
||||
"General": "Geral",
|
||||
"Payment": "Pagamento"
|
||||
},
|
||||
"Entertainment": {
|
||||
"heading": "Entretenimento",
|
||||
"Entertainment": "Entretenimento",
|
||||
"Games": "Jogos",
|
||||
"Movies": "Filmes",
|
||||
"Music": "Música",
|
||||
"Sports": "Esportes"
|
||||
},
|
||||
"Food and Drink": {
|
||||
"heading": "Comida e Bebida",
|
||||
"Food and Drink": "Comida e Bebida",
|
||||
"Dining Out": "Jantar fora",
|
||||
"Groceries": "Mercearia",
|
||||
"Liquor": "Bebidas alcoólicas"
|
||||
},
|
||||
"Home": {
|
||||
"heading": "Casa",
|
||||
"Home": "Casa",
|
||||
"Electronics": "Eletrônicos",
|
||||
"Furniture": "Móveis",
|
||||
"Household Supplies": "Suprimentos domésticos",
|
||||
"Maintenance": "Manutenção",
|
||||
"Mortgage": "Financiamento Habitacional",
|
||||
"Pets": "Animais de estimação",
|
||||
"Rent": "Aluguel",
|
||||
"Services": "Serviços"
|
||||
},
|
||||
"Life": {
|
||||
"heading": "Vida",
|
||||
"Childcare": "Cuidados infantis",
|
||||
"Clothing": "Roupas",
|
||||
"Education": "Educação",
|
||||
"Gifts": "Presentes",
|
||||
"Insurance": "Seguro",
|
||||
"Medical Expenses": "Despesas médicas",
|
||||
"Taxes": "Impostos",
|
||||
"Donation": "Doação"
|
||||
},
|
||||
"Transportation": {
|
||||
"heading": "Transporte",
|
||||
"Transportation": "Transporte",
|
||||
"Bicycle": "Bicicleta",
|
||||
"Bus/Train": "Ônibus/Trem",
|
||||
"Car": "Carro",
|
||||
"Gas/Fuel": "Gasolina/Combustível",
|
||||
"Hotel": "Hotel",
|
||||
"Parking": "Estacionamento",
|
||||
"Plane": "Avião",
|
||||
"Taxi": "Táxi"
|
||||
},
|
||||
"Utilities": {
|
||||
"heading": "Utilitários",
|
||||
"Utilities": "Utilitários",
|
||||
"Cleaning": "Limpeza",
|
||||
"Electricity": "Eletricidade",
|
||||
"Heat/Gas": "Calor/Gás",
|
||||
"Trash": "Lixo",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@
|
||||
"createFirst": "Adaug-o pe prima",
|
||||
"noExpenses": "Grupul tău nu conține nicio cheltuială încă.",
|
||||
"exportJson": "Salvează în JSON",
|
||||
"exportCsv": "Salvează în CSV",
|
||||
"searchPlaceholder": "Caută o cheltuială…",
|
||||
"ActiveUserModal": {
|
||||
"title": "Cum te numești?",
|
||||
@@ -35,7 +36,7 @@
|
||||
"earlierThisMonth": "La începutul lunii",
|
||||
"lastMonth": "Luna trecută",
|
||||
"earlierThisYear": "La începutul anului",
|
||||
"lastYera": "Anul trecut",
|
||||
"lastYear": "Anul trecut",
|
||||
"older": "Mai vechi"
|
||||
}
|
||||
},
|
||||
@@ -209,7 +210,7 @@
|
||||
"ExpenseDocumentsInput": {
|
||||
"TooBigToast": {
|
||||
"title": "Fișierul este prea mare",
|
||||
"description": "Dimensiunea maximă a fișierului pe care îl poți atașa este {maxSize}. Fișierul tău are ${size}."
|
||||
"description": "Dimensiunea maximă a fișierului pe care îl poți atașa este {maxSize}. Fișierul tău are {size}."
|
||||
},
|
||||
"ErrorToast": {
|
||||
"title": "Eroare la adăugarea documentului.",
|
||||
@@ -234,7 +235,7 @@
|
||||
"unknown": "Necunoscut",
|
||||
"TooBigToast": {
|
||||
"title": "Fișierul este prea mare",
|
||||
"description": "Dimensiunea maximă a fișierului pe care il poți atașa este {maxSize}. Fișierul tău are ${size}."
|
||||
"description": "Dimensiunea maximă a fișierului pe care il poți atașa este {maxSize}. Fișierul tău are {size}."
|
||||
},
|
||||
"ErrorToast": {
|
||||
"title": "Eroare la adăugarea documentului.",
|
||||
@@ -294,19 +295,6 @@
|
||||
"Settings": {
|
||||
"title": "Setări"
|
||||
},
|
||||
"Locale": {
|
||||
"en-US": "Engleză",
|
||||
"fi": "Finlandeză",
|
||||
"fr-FR": "Franceză",
|
||||
"es": "Spaniolă",
|
||||
"de-DE": "Germană",
|
||||
"zh-CN": "Chineză (Simplificată)",
|
||||
"pl-PL": "Poloneză",
|
||||
"ru-RU": "Rusă",
|
||||
"it-IT": "Italiană",
|
||||
"ua-UA": "Ucraineană",
|
||||
"ro": "Română"
|
||||
},
|
||||
"Share": {
|
||||
"title": "Distribuie",
|
||||
"description": "Pentru ca ceilalți participanți să poată vedea grupul și cheltuielile adăugate, distribuie URL-ul acestuia cu ei.",
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
"createFirst": "Создать первый расход",
|
||||
"noExpenses": "У вашей группы пока что нет расходов.",
|
||||
"exportJson": "Экспортировать в JSON",
|
||||
"exportCsv": "Экспортировать в CSV",
|
||||
"searchPlaceholder": "Поиск расходов…",
|
||||
"ActiveUserModal": {
|
||||
"title": "Кто вы?",
|
||||
@@ -35,7 +36,7 @@
|
||||
"earlierThisMonth": "Ранее в этом месяце",
|
||||
"lastMonth": "В прошлом месяце",
|
||||
"earlierThisYear": "Ранее в этом году",
|
||||
"lastYera": "В прошлом году",
|
||||
"lastYear": "В прошлом году",
|
||||
"older": "Очень давно"
|
||||
}
|
||||
},
|
||||
@@ -209,7 +210,7 @@
|
||||
"ExpenseDocumentsInput": {
|
||||
"TooBigToast": {
|
||||
"title": "Файл слишком большой",
|
||||
"description": "Максимальный размер файла, который можно загрузить — {maxSize}. Размер вашего файла — ${size}."
|
||||
"description": "Максимальный размер файла, который можно загрузить — {maxSize}. Размер вашего файла — {size}."
|
||||
},
|
||||
"ErrorToast": {
|
||||
"title": "Ошибка при загрузке документа",
|
||||
@@ -234,7 +235,7 @@
|
||||
"unknown": "Неизвестно",
|
||||
"TooBigToast": {
|
||||
"title": "Файл слишком большой",
|
||||
"description": "Максимальный размер файла, который можно загрузить — {maxSize}. Размер вашего файла — ${size}."
|
||||
"description": "Максимальный размер файла, который можно загрузить — {maxSize}. Размер вашего файла — {size}."
|
||||
},
|
||||
"ErrorToast": {
|
||||
"title": "Ошибка при загрузке документа",
|
||||
@@ -294,19 +295,6 @@
|
||||
"Settings": {
|
||||
"title": "Настройки"
|
||||
},
|
||||
"Locale": {
|
||||
"en-US": "English",
|
||||
"fi": "Suomi",
|
||||
"fr-FR": "Français",
|
||||
"es": "Español",
|
||||
"de-DE": "Deutsch",
|
||||
"zh-CN": "Chinese (Simplified)",
|
||||
"pl-PL": "Polski",
|
||||
"ru-RU": "Русский",
|
||||
"it-IT": "Italiano",
|
||||
"ua-UA": "Українська",
|
||||
"ro": "Română"
|
||||
},
|
||||
"Share": {
|
||||
"title": "Поделиться",
|
||||
"description": "Чтобы другие участники получили доступ к этой группе и смогли добавлять расходы, отправьте им этот URL.",
|
||||
@@ -398,4 +386,4 @@
|
||||
"Water": "Вода"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
389
messages/tr-TR.json
Normal file
@@ -0,0 +1,389 @@
|
||||
{
|
||||
"Homepage": {
|
||||
"title": "<strong>Masrafları</strong> <strong>Arkadaşlar ve Aile</strong> ile paylaş",
|
||||
"description": "Yeni <strong>Spliit</strong> kurulumunuza hoş geldiniz !",
|
||||
"button": {
|
||||
"groups": "Gruplara git",
|
||||
"github": "GitHub"
|
||||
}
|
||||
},
|
||||
"Header": {
|
||||
"groups": "Gruplar"
|
||||
},
|
||||
"Footer": {
|
||||
"madeIn": "Montréal, Québec 🇨🇦'da yapıldı",
|
||||
"builtBy": "<author>Sebastien Castiel</author> ve <source>katkıda bulunanlar</source> tarafından geliştirildi"
|
||||
},
|
||||
"Expenses": {
|
||||
"title": "Masraflar",
|
||||
"description": "Grubunuz için oluşturduğunuz masraflar burada.",
|
||||
"create": "Masraf oluştur",
|
||||
"createFirst": "İlk masrafı oluştur",
|
||||
"noExpenses": "Grubunuzda henüz herhangi bir masraf yok.",
|
||||
"exportJson": "JSON olarak dışa aktar",
|
||||
"exportCsv": "CSV olarak dışa aktar",
|
||||
"searchPlaceholder": "Bir masraf arayın…",
|
||||
"ActiveUserModal": {
|
||||
"title": "Kimsiniz?",
|
||||
"description": "Bilgilerin nasıl görüntüleneceğini özelleştirebilmemiz için hangi katılımcı olduğunuzu belirtin.",
|
||||
"nobody": "Kimseyi seçmek istemiyorum",
|
||||
"save": "Değişiklikleri kaydet",
|
||||
"footer": "Bu ayar daha sonra grup ayarlarında değiştirilebilir."
|
||||
},
|
||||
"Groups": {
|
||||
"upcoming": "Yaklaşan",
|
||||
"thisWeek": "Bu hafta",
|
||||
"earlierThisMonth": "Bu ayın başlarında",
|
||||
"lastMonth": "Geçen ay",
|
||||
"earlierThisYear": "Bu yılın başlarında",
|
||||
"lastYear": "Geçen yıl",
|
||||
"older": "Daha eski"
|
||||
}
|
||||
},
|
||||
"ExpenseCard": {
|
||||
"paidBy": "<strong>{paidBy}</strong> tarafından ödendi, <paidFor></paidFor> için",
|
||||
"receivedBy": "<strong>{paidBy}</strong> tarafından alındı, <paidFor></paidFor> için",
|
||||
"yourBalance": "Bakiyeniz:"
|
||||
},
|
||||
"Groups": {
|
||||
"myGroups": "Gruplarım",
|
||||
"create": "Oluştur",
|
||||
"loadingRecent": "Son gruplar yükleniyor…",
|
||||
"NoRecent": {
|
||||
"description": "Son zamanlarda hiç grup ziyaret etmediniz.",
|
||||
"create": "Bir tane oluştur",
|
||||
"orAsk": "ya da bir arkadaşınızdan mevcut bir grubun bağlantısını göndermesini isteyin."
|
||||
},
|
||||
"recent": "Son gruplar",
|
||||
"starred": "Yıldızlı gruplar",
|
||||
"archived": "Arşivlenmiş gruplar",
|
||||
"archive": "Grubu arşivle",
|
||||
"unarchive": "Arşivden çıkar",
|
||||
"removeRecent": "Son gruplardan kaldır",
|
||||
"RecentRemovedToast": {
|
||||
"title": "Grup kaldırıldı",
|
||||
"description": "Grup son gruplar listenizden kaldırıldı.",
|
||||
"undoAlt": "Grup kaldırma işlemini geri al",
|
||||
"undo": "Geri al"
|
||||
},
|
||||
"AddByURL": {
|
||||
"button": "URL ile ekle",
|
||||
"title": "URL ile grup ekle",
|
||||
"description": "Bir grup sizinle paylaşıldıysa, URL'sini buraya yapıştırarak listeye ekleyebilirsiniz.",
|
||||
"error": "Üzgünüz, sağladığınız URL'den bir grup bulamadık…"
|
||||
},
|
||||
"NotFound": {
|
||||
"text": "Bu grup mevcut değil.",
|
||||
"link": "Son ziyaret ettiğiniz gruplara dön"
|
||||
}
|
||||
},
|
||||
"GroupForm": {
|
||||
"title": "Grup bilgileri",
|
||||
"NameField": {
|
||||
"label": "Grup adı",
|
||||
"placeholder": "Yaz tatili",
|
||||
"description": "Grubunuz için bir ad girin."
|
||||
},
|
||||
"InformationField": {
|
||||
"label": "Grup bilgisi",
|
||||
"placeholder": "Grup katılımcıları için hangi bilgiler önemli?"
|
||||
},
|
||||
"CurrencyField": {
|
||||
"label": "Para birimi simgesi",
|
||||
"placeholder": "$, €, £…",
|
||||
"description": "Tutarları göstermek için kullanacağız."
|
||||
},
|
||||
"Participants": {
|
||||
"title": "Katılımcılar",
|
||||
"description": "Her katılımcı için bir ad girin.",
|
||||
"protectedParticipant": "Bu katılımcı masraflara dahil olduğundan kaldırılamaz.",
|
||||
"new": "Yeni",
|
||||
"add": "Katılımcı ekle",
|
||||
"John": "John",
|
||||
"Jane": "Jane",
|
||||
"Jack": "Jack"
|
||||
},
|
||||
"Settings": {
|
||||
"title": "Yerel ayarlar",
|
||||
"description": "Bu ayarlar cihaz bazında belirlenir ve deneyiminizi özelleştirmek için kullanılır.",
|
||||
"ActiveUserField": {
|
||||
"label": "Aktif kullanıcı",
|
||||
"placeholder": "Bir katılımcı seçin",
|
||||
"none": "Yok",
|
||||
"description": "Masrafların varsayılan olarak kimin adına ekleneceği."
|
||||
},
|
||||
"save": "Kaydet",
|
||||
"saving": "Kaydediliyor…",
|
||||
"create": "Oluştur",
|
||||
"creating": "Oluşturuluyor…",
|
||||
"cancel": "İptal"
|
||||
}
|
||||
},
|
||||
"ExpenseForm": {
|
||||
"Income": {
|
||||
"create": "Gelir oluştur",
|
||||
"edit": "Geliri düzenle",
|
||||
"TitleField": {
|
||||
"label": "Gelir başlığı",
|
||||
"placeholder": "Pazartesi akşamı restoran",
|
||||
"description": "Gelir için bir açıklama girin."
|
||||
},
|
||||
"DateField": {
|
||||
"label": "Gelir tarihi",
|
||||
"description": "Gelirin alındığı tarihi girin."
|
||||
},
|
||||
"categoryFieldDescription": "Gelir kategorisini seçin.",
|
||||
"paidByField": {
|
||||
"label": "Geliri alan",
|
||||
"description": "Geliri alan katılımcıyı seçin."
|
||||
},
|
||||
"paidFor": {
|
||||
"title": "Gelirin alındığı kişiler",
|
||||
"description": "Gelirin kim(ler) için alındığını seçin."
|
||||
},
|
||||
"splitModeDescription": "Gelirin nasıl paylaştırılacağını seçin.",
|
||||
"attachDescription": "Gelire makbuz ekleyin ve görüntüleyin."
|
||||
},
|
||||
"Expense": {
|
||||
"create": "Masraf oluştur",
|
||||
"edit": "Masrafı düzenle",
|
||||
"TitleField": {
|
||||
"label": "Masraf başlığı",
|
||||
"placeholder": "Pazartesi akşamı restoran",
|
||||
"description": "Masraf için bir açıklama girin."
|
||||
},
|
||||
"DateField": {
|
||||
"label": "Masraf tarihi",
|
||||
"description": "Masrafın ödendiği tarihi girin."
|
||||
},
|
||||
"categoryFieldDescription": "Masraf kategorisini seçin.",
|
||||
"paidByField": {
|
||||
"label": "Ödeyen",
|
||||
"description": "Masrafı ödeyen katılımcıyı seçin."
|
||||
},
|
||||
"paidFor": {
|
||||
"title": "Masraf kimin için ödendi",
|
||||
"description": "Masrafın kim(ler) için ödendiğini seçin."
|
||||
},
|
||||
"splitModeDescription": "Masrafın nasıl paylaştırılacağını seçin.",
|
||||
"attachDescription": "Masrafa makbuz ekleyin ve görüntüleyin."
|
||||
},
|
||||
"amountField": {
|
||||
"label": "Tutar"
|
||||
},
|
||||
"isReimbursementField": {
|
||||
"label": "Bu bir geri ödeme"
|
||||
},
|
||||
"categoryField": {
|
||||
"label": "Kategori"
|
||||
},
|
||||
"notesField": {
|
||||
"label": "Notlar"
|
||||
},
|
||||
"selectNone": "Hiçbirini seçme",
|
||||
"selectAll": "Hepsini seç",
|
||||
"shares": "pay",
|
||||
"advancedOptions": "Gelişmiş paylaşım seçenekleri…",
|
||||
"SplitModeField": {
|
||||
"label": "Paylaşım modu",
|
||||
"evenly": "Eşit pay",
|
||||
"byShares": "Eşit olmayan – Pay adedine göre",
|
||||
"byPercentage": "Eşit olmayan – Yüzdeye göre",
|
||||
"byAmount": "Eşit olmayan – Tutar bazında",
|
||||
"saveAsDefault": "Varsayılan paylaşım ayarları olarak kaydet"
|
||||
},
|
||||
"DeletePopup": {
|
||||
"label": "Sil",
|
||||
"title": "Bu masraf silinsin mi?",
|
||||
"description": "Bu masrafı gerçekten silmek istiyor musunuz? Bu işlem geri alınamaz.",
|
||||
"yes": "Evet",
|
||||
"cancel": "İptal"
|
||||
},
|
||||
"attachDocuments": "Belge ekle",
|
||||
"create": "Oluştur",
|
||||
"creating": "Oluşturuluyor…",
|
||||
"save": "Kaydet",
|
||||
"saving": "Kaydediliyor…",
|
||||
"cancel": "İptal",
|
||||
"reimbursement": "Geri ödeme"
|
||||
},
|
||||
"ExpenseDocumentsInput": {
|
||||
"TooBigToast": {
|
||||
"title": "Dosya çok büyük",
|
||||
"description": "Yükleyebileceğiniz maksimum dosya boyutu {maxSize}. Dosyanız {size} boyutunda."
|
||||
},
|
||||
"ErrorToast": {
|
||||
"title": "Belge yüklenirken hata oluştu",
|
||||
"description": "Belge yüklenirken bir sorun oluştu. Lütfen daha sonra tekrar deneyin veya farklı bir dosya seçin.",
|
||||
"retry": "Tekrar dene"
|
||||
}
|
||||
},
|
||||
"CreateFromReceipt": {
|
||||
"Dialog": {
|
||||
"triggerTitle": "Makbuzdan masraf oluştur",
|
||||
"title": "Makbuzdan oluştur",
|
||||
"description": "Bir makbuz fotoğrafındaki masraf bilgilerini çekin.",
|
||||
"body": "Bir makbuz fotoğrafı yükleyin, mümkünse masraf bilgilerini otomatik olarak çıkaracağız.",
|
||||
"selectImage": "Resim seç…",
|
||||
"titleLabel": "Başlık:",
|
||||
"categoryLabel": "Kategori:",
|
||||
"amountLabel": "Tutar:",
|
||||
"dateLabel": "Tarih:",
|
||||
"editNext": "Masraf bilgilerini sonraki adımda düzenleyebileceksiniz.",
|
||||
"continue": "Devam et"
|
||||
},
|
||||
"unknown": "Bilinmiyor",
|
||||
"TooBigToast": {
|
||||
"title": "Dosya çok büyük",
|
||||
"description": "Yükleyebileceğiniz maksimum dosya boyutu {maxSize}. Dosyanız {size} boyutunda."
|
||||
},
|
||||
"ErrorToast": {
|
||||
"title": "Belge yüklenirken hata oluştu",
|
||||
"description": "Belge yüklenirken bir sorun oluştu. Lütfen daha sonra tekrar deneyin veya farklı bir dosya seçin.",
|
||||
"retry": "Tekrar dene"
|
||||
}
|
||||
},
|
||||
"Balances": {
|
||||
"title": "Bakiyeler",
|
||||
"description": "Her katılımcının ödediği veya kendisi için ödenen tutar burada gösterilir.",
|
||||
"Reimbursements": {
|
||||
"title": "Önerilen geri ödemeler",
|
||||
"description": "Katılımcılar arasındaki en uygun geri ödeme önerileri aşağıdadır.",
|
||||
"noImbursements": "Görünüşe göre grubunuzun hiçbir geri ödemeye ihtiyacı yok 😁",
|
||||
"owes": "<strong>{from}</strong>, <strong>{to}</strong>'ya borçlu",
|
||||
"markAsPaid": "Ödendi olarak işaretle"
|
||||
}
|
||||
},
|
||||
"Stats": {
|
||||
"title": "İstatistikler",
|
||||
"Totals": {
|
||||
"title": "Toplamlar",
|
||||
"description": "Grubun tüm harcama özeti.",
|
||||
"groupSpendings": "Grubun toplam harcamaları",
|
||||
"groupEarnings": "Grubun toplam gelirleri",
|
||||
"yourSpendings": "Sizin toplam harcamalarınız",
|
||||
"yourEarnings": "Sizin toplam gelirleriniz",
|
||||
"yourShare": "Sizin toplam payınız"
|
||||
}
|
||||
},
|
||||
"Activity": {
|
||||
"title": "Etkinlik",
|
||||
"description": "Bu gruptaki tüm etkinliklerin genel görünümü.",
|
||||
"noActivity": "Grubunuzda henüz bir etkinlik yok.",
|
||||
"someone": "Birisi",
|
||||
"settingsModified": "Grup ayarları <strong>{participant}</strong> tarafından değiştirildi.",
|
||||
"expenseCreated": "Masraf <em>{expense}</em>, <strong>{participant}</strong> tarafından oluşturuldu.",
|
||||
"expenseUpdated": "Masraf <em>{expense}</em>, <strong>{participant}</strong> tarafından güncellendi.",
|
||||
"expenseDeleted": "Masraf <em>{expense}</em>, <strong>{participant}</strong> tarafından silindi.",
|
||||
"Groups": {
|
||||
"today": "Bugün",
|
||||
"yesterday": "Dün",
|
||||
"earlierThisWeek": "Bu haftanın başlarında",
|
||||
"lastWeek": "Geçen hafta",
|
||||
"earlierThisMonth": "Bu ayın başlarında",
|
||||
"lastMonth": "Geçen ay",
|
||||
"earlierThisYear": "Bu yılın başlarında",
|
||||
"lastYear": "Geçen yıl",
|
||||
"older": "Daha eski"
|
||||
}
|
||||
},
|
||||
"Information": {
|
||||
"title": "Bilgi",
|
||||
"description": "Grup katılımcıları için yararlı olabilecek bilgileri buraya ekleyebilirsiniz.",
|
||||
"empty": "Henüz grup bilgisi bulunmuyor."
|
||||
},
|
||||
"Settings": {
|
||||
"title": "Ayarlar"
|
||||
},
|
||||
"Share": {
|
||||
"title": "Paylaş",
|
||||
"description": "Diğer katılımcıların grubu görmesi ve masraf ekleyebilmesi için onlarla bu grubun URL'sini paylaşın.",
|
||||
"warning": "Uyarı!",
|
||||
"warningHelp": "Grubun URL'sine sahip olan herkes masrafları görebilir ve düzenleyebilir. Lütfen paylaşırken dikkatli olun!"
|
||||
},
|
||||
"SchemaErrors": {
|
||||
"min1": "En az bir karakter girin.",
|
||||
"min2": "En az iki karakter girin.",
|
||||
"max5": "En fazla beş karakter girin.",
|
||||
"max50": "En fazla 50 karakter girin.",
|
||||
"duplicateParticipantName": "Başka bir katılımcı zaten bu ada sahip.",
|
||||
"titleRequired": "Lütfen bir başlık girin.",
|
||||
"invalidNumber": "Geçersiz numara.",
|
||||
"amountRequired": "Bir tutar girmelisiniz.",
|
||||
"amountNotZero": "Tutar sıfır olamaz.",
|
||||
"amountTenMillion": "Tutar 10.000.000'dan düşük olmalı.",
|
||||
"paidByRequired": "Bir katılımcı seçmelisiniz.",
|
||||
"paidForMin1": "Masraf en az bir katılımcı için ödenmiş olmalıdır.",
|
||||
"noZeroShares": "Tüm paylar 0'dan büyük olmalıdır.",
|
||||
"amountSum": "Tutarların toplamı masraf tutarına eşit olmalıdır.",
|
||||
"percentageSum": "Yüzdelerin toplamı 100 olmalıdır."
|
||||
},
|
||||
"Categories": {
|
||||
"search": "Kategori ara...",
|
||||
"noCategory": "Kategori bulunamadı.",
|
||||
"Uncategorized": {
|
||||
"heading": "Kategorize Edilmemiş",
|
||||
"General": "Genel",
|
||||
"Payment": "Ödeme"
|
||||
},
|
||||
"Entertainment": {
|
||||
"heading": "Eğlence",
|
||||
"Entertainment": "Eğlence",
|
||||
"Games": "Oyunlar",
|
||||
"Movies": "Filmler",
|
||||
"Music": "Müzik",
|
||||
"Sports": "Spor"
|
||||
},
|
||||
"Food and Drink": {
|
||||
"heading": "Yiyecek ve İçecek",
|
||||
"Food and Drink": "Yiyecek ve İçecek",
|
||||
"Dining Out": "Dışarıda Yemek",
|
||||
"Groceries": "Market Alışverişi",
|
||||
"Liquor": "Alkollü İçecekler"
|
||||
},
|
||||
"Home": {
|
||||
"heading": "Ev",
|
||||
"Home": "Ev",
|
||||
"Electronics": "Elektronik",
|
||||
"Furniture": "Mobilya",
|
||||
"Household Supplies": "Ev İhtiyaçları",
|
||||
"Maintenance": "Bakım",
|
||||
"Mortgage": "Mortgage",
|
||||
"Pets": "Evcil Hayvanlar",
|
||||
"Rent": "Kira",
|
||||
"Services": "Hizmetler"
|
||||
},
|
||||
"Life": {
|
||||
"heading": "Yaşam",
|
||||
"Childcare": "Çocuk Bakımı",
|
||||
"Clothing": "Giyim",
|
||||
"Education": "Eğitim",
|
||||
"Gifts": "Hediyeler",
|
||||
"Insurance": "Sigorta",
|
||||
"Medical Expenses": "Sağlık Giderleri",
|
||||
"Taxes": "Vergiler"
|
||||
},
|
||||
"Transportation": {
|
||||
"heading": "Ulaşım",
|
||||
"Transportation": "Ulaşım",
|
||||
"Bicycle": "Bisiklet",
|
||||
"Bus/Train": "Otobüs/Tren",
|
||||
"Car": "Araba",
|
||||
"Gas/Fuel": "Benzin/Yakıt",
|
||||
"Hotel": "Otel",
|
||||
"Parking": "Otopark",
|
||||
"Plane": "Uçak",
|
||||
"Taxi": "Taksi"
|
||||
},
|
||||
"Utilities": {
|
||||
"heading": "Faturalar",
|
||||
"Utilities": "Faturalar",
|
||||
"Cleaning": "Temizlik",
|
||||
"Electricity": "Elektrik",
|
||||
"Heat/Gas": "Isınma/Gaz",
|
||||
"Trash": "Çöp",
|
||||
"TV/Phone/Internet": "TV/Telefon/İnternet",
|
||||
"Water": "Su"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@
|
||||
"createFirst": "Створіть першу витрату",
|
||||
"noExpenses": "У вашій групі ще немає витрат",
|
||||
"exportJson": "Експортувати у JSON",
|
||||
"exportCsv": "Експортувати у CSV",
|
||||
"searchPlaceholder": "Пошук витрат...",
|
||||
"ActiveUserModal": {
|
||||
"title": "Хто ви?",
|
||||
@@ -35,7 +36,7 @@
|
||||
"earlierThisMonth": "Раніше цього місяця",
|
||||
"lastMonth": "Минулого місяця",
|
||||
"earlierThisYear": "Раніше цього року",
|
||||
"lastYera": "Минулого року",
|
||||
"lastYear": "Минулого року",
|
||||
"older": "Старіші"
|
||||
}
|
||||
},
|
||||
@@ -294,19 +295,6 @@
|
||||
"Settings": {
|
||||
"title": "Налаштування"
|
||||
},
|
||||
"Locale": {
|
||||
"en-US": "English",
|
||||
"fi": "Suomi",
|
||||
"fr-FR": "Français",
|
||||
"es": "Español",
|
||||
"de-DE": "Deutsch",
|
||||
"zh-CN": "Chinese (Simplified)",
|
||||
"pl-PL": "Polski",
|
||||
"ru-RU": "Русский",
|
||||
"it-IT": "Italiano",
|
||||
"ua-UA": "Українська",
|
||||
"ro": "Română"
|
||||
},
|
||||
"Share": {
|
||||
"title": "Поділитися",
|
||||
"description": "Щоб інші учасники могли побачити групу і додати витрати, поділіться з ними її URL",
|
||||
@@ -398,4 +386,4 @@
|
||||
"Water": "Вода"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@
|
||||
"createFirst": "创建首个消费",
|
||||
"noExpenses": "你的群组内目前没有任何消费。",
|
||||
"exportJson": "导出到JSON",
|
||||
"exportCsv": "导出到CSV",
|
||||
"searchPlaceholder": "查找消费……",
|
||||
"ActiveUserModal": {
|
||||
"title": "你是哪位?",
|
||||
@@ -35,7 +36,7 @@
|
||||
"earlierThisMonth": "本月早些时候",
|
||||
"lastMonth": "上个月",
|
||||
"earlierThisYear": "本年早些时候",
|
||||
"lastYera": "去年",
|
||||
"lastYear": "去年",
|
||||
"older": "更早"
|
||||
}
|
||||
},
|
||||
@@ -209,7 +210,7 @@
|
||||
"ExpenseDocumentsInput": {
|
||||
"TooBigToast": {
|
||||
"title": "文件过大",
|
||||
"description": "可上传的最大文件大小为{maxSize},你的文件为${size}。"
|
||||
"description": "可上传的最大文件大小为{maxSize},你的文件为{size}。"
|
||||
},
|
||||
"ErrorToast": {
|
||||
"title": "上传文档时发生错误",
|
||||
@@ -234,7 +235,7 @@
|
||||
"unknown": "未知",
|
||||
"TooBigToast": {
|
||||
"title": "文件过大",
|
||||
"description": "可上传的最大文件大小为{maxSize},你的文件为${size}。"
|
||||
"description": "可上传的最大文件大小为{maxSize},你的文件为{size}。"
|
||||
},
|
||||
"ErrorToast": {
|
||||
"title": "上传文档时发生错误",
|
||||
@@ -294,19 +295,6 @@
|
||||
"Settings": {
|
||||
"title": "设定"
|
||||
},
|
||||
"Locale": {
|
||||
"en-US": "English",
|
||||
"fi": "Suomi",
|
||||
"fr-FR": "Français",
|
||||
"es": "Español",
|
||||
"de-DE": "Deutsch",
|
||||
"zh-CN": "Chinese (Simplified)",
|
||||
"pl-PL": "Polski",
|
||||
"ru-RU": "Русский",
|
||||
"it-IT": "Italiano",
|
||||
"ua-UA": "Українська",
|
||||
"ro": "Română"
|
||||
},
|
||||
"Share": {
|
||||
"title": "分享",
|
||||
"description": "请将此URL分享给其他群组成员,以使其可以查看群组并添加消费。",
|
||||
@@ -398,4 +386,4 @@
|
||||
"Water": "水"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
389
messages/zh-TW.json
Normal file
@@ -0,0 +1,389 @@
|
||||
{
|
||||
"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": "你的群組內目前沒有任何消費紀錄。",
|
||||
"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": "你的餘額:"
|
||||
},
|
||||
"Groups": {
|
||||
"myGroups": "我的群組",
|
||||
"create": "建立",
|
||||
"loadingRecent": "讀取最近的群組……",
|
||||
"NoRecent": {
|
||||
"description": "你最近沒有訪問過任何群組。",
|
||||
"create": "建立一個新群組",
|
||||
"orAsk": "或請朋友發送已建立的群組鏈接。"
|
||||
},
|
||||
"recent": "最近的群組",
|
||||
"starred": "已加星標的群組",
|
||||
"archived": "已封存的群組",
|
||||
"archive": "將群組封存",
|
||||
"unarchive": "取消封存群組",
|
||||
"removeRecent": "從最近的群組中移除",
|
||||
"RecentRemovedToast": {
|
||||
"title": "群組已被移除",
|
||||
"description": "該群組已從您的最近群組列表中移除。",
|
||||
"undoAlt": "撤銷移除群組",
|
||||
"undo": "取消操作"
|
||||
},
|
||||
"AddByURL": {
|
||||
"button": "透過連結加入",
|
||||
"title": "透過連結加入群組",
|
||||
"description": "如果某個群組已與您分享,您可以在此處貼上其網址以添加到群組列表中。",
|
||||
"error": "哇哇,我們無法從您提供的網址中找到有效群組……"
|
||||
},
|
||||
"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": "取消"
|
||||
}
|
||||
},
|
||||
"ExpenseForm": {
|
||||
"Income": {
|
||||
"create": "新增收入",
|
||||
"edit": "編輯收入",
|
||||
"TitleField": {
|
||||
"label": "收入標題",
|
||||
"placeholder": "禮拜一晚餐",
|
||||
"description": "輸入此筆收入的描述。"
|
||||
},
|
||||
"DateField": {
|
||||
"label": "收入日期",
|
||||
"description": "輸入收到這筆收入的日期。"
|
||||
},
|
||||
"categoryFieldDescription": "選擇收入類別。",
|
||||
"paidByField": {
|
||||
"label": "接收人",
|
||||
"description": "選擇接收這筆收入的成員。"
|
||||
},
|
||||
"paidFor": {
|
||||
"title": "應接收人",
|
||||
"description": "選擇應參與此筆收入的成員。"
|
||||
},
|
||||
"splitModeDescription": "選擇如何分配此筆收入。",
|
||||
"attachDescription": "查看/附上此筆收入的收據。"
|
||||
},
|
||||
"Expense": {
|
||||
"create": "新增消費紀錄",
|
||||
"edit": "編輯消費紀錄",
|
||||
"TitleField": {
|
||||
"label": "支出標題",
|
||||
"placeholder": "週一晚餐",
|
||||
"description": "輸入此筆消費的描述。"
|
||||
},
|
||||
"DateField": {
|
||||
"label": "消費日期",
|
||||
"description": "輸入支付此消費的日期。"
|
||||
},
|
||||
"categoryFieldDescription": "選擇消費類別。",
|
||||
"paidByField": {
|
||||
"label": "支付人",
|
||||
"description": "选择支付这笔消费的群组成员。"
|
||||
},
|
||||
"paidFor": {
|
||||
"title": "應支付人",
|
||||
"description": "選擇需參與此筆消費的成員。"
|
||||
},
|
||||
"splitModeDescription": "選擇如何分配此筆消費。",
|
||||
"attachDescription": "查看/附上此筆消費的收據。"
|
||||
},
|
||||
"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": "報銷"
|
||||
},
|
||||
"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": "請輸入至少 1 個字。",
|
||||
"min2": "請輸入至少 2 個字。",
|
||||
"max5": "請輸入至少 5 個字。",
|
||||
"max50": "請輸入至少 50 個字。",
|
||||
"duplicateParticipantName": "此名稱已被使用",
|
||||
"titleRequired": "請輸入標題。",
|
||||
"invalidNumber": "數值無效。",
|
||||
"amountRequired": "必須輸入一個金額。",
|
||||
"amountNotZero": "金額不可為 0。",
|
||||
"amountTenMillion": "金額需小於 10,000,000。",
|
||||
"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": "衣服",
|
||||
"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": "水費"
|
||||
}
|
||||
}
|
||||
}
|
||||
411
package-lock.json
generated
@@ -11,6 +11,7 @@
|
||||
"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",
|
||||
@@ -37,7 +38,7 @@
|
||||
"content-disposition": "^0.5.4",
|
||||
"dayjs": "^1.11.10",
|
||||
"embla-carousel-react": "^8.0.0-rc21",
|
||||
"lucide-react": "^0.290.0",
|
||||
"lucide-react": "^0.501.0",
|
||||
"nanoid": "^5.0.4",
|
||||
"negotiator": "^0.6.3",
|
||||
"next": "^14.2.5",
|
||||
@@ -55,6 +56,7 @@
|
||||
"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",
|
||||
@@ -77,6 +79,7 @@
|
||||
"@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",
|
||||
@@ -3976,12 +3979,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.24.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.24.7.tgz",
|
||||
"integrity": "sha512-BcYH1CVJBO9tvyIZ2jVeXgSIMvGZ2FDRvDdOIVQyuklNKSsx+eppDEBq/g47Ayw+RqNFE+URvOShmf+f/qwAlA==",
|
||||
"version": "7.26.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz",
|
||||
"integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/highlight": "^7.24.7",
|
||||
"@babel/helper-validator-identifier": "^7.25.9",
|
||||
"js-tokens": "^4.0.0",
|
||||
"picocolors": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
@@ -4127,19 +4132,21 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-string-parser": {
|
||||
"version": "7.24.8",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.24.8.tgz",
|
||||
"integrity": "sha512-pO9KhhRcuUyGnJWwyEgnRJTSIZHiT+vMD0kPeD+so0l7mxkMT19g3pjY9GTnHySck/hDzq+dtW/4VgnMkippsQ==",
|
||||
"version": "7.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz",
|
||||
"integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-validator-identifier": {
|
||||
"version": "7.24.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.7.tgz",
|
||||
"integrity": "sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w==",
|
||||
"version": "7.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz",
|
||||
"integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
@@ -4154,111 +4161,27 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helpers": {
|
||||
"version": "7.25.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.25.0.tgz",
|
||||
"integrity": "sha512-MjgLZ42aCm0oGjJj8CtSM3DB8NOOf8h2l7DCTePJs29u+v7yO/RBX9nShlKMgFnRks/Q4tBAe7Hxnov9VkGwLw==",
|
||||
"version": "7.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.0.tgz",
|
||||
"integrity": "sha512-U5eyP/CTFPuNE3qk+WZMxFkp/4zUzdceQlfzf7DdGdhp+Fezd7HD+i8Y24ZuTMKX3wQBld449jijbGq6OdGNQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/template": "^7.25.0",
|
||||
"@babel/types": "^7.25.0"
|
||||
"@babel/template": "^7.27.0",
|
||||
"@babel/types": "^7.27.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/highlight": {
|
||||
"version": "7.24.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.24.7.tgz",
|
||||
"integrity": "sha512-EStJpq4OuY8xYfhGVXngigBJRWxftKX9ksiGDnmlY3o7B/V7KIAc9X4oiK87uPJSc/vs5L869bem5fhZa8caZw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@babel/helper-validator-identifier": "^7.24.7",
|
||||
"chalk": "^2.4.2",
|
||||
"js-tokens": "^4.0.0",
|
||||
"picocolors": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/highlight/node_modules/ansi-styles": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
|
||||
"integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"color-convert": "^1.9.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/highlight/node_modules/chalk": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
|
||||
"integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"ansi-styles": "^3.2.1",
|
||||
"escape-string-regexp": "^1.0.5",
|
||||
"supports-color": "^5.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/highlight/node_modules/color-convert": {
|
||||
"version": "1.9.3",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
|
||||
"integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"color-name": "1.1.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/highlight/node_modules/color-name": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
|
||||
"integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@babel/highlight/node_modules/escape-string-regexp": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
|
||||
"integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/highlight/node_modules/has-flag": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
|
||||
"integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/highlight/node_modules/supports-color": {
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
|
||||
"integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"has-flag": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/parser": {
|
||||
"version": "7.25.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.25.3.tgz",
|
||||
"integrity": "sha512-iLTJKDbJ4hMvFPgQwwsVoxtHyWpKKPBrxkANrSYewDPaPpT5py5yeVkgPIJ7XYXhndxJpaA3PyALSXQ7u8e/Dw==",
|
||||
"version": "7.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.0.tgz",
|
||||
"integrity": "sha512-iaepho73/2Pz7w2eMS0Q5f83+0RKI7i4xmiYeBmDzfRVbQtTOG7Ts0S4HzJVsTMGI9keU8rNfuZr8DKfSt7Yyg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.25.2"
|
||||
"@babel/types": "^7.27.0"
|
||||
},
|
||||
"bin": {
|
||||
"parser": "bin/babel-parser.js"
|
||||
@@ -4490,9 +4413,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.23.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.2.tgz",
|
||||
"integrity": "sha512-mM8eg4yl5D6i3lu2QKPuPH4FArvJ8KhTofbE7jwMUv9KX5mBvwPAqnV3MlyBNqdp9RyRKP6Yck8TrfYrPvX3bg==",
|
||||
"version": "7.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz",
|
||||
"integrity": "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"regenerator-runtime": "^0.14.0"
|
||||
@@ -4502,14 +4425,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/template": {
|
||||
"version": "7.25.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.0.tgz",
|
||||
"integrity": "sha512-aOOgh1/5XzKvg1jvVz7AVrx2piJ2XBi227DHmbY6y+bM9H2FlN+IfecYu4Xl0cNiiVejlsCri89LUsbj8vJD9Q==",
|
||||
"version": "7.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.0.tgz",
|
||||
"integrity": "sha512-2ncevenBqXI6qRMukPlXwHKHchC7RyMuu4xv5JBXRfOGVcTy1mXCD12qrp7Jsoxll1EV3+9sE4GugBVRjT2jFA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.24.7",
|
||||
"@babel/parser": "^7.25.0",
|
||||
"@babel/types": "^7.25.0"
|
||||
"@babel/code-frame": "^7.26.2",
|
||||
"@babel/parser": "^7.27.0",
|
||||
"@babel/types": "^7.27.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -4543,14 +4467,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/types": {
|
||||
"version": "7.25.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.25.2.tgz",
|
||||
"integrity": "sha512-YTnYtra7W9e6/oAZEHj0bJehPRUlLH9/fbpT5LfB0NhQXyALCRkRs3zH9v07IYhkgpqX6Z78FnuccZr/l4Fs4Q==",
|
||||
"version": "7.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.0.tgz",
|
||||
"integrity": "sha512-H45s8fVLYjbhFH62dIJ3WtmJ6RSPt/3DRO0ZcT2SUiYiQyz3BLVb9ADEnLl91m74aQPS3AzzeajZHYOalWe3bg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-string-parser": "^7.24.8",
|
||||
"@babel/helper-validator-identifier": "^7.24.7",
|
||||
"to-fast-properties": "^2.0.0"
|
||||
"@babel/helper-string-parser": "^7.25.9",
|
||||
"@babel/helper-validator-identifier": "^7.25.9"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -4767,52 +4691,6 @@
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@img/sharp-darwin-x64": {
|
||||
"version": "0.33.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.2.tgz",
|
||||
"integrity": "sha512-/rK/69Rrp9x5kaWBjVN07KixZanRr+W1OiyKdXcbjQD6KbW+obaTeBBtLUAtbBsnlTTmWthw99xqoOS7SsySDg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"glibc": ">=2.26",
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0",
|
||||
"npm": ">=9.6.5",
|
||||
"pnpm": ">=7.1.0",
|
||||
"yarn": ">=3.2.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-x64": "1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-darwin-x64": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.1.tgz",
|
||||
"integrity": "sha512-eVU/JYLPVjhhrd8Tk6gosl5pVlvsqiFlt50wotCvdkFGf+mDNBJxMh+bvav+Wt3EBnNZWq8Sp2I7XfSjm8siog==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"macos": ">=10.13",
|
||||
"npm": ">=9.6.5",
|
||||
"pnpm": ">=7.1.0",
|
||||
"yarn": ">=3.2.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/cliui": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
||||
@@ -5320,10 +5198,27 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@json2csv/formatters": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@json2csv/formatters/-/formatters-7.0.6.tgz",
|
||||
"integrity": "sha512-hjIk1H1TR4ydU5ntIENEPgoMGW+Q7mJ+537sDFDbsk+Y3EPl2i4NfFVjw0NJRgT+ihm8X30M67mA8AS6jPidSA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@json2csv/plainjs": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@json2csv/plainjs/-/plainjs-7.0.6.tgz",
|
||||
"integrity": "sha512-4Md7RPDCSYpmW1HWIpWBOqCd4vWfIqm53S3e/uzQ62iGi7L3r34fK/8nhOMEe+/eVfCx8+gdSCt1d74SlacQHw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@json2csv/formatters": "^7.0.6",
|
||||
"@streamparser/json": "^0.0.20"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/env": {
|
||||
"version": "14.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.5.tgz",
|
||||
"integrity": "sha512-/zZGkrTOsraVfYjGP8uM0p6r0BDT6xWpkjdVbcz66PJVSpwXX3yNiRycxAuDfBKGWBrZBXRuK/YVlkNgxHGwmA=="
|
||||
"version": "14.2.28",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.28.tgz",
|
||||
"integrity": "sha512-PAmWhJfJQlP+kxZwCjrVd9QnR5x0R3u0mTXTiZDgSd4h5LdXmjxCCWbN9kq6hkZBOax8Rm3xDW5HagWyJuT37g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@next/eslint-plugin-next": {
|
||||
"version": "14.1.0",
|
||||
@@ -5381,9 +5276,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-arm64": {
|
||||
"version": "14.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.5.tgz",
|
||||
"integrity": "sha512-/9zVxJ+K9lrzSGli1///ujyRfon/ZneeZ+v4ptpiPoOU+GKZnm8Wj8ELWU1Pm7GHltYRBklmXMTUqM/DqQ99FQ==",
|
||||
"version": "14.2.28",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.28.tgz",
|
||||
"integrity": "sha512-kzGChl9setxYWpk3H6fTZXXPFFjg7urptLq5o5ZgYezCrqlemKttwMT5iFyx/p1e/JeglTwDFRtb923gTJ3R1w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -5396,9 +5291,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-x64": {
|
||||
"version": "14.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.5.tgz",
|
||||
"integrity": "sha512-vXHOPCwfDe9qLDuq7U1OYM2wUY+KQ4Ex6ozwsKxp26BlJ6XXbHleOUldenM67JRyBfVjv371oneEvYd3H2gNSA==",
|
||||
"version": "14.2.28",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.28.tgz",
|
||||
"integrity": "sha512-z6FXYHDJlFOzVEOiiJ/4NG8aLCeayZdcRSMjPDysW297Up6r22xw6Ea9AOwQqbNsth8JNgIK8EkWz2IDwaLQcw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -5411,9 +5306,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-gnu": {
|
||||
"version": "14.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.5.tgz",
|
||||
"integrity": "sha512-vlhB8wI+lj8q1ExFW8lbWutA4M2ZazQNvMWuEDqZcuJJc78iUnLdPPunBPX8rC4IgT6lIx/adB+Cwrl99MzNaA==",
|
||||
"version": "14.2.28",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.28.tgz",
|
||||
"integrity": "sha512-9ARHLEQXhAilNJ7rgQX8xs9aH3yJSj888ssSjJLeldiZKR4D7N08MfMqljk77fAwZsWwsrp8ohHsMvurvv9liQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -5426,9 +5321,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-musl": {
|
||||
"version": "14.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.5.tgz",
|
||||
"integrity": "sha512-NpDB9NUR2t0hXzJJwQSGu1IAOYybsfeB+LxpGsXrRIb7QOrYmidJz3shzY8cM6+rO4Aojuef0N/PEaX18pi9OA==",
|
||||
"version": "14.2.28",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.28.tgz",
|
||||
"integrity": "sha512-p6gvatI1nX41KCizEe6JkF0FS/cEEF0u23vKDpl+WhPe/fCTBeGkEBh7iW2cUM0rvquPVwPWdiUR6Ebr/kQWxQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -5441,9 +5336,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-gnu": {
|
||||
"version": "14.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.5.tgz",
|
||||
"integrity": "sha512-8XFikMSxWleYNryWIjiCX+gU201YS+erTUidKdyOVYi5qUQo/gRxv/3N1oZFCgqpesN6FPeqGM72Zve+nReVXQ==",
|
||||
"version": "14.2.28",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.28.tgz",
|
||||
"integrity": "sha512-nsiSnz2wO6GwMAX2o0iucONlVL7dNgKUqt/mDTATGO2NY59EO/ZKnKEr80BJFhuA5UC1KZOMblJHWZoqIJddpA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -5456,9 +5351,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-musl": {
|
||||
"version": "14.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.5.tgz",
|
||||
"integrity": "sha512-6QLwi7RaYiQDcRDSU/os40r5o06b5ue7Jsk5JgdRBGGp8l37RZEh9JsLSM8QF0YDsgcosSeHjglgqi25+m04IQ==",
|
||||
"version": "14.2.28",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.28.tgz",
|
||||
"integrity": "sha512-+IuGQKoI3abrXFqx7GtlvNOpeExUH1mTIqCrh1LGFf8DnlUcTmOOCApEnPJUSLrSbzOdsF2ho2KhnQoO0I1RDw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -5471,9 +5366,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-arm64-msvc": {
|
||||
"version": "14.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.5.tgz",
|
||||
"integrity": "sha512-1GpG2VhbspO+aYoMOQPQiqc/tG3LzmsdBH0LhnDS3JrtDx2QmzXe0B6mSZZiN3Bq7IOMXxv1nlsjzoS1+9mzZw==",
|
||||
"version": "14.2.28",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.28.tgz",
|
||||
"integrity": "sha512-l61WZ3nevt4BAnGksUVFKy2uJP5DPz2E0Ma/Oklvo3sGj9sw3q7vBWONFRgz+ICiHpW5mV+mBrkB3XEubMrKaA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -5486,9 +5381,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-ia32-msvc": {
|
||||
"version": "14.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.5.tgz",
|
||||
"integrity": "sha512-Igh9ZlxwvCDsu6438FXlQTHlRno4gFpJzqPjSIBZooD22tKeI4fE/YMRoHVJHmrQ2P5YL1DoZ0qaOKkbeFWeMg==",
|
||||
"version": "14.2.28",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.28.tgz",
|
||||
"integrity": "sha512-+Kcp1T3jHZnJ9v9VTJ/yf1t/xmtFAc/Sge4v7mVc1z+NYfYzisi8kJ9AsY8itbgq+WgEwMtOpiLLJsUy2qnXZw==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -5501,12 +5396,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-x64-msvc": {
|
||||
"version": "14.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.5.tgz",
|
||||
"integrity": "sha512-tEQ7oinq1/CjSG9uSTerca3v4AZ+dFa+4Yu6ihaG8Ud8ddqLQgFGcnwYls13H5X5CPDPZJdYxyeMui6muOLd4g==",
|
||||
"version": "14.2.28",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.28.tgz",
|
||||
"integrity": "sha512-1gCmpvyhz7DkB1srRItJTnmR2UwQPAUXXIg9r0/56g3O8etGmwlX68skKXJOp9EejW3hhv7nSQUJ2raFiz4MoA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
@@ -8930,6 +8826,12 @@
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@streamparser/json": {
|
||||
"version": "0.0.20",
|
||||
"resolved": "https://registry.npmjs.org/@streamparser/json/-/json-0.0.20.tgz",
|
||||
"integrity": "sha512-VqAAkydywPpkw63WQhPVKCD3SdwXuihCUVZbbiY3SfSTGQyHmwRoq27y4dmJdZuJwd5JIlQoMPyGvMbUPY0RKQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@swc/counter": {
|
||||
"version": "0.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
|
||||
@@ -10902,9 +10804,9 @@
|
||||
"devOptional": true
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.3",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
|
||||
"integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -10973,6 +10875,13 @@
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/currency-list": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/currency-list/-/currency-list-1.0.8.tgz",
|
||||
"integrity": "sha512-KBUtf8AzoP2WYeAKUYFhhNdHRx8Xw2UoOUnBNVir43RxL96T+iSHMtThtT1DFNtYbbVVyD08ETe3aamn+JX7RA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/damerau-levenshtein": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
||||
@@ -11132,7 +11041,6 @@
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
||||
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
@@ -12289,19 +12197,6 @@
|
||||
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
@@ -14579,12 +14474,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "0.290.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.290.0.tgz",
|
||||
"integrity": "sha512-CBDPRLOPjdo+bVlxhaa7FVWaB8OrZZQ34mwm0Fsz9ut6JltN/Td55640ur8bRWSJuz6+nX2klKrpBpV7ktwD3Q==",
|
||||
"version": "0.501.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.501.0.tgz",
|
||||
"integrity": "sha512-E2KoyhW59fCb/yUbR3rbDer83fqn7a8NG91ZhIot2yWaPHjPyGzzsNKh40N//GezYShAuycf3TcQksRQznIsRw==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0"
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lz-string": {
|
||||
@@ -14664,12 +14559,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/micromatch": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz",
|
||||
"integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==",
|
||||
"version": "4.0.8",
|
||||
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
||||
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"braces": "^3.0.2",
|
||||
"braces": "^3.0.3",
|
||||
"picomatch": "^2.3.1"
|
||||
},
|
||||
"engines": {
|
||||
@@ -14761,15 +14656,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.0.4.tgz",
|
||||
"integrity": "sha512-vAjmBf13gsmhXSgBrtIclinISzFFy22WwCYoyilZlsrRXNIHSwgFQ1bEdjRwMT3aoadeIF6HMuDRlOxzfXV8ig==",
|
||||
"version": "5.1.5",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.5.tgz",
|
||||
"integrity": "sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"nanoid": "bin/nanoid.js"
|
||||
},
|
||||
@@ -14793,11 +14689,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/next": {
|
||||
"version": "14.2.5",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-14.2.5.tgz",
|
||||
"integrity": "sha512-0f8aRfBVL+mpzfBjYfQuLWh2WyAwtJXCRfkPF4UJ5qd2YwrHczsrSzXU4tRMV0OAxR8ZJZWPFn6uhSC56UTsLA==",
|
||||
"version": "14.2.28",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-14.2.28.tgz",
|
||||
"integrity": "sha512-QLEIP/kYXynIxtcKB6vNjtWLVs3Y4Sb+EClTC/CSVzdLD1gIuItccpu/n1lhmduffI32iPGEK2cLLxxt28qgYA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@next/env": "14.2.5",
|
||||
"@next/env": "14.2.28",
|
||||
"@swc/helpers": "0.5.5",
|
||||
"busboy": "1.6.0",
|
||||
"caniuse-lite": "^1.0.30001579",
|
||||
@@ -14812,15 +14709,15 @@
|
||||
"node": ">=18.17.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@next/swc-darwin-arm64": "14.2.5",
|
||||
"@next/swc-darwin-x64": "14.2.5",
|
||||
"@next/swc-linux-arm64-gnu": "14.2.5",
|
||||
"@next/swc-linux-arm64-musl": "14.2.5",
|
||||
"@next/swc-linux-x64-gnu": "14.2.5",
|
||||
"@next/swc-linux-x64-musl": "14.2.5",
|
||||
"@next/swc-win32-arm64-msvc": "14.2.5",
|
||||
"@next/swc-win32-ia32-msvc": "14.2.5",
|
||||
"@next/swc-win32-x64-msvc": "14.2.5"
|
||||
"@next/swc-darwin-arm64": "14.2.28",
|
||||
"@next/swc-darwin-x64": "14.2.28",
|
||||
"@next/swc-linux-arm64-gnu": "14.2.28",
|
||||
"@next/swc-linux-arm64-musl": "14.2.28",
|
||||
"@next/swc-linux-x64-gnu": "14.2.28",
|
||||
"@next/swc-linux-x64-musl": "14.2.28",
|
||||
"@next/swc-win32-arm64-msvc": "14.2.28",
|
||||
"@next/swc-win32-ia32-msvc": "14.2.28",
|
||||
"@next/swc-win32-x64-msvc": "14.2.28"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": "^1.1.0",
|
||||
@@ -15704,15 +15601,16 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/postcss/node_modules/nanoid": {
|
||||
"version": "3.3.7",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
|
||||
"integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==",
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"nanoid": "bin/nanoid.cjs"
|
||||
},
|
||||
@@ -16952,6 +16850,19 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/swr": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/swr/-/swr-2.3.3.tgz",
|
||||
"integrity": "sha512-dshNvs3ExOqtZ6kJBaAsabhPdHyeY4P2cKwRCniDVifBMoG/SVI7tfLWqPXriVspf2Rg4tPzXJTnwaihIeFw2A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dequal": "^2.0.3",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/symbol-tree": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
|
||||
@@ -17084,15 +16995,6 @@
|
||||
"integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/to-fast-properties": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz",
|
||||
"integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/to-regex-range": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
@@ -17480,6 +17382,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/use-sync-external-store": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz",
|
||||
"integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
|
||||
@@ -13,11 +13,13 @@
|
||||
"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",
|
||||
@@ -44,7 +46,7 @@
|
||||
"content-disposition": "^0.5.4",
|
||||
"dayjs": "^1.11.10",
|
||||
"embla-carousel-react": "^8.0.0-rc21",
|
||||
"lucide-react": "^0.290.0",
|
||||
"lucide-react": "^0.501.0",
|
||||
"nanoid": "^5.0.4",
|
||||
"negotiator": "^0.6.3",
|
||||
"next": "^14.2.5",
|
||||
@@ -62,6 +64,7 @@
|
||||
"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",
|
||||
@@ -84,6 +87,7 @@
|
||||
"@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",
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "RecurrenceRule" AS ENUM ('NONE', 'DAILY', 'WEEKLY', 'MONTHLY');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Expense" ADD COLUMN "recurrenceRule" "RecurrenceRule" DEFAULT 'NONE',
|
||||
ADD COLUMN "recurringExpenseLinkId" TEXT;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "RecurringExpenseLink" (
|
||||
"id" TEXT NOT NULL,
|
||||
"groupId" TEXT NOT NULL,
|
||||
"currentFrameExpenseId" TEXT NOT NULL,
|
||||
"nextExpenseCreatedAt" TIMESTAMP(3),
|
||||
"nextExpenseDate" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "RecurringExpenseLink_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "RecurringExpenseLink_currentFrameExpenseId_key" ON "RecurringExpenseLink"("currentFrameExpenseId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "RecurringExpenseLink_groupId_idx" ON "RecurringExpenseLink"("groupId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "RecurringExpenseLink_groupId_nextExpenseCreatedAt_nextExpen_idx" ON "RecurringExpenseLink"("groupId", "nextExpenseCreatedAt", "nextExpenseDate" DESC);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "RecurringExpenseLink" ADD CONSTRAINT "RecurringExpenseLink_currentFrameExpenseId_fkey" FOREIGN KEY ("currentFrameExpenseId") REFERENCES "Expense"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1 @@
|
||||
INSERT INTO "Category" ("id", "grouping", "name") VALUES (43, 'Life', 'Donation');
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Group" ADD COLUMN "currencyCode" TEXT;
|
||||
@@ -0,0 +1,4 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Expense" ADD COLUMN "conversionRate" DECIMAL(65,30),
|
||||
ADD COLUMN "originalAmount" INTEGER,
|
||||
ADD COLUMN "originalCurrency" TEXT;
|
||||
@@ -16,6 +16,7 @@ model Group {
|
||||
name String
|
||||
information String? @db.Text
|
||||
currency String @default("$")
|
||||
currencyCode String?
|
||||
participants Participant[]
|
||||
expenses Expense[]
|
||||
activities Activity[]
|
||||
@@ -39,22 +40,29 @@ 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?
|
||||
recurringExpenseLinkId String?
|
||||
}
|
||||
|
||||
model ExpenseDocument {
|
||||
@@ -73,6 +81,29 @@ enum SplitMode {
|
||||
BY_AMOUNT
|
||||
}
|
||||
|
||||
model RecurringExpenseLink {
|
||||
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
|
||||
|
||||
@@index([groupId])
|
||||
@@index([groupId, nextExpenseCreatedAt, nextExpenseDate(sort: Desc)])
|
||||
}
|
||||
|
||||
enum RecurrenceRule {
|
||||
NONE
|
||||
DAILY
|
||||
WEEKLY
|
||||
MONTHLY
|
||||
}
|
||||
|
||||
model ExpensePaidFor {
|
||||
expense Expense @relation(fields: [expenseId], references: [id], onDelete: Cascade)
|
||||
participant Participant @relation(fields: [participantId], references: [id], onDelete: Cascade)
|
||||
|
||||
|
Before Width: | Height: | Size: 28 KiB |
BIN
public/logo/128x128.png
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
BIN
public/logo/144x144.png
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
BIN
public/logo/192x192.png
Normal file
|
After Width: | Height: | Size: 6.6 KiB |
BIN
public/logo/256x256.png
Normal file
|
After Width: | Height: | Size: 8.8 KiB |
BIN
public/logo/48x48.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
public/logo/512x512-maskable.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
public/logo/512x512.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
public/logo/64x64.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
@@ -3,4 +3,4 @@
|
||||
set -euxo pipefail
|
||||
|
||||
npx prisma migrate deploy
|
||||
npm run start
|
||||
exec npm run start
|
||||
|
||||
7
src/app/api/health/liveness/route.ts
Normal 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()
|
||||
}
|
||||
7
src/app/api/health/readiness/route.ts
Normal 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()
|
||||
}
|
||||
7
src/app/api/health/route.ts
Normal 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()
|
||||
}
|
||||
@@ -41,7 +41,8 @@
|
||||
--muted-foreground: 240 5% 64.9%;
|
||||
--accent: 12 6.5% 15.1%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
/* --destructive: 0 62.8% 30.6%; */
|
||||
--destructive: 0 87% 47%;
|
||||
--destructive-foreground: 0 85.7% 97.3%;
|
||||
--border: 240 3.7% 15.9%;
|
||||
--input: 240 3.7% 15.9%;
|
||||
|
||||
@@ -9,6 +9,7 @@ import dayjs, { type Dayjs } from 'dayjs'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { forwardRef, useEffect } from 'react'
|
||||
import { useInView } from 'react-intersection-observer'
|
||||
import { useCurrentGroup } from '../current-group-context'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
@@ -82,11 +83,9 @@ const ActivitiesLoading = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
})
|
||||
ActivitiesLoading.displayName = 'ActivitiesLoading'
|
||||
|
||||
export function ActivityList({ groupId }: { groupId: string }) {
|
||||
export function ActivityList() {
|
||||
const t = useTranslations('Activity')
|
||||
|
||||
const { data: groupData, isLoading: groupIsLoading } =
|
||||
trpc.groups.get.useQuery({ groupId })
|
||||
const { group, groupId } = useCurrentGroup()
|
||||
|
||||
const {
|
||||
data: activitiesData,
|
||||
@@ -105,7 +104,7 @@ export function ActivityList({ groupId }: { groupId: string }) {
|
||||
if (inView && hasMore && !isLoading) fetchNextPage()
|
||||
}, [fetchNextPage, hasMore, inView, isLoading])
|
||||
|
||||
if (isLoading || !activities || !groupData) return <ActivitiesLoading />
|
||||
if (isLoading || !activities || !group) return <ActivitiesLoading />
|
||||
|
||||
const groupedActivitiesByDate = getGroupedActivitiesByDate(activities)
|
||||
|
||||
@@ -131,7 +130,7 @@ export function ActivityList({ groupId }: { groupId: string }) {
|
||||
{groupActivities.map((activity) => {
|
||||
const participant =
|
||||
activity.participantId !== null
|
||||
? groupData.group.participants.find(
|
||||
? group.participants.find(
|
||||
(p) => p.id === activity.participantId,
|
||||
)
|
||||
: undefined
|
||||
|
||||
@@ -13,7 +13,7 @@ export const metadata: Metadata = {
|
||||
title: 'Activity',
|
||||
}
|
||||
|
||||
export function ActivityPageClient({ groupId }: { groupId: string }) {
|
||||
export function ActivityPageClient() {
|
||||
const t = useTranslations('Activity')
|
||||
|
||||
return (
|
||||
@@ -24,7 +24,7 @@ export function ActivityPageClient({ groupId }: { groupId: string }) {
|
||||
<CardDescription>{t('description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col space-y-4">
|
||||
<ActivityList groupId={groupId} />
|
||||
<ActivityList />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
|
||||
@@ -5,10 +5,6 @@ export const metadata: Metadata = {
|
||||
title: 'Activity',
|
||||
}
|
||||
|
||||
export default async function ActivityPage({
|
||||
params: { groupId },
|
||||
}: {
|
||||
params: { groupId: string }
|
||||
}) {
|
||||
return <ActivityPageClient groupId={groupId} />
|
||||
export default async function ActivityPage() {
|
||||
return <ActivityPageClient />
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -10,18 +10,16 @@ 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'
|
||||
import { match } from 'ts-pattern'
|
||||
import { useCurrentGroup } from '../current-group-context'
|
||||
|
||||
export default function BalancesAndReimbursements({
|
||||
groupId,
|
||||
}: {
|
||||
groupId: string
|
||||
}) {
|
||||
export default function BalancesAndReimbursements() {
|
||||
const utils = trpc.useUtils()
|
||||
const { data: groupData, isLoading: groupIsLoading } =
|
||||
trpc.groups.get.useQuery({ groupId })
|
||||
const { groupId, group } = useCurrentGroup()
|
||||
const { data: balancesData, isLoading: balancesAreLoading } =
|
||||
trpc.groups.balances.list.useQuery({
|
||||
groupId,
|
||||
@@ -34,8 +32,7 @@ export default function BalancesAndReimbursements({
|
||||
utils.groups.balances.invalidate()
|
||||
}, [utils])
|
||||
|
||||
const isLoading =
|
||||
balancesAreLoading || !balancesData || groupIsLoading || !groupData?.group
|
||||
const isLoading = balancesAreLoading || !balancesData || !group
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -46,14 +43,12 @@ export default function BalancesAndReimbursements({
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<BalancesLoading
|
||||
participantCount={groupData?.group.participants.length}
|
||||
/>
|
||||
<BalancesLoading participantCount={group?.participants.length} />
|
||||
) : (
|
||||
<BalancesList
|
||||
balances={balancesData.balances}
|
||||
participants={groupData.group.participants}
|
||||
currency={groupData.group.currency}
|
||||
participants={group?.participants}
|
||||
currency={getCurrencyFromGroup(group)}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
@@ -66,14 +61,14 @@ export default function BalancesAndReimbursements({
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<ReimbursementsLoading
|
||||
participantCount={groupData?.group.participants.length}
|
||||
participantCount={group?.participants.length}
|
||||
/>
|
||||
) : (
|
||||
<ReimbursementList
|
||||
reimbursements={balancesData.reimbursements}
|
||||
participants={groupData.group.participants}
|
||||
currency={groupData.group.currency}
|
||||
groupId={groupData.group.id}
|
||||
participants={group?.participants}
|
||||
currency={getCurrencyFromGroup(group)}
|
||||
groupId={groupId}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
@@ -109,6 +104,12 @@ const BalancesLoading = ({
|
||||
}: {
|
||||
participantCount?: number
|
||||
}) => {
|
||||
const barWidth = (index: number) =>
|
||||
match(index % 3)
|
||||
.with(0, () => 'w-1/3')
|
||||
.with(1, () => 'w-2/3')
|
||||
.otherwise(() => 'w-full')
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 py-1 gap-y-2">
|
||||
{Array(participantCount)
|
||||
@@ -120,17 +121,13 @@ const BalancesLoading = ({
|
||||
<Skeleton className="h-3 w-16" />
|
||||
</div>
|
||||
<div className="self-start">
|
||||
<Skeleton
|
||||
className={`h-7 w-${(index % 3) + 1}/3 rounded-l-none`}
|
||||
/>
|
||||
<Skeleton className={`h-7 ${barWidth(index)} rounded-l-none`} />
|
||||
</div>
|
||||
</Fragment>
|
||||
) : (
|
||||
<Fragment key={index}>
|
||||
<div className="flex items-center justify-end">
|
||||
<Skeleton
|
||||
className={`h-7 w-${(index % 3) + 1}/3 rounded-r-none`}
|
||||
/>
|
||||
<Skeleton className={`h-7 ${barWidth(index)} rounded-r-none`} />
|
||||
</div>
|
||||
<div className="flex items-center pl-2">
|
||||
<Skeleton className="h-3 w-16" />
|
||||
|
||||
@@ -1,19 +1,10 @@
|
||||
import { cached } from '@/app/cached-functions'
|
||||
import BalancesAndReimbursements from '@/app/groups/[groupId]/balances/balances-and-reimbursements'
|
||||
import { Metadata } from 'next'
|
||||
import { notFound } from 'next/navigation'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Balances',
|
||||
}
|
||||
|
||||
export default async function GroupPage({
|
||||
params: { groupId },
|
||||
}: {
|
||||
params: { groupId: string }
|
||||
}) {
|
||||
const group = await cached.getGroup(groupId)
|
||||
if (!group) notFound()
|
||||
|
||||
return <BalancesAndReimbursements groupId={groupId} />
|
||||
export default async function GroupPage() {
|
||||
return <BalancesAndReimbursements />
|
||||
}
|
||||
|
||||
30
src/app/groups/[groupId]/current-group-context.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { AppRouterOutput } from '@/trpc/routers/_app'
|
||||
import { PropsWithChildren, createContext, useContext } from 'react'
|
||||
|
||||
type Group = NonNullable<AppRouterOutput['groups']['get']['group']>
|
||||
|
||||
type GroupContext =
|
||||
| { isLoading: false; groupId: string; group: Group }
|
||||
| { isLoading: true; groupId: string; group: undefined }
|
||||
|
||||
const CurrentGroupContext = createContext<GroupContext | null>(null)
|
||||
|
||||
export const useCurrentGroup = () => {
|
||||
const context = useContext(CurrentGroupContext)
|
||||
if (!context)
|
||||
throw new Error(
|
||||
'Missing context. Should be called inside a CurrentGroupProvider.',
|
||||
)
|
||||
return context
|
||||
}
|
||||
|
||||
export const CurrentGroupProvider = ({
|
||||
children,
|
||||
...props
|
||||
}: PropsWithChildren<GroupContext>) => {
|
||||
return (
|
||||
<CurrentGroupContext.Provider value={props}>
|
||||
{children}
|
||||
</CurrentGroupContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
import { GroupForm } from '@/components/group-form'
|
||||
import { trpc } from '@/trpc/client'
|
||||
import { useCurrentGroup } from '../current-group-context'
|
||||
|
||||
export const EditGroup = ({ groupId }: { groupId: string }) => {
|
||||
const { data, isLoading } = trpc.groups.get.useQuery({ groupId })
|
||||
export const EditGroup = () => {
|
||||
const { groupId } = useCurrentGroup()
|
||||
const { data, isLoading } = trpc.groups.getDetails.useQuery({ groupId })
|
||||
const { mutateAsync } = trpc.groups.update.useMutation()
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
|
||||
@@ -5,10 +5,6 @@ export const metadata: Metadata = {
|
||||
title: 'Settings',
|
||||
}
|
||||
|
||||
export default async function EditGroupPage({
|
||||
params: { groupId },
|
||||
}: {
|
||||
params: { groupId: string }
|
||||
}) {
|
||||
return <EditGroup groupId={groupId} />
|
||||
export default async function EditGroupPage() {
|
||||
return <EditGroup />
|
||||
}
|
||||
|
||||
@@ -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 = <></>
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
FerrisWheel,
|
||||
Fuel,
|
||||
Gift,
|
||||
HandHelping,
|
||||
Home,
|
||||
Hotel,
|
||||
Lamp,
|
||||
@@ -96,6 +97,8 @@ function getCategoryIcon(category: string): LucideIcon {
|
||||
return Baby
|
||||
case 'Life/Clothing':
|
||||
return Shirt
|
||||
case 'Life/Donation':
|
||||
return HandHelping
|
||||
case 'Life/Education':
|
||||
return LibraryBig
|
||||
case 'Life/Gifts':
|
||||
|
||||
@@ -22,7 +22,7 @@ export async function extractExpenseInformationFromImage(imageUrl: string) {
|
||||
text: `
|
||||
This image contains a receipt.
|
||||
Read the total amount and store it as a non-formatted number without any other text or currency.
|
||||
Then guess the category for this receipt amoung the following categories and store its ID: ${categories.map(
|
||||
Then guess the category for this receipt among the following categories and store its ID: ${categories.map(
|
||||
(category) => formatCategoryForAIPrompt(category),
|
||||
)}.
|
||||
Guess the expense’s date and store it as yyyy-mm-dd.
|
||||
|
||||
@@ -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'
|
||||
@@ -34,14 +39,11 @@ import { getImageData, usePresignedUpload } from 'next-s3-upload'
|
||||
import Image from 'next/image'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { PropsWithChildren, ReactNode, useState } from 'react'
|
||||
import { useCurrentGroup } from '../current-group-context'
|
||||
|
||||
const MAX_FILE_SIZE = 5 * 1024 ** 2
|
||||
|
||||
export function CreateFromReceiptButton({ groupId }: { groupId: string }) {
|
||||
return <CreateFromReceiptButton_ groupId={groupId} />
|
||||
}
|
||||
|
||||
function CreateFromReceiptButton_({ groupId }: { groupId: string }) {
|
||||
export function CreateFromReceiptButton() {
|
||||
const t = useTranslations('CreateFromReceipt')
|
||||
const isDesktop = useMediaQuery('(min-width: 640px)')
|
||||
|
||||
@@ -70,15 +72,14 @@ function CreateFromReceiptButton_({ groupId }: { groupId: string }) {
|
||||
}
|
||||
description={<>{t('Dialog.description')}</>}
|
||||
>
|
||||
<ReceiptDialogContent groupId={groupId} />
|
||||
<ReceiptDialogContent />
|
||||
</DialogOrDrawer>
|
||||
)
|
||||
}
|
||||
|
||||
function ReceiptDialogContent({ groupId }: { groupId: string }) {
|
||||
const { data: groupData } = trpc.groups.get.useQuery({ groupId })
|
||||
function ReceiptDialogContent() {
|
||||
const { group } = useCurrentGroup()
|
||||
const { data: categoriesData } = trpc.categories.list.useQuery()
|
||||
const group = groupData?.group
|
||||
const categories = categoriesData?.categories
|
||||
|
||||
const locale = useLocale()
|
||||
@@ -195,7 +196,7 @@ function ReceiptDialogContent({ groupId }: { groupId: string }) {
|
||||
<Unknown />
|
||||
)
|
||||
) : (
|
||||
'' || '…'
|
||||
''
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -206,7 +207,7 @@ function ReceiptDialogContent({ groupId }: { groupId: string }) {
|
||||
receiptInfo.amount ? (
|
||||
<>
|
||||
{formatCurrency(
|
||||
group.currency,
|
||||
getCurrencyFromGroup(group),
|
||||
receiptInfo.amount,
|
||||
locale,
|
||||
true,
|
||||
|
||||
11
src/app/groups/[groupId]/expenses/documents-count.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Paperclip } from 'lucide-react'
|
||||
|
||||
export function DocumentsCount({ count }: { count: number }) {
|
||||
if (count === 0) return <></>
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<Paperclip className="w-3.5 h-3.5 mr-1 mt-0.5 text-muted-foreground" />
|
||||
<span>{count}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
'use client'
|
||||
import { ActiveUserBalance } from '@/app/groups/[groupId]/expenses/active-user-balance'
|
||||
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 { Currency } from '@/lib/currency'
|
||||
import { cn, formatCurrency, formatDate } from '@/lib/utils'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import { useLocale, useTranslations } from 'next-intl'
|
||||
@@ -12,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,
|
||||
@@ -32,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()
|
||||
|
||||
@@ -60,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 }} />
|
||||
@@ -75,6 +95,9 @@ export function ExpenseCard({ expense, currency, groupId }: Props) {
|
||||
>
|
||||
{formatCurrency(currency, expense.amount, locale)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<DocumentsCount count={expense._count.documents} />
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{formatDate(expense.expenseDate, locale, { dateStyle: 'medium' })}
|
||||
</div>
|
||||
|
||||
@@ -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,19 +33,29 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Locale } from '@/i18n'
|
||||
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 { cn } from '@/lib/utils'
|
||||
import { calculateShare } from '@/lib/totals'
|
||||
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'
|
||||
@@ -64,13 +75,13 @@ const enforceCurrencyPattern = (value: string) =>
|
||||
.replace(/[^-\d.]/g, '') // remove all non-numeric characters
|
||||
|
||||
const getDefaultSplittingOptions = (
|
||||
group: AppRouterOutput['groups']['get']['group'],
|
||||
group: NonNullable<AppRouterOutput['groups']['get']['group']>,
|
||||
) => {
|
||||
const defaultValue = {
|
||||
splitMode: 'EVENLY' as const,
|
||||
paidFor: group.participants.map(({ id }) => ({
|
||||
participant: id,
|
||||
shares: '1' as unknown as number,
|
||||
shares: 1,
|
||||
})),
|
||||
}
|
||||
|
||||
@@ -102,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,
|
||||
})),
|
||||
}
|
||||
}
|
||||
@@ -116,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
|
||||
@@ -145,7 +156,7 @@ export function ExpenseForm({
|
||||
onDelete,
|
||||
runtimeFeatureFlags,
|
||||
}: {
|
||||
group: AppRouterOutput['groups']['get']['group']
|
||||
group: NonNullable<AppRouterOutput['groups']['get']['group']>
|
||||
categories: AppRouterOutput['categories']['list']['categories']
|
||||
expense?: AppRouterOutput['groups']['expenses']['get']['expense']
|
||||
onSubmit: (value: ExpenseFormValues, participantId?: string) => Promise<void>
|
||||
@@ -153,6 +164,7 @@ export function ExpenseForm({
|
||||
runtimeFeatureFlags: RuntimeFeatureFlags
|
||||
}) {
|
||||
const t = useTranslations('ExpenseForm')
|
||||
const locale = useLocale() as Locale
|
||||
const isCreate = expense === undefined
|
||||
const searchParams = useSearchParams()
|
||||
|
||||
@@ -165,40 +177,56 @@ export function ExpenseForm({
|
||||
}
|
||||
return field?.value
|
||||
}
|
||||
|
||||
const getSelectedRecurrenceRule = (field?: { value: string }) => {
|
||||
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,
|
||||
})),
|
||||
splitMode: expense.splitMode,
|
||||
saveDefaultSplittingOptions: false,
|
||||
isReimbursement: expense.isReimbursement,
|
||||
documents: expense.documents,
|
||||
notes: expense.notes ?? '',
|
||||
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,
|
||||
}
|
||||
: undefined,
|
||||
],
|
||||
@@ -207,13 +235,17 @@ export function ExpenseForm({
|
||||
saveDefaultSplittingOptions: false,
|
||||
documents: [],
|
||||
notes: '',
|
||||
recurrenceRule: RecurrenceRule.NONE,
|
||||
}
|
||||
: {
|
||||
title: searchParams.get('title') ?? '',
|
||||
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
|
||||
@@ -234,6 +266,7 @@ export function ExpenseForm({
|
||||
]
|
||||
: [],
|
||||
notes: '',
|
||||
recurrenceRule: RecurrenceRule.NONE,
|
||||
},
|
||||
})
|
||||
const [isCategoryLoading, setCategoryLoading] = useState(false)
|
||||
@@ -241,6 +274,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)
|
||||
}
|
||||
|
||||
@@ -250,7 +299,23 @@ export function ExpenseForm({
|
||||
>(new Set())
|
||||
|
||||
const sExpense = isIncome ? 'Income' : 'Expense'
|
||||
const sPaid = isIncome ? 'received' : 'paid'
|
||||
|
||||
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())
|
||||
@@ -294,9 +359,9 @@ export function ExpenseForm({
|
||||
if (!editedParticipants.includes(participant.participant)) {
|
||||
return {
|
||||
...participant,
|
||||
shares: String(
|
||||
Number(amountPerRemaining.toFixed(2)),
|
||||
) as unknown as number,
|
||||
shares: Number(
|
||||
amountPerRemaining.toFixed(groupCurrency.decimal_digits),
|
||||
),
|
||||
}
|
||||
}
|
||||
return participant
|
||||
@@ -310,6 +375,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)}>
|
||||
@@ -376,11 +506,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>
|
||||
@@ -433,28 +727,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"
|
||||
@@ -466,7 +738,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 }) => (
|
||||
@@ -495,6 +769,43 @@ export function ExpenseForm({
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="recurrenceRule"
|
||||
render={({ field }) => (
|
||||
<FormItem className="sm:order-5">
|
||||
<FormLabel>{t(`${sExpense}.recurrenceRule.label`)}</FormLabel>
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
form.setValue('recurrenceRule', value as RecurrenceRule)
|
||||
}}
|
||||
defaultValue={getSelectedRecurrenceRule(field)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="NONE" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="NONE">
|
||||
{t(`${sExpense}.recurrenceRule.none`)}
|
||||
</SelectItem>
|
||||
<SelectItem value="DAILY">
|
||||
{t(`${sExpense}.recurrenceRule.daily`)}
|
||||
</SelectItem>
|
||||
<SelectItem value="WEEKLY">
|
||||
{t(`${sExpense}.recurrenceRule.weekly`)}
|
||||
</SelectItem>
|
||||
<SelectItem value="MONTHLY">
|
||||
{t(`${sExpense}.recurrenceRule.monthly`)}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
{t(`${sExpense}.recurrenceRule.description`)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -516,7 +827,7 @@ export function ExpenseForm({
|
||||
participant: p.id,
|
||||
shares:
|
||||
paidFor.find((pfor) => pfor.participant === p.id)
|
||||
?.shares ?? ('1' as unknown as number),
|
||||
?.shares ?? 1,
|
||||
}))
|
||||
form.setValue('paidFor', newPaidFor, {
|
||||
shouldDirty: true,
|
||||
@@ -554,7 +865,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>
|
||||
@@ -575,7 +886,7 @@ export function ExpenseForm({
|
||||
...field.value,
|
||||
{
|
||||
participant: id,
|
||||
shares: '1' as unknown as number,
|
||||
shares: 1,
|
||||
},
|
||||
],
|
||||
options,
|
||||
@@ -592,108 +903,255 @@ export function ExpenseForm({
|
||||
</FormControl>
|
||||
<FormLabel className="text-sm font-normal flex-1">
|
||||
{name}
|
||||
{field.value?.some(
|
||||
({ participant }) => participant === id,
|
||||
) &&
|
||||
!form.watch('isReimbursement') && (
|
||||
<span className="text-muted-foreground ml-2">
|
||||
(
|
||||
{formatCurrency(
|
||||
groupCurrency,
|
||||
calculateShare(id, {
|
||||
amount: amountAsMinorUnits(
|
||||
Number(form.watch('amount')),
|
||||
groupCurrency,
|
||||
), // Convert to cents
|
||||
paidFor: field.value.map(
|
||||
({ participant, shares }) => ({
|
||||
participant: {
|
||||
id: participant,
|
||||
name: '',
|
||||
groupId: '',
|
||||
},
|
||||
shares:
|
||||
form.watch('splitMode') ===
|
||||
'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: '',
|
||||
}),
|
||||
),
|
||||
splitMode: form.watch('splitMode'),
|
||||
isReimbursement:
|
||||
form.watch('isReimbursement'),
|
||||
}),
|
||||
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>
|
||||
)
|
||||
}}
|
||||
|
||||
@@ -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'
|
||||
@@ -11,6 +12,7 @@ import Link from 'next/link'
|
||||
import { forwardRef, useEffect, useMemo, useState } from 'react'
|
||||
import { useInView } from 'react-intersection-observer'
|
||||
import { useDebounce } from 'use-debounce'
|
||||
import { useCurrentGroup } from '../current-group-context'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
@@ -56,12 +58,12 @@ function getGroupedExpensesByDate(expenses: ExpensesType) {
|
||||
}, {})
|
||||
}
|
||||
|
||||
export function ExpenseList({ groupId }: { groupId: string }) {
|
||||
const { data: groupData } = trpc.groups.get.useQuery({ groupId })
|
||||
export function ExpenseList() {
|
||||
const { groupId, group } = useCurrentGroup()
|
||||
const [searchText, setSearchText] = useState('')
|
||||
const [debouncedSearchText] = useDebounce(searchText, 300)
|
||||
|
||||
const participants = groupData?.group.participants
|
||||
const participants = group?.participants
|
||||
|
||||
useEffect(() => {
|
||||
if (!participants) return
|
||||
@@ -103,6 +105,7 @@ const ExpenseListForSearch = ({
|
||||
searchText: string
|
||||
}) => {
|
||||
const utils = trpc.useUtils()
|
||||
const { group } = useCurrentGroup()
|
||||
|
||||
useEffect(() => {
|
||||
// Until we use tRPC more widely and can invalidate the cache on expense
|
||||
@@ -124,11 +127,7 @@ const ExpenseListForSearch = ({
|
||||
const expenses = data?.pages.flatMap((page) => page.expenses)
|
||||
const hasMore = data?.pages.at(-1)?.hasMore ?? false
|
||||
|
||||
const { data: groupData, isLoading: groupIsLoading } =
|
||||
trpc.groups.get.useQuery({ groupId })
|
||||
|
||||
const isLoading =
|
||||
expensesAreLoading || !expenses || groupIsLoading || !groupData
|
||||
const isLoading = expensesAreLoading || !expenses || !group
|
||||
|
||||
useEffect(() => {
|
||||
if (inView && hasMore && !isLoading) fetchNextPage()
|
||||
@@ -172,8 +171,9 @@ const ExpenseListForSearch = ({
|
||||
<ExpenseCard
|
||||
key={expense.id}
|
||||
expense={expense}
|
||||
currency={groupData.group.currency}
|
||||
currency={getCurrencyFromGroup(group)}
|
||||
groupId={groupId}
|
||||
participantCount={group.participants.length}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
162
src/app/groups/[groupId]/expenses/export/csv/route.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
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'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
const splitModeLabel = {
|
||||
EVENLY: 'Evenly',
|
||||
BY_SHARES: 'Unevenly – By shares',
|
||||
BY_PERCENTAGE: 'Unevenly – By percentage',
|
||||
BY_AMOUNT: 'Unevenly – By amount',
|
||||
}
|
||||
|
||||
function formatDate(isoDateString: Date): string {
|
||||
const date = new Date(isoDateString)
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0') // Months are zero-based
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}` // YYYY-MM-DD format
|
||||
}
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
export async function GET(
|
||||
req: Request,
|
||||
{ params: { groupId } }: { params: { groupId: string } },
|
||||
) {
|
||||
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,
|
||||
splitMode: true,
|
||||
},
|
||||
},
|
||||
participants: { select: { id: true, name: true } },
|
||||
},
|
||||
})
|
||||
|
||||
if (!group) {
|
||||
return NextResponse.json({ error: 'Invalid group ID' }, { status: 404 })
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
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 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' },
|
||||
{ label: 'Description', value: 'title' },
|
||||
{ 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) => ({
|
||||
label: participant.name,
|
||||
value: participant.name,
|
||||
})),
|
||||
]
|
||||
|
||||
const currency = getCurrencyFromGroup(group)
|
||||
|
||||
const expenses = group.expenses.map((expense) => ({
|
||||
date: formatDate(expense.expenseDate),
|
||||
title: expense.title,
|
||||
categoryName: expense.category?.name || '',
|
||||
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(
|
||||
group.participants.map((participant) => {
|
||||
const { totalShares, participantShare } = expense.paidFor.reduce(
|
||||
(acc, { participantId, shares }) => {
|
||||
acc.totalShares += shares
|
||||
if (participantId === participant.id) {
|
||||
acc.participantShare = shares
|
||||
}
|
||||
return acc
|
||||
},
|
||||
{ totalShares: 0, participantShare: 0 },
|
||||
)
|
||||
|
||||
const isPaidByParticipant = expense.paidById === participant.id
|
||||
const participantAmountShare = +formatAmountAsDecimal(
|
||||
(expense.amount / totalShares) * participantShare,
|
||||
currency,
|
||||
)
|
||||
|
||||
return [
|
||||
participant.name,
|
||||
participantAmountShare * (isPaidByParticipant ? 1 : -1),
|
||||
]
|
||||
}),
|
||||
),
|
||||
}))
|
||||
|
||||
const json2csvParser = new Parser({ fields })
|
||||
const csv = json2csvParser.parse(expenses)
|
||||
|
||||
const date = new Date().toISOString().split('T')[0]
|
||||
const filename = `Spliit Export - ${group.name} - ${date}.csv`
|
||||
|
||||
// \uFEFF character is added at the beginning of the CSV content to ensure that it is interpreted as UTF-8 with BOM (Byte Order Mark), which helps some applications correctly interpret the encoding.
|
||||
return new NextResponse(`\uFEFF${csv}`, {
|
||||
headers: {
|
||||
'Content-Type': 'text/csv; charset=utf-8',
|
||||
'Content-Disposition': contentDisposition(filename),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -12,17 +12,24 @@ export async function GET(
|
||||
id: true,
|
||||
name: true,
|
||||
currency: true,
|
||||
currencyCode: true,
|
||||
expenses: {
|
||||
select: {
|
||||
createdAt: true,
|
||||
expenseDate: true,
|
||||
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,
|
||||
splitMode: true,
|
||||
recurrenceRule: true,
|
||||
},
|
||||
orderBy: [{ expenseDate: 'asc' }, { createdAt: 'asc' }],
|
||||
},
|
||||
participants: { select: { id: true, name: true } },
|
||||
},
|
||||
@@ -31,7 +38,7 @@ export async function GET(
|
||||
return NextResponse.json({ error: 'Invalid group ID' }, { status: 404 })
|
||||
|
||||
const date = new Date().toISOString().split('T')[0]
|
||||
const filename = `Spliit Export - ${group.name} - ${date}`
|
||||
const filename = `Spliit Export - ${date}`
|
||||
return NextResponse.json(group, {
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { ActiveUserModal } from '@/app/groups/[groupId]/expenses/active-user-modal'
|
||||
import { CreateFromReceiptButton } from '@/app/groups/[groupId]/expenses/create-from-receipt-button'
|
||||
import { ExpenseList } from '@/app/groups/[groupId]/expenses/expense-list'
|
||||
import ExportButton from '@/app/groups/[groupId]/export-button'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
@@ -11,10 +12,11 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Download, Plus } from 'lucide-react'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { Metadata } from 'next'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import Link from 'next/link'
|
||||
import { useCurrentGroup } from '../current-group-context'
|
||||
|
||||
export const revalidate = 3600
|
||||
|
||||
@@ -23,13 +25,12 @@ export const metadata: Metadata = {
|
||||
}
|
||||
|
||||
export default function GroupExpensesPageClient({
|
||||
groupId,
|
||||
enableReceiptExtract,
|
||||
}: {
|
||||
groupId: string
|
||||
enableReceiptExtract: boolean
|
||||
}) {
|
||||
const t = useTranslations('Expenses')
|
||||
const { groupId } = useCurrentGroup()
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -40,19 +41,8 @@ export default function GroupExpensesPageClient({
|
||||
<CardDescription>{t('description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardHeader className="p-4 sm:p-6 flex flex-row space-y-0 gap-2">
|
||||
<Button variant="secondary" size="icon" asChild>
|
||||
<Link
|
||||
prefetch={false}
|
||||
href={`/groups/${groupId}/expenses/export/json`}
|
||||
target="_blank"
|
||||
title={t('exportJson')}
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
{enableReceiptExtract && (
|
||||
<CreateFromReceiptButton groupId={groupId} />
|
||||
)}
|
||||
<ExportButton groupId={groupId} />
|
||||
{enableReceiptExtract && <CreateFromReceiptButton />}
|
||||
<Button asChild size="icon">
|
||||
<Link
|
||||
href={`/groups/${groupId}/expenses/create`}
|
||||
@@ -65,7 +55,7 @@ export default function GroupExpensesPageClient({
|
||||
</div>
|
||||
|
||||
<CardContent className="p-0 pt-2 pb-4 sm:pb-6 flex flex-col gap-4 relative">
|
||||
<ExpenseList groupId={groupId} />
|
||||
<ExpenseList />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -8,14 +8,9 @@ export const metadata: Metadata = {
|
||||
title: 'Expenses',
|
||||
}
|
||||
|
||||
export default async function GroupExpensesPage({
|
||||
params: { groupId },
|
||||
}: {
|
||||
params: { groupId: string }
|
||||
}) {
|
||||
export default async function GroupExpensesPage() {
|
||||
return (
|
||||
<GroupExpensesPageClient
|
||||
groupId={groupId}
|
||||
enableReceiptExtract={env.NEXT_PUBLIC_ENABLE_RECEIPT_EXTRACT}
|
||||
/>
|
||||
)
|
||||
|
||||
53
src/app/groups/[groupId]/export-button.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
'use client'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Download, FileDown, FileJson } from 'lucide-react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import Link from 'next/link'
|
||||
|
||||
export default function ExportButton({ groupId }: { groupId: string }) {
|
||||
const t = useTranslations('Expenses')
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button title={t('export')} variant="secondary" size="icon">
|
||||
<Download className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
prefetch={false}
|
||||
href={`/groups/${groupId}/expenses/export/json`}
|
||||
target="_blank"
|
||||
title={t('exportJson')}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FileJson className="w-4 h-4" />
|
||||
<p>{t('exportJson')}</p>
|
||||
</div>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
prefetch={false}
|
||||
href={`/groups/${groupId}/expenses/export/csv`}
|
||||
target="_blank"
|
||||
title={t('exportCsv')}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FileDown className="w-4 h-4" />
|
||||
<p>{t('exportCsv')}</p>
|
||||
</div>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -3,27 +3,27 @@
|
||||
import { GroupTabs } from '@/app/groups/[groupId]/group-tabs'
|
||||
import { ShareButton } from '@/app/groups/[groupId]/share-button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { trpc } from '@/trpc/client'
|
||||
import Link from 'next/link'
|
||||
import { useCurrentGroup } from './current-group-context'
|
||||
|
||||
export const GroupHeader = ({ groupId }: { groupId: string }) => {
|
||||
const { data, isLoading } = trpc.groups.get.useQuery({ groupId })
|
||||
export const GroupHeader = () => {
|
||||
const { isLoading, groupId, group } = useCurrentGroup()
|
||||
|
||||
return (
|
||||
<div className="flex flex-col justify-between gap-3">
|
||||
<h1 className="font-bold text-2xl">
|
||||
<Link href={`/groups/${groupId}`}>
|
||||
{isLoading || !data ? (
|
||||
{isLoading ? (
|
||||
<Skeleton className="mt-1.5 mb-1.5 h-5 w-32" />
|
||||
) : (
|
||||
<div className="flex">{data.group.name}</div>
|
||||
<div className="flex">{group.name}</div>
|
||||
)}
|
||||
</Link>
|
||||
</h1>
|
||||
|
||||
<div className="flex gap-2 justify-between">
|
||||
<GroupTabs groupId={groupId} />
|
||||
{data?.group && <ShareButton group={data.group} />}
|
||||
{group && <ShareButton group={group} />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -9,14 +9,14 @@ import {
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { trpc } from '@/trpc/client'
|
||||
import { Pencil } from 'lucide-react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import Link from 'next/link'
|
||||
import { useCurrentGroup } from '../current-group-context'
|
||||
|
||||
export default function GroupInformation({ groupId }: { groupId: string }) {
|
||||
const t = useTranslations('Information')
|
||||
const { data, isLoading } = trpc.groups.get.useQuery({ groupId })
|
||||
const { isLoading, group } = useCurrentGroup()
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -35,13 +35,13 @@ export default function GroupInformation({ groupId }: { groupId: string }) {
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="prose prose-sm sm:prose-base max-w-full whitespace-break-spaces">
|
||||
{isLoading || !data ? (
|
||||
{isLoading ? (
|
||||
<div className="py-1 flex flex-col gap-2">
|
||||
<Skeleton className="h-3 w-3/4" />
|
||||
<Skeleton className="h-3 w-1/2" />
|
||||
</div>
|
||||
) : data.group.information ? (
|
||||
<p className="text-foreground">{data.group.information}</p>
|
||||
) : group.information ? (
|
||||
<p className="text-foreground">{group.information}</p>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">{t('empty')}</p>
|
||||
)}
|
||||
|
||||
49
src/app/groups/[groupId]/layout.client.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
'use client'
|
||||
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { trpc } from '@/trpc/client'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { PropsWithChildren, useEffect } from 'react'
|
||||
import { CurrentGroupProvider } from './current-group-context'
|
||||
import { GroupHeader } from './group-header'
|
||||
import { SaveGroupLocally } from './save-recent-group'
|
||||
|
||||
export function GroupLayoutClient({
|
||||
groupId,
|
||||
children,
|
||||
}: PropsWithChildren<{ groupId: string }>) {
|
||||
const { data, isLoading } = trpc.groups.get.useQuery({ groupId })
|
||||
const t = useTranslations('Groups.NotFound')
|
||||
const { toast } = useToast()
|
||||
|
||||
useEffect(() => {
|
||||
if (data && !data.group) {
|
||||
toast({
|
||||
description: t('text'),
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
}, [data])
|
||||
|
||||
const props =
|
||||
isLoading || !data?.group
|
||||
? { isLoading: true as const, groupId, group: undefined }
|
||||
: { isLoading: false as const, groupId, group: data.group }
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<CurrentGroupProvider {...props}>
|
||||
<GroupHeader />
|
||||
{children}
|
||||
</CurrentGroupProvider>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<CurrentGroupProvider {...props}>
|
||||
<GroupHeader />
|
||||
{children}
|
||||
<SaveGroupLocally />
|
||||
</CurrentGroupProvider>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
import { cached } from '@/app/cached-functions'
|
||||
import { GroupHeader } from '@/app/groups/[groupId]/group-header'
|
||||
import { SaveGroupLocally } from '@/app/groups/[groupId]/save-recent-group'
|
||||
import { Metadata } from 'next'
|
||||
import { notFound } from 'next/navigation'
|
||||
import { PropsWithChildren } from 'react'
|
||||
import { GroupLayoutClient } from './layout.client'
|
||||
|
||||
type Props = {
|
||||
params: {
|
||||
@@ -24,20 +22,9 @@ export async function generateMetadata({
|
||||
}
|
||||
}
|
||||
|
||||
export default async function GroupLayout({
|
||||
export default function GroupLayout({
|
||||
children,
|
||||
params: { groupId },
|
||||
}: PropsWithChildren<Props>) {
|
||||
const group = await cached.getGroup(groupId)
|
||||
if (!group) notFound()
|
||||
|
||||
return (
|
||||
<>
|
||||
<GroupHeader groupId={groupId} />
|
||||
|
||||
{children}
|
||||
|
||||
<SaveGroupLocally group={{ id: group.id, name: group.name }} />
|
||||
</>
|
||||
)
|
||||
return <GroupLayoutClient groupId={groupId}>{children}</GroupLayoutClient>
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
'use client'
|
||||
import {
|
||||
RecentGroup,
|
||||
saveRecentGroup,
|
||||
} from '@/app/groups/recent-groups-helpers'
|
||||
import { saveRecentGroup } from '@/app/groups/recent-groups-helpers'
|
||||
import { useEffect } from 'react'
|
||||
import { useCurrentGroup } from './current-group-context'
|
||||
|
||||
type Props = {
|
||||
group: RecentGroup
|
||||
}
|
||||
export function SaveGroupLocally() {
|
||||
const { group } = useCurrentGroup()
|
||||
|
||||
export function SaveGroupLocally({ group }: Props) {
|
||||
useEffect(() => {
|
||||
saveRecentGroup(group)
|
||||
if (group) saveRecentGroup({ id: group.id, name: group.name })
|
||||
}, [group])
|
||||
|
||||
return null
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from '@/components/ui/card'
|
||||
import { useTranslations } from 'next-intl'
|
||||
|
||||
export function TotalsPageClient({ groupId }: { groupId: string }) {
|
||||
export function TotalsPageClient() {
|
||||
const t = useTranslations('Stats')
|
||||
|
||||
return (
|
||||
@@ -19,7 +19,7 @@ export function TotalsPageClient({ groupId }: { groupId: string }) {
|
||||
<CardDescription>{t('Totals.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col space-y-4">
|
||||
<Totals groupId={groupId} />
|
||||
<Totals />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
|
||||
@@ -5,10 +5,6 @@ export const metadata: Metadata = {
|
||||
title: 'Totals',
|
||||
}
|
||||
|
||||
export default async function TotalsPage({
|
||||
params: { groupId },
|
||||
}: {
|
||||
params: { groupId: string }
|
||||
}) {
|
||||
return <TotalsPageClient groupId={groupId} />
|
||||
export default async function TotalsPage() {
|
||||
return <TotalsPageClient />
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -4,17 +4,19 @@ 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'
|
||||
|
||||
export function Totals({ groupId }: { groupId: string }) {
|
||||
export function Totals() {
|
||||
const { groupId, group } = useCurrentGroup()
|
||||
const activeUser = useActiveUser(groupId)
|
||||
|
||||
const participantId =
|
||||
activeUser && activeUser !== 'None' ? activeUser : undefined
|
||||
const { data } = trpc.groups.stats.get.useQuery({ groupId, participantId })
|
||||
const { data: groupData } = trpc.groups.get.useQuery({ groupId })
|
||||
|
||||
if (!data || !groupData)
|
||||
if (!data || !group)
|
||||
return (
|
||||
<div className="flex flex-col gap-7">
|
||||
{[0, 1, 2].map((index) => (
|
||||
@@ -31,23 +33,24 @@ export function Totals({ groupId }: { groupId: string }) {
|
||||
totalParticipantShare,
|
||||
totalParticipantSpendings,
|
||||
} = data
|
||||
const { group } = groupData
|
||||
|
||||
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}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
'use server'
|
||||
import { getGroups } from '@/lib/api'
|
||||
|
||||
export async function getGroupsAction(groupIds: string[]) {
|
||||
'use server'
|
||||
return getGroups(groupIds)
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
'use server'
|
||||
|
||||
import { getGroup } from '@/lib/api'
|
||||
|
||||
export async function getGroupInfoAction(groupId: string) {
|
||||
'use server'
|
||||
return getGroup(groupId)
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import { getGroupInfoAction } from '@/app/groups/add-group-by-url-button-actions'
|
||||
import { saveRecentGroup } from '@/app/groups/recent-groups-helpers'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
@@ -8,6 +7,7 @@ import {
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import { useMediaQuery } from '@/lib/hooks'
|
||||
import { trpc } from '@/trpc/client'
|
||||
import { Loader2, Plus } from 'lucide-react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useState } from 'react'
|
||||
@@ -23,14 +23,12 @@ export function AddGroupByUrlButton({ reload }: Props) {
|
||||
const [error, setError] = useState(false)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [pending, setPending] = useState(false)
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="secondary">
|
||||
{/* <Plus className="w-4 h-4 mr-2" /> */}
|
||||
{t('button')}
|
||||
</Button>
|
||||
<Button variant="secondary">{t('button')}</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align={isDesktop ? 'end' : 'start'}
|
||||
@@ -47,15 +45,17 @@ export function AddGroupByUrlButton({ reload }: Props) {
|
||||
new RegExp(`${window.location.origin}/groups/([^/]+)`),
|
||||
) ?? []
|
||||
setPending(true)
|
||||
const group = groupId ? await getGroupInfoAction(groupId) : null
|
||||
setPending(false)
|
||||
if (!group) {
|
||||
setError(true)
|
||||
} else {
|
||||
const { group } = await utils.groups.get.fetch({
|
||||
groupId: groupId,
|
||||
})
|
||||
if (group) {
|
||||
saveRecentGroup({ id: group.id, name: group.name })
|
||||
reload()
|
||||
setUrl('')
|
||||
setOpen(false)
|
||||
} else {
|
||||
setError(true)
|
||||
setPending(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
'use client'
|
||||
import { RecentGroupsState } from '@/app/groups/recent-group-list'
|
||||
import {
|
||||
RecentGroup,
|
||||
archiveGroup,
|
||||
deleteRecentGroup,
|
||||
getArchivedGroups,
|
||||
getStarredGroups,
|
||||
saveRecentGroup,
|
||||
starGroup,
|
||||
unarchiveGroup,
|
||||
unstarGroup,
|
||||
@@ -19,46 +14,32 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { ToastAction } from '@/components/ui/toast'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { AppRouterOutput } from '@/trpc/routers/_app'
|
||||
import { StarFilledIcon } from '@radix-ui/react-icons'
|
||||
import { Calendar, MoreHorizontal, Star, Users } from 'lucide-react'
|
||||
import { useLocale, useTranslations } from 'next-intl'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { SetStateAction } from 'react'
|
||||
|
||||
export function RecentGroupListCard({
|
||||
group,
|
||||
state,
|
||||
setState,
|
||||
groupDetail,
|
||||
isStarred,
|
||||
isArchived,
|
||||
refreshGroupsFromStorage,
|
||||
}: {
|
||||
group: RecentGroup
|
||||
state: RecentGroupsState
|
||||
setState: (state: SetStateAction<RecentGroupsState>) => void
|
||||
groupDetail?: AppRouterOutput['groups']['list']['groups'][number]
|
||||
isStarred: boolean
|
||||
isArchived: boolean
|
||||
refreshGroupsFromStorage: () => void
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const locale = useLocale()
|
||||
const toast = useToast()
|
||||
const t = useTranslations('Groups')
|
||||
|
||||
const details =
|
||||
state.status === 'complete'
|
||||
? state.groupsDetails.find((d) => d.id === group.id)
|
||||
: null
|
||||
|
||||
if (state.status === 'pending') return null
|
||||
|
||||
const refreshGroupsFromStorage = () =>
|
||||
setState({
|
||||
...state,
|
||||
starredGroups: getStarredGroups(),
|
||||
archivedGroups: getArchivedGroups(),
|
||||
})
|
||||
|
||||
const isStarred = state.starredGroups.includes(group.id)
|
||||
const isArchived = state.archivedGroups.includes(group.id)
|
||||
|
||||
return (
|
||||
<li key={group.id}>
|
||||
<Button
|
||||
@@ -116,27 +97,11 @@ export function RecentGroupListCard({
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
deleteRecentGroup(group)
|
||||
setState({
|
||||
...state,
|
||||
groups: state.groups.filter((g) => g.id !== group.id),
|
||||
})
|
||||
refreshGroupsFromStorage()
|
||||
|
||||
toast.toast({
|
||||
title: t('RecentRemovedToast.title'),
|
||||
description: t('RecentRemovedToast.description'),
|
||||
action: (
|
||||
<ToastAction
|
||||
altText={t('RecentRemovedToast.undoAlt')}
|
||||
onClick={() => {
|
||||
saveRecentGroup(group)
|
||||
setState({
|
||||
...state,
|
||||
groups: state.groups,
|
||||
})
|
||||
}}
|
||||
>
|
||||
{t('RecentRemovedToast.undo')}
|
||||
</ToastAction>
|
||||
),
|
||||
})
|
||||
}}
|
||||
>
|
||||
@@ -161,18 +126,21 @@ export function RecentGroupListCard({
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground font-normal text-xs">
|
||||
{details ? (
|
||||
{groupDetail ? (
|
||||
<div className="w-full flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<Users className="w-3 h-3 inline mr-1" />
|
||||
<span>{details._count.participants}</span>
|
||||
<span>{groupDetail._count.participants}</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Calendar className="w-3 h-3 inline mx-1" />
|
||||
<span>
|
||||
{new Date(details.createdAt).toLocaleDateString(locale, {
|
||||
dateStyle: 'medium',
|
||||
})}
|
||||
{new Date(groupDetail.createdAt).toLocaleDateString(
|
||||
locale,
|
||||
{
|
||||
dateStyle: 'medium',
|
||||
},
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
'use client'
|
||||
import { getGroupsAction } from '@/app/groups/actions'
|
||||
import { AddGroupByUrlButton } from '@/app/groups/add-group-by-url-button'
|
||||
import {
|
||||
RecentGroups,
|
||||
@@ -9,10 +8,12 @@ import {
|
||||
} from '@/app/groups/recent-groups-helpers'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { getGroups } from '@/lib/api'
|
||||
import { trpc } from '@/trpc/client'
|
||||
import { AppRouterOutput } from '@/trpc/routers/_app'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import Link from 'next/link'
|
||||
import { PropsWithChildren, SetStateAction, useEffect, useState } from 'react'
|
||||
import { PropsWithChildren, useEffect, useState } from 'react'
|
||||
import { RecentGroupListCard } from './recent-group-list-card'
|
||||
|
||||
export type RecentGroupsState =
|
||||
@@ -31,16 +32,22 @@ export type RecentGroupsState =
|
||||
archivedGroups: string[]
|
||||
}
|
||||
|
||||
function sortGroups(
|
||||
state: RecentGroupsState & { status: 'complete' | 'partial' },
|
||||
) {
|
||||
function sortGroups({
|
||||
groups,
|
||||
starredGroups,
|
||||
archivedGroups,
|
||||
}: {
|
||||
groups: RecentGroups
|
||||
starredGroups: string[]
|
||||
archivedGroups: string[]
|
||||
}) {
|
||||
const starredGroupInfo = []
|
||||
const groupInfo = []
|
||||
const archivedGroupInfo = []
|
||||
for (const group of state.groups) {
|
||||
if (state.starredGroups.includes(group.id)) {
|
||||
for (const group of groups) {
|
||||
if (starredGroups.includes(group.id)) {
|
||||
starredGroupInfo.push(group)
|
||||
} else if (state.archivedGroups.includes(group.id)) {
|
||||
} else if (archivedGroups.includes(group.id)) {
|
||||
archivedGroupInfo.push(group)
|
||||
} else {
|
||||
groupInfo.push(group)
|
||||
@@ -54,7 +61,6 @@ function sortGroups(
|
||||
}
|
||||
|
||||
export function RecentGroupList() {
|
||||
const t = useTranslations('Groups')
|
||||
const [state, setState] = useState<RecentGroupsState>({ status: 'pending' })
|
||||
|
||||
function loadGroups() {
|
||||
@@ -67,24 +73,43 @@ export function RecentGroupList() {
|
||||
starredGroups,
|
||||
archivedGroups,
|
||||
})
|
||||
getGroupsAction(groupsInStorage.map((g) => g.id)).then((groupsDetails) => {
|
||||
setState({
|
||||
status: 'complete',
|
||||
groups: groupsInStorage,
|
||||
groupsDetails,
|
||||
starredGroups,
|
||||
archivedGroups,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadGroups()
|
||||
}, [])
|
||||
|
||||
if (state.status === 'pending') {
|
||||
if (state.status === 'pending') return null
|
||||
|
||||
return (
|
||||
<RecentGroupList_
|
||||
groups={state.groups}
|
||||
starredGroups={state.starredGroups}
|
||||
archivedGroups={state.archivedGroups}
|
||||
refreshGroupsFromStorage={() => loadGroups()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function RecentGroupList_({
|
||||
groups,
|
||||
starredGroups,
|
||||
archivedGroups,
|
||||
refreshGroupsFromStorage,
|
||||
}: {
|
||||
groups: RecentGroups
|
||||
starredGroups: string[]
|
||||
archivedGroups: string[]
|
||||
refreshGroupsFromStorage: () => void
|
||||
}) {
|
||||
const t = useTranslations('Groups')
|
||||
const { data, isLoading } = trpc.groups.list.useQuery({
|
||||
groupIds: groups.map((group) => group.id),
|
||||
})
|
||||
|
||||
if (isLoading || !data) {
|
||||
return (
|
||||
<GroupsPage reload={loadGroups}>
|
||||
<GroupsPage reload={refreshGroupsFromStorage}>
|
||||
<p>
|
||||
<Loader2 className="w-4 m-4 mr-2 inline animate-spin" />{' '}
|
||||
{t('loadingRecent')}
|
||||
@@ -93,9 +118,9 @@ export function RecentGroupList() {
|
||||
)
|
||||
}
|
||||
|
||||
if (state.groups.length === 0) {
|
||||
if (data.groups.length === 0) {
|
||||
return (
|
||||
<GroupsPage reload={loadGroups}>
|
||||
<GroupsPage reload={refreshGroupsFromStorage}>
|
||||
<div className="text-sm space-y-2">
|
||||
<p>{t('NoRecent.description')}</p>
|
||||
<p>
|
||||
@@ -109,17 +134,23 @@ export function RecentGroupList() {
|
||||
)
|
||||
}
|
||||
|
||||
const { starredGroupInfo, groupInfo, archivedGroupInfo } = sortGroups(state)
|
||||
const { starredGroupInfo, groupInfo, archivedGroupInfo } = sortGroups({
|
||||
groups,
|
||||
starredGroups,
|
||||
archivedGroups,
|
||||
})
|
||||
|
||||
return (
|
||||
<GroupsPage reload={loadGroups}>
|
||||
<GroupsPage reload={refreshGroupsFromStorage}>
|
||||
{starredGroupInfo.length > 0 && (
|
||||
<>
|
||||
<h2 className="mb-2">{t('starred')}</h2>
|
||||
<GroupList
|
||||
groups={starredGroupInfo}
|
||||
state={state}
|
||||
setState={setState}
|
||||
groupDetails={data.groups}
|
||||
archivedGroups={archivedGroups}
|
||||
starredGroups={starredGroups}
|
||||
refreshGroupsFromStorage={refreshGroupsFromStorage}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@@ -127,7 +158,13 @@ export function RecentGroupList() {
|
||||
{groupInfo.length > 0 && (
|
||||
<>
|
||||
<h2 className="mt-6 mb-2">{t('recent')}</h2>
|
||||
<GroupList groups={groupInfo} state={state} setState={setState} />
|
||||
<GroupList
|
||||
groups={groupInfo}
|
||||
groupDetails={data.groups}
|
||||
archivedGroups={archivedGroups}
|
||||
starredGroups={starredGroups}
|
||||
refreshGroupsFromStorage={refreshGroupsFromStorage}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -137,8 +174,10 @@ export function RecentGroupList() {
|
||||
<div className="opacity-50">
|
||||
<GroupList
|
||||
groups={archivedGroupInfo}
|
||||
state={state}
|
||||
setState={setState}
|
||||
groupDetails={data.groups}
|
||||
archivedGroups={archivedGroups}
|
||||
starredGroups={starredGroups}
|
||||
refreshGroupsFromStorage={refreshGroupsFromStorage}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
@@ -149,12 +188,16 @@ export function RecentGroupList() {
|
||||
|
||||
function GroupList({
|
||||
groups,
|
||||
state,
|
||||
setState,
|
||||
groupDetails,
|
||||
starredGroups,
|
||||
archivedGroups,
|
||||
refreshGroupsFromStorage,
|
||||
}: {
|
||||
groups: RecentGroups
|
||||
state: RecentGroupsState
|
||||
setState: (state: SetStateAction<RecentGroupsState>) => void
|
||||
groupDetails?: AppRouterOutput['groups']['list']['groups']
|
||||
starredGroups: string[]
|
||||
archivedGroups: string[]
|
||||
refreshGroupsFromStorage: () => void
|
||||
}) {
|
||||
return (
|
||||
<ul className="grid gap-2 sm:grid-cols-2">
|
||||
@@ -162,8 +205,12 @@ function GroupList({
|
||||
<RecentGroupListCard
|
||||
key={group.id}
|
||||
group={group}
|
||||
state={state}
|
||||
setState={setState}
|
||||
groupDetail={groupDetails?.find(
|
||||
(groupDetail) => groupDetail.id === group.id,
|
||||
)}
|
||||
isStarred={starredGroups.includes(group.id)}
|
||||
isArchived={archivedGroups.includes(group.id)}
|
||||
refreshGroupsFromStorage={refreshGroupsFromStorage}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -7,22 +7,48 @@ export default function manifest(): MetadataRoute.Manifest {
|
||||
description:
|
||||
'A minimalist web application to share expenses with friends and family. No ads, no account, no problem.',
|
||||
start_url: '/groups',
|
||||
id: '/groups',
|
||||
display: 'standalone',
|
||||
background_color: '#fff',
|
||||
theme_color: '#047857',
|
||||
icons: [
|
||||
{
|
||||
src: '/android-chrome-192x192.png',
|
||||
src: '/logo/48x48.png',
|
||||
sizes: '48x48',
|
||||
type: 'image/png',
|
||||
},
|
||||
{
|
||||
src: '/logo/64x64.png',
|
||||
sizes: '64x64',
|
||||
type: 'image/png',
|
||||
},
|
||||
{
|
||||
src: '/logo/128x128.png',
|
||||
sizes: '128x128',
|
||||
type: 'image/png',
|
||||
},
|
||||
{
|
||||
src: '/logo/144x144.png',
|
||||
sizes: '144x144',
|
||||
type: 'image/png',
|
||||
},
|
||||
{
|
||||
src: '/logo/192x192.png',
|
||||
sizes: '192x192',
|
||||
type: 'image/png',
|
||||
},
|
||||
{
|
||||
src: '/android-chrome-512x512.png',
|
||||
src: '/logo/256x256.png',
|
||||
sizes: '256x256',
|
||||
type: 'image/png',
|
||||
},
|
||||
{
|
||||
src: '/logo/512x512.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png',
|
||||
},
|
||||
{
|
||||
src: '/logo-512x512-maskable.png',
|
||||
src: '/logo/512x512-maskable.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png',
|
||||
purpose: 'maskable',
|
||||
|
||||
198
src/components/currency-selector.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
import { ChevronDown, Loader2 } from 'lucide-react'
|
||||
|
||||
import { Button, ButtonProps } from '@/components/ui/button'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
} from '@/components/ui/command'
|
||||
import { Drawer, DrawerContent, DrawerTrigger } from '@/components/ui/drawer'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import { Currency } from '@/lib/currency'
|
||||
import { useMediaQuery } from '@/lib/hooks'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { forwardRef, useEffect, useState } from 'react'
|
||||
|
||||
type Props = {
|
||||
currencies: Currency[]
|
||||
onValueChange: (currencyCode: Currency['code']) => void
|
||||
/** Currency code to be selected by default. Overwriting this value will update current selection, too. */
|
||||
defaultValue: Currency['code']
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
export function CurrencySelector({
|
||||
currencies,
|
||||
onValueChange,
|
||||
defaultValue,
|
||||
isLoading,
|
||||
}: Props) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [value, setValue] = useState<string>(defaultValue)
|
||||
const isDesktop = useMediaQuery('(min-width: 768px)')
|
||||
|
||||
// allow overwriting currently selected currency from outside
|
||||
useEffect(() => {
|
||||
setValue(defaultValue)
|
||||
onValueChange(defaultValue)
|
||||
}, [defaultValue])
|
||||
|
||||
const selectedCurrency =
|
||||
currencies.find((currency) => (currency.code ?? '') === value) ??
|
||||
currencies[0]
|
||||
|
||||
if (isDesktop) {
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<CurrencyButton
|
||||
currency={selectedCurrency}
|
||||
open={open}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" align="start">
|
||||
<CurrencyCommand
|
||||
currencies={currencies}
|
||||
onValueChange={(code) => {
|
||||
setValue(code)
|
||||
onValueChange(code)
|
||||
setOpen(false)
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={setOpen}>
|
||||
<DrawerTrigger asChild>
|
||||
<CurrencyButton
|
||||
currency={selectedCurrency}
|
||||
open={open}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent className="p-0">
|
||||
<CurrencyCommand
|
||||
currencies={currencies}
|
||||
onValueChange={(id) => {
|
||||
setValue(id)
|
||||
onValueChange(id)
|
||||
setOpen(false)
|
||||
}}
|
||||
/>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
function CurrencyCommand({
|
||||
currencies,
|
||||
onValueChange,
|
||||
}: {
|
||||
currencies: Currency[]
|
||||
onValueChange: (currencyId: Currency['code']) => void
|
||||
}) {
|
||||
const currencyGroup = (currency: Currency) => {
|
||||
switch (currency.code) {
|
||||
case 'USD':
|
||||
case 'EUR':
|
||||
case 'JPY':
|
||||
case 'GBP':
|
||||
case 'CNY':
|
||||
return 'common'
|
||||
default:
|
||||
if (currency.code === '') return 'custom'
|
||||
return 'other'
|
||||
}
|
||||
}
|
||||
const t = useTranslations('Currencies')
|
||||
const currenciesByGroup = currencies.reduce<Record<string, Currency[]>>(
|
||||
(acc, currency) => ({
|
||||
...acc,
|
||||
[currencyGroup(currency)]: (acc[currencyGroup(currency)] ?? []).concat([
|
||||
currency,
|
||||
]),
|
||||
}),
|
||||
{},
|
||||
)
|
||||
|
||||
return (
|
||||
<Command>
|
||||
<CommandInput placeholder={t('search')} className="text-base" />
|
||||
<CommandEmpty>{t('noCurrency')}</CommandEmpty>
|
||||
<div className="w-full max-h-[300px] overflow-y-auto">
|
||||
{Object.entries(currenciesByGroup).map(
|
||||
([group, groupCurrencies], index) => (
|
||||
<CommandGroup key={index} heading={t(`${group}.heading`)}>
|
||||
{groupCurrencies.map((currency) => (
|
||||
<CommandItem
|
||||
key={currency.code}
|
||||
value={`${currency.code} ${currency.name} ${currency.symbol}`}
|
||||
onSelect={(currentValue) => {
|
||||
onValueChange(currency.code)
|
||||
}}
|
||||
>
|
||||
<CurrencyLabel currency={currency} />
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</Command>
|
||||
)
|
||||
}
|
||||
|
||||
type CurrencyButtonProps = {
|
||||
currency: Currency
|
||||
open: boolean
|
||||
isLoading: boolean
|
||||
}
|
||||
const CurrencyButton = forwardRef<HTMLButtonElement, CurrencyButtonProps>(
|
||||
(
|
||||
{ currency, open, isLoading, ...props }: ButtonProps & CurrencyButtonProps,
|
||||
ref,
|
||||
) => {
|
||||
const iconClassName = 'ml-2 h-4 w-4 shrink-0 opacity-50'
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="flex w-full justify-between"
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
<CurrencyLabel currency={currency} />
|
||||
{isLoading ? (
|
||||
<Loader2 className={`animate-spin ${iconClassName}`} />
|
||||
) : (
|
||||
<ChevronDown className={iconClassName} />
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
},
|
||||
)
|
||||
CurrencyButton.displayName = 'CurrencyButton'
|
||||
|
||||
function CurrencyLabel({ currency }: { currency: Currency }) {
|
||||
const flagUrl = `https://flagcdn.com/h24/${
|
||||
currency?.code.length ? currency.code.slice(0, 2).toLowerCase() : 'un'
|
||||
}.png`
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<img src={flagUrl} className="w-4" alt="" />
|
||||
{currency.name}
|
||||
{currency.code ? ` (${currency.code})` : ''}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -30,14 +30,17 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Locale } from '@/i18n'
|
||||
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),
|
||||
@@ -62,12 +66,13 @@ export function GroupForm({
|
||||
name: group.name,
|
||||
information: group.information ?? '',
|
||||
currency: group.currency,
|
||||
currencyCode: group.currencyCode,
|
||||
participants: group.participants,
|
||||
}
|
||||
: {
|
||||
name: '',
|
||||
information: '',
|
||||
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 +150,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
|
||||
|
||||
@@ -7,24 +7,26 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { locales } from '@/i18n'
|
||||
import { Locale, localeLabels } from '@/i18n'
|
||||
import { setUserLocale } from '@/lib/locale'
|
||||
import { useLocale, useTranslations } from 'next-intl'
|
||||
import { useLocale } from 'next-intl'
|
||||
|
||||
export function LocaleSwitcher() {
|
||||
const t = useTranslations('Locale')
|
||||
const locale = useLocale()
|
||||
const locale = useLocale() as Locale
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="-my-3 text-primary">
|
||||
<span>{t(locale)}</span>
|
||||
<span>{localeLabels[locale]}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
{locales.map((locale) => (
|
||||
<DropdownMenuItem key={locale} onClick={() => setUserLocale(locale)}>
|
||||
{t(locale)}
|
||||
{Object.entries(localeLabels).map(([locale, label]) => (
|
||||
<DropdownMenuItem
|
||||
key={locale}
|
||||
onClick={() => setUserLocale(locale as Locale)}
|
||||
>
|
||||
{label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
|
||||
@@ -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
|
||||
|
||||
52
src/i18n.ts
@@ -1,27 +1,51 @@
|
||||
import deepmerge from 'deepmerge'
|
||||
import { getRequestConfig } from 'next-intl/server'
|
||||
import { getUserLocale } from './lib/locale'
|
||||
|
||||
export const locales = [
|
||||
'en-US',
|
||||
'fi',
|
||||
'fr-FR',
|
||||
'es',
|
||||
'de-DE',
|
||||
'zh-CN',
|
||||
'ru-RU',
|
||||
'it-IT',
|
||||
'ua-UA',
|
||||
'ro',
|
||||
] as const
|
||||
export type Locale = (typeof locales)[number]
|
||||
export const localeLabels = {
|
||||
'en-US': 'English',
|
||||
fi: 'Suomi',
|
||||
'fr-FR': 'Français',
|
||||
es: 'Español',
|
||||
'de-DE': 'Deutsch',
|
||||
'zh-CN': '简体中文',
|
||||
'zh-TW': '正體中文',
|
||||
'ja-JP': '日本語',
|
||||
'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',
|
||||
ca: 'Català',
|
||||
'cs-CZ': 'Česky',
|
||||
} as const
|
||||
|
||||
export const locales: (keyof typeof localeLabels)[] = Object.keys(
|
||||
localeLabels,
|
||||
) as any
|
||||
export type Locale = keyof typeof localeLabels
|
||||
export type Locales = ReadonlyArray<Locale>
|
||||
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,
|
||||
}
|
||||
})
|
||||
|
||||
287
src/lib/api.ts
@@ -1,6 +1,11 @@
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { ExpenseFormValues, GroupFormValues } from '@/lib/schemas'
|
||||
import { ActivityType, Expense } 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,6 +56,14 @@ export async function createExpense(
|
||||
data: expenseFormValues.title,
|
||||
})
|
||||
|
||||
const isCreateRecurrence =
|
||||
expenseFormValues.recurrenceRule !== RecurrenceRule.NONE
|
||||
const recurringExpenseLinkPayload = createPayloadForNewRecurringExpenseLink(
|
||||
expenseFormValues.recurrenceRule as RecurrenceRule,
|
||||
expenseFormValues.expenseDate,
|
||||
groupId,
|
||||
)
|
||||
|
||||
return prisma.expense.create({
|
||||
data: {
|
||||
id: expenseId,
|
||||
@@ -57,9 +71,20 @@ 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,
|
||||
recurrenceRule: expenseFormValues.recurrenceRule,
|
||||
recurringExpenseLink: {
|
||||
...(isCreateRecurrence
|
||||
? {
|
||||
create: recurringExpenseLinkPayload,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
paidFor: {
|
||||
createMany: {
|
||||
data: expenseFormValues.paidFor.map((paidFor) => ({
|
||||
@@ -152,15 +177,46 @@ export async function updateExpense(
|
||||
data: expenseFormValues.title,
|
||||
})
|
||||
|
||||
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 &&
|
||||
// Update the exisiting RecurrenceExpenseLink only if it has not been acted upon yet
|
||||
existingExpense.recurringExpenseLink?.nextExpenseCreatedAt === null
|
||||
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,
|
||||
)
|
||||
|
||||
const updatedRecurrenceExpenseLinkNextExpenseDate = calculateNextDate(
|
||||
expenseFormValues.recurrenceRule as RecurrenceRule,
|
||||
existingExpense.expenseDate,
|
||||
)
|
||||
|
||||
return prisma.expense.update({
|
||||
where: { id: expenseId },
|
||||
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,
|
||||
splitMode: expenseFormValues.splitMode,
|
||||
recurrenceRule: expenseFormValues.recurrenceRule,
|
||||
paidFor: {
|
||||
create: expenseFormValues.paidFor
|
||||
.filter(
|
||||
@@ -191,6 +247,21 @@ export async function updateExpense(
|
||||
),
|
||||
),
|
||||
},
|
||||
recurringExpenseLink: {
|
||||
...(isCreateRecurrenceExpenseLink
|
||||
? {
|
||||
create: newRecurringExpenseLink,
|
||||
}
|
||||
: {}),
|
||||
...(isUpdateRecurrenceExpenseLink
|
||||
? {
|
||||
update: {
|
||||
nextExpenseDate: updatedRecurrenceExpenseLinkNextExpenseDate,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
delete: isDeleteRecurrenceExpenseLink,
|
||||
},
|
||||
isReimbursement: expenseFormValues.isReimbursement,
|
||||
documents: {
|
||||
connectOrCreate: expenseFormValues.documents.map((doc) => ({
|
||||
@@ -229,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),
|
||||
@@ -269,6 +341,8 @@ export async function getGroupExpenses(
|
||||
groupId: string,
|
||||
options?: { offset?: number; length?: number; filter?: string },
|
||||
) {
|
||||
await createRecurringExpenses()
|
||||
|
||||
return prisma.expense.findMany({
|
||||
select: {
|
||||
amount: true,
|
||||
@@ -285,7 +359,9 @@ export async function getGroupExpenses(
|
||||
},
|
||||
},
|
||||
splitMode: true,
|
||||
recurrenceRule: true,
|
||||
title: true,
|
||||
_count: { select: { documents: true } },
|
||||
},
|
||||
where: {
|
||||
groupId,
|
||||
@@ -306,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 },
|
||||
include: {
|
||||
paidBy: true,
|
||||
paidFor: true,
|
||||
category: true,
|
||||
documents: true,
|
||||
recurringExpenseLink: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -354,3 +436,204 @@ export async function logActivity(
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
for (const recurringExpenseLink of recurringExpenseLinksWithExpensesToCreate) {
|
||||
let newExpenseDate = recurringExpenseLink.nextExpenseDate
|
||||
|
||||
let currentExpenseRecord = recurringExpenseLink.currentFrameExpense
|
||||
let currentReccuringExpenseLinkId = recurringExpenseLink.id
|
||||
|
||||
while (newExpenseDate < utcDateFromLocal) {
|
||||
const newExpenseId = randomId()
|
||||
const newRecurringExpenseLinkId = randomId()
|
||||
|
||||
const newRecurringExpenseNextExpenseDate = calculateNextDate(
|
||||
currentExpenseRecord.recurrenceRule as RecurrenceRule,
|
||||
newExpenseDate,
|
||||
)
|
||||
|
||||
const {
|
||||
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,
|
||||
})),
|
||||
},
|
||||
},
|
||||
documents: {
|
||||
connect: currentExpenseRecord.documents.map(
|
||||
(documentRecord) => ({
|
||||
id: documentRecord.id,
|
||||
}),
|
||||
),
|
||||
},
|
||||
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,
|
||||
},
|
||||
})
|
||||
|
||||
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
|
||||
|
||||
// Set the values for the next iteration of the for-loop in case multiple recurring Expenses need to be created
|
||||
currentExpenseRecord = newExpense
|
||||
currentReccuringExpenseLinkId = newRecurringExpenseLinkId
|
||||
newExpenseDate = newRecurringExpenseNextExpenseDate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createPayloadForNewRecurringExpenseLink(
|
||||
recurrenceRule: RecurrenceRule,
|
||||
priorDateToNextRecurrence: Date,
|
||||
groupId: String,
|
||||
): RecurringExpenseLink {
|
||||
const nextExpenseDate = calculateNextDate(
|
||||
recurrenceRule,
|
||||
priorDateToNextRecurrence,
|
||||
)
|
||||
|
||||
const recurringExpenseLinkId = randomId()
|
||||
const recurringExpenseLinkPayload = {
|
||||
id: recurringExpenseLinkId,
|
||||
groupId: groupId,
|
||||
nextExpenseDate: nextExpenseDate,
|
||||
}
|
||||
|
||||
return recurringExpenseLinkPayload as RecurringExpenseLink
|
||||
}
|
||||
|
||||
// TODO: Modify this function to use a more comprehensive recurrence Rule library like rrule (https://github.com/jkbrzt/rrule)
|
||||
//
|
||||
// Current limitations:
|
||||
// - If a date is intended to be repeated monthly on the 29th, 30th or 31st, it will change to repeating on the smallest
|
||||
// date that the reccurence has encountered. Ex. If a recurrence is created for Jan 31st on 2025, the recurring expense
|
||||
// will be created for Feb 28th, March 28, etc. until it is cancelled or fixed
|
||||
function calculateNextDate(
|
||||
recurrenceRule: RecurrenceRule,
|
||||
priorDateToNextRecurrence: Date,
|
||||
): Date {
|
||||
const nextDate = new Date(priorDateToNextRecurrence)
|
||||
switch (recurrenceRule) {
|
||||
case RecurrenceRule.DAILY:
|
||||
nextDate.setUTCDate(nextDate.getUTCDate() + 1)
|
||||
break
|
||||
case RecurrenceRule.WEEKLY:
|
||||
nextDate.setUTCDate(nextDate.getUTCDate() + 7)
|
||||
break
|
||||
case RecurrenceRule.MONTHLY:
|
||||
const nextYear = nextDate.getUTCFullYear()
|
||||
const nextMonth = nextDate.getUTCMonth() + 1
|
||||
let nextDay = nextDate.getUTCDate()
|
||||
|
||||
// Reduce the next day until it is within the direct next month
|
||||
while (!isDateInNextMonth(nextYear, nextMonth, nextDay)) {
|
||||
nextDay -= 1
|
||||
}
|
||||
nextDate.setUTCMonth(nextMonth, nextDay)
|
||||
break
|
||||
}
|
||||
|
||||
return nextDate
|
||||
}
|
||||
|
||||
function isDateInNextMonth(
|
||||
utcYear: number,
|
||||
utcMonth: number,
|
||||
utcDate: number,
|
||||
): Boolean {
|
||||
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) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
4898
src/lib/currency-data.json
Normal file
89
src/lib/currency.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { Locale } from '@/i18n'
|
||||
import currencyList from './currency-data.json'
|
||||
|
||||
export type Currency = {
|
||||
name: string
|
||||
symbol_native: string
|
||||
symbol: string
|
||||
code: string
|
||||
name_plural: string
|
||||
rounding: number
|
||||
decimal_digits: number
|
||||
}
|
||||
|
||||
export const supportedCurrencyCodes = [
|
||||
'USD',
|
||||
'EUR',
|
||||
'JPY',
|
||||
'BGN',
|
||||
'CZK',
|
||||
'DKK',
|
||||
'GBP',
|
||||
'HUF',
|
||||
'PLN',
|
||||
'RON',
|
||||
'SEK',
|
||||
'CHF',
|
||||
'ISK',
|
||||
'NOK',
|
||||
'TRY',
|
||||
'AUD',
|
||||
'BRL',
|
||||
'CAD',
|
||||
'CNY',
|
||||
'HKD',
|
||||
'IDR',
|
||||
'ILS',
|
||||
'INR',
|
||||
'KRW',
|
||||
'MXN',
|
||||
'NZD',
|
||||
'PHP',
|
||||
'SGD',
|
||||
'THB',
|
||||
'ZAR',
|
||||
] as const
|
||||
export type supportedCurrencyCodeType = (typeof supportedCurrencyCodes)[number]
|
||||
|
||||
export function defaultCurrencyList(
|
||||
locale: Locale = 'en-US',
|
||||
customChoice: string | null = null,
|
||||
) {
|
||||
const currencies = customChoice
|
||||
? [
|
||||
{
|
||||
name: customChoice,
|
||||
symbol_native: '',
|
||||
symbol: '',
|
||||
code: '',
|
||||
name_plural: customChoice,
|
||||
rounding: 0,
|
||||
decimal_digits: 2,
|
||||
},
|
||||
]
|
||||
: []
|
||||
const allCurrencies = currencyList[locale]
|
||||
return currencies.concat(Object.values(allCurrencies))
|
||||
}
|
||||
|
||||
export function getCurrency(
|
||||
currencyCode: string | undefined | null,
|
||||
locale: Locale = 'en-US',
|
||||
customChoice = 'Custom',
|
||||
): Currency {
|
||||
const defaultCurrency = {
|
||||
name: customChoice,
|
||||
symbol_native: '',
|
||||
symbol: '',
|
||||
code: '',
|
||||
name_plural: customChoice,
|
||||
rounding: 0,
|
||||
decimal_digits: 2,
|
||||
}
|
||||
if (!currencyCode || currencyCode === '') return defaultCurrency
|
||||
const currencyListInLocale = currencyList[locale] ?? currencyList['en-US']
|
||||
return (
|
||||
currencyListInLocale[currencyCode as supportedCurrencyCodeType] ??
|
||||
defaultCurrency
|
||||
)
|
||||
}
|
||||
@@ -21,6 +21,7 @@ const envSchema = z
|
||||
interpretEnvVarAsBool,
|
||||
z.boolean().default(false),
|
||||
),
|
||||
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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SplitMode } from '@prisma/client'
|
||||
import { RecurrenceRule, SplitMode } from '@prisma/client'
|
||||
import * as z from 'zod'
|
||||
|
||||
export const groupFormSchema = z
|
||||
@@ -6,6 +6,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 +32,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 +61,34 @@ export const expenseFormSchema = z
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'invalidNumber',
|
||||
})
|
||||
return Math.round(valueAsNumber * 100)
|
||||
return valueAsNumber
|
||||
}),
|
||||
],
|
||||
{ required_error: 'amountRequired' },
|
||||
)
|
||||
.refine((amount) => amount != 1, 'amountNotZero')
|
||||
.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 +99,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,20 +134,26 @@ export const expenseFormSchema = z
|
||||
)
|
||||
.default([]),
|
||||
notes: z.string().optional(),
|
||||
recurrenceRule: z
|
||||
.enum<RecurrenceRule, [RecurrenceRule, ...RecurrenceRule[]]>(
|
||||
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 sum = expense.paidFor.reduce(
|
||||
// Total hack, but multiplying by 1000 avoids floating point rounding issues
|
||||
// The ideal solution is using the group's currency decimal digits to determine the multiplier, but I can't seem to access that here
|
||||
(sum, { shares }) => sum + Math.round(Number(shares) * 1000),
|
||||
0,
|
||||
)
|
||||
if (sum !== Math.round(expense.amount * 1000)) {
|
||||
const detail =
|
||||
sum < expense.amount
|
||||
? `${((expense.amount - sum) / 100).toFixed(2)} missing`
|
||||
@@ -132,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
|
||||
@@ -147,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>
|
||||
|
||||
|
||||
@@ -23,48 +23,56 @@ export function getTotalActiveUserPaidFor(
|
||||
)
|
||||
}
|
||||
|
||||
type Expense = NonNullable<Awaited<ReturnType<typeof getGroupExpenses>>>[number]
|
||||
|
||||
export function calculateShare(
|
||||
participantId: string | null,
|
||||
expense: Pick<
|
||||
Expense,
|
||||
'amount' | 'paidFor' | 'splitMode' | 'isReimbursement'
|
||||
>,
|
||||
): number {
|
||||
if (expense.isReimbursement) return 0
|
||||
|
||||
const paidFors = expense.paidFor
|
||||
const userPaidFor = paidFors.find(
|
||||
(paidFor) => paidFor.participant.id === participantId,
|
||||
)
|
||||
|
||||
if (!userPaidFor) return 0
|
||||
|
||||
const shares = Number(userPaidFor.shares)
|
||||
|
||||
switch (expense.splitMode) {
|
||||
case 'EVENLY':
|
||||
// Divide the total expense evenly among all participants
|
||||
return expense.amount / paidFors.length
|
||||
case 'BY_AMOUNT':
|
||||
// Directly add the user's share if the split mode is BY_AMOUNT
|
||||
return shares
|
||||
case 'BY_PERCENTAGE':
|
||||
// Calculate the user's share based on their percentage of the total expense
|
||||
return (expense.amount * shares) / 10000 // Assuming shares are out of 10000 for percentage
|
||||
case 'BY_SHARES':
|
||||
// Calculate the user's share based on their shares relative to the total shares
|
||||
const totalShares = paidFors.reduce(
|
||||
(sum, paidFor) => sum + Number(paidFor.shares),
|
||||
0,
|
||||
)
|
||||
return (expense.amount * shares) / totalShares
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
export function getTotalActiveUserShare(
|
||||
activeUserId: string | null,
|
||||
expenses: NonNullable<Awaited<ReturnType<typeof getGroupExpenses>>>,
|
||||
): number {
|
||||
let total = 0
|
||||
|
||||
expenses.forEach((expense) => {
|
||||
if (expense.isReimbursement) return
|
||||
|
||||
const paidFors = expense.paidFor
|
||||
const userPaidFor = paidFors.find(
|
||||
(paidFor) => paidFor.participant.id === activeUserId,
|
||||
)
|
||||
|
||||
if (!userPaidFor) {
|
||||
// If the active user is not involved in the expense, skip it
|
||||
return
|
||||
}
|
||||
|
||||
switch (expense.splitMode) {
|
||||
case 'EVENLY':
|
||||
// Divide the total expense evenly among all participants
|
||||
total += expense.amount / paidFors.length
|
||||
break
|
||||
case 'BY_AMOUNT':
|
||||
// Directly add the user's share if the split mode is BY_AMOUNT
|
||||
total += userPaidFor.shares
|
||||
break
|
||||
case 'BY_PERCENTAGE':
|
||||
// Calculate the user's share based on their percentage of the total expense
|
||||
total += (expense.amount * userPaidFor.shares) / 10000 // Assuming shares are out of 10000 for percentage
|
||||
break
|
||||
case 'BY_SHARES':
|
||||
// Calculate the user's share based on their shares relative to the total shares
|
||||
const totalShares = paidFors.reduce(
|
||||
(sum, paidFor) => sum + paidFor.shares,
|
||||
0,
|
||||
)
|
||||
total += (expense.amount * userPaidFor.shares) / totalShares
|
||||
break
|
||||
}
|
||||
})
|
||||
const total = expenses.reduce(
|
||||
(sum, expense) => sum + calculateShare(activeUserId, expense),
|
||||
0,
|
||||
)
|
||||
|
||||
return parseFloat(total.toFixed(2))
|
||||
}
|
||||
|
||||
@@ -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}`,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -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))
|
||||
@@ -20,7 +21,6 @@ export function formatDate(
|
||||
) {
|
||||
return date.toLocaleString(locale, {
|
||||
...options,
|
||||
timeZone: 'UTC',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -34,20 +34,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) {
|
||||
|
||||
40
src/scripts/generateCurrencyData.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
// @ts-nocheck
|
||||
import { Locale, locales } from '@/i18n'
|
||||
import {
|
||||
Currency,
|
||||
supportedCurrencyCodeType,
|
||||
supportedCurrencyCodes,
|
||||
} from '@/lib/currency'
|
||||
import CurrencyList from 'currency-list'
|
||||
|
||||
import fs from 'node:fs'
|
||||
|
||||
const currencyList = locales.reduce((curList, locale) => {
|
||||
const currencyData = supportedCurrencyCodes.reduce(
|
||||
(curData, currencyCode) => {
|
||||
try {
|
||||
return {
|
||||
...curData,
|
||||
[currencyCode]: CurrencyList.get(
|
||||
currencyCode,
|
||||
locale.replaceAll('-', '_'),
|
||||
),
|
||||
}
|
||||
} catch {
|
||||
// For currency translations which are not found in the library (e.g. ua), use English.
|
||||
return {
|
||||
...curData,
|
||||
[currencyCode]: CurrencyList.get(currencyCode, 'en_US'),
|
||||
}
|
||||
}
|
||||
},
|
||||
{},
|
||||
)
|
||||
return { ...curList, [locale]: currencyData }
|
||||
}, {}) as {
|
||||
[K in Locale]: {
|
||||
[K in supportedCurrencyCodeType]: Currency
|
||||
}
|
||||
}
|
||||
|
||||
fs.writeFileSync('src/lib/currency-data.json', JSON.stringify(currencyList))
|
||||