mirror of
https://github.com/spliit-app/spliit.git
synced 2026-02-15 20:16:12 +01:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb78848601 | ||
|
|
a9f008683f | ||
|
|
52a2b552cb | ||
|
|
0e77a666f4 | ||
|
|
c49d0ea220 | ||
|
|
05a793ee39 | ||
|
|
d641540b65 |
@@ -38,12 +38,15 @@
|
|||||||
"earlierThisYear": "Dieses Jahr",
|
"earlierThisYear": "Dieses Jahr",
|
||||||
"lastYear": "Letztes Jahr",
|
"lastYear": "Letztes Jahr",
|
||||||
"older": "Älter"
|
"older": "Älter"
|
||||||
}
|
},
|
||||||
|
"export": "Exportieren"
|
||||||
},
|
},
|
||||||
"ExpenseCard": {
|
"ExpenseCard": {
|
||||||
"paidBy": "Gezahlt von <strong>{paidBy}</strong> für <paidFor></paidFor>",
|
"paidBy": "Gezahlt von <strong>{paidBy}</strong> für <paidFor></paidFor>",
|
||||||
"receivedBy": "Empfangen von <strong>{paidBy}</strong> für <paidFor></paidFor>",
|
"receivedBy": "Empfangen von <strong>{paidBy}</strong> für <paidFor></paidFor>",
|
||||||
"yourBalance": "Deine Bilanz:"
|
"yourBalance": "Deine Bilanz:",
|
||||||
|
"everyone": "jeder",
|
||||||
|
"notInvolved": "Du bist nicht involviert"
|
||||||
},
|
},
|
||||||
"Groups": {
|
"Groups": {
|
||||||
"myGroups": "Meine Gruppen",
|
"myGroups": "Meine Gruppen",
|
||||||
@@ -117,6 +120,12 @@
|
|||||||
"create": "Erstellen",
|
"create": "Erstellen",
|
||||||
"creating": "Erstellt…",
|
"creating": "Erstellt…",
|
||||||
"cancel": "Abbrechen"
|
"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": {
|
"ExpenseForm": {
|
||||||
@@ -240,7 +249,7 @@
|
|||||||
"triggerTitle": "Ausgabe von Rechnungsbeleg erstellen",
|
"triggerTitle": "Ausgabe von Rechnungsbeleg erstellen",
|
||||||
"title": "Von Rechnungsbeleg erstellen",
|
"title": "Von Rechnungsbeleg erstellen",
|
||||||
"description": "Ausgabeninformationen von einem Foto einer Rechnung lesen.",
|
"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…",
|
"selectImage": "Bild wählen…",
|
||||||
"titleLabel": "Titel:",
|
"titleLabel": "Titel:",
|
||||||
"categoryLabel": "Kategorie:",
|
"categoryLabel": "Kategorie:",
|
||||||
@@ -295,7 +304,7 @@
|
|||||||
"Groups": {
|
"Groups": {
|
||||||
"today": "Heute",
|
"today": "Heute",
|
||||||
"yesterday": "Gestern",
|
"yesterday": "Gestern",
|
||||||
"earlierThisWeek": "Diese Woche",
|
"earlierThisWeek": "Anfang dieser Woche",
|
||||||
"lastWeek": "Letze Woche",
|
"lastWeek": "Letze Woche",
|
||||||
"earlierThisMonth": "Diesen Monat",
|
"earlierThisMonth": "Diesen Monat",
|
||||||
"lastMonth": "Letzen Monat",
|
"lastMonth": "Letzen Monat",
|
||||||
@@ -328,7 +337,7 @@
|
|||||||
"invalidNumber": "Zahl nicht valide.",
|
"invalidNumber": "Zahl nicht valide.",
|
||||||
"amountRequired": "Du musst einen Betrag angeben.",
|
"amountRequired": "Du musst einen Betrag angeben.",
|
||||||
"amountNotZero": "Der Betrag darf nicht 0 sein.",
|
"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.",
|
"paidByRequired": "Du musst ein Mitglied auswählen.",
|
||||||
"paidForMin1": "Die Ausgabe muss mindestens für ein Mitglied bezahlt werden.",
|
"paidForMin1": "Die Ausgabe muss mindestens für ein Mitglied bezahlt werden.",
|
||||||
"noZeroShares": "Alle Anteile müssen größer als 0 sein.",
|
"noZeroShares": "Alle Anteile müssen größer als 0 sein.",
|
||||||
@@ -403,5 +412,18 @@
|
|||||||
"TV/Phone/Internet": "TV/Internet/Telefonie",
|
"TV/Phone/Internet": "TV/Internet/Telefonie",
|
||||||
"Water": "Wasser"
|
"Water": "Wasser"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Currencies": {
|
||||||
|
"search": "Währung suchen...",
|
||||||
|
"noCurrency": "Keine Währungen gefunden.",
|
||||||
|
"other": {
|
||||||
|
"heading": "Andere Währungen"
|
||||||
|
},
|
||||||
|
"custom": {
|
||||||
|
"heading": "Benutzerdefinierte"
|
||||||
|
},
|
||||||
|
"common": {
|
||||||
|
"heading": "Geläufigste"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -141,6 +141,10 @@
|
|||||||
"label": "Income date",
|
"label": "Income date",
|
||||||
"description": "Enter the date the income was received."
|
"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.",
|
"categoryFieldDescription": "Select the income category.",
|
||||||
"paidByField": {
|
"paidByField": {
|
||||||
"label": "Received by",
|
"label": "Received by",
|
||||||
@@ -165,6 +169,10 @@
|
|||||||
"label": "Expense date",
|
"label": "Expense date",
|
||||||
"description": "Enter the date the expense was paid."
|
"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.",
|
"categoryFieldDescription": "Select the expense category.",
|
||||||
"paidByField": {
|
"paidByField": {
|
||||||
"label": "Paid by",
|
"label": "Paid by",
|
||||||
@@ -190,6 +198,27 @@
|
|||||||
"amountField": {
|
"amountField": {
|
||||||
"label": "Amount"
|
"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": {
|
"isReimbursementField": {
|
||||||
"label": "This is a reimbursement"
|
"label": "This is a reimbursement"
|
||||||
},
|
},
|
||||||
@@ -331,6 +360,7 @@
|
|||||||
"amountRequired": "You must enter an amount.",
|
"amountRequired": "You must enter an amount.",
|
||||||
"amountNotZero": "The amount must not be zero.",
|
"amountNotZero": "The amount must not be zero.",
|
||||||
"amountTenMillion": "The amount must be lower than 10,000,000.",
|
"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.",
|
"paidByRequired": "You must select a participant.",
|
||||||
"paidForMin1": "The expense must be paid for at least one participant.",
|
"paidForMin1": "The expense must be paid for at least one participant.",
|
||||||
"noZeroShares": "All shares must be higher than 0.",
|
"noZeroShares": "All shares must be higher than 0.",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"Homepage": {
|
"Homepage": {
|
||||||
"title": "Partagez <strong>vos dépenses</strong> avec <strong>vos amis</strong> & <strong>votre famille :)</strong>",
|
"title": "Partagez <strong>vos dépenses</strong> avec <strong>vos amis & votre famille</strong>",
|
||||||
"description": "Bienvenue sur votre instance <strong>Spliit</strong> !",
|
"description": "Bienvenue sur votre instance <strong>Spliit</strong> !",
|
||||||
"button": {
|
"button": {
|
||||||
"groups": "Accéder aux groupes",
|
"groups": "Accéder aux groupes",
|
||||||
@@ -38,12 +38,15 @@
|
|||||||
"earlierThisYear": "Plus tôt cette année",
|
"earlierThisYear": "Plus tôt cette année",
|
||||||
"lastYear": "L'année dernière",
|
"lastYear": "L'année dernière",
|
||||||
"older": "Plus ancien"
|
"older": "Plus ancien"
|
||||||
}
|
},
|
||||||
|
"export": "Exporter"
|
||||||
},
|
},
|
||||||
"ExpenseCard": {
|
"ExpenseCard": {
|
||||||
"paidBy": "Payé par <strong>{paidBy}</strong> pour <paidFor></paidFor>",
|
"paidBy": "Payé par <strong>{paidBy}</strong> pour <paidFor></paidFor>",
|
||||||
"receivedBy": "Reçu par <strong>{paidBy}</strong> pour <paidFor></paidFor>",
|
"receivedBy": "Reçu par <strong>{paidBy}</strong> pour <paidFor></paidFor>",
|
||||||
"yourBalance": "Votre solde :"
|
"yourBalance": "Votre solde :",
|
||||||
|
"everyone": "tout le monde",
|
||||||
|
"notInvolved": "Vous n'êtes pas concerné"
|
||||||
},
|
},
|
||||||
"Groups": {
|
"Groups": {
|
||||||
"myGroups": "Mes groupes",
|
"myGroups": "Mes groupes",
|
||||||
@@ -99,9 +102,9 @@
|
|||||||
"protectedParticipant": "Ce participant fait partie des dépenses et ne peut pas être supprimé.",
|
"protectedParticipant": "Ce participant fait partie des dépenses et ne peut pas être supprimé.",
|
||||||
"new": "Nouveau",
|
"new": "Nouveau",
|
||||||
"add": "Ajouter un participant",
|
"add": "Ajouter un participant",
|
||||||
"John": "John",
|
"John": "Jean",
|
||||||
"Jane": "Jane",
|
"Jane": "Jeanne",
|
||||||
"Jack": "Jack"
|
"Jack": "Jacques"
|
||||||
},
|
},
|
||||||
"Settings": {
|
"Settings": {
|
||||||
"title": "Paramètres locaux",
|
"title": "Paramètres locaux",
|
||||||
@@ -117,6 +120,12 @@
|
|||||||
"create": "Créer",
|
"create": "Créer",
|
||||||
"creating": "Création…",
|
"creating": "Création…",
|
||||||
"cancel": "Annuler"
|
"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": {
|
"ExpenseForm": {
|
||||||
@@ -150,7 +159,11 @@
|
|||||||
"description": "Sélectionnez pour qui le revenu a été reçu."
|
"description": "Sélectionnez pour qui le revenu a été reçu."
|
||||||
},
|
},
|
||||||
"splitModeDescription": "Sélectionnez comment diviser le revenu.",
|
"splitModeDescription": "Sélectionnez comment diviser le revenu.",
|
||||||
"attachDescription": "Voir et joindre des reçus au revenu."
|
"attachDescription": "Voir et joindre des reçus au revenu.",
|
||||||
|
"currencyField": {
|
||||||
|
"label": "Devise de la recette",
|
||||||
|
"description": "La devise dans laquelle le bénéfice a été perçu."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"Expense": {
|
"Expense": {
|
||||||
"create": "Créer une dépense",
|
"create": "Créer une dépense",
|
||||||
@@ -167,7 +180,8 @@
|
|||||||
"categoryFieldDescription": "Sélectionnez la catégorie de dépense.",
|
"categoryFieldDescription": "Sélectionnez la catégorie de dépense.",
|
||||||
"paidByField": {
|
"paidByField": {
|
||||||
"label": "Payé par",
|
"label": "Payé par",
|
||||||
"description": "Sélectionnez le participant qui a réglé la dépense."
|
"description": "Sélectionnez le participant qui a réglé la dépense.",
|
||||||
|
"placeholder": "Sélectionner un participant"
|
||||||
},
|
},
|
||||||
"recurrenceRule": {
|
"recurrenceRule": {
|
||||||
"label": "Récurrence de la dépense",
|
"label": "Récurrence de la dépense",
|
||||||
@@ -182,7 +196,11 @@
|
|||||||
"description": "Sélectionnez les participants concernés"
|
"description": "Sélectionnez les participants concernés"
|
||||||
},
|
},
|
||||||
"splitModeDescription": "Sélectionnez comment diviser la dépense.",
|
"splitModeDescription": "Sélectionnez comment diviser la dépense.",
|
||||||
"attachDescription": "Voir et joindre des reçus à la dépense."
|
"attachDescription": "Voir et joindre des reçus à la dépense.",
|
||||||
|
"currencyField": {
|
||||||
|
"label": "Devise de la dépense",
|
||||||
|
"description": "La devise dans laquelle la dépense a été payée."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"amountField": {
|
"amountField": {
|
||||||
"label": "Montant"
|
"label": "Montant"
|
||||||
@@ -221,7 +239,21 @@
|
|||||||
"save": "Sauvegarder",
|
"save": "Sauvegarder",
|
||||||
"saving": "Sauvegarde…",
|
"saving": "Sauvegarde…",
|
||||||
"cancel": "Annuler",
|
"cancel": "Annuler",
|
||||||
"reimbursement": "Remboursement"
|
"reimbursement": "Remboursement",
|
||||||
|
"conversionUnavailable": "Pour définir une devise différente pour chaque dépense et convertir les montants, sélectionnez une devise non personnalisée pour le groupe.",
|
||||||
|
"originalAmountField": {
|
||||||
|
"label": "Montant à convertir"
|
||||||
|
},
|
||||||
|
"conversionRateField": {
|
||||||
|
"useCustom": "Utiliser un taux personnalisé",
|
||||||
|
"label": "Taux de change"
|
||||||
|
},
|
||||||
|
"conversionRateState": {
|
||||||
|
"loading": "Obtention des taux de change…",
|
||||||
|
"success": "Taux obtenus:",
|
||||||
|
"error": "Oups, nous n'avons pas pu obtenir les taux de change les plus récents.",
|
||||||
|
"refresh": "Actualiser"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"ExpenseDocumentsInput": {
|
"ExpenseDocumentsInput": {
|
||||||
"TooBigToast": {
|
"TooBigToast": {
|
||||||
@@ -348,7 +380,7 @@
|
|||||||
"Games": "Jeux",
|
"Games": "Jeux",
|
||||||
"Movies": "Films",
|
"Movies": "Films",
|
||||||
"Music": "Musique",
|
"Music": "Musique",
|
||||||
"Sports": "Sports"
|
"Sports": "Sport"
|
||||||
},
|
},
|
||||||
"Food and Drink": {
|
"Food and Drink": {
|
||||||
"heading": "Nourriture et boissons",
|
"heading": "Nourriture et boissons",
|
||||||
@@ -377,7 +409,8 @@
|
|||||||
"Gifts": "Cadeaux",
|
"Gifts": "Cadeaux",
|
||||||
"Insurance": "Assurance",
|
"Insurance": "Assurance",
|
||||||
"Medical Expenses": "Dépenses médicales",
|
"Medical Expenses": "Dépenses médicales",
|
||||||
"Taxes": "Impôts"
|
"Taxes": "Impôts",
|
||||||
|
"Donation": "Don"
|
||||||
},
|
},
|
||||||
"Transportation": {
|
"Transportation": {
|
||||||
"heading": "Transport",
|
"heading": "Transport",
|
||||||
@@ -401,5 +434,18 @@
|
|||||||
"TV/Phone/Internet": "TV/Téléphone/Internet",
|
"TV/Phone/Internet": "TV/Téléphone/Internet",
|
||||||
"Water": "Eau"
|
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,8 @@
|
|||||||
"paidBy": "Betaald door <strong>{paidBy}</strong> voor <paidFor></paidFor>",
|
"paidBy": "Betaald door <strong>{paidBy}</strong> voor <paidFor></paidFor>",
|
||||||
"everyone": "iedereen",
|
"everyone": "iedereen",
|
||||||
"receivedBy": "Ontvangen door <strong>{paidBy}</strong> voor <paidFor></paidFor>",
|
"receivedBy": "Ontvangen door <strong>{paidBy}</strong> voor <paidFor></paidFor>",
|
||||||
"yourBalance": "Jouw balans:"
|
"yourBalance": "Jouw balans:",
|
||||||
|
"notInvolved": "Je bent hier niet bij betrokken"
|
||||||
},
|
},
|
||||||
"Groups": {
|
"Groups": {
|
||||||
"myGroups": "Mijn groepen",
|
"myGroups": "Mijn groepen",
|
||||||
@@ -119,6 +120,12 @@
|
|||||||
"create": "Groep maken",
|
"create": "Groep maken",
|
||||||
"creating": "Aan het maken…",
|
"creating": "Aan het maken…",
|
||||||
"cancel": "Annuleren"
|
"cancel": "Annuleren"
|
||||||
|
},
|
||||||
|
"CurrencyCodeField": {
|
||||||
|
"label": "Hoofdvaluta",
|
||||||
|
"createDescription": "Alle hoeveelheden en saldi worden in deze valuta weergegeven.",
|
||||||
|
"editDescription": "Alle bedragen en saldi worden in deze valuta weergegeven. Als je dit wijzigt, worden reeds ingevoerde uitgaven NIET omgerekend, behalve wanneer de valuta andere \"kleinste eenheden\" heeft dan de huidige (bijvoorbeeld bij een wijziging van Amerikaanse dollar naar Japanse yen)",
|
||||||
|
"customOption": "Aangepast"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ExpenseForm": {
|
"ExpenseForm": {
|
||||||
@@ -152,7 +159,11 @@
|
|||||||
"description": "Selecteer voor wie het inkomen is ontvangen."
|
"description": "Selecteer voor wie het inkomen is ontvangen."
|
||||||
},
|
},
|
||||||
"splitModeDescription": "Selecteer hoe het inkomen verdeeld moet worden.",
|
"splitModeDescription": "Selecteer hoe het inkomen verdeeld moet worden.",
|
||||||
"attachDescription": "Bekijk en voeg bijlagen toe aan het inkomen."
|
"attachDescription": "Bekijk en voeg bijlagen toe aan het inkomen.",
|
||||||
|
"currencyField": {
|
||||||
|
"label": "Munteenheid van inkomen",
|
||||||
|
"description": "De munteenheid waar het inkomen in is ontvangen."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"Expense": {
|
"Expense": {
|
||||||
"create": "Maak uitgave",
|
"create": "Maak uitgave",
|
||||||
@@ -169,7 +180,8 @@
|
|||||||
"categoryFieldDescription": "Selecteer de uitgavecategorie.",
|
"categoryFieldDescription": "Selecteer de uitgavecategorie.",
|
||||||
"paidByField": {
|
"paidByField": {
|
||||||
"label": "Betaald door",
|
"label": "Betaald door",
|
||||||
"description": "Selecteer de deelnemer die de uitgave heeft gedaan."
|
"description": "Selecteer de deelnemer die de uitgave heeft gedaan.",
|
||||||
|
"placeholder": "Selecteer een deelnemer"
|
||||||
},
|
},
|
||||||
"recurrenceRule": {
|
"recurrenceRule": {
|
||||||
"label": "Terugkerende uitgave",
|
"label": "Terugkerende uitgave",
|
||||||
@@ -184,7 +196,11 @@
|
|||||||
"description": "Selecteer voor wie de uitgave is gedaan."
|
"description": "Selecteer voor wie de uitgave is gedaan."
|
||||||
},
|
},
|
||||||
"splitModeDescription": "Selecteer hoe de uitgave verdeeld moet worden.",
|
"splitModeDescription": "Selecteer hoe de uitgave verdeeld moet worden.",
|
||||||
"attachDescription": "Bekijk en voeg bijlagen toe aan de uitgave."
|
"attachDescription": "Bekijk en voeg bijlagen toe aan de uitgave.",
|
||||||
|
"currencyField": {
|
||||||
|
"label": "Munteenheid van uitgave",
|
||||||
|
"description": "De munteenheid waar de uitgave in is betaald."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"amountField": {
|
"amountField": {
|
||||||
"label": "Bedrag"
|
"label": "Bedrag"
|
||||||
@@ -201,7 +217,7 @@
|
|||||||
"selectNone": "Selecteer niemand",
|
"selectNone": "Selecteer niemand",
|
||||||
"selectAll": "Selecteer iedereen",
|
"selectAll": "Selecteer iedereen",
|
||||||
"shares": "deel/delen",
|
"shares": "deel/delen",
|
||||||
"advancedOptions": "Geavanceerde split-opties",
|
"advancedOptions": "Andere split-opties…",
|
||||||
"SplitModeField": {
|
"SplitModeField": {
|
||||||
"label": "Split soort",
|
"label": "Split soort",
|
||||||
"evenly": "Gelijk verdeeld",
|
"evenly": "Gelijk verdeeld",
|
||||||
@@ -213,7 +229,7 @@
|
|||||||
"DeletePopup": {
|
"DeletePopup": {
|
||||||
"label": "Verwijderen",
|
"label": "Verwijderen",
|
||||||
"title": "Deze uitgave verwijderen?",
|
"title": "Deze uitgave verwijderen?",
|
||||||
"description": "Wil je deze uitgave echt verwijderen?",
|
"description": "Wil je deze uitgave echt verwijderen? Dit kan niet ongedaan worden.",
|
||||||
"yes": "Ja",
|
"yes": "Ja",
|
||||||
"cancel": "Annuleer"
|
"cancel": "Annuleer"
|
||||||
},
|
},
|
||||||
@@ -223,7 +239,28 @@
|
|||||||
"save": "Opslaan",
|
"save": "Opslaan",
|
||||||
"saving": "Aan het opslaan…",
|
"saving": "Aan het opslaan…",
|
||||||
"cancel": "Annuleren",
|
"cancel": "Annuleren",
|
||||||
"reimbursement": "Terugbetaling"
|
"reimbursement": "Terugbetaling",
|
||||||
|
"conversionUnavailable": "Om een andere munteenheid in te stellen voor een uitgave en bedragen om te rekenen, kies een standaard munteenheid voor de groep.",
|
||||||
|
"originalAmountField": {
|
||||||
|
"label": "Om te rekenen bedrag"
|
||||||
|
},
|
||||||
|
"conversionRateField": {
|
||||||
|
"useApi": "Gebruik koersen van Frankfurter",
|
||||||
|
"useCustom": "Gebruik aangepaste koers",
|
||||||
|
"label": "Wisselkoers"
|
||||||
|
},
|
||||||
|
"conversionRateState": {
|
||||||
|
"loading": "Wisselkoers aan het ophalen…",
|
||||||
|
"success": "Verkregen wisselkoers:",
|
||||||
|
"error": "Oeps, we konden de nieuwste wisselkoersen niet verkrijgen.",
|
||||||
|
"staleRate": "Gebruikte wisselkoers:",
|
||||||
|
"noRate": "Voer hieronder een aangepaste koers in.",
|
||||||
|
"currencyNotFound": "Oeps, Frankfurter heeft geen koersen voor deze munteenheid op deze dag.",
|
||||||
|
"noDate": "Voer de datum van de uitgave in om een wisselkoers te krijgen.",
|
||||||
|
"dateMismatch": "Wisselkoers van: {date}",
|
||||||
|
"refresh": "Ververs",
|
||||||
|
"customRate": "Aangepaste wisselkoers wordt gebruikt"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"ExpenseDocumentsInput": {
|
"ExpenseDocumentsInput": {
|
||||||
"TooBigToast": {
|
"TooBigToast": {
|
||||||
@@ -334,7 +371,8 @@
|
|||||||
"paidForMin1": "De uitgave moet voor ten minste één deelnemer zijn gedaan.",
|
"paidForMin1": "De uitgave moet voor ten minste één deelnemer zijn gedaan.",
|
||||||
"noZeroShares": "Een deel mag niet 0 zijn.",
|
"noZeroShares": "Een deel mag niet 0 zijn.",
|
||||||
"amountSum": "Het totaalbedrag moet gelijk zijn aan het uitgavebedrag.",
|
"amountSum": "Het totaalbedrag moet gelijk zijn aan het uitgavebedrag.",
|
||||||
"percentageSum": "Het totaalpercentage moet gelijk zijn aan 100%."
|
"percentageSum": "Het totaalpercentage moet gelijk zijn aan 100%.",
|
||||||
|
"ratePositive": "De koers moet groter dan nul zijn."
|
||||||
},
|
},
|
||||||
"Categories": {
|
"Categories": {
|
||||||
"search": "Categorie zoeken…",
|
"search": "Categorie zoeken…",
|
||||||
@@ -404,5 +442,18 @@
|
|||||||
"TV/Phone/Internet": "Internet/TV/Telefoon",
|
"TV/Phone/Internet": "Internet/TV/Telefoon",
|
||||||
"Water": "Water"
|
"Water": "Water"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Currencies": {
|
||||||
|
"noCurrency": "Geen valuta gevonden.",
|
||||||
|
"custom": {
|
||||||
|
"heading": "Aangepast"
|
||||||
|
},
|
||||||
|
"common": {
|
||||||
|
"heading": "Meest voorkomend"
|
||||||
|
},
|
||||||
|
"other": {
|
||||||
|
"heading": "Andere valuta"
|
||||||
|
},
|
||||||
|
"search": "Valuta zoeken..."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"Homepage": {
|
"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>!",
|
"description": "Bem-vindo à sua nova instalação do <strong>Spliit</strong>!",
|
||||||
"button": {
|
"button": {
|
||||||
"groups": "Ir para grupos",
|
"groups": "Ir para grupos",
|
||||||
@@ -38,12 +38,15 @@
|
|||||||
"earlierThisYear": "Anteriores neste ano",
|
"earlierThisYear": "Anteriores neste ano",
|
||||||
"lastYear": "Ano passado",
|
"lastYear": "Ano passado",
|
||||||
"older": "Mais antigas"
|
"older": "Mais antigas"
|
||||||
}
|
},
|
||||||
|
"export": "Exportar"
|
||||||
},
|
},
|
||||||
"ExpenseCard": {
|
"ExpenseCard": {
|
||||||
"paidBy": "Pago por <strong>{paidBy}</strong> para <paidFor></paidFor>",
|
"paidBy": "Pago por <strong>{paidBy}</strong> para <paidFor></paidFor>",
|
||||||
"receivedBy": "Recebido por <strong>{paidBy}</strong> para <paidFor></paidFor>",
|
"receivedBy": "Recebido por <strong>{paidBy}</strong> para <paidFor></paidFor>",
|
||||||
"yourBalance": "Seu saldo:"
|
"yourBalance": "Seu saldo:",
|
||||||
|
"everyone": "Todos",
|
||||||
|
"notInvolved": "Você não está envolvido"
|
||||||
},
|
},
|
||||||
"Groups": {
|
"Groups": {
|
||||||
"myGroups": "Meus grupos",
|
"myGroups": "Meus grupos",
|
||||||
@@ -117,6 +120,12 @@
|
|||||||
"create": "Criar",
|
"create": "Criar",
|
||||||
"creating": "Criando…",
|
"creating": "Criando…",
|
||||||
"cancel": "Cancelar"
|
"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": {
|
"ExpenseForm": {
|
||||||
@@ -159,14 +168,23 @@
|
|||||||
"categoryFieldDescription": "Selecione a categoria da despesa.",
|
"categoryFieldDescription": "Selecione a categoria da despesa.",
|
||||||
"paidByField": {
|
"paidByField": {
|
||||||
"label": "Pago por",
|
"label": "Pago por",
|
||||||
"description": "Selecione o participante que pagou a despesa."
|
"description": "Selecione o participante que pagou a despesa.",
|
||||||
|
"placeholder": "Selecione um participante"
|
||||||
},
|
},
|
||||||
"paidFor": {
|
"paidFor": {
|
||||||
"title": "Pago para",
|
"title": "Pago para",
|
||||||
"description": "Selecione para quem a despesa foi paga."
|
"description": "Selecione para quem a despesa foi paga."
|
||||||
},
|
},
|
||||||
"splitModeDescription": "Selecione como dividir a despesa.",
|
"splitModeDescription": "Selecione como dividir a despesa.",
|
||||||
"attachDescription": "Veja e anexe recibos à despesa."
|
"attachDescription": "Veja e anexe recibos à despesa.",
|
||||||
|
"recurrenceRule": {
|
||||||
|
"label": "Recorrência da Despesa",
|
||||||
|
"description": "Selecione a frequência de recorrência da despesa.",
|
||||||
|
"none": "Nenhuma",
|
||||||
|
"daily": "Diariamente",
|
||||||
|
"weekly": "Semanalmente",
|
||||||
|
"monthly": "Mensalmente"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"amountField": {
|
"amountField": {
|
||||||
"label": "Valor"
|
"label": "Valor"
|
||||||
@@ -361,7 +379,8 @@
|
|||||||
"Gifts": "Presentes",
|
"Gifts": "Presentes",
|
||||||
"Insurance": "Seguro",
|
"Insurance": "Seguro",
|
||||||
"Medical Expenses": "Despesas médicas",
|
"Medical Expenses": "Despesas médicas",
|
||||||
"Taxes": "Impostos"
|
"Taxes": "Impostos",
|
||||||
|
"Donation": "Doação"
|
||||||
},
|
},
|
||||||
"Transportation": {
|
"Transportation": {
|
||||||
"heading": "Transporte",
|
"heading": "Transporte",
|
||||||
@@ -385,5 +404,18 @@
|
|||||||
"TV/Phone/Internet": "TV/Telefone/Internet",
|
"TV/Phone/Internet": "TV/Telefone/Internet",
|
||||||
"Water": "Água"
|
"Water": "Água"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Currencies": {
|
||||||
|
"search": "Pesquisar moeda...",
|
||||||
|
"noCurrency": "Nenhuma moeda encontrada.",
|
||||||
|
"custom": {
|
||||||
|
"heading": "Customizado"
|
||||||
|
},
|
||||||
|
"common": {
|
||||||
|
"heading": "Mais comum"
|
||||||
|
},
|
||||||
|
"other": {
|
||||||
|
"heading": "Outras moedas"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
24
package-lock.json
generated
24
package-lock.json
generated
@@ -56,6 +56,7 @@
|
|||||||
"server-only": "^0.0.1",
|
"server-only": "^0.0.1",
|
||||||
"sharp": "^0.33.2",
|
"sharp": "^0.33.2",
|
||||||
"superjson": "^2.2.1",
|
"superjson": "^2.2.1",
|
||||||
|
"swr": "^2.3.3",
|
||||||
"tailwind-merge": "^1.14.0",
|
"tailwind-merge": "^1.14.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"ts-pattern": "^5.0.6",
|
"ts-pattern": "^5.0.6",
|
||||||
@@ -11040,7 +11041,6 @@
|
|||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
||||||
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
|
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
@@ -16850,6 +16850,19 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/symbol-tree": {
|
||||||
"version": "3.2.4",
|
"version": "3.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
|
||||||
@@ -17369,6 +17382,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/use-sync-external-store": {
|
||||||
|
"version": "1.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz",
|
||||||
|
"integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/util-deprecate": {
|
"node_modules/util-deprecate": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
|
|||||||
@@ -64,6 +64,7 @@
|
|||||||
"server-only": "^0.0.1",
|
"server-only": "^0.0.1",
|
||||||
"sharp": "^0.33.2",
|
"sharp": "^0.33.2",
|
||||||
"superjson": "^2.2.1",
|
"superjson": "^2.2.1",
|
||||||
|
"swr": "^2.3.3",
|
||||||
"tailwind-merge": "^1.14.0",
|
"tailwind-merge": "^1.14.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"ts-pattern": "^5.0.6",
|
"ts-pattern": "^5.0.6",
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Expense" ADD COLUMN "conversionRate" DECIMAL(65,30),
|
||||||
|
ADD COLUMN "originalAmount" INTEGER,
|
||||||
|
ADD COLUMN "originalCurrency" TEXT;
|
||||||
@@ -16,7 +16,7 @@ model Group {
|
|||||||
name String
|
name String
|
||||||
information String? @db.Text
|
information String? @db.Text
|
||||||
currency String @default("$")
|
currency String @default("$")
|
||||||
currencyCode String?
|
currencyCode String?
|
||||||
participants Participant[]
|
participants Participant[]
|
||||||
expenses Expense[]
|
expenses Expense[]
|
||||||
activities Activity[]
|
activities Activity[]
|
||||||
@@ -40,25 +40,28 @@ model Category {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Expense {
|
model Expense {
|
||||||
id String @id
|
id String @id
|
||||||
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
||||||
expenseDate DateTime @default(dbgenerated("CURRENT_DATE")) @db.Date
|
expenseDate DateTime @default(dbgenerated("CURRENT_DATE")) @db.Date
|
||||||
title String
|
title String
|
||||||
category Category? @relation(fields: [categoryId], references: [id])
|
category Category? @relation(fields: [categoryId], references: [id])
|
||||||
categoryId Int @default(0)
|
categoryId Int @default(0)
|
||||||
amount Int
|
amount Int
|
||||||
paidBy Participant @relation(fields: [paidById], references: [id], onDelete: Cascade)
|
originalAmount Int?
|
||||||
paidById String
|
originalCurrency String?
|
||||||
paidFor ExpensePaidFor[]
|
conversionRate Decimal?
|
||||||
groupId String
|
paidBy Participant @relation(fields: [paidById], references: [id], onDelete: Cascade)
|
||||||
isReimbursement Boolean @default(false)
|
paidById String
|
||||||
splitMode SplitMode @default(EVENLY)
|
paidFor ExpensePaidFor[]
|
||||||
createdAt DateTime @default(now())
|
groupId String
|
||||||
documents ExpenseDocument[]
|
isReimbursement Boolean @default(false)
|
||||||
notes String?
|
splitMode SplitMode @default(EVENLY)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
documents ExpenseDocument[]
|
||||||
|
notes String?
|
||||||
|
|
||||||
recurrenceRule RecurrenceRule? @default(NONE)
|
recurrenceRule RecurrenceRule? @default(NONE)
|
||||||
recurringExpenseLink RecurringExpenseLink?
|
recurringExpenseLink RecurringExpenseLink?
|
||||||
recurringExpenseLinkId String?
|
recurringExpenseLinkId String?
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,16 +82,16 @@ enum SplitMode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model RecurringExpenseLink {
|
model RecurringExpenseLink {
|
||||||
id String @id
|
id String @id
|
||||||
groupId String
|
groupId String
|
||||||
currentFrameExpense Expense @relation(fields: [currentFrameExpenseId], references: [id], onDelete: Cascade)
|
currentFrameExpense Expense @relation(fields: [currentFrameExpenseId], references: [id], onDelete: Cascade)
|
||||||
currentFrameExpenseId String @unique
|
currentFrameExpenseId String @unique
|
||||||
|
|
||||||
// Note: We do not want to link to the next expense because once it is created, it should be
|
// 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
|
// 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.
|
// and any prior related recurring expenses, they'll need to delete them one by one.
|
||||||
nextExpenseCreatedAt DateTime?
|
nextExpenseCreatedAt DateTime?
|
||||||
nextExpenseDate DateTime
|
nextExpenseDate DateTime
|
||||||
|
|
||||||
@@index([groupId])
|
@@index([groupId])
|
||||||
@@index([groupId, nextExpenseCreatedAt, nextExpenseDate(sort: Desc)])
|
@@index([groupId, nextExpenseCreatedAt, nextExpenseDate(sort: Desc)])
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { CategorySelector } from '@/components/category-selector'
|
import { CategorySelector } from '@/components/category-selector'
|
||||||
|
import { CurrencySelector } from '@/components/currency-selector'
|
||||||
import { ExpenseDocumentsInput } from '@/components/expense-documents-input'
|
import { ExpenseDocumentsInput } from '@/components/expense-documents-input'
|
||||||
import { SubmitButton } from '@/components/submit-button'
|
import { SubmitButton } from '@/components/submit-button'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
@@ -32,9 +33,11 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
|
import { Locale } from '@/i18n'
|
||||||
import { randomId } from '@/lib/api'
|
import { randomId } from '@/lib/api'
|
||||||
|
import { defaultCurrencyList, getCurrency } from '@/lib/currency'
|
||||||
import { RuntimeFeatureFlags } from '@/lib/featureFlags'
|
import { RuntimeFeatureFlags } from '@/lib/featureFlags'
|
||||||
import { useActiveUser } from '@/lib/hooks'
|
import { useActiveUser, useCurrencyRate } from '@/lib/hooks'
|
||||||
import {
|
import {
|
||||||
ExpenseFormValues,
|
ExpenseFormValues,
|
||||||
SplittingOptions,
|
SplittingOptions,
|
||||||
@@ -51,7 +54,7 @@ import {
|
|||||||
import { AppRouterOutput } from '@/trpc/routers/_app'
|
import { AppRouterOutput } from '@/trpc/routers/_app'
|
||||||
import { zodResolver } from '@hookform/resolvers/zod'
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
import { RecurrenceRule } from '@prisma/client'
|
import { RecurrenceRule } from '@prisma/client'
|
||||||
import { Save } from 'lucide-react'
|
import { ChevronRight, Save } from 'lucide-react'
|
||||||
import { useLocale, useTranslations } from 'next-intl'
|
import { useLocale, useTranslations } from 'next-intl'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useSearchParams } from 'next/navigation'
|
import { useSearchParams } from 'next/navigation'
|
||||||
@@ -78,7 +81,7 @@ const getDefaultSplittingOptions = (
|
|||||||
splitMode: 'EVENLY' as const,
|
splitMode: 'EVENLY' as const,
|
||||||
paidFor: group.participants.map(({ id }) => ({
|
paidFor: group.participants.map(({ id }) => ({
|
||||||
participant: id,
|
participant: id,
|
||||||
shares: '1' as unknown as number,
|
shares: 1,
|
||||||
})),
|
})),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,7 +113,7 @@ const getDefaultSplittingOptions = (
|
|||||||
splitMode: parsedDefaultSplitMode.splitMode,
|
splitMode: parsedDefaultSplitMode.splitMode,
|
||||||
paidFor: parsedDefaultSplitMode.paidFor.map((paidFor) => ({
|
paidFor: parsedDefaultSplitMode.paidFor.map((paidFor) => ({
|
||||||
participant: paidFor.participant,
|
participant: paidFor.participant,
|
||||||
shares: String(paidFor.shares / 100) as unknown as number,
|
shares: paidFor.shares / 100,
|
||||||
})),
|
})),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -124,7 +127,7 @@ async function persistDefaultSplittingOptions(
|
|||||||
if (expenseFormValues.splitMode === 'EVENLY') {
|
if (expenseFormValues.splitMode === 'EVENLY') {
|
||||||
return expenseFormValues.paidFor.map(({ participant }) => ({
|
return expenseFormValues.paidFor.map(({ participant }) => ({
|
||||||
participant,
|
participant,
|
||||||
shares: '100' as unknown as number,
|
shares: 100,
|
||||||
}))
|
}))
|
||||||
} else if (expenseFormValues.splitMode === 'BY_AMOUNT') {
|
} else if (expenseFormValues.splitMode === 'BY_AMOUNT') {
|
||||||
return null
|
return null
|
||||||
@@ -161,7 +164,7 @@ export function ExpenseForm({
|
|||||||
runtimeFeatureFlags: RuntimeFeatureFlags
|
runtimeFeatureFlags: RuntimeFeatureFlags
|
||||||
}) {
|
}) {
|
||||||
const t = useTranslations('ExpenseForm')
|
const t = useTranslations('ExpenseForm')
|
||||||
const locale = useLocale()
|
const locale = useLocale() as Locale
|
||||||
const isCreate = expense === undefined
|
const isCreate = expense === undefined
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
|
|
||||||
@@ -186,18 +189,18 @@ export function ExpenseForm({
|
|||||||
? {
|
? {
|
||||||
title: expense.title,
|
title: expense.title,
|
||||||
expenseDate: expense.expenseDate ?? new Date(),
|
expenseDate: expense.expenseDate ?? new Date(),
|
||||||
amount: String(
|
amount: amountAsDecimal(expense.amount, groupCurrency),
|
||||||
amountAsDecimal(expense.amount, groupCurrency),
|
originalCurrency: expense.originalCurrency ?? group.currencyCode,
|
||||||
) as unknown as number, // hack
|
originalAmount: expense.originalAmount ?? undefined,
|
||||||
|
conversionRate: expense.conversionRate?.toNumber(),
|
||||||
category: expense.categoryId,
|
category: expense.categoryId,
|
||||||
paidBy: expense.paidById,
|
paidBy: expense.paidById,
|
||||||
paidFor: expense.paidFor.map(({ participantId, shares }) => ({
|
paidFor: expense.paidFor.map(({ participantId, shares }) => ({
|
||||||
participant: participantId,
|
participant: participantId,
|
||||||
shares: String(
|
shares:
|
||||||
expense.splitMode === 'BY_AMOUNT'
|
expense.splitMode === 'BY_AMOUNT'
|
||||||
? amountAsDecimal(shares, groupCurrency)
|
? amountAsDecimal(shares, groupCurrency)
|
||||||
: shares / 100,
|
: shares / 100,
|
||||||
) as unknown as number,
|
|
||||||
})),
|
})),
|
||||||
splitMode: expense.splitMode,
|
splitMode: expense.splitMode,
|
||||||
saveDefaultSplittingOptions: false,
|
saveDefaultSplittingOptions: false,
|
||||||
@@ -210,19 +213,20 @@ export function ExpenseForm({
|
|||||||
? {
|
? {
|
||||||
title: t('reimbursement'),
|
title: t('reimbursement'),
|
||||||
expenseDate: new Date(),
|
expenseDate: new Date(),
|
||||||
amount: String(
|
amount: amountAsDecimal(
|
||||||
amountAsDecimal(
|
Number(searchParams.get('amount')) || 0,
|
||||||
Number(searchParams.get('amount')) || 0,
|
groupCurrency,
|
||||||
groupCurrency,
|
),
|
||||||
),
|
originalCurrency: group.currencyCode,
|
||||||
) as unknown as number, // hack
|
originalAmount: undefined,
|
||||||
|
conversionRate: undefined,
|
||||||
category: 1, // category with Id 1 is Payment
|
category: 1, // category with Id 1 is Payment
|
||||||
paidBy: searchParams.get('from') ?? undefined,
|
paidBy: searchParams.get('from') ?? undefined,
|
||||||
paidFor: [
|
paidFor: [
|
||||||
searchParams.get('to')
|
searchParams.get('to')
|
||||||
? {
|
? {
|
||||||
participant: searchParams.get('to')!,
|
participant: searchParams.get('to')!,
|
||||||
shares: '1' as unknown as number,
|
shares: 1,
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
],
|
],
|
||||||
@@ -238,7 +242,10 @@ export function ExpenseForm({
|
|||||||
expenseDate: searchParams.get('date')
|
expenseDate: searchParams.get('date')
|
||||||
? new Date(searchParams.get('date') as string)
|
? new Date(searchParams.get('date') as string)
|
||||||
: new Date(),
|
: new Date(),
|
||||||
amount: (searchParams.get('amount') || 0) as unknown as number, // hack,
|
amount: Number(searchParams.get('amount')) || 0,
|
||||||
|
originalCurrency: group.currencyCode ?? undefined,
|
||||||
|
originalAmount: undefined,
|
||||||
|
conversionRate: undefined,
|
||||||
category: searchParams.get('categoryId')
|
category: searchParams.get('categoryId')
|
||||||
? Number(searchParams.get('categoryId'))
|
? Number(searchParams.get('categoryId'))
|
||||||
: 0, // category with Id 0 is General
|
: 0, // category with Id 0 is General
|
||||||
@@ -277,6 +284,12 @@ export function ExpenseForm({
|
|||||||
? amountAsMinorUnits(shares, groupCurrency)
|
? amountAsMinorUnits(shares, groupCurrency)
|
||||||
: shares,
|
: shares,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
// Currency should be blank if same as group currency
|
||||||
|
if (!conversionRequired) {
|
||||||
|
delete values.originalAmount
|
||||||
|
delete values.originalCurrency
|
||||||
|
}
|
||||||
return onSubmit(values, activeUserId ?? undefined)
|
return onSubmit(values, activeUserId ?? undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,6 +300,23 @@ export function ExpenseForm({
|
|||||||
|
|
||||||
const sExpense = isIncome ? 'Income' : 'Expense'
|
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(() => {
|
useEffect(() => {
|
||||||
setManuallyEditedParticipants(new Set())
|
setManuallyEditedParticipants(new Set())
|
||||||
}, [form.watch('splitMode'), form.watch('amount')])
|
}, [form.watch('splitMode'), form.watch('amount')])
|
||||||
@@ -329,11 +359,9 @@ export function ExpenseForm({
|
|||||||
if (!editedParticipants.includes(participant.participant)) {
|
if (!editedParticipants.includes(participant.participant)) {
|
||||||
return {
|
return {
|
||||||
...participant,
|
...participant,
|
||||||
shares: String(
|
shares: Number(
|
||||||
Number(
|
amountPerRemaining.toFixed(groupCurrency.decimal_digits),
|
||||||
amountPerRemaining.toFixed(groupCurrency.decimal_digits),
|
),
|
||||||
),
|
|
||||||
) as unknown as number,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return participant
|
return participant
|
||||||
@@ -347,6 +375,71 @@ export function ExpenseForm({
|
|||||||
form.watch('splitMode'),
|
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 (
|
return (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(submit)}>
|
<form onSubmit={form.handleSubmit(submit)}>
|
||||||
@@ -413,11 +506,175 @@ export function ExpenseForm({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
name="originalCurrency"
|
||||||
|
render={({ field: { onChange, ...field } }) => (
|
||||||
|
<FormItem className="sm:order-3">
|
||||||
|
<FormLabel>{t(`${sExpense}.currencyField.label`)}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
{group.currencyCode ? (
|
||||||
|
<CurrencySelector
|
||||||
|
currencies={defaultCurrencyList(locale, '')}
|
||||||
|
defaultValue={form.watch(field.name) ?? ''}
|
||||||
|
isLoading={false}
|
||||||
|
onValueChange={(v) => onChange(v)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
className="text-base"
|
||||||
|
disabled={true}
|
||||||
|
{...field}
|
||||||
|
placeholder={group.currency}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
{t(`${sExpense}.currencyField.description`)}{' '}
|
||||||
|
{!group.currencyCode && t('conversionUnavailable')}
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`sm:order-4 ${
|
||||||
|
!conversionRequired ? 'max-sm:hidden sm:invisible' : ''
|
||||||
|
} col-span-2 md:col-span-1 space-y-2`}
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="originalAmount"
|
||||||
|
render={({ field: { onChange, ...field } }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t('originalAmountField.label')}</FormLabel>
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<span>{originalCurrency.symbol}</span>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
className="text-base max-w-[120px]"
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
placeholder="0.00"
|
||||||
|
onChange={(event) => {
|
||||||
|
const v = enforceCurrencyPattern(event.target.value)
|
||||||
|
onChange(v)
|
||||||
|
}}
|
||||||
|
{...field}
|
||||||
|
onFocus={(e) => {
|
||||||
|
const target = e.currentTarget
|
||||||
|
setTimeout(() => target.select(), 1)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</div>
|
||||||
|
<FormDescription>
|
||||||
|
{isNaN(form.getValues('expenseDate').getTime()) ? (
|
||||||
|
t('conversionRateState.noDate')
|
||||||
|
) : form.getValues('expenseDate') &&
|
||||||
|
!usingCustomConversionRate ? (
|
||||||
|
<>
|
||||||
|
{conversionRateMessage}
|
||||||
|
{!exchangeRate.isLoading && (
|
||||||
|
<Button
|
||||||
|
className="h-auto py-0"
|
||||||
|
variant="link"
|
||||||
|
onClick={() => exchangeRate.refresh()}
|
||||||
|
>
|
||||||
|
{t('conversionRateState.refresh')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
t('conversionRateState.customRate')
|
||||||
|
)}
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Collapsible
|
||||||
|
open={usingCustomConversionRate}
|
||||||
|
onOpenChange={setUsingCustomConversionRate}
|
||||||
|
>
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<Button variant="link" className="-mx-4">
|
||||||
|
{usingCustomConversionRate
|
||||||
|
? t('conversionRateField.useApi')
|
||||||
|
: t('conversionRateField.useCustom')}
|
||||||
|
</Button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="conversionRate"
|
||||||
|
render={({ field: { onChange, ...field } }) => (
|
||||||
|
<FormItem
|
||||||
|
className={`sm:order-4 ${
|
||||||
|
!conversionRequired
|
||||||
|
? 'max-sm:hidden sm:invisible'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<FormLabel>{t('conversionRateField.label')}</FormLabel>
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<span>
|
||||||
|
{originalCurrency.symbol} 1 = {group.currency}
|
||||||
|
</span>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
className="text-base max-w-[120px]"
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
placeholder="0.00"
|
||||||
|
onChange={(event) => {
|
||||||
|
const v = enforceCurrencyPattern(
|
||||||
|
event.target.value,
|
||||||
|
)
|
||||||
|
onChange(v)
|
||||||
|
}}
|
||||||
|
{...field}
|
||||||
|
onFocus={(e) => {
|
||||||
|
const target = e.currentTarget
|
||||||
|
setTimeout(() => target.select(), 1)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</div>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
</div>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="category"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="order-3 sm:order-2">
|
||||||
|
<FormLabel>{t('categoryField.label')}</FormLabel>
|
||||||
|
<CategorySelector
|
||||||
|
categories={categories}
|
||||||
|
defaultValue={
|
||||||
|
form.watch(field.name) // may be overwritten externally
|
||||||
|
}
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
isLoading={isCategoryLoading}
|
||||||
|
/>
|
||||||
|
<FormDescription>
|
||||||
|
{t(`${sExpense}.categoryFieldDescription`)}
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="amount"
|
name="amount"
|
||||||
render={({ field: { onChange, ...field } }) => (
|
render={({ field: { onChange, ...field } }) => (
|
||||||
<FormItem className="sm:order-3">
|
<FormItem className="sm:order-5">
|
||||||
<FormLabel>{t('amountField.label')}</FormLabel>
|
<FormLabel>{t('amountField.label')}</FormLabel>
|
||||||
<div className="flex items-baseline gap-2">
|
<div className="flex items-baseline gap-2">
|
||||||
<span>{group.currency}</span>
|
<span>{group.currency}</span>
|
||||||
@@ -470,28 +727,6 @@ export function ExpenseForm({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="category"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="order-3 sm:order-2">
|
|
||||||
<FormLabel>{t('categoryField.label')}</FormLabel>
|
|
||||||
<CategorySelector
|
|
||||||
categories={categories}
|
|
||||||
defaultValue={
|
|
||||||
form.watch(field.name) // may be overwritten externally
|
|
||||||
}
|
|
||||||
onValueChange={field.onChange}
|
|
||||||
isLoading={isCategoryLoading}
|
|
||||||
/>
|
|
||||||
<FormDescription>
|
|
||||||
{t(`${sExpense}.categoryFieldDescription`)}
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="paidBy"
|
name="paidBy"
|
||||||
@@ -592,7 +827,7 @@ export function ExpenseForm({
|
|||||||
participant: p.id,
|
participant: p.id,
|
||||||
shares:
|
shares:
|
||||||
paidFor.find((pfor) => pfor.participant === p.id)
|
paidFor.find((pfor) => pfor.participant === p.id)
|
||||||
?.shares ?? ('1' as unknown as number),
|
?.shares ?? 1,
|
||||||
}))
|
}))
|
||||||
form.setValue('paidFor', newPaidFor, {
|
form.setValue('paidFor', newPaidFor, {
|
||||||
shouldDirty: true,
|
shouldDirty: true,
|
||||||
@@ -630,7 +865,7 @@ export function ExpenseForm({
|
|||||||
data-id={`${id}/${form.getValues().splitMode}/${
|
data-id={`${id}/${form.getValues().splitMode}/${
|
||||||
group.currency
|
group.currency
|
||||||
}`}
|
}`}
|
||||||
className="flex items-center border-t last-of-type:border-b last-of-type:!mb-4 -mx-6 px-6 py-3"
|
className="flex flex-wrap gap-y-4 items-center border-t last-of-type:border-b last-of-type:!mb-4 -mx-6 px-6 py-3"
|
||||||
>
|
>
|
||||||
<FormItem className="flex-1 flex flex-row items-start space-x-3 space-y-0">
|
<FormItem className="flex-1 flex flex-row items-start space-x-3 space-y-0">
|
||||||
<FormControl>
|
<FormControl>
|
||||||
@@ -651,7 +886,7 @@ export function ExpenseForm({
|
|||||||
...field.value,
|
...field.value,
|
||||||
{
|
{
|
||||||
participant: id,
|
participant: id,
|
||||||
shares: '1' as unknown as number,
|
shares: 1,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
options,
|
options,
|
||||||
@@ -714,106 +949,209 @@ export function ExpenseForm({
|
|||||||
)}
|
)}
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
{form.getValues().splitMode !== 'EVENLY' && (
|
<div className="flex">
|
||||||
<FormField
|
{form.getValues().splitMode === 'BY_AMOUNT' &&
|
||||||
name={`paidFor[${field.value.findIndex(
|
!!conversionRequired && (
|
||||||
({ participant }) => participant === id,
|
<FormField
|
||||||
)}].shares`}
|
name={`paidFor[${field.value.findIndex(
|
||||||
render={() => {
|
({ participant }) => participant === id,
|
||||||
const sharesLabel = (
|
)}].originalAmount`}
|
||||||
<span
|
render={() => {
|
||||||
className={cn('text-sm', {
|
const sharesLabel = (
|
||||||
'text-muted': !field.value?.some(
|
<span
|
||||||
({ participant }) =>
|
className={cn('text-sm', {
|
||||||
participant === id,
|
'text-muted': !field.value?.some(
|
||||||
),
|
({ participant }) =>
|
||||||
})}
|
participant === id,
|
||||||
>
|
),
|
||||||
{match(form.getValues().splitMode)
|
})}
|
||||||
.with('BY_SHARES', () => (
|
>
|
||||||
<>{t('shares')}</>
|
{originalCurrency.symbol}
|
||||||
))
|
</span>
|
||||||
.with('BY_PERCENTAGE', () => <>%</>)
|
)
|
||||||
.with('BY_AMOUNT', () => (
|
return (
|
||||||
<>{group.currency}</>
|
<div>
|
||||||
))
|
<div className="flex gap-1 items-center">
|
||||||
.otherwise(() => (
|
{sharesLabel}
|
||||||
<></>
|
<FormControl>
|
||||||
))}
|
<Input
|
||||||
</span>
|
key={String(
|
||||||
)
|
!field.value?.some(
|
||||||
return (
|
({ participant }) =>
|
||||||
<div>
|
participant === id,
|
||||||
<div className="flex gap-1 items-center">
|
),
|
||||||
{form.getValues().splitMode ===
|
)}
|
||||||
'BY_AMOUNT' && sharesLabel}
|
className="text-base w-[80px] -my-2"
|
||||||
<FormControl>
|
type="text"
|
||||||
<Input
|
inputMode="decimal"
|
||||||
key={String(
|
disabled={
|
||||||
!field.value?.some(
|
!field.value?.some(
|
||||||
({ participant }) =>
|
({ participant }) =>
|
||||||
participant === id,
|
participant === id,
|
||||||
),
|
)
|
||||||
)}
|
}
|
||||||
className="text-base w-[80px] -my-2"
|
value={
|
||||||
type="text"
|
field.value.find(
|
||||||
disabled={
|
({ participant }) =>
|
||||||
!field.value?.some(
|
participant === id,
|
||||||
({ participant }) =>
|
)?.originalAmount ?? ''
|
||||||
participant === id,
|
}
|
||||||
)
|
onChange={(event) => {
|
||||||
}
|
const originalAmount = Number(
|
||||||
value={
|
event.target.value,
|
||||||
field.value?.find(
|
)
|
||||||
({ participant }) =>
|
let convertedAmount = ''
|
||||||
participant === id,
|
if (
|
||||||
)?.shares
|
!Number.isNaN(
|
||||||
}
|
originalAmount,
|
||||||
onChange={(event) => {
|
) &&
|
||||||
field.onChange(
|
exchangeRate.data
|
||||||
field.value.map((p) =>
|
) {
|
||||||
p.participant === id
|
convertedAmount = (
|
||||||
? {
|
originalAmount *
|
||||||
participant: id,
|
exchangeRate.data
|
||||||
shares:
|
).toFixed(
|
||||||
enforceCurrencyPattern(
|
groupCurrency.decimal_digits,
|
||||||
event.target.value,
|
)
|
||||||
),
|
}
|
||||||
}
|
field.onChange(
|
||||||
: p,
|
field.value.map((p) =>
|
||||||
|
p.participant === id
|
||||||
|
? {
|
||||||
|
participant: id,
|
||||||
|
originalAmount:
|
||||||
|
event.target
|
||||||
|
.value,
|
||||||
|
shares:
|
||||||
|
enforceCurrencyPattern(
|
||||||
|
convertedAmount,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: p,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
setManuallyEditedParticipants(
|
||||||
|
(prev) =>
|
||||||
|
new Set(prev).add(id),
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
step={
|
||||||
|
10 **
|
||||||
|
-originalCurrency.decimal_digits
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<ChevronRight className="h-4 w-4 mx-1 opacity-50" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{form.getValues().splitMode !== 'EVENLY' && (
|
||||||
|
<FormField
|
||||||
|
name={`paidFor[${field.value.findIndex(
|
||||||
|
({ participant }) => participant === id,
|
||||||
|
)}].shares`}
|
||||||
|
render={() => {
|
||||||
|
const sharesLabel = (
|
||||||
|
<span
|
||||||
|
className={cn('text-sm', {
|
||||||
|
'text-muted': !field.value?.some(
|
||||||
|
({ participant }) =>
|
||||||
|
participant === id,
|
||||||
|
),
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{match(form.getValues().splitMode)
|
||||||
|
.with('BY_SHARES', () => (
|
||||||
|
<>{t('shares')}</>
|
||||||
|
))
|
||||||
|
.with('BY_PERCENTAGE', () => <>%</>)
|
||||||
|
.with('BY_AMOUNT', () => (
|
||||||
|
<>{group.currency}</>
|
||||||
|
))
|
||||||
|
.otherwise(() => (
|
||||||
|
<></>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex gap-1 items-center">
|
||||||
|
{form.getValues().splitMode ===
|
||||||
|
'BY_AMOUNT' && sharesLabel}
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
key={String(
|
||||||
|
!field.value?.some(
|
||||||
|
({ participant }) =>
|
||||||
|
participant === id,
|
||||||
),
|
),
|
||||||
)
|
)}
|
||||||
setManuallyEditedParticipants(
|
className="text-base w-[80px] -my-2"
|
||||||
(prev) => new Set(prev).add(id),
|
type="text"
|
||||||
)
|
disabled={
|
||||||
}}
|
!field.value?.some(
|
||||||
inputMode={
|
({ participant }) =>
|
||||||
form.getValues().splitMode ===
|
participant === id,
|
||||||
'BY_AMOUNT'
|
)
|
||||||
? 'decimal'
|
}
|
||||||
: 'numeric'
|
value={
|
||||||
}
|
field.value?.find(
|
||||||
step={
|
({ participant }) =>
|
||||||
form.getValues().splitMode ===
|
participant === id,
|
||||||
'BY_AMOUNT'
|
)?.shares
|
||||||
? 0.01
|
}
|
||||||
: 1
|
onChange={(event) => {
|
||||||
}
|
field.onChange(
|
||||||
/>
|
field.value.map((p) =>
|
||||||
</FormControl>
|
p.participant === id
|
||||||
{[
|
? {
|
||||||
'BY_SHARES',
|
participant: id,
|
||||||
'BY_PERCENTAGE',
|
shares:
|
||||||
].includes(
|
enforceCurrencyPattern(
|
||||||
form.getValues().splitMode,
|
event.target
|
||||||
) && sharesLabel}
|
.value,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: p,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
setManuallyEditedParticipants(
|
||||||
|
(prev) =>
|
||||||
|
new Set(prev).add(id),
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
inputMode={
|
||||||
|
form.getValues().splitMode ===
|
||||||
|
'BY_AMOUNT'
|
||||||
|
? 'decimal'
|
||||||
|
: 'numeric'
|
||||||
|
}
|
||||||
|
step={
|
||||||
|
form.getValues().splitMode ===
|
||||||
|
'BY_AMOUNT'
|
||||||
|
? 10 **
|
||||||
|
-groupCurrency.decimal_digits
|
||||||
|
: 1
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
{[
|
||||||
|
'BY_SHARES',
|
||||||
|
'BY_PERCENTAGE',
|
||||||
|
].includes(
|
||||||
|
form.getValues().splitMode,
|
||||||
|
) && sharesLabel}
|
||||||
|
</div>
|
||||||
|
<FormMessage className="float-right" />
|
||||||
</div>
|
</div>
|
||||||
<FormMessage className="float-right" />
|
)
|
||||||
</div>
|
}}
|
||||||
)
|
/>
|
||||||
}}
|
)}
|
||||||
/>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { getCurrency } from '@/lib/currency'
|
||||||
import { formatAmountAsDecimal, getCurrencyFromGroup } from '@/lib/utils'
|
import { formatAmountAsDecimal, getCurrencyFromGroup } from '@/lib/utils'
|
||||||
import { Parser } from '@json2csv/plainjs'
|
import { Parser } from '@json2csv/plainjs'
|
||||||
import { PrismaClient } from '@prisma/client'
|
import { PrismaClient } from '@prisma/client'
|
||||||
@@ -38,6 +39,9 @@ export async function GET(
|
|||||||
title: true,
|
title: true,
|
||||||
category: { select: { name: true } },
|
category: { select: { name: true } },
|
||||||
amount: true,
|
amount: true,
|
||||||
|
originalAmount: true,
|
||||||
|
originalCurrency: true,
|
||||||
|
conversionRate: true,
|
||||||
paidById: true,
|
paidById: true,
|
||||||
paidFor: { select: { participantId: true, shares: true } },
|
paidFor: { select: { participantId: true, shares: true } },
|
||||||
isReimbursement: true,
|
isReimbursement: true,
|
||||||
@@ -54,30 +58,29 @@ export async function GET(
|
|||||||
|
|
||||||
/*
|
/*
|
||||||
|
|
||||||
CSV Structure:
|
CSV Columns:
|
||||||
|
|
||||||
--------------------------------------------------------------
|
|
||||||
| Date | Description | Category | Currency | Cost
|
|
||||||
--------------------------------------------------------------
|
|
||||||
| Is Reimbursement | Split mode | UserA | UserB
|
|
||||||
--------------------------------------------------------------
|
|
||||||
|
|
||||||
Columns:
|
|
||||||
- Date: The date of the expense.
|
- Date: The date of the expense.
|
||||||
- Description: A brief description of the expense.
|
- Description: A brief description of the expense.
|
||||||
- Category: The category of the expense (e.g., Food, Travel, etc.).
|
- Category: The category of the expense (e.g., Food, Travel, etc.).
|
||||||
- Currency: The currency in which the expense is recorded.
|
- Currency: The currency in which the expense is recorded.
|
||||||
- Cost: The amount spent.
|
- 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.
|
- 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).
|
- 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).
|
- UserA, UserB: User-specific data or balances (e.g., amount owed or contributed by each user).
|
||||||
|
|
||||||
Example Row:
|
Example Table:
|
||||||
------------------------------------------------------------------------------------------
|
+------------+------------------+----------+----------+----------+---------------+-------------------+-----------------+------------------+----------------------+--------+-----------+
|
||||||
| 2025-01-06 | Dinner with team | Food | ₹ | 5000 | No | Evenly | John | Jane
|
| Date | Description | Category | Currency | Cost | Original cost | Original currency | Conversion rate | Is reinbursement | Split mode | User A | User B |
|
||||||
------------------------------------------------------------------------------------------
|
+------------+------------------+----------+----------+----------+---------------+-------------------+-----------------+------------------+----------------------+--------+-----------+
|
||||||
|
| 2025-01-06 | Dinner with team | Food | INR | 5000 | | | | No | Evenly | 2500 | -2500 |
|
||||||
|
+------------+------------------+----------+----------+----------+---------------+-------------------+-----------------+------------------+----------------------+--------+-----------+
|
||||||
|
| 2025-02-07 | Plane tickets | Travel | INR | 97264.09 | 1000 | EUR | 97.2641 | No | Unevenly - By amount | -80000 | -17264.09 |
|
||||||
|
+------------+------------------+----------+----------+----------+---------------+-------------------+-----------------+------------------+----------------------+--------+-----------+
|
||||||
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const fields = [
|
const fields = [
|
||||||
{ label: 'Date', value: 'date' },
|
{ label: 'Date', value: 'date' },
|
||||||
@@ -85,6 +88,9 @@ export async function GET(
|
|||||||
{ label: 'Category', value: 'categoryName' },
|
{ label: 'Category', value: 'categoryName' },
|
||||||
{ label: 'Currency', value: 'currency' },
|
{ label: 'Currency', value: 'currency' },
|
||||||
{ label: 'Cost', value: 'amount' },
|
{ 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: 'Is Reimbursement', value: 'isReimbursement' },
|
||||||
{ label: 'Split mode', value: 'splitMode' },
|
{ label: 'Split mode', value: 'splitMode' },
|
||||||
...group.participants.map((participant) => ({
|
...group.participants.map((participant) => ({
|
||||||
@@ -101,6 +107,16 @@ export async function GET(
|
|||||||
categoryName: expense.category?.name || '',
|
categoryName: expense.category?.name || '',
|
||||||
currency: group.currencyCode ?? group.currency,
|
currency: group.currencyCode ?? group.currency,
|
||||||
amount: formatAmountAsDecimal(expense.amount, currency),
|
amount: formatAmountAsDecimal(expense.amount, currency),
|
||||||
|
originalAmount: expense.originalAmount
|
||||||
|
? formatAmountAsDecimal(
|
||||||
|
expense.originalAmount,
|
||||||
|
getCurrency(expense.originalCurrency),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
originalCurrency: expense.originalCurrency,
|
||||||
|
conversionRate: expense.conversionRate
|
||||||
|
? expense.conversionRate.toString()
|
||||||
|
: null,
|
||||||
isReimbursement: expense.isReimbursement ? 'Yes' : 'No',
|
isReimbursement: expense.isReimbursement ? 'Yes' : 'No',
|
||||||
splitMode: splitModeLabel[expense.splitMode],
|
splitMode: splitModeLabel[expense.splitMode],
|
||||||
...Object.fromEntries(
|
...Object.fromEntries(
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ export async function GET(
|
|||||||
title: true,
|
title: true,
|
||||||
category: { select: { grouping: true, name: true } },
|
category: { select: { grouping: true, name: true } },
|
||||||
amount: true,
|
amount: true,
|
||||||
|
originalAmount: true,
|
||||||
|
originalCurrency: true,
|
||||||
|
conversionRate: true,
|
||||||
paidById: true,
|
paidById: true,
|
||||||
paidFor: { select: { participantId: true, shares: true } },
|
paidFor: { select: { participantId: true, shares: true } },
|
||||||
isReimbursement: true,
|
isReimbursement: true,
|
||||||
|
|||||||
@@ -71,6 +71,9 @@ export async function createExpense(
|
|||||||
expenseDate: expenseFormValues.expenseDate,
|
expenseDate: expenseFormValues.expenseDate,
|
||||||
categoryId: expenseFormValues.category,
|
categoryId: expenseFormValues.category,
|
||||||
amount: expenseFormValues.amount,
|
amount: expenseFormValues.amount,
|
||||||
|
originalAmount: expenseFormValues.originalAmount,
|
||||||
|
originalCurrency: expenseFormValues.originalCurrency,
|
||||||
|
conversionRate: expenseFormValues.conversionRate,
|
||||||
title: expenseFormValues.title,
|
title: expenseFormValues.title,
|
||||||
paidById: expenseFormValues.paidBy,
|
paidById: expenseFormValues.paidBy,
|
||||||
splitMode: expenseFormValues.splitMode,
|
splitMode: expenseFormValues.splitMode,
|
||||||
@@ -206,6 +209,9 @@ export async function updateExpense(
|
|||||||
data: {
|
data: {
|
||||||
expenseDate: expenseFormValues.expenseDate,
|
expenseDate: expenseFormValues.expenseDate,
|
||||||
amount: expenseFormValues.amount,
|
amount: expenseFormValues.amount,
|
||||||
|
originalAmount: expenseFormValues.originalAmount,
|
||||||
|
originalCurrency: expenseFormValues.originalCurrency,
|
||||||
|
conversionRate: expenseFormValues.conversionRate,
|
||||||
title: expenseFormValues.title,
|
title: expenseFormValues.title,
|
||||||
categoryId: expenseFormValues.category,
|
categoryId: expenseFormValues.category,
|
||||||
paidById: expenseFormValues.paidBy,
|
paidById: expenseFormValues.paidBy,
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import dayjs from 'dayjs'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
import useSWR, { Fetcher } from 'swr'
|
||||||
|
|
||||||
export function useMediaQuery(query: string): boolean {
|
export function useMediaQuery(query: string): boolean {
|
||||||
const getMatches = (query: string): boolean => {
|
const getMatches = (query: string): boolean => {
|
||||||
@@ -64,3 +66,62 @@ export function useActiveUser(groupId?: string) {
|
|||||||
|
|
||||||
return activeUser
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -32,6 +32,19 @@ export const groupFormSchema = z
|
|||||||
|
|
||||||
export type GroupFormValues = z.infer<typeof groupFormSchema>
|
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
|
export const expenseFormSchema = z
|
||||||
.object({
|
.object({
|
||||||
expenseDate: z.coerce.date(),
|
expenseDate: z.coerce.date(),
|
||||||
@@ -55,11 +68,27 @@ export const expenseFormSchema = z
|
|||||||
)
|
)
|
||||||
.refine((amount) => amount != 0, 'amountNotZero')
|
.refine((amount) => amount != 0, 'amountNotZero')
|
||||||
.refine((amount) => amount <= 10_000_000_00, 'amountTenMillion'),
|
.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' }),
|
paidBy: z.string({ required_error: 'paidByRequired' }),
|
||||||
paidFor: z
|
paidFor: z
|
||||||
.array(
|
.array(
|
||||||
z.object({
|
z.object({
|
||||||
participant: z.string(),
|
participant: z.string(),
|
||||||
|
originalAmount: z.string().optional(), // For converting shares by amounts in original currency, not saved.
|
||||||
shares: z.union([
|
shares: z.union([
|
||||||
z.number(),
|
z.number(),
|
||||||
z.string().transform((value, ctx) => {
|
z.string().transform((value, ctx) => {
|
||||||
@@ -163,17 +192,18 @@ export const expenseFormSchema = z
|
|||||||
// Format the share split as a number (if from form submission)
|
// Format the share split as a number (if from form submission)
|
||||||
return {
|
return {
|
||||||
...expense,
|
...expense,
|
||||||
paidFor: expense.paidFor.map(({ participant, shares }) => {
|
paidFor: expense.paidFor.map((paidFor) => {
|
||||||
|
const shares = paidFor.shares
|
||||||
if (typeof shares === 'string' && expense.splitMode !== 'BY_AMOUNT') {
|
if (typeof shares === 'string' && expense.splitMode !== 'BY_AMOUNT') {
|
||||||
// For splitting not by amount, preserve the previous behaviour of multiplying the share by 100
|
// For splitting not by amount, preserve the previous behaviour of multiplying the share by 100
|
||||||
return {
|
return {
|
||||||
participant,
|
...paidFor,
|
||||||
shares: Math.round(Number(shares) * 100),
|
shares: Math.round(Number(shares) * 100),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Otherwise, no need as the number will have been formatted according to currency.
|
// Otherwise, no need as the number will have been formatted according to currency.
|
||||||
return {
|
return {
|
||||||
participant,
|
...paidFor,
|
||||||
shares: Number(shares),
|
shares: Number(shares),
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -99,10 +99,10 @@ export function amountAsDecimal(
|
|||||||
* - €1.5 = 150 "minor units" of euros (cents)
|
* - €1.5 = 150 "minor units" of euros (cents)
|
||||||
* - JPY 1000 = 1000 "minor units" of yen (the yen does not have minor units in practice)
|
* - JPY 1000 = 1000 "minor units" of yen (the yen does not have minor units in practice)
|
||||||
*
|
*
|
||||||
* @param amount The amount in decimal major units
|
* @param amount The amount in decimal major units (always an integer)
|
||||||
*/
|
*/
|
||||||
export function amountAsMinorUnits(amount: number, currency: Currency) {
|
export function amountAsMinorUnits(amount: number, currency: Currency) {
|
||||||
return amount * 10 ** currency.decimal_digits
|
return Math.round(amount * 10 ** currency.decimal_digits)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
'use client' // <-- to make sure we can mount the Provider from a server component
|
'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 type { QueryClient } from '@tanstack/react-query'
|
||||||
import { QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { httpBatchLink } from '@trpc/client'
|
import { httpBatchLink } from '@trpc/client'
|
||||||
@@ -8,6 +9,15 @@ import superjson from 'superjson'
|
|||||||
import { makeQueryClient } from './query-client'
|
import { makeQueryClient } from './query-client'
|
||||||
import type { AppRouter } from './routers/_app'
|
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>()
|
export const trpc = createTRPCReact<AppRouter>()
|
||||||
|
|
||||||
let clientQueryClientSingleton: QueryClient
|
let clientQueryClientSingleton: QueryClient
|
||||||
|
|||||||
@@ -1,7 +1,17 @@
|
|||||||
|
import { Prisma } from '@prisma/client'
|
||||||
import { initTRPC } from '@trpc/server'
|
import { initTRPC } from '@trpc/server'
|
||||||
import { cache } from 'react'
|
import { cache } from 'react'
|
||||||
import superjson from 'superjson'
|
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 () => {
|
export const createTRPCContext = cache(async () => {
|
||||||
/**
|
/**
|
||||||
* @see: https://trpc.io/docs/server/context
|
* @see: https://trpc.io/docs/server/context
|
||||||
|
|||||||
Reference in New Issue
Block a user