mirror of
https://github.com/spliit-app/spliit.git
synced 2026-02-14 11:36:13 +01:00
Compare commits
8 Commits
414-fix-de
...
add-e2e-te
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d58ff9946 | ||
|
|
e349a50600 | ||
|
|
5e81dd9deb | ||
|
|
1a4c5ee3e1 | ||
|
|
05a25f7e4f | ||
|
|
378a369c8f | ||
|
|
a042ba0ce6 | ||
|
|
9d375bb6be |
7
.claude/commands/commit.md
Normal file
7
.claude/commands/commit.md
Normal file
@@ -0,0 +1,7 @@
|
||||
Commit the changes to the repository:
|
||||
|
||||
1. Change the current directory to the root of the repository.
|
||||
2. If currently on the main branch, create a new feature branch.
|
||||
3. Add all the changes to the staging area, and make a commit.
|
||||
4. Push the changes to the remote repository.
|
||||
5. Create a pull request to the main branch, or update the existing one.
|
||||
8
.claude/commands/main.md
Normal file
8
.claude/commands/main.md
Normal file
@@ -0,0 +1,8 @@
|
||||
Go back to the main branch and pull the latest changes.
|
||||
|
||||
1. Check if there are any changes on the current branch that are not committed.
|
||||
2. If so, ask the user if they want to commit the changes.
|
||||
3. If they don't, stash the changes.
|
||||
4. Go on the main branch and pull the latest changes.
|
||||
5. Pop the changes from the stash.
|
||||
6. If there are any conflicts, resolve them.
|
||||
18
.claude/settings.json
Normal file
18
.claude/settings.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/claude-code-settings.json",
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(ls:*)",
|
||||
"Bash(grep:*)",
|
||||
"Bash(find:*)",
|
||||
"Bash(mkdir:*)",
|
||||
"Bash(git:*)",
|
||||
"Bash(gh:*)",
|
||||
"Bash(cd:*)",
|
||||
"Bash(yarn:*)",
|
||||
"Bash(npm:*)",
|
||||
"WebFetch(domain:docs.anthropic.com)"
|
||||
],
|
||||
"deny": []
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
POSTGRES_PRISMA_URL=postgresql://postgres:1234@localhost
|
||||
POSTGRES_URL_NON_POOLING=postgresql://postgres:1234@localhost
|
||||
|
||||
NEXT_PUBLIC_DEFAULT_CURRENCY_CODE=""
|
||||
NEXT_PUBLIC_DEFAULT_CURRENCY_SYMBOL=""
|
||||
45
.github/workflows/cd.yml
vendored
45
.github/workflows/cd.yml
vendored
@@ -1,45 +0,0 @@
|
||||
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
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -25,6 +25,10 @@ npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# crush
|
||||
.crush/
|
||||
test-results/
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
*.env
|
||||
|
||||
30
.vscode/settings.json
vendored
Normal file
30
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"workbench.colorCustomizations": {
|
||||
"statusBarItem.warningBackground": "#681DD7",
|
||||
"statusBarItem.warningForeground": "#ffffff",
|
||||
"statusBarItem.warningHoverBackground": "#681DD7",
|
||||
"statusBarItem.warningHoverForeground": "#ffffff90",
|
||||
"statusBarItem.remoteBackground": "#681DD7",
|
||||
"statusBarItem.remoteForeground": "#ffffff",
|
||||
"statusBarItem.remoteHoverBackground": "#681DD7",
|
||||
"statusBarItem.remoteHoverForeground": "#ffffff90",
|
||||
"focusBorder": "#681DD799",
|
||||
"progressBar.background": "#681DD7",
|
||||
"textLink.foreground": "#a85dff",
|
||||
"textLink.activeForeground": "#b56aff",
|
||||
"selection.background": "#5b10ca",
|
||||
"activityBarBadge.background": "#681DD7",
|
||||
"activityBarBadge.foreground": "#ffffff",
|
||||
"activityBar.activeBorder": "#681DD7",
|
||||
"list.highlightForeground": "#681dd7",
|
||||
"list.focusAndSelectionOutline": "#681DD799",
|
||||
"button.background": "#681DD7",
|
||||
"button.foreground": "#ffffff",
|
||||
"button.hoverBackground": "#752ae4",
|
||||
"tab.activeBorderTop": "#752ae4",
|
||||
"pickerGroup.foreground": "#752ae4",
|
||||
"list.activeSelectionBackground": "#681DD74d",
|
||||
"panelTitle.activeBorder": "#752ae4"
|
||||
},
|
||||
"window.title": "spliit--add-e2e-tests"
|
||||
}
|
||||
44
CLAUDE.md
Normal file
44
CLAUDE.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# CLAUDE.md
|
||||
|
||||
## Build, Lint, and Test Commands
|
||||
- **Build the project**: `npm run build`
|
||||
- **Start the project**: `npm run start`
|
||||
- **Run the project in development**: `npm run dev`
|
||||
- **Lint the project**: `npm run lint`
|
||||
- **Check types**: `npm run check-types`
|
||||
- **Format code**: `npm run prettier`
|
||||
- **Test the project**: `npm run test`
|
||||
- **Run a single test**: Use Jest's `-t` option, e.g., `npm run test -- -t 'test name'`
|
||||
|
||||
## Code Style Guidelines
|
||||
|
||||
### Import Conventions
|
||||
- Use `import` statements for importing modules.
|
||||
- Organize imports using **prettier-plugin-organize-imports**.
|
||||
- Import globals from libraries before local modules.
|
||||
|
||||
### Formatting
|
||||
- Use **Prettier** for code formatting.
|
||||
- Adhere to a line width of 80 characters where possible.
|
||||
- Use 2 spaces for indentation.
|
||||
|
||||
### Types
|
||||
- Utilize TypeScript for static typing throughout the codebase.
|
||||
- Define interfaces and types for complex objects.
|
||||
|
||||
### Naming Conventions
|
||||
- Use camelCase for variable and function names.
|
||||
- Use PascalCase for component and type/interface names.
|
||||
|
||||
### Error Handling
|
||||
- Use `try...catch` blocks for async functions.
|
||||
- Handle errors gracefully and log them where required.
|
||||
|
||||
### Miscellaneous
|
||||
- Ensure all new components are functional components.
|
||||
- Prefer arrow functions for component definition.
|
||||
- Use hooks like `useEffect` and `useState` for managing component state.
|
||||
|
||||
---
|
||||
|
||||
**Note**: Please follow these guidelines to maintain consistency and quality within the codebase.
|
||||
20
README.md
20
README.md
@@ -35,24 +35,13 @@ 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!
|
||||
Join the discussion in [the Spliit Discord server](https://discord.gg/YSyVXbwvSY).
|
||||
The project is open to contributions. Feel free to open an issue or even a pull-request!
|
||||
|
||||
If you want to contribute financially and help us keep the application free and without ads, you can also:
|
||||
|
||||
- 💜 [Sponsor me (Sebastien)](https://github.com/sponsors/scastiel), or
|
||||
- 💙 [Make a small one-time donation](https://donate.stripe.com/28o3eh96G7hH8k89Ba).
|
||||
|
||||
### 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)
|
||||
@@ -68,13 +57,6 @@ Here is the current state of translation:
|
||||
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
|
||||
|
||||
398
messages/ca.json
398
messages/ca.json
@@ -1,398 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,399 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,15 +38,12 @@
|
||||
"earlierThisYear": "Dieses Jahr",
|
||||
"lastYear": "Letztes Jahr",
|
||||
"older": "Älter"
|
||||
},
|
||||
"export": "Exportieren"
|
||||
}
|
||||
},
|
||||
"ExpenseCard": {
|
||||
"paidBy": "Gezahlt von <strong>{paidBy}</strong> für <paidFor></paidFor>",
|
||||
"receivedBy": "Empfangen von <strong>{paidBy}</strong> für <paidFor></paidFor>",
|
||||
"yourBalance": "Deine Bilanz:",
|
||||
"everyone": "jeder",
|
||||
"notInvolved": "Du bist nicht involviert"
|
||||
"yourBalance": "Deine Bilanz:"
|
||||
},
|
||||
"Groups": {
|
||||
"myGroups": "Meine Gruppen",
|
||||
@@ -120,12 +117,6 @@
|
||||
"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": {
|
||||
@@ -147,12 +138,13 @@
|
||||
"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"
|
||||
"label": "Expense Recurrence",
|
||||
"description": "Select how often the expense should repeat.",
|
||||
|
||||
"none": "None",
|
||||
"daily": "Daily",
|
||||
"weekly": "Weekly",
|
||||
"monthly": "Monthly"
|
||||
},
|
||||
"paidFor": {
|
||||
"title": "Empfangen für",
|
||||
@@ -176,17 +168,8 @@
|
||||
"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."
|
||||
@@ -249,7 +232,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:",
|
||||
@@ -304,7 +287,7 @@
|
||||
"Groups": {
|
||||
"today": "Heute",
|
||||
"yesterday": "Gestern",
|
||||
"earlierThisWeek": "Anfang dieser Woche",
|
||||
"earlierThisWeek": "Diese Woche",
|
||||
"lastWeek": "Letze Woche",
|
||||
"earlierThisMonth": "Diesen Monat",
|
||||
"lastMonth": "Letzen Monat",
|
||||
@@ -337,7 +320,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.",
|
||||
@@ -412,18 +395,5 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -43,10 +43,8 @@
|
||||
},
|
||||
"ExpenseCard": {
|
||||
"paidBy": "Paid by <strong>{paidBy}</strong> for <paidFor></paidFor>",
|
||||
"everyone": "everyone",
|
||||
"receivedBy": "Received by <strong>{paidBy}</strong> for <paidFor></paidFor>",
|
||||
"yourBalance": "Your balance:",
|
||||
"notInvolved": "You are not involved"
|
||||
"yourBalance": "Your balance:"
|
||||
},
|
||||
"Groups": {
|
||||
"myGroups": "My groups",
|
||||
@@ -96,12 +94,6 @@
|
||||
"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.",
|
||||
@@ -141,10 +133,6 @@
|
||||
"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",
|
||||
@@ -169,14 +157,9 @@
|
||||
"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": {
|
||||
@@ -198,27 +181,6 @@
|
||||
"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"
|
||||
},
|
||||
@@ -360,7 +322,6 @@
|
||||
"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.",
|
||||
@@ -435,18 +396,5 @@
|
||||
"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,6 @@
|
||||
"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…",
|
||||
@@ -138,6 +137,15 @@
|
||||
"label": "Recibido por",
|
||||
"description": "Seleccione el participante que recibió los ingresos."
|
||||
},
|
||||
"recurrenceRule": {
|
||||
"label": "Recurrencia del gasto",
|
||||
"description": "Seleccione con qué frecuencia debe repetirse el gasto.",
|
||||
|
||||
"none": "Ninguno",
|
||||
"daily": "Diario",
|
||||
"weekly": "Semanal",
|
||||
"monthly": "Mensual"
|
||||
},
|
||||
"paidFor": {
|
||||
"title": "Recibido para for",
|
||||
"description": "Seleccione para quién se recibió el ingreso."
|
||||
@@ -162,14 +170,6 @@
|
||||
"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."
|
||||
@@ -259,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 a <strong>{to}</strong>",
|
||||
"owes": "<strong>{from}</strong> debe <strong>{to}</strong>",
|
||||
"markAsPaid": "Marcar como pagado"
|
||||
}
|
||||
},
|
||||
@@ -395,4 +395,4 @@
|
||||
"Water": "Agua"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -137,6 +137,15 @@
|
||||
"label": "Vastaanottaja",
|
||||
"description": "Valitse kuka vastaanotti tulon."
|
||||
},
|
||||
"recurrenceRule": {
|
||||
"label": "Expense Recurrence",
|
||||
"description": "Select how often the expense should repeat.",
|
||||
|
||||
"none": "None",
|
||||
"daily": "Daily",
|
||||
"weekly": "Weekly",
|
||||
"monthly": "Monthly"
|
||||
},
|
||||
"paidFor": {
|
||||
"title": "Tulon jakaminen",
|
||||
"description": "Valitse kenelle tulo jaetaan."
|
||||
@@ -231,6 +240,16 @@
|
||||
"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": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"Homepage": {
|
||||
"title": "Partagez <strong>vos dépenses</strong> avec <strong>vos amis & votre famille</strong>",
|
||||
"title": "Partagez <strong>vos dépenses</strong> avec <strong>vos amis</strong> & <strong>votre famille :)</strong>",
|
||||
"description": "Bienvenue sur votre instance <strong>Spliit</strong> !",
|
||||
"button": {
|
||||
"groups": "Accéder aux groupes",
|
||||
@@ -38,15 +38,12 @@
|
||||
"earlierThisYear": "Plus tôt cette année",
|
||||
"lastYear": "L'année dernière",
|
||||
"older": "Plus ancien"
|
||||
},
|
||||
"export": "Exporter"
|
||||
}
|
||||
},
|
||||
"ExpenseCard": {
|
||||
"paidBy": "Payé par <strong>{paidBy}</strong> pour <paidFor></paidFor>",
|
||||
"receivedBy": "Reçu par <strong>{paidBy}</strong> pour <paidFor></paidFor>",
|
||||
"yourBalance": "Votre solde :",
|
||||
"everyone": "tout le monde",
|
||||
"notInvolved": "Vous n'êtes pas concerné"
|
||||
"yourBalance": "Votre solde :"
|
||||
},
|
||||
"Groups": {
|
||||
"myGroups": "Mes groupes",
|
||||
@@ -102,9 +99,9 @@
|
||||
"protectedParticipant": "Ce participant fait partie des dépenses et ne peut pas être supprimé.",
|
||||
"new": "Nouveau",
|
||||
"add": "Ajouter un participant",
|
||||
"John": "Jean",
|
||||
"Jane": "Jeanne",
|
||||
"Jack": "Jacques"
|
||||
"John": "John",
|
||||
"Jane": "Jane",
|
||||
"Jack": "Jack"
|
||||
},
|
||||
"Settings": {
|
||||
"title": "Paramètres locaux",
|
||||
@@ -120,12 +117,6 @@
|
||||
"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": {
|
||||
@@ -147,23 +138,20 @@
|
||||
"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"
|
||||
"label": "Expense Recurrence",
|
||||
"description": "Select how often the expense should repeat.",
|
||||
|
||||
"none": "None",
|
||||
"daily": "Daily",
|
||||
"weekly": "Weekly",
|
||||
"monthly": "Monthly"
|
||||
},
|
||||
"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.",
|
||||
"currencyField": {
|
||||
"label": "Devise de la recette",
|
||||
"description": "La devise dans laquelle le bénéfice a été perçu."
|
||||
}
|
||||
"attachDescription": "Voir et joindre des reçus au revenu."
|
||||
},
|
||||
"Expense": {
|
||||
"create": "Créer une dépense",
|
||||
@@ -180,27 +168,14 @@
|
||||
"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.",
|
||||
"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"
|
||||
"description": "Sélectionnez le participant qui a réglé la dépense."
|
||||
},
|
||||
"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.",
|
||||
"currencyField": {
|
||||
"label": "Devise de la dépense",
|
||||
"description": "La devise dans laquelle la dépense a été payée."
|
||||
}
|
||||
"attachDescription": "Voir et joindre des reçus à la dépense."
|
||||
},
|
||||
"amountField": {
|
||||
"label": "Montant"
|
||||
@@ -239,21 +214,7 @@
|
||||
"save": "Sauvegarder",
|
||||
"saving": "Sauvegarde…",
|
||||
"cancel": "Annuler",
|
||||
"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"
|
||||
}
|
||||
"reimbursement": "Remboursement"
|
||||
},
|
||||
"ExpenseDocumentsInput": {
|
||||
"TooBigToast": {
|
||||
@@ -380,7 +341,7 @@
|
||||
"Games": "Jeux",
|
||||
"Movies": "Films",
|
||||
"Music": "Musique",
|
||||
"Sports": "Sport"
|
||||
"Sports": "Sports"
|
||||
},
|
||||
"Food and Drink": {
|
||||
"heading": "Nourriture et boissons",
|
||||
@@ -409,8 +370,7 @@
|
||||
"Gifts": "Cadeaux",
|
||||
"Insurance": "Assurance",
|
||||
"Medical Expenses": "Dépenses médicales",
|
||||
"Taxes": "Impôts",
|
||||
"Donation": "Don"
|
||||
"Taxes": "Impôts"
|
||||
},
|
||||
"Transportation": {
|
||||
"heading": "Transport",
|
||||
@@ -434,18 +394,5 @@
|
||||
"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 installazione di <strong>Spliit</strong>!",
|
||||
"description": "Benvenuto nella tua nuova instanza di <strong>Spliit</strong>!",
|
||||
"button": {
|
||||
"groups": "Vai ai gruppi",
|
||||
"github": "GitHub"
|
||||
@@ -12,7 +12,7 @@
|
||||
},
|
||||
"Footer": {
|
||||
"madeIn": "Realizzato a Montréal, Québec 🇨🇦",
|
||||
"builtBy": "Sviluppato da <author>Sebastien Castiel</author> e <source>contributori</source>"
|
||||
"builtBy": "Costruito da <author>Sebastien Castiel</author> e <source>contributori</source>"
|
||||
},
|
||||
"Expenses": {
|
||||
"title": "Spese",
|
||||
@@ -38,15 +38,12 @@
|
||||
"earlierThisYear": "All'inizio di quest'anno",
|
||||
"lastYear": "Ultimo anno",
|
||||
"older": "Più vecchio"
|
||||
},
|
||||
"export": "Esporta"
|
||||
}
|
||||
},
|
||||
"ExpenseCard": {
|
||||
"paidBy": "Pagato da <strong>{paidBy}</strong> per <paidFor></paidFor>",
|
||||
"receivedBy": "Ricevuto da <strong>{paidBy}</strong> per <paidFor></paidFor>",
|
||||
"yourBalance": "Il tuo saldo:",
|
||||
"notInvolved": "Non sei coinvolto",
|
||||
"everyone": "tutti"
|
||||
"yourBalance": "Il tuo bilancio:"
|
||||
},
|
||||
"Groups": {
|
||||
"myGroups": "I miei gruppi",
|
||||
@@ -54,8 +51,8 @@
|
||||
"loadingRecent": "Caricamento gruppi recenti…",
|
||||
"NoRecent": {
|
||||
"description": "Non hai visitato nessun gruppo di recente.",
|
||||
"create": "Creane uno",
|
||||
"orAsk": "oppure chiedi a un amico di inviarti il link a uno esistente."
|
||||
"create": "Creane una",
|
||||
"orAsk": "oppure chiedi a un amico di inviarti il collegamento a uno esistente."
|
||||
},
|
||||
"recent": "Gruppi recenti",
|
||||
"starred": "Gruppi speciali",
|
||||
@@ -73,7 +70,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": "Oops, non siamo riusciti a trovare il gruppo dall'URL che hai fornito…"
|
||||
"error": "Spiacenti, non siamo in grado di trovare il gruppo dall'URL che hai fornito..."
|
||||
},
|
||||
"NotFound": {
|
||||
"text": "Questo gruppo non esiste.",
|
||||
@@ -135,58 +132,50 @@
|
||||
"label": "Data entrata",
|
||||
"description": "Inserisci la data in cui è stato ricevuta l'entrata."
|
||||
},
|
||||
"categoryFieldDescription": "Seleziona la categoria dell'entrata.",
|
||||
"categoryFieldDescription": "Seleziona categoria entrata.",
|
||||
"paidByField": {
|
||||
"label": "Ricevuta da",
|
||||
"description": "Seleziona il partecipante che ha ricevuto l'entrata."
|
||||
"label": "Ricevuto da",
|
||||
"description": "Seleziona partecipante che ha ricevuto l'entrata."
|
||||
},
|
||||
"recurrenceRule": {
|
||||
"label": "Spesa ricorrente",
|
||||
"description": "Seleziona quanto spesso deve ripetersi.",
|
||||
"none": "Mai",
|
||||
"daily": "Giornaliera",
|
||||
"weekly": "Settimanale",
|
||||
"monthly": "Mensile"
|
||||
"label": "Expense Recurrence",
|
||||
"description": "Select how often the expense should repeat.",
|
||||
|
||||
"none": "None",
|
||||
"daily": "Daily",
|
||||
"weekly": "Weekly",
|
||||
"monthly": "Monthly"
|
||||
},
|
||||
"paidFor": {
|
||||
"title": "Ricevuto per",
|
||||
"description": "Seleziona per chi è stato ricevuto il reddito."
|
||||
"description": "Seleziona per chi è stato ricevuta l'entrata."
|
||||
},
|
||||
"splitModeDescription": "Seleziona come dividere l'entrata.",
|
||||
"attachDescription": "Vedi ed allega la ricevuta per l'entrata."
|
||||
"attachDescription": "Vedi allegati entrata."
|
||||
},
|
||||
"Expense": {
|
||||
"create": "Crea spesa",
|
||||
"edit": "Edita spesa",
|
||||
"edit": "Modifica spesa",
|
||||
"TitleField": {
|
||||
"label": "Titolo Spesa",
|
||||
"label": "Titolo spesa",
|
||||
"placeholder": "Ristorante del lunedì sera",
|
||||
"description": "Inserisci una descrizione per la spesa."
|
||||
"description": "Inserisci una descrizione per l'uscita."
|
||||
},
|
||||
"DateField": {
|
||||
"label": "Data spesa",
|
||||
"description": "Inserisci la data in cui si è svolta la spesa."
|
||||
"description": "Inserisci la data di quando è stata fatta la spesa"
|
||||
},
|
||||
"categoryFieldDescription": "Seleziona la categoria della spesa.",
|
||||
"categoryFieldDescription": "Seleziona una categoria per la spesa.",
|
||||
"paidByField": {
|
||||
"label": "Pagato da",
|
||||
"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"
|
||||
"description": "Seleziona il partecipante che ha pagato la spesa."
|
||||
},
|
||||
"paidFor": {
|
||||
"title": "Pagato per",
|
||||
"description": "Seleleziona per chi è stato pagato."
|
||||
"description": "Seleziona per chi è stata pagato."
|
||||
},
|
||||
"splitModeDescription": "Seleziona come dividere la spesa.",
|
||||
"attachDescription": "Vedi ed allega la ricevuta per la spesa."
|
||||
"attachDescription": "Vedi allegati spesa."
|
||||
},
|
||||
"amountField": {
|
||||
"label": "Importo"
|
||||
@@ -329,7 +318,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.",
|
||||
@@ -362,11 +351,11 @@
|
||||
"Liquor": "Liquori"
|
||||
},
|
||||
"Home": {
|
||||
"heading": "Casa",
|
||||
"Home": "Casa",
|
||||
"Electronics": "Elettronica di consumo",
|
||||
"Furniture": "Mobili",
|
||||
"Household Supplies": "Prodotti per la casa",
|
||||
"heading": "Home",
|
||||
"Home": "Home",
|
||||
"Electronics": "Elettronica",
|
||||
"Furniture": "Mobilia",
|
||||
"Household Supplies": "Forniture per la casa",
|
||||
"Maintenance": "Manutenzione",
|
||||
"Mortgage": "Mutuo",
|
||||
"Pets": "Animali",
|
||||
@@ -374,13 +363,12 @@
|
||||
"Services": "Servizi"
|
||||
},
|
||||
"Life": {
|
||||
"heading": "Vita",
|
||||
"Childcare": "Cura dei bambini",
|
||||
"Clothing": "Abbigliamento",
|
||||
"Donation": "Donazioni",
|
||||
"heading": "Life",
|
||||
"Childcare": "Assistenza all'infanzia",
|
||||
"Clothing": "Vestiti",
|
||||
"Education": "Istruzione",
|
||||
"Gifts": "Regali",
|
||||
"Insurance": "Assicurazioni",
|
||||
"Insurance": "Assicurazione",
|
||||
"Medical Expenses": "Spese Mediche",
|
||||
"Taxes": "Tasse"
|
||||
},
|
||||
@@ -397,14 +385,14 @@
|
||||
"Taxi": "Taxi"
|
||||
},
|
||||
"Utilities": {
|
||||
"heading": "Utenze",
|
||||
"Utilities": "Utenze",
|
||||
"Cleaning": "Pulizie",
|
||||
"heading": "Utilità",
|
||||
"Utilities": "Utilità",
|
||||
"Cleaning": "Pulizia",
|
||||
"Electricity": "Elettricità",
|
||||
"Heat/Gas": "Riscaldamento/Gas",
|
||||
"Trash": "Rifiuti",
|
||||
"Trash": "Spazzatura",
|
||||
"TV/Phone/Internet": "TV/Telefono/Internet",
|
||||
"Water": "Acqua"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,399 +0,0 @@
|
||||
{
|
||||
"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": "水道"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,15 +20,14 @@
|
||||
"create": "Maak uitgave",
|
||||
"createFirst": "Maak de eerste",
|
||||
"noExpenses": "Je groep heeft nog geen uitgaven.",
|
||||
"export": "Exporteren",
|
||||
"exportJson": "Exporteren naar JSON",
|
||||
"exportCsv": "Exporteren naar CSV",
|
||||
"exportJson": "Exporteer naar JSON",
|
||||
"exportCsv": "Exporteer 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",
|
||||
"save": "Sla op",
|
||||
"footer": "Deze instelling kan later worden gewijzigd in de instellingen van de groep."
|
||||
},
|
||||
"Groups": {
|
||||
@@ -43,10 +42,8 @@
|
||||
},
|
||||
"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"
|
||||
"yourBalance": "Jouw balans:"
|
||||
},
|
||||
"Groups": {
|
||||
"myGroups": "Mijn groepen",
|
||||
@@ -115,17 +112,11 @@
|
||||
"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"
|
||||
"save": "Sla op",
|
||||
"saving": "Opslaan…",
|
||||
"create": "Maak groep",
|
||||
"creating": "Maken…",
|
||||
"cancel": "Annuleer"
|
||||
}
|
||||
},
|
||||
"ExpenseForm": {
|
||||
@@ -146,24 +137,12 @@
|
||||
"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."
|
||||
}
|
||||
"attachDescription": "Bekijk en voeg bijlagen toe aan het inkomen."
|
||||
},
|
||||
"Expense": {
|
||||
"create": "Maak uitgave",
|
||||
@@ -180,27 +159,14 @@
|
||||
"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"
|
||||
"description": "Selecteer de deelnemer die de uitgave heeft gedaan."
|
||||
},
|
||||
"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."
|
||||
}
|
||||
"attachDescription": "Bekijk en voeg bijlagen toe aan de uitgave."
|
||||
},
|
||||
"amountField": {
|
||||
"label": "Bedrag"
|
||||
@@ -217,50 +183,29 @@
|
||||
"selectNone": "Selecteer niemand",
|
||||
"selectAll": "Selecteer iedereen",
|
||||
"shares": "deel/delen",
|
||||
"advancedOptions": "Andere split-opties…",
|
||||
"advancedOptions": "Geavanceerde 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"
|
||||
"saveAsDefault": "Sla op als standaard-optie"
|
||||
},
|
||||
"DeletePopup": {
|
||||
"label": "Verwijderen",
|
||||
"title": "Deze uitgave verwijderen?",
|
||||
"description": "Wil je deze uitgave echt verwijderen? Dit kan niet ongedaan worden.",
|
||||
"description": "Wil je deze uitgave echt verwijderen?",
|
||||
"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"
|
||||
}
|
||||
"create": "Maak",
|
||||
"creating": "Maken…",
|
||||
"save": "Sla op",
|
||||
"saving": "Opslaan…",
|
||||
"cancel": "Annuleer",
|
||||
"reimbursement": "Terugbetaling"
|
||||
},
|
||||
"ExpenseDocumentsInput": {
|
||||
"TooBigToast": {
|
||||
@@ -371,8 +316,7 @@
|
||||
"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."
|
||||
"percentageSum": "Het totaalpercentage moet gelijk zijn aan 100%."
|
||||
},
|
||||
"Categories": {
|
||||
"search": "Categorie zoeken…",
|
||||
@@ -413,7 +357,6 @@
|
||||
"heading": "Leven",
|
||||
"Childcare": "Kinderopvang",
|
||||
"Clothing": "Kleding",
|
||||
"Donation": "Donatie",
|
||||
"Education": "Onderwijs",
|
||||
"Gifts": "Cadeaus",
|
||||
"Insurance": "Verzekering",
|
||||
@@ -442,18 +385,5 @@
|
||||
"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": "Dziel <strong>Wydatki</strong> z <strong>Rodziną i Przyjaciółmi</strong>",
|
||||
"description": "Witaj na Twojej nowej instancji <strong>Spliit</strong> !",
|
||||
"title": "Podziel <strong>Wydatki</strong> z <strong>Rodziną i Przyjaciółmi</strong>",
|
||||
"description": "Witaj na twojej nowej instancji <strong>Spliita</strong> !",
|
||||
"button": {
|
||||
"groups": "Przejdź do grup",
|
||||
"github": "GitHub"
|
||||
@@ -11,19 +11,18 @@
|
||||
"groups": "Grupy"
|
||||
},
|
||||
"Footer": {
|
||||
"madeIn": "Stworzone w Montréalu, Québec 🇨🇦",
|
||||
"madeIn": "Stworzone 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.",
|
||||
"export": "Eksportuj",
|
||||
"exportJson": "Eksportuj jako JSON",
|
||||
"exportCsv": "Eksportuj jako CSV",
|
||||
"searchPlaceholder": "Szukaj wydatku…",
|
||||
"exportJson": "Eksportuj do JSONa",
|
||||
"exportCsv": "Eksportuj do CSVa",
|
||||
"searchPlaceholder": "Szukaj wydatku...",
|
||||
"ActiveUserModal": {
|
||||
"title": "Kim jesteś?",
|
||||
"description": "Podaj, którym uczestnikiem jesteś aby pozwolić nam określić jakie informacje mają być wyświetlane.",
|
||||
@@ -49,17 +48,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 wysłał Ci link do już istniejącej."
|
||||
"orAsk": "albo poproś przyjaciela, aby ci wysłał link do już istniejącej."
|
||||
},
|
||||
"recent": "Ostatnie grupy",
|
||||
"starred": "Ulubione grupy",
|
||||
"starred": "Ogwiazdkowane grupy",
|
||||
"archived": "Zarchiwizowane grupy",
|
||||
"archive": "Zarchiwizuj grupę",
|
||||
"unarchive": "Cofnij archiwizację grupy",
|
||||
"unarchive": "Odarchwiruj grupę",
|
||||
"removeRecent": "Usuń z ostatnich grup",
|
||||
"RecentRemovedToast": {
|
||||
"title": "Grupa została usunięta",
|
||||
@@ -68,10 +67,10 @@
|
||||
"undo": "Cofnij"
|
||||
},
|
||||
"AddByURL": {
|
||||
"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…"
|
||||
"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..."
|
||||
},
|
||||
"NotFound": {
|
||||
"text": "Ta grupa nie istnieje.",
|
||||
@@ -106,7 +105,7 @@
|
||||
},
|
||||
"Settings": {
|
||||
"title": "Ustawienia lokalne",
|
||||
"description": "Te ustawienia są zapisywane na tym urządzeniu i służą do dostosowania Twoich doświadczeń z aplikacją.",
|
||||
"description": "Te ustawienia są zapisywane dla tego urządzenia i służą do dostosowania twoich doświadczeń z aplikacją.",
|
||||
"ActiveUserField": {
|
||||
"label": "Aktywny użytkownik",
|
||||
"placeholder": "Wybierz użytkownika",
|
||||
@@ -133,11 +132,20 @@
|
||||
"label": "Data wpływu",
|
||||
"description": "Podaj datę otrzymania wpływu."
|
||||
},
|
||||
"categoryFieldDescription": "Wybierz kategorię wpływu.",
|
||||
"categoryFieldDescription": "Wybierz typ wpływu.",
|
||||
"paidByField": {
|
||||
"label": "Otrzymane przez",
|
||||
"description": "Wybierz członka, który otrzymał wpływ."
|
||||
},
|
||||
"recurrenceRule": {
|
||||
"label": "Expense Recurrence",
|
||||
"description": "Select how often the expense should repeat.",
|
||||
|
||||
"none": "None",
|
||||
"daily": "Daily",
|
||||
"weekly": "Weekly",
|
||||
"monthly": "Monthly"
|
||||
},
|
||||
"paidFor": {
|
||||
"title": "Otrzymany dla",
|
||||
"description": "Podaj dla kogo wpływ był przeznaczony."
|
||||
@@ -162,14 +170,6 @@
|
||||
"label": "Opłacone przez",
|
||||
"description": "Wybierz członka, który zapłacił."
|
||||
},
|
||||
"recurrenceRule": {
|
||||
"label": "Powtarzalnośc wydatku",
|
||||
"description": "Wybierz jak często wydatek ma się powtarzać.",
|
||||
"none": "Jednorazowo",
|
||||
"daily": "Codziennie",
|
||||
"weekly": "Co tydzień",
|
||||
"monthly": "Co miesiąc"
|
||||
},
|
||||
"paidFor": {
|
||||
"title": "Opłacone dla",
|
||||
"description": "Wybierz kogo dotyczył wydatek."
|
||||
@@ -192,7 +192,7 @@
|
||||
"selectNone": "Nie wybieraj nikogo",
|
||||
"selectAll": "Wybierz wszystkich",
|
||||
"shares": "udział(y)",
|
||||
"advancedOptions": "Zaawansowane opcje podziału…",
|
||||
"advancedOptions": "Zaawansowane opcje podziału...",
|
||||
"SplitModeField": {
|
||||
"label": "Typ podziału",
|
||||
"evenly": "Równy",
|
||||
@@ -219,7 +219,7 @@
|
||||
"ExpenseDocumentsInput": {
|
||||
"TooBigToast": {
|
||||
"title": "Ten plik jest zbyt duży",
|
||||
"description": "Maksymalny rozmiar pliku to {maxSize}. Twój plik ma {size}."
|
||||
"description": "Maksymalny rozmiar pliku to: {maxSize}. Twój plik ma: {size}."
|
||||
},
|
||||
"ErrorToast": {
|
||||
"title": "Błąd podczas wysyłania dokumentu",
|
||||
@@ -231,9 +231,9 @@
|
||||
"Dialog": {
|
||||
"triggerTitle": "Utwórz wydatek z paragonu",
|
||||
"title": "Utwórz z paragonu",
|
||||
"description": "Wyodrębnij informacje o wydatkach ze zdjęcia paragonu.",
|
||||
"description": "Wyodrębnianie informacji o wydatkach ze zdjęcia paragonu.",
|
||||
"body": "Prześlij zdjęcie paragonu, a my zeskanujemy je, aby wyodrębnić informacje o wydatkach, jeśli to możliwe.",
|
||||
"selectImage": "Wybierz obraz…",
|
||||
"selectImage": "Wybierz obraz...",
|
||||
"titleLabel": "Tytuł:",
|
||||
"categoryLabel": "Kategoria:",
|
||||
"amountLabel": "Suma:",
|
||||
@@ -244,7 +244,7 @@
|
||||
"unknown": "Nieznany",
|
||||
"TooBigToast": {
|
||||
"title": "Ten plik jest zbyt duży",
|
||||
"description": "Maksymalny rozmiar pliku to {maxSize}. Twój plik ma {size}."
|
||||
"description": "Maksymalny rozmiar pliku to: {maxSize}. Twój plik ma: {size}."
|
||||
},
|
||||
"ErrorToast": {
|
||||
"title": "Błąd podczas wysyłania dokumentu",
|
||||
@@ -254,7 +254,7 @@
|
||||
},
|
||||
"Balances": {
|
||||
"title": "Salda",
|
||||
"description": "Jest to kwota, którą każdy członek zapłacił lub otrzymał.",
|
||||
"description": "Jest to kwota, którą każdy członek zapłacił lub za którą otrzymał zapłatę.",
|
||||
"Reimbursements": {
|
||||
"title": "Sugerowane zwroty",
|
||||
"description": "Oto sugestie dotyczące optymalizacji zwrotów między uczestnikami.",
|
||||
@@ -298,7 +298,7 @@
|
||||
},
|
||||
"Information": {
|
||||
"title": "Informacje",
|
||||
"description": "Użyj tego miejsca, aby dodać wszelkie informacje, które mogą być istotne dla uczestników grupy.",
|
||||
"description": "Użyj tego miejsca, aby dodać wszelkie informacje, które mogą być istotne dla uczestników grupy..",
|
||||
"empty": "Jeszcze nic tu nie ma."
|
||||
},
|
||||
"Settings": {
|
||||
@@ -320,7 +320,7 @@
|
||||
"invalidNumber": "Niewłaściwa liczba.",
|
||||
"amountRequired": "Należy wprowadzić kwotę.",
|
||||
"amountNotZero": "Kwota nie może być zerem.",
|
||||
"amountTenMillion": "Kwota musi być niższa niż 10 000 000.",
|
||||
"amountTenMillion": "Kwota musi być niższa niż 10,000,000.",
|
||||
"paidByRequired": "Musisz wybrać członka.",
|
||||
"paidForMin1": "Wydatek musi zostać opłacony za co najmniej jednego uczestnika.",
|
||||
"noZeroShares": "Wszystkie udziały muszą być większe niż 0.",
|
||||
@@ -328,7 +328,7 @@
|
||||
"percentageSum": "Suma procentów musi być równa 100."
|
||||
},
|
||||
"Categories": {
|
||||
"search": "Szukaj kategorii…",
|
||||
"search": "Szukaj kategorii...",
|
||||
"noCategory": "Nie znaleziono kategorii.",
|
||||
"Uncategorized": {
|
||||
"heading": "Bez kategorii",
|
||||
@@ -341,7 +341,7 @@
|
||||
"Games": "Gry",
|
||||
"Movies": "Filmy",
|
||||
"Music": "Muzyka",
|
||||
"Sports": "Sport"
|
||||
"Sports": "Sporty"
|
||||
},
|
||||
"Food and Drink": {
|
||||
"heading": "Jedzenie i Napoje",
|
||||
@@ -357,7 +357,7 @@
|
||||
"Furniture": "Meble",
|
||||
"Household Supplies": "Artykuły gospodarstwa domowego",
|
||||
"Maintenance": "Utrzymanie",
|
||||
"Mortgage": "Kredyt",
|
||||
"Mortgage": "Czynsz",
|
||||
"Pets": "Zwierzaki",
|
||||
"Rent": "Czynsz",
|
||||
"Services": "Usługi"
|
||||
@@ -366,7 +366,6 @@
|
||||
"heading": "Życie",
|
||||
"Childcare": "Opieka nad dzieckiem",
|
||||
"Clothing": "Ubrania",
|
||||
"Donation": "Darowizna",
|
||||
"Education": "Edukacja",
|
||||
"Gifts": "Prezenty",
|
||||
"Insurance": "Ubezpieczenie",
|
||||
@@ -377,20 +376,20 @@
|
||||
"heading": "Transport",
|
||||
"Transportation": "Transport",
|
||||
"Bicycle": "Rower",
|
||||
"Bus/Train": "Autobus/Pociąg",
|
||||
"Bus/Train": "Bus/Pociąg",
|
||||
"Car": "Samochód",
|
||||
"Gas/Fuel": "Paliwo",
|
||||
"Hotel": "Hotel",
|
||||
"Parking": "Parking",
|
||||
"Plane": "Samolot",
|
||||
"Taxi": "Taksówka"
|
||||
"Plane": "Pociąg",
|
||||
"Taxi": "Taxi"
|
||||
},
|
||||
"Utilities": {
|
||||
"heading": "Media",
|
||||
"Utilities": "Media",
|
||||
"Cleaning": "Sprzątanie",
|
||||
"Electricity": "Prąd",
|
||||
"Heat/Gas": "Ogrzewanie/Gaz",
|
||||
"Electricity": "Prąg",
|
||||
"Heat/Gas": "Ogrzewanie",
|
||||
"Trash": "Śmieci",
|
||||
"TV/Phone/Internet": "TV/Telefon/Internet",
|
||||
"Water": "Woda"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"Homepage": {
|
||||
"title": "Compartilhe <strong>Despesas</strong> com <strong>Amigos e Família</strong>",
|
||||
"title": "Compartilhe <strong>Despesas</strong> com <strong>Amigos e Família</strong>",
|
||||
"description": "Bem-vindo à sua nova instalação do <strong>Spliit</strong>!",
|
||||
"button": {
|
||||
"groups": "Ir para grupos",
|
||||
@@ -38,15 +38,12 @@
|
||||
"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"
|
||||
"yourBalance": "Seu saldo:"
|
||||
},
|
||||
"Groups": {
|
||||
"myGroups": "Meus grupos",
|
||||
@@ -120,12 +117,6 @@
|
||||
"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": {
|
||||
@@ -168,23 +159,14 @@
|
||||
"categoryFieldDescription": "Selecione a categoria da despesa.",
|
||||
"paidByField": {
|
||||
"label": "Pago por",
|
||||
"description": "Selecione o participante que pagou a despesa.",
|
||||
"placeholder": "Selecione um participante"
|
||||
"description": "Selecione o participante que pagou a despesa."
|
||||
},
|
||||
"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"
|
||||
}
|
||||
"attachDescription": "Veja e anexe recibos à despesa."
|
||||
},
|
||||
"amountField": {
|
||||
"label": "Valor"
|
||||
@@ -379,8 +361,7 @@
|
||||
"Gifts": "Presentes",
|
||||
"Insurance": "Seguro",
|
||||
"Medical Expenses": "Despesas médicas",
|
||||
"Taxes": "Impostos",
|
||||
"Donation": "Doação"
|
||||
"Taxes": "Impostos"
|
||||
},
|
||||
"Transportation": {
|
||||
"heading": "Transporte",
|
||||
@@ -404,18 +385,5 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,6 +128,15 @@
|
||||
"placeholder": "Cina de luni seară",
|
||||
"description": "Adaugă o descriere pentru venit."
|
||||
},
|
||||
"recurrenceRule": {
|
||||
"label": "Expense Recurrence",
|
||||
"description": "Select how often the expense should repeat.",
|
||||
|
||||
"none": "None",
|
||||
"daily": "Daily",
|
||||
"weekly": "Weekly",
|
||||
"monthly": "Monthly"
|
||||
},
|
||||
"DateField": {
|
||||
"label": "Data venitului",
|
||||
"description": "Adaugă data la care venitul a fost primit."
|
||||
|
||||
@@ -137,6 +137,15 @@
|
||||
"label": "Получивший",
|
||||
"description": "Выберите участника, который получил этот доход."
|
||||
},
|
||||
"recurrenceRule": {
|
||||
"label": "Expense Recurrence",
|
||||
"description": "Select how often the expense should repeat.",
|
||||
|
||||
"none": "None",
|
||||
"daily": "Daily",
|
||||
"weekly": "Weekly",
|
||||
"monthly": "Monthly"
|
||||
},
|
||||
"paidFor": {
|
||||
"title": "Участники",
|
||||
"description": "Выберите тех, между кем этот доход будет распределен."
|
||||
|
||||
@@ -137,6 +137,15 @@
|
||||
"label": "Отримав",
|
||||
"description": "Оберіть учасника, який отримав дохід"
|
||||
},
|
||||
"recurrenceRule": {
|
||||
"label": "Expense Recurrence",
|
||||
"description": "Select how often the expense should repeat.",
|
||||
|
||||
"none": "None",
|
||||
"daily": "Daily",
|
||||
"weekly": "Weekly",
|
||||
"monthly": "Monthly"
|
||||
},
|
||||
"paidFor": {
|
||||
"title": "Учасники",
|
||||
"description": "Виберіть тих, між ким цей дохід буде розподілено"
|
||||
|
||||
@@ -137,6 +137,15 @@
|
||||
"label": "接收到",
|
||||
"description": "选择接收到这笔收入的群组成员。"
|
||||
},
|
||||
"recurrenceRule": {
|
||||
"label": "Expense Recurrence",
|
||||
"description": "Select how often the expense should repeat.",
|
||||
|
||||
"none": "None",
|
||||
"daily": "Daily",
|
||||
"weekly": "Weekly",
|
||||
"monthly": "Monthly"
|
||||
},
|
||||
"paidFor": {
|
||||
"title": "接收给",
|
||||
"description": "选择收入是为谁而收。"
|
||||
|
||||
@@ -30,7 +30,7 @@ const nextConfig = {
|
||||
// Required to run in a codespace (see https://github.com/vercel/next.js/issues/58019)
|
||||
experimental: {
|
||||
serverActions: {
|
||||
allowedOrigins: ['localhost:3000'],
|
||||
allowedOrigins: ['localhost:3000', 'localhost:3003'],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
163
package-lock.json
generated
163
package-lock.json
generated
@@ -56,7 +56,6 @@
|
||||
"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",
|
||||
@@ -66,6 +65,7 @@
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.54.1",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/jest-dom": "^6.4.8",
|
||||
"@testing-library/react": "^16.0.0",
|
||||
@@ -79,7 +79,6 @@
|
||||
"@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",
|
||||
@@ -4691,6 +4690,52 @@
|
||||
"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",
|
||||
@@ -5282,6 +5327,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
@@ -5297,6 +5343,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
@@ -5312,6 +5359,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -5327,6 +5375,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -5342,6 +5391,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -5357,6 +5407,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -5372,6 +5423,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
@@ -5387,6 +5439,7 @@
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
@@ -5456,6 +5509,22 @@
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.54.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.54.1.tgz",
|
||||
"integrity": "sha512-FS8hQ12acieG2dYSksmLOF7BNxnVf2afRJdCuM1eMSxj6QTSE6G4InGF7oApGgDb65MX7AwMVlIkpru0yZA4Xw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.54.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@prisma/client": {
|
||||
"version": "5.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.9.1.tgz",
|
||||
@@ -10875,13 +10944,6 @@
|
||||
"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",
|
||||
@@ -11041,6 +11103,7 @@
|
||||
"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"
|
||||
@@ -12197,6 +12260,19 @@
|
||||
"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",
|
||||
@@ -15469,6 +15545,53 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.54.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.54.1.tgz",
|
||||
"integrity": "sha512-peWpSwIBmSLi6aW2auvrUtf2DqY16YYcCMO8rTVx486jKmDTJg7UAhyrraP98GB8BoPURZP8+nxO7TSd4cPr5g==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.54.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.54.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.54.1.tgz",
|
||||
"integrity": "sha512-Nbjs2zjj0htNhzgiy5wu+3w09YetDx5pkrpI/kZotDlDUaYk0HVA5xrBVPdow4SAUIlhgKcJeJg4GRKW6xHusA==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.4.31",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
|
||||
@@ -16850,19 +16973,6 @@
|
||||
"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",
|
||||
@@ -17382,15 +17492,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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",
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
"build-image": "./scripts/build-image.sh",
|
||||
"start-container": "docker compose --env-file container.env up",
|
||||
"test": "jest",
|
||||
"generate-currency-data": "ts-node -T ./src/scripts/generateCurrencyData.ts"
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:ui": "npm run test:e2e -- --ui"
|
||||
},
|
||||
"dependencies": {
|
||||
"@formatjs/intl-localematcher": "^0.5.4",
|
||||
@@ -64,7 +65,6 @@
|
||||
"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",
|
||||
@@ -74,6 +74,7 @@
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.54.1",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/jest-dom": "^6.4.8",
|
||||
"@testing-library/react": "^16.0.0",
|
||||
@@ -87,7 +88,6 @@
|
||||
"@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",
|
||||
|
||||
38
playwright.config.ts
Normal file
38
playwright.config.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { PlaywrightTestConfig } from '@playwright/test';
|
||||
|
||||
const config: PlaywrightTestConfig = {
|
||||
testDir: './tests',
|
||||
timeout: 30000,
|
||||
retries: 2, // Increased retries for better stability
|
||||
use: {
|
||||
headless: true,
|
||||
viewport: { width: 1280, height: 720 },
|
||||
ignoreHTTPSErrors: true,
|
||||
video: 'on-first-retry',
|
||||
// Add navigation timeout for more reliable page loads
|
||||
navigationTimeout: 15000,
|
||||
// Add action timeout for more reliable interactions
|
||||
actionTimeout: 10000,
|
||||
},
|
||||
// Global test settings
|
||||
expect: {
|
||||
// Default timeout for expect() assertions
|
||||
timeout: 10000,
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { browserName: 'chromium' },
|
||||
},
|
||||
{
|
||||
name: 'firefox',
|
||||
use: { browserName: 'firefox' },
|
||||
},
|
||||
{
|
||||
name: 'webkit',
|
||||
use: { browserName: 'webkit' },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default config;
|
||||
305
prds/add-e2e-tests.md
Normal file
305
prds/add-e2e-tests.md
Normal file
@@ -0,0 +1,305 @@
|
||||
# End-to-End Testing Plan for Spliit
|
||||
|
||||
## Overview
|
||||
|
||||
This document outlines a comprehensive E2E testing strategy for Spliit, a expense splitting application. The plan prioritizes testing the most critical user workflows while ensuring good coverage of core features.
|
||||
|
||||
## Application Features Analysis
|
||||
|
||||
Based on the codebase analysis, Spliit is a group expense management application with the following key features:
|
||||
|
||||
### Core Features
|
||||
- **Group Management**: Create, edit, and manage expense groups with participants
|
||||
- **Expense Management**: Add, edit, delete expenses with various categories
|
||||
- **Split Modes**: Multiple ways to split expenses (evenly, by shares, by percentage, by amount)
|
||||
- **Balance Tracking**: View participant balances and what they owe/are owed
|
||||
- **Reimbursements**: Handle payments between participants
|
||||
- **Recurring Expenses**: Set up expenses that repeat (daily, weekly, monthly)
|
||||
- **Document Attachments**: Attach receipts/documents to expenses
|
||||
- **Activity Tracking**: View history of group activities
|
||||
- **Export Functionality**: Export data in CSV/JSON formats
|
||||
- **Statistics**: View spending analytics and trends
|
||||
|
||||
### Technical Details
|
||||
- Built with Next.js 14, TypeScript, tRPC, Prisma
|
||||
- Uses PostgreSQL database
|
||||
- Supports multiple currencies
|
||||
- Internationalized (i18n) interface
|
||||
- PWA capabilities
|
||||
|
||||
## Implementation Constraints
|
||||
|
||||
### Test ID Strategy
|
||||
During the migration, **the only modification allowed to application files is adding test IDs** (`data-testid` attributes). This constraint ensures:
|
||||
- Minimal impact on production code
|
||||
- No risk of introducing bugs through test implementation
|
||||
- Clean separation between test infrastructure and application logic
|
||||
- Easy identification of test-specific elements
|
||||
|
||||
### Test ID Naming Convention
|
||||
- Use kebab-case format: `data-testid="expense-card"`
|
||||
- Be descriptive and specific: `data-testid="balance-amount-john"`
|
||||
- Include context when needed: `data-testid="group-participant-list"`
|
||||
- Avoid generic names: prefer `expense-title-input` over `input`
|
||||
|
||||
### Test ID Implementation Guidelines
|
||||
1. **Strategic Placement**: Add test IDs only where needed for reliable element selection
|
||||
2. **Minimal Footprint**: Don't add test IDs to every element, focus on key interaction points
|
||||
3. **Future-Proof**: Choose stable elements that are unlikely to change frequently
|
||||
4. **Documentation**: Maintain a registry of added test IDs for reference
|
||||
|
||||
### Test Development Workflow
|
||||
**Mandatory Process**: After writing any new test, the development process **must run** `npm run test:e2e` to verify that:
|
||||
- The new test passes successfully
|
||||
- No existing tests are broken
|
||||
- All test interactions work as expected
|
||||
- Test reliability is maintained
|
||||
|
||||
This validation step is **required before continuing** with additional test development or implementation work.
|
||||
|
||||
## Priority-Based Testing Strategy
|
||||
|
||||
### Priority 1: Critical User Journeys (Must Have)
|
||||
|
||||
These are the core workflows that users perform most frequently:
|
||||
|
||||
#### 1. Group Creation and Management
|
||||
- **Test**: `group-lifecycle.spec.ts`
|
||||
- **Coverage**:
|
||||
- Create a new group with basic information
|
||||
- Add participants to the group
|
||||
- Edit group details (name, currency, information)
|
||||
- Navigate between group tabs (expenses, balances, information, stats, activity, settings)
|
||||
|
||||
#### 2. Basic Expense Management
|
||||
- **Test**: `expense-basic.spec.ts`
|
||||
- **Coverage**:
|
||||
- Create simple expense (equal split)
|
||||
- Edit existing expense
|
||||
- Delete expense
|
||||
- View expense details
|
||||
- Add expense notes
|
||||
|
||||
#### 3. Balance Calculation and Viewing
|
||||
- **Test**: `balances-and-reimbursements.spec.ts`
|
||||
- **Coverage**:
|
||||
- View participant balances after expenses
|
||||
- Verify balance calculations are correct
|
||||
- Create reimbursement from balance view
|
||||
- Mark reimbursements as completed
|
||||
|
||||
### Priority 2: Advanced Features (Should Have)
|
||||
|
||||
#### 4. Complex Expense Splitting
|
||||
- **Test**: `expense-splitting.spec.ts`
|
||||
- **Coverage**:
|
||||
- Split by shares
|
||||
- Split by percentage
|
||||
- Split by specific amounts
|
||||
- Exclude participants from expenses
|
||||
- Verify split calculations
|
||||
|
||||
#### 5. Categories and Organization
|
||||
- **Test**: `categories-and-organization.spec.ts`
|
||||
- **Coverage**:
|
||||
- Assign categories to expenses
|
||||
- Filter expenses by category
|
||||
- View category-based statistics
|
||||
|
||||
#### 6. Reimbursement Workflows
|
||||
- **Test**: `reimbursement-flows.spec.ts`
|
||||
- **Coverage**:
|
||||
- Create direct reimbursement
|
||||
- Generate reimbursement from suggestion
|
||||
- Track reimbursement status
|
||||
- Multiple reimbursement scenarios
|
||||
|
||||
### Priority 3: Advanced Functionality (Could Have)
|
||||
|
||||
#### 7. Recurring Expenses
|
||||
- **Test**: `recurring-expenses.spec.ts`
|
||||
- **Coverage**:
|
||||
- Set up daily recurring expense
|
||||
- Set up weekly recurring expense
|
||||
- Set up monthly recurring expense
|
||||
- Edit/cancel recurring expenses
|
||||
|
||||
#### 8. Document Management
|
||||
- **Test**: `document-management.spec.ts`
|
||||
- **Coverage**:
|
||||
- Upload receipt to expense
|
||||
- View attached documents
|
||||
- Remove documents
|
||||
- Multiple document attachments
|
||||
|
||||
#### 9. Data Export and Statistics
|
||||
- **Test**: `export-and-stats.spec.ts`
|
||||
- **Coverage**:
|
||||
- Export group data as CSV
|
||||
- Export group data as JSON
|
||||
- View spending statistics
|
||||
- Verify statistical calculations
|
||||
|
||||
#### 10. Activity Tracking
|
||||
- **Test**: `activity-tracking.spec.ts`
|
||||
- **Coverage**:
|
||||
- View activity feed
|
||||
- Verify activity entries for various actions
|
||||
- Activity timestamp accuracy
|
||||
|
||||
### Priority 4: Edge Cases and Error Handling (Nice to Have)
|
||||
|
||||
#### 11. Error Scenarios
|
||||
- **Test**: `error-handling.spec.ts`
|
||||
- **Coverage**:
|
||||
- Invalid input validation
|
||||
- Network error recovery
|
||||
- Large group management
|
||||
- Currency formatting edge cases
|
||||
|
||||
#### 12. Multi-User Workflows
|
||||
- **Test**: `multi-user-scenarios.spec.ts`
|
||||
- **Coverage**:
|
||||
- Multiple users in same group
|
||||
- Concurrent expense creation
|
||||
- Conflict resolution
|
||||
|
||||
## Test Implementation Structure
|
||||
|
||||
### Page Object Model (POM) Extensions
|
||||
|
||||
Extend the existing POM classes:
|
||||
|
||||
```typescript
|
||||
// Additional POM classes needed:
|
||||
- BalancePage.ts // For balance viewing and interactions
|
||||
- ReimbursementPage.ts // For reimbursement workflows
|
||||
- StatisticsPage.ts // For stats and analytics
|
||||
- ActivityPage.ts // For activity tracking
|
||||
- ExportPage.ts // For data export functionality
|
||||
- SettingsPage.ts // For group settings and management
|
||||
```
|
||||
|
||||
### Test Data Management
|
||||
|
||||
```typescript
|
||||
// test-data/
|
||||
- groups.ts // Test group configurations
|
||||
- expenses.ts // Various expense scenarios
|
||||
- participants.ts // Participant data sets
|
||||
- currencies.ts // Different currency formats
|
||||
```
|
||||
|
||||
### Utility Functions
|
||||
|
||||
```typescript
|
||||
// utils/
|
||||
- calculations.ts // Balance and split calculation helpers
|
||||
- currency.ts // Currency formatting utilities
|
||||
- date.ts // Date manipulation for recurring expenses
|
||||
- validation.ts // Input validation helpers
|
||||
```
|
||||
|
||||
## Test Execution Strategy
|
||||
|
||||
### Test Suites Organization
|
||||
|
||||
1. **Smoke Tests** (`smoke/`): Critical path tests that run on every commit
|
||||
- Basic group creation
|
||||
- Simple expense creation
|
||||
- Balance viewing
|
||||
|
||||
2. **Regression Tests** (`regression/`): Full feature coverage
|
||||
- All Priority 1 and 2 tests
|
||||
- Run on PRs and releases
|
||||
|
||||
3. **Extended Tests** (`extended/`): Comprehensive coverage
|
||||
- All priorities including edge cases
|
||||
- Run nightly or on demand
|
||||
|
||||
### Performance Considerations
|
||||
|
||||
- **Parallel Execution**: Group tests by functionality to run in parallel
|
||||
- **Test Isolation**: Each test should create its own group/data
|
||||
- **Cleanup**: Implement proper test data cleanup
|
||||
- **Database**: Consider using test database with fast reset
|
||||
|
||||
### Browser Coverage
|
||||
|
||||
- **Primary**: Chrome (latest)
|
||||
- **Secondary**: Firefox, Safari, Edge
|
||||
- **Mobile**: Mobile Chrome and Safari viewports
|
||||
|
||||
## Success Metrics
|
||||
|
||||
### Coverage Goals
|
||||
- **Critical Paths**: 100% coverage
|
||||
- **Core Features**: 95% coverage
|
||||
- **Advanced Features**: 80% coverage
|
||||
- **Edge Cases**: 60% coverage
|
||||
|
||||
### Quality Gates
|
||||
- All Priority 1 tests must pass for deployment
|
||||
- No more than 2% flaky test rate
|
||||
- Test execution time under 30 minutes for full suite
|
||||
- 95% test reliability score
|
||||
|
||||
## Implementation Timeline
|
||||
|
||||
### Phase 1 (Week 1-2): Foundation
|
||||
- **Identify and add required test IDs** to application components
|
||||
- Extend POM architecture
|
||||
- Implement Priority 1 tests
|
||||
- Set up test data management
|
||||
- Basic CI integration
|
||||
|
||||
### Phase 2 (Week 3-4): Core Features
|
||||
- **Add additional test IDs** for Priority 2 features
|
||||
- Implement Priority 2 tests
|
||||
- Add utility functions
|
||||
- Enhance test reliability
|
||||
- Performance optimization
|
||||
|
||||
### Phase 3 (Week 5-6): Advanced Features
|
||||
- **Complete test ID coverage** for remaining features
|
||||
- Implement Priority 3 tests
|
||||
- Cross-browser testing setup
|
||||
- Test reporting and analytics
|
||||
- Documentation completion
|
||||
|
||||
### Phase 4 (Week 7-8): Polish and Maintenance
|
||||
- **Finalize test ID registry and documentation**
|
||||
- Priority 4 tests implementation
|
||||
- Test suite optimization
|
||||
- Maintenance procedures
|
||||
- Team training
|
||||
|
||||
## Maintenance and Evolution
|
||||
|
||||
### Regular Reviews
|
||||
- Monthly test suite review for relevance
|
||||
- Quarterly performance optimization
|
||||
- Bi-annual architecture assessment
|
||||
|
||||
### Continuous Improvement
|
||||
- Monitor test flakiness and fix root causes
|
||||
- Add tests for new features immediately
|
||||
- Update tests when UI/UX changes
|
||||
- Regular dependency updates
|
||||
|
||||
## Risk Mitigation
|
||||
|
||||
### Common Pitfalls
|
||||
- **Over-testing**: Focus on user value, not code coverage
|
||||
- **Flaky tests**: Implement proper waits and retries
|
||||
- **Slow execution**: Optimize test data and parallel execution
|
||||
- **Maintenance burden**: Keep tests simple and focused
|
||||
|
||||
### Mitigation Strategies
|
||||
- Regular test review and cleanup
|
||||
- Investment in test infrastructure
|
||||
- Clear ownership and responsibility
|
||||
- Automated test health monitoring
|
||||
- **Mandatory test validation**: Always run `npm run test:e2e` after each new test implementation
|
||||
|
||||
This plan provides a structured approach to achieving comprehensive E2E test coverage for Spliit while prioritizing the most critical user workflows and maintaining sustainable test maintenance practices.
|
||||
93
prds/e2e-testing-setup.md
Normal file
93
prds/e2e-testing-setup.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# Setting Up E2E Testing with Playwright
|
||||
|
||||
Follow these steps to integrate Playwright for end-to-end testing in your application:
|
||||
|
||||
## Step 1: Install Playwright
|
||||
|
||||
Install Playwright along with its testing library by running:
|
||||
|
||||
```bash
|
||||
npm install --save-dev @playwright/test
|
||||
```
|
||||
|
||||
This command sets up Playwright to manage automated browser interactions.
|
||||
|
||||
## Step 2: Configure Playwright
|
||||
|
||||
Create a Playwright configuration file named `playwright.config.ts` at the root of your project with the following content:
|
||||
|
||||
```typescript
|
||||
import { PlaywrightTestConfig } from '@playwright/test';
|
||||
|
||||
const config: PlaywrightTestConfig = {
|
||||
testDir: './tests',
|
||||
timeout: 30000,
|
||||
retries: 1,
|
||||
use: {
|
||||
headless: true,
|
||||
viewport: { width: 1280, height: 720 },
|
||||
ignoreHTTPSErrors: true,
|
||||
video: 'on-first-retry',
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { browserName: 'chromium' },
|
||||
},
|
||||
{
|
||||
name: 'firefox',
|
||||
use: { browserName: 'firefox' },
|
||||
},
|
||||
{
|
||||
name: 'webkit',
|
||||
use: { browserName: 'webkit' },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default config;
|
||||
```
|
||||
|
||||
## Step 3: Add E2E Test
|
||||
|
||||
Create a `tests` directory in the root of your project. Add E2E tests under this directory.
|
||||
|
||||
Example test file `tests/example.spec.ts`:
|
||||
|
||||
```typescript
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('basic test', async ({ page }) => {
|
||||
await page.goto('http://localhost:3000'); // replace with your local dev URL
|
||||
await expect(page).toHaveTitle(/Your App Title/); // update with your app's title
|
||||
});
|
||||
```
|
||||
|
||||
## Step 4: Update Package.json
|
||||
|
||||
Add a script in `package.json` for running Playwright tests:
|
||||
|
||||
```json
|
||||
"scripts": {
|
||||
...
|
||||
"test:e2e": "playwright test"
|
||||
}
|
||||
```
|
||||
|
||||
## Step 5: Run the Test
|
||||
|
||||
Ensure your application is running locally, then execute your tests with:
|
||||
|
||||
```bash
|
||||
npm run test:e2e
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Additional Considerations**
|
||||
|
||||
- If using CI/CD, adapt Playwright settings to accommodate environment constraints.
|
||||
- Manage environment variables as necessary for successful testing.
|
||||
- Utilize Playwright's `global-setup.js` and `global-teardown.js` for set up and tear down logic.
|
||||
|
||||
Follow these steps to effectively incorporate E2E testing into your build process using Playwright. For further customization or troubleshooting, consult Playwright's documentation.
|
||||
@@ -1,2 +0,0 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Group" ADD COLUMN "currencyCode" TEXT;
|
||||
@@ -1,4 +0,0 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Expense" ADD COLUMN "conversionRate" DECIMAL(65,30),
|
||||
ADD COLUMN "originalAmount" INTEGER,
|
||||
ADD COLUMN "originalCurrency" TEXT;
|
||||
@@ -16,7 +16,6 @@ model Group {
|
||||
name String
|
||||
information String? @db.Text
|
||||
currency String @default("$")
|
||||
currencyCode String?
|
||||
participants Participant[]
|
||||
expenses Expense[]
|
||||
activities Activity[]
|
||||
@@ -40,28 +39,25 @@ 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
|
||||
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?
|
||||
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?
|
||||
|
||||
recurrenceRule RecurrenceRule? @default(NONE)
|
||||
recurringExpenseLink RecurringExpenseLink?
|
||||
recurrenceRule RecurrenceRule? @default(NONE)
|
||||
recurringExpenseLink RecurringExpenseLink?
|
||||
recurringExpenseLinkId String?
|
||||
}
|
||||
|
||||
@@ -82,16 +78,16 @@ enum SplitMode {
|
||||
}
|
||||
|
||||
model RecurringExpenseLink {
|
||||
id String @id
|
||||
groupId String
|
||||
currentFrameExpense Expense @relation(fields: [currentFrameExpenseId], references: [id], onDelete: Cascade)
|
||||
currentFrameExpenseId String @unique
|
||||
id String @id
|
||||
groupId String
|
||||
currentFrameExpense Expense @relation(fields: [currentFrameExpenseId], references: [id], onDelete: Cascade)
|
||||
currentFrameExpenseId String @unique
|
||||
|
||||
// Note: We do not want to link to the next expense because once it is created, it should be
|
||||
// treated as it's own independent entity. This means that if a user wants to delete an Expense
|
||||
// and any prior related recurring expenses, they'll need to delete them one by one.
|
||||
nextExpenseCreatedAt DateTime?
|
||||
nextExpenseDate DateTime
|
||||
nextExpenseDate DateTime
|
||||
|
||||
@@index([groupId])
|
||||
@@index([groupId, nextExpenseCreatedAt, nextExpenseDate(sort: Desc)])
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
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()
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
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()
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
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()
|
||||
}
|
||||
@@ -18,7 +18,7 @@ export function ActivityPageClient() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="mb-4">
|
||||
<Card className="mb-4" data-testid="activity-content">
|
||||
<CardHeader>
|
||||
<CardTitle>{t('title')}</CardTitle>
|
||||
<CardDescription>{t('description')}</CardDescription>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
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'
|
||||
@@ -7,7 +6,7 @@ import { useLocale } from 'next-intl'
|
||||
type Props = {
|
||||
balances: Balances
|
||||
participants: Participant[]
|
||||
currency: Currency
|
||||
currency: string
|
||||
}
|
||||
|
||||
export function BalancesList({ balances, participants, currency }: Props) {
|
||||
|
||||
@@ -10,7 +10,6 @@ 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'
|
||||
@@ -35,8 +34,8 @@ export default function BalancesAndReimbursements() {
|
||||
const isLoading = balancesAreLoading || !balancesData || !group
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="mb-4">
|
||||
<div data-testid="balances-content">
|
||||
<Card className="mb-4" data-testid="balances-card">
|
||||
<CardHeader>
|
||||
<CardTitle>{t('title')}</CardTitle>
|
||||
<CardDescription>{t('description')}</CardDescription>
|
||||
@@ -48,7 +47,7 @@ export default function BalancesAndReimbursements() {
|
||||
<BalancesList
|
||||
balances={balancesData.balances}
|
||||
participants={group?.participants}
|
||||
currency={getCurrencyFromGroup(group)}
|
||||
currency={group?.currency}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
@@ -67,13 +66,13 @@ export default function BalancesAndReimbursements() {
|
||||
<ReimbursementList
|
||||
reimbursements={balancesData.reimbursements}
|
||||
participants={group?.participants}
|
||||
currency={getCurrencyFromGroup(group)}
|
||||
currency={group?.currency}
|
||||
groupId={groupId}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,8 @@ export const EditGroup = () => {
|
||||
if (isLoading) return <></>
|
||||
|
||||
return (
|
||||
<GroupForm
|
||||
<div data-testid="edit-content">
|
||||
<GroupForm
|
||||
group={data?.group}
|
||||
onSubmit={async (groupFormValues, participantId) => {
|
||||
await mutateAsync({ groupId, participantId, groupFormValues })
|
||||
@@ -21,5 +22,6 @@ export const EditGroup = () => {
|
||||
}}
|
||||
protectedParticipantIds={data?.participantsWithExpenses}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
'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: Currency
|
||||
currency: string
|
||||
expense: Parameters<typeof getBalances>[0][number]
|
||||
}
|
||||
|
||||
@@ -19,7 +18,7 @@ export function ActiveUserBalance({ groupId, currency, expense }: Props) {
|
||||
}
|
||||
|
||||
const balances = getBalances([expense])
|
||||
let fmtBalance = <>{t('notInvolved')}</>
|
||||
let fmtBalance = <>You are not involved</>
|
||||
if (Object.hasOwn(balances, activeUserId)) {
|
||||
const balance = balances[activeUserId]
|
||||
let balanceDetail = <></>
|
||||
|
||||
@@ -26,12 +26,7 @@ import {
|
||||
import { ToastAction } from '@/components/ui/toast'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { useMediaQuery } from '@/lib/hooks'
|
||||
import {
|
||||
formatCurrency,
|
||||
formatDate,
|
||||
formatFileSize,
|
||||
getCurrencyFromGroup,
|
||||
} from '@/lib/utils'
|
||||
import { formatCurrency, formatDate, formatFileSize } from '@/lib/utils'
|
||||
import { trpc } from '@/trpc/client'
|
||||
import { ChevronRight, FileQuestion, Loader2, Receipt } from 'lucide-react'
|
||||
import { useLocale, useTranslations } from 'next-intl'
|
||||
@@ -207,7 +202,7 @@ function ReceiptDialogContent() {
|
||||
receiptInfo.amount ? (
|
||||
<>
|
||||
{formatCurrency(
|
||||
getCurrencyFromGroup(group),
|
||||
group.currency,
|
||||
receiptInfo.amount,
|
||||
locale,
|
||||
true,
|
||||
|
||||
@@ -4,7 +4,6 @@ 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'
|
||||
@@ -14,27 +13,15 @@ import { Fragment } from 'react'
|
||||
|
||||
type Expense = Awaited<ReturnType<typeof getGroupExpenses>>[number]
|
||||
|
||||
function Participants({
|
||||
expense,
|
||||
participantCount,
|
||||
}: {
|
||||
expense: Expense
|
||||
participantCount: number
|
||||
}) {
|
||||
function Participants({ expense }: { expense: Expense }) {
|
||||
const t = useTranslations('ExpenseCard')
|
||||
const key = expense.amount > 0 ? 'paidBy' : 'receivedBy'
|
||||
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 paidFor = 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,
|
||||
@@ -46,23 +33,18 @@ function Participants({
|
||||
|
||||
type Props = {
|
||||
expense: Expense
|
||||
currency: Currency
|
||||
currency: string
|
||||
groupId: string
|
||||
participantCount: number
|
||||
}
|
||||
|
||||
export function ExpenseCard({
|
||||
expense,
|
||||
currency,
|
||||
groupId,
|
||||
participantCount,
|
||||
}: Props) {
|
||||
export function ExpenseCard({ expense, currency, groupId }: Props) {
|
||||
const router = useRouter()
|
||||
const locale = useLocale()
|
||||
|
||||
return (
|
||||
<div
|
||||
key={expense.id}
|
||||
data-expense-card
|
||||
className={cn(
|
||||
'flex justify-between sm:mx-6 px-4 sm:rounded-lg sm:pr-2 sm:pl-4 py-4 text-sm cursor-pointer hover:bg-accent gap-1 items-stretch',
|
||||
expense.isReimbursement && 'italic',
|
||||
@@ -80,7 +62,7 @@ export function ExpenseCard({
|
||||
{expense.title}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<Participants expense={expense} participantCount={participantCount} />
|
||||
<Participants expense={expense} />
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<ActiveUserBalance {...{ groupId, currency, expense }} />
|
||||
@@ -92,6 +74,7 @@ export function ExpenseCard({
|
||||
'tabular-nums whitespace-nowrap',
|
||||
expense.isReimbursement ? 'italic' : 'font-bold',
|
||||
)}
|
||||
data-amount
|
||||
>
|
||||
{formatCurrency(currency, expense.amount, locale)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
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'
|
||||
@@ -33,29 +32,21 @@ 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, useCurrencyRate } from '@/lib/hooks'
|
||||
import { useActiveUser } from '@/lib/hooks'
|
||||
import {
|
||||
ExpenseFormValues,
|
||||
SplittingOptions,
|
||||
expenseFormSchema,
|
||||
} from '@/lib/schemas'
|
||||
import { calculateShare } from '@/lib/totals'
|
||||
import {
|
||||
amountAsDecimal,
|
||||
amountAsMinorUnits,
|
||||
cn,
|
||||
formatCurrency,
|
||||
getCurrencyFromGroup,
|
||||
} from '@/lib/utils'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { AppRouterOutput } from '@/trpc/routers/_app'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { RecurrenceRule } from '@prisma/client'
|
||||
import { ChevronRight, Save } from 'lucide-react'
|
||||
import { useLocale, useTranslations } from 'next-intl'
|
||||
import { Save } from 'lucide-react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import Link from 'next/link'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { useEffect, useState } from 'react'
|
||||
@@ -81,7 +72,7 @@ const getDefaultSplittingOptions = (
|
||||
splitMode: 'EVENLY' as const,
|
||||
paidFor: group.participants.map(({ id }) => ({
|
||||
participant: id,
|
||||
shares: 1,
|
||||
shares: '1' as unknown as number,
|
||||
})),
|
||||
}
|
||||
|
||||
@@ -113,7 +104,7 @@ const getDefaultSplittingOptions = (
|
||||
splitMode: parsedDefaultSplitMode.splitMode,
|
||||
paidFor: parsedDefaultSplitMode.paidFor.map((paidFor) => ({
|
||||
participant: paidFor.participant,
|
||||
shares: paidFor.shares / 100,
|
||||
shares: String(paidFor.shares / 100) as unknown as number,
|
||||
})),
|
||||
}
|
||||
}
|
||||
@@ -127,7 +118,7 @@ async function persistDefaultSplittingOptions(
|
||||
if (expenseFormValues.splitMode === 'EVENLY') {
|
||||
return expenseFormValues.paidFor.map(({ participant }) => ({
|
||||
participant,
|
||||
shares: 100,
|
||||
shares: '100' as unknown as number,
|
||||
}))
|
||||
} else if (expenseFormValues.splitMode === 'BY_AMOUNT') {
|
||||
return null
|
||||
@@ -164,7 +155,6 @@ export function ExpenseForm({
|
||||
runtimeFeatureFlags: RuntimeFeatureFlags
|
||||
}) {
|
||||
const t = useTranslations('ExpenseForm')
|
||||
const locale = useLocale() as Locale
|
||||
const isCreate = expense === undefined
|
||||
const searchParams = useSearchParams()
|
||||
|
||||
@@ -182,25 +172,18 @@ export function ExpenseForm({
|
||||
return field?.value as RecurrenceRule
|
||||
}
|
||||
const defaultSplittingOptions = getDefaultSplittingOptions(group)
|
||||
const groupCurrency = getCurrencyFromGroup(group)
|
||||
const form = useForm<ExpenseFormValues>({
|
||||
resolver: zodResolver(expenseFormSchema),
|
||||
defaultValues: expense
|
||||
? {
|
||||
title: expense.title,
|
||||
expenseDate: expense.expenseDate ?? new Date(),
|
||||
amount: amountAsDecimal(expense.amount, groupCurrency),
|
||||
originalCurrency: expense.originalCurrency ?? group.currencyCode,
|
||||
originalAmount: expense.originalAmount ?? undefined,
|
||||
conversionRate: expense.conversionRate?.toNumber(),
|
||||
amount: String(expense.amount / 100) as unknown as number, // hack
|
||||
category: expense.categoryId,
|
||||
paidBy: expense.paidById,
|
||||
paidFor: expense.paidFor.map(({ participantId, shares }) => ({
|
||||
participant: participantId,
|
||||
shares:
|
||||
expense.splitMode === 'BY_AMOUNT'
|
||||
? amountAsDecimal(shares, groupCurrency)
|
||||
: shares / 100,
|
||||
shares: String(shares / 100) as unknown as number,
|
||||
})),
|
||||
splitMode: expense.splitMode,
|
||||
saveDefaultSplittingOptions: false,
|
||||
@@ -213,20 +196,16 @@ export function ExpenseForm({
|
||||
? {
|
||||
title: t('reimbursement'),
|
||||
expenseDate: new Date(),
|
||||
amount: amountAsDecimal(
|
||||
Number(searchParams.get('amount')) || 0,
|
||||
groupCurrency,
|
||||
),
|
||||
originalCurrency: group.currencyCode,
|
||||
originalAmount: undefined,
|
||||
conversionRate: undefined,
|
||||
amount: String(
|
||||
(Number(searchParams.get('amount')) || 0) / 100,
|
||||
) as unknown as number, // hack
|
||||
category: 1, // category with Id 1 is Payment
|
||||
paidBy: searchParams.get('from') ?? undefined,
|
||||
paidFor: [
|
||||
searchParams.get('to')
|
||||
? {
|
||||
participant: searchParams.get('to')!,
|
||||
shares: 1,
|
||||
shares: '1' as unknown as number,
|
||||
}
|
||||
: undefined,
|
||||
],
|
||||
@@ -242,10 +221,7 @@ export function ExpenseForm({
|
||||
expenseDate: searchParams.get('date')
|
||||
? new Date(searchParams.get('date') as string)
|
||||
: new Date(),
|
||||
amount: Number(searchParams.get('amount')) || 0,
|
||||
originalCurrency: group.currencyCode ?? undefined,
|
||||
originalAmount: undefined,
|
||||
conversionRate: undefined,
|
||||
amount: (searchParams.get('amount') || 0) as unknown as number, // hack,
|
||||
category: searchParams.get('categoryId')
|
||||
? Number(searchParams.get('categoryId'))
|
||||
: 0, // category with Id 0 is General
|
||||
@@ -274,22 +250,6 @@ 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)
|
||||
}
|
||||
|
||||
@@ -300,23 +260,6 @@ export function ExpenseForm({
|
||||
|
||||
const sExpense = isIncome ? 'Income' : 'Expense'
|
||||
|
||||
const originalCurrency = getCurrency(
|
||||
form.getValues('originalCurrency'),
|
||||
locale,
|
||||
'Custom',
|
||||
)
|
||||
const exchangeRate = useCurrencyRate(
|
||||
form.watch('expenseDate'),
|
||||
form.watch('originalCurrency') ?? '',
|
||||
groupCurrency.code,
|
||||
)
|
||||
|
||||
const conversionRequired =
|
||||
group.currencyCode &&
|
||||
group.currencyCode.length &&
|
||||
originalCurrency.code.length &&
|
||||
originalCurrency.code !== group.currencyCode
|
||||
|
||||
useEffect(() => {
|
||||
setManuallyEditedParticipants(new Set())
|
||||
}, [form.watch('splitMode'), form.watch('amount')])
|
||||
@@ -359,9 +302,9 @@ export function ExpenseForm({
|
||||
if (!editedParticipants.includes(participant.participant)) {
|
||||
return {
|
||||
...participant,
|
||||
shares: Number(
|
||||
amountPerRemaining.toFixed(groupCurrency.decimal_digits),
|
||||
),
|
||||
shares: String(
|
||||
Number(amountPerRemaining.toFixed(2)),
|
||||
) as unknown as number,
|
||||
}
|
||||
}
|
||||
return participant
|
||||
@@ -375,71 +318,6 @@ 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)}>
|
||||
@@ -506,175 +384,11 @@ 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-5">
|
||||
<FormItem className="sm:order-3">
|
||||
<FormLabel>{t('amountField.label')}</FormLabel>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span>{group.currency}</span>
|
||||
@@ -727,6 +441,28 @@ 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"
|
||||
@@ -738,9 +474,7 @@ export function ExpenseForm({
|
||||
defaultValue={getSelectedPayer(field)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={t(`${sExpense}.paidByField.placeholder`)}
|
||||
/>
|
||||
<SelectValue placeholder="Select a participant" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{group.participants.map(({ id, name }) => (
|
||||
@@ -827,7 +561,7 @@ export function ExpenseForm({
|
||||
participant: p.id,
|
||||
shares:
|
||||
paidFor.find((pfor) => pfor.participant === p.id)
|
||||
?.shares ?? 1,
|
||||
?.shares ?? ('1' as unknown as number),
|
||||
}))
|
||||
form.setValue('paidFor', newPaidFor, {
|
||||
shouldDirty: true,
|
||||
@@ -865,7 +599,7 @@ export function ExpenseForm({
|
||||
data-id={`${id}/${form.getValues().splitMode}/${
|
||||
group.currency
|
||||
}`}
|
||||
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"
|
||||
className="flex items-center border-t last-of-type:border-b last-of-type:!mb-4 -mx-6 px-6 py-3"
|
||||
>
|
||||
<FormItem className="flex-1 flex flex-row items-start space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
@@ -886,7 +620,7 @@ export function ExpenseForm({
|
||||
...field.value,
|
||||
{
|
||||
participant: id,
|
||||
shares: 1,
|
||||
shares: '1' as unknown as number,
|
||||
},
|
||||
],
|
||||
options,
|
||||
@@ -908,14 +642,11 @@ export function ExpenseForm({
|
||||
) &&
|
||||
!form.watch('isReimbursement') && (
|
||||
<span className="text-muted-foreground ml-2">
|
||||
(
|
||||
{formatCurrency(
|
||||
groupCurrency,
|
||||
({group.currency}{' '}
|
||||
{(
|
||||
calculateShare(id, {
|
||||
amount: amountAsMinorUnits(
|
||||
Number(form.watch('amount')),
|
||||
groupCurrency,
|
||||
), // Convert to cents
|
||||
amount:
|
||||
Number(form.watch('amount')) * 100, // Convert to cents
|
||||
paidFor: field.value.map(
|
||||
({ participant, shares }) => ({
|
||||
participant: {
|
||||
@@ -925,14 +656,10 @@ export function ExpenseForm({
|
||||
},
|
||||
shares:
|
||||
form.watch('splitMode') ===
|
||||
'BY_PERCENTAGE'
|
||||
'BY_PERCENTAGE' ||
|
||||
form.watch('splitMode') ===
|
||||
'BY_AMOUNT'
|
||||
? Number(shares) * 100 // Convert percentage to basis points (e.g., 50% -> 5000)
|
||||
: form.watch('splitMode') ===
|
||||
'BY_AMOUNT'
|
||||
? amountAsMinorUnits(
|
||||
shares,
|
||||
groupCurrency,
|
||||
)
|
||||
: shares,
|
||||
expenseId: '',
|
||||
participantId: '',
|
||||
@@ -941,217 +668,113 @@ export function ExpenseForm({
|
||||
splitMode: form.watch('splitMode'),
|
||||
isReimbursement:
|
||||
form.watch('isReimbursement'),
|
||||
}),
|
||||
locale,
|
||||
)}
|
||||
}) / 100
|
||||
).toFixed(2)}
|
||||
)
|
||||
</span>
|
||||
)}
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
<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,
|
||||
{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,
|
||||
),
|
||||
)}
|
||||
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" />
|
||||
)
|
||||
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}
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<FormMessage className="float-right" />
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
|
||||
@@ -4,7 +4,6 @@ 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'
|
||||
@@ -171,9 +170,8 @@ const ExpenseListForSearch = ({
|
||||
<ExpenseCard
|
||||
key={expense.id}
|
||||
expense={expense}
|
||||
currency={getCurrencyFromGroup(group)}
|
||||
currency={group.currency}
|
||||
groupId={groupId}
|
||||
participantCount={group.participants.length}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
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'
|
||||
@@ -32,16 +30,12 @@ export async function GET(
|
||||
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,
|
||||
@@ -58,29 +52,30 @@ export async function GET(
|
||||
|
||||
/*
|
||||
|
||||
CSV Columns:
|
||||
CSV Structure:
|
||||
|
||||
--------------------------------------------------------------
|
||||
| Date | Description | Category | Currency | Cost
|
||||
--------------------------------------------------------------
|
||||
| Is Reimbursement | Split mode | UserA | UserB
|
||||
--------------------------------------------------------------
|
||||
|
||||
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 |
|
||||
+------------+------------------+----------+----------+----------+---------------+-------------------+-----------------+------------------+----------------------+--------+-----------+
|
||||
Example Row:
|
||||
------------------------------------------------------------------------------------------
|
||||
| 2025-01-06 | Dinner with team | Food | ₹ | 5000 | No | Evenly | John | Jane
|
||||
------------------------------------------------------------------------------------------
|
||||
|
||||
*/
|
||||
*/
|
||||
|
||||
const fields = [
|
||||
{ label: 'Date', value: 'date' },
|
||||
@@ -88,9 +83,6 @@ export async function GET(
|
||||
{ label: 'Category', value: 'categoryName' },
|
||||
{ label: 'Currency', value: 'currency' },
|
||||
{ label: 'Cost', value: 'amount' },
|
||||
{ label: 'Original cost', value: 'originalAmount' },
|
||||
{ label: 'Original currency', value: 'originalCurrency' },
|
||||
{ label: 'Conversion rate', value: 'conversionRate' },
|
||||
{ label: 'Is Reimbursement', value: 'isReimbursement' },
|
||||
{ label: 'Split mode', value: 'splitMode' },
|
||||
...group.participants.map((participant) => ({
|
||||
@@ -99,24 +91,12 @@ export async function GET(
|
||||
})),
|
||||
]
|
||||
|
||||
const currency = getCurrencyFromGroup(group)
|
||||
|
||||
const expenses = group.expenses.map((expense) => ({
|
||||
date: formatDate(expense.expenseDate),
|
||||
title: expense.title,
|
||||
categoryName: expense.category?.name || '',
|
||||
currency: group.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,
|
||||
currency: group.currency,
|
||||
amount: (expense.amount / 100).toFixed(2),
|
||||
isReimbursement: expense.isReimbursement ? 'Yes' : 'No',
|
||||
splitMode: splitModeLabel[expense.splitMode],
|
||||
...Object.fromEntries(
|
||||
@@ -133,10 +113,10 @@ export async function GET(
|
||||
)
|
||||
|
||||
const isPaidByParticipant = expense.paidById === participant.id
|
||||
const participantAmountShare = +formatAmountAsDecimal(
|
||||
(expense.amount / totalShares) * participantShare,
|
||||
currency,
|
||||
)
|
||||
const participantAmountShare = +(
|
||||
((expense.amount / totalShares) * participantShare) /
|
||||
100
|
||||
).toFixed(2)
|
||||
|
||||
return [
|
||||
participant.name,
|
||||
|
||||
@@ -12,7 +12,6 @@ export async function GET(
|
||||
id: true,
|
||||
name: true,
|
||||
currency: true,
|
||||
currencyCode: true,
|
||||
expenses: {
|
||||
select: {
|
||||
createdAt: true,
|
||||
@@ -20,9 +19,6 @@ export async function GET(
|
||||
title: true,
|
||||
category: { select: { grouping: true, name: true } },
|
||||
amount: true,
|
||||
originalAmount: true,
|
||||
originalCurrency: true,
|
||||
conversionRate: true,
|
||||
paidById: true,
|
||||
paidFor: { select: { participantId: true, shares: true } },
|
||||
isReimbursement: true,
|
||||
|
||||
@@ -34,7 +34,7 @@ export default function GroupExpensesPageClient({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="mb-4 rounded-none -mx-4 border-x-0 sm:border-x sm:rounded-lg sm:mx-0">
|
||||
<Card className="mb-4 rounded-none -mx-4 border-x-0 sm:border-x sm:rounded-lg sm:mx-0" data-testid="expenses-content">
|
||||
<div className="flex flex-1">
|
||||
<CardHeader className="flex-1 p-4 sm:p-6">
|
||||
<CardTitle>{t('title')}</CardTitle>
|
||||
|
||||
@@ -16,7 +16,9 @@ export const GroupHeader = () => {
|
||||
{isLoading ? (
|
||||
<Skeleton className="mt-1.5 mb-1.5 h-5 w-32" />
|
||||
) : (
|
||||
<div className="flex">{group.name}</div>
|
||||
<div className="flex" data-testid="group-name">
|
||||
{group.name}
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
</h1>
|
||||
|
||||
@@ -23,12 +23,12 @@ export function GroupTabs({ groupId }: Props) {
|
||||
}}
|
||||
>
|
||||
<TabsList>
|
||||
<TabsTrigger value="expenses">{t('Expenses.title')}</TabsTrigger>
|
||||
<TabsTrigger value="balances">{t('Balances.title')}</TabsTrigger>
|
||||
<TabsTrigger value="information">{t('Information.title')}</TabsTrigger>
|
||||
<TabsTrigger value="stats">{t('Stats.title')}</TabsTrigger>
|
||||
<TabsTrigger value="activity">{t('Activity.title')}</TabsTrigger>
|
||||
<TabsTrigger value="edit">{t('Settings.title')}</TabsTrigger>
|
||||
<TabsTrigger value="expenses" data-testid="tab-expenses">{t('Expenses.title')}</TabsTrigger>
|
||||
<TabsTrigger value="balances" data-testid="tab-balances">{t('Balances.title')}</TabsTrigger>
|
||||
<TabsTrigger value="information" data-testid="tab-information">{t('Information.title')}</TabsTrigger>
|
||||
<TabsTrigger value="stats" data-testid="tab-stats">{t('Stats.title')}</TabsTrigger>
|
||||
<TabsTrigger value="activity" data-testid="tab-activity">{t('Activity.title')}</TabsTrigger>
|
||||
<TabsTrigger value="edit" data-testid="tab-edit">{t('Settings.title')}</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
)
|
||||
|
||||
@@ -20,7 +20,7 @@ export default function GroupInformation({ groupId }: { groupId: string }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="mb-4">
|
||||
<Card className="mb-4" data-testid="information-content">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex justify-between">
|
||||
<span>{t('title')}</span>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
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'
|
||||
@@ -9,7 +8,7 @@ import Link from 'next/link'
|
||||
type Props = {
|
||||
reimbursements: Reimbursement[]
|
||||
participants: Participant[]
|
||||
currency: Currency
|
||||
currency: string
|
||||
groupId: string
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ export function TotalsPageClient() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="mb-4">
|
||||
<Card className="mb-4" data-testid="stats-content">
|
||||
<CardHeader>
|
||||
<CardTitle>{t('Totals.title')}</CardTitle>
|
||||
<CardDescription>{t('Totals.description')}</CardDescription>
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { Currency } from '@/lib/currency'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
import { useLocale, useTranslations } from 'next-intl'
|
||||
|
||||
type Props = {
|
||||
totalGroupSpendings: number
|
||||
currency: Currency
|
||||
currency: string
|
||||
}
|
||||
|
||||
export function TotalsGroupSpending({ totalGroupSpendings, currency }: Props) {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
'use client'
|
||||
import { Currency } from '@/lib/currency'
|
||||
import { cn, formatCurrency } from '@/lib/utils'
|
||||
import { useLocale, useTranslations } from 'next-intl'
|
||||
|
||||
@@ -8,7 +7,7 @@ export function TotalsYourShare({
|
||||
currency,
|
||||
}: {
|
||||
totalParticipantShare?: number
|
||||
currency: Currency
|
||||
currency: string
|
||||
}) {
|
||||
const locale = useLocale()
|
||||
const t = useTranslations('Stats.Totals')
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
'use client'
|
||||
import { Currency } from '@/lib/currency'
|
||||
import { cn, formatCurrency } from '@/lib/utils'
|
||||
import { useLocale, useTranslations } from 'next-intl'
|
||||
|
||||
@@ -8,7 +7,7 @@ export function TotalsYourSpendings({
|
||||
currency,
|
||||
}: {
|
||||
totalParticipantSpendings?: number
|
||||
currency: Currency
|
||||
currency: string
|
||||
}) {
|
||||
const locale = useLocale()
|
||||
const t = useTranslations('Stats.Totals')
|
||||
|
||||
@@ -4,7 +4,6 @@ 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'
|
||||
|
||||
@@ -34,23 +33,21 @@ export function Totals() {
|
||||
totalParticipantSpendings,
|
||||
} = data
|
||||
|
||||
const currency = getCurrencyFromGroup(group)
|
||||
|
||||
return (
|
||||
<>
|
||||
<TotalsGroupSpending
|
||||
totalGroupSpendings={totalGroupSpendings}
|
||||
currency={currency}
|
||||
currency={group.currency}
|
||||
/>
|
||||
{participantId && (
|
||||
<>
|
||||
<TotalsYourSpendings
|
||||
totalParticipantSpendings={totalParticipantSpendings}
|
||||
currency={currency}
|
||||
currency={group.currency}
|
||||
/>
|
||||
<TotalsYourShare
|
||||
totalParticipantShare={totalParticipantShare}
|
||||
currency={currency}
|
||||
currency={group.currency}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,198 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -17,7 +17,7 @@ export function DeletePopup({ onDelete }: { onDelete: () => Promise<void> }) {
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="destructive">
|
||||
<Button variant="destructive" data-testid="delete-expense-button">
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
{t('label')}
|
||||
</Button>
|
||||
@@ -31,6 +31,7 @@ export function DeletePopup({ onDelete }: { onDelete: () => Promise<void> }) {
|
||||
variant="destructive"
|
||||
loadingContent="Deleting…"
|
||||
action={onDelete}
|
||||
data-testid="confirm-delete-button"
|
||||
>
|
||||
{t('yes')}
|
||||
</AsyncButton>
|
||||
|
||||
@@ -30,17 +30,14 @@ 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 { useLocale, useTranslations } from 'next-intl'
|
||||
import { 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 = {
|
||||
@@ -57,7 +54,6 @@ export function GroupForm({
|
||||
onSubmit,
|
||||
protectedParticipantIds = [],
|
||||
}: Props) {
|
||||
const locale = useLocale()
|
||||
const t = useTranslations('GroupForm')
|
||||
const form = useForm<GroupFormValues>({
|
||||
resolver: zodResolver(groupFormSchema),
|
||||
@@ -66,13 +62,12 @@ export function GroupForm({
|
||||
name: group.name,
|
||||
information: group.information ?? '',
|
||||
currency: group.currency,
|
||||
currencyCode: group.currencyCode,
|
||||
participants: group.participants,
|
||||
}
|
||||
: {
|
||||
name: '',
|
||||
information: '',
|
||||
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
|
||||
currency: process.env.NEXT_PUBLIC_DEFAULT_CURRENCY_SYMBOL || '',
|
||||
participants: [
|
||||
{ name: t('Participants.John') },
|
||||
{ name: t('Participants.Jane') },
|
||||
@@ -148,50 +143,11 @@ export function GroupForm({
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="currencyCode"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('CurrencyCodeField.label')}</FormLabel>
|
||||
<CurrencySelector
|
||||
currencies={defaultCurrencyList(
|
||||
locale as Locale,
|
||||
t('CurrencyCodeField.customOption'),
|
||||
)}
|
||||
defaultValue={form.watch(field.name) ?? ''}
|
||||
onValueChange={(newCurrency) => {
|
||||
field.onChange(newCurrency)
|
||||
const currency = getCurrency(newCurrency)
|
||||
if (
|
||||
currency.code.length ||
|
||||
form.getFieldState('currency').isTouched
|
||||
)
|
||||
form.setValue('currency', currency.symbol, {
|
||||
shouldValidate: true,
|
||||
shouldTouch: true,
|
||||
shouldDirty: true,
|
||||
})
|
||||
}}
|
||||
isLoading={false}
|
||||
/>
|
||||
<FormDescription>
|
||||
{t(
|
||||
group
|
||||
? 'CurrencyCodeField.editDescription'
|
||||
: 'CurrencyCodeField.createDescription',
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="currency"
|
||||
render={({ field }) => (
|
||||
<FormItem hidden={!!form.watch('currencyCode')?.length}>
|
||||
<FormItem>
|
||||
<FormLabel>{t('CurrencyField.label')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
'use client'
|
||||
import { Currency } from '@/lib/currency'
|
||||
import { cn, formatCurrency } from '@/lib/utils'
|
||||
import { useLocale } from 'next-intl'
|
||||
|
||||
type Props = {
|
||||
currency: Currency
|
||||
currency: string
|
||||
amount: number
|
||||
bold?: boolean
|
||||
colored?: boolean
|
||||
|
||||
17
src/i18n.ts
17
src/i18n.ts
@@ -1,4 +1,3 @@
|
||||
import deepmerge from 'deepmerge'
|
||||
import { getRequestConfig } from 'next-intl/server'
|
||||
import { getUserLocale } from './lib/locale'
|
||||
|
||||
@@ -10,7 +9,6 @@ export const localeLabels = {
|
||||
'de-DE': 'Deutsch',
|
||||
'zh-CN': '简体中文',
|
||||
'zh-TW': '正體中文',
|
||||
'ja-JP': '日本語',
|
||||
'pl-PL': 'Polski',
|
||||
'ru-RU': 'Русский',
|
||||
'it-IT': 'Italiano',
|
||||
@@ -19,8 +17,6 @@ export const localeLabels = {
|
||||
'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(
|
||||
@@ -32,20 +28,9 @@ 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,
|
||||
messages: (await import(`../messages/${locale}.json`)).default,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -19,7 +19,6 @@ 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 }) => ({
|
||||
@@ -71,9 +70,6 @@ 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,
|
||||
@@ -209,9 +205,6 @@ export async function updateExpense(
|
||||
data: {
|
||||
expenseDate: expenseFormValues.expenseDate,
|
||||
amount: expenseFormValues.amount,
|
||||
originalAmount: expenseFormValues.originalAmount,
|
||||
originalCurrency: expenseFormValues.originalCurrency,
|
||||
conversionRate: expenseFormValues.conversionRate,
|
||||
title: expenseFormValues.title,
|
||||
categoryId: expenseFormValues.category,
|
||||
paidById: expenseFormValues.paidBy,
|
||||
@@ -300,7 +293,6 @@ 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),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,89 +0,0 @@
|
||||
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,7 +21,7 @@ const envSchema = z
|
||||
interpretEnvVarAsBool,
|
||||
z.boolean().default(false),
|
||||
),
|
||||
NEXT_PUBLIC_DEFAULT_CURRENCY_CODE: z.string().optional(),
|
||||
NEXT_PUBLIC_DEFAULT_CURRENCY_SYMBOL: z.string().optional(),
|
||||
S3_UPLOAD_KEY: z.string().optional(),
|
||||
S3_UPLOAD_SECRET: z.string().optional(),
|
||||
S3_UPLOAD_BUCKET: z.string().optional(),
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
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,6 +1,4 @@
|
||||
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 => {
|
||||
@@ -66,62 +64,3 @@ 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,8 +17,7 @@ function getAcceptLanguageLocale(requestHeaders: Headers, locales: Locales) {
|
||||
try {
|
||||
locale = match(languages, locales, defaultLocale)
|
||||
} catch (e) {
|
||||
// invalid language - fallback to default
|
||||
locale = defaultLocale
|
||||
// invalid language
|
||||
}
|
||||
return locale
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ 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({
|
||||
@@ -32,19 +31,6 @@ 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(),
|
||||
@@ -61,34 +47,18 @@ export const expenseFormSchema = z
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'invalidNumber',
|
||||
})
|
||||
return valueAsNumber
|
||||
return Math.round(valueAsNumber * 100)
|
||||
}),
|
||||
],
|
||||
{ required_error: 'amountRequired' },
|
||||
)
|
||||
.refine((amount) => amount != 0, 'amountNotZero')
|
||||
.refine((amount) => amount <= 10_000_000_00, 'amountTenMillion'),
|
||||
originalAmount: z
|
||||
.union([
|
||||
z.literal('').transform(() => undefined),
|
||||
inputCoercedToNumber
|
||||
.refine((amount) => amount != 0, 'amountNotZero')
|
||||
.refine((amount) => amount <= 10_000_000_00, 'amountTenMillion'),
|
||||
])
|
||||
.optional(),
|
||||
originalCurrency: z.union([z.string().length(3).nullish(), z.literal('')]),
|
||||
conversionRate: z
|
||||
.union([
|
||||
z.literal('').transform(() => undefined),
|
||||
inputCoercedToNumber.refine((amount) => amount > 0, 'ratePositive'),
|
||||
])
|
||||
.optional(),
|
||||
paidBy: z.string({ required_error: 'paidByRequired' }),
|
||||
paidFor: z
|
||||
.array(
|
||||
z.object({
|
||||
participant: z.string(),
|
||||
originalAmount: z.string().optional(), // For converting shares by amounts in original currency, not saved.
|
||||
shares: z.union([
|
||||
z.number(),
|
||||
z.string().transform((value, ctx) => {
|
||||
@@ -99,16 +69,17 @@ export const expenseFormSchema = z
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'invalidNumber',
|
||||
})
|
||||
return value
|
||||
return Math.round(valueAsNumber * 100)
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
)
|
||||
.min(1, 'paidForMin1')
|
||||
.superRefine((paidFor, ctx) => {
|
||||
let sum = 0
|
||||
for (const { shares } of paidFor) {
|
||||
const shareNumber = Number(shares)
|
||||
if (shareNumber <= 0) {
|
||||
sum += shares
|
||||
if (shares < 1) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'noZeroShares',
|
||||
@@ -141,19 +112,18 @@ export const expenseFormSchema = z
|
||||
.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': {
|
||||
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)) {
|
||||
if (sum !== expense.amount) {
|
||||
const detail =
|
||||
sum < expense.amount
|
||||
? `${((expense.amount - sum) / 100).toFixed(2)} missing`
|
||||
@@ -167,14 +137,6 @@ 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
|
||||
@@ -190,27 +152,6 @@ 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>
|
||||
|
||||
|
||||
@@ -1,16 +1,7 @@
|
||||
import { Currency } from './currency'
|
||||
import { formatCurrency } from './utils'
|
||||
|
||||
describe('formatCurrency', () => {
|
||||
const currency: Currency = {
|
||||
name: 'Test',
|
||||
symbol_native: '',
|
||||
symbol: 'CUR',
|
||||
code: '',
|
||||
name_plural: '',
|
||||
rounding: 0,
|
||||
decimal_digits: 2,
|
||||
}
|
||||
const currency = 'CUR'
|
||||
/** For testing decimals */
|
||||
const partialAmount = 1.23
|
||||
/** For testing small full amounts */
|
||||
@@ -36,32 +27,32 @@ describe('formatCurrency', () => {
|
||||
{
|
||||
amount: partialAmount,
|
||||
locale: `en-US`,
|
||||
result: `${currency.symbol}1.23`,
|
||||
result: `${currency}1.23`,
|
||||
},
|
||||
{
|
||||
amount: smallAmount,
|
||||
locale: `en-US`,
|
||||
result: `${currency.symbol}1.00`,
|
||||
result: `${currency}1.00`,
|
||||
},
|
||||
{
|
||||
amount: largeAmount,
|
||||
locale: `en-US`,
|
||||
result: `${currency.symbol}10,000.00`,
|
||||
result: `${currency}10,000.00`,
|
||||
},
|
||||
{
|
||||
amount: partialAmount,
|
||||
locale: `de-DE`,
|
||||
result: `1,23${nbsp}${currency.symbol}`,
|
||||
result: `1,23${nbsp}${currency}`,
|
||||
},
|
||||
{
|
||||
amount: smallAmount,
|
||||
locale: `de-DE`,
|
||||
result: `1,00${nbsp}${currency.symbol}`,
|
||||
result: `1,00${nbsp}${currency}`,
|
||||
},
|
||||
{
|
||||
amount: largeAmount,
|
||||
locale: `de-DE`,
|
||||
result: `10.000,00${nbsp}${currency.symbol}`,
|
||||
result: `10.000,00${nbsp}${currency}`,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Category, Group } from '@prisma/client'
|
||||
import { Category } 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))
|
||||
@@ -34,85 +33,20 @@ 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: Currency,
|
||||
currency: string,
|
||||
amount: number,
|
||||
locale: string,
|
||||
fractions?: boolean,
|
||||
) {
|
||||
const format = new Intl.NumberFormat(locale, {
|
||||
minimumFractionDigits: currency.decimal_digits,
|
||||
maximumFractionDigits: currency.decimal_digits,
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
style: 'currency',
|
||||
// '€' will be placed in correct position
|
||||
currency: currency.code.length ? currency.code : 'EUR',
|
||||
currency: 'EUR',
|
||||
})
|
||||
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)
|
||||
const formattedAmount = format.format(fractions ? amount : amount / 100)
|
||||
return formattedAmount.replace('€', currency)
|
||||
}
|
||||
|
||||
export function formatFileSize(size: number, locale: string) {
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
// @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))
|
||||
@@ -1,5 +1,4 @@
|
||||
'use client' // <-- to make sure we can mount the Provider from a server component
|
||||
import { Prisma } from '@prisma/client'
|
||||
import type { QueryClient } from '@tanstack/react-query'
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { httpBatchLink } from '@trpc/client'
|
||||
@@ -9,15 +8,6 @@ import superjson from 'superjson'
|
||||
import { makeQueryClient } from './query-client'
|
||||
import type { AppRouter } from './routers/_app'
|
||||
|
||||
superjson.registerCustom<Prisma.Decimal, string>(
|
||||
{
|
||||
isApplicable: (v): v is Prisma.Decimal => Prisma.Decimal.isDecimal(v),
|
||||
serialize: (v) => v.toJSON(),
|
||||
deserialize: (v) => new Prisma.Decimal(v),
|
||||
},
|
||||
'decimal.js',
|
||||
)
|
||||
|
||||
export const trpc = createTRPCReact<AppRouter>()
|
||||
|
||||
let clientQueryClientSingleton: QueryClient
|
||||
|
||||
@@ -1,17 +1,7 @@
|
||||
import { Prisma } from '@prisma/client'
|
||||
import { initTRPC } from '@trpc/server'
|
||||
import { cache } from 'react'
|
||||
import superjson from 'superjson'
|
||||
|
||||
superjson.registerCustom<Prisma.Decimal, string>(
|
||||
{
|
||||
isApplicable: (v): v is Prisma.Decimal => Prisma.Decimal.isDecimal(v),
|
||||
serialize: (v) => v.toJSON(),
|
||||
deserialize: (v) => new Prisma.Decimal(v),
|
||||
},
|
||||
'decimal.js',
|
||||
)
|
||||
|
||||
export const createTRPCContext = cache(async () => {
|
||||
/**
|
||||
* @see: https://trpc.io/docs/server/context
|
||||
|
||||
262
tests/balances-and-reimbursements.spec.ts
Normal file
262
tests/balances-and-reimbursements.spec.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
import { CreateGroupPage } from './pom/create-group-page'
|
||||
import { ExpensePage } from './pom/expense-page'
|
||||
import { GroupPage } from './pom/group-page'
|
||||
import { BalancePage } from './pom/balance-page'
|
||||
import { testExpenses, generateUniqueExpenseTitle } from './test-data/expenses'
|
||||
import { generateUniqueGroupName } from './test-data/groups'
|
||||
import { CalculationUtils } from './utils/calculations'
|
||||
import { ReliabilityUtils } from './utils/reliability'
|
||||
|
||||
test.describe('Balance Calculation and Reimbursements', () => {
|
||||
test('View participant balances after expenses', async ({ page }) => {
|
||||
const createGroupPage = new CreateGroupPage(page)
|
||||
const groupPage = new GroupPage(page)
|
||||
const expensePage = new ExpensePage(page)
|
||||
const balancePage = new BalancePage(page)
|
||||
|
||||
const groupName = generateUniqueGroupName()
|
||||
|
||||
await test.step('Set up test group with participants', async () => {
|
||||
await createGroupPage.navigate()
|
||||
await createGroupPage.fillGroupName(groupName)
|
||||
await createGroupPage.fillCurrency('USD')
|
||||
await createGroupPage.addParticipant('Alice', 0)
|
||||
await createGroupPage.addParticipant('Bob', 1)
|
||||
await createGroupPage.submit()
|
||||
|
||||
// Wait for the group page to fully load
|
||||
await groupPage.waitForGroupPageLoad()
|
||||
await expect(groupPage.title).toHaveText(groupName)
|
||||
})
|
||||
|
||||
await test.step('Create expense paid by Alice', async () => {
|
||||
await groupPage.createExpense()
|
||||
|
||||
const expenseTitle = generateUniqueExpenseTitle()
|
||||
await expensePage.fillTitle(expenseTitle)
|
||||
await expensePage.fillAmount('20.00')
|
||||
await expensePage.selectPayer('Alice')
|
||||
await expensePage.submit()
|
||||
|
||||
// Verify expense was created
|
||||
const expenseCard = groupPage.getExpenseCard(expenseTitle)
|
||||
await expect(expenseCard).toBeVisible()
|
||||
})
|
||||
|
||||
await test.step('View balances and verify calculations', async () => {
|
||||
// Navigate to balances tab with enhanced reliability
|
||||
await ReliabilityUtils.navigateToTab(page, 'balances', /\/groups\/[^\/]+\/balances$/)
|
||||
|
||||
// Verify content loaded with multiple fallback strategies
|
||||
await ReliabilityUtils.verifyContentLoaded(page, [
|
||||
'balances-content',
|
||||
'balances-card',
|
||||
'text=Balances',
|
||||
'[data-testid="balances-content"]'
|
||||
])
|
||||
|
||||
// Alice paid $20, so she should be owed $10 (paid $20, owes $10)
|
||||
// Bob paid $0, so he should owe $10 (paid $0, owes $10)
|
||||
// The balances should sum to zero for the group
|
||||
})
|
||||
|
||||
await test.step('Create second expense paid by Bob', async () => {
|
||||
// Navigate back to expenses
|
||||
await page.getByTestId('tab-expenses').click()
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
await groupPage.createExpense()
|
||||
|
||||
const expenseTitle2 = generateUniqueExpenseTitle()
|
||||
await expensePage.fillTitle(expenseTitle2)
|
||||
await expensePage.fillAmount('30.00')
|
||||
await expensePage.selectPayer('Bob')
|
||||
await expensePage.submit()
|
||||
})
|
||||
|
||||
await test.step('Verify updated balances', async () => {
|
||||
// Navigate to balances tab with enhanced reliability
|
||||
await ReliabilityUtils.navigateToTab(page, 'balances', /\/groups\/[^\/]+\/balances$/)
|
||||
|
||||
// Verify content loaded with multiple fallback strategies
|
||||
await ReliabilityUtils.verifyContentLoaded(page, [
|
||||
'balances-content',
|
||||
'balances-card',
|
||||
'text=Balances',
|
||||
'[data-testid="balances-content"]'
|
||||
])
|
||||
|
||||
// Now Alice: paid $20, owes $25 = balance -$5 (owes $5)
|
||||
// Now Bob: paid $30, owes $25 = balance +$5 (is owed $5)
|
||||
// Total expenses: $50, split evenly: $25 each
|
||||
})
|
||||
})
|
||||
|
||||
test('Multiple expenses with different amounts', async ({ page }) => {
|
||||
const createGroupPage = new CreateGroupPage(page)
|
||||
const groupPage = new GroupPage(page)
|
||||
const expensePage = new ExpensePage(page)
|
||||
|
||||
const groupName = generateUniqueGroupName()
|
||||
const expenses = [
|
||||
{ title: `Lunch ${Date.now()}`, amount: '24.00', payer: 'Alice' },
|
||||
{ title: `Coffee ${Date.now() + 1}`, amount: '8.00', payer: 'Bob' },
|
||||
{ title: `Dinner ${Date.now() + 2}`, amount: '48.00', payer: 'Charlie' }
|
||||
]
|
||||
|
||||
await test.step('Set up test group with three participants', async () => {
|
||||
await createGroupPage.navigate()
|
||||
await createGroupPage.fillGroupName(groupName)
|
||||
await createGroupPage.fillCurrency('USD')
|
||||
await createGroupPage.addParticipant('Alice', 0)
|
||||
await createGroupPage.addParticipant('Bob', 1)
|
||||
await createGroupPage.addParticipant('Charlie', 2)
|
||||
await createGroupPage.submit()
|
||||
|
||||
// Wait for the group page to fully load
|
||||
await groupPage.waitForGroupPageLoad()
|
||||
})
|
||||
|
||||
await test.step('Create multiple expenses', async () => {
|
||||
for (const expense of expenses) {
|
||||
await groupPage.createExpense()
|
||||
|
||||
await expensePage.fillTitle(expense.title)
|
||||
await expensePage.fillAmount(expense.amount)
|
||||
await expensePage.selectPayer(expense.payer)
|
||||
await expensePage.submit()
|
||||
|
||||
// Verify expense was created
|
||||
const expenseCard = groupPage.getExpenseCard(expense.title)
|
||||
await expect(expenseCard).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
await test.step('View final balances', async () => {
|
||||
// Navigate to balances tab with enhanced reliability
|
||||
await ReliabilityUtils.navigateToTab(page, 'balances', /\/groups\/[^\/]+\/balances$/)
|
||||
|
||||
// Verify content loaded with multiple fallback strategies
|
||||
await ReliabilityUtils.verifyContentLoaded(page, [
|
||||
'balances-content',
|
||||
'balances-card',
|
||||
'text=Balances',
|
||||
'[data-testid="balances-content"]'
|
||||
])
|
||||
|
||||
// Total expenses: $24 + $8 + $48 = $80
|
||||
// Split 3 ways: $80 / 3 = $26.67 each
|
||||
// Alice: paid $24, owes $26.67 = balance -$2.67
|
||||
// Bob: paid $8, owes $26.67 = balance -$18.67
|
||||
// Charlie: paid $48, owes $26.67 = balance +$21.33
|
||||
// Balances should sum to zero: -2.67 + -18.67 + 21.33 = -0.01 (due to rounding)
|
||||
})
|
||||
})
|
||||
|
||||
test('Single person pays all expenses', async ({ page }) => {
|
||||
const createGroupPage = new CreateGroupPage(page)
|
||||
const groupPage = new GroupPage(page)
|
||||
const expensePage = new ExpensePage(page)
|
||||
|
||||
const groupName = generateUniqueGroupName()
|
||||
|
||||
await test.step('Set up test group', async () => {
|
||||
await createGroupPage.navigate()
|
||||
await createGroupPage.fillGroupName(groupName)
|
||||
await createGroupPage.fillCurrency('USD')
|
||||
await createGroupPage.addParticipant('Payer', 0)
|
||||
await createGroupPage.addParticipant('Person1', 1)
|
||||
await createGroupPage.addParticipant('Person2', 2)
|
||||
await createGroupPage.submit()
|
||||
|
||||
// Wait for the group page to fully load
|
||||
await groupPage.waitForGroupPageLoad()
|
||||
})
|
||||
|
||||
await test.step('Create expenses all paid by same person', async () => {
|
||||
const expenses = [
|
||||
{ title: `Expense1 ${Date.now()}`, amount: '15.00' },
|
||||
{ title: `Expense2 ${Date.now() + 1}`, amount: '30.00' },
|
||||
{ title: `Expense3 ${Date.now() + 2}`, amount: '45.00' }
|
||||
]
|
||||
|
||||
for (const expense of expenses) {
|
||||
await groupPage.createExpense()
|
||||
|
||||
await expensePage.fillTitle(expense.title)
|
||||
await expensePage.fillAmount(expense.amount)
|
||||
await expensePage.selectPayer('Payer')
|
||||
await expensePage.submit()
|
||||
}
|
||||
})
|
||||
|
||||
await test.step('Verify balances show correct amounts owed', async () => {
|
||||
// Navigate to balances tab with enhanced reliability
|
||||
await ReliabilityUtils.navigateToTab(page, 'balances', /\/groups\/[^\/]+\/balances$/)
|
||||
|
||||
// Verify content loaded with multiple fallback strategies
|
||||
await ReliabilityUtils.verifyContentLoaded(page, [
|
||||
'balances-content',
|
||||
'balances-card',
|
||||
'text=Balances',
|
||||
'[data-testid="balances-content"]'
|
||||
])
|
||||
|
||||
// Total expenses: $15 + $30 + $45 = $90
|
||||
// Split 3 ways: $30 each
|
||||
// Payer: paid $90, owes $30 = balance +$60 (is owed $60)
|
||||
// Person1: paid $0, owes $30 = balance -$30 (owes $30)
|
||||
// Person2: paid $0, owes $30 = balance -$30 (owes $30)
|
||||
// Total: +60 - 30 - 30 = 0 ✓
|
||||
})
|
||||
})
|
||||
|
||||
test('Equal split verification', async ({ page }) => {
|
||||
const createGroupPage = new CreateGroupPage(page)
|
||||
const groupPage = new GroupPage(page)
|
||||
const expensePage = new ExpensePage(page)
|
||||
|
||||
const groupName = generateUniqueGroupName()
|
||||
|
||||
await test.step('Set up test group', async () => {
|
||||
await createGroupPage.navigate()
|
||||
await createGroupPage.fillGroupName(groupName)
|
||||
await createGroupPage.fillCurrency('USD')
|
||||
await createGroupPage.addParticipant('User1', 0)
|
||||
await createGroupPage.addParticipant('User2', 1)
|
||||
await createGroupPage.submit()
|
||||
|
||||
// Wait for the group page to fully load
|
||||
await groupPage.waitForGroupPageLoad()
|
||||
})
|
||||
|
||||
await test.step('Create expense with even amount', async () => {
|
||||
await groupPage.createExpense()
|
||||
|
||||
const expenseTitle = generateUniqueExpenseTitle()
|
||||
await expensePage.fillTitle(expenseTitle)
|
||||
await expensePage.fillAmount('100.00')
|
||||
await expensePage.selectPayer('User1')
|
||||
await expensePage.submit()
|
||||
})
|
||||
|
||||
await test.step('Verify equal split calculation', async () => {
|
||||
// Navigate to balances tab with enhanced reliability
|
||||
await ReliabilityUtils.navigateToTab(page, 'balances', /\/balances$/)
|
||||
|
||||
// $100 split evenly between 2 people = $50 each
|
||||
// User1: paid $100, owes $50 = balance +$50 (is owed $50)
|
||||
// User2: paid $0, owes $50 = balance -$50 (owes $50)
|
||||
|
||||
// Verify content loaded with multiple fallback strategies
|
||||
await ReliabilityUtils.verifyContentLoaded(page, [
|
||||
'balances-content',
|
||||
'balances-card',
|
||||
'text=Balances',
|
||||
'[data-testid="balances-content"]',
|
||||
'text=USD'
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
38
tests/create-group.spec.ts
Normal file
38
tests/create-group.spec.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
import { CreateGroupPage } from './pom/create-group-page'
|
||||
import { ExpensePage } from './pom/expense-page'
|
||||
import { GroupPage } from './pom/group-page'
|
||||
|
||||
test('Create a new group and add an expense', async ({ page }) => {
|
||||
const createGroupPage = new CreateGroupPage(page)
|
||||
const groupPage = new GroupPage(page)
|
||||
const expensePage = new ExpensePage(page)
|
||||
|
||||
await test.step('Create a new group', async () => {
|
||||
await createGroupPage.navigate()
|
||||
await createGroupPage.fillGroupName('New Test Group')
|
||||
await createGroupPage.fillCurrency('USD')
|
||||
await createGroupPage.fillAdditionalInfo('This is a test group.')
|
||||
await createGroupPage.addParticipant('John', 0)
|
||||
await createGroupPage.addParticipant('Jane', 1)
|
||||
await createGroupPage.submit()
|
||||
})
|
||||
|
||||
await test.step('Check that the group is created', async () => {
|
||||
await expect(groupPage.title).toHaveText('New Test Group')
|
||||
})
|
||||
|
||||
await test.step('Create an expense', async () => {
|
||||
await groupPage.createExpense()
|
||||
await expensePage.fillTitle('Coffee')
|
||||
await expensePage.fillAmount('4.5')
|
||||
await expensePage.selectPayer('John')
|
||||
await expensePage.submit()
|
||||
})
|
||||
|
||||
await test.step('Check that the expense is created', async () => {
|
||||
const expenseCard = groupPage.getExpenseCard('Coffee')
|
||||
await expect(expenseCard).toBeVisible()
|
||||
await expect(expenseCard.locator('[data-amount]')).toHaveText('USD4.50')
|
||||
})
|
||||
})
|
||||
45
tests/expense-basic-simple.spec.ts
Normal file
45
tests/expense-basic-simple.spec.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
import { CreateGroupPage } from './pom/create-group-page'
|
||||
import { ExpensePage } from './pom/expense-page'
|
||||
import { GroupPage } from './pom/group-page'
|
||||
import { testExpenses, generateUniqueExpenseTitle } from './test-data/expenses'
|
||||
import { generateUniqueGroupName } from './test-data/groups'
|
||||
|
||||
test('Simple expense creation', async ({ page }) => {
|
||||
const createGroupPage = new CreateGroupPage(page)
|
||||
const groupPage = new GroupPage(page)
|
||||
const expensePage = new ExpensePage(page)
|
||||
|
||||
const groupName = generateUniqueGroupName()
|
||||
const expenseTitle = generateUniqueExpenseTitle()
|
||||
const expenseData = { ...testExpenses.simple, title: expenseTitle }
|
||||
|
||||
await test.step('Set up test group', async () => {
|
||||
await createGroupPage.navigate()
|
||||
await createGroupPage.fillGroupName(groupName)
|
||||
await createGroupPage.fillCurrency('USD')
|
||||
await createGroupPage.addParticipant('Alice', 0)
|
||||
await createGroupPage.addParticipant('Bob', 1)
|
||||
await createGroupPage.submit()
|
||||
|
||||
await expect(groupPage.title).toHaveText(groupName)
|
||||
})
|
||||
|
||||
await test.step('Create new expense', async () => {
|
||||
await groupPage.createExpense()
|
||||
|
||||
await expensePage.fillTitle(expenseData.title)
|
||||
await expensePage.fillAmount(expenseData.amount)
|
||||
await expensePage.selectPayer('Alice')
|
||||
await expensePage.submit()
|
||||
})
|
||||
|
||||
await test.step('Verify expense is created and displayed', async () => {
|
||||
// Should navigate back to expenses page
|
||||
await expect(page).toHaveURL(/\/groups\/[^\/]+\/expenses$/)
|
||||
|
||||
const expenseCard = groupPage.getExpenseCard(expenseData.title)
|
||||
await expect(expenseCard).toBeVisible()
|
||||
await expect(expenseCard.locator('[data-amount]')).toHaveText('USD4.50')
|
||||
})
|
||||
})
|
||||
176
tests/expense-basic.spec.ts
Normal file
176
tests/expense-basic.spec.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
import { CreateGroupPage } from './pom/create-group-page'
|
||||
import { ExpensePage } from './pom/expense-page'
|
||||
import { GroupPage } from './pom/group-page'
|
||||
import { testExpenses, generateUniqueExpenseTitle } from './test-data/expenses'
|
||||
import { generateUniqueGroupName } from './test-data/groups'
|
||||
|
||||
test.describe('Basic Expense Management', () => {
|
||||
test('Create, view, edit, and delete expense', async ({ page }) => {
|
||||
const createGroupPage = new CreateGroupPage(page)
|
||||
const groupPage = new GroupPage(page)
|
||||
const expensePage = new ExpensePage(page)
|
||||
|
||||
const groupName = generateUniqueGroupName()
|
||||
const expenseTitle = generateUniqueExpenseTitle()
|
||||
const expenseData = { ...testExpenses.simple, title: expenseTitle }
|
||||
|
||||
await test.step('Set up test group', async () => {
|
||||
await createGroupPage.navigate()
|
||||
await createGroupPage.fillGroupName(groupName)
|
||||
await createGroupPage.fillCurrency('USD')
|
||||
await createGroupPage.addParticipant('Alice', 0)
|
||||
await createGroupPage.addParticipant('Bob', 1)
|
||||
await createGroupPage.submit()
|
||||
|
||||
await expect(groupPage.title).toHaveText(groupName)
|
||||
})
|
||||
|
||||
await test.step('Create new expense', async () => {
|
||||
await groupPage.createExpense()
|
||||
|
||||
await expensePage.fillTitle(expenseData.title)
|
||||
await expensePage.fillAmount(expenseData.amount)
|
||||
await expensePage.selectPayer('Alice')
|
||||
await expensePage.submit()
|
||||
})
|
||||
|
||||
await test.step('Verify expense is created and displayed', async () => {
|
||||
const expenseCard = groupPage.getExpenseCard(expenseData.title)
|
||||
await expect(expenseCard).toBeVisible()
|
||||
await expect(expenseCard.locator('[data-amount]')).toHaveText('USD4.50')
|
||||
})
|
||||
|
||||
await test.step('Edit the expense', async () => {
|
||||
const expenseCard = groupPage.getExpenseCard(expenseData.title)
|
||||
await expenseCard.click()
|
||||
|
||||
// Should navigate to expense edit page
|
||||
await expect(page).toHaveURL(/\/expenses\/[^\/]+\/edit$/)
|
||||
|
||||
// Update the expense
|
||||
const newTitle = `${expenseData.title} - Updated`
|
||||
const newAmount = '6.75'
|
||||
|
||||
await expensePage.fillTitle(newTitle)
|
||||
await expensePage.fillAmount(newAmount)
|
||||
await expensePage.submit()
|
||||
|
||||
// Verify we're back to the group expenses page
|
||||
await expect(page).toHaveURL(/\/groups\/[^\/]+\/expenses$/)
|
||||
})
|
||||
|
||||
await test.step('Verify expense was updated', async () => {
|
||||
const updatedExpenseCard = groupPage.getExpenseCard(`${expenseData.title} - Updated`)
|
||||
await expect(updatedExpenseCard).toBeVisible()
|
||||
await expect(updatedExpenseCard.locator('[data-amount]')).toHaveText('USD6.75')
|
||||
})
|
||||
|
||||
await test.step('Delete the expense', async () => {
|
||||
const expenseCard = groupPage.getExpenseCard(`${expenseData.title} - Updated`)
|
||||
await expenseCard.click()
|
||||
|
||||
// Should be on edit page
|
||||
await expect(page).toHaveURL(/\/expenses\/[^\/]+\/edit$/)
|
||||
|
||||
// Click delete button
|
||||
await page.getByTestId('delete-expense-button').click()
|
||||
|
||||
// Confirm deletion
|
||||
await page.getByTestId('confirm-delete-button').click()
|
||||
|
||||
// Should be back to group expenses page
|
||||
await expect(page).toHaveURL(/\/groups\/[^\/]+\/expenses$/)
|
||||
})
|
||||
|
||||
await test.step('Verify expense was deleted', async () => {
|
||||
// The expense should no longer be visible
|
||||
const expenseCard = groupPage.getExpenseCard(`${expenseData.title} - Updated`)
|
||||
await expect(expenseCard).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test('Create expense with notes', async ({ page }) => {
|
||||
const createGroupPage = new CreateGroupPage(page)
|
||||
const groupPage = new GroupPage(page)
|
||||
const expensePage = new ExpensePage(page)
|
||||
|
||||
const groupName = generateUniqueGroupName()
|
||||
const expenseTitle = generateUniqueExpenseTitle()
|
||||
const expenseData = { ...testExpenses.restaurant, title: expenseTitle }
|
||||
|
||||
await test.step('Set up test group', async () => {
|
||||
await createGroupPage.navigate()
|
||||
await createGroupPage.fillGroupName(groupName)
|
||||
await createGroupPage.fillCurrency('USD')
|
||||
await createGroupPage.addParticipant('John', 0)
|
||||
await createGroupPage.addParticipant('Jane', 1)
|
||||
await createGroupPage.submit()
|
||||
})
|
||||
|
||||
await test.step('Create expense with notes', async () => {
|
||||
await groupPage.createExpense()
|
||||
|
||||
await expensePage.fillTitle(expenseData.title)
|
||||
await expensePage.fillAmount(expenseData.amount)
|
||||
await expensePage.selectPayer('John')
|
||||
|
||||
// Add notes if the field exists
|
||||
const notesField = page.getByTestId('expense-notes-input')
|
||||
if (await notesField.isVisible()) {
|
||||
await notesField.fill(expenseData.notes)
|
||||
}
|
||||
|
||||
await expensePage.submit()
|
||||
})
|
||||
|
||||
await test.step('Verify expense with notes is created', async () => {
|
||||
const expenseCard = groupPage.getExpenseCard(expenseData.title)
|
||||
await expect(expenseCard).toBeVisible()
|
||||
await expect(expenseCard.locator('[data-amount]')).toHaveText('USD85.20')
|
||||
})
|
||||
})
|
||||
|
||||
test('Create multiple expenses and verify list', async ({ page }) => {
|
||||
const createGroupPage = new CreateGroupPage(page)
|
||||
const groupPage = new GroupPage(page)
|
||||
const expensePage = new ExpensePage(page)
|
||||
|
||||
const groupName = generateUniqueGroupName()
|
||||
const expenses = [
|
||||
{ ...testExpenses.coffee, title: `Coffee ${Date.now()}` },
|
||||
{ ...testExpenses.transport, title: `Transport ${Date.now() + 1}` },
|
||||
{ ...testExpenses.grocery, title: `Grocery ${Date.now() + 2}` }
|
||||
]
|
||||
|
||||
await test.step('Set up test group', async () => {
|
||||
await createGroupPage.navigate()
|
||||
await createGroupPage.fillGroupName(groupName)
|
||||
await createGroupPage.fillCurrency('USD')
|
||||
await createGroupPage.addParticipant('User1', 0)
|
||||
await createGroupPage.addParticipant('User2', 1)
|
||||
await createGroupPage.submit()
|
||||
})
|
||||
|
||||
await test.step('Create multiple expenses', async () => {
|
||||
for (const expense of expenses) {
|
||||
await groupPage.createExpense()
|
||||
|
||||
await expensePage.fillTitle(expense.title)
|
||||
await expensePage.fillAmount(expense.amount)
|
||||
await expensePage.selectPayer('User1')
|
||||
await expensePage.submit()
|
||||
|
||||
// Wait for navigation back to group expenses page
|
||||
await expect(page).toHaveURL(/\/groups\/[^\/]+\/expenses$/)
|
||||
}
|
||||
})
|
||||
|
||||
await test.step('Verify all expenses are listed', async () => {
|
||||
for (const expense of expenses) {
|
||||
const expenseCard = groupPage.getExpenseCard(expense.title)
|
||||
await expect(expenseCard).toBeVisible()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
36
tests/group-lifecycle-simple.spec.ts
Normal file
36
tests/group-lifecycle-simple.spec.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
import { CreateGroupPage } from './pom/create-group-page'
|
||||
import { GroupPage } from './pom/group-page'
|
||||
import { generateUniqueGroupName } from './test-data/groups'
|
||||
|
||||
test('Simple group creation and tab navigation', async ({ page }) => {
|
||||
const createGroupPage = new CreateGroupPage(page)
|
||||
const groupPage = new GroupPage(page)
|
||||
|
||||
const groupName = generateUniqueGroupName()
|
||||
|
||||
await test.step('Create a new group', async () => {
|
||||
await createGroupPage.navigate()
|
||||
await createGroupPage.fillGroupName(groupName)
|
||||
await createGroupPage.fillCurrency('USD')
|
||||
await createGroupPage.addParticipant('Alice', 0)
|
||||
await createGroupPage.addParticipant('Bob', 1)
|
||||
await createGroupPage.submit()
|
||||
})
|
||||
|
||||
await test.step('Verify group is created', async () => {
|
||||
await expect(groupPage.title).toHaveText(groupName)
|
||||
})
|
||||
|
||||
await test.step('Test basic tab navigation', async () => {
|
||||
// Navigate to balances tab
|
||||
await page.getByTestId('tab-balances').click()
|
||||
await expect(page).toHaveURL(/\/balances$/)
|
||||
await expect(page.getByTestId('balances-content')).toBeVisible()
|
||||
|
||||
// Navigate back to expenses tab
|
||||
await page.getByTestId('tab-expenses').click()
|
||||
await expect(page).toHaveURL(/\/expenses$/)
|
||||
await expect(page.getByTestId('expenses-content')).toBeVisible()
|
||||
})
|
||||
})
|
||||
133
tests/group-lifecycle.spec.ts
Normal file
133
tests/group-lifecycle.spec.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
import { CreateGroupPage } from './pom/create-group-page'
|
||||
import { GroupPage } from './pom/group-page'
|
||||
import { SettingsPage } from './pom/settings-page'
|
||||
import { testGroups, generateUniqueGroupName } from './test-data/groups'
|
||||
|
||||
test.describe('Group Lifecycle Management', () => {
|
||||
test('Complete group lifecycle: create, navigate tabs, edit details', async ({ page }) => {
|
||||
const createGroupPage = new CreateGroupPage(page)
|
||||
const groupPage = new GroupPage(page)
|
||||
const settingsPage = new SettingsPage(page)
|
||||
|
||||
const groupName = generateUniqueGroupName()
|
||||
const groupData = { ...testGroups.basic, name: groupName }
|
||||
|
||||
await test.step('Create a new group with participants', async () => {
|
||||
await createGroupPage.navigate()
|
||||
await createGroupPage.fillGroupName(groupData.name)
|
||||
await createGroupPage.fillCurrency(groupData.currency)
|
||||
await createGroupPage.fillAdditionalInfo(groupData.information)
|
||||
|
||||
// Add participants
|
||||
for (let i = 0; i < groupData.participants.length; i++) {
|
||||
await createGroupPage.addParticipant(groupData.participants[i], i)
|
||||
}
|
||||
|
||||
await createGroupPage.submit()
|
||||
})
|
||||
|
||||
await test.step('Verify group is created and displayed correctly', async () => {
|
||||
// Wait for the group page to fully load
|
||||
await groupPage.waitForGroupPageLoad()
|
||||
await expect(groupPage.title).toHaveText(groupData.name)
|
||||
await expect(page).toHaveURL(/\/groups\/[^\/]+/)
|
||||
})
|
||||
|
||||
await test.step('Navigate between group tabs', async () => {
|
||||
// Test navigation to each tab
|
||||
const tabs = [
|
||||
{ name: 'expenses', content: 'expenses-content' },
|
||||
{ name: 'balances', content: 'balances-content' },
|
||||
{ name: 'information', content: 'information-content' },
|
||||
{ name: 'stats', content: 'stats-content' },
|
||||
{ name: 'activity', content: 'activity-content' },
|
||||
{ name: 'edit', content: 'edit-content' }
|
||||
]
|
||||
|
||||
for (const tab of tabs) {
|
||||
await page.getByTestId(`tab-${tab.name}`).click()
|
||||
// Wait for navigation to complete
|
||||
await page.waitForLoadState('networkidle')
|
||||
await expect(page).toHaveURL(new RegExp(`\\/groups\\/[^\\/]+\\/${tab.name}`))
|
||||
|
||||
// Verify tab content loads with retry
|
||||
await expect(page.getByTestId(tab.content)).toBeVisible({ timeout: 10000 })
|
||||
}
|
||||
})
|
||||
|
||||
await test.step('Verify we can access edit page', async () => {
|
||||
// Navigate to settings if not already there
|
||||
await page.getByTestId('tab-edit').click()
|
||||
|
||||
// Just verify we can access the edit content
|
||||
await expect(page.getByTestId('edit-content')).toBeVisible()
|
||||
})
|
||||
|
||||
await test.step('Return to expenses tab', async () => {
|
||||
// Navigate back to main group page
|
||||
await page.getByTestId('tab-expenses').click()
|
||||
await expect(page.getByTestId('expenses-content')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test('Create minimal group with two participants', async ({ page }) => {
|
||||
const createGroupPage = new CreateGroupPage(page)
|
||||
const groupPage = new GroupPage(page)
|
||||
|
||||
const groupName = generateUniqueGroupName()
|
||||
const groupData = { ...testGroups.minimal, name: groupName }
|
||||
|
||||
await test.step('Create minimal group', async () => {
|
||||
await createGroupPage.navigate()
|
||||
await createGroupPage.fillGroupName(groupData.name)
|
||||
await createGroupPage.fillCurrency(groupData.currency)
|
||||
|
||||
// Add only two participants
|
||||
await createGroupPage.addParticipant(groupData.participants[0], 0)
|
||||
await createGroupPage.addParticipant(groupData.participants[1], 1)
|
||||
|
||||
await createGroupPage.submit()
|
||||
})
|
||||
|
||||
await test.step('Verify minimal group creation', async () => {
|
||||
// Wait for the group page to fully load
|
||||
await groupPage.waitForGroupPageLoad()
|
||||
await expect(groupPage.title).toHaveText(groupData.name)
|
||||
|
||||
// Verify we're on the expenses page
|
||||
await expect(page.getByTestId('expenses-content')).toBeVisible({ timeout: 10000 })
|
||||
})
|
||||
})
|
||||
|
||||
test('Create group with three participants', async ({ page }) => {
|
||||
const createGroupPage = new CreateGroupPage(page)
|
||||
const groupPage = new GroupPage(page)
|
||||
|
||||
const groupName = generateUniqueGroupName()
|
||||
const groupData = { ...testGroups.basic, name: groupName }
|
||||
|
||||
await test.step('Create group with 3 participants', async () => {
|
||||
await createGroupPage.navigate()
|
||||
await createGroupPage.fillGroupName(groupData.name)
|
||||
await createGroupPage.fillCurrency(groupData.currency)
|
||||
await createGroupPage.fillAdditionalInfo(groupData.information)
|
||||
|
||||
// Add 3 participants
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await createGroupPage.addParticipant(groupData.participants[i], i)
|
||||
}
|
||||
|
||||
await createGroupPage.submit()
|
||||
})
|
||||
|
||||
await test.step('Verify group with 3 participants is created', async () => {
|
||||
// Wait for the group page to fully load
|
||||
await groupPage.waitForGroupPageLoad()
|
||||
await expect(groupPage.title).toHaveText(groupData.name)
|
||||
|
||||
// Verify we're on the expenses page
|
||||
await expect(page.getByTestId('expenses-content')).toBeVisible({ timeout: 10000 })
|
||||
})
|
||||
})
|
||||
})
|
||||
37
tests/pom/balance-page.ts
Normal file
37
tests/pom/balance-page.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Locator, Page } from '@playwright/test'
|
||||
|
||||
export class BalancePage {
|
||||
page: Page
|
||||
balancesSection: Locator
|
||||
reimbursementsSection: Locator
|
||||
balancesList: Locator
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page
|
||||
this.balancesSection = page.getByTestId('balances-section')
|
||||
this.reimbursementsSection = page.getByTestId('reimbursements-section')
|
||||
this.balancesList = page.getByTestId('balances-list')
|
||||
}
|
||||
|
||||
async navigateToGroupBalances(groupId: string) {
|
||||
await this.page.goto(`http://localhost:3002/groups/${groupId}/balances`)
|
||||
}
|
||||
|
||||
async getParticipantBalance(participantName: string) {
|
||||
return this.page.getByTestId(`balance-${participantName.toLowerCase()}`)
|
||||
}
|
||||
|
||||
async getBalanceAmount(participantName: string) {
|
||||
const balanceElement = await this.getParticipantBalance(participantName)
|
||||
return balanceElement.getByTestId('balance-amount')
|
||||
}
|
||||
|
||||
async createReimbursementFromBalance(fromParticipant: string, toParticipant: string) {
|
||||
const reimbursementButton = this.page.getByTestId(`create-reimbursement-${fromParticipant}-${toParticipant}`)
|
||||
await reimbursementButton.click()
|
||||
}
|
||||
|
||||
async getReimbursementSuggestion(fromParticipant: string, toParticipant: string) {
|
||||
return this.page.getByTestId(`reimbursement-suggestion-${fromParticipant}-${toParticipant}`)
|
||||
}
|
||||
}
|
||||
36
tests/pom/create-group-page.ts
Normal file
36
tests/pom/create-group-page.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Page } from '@playwright/test'
|
||||
|
||||
export class CreateGroupPage {
|
||||
constructor(private page: Page) {}
|
||||
|
||||
async navigate() {
|
||||
await this.page.goto('http://localhost:3002/groups/create')
|
||||
await this.page.waitForLoadState('networkidle')
|
||||
}
|
||||
|
||||
async fillGroupName(name: string) {
|
||||
await this.page.getByRole('textbox', { name: 'Group name' }).fill(name)
|
||||
}
|
||||
|
||||
async fillCurrency(currency: string) {
|
||||
await this.page.getByRole('textbox', { name: 'Currency' }).fill(currency)
|
||||
}
|
||||
|
||||
async fillAdditionalInfo(info: string) {
|
||||
await this.page
|
||||
.getByRole('textbox', { name: 'Group Information' })
|
||||
.fill(info)
|
||||
}
|
||||
|
||||
async addParticipant(participantName: string, index: number) {
|
||||
await this.page
|
||||
.locator(`input[name="participants.${index}.name"]`)
|
||||
.fill(participantName)
|
||||
}
|
||||
|
||||
async submit() {
|
||||
await this.page.getByRole('button', { name: 'Create' }).click()
|
||||
// Wait for navigation to complete
|
||||
await this.page.waitForLoadState('networkidle')
|
||||
}
|
||||
}
|
||||
47
tests/pom/expense-page.ts
Normal file
47
tests/pom/expense-page.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Page } from '@playwright/test'
|
||||
|
||||
export class ExpensePage {
|
||||
constructor(private page: Page) {}
|
||||
|
||||
async navigateToGroupExpenses(groupId: string) {
|
||||
await this.page.goto(`http://localhost:3002/groups/${groupId}/expenses`)
|
||||
}
|
||||
|
||||
async fillTitle(expenseTitle: string) {
|
||||
await this.page
|
||||
.getByRole('textbox', { name: 'Expense title' })
|
||||
.fill(expenseTitle)
|
||||
}
|
||||
|
||||
async fillAmount(expenseAmount: string) {
|
||||
await this.page.getByRole('textbox', { name: 'Amount' }).fill(expenseAmount)
|
||||
}
|
||||
|
||||
async selectPayer(payer: string) {
|
||||
await this.page
|
||||
.getByRole('combobox')
|
||||
.filter({ hasText: 'Select a participant' })
|
||||
.click()
|
||||
await this.page.getByRole('option', { name: payer, exact: true }).click()
|
||||
}
|
||||
|
||||
async submit() {
|
||||
// Look for either Create or Save button
|
||||
const createButton = this.page.getByRole('button', { name: 'Create' })
|
||||
const saveButton = this.page.getByRole('button', { name: 'Save' })
|
||||
|
||||
if (await createButton.isVisible()) {
|
||||
await createButton.click()
|
||||
} else {
|
||||
await saveButton.click()
|
||||
}
|
||||
|
||||
// Wait for navigation to complete
|
||||
await this.page.waitForLoadState('networkidle')
|
||||
}
|
||||
|
||||
async waitForPageLoad() {
|
||||
// Wait for the expense form to be fully loaded
|
||||
await this.page.waitForLoadState('networkidle')
|
||||
}
|
||||
}
|
||||
41
tests/pom/group-page.ts
Normal file
41
tests/pom/group-page.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Locator, Page } from '@playwright/test'
|
||||
|
||||
export class GroupPage {
|
||||
page: Page
|
||||
title: Locator
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page
|
||||
this.title = page.getByTestId('group-name')
|
||||
}
|
||||
|
||||
async createExpense() {
|
||||
// Wait for the page to be in a stable state before clicking
|
||||
await this.page.waitForLoadState('networkidle')
|
||||
|
||||
// Retry clicking the create expense link if it fails
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
try {
|
||||
await this.page.getByRole('link', { name: 'Create expense' }).click({ timeout: 5000 })
|
||||
break
|
||||
} catch (error) {
|
||||
if (attempt === 2) throw error
|
||||
await this.page.waitForTimeout(1000)
|
||||
await this.page.waitForLoadState('networkidle')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async waitForGroupPageLoad() {
|
||||
// Wait for group name to be visible
|
||||
await this.title.waitFor({ state: 'visible' })
|
||||
// Wait for network to be idle
|
||||
await this.page.waitForLoadState('networkidle')
|
||||
}
|
||||
|
||||
getExpenseCard(expenseTitle: string) {
|
||||
return this.page
|
||||
.locator('[data-expense-card]')
|
||||
.filter({ hasText: expenseTitle })
|
||||
}
|
||||
}
|
||||
52
tests/pom/settings-page.ts
Normal file
52
tests/pom/settings-page.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { Locator, Page } from '@playwright/test'
|
||||
|
||||
export class SettingsPage {
|
||||
page: Page
|
||||
groupNameInput: Locator
|
||||
currencyInput: Locator
|
||||
informationTextarea: Locator
|
||||
saveButton: Locator
|
||||
participantsList: Locator
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page
|
||||
this.groupNameInput = page.getByTestId('group-name-input')
|
||||
this.currencyInput = page.getByTestId('group-currency-input')
|
||||
this.informationTextarea = page.getByTestId('group-information-input')
|
||||
this.saveButton = page.getByTestId('save-group-button')
|
||||
this.participantsList = page.getByTestId('participants-list')
|
||||
}
|
||||
|
||||
async navigateToGroupSettings(groupId: string) {
|
||||
await this.page.goto(`http://localhost:3002/groups/${groupId}/edit`)
|
||||
}
|
||||
|
||||
async updateGroupName(newName: string) {
|
||||
await this.groupNameInput.fill(newName)
|
||||
}
|
||||
|
||||
async updateCurrency(newCurrency: string) {
|
||||
await this.currencyInput.fill(newCurrency)
|
||||
}
|
||||
|
||||
async updateInformation(newInfo: string) {
|
||||
await this.informationTextarea.fill(newInfo)
|
||||
}
|
||||
|
||||
async addParticipant(participantName: string) {
|
||||
const addButton = this.page.getByTestId('add-participant-button')
|
||||
await addButton.click()
|
||||
|
||||
const newParticipantInput = this.page.getByTestId('new-participant-input')
|
||||
await newParticipantInput.fill(participantName)
|
||||
}
|
||||
|
||||
async removeParticipant(participantName: string) {
|
||||
const removeButton = this.page.getByTestId(`remove-participant-${participantName}`)
|
||||
await removeButton.click()
|
||||
}
|
||||
|
||||
async saveChanges() {
|
||||
await this.saveButton.click()
|
||||
}
|
||||
}
|
||||
62
tests/test-data/expenses.ts
Normal file
62
tests/test-data/expenses.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
export const testExpenses = {
|
||||
simple: {
|
||||
title: 'Coffee',
|
||||
amount: '4.50',
|
||||
category: 'Food & Drinks',
|
||||
notes: 'Morning coffee'
|
||||
},
|
||||
|
||||
coffee: {
|
||||
title: 'Coffee',
|
||||
amount: '4.50',
|
||||
category: 'Food & Drinks',
|
||||
notes: 'Morning coffee'
|
||||
},
|
||||
|
||||
restaurant: {
|
||||
title: 'Dinner at Restaurant',
|
||||
amount: '85.20',
|
||||
category: 'Food & Drinks',
|
||||
notes: 'Group dinner'
|
||||
},
|
||||
|
||||
grocery: {
|
||||
title: 'Grocery Shopping',
|
||||
amount: '156.78',
|
||||
category: 'Food & Drinks',
|
||||
notes: 'Weekly groceries'
|
||||
},
|
||||
|
||||
transport: {
|
||||
title: 'Taxi Ride',
|
||||
amount: '23.50',
|
||||
category: 'Transportation',
|
||||
notes: 'Airport transfer'
|
||||
},
|
||||
|
||||
accommodation: {
|
||||
title: 'Hotel Stay',
|
||||
amount: '320.00',
|
||||
category: 'Accommodation',
|
||||
notes: '2 nights hotel booking'
|
||||
},
|
||||
|
||||
entertainment: {
|
||||
title: 'Movie Tickets',
|
||||
amount: '42.00',
|
||||
category: 'Entertainment',
|
||||
notes: 'Cinema tickets for 3 people'
|
||||
}
|
||||
}
|
||||
|
||||
export const splitModes = {
|
||||
evenly: 'EVENLY',
|
||||
byShares: 'BY_SHARES',
|
||||
byPercentage: 'BY_PERCENTAGE',
|
||||
byAmount: 'BY_AMOUNT'
|
||||
}
|
||||
|
||||
export const generateUniqueExpenseTitle = () => {
|
||||
const timestamp = Date.now()
|
||||
return `Test Expense ${timestamp}`
|
||||
}
|
||||
34
tests/test-data/groups.ts
Normal file
34
tests/test-data/groups.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
export const testGroups = {
|
||||
basic: {
|
||||
name: 'Test Group',
|
||||
currency: 'USD',
|
||||
information: 'A test group for E2E testing',
|
||||
participants: ['Alice', 'Bob', 'Charlie']
|
||||
},
|
||||
|
||||
family: {
|
||||
name: 'Family Expenses',
|
||||
currency: 'EUR',
|
||||
information: 'Family expense tracking',
|
||||
participants: ['Mom', 'Dad', 'Sister', 'Brother']
|
||||
},
|
||||
|
||||
vacation: {
|
||||
name: 'Summer Vacation 2024',
|
||||
currency: 'USD',
|
||||
information: 'Vacation expenses for the group trip',
|
||||
participants: ['John', 'Jane', 'Mike', 'Sarah', 'Tom']
|
||||
},
|
||||
|
||||
minimal: {
|
||||
name: 'Two Person Group',
|
||||
currency: 'USD',
|
||||
information: '',
|
||||
participants: ['Person1', 'Person2']
|
||||
}
|
||||
}
|
||||
|
||||
export const generateUniqueGroupName = () => {
|
||||
const timestamp = Date.now()
|
||||
return `Test Group ${timestamp}`
|
||||
}
|
||||
57
tests/utils/calculations.ts
Normal file
57
tests/utils/calculations.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
export class CalculationUtils {
|
||||
/**
|
||||
* Calculate expected balance for a participant
|
||||
*/
|
||||
static calculateExpectedBalance(
|
||||
participantExpenses: number[],
|
||||
participantShares: number[]
|
||||
): number {
|
||||
const totalPaid = participantExpenses.reduce((sum, expense) => sum + expense, 0)
|
||||
const totalOwed = participantShares.reduce((sum, share) => sum + share, 0)
|
||||
|
||||
return totalPaid - totalOwed
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate even split amount
|
||||
*/
|
||||
static calculateEvenSplit(totalAmount: number, participantCount: number): number {
|
||||
return totalAmount / participantCount
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate split by percentage
|
||||
*/
|
||||
static calculatePercentageSplit(totalAmount: number, percentage: number): number {
|
||||
return (totalAmount * percentage) / 100
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate split by shares
|
||||
*/
|
||||
static calculateShareSplit(totalAmount: number, shares: number, totalShares: number): number {
|
||||
return (totalAmount * shares) / totalShares
|
||||
}
|
||||
|
||||
/**
|
||||
* Format currency amount to 2 decimal places
|
||||
*/
|
||||
static formatCurrency(amount: number, currency: string = 'USD'): string {
|
||||
return `${currency}${amount.toFixed(2)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse currency string to number
|
||||
*/
|
||||
static parseCurrency(currencyString: string): number {
|
||||
return parseFloat(currencyString.replace(/[^0-9.-]+/g, ''))
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that balances sum to zero (group balance check)
|
||||
*/
|
||||
static validateGroupBalance(balances: number[]): boolean {
|
||||
const sum = balances.reduce((total, balance) => total + balance, 0)
|
||||
return Math.abs(sum) < 0.01 // Allow for small rounding errors
|
||||
}
|
||||
}
|
||||
142
tests/utils/reliability.ts
Normal file
142
tests/utils/reliability.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { Page, Locator, expect } from '@playwright/test'
|
||||
|
||||
export class ReliabilityUtils {
|
||||
/**
|
||||
* Wait for element with multiple strategies and retries
|
||||
*/
|
||||
static async waitForElement(
|
||||
page: Page,
|
||||
selectors: string[],
|
||||
options: { timeout?: number; retries?: number } = {}
|
||||
): Promise<Locator> {
|
||||
const { timeout = 10000, retries = 3 } = options
|
||||
|
||||
for (let attempt = 0; attempt < retries; attempt++) {
|
||||
for (const selector of selectors) {
|
||||
try {
|
||||
const element = page.locator(selector)
|
||||
await element.waitFor({ state: 'visible', timeout: timeout / retries })
|
||||
return element
|
||||
} catch (error) {
|
||||
// Continue to next selector
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if (attempt < retries - 1) {
|
||||
// Wait a bit before retry
|
||||
await page.waitForTimeout(1000)
|
||||
await page.waitForLoadState('networkidle')
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`None of the selectors found after ${retries} attempts: ${selectors.join(', ')}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to tab with reliability checks
|
||||
*/
|
||||
static async navigateToTab(page: Page, tabName: string, expectedUrl: RegExp) {
|
||||
// Click tab with retry and verification
|
||||
for (let attempt = 0; attempt < 5; attempt++) {
|
||||
try {
|
||||
// Ensure we're in a stable state before clicking
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Click the tab
|
||||
await page.getByTestId(`tab-${tabName}`).click()
|
||||
|
||||
// Wait for the URL to change with a shorter timeout per attempt
|
||||
try {
|
||||
await page.waitForURL(expectedUrl, { timeout: 3000 })
|
||||
// If we get here, navigation succeeded
|
||||
break
|
||||
} catch (urlError) {
|
||||
// URL didn't change, try again
|
||||
if (attempt === 4) {
|
||||
// Last attempt failed, throw the error
|
||||
throw new Error(`Failed to navigate to ${tabName} tab after ${attempt + 1} attempts. Current URL: ${page.url()}`)
|
||||
}
|
||||
|
||||
// Wait a bit before retrying
|
||||
await page.waitForTimeout(1000)
|
||||
continue
|
||||
}
|
||||
} catch (error) {
|
||||
if (attempt === 4) throw error
|
||||
await page.waitForTimeout(500)
|
||||
}
|
||||
}
|
||||
|
||||
// Final stability checks
|
||||
await page.waitForLoadState('networkidle')
|
||||
await page.waitForTimeout(300)
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify content loaded with multiple fallback strategies
|
||||
*/
|
||||
static async verifyContentLoaded(page: Page, contentIdentifiers: string[]) {
|
||||
let lastError: Error | null = null
|
||||
|
||||
for (const identifier of contentIdentifiers) {
|
||||
try {
|
||||
if (identifier.startsWith('text=')) {
|
||||
await expect(page.locator(identifier)).toBeVisible({ timeout: 5000 })
|
||||
return
|
||||
} else if (identifier.startsWith('[data-testid=')) {
|
||||
await expect(page.locator(identifier)).toBeVisible({ timeout: 5000 })
|
||||
return
|
||||
} else {
|
||||
await expect(page.getByTestId(identifier)).toBeVisible({ timeout: 5000 })
|
||||
return
|
||||
}
|
||||
} catch (error) {
|
||||
lastError = error as Error
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError || new Error(`No content identifiers found: ${contentIdentifiers.join(', ')}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhanced page load waiting
|
||||
*/
|
||||
static async waitForStablePage(page: Page) {
|
||||
// Wait for network to be idle
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Wait for any pending JavaScript
|
||||
await page.waitForLoadState('domcontentloaded')
|
||||
|
||||
// Additional short wait for dynamic content
|
||||
await page.waitForTimeout(300)
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry operation with exponential backoff
|
||||
*/
|
||||
static async retryOperation<T>(
|
||||
operation: () => Promise<T>,
|
||||
maxRetries: number = 3,
|
||||
baseDelay: number = 1000
|
||||
): Promise<T> {
|
||||
let lastError: Error
|
||||
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
try {
|
||||
return await operation()
|
||||
} catch (error) {
|
||||
lastError = error as Error
|
||||
|
||||
if (attempt < maxRetries - 1) {
|
||||
const delay = baseDelay * Math.pow(2, attempt)
|
||||
await new Promise(resolve => setTimeout(resolve, delay))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError!
|
||||
}
|
||||
}
|
||||
@@ -28,8 +28,7 @@
|
||||
"require": ["tsconfig-paths/register", "dotenv/config"],
|
||||
"compilerOptions": {
|
||||
"isolatedModules": false,
|
||||
"moduleResolution": "nodenext",
|
||||
"module": "nodenext"
|
||||
"module": "CommonJS"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user