10 Commits

Author SHA1 Message Date
Sebastien Castiel
39c1a2ffc6 Fix languages in Romanian translation 2024-10-20 11:51:04 -04:00
stefansn
f5154393e2 Add Romanian translations (#248)
* Add Romanian translations

Create ro.json.

* Add ro option.

Add ro option.

* Update ro.json

* Prettier

---------

Co-authored-by: Sebastien Castiel <sebastien@castiel.me>
2024-10-19 23:10:58 -04:00
Marco Sciuto
e9d583113a Update it-IT.json (#245) 2024-10-19 23:09:07 -04:00
Sebastien Castiel
21d0c02687 Use tRPC for expense form (#251) 2024-10-19 22:59:47 -04:00
Sebastien Castiel
2281316d58 Use tRPC for group create form (#250) 2024-10-19 21:48:17 -04:00
Sebastien Castiel
210c12b7ef Use tRPC in other group pages (#249)
* Use tRPC in group edition + group layout

* Use tRPC in group modals

* Use tRPC in group stats

* Use tRPC in group activity
2024-10-19 21:29:53 -04:00
Sebastien Castiel
66e15e419e Add tRPC, use it for group expenses, balances and information page (#246)
* Add tRPC, use it for group expense list

* Use tRPC for balances

* Use tRPC in group information + better loading states
2024-10-19 17:42:11 -04:00
Paweł Kotiuk
727803ea5c [translation] Add Polish language (#243)
* Add polish translation file

* Add polish to other translations
2024-10-14 19:19:59 -04:00
Thorsten Herfurtner
7add7efea2 Fix missing translation for expense title in expense-form when making a reinbursement (#244) 2024-10-14 19:13:10 -04:00
bitgroestl
a7c80f65c3 fix Dockerfile (#206)
Co-authored-by: samuel <samuel@t460.localdomain>
2024-10-14 19:11:50 -04:00
73 changed files with 2462 additions and 815 deletions

View File

@@ -1,9 +1,9 @@
FROM node:21-alpine as base
FROM node:21-alpine AS base
WORKDIR /usr/app
COPY ./package.json \
./package-lock.json \
./next.config.js \
./next.config.mjs \
./tsconfig.json \
./reset.d.ts \
./tailwind.config.js \
@@ -16,6 +16,7 @@ RUN apk add --no-cache openssl && \
npx prisma generate
COPY ./src ./src
COPY ./messages ./messages
ENV NEXT_TELEMETRY_DISABLED=1
@@ -24,21 +25,21 @@ RUN npm run build
RUN rm -r .next/cache
FROM node:21-alpine as runtime-deps
FROM node:21-alpine AS runtime-deps
WORKDIR /usr/app
COPY --from=base /usr/app/package.json /usr/app/package-lock.json /usr/app/next.config.js ./
COPY --from=base /usr/app/package.json /usr/app/package-lock.json /usr/app/next.config.mjs ./
COPY --from=base /usr/app/prisma ./prisma
RUN npm ci --omit=dev --omit=optional --ignore-scripts && \
npx prisma generate
FROM node:21-alpine as runner
FROM node:21-alpine AS runner
EXPOSE 3000/tcp
WORKDIR /usr/app
COPY --from=base /usr/app/package.json /usr/app/package-lock.json /usr/app/next.config.js ./
COPY --from=base /usr/app/package.json /usr/app/package-lock.json /usr/app/next.config.mjs ./
COPY --from=runtime-deps /usr/app/node_modules ./node_modules
COPY ./public ./public
COPY ./scripts ./scripts

View File

@@ -203,7 +203,8 @@
"creating": "Erstellt…",
"save": "Speichern",
"saving": "Speichert…",
"cancel": "Abbrechen"
"cancel": "Abbrechen",
"reimbursement": "Rückzahlung"
},
"ExpenseDocumentsInput": {
"TooBigToast": {
@@ -300,9 +301,11 @@
"es": "Español",
"de-DE": "Deutsch",
"zh-CN": "Chinese (Simplified)",
"pl-PL": "Polski",
"ru-RU": "Русский",
"it-IT": "Italiano",
"ua-UA": "Українська"
"ua-UA": "Українська",
"ro": "Română"
},
"Share": {
"title": "Teilen",

View File

@@ -203,7 +203,8 @@
"creating": "Creating…",
"save": "Save",
"saving": "Saving…",
"cancel": "Cancel"
"cancel": "Cancel",
"reimbursement": "Reimbursement"
},
"ExpenseDocumentsInput": {
"TooBigToast": {
@@ -300,9 +301,11 @@
"es": "Español",
"de-DE": "Deutsch",
"zh-CN": "Chinese (Simplified)",
"pl-PL": "Polski",
"ru-RU": "Русский",
"it-IT": "Italiano",
"ua-UA": "Українська"
"ua-UA": "Українська",
"ro": "Română"
},
"Share": {
"title": "Share",

View File

@@ -203,7 +203,8 @@
"creating": "Creando",
"save": "Guardar",
"saving": "Guardando",
"cancel": "Cancelar"
"cancel": "Cancelar",
"reimbursement": "Reembolso"
},
"ExpenseDocumentsInput": {
"TooBigToast": {
@@ -300,9 +301,11 @@
"es": "Español",
"de-DE": "Deutsch",
"zh-CN": "Chinese (Simplified)",
"pl-PL": "Polski",
"ru-RU": "Русский",
"it-IT": "Italiano",
"ua-UA": "Українська"
"ua-UA": "Українська",
"ro": "Română"
},
"Share": {
"title": "Compartir",

View File

@@ -203,7 +203,8 @@
"creating": "Luodaan kulua…",
"save": "Tallenna",
"saving": "Tallennetaan…",
"cancel": "Peruuta"
"cancel": "Peruuta",
"reimbursement": "Velanmaksu"
},
"ExpenseDocumentsInput": {
"TooBigToast": {
@@ -300,9 +301,11 @@
"es": "Español",
"de-DE": "Deutsch",
"zh-CN": "Chinese (Simplified)",
"pl-PL": "Polski",
"ru-RU": "Русский",
"it-IT": "Italiano",
"ua-UA": "Українська"
"ua-UA": "Українська",
"ro": "Română"
},
"Share": {
"title": "Jaa",

View File

@@ -203,7 +203,8 @@
"creating": "Création…",
"save": "Sauvegarder",
"saving": "Sauvegarde…",
"cancel": "Annuler"
"cancel": "Annuler",
"reimbursement": "Remboursement"
},
"ExpenseDocumentsInput": {
"TooBigToast": {
@@ -300,9 +301,11 @@
"es": "Español",
"de-DE": "Deutsch",
"zh-CN": "Chinese (Simplified)",
"pl-PL": "Polski",
"ru-RU": "Русский",
"it-IT": "Italiano",
"ua-UA": "Українська"
"ua-UA": "Українська",
"ro": "Română"
},
"Share": {
"title": "Partager",

View File

@@ -4,7 +4,7 @@
"description": "Benvenuto nella tua nuova instanza di <strong>Spliit</strong>!",
"button": {
"groups": "Vai ai gruppi",
"github": "GitHub"
"github": "GitHub"
}
},
"Header": {
@@ -57,7 +57,7 @@
"starred": "Gruppi speciali",
"archived": "Gruppi archiviati",
"archive": "Archivia gruppo",
"unarchive": "Rimuovi dall'archivio il gruppo",
"unarchive": "Rimuovi il gruppo dall'archivio",
"removeRecent": "Rimuovi dai gruppi recenti",
"RecentRemovedToast": {
"title": "Il gruppo è stato rimosso",
@@ -168,7 +168,7 @@
"attachDescription": "Vedi allegati spesa."
},
"amountField": {
"label": "Quantità"
"label": "Importo"
},
"isReimbursementField": {
"label": "Questo è un rimborso"
@@ -186,10 +186,10 @@
"SplitModeField": {
"label": "Modalità split",
"evenly": "Uniforme",
"byShares": "Non uniforme Per scelta",
"byShares": "Non uniforme Per quote",
"byPercentage": "Non uniforme Per percentuale",
"byAmount": "Non uniforme Per quantità",
"saveAsDefault": "Salva come opzioni di suddivisione predefinite"
"byAmount": "Non uniforme Per importo",
"saveAsDefault": "Salva come opzione di suddivisione predefinita"
},
"DeletePopup": {
"label": "Rimuovi",
@@ -203,7 +203,8 @@
"creating": "Sto creando…",
"save": "Salva",
"saving": "Sto salvando…",
"cancel": "Annulla"
"cancel": "Annulla",
"reimbursement": "Rimborso"
},
"ExpenseDocumentsInput": {
"TooBigToast": {
@@ -225,7 +226,7 @@
"selectImage": "Seleziona immagine…",
"titleLabel": "Titolo:",
"categoryLabel": "Categoria:",
"amountLabel": "Quantità:",
"amountLabel": "Importo:",
"dateLabel": "Data:",
"editNext": "Successivamente potrai modificare le informazioni sulle spese.",
"continue": "Continua"
@@ -243,13 +244,13 @@
},
"Balances": {
"title": "Bilanci",
"description": "Questo è l'importo che ciascun partecipante ha pagato o è stato pagato.",
"description": "Questo è l'importo che ciascun partecipante ha pagato o deve pagare.",
"Reimbursements": {
"title": "Rimborsi suggeriti",
"description": "Ecco alcuni suggerimenti per ottimizzare i rimborsi tra i partecipanti.",
"noImbursements": "Sembra che il tuo gruppo non abbia bisogno di alcun rimborso 😁",
"owes": "<strong>{from}</strong> deve <strong>{to}</strong>",
"markAsPaid": "Marca come pagato"
"markAsPaid": "Segna come pagato"
}
},
"Stats": {
@@ -288,7 +289,7 @@
"Information": {
"title": "Informazioni",
"description": "Utilizza questo posto per aggiungere qualsiasi informazione che possa essere rilevante per i partecipanti al gruppo.",
"empty": "Nessuna informazione sul gruppo ancora."
"empty": "Ancora nessuna informazione sul gruppo."
},
"Settings": {
"title": "Impostazioni"
@@ -300,9 +301,11 @@
"es": "Español",
"de-DE": "Deutsch",
"zh-CN": "Chinese (Simplified)",
"pl-PL": "Polski",
"ru-RU": "Русский",
"it-IT": "Italiano",
"ua-UA": "Українська"
"ua-UA": "Українська",
"ro": "Română"
},
"Share": {
"title": "Condividi",
@@ -318,12 +321,12 @@
"duplicateParticipantName": "Un altro partecipante ha già questo nome.",
"titleRequired": "Inserisci un titolo.",
"invalidNumber": "Numero invalido.",
"amountRequired": "Devi inserire una quanttà",
"amountNotZero": "La quantità non deve essere zero.",
"amountRequired": "Devi inserire un importo",
"amountNotZero": "L'importo non deve essere zero.",
"amountTenMillion": "L'importo deve essere inferiore a 10.000.000.",
"paidByRequired": "È necessario selezionare un partecipante.",
"paidForMin1": "La spesa dovrà essere sostenuta per almeno un partecipante.",
"noZeroShares": "Tutte le condivisioni devono essere superiori a 0.",
"paidForMin1": "La spesa deve essere pagata per almeno un partecipante.",
"noZeroShares": "Tutti gli importi devono essere superiori a 0.",
"amountSum": "La somma degli importi deve essere uguale all'importo della spesa.",
"percentageSum": "La somma delle percentuali deve essere uguale a 100."
},

400
messages/pl-PL.json Normal file
View File

@@ -0,0 +1,400 @@
{
"Homepage": {
"title": "Podziel <strong>Wydatki</strong> z <strong>Rodziną i Przyjaciółmi</strong>",
"description": "Witaj na twojej nowej instancji <strong>Spliita</strong> !",
"button": {
"groups": "Przejdź do grup",
"github": "GitHub"
}
},
"Header": {
"groups": "Grupy"
},
"Footer": {
"madeIn": "Stworzone Montréalu, Québec 🇨🇦",
"builtBy": "Napisane przez <author>Sebastien Castiela</author> i <source>kontrybutorów</source>"
},
"Expenses": {
"title": "Wydatki",
"description": "Tutaj są wydatki, które utworzyłeś dla twojej grupy.",
"create": "Dodaj wydatek",
"createFirst": "Stwórz swój pierwszy",
"noExpenses": "Twoja grupa nie ma jeszcze żadnych wydatków.",
"exportJson": "Eksportuj do JSONa",
"searchPlaceholder": "Szukaj wydatku...",
"ActiveUserModal": {
"title": "Kim jesteś?",
"description": "Podaj, którym uczestnikiem jesteś aby pozwolić nam określić jakie informacje mają być wyświetlane.",
"nobody": "Nie chcę wybierać nikogo",
"save": "Zapisz zmiany",
"footer": "To ustawienie może być potem zmienione w ustawieniach grupy."
},
"Groups": {
"upcoming": "Nadchodzące",
"thisWeek": "Ten tydzień",
"earlierThisMonth": "Wcześniej w tym miesiącu",
"lastMonth": "Ostatni miesiąc",
"earlierThisYear": "Wcześniej w tym roku",
"lastYera": "Poprzedni rok",
"older": "Starsze"
}
},
"ExpenseCard": {
"paidBy": "Opłacone przez <strong>{paidBy}</strong> dla <paidFor></paidFor>",
"receivedBy": "Otrzymane przez <strong>{paidBy}</strong> od <paidFor></paidFor>",
"yourBalance": "Twjoje saldo:"
},
"Groups": {
"myGroups": "Moje grupy",
"create": "Stwórz",
"loadingRecent": "Wczytywanie ostatnich grup...",
"NoRecent": {
"description": "Nie odwiedzałeś ostatnio żadnych grup.",
"create": "Stwórz",
"orAsk": "albo poproś przyjaciela, aby ci wysłał link do już istniejącej."
},
"recent": "Ostatnie grupy",
"starred": "Ogwiazdkowane grupy",
"archived": "Zarchiwizowane grupy",
"archive": "Zarchiwizuj grupę",
"unarchive": "Odarchwiruj grupę",
"removeRecent": "Usuń z ostatnich grup",
"RecentRemovedToast": {
"title": "Grupa została usunięta",
"description": "Grupa została usunięta z listy twoich ostatnich grup.",
"undoAlt": "Cofnij usunięcie grupy",
"undo": "Cofnij"
},
"AddByURL": {
"button": "Dodaj poprzez link URL",
"title": "Dodaj grupę poprzez link URL",
"description": "Jeśli grupa została ci udostępniona możesz wkleić jej link tutaj, aby dodać ją do twojej listy.",
"error": "Ups, nie możemy znaleźć grupy z podanego linka..."
},
"NotFound": {
"text": "Ta grupa nie istnieje.",
"link": "Idź do ostatnio odwiedzanych grup"
}
},
"GroupForm": {
"title": "Informacje o grupie",
"NameField": {
"label": "Nazwa grupy",
"placeholder": "Letni wyjazd",
"description": "Podaj nazwę dla twojej grupy."
},
"InformationField": {
"label": "Informacje o grupie",
"placeholder": "Jakie informacje mogą być ważne dla członków grupy?"
},
"CurrencyField": {
"label": "Symbol waluty",
"placeholder": "PLN, zł, $, €, £…",
"description": "Użyjemy go do wyświetlania ilości."
},
"Participants": {
"title": "Członkowie",
"description": "Podaj nazwę dla każdego członka.",
"protectedParticipant": "Ten członek wciąż bierze udział w rozliczeniach i nie może być usunięty.",
"new": "Nowy",
"add": "Dodaj członka",
"John": "Jan",
"Jane": "Joanna",
"Jack": "Jacek"
},
"Settings": {
"title": "Ustawienia lokalne",
"description": "Te ustawienia są ustawiane dla konkretnego urządzenia i służą do dostosowania twoich doświadczeń z aplikacją.",
"ActiveUserField": {
"label": "Aktywny użytkownik",
"placeholder": "Wybierz członka",
"none": "Brak",
"description": "Użytkownik używany domyślnie do wprowadzania wydatków."
},
"save": "Zapisz",
"saving": "Zapisywanie…",
"create": "Stwórz",
"creating": "Tworzenie…",
"cancel": "Anuluj"
}
},
"ExpenseForm": {
"Income": {
"create": "Dodaj wpływ",
"edit": "Edytuj wpływ",
"TitleField": {
"label": "Tytuł wpływu",
"placeholder": "Zwrot kaucji",
"description": "Podaj opis wpływu."
},
"DateField": {
"label": "Data wpływu",
"description": "Podaj datę otrzymania wpływu."
},
"categoryFieldDescription": "Wybierz typ wpływu.",
"paidByField": {
"label": "Otrzymane przez",
"description": "Wybierz członka, który otrzymał wpływ."
},
"paidFor": {
"title": "Otrzymany dla",
"description": "Podaj dla kogo wpływ był przeznaczony."
},
"splitModeDescription": "Wybierz jak podzielić wpływ.",
"attachDescription": "Zobacz i załącz rachunki do wpływu."
},
"Expense": {
"create": "Stwórz wydatek",
"edit": "Edytuj wydatek",
"TitleField": {
"label": "Tytuł wydatku",
"placeholder": "Poniedziałkowe wyjście do restauracji",
"description": "Podaj opis wydatku."
},
"DateField": {
"label": "Data wydatku",
"description": "Podaj datę opłacenia wydatku."
},
"categoryFieldDescription": "Podaj kategorię wydatku.",
"paidByField": {
"label": "Opłacone przez",
"description": "Wybierz członka, który zapłacił."
},
"paidFor": {
"title": "Opłacone dla",
"description": "Wybierz kogo dotyczył wydatek."
},
"splitModeDescription": "Wybierz jak podzielić wydatek.",
"attachDescription": "Zobacz i załącz rachunki do wydatku."
},
"amountField": {
"label": "Ilość"
},
"isReimbursementField": {
"label": "To jest zwrot kosztów"
},
"categoryField": {
"label": "Kategoria"
},
"notesField": {
"label": "Notatki"
},
"selectNone": "Nie wybieraj żadnego",
"selectAll": "Wybierz wszystkie",
"shares": "udział(y)",
"advancedOptions": "Zaawansowane opcje podziału...",
"SplitModeField": {
"label": "Typ podziału",
"evenly": "Równy",
"byShares": "Nierówny Poprzez udziały",
"byPercentage": "Nierówny Procentowo",
"byAmount": "Nierówny Na konkretne sumy",
"saveAsDefault": "Wybierz jako domyślny typ podziału"
},
"DeletePopup": {
"label": "Usuń",
"title": "Usunąć ten wydatek?",
"description": "Czy na pewno chcesz usunąć ten wydatek? Ta akcja jest nieodwracalna.",
"yes": "Tak",
"cancel": "Anuluj"
},
"attachDocuments": "Załącz dokumenty",
"create": "Stwórz",
"creating": "Tworzenie…",
"save": "Zapisz",
"saving": "Zapisywanie…",
"cancel": "Anuluj"
},
"ExpenseDocumentsInput": {
"TooBigToast": {
"title": "Ten plik jest zbyt duży",
"description": "Maksymalny rozmiar pliku to: {maxSize}. Twój plik ma: ${size}."
},
"ErrorToast": {
"title": "Błąd podczas wysyłania dokumentu",
"description": "Coś poszło nie tak podczas wysyłania dokumentu. Proszę spróbuj ponownie później, albo wybierz inny plik.",
"retry": "Ponów"
}
},
"CreateFromReceipt": {
"Dialog": {
"triggerTitle": "Utwórz wydatek z paragonu",
"title": "Utwórz z paragonu",
"description": "Wyodrębnianie informacji o wydatkach ze zdjęcia paragonu.",
"body": "Prześlij zdjęcie paragonu, a my zeskanujemy je, aby wyodrębnić informacje o wydatkach, jeśli to możliwe.",
"selectImage": "Wybierz obraz...",
"titleLabel": "Tytuł:",
"categoryLabel": "Kategoria:",
"amountLabel": "Ilość:",
"dateLabel": "Data:",
"editNext": "Następnie będziesz mógł edytować informacje o wydatkach.",
"continue": "Kontynuuj"
},
"unknown": "Nieznany",
"TooBigToast": {
"title": "Ten plik jest zbyt duży",
"description": "Maksymalny rozmiar pliku to: {maxSize}. Twój plik ma: ${size}."
},
"ErrorToast": {
"title": "Błąd podczas wysyłania dokumentu",
"description": "Coś poszło nie tak podczas wysyłania dokumentu. Proszę spróbuj ponownie później, albo wybierz inny plik.",
"retry": "Ponów"
}
},
"Balances": {
"title": "Salda",
"description": "Jest to kwota, którą każdy członek zapłacił lub za którą otrzymał zapłatę.",
"Reimbursements": {
"title": "Sugerowane zwroty",
"description": "Oto sugestie dotyczące optymalizacji zwrotów między uczestnikami.",
"noImbursements": "Wygląda na to, że w twojej grupie nie ma potrzeby żadnych zwrotów 😁",
"owes": "<strong>{from}</strong> jest winny dla <strong>{to}</strong>",
"markAsPaid": "Zaznacz jako opłacone"
}
},
"Stats": {
"title": "Statystyki",
"Totals": {
"title": "Podsumowanie",
"description": "Podsumowanie wydatków dla całej grupy.",
"groupSpendings": "Wydatki grupy",
"groupEarnings": "Wpływy grupy",
"yourSpendings": "Twoje wydatki",
"yourEarnings": "Twoje wpływy",
"yourShare": "Twoje udziały"
}
},
"Activity": {
"title": "Aktywność",
"description": "Przegląd wszystkich działań w tej grupie.",
"noActivity": "W grupie nie ma jeszcze żadnej aktywności.",
"someone": "Ktoś",
"settingsModified": "Ustawienia grupy zostały zmienione przez <strong>{participant}</strong>.",
"expenseCreated": "Wydatek <em>{expense}</em> stworzony przez <strong>{participant}</strong>.",
"expenseUpdated": "Wydatek <em>{expense}</em> zaktualizowany przez <strong>{participant}</strong>.",
"expenseDeleted": "Wydatek <em>{expense}</em> usunięty przez <strong>{participant}</strong>.",
"Groups": {
"today": "Dzisiaj",
"yesterday": "Wczoraj",
"earlierThisWeek": "Wcześniej w tym tygodniu",
"lastWeek": "W zeszłym tygodniu",
"earlierThisMonth": "Wcześniej w tym miesiącu",
"lastMonth": "Ostatni miesiąc",
"earlierThisYear": "Wcześniej w tym roku",
"lastYera": "Poprzedni rok",
"older": "Starsze"
}
},
"Information": {
"title": "Informacje",
"description": "Użyj tego miejsca, aby dodać wszelkie informacje, które mogą być istotne dla uczestników grupy..",
"empty": "Jeszcze nic tu nie ma."
},
"Settings": {
"title": "Ustawienia"
},
"Locale": {
"en-US": "English",
"fi": "Suomi",
"fr-FR": "Français",
"es": "Español",
"de-DE": "Deutsch",
"zh-CN": "Chinese (Simplified)",
"pl-PL": "Polski",
"ru-RU": "Русский",
"it-IT": "Italiano",
"ua-UA": "Українська",
"ro": "Română"
},
"Share": {
"title": "Udostępnij",
"description": "Aby inni uczestnicy mogli zobaczyć grupę i dodać wydatki, udostępnij im jej adres URL.",
"warning": "Uwaga!",
"warningHelp": "Każda osoba posiadająca adres URL grupy będzie mogła przeglądać i edytować wydatki. Udostępniaj ostrożnie!"
},
"SchemaErrors": {
"min1": "Wprowadź co najmniej jeden znak.",
"min2": "Wprowadź co najmniej dwa znaki.",
"max5": "Wprowadź maksymalnie pięć znaków.",
"max50": "Wprowadź maksymalnie 50 znaków.",
"duplicateParticipantName": "Ta nazwa jest już zajęta.",
"titleRequired": "Podaj tytuł.",
"invalidNumber": "Niewłaściwa liczba.",
"amountRequired": "Należy wprowadzić kwotę.",
"amountNotZero": "Kwota nie może być zerem.",
"amountTenMillion": "Kwota musi być niższa niż 10,000,000.",
"paidByRequired": "Musisz wybrać członka.",
"paidForMin1": "Wydatek musi zostać opłacony za co najmniej jednego uczestnika.",
"noZeroShares": "Wszystkie udziały muszą być większe niż 0.",
"amountSum": "Suma udziałów musi być równa wydatkowi.",
"percentageSum": "Suma procentów musi być równa 100."
},
"Categories": {
"search": "Szukaj kategorii...",
"noCategory": "Nie znaleziono kategorii.",
"Uncategorized": {
"heading": "Bez kategorii",
"General": "Ogólne",
"Payment": "Płatność"
},
"Entertainment": {
"heading": "Rozrywka",
"Entertainment": "Rozrywka",
"Games": "Gry",
"Movies": "Filmy",
"Music": "Muzyka",
"Sports": "Sporty"
},
"Food and Drink": {
"heading": "Jedzenie i Napoje",
"Food and Drink": "Jedzenie i Napoje",
"Dining Out": "Jedzenie na mieście",
"Groceries": "Zakupy",
"Liquor": "Alkohole"
},
"Home": {
"heading": "Dom",
"Home": "Dom",
"Electronics": "Elektronika",
"Furniture": "Meble",
"Household Supplies": "Artykuły gospodarstwa domowego",
"Maintenance": "Utrzymanie",
"Mortgage": "Czynsz",
"Pets": "Zwierzaki",
"Rent": "Czynsz",
"Services": "Usługi"
},
"Life": {
"heading": "Życie",
"Childcare": "Opieka nad dzieckiem",
"Clothing": "Ubrania",
"Education": "Edukacja",
"Gifts": "Prezenty",
"Insurance": "Ubezpieczenie",
"Medical Expenses": "Wydatki medyczne",
"Taxes": "Podatki"
},
"Transportation": {
"heading": "Transport",
"Transportation": "Transport",
"Bicycle": "Rower",
"Bus/Train": "Bus/Pociąg",
"Car": "Samochód",
"Gas/Fuel": "Paliwo",
"Hotel": "Hotel",
"Parking": "Parking",
"Plane": "Pociąg",
"Taxi": "Taxi"
},
"Utilities": {
"heading": "Media",
"Utilities": "Media",
"Cleaning": "Sprzątanie",
"Electricity": "Prąg",
"Heat/Gas": "Ogrzewanie",
"Trash": "Śmieci",
"TV/Phone/Internet": "TV/Telefon/Internet",
"Water": "Woda"
}
}
}

401
messages/ro.json Normal file
View File

@@ -0,0 +1,401 @@
{
"Homepage": {
"title": "Distribuie <strong>Cheltuielile</strong> cu <strong>Prietenii & Familia</strong>",
"description": "Bine ai venit pe noua ta instanță de <strong>Spliit</strong> !",
"button": {
"groups": "Mergi la grupuri",
"github": "GitHub"
}
},
"Header": {
"groups": "Grupuri"
},
"Footer": {
"madeIn": "Dezvoltat în Montréal, Québec 🇨🇦",
"builtBy": "Dezvoltat de către <author>Sebastien Castiel</author> și <source>contribuitori</source>"
},
"Expenses": {
"title": "Cheltuieli",
"description": "Aici sunt cheltuielile pe care le-ai creat pentru grupul tău.",
"create": "Adaugă o cheltuială",
"createFirst": "Adaug-o pe prima",
"noExpenses": "Grupul tău nu conține nicio cheltuială încă.",
"exportJson": "Salvează în JSON",
"searchPlaceholder": "Caută o cheltuială…",
"ActiveUserModal": {
"title": "Cum te numești?",
"description": "Spune-ne cine ești ca să putem îți afișăm informațiile relevante.",
"nobody": "Nu doresc să aleg pe nimeni",
"save": "Salvează",
"footer": "Această setare se poate schimba mai târziu din setările grupului."
},
"Groups": {
"upcoming": "Urmează",
"thisWeek": "În această săptămână",
"earlierThisMonth": "La începutul lunii",
"lastMonth": "Luna trecută",
"earlierThisYear": "La începutul anului",
"lastYera": "Anul trecut",
"older": "Mai vechi"
}
},
"ExpenseCard": {
"paidBy": "Plătit de <strong>{paidBy}</strong> pentru <paidFor></paidFor>",
"receivedBy": "Primit de <strong>{paidBy}</strong> pentru <paidFor></paidFor>",
"yourBalance": "Soldul tău:"
},
"Groups": {
"myGroups": "Grupurile mele",
"create": "Adaugă",
"loadingRecent": "Se încarcă ultimele tale grupuri…",
"NoRecent": {
"description": "Nu ai accesat niciun grup recent.",
"create": "Adaugă unul",
"orAsk": "sau roagă un prieten să îți trimită un link către unul deja existent."
},
"recent": "Ultimele grupuri",
"starred": "Grupuri favorite",
"archived": "Grupuri arhivate",
"archive": "Arhivează grupul",
"unarchive": "Dezarhivează grupul",
"removeRecent": "Șterge din ultimele grupuri",
"RecentRemovedToast": {
"title": "Grupul a fost șters.",
"description": "Grupul a fost șters din lista ta de grupuri recente.",
"undoAlt": "Anulează ștergerea grupului",
"undo": "Anulează"
},
"AddByURL": {
"button": "Adaugă folosind un URL",
"title": "Adaugă un grup folosind un URL",
"description": "Dacă un grup a fost distribuit cu tine, poți atașa URL-ul acestuia aici pentru a-l adăuga în listă.",
"error": "Ups, nu am găsit grupul folosind URL-ul primit de la tine…"
},
"NotFound": {
"text": "Acest grup nu există.",
"link": "Mergi la ultimele grupuri vizitate"
}
},
"GroupForm": {
"title": "Informații despre grup",
"NameField": {
"label": "Numele grupului",
"placeholder": "Vacanță de vară",
"description": "Adaugă un nume pentru grupul tău."
},
"InformationField": {
"label": "Informații despre grup",
"placeholder": "Ce informație este relevantă pentru membrii grupului?"
},
"CurrencyField": {
"label": "Monedă",
"placeholder": "$, €, £, RON …",
"description": "O vom folosi pentru a afișa sume."
},
"Participants": {
"title": "Membri",
"description": "Adaugă numele fiecărui membru.",
"protectedParticipant": "Acest membru a luat parte la cheltuieli și nu poate să fie șters.",
"new": "Nou",
"add": "Adaugă membru",
"John": "John",
"Jane": "Jane",
"Jack": "Jack"
},
"Settings": {
"title": "Setări locale",
"description": "Aceste setări sunt făcute pentru fiecare dispozitiv și sunt folosite pentru a-ți personaliza experiența.",
"ActiveUserField": {
"label": "Utilizator activ",
"placeholder": "Selectează un membru",
"none": "Niciunul",
"description": "Utilizatorul implicit pentru plata cheltuielilor."
},
"save": "Salvează",
"saving": "Se salvează…",
"create": "Adaugă",
"creating": "Se adaugă…",
"cancel": "Anulează"
}
},
"ExpenseForm": {
"Income": {
"create": "Adaugă un venit",
"edit": "Modifică venitul",
"TitleField": {
"label": "Titlul venitului",
"placeholder": "Cina de luni seară",
"description": "Adaugă o descriere pentru venit."
},
"DateField": {
"label": "Data venitului",
"description": "Adaugă data la care venitul a fost primit."
},
"categoryFieldDescription": "Selectează categoria venitului.",
"paidByField": {
"label": "Primit de către",
"description": "Selectează membrul care a primit venitul."
},
"paidFor": {
"title": "Primit pentru",
"description": "Selectează pentru cine a fost primit venitul."
},
"splitModeDescription": "Selectează cum să fie împărțit venitul.",
"attachDescription": "Vizualizează și atașează bonul pentru venit."
},
"Expense": {
"create": "Adaugă o cheltuială",
"edit": "Modifică cheltuiala",
"TitleField": {
"label": "Titlul cheltuielii",
"placeholder": "Cina de luni seară",
"description": "Adaugă o descriere pentru cheltuială."
},
"DateField": {
"label": "Data cheltuielii",
"description": "Adaugă data la care cheltuiala a fost facută."
},
"categoryFieldDescription": "Selectează categoria cheltuielii.",
"paidByField": {
"label": "Plătit de către",
"description": "Selectează membrul care a plătit cheltuiala."
},
"paidFor": {
"title": "Plătit pentru",
"description": "Selectează pentru cine a fost platită cheltuiala."
},
"splitModeDescription": "Selectează cum să fie împărțită cheltuiala.",
"attachDescription": "Vizualizează și atașează bonul pentru cheltuială."
},
"amountField": {
"label": "Sumă"
},
"isReimbursementField": {
"label": "Aceasta este o rambursare."
},
"categoryField": {
"label": "Categorie"
},
"notesField": {
"label": "Notițe"
},
"selectNone": "Nu selectez nimic",
"selectAll": "Selectez tot",
"shares": "distribuiri",
"advancedOptions": "Opțiuni avansate de împărțire…",
"SplitModeField": {
"label": "Împărțire",
"evenly": "Egal",
"byShares": "Inegal În funcție de parte",
"byPercentage": "Inegal În funcție de procentaj",
"byAmount": "Inegal În funcție de sumă",
"saveAsDefault": "Salvează ca și implicite opțiunile de împărțire"
},
"DeletePopup": {
"label": "Șterge",
"title": "Ștergi această cheltuială?",
"description": "Ești sigur că vrei să ștergi această cheltuială? Această acțiune este ireversibilă.",
"yes": "Da",
"cancel": "Anulează"
},
"attachDocuments": "Atașează documente",
"create": "Adaugă",
"creating": "Se adaugă…",
"save": "Salvează",
"saving": "Se salvează…",
"cancel": "Anulează",
"reimbursement": "Rambursare"
},
"ExpenseDocumentsInput": {
"TooBigToast": {
"title": "Fișierul este prea mare",
"description": "Dimensiunea maximă a fișierului pe care îl poți atașa este {maxSize}. Fișierul tău are ${size}."
},
"ErrorToast": {
"title": "Eroare la adăugarea documentului.",
"description": "Ceva a mers greșit la adăugarea fișierului. Încearcă mai târziu sau cum un alt fișier.",
"retry": "Reîncearcă"
}
},
"CreateFromReceipt": {
"Dialog": {
"triggerTitle": "Adaugă o cheltuială dintr-un bon",
"title": "Adaugă din bon",
"description": "Extrage informații despre cheltuială dintr-o poză cu bonul.",
"body": "Adaugă o poză cu bonul și vom încerca să o scanăm pentru a extrage informații despre cheltuială.",
"selectImage": "Selectează o imagine…",
"titleLabel": "Titlu:",
"categoryLabel": "Categorie:",
"amountLabel": "Sumă:",
"dateLabel": "Data:",
"editNext": "Vei putea sa modifici informațiile despre cheltuială în continuare.",
"continue": "Continuă"
},
"unknown": "Necunoscut",
"TooBigToast": {
"title": "Fișierul este prea mare",
"description": "Dimensiunea maximă a fișierului pe care il poți atașa este {maxSize}. Fișierul tău are ${size}."
},
"ErrorToast": {
"title": "Eroare la adăugarea documentului.",
"description": "Ceva a mers greșit la adăugarea fișierului. Încearcă mai târziu sau cum un alt fișier.",
"retry": "Reîncearcă"
}
},
"Balances": {
"title": "Solduri",
"description": "Aceasta este suma pe care fiecare membru a plătit-o sau cu care a fost plătit.",
"Reimbursements": {
"title": "Rambursări sugerate",
"description": "Acestea sunt sugestiile pentru rambursări optimizate între membrii.",
"noImbursements": "Se pare că grupul tău nu are nevoie de rambursări 😁",
"owes": "<strong>{from}</strong> datorează <strong>{to}</strong>",
"markAsPaid": "Bifează ca plătit"
}
},
"Stats": {
"title": "Statistici",
"Totals": {
"title": "Totaluri",
"description": "Sumarul cheltuielior pentru întregul grup.",
"groupSpendings": "Totalul cheltuielilor din grup",
"groupEarnings": "Totalul veniturilor din grup",
"yourSpendings": "Totalul cheltuielilor tale",
"yourEarnings": "Totalul veniturilor tale",
"yourShare": "Partea ta"
}
},
"Activity": {
"title": "Activități",
"description": "Rezumatul întregii activități a grupului.",
"noActivity": "Nu este nicio activitate în grupul tău încă.",
"someone": "Cineva",
"settingsModified": "Setările grupului au fost modificate de <strong>{participant}</strong>.",
"expenseCreated": "Cheltuială <em>{expense}</em> adăugată de <strong>{participant}</strong>.",
"expenseUpdated": "Cheltuială <em>{expense}</em> modificată de <strong>{participant}</strong>.",
"expenseDeleted": "Cheltuială <em>{expense}</em> ștearsă de <strong>{participant}</strong>.",
"Groups": {
"today": "Azi",
"yesterday": "Ieri",
"earlierThisWeek": "La începutul săptămânii",
"lastWeek": "Săptămâna trecută",
"earlierThisMonth": "La începutul lunii",
"lastMonth": "Luna trecuta",
"earlierThisYear": "La începutul anului",
"lastYear": "Anul trecut",
"older": "Mai vechi"
}
},
"Information": {
"title": "Informații",
"description": "Adaugă aici orice informație care poate să fie relevantă pentru membrii grupului.",
"empty": "Nicio informație de grup încă."
},
"Settings": {
"title": "Setări"
},
"Locale": {
"en-US": "English",
"fi": "Suomi",
"fr-FR": "Français",
"es": "Español",
"de-DE": "Deutsch",
"zh-CN": "Chinese (Simplified)",
"pl-PL": "Polski",
"ru-RU": "Русский",
"it-IT": "Italiano",
"ua-UA": "Українська",
"ro": "Română"
},
"Share": {
"title": "Distribuie",
"description": "Pentru ca ceilalți participanți să poată vedea grupul și cheltuielile adăugate, distribuie URL-ul acestuia cu ei.",
"warning": "Avertisment!",
"warningHelp": "Oricine are URL-ul grupului va putea să vadă și să editeze cheltuielile. Distribuie cu grijă!"
},
"SchemaErrors": {
"min1": "Introduceți cel puțin un caracter.",
"min2": "Introduceți cel puțin două caractere.",
"max5": "Introduceți cel mult cinci caractere.",
"max50": "Introduceți cel mult 50 de caractere.",
"duplicateParticipantName": "Un alt membru are deja acest nume.",
"titleRequired": "Vă rugăm să introduceți un titlu.",
"invalidNumber": "Număr invalid.",
"amountRequired": "Trebuie să introduceți o sumă.",
"amountNotZero": "Suma nu trebuie să fie zero.",
"amountTenMillion": "Suma trebuie să fie mai mică de 10,000,000.",
"paidByRequired": "Trebuie să selectați un membru.",
"paidForMin1": "Cheltuiala trebuie plătită pentru cel puțin un membru.",
"noZeroShares": "Toate părțile trebuie să fie mai mari de 0.",
"amountSum": "Suma valorilor trebuie să fie egală cu suma cheltuielilor.",
"percentageSum": "Suma procentajelor trebuie să fie egală cu 100."
},
"Categories": {
"search": "Căutați categorie…",
"noCategory": "Nicio categorie găsită.",
"Uncategorized": {
"heading": "Fără categorie",
"General": "General",
"Payment": "Plată"
},
"Entertainment": {
"heading": "Divertisment",
"Entertainment": "Divertisment",
"Games": "Jocuri",
"Movies": "Filme",
"Music": "Muzică",
"Sports": "Sporturi"
},
"Food and Drink": {
"heading": "Mâncare și Băutură",
"Food and Drink": "Mâncare și Băutură",
"Dining Out": "Cină în oraș",
"Groceries": "Alimente",
"Liquor": "Băuturi alcoolice"
},
"Home": {
"heading": "Acasă",
"Home": "Acasă",
"Electronics": "Electronice",
"Furniture": "Mobilier",
"Household Supplies": "Produse de uz casnic",
"Maintenance": "Întreținere",
"Mortgage": "Ipotecă",
"Pets": "Animale de companie",
"Rent": "Chirie",
"Services": "Servicii"
},
"Life": {
"heading": "Viață",
"Childcare": "Îngrijirea copiilor",
"Clothing": "Îmbrăcăminte",
"Education": "Educație",
"Gifts": "Cadouri",
"Insurance": "Asigurare",
"Medical Expenses": "Cheltuieli medicale",
"Taxes": "Impozite"
},
"Transportation": {
"heading": "Transport",
"Transportation": "Transport",
"Bicycle": "Bicicletă",
"Bus/Train": "Autobuz/Tren",
"Car": "Mașină",
"Gas/Fuel": "Gaz/Combustibil",
"Hotel": "Hotel",
"Parking": "Parcare",
"Plane": "Avion",
"Taxi": "Taxi"
},
"Utilities": {
"heading": "Utilități",
"Utilities": "Utilități",
"Cleaning": "Curățenie",
"Electricity": "Electricitate",
"Heat/Gas": "Încălzire/Gaz",
"Trash": "Gunoi",
"TV/Phone/Internet": "TV/Telefon/Internet",
"Water": "Apă"
}
}
}

View File

@@ -203,7 +203,8 @@
"creating": "Создание…",
"save": "Сохранить",
"saving": "Сохранение…",
"cancel": "Отмена"
"cancel": "Отмена",
"reimbursement": "Возмещение"
},
"ExpenseDocumentsInput": {
"TooBigToast": {
@@ -300,9 +301,11 @@
"es": "Español",
"de-DE": "Deutsch",
"zh-CN": "Chinese (Simplified)",
"pl-PL": "Polski",
"ru-RU": "Русский",
"it-IT": "Italiano",
"ua-UA": "Українська"
"ua-UA": "Українська",
"ro": "Română"
},
"Share": {
"title": "Поделиться",

View File

@@ -203,7 +203,8 @@
"creating": "Створення..",
"save": "Зберегти",
"saving": "Збереження..",
"cancel": "Скасувати"
"cancel": "Скасувати",
"reimbursement": "Відшкодування"
},
"ExpenseDocumentsInput": {
"TooBigToast": {
@@ -300,9 +301,11 @@
"es": "Español",
"de-DE": "Deutsch",
"zh-CN": "Chinese (Simplified)",
"pl-PL": "Polski",
"ru-RU": "Русский",
"it-IT": "Italiano",
"ua-UA": "Українська"
"ua-UA": "Українська",
"ro": "Română"
},
"Share": {
"title": "Поділитися",

View File

@@ -203,7 +203,8 @@
"creating": "创建中……",
"save": "保存",
"saving": "保存中……",
"cancel": "取消"
"cancel": "取消",
"reimbursement": "报销"
},
"ExpenseDocumentsInput": {
"TooBigToast": {
@@ -300,9 +301,11 @@
"es": "Español",
"de-DE": "Deutsch",
"zh-CN": "Chinese (Simplified)",
"pl-PL": "Polski",
"ru-RU": "Русский",
"it-IT": "Italiano",
"ua-UA": "Українська"
"ua-UA": "Українська",
"ro": "Română"
},
"Share": {
"title": "分享",

136
package-lock.json generated
View File

@@ -26,7 +26,12 @@
"@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-toast": "^1.1.5",
"@tailwindcss/typography": "^0.5.10",
"@tanstack/react-query": "^5.59.15",
"@trpc/client": "^11.0.0-rc.586",
"@trpc/react-query": "^11.0.0-rc.586",
"@trpc/server": "^11.0.0-rc.586",
"class-variance-authority": "^0.7.0",
"client-only": "^0.0.1",
"clsx": "^2.0.0",
"cmdk": "^0.2.0",
"content-disposition": "^0.5.4",
@@ -47,13 +52,16 @@
"react-dom": "^18.3.1",
"react-hook-form": "^7.47.0",
"react-intersection-observer": "^9.8.0",
"server-only": "^0.0.1",
"sharp": "^0.33.2",
"superjson": "^2.2.1",
"tailwind-merge": "^1.14.0",
"tailwindcss-animate": "^1.0.7",
"ts-pattern": "^5.0.6",
"use-debounce": "^10.0.4",
"uuid": "^9.0.1",
"vaul": "^0.8.0",
"zod": "^3.22.4"
"zod": "^3.23.8"
},
"devDependencies": {
"@testing-library/dom": "^10.4.0",
@@ -8962,6 +8970,32 @@
"node": ">=4"
}
},
"node_modules/@tanstack/query-core": {
"version": "5.59.13",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.59.13.tgz",
"integrity": "sha512-Oou0bBu/P8+oYjXsJQ11j+gcpLAMpqW42UlokQYEz4dE7+hOtVO9rVuolJKgEccqzvyFzqX4/zZWY+R/v1wVsQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/react-query": {
"version": "5.59.15",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.59.15.tgz",
"integrity": "sha512-QbVlAkTI78wB4Mqgf2RDmgC0AOiJqer2c5k9STOOSXGv1S6ZkY37r/6UpE8DbQ2Du0ohsdoXgFNEyv+4eDoPEw==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.59.13"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^18 || ^19"
}
},
"node_modules/@testing-library/dom": {
"version": "10.4.0",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz",
@@ -9063,6 +9097,43 @@
"integrity": "sha512-AqlrT8YA1o7Ff5wPfMOL0pvL+1X+sw60NN6CcOCqs658emD6RfiXhF7Gu9QcfKBH7ELY2nInLhKSCWVoNL70MQ==",
"dev": true
},
"node_modules/@trpc/client": {
"version": "11.0.0-rc.586",
"resolved": "https://registry.npmjs.org/@trpc/client/-/client-11.0.0-rc.586.tgz",
"integrity": "sha512-shCIpBzT+SzEbVXbCdpbSrPogG4c9J6hXh+xh5pidY1MTYcBHkeZVBLjy/fVSX+fB9wRoZXNaaoXO+ijYAZBcQ==",
"funding": [
"https://trpc.io/sponsor"
],
"license": "MIT",
"peerDependencies": {
"@trpc/server": "11.0.0-rc.586+3388c9691"
}
},
"node_modules/@trpc/react-query": {
"version": "11.0.0-rc.586",
"resolved": "https://registry.npmjs.org/@trpc/react-query/-/react-query-11.0.0-rc.586.tgz",
"integrity": "sha512-fYIo9Y9lM2tqTBY9NBT5ZPX4R++SaauOl6qjvnSwmIBupboiueLMMWfMh+cmJiAVim1Hg0OvgoS6WRFIYMlFYg==",
"funding": [
"https://trpc.io/sponsor"
],
"license": "MIT",
"peerDependencies": {
"@tanstack/react-query": "^5.59.15",
"@trpc/client": "11.0.0-rc.586+3388c9691",
"@trpc/server": "11.0.0-rc.586+3388c9691",
"react": ">=18.2.0",
"react-dom": ">=18.2.0"
}
},
"node_modules/@trpc/server": {
"version": "11.0.0-rc.586",
"resolved": "https://registry.npmjs.org/@trpc/server/-/server-11.0.0-rc.586.tgz",
"integrity": "sha512-G0713HRFYyBLjN58DYq88hTH4kfKNZt9GXR0/TkVD7rENpOUBk6LKorqSDQ0y0/8aqu11HdDHsn6vBTWK3D44Q==",
"funding": [
"https://trpc.io/sponsor"
],
"license": "MIT"
},
"node_modules/@tsconfig/node10": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz",
@@ -10788,6 +10859,21 @@
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"dev": true
},
"node_modules/copy-anything": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-3.0.5.tgz",
"integrity": "sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==",
"license": "MIT",
"dependencies": {
"is-what": "^4.1.8"
},
"engines": {
"node": ">=12.13"
},
"funding": {
"url": "https://github.com/sponsors/mesqueeb"
}
},
"node_modules/create-jest": {
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz",
@@ -13155,6 +13241,18 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-what": {
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/is-what/-/is-what-4.1.16.tgz",
"integrity": "sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==",
"license": "MIT",
"engines": {
"node": ">=12.13"
},
"funding": {
"url": "https://github.com/sponsors/mesqueeb"
}
},
"node_modules/isarray": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
@@ -16298,6 +16396,12 @@
"semver": "bin/semver.js"
}
},
"node_modules/server-only": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/server-only/-/server-only-0.0.1.tgz",
"integrity": "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==",
"license": "MIT"
},
"node_modules/set-function-length": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.1.1.tgz",
@@ -16811,6 +16915,18 @@
"node": ">=8"
}
},
"node_modules/superjson": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.1.tgz",
"integrity": "sha512-8iGv75BYOa0xRJHK5vRLEjE2H/i4lulTjzpUXic3Eg8akftYjkmQDa8JARQ42rlczXyFR3IeRoeFCc7RxHsYZA==",
"license": "MIT",
"dependencies": {
"copy-anything": "^3.0.2"
},
"engines": {
"node": ">=16"
}
},
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
@@ -17319,6 +17435,18 @@
}
}
},
"node_modules/use-debounce": {
"version": "10.0.4",
"resolved": "https://registry.npmjs.org/use-debounce/-/use-debounce-10.0.4.tgz",
"integrity": "sha512-6Cf7Yr7Wk7Kdv77nnJMf6de4HuDE4dTxKij+RqE9rufDsI6zsbjyAxcH5y2ueJCQAnfgKbzXbZHYlkFwmBlWkw==",
"license": "MIT",
"engines": {
"node": ">= 16.0.0"
},
"peerDependencies": {
"react": "*"
}
},
"node_modules/use-intl": {
"version": "3.17.2",
"resolved": "https://registry.npmjs.org/use-intl/-/use-intl-3.17.2.tgz",
@@ -17819,9 +17947,9 @@
}
},
"node_modules/zod": {
"version": "3.22.4",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.22.4.tgz",
"integrity": "sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==",
"version": "3.23.8",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz",
"integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"

View File

@@ -33,7 +33,12 @@
"@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-toast": "^1.1.5",
"@tailwindcss/typography": "^0.5.10",
"@tanstack/react-query": "^5.59.15",
"@trpc/client": "^11.0.0-rc.586",
"@trpc/react-query": "^11.0.0-rc.586",
"@trpc/server": "^11.0.0-rc.586",
"class-variance-authority": "^0.7.0",
"client-only": "^0.0.1",
"clsx": "^2.0.0",
"cmdk": "^0.2.0",
"content-disposition": "^0.5.4",
@@ -54,13 +59,16 @@
"react-dom": "^18.3.1",
"react-hook-form": "^7.47.0",
"react-intersection-observer": "^9.8.0",
"server-only": "^0.0.1",
"sharp": "^0.33.2",
"superjson": "^2.2.1",
"tailwind-merge": "^1.14.0",
"tailwindcss-animate": "^1.0.7",
"ts-pattern": "^5.0.6",
"use-debounce": "^10.0.4",
"uuid": "^9.0.1",
"vaul": "^0.8.0",
"zod": "^3.22.4"
"zod": "^3.23.8"
},
"devDependencies": {
"@testing-library/dom": "^10.4.0",

View File

@@ -0,0 +1,13 @@
import { createTRPCContext } from '@/trpc/init'
import { appRouter } from '@/trpc/routers/_app'
import { fetchRequestHandler } from '@trpc/server/adapters/fetch'
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: '/api/trpc',
req,
router: appRouter,
createContext: createTRPCContext,
})
export { handler as GET, handler as POST }

View File

@@ -1,18 +1,20 @@
'use client'
import { Button } from '@/components/ui/button'
import { getGroupExpenses } from '@/lib/api'
import { DateTimeStyle, cn, formatDate } from '@/lib/utils'
import { Activity, ActivityType, Participant } from '@prisma/client'
import { AppRouterOutput } from '@/trpc/routers/_app'
import { ActivityType, Participant } from '@prisma/client'
import { ChevronRight } from 'lucide-react'
import { useLocale, useTranslations } from 'next-intl'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
export type Activity =
AppRouterOutput['groups']['activities']['list']['activities'][number]
type Props = {
groupId: string
activity: Activity
participant?: Participant
expense?: Awaited<ReturnType<typeof getGroupExpenses>>[number]
dateStyle: DateTimeStyle
}
@@ -44,13 +46,12 @@ export function ActivityItem({
groupId,
activity,
participant,
expense,
dateStyle,
}: Props) {
const router = useRouter()
const locale = useLocale()
const expenseExists = expense !== undefined
const expenseExists = activity.expense !== undefined
const summary = useSummary(activity, participant?.name)
return (

View File

@@ -1,15 +1,16 @@
import { ActivityItem } from '@/app/groups/[groupId]/activity/activity-item'
import { getGroupExpenses } from '@/lib/api'
import { Activity, Participant } from '@prisma/client'
'use client'
import {
Activity,
ActivityItem,
} from '@/app/groups/[groupId]/activity/activity-item'
import { Skeleton } from '@/components/ui/skeleton'
import { trpc } from '@/trpc/client'
import dayjs, { type Dayjs } from 'dayjs'
import { useTranslations } from 'next-intl'
import { forwardRef, useEffect } from 'react'
import { useInView } from 'react-intersection-observer'
type Props = {
groupId: string
participants: Participant[]
expenses: Awaited<ReturnType<typeof getGroupExpenses>>
activities: Activity[]
}
const PAGE_SIZE = 20
const DATE_GROUPS = {
TODAY: 'today',
@@ -48,23 +49,64 @@ function getDateGroup(date: Dayjs, today: Dayjs) {
function getGroupedActivitiesByDate(activities: Activity[]) {
const today = dayjs()
return activities.reduce(
(result: { [key: string]: Activity[] }, activity: Activity) => {
(result, activity) => {
const activityGroup = getDateGroup(dayjs(activity.time), today)
result[activityGroup] = result[activityGroup] ?? []
result[activityGroup].push(activity)
return result
},
{},
{} as {
[key: string]: Activity[]
},
)
}
export function ActivityList({
groupId,
participants,
expenses,
activities,
}: Props) {
const ActivitiesLoading = forwardRef<HTMLDivElement>((_, ref) => {
return (
<div ref={ref} className="flex flex-col gap-4">
<Skeleton className="mt-2 h-3 w-24" />
{Array(5)
.fill(undefined)
.map((_, index) => (
<div key={index} className="flex gap-2 p-2">
<div className="flex-0">
<Skeleton className="h-3 w-12" />
</div>
<div className="flex-1">
<Skeleton className="h-3 w-48" />
</div>
</div>
))}
</div>
)
})
ActivitiesLoading.displayName = 'ActivitiesLoading'
export function ActivityList({ groupId }: { groupId: string }) {
const t = useTranslations('Activity')
const { data: groupData, isLoading: groupIsLoading } =
trpc.groups.get.useQuery({ groupId })
const {
data: activitiesData,
isLoading,
fetchNextPage,
} = trpc.groups.activities.list.useInfiniteQuery(
{ groupId, limit: PAGE_SIZE },
{ getNextPageParam: ({ nextCursor }) => nextCursor },
)
const { ref: loadingRef, inView } = useInView()
const activities = activitiesData?.pages.flatMap((page) => page.activities)
const hasMore = activitiesData?.pages.at(-1)?.hasMore ?? false
useEffect(() => {
if (inView && hasMore && !isLoading) fetchNextPage()
}, [fetchNextPage, hasMore, inView, isLoading])
if (isLoading || !activities || !groupData) return <ActivitiesLoading />
const groupedActivitiesByDate = getGroupedActivitiesByDate(activities)
return activities.length > 0 ? (
@@ -86,27 +128,29 @@ export function ActivityList({
>
{t(`Groups.${dateGroup}`)}
</div>
{groupActivities.map((activity: Activity) => {
{groupActivities.map((activity) => {
const participant =
activity.participantId !== null
? participants.find((p) => p.id === activity.participantId)
: undefined
const expense =
activity.expenseId !== null
? expenses.find((e) => e.id === activity.expenseId)
? groupData.group.participants.find(
(p) => p.id === activity.participantId,
)
: undefined
return (
<ActivityItem
key={activity.id}
{...{ groupId, activity, participant, expense, dateStyle }}
groupId={groupId}
activity={activity}
participant={participant}
dateStyle={dateStyle}
/>
)
})}
</div>
)
})}
{hasMore && <ActivitiesLoading ref={loadingRef} />}
</>
) : (
<p className="px-6 text-sm py-6">{t('noActivity')}</p>
<p className="text-sm py-6">{t('noActivity')}</p>
)
}

View File

@@ -0,0 +1,32 @@
import { ActivityList } from '@/app/groups/[groupId]/activity/activity-list'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Metadata } from 'next'
import { useTranslations } from 'next-intl'
export const metadata: Metadata = {
title: 'Activity',
}
export function ActivityPageClient({ groupId }: { groupId: string }) {
const t = useTranslations('Activity')
return (
<>
<Card className="mb-4">
<CardHeader>
<CardTitle>{t('title')}</CardTitle>
<CardDescription>{t('description')}</CardDescription>
</CardHeader>
<CardContent className="flex flex-col space-y-4">
<ActivityList groupId={groupId} />
</CardContent>
</Card>
</>
)
}

View File

@@ -1,16 +1,5 @@
import { cached } from '@/app/cached-functions'
import { ActivityList } from '@/app/groups/[groupId]/activity/activity-list'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { getActivities, getGroupExpenses } from '@/lib/api'
import { ActivityPageClient } from '@/app/groups/[groupId]/activity/page.client'
import { Metadata } from 'next'
import { getTranslations } from 'next-intl/server'
import { notFound } from 'next/navigation'
export const metadata: Metadata = {
title: 'Activity',
@@ -21,31 +10,5 @@ export default async function ActivityPage({
}: {
params: { groupId: string }
}) {
const t = await getTranslations('Activity')
const group = await cached.getGroup(groupId)
if (!group) notFound()
const expenses = await getGroupExpenses(groupId)
const activities = await getActivities(groupId)
return (
<>
<Card className="mb-4">
<CardHeader>
<CardTitle>{t('title')}</CardTitle>
<CardDescription>{t('description')}</CardDescription>
</CardHeader>
<CardContent className="flex flex-col space-y-4">
<ActivityList
{...{
groupId,
participants: group.participants,
expenses,
activities,
}}
/>
</CardContent>
</Card>
</>
)
return <ActivityPageClient groupId={groupId} />
}

View File

@@ -0,0 +1,143 @@
'use client'
import { BalancesList } from '@/app/groups/[groupId]/balances-list'
import { ReimbursementList } from '@/app/groups/[groupId]/reimbursement-list'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { trpc } from '@/trpc/client'
import { useTranslations } from 'next-intl'
import { Fragment, useEffect } from 'react'
export default function BalancesAndReimbursements({
groupId,
}: {
groupId: string
}) {
const utils = trpc.useUtils()
const { data: groupData, isLoading: groupIsLoading } =
trpc.groups.get.useQuery({ groupId })
const { data: balancesData, isLoading: balancesAreLoading } =
trpc.groups.balances.list.useQuery({
groupId,
})
const t = useTranslations('Balances')
useEffect(() => {
// Until we use tRPC more widely and can invalidate the cache on expense
// update, it's easier and safer to invalidate the cache on page load.
utils.groups.balances.invalidate()
}, [utils])
const isLoading =
balancesAreLoading || !balancesData || groupIsLoading || !groupData?.group
return (
<>
<Card className="mb-4">
<CardHeader>
<CardTitle>{t('title')}</CardTitle>
<CardDescription>{t('description')}</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<BalancesLoading
participantCount={groupData?.group.participants.length}
/>
) : (
<BalancesList
balances={balancesData.balances}
participants={groupData.group.participants}
currency={groupData.group.currency}
/>
)}
</CardContent>
</Card>
<Card className="mb-4">
<CardHeader>
<CardTitle>{t('Reimbursements.title')}</CardTitle>
<CardDescription>{t('Reimbursements.description')}</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<ReimbursementsLoading
participantCount={groupData?.group.participants.length}
/>
) : (
<ReimbursementList
reimbursements={balancesData.reimbursements}
participants={groupData.group.participants}
currency={groupData.group.currency}
groupId={groupData.group.id}
/>
)}
</CardContent>
</Card>
</>
)
}
const ReimbursementsLoading = ({
participantCount = 3,
}: {
participantCount?: number
}) => {
return (
<div className="flex flex-col">
{Array(participantCount - 1)
.fill(undefined)
.map((_, index) => (
<div key={index} className="flex justify-between py-5">
<div className="flex flex-col sm:flex-row gap-3 sm:gap-4">
<Skeleton className="h-3 w-32" />
<Skeleton className="h-3 w-24" />
</div>
<Skeleton className="h-3 w-16" />
</div>
))}
</div>
)
}
const BalancesLoading = ({
participantCount = 3,
}: {
participantCount?: number
}) => {
return (
<div className="grid grid-cols-2 py-1 gap-y-2">
{Array(participantCount)
.fill(undefined)
.map((_, index) =>
index % 2 === 0 ? (
<Fragment key={index}>
<div className="flex items-center justify-end pr-2">
<Skeleton className="h-3 w-16" />
</div>
<div className="self-start">
<Skeleton
className={`h-7 w-${(index % 3) + 1}/3 rounded-l-none`}
/>
</div>
</Fragment>
) : (
<Fragment key={index}>
<div className="flex items-center justify-end">
<Skeleton
className={`h-7 w-${(index % 3) + 1}/3 rounded-r-none`}
/>
</div>
<div className="flex items-center pl-2">
<Skeleton className="h-3 w-16" />
</div>
</Fragment>
),
)}
</div>
)
}

View File

@@ -1,21 +1,6 @@
import { cached } from '@/app/cached-functions'
import { BalancesList } from '@/app/groups/[groupId]/balances-list'
import { ReimbursementList } from '@/app/groups/[groupId]/reimbursement-list'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { getGroupExpenses } from '@/lib/api'
import {
getBalances,
getPublicBalances,
getSuggestedReimbursements,
} from '@/lib/balances'
import BalancesAndReimbursements from '@/app/groups/[groupId]/balances/balances-and-reimbursements'
import { Metadata } from 'next'
import { getTranslations } from 'next-intl/server'
import { notFound } from 'next/navigation'
export const metadata: Metadata = {
@@ -27,44 +12,8 @@ export default async function GroupPage({
}: {
params: { groupId: string }
}) {
const t = await getTranslations('Balances')
const group = await cached.getGroup(groupId)
if (!group) notFound()
const expenses = await getGroupExpenses(groupId)
const balances = getBalances(expenses)
const reimbursements = getSuggestedReimbursements(balances)
const publicBalances = getPublicBalances(reimbursements)
return (
<>
<Card className="mb-4">
<CardHeader>
<CardTitle>{t('title')}</CardTitle>
<CardDescription>{t('description')}</CardDescription>
</CardHeader>
<CardContent>
<BalancesList
balances={publicBalances}
participants={group.participants}
currency={group.currency}
/>
</CardContent>
</Card>
<Card className="mb-4">
<CardHeader>
<CardTitle>{t('Reimbursements.title')}</CardTitle>
<CardDescription>{t('Reimbursements.description')}</CardDescription>
</CardHeader>
<CardContent className="p-0">
<ReimbursementList
reimbursements={reimbursements}
participants={group.participants}
currency={group.currency}
groupId={groupId}
/>
</CardContent>
</Card>
</>
)
return <BalancesAndReimbursements groupId={groupId} />
}

View File

@@ -0,0 +1,23 @@
'use client'
import { GroupForm } from '@/components/group-form'
import { trpc } from '@/trpc/client'
export const EditGroup = ({ groupId }: { groupId: string }) => {
const { data, isLoading } = trpc.groups.get.useQuery({ groupId })
const { mutateAsync } = trpc.groups.update.useMutation()
const utils = trpc.useUtils()
if (isLoading) return <></>
return (
<GroupForm
group={data?.group}
onSubmit={async (groupFormValues, participantId) => {
await mutateAsync({ groupId, participantId, groupFormValues })
await utils.groups.invalidate()
}}
protectedParticipantIds={data?.participantsWithExpenses}
/>
)
}

View File

@@ -1,9 +1,5 @@
import { cached } from '@/app/cached-functions'
import { GroupForm } from '@/components/group-form'
import { getGroupExpensesParticipants, updateGroup } from '@/lib/api'
import { groupFormSchema } from '@/lib/schemas'
import { EditGroup } from '@/app/groups/[groupId]/edit/edit-group'
import { Metadata } from 'next'
import { notFound, redirect } from 'next/navigation'
export const metadata: Metadata = {
title: 'Settings',
@@ -14,22 +10,5 @@ export default async function EditGroupPage({
}: {
params: { groupId: string }
}) {
const group = await cached.getGroup(groupId)
if (!group) notFound()
async function updateGroupAction(values: unknown, participantId?: string) {
'use server'
const groupFormValues = groupFormSchema.parse(values)
const group = await updateGroup(groupId, groupFormValues, participantId)
redirect(`/groups/${group.id}`)
}
const protectedParticipantIds = await getGroupExpensesParticipants(groupId)
return (
<GroupForm
group={group}
onSubmit={updateGroupAction}
protectedParticipantIds={protectedParticipantIds}
/>
)
return <EditGroup groupId={groupId} />
}

View File

@@ -1,19 +1,9 @@
import { cached } from '@/app/cached-functions'
import { ExpenseForm } from '@/components/expense-form'
import {
deleteExpense,
getCategories,
getExpense,
updateExpense,
} from '@/lib/api'
import { EditExpenseForm } from '@/app/groups/[groupId]/expenses/edit-expense-form'
import { getRuntimeFeatureFlags } from '@/lib/featureFlags'
import { expenseFormSchema } from '@/lib/schemas'
import { Metadata } from 'next'
import { notFound, redirect } from 'next/navigation'
import { Suspense } from 'react'
export const metadata: Metadata = {
title: 'Edit expense',
title: 'Edit Expense',
}
export default async function EditExpensePage({
@@ -21,35 +11,11 @@ export default async function EditExpensePage({
}: {
params: { groupId: string; expenseId: string }
}) {
const categories = await getCategories()
const group = await cached.getGroup(groupId)
if (!group) notFound()
const expense = await getExpense(groupId, expenseId)
if (!expense) notFound()
async function updateExpenseAction(values: unknown, participantId?: string) {
'use server'
const expenseFormValues = expenseFormSchema.parse(values)
await updateExpense(groupId, expenseId, expenseFormValues, participantId)
redirect(`/groups/${groupId}`)
}
async function deleteExpenseAction(participantId?: string) {
'use server'
await deleteExpense(groupId, expenseId, participantId)
redirect(`/groups/${groupId}`)
}
return (
<Suspense>
<ExpenseForm
group={group}
expense={expense}
categories={categories}
onSubmit={updateExpenseAction}
onDelete={deleteExpenseAction}
runtimeFeatureFlags={await getRuntimeFeatureFlags()}
/>
</Suspense>
<EditExpenseForm
groupId={groupId}
expenseId={expenseId}
runtimeFeatureFlags={await getRuntimeFeatureFlags()}
/>
)
}

View File

@@ -18,22 +18,24 @@ import {
} from '@/components/ui/drawer'
import { Label } from '@/components/ui/label'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { getGroup } from '@/lib/api'
import { useMediaQuery } from '@/lib/hooks'
import { cn } from '@/lib/utils'
import { trpc } from '@/trpc/client'
import { AppRouterOutput } from '@/trpc/routers/_app'
import { useTranslations } from 'next-intl'
import { ComponentProps, useEffect, useState } from 'react'
type Props = {
group: NonNullable<Awaited<ReturnType<typeof getGroup>>>
}
export function ActiveUserModal({ group }: Props) {
export function ActiveUserModal({ groupId }: { groupId: string }) {
const t = useTranslations('Expenses.ActiveUserModal')
const [open, setOpen] = useState(false)
const isDesktop = useMediaQuery('(min-width: 768px)')
const { data: groupData } = trpc.groups.get.useQuery({ groupId })
const group = groupData?.group
useEffect(() => {
if (!group) return
const tempUser = localStorage.getItem(`newGroup-activeUser`)
const activeUser = localStorage.getItem(`${group.id}-activeUser`)
if (!tempUser && !activeUser) {
@@ -42,6 +44,8 @@ export function ActiveUserModal({ group }: Props) {
}, [group])
function updateOpen(open: boolean) {
if (!group) return
if (!open && !localStorage.getItem(`${group.id}-activeUser`)) {
localStorage.setItem(`${group.id}-activeUser`, 'None')
}
@@ -93,7 +97,10 @@ function ActiveUserForm({
group,
close,
className,
}: ComponentProps<'form'> & { group: Props['group']; close: () => void }) {
}: ComponentProps<'form'> & {
group?: AppRouterOutput['groups']['get']['group']
close: () => void
}) {
const t = useTranslations('Expenses.ActiveUserModal')
const [selected, setSelected] = useState('None')
@@ -101,6 +108,8 @@ function ActiveUserForm({
<form
className={cn('grid items-start gap-4', className)}
onSubmit={(event) => {
if (!group) return
event.preventDefault()
localStorage.setItem(`${group.id}-activeUser`, selected)
close()
@@ -114,7 +123,7 @@ function ActiveUserForm({
{t('nobody')}
</Label>
</div>
{group.participants.map((participant) => (
{group?.participants.map((participant) => (
<div key={participant.id} className="flex items-center space-x-2">
<RadioGroupItem value={participant.id} id={participant.id} />
<Label htmlFor={participant.id} className="flex-1">

View File

@@ -0,0 +1,45 @@
'use client'
import { RuntimeFeatureFlags } from '@/lib/featureFlags'
import { trpc } from '@/trpc/client'
import { useRouter } from 'next/navigation'
import { ExpenseForm } from './expense-form'
export function CreateExpenseForm({
groupId,
runtimeFeatureFlags,
}: {
groupId: string
expenseId?: string
runtimeFeatureFlags: RuntimeFeatureFlags
}) {
const { data: groupData } = trpc.groups.get.useQuery({ groupId })
const group = groupData?.group
const { data: categoriesData } = trpc.categories.list.useQuery()
const categories = categoriesData?.categories
const { mutateAsync: createExpenseMutateAsync } =
trpc.groups.expenses.create.useMutation()
const utils = trpc.useUtils()
const router = useRouter()
if (!group || !categories) return null
return (
<ExpenseForm
group={group}
categories={categories}
onSubmit={async (expenseFormValues, participantId) => {
await createExpenseMutateAsync({
groupId,
expenseFormValues,
participantId,
})
utils.groups.expenses.invalidate()
router.push(`/groups/${group.id}`)
}}
runtimeFeatureFlags={runtimeFeatureFlags}
/>
)
}

View File

@@ -27,7 +27,7 @@ import { ToastAction } from '@/components/ui/toast'
import { useToast } from '@/components/ui/use-toast'
import { useMediaQuery } from '@/lib/hooks'
import { formatCurrency, formatDate, formatFileSize } from '@/lib/utils'
import { Category } from '@prisma/client'
import { trpc } from '@/trpc/client'
import { ChevronRight, FileQuestion, Loader2, Receipt } from 'lucide-react'
import { useLocale, useTranslations } from 'next-intl'
import { getImageData, usePresignedUpload } from 'next-s3-upload'
@@ -35,19 +35,52 @@ import Image from 'next/image'
import { useRouter } from 'next/navigation'
import { PropsWithChildren, ReactNode, useState } from 'react'
type Props = {
groupId: string
groupCurrency: string
categories: Category[]
}
const MAX_FILE_SIZE = 5 * 1024 ** 2
export function CreateFromReceiptButton({
groupId,
groupCurrency,
categories,
}: Props) {
export function CreateFromReceiptButton({ groupId }: { groupId: string }) {
return <CreateFromReceiptButton_ groupId={groupId} />
}
function CreateFromReceiptButton_({ groupId }: { groupId: string }) {
const t = useTranslations('CreateFromReceipt')
const isDesktop = useMediaQuery('(min-width: 640px)')
const DialogOrDrawer = isDesktop
? CreateFromReceiptDialog
: CreateFromReceiptDrawer
return (
<DialogOrDrawer
trigger={
<Button
size="icon"
variant="secondary"
title={t('Dialog.triggerTitle')}
>
<Receipt className="w-4 h-4" />
</Button>
}
title={
<>
<span>{t('Dialog.title')}</span>
<Badge className="bg-pink-700 hover:bg-pink-600 dark:bg-pink-500 dark:hover:bg-pink-600">
Beta
</Badge>
</>
}
description={<>{t('Dialog.description')}</>}
>
<ReceiptDialogContent groupId={groupId} />
</DialogOrDrawer>
)
}
function ReceiptDialogContent({ groupId }: { groupId: string }) {
const { data: groupData } = trpc.groups.get.useQuery({ groupId })
const { data: categoriesData } = trpc.categories.list.useQuery()
const group = groupData?.group
const categories = categoriesData?.categories
const locale = useLocale()
const t = useTranslations('CreateFromReceipt')
const [pending, setPending] = useState(false)
@@ -58,7 +91,6 @@ export function CreateFromReceiptButton({
| null
| (ReceiptExtractedInfo & { url: string; width?: number; height?: number })
>(null)
const isDesktop = useMediaQuery('(min-width: 640px)')
const handleFileChange = async (file: File) => {
if (file.size > MAX_FILE_SIZE) {
@@ -107,160 +139,130 @@ export function CreateFromReceiptButton({
const receiptInfoCategory =
(receiptInfo?.categoryId &&
categories.find((c) => String(c.id) === receiptInfo.categoryId)) ||
categories?.find((c) => String(c.id) === receiptInfo.categoryId)) ||
null
const DialogOrDrawer = isDesktop
? CreateFromReceiptDialog
: CreateFromReceiptDrawer
return (
<DialogOrDrawer
trigger={
<Button
size="icon"
variant="secondary"
title={t('Dialog.triggerTitle')}
>
<Receipt className="w-4 h-4" />
</Button>
}
title={
<>
<span>{t('Dialog.title')}</span>
<Badge className="bg-pink-700 hover:bg-pink-600 dark:bg-pink-500 dark:hover:bg-pink-600">
Beta
</Badge>
</>
}
description={<>{t('Dialog.description')}</>}
>
<div className="prose prose-sm dark:prose-invert">
<p>{t('Dialog.body')}</p>
<div>
<FileInput
onChange={handleFileChange}
accept="image/jpeg,image/png"
/>
<div className="grid gap-x-4 gap-y-2 grid-cols-3">
<Button
variant="secondary"
className="row-span-3 w-full h-full relative"
title="Create expense from receipt"
onClick={openFileDialog}
disabled={pending}
>
{pending ? (
<Loader2 className="w-8 h-8 animate-spin" />
) : receiptInfo ? (
<div className="absolute top-2 left-2 bottom-2 right-2">
<Image
src={receiptInfo.url}
width={receiptInfo.width}
height={receiptInfo.height}
className="w-full h-full m-0 object-contain drop-shadow-lg"
alt="Scanned receipt"
/>
</div>
<div className="prose prose-sm dark:prose-invert">
<p>{t('Dialog.body')}</p>
<div>
<FileInput onChange={handleFileChange} accept="image/jpeg,image/png" />
<div className="grid gap-x-4 gap-y-2 grid-cols-3">
<Button
variant="secondary"
className="row-span-3 w-full h-full relative"
title="Create expense from receipt"
onClick={openFileDialog}
disabled={pending}
>
{pending ? (
<Loader2 className="w-8 h-8 animate-spin" />
) : receiptInfo ? (
<div className="absolute top-2 left-2 bottom-2 right-2">
<Image
src={receiptInfo.url}
width={receiptInfo.width}
height={receiptInfo.height}
className="w-full h-full m-0 object-contain drop-shadow-lg"
alt="Scanned receipt"
/>
</div>
) : (
<span className="text-xs sm:text-sm text-muted-foreground">
{t('Dialog.selectImage')}
</span>
)}
</Button>
<div className="col-span-2">
<strong>{t('Dialog.titleLabel')}</strong>
<div>{receiptInfo ? receiptInfo.title ?? <Unknown /> : '…'}</div>
</div>
<div className="col-span-2">
<strong>{t('Dialog.categoryLabel')}</strong>
<div>
{receiptInfo ? (
receiptInfoCategory ? (
<div className="flex items-center">
<CategoryIcon
category={receiptInfoCategory}
className="inline w-4 h-4 mr-2"
/>
<span className="mr-1">{receiptInfoCategory.grouping}</span>
<ChevronRight className="inline w-3 h-3 mr-1" />
<span>{receiptInfoCategory.name}</span>
</div>
) : (
<Unknown />
)
) : (
<span className="text-xs sm:text-sm text-muted-foreground">
{t('Dialog.selectImage')}
</span>
'' || '…'
)}
</Button>
<div className="col-span-2">
<strong>{t('Dialog.titleLabel')}</strong>
<div>{receiptInfo ? receiptInfo.title ?? <Unknown /> : '…'}</div>
</div>
<div className="col-span-2">
<strong>{t('Dialog.categoryLabel')}</strong>
<div>
{receiptInfo ? (
receiptInfoCategory ? (
<div className="flex items-center">
<CategoryIcon
category={receiptInfoCategory}
className="inline w-4 h-4 mr-2"
/>
<span className="mr-1">
{receiptInfoCategory.grouping}
</span>
<ChevronRight className="inline w-3 h-3 mr-1" />
<span>{receiptInfoCategory.name}</span>
</div>
) : (
<Unknown />
)
) : (
'' || '…'
)}
</div>
</div>
</div>
<div>
<strong>{t('Dialog.amountLabel')}</strong>
<div>
<strong>{t('Dialog.amountLabel')}</strong>
<div>
{receiptInfo ? (
receiptInfo.amount ? (
<>
{formatCurrency(
groupCurrency,
receiptInfo.amount,
locale,
true,
)}
</>
) : (
<Unknown />
)
) : (
'…'
)}
</div>
</div>
<div>
<strong>{t('Dialog.dateLabel')}</strong>
<div>
{receiptInfo ? (
receiptInfo.date ? (
formatDate(
new Date(`${receiptInfo?.date}T12:00:00.000Z`),
{receiptInfo && group ? (
receiptInfo.amount ? (
<>
{formatCurrency(
group.currency,
receiptInfo.amount,
locale,
{ dateStyle: 'medium' },
)
) : (
<Unknown />
true,
)}
</>
) : (
<Unknown />
)
) : (
'…'
)}
</div>
</div>
<div>
<strong>{t('Dialog.dateLabel')}</strong>
<div>
{receiptInfo ? (
receiptInfo.date ? (
formatDate(
new Date(`${receiptInfo?.date}T12:00:00.000Z`),
locale,
{ dateStyle: 'medium' },
)
) : (
'…'
)}
</div>
<Unknown />
)
) : (
'…'
)}
</div>
</div>
</div>
<p>{t('Dialog.editNext')}</p>
<div className="text-center">
<Button
disabled={pending || !receiptInfo}
onClick={() => {
if (!receiptInfo) return
router.push(
`/groups/${groupId}/expenses/create?amount=${
receiptInfo.amount
}&categoryId=${receiptInfo.categoryId}&date=${
receiptInfo.date
}&title=${encodeURIComponent(
receiptInfo.title ?? '',
)}&imageUrl=${encodeURIComponent(receiptInfo.url)}&imageWidth=${
receiptInfo.width
}&imageHeight=${receiptInfo.height}`,
)
}}
>
{t('Dialog.continue')}
</Button>
</div>
</div>
</DialogOrDrawer>
<p>{t('Dialog.editNext')}</p>
<div className="text-center">
<Button
disabled={pending || !receiptInfo}
onClick={() => {
if (!receiptInfo || !group) return
router.push(
`/groups/${group.id}/expenses/create?amount=${
receiptInfo.amount
}&categoryId=${receiptInfo.categoryId}&date=${
receiptInfo.date
}&title=${encodeURIComponent(
receiptInfo.title ?? '',
)}&imageUrl=${encodeURIComponent(receiptInfo.url)}&imageWidth=${
receiptInfo.width
}&imageHeight=${receiptInfo.height}`,
)
}}
>
{t('Dialog.continue')}
</Button>
</div>
</div>
)
}

View File

@@ -1,14 +1,9 @@
import { cached } from '@/app/cached-functions'
import { ExpenseForm } from '@/components/expense-form'
import { createExpense, getCategories } from '@/lib/api'
import { CreateExpenseForm } from '@/app/groups/[groupId]/expenses/create-expense-form'
import { getRuntimeFeatureFlags } from '@/lib/featureFlags'
import { expenseFormSchema } from '@/lib/schemas'
import { Metadata } from 'next'
import { notFound, redirect } from 'next/navigation'
import { Suspense } from 'react'
export const metadata: Metadata = {
title: 'Create expense',
title: 'Create Expense',
}
export default async function ExpensePage({
@@ -16,25 +11,10 @@ export default async function ExpensePage({
}: {
params: { groupId: string }
}) {
const categories = await getCategories()
const group = await cached.getGroup(groupId)
if (!group) notFound()
async function createExpenseAction(values: unknown, participantId?: string) {
'use server'
const expenseFormValues = expenseFormSchema.parse(values)
await createExpense(expenseFormValues, groupId, participantId)
redirect(`/groups/${groupId}`)
}
return (
<Suspense>
<ExpenseForm
group={group}
categories={categories}
onSubmit={createExpenseAction}
runtimeFeatureFlags={await getRuntimeFeatureFlags()}
/>
</Suspense>
<CreateExpenseForm
groupId={groupId}
runtimeFeatureFlags={await getRuntimeFeatureFlags()}
/>
)
}

View File

@@ -0,0 +1,65 @@
'use client'
import { RuntimeFeatureFlags } from '@/lib/featureFlags'
import { trpc } from '@/trpc/client'
import { useRouter } from 'next/navigation'
import { ExpenseForm } from './expense-form'
export function EditExpenseForm({
groupId,
expenseId,
runtimeFeatureFlags,
}: {
groupId: string
expenseId: string
runtimeFeatureFlags: RuntimeFeatureFlags
}) {
const { data: groupData } = trpc.groups.get.useQuery({ groupId })
const group = groupData?.group
const { data: categoriesData } = trpc.categories.list.useQuery()
const categories = categoriesData?.categories
const { data: expenseData } = trpc.groups.expenses.get.useQuery({
groupId,
expenseId,
})
const expense = expenseData?.expense
const { mutateAsync: updateExpenseMutateAsync } =
trpc.groups.expenses.update.useMutation()
const { mutateAsync: deleteExpenseMutateAsync } =
trpc.groups.expenses.delete.useMutation()
const utils = trpc.useUtils()
const router = useRouter()
if (!group || !categories || !expense) return null
return (
<ExpenseForm
group={group}
expense={expense}
categories={categories}
onSubmit={async (expenseFormValues, participantId) => {
await updateExpenseMutateAsync({
expenseId,
groupId,
expenseFormValues,
participantId,
})
utils.groups.expenses.invalidate()
router.push(`/groups/${group.id}`)
}}
onDelete={async (participantId) => {
await deleteExpenseMutateAsync({
expenseId,
groupId,
participantId,
})
utils.groups.expenses.invalidate()
router.push(`/groups/${group.id}`)
}}
runtimeFeatureFlags={runtimeFeatureFlags}
/>
)
}

View File

@@ -1,4 +1,3 @@
'use client'
import { CategorySelector } from '@/components/category-selector'
import { ExpenseDocumentsInput } from '@/components/expense-documents-input'
import { SubmitButton } from '@/components/submit-button'
@@ -33,7 +32,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { getCategories, getExpense, getGroup, randomId } from '@/lib/api'
import { randomId } from '@/lib/api'
import { RuntimeFeatureFlags } from '@/lib/featureFlags'
import { useActiveUser } from '@/lib/hooks'
import {
@@ -42,6 +41,7 @@ import {
expenseFormSchema,
} from '@/lib/schemas'
import { cn } from '@/lib/utils'
import { AppRouterOutput } from '@/trpc/routers/_app'
import { zodResolver } from '@hookform/resolvers/zod'
import { Save } from 'lucide-react'
import { useTranslations } from 'next-intl'
@@ -50,18 +50,9 @@ import { useSearchParams } from 'next/navigation'
import { useEffect, useState } from 'react'
import { useForm } from 'react-hook-form'
import { match } from 'ts-pattern'
import { DeletePopup } from './delete-popup'
import { extractCategoryFromTitle } from './expense-form-actions'
import { Textarea } from './ui/textarea'
export type Props = {
group: NonNullable<Awaited<ReturnType<typeof getGroup>>>
expense?: NonNullable<Awaited<ReturnType<typeof getExpense>>>
categories: NonNullable<Awaited<ReturnType<typeof getCategories>>>
onSubmit: (values: ExpenseFormValues, participantId?: string) => Promise<void>
onDelete?: (participantId?: string) => Promise<void>
runtimeFeatureFlags: RuntimeFeatureFlags
}
import { DeletePopup } from '../../../../components/delete-popup'
import { extractCategoryFromTitle } from '../../../../components/expense-form-actions'
import { Textarea } from '../../../../components/ui/textarea'
const enforceCurrencyPattern = (value: string) =>
value
@@ -72,7 +63,9 @@ const enforceCurrencyPattern = (value: string) =>
.replace(/#/, '.') // change back # to dot
.replace(/[^-\d.]/g, '') // remove all non-numeric characters
const getDefaultSplittingOptions = (group: Props['group']) => {
const getDefaultSplittingOptions = (
group: AppRouterOutput['groups']['get']['group'],
) => {
const defaultValue = {
splitMode: 'EVENLY' as const,
paidFor: group.participants.map(({ id }) => ({
@@ -146,15 +139,23 @@ async function persistDefaultSplittingOptions(
export function ExpenseForm({
group,
expense,
categories,
expense,
onSubmit,
onDelete,
runtimeFeatureFlags,
}: Props) {
}: {
group: AppRouterOutput['groups']['get']['group']
categories: AppRouterOutput['categories']['list']['categories']
expense?: AppRouterOutput['groups']['expenses']['get']['expense']
onSubmit: (value: ExpenseFormValues, participantId?: string) => Promise<void>
onDelete?: (participantId?: string) => Promise<void>
runtimeFeatureFlags: RuntimeFeatureFlags
}) {
const t = useTranslations('ExpenseForm')
const isCreate = expense === undefined
const searchParams = useSearchParams()
const getSelectedPayer = (field?: { value: string }) => {
if (isCreate && typeof window !== 'undefined') {
const activeUser = localStorage.getItem(`${group.id}-activeUser`)
@@ -186,7 +187,7 @@ export function ExpenseForm({
}
: searchParams.get('reimbursement')
? {
title: 'Reimbursement',
title: t('reimbursement'),
expenseDate: new Date(),
amount: String(
(Number(searchParams.get('amount')) || 0) / 100,

View File

@@ -4,26 +4,20 @@ import { getGroupExpensesAction } from '@/app/groups/[groupId]/expenses/expense-
import { Button } from '@/components/ui/button'
import { SearchBar } from '@/components/ui/search-bar'
import { Skeleton } from '@/components/ui/skeleton'
import { normalizeString } from '@/lib/utils'
import { Participant } from '@prisma/client'
import { trpc } from '@/trpc/client'
import dayjs, { type Dayjs } from 'dayjs'
import { useTranslations } from 'next-intl'
import Link from 'next/link'
import { useEffect, useMemo, useState } from 'react'
import { forwardRef, useEffect, useMemo, useState } from 'react'
import { useInView } from 'react-intersection-observer'
import { useDebounce } from 'use-debounce'
const PAGE_SIZE = 20
type ExpensesType = NonNullable<
Awaited<ReturnType<typeof getGroupExpensesAction>>
>
type Props = {
expensesFirstPage: ExpensesType
expenseCount: number
participants: Participant[]
currency: string
groupId: string
}
const EXPENSE_GROUPS = {
UPCOMING: 'upcoming',
THIS_WEEK: 'thisWeek',
@@ -62,24 +56,16 @@ function getGroupedExpensesByDate(expenses: ExpensesType) {
}, {})
}
export function ExpenseList({
expensesFirstPage,
expenseCount,
currency,
participants,
groupId,
}: Props) {
const firstLen = expensesFirstPage.length
export function ExpenseList({ groupId }: { groupId: string }) {
const { data: groupData } = trpc.groups.get.useQuery({ groupId })
const [searchText, setSearchText] = useState('')
const [dataIndex, setDataIndex] = useState(firstLen)
const [dataLen, setDataLen] = useState(firstLen)
const [hasMoreData, setHasMoreData] = useState(expenseCount > firstLen)
const [isFetching, setIsFetching] = useState(false)
const [expenses, setExpenses] = useState(expensesFirstPage)
const { ref, inView } = useInView()
const t = useTranslations('Expenses')
const [debouncedSearchText] = useDebounce(searchText, 300)
const participants = groupData?.group.participants
useEffect(() => {
if (!participants) return
const activeUser = localStorage.getItem('newGroup-activeUser')
const newUser = localStorage.getItem(`${groupId}-newUser`)
if (activeUser || newUser) {
@@ -98,57 +84,80 @@ export function ExpenseList({
}
}, [groupId, participants])
return (
<>
<SearchBar onValueChange={(value) => setSearchText(value)} />
<ExpenseListForSearch
groupId={groupId}
searchText={debouncedSearchText}
/>
</>
)
}
const ExpenseListForSearch = ({
groupId,
searchText,
}: {
groupId: string
searchText: string
}) => {
const utils = trpc.useUtils()
useEffect(() => {
const fetchNextPage = async () => {
setIsFetching(true)
// Until we use tRPC more widely and can invalidate the cache on expense
// update, it's easier and safer to invalidate the cache on page load.
utils.groups.expenses.invalidate()
}, [utils])
const newExpenses = await getGroupExpensesAction(groupId, {
offset: dataIndex,
length: dataLen,
})
const t = useTranslations('Expenses')
const { ref: loadingRef, inView } = useInView()
if (newExpenses !== null) {
const exp = expenses.concat(newExpenses)
setExpenses(exp)
setHasMoreData(exp.length < expenseCount)
setDataIndex(dataIndex + dataLen)
setDataLen(Math.ceil(1.5 * dataLen))
}
const {
data,
isLoading: expensesAreLoading,
fetchNextPage,
} = trpc.groups.expenses.list.useInfiniteQuery(
{ groupId, limit: PAGE_SIZE, filter: searchText },
{ getNextPageParam: ({ nextCursor }) => nextCursor },
)
const expenses = data?.pages.flatMap((page) => page.expenses)
const hasMore = data?.pages.at(-1)?.hasMore ?? false
setTimeout(() => setIsFetching(false), 500)
}
const { data: groupData, isLoading: groupIsLoading } =
trpc.groups.get.useQuery({ groupId })
if (inView && hasMoreData && !isFetching) fetchNextPage()
}, [
dataIndex,
dataLen,
expenseCount,
expenses,
groupId,
hasMoreData,
inView,
isFetching,
])
const isLoading =
expensesAreLoading || !expenses || groupIsLoading || !groupData
useEffect(() => {
if (inView && hasMore && !isLoading) fetchNextPage()
}, [fetchNextPage, hasMore, inView, isLoading])
const groupedExpensesByDate = useMemo(
() => getGroupedExpensesByDate(expenses),
() => (expenses ? getGroupedExpensesByDate(expenses) : {}),
[expenses],
)
return expenses.length > 0 ? (
if (isLoading) return <ExpensesLoading />
if (expenses.length === 0)
return (
<p className="px-6 text-sm py-6">
{t('noExpenses')}{' '}
<Button variant="link" asChild className="-m-4">
<Link href={`/groups/${groupId}/expenses/create`}>
{t('createFirst')}
</Link>
</Button>
</p>
)
return (
<>
<SearchBar
onValueChange={(value) => setSearchText(normalizeString(value))}
/>
{Object.values(EXPENSE_GROUPS).map((expenseGroup: string) => {
let groupExpenses = groupedExpensesByDate[expenseGroup]
if (!groupExpenses) return null
groupExpenses = groupExpenses.filter(({ title }) =>
normalizeString(title).includes(searchText),
)
if (groupExpenses.length === 0) return null
if (!groupExpenses || groupExpenses.length === 0) return null
return (
<div key={expenseGroup}>
@@ -163,38 +172,41 @@ export function ExpenseList({
<ExpenseCard
key={expense.id}
expense={expense}
currency={currency}
currency={groupData.group.currency}
groupId={groupId}
/>
))}
</div>
)
})}
{expenses.length < expenseCount &&
[0, 1, 2].map((i) => (
<div
key={i}
className="border-t flex justify-between items-center px-6 py-4 text-sm"
ref={i === 0 ? ref : undefined}
>
<div className="flex flex-col gap-2">
<Skeleton className="h-4 w-16 rounded-full" />
<Skeleton className="h-4 w-32 rounded-full" />
</div>
<div>
<Skeleton className="h-4 w-16 rounded-full" />
</div>
</div>
))}
{hasMore && <ExpensesLoading ref={loadingRef} />}
</>
) : (
<p className="px-6 text-sm py-6">
{t('noExpenses')}{' '}
<Button variant="link" asChild className="-m-4">
<Link href={`/groups/${groupId}/expenses/create`}>
{t('createFirst')}
</Link>
</Button>
</p>
)
}
const ExpensesLoading = forwardRef<HTMLDivElement>((_, ref) => {
return (
<div ref={ref}>
<Skeleton className="mx-4 sm:mx-6 mt-1 mb-2 h-3 w-32 rounded-full" />
{[0, 1, 2].map((i) => (
<div
key={i}
className="flex justify-between items-start px-2 sm:px-6 py-4 text-sm gap-2"
>
<div className="flex-0 pl-2 pr-1">
<Skeleton className="h-4 w-4 rounded-full" />
</div>
<div className="flex-1 flex flex-col gap-2">
<Skeleton className="h-4 w-16 rounded-full" />
<Skeleton className="h-4 w-32 rounded-full" />
</div>
<div className="flex-0 flex flex-col gap-2 items-end mr-2 sm:mr-12">
<Skeleton className="h-4 w-16 rounded-full" />
<Skeleton className="h-4 w-20 rounded-full" />
</div>
</div>
))}
</div>
)
})
ExpensesLoading.displayName = 'ExpensesLoading'

View File

@@ -0,0 +1,75 @@
'use client'
import { ActiveUserModal } from '@/app/groups/[groupId]/expenses/active-user-modal'
import { CreateFromReceiptButton } from '@/app/groups/[groupId]/expenses/create-from-receipt-button'
import { ExpenseList } from '@/app/groups/[groupId]/expenses/expense-list'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Download, Plus } from 'lucide-react'
import { Metadata } from 'next'
import { useTranslations } from 'next-intl'
import Link from 'next/link'
export const revalidate = 3600
export const metadata: Metadata = {
title: 'Expenses',
}
export default function GroupExpensesPageClient({
groupId,
enableReceiptExtract,
}: {
groupId: string
enableReceiptExtract: boolean
}) {
const t = useTranslations('Expenses')
return (
<>
<Card className="mb-4 rounded-none -mx-4 border-x-0 sm:border-x sm:rounded-lg sm:mx-0">
<div className="flex flex-1">
<CardHeader className="flex-1 p-4 sm:p-6">
<CardTitle>{t('title')}</CardTitle>
<CardDescription>{t('description')}</CardDescription>
</CardHeader>
<CardHeader className="p-4 sm:p-6 flex flex-row space-y-0 gap-2">
<Button variant="secondary" size="icon" asChild>
<Link
prefetch={false}
href={`/groups/${groupId}/expenses/export/json`}
target="_blank"
title={t('exportJson')}
>
<Download className="w-4 h-4" />
</Link>
</Button>
{enableReceiptExtract && (
<CreateFromReceiptButton groupId={groupId} />
)}
<Button asChild size="icon">
<Link
href={`/groups/${groupId}/expenses/create`}
title={t('create')}
>
<Plus className="w-4 h-4" />
</Link>
</Button>
</CardHeader>
</div>
<CardContent className="p-0 pt-2 pb-4 sm:pb-6 flex flex-col gap-4 relative">
<ExpenseList groupId={groupId} />
</CardContent>
</Card>
<ActiveUserModal groupId={groupId} />
</>
)
}

View File

@@ -1,28 +1,6 @@
import { cached } from '@/app/cached-functions'
import { ActiveUserModal } from '@/app/groups/[groupId]/expenses/active-user-modal'
import { CreateFromReceiptButton } from '@/app/groups/[groupId]/expenses/create-from-receipt-button'
import { ExpenseList } from '@/app/groups/[groupId]/expenses/expense-list'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import {
getCategories,
getGroupExpenseCount,
getGroupExpenses,
} from '@/lib/api'
import GroupExpensesPageClient from '@/app/groups/[groupId]/expenses/page.client'
import { env } from '@/lib/env'
import { Download, Plus } from 'lucide-react'
import { Metadata } from 'next'
import { getTranslations } from 'next-intl/server'
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { Suspense } from 'react'
export const revalidate = 3600
@@ -35,95 +13,10 @@ export default async function GroupExpensesPage({
}: {
params: { groupId: string }
}) {
const t = await getTranslations('Expenses')
const group = await cached.getGroup(groupId)
if (!group) notFound()
const categories = await getCategories()
return (
<>
<Card className="mb-4 rounded-none -mx-4 border-x-0 sm:border-x sm:rounded-lg sm:mx-0">
<div className="flex flex-1">
<CardHeader className="flex-1 p-4 sm:p-6">
<CardTitle>{t('title')}</CardTitle>
<CardDescription>{t('description')}</CardDescription>
</CardHeader>
<CardHeader className="p-4 sm:p-6 flex flex-row space-y-0 gap-2">
<Button variant="secondary" size="icon" asChild>
<Link
prefetch={false}
href={`/groups/${groupId}/expenses/export/json`}
target="_blank"
title={t('exportJson')}
>
<Download className="w-4 h-4" />
</Link>
</Button>
{env.NEXT_PUBLIC_ENABLE_RECEIPT_EXTRACT && (
<CreateFromReceiptButton
groupId={groupId}
groupCurrency={group.currency}
categories={categories}
/>
)}
<Button asChild size="icon">
<Link
href={`/groups/${groupId}/expenses/create`}
title={t('create')}
>
<Plus className="w-4 h-4" />
</Link>
</Button>
</CardHeader>
</div>
<CardContent className="p-0 pt-2 pb-4 sm:pb-6 flex flex-col gap-4 relative">
<Suspense
fallback={[0, 1, 2].map((i) => (
<div
key={i}
className="border-t flex justify-between items-center px-6 py-4 text-sm"
>
<div className="flex flex-col gap-2">
<Skeleton className="h-4 w-16 rounded-full" />
<Skeleton className="h-4 w-32 rounded-full" />
</div>
<div>
<Skeleton className="h-4 w-16 rounded-full" />
</div>
</div>
))}
>
<Expenses group={group} />
</Suspense>
</CardContent>
</Card>
<ActiveUserModal group={group} />
</>
)
}
type Props = {
group: NonNullable<Awaited<ReturnType<typeof cached.getGroup>>>
}
async function Expenses({ group }: Props) {
const expenseCount = await getGroupExpenseCount(group.id)
const expenses = await getGroupExpenses(group.id, {
offset: 0,
length: 200,
})
return (
<ExpenseList
expensesFirstPage={expenses}
expenseCount={expenseCount}
groupId={group.id}
currency={group.currency}
participants={group.participants}
<GroupExpensesPageClient
groupId={groupId}
enableReceiptExtract={env.NEXT_PUBLIC_ENABLE_RECEIPT_EXTRACT}
/>
)
}

View File

@@ -0,0 +1,30 @@
'use client'
import { GroupTabs } from '@/app/groups/[groupId]/group-tabs'
import { ShareButton } from '@/app/groups/[groupId]/share-button'
import { Skeleton } from '@/components/ui/skeleton'
import { trpc } from '@/trpc/client'
import Link from 'next/link'
export const GroupHeader = ({ groupId }: { groupId: string }) => {
const { data, isLoading } = trpc.groups.get.useQuery({ groupId })
return (
<div className="flex flex-col justify-between gap-3">
<h1 className="font-bold text-2xl">
<Link href={`/groups/${groupId}`}>
{isLoading || !data ? (
<Skeleton className="mt-1.5 mb-1.5 h-5 w-32" />
) : (
<div className="flex">{data.group.name}</div>
)}
</Link>
</h1>
<div className="flex gap-2 justify-between">
<GroupTabs groupId={groupId} />
{data?.group && <ShareButton group={data.group} />}
</div>
</div>
)
}

View File

@@ -0,0 +1,52 @@
'use client'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { trpc } from '@/trpc/client'
import { Pencil } from 'lucide-react'
import { useTranslations } from 'next-intl'
import Link from 'next/link'
export default function GroupInformation({ groupId }: { groupId: string }) {
const t = useTranslations('Information')
const { data, isLoading } = trpc.groups.get.useQuery({ groupId })
return (
<>
<Card className="mb-4">
<CardHeader>
<CardTitle className="flex justify-between">
<span>{t('title')}</span>
<Button size="icon" asChild className="-mb-12">
<Link href={`/groups/${groupId}/edit`}>
<Pencil className="w-4 h-4" />
</Link>
</Button>
</CardTitle>
<CardDescription className="mr-12">
{t('description')}
</CardDescription>
</CardHeader>
<CardContent className="prose prose-sm sm:prose-base max-w-full whitespace-break-spaces">
{isLoading || !data ? (
<div className="py-1 flex flex-col gap-2">
<Skeleton className="h-3 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</div>
) : data.group.information ? (
<p className="text-foreground">{data.group.information}</p>
) : (
<p className="text-muted-foreground text-sm">{t('empty')}</p>
)}
</CardContent>
</Card>
</>
)
}

View File

@@ -1,54 +1,14 @@
import { cached } from '@/app/cached-functions'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Pencil } from 'lucide-react'
import GroupInformation from '@/app/groups/[groupId]/information/group-information'
import { Metadata } from 'next'
import { getTranslations } from 'next-intl/server'
import Link from 'next/link'
import { notFound } from 'next/navigation'
export const metadata: Metadata = {
title: 'Totals',
title: 'Group Information',
}
export default async function InformationPage({
export default function InformationPage({
params: { groupId },
}: {
params: { groupId: string }
}) {
const group = await cached.getGroup(groupId)
if (!group) notFound()
const t = await getTranslations('Information')
return (
<>
<Card className="mb-4">
<CardHeader>
<CardTitle className="flex justify-between">
<span>{t('title')}</span>
<Button size="icon" asChild className="-mb-12">
<Link href={`/groups/${groupId}/edit`}>
<Pencil className="w-4 h-4" />
</Link>
</Button>
</CardTitle>
<CardDescription className="mr-12">
{t('description')}
</CardDescription>
</CardHeader>
<CardContent className="prose prose-sm sm:prose-base max-w-full whitespace-break-spaces">
{group.information || (
<p className="text-muted-foreground italic">{t('empty')}</p>
)}
</CardContent>
</Card>
</>
)
return <GroupInformation groupId={groupId} />
}

View File

@@ -1,11 +1,9 @@
import { cached } from '@/app/cached-functions'
import { GroupTabs } from '@/app/groups/[groupId]/group-tabs'
import { GroupHeader } from '@/app/groups/[groupId]/group-header'
import { SaveGroupLocally } from '@/app/groups/[groupId]/save-recent-group'
import { ShareButton } from '@/app/groups/[groupId]/share-button'
import { Metadata } from 'next'
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { PropsWithChildren, Suspense } from 'react'
import { PropsWithChildren } from 'react'
type Props = {
params: {
@@ -35,18 +33,7 @@ export default async function GroupLayout({
return (
<>
<div className="flex flex-col justify-between gap-3">
<h1 className="font-bold text-2xl">
<Link href={`/groups/${groupId}`}>{group.name}</Link>
</h1>
<div className="flex gap-2 justify-between">
<Suspense>
<GroupTabs groupId={groupId} />
</Suspense>
<ShareButton group={group} />
</div>
</div>
<GroupHeader groupId={groupId} />
{children}

View File

@@ -21,14 +21,14 @@ export function ReimbursementList({
const locale = useLocale()
const t = useTranslations('Balances.Reimbursements')
if (reimbursements.length === 0) {
return <p className="px-6 text-sm pb-6">{t('noImbursements')}</p>
return <p className="text-sm pb-6">{t('noImbursements')}</p>
}
const getParticipant = (id: string) => participants.find((p) => p.id === id)
return (
<div className="text-sm">
{reimbursements.map((reimbursement, index) => (
<div className="border-t px-6 py-4 flex justify-between" key={index}>
<div className="py-4 flex justify-between" key={index}>
<div className="flex flex-col gap-1 items-start sm:flex-row sm:items-baseline sm:gap-4">
<div>
{t.rich('owes', {

View File

@@ -0,0 +1,27 @@
import { Totals } from '@/app/groups/[groupId]/stats/totals'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { useTranslations } from 'next-intl'
export function TotalsPageClient({ groupId }: { groupId: string }) {
const t = useTranslations('Stats')
return (
<>
<Card className="mb-4">
<CardHeader>
<CardTitle>{t('Totals.title')}</CardTitle>
<CardDescription>{t('Totals.description')}</CardDescription>
</CardHeader>
<CardContent className="flex flex-col space-y-4">
<Totals groupId={groupId} />
</CardContent>
</Card>
</>
)
}

View File

@@ -1,17 +1,5 @@
import { cached } from '@/app/cached-functions'
import { Totals } from '@/app/groups/[groupId]/stats/totals'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { getGroupExpenses } from '@/lib/api'
import { getTotalGroupSpending } from '@/lib/totals'
import { TotalsPageClient } from '@/app/groups/[groupId]/stats/page.client'
import { Metadata } from 'next'
import { getTranslations } from 'next-intl/server'
import { notFound } from 'next/navigation'
export const metadata: Metadata = {
title: 'Totals',
@@ -22,28 +10,5 @@ export default async function TotalsPage({
}: {
params: { groupId: string }
}) {
const t = await getTranslations('Stats')
const group = await cached.getGroup(groupId)
if (!group) notFound()
const expenses = await getGroupExpenses(groupId)
const totalGroupSpendings = getTotalGroupSpending(expenses)
return (
<>
<Card className="mb-4">
<CardHeader>
<CardTitle>{t('Totals.title')}</CardTitle>
<CardDescription>{t('Totals.description')}</CardDescription>
</CardHeader>
<CardContent className="flex flex-col space-y-4">
<Totals
group={group}
expenses={expenses}
totalGroupSpendings={totalGroupSpendings}
/>
</CardContent>
</Card>
</>
)
return <TotalsPageClient groupId={groupId} />
}

View File

@@ -1,30 +1,16 @@
'use client'
import { getGroup, getGroupExpenses } from '@/lib/api'
import { getTotalActiveUserShare } from '@/lib/totals'
import { cn, formatCurrency } from '@/lib/utils'
import { useLocale, useTranslations } from 'next-intl'
import { useEffect, useState } from 'react'
type Props = {
group: NonNullable<Awaited<ReturnType<typeof getGroup>>>
expenses: NonNullable<Awaited<ReturnType<typeof getGroupExpenses>>>
}
export function TotalsYourShare({ group, expenses }: Props) {
export function TotalsYourShare({
totalParticipantShare = 0,
currency,
}: {
totalParticipantShare?: number
currency: string
}) {
const locale = useLocale()
const t = useTranslations('Stats.Totals')
const [activeUser, setActiveUser] = useState('')
useEffect(() => {
const activeUser = localStorage.getItem(`${group.id}-activeUser`)
if (activeUser) setActiveUser(activeUser)
}, [group, expenses])
const totalActiveUserShare =
activeUser === '' || activeUser === 'None'
? 0
: getTotalActiveUserShare(activeUser, expenses)
const currency = group.currency
return (
<div>
@@ -32,10 +18,10 @@ export function TotalsYourShare({ group, expenses }: Props) {
<div
className={cn(
'text-lg',
totalActiveUserShare < 0 ? 'text-green-600' : 'text-red-600',
totalParticipantShare < 0 ? 'text-green-600' : 'text-red-600',
)}
>
{formatCurrency(currency, Math.abs(totalActiveUserShare), locale)}
{formatCurrency(currency, Math.abs(totalParticipantShare), locale)}
</div>
</div>
)

View File

@@ -1,26 +1,19 @@
'use client'
import { getGroup, getGroupExpenses } from '@/lib/api'
import { useActiveUser } from '@/lib/hooks'
import { getTotalActiveUserPaidFor } from '@/lib/totals'
import { cn, formatCurrency } from '@/lib/utils'
import { useLocale, useTranslations } from 'next-intl'
type Props = {
group: NonNullable<Awaited<ReturnType<typeof getGroup>>>
expenses: NonNullable<Awaited<ReturnType<typeof getGroupExpenses>>>
}
export function TotalsYourSpendings({ group, expenses }: Props) {
export function TotalsYourSpendings({
totalParticipantSpendings = 0,
currency,
}: {
totalParticipantSpendings?: number
currency: string
}) {
const locale = useLocale()
const t = useTranslations('Stats.Totals')
const activeUser = useActiveUser(group.id)
const totalYourSpendings =
activeUser === '' || activeUser === 'None'
? 0
: getTotalActiveUserPaidFor(activeUser, expenses)
const currency = group.currency
const balance = totalYourSpendings < 0 ? 'yourEarnings' : 'yourSpendings'
const balance =
totalParticipantSpendings < 0 ? 'yourEarnings' : 'yourSpendings'
return (
<div>
@@ -29,10 +22,10 @@ export function TotalsYourSpendings({ group, expenses }: Props) {
<div
className={cn(
'text-lg',
totalYourSpendings < 0 ? 'text-green-600' : 'text-red-600',
totalParticipantSpendings < 0 ? 'text-green-600' : 'text-red-600',
)}
>
{formatCurrency(currency, Math.abs(totalYourSpendings), locale)}
{formatCurrency(currency, Math.abs(totalParticipantSpendings), locale)}
</div>
</div>
)

View File

@@ -2,20 +2,36 @@
import { TotalsGroupSpending } from '@/app/groups/[groupId]/stats/totals-group-spending'
import { TotalsYourShare } from '@/app/groups/[groupId]/stats/totals-your-share'
import { TotalsYourSpendings } from '@/app/groups/[groupId]/stats/totals-your-spending'
import { getGroup, getGroupExpenses } from '@/lib/api'
import { Skeleton } from '@/components/ui/skeleton'
import { useActiveUser } from '@/lib/hooks'
import { trpc } from '@/trpc/client'
export function Totals({
group,
expenses,
totalGroupSpendings,
}: {
group: NonNullable<Awaited<ReturnType<typeof getGroup>>>
expenses: NonNullable<Awaited<ReturnType<typeof getGroupExpenses>>>
totalGroupSpendings: number
}) {
const activeUser = useActiveUser(group.id)
console.log('activeUser', activeUser)
export function Totals({ groupId }: { groupId: string }) {
const activeUser = useActiveUser(groupId)
const participantId =
activeUser && activeUser !== 'None' ? activeUser : undefined
const { data } = trpc.groups.stats.get.useQuery({ groupId, participantId })
const { data: groupData } = trpc.groups.get.useQuery({ groupId })
if (!data || !groupData)
return (
<div className="flex flex-col gap-7">
{[0, 1, 2].map((index) => (
<div key={index}>
<Skeleton className="mt-1 h-3 w-48" />
<Skeleton className="mt-3 h-4 w-20" />
</div>
))}
</div>
)
const {
totalGroupSpendings,
totalParticipantShare,
totalParticipantSpendings,
} = data
const { group } = groupData
return (
<>
@@ -23,10 +39,16 @@ export function Totals({
totalGroupSpendings={totalGroupSpendings}
currency={group.currency}
/>
{activeUser && activeUser !== 'None' && (
{participantId && (
<>
<TotalsYourSpendings group={group} expenses={expenses} />
<TotalsYourShare group={group} expenses={expenses} />
<TotalsYourSpendings
totalParticipantSpendings={totalParticipantSpendings}
currency={group.currency}
/>
<TotalsYourShare
totalParticipantShare={totalParticipantShare}
currency={group.currency}
/>
</>
)}
</>

View File

@@ -0,0 +1,21 @@
'use client'
import { GroupForm } from '@/components/group-form'
import { trpc } from '@/trpc/client'
import { useRouter } from 'next/navigation'
export const CreateGroup = () => {
const { mutateAsync } = trpc.groups.create.useMutation()
const utils = trpc.useUtils()
const router = useRouter()
return (
<GroupForm
onSubmit={async (groupFormValues) => {
const { groupId } = await mutateAsync({ groupFormValues })
await utils.groups.invalidate()
router.push(`/groups/${groupId}`)
}}
/>
)
}

View File

@@ -1,15 +1,10 @@
import { GroupForm } from '@/components/group-form'
import { createGroup } from '@/lib/api'
import { groupFormSchema } from '@/lib/schemas'
import { redirect } from 'next/navigation'
import { CreateGroup } from '@/app/groups/create/create-group'
import { Metadata } from 'next'
export const metadata: Metadata = {
title: 'Create Group',
}
export default function CreateGroupPage() {
async function createGroupAction(values: unknown) {
'use server'
const groupFormValues = groupFormSchema.parse(values)
const group = await createGroup(groupFormValues)
redirect(`/groups/${group.id}`)
}
return <GroupForm onSubmit={createGroupAction} />
return <CreateGroup />
}

View File

@@ -6,6 +6,7 @@ import { ThemeToggle } from '@/components/theme-toggle'
import { Button } from '@/components/ui/button'
import { Toaster } from '@/components/ui/toaster'
import { env } from '@/lib/env'
import { TRPCProvider } from '@/trpc/client'
import type { Metadata, Viewport } from 'next'
import { NextIntlClientProvider, useTranslations } from 'next-intl'
import { getLocale, getMessages } from 'next-intl/server'
@@ -65,7 +66,7 @@ export const viewport: Viewport = {
function Content({ children }: { children: React.ReactNode }) {
const t = useTranslations()
return (
<>
<TRPCProvider>
<header className="fixed top-0 left-0 right-0 h-16 flex justify-between bg-white dark:bg-gray-950 bg-opacity-50 dark:bg-opacity-50 p-2 border-b backdrop-blur-sm z-50">
<Link
className="flex items-center gap-2 hover:scale-105 transition-transform"
@@ -142,7 +143,7 @@ function Content({ children }: { children: React.ReactNode }) {
</div>
</footer>
<Toaster />
</>
</TRPCProvider>
)
}

View File

@@ -1,5 +1,3 @@
'use client'
import { Trash2 } from 'lucide-react'
import { useTranslations } from 'next-intl'
import { AsyncButton } from './async-button'

View File

@@ -1,4 +1,3 @@
'use client'
import { SubmitButton } from '@/components/submit-button'
import { Button } from '@/components/ui/button'
import {
@@ -175,7 +174,7 @@ export function GroupForm({
<FormLabel>{t('InformationField.label')}</FormLabel>
<FormControl>
<Textarea
rows={10}
rows={2}
className="text-base"
{...field}
placeholder={t('InformationField.placeholder')}

View File

@@ -11,6 +11,7 @@ export const locales = [
'ru-RU',
'it-IT',
'ua-UA',
'ro',
] as const
export type Locale = (typeof locales)[number]
export type Locales = ReadonlyArray<Locale>

View File

@@ -267,7 +267,7 @@ export async function getCategories() {
export async function getGroupExpenses(
groupId: string,
options?: { offset: number; length: number },
options?: { offset?: number; length?: number; filter?: string },
) {
return prisma.expense.findMany({
select: {
@@ -287,7 +287,12 @@ export async function getGroupExpenses(
splitMode: true,
title: true,
},
where: { groupId },
where: {
groupId,
title: options?.filter
? { contains: options.filter, mode: 'insensitive' }
: undefined,
},
orderBy: [{ expenseDate: 'desc' }, { createdAt: 'desc' }],
skip: options && options.offset,
take: options && options.length,
@@ -305,11 +310,34 @@ export async function getExpense(groupId: string, expenseId: string) {
})
}
export async function getActivities(groupId: string) {
return prisma.activity.findMany({
export async function getActivities(
groupId: string,
options?: { offset?: number; length?: number },
) {
const activities = await prisma.activity.findMany({
where: { groupId },
orderBy: [{ time: 'desc' }],
skip: options?.offset,
take: options?.length,
})
const expenseIds = activities
.map((activity) => activity.expenseId)
.filter(Boolean)
const expenses = await prisma.expense.findMany({
where: {
groupId,
id: { in: expenseIds },
},
})
return activities.map((activity) => ({
...activity,
expense:
activity.expenseId !== null
? expenses.find((expense) => expense.id === activity.expenseId)
: undefined,
}))
}
export async function logActivity(

View File

@@ -52,12 +52,14 @@ export function useBaseUrl() {
/**
* @returns The active user, or `null` until it is fetched from local storage
*/
export function useActiveUser(groupId: string) {
export function useActiveUser(groupId?: string) {
const [activeUser, setActiveUser] = useState<string | null>(null)
useEffect(() => {
const activeUser = localStorage.getItem(`${groupId}-activeUser`)
if (activeUser) setActiveUser(activeUser)
if (groupId) {
const activeUser = localStorage.getItem(`${groupId}-activeUser`)
if (activeUser) setActiveUser(activeUser)
}
}, [groupId])
return activeUser

60
src/trpc/client.tsx Normal file
View File

@@ -0,0 +1,60 @@
'use client' // <-- to make sure we can mount the Provider from a server component
import type { QueryClient } from '@tanstack/react-query'
import { QueryClientProvider } from '@tanstack/react-query'
import { httpBatchLink } from '@trpc/client'
import { createTRPCReact } from '@trpc/react-query'
import { useState } from 'react'
import superjson from 'superjson'
import { makeQueryClient } from './query-client'
import type { AppRouter } from './routers/_app'
export const trpc = createTRPCReact<AppRouter>()
let clientQueryClientSingleton: QueryClient
function getQueryClient() {
if (typeof window === 'undefined') {
// Server: always make a new query client
return makeQueryClient()
}
// Browser: use singleton pattern to keep the same query client
return (clientQueryClientSingleton ??= makeQueryClient())
}
function getUrl() {
const base = (() => {
if (typeof window !== 'undefined') return ''
if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`
return 'http://localhost:3000'
})()
return `${base}/api/trpc`
}
export function TRPCProvider(
props: Readonly<{
children: React.ReactNode
}>,
) {
// NOTE: Avoid useState when initializing the query client if you don't
// have a suspense boundary between this and the code that may
// suspend because React will throw away the client on the initial
// render if it suspends and there is no boundary
const queryClient = getQueryClient()
const [trpcClient] = useState(() =>
trpc.createClient({
links: [
httpBatchLink({
transformer: superjson,
url: getUrl(),
}),
],
}),
)
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
{props.children}
</QueryClientProvider>
</trpc.Provider>
)
}

25
src/trpc/init.ts Normal file
View File

@@ -0,0 +1,25 @@
import { initTRPC } from '@trpc/server'
import { cache } from 'react'
import superjson from 'superjson'
export const createTRPCContext = cache(async () => {
/**
* @see: https://trpc.io/docs/server/context
*/
return {}
})
// Avoid exporting the entire t-object
// since it's not very descriptive.
// For instance, the use of a t variable
// is common in i18n libraries.
const t = initTRPC.create({
/**
* @see https://trpc.io/docs/server/data-transformers
*/
transformer: superjson,
})
// Base router and procedure helpers
export const createTRPCRouter = t.router
export const baseProcedure = t.procedure

21
src/trpc/query-client.ts Normal file
View File

@@ -0,0 +1,21 @@
import { defaultShouldDehydrateQuery, QueryClient } from '@tanstack/react-query'
import superjson from 'superjson'
export function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 30 * 1000,
},
dehydrate: {
serializeData: superjson.serialize,
shouldDehydrateQuery: (query) =>
defaultShouldDehydrateQuery(query) ||
query.state.status === 'pending',
},
hydrate: {
deserializeData: superjson.deserialize,
},
},
})
}

12
src/trpc/routers/_app.ts Normal file
View File

@@ -0,0 +1,12 @@
import { categoriesRouter } from '@/trpc/routers/categories'
import { groupsRouter } from '@/trpc/routers/groups'
import { inferRouterOutputs } from '@trpc/server'
import { createTRPCRouter } from '../init'
export const appRouter = createTRPCRouter({
groups: groupsRouter,
categories: categoriesRouter,
})
export type AppRouter = typeof appRouter
export type AppRouterOutput = inferRouterOutputs<AppRouter>

View File

@@ -0,0 +1,6 @@
import { createTRPCRouter } from '@/trpc/init'
import { listCategoriesProcedure } from '@/trpc/routers/categories/list.procedure'
export const categoriesRouter = createTRPCRouter({
list: listCategoriesProcedure,
})

View File

@@ -0,0 +1,6 @@
import { getCategories } from '@/lib/api'
import { baseProcedure } from '@/trpc/init'
export const listCategoriesProcedure = baseProcedure.query(async () => {
return { categories: await getCategories() }
})

View File

@@ -0,0 +1,6 @@
import { createTRPCRouter } from '@/trpc/init'
import { listGroupActivitiesProcedure } from '@/trpc/routers/groups/activities/list.procedure'
export const activitiesRouter = createTRPCRouter({
list: listGroupActivitiesProcedure,
})

View File

@@ -0,0 +1,23 @@
import { getActivities } from '@/lib/api'
import { baseProcedure } from '@/trpc/init'
import { z } from 'zod'
export const listGroupActivitiesProcedure = baseProcedure
.input(
z.object({
groupId: z.string(),
cursor: z.number().optional().default(0),
limit: z.number().optional().default(5),
}),
)
.query(async ({ input: { groupId, cursor, limit } }) => {
const activities = await getActivities(groupId, {
offset: cursor,
length: limit + 1,
})
return {
activities: activities.slice(0, limit),
hasMore: !!activities[limit],
nextCursor: cursor + limit,
}
})

View File

@@ -0,0 +1,6 @@
import { createTRPCRouter } from '@/trpc/init'
import { listGroupBalancesProcedure } from '@/trpc/routers/groups/balances/list.procedure'
export const groupBalancesRouter = createTRPCRouter({
list: listGroupBalancesProcedure,
})

View File

@@ -0,0 +1,19 @@
import { getGroupExpenses } from '@/lib/api'
import {
getBalances,
getPublicBalances,
getSuggestedReimbursements,
} from '@/lib/balances'
import { baseProcedure } from '@/trpc/init'
import { z } from 'zod'
export const listGroupBalancesProcedure = baseProcedure
.input(z.object({ groupId: z.string().min(1) }))
.query(async ({ input: { groupId } }) => {
const expenses = await getGroupExpenses(groupId)
const balances = getBalances(expenses)
const reimbursements = getSuggestedReimbursements(balances)
const publicBalances = getPublicBalances(reimbursements)
return { balances: publicBalances, reimbursements }
})

View File

@@ -0,0 +1,15 @@
import { createGroup } from '@/lib/api'
import { groupFormSchema } from '@/lib/schemas'
import { baseProcedure } from '@/trpc/init'
import { z } from 'zod'
export const createGroupProcedure = baseProcedure
.input(
z.object({
groupFormValues: groupFormSchema,
}),
)
.mutation(async ({ input: { groupFormValues } }) => {
const group = await createGroup(groupFormValues)
return { groupId: group.id }
})

View File

@@ -0,0 +1,23 @@
import { createExpense } from '@/lib/api'
import { expenseFormSchema } from '@/lib/schemas'
import { baseProcedure } from '@/trpc/init'
import { z } from 'zod'
export const createGroupExpenseProcedure = baseProcedure
.input(
z.object({
groupId: z.string().min(1),
expenseFormValues: expenseFormSchema,
participantId: z.string().optional(),
}),
)
.mutation(
async ({ input: { groupId, expenseFormValues, participantId } }) => {
const expense = await createExpense(
expenseFormValues,
groupId,
participantId,
)
return { expenseId: expense.id }
},
)

View File

@@ -0,0 +1,16 @@
import { deleteExpense } from '@/lib/api'
import { baseProcedure } from '@/trpc/init'
import { z } from 'zod'
export const deleteGroupExpenseProcedure = baseProcedure
.input(
z.object({
expenseId: z.string().min(1),
groupId: z.string().min(1),
participantId: z.string().optional(),
}),
)
.mutation(async ({ input: { expenseId, groupId, participantId } }) => {
await deleteExpense(groupId, expenseId, participantId)
return {}
})

View File

@@ -0,0 +1,17 @@
import { getExpense } from '@/lib/api'
import { baseProcedure } from '@/trpc/init'
import { TRPCError } from '@trpc/server'
import { z } from 'zod'
export const getGroupExpenseProcedure = baseProcedure
.input(z.object({ groupId: z.string().min(1), expenseId: z.string().min(1) }))
.query(async ({ input: { groupId, expenseId } }) => {
const expense = await getExpense(groupId, expenseId)
if (!expense) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Expense not found',
})
}
return { expense }
})

View File

@@ -0,0 +1,14 @@
import { createTRPCRouter } from '@/trpc/init'
import { createGroupExpenseProcedure } from '@/trpc/routers/groups/expenses/create.procedure'
import { deleteGroupExpenseProcedure } from '@/trpc/routers/groups/expenses/delete.procedure'
import { getGroupExpenseProcedure } from '@/trpc/routers/groups/expenses/get.procedure'
import { listGroupExpensesProcedure } from '@/trpc/routers/groups/expenses/list.procedure'
import { updateGroupExpenseProcedure } from '@/trpc/routers/groups/expenses/update.procedure'
export const groupExpensesRouter = createTRPCRouter({
list: listGroupExpensesProcedure,
get: getGroupExpenseProcedure,
create: createGroupExpenseProcedure,
update: updateGroupExpenseProcedure,
delete: deleteGroupExpenseProcedure,
})

View File

@@ -0,0 +1,29 @@
import { getGroupExpenses } from '@/lib/api'
import { baseProcedure } from '@/trpc/init'
import { z } from 'zod'
export const listGroupExpensesProcedure = baseProcedure
.input(
z.object({
groupId: z.string().min(1),
cursor: z.number().optional(),
limit: z.number().optional(),
filter: z.string().optional(),
}),
)
.query(async ({ input: { groupId, cursor = 0, limit = 10, filter } }) => {
const expenses = await getGroupExpenses(groupId, {
offset: cursor,
length: limit + 1,
filter,
})
return {
expenses: expenses.slice(0, limit).map((expense) => ({
...expense,
createdAt: new Date(expense.createdAt),
expenseDate: new Date(expense.expenseDate),
})),
hasMore: !!expenses[limit],
nextCursor: cursor + limit,
}
})

View File

@@ -0,0 +1,27 @@
import { updateExpense } from '@/lib/api'
import { expenseFormSchema } from '@/lib/schemas'
import { baseProcedure } from '@/trpc/init'
import { z } from 'zod'
export const updateGroupExpenseProcedure = baseProcedure
.input(
z.object({
expenseId: z.string().min(1),
groupId: z.string().min(1),
expenseFormValues: expenseFormSchema,
participantId: z.string().optional(),
}),
)
.mutation(
async ({
input: { expenseId, groupId, expenseFormValues, participantId },
}) => {
const expense = await updateExpense(
groupId,
expenseId,
expenseFormValues,
participantId,
)
return { expenseId: expense.id }
},
)

View File

@@ -0,0 +1,19 @@
import { getGroup, getGroupExpensesParticipants } from '@/lib/api'
import { baseProcedure } from '@/trpc/init'
import { TRPCError } from '@trpc/server'
import { z } from 'zod'
export const getGroupProcedure = baseProcedure
.input(z.object({ groupId: z.string().min(1) }))
.query(async ({ input: { groupId } }) => {
const group = await getGroup(groupId)
if (!group) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Group not found.',
})
}
const participantsWithExpenses = await getGroupExpensesParticipants(groupId)
return { group, participantsWithExpenses }
})

View File

@@ -0,0 +1,19 @@
import { createTRPCRouter } from '@/trpc/init'
import { activitiesRouter } from '@/trpc/routers/groups/activities'
import { groupBalancesRouter } from '@/trpc/routers/groups/balances'
import { createGroupProcedure } from '@/trpc/routers/groups/create.procedure'
import { groupExpensesRouter } from '@/trpc/routers/groups/expenses'
import { getGroupProcedure } from '@/trpc/routers/groups/get.procedure'
import { groupStatsRouter } from '@/trpc/routers/groups/stats'
import { updateGroupProcedure } from '@/trpc/routers/groups/update.procedure'
export const groupsRouter = createTRPCRouter({
expenses: groupExpensesRouter,
balances: groupBalancesRouter,
stats: groupStatsRouter,
activities: activitiesRouter,
get: getGroupProcedure,
create: createGroupProcedure,
update: updateGroupProcedure,
})

View File

@@ -0,0 +1,35 @@
import { getGroupExpenses } from '@/lib/api'
import {
getTotalActiveUserPaidFor,
getTotalActiveUserShare,
getTotalGroupSpending,
} from '@/lib/totals'
import { baseProcedure } from '@/trpc/init'
import { z } from 'zod'
export const getGroupStatsProcedure = baseProcedure
.input(
z.object({
groupId: z.string().min(1),
participantId: z.string().optional(),
}),
)
.query(async ({ input: { groupId, participantId } }) => {
const expenses = await getGroupExpenses(groupId)
const totalGroupSpendings = getTotalGroupSpending(expenses)
const totalParticipantSpendings =
participantId !== undefined
? getTotalActiveUserPaidFor(participantId, expenses)
: undefined
const totalParticipantShare =
participantId !== undefined
? getTotalActiveUserShare(participantId, expenses)
: undefined
return {
totalGroupSpendings,
totalParticipantSpendings,
totalParticipantShare,
}
})

View File

@@ -0,0 +1,6 @@
import { createTRPCRouter } from '@/trpc/init'
import { getGroupStatsProcedure } from '@/trpc/routers/groups/stats/get.procedure'
export const groupStatsRouter = createTRPCRouter({
get: getGroupStatsProcedure,
})

View File

@@ -0,0 +1,16 @@
import { updateGroup } from '@/lib/api'
import { groupFormSchema } from '@/lib/schemas'
import { baseProcedure } from '@/trpc/init'
import { z } from 'zod'
export const updateGroupProcedure = baseProcedure
.input(
z.object({
groupId: z.string().min(1),
groupFormValues: groupFormSchema,
participantId: z.string().optional(),
}),
)
.mutation(async ({ input: { groupId, groupFormValues, participantId } }) => {
await updateGroup(groupId, groupFormValues, participantId)
})