mirror of
https://github.com/spliit-app/spliit.git
synced 2026-02-14 11:36:13 +01:00
Compare commits
8 Commits
1.17.0
...
414-fix-de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df0a32617d | ||
|
|
eb78848601 | ||
|
|
a9f008683f | ||
|
|
52a2b552cb | ||
|
|
0e77a666f4 | ||
|
|
c49d0ea220 | ||
|
|
05a793ee39 | ||
|
|
d641540b65 |
@@ -38,12 +38,15 @@
|
||||
"earlierThisYear": "Dieses Jahr",
|
||||
"lastYear": "Letztes Jahr",
|
||||
"older": "Älter"
|
||||
}
|
||||
},
|
||||
"export": "Exportieren"
|
||||
},
|
||||
"ExpenseCard": {
|
||||
"paidBy": "Gezahlt von <strong>{paidBy}</strong> für <paidFor></paidFor>",
|
||||
"receivedBy": "Empfangen von <strong>{paidBy}</strong> für <paidFor></paidFor>",
|
||||
"yourBalance": "Deine Bilanz:"
|
||||
"yourBalance": "Deine Bilanz:",
|
||||
"everyone": "jeder",
|
||||
"notInvolved": "Du bist nicht involviert"
|
||||
},
|
||||
"Groups": {
|
||||
"myGroups": "Meine Gruppen",
|
||||
@@ -117,6 +120,12 @@
|
||||
"create": "Erstellen",
|
||||
"creating": "Erstellt…",
|
||||
"cancel": "Abbrechen"
|
||||
},
|
||||
"CurrencyCodeField": {
|
||||
"label": "Hauptwährung",
|
||||
"createDescription": "Alle Beträge und Salden werden in dieser Währung angegeben.",
|
||||
"customOption": "benutzerdefiniert",
|
||||
"editDescription": "Alle Beträge und Salden werden in dieser Währung angegeben. Bei Änderung dieser, werden bereits eingegebene Ausgaben NICHT umgerechnet, es sei denn, die Währung hat andere \"kleinere Einheiten\" als die aktuelle (z. B. Wechsel von US-Dollar zu Japanischem Yen)"
|
||||
}
|
||||
},
|
||||
"ExpenseForm": {
|
||||
@@ -240,7 +249,7 @@
|
||||
"triggerTitle": "Ausgabe von Rechnungsbeleg erstellen",
|
||||
"title": "Von Rechnungsbeleg erstellen",
|
||||
"description": "Ausgabeninformationen von einem Foto einer Rechnung lesen.",
|
||||
"body": "Lade ein Foto der Rechnung hoch und wir versuchen die Ausgabeinformationen zu extrahieren",
|
||||
"body": "Lade ein Foto der Rechnung hoch und wir versuchen die Ausgabeinformationen zu extrahieren.",
|
||||
"selectImage": "Bild wählen…",
|
||||
"titleLabel": "Titel:",
|
||||
"categoryLabel": "Kategorie:",
|
||||
@@ -295,7 +304,7 @@
|
||||
"Groups": {
|
||||
"today": "Heute",
|
||||
"yesterday": "Gestern",
|
||||
"earlierThisWeek": "Diese Woche",
|
||||
"earlierThisWeek": "Anfang dieser Woche",
|
||||
"lastWeek": "Letze Woche",
|
||||
"earlierThisMonth": "Diesen Monat",
|
||||
"lastMonth": "Letzen Monat",
|
||||
@@ -328,7 +337,7 @@
|
||||
"invalidNumber": "Zahl nicht valide.",
|
||||
"amountRequired": "Du musst einen Betrag angeben.",
|
||||
"amountNotZero": "Der Betrag darf nicht 0 sein.",
|
||||
"amountTenMillion": "Der Betrag muss kleiner als 10.000.000 sein",
|
||||
"amountTenMillion": "Der Betrag muss kleiner als 10.000.000 sein.",
|
||||
"paidByRequired": "Du musst ein Mitglied auswählen.",
|
||||
"paidForMin1": "Die Ausgabe muss mindestens für ein Mitglied bezahlt werden.",
|
||||
"noZeroShares": "Alle Anteile müssen größer als 0 sein.",
|
||||
@@ -403,5 +412,18 @@
|
||||
"TV/Phone/Internet": "TV/Internet/Telefonie",
|
||||
"Water": "Wasser"
|
||||
}
|
||||
},
|
||||
"Currencies": {
|
||||
"search": "Währung suchen...",
|
||||
"noCurrency": "Keine Währungen gefunden.",
|
||||
"other": {
|
||||
"heading": "Andere Währungen"
|
||||
},
|
||||
"custom": {
|
||||
"heading": "Benutzerdefinierte"
|
||||
},
|
||||
"common": {
|
||||
"heading": "Geläufigste"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,6 +141,10 @@
|
||||
"label": "Income date",
|
||||
"description": "Enter the date the income was received."
|
||||
},
|
||||
"currencyField": {
|
||||
"label": "Currency of income",
|
||||
"description": "The currency in which the income was received."
|
||||
},
|
||||
"categoryFieldDescription": "Select the income category.",
|
||||
"paidByField": {
|
||||
"label": "Received by",
|
||||
@@ -165,6 +169,10 @@
|
||||
"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",
|
||||
@@ -190,6 +198,27 @@
|
||||
"amountField": {
|
||||
"label": "Amount"
|
||||
},
|
||||
"conversionUnavailable": "To set a different currency per expense and convert amounts, select a non-custom currency for the group.",
|
||||
"originalAmountField": {
|
||||
"label": "Amount to convert"
|
||||
},
|
||||
"conversionRateField": {
|
||||
"useApi": "Use rates from Frankfurter",
|
||||
"useCustom": "Use custom rate",
|
||||
"label": "Exchange rate"
|
||||
},
|
||||
"conversionRateState": {
|
||||
"loading": "Getting exchange rates…",
|
||||
"success": "Obtained rates:",
|
||||
"error": "Oops, we could not get the most recent rates.",
|
||||
"staleRate": "Using rate:",
|
||||
"noRate": "Enter a custom rate below.",
|
||||
"currencyNotFound": "Oops, Frankfurter does not have the rate for this currency at this day.",
|
||||
"noDate": "Enter the expense date to get a conversion rate.",
|
||||
"dateMismatch": "Rates from date: {date}",
|
||||
"refresh": "Refresh",
|
||||
"customRate": "Using custom rate"
|
||||
},
|
||||
"isReimbursementField": {
|
||||
"label": "This is a reimbursement"
|
||||
},
|
||||
@@ -331,6 +360,7 @@
|
||||
"amountRequired": "You must enter an amount.",
|
||||
"amountNotZero": "The amount must not be zero.",
|
||||
"amountTenMillion": "The amount must be lower than 10,000,000.",
|
||||
"ratePositive": "The rate must be strictly greater than zero.",
|
||||
"paidByRequired": "You must select a participant.",
|
||||
"paidForMin1": "The expense must be paid for at least one participant.",
|
||||
"noZeroShares": "All shares must be higher than 0.",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"Homepage": {
|
||||
"title": "Partagez <strong>vos dépenses</strong> avec <strong>vos amis</strong> & <strong>votre famille :)</strong>",
|
||||
"title": "Partagez <strong>vos dépenses</strong> avec <strong>vos amis & votre famille</strong>",
|
||||
"description": "Bienvenue sur votre instance <strong>Spliit</strong> !",
|
||||
"button": {
|
||||
"groups": "Accéder aux groupes",
|
||||
@@ -38,12 +38,15 @@
|
||||
"earlierThisYear": "Plus tôt cette année",
|
||||
"lastYear": "L'année dernière",
|
||||
"older": "Plus ancien"
|
||||
}
|
||||
},
|
||||
"export": "Exporter"
|
||||
},
|
||||
"ExpenseCard": {
|
||||
"paidBy": "Payé par <strong>{paidBy}</strong> pour <paidFor></paidFor>",
|
||||
"receivedBy": "Reçu par <strong>{paidBy}</strong> pour <paidFor></paidFor>",
|
||||
"yourBalance": "Votre solde :"
|
||||
"yourBalance": "Votre solde :",
|
||||
"everyone": "tout le monde",
|
||||
"notInvolved": "Vous n'êtes pas concerné"
|
||||
},
|
||||
"Groups": {
|
||||
"myGroups": "Mes groupes",
|
||||
@@ -99,9 +102,9 @@
|
||||
"protectedParticipant": "Ce participant fait partie des dépenses et ne peut pas être supprimé.",
|
||||
"new": "Nouveau",
|
||||
"add": "Ajouter un participant",
|
||||
"John": "John",
|
||||
"Jane": "Jane",
|
||||
"Jack": "Jack"
|
||||
"John": "Jean",
|
||||
"Jane": "Jeanne",
|
||||
"Jack": "Jacques"
|
||||
},
|
||||
"Settings": {
|
||||
"title": "Paramètres locaux",
|
||||
@@ -117,6 +120,12 @@
|
||||
"create": "Créer",
|
||||
"creating": "Création…",
|
||||
"cancel": "Annuler"
|
||||
},
|
||||
"CurrencyCodeField": {
|
||||
"label": "Devise principale",
|
||||
"createDescription": "Tous les montants et soldes seront dans cette devise.",
|
||||
"editDescription": "Tous les montants et soldes seront exprimés dans cette devise. La modification de cette option n'entraînera PAS la conversion des dépenses déjà saisies, sauf si la devise a des « unités mineures » différentes de celles de la devise actuelle (par exemple, passage du dollar américain au yen japonais).",
|
||||
"customOption": "Personalisée"
|
||||
}
|
||||
},
|
||||
"ExpenseForm": {
|
||||
@@ -150,7 +159,11 @@
|
||||
"description": "Sélectionnez pour qui le revenu a été reçu."
|
||||
},
|
||||
"splitModeDescription": "Sélectionnez comment diviser le revenu.",
|
||||
"attachDescription": "Voir et joindre des reçus au revenu."
|
||||
"attachDescription": "Voir et joindre des reçus au revenu.",
|
||||
"currencyField": {
|
||||
"label": "Devise de la recette",
|
||||
"description": "La devise dans laquelle le bénéfice a été perçu."
|
||||
}
|
||||
},
|
||||
"Expense": {
|
||||
"create": "Créer une dépense",
|
||||
@@ -167,7 +180,8 @@
|
||||
"categoryFieldDescription": "Sélectionnez la catégorie de dépense.",
|
||||
"paidByField": {
|
||||
"label": "Payé par",
|
||||
"description": "Sélectionnez le participant qui a réglé la dépense."
|
||||
"description": "Sélectionnez le participant qui a réglé la dépense.",
|
||||
"placeholder": "Sélectionner un participant"
|
||||
},
|
||||
"recurrenceRule": {
|
||||
"label": "Récurrence de la dépense",
|
||||
@@ -182,7 +196,11 @@
|
||||
"description": "Sélectionnez les participants concernés"
|
||||
},
|
||||
"splitModeDescription": "Sélectionnez comment diviser la dépense.",
|
||||
"attachDescription": "Voir et joindre des reçus à la dépense."
|
||||
"attachDescription": "Voir et joindre des reçus à la dépense.",
|
||||
"currencyField": {
|
||||
"label": "Devise de la dépense",
|
||||
"description": "La devise dans laquelle la dépense a été payée."
|
||||
}
|
||||
},
|
||||
"amountField": {
|
||||
"label": "Montant"
|
||||
@@ -221,7 +239,21 @@
|
||||
"save": "Sauvegarder",
|
||||
"saving": "Sauvegarde…",
|
||||
"cancel": "Annuler",
|
||||
"reimbursement": "Remboursement"
|
||||
"reimbursement": "Remboursement",
|
||||
"conversionUnavailable": "Pour définir une devise différente pour chaque dépense et convertir les montants, sélectionnez une devise non personnalisée pour le groupe.",
|
||||
"originalAmountField": {
|
||||
"label": "Montant à convertir"
|
||||
},
|
||||
"conversionRateField": {
|
||||
"useCustom": "Utiliser un taux personnalisé",
|
||||
"label": "Taux de change"
|
||||
},
|
||||
"conversionRateState": {
|
||||
"loading": "Obtention des taux de change…",
|
||||
"success": "Taux obtenus:",
|
||||
"error": "Oups, nous n'avons pas pu obtenir les taux de change les plus récents.",
|
||||
"refresh": "Actualiser"
|
||||
}
|
||||
},
|
||||
"ExpenseDocumentsInput": {
|
||||
"TooBigToast": {
|
||||
@@ -348,7 +380,7 @@
|
||||
"Games": "Jeux",
|
||||
"Movies": "Films",
|
||||
"Music": "Musique",
|
||||
"Sports": "Sports"
|
||||
"Sports": "Sport"
|
||||
},
|
||||
"Food and Drink": {
|
||||
"heading": "Nourriture et boissons",
|
||||
@@ -377,7 +409,8 @@
|
||||
"Gifts": "Cadeaux",
|
||||
"Insurance": "Assurance",
|
||||
"Medical Expenses": "Dépenses médicales",
|
||||
"Taxes": "Impôts"
|
||||
"Taxes": "Impôts",
|
||||
"Donation": "Don"
|
||||
},
|
||||
"Transportation": {
|
||||
"heading": "Transport",
|
||||
@@ -401,5 +434,18 @@
|
||||
"TV/Phone/Internet": "TV/Téléphone/Internet",
|
||||
"Water": "Eau"
|
||||
}
|
||||
},
|
||||
"Currencies": {
|
||||
"search": "Chercher une devise...",
|
||||
"noCurrency": "Aucune devise trouvée.",
|
||||
"custom": {
|
||||
"heading": "Personnalisée"
|
||||
},
|
||||
"common": {
|
||||
"heading": "Les plus courantes"
|
||||
},
|
||||
"other": {
|
||||
"heading": "Autres devises"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +45,8 @@
|
||||
"paidBy": "Betaald door <strong>{paidBy}</strong> voor <paidFor></paidFor>",
|
||||
"everyone": "iedereen",
|
||||
"receivedBy": "Ontvangen door <strong>{paidBy}</strong> voor <paidFor></paidFor>",
|
||||
"yourBalance": "Jouw balans:"
|
||||
"yourBalance": "Jouw balans:",
|
||||
"notInvolved": "Je bent hier niet bij betrokken"
|
||||
},
|
||||
"Groups": {
|
||||
"myGroups": "Mijn groepen",
|
||||
@@ -119,6 +120,12 @@
|
||||
"create": "Groep maken",
|
||||
"creating": "Aan het maken…",
|
||||
"cancel": "Annuleren"
|
||||
},
|
||||
"CurrencyCodeField": {
|
||||
"label": "Hoofdvaluta",
|
||||
"createDescription": "Alle hoeveelheden en saldi worden in deze valuta weergegeven.",
|
||||
"editDescription": "Alle bedragen en saldi worden in deze valuta weergegeven. Als je dit wijzigt, worden reeds ingevoerde uitgaven NIET omgerekend, behalve wanneer de valuta andere \"kleinste eenheden\" heeft dan de huidige (bijvoorbeeld bij een wijziging van Amerikaanse dollar naar Japanse yen)",
|
||||
"customOption": "Aangepast"
|
||||
}
|
||||
},
|
||||
"ExpenseForm": {
|
||||
@@ -152,7 +159,11 @@
|
||||
"description": "Selecteer voor wie het inkomen is ontvangen."
|
||||
},
|
||||
"splitModeDescription": "Selecteer hoe het inkomen verdeeld moet worden.",
|
||||
"attachDescription": "Bekijk en voeg bijlagen toe aan het inkomen."
|
||||
"attachDescription": "Bekijk en voeg bijlagen toe aan het inkomen.",
|
||||
"currencyField": {
|
||||
"label": "Munteenheid van inkomen",
|
||||
"description": "De munteenheid waar het inkomen in is ontvangen."
|
||||
}
|
||||
},
|
||||
"Expense": {
|
||||
"create": "Maak uitgave",
|
||||
@@ -169,7 +180,8 @@
|
||||
"categoryFieldDescription": "Selecteer de uitgavecategorie.",
|
||||
"paidByField": {
|
||||
"label": "Betaald door",
|
||||
"description": "Selecteer de deelnemer die de uitgave heeft gedaan."
|
||||
"description": "Selecteer de deelnemer die de uitgave heeft gedaan.",
|
||||
"placeholder": "Selecteer een deelnemer"
|
||||
},
|
||||
"recurrenceRule": {
|
||||
"label": "Terugkerende uitgave",
|
||||
@@ -184,7 +196,11 @@
|
||||
"description": "Selecteer voor wie de uitgave is gedaan."
|
||||
},
|
||||
"splitModeDescription": "Selecteer hoe de uitgave verdeeld moet worden.",
|
||||
"attachDescription": "Bekijk en voeg bijlagen toe aan de uitgave."
|
||||
"attachDescription": "Bekijk en voeg bijlagen toe aan de uitgave.",
|
||||
"currencyField": {
|
||||
"label": "Munteenheid van uitgave",
|
||||
"description": "De munteenheid waar de uitgave in is betaald."
|
||||
}
|
||||
},
|
||||
"amountField": {
|
||||
"label": "Bedrag"
|
||||
@@ -201,7 +217,7 @@
|
||||
"selectNone": "Selecteer niemand",
|
||||
"selectAll": "Selecteer iedereen",
|
||||
"shares": "deel/delen",
|
||||
"advancedOptions": "Geavanceerde split-opties",
|
||||
"advancedOptions": "Andere split-opties…",
|
||||
"SplitModeField": {
|
||||
"label": "Split soort",
|
||||
"evenly": "Gelijk verdeeld",
|
||||
@@ -213,7 +229,7 @@
|
||||
"DeletePopup": {
|
||||
"label": "Verwijderen",
|
||||
"title": "Deze uitgave verwijderen?",
|
||||
"description": "Wil je deze uitgave echt verwijderen?",
|
||||
"description": "Wil je deze uitgave echt verwijderen? Dit kan niet ongedaan worden.",
|
||||
"yes": "Ja",
|
||||
"cancel": "Annuleer"
|
||||
},
|
||||
@@ -223,7 +239,28 @@
|
||||
"save": "Opslaan",
|
||||
"saving": "Aan het opslaan…",
|
||||
"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": {
|
||||
"TooBigToast": {
|
||||
@@ -334,7 +371,8 @@
|
||||
"paidForMin1": "De uitgave moet voor ten minste één deelnemer zijn gedaan.",
|
||||
"noZeroShares": "Een deel mag niet 0 zijn.",
|
||||
"amountSum": "Het totaalbedrag moet gelijk zijn aan het uitgavebedrag.",
|
||||
"percentageSum": "Het totaalpercentage moet gelijk zijn aan 100%."
|
||||
"percentageSum": "Het totaalpercentage moet gelijk zijn aan 100%.",
|
||||
"ratePositive": "De koers moet groter dan nul zijn."
|
||||
},
|
||||
"Categories": {
|
||||
"search": "Categorie zoeken…",
|
||||
@@ -404,5 +442,18 @@
|
||||
"TV/Phone/Internet": "Internet/TV/Telefoon",
|
||||
"Water": "Water"
|
||||
}
|
||||
},
|
||||
"Currencies": {
|
||||
"noCurrency": "Geen valuta gevonden.",
|
||||
"custom": {
|
||||
"heading": "Aangepast"
|
||||
},
|
||||
"common": {
|
||||
"heading": "Meest voorkomend"
|
||||
},
|
||||
"other": {
|
||||
"heading": "Andere valuta"
|
||||
},
|
||||
"search": "Valuta zoeken..."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"Homepage": {
|
||||
"title": "Compartilhe <strong>Despesas</strong> com <strong>Amigos e Família</strong>",
|
||||
"title": "Compartilhe <strong>Despesas</strong> com <strong>Amigos e Família</strong>",
|
||||
"description": "Bem-vindo à sua nova instalação do <strong>Spliit</strong>!",
|
||||
"button": {
|
||||
"groups": "Ir para grupos",
|
||||
@@ -38,12 +38,15 @@
|
||||
"earlierThisYear": "Anteriores neste ano",
|
||||
"lastYear": "Ano passado",
|
||||
"older": "Mais antigas"
|
||||
}
|
||||
},
|
||||
"export": "Exportar"
|
||||
},
|
||||
"ExpenseCard": {
|
||||
"paidBy": "Pago por <strong>{paidBy}</strong> para <paidFor></paidFor>",
|
||||
"receivedBy": "Recebido por <strong>{paidBy}</strong> para <paidFor></paidFor>",
|
||||
"yourBalance": "Seu saldo:"
|
||||
"yourBalance": "Seu saldo:",
|
||||
"everyone": "Todos",
|
||||
"notInvolved": "Você não está envolvido"
|
||||
},
|
||||
"Groups": {
|
||||
"myGroups": "Meus grupos",
|
||||
@@ -117,6 +120,12 @@
|
||||
"create": "Criar",
|
||||
"creating": "Criando…",
|
||||
"cancel": "Cancelar"
|
||||
},
|
||||
"CurrencyCodeField": {
|
||||
"label": "Moeda principal",
|
||||
"createDescription": "Todos os valores e saldos estarão nesta moeda.",
|
||||
"editDescription": "Todos os valores e saldos estarão nesta moeda. A sua alteração NÃO irá converter despesas já registradas, exceto quando a moeda possuir \"unidades menores\" que a atual (ex. Alterar de Dólar Americano para Yen Japonês)",
|
||||
"customOption": "Customizado"
|
||||
}
|
||||
},
|
||||
"ExpenseForm": {
|
||||
@@ -159,14 +168,23 @@
|
||||
"categoryFieldDescription": "Selecione a categoria da despesa.",
|
||||
"paidByField": {
|
||||
"label": "Pago por",
|
||||
"description": "Selecione o participante que pagou a despesa."
|
||||
"description": "Selecione o participante que pagou a despesa.",
|
||||
"placeholder": "Selecione um participante"
|
||||
},
|
||||
"paidFor": {
|
||||
"title": "Pago para",
|
||||
"description": "Selecione para quem a despesa foi paga."
|
||||
},
|
||||
"splitModeDescription": "Selecione como dividir a despesa.",
|
||||
"attachDescription": "Veja e anexe recibos à despesa."
|
||||
"attachDescription": "Veja e anexe recibos à despesa.",
|
||||
"recurrenceRule": {
|
||||
"label": "Recorrência da Despesa",
|
||||
"description": "Selecione a frequência de recorrência da despesa.",
|
||||
"none": "Nenhuma",
|
||||
"daily": "Diariamente",
|
||||
"weekly": "Semanalmente",
|
||||
"monthly": "Mensalmente"
|
||||
}
|
||||
},
|
||||
"amountField": {
|
||||
"label": "Valor"
|
||||
@@ -361,7 +379,8 @@
|
||||
"Gifts": "Presentes",
|
||||
"Insurance": "Seguro",
|
||||
"Medical Expenses": "Despesas médicas",
|
||||
"Taxes": "Impostos"
|
||||
"Taxes": "Impostos",
|
||||
"Donation": "Doação"
|
||||
},
|
||||
"Transportation": {
|
||||
"heading": "Transporte",
|
||||
@@ -385,5 +404,18 @@
|
||||
"TV/Phone/Internet": "TV/Telefone/Internet",
|
||||
"Water": "Água"
|
||||
}
|
||||
},
|
||||
"Currencies": {
|
||||
"search": "Pesquisar moeda...",
|
||||
"noCurrency": "Nenhuma moeda encontrada.",
|
||||
"custom": {
|
||||
"heading": "Customizado"
|
||||
},
|
||||
"common": {
|
||||
"heading": "Mais comum"
|
||||
},
|
||||
"other": {
|
||||
"heading": "Outras moedas"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
24
package-lock.json
generated
24
package-lock.json
generated
@@ -56,6 +56,7 @@
|
||||
"server-only": "^0.0.1",
|
||||
"sharp": "^0.33.2",
|
||||
"superjson": "^2.2.1",
|
||||
"swr": "^2.3.3",
|
||||
"tailwind-merge": "^1.14.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"ts-pattern": "^5.0.6",
|
||||
@@ -11040,7 +11041,6 @@
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
||||
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
@@ -16850,6 +16850,19 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/swr": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/swr/-/swr-2.3.3.tgz",
|
||||
"integrity": "sha512-dshNvs3ExOqtZ6kJBaAsabhPdHyeY4P2cKwRCniDVifBMoG/SVI7tfLWqPXriVspf2Rg4tPzXJTnwaihIeFw2A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dequal": "^2.0.3",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/symbol-tree": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
|
||||
@@ -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": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
|
||||
@@ -64,6 +64,7 @@
|
||||
"server-only": "^0.0.1",
|
||||
"sharp": "^0.33.2",
|
||||
"superjson": "^2.2.1",
|
||||
"swr": "^2.3.3",
|
||||
"tailwind-merge": "^1.14.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"ts-pattern": "^5.0.6",
|
||||
|
||||
@@ -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
|
||||
information String? @db.Text
|
||||
currency String @default("$")
|
||||
currencyCode String?
|
||||
currencyCode String?
|
||||
participants Participant[]
|
||||
expenses Expense[]
|
||||
activities Activity[]
|
||||
@@ -40,25 +40,28 @@ model Category {
|
||||
}
|
||||
|
||||
model Expense {
|
||||
id String @id
|
||||
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
||||
expenseDate DateTime @default(dbgenerated("CURRENT_DATE")) @db.Date
|
||||
title String
|
||||
category Category? @relation(fields: [categoryId], references: [id])
|
||||
categoryId Int @default(0)
|
||||
amount Int
|
||||
paidBy Participant @relation(fields: [paidById], references: [id], onDelete: Cascade)
|
||||
paidById String
|
||||
paidFor ExpensePaidFor[]
|
||||
groupId String
|
||||
isReimbursement Boolean @default(false)
|
||||
splitMode SplitMode @default(EVENLY)
|
||||
createdAt DateTime @default(now())
|
||||
documents ExpenseDocument[]
|
||||
notes String?
|
||||
id String @id
|
||||
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
||||
expenseDate DateTime @default(dbgenerated("CURRENT_DATE")) @db.Date
|
||||
title String
|
||||
category Category? @relation(fields: [categoryId], references: [id])
|
||||
categoryId Int @default(0)
|
||||
amount Int
|
||||
originalAmount Int?
|
||||
originalCurrency String?
|
||||
conversionRate Decimal?
|
||||
paidBy Participant @relation(fields: [paidById], references: [id], onDelete: Cascade)
|
||||
paidById String
|
||||
paidFor ExpensePaidFor[]
|
||||
groupId String
|
||||
isReimbursement Boolean @default(false)
|
||||
splitMode SplitMode @default(EVENLY)
|
||||
createdAt DateTime @default(now())
|
||||
documents ExpenseDocument[]
|
||||
notes String?
|
||||
|
||||
recurrenceRule RecurrenceRule? @default(NONE)
|
||||
recurringExpenseLink RecurringExpenseLink?
|
||||
recurrenceRule RecurrenceRule? @default(NONE)
|
||||
recurringExpenseLink RecurringExpenseLink?
|
||||
recurringExpenseLinkId String?
|
||||
}
|
||||
|
||||
@@ -79,16 +82,16 @@ enum SplitMode {
|
||||
}
|
||||
|
||||
model RecurringExpenseLink {
|
||||
id String @id
|
||||
groupId String
|
||||
currentFrameExpense Expense @relation(fields: [currentFrameExpenseId], references: [id], onDelete: Cascade)
|
||||
currentFrameExpenseId String @unique
|
||||
id String @id
|
||||
groupId String
|
||||
currentFrameExpense Expense @relation(fields: [currentFrameExpenseId], references: [id], onDelete: Cascade)
|
||||
currentFrameExpenseId String @unique
|
||||
|
||||
// Note: We do not want to link to the next expense because once it is created, it should be
|
||||
// treated as it's own independent entity. This means that if a user wants to delete an Expense
|
||||
// and any prior related recurring expenses, they'll need to delete them one by one.
|
||||
nextExpenseCreatedAt DateTime?
|
||||
nextExpenseDate DateTime
|
||||
nextExpenseDate DateTime
|
||||
|
||||
@@index([groupId])
|
||||
@@index([groupId, nextExpenseCreatedAt, nextExpenseDate(sort: Desc)])
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { CategorySelector } from '@/components/category-selector'
|
||||
import { CurrencySelector } from '@/components/currency-selector'
|
||||
import { ExpenseDocumentsInput } from '@/components/expense-documents-input'
|
||||
import { SubmitButton } from '@/components/submit-button'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -32,9 +33,11 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Locale } from '@/i18n'
|
||||
import { randomId } from '@/lib/api'
|
||||
import { defaultCurrencyList, getCurrency } from '@/lib/currency'
|
||||
import { RuntimeFeatureFlags } from '@/lib/featureFlags'
|
||||
import { useActiveUser } from '@/lib/hooks'
|
||||
import { useActiveUser, useCurrencyRate } from '@/lib/hooks'
|
||||
import {
|
||||
ExpenseFormValues,
|
||||
SplittingOptions,
|
||||
@@ -51,7 +54,7 @@ import {
|
||||
import { AppRouterOutput } from '@/trpc/routers/_app'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { RecurrenceRule } from '@prisma/client'
|
||||
import { Save } from 'lucide-react'
|
||||
import { ChevronRight, Save } from 'lucide-react'
|
||||
import { useLocale, useTranslations } from 'next-intl'
|
||||
import Link from 'next/link'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
@@ -78,7 +81,7 @@ const getDefaultSplittingOptions = (
|
||||
splitMode: 'EVENLY' as const,
|
||||
paidFor: group.participants.map(({ id }) => ({
|
||||
participant: id,
|
||||
shares: '1' as unknown as number,
|
||||
shares: 1,
|
||||
})),
|
||||
}
|
||||
|
||||
@@ -110,7 +113,7 @@ const getDefaultSplittingOptions = (
|
||||
splitMode: parsedDefaultSplitMode.splitMode,
|
||||
paidFor: parsedDefaultSplitMode.paidFor.map((paidFor) => ({
|
||||
participant: paidFor.participant,
|
||||
shares: String(paidFor.shares / 100) as unknown as number,
|
||||
shares: paidFor.shares / 100,
|
||||
})),
|
||||
}
|
||||
}
|
||||
@@ -124,7 +127,7 @@ async function persistDefaultSplittingOptions(
|
||||
if (expenseFormValues.splitMode === 'EVENLY') {
|
||||
return expenseFormValues.paidFor.map(({ participant }) => ({
|
||||
participant,
|
||||
shares: '100' as unknown as number,
|
||||
shares: 100,
|
||||
}))
|
||||
} else if (expenseFormValues.splitMode === 'BY_AMOUNT') {
|
||||
return null
|
||||
@@ -161,7 +164,7 @@ export function ExpenseForm({
|
||||
runtimeFeatureFlags: RuntimeFeatureFlags
|
||||
}) {
|
||||
const t = useTranslations('ExpenseForm')
|
||||
const locale = useLocale()
|
||||
const locale = useLocale() as Locale
|
||||
const isCreate = expense === undefined
|
||||
const searchParams = useSearchParams()
|
||||
|
||||
@@ -186,18 +189,18 @@ export function ExpenseForm({
|
||||
? {
|
||||
title: expense.title,
|
||||
expenseDate: expense.expenseDate ?? new Date(),
|
||||
amount: String(
|
||||
amountAsDecimal(expense.amount, groupCurrency),
|
||||
) as unknown as number, // hack
|
||||
amount: amountAsDecimal(expense.amount, groupCurrency),
|
||||
originalCurrency: expense.originalCurrency ?? group.currencyCode,
|
||||
originalAmount: expense.originalAmount ?? undefined,
|
||||
conversionRate: expense.conversionRate?.toNumber(),
|
||||
category: expense.categoryId,
|
||||
paidBy: expense.paidById,
|
||||
paidFor: expense.paidFor.map(({ participantId, shares }) => ({
|
||||
participant: participantId,
|
||||
shares: String(
|
||||
shares:
|
||||
expense.splitMode === 'BY_AMOUNT'
|
||||
? amountAsDecimal(shares, groupCurrency)
|
||||
: shares / 100,
|
||||
) as unknown as number,
|
||||
})),
|
||||
splitMode: expense.splitMode,
|
||||
saveDefaultSplittingOptions: false,
|
||||
@@ -210,19 +213,20 @@ export function ExpenseForm({
|
||||
? {
|
||||
title: t('reimbursement'),
|
||||
expenseDate: new Date(),
|
||||
amount: String(
|
||||
amountAsDecimal(
|
||||
Number(searchParams.get('amount')) || 0,
|
||||
groupCurrency,
|
||||
),
|
||||
) as unknown as number, // hack
|
||||
amount: amountAsDecimal(
|
||||
Number(searchParams.get('amount')) || 0,
|
||||
groupCurrency,
|
||||
),
|
||||
originalCurrency: group.currencyCode,
|
||||
originalAmount: undefined,
|
||||
conversionRate: undefined,
|
||||
category: 1, // category with Id 1 is Payment
|
||||
paidBy: searchParams.get('from') ?? undefined,
|
||||
paidFor: [
|
||||
searchParams.get('to')
|
||||
? {
|
||||
participant: searchParams.get('to')!,
|
||||
shares: '1' as unknown as number,
|
||||
shares: 1,
|
||||
}
|
||||
: undefined,
|
||||
],
|
||||
@@ -238,7 +242,10 @@ export function ExpenseForm({
|
||||
expenseDate: searchParams.get('date')
|
||||
? new Date(searchParams.get('date') as string)
|
||||
: new Date(),
|
||||
amount: (searchParams.get('amount') || 0) as unknown as number, // hack,
|
||||
amount: Number(searchParams.get('amount')) || 0,
|
||||
originalCurrency: group.currencyCode ?? undefined,
|
||||
originalAmount: undefined,
|
||||
conversionRate: undefined,
|
||||
category: searchParams.get('categoryId')
|
||||
? Number(searchParams.get('categoryId'))
|
||||
: 0, // category with Id 0 is General
|
||||
@@ -277,6 +284,12 @@ export function ExpenseForm({
|
||||
? 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)
|
||||
}
|
||||
|
||||
@@ -287,6 +300,23 @@ export function ExpenseForm({
|
||||
|
||||
const sExpense = isIncome ? 'Income' : 'Expense'
|
||||
|
||||
const originalCurrency = getCurrency(
|
||||
form.getValues('originalCurrency'),
|
||||
locale,
|
||||
'Custom',
|
||||
)
|
||||
const exchangeRate = useCurrencyRate(
|
||||
form.watch('expenseDate'),
|
||||
form.watch('originalCurrency') ?? '',
|
||||
groupCurrency.code,
|
||||
)
|
||||
|
||||
const conversionRequired =
|
||||
group.currencyCode &&
|
||||
group.currencyCode.length &&
|
||||
originalCurrency.code.length &&
|
||||
originalCurrency.code !== group.currencyCode
|
||||
|
||||
useEffect(() => {
|
||||
setManuallyEditedParticipants(new Set())
|
||||
}, [form.watch('splitMode'), form.watch('amount')])
|
||||
@@ -329,11 +359,9 @@ export function ExpenseForm({
|
||||
if (!editedParticipants.includes(participant.participant)) {
|
||||
return {
|
||||
...participant,
|
||||
shares: String(
|
||||
Number(
|
||||
amountPerRemaining.toFixed(groupCurrency.decimal_digits),
|
||||
),
|
||||
) as unknown as number,
|
||||
shares: Number(
|
||||
amountPerRemaining.toFixed(groupCurrency.decimal_digits),
|
||||
),
|
||||
}
|
||||
}
|
||||
return participant
|
||||
@@ -347,6 +375,71 @@ export function ExpenseForm({
|
||||
form.watch('splitMode'),
|
||||
])
|
||||
|
||||
const [usingCustomConversionRate, setUsingCustomConversionRate] = useState(
|
||||
!!form.formState.defaultValues?.conversionRate,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!usingCustomConversionRate && exchangeRate.data) {
|
||||
form.setValue('conversionRate', exchangeRate.data)
|
||||
}
|
||||
}, [exchangeRate.data, usingCustomConversionRate])
|
||||
|
||||
useEffect(() => {
|
||||
if (!form.getFieldState('originalAmount').isTouched) return
|
||||
const originalAmount = form.getValues('originalAmount') ?? 0
|
||||
const conversionRate = form.getValues('conversionRate')
|
||||
|
||||
if (conversionRate && originalAmount) {
|
||||
const rate = Number(conversionRate)
|
||||
const convertedAmount = originalAmount * rate
|
||||
if (!Number.isNaN(convertedAmount)) {
|
||||
const v = enforceCurrencyPattern(
|
||||
convertedAmount.toFixed(groupCurrency.decimal_digits),
|
||||
)
|
||||
const income = Number(v) < 0
|
||||
setIsIncome(income)
|
||||
if (income) form.setValue('isReimbursement', false)
|
||||
form.setValue('amount', Number(v))
|
||||
}
|
||||
}
|
||||
}, [
|
||||
form.watch('originalAmount'),
|
||||
form.watch('conversionRate'),
|
||||
form.getFieldState('originalAmount').isTouched,
|
||||
])
|
||||
|
||||
let conversionRateMessage = ''
|
||||
if (exchangeRate.isLoading) {
|
||||
conversionRateMessage = t('conversionRateState.loading')
|
||||
} else {
|
||||
let ratesDisplay = ''
|
||||
if (exchangeRate.data) {
|
||||
// non breaking spaces so the rate text is not split with line feeds
|
||||
ratesDisplay = `${form.getValues('originalCurrency')}\xa01\xa0=\xa0${
|
||||
group.currencyCode
|
||||
}\xa0${exchangeRate.data}`
|
||||
}
|
||||
if (exchangeRate.error) {
|
||||
if (exchangeRate.error instanceof RangeError && exchangeRate.data)
|
||||
conversionRateMessage = t('conversionRateState.dateMismatch', {
|
||||
date: exchangeRate.error.message,
|
||||
})
|
||||
else {
|
||||
conversionRateMessage = t('conversionRateState.error')
|
||||
}
|
||||
conversionRateMessage +=
|
||||
' ' +
|
||||
(ratesDisplay.length
|
||||
? `${t('conversionRateState.staleRate')} ${ratesDisplay}`
|
||||
: t('conversionRateState.noRate'))
|
||||
} else {
|
||||
conversionRateMessage = ratesDisplay.length
|
||||
? `${t('conversionRateState.success')} ${ratesDisplay}`
|
||||
: t('conversionRateState.currencyNotFound')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(submit)}>
|
||||
@@ -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
|
||||
control={form.control}
|
||||
name="amount"
|
||||
render={({ field: { onChange, ...field } }) => (
|
||||
<FormItem className="sm:order-3">
|
||||
<FormItem className="sm:order-5">
|
||||
<FormLabel>{t('amountField.label')}</FormLabel>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span>{group.currency}</span>
|
||||
@@ -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
|
||||
control={form.control}
|
||||
name="paidBy"
|
||||
@@ -592,7 +827,7 @@ export function ExpenseForm({
|
||||
participant: p.id,
|
||||
shares:
|
||||
paidFor.find((pfor) => pfor.participant === p.id)
|
||||
?.shares ?? ('1' as unknown as number),
|
||||
?.shares ?? 1,
|
||||
}))
|
||||
form.setValue('paidFor', newPaidFor, {
|
||||
shouldDirty: true,
|
||||
@@ -630,7 +865,7 @@ export function ExpenseForm({
|
||||
data-id={`${id}/${form.getValues().splitMode}/${
|
||||
group.currency
|
||||
}`}
|
||||
className="flex items-center border-t last-of-type:border-b last-of-type:!mb-4 -mx-6 px-6 py-3"
|
||||
className="flex flex-wrap gap-y-4 items-center border-t last-of-type:border-b last-of-type:!mb-4 -mx-6 px-6 py-3"
|
||||
>
|
||||
<FormItem className="flex-1 flex flex-row items-start space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
@@ -651,7 +886,7 @@ export function ExpenseForm({
|
||||
...field.value,
|
||||
{
|
||||
participant: id,
|
||||
shares: '1' as unknown as number,
|
||||
shares: 1,
|
||||
},
|
||||
],
|
||||
options,
|
||||
@@ -714,106 +949,209 @@ export function ExpenseForm({
|
||||
)}
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
{form.getValues().splitMode !== 'EVENLY' && (
|
||||
<FormField
|
||||
name={`paidFor[${field.value.findIndex(
|
||||
({ participant }) => participant === id,
|
||||
)}].shares`}
|
||||
render={() => {
|
||||
const sharesLabel = (
|
||||
<span
|
||||
className={cn('text-sm', {
|
||||
'text-muted': !field.value?.some(
|
||||
({ participant }) =>
|
||||
participant === id,
|
||||
),
|
||||
})}
|
||||
>
|
||||
{match(form.getValues().splitMode)
|
||||
.with('BY_SHARES', () => (
|
||||
<>{t('shares')}</>
|
||||
))
|
||||
.with('BY_PERCENTAGE', () => <>%</>)
|
||||
.with('BY_AMOUNT', () => (
|
||||
<>{group.currency}</>
|
||||
))
|
||||
.otherwise(() => (
|
||||
<></>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
return (
|
||||
<div>
|
||||
<div className="flex gap-1 items-center">
|
||||
{form.getValues().splitMode ===
|
||||
'BY_AMOUNT' && sharesLabel}
|
||||
<FormControl>
|
||||
<Input
|
||||
key={String(
|
||||
!field.value?.some(
|
||||
({ participant }) =>
|
||||
participant === id,
|
||||
),
|
||||
)}
|
||||
className="text-base w-[80px] -my-2"
|
||||
type="text"
|
||||
disabled={
|
||||
!field.value?.some(
|
||||
({ participant }) =>
|
||||
participant === id,
|
||||
)
|
||||
}
|
||||
value={
|
||||
field.value?.find(
|
||||
({ participant }) =>
|
||||
participant === id,
|
||||
)?.shares
|
||||
}
|
||||
onChange={(event) => {
|
||||
field.onChange(
|
||||
field.value.map((p) =>
|
||||
p.participant === id
|
||||
? {
|
||||
participant: id,
|
||||
shares:
|
||||
enforceCurrencyPattern(
|
||||
event.target.value,
|
||||
),
|
||||
}
|
||||
: p,
|
||||
<div className="flex">
|
||||
{form.getValues().splitMode === 'BY_AMOUNT' &&
|
||||
!!conversionRequired && (
|
||||
<FormField
|
||||
name={`paidFor[${field.value.findIndex(
|
||||
({ participant }) => participant === id,
|
||||
)}].originalAmount`}
|
||||
render={() => {
|
||||
const sharesLabel = (
|
||||
<span
|
||||
className={cn('text-sm', {
|
||||
'text-muted': !field.value?.some(
|
||||
({ participant }) =>
|
||||
participant === id,
|
||||
),
|
||||
})}
|
||||
>
|
||||
{originalCurrency.symbol}
|
||||
</span>
|
||||
)
|
||||
return (
|
||||
<div>
|
||||
<div className="flex gap-1 items-center">
|
||||
{sharesLabel}
|
||||
<FormControl>
|
||||
<Input
|
||||
key={String(
|
||||
!field.value?.some(
|
||||
({ participant }) =>
|
||||
participant === id,
|
||||
),
|
||||
)}
|
||||
className="text-base w-[80px] -my-2"
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
disabled={
|
||||
!field.value?.some(
|
||||
({ participant }) =>
|
||||
participant === id,
|
||||
)
|
||||
}
|
||||
value={
|
||||
field.value.find(
|
||||
({ participant }) =>
|
||||
participant === id,
|
||||
)?.originalAmount ?? ''
|
||||
}
|
||||
onChange={(event) => {
|
||||
const originalAmount = Number(
|
||||
event.target.value,
|
||||
)
|
||||
let convertedAmount = ''
|
||||
if (
|
||||
!Number.isNaN(
|
||||
originalAmount,
|
||||
) &&
|
||||
exchangeRate.data
|
||||
) {
|
||||
convertedAmount = (
|
||||
originalAmount *
|
||||
exchangeRate.data
|
||||
).toFixed(
|
||||
groupCurrency.decimal_digits,
|
||||
)
|
||||
}
|
||||
field.onChange(
|
||||
field.value.map((p) =>
|
||||
p.participant === id
|
||||
? {
|
||||
participant: id,
|
||||
originalAmount:
|
||||
event.target
|
||||
.value,
|
||||
shares:
|
||||
enforceCurrencyPattern(
|
||||
convertedAmount,
|
||||
),
|
||||
}
|
||||
: p,
|
||||
),
|
||||
)
|
||||
setManuallyEditedParticipants(
|
||||
(prev) =>
|
||||
new Set(prev).add(id),
|
||||
)
|
||||
}}
|
||||
step={
|
||||
10 **
|
||||
-originalCurrency.decimal_digits
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<ChevronRight className="h-4 w-4 mx-1 opacity-50" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{form.getValues().splitMode !== 'EVENLY' && (
|
||||
<FormField
|
||||
name={`paidFor[${field.value.findIndex(
|
||||
({ participant }) => participant === id,
|
||||
)}].shares`}
|
||||
render={() => {
|
||||
const sharesLabel = (
|
||||
<span
|
||||
className={cn('text-sm', {
|
||||
'text-muted': !field.value?.some(
|
||||
({ participant }) =>
|
||||
participant === id,
|
||||
),
|
||||
})}
|
||||
>
|
||||
{match(form.getValues().splitMode)
|
||||
.with('BY_SHARES', () => (
|
||||
<>{t('shares')}</>
|
||||
))
|
||||
.with('BY_PERCENTAGE', () => <>%</>)
|
||||
.with('BY_AMOUNT', () => (
|
||||
<>{group.currency}</>
|
||||
))
|
||||
.otherwise(() => (
|
||||
<></>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
return (
|
||||
<div>
|
||||
<div className="flex gap-1 items-center">
|
||||
{form.getValues().splitMode ===
|
||||
'BY_AMOUNT' && sharesLabel}
|
||||
<FormControl>
|
||||
<Input
|
||||
key={String(
|
||||
!field.value?.some(
|
||||
({ participant }) =>
|
||||
participant === id,
|
||||
),
|
||||
)
|
||||
setManuallyEditedParticipants(
|
||||
(prev) => new Set(prev).add(id),
|
||||
)
|
||||
}}
|
||||
inputMode={
|
||||
form.getValues().splitMode ===
|
||||
'BY_AMOUNT'
|
||||
? 'decimal'
|
||||
: 'numeric'
|
||||
}
|
||||
step={
|
||||
form.getValues().splitMode ===
|
||||
'BY_AMOUNT'
|
||||
? 0.01
|
||||
: 1
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
{[
|
||||
'BY_SHARES',
|
||||
'BY_PERCENTAGE',
|
||||
].includes(
|
||||
form.getValues().splitMode,
|
||||
) && sharesLabel}
|
||||
)}
|
||||
className="text-base w-[80px] -my-2"
|
||||
type="text"
|
||||
disabled={
|
||||
!field.value?.some(
|
||||
({ participant }) =>
|
||||
participant === id,
|
||||
)
|
||||
}
|
||||
value={
|
||||
field.value?.find(
|
||||
({ participant }) =>
|
||||
participant === id,
|
||||
)?.shares
|
||||
}
|
||||
onChange={(event) => {
|
||||
field.onChange(
|
||||
field.value.map((p) =>
|
||||
p.participant === id
|
||||
? {
|
||||
participant: id,
|
||||
shares:
|
||||
enforceCurrencyPattern(
|
||||
event.target
|
||||
.value,
|
||||
),
|
||||
}
|
||||
: p,
|
||||
),
|
||||
)
|
||||
setManuallyEditedParticipants(
|
||||
(prev) =>
|
||||
new Set(prev).add(id),
|
||||
)
|
||||
}}
|
||||
inputMode={
|
||||
form.getValues().splitMode ===
|
||||
'BY_AMOUNT'
|
||||
? 'decimal'
|
||||
: 'numeric'
|
||||
}
|
||||
step={
|
||||
form.getValues().splitMode ===
|
||||
'BY_AMOUNT'
|
||||
? 10 **
|
||||
-groupCurrency.decimal_digits
|
||||
: 1
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
{[
|
||||
'BY_SHARES',
|
||||
'BY_PERCENTAGE',
|
||||
].includes(
|
||||
form.getValues().splitMode,
|
||||
) && sharesLabel}
|
||||
</div>
|
||||
<FormMessage className="float-right" />
|
||||
</div>
|
||||
<FormMessage className="float-right" />
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getCurrency } from '@/lib/currency'
|
||||
import { formatAmountAsDecimal, getCurrencyFromGroup } from '@/lib/utils'
|
||||
import { Parser } from '@json2csv/plainjs'
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
@@ -38,6 +39,9 @@ export async function GET(
|
||||
title: true,
|
||||
category: { select: { name: true } },
|
||||
amount: true,
|
||||
originalAmount: true,
|
||||
originalCurrency: true,
|
||||
conversionRate: true,
|
||||
paidById: true,
|
||||
paidFor: { select: { participantId: true, shares: true } },
|
||||
isReimbursement: true,
|
||||
@@ -54,30 +58,29 @@ export async function GET(
|
||||
|
||||
/*
|
||||
|
||||
CSV Structure:
|
||||
|
||||
--------------------------------------------------------------
|
||||
| Date | Description | Category | Currency | Cost
|
||||
--------------------------------------------------------------
|
||||
| Is Reimbursement | Split mode | UserA | UserB
|
||||
--------------------------------------------------------------
|
||||
|
||||
Columns:
|
||||
CSV Columns:
|
||||
- Date: The date of the expense.
|
||||
- Description: A brief description of the expense.
|
||||
- Category: The category of the expense (e.g., Food, Travel, etc.).
|
||||
- Currency: The currency in which the expense is recorded.
|
||||
- Cost: The amount spent.
|
||||
- Original cost: The amount spent in the original currency.
|
||||
- Original currency: The currency the amount was originally spent in.
|
||||
- Conversion rate: The rate used to convert the amount.
|
||||
- Is Reimbursement: Whether the expense is a reimbursement or not.
|
||||
- Split mode: The method used to split the expense (e.g., Evenly, By shares, By percentage, By amount).
|
||||
- UserA, UserB: User-specific data or balances (e.g., amount owed or contributed by each user).
|
||||
|
||||
Example Row:
|
||||
------------------------------------------------------------------------------------------
|
||||
| 2025-01-06 | Dinner with team | Food | ₹ | 5000 | No | Evenly | John | Jane
|
||||
------------------------------------------------------------------------------------------
|
||||
Example Table:
|
||||
+------------+------------------+----------+----------+----------+---------------+-------------------+-----------------+------------------+----------------------+--------+-----------+
|
||||
| Date | Description | Category | Currency | Cost | Original cost | Original currency | Conversion rate | Is reinbursement | Split mode | User A | User B |
|
||||
+------------+------------------+----------+----------+----------+---------------+-------------------+-----------------+------------------+----------------------+--------+-----------+
|
||||
| 2025-01-06 | Dinner with team | Food | INR | 5000 | | | | No | Evenly | 2500 | -2500 |
|
||||
+------------+------------------+----------+----------+----------+---------------+-------------------+-----------------+------------------+----------------------+--------+-----------+
|
||||
| 2025-02-07 | Plane tickets | Travel | INR | 97264.09 | 1000 | EUR | 97.2641 | No | Unevenly - By amount | -80000 | -17264.09 |
|
||||
+------------+------------------+----------+----------+----------+---------------+-------------------+-----------------+------------------+----------------------+--------+-----------+
|
||||
|
||||
*/
|
||||
*/
|
||||
|
||||
const fields = [
|
||||
{ label: 'Date', value: 'date' },
|
||||
@@ -85,6 +88,9 @@ export async function GET(
|
||||
{ label: 'Category', value: 'categoryName' },
|
||||
{ label: 'Currency', value: 'currency' },
|
||||
{ label: 'Cost', value: 'amount' },
|
||||
{ label: 'Original cost', value: 'originalAmount' },
|
||||
{ label: 'Original currency', value: 'originalCurrency' },
|
||||
{ label: 'Conversion rate', value: 'conversionRate' },
|
||||
{ label: 'Is Reimbursement', value: 'isReimbursement' },
|
||||
{ label: 'Split mode', value: 'splitMode' },
|
||||
...group.participants.map((participant) => ({
|
||||
@@ -101,6 +107,16 @@ export async function GET(
|
||||
categoryName: expense.category?.name || '',
|
||||
currency: group.currencyCode ?? group.currency,
|
||||
amount: formatAmountAsDecimal(expense.amount, currency),
|
||||
originalAmount: expense.originalAmount
|
||||
? formatAmountAsDecimal(
|
||||
expense.originalAmount,
|
||||
getCurrency(expense.originalCurrency),
|
||||
)
|
||||
: null,
|
||||
originalCurrency: expense.originalCurrency,
|
||||
conversionRate: expense.conversionRate
|
||||
? expense.conversionRate.toString()
|
||||
: null,
|
||||
isReimbursement: expense.isReimbursement ? 'Yes' : 'No',
|
||||
splitMode: splitModeLabel[expense.splitMode],
|
||||
...Object.fromEntries(
|
||||
|
||||
@@ -20,6 +20,9 @@ export async function GET(
|
||||
title: true,
|
||||
category: { select: { grouping: true, name: true } },
|
||||
amount: true,
|
||||
originalAmount: true,
|
||||
originalCurrency: true,
|
||||
conversionRate: true,
|
||||
paidById: true,
|
||||
paidFor: { select: { participantId: true, shares: true } },
|
||||
isReimbursement: true,
|
||||
|
||||
@@ -71,6 +71,9 @@ export async function createExpense(
|
||||
expenseDate: expenseFormValues.expenseDate,
|
||||
categoryId: expenseFormValues.category,
|
||||
amount: expenseFormValues.amount,
|
||||
originalAmount: expenseFormValues.originalAmount,
|
||||
originalCurrency: expenseFormValues.originalCurrency,
|
||||
conversionRate: expenseFormValues.conversionRate,
|
||||
title: expenseFormValues.title,
|
||||
paidById: expenseFormValues.paidBy,
|
||||
splitMode: expenseFormValues.splitMode,
|
||||
@@ -206,6 +209,9 @@ export async function updateExpense(
|
||||
data: {
|
||||
expenseDate: expenseFormValues.expenseDate,
|
||||
amount: expenseFormValues.amount,
|
||||
originalAmount: expenseFormValues.originalAmount,
|
||||
originalCurrency: expenseFormValues.originalCurrency,
|
||||
conversionRate: expenseFormValues.conversionRate,
|
||||
title: expenseFormValues.title,
|
||||
categoryId: expenseFormValues.category,
|
||||
paidById: expenseFormValues.paidBy,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import dayjs from 'dayjs'
|
||||
import { useEffect, useState } from 'react'
|
||||
import useSWR, { Fetcher } from 'swr'
|
||||
|
||||
export function useMediaQuery(query: string): boolean {
|
||||
const getMatches = (query: string): boolean => {
|
||||
@@ -64,3 +66,62 @@ export function useActiveUser(groupId?: string) {
|
||||
|
||||
return activeUser
|
||||
}
|
||||
|
||||
interface FrankfurterAPIResponse {
|
||||
base: string
|
||||
date: string
|
||||
rates: Record<string, number>
|
||||
}
|
||||
|
||||
const fetcher: Fetcher<FrankfurterAPIResponse> = (url: string) =>
|
||||
fetch(url).then(async (res) => {
|
||||
if (!res.ok)
|
||||
throw new TypeError('Unsuccessful response from API', { cause: res })
|
||||
return res.json() as Promise<FrankfurterAPIResponse>
|
||||
})
|
||||
|
||||
export function useCurrencyRate(
|
||||
date: Date,
|
||||
baseCurrency: string,
|
||||
targetCurrency: string,
|
||||
) {
|
||||
const dateString = dayjs(date).format('YYYY-MM-DD')
|
||||
|
||||
// Only send request if both currency codes are given and not the same
|
||||
const url =
|
||||
!isNaN(date.getTime()) &&
|
||||
!!baseCurrency.length &&
|
||||
!!targetCurrency.length &&
|
||||
baseCurrency !== targetCurrency &&
|
||||
`https://api.frankfurter.app/${dateString}?base=${baseCurrency}`
|
||||
const { data, error, isLoading, mutate } = useSWR<FrankfurterAPIResponse>(
|
||||
url,
|
||||
fetcher,
|
||||
{ shouldRetryOnError: false, revalidateOnFocus: false },
|
||||
)
|
||||
|
||||
if (data) {
|
||||
let exchangeRate = undefined
|
||||
let sentError = error
|
||||
if (!error && data.date !== dateString) {
|
||||
// this happens if for example, the requested date is in the future.
|
||||
sentError = new RangeError(data.date)
|
||||
}
|
||||
if (data.rates[targetCurrency]) {
|
||||
exchangeRate = data.rates[targetCurrency]
|
||||
}
|
||||
return {
|
||||
data: exchangeRate,
|
||||
error: sentError,
|
||||
isLoading,
|
||||
refresh: mutate,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
data,
|
||||
error,
|
||||
isLoading,
|
||||
refresh: mutate,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,19 @@ export const groupFormSchema = z
|
||||
|
||||
export type GroupFormValues = z.infer<typeof groupFormSchema>
|
||||
|
||||
const inputCoercedToNumber = z.union([
|
||||
z.number(),
|
||||
z.string().transform((value, ctx) => {
|
||||
const valueAsNumber = Number(value)
|
||||
if (Number.isNaN(valueAsNumber))
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'invalidNumber',
|
||||
})
|
||||
return valueAsNumber
|
||||
}),
|
||||
])
|
||||
|
||||
export const expenseFormSchema = z
|
||||
.object({
|
||||
expenseDate: z.coerce.date(),
|
||||
@@ -55,11 +68,27 @@ export const expenseFormSchema = z
|
||||
)
|
||||
.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) => {
|
||||
@@ -119,10 +148,12 @@ export const expenseFormSchema = z
|
||||
break // noop
|
||||
case 'BY_AMOUNT': {
|
||||
const sum = expense.paidFor.reduce(
|
||||
(sum, { shares }) => sum + Number(shares),
|
||||
// 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 !== expense.amount) {
|
||||
if (sum !== Math.round(expense.amount * 1000)) {
|
||||
const detail =
|
||||
sum < expense.amount
|
||||
? `${((expense.amount - sum) / 100).toFixed(2)} missing`
|
||||
@@ -163,17 +194,18 @@ export const expenseFormSchema = z
|
||||
// Format the share split as a number (if from form submission)
|
||||
return {
|
||||
...expense,
|
||||
paidFor: expense.paidFor.map(({ participant, shares }) => {
|
||||
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 {
|
||||
participant,
|
||||
...paidFor,
|
||||
shares: Math.round(Number(shares) * 100),
|
||||
}
|
||||
}
|
||||
// Otherwise, no need as the number will have been formatted according to currency.
|
||||
return {
|
||||
participant,
|
||||
...paidFor,
|
||||
shares: Number(shares),
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -99,10 +99,10 @@ export function amountAsDecimal(
|
||||
* - €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
|
||||
* @param amount The amount in decimal major units (always an integer)
|
||||
*/
|
||||
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
|
||||
import { Prisma } from '@prisma/client'
|
||||
import type { QueryClient } from '@tanstack/react-query'
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { httpBatchLink } from '@trpc/client'
|
||||
@@ -8,6 +9,15 @@ import superjson from 'superjson'
|
||||
import { makeQueryClient } from './query-client'
|
||||
import type { AppRouter } from './routers/_app'
|
||||
|
||||
superjson.registerCustom<Prisma.Decimal, string>(
|
||||
{
|
||||
isApplicable: (v): v is Prisma.Decimal => Prisma.Decimal.isDecimal(v),
|
||||
serialize: (v) => v.toJSON(),
|
||||
deserialize: (v) => new Prisma.Decimal(v),
|
||||
},
|
||||
'decimal.js',
|
||||
)
|
||||
|
||||
export const trpc = createTRPCReact<AppRouter>()
|
||||
|
||||
let clientQueryClientSingleton: QueryClient
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
import { Prisma } from '@prisma/client'
|
||||
import { initTRPC } from '@trpc/server'
|
||||
import { cache } from 'react'
|
||||
import superjson from 'superjson'
|
||||
|
||||
superjson.registerCustom<Prisma.Decimal, string>(
|
||||
{
|
||||
isApplicable: (v): v is Prisma.Decimal => Prisma.Decimal.isDecimal(v),
|
||||
serialize: (v) => v.toJSON(),
|
||||
deserialize: (v) => new Prisma.Decimal(v),
|
||||
},
|
||||
'decimal.js',
|
||||
)
|
||||
|
||||
export const createTRPCContext = cache(async () => {
|
||||
/**
|
||||
* @see: https://trpc.io/docs/server/context
|
||||
|
||||
Reference in New Issue
Block a user