Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a11efc79c1 | ||
|
|
e63f3aa68f | ||
|
|
d77411c21e | ||
|
|
94c101cf7b | ||
|
|
2bced00f82 | ||
|
|
233b338bc5 | ||
|
|
728e072376 | ||
|
|
9fec8f9eaa | ||
|
|
6346fc8ec5 | ||
|
|
1c83ebd6f9 | ||
|
|
a65c3c9dfe | ||
|
|
86c084da6f | ||
|
|
03712f1503 | ||
|
|
ffbcb6b74d | ||
|
|
0a16a4ad38 | ||
|
|
75747157f0 | ||
|
|
2c4b4f1594 | ||
|
|
2fda3e453c | ||
|
|
c14c854a79 | ||
|
|
0c3368fd35 | ||
|
|
92909ce27f | ||
|
|
ff6c48a0c8 | ||
|
|
6c5c9d5bed | ||
|
|
f9307fd22d | ||
|
|
9302a32f4c | ||
|
|
98e2345bb9 | ||
|
|
5732f78e80 | ||
|
|
72ad0a4c90 | ||
|
|
2c973f976f | ||
|
|
5374d9e9c7 | ||
|
|
5111f3574f | ||
|
|
4db788680e | ||
|
|
39c1a2ffc6 | ||
|
|
f5154393e2 | ||
|
|
e9d583113a | ||
|
|
21d0c02687 | ||
|
|
2281316d58 | ||
|
|
210c12b7ef | ||
|
|
66e15e419e | ||
|
|
727803ea5c | ||
|
|
7add7efea2 | ||
|
|
a7c80f65c3 | ||
|
|
1e4edf7504 | ||
|
|
24053ca5ab | ||
|
|
343363d54f |
@@ -1,2 +1,4 @@
|
||||
POSTGRES_PRISMA_URL=postgresql://postgres:1234@localhost
|
||||
POSTGRES_URL_NON_POOLING=postgresql://postgres:1234@localhost
|
||||
POSTGRES_URL_NON_POOLING=postgresql://postgres:1234@localhost
|
||||
|
||||
NEXT_PUBLIC_DEFAULT_CURRENCY_SYMBOL=""
|
||||
13
Dockerfile
@@ -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
|
||||
|
||||
15
compose.yaml
@@ -1,6 +1,7 @@
|
||||
services:
|
||||
app:
|
||||
image: spliit2:latest
|
||||
build: .
|
||||
image: spliit:latest
|
||||
ports:
|
||||
- 3000:3000
|
||||
env_file:
|
||||
@@ -8,11 +9,13 @@ services:
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- spliit_network
|
||||
|
||||
db:
|
||||
image: postgres:latest
|
||||
ports:
|
||||
- 5432:5432
|
||||
expose:
|
||||
- 5432
|
||||
env_file:
|
||||
- container.env
|
||||
volumes:
|
||||
@@ -22,3 +25,9 @@ services:
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- spliit_network
|
||||
|
||||
networks:
|
||||
spliit_network:
|
||||
driver: bridge
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
"createFirst": "Erstelle die Erste",
|
||||
"noExpenses": "Deine Gruppe hat noch keine Ausgaben.",
|
||||
"exportJson": "Als JSON exportieren",
|
||||
"exportCsv": "Als CSV exportieren",
|
||||
"searchPlaceholder": "Suche nach einer Ausgabe…",
|
||||
"ActiveUserModal": {
|
||||
"title": "Wer bist du?",
|
||||
@@ -35,7 +36,7 @@
|
||||
"earlierThisMonth": "Diesen Monat",
|
||||
"lastMonth": "Letzten Monat",
|
||||
"earlierThisYear": "Dieses Jahr",
|
||||
"lastYera": "Letztes Jahr",
|
||||
"lastYear": "Letztes Jahr",
|
||||
"older": "Älter"
|
||||
}
|
||||
},
|
||||
@@ -136,6 +137,15 @@
|
||||
"label": "Empfangen von",
|
||||
"description": "Wähle das Mitglied, das die Einnahme erhalten hat."
|
||||
},
|
||||
"recurrenceRule": {
|
||||
"label": "Expense Recurrence",
|
||||
"description": "Select how often the expense should repeat.",
|
||||
|
||||
"none": "None",
|
||||
"daily": "Daily",
|
||||
"weekly": "Weekly",
|
||||
"monthly": "Monthly"
|
||||
},
|
||||
"paidFor": {
|
||||
"title": "Empfangen für",
|
||||
"description": "Wähle für wen die Einnahme empfangen wurde."
|
||||
@@ -144,7 +154,7 @@
|
||||
"attachDescription": "Füge der Einnahme einen Beleg hinzu."
|
||||
},
|
||||
"Expense": {
|
||||
"create": "Augabe erstellen",
|
||||
"create": "Ausgabe erstellen",
|
||||
"edit": "Ausgabe bearbeiten",
|
||||
"TitleField": {
|
||||
"label": "Titel der Ausgabe",
|
||||
@@ -203,12 +213,13 @@
|
||||
"creating": "Erstellt…",
|
||||
"save": "Speichern",
|
||||
"saving": "Speichert…",
|
||||
"cancel": "Abbrechen"
|
||||
"cancel": "Abbrechen",
|
||||
"reimbursement": "Rückzahlung"
|
||||
},
|
||||
"ExpenseDocumentsInput": {
|
||||
"TooBigToast": {
|
||||
"title": "Die Datei ist zu groß",
|
||||
"description": "Die maximale Dateigröße ist {maxSize}. Deine ist ${size}."
|
||||
"description": "Die maximale Dateigröße ist {maxSize}. Deine ist {size}."
|
||||
},
|
||||
"ErrorToast": {
|
||||
"title": "Fehler beim Hochladen der Datei",
|
||||
@@ -233,7 +244,7 @@
|
||||
"unknown": "Unbekannt",
|
||||
"TooBigToast": {
|
||||
"title": "Die Datei ist zu groß",
|
||||
"description": "Die maximale Dateigröße ist {maxSize}. Deine ist ${size}."
|
||||
"description": "Die maximale Dateigröße ist {maxSize}. Deine ist {size}."
|
||||
},
|
||||
"ErrorToast": {
|
||||
"title": "Fehler beim Hochladen der Datei",
|
||||
@@ -270,7 +281,7 @@
|
||||
"noActivity": "Es gab noch keine Aktivität in dieser Gruppe.",
|
||||
"someone": "Jemand",
|
||||
"settingsModified": "Die Gruppeneinstellungen wurden von <strong>{participant}</strong> verändert.",
|
||||
"expenseCreated": "Augabe <em>{expense}</em> wurde von <strong>{participant}</strong> erstellt.",
|
||||
"expenseCreated": "Ausgabe <em>{expense}</em> wurde von <strong>{participant}</strong> erstellt.",
|
||||
"expenseUpdated": "Ausgabe <em>{expense}</em> wurde von <strong>{participant}</strong> aktualisiert.",
|
||||
"expenseDeleted": "Ausgabe <em>{expense}</em> wurde von <strong>{participant}</strong> gelöscht.",
|
||||
"Groups": {
|
||||
@@ -293,20 +304,11 @@
|
||||
"Settings": {
|
||||
"title": "Einstellungen"
|
||||
},
|
||||
"Locale": {
|
||||
"en-US": "English",
|
||||
"fi": "Suomi",
|
||||
"fr-FR": "Français",
|
||||
"es": "Español",
|
||||
"de-DE": "Deutsch",
|
||||
"zh-CN": "Chinese (Simplified)",
|
||||
"ru-RU": "Русский"
|
||||
},
|
||||
"Share": {
|
||||
"title": "Teilen",
|
||||
"description": "Teile die URL, damit andere Mitglieder die Gruppe sehen und Ausgaben hinzufügen können.",
|
||||
"warning": "Achtung!",
|
||||
"warningHelp": "Jede person mit der Gruppen-URL kann Ausgaben sehen und editieren. Teile den Link mit Bedacht!"
|
||||
"warningHelp": "Jede Person mit der Gruppen-URL kann Ausgaben sehen und editieren. Teile den Link mit Bedacht!"
|
||||
},
|
||||
"SchemaErrors": {
|
||||
"min1": "Gib mindestens ein Zeichen ein.",
|
||||
@@ -364,6 +366,7 @@
|
||||
"heading": "Leben",
|
||||
"Childcare": "Kinderversorgung",
|
||||
"Clothing": "Kleidung",
|
||||
"Donation": "Spende",
|
||||
"Education": "Bildung",
|
||||
"Gifts": "Geschenke",
|
||||
"Insurance": "Versicherung",
|
||||
@@ -393,4 +396,4 @@
|
||||
"Water": "Wasser"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,9 @@
|
||||
"create": "Create expense",
|
||||
"createFirst": "Create the first one",
|
||||
"noExpenses": "Your group doesn’t contain any expense yet.",
|
||||
"export": "Export",
|
||||
"exportJson": "Export to JSON",
|
||||
"exportCsv": "Export to CSV",
|
||||
"searchPlaceholder": "Search for an expense…",
|
||||
"ActiveUserModal": {
|
||||
"title": "Who are you?",
|
||||
@@ -35,7 +37,7 @@
|
||||
"earlierThisMonth": "Earlier this month",
|
||||
"lastMonth": "Last month",
|
||||
"earlierThisYear": "Earlier this year",
|
||||
"lastYera": "Last year",
|
||||
"lastYear": "Last year",
|
||||
"older": "Older"
|
||||
}
|
||||
},
|
||||
@@ -160,6 +162,15 @@
|
||||
"label": "Paid by",
|
||||
"description": "Select the participant who paid the expense."
|
||||
},
|
||||
"recurrenceRule": {
|
||||
"label": "Expense Recurrence",
|
||||
"description": "Select how often the expense should repeat.",
|
||||
|
||||
"none": "None",
|
||||
"daily": "Daily",
|
||||
"weekly": "Weekly",
|
||||
"monthly": "Monthly"
|
||||
},
|
||||
"paidFor": {
|
||||
"title": "Paid for",
|
||||
"description": "Select who the expense was paid for."
|
||||
@@ -203,12 +214,13 @@
|
||||
"creating": "Creating…",
|
||||
"save": "Save",
|
||||
"saving": "Saving…",
|
||||
"cancel": "Cancel"
|
||||
"cancel": "Cancel",
|
||||
"reimbursement": "Reimbursement"
|
||||
},
|
||||
"ExpenseDocumentsInput": {
|
||||
"TooBigToast": {
|
||||
"title": "The file is too big",
|
||||
"description": "The maximum file size you can upload is {maxSize}. Yours is ${size}."
|
||||
"description": "The maximum file size you can upload is {maxSize}. Yours is {size}."
|
||||
},
|
||||
"ErrorToast": {
|
||||
"title": "Error while uploading document",
|
||||
@@ -233,7 +245,7 @@
|
||||
"unknown": "Unknown",
|
||||
"TooBigToast": {
|
||||
"title": "The file is too big",
|
||||
"description": "The maximum file size you can upload is {maxSize}. Yours is ${size}."
|
||||
"description": "The maximum file size you can upload is {maxSize}. Yours is {size}."
|
||||
},
|
||||
"ErrorToast": {
|
||||
"title": "Error while uploading document",
|
||||
@@ -293,15 +305,6 @@
|
||||
"Settings": {
|
||||
"title": "Settings"
|
||||
},
|
||||
"Locale": {
|
||||
"en-US": "English",
|
||||
"fi": "Suomi",
|
||||
"fr-FR": "Français",
|
||||
"es": "Español",
|
||||
"de-DE": "Deutsch",
|
||||
"zh-CN": "Chinese (Simplified)",
|
||||
"ru-RU": "Русский"
|
||||
},
|
||||
"Share": {
|
||||
"title": "Share",
|
||||
"description": "For other participants to see the group and add expenses, share its URL with them.",
|
||||
@@ -364,6 +367,7 @@
|
||||
"heading": "Life",
|
||||
"Childcare": "Childcare",
|
||||
"Clothing": "Clothing",
|
||||
"Donation": "Donation",
|
||||
"Education": "Education",
|
||||
"Gifts": "Gifts",
|
||||
"Insurance": "Insurance",
|
||||
@@ -393,4 +397,4 @@
|
||||
"Water": "Water"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@
|
||||
"createFirst": "Crea el primero",
|
||||
"noExpenses": "Tu grupo aun no tiene gastos.",
|
||||
"exportJson": "Exportar a JSON",
|
||||
"exportCsv": "Exportar a CSV",
|
||||
"searchPlaceholder": "Busca un gasto…",
|
||||
"ActiveUserModal": {
|
||||
"title": "¿Quién es usted?",
|
||||
@@ -35,7 +36,7 @@
|
||||
"earlierThisMonth": "A principios de este mes",
|
||||
"lastMonth": "El mes pasado",
|
||||
"earlierThisYear": "A principios de este año",
|
||||
"lastYera": "El año pasado",
|
||||
"lastYear": "El año pasado",
|
||||
"older": "Más antiguos"
|
||||
}
|
||||
},
|
||||
@@ -136,6 +137,15 @@
|
||||
"label": "Recibido por",
|
||||
"description": "Seleccione el participante que recibió los ingresos."
|
||||
},
|
||||
"recurrenceRule": {
|
||||
"label": "Recurrencia del gasto",
|
||||
"description": "Seleccione con qué frecuencia debe repetirse el gasto.",
|
||||
|
||||
"none": "Ninguno",
|
||||
"daily": "Diario",
|
||||
"weekly": "Semanal",
|
||||
"monthly": "Mensual"
|
||||
},
|
||||
"paidFor": {
|
||||
"title": "Recibido para for",
|
||||
"description": "Seleccione para quién se recibió el ingreso."
|
||||
@@ -203,12 +213,13 @@
|
||||
"creating": "Creando",
|
||||
"save": "Guardar",
|
||||
"saving": "Guardando",
|
||||
"cancel": "Cancelar"
|
||||
"cancel": "Cancelar",
|
||||
"reimbursement": "Reembolso"
|
||||
},
|
||||
"ExpenseDocumentsInput": {
|
||||
"TooBigToast": {
|
||||
"title": "El archivo es demasiado grande",
|
||||
"description": "El tamaño máximo de archivo que puede cargar es {maxSize}. El tuyo pesa ${size}."
|
||||
"description": "El tamaño máximo de archivo que puede cargar es {maxSize}. El tuyo pesa {size}."
|
||||
},
|
||||
"ErrorToast": {
|
||||
"title": "Error al cargar el documento",
|
||||
@@ -233,7 +244,7 @@
|
||||
"unknown": "Desconocido",
|
||||
"TooBigToast": {
|
||||
"title": "El archivo es demasiado grande",
|
||||
"description": "El tamaño máximo de archivo que puede cargar es {maxSize}. El tuyo pesa ${size}."
|
||||
"description": "El tamaño máximo de archivo que puede cargar es {maxSize}. El tuyo pesa {size}."
|
||||
},
|
||||
"ErrorToast": {
|
||||
"title": "Error al cargar el documento",
|
||||
@@ -293,15 +304,6 @@
|
||||
"Settings": {
|
||||
"title": "Ajustes"
|
||||
},
|
||||
"Locale": {
|
||||
"en-US": "English",
|
||||
"fi": "Suomi",
|
||||
"fr-FR": "Français",
|
||||
"es": "Español",
|
||||
"de-DE": "Deutsch",
|
||||
"zh-CN": "Chinese (Simplified)",
|
||||
"ru-RU": "Русский"
|
||||
},
|
||||
"Share": {
|
||||
"title": "Compartir",
|
||||
"description": "Para que otros participantes puedan ver el grupo y añadir gastos, compárteles su URL.",
|
||||
@@ -393,4 +395,4 @@
|
||||
"Water": "Agua"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@
|
||||
"createFirst": "Lisää ensimmäinen kulu",
|
||||
"noExpenses": "Ryhmälläsi ei ole vielä yhtään kulua.",
|
||||
"exportJson": "Vie JSON-tiedostoon",
|
||||
"exportCsv": "Vie CSV-tiedostoon",
|
||||
"searchPlaceholder": "Etsi kulua…",
|
||||
"ActiveUserModal": {
|
||||
"title": "Kuka olet?",
|
||||
@@ -136,6 +137,15 @@
|
||||
"label": "Vastaanottaja",
|
||||
"description": "Valitse kuka vastaanotti tulon."
|
||||
},
|
||||
"recurrenceRule": {
|
||||
"label": "Expense Recurrence",
|
||||
"description": "Select how often the expense should repeat.",
|
||||
|
||||
"none": "None",
|
||||
"daily": "Daily",
|
||||
"weekly": "Weekly",
|
||||
"monthly": "Monthly"
|
||||
},
|
||||
"paidFor": {
|
||||
"title": "Tulon jakaminen",
|
||||
"description": "Valitse kenelle tulo jaetaan."
|
||||
@@ -203,12 +213,13 @@
|
||||
"creating": "Luodaan kulua…",
|
||||
"save": "Tallenna",
|
||||
"saving": "Tallennetaan…",
|
||||
"cancel": "Peruuta"
|
||||
"cancel": "Peruuta",
|
||||
"reimbursement": "Velanmaksu"
|
||||
},
|
||||
"ExpenseDocumentsInput": {
|
||||
"TooBigToast": {
|
||||
"title": "Tiedosto on liian suuri",
|
||||
"description": "Maksimikoko ladattavalle tiedostolle on {maxSize}. Tiedostosi on ${size}."
|
||||
"description": "Maksimikoko ladattavalle tiedostolle on {maxSize}. Tiedostosi on {size}."
|
||||
},
|
||||
"ErrorToast": {
|
||||
"title": "Virhe tiedostoa ladattaessa",
|
||||
@@ -233,7 +244,7 @@
|
||||
"unknown": "Unknown",
|
||||
"TooBigToast": {
|
||||
"title": "The file is too big",
|
||||
"description": "The maximum file size you can upload is {maxSize}. Yours is ${size}."
|
||||
"description": "The maximum file size you can upload is {maxSize}. Yours is {size}."
|
||||
},
|
||||
"ErrorToast": {
|
||||
"title": "Error while uploading document",
|
||||
@@ -293,15 +304,6 @@
|
||||
"Settings": {
|
||||
"title": "Asetukset"
|
||||
},
|
||||
"Locale": {
|
||||
"en-US": "English",
|
||||
"fi": "Suomi",
|
||||
"fr-FR": "Français",
|
||||
"es": "Español",
|
||||
"de-DE": "Deutsch",
|
||||
"zh-CN": "Chinese (Simplified)",
|
||||
"ru-RU": "Русский"
|
||||
},
|
||||
"Share": {
|
||||
"title": "Jaa",
|
||||
"description": "Jaa ryhmän URL muille jäsenille, jotta he voivat nähdä sen ja lisätä kuluja.",
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
"createFirst": "Créer la première :)",
|
||||
"noExpenses": "Votre groupe n'a effectué aucune dépense pour le moment.",
|
||||
"exportJson": "Exporter en JSON",
|
||||
"exportCsv": "Exporter en CSV",
|
||||
"searchPlaceholder": "Rechercher une dépense…",
|
||||
"ActiveUserModal": {
|
||||
"title": "Qui êtes-vous ?",
|
||||
@@ -35,7 +36,7 @@
|
||||
"earlierThisMonth": "Plus tôt ce mois-ci",
|
||||
"lastMonth": "Le mois dernier",
|
||||
"earlierThisYear": "Plus tôt cette année",
|
||||
"lastYera": "L'année dernière",
|
||||
"lastYear": "L'année dernière",
|
||||
"older": "Plus ancien"
|
||||
}
|
||||
},
|
||||
@@ -136,6 +137,15 @@
|
||||
"label": "Reçu par",
|
||||
"description": "Sélectionnez le participant qui a reçu le revenu."
|
||||
},
|
||||
"recurrenceRule": {
|
||||
"label": "Expense Recurrence",
|
||||
"description": "Select how often the expense should repeat.",
|
||||
|
||||
"none": "None",
|
||||
"daily": "Daily",
|
||||
"weekly": "Weekly",
|
||||
"monthly": "Monthly"
|
||||
},
|
||||
"paidFor": {
|
||||
"title": "Reçu pour",
|
||||
"description": "Sélectionnez pour qui le revenu a été reçu."
|
||||
@@ -203,12 +213,13 @@
|
||||
"creating": "Création…",
|
||||
"save": "Sauvegarder",
|
||||
"saving": "Sauvegarde…",
|
||||
"cancel": "Annuler"
|
||||
"cancel": "Annuler",
|
||||
"reimbursement": "Remboursement"
|
||||
},
|
||||
"ExpenseDocumentsInput": {
|
||||
"TooBigToast": {
|
||||
"title": "Le fichier est trop grand",
|
||||
"description": "La taille maximale du fichier que vous pouvez télécharger est {maxSize}. La vôtre est ${size}."
|
||||
"description": "La taille maximale du fichier que vous pouvez télécharger est {maxSize}. La vôtre est {size}."
|
||||
},
|
||||
"ErrorToast": {
|
||||
"title": "Erreur lors du téléchargement du document",
|
||||
@@ -233,7 +244,7 @@
|
||||
"unknown": "Inconnu",
|
||||
"TooBigToast": {
|
||||
"title": "Le fichier est trop grand",
|
||||
"description": "La taille maximale du fichier que vous pouvez télécharger est {maxSize}. La vôtre est ${size}."
|
||||
"description": "La taille maximale du fichier que vous pouvez télécharger est {maxSize}. La vôtre est {size}."
|
||||
},
|
||||
"ErrorToast": {
|
||||
"title": "Erreur lors du téléchargement du document",
|
||||
@@ -293,15 +304,6 @@
|
||||
"Settings": {
|
||||
"title": "Paramètres"
|
||||
},
|
||||
"Locale": {
|
||||
"en-US": "English",
|
||||
"fi": "Suomi",
|
||||
"fr-FR": "Français",
|
||||
"es": "Español",
|
||||
"de-DE": "Deutsch",
|
||||
"zh-CN": "Chinese (Simplified)",
|
||||
"ru-RU": "Русский"
|
||||
},
|
||||
"Share": {
|
||||
"title": "Partager",
|
||||
"description": "Pour que d'autres participants puissent voir le groupe et ajouter des dépenses, partagez son URL avec eux.",
|
||||
@@ -393,4 +395,4 @@
|
||||
"Water": "Eau"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
398
messages/it-IT.json
Normal file
@@ -0,0 +1,398 @@
|
||||
{
|
||||
"Homepage": {
|
||||
"title": "Condividi <strong>Spese</strong> con <strong>Amici & Familiari</strong>",
|
||||
"description": "Benvenuto nella tua nuova instanza di <strong>Spliit</strong>!",
|
||||
"button": {
|
||||
"groups": "Vai ai gruppi",
|
||||
"github": "GitHub"
|
||||
}
|
||||
},
|
||||
"Header": {
|
||||
"groups": "Gruppi"
|
||||
},
|
||||
"Footer": {
|
||||
"madeIn": "Realizzato a Montréal, Québec 🇨🇦",
|
||||
"builtBy": "Costruito da <author>Sebastien Castiel</author> e <source>contributori</source>"
|
||||
},
|
||||
"Expenses": {
|
||||
"title": "Spese",
|
||||
"description": "Ecco le spese che hai creato per il tuo gruppo.",
|
||||
"create": "Crea spesa",
|
||||
"createFirst": "Crea la prima",
|
||||
"noExpenses": "Il tuo gruppo non contiene ancora spese.",
|
||||
"exportJson": "Esporta file JSON",
|
||||
"exportCsv": "Esporta file CSV",
|
||||
"searchPlaceholder": "Cerca una spesa…",
|
||||
"ActiveUserModal": {
|
||||
"title": "Chi sei?",
|
||||
"description": "Dicci quale partecipante sei per consentirci di personalizzare la modalità di visualizzazione delle informazioni.",
|
||||
"nobody": "Non voglio selezionare nessuno",
|
||||
"save": "Salva cambiamenti",
|
||||
"footer": "Questa impostazione può essere modificata successivamente nelle impostazioni del gruppo."
|
||||
},
|
||||
"Groups": {
|
||||
"upcoming": "In arrivo",
|
||||
"thisWeek": "Questa settimana",
|
||||
"earlierThisMonth": "All'inizio di questo mese",
|
||||
"lastMonth": "Ultimo mese",
|
||||
"earlierThisYear": "All'inizio di quest'anno",
|
||||
"lastYear": "Ultimo anno",
|
||||
"older": "Più vecchio"
|
||||
}
|
||||
},
|
||||
"ExpenseCard": {
|
||||
"paidBy": "Pagato da <strong>{paidBy}</strong> per <paidFor></paidFor>",
|
||||
"receivedBy": "Ricevuto da <strong>{paidBy}</strong> per <paidFor></paidFor>",
|
||||
"yourBalance": "Il tuo bilancio:"
|
||||
},
|
||||
"Groups": {
|
||||
"myGroups": "I miei gruppi",
|
||||
"create": "Crea",
|
||||
"loadingRecent": "Caricamento gruppi recenti…",
|
||||
"NoRecent": {
|
||||
"description": "Non hai visitato nessun gruppo di recente.",
|
||||
"create": "Creane una",
|
||||
"orAsk": "oppure chiedi a un amico di inviarti il collegamento a uno esistente."
|
||||
},
|
||||
"recent": "Gruppi recenti",
|
||||
"starred": "Gruppi speciali",
|
||||
"archived": "Gruppi archiviati",
|
||||
"archive": "Archivia gruppo",
|
||||
"unarchive": "Rimuovi il gruppo dall'archivio",
|
||||
"removeRecent": "Rimuovi dai gruppi recenti",
|
||||
"RecentRemovedToast": {
|
||||
"title": "Il gruppo è stato rimosso",
|
||||
"description": "Il gruppo è stato rimosso dall'elenco dei gruppi recenti.",
|
||||
"undoAlt": "Annulla la rimozione del gruppo",
|
||||
"undo": "Annulla"
|
||||
},
|
||||
"AddByURL": {
|
||||
"button": "Aggiungi tramite URL",
|
||||
"title": "Aggiungi un gruppo tramite URL",
|
||||
"description": "Se un gruppo è stato condiviso con te, puoi incollare qui il suo URL per aggiungerlo al tuo elenco.",
|
||||
"error": "Spiacenti, non siamo in grado di trovare il gruppo dall'URL che hai fornito..."
|
||||
},
|
||||
"NotFound": {
|
||||
"text": "Questo gruppo non esiste.",
|
||||
"link": "Vai ai gruppi visitati di recente"
|
||||
}
|
||||
},
|
||||
"GroupForm": {
|
||||
"title": "Informazioni del gruppo",
|
||||
"NameField": {
|
||||
"label": "Nome del gruppo",
|
||||
"placeholder": "Vacanze estive",
|
||||
"description": "Inserisci il nome del gruppo."
|
||||
},
|
||||
"InformationField": {
|
||||
"label": "Informazioni del gruppo",
|
||||
"placeholder": "Quali informazioni sono rilevanti per i partecipanti al gruppo?"
|
||||
},
|
||||
"CurrencyField": {
|
||||
"label": "Simbolo valuta",
|
||||
"placeholder": "$, €, £…",
|
||||
"description": "Lo useremo per visualizzare gli importi."
|
||||
},
|
||||
"Participants": {
|
||||
"title": "Partecipanti",
|
||||
"description": "Immettere il nome per ciascun partecipante.",
|
||||
"protectedParticipant": "Questo partecipante fa parte delle spese e non può essere rimosso.",
|
||||
"new": "Nuovo",
|
||||
"add": "Aggiungi partecipante",
|
||||
"John": "Fabio",
|
||||
"Jane": "Kaneda",
|
||||
"Jack": "Albano"
|
||||
},
|
||||
"Settings": {
|
||||
"title": "Impostazioni locali",
|
||||
"description": "Queste impostazioni sono impostate per dispositivo e vengono utilizzate per personalizzare la tua esperienza.",
|
||||
"ActiveUserField": {
|
||||
"label": "Utente attivo",
|
||||
"placeholder": "Seleziona un partecipante",
|
||||
"none": "Nessuno",
|
||||
"description": "Utente utilizzato come predefinito per il pagamento delle spese."
|
||||
},
|
||||
"save": "Salva",
|
||||
"saving": "Salvataggio…",
|
||||
"create": "Crea",
|
||||
"creating": "Sto creando…",
|
||||
"cancel": "Annulla"
|
||||
}
|
||||
},
|
||||
"ExpenseForm": {
|
||||
"Income": {
|
||||
"create": "Crea entrata",
|
||||
"edit": "Modifica entrata",
|
||||
"TitleField": {
|
||||
"label": "Titolo entrata",
|
||||
"placeholder": "Ristorante del lunedì sera",
|
||||
"description": "Inserisci una descrizione per l'entrata."
|
||||
},
|
||||
"DateField": {
|
||||
"label": "Data entrata",
|
||||
"description": "Inserisci la data in cui è stato ricevuta l'entrata."
|
||||
},
|
||||
"categoryFieldDescription": "Seleziona categoria entrata.",
|
||||
"paidByField": {
|
||||
"label": "Ricevuto da",
|
||||
"description": "Seleziona partecipante che ha ricevuto l'entrata."
|
||||
},
|
||||
"recurrenceRule": {
|
||||
"label": "Expense Recurrence",
|
||||
"description": "Select how often the expense should repeat.",
|
||||
|
||||
"none": "None",
|
||||
"daily": "Daily",
|
||||
"weekly": "Weekly",
|
||||
"monthly": "Monthly"
|
||||
},
|
||||
"paidFor": {
|
||||
"title": "Ricevuto per",
|
||||
"description": "Seleziona per chi è stato ricevuta l'entrata."
|
||||
},
|
||||
"splitModeDescription": "Seleziona come dividere l'entrata.",
|
||||
"attachDescription": "Vedi allegati entrata."
|
||||
},
|
||||
"Expense": {
|
||||
"create": "Crea spesa",
|
||||
"edit": "Modifica spesa",
|
||||
"TitleField": {
|
||||
"label": "Titolo spesa",
|
||||
"placeholder": "Ristorante del lunedì sera",
|
||||
"description": "Inserisci una descrizione per l'uscita."
|
||||
},
|
||||
"DateField": {
|
||||
"label": "Data spesa",
|
||||
"description": "Inserisci la data di quando è stata fatta la spesa"
|
||||
},
|
||||
"categoryFieldDescription": "Seleziona una categoria per la spesa.",
|
||||
"paidByField": {
|
||||
"label": "Pagato da",
|
||||
"description": "Seleziona il partecipante che ha pagato la spesa."
|
||||
},
|
||||
"paidFor": {
|
||||
"title": "Pagato per",
|
||||
"description": "Seleziona per chi è stata pagato."
|
||||
},
|
||||
"splitModeDescription": "Seleziona come dividere la spesa.",
|
||||
"attachDescription": "Vedi allegati spesa."
|
||||
},
|
||||
"amountField": {
|
||||
"label": "Importo"
|
||||
},
|
||||
"isReimbursementField": {
|
||||
"label": "Questo è un rimborso"
|
||||
},
|
||||
"categoryField": {
|
||||
"label": "Categoria"
|
||||
},
|
||||
"notesField": {
|
||||
"label": "Note"
|
||||
},
|
||||
"selectNone": "Seleziona nessuna",
|
||||
"selectAll": "Seleziona tutto",
|
||||
"shares": "condividi",
|
||||
"advancedOptions": "Opzioni di divisione avanzate…",
|
||||
"SplitModeField": {
|
||||
"label": "Modalità split",
|
||||
"evenly": "Uniforme",
|
||||
"byShares": "Non uniforme – Per quote",
|
||||
"byPercentage": "Non uniforme – Per percentuale",
|
||||
"byAmount": "Non uniforme – Per importo",
|
||||
"saveAsDefault": "Salva come opzione di suddivisione predefinita"
|
||||
},
|
||||
"DeletePopup": {
|
||||
"label": "Rimuovi",
|
||||
"title": "Rimuovere questa spesa?",
|
||||
"description": "Vuoi davvero eliminare questa spesa? Questa azione è irreversibile.",
|
||||
"yes": "Si",
|
||||
"cancel": "Annulla"
|
||||
},
|
||||
"attachDocuments": "Documenti allegati",
|
||||
"create": "Crea",
|
||||
"creating": "Sto creando…",
|
||||
"save": "Salva",
|
||||
"saving": "Sto salvando…",
|
||||
"cancel": "Annulla",
|
||||
"reimbursement": "Rimborso"
|
||||
},
|
||||
"ExpenseDocumentsInput": {
|
||||
"TooBigToast": {
|
||||
"title": "Il file è troppo grande",
|
||||
"description": "La dimensione massima del file che puoi caricare è {maxSize}. Il tuo è {size}."
|
||||
},
|
||||
"ErrorToast": {
|
||||
"title": "Errore durante il caricamento del documento",
|
||||
"description": "Si è verificato un errore durante il caricamento del documento. Riprova più tardi o seleziona un file diverso.",
|
||||
"retry": "Riprova"
|
||||
}
|
||||
},
|
||||
"CreateFromReceipt": {
|
||||
"Dialog": {
|
||||
"triggerTitle": "Crea spesa dalla ricevuta",
|
||||
"title": "Crea dalla ricevuta",
|
||||
"description": "Estrai le informazioni sulla spesa da una foto della ricevuta.",
|
||||
"body": "Carica la foto di una ricevuta e, se possibile, la scannerizzeremo per estrarre le informazioni sulle spese.",
|
||||
"selectImage": "Seleziona immagine…",
|
||||
"titleLabel": "Titolo:",
|
||||
"categoryLabel": "Categoria:",
|
||||
"amountLabel": "Importo:",
|
||||
"dateLabel": "Data:",
|
||||
"editNext": "Successivamente potrai modificare le informazioni sulle spese.",
|
||||
"continue": "Continua"
|
||||
},
|
||||
"unknown": "Sconosciuto",
|
||||
"TooBigToast": {
|
||||
"title": "Il file è troppo grande",
|
||||
"description": "La dimensione massima del file che puoi caricare è {maxSize}. Il tuo è {size}."
|
||||
},
|
||||
"ErrorToast": {
|
||||
"title": "Errore durante il caricamento del documento",
|
||||
"description": "Si è verificato un errore durante il caricamento del documento. Riprova più tardi o seleziona un file diverso.",
|
||||
"retry": "Riprova"
|
||||
}
|
||||
},
|
||||
"Balances": {
|
||||
"title": "Bilanci",
|
||||
"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": "Segna come pagato"
|
||||
}
|
||||
},
|
||||
"Stats": {
|
||||
"title": "Statistiche",
|
||||
"Totals": {
|
||||
"title": "Totali",
|
||||
"description": "Riepilogo delle spese dell'intero gruppo.",
|
||||
"groupSpendings": "Spese totali del gruppo",
|
||||
"groupEarnings": "Guadagno totale del gruppo",
|
||||
"yourSpendings": "Le tue spese totali",
|
||||
"yourEarnings": "I tuoi guadagni totali",
|
||||
"yourShare": "La tua quota totale"
|
||||
}
|
||||
},
|
||||
"Activity": {
|
||||
"title": "Attività",
|
||||
"description": "Panoramica di tutte le attività in questo gruppo.",
|
||||
"noActivity": "Non c'è ancora alcuna attività nel tuo gruppo.",
|
||||
"someone": "Qualcuno",
|
||||
"settingsModified": "Le impostazioni del gruppo sono state modificate da <strong>{participant}</strong>.",
|
||||
"expenseCreated": "Spesa <em>{expense}</em> creata da <strong>{participant}</strong>.",
|
||||
"expenseUpdated": "Spesa <em>{expense}</em> aggiornata da <strong>{participant}</strong>.",
|
||||
"expenseDeleted": "Spesa <em>{expense}</em> cancellata da <strong>{participant}</strong>.",
|
||||
"Groups": {
|
||||
"today": "Oggi",
|
||||
"yesterday": "Ieri",
|
||||
"earlierThisWeek": "All'inizio di questa settimana",
|
||||
"lastWeek": "La settimana scorsa",
|
||||
"earlierThisMonth": "All'inizio di questo mese",
|
||||
"lastMonth": "Lo scorso mese",
|
||||
"earlierThisYear": "All'inizio di questo anno",
|
||||
"lastYear": "Lo scorso anno",
|
||||
"older": "Più vecchio"
|
||||
}
|
||||
},
|
||||
"Information": {
|
||||
"title": "Informazioni",
|
||||
"description": "Utilizza questo posto per aggiungere qualsiasi informazione che possa essere rilevante per i partecipanti al gruppo.",
|
||||
"empty": "Ancora nessuna informazione sul gruppo."
|
||||
},
|
||||
"Settings": {
|
||||
"title": "Impostazioni"
|
||||
},
|
||||
"Share": {
|
||||
"title": "Condividi",
|
||||
"description": "Per consentire agli altri partecipanti di vedere il gruppo e aggiungere spese, condividi il suo URL con loro.",
|
||||
"warning": "Attenzione!",
|
||||
"warningHelp": "Ogni persona con l'URL del gruppo potrà vedere e modificare le spese. Condividi con cautela!"
|
||||
},
|
||||
"SchemaErrors": {
|
||||
"min1": "Inserisci almeno un carattere.",
|
||||
"min2": "Inserisci almeno due caratteri.",
|
||||
"max5": "Inserisci al massimo cinque caratteri.",
|
||||
"max50": "Inserisci al massimo cinquanta caratteri.",
|
||||
"duplicateParticipantName": "Un altro partecipante ha già questo nome.",
|
||||
"titleRequired": "Inserisci un titolo.",
|
||||
"invalidNumber": "Numero invalido.",
|
||||
"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 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."
|
||||
},
|
||||
"Categories": {
|
||||
"search": "Cerca categoria...",
|
||||
"noCategory": "Nessuna categoria trovata.",
|
||||
"Uncategorized": {
|
||||
"heading": "Senza categoria",
|
||||
"General": "Generale",
|
||||
"Payment": "Pagamento"
|
||||
},
|
||||
"Entertainment": {
|
||||
"heading": "Intrattenimento",
|
||||
"Entertainment": "Intrattenimento",
|
||||
"Games": "Giochi",
|
||||
"Movies": "Film",
|
||||
"Music": "Musica",
|
||||
"Sports": "Sport"
|
||||
},
|
||||
"Food and Drink": {
|
||||
"heading": "Cibo e Bevande",
|
||||
"Food and Drink": "Cibo e Bevande",
|
||||
"Dining Out": "Mangiare fuori",
|
||||
"Groceries": "Generi alimentari",
|
||||
"Liquor": "Liquori"
|
||||
},
|
||||
"Home": {
|
||||
"heading": "Home",
|
||||
"Home": "Home",
|
||||
"Electronics": "Elettronica",
|
||||
"Furniture": "Mobilia",
|
||||
"Household Supplies": "Forniture per la casa",
|
||||
"Maintenance": "Manutenzione",
|
||||
"Mortgage": "Mutuo",
|
||||
"Pets": "Animali",
|
||||
"Rent": "Affitti",
|
||||
"Services": "Servizi"
|
||||
},
|
||||
"Life": {
|
||||
"heading": "Life",
|
||||
"Childcare": "Assistenza all'infanzia",
|
||||
"Clothing": "Vestiti",
|
||||
"Education": "Istruzione",
|
||||
"Gifts": "Regali",
|
||||
"Insurance": "Assicurazione",
|
||||
"Medical Expenses": "Spese Mediche",
|
||||
"Taxes": "Tasse"
|
||||
},
|
||||
"Transportation": {
|
||||
"heading": "Trasporti",
|
||||
"Transportation": "Trasporti",
|
||||
"Bicycle": "Bicicletta",
|
||||
"Bus/Train": "Bus/Treno",
|
||||
"Car": "Auto",
|
||||
"Gas/Fuel": "Gas/Carburante",
|
||||
"Hotel": "Hotel",
|
||||
"Parking": "Parcheggio",
|
||||
"Plane": "Aereo",
|
||||
"Taxi": "Taxi"
|
||||
},
|
||||
"Utilities": {
|
||||
"heading": "Utilità",
|
||||
"Utilities": "Utilità",
|
||||
"Cleaning": "Pulizia",
|
||||
"Electricity": "Elettricità",
|
||||
"Heat/Gas": "Riscaldamento/Gas",
|
||||
"Trash": "Spazzatura",
|
||||
"TV/Phone/Internet": "TV/Telefono/Internet",
|
||||
"Water": "Acqua"
|
||||
}
|
||||
}
|
||||
}
|
||||
389
messages/nl-NL.json
Normal file
@@ -0,0 +1,389 @@
|
||||
{
|
||||
"Homepage": {
|
||||
"title": "Deel <strong>Uitgaven</strong> met <strong>Vrienden & Familie</strong>",
|
||||
"description": "Welkom op je nieuwe <strong>Spliit</strong>-instantie!",
|
||||
"button": {
|
||||
"groups": "Ga naar groepen",
|
||||
"github": "GitHub"
|
||||
}
|
||||
},
|
||||
"Header": {
|
||||
"groups": "Groepen"
|
||||
},
|
||||
"Footer": {
|
||||
"madeIn": "Gemaakt in Montréal, Québec 🇨🇦",
|
||||
"builtBy": "Geschreven door <author>Sebastien Castiel</author> en <source>bijdragers</source>"
|
||||
},
|
||||
"Expenses": {
|
||||
"title": "Uitgaven",
|
||||
"description": "Dit zijn de uitgaven die je gemaakt hebt voor je groep.",
|
||||
"create": "Maak uitgave",
|
||||
"createFirst": "Maak de eerste",
|
||||
"noExpenses": "Je groep heeft nog geen uitgaven.",
|
||||
"exportJson": "Exporteer naar JSON",
|
||||
"exportCsv": "Exporteer naar CSV",
|
||||
"searchPlaceholder": "Zoek naar een uitgave…",
|
||||
"ActiveUserModal": {
|
||||
"title": "Wie ben jij?",
|
||||
"description": "Zeg ons welke deelnemer je bent zodat wij persoonlijke informatie kunnen aantonen.",
|
||||
"nobody": "Ik wil niemand selecteren",
|
||||
"save": "Sla op",
|
||||
"footer": "Deze instelling kan later worden gewijzigd in de instellingen van de groep."
|
||||
},
|
||||
"Groups": {
|
||||
"upcoming": "Aankomend",
|
||||
"thisWeek": "Deze week",
|
||||
"earlierThisMonth": "Eerder deze maand",
|
||||
"lastMonth": "Vorige maand",
|
||||
"earlierThisYear": "Eerder dit jaar",
|
||||
"lastYear": "Vorig jaar",
|
||||
"older": "Ouder"
|
||||
}
|
||||
},
|
||||
"ExpenseCard": {
|
||||
"paidBy": "Betaald door <strong>{paidBy}</strong> voor <paidFor></paidFor>",
|
||||
"receivedBy": "Ontvangen door <strong>{paidBy}</strong> voor <paidFor></paidFor>",
|
||||
"yourBalance": "Jouw balans:"
|
||||
},
|
||||
"Groups": {
|
||||
"myGroups": "Mijn groepen",
|
||||
"create": "Maak",
|
||||
"loadingRecent": "Recente groepen laden…",
|
||||
"NoRecent": {
|
||||
"description": "Je hebt de laatste tijd geen groepen bezocht.",
|
||||
"create": "Maak er één",
|
||||
"orAsk": "of vraag een vriend om je de link naar een bestaande groep te sturen."
|
||||
},
|
||||
"recent": "Recente groepen",
|
||||
"starred": "Favoriete groepen",
|
||||
"archived": "Gearchiveerde groepen",
|
||||
"archive": "Archiveer groep",
|
||||
"unarchive": "Herstel groep",
|
||||
"removeRecent": "Verwijder uit recente groepen",
|
||||
"RecentRemovedToast": {
|
||||
"title": "Groep verwijderd",
|
||||
"description": "Deze groep is verwijderd uit je recente groepen.",
|
||||
"undoAlt": "Maak het verwijderen van de groep ongedaan",
|
||||
"undo": "Ongedaan maken"
|
||||
},
|
||||
"AddByURL": {
|
||||
"button": "Voeg toe met URL",
|
||||
"title": "Voeg een groep toe met een URL",
|
||||
"description": "Als een groep met je gedeeld is, kun je de URL hier plakken om deze aan je lijst toe te voegen.",
|
||||
"error": "Oeps, we kunnen de groep niet vinden met de URL die je hebt opgegeven…"
|
||||
},
|
||||
"NotFound": {
|
||||
"text": "Deze groep bestaat niet.",
|
||||
"link": "Ga naar je recente groepen"
|
||||
}
|
||||
},
|
||||
"GroupForm": {
|
||||
"title": "Groepsinformatie",
|
||||
"NameField": {
|
||||
"label": "Groepsnaam",
|
||||
"placeholder": "Zomervakantie",
|
||||
"description": "Geef je groep een naam."
|
||||
},
|
||||
"InformationField": {
|
||||
"label": "Groepsinformatie",
|
||||
"placeholder": "Welke informatie is relevant voor de groep?"
|
||||
},
|
||||
"CurrencyField": {
|
||||
"label": "Symbool van de valuta",
|
||||
"placeholder": "€, $, £…",
|
||||
"description": "Die gebruiken we om de bedragen in de groep aan te geven."
|
||||
},
|
||||
"Participants": {
|
||||
"title": "Deelnemers",
|
||||
"description": "Voer de naam in van de deelnemers in de groep.",
|
||||
"protectedParticipant": "Deze deelnemer maakt deel uit van de uitgaven en kan niet worden verwijderd.",
|
||||
"new": "Nieuwe deelnemer",
|
||||
"add": "Voeg deelnemer toe",
|
||||
"John": "Jan",
|
||||
"Jane": "Julia",
|
||||
"Jack": "Jacob"
|
||||
},
|
||||
"Settings": {
|
||||
"title": "Lokale instellingen",
|
||||
"description": "Deze instellingen worden per apparaat ingesteld en worden gebruikt om je ervaring aan te passen.",
|
||||
"ActiveUserField": {
|
||||
"label": "Huidige gebruiker",
|
||||
"placeholder": "Selecteer een deelnemer",
|
||||
"none": "Geen",
|
||||
"description": "De deelnemer die automatisch wordt geselecteerd als je een uitgave maakt."
|
||||
},
|
||||
"save": "Sla op",
|
||||
"saving": "Opslaan…",
|
||||
"create": "Maak groep",
|
||||
"creating": "Maken…",
|
||||
"cancel": "Annuleer"
|
||||
}
|
||||
},
|
||||
"ExpenseForm": {
|
||||
"Income": {
|
||||
"create": "Maak inkomen",
|
||||
"edit": "Bewerk inkomen",
|
||||
"TitleField": {
|
||||
"label": "Titel inkomen",
|
||||
"placeholder": "Restaurant maandagavond",
|
||||
"description": "Voer een beschrijving in voor het inkomen."
|
||||
},
|
||||
"DateField": {
|
||||
"label": "Datum inkomen",
|
||||
"description": "Voer de datum in waarop het inkomen is ontvangen."
|
||||
},
|
||||
"categoryFieldDescription": "Selecteer de inkomencategorie.",
|
||||
"paidByField": {
|
||||
"label": "Ontvangen door",
|
||||
"description": "Selecteer de deelnemer die het inkomen heeft ontvangen."
|
||||
},
|
||||
"paidFor": {
|
||||
"title": "Ontvangen voor",
|
||||
"description": "Selecteer voor wie het inkomen is ontvangen."
|
||||
},
|
||||
"splitModeDescription": "Selecteer hoe het inkomen verdeeld moet worden.",
|
||||
"attachDescription": "Bekijk en voeg bijlagen toe aan het inkomen."
|
||||
},
|
||||
"Expense": {
|
||||
"create": "Maak uitgave",
|
||||
"edit": "Bewerk uitgave",
|
||||
"TitleField": {
|
||||
"label": "Titel uitgave",
|
||||
"placeholder": "Restaurant maandagavond",
|
||||
"description": "Voer een beschrijving in voor de uitgave."
|
||||
},
|
||||
"DateField": {
|
||||
"label": "Datum uitgave",
|
||||
"description": "Voer de datum in waarop de uitgave is gedaan."
|
||||
},
|
||||
"categoryFieldDescription": "Selecteer de uitgavecategorie.",
|
||||
"paidByField": {
|
||||
"label": "Betaald door",
|
||||
"description": "Selecteer de deelnemer die de uitgave heeft gedaan."
|
||||
},
|
||||
"paidFor": {
|
||||
"title": "Betaald voor",
|
||||
"description": "Selecteer voor wie de uitgave is gedaan."
|
||||
},
|
||||
"splitModeDescription": "Selecteer hoe de uitgave verdeeld moet worden.",
|
||||
"attachDescription": "Bekijk en voeg bijlagen toe aan de uitgave."
|
||||
},
|
||||
"amountField": {
|
||||
"label": "Bedrag"
|
||||
},
|
||||
"isReimbursementField": {
|
||||
"label": "Dit is een terugbetaling"
|
||||
},
|
||||
"categoryField": {
|
||||
"label": "Categorie"
|
||||
},
|
||||
"notesField": {
|
||||
"label": "Notities"
|
||||
},
|
||||
"selectNone": "Selecteer niemand",
|
||||
"selectAll": "Selecteer iedereen",
|
||||
"shares": "deel/delen",
|
||||
"advancedOptions": "Geavanceerde split-opties",
|
||||
"SplitModeField": {
|
||||
"label": "Split soort",
|
||||
"evenly": "Gelijk verdeeld",
|
||||
"byShares": "Ongelijk – Met delen",
|
||||
"byPercentage": "Ongelijk – Met percentage",
|
||||
"byAmount": "Ongelijk – Met bedrag",
|
||||
"saveAsDefault": "Sla op als standaard-optie"
|
||||
},
|
||||
"DeletePopup": {
|
||||
"label": "Verwijderen",
|
||||
"title": "Deze uitgave verwijderen?",
|
||||
"description": "Wil je deze uitgave echt verwijderen?",
|
||||
"yes": "Ja",
|
||||
"cancel": "Annuleer"
|
||||
},
|
||||
"attachDocuments": "Voeg documenten toe",
|
||||
"create": "Maak",
|
||||
"creating": "Maken…",
|
||||
"save": "Sla op",
|
||||
"saving": "Opslaan…",
|
||||
"cancel": "Annuleer",
|
||||
"reimbursement": "Terugbetaling"
|
||||
},
|
||||
"ExpenseDocumentsInput": {
|
||||
"TooBigToast": {
|
||||
"title": "Het bestand is te groot",
|
||||
"description": "De maximum bestandsgrootte {maxSize}. Jouw bestand is {size}."
|
||||
},
|
||||
"ErrorToast": {
|
||||
"title": "Fout bij het uploaden van document",
|
||||
"description": "Er is iets mis gegaan bij het uploaden van het document. Probeer het later opnieuw of kies een ander bestand.",
|
||||
"retry": "Probeer opnieuw"
|
||||
}
|
||||
},
|
||||
"CreateFromReceipt": {
|
||||
"Dialog": {
|
||||
"triggerTitle": "Uitgave maken van foto",
|
||||
"title": "Maak uitgave van foto",
|
||||
"description": "Uitgave-informatie van een foto van een bon lezen.",
|
||||
"body": "Upload de foto van een bon, en we lezen de uitgave-informatie eruit.",
|
||||
"selectImage": "Selecteer foto…",
|
||||
"titleLabel": "Titel:",
|
||||
"categoryLabel": "Categorie:",
|
||||
"amountLabel": "Bedrag:",
|
||||
"dateLabel": "Datum:",
|
||||
"editNext": "Hierna kun je de uitgave-informatie bewerken.",
|
||||
"continue": "Doorgaan"
|
||||
},
|
||||
"unknown": "Onbekend",
|
||||
"TooBigToast": {
|
||||
"title": "Het bestand is te groot",
|
||||
"description": "De maximum bestandsgrootte {maxSize}. Jouw bestand is {size}."
|
||||
},
|
||||
"ErrorToast": {
|
||||
"title": "Fout bij het uploaden van document",
|
||||
"description": "Er is iets mis gegaan bij het uploaden van het document. Probeer het later opnieuw of kies een ander bestand.",
|
||||
"retry": "Probeer opnieuw"
|
||||
}
|
||||
},
|
||||
"Balances": {
|
||||
"title": "Balans",
|
||||
"description": "Dit zijn de bedragen die elke deelnemer heeft betaald of waarvoor is betaald.",
|
||||
"Reimbursements": {
|
||||
"title": "Voorgestelde terugbetalingen",
|
||||
"description": "Dit zijn de voorgestelde terugbetalingen tussen deelnemers.",
|
||||
"noImbursements": "Lijkt erop dat je groep geen terugbetalingen nodig heeft 😁",
|
||||
"owes": "<strong>{from}</strong> betaalt aan <strong>{to}</strong>",
|
||||
"markAsPaid": "Markeer als betaald"
|
||||
}
|
||||
},
|
||||
"Stats": {
|
||||
"title": "Statistieken",
|
||||
"Totals": {
|
||||
"title": "Totaaluitgaven",
|
||||
"description": "Uitgavenoverzicht van de hele groep.",
|
||||
"groupSpendings": "Totale uitgaven van de groep",
|
||||
"groupEarnings": "Totale inkomsten van de groep",
|
||||
"yourSpendings": "Jouw totale uitgaven",
|
||||
"yourEarnings": "Jouw totale inkomsten",
|
||||
"yourShare": "Jouw totale aandeel"
|
||||
}
|
||||
},
|
||||
"Activity": {
|
||||
"title": "Gebeurtenissen",
|
||||
"description": "Overzicht van de gebeurtenissen in je groep.",
|
||||
"noActivity": "Er zijn geen gebeurtenissen in deze groep.",
|
||||
"someone": "Iemand",
|
||||
"settingsModified": "Groepsinstellingen zijn aangepast door <strong>{participant}</strong>.",
|
||||
"expenseCreated": "Uitgave <em>{expense}</em> gemaakt door <strong>{participant}</strong>.",
|
||||
"expenseUpdated": "Uitgave <em>{expense}</em> bewerkt door <strong>{participant}</strong>.",
|
||||
"expenseDeleted": "Uitgave <em>{expense}</em> verwijderd door <strong>{participant}</strong>.",
|
||||
"Groups": {
|
||||
"today": "Vandaag",
|
||||
"yesterday": "Gisteren",
|
||||
"earlierThisWeek": "Eerder deze week",
|
||||
"lastWeek": "Vorige week",
|
||||
"earlierThisMonth": "Eerder deze maand",
|
||||
"lastMonth": "Vorige maand",
|
||||
"earlierThisYear": "Eerder dit jaar",
|
||||
"lastYear": "Vorig jaar",
|
||||
"older": "Ouder"
|
||||
}
|
||||
},
|
||||
"Information": {
|
||||
"title": "Informatie",
|
||||
"description": "Gebruike deze plek om informatie toe te voegen die relevant kan zijn voor de groepsleden.",
|
||||
"empty": "Nog geen informatie toegevoegd."
|
||||
},
|
||||
"Settings": {
|
||||
"title": "Instellingen"
|
||||
},
|
||||
"Share": {
|
||||
"title": "Delen",
|
||||
"description": "Om andere deelnemers de groep te laten zien en uitgaven toe te voegen, deel je de URL met hen.",
|
||||
"warning": "Waarschuwing!",
|
||||
"warningHelp": "Iedereen met de groeps-URL kan de uitgaven zien en bewerken. Deel voorzichtig!"
|
||||
},
|
||||
"SchemaErrors": {
|
||||
"min1": "Vul ten minste één karakter in.",
|
||||
"min2": "Vul ten minste twee karakters in.",
|
||||
"max5": "Vul maximaal vijf karakters in.",
|
||||
"max50": "Vul maximaal 50 karakters in.",
|
||||
"duplicateParticipantName": "Er is al een deelnemer met deze naam.",
|
||||
"titleRequired": "Vul een titel in.",
|
||||
"invalidNumber": "Ongeldig getal.",
|
||||
"amountRequired": "Vul een bedrag in.",
|
||||
"amountNotZero": "Het bedrag moet hoger zijn dan 0.",
|
||||
"amountTenMillion": "Het bedrag mag niet hoger zijn dan 10,000,000.",
|
||||
"paidByRequired": "Selecteer een deelnemer die de uitgave heeft gedaan.",
|
||||
"paidForMin1": "De uitgave moet voor ten minste één deelnemer zijn gedaan.",
|
||||
"noZeroShares": "Een deel mag niet 0 zijn.",
|
||||
"amountSum": "Het totaalbedrag moet gelijk zijn aan het uitgavebedrag.",
|
||||
"percentageSum": "Het totaalpercentage moet gelijk zijn aan 100%."
|
||||
},
|
||||
"Categories": {
|
||||
"search": "Categorie zoeken…",
|
||||
"noCategory": "Geen categorieën gevonden.",
|
||||
"Uncategorized": {
|
||||
"heading": "Geen categorie",
|
||||
"General": "Algemeen",
|
||||
"Payment": "Betaling"
|
||||
},
|
||||
"Entertainment": {
|
||||
"heading": "Vermaak",
|
||||
"Entertainment": "Vermaak",
|
||||
"Games": "Games",
|
||||
"Movies": "Film",
|
||||
"Music": "Muziek",
|
||||
"Sports": "Sport"
|
||||
},
|
||||
"Food and Drink": {
|
||||
"heading": "Eten en Drinken",
|
||||
"Food and Drink": "Eten en Drinken",
|
||||
"Dining Out": "Uit eten",
|
||||
"Groceries": "Boodschappen",
|
||||
"Liquor": "Drank"
|
||||
},
|
||||
"Home": {
|
||||
"heading": "Thuis",
|
||||
"Home": "Thuis",
|
||||
"Electronics": "Elektronica",
|
||||
"Furniture": "Meubels",
|
||||
"Household Supplies": "Huishoudelijke artikelen",
|
||||
"Maintenance": "Onderhoud",
|
||||
"Mortgage": "Hypotheek",
|
||||
"Pets": "Huisdieren",
|
||||
"Rent": "Huur",
|
||||
"Services": "Diensten"
|
||||
},
|
||||
"Life": {
|
||||
"heading": "Leven",
|
||||
"Childcare": "Kinderopvang",
|
||||
"Clothing": "Kleding",
|
||||
"Education": "Onderwijs",
|
||||
"Gifts": "Cadeaus",
|
||||
"Insurance": "Verzekering",
|
||||
"Medical Expenses": "Medische kosten",
|
||||
"Taxes": "Belastingen"
|
||||
},
|
||||
"Transportation": {
|
||||
"heading": "Vervoer",
|
||||
"Transportation": "Vervoer",
|
||||
"Bicycle": "Fiets",
|
||||
"Bus/Train": "Bus/Trein",
|
||||
"Car": "Auto",
|
||||
"Gas/Fuel": "Tanken",
|
||||
"Hotel": "Hotel",
|
||||
"Parking": "Parkeren",
|
||||
"Plane": "Vliegtuig",
|
||||
"Taxi": "Taxi"
|
||||
},
|
||||
"Utilities": {
|
||||
"heading": "Nutsvoorzieningen",
|
||||
"Utilities": "Nutsvoorzieningen",
|
||||
"Cleaning": "Schoonmaak",
|
||||
"Electricity": "Elektriciteit",
|
||||
"Heat/Gas": "Verwarming/Gas",
|
||||
"Trash": "Afval",
|
||||
"TV/Phone/Internet": "Internet/TV/Telefoon",
|
||||
"Water": "Water"
|
||||
}
|
||||
}
|
||||
}
|
||||
398
messages/pl-PL.json
Normal file
@@ -0,0 +1,398 @@
|
||||
{
|
||||
"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",
|
||||
"exportCsv": "Eksportuj do CSVa",
|
||||
"searchPlaceholder": "Szukaj wydatku...",
|
||||
"ActiveUserModal": {
|
||||
"title": "Kim jesteś?",
|
||||
"description": "Podaj, którym uczestnikiem jesteś aby pozwolić nam określić jakie informacje mają być wyświetlane.",
|
||||
"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",
|
||||
"lastYear": "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 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 kwot."
|
||||
},
|
||||
"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ą zapisywane dla tego urządzenia i służą do dostosowania twoich doświadczeń z aplikacją.",
|
||||
"ActiveUserField": {
|
||||
"label": "Aktywny użytkownik",
|
||||
"placeholder": "Wybierz użytkownika",
|
||||
"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."
|
||||
},
|
||||
"recurrenceRule": {
|
||||
"label": "Expense Recurrence",
|
||||
"description": "Select how often the expense should repeat.",
|
||||
|
||||
"none": "None",
|
||||
"daily": "Daily",
|
||||
"weekly": "Weekly",
|
||||
"monthly": "Monthly"
|
||||
},
|
||||
"paidFor": {
|
||||
"title": "Otrzymany dla",
|
||||
"description": "Podaj dla kogo wpływ był przeznaczony."
|
||||
},
|
||||
"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ę 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": "Suma"
|
||||
},
|
||||
"isReimbursementField": {
|
||||
"label": "Oznacz jako zwrot kosztów"
|
||||
},
|
||||
"categoryField": {
|
||||
"label": "Kategoria"
|
||||
},
|
||||
"notesField": {
|
||||
"label": "Notatki"
|
||||
},
|
||||
"selectNone": "Nie wybieraj nikogo",
|
||||
"selectAll": "Wybierz wszystkich",
|
||||
"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",
|
||||
"reimbursement": "Zwrot środków"
|
||||
},
|
||||
"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": "Suma:",
|
||||
"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",
|
||||
"lastYear": "Poprzedni rok",
|
||||
"older": "Starsze"
|
||||
}
|
||||
},
|
||||
"Information": {
|
||||
"title": "Informacje",
|
||||
"description": "Użyj tego miejsca, aby dodać wszelkie informacje, które mogą być istotne dla uczestników grupy..",
|
||||
"empty": "Jeszcze nic tu nie ma."
|
||||
},
|
||||
"Settings": {
|
||||
"title": "Ustawienia"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
389
messages/pt-BR.json
Normal file
@@ -0,0 +1,389 @@
|
||||
{
|
||||
"Homepage": {
|
||||
"title": "Compartilhe <strong>Despesas</strong> com <strong>Amigos e Família</strong>",
|
||||
"description": "Bem-vindo à sua nova instalação do <strong>Spliit</strong>!",
|
||||
"button": {
|
||||
"groups": "Ir para grupos",
|
||||
"github": "GitHub"
|
||||
}
|
||||
},
|
||||
"Header": {
|
||||
"groups": "Grupos"
|
||||
},
|
||||
"Footer": {
|
||||
"madeIn": "Feito em Montréal, Québec 🇨🇦",
|
||||
"builtBy": "Desenvolvido por <author>Sebastien Castiel</author> e <source>contribuidores</source>"
|
||||
},
|
||||
"Expenses": {
|
||||
"title": "Despesas",
|
||||
"description": "Aqui estão as despesas que você criou para o seu grupo.",
|
||||
"create": "Criar despesa",
|
||||
"createFirst": "Crie a primeira",
|
||||
"noExpenses": "Seu grupo ainda não contém nenhuma despesa.",
|
||||
"exportJson": "Exportar para JSON",
|
||||
"exportCsv": "Exportar para CSV",
|
||||
"searchPlaceholder": "Pesquisar por uma despesa…",
|
||||
"ActiveUserModal": {
|
||||
"title": "Quem é você?",
|
||||
"description": "Informe qual participante você é para personalizarmos a exibição das informações.",
|
||||
"nobody": "Não quero selecionar ninguém",
|
||||
"save": "Salvar alterações",
|
||||
"footer": "Essa configuração pode ser alterada posteriormente nas configurações do grupo."
|
||||
},
|
||||
"Groups": {
|
||||
"upcoming": "Próximas",
|
||||
"thisWeek": "Esta semana",
|
||||
"earlierThisMonth": "Anteriores neste mês",
|
||||
"lastMonth": "Mês passado",
|
||||
"earlierThisYear": "Anteriores neste ano",
|
||||
"lastYear": "Ano passado",
|
||||
"older": "Mais antigas"
|
||||
}
|
||||
},
|
||||
"ExpenseCard": {
|
||||
"paidBy": "Pago por <strong>{paidBy}</strong> para <paidFor></paidFor>",
|
||||
"receivedBy": "Recebido por <strong>{paidBy}</strong> para <paidFor></paidFor>",
|
||||
"yourBalance": "Seu saldo:"
|
||||
},
|
||||
"Groups": {
|
||||
"myGroups": "Meus grupos",
|
||||
"create": "Criar",
|
||||
"loadingRecent": "Carregando grupos recentes…",
|
||||
"NoRecent": {
|
||||
"description": "Você não visitou nenhum grupo recentemente.",
|
||||
"create": "Crie um",
|
||||
"orAsk": "ou peça a um amigo para enviar o link de um existente."
|
||||
},
|
||||
"recent": "Grupos recentes",
|
||||
"starred": "Grupos favoritos",
|
||||
"archived": "Grupos arquivados",
|
||||
"archive": "Arquivar grupo",
|
||||
"unarchive": "Desarquivar grupo",
|
||||
"removeRecent": "Remover dos grupos recentes",
|
||||
"RecentRemovedToast": {
|
||||
"title": "Grupo removido",
|
||||
"description": "O grupo foi removido da sua lista de grupos recentes.",
|
||||
"undoAlt": "Desfazer remoção do grupo",
|
||||
"undo": "Desfazer"
|
||||
},
|
||||
"AddByURL": {
|
||||
"button": "Adicionar por URL",
|
||||
"title": "Adicionar um grupo por URL",
|
||||
"description": "Se um grupo foi compartilhado com você, você pode colar sua URL aqui para adicioná-lo à sua lista.",
|
||||
"error": "Ops, não conseguimos encontrar o grupo a partir da URL fornecida…"
|
||||
},
|
||||
"NotFound": {
|
||||
"text": "Este grupo não existe.",
|
||||
"link": "Ir para grupos visitados recentemente"
|
||||
}
|
||||
},
|
||||
"GroupForm": {
|
||||
"title": "Informações do grupo",
|
||||
"NameField": {
|
||||
"label": "Nome do grupo",
|
||||
"placeholder": "Férias de verão",
|
||||
"description": "Insira um nome para o seu grupo."
|
||||
},
|
||||
"InformationField": {
|
||||
"label": "Informações do grupo",
|
||||
"placeholder": "Quais informações são relevantes para os participantes do grupo?"
|
||||
},
|
||||
"CurrencyField": {
|
||||
"label": "Símbolo da moeda",
|
||||
"placeholder": "$, €, £, R$…",
|
||||
"description": "Vamos usá-lo para exibir valores."
|
||||
},
|
||||
"Participants": {
|
||||
"title": "Participantes",
|
||||
"description": "Insira o nome de cada participante.",
|
||||
"protectedParticipant": "Este participante faz parte das despesas e não pode ser removido.",
|
||||
"new": "Novo",
|
||||
"add": "Adicionar participante",
|
||||
"John": "João",
|
||||
"Jane": "Maria",
|
||||
"Jack": "José"
|
||||
},
|
||||
"Settings": {
|
||||
"title": "Configurações locais",
|
||||
"description": "Essas configurações são definidas por dispositivo e são usadas para personalizar sua experiência.",
|
||||
"ActiveUserField": {
|
||||
"label": "Usuário ativo",
|
||||
"placeholder": "Selecione um participante",
|
||||
"none": "Nenhum",
|
||||
"description": "Usuário usado como padrão para pagar despesas."
|
||||
},
|
||||
"save": "Salvar",
|
||||
"saving": "Salvando…",
|
||||
"create": "Criar",
|
||||
"creating": "Criando…",
|
||||
"cancel": "Cancelar"
|
||||
}
|
||||
},
|
||||
"ExpenseForm": {
|
||||
"Income": {
|
||||
"create": "Criar receita",
|
||||
"edit": "Editar receita",
|
||||
"TitleField": {
|
||||
"label": "Título da receita",
|
||||
"placeholder": "Restaurante na segunda à noite",
|
||||
"description": "Insira uma descrição para a receita."
|
||||
},
|
||||
"DateField": {
|
||||
"label": "Data da receita",
|
||||
"description": "Insira a data em que a receita foi recebida."
|
||||
},
|
||||
"categoryFieldDescription": "Selecione a categoria da receita.",
|
||||
"paidByField": {
|
||||
"label": "Recebido por",
|
||||
"description": "Selecione o participante que recebeu a receita."
|
||||
},
|
||||
"paidFor": {
|
||||
"title": "Recebido para",
|
||||
"description": "Selecione para quem a receita foi recebida."
|
||||
},
|
||||
"splitModeDescription": "Selecione como dividir a receita.",
|
||||
"attachDescription": "Veja e anexe recibos à receita."
|
||||
},
|
||||
"Expense": {
|
||||
"create": "Criar despesa",
|
||||
"edit": "Editar despesa",
|
||||
"TitleField": {
|
||||
"label": "Título da despesa",
|
||||
"placeholder": "Restaurante na segunda à noite",
|
||||
"description": "Insira uma descrição para a despesa."
|
||||
},
|
||||
"DateField": {
|
||||
"label": "Data da despesa",
|
||||
"description": "Insira a data em que a despesa foi paga."
|
||||
},
|
||||
"categoryFieldDescription": "Selecione a categoria da despesa.",
|
||||
"paidByField": {
|
||||
"label": "Pago por",
|
||||
"description": "Selecione o participante que pagou a despesa."
|
||||
},
|
||||
"paidFor": {
|
||||
"title": "Pago para",
|
||||
"description": "Selecione para quem a despesa foi paga."
|
||||
},
|
||||
"splitModeDescription": "Selecione como dividir a despesa.",
|
||||
"attachDescription": "Veja e anexe recibos à despesa."
|
||||
},
|
||||
"amountField": {
|
||||
"label": "Valor"
|
||||
},
|
||||
"isReimbursementField": {
|
||||
"label": "Isso é um reembolso"
|
||||
},
|
||||
"categoryField": {
|
||||
"label": "Categoria"
|
||||
},
|
||||
"notesField": {
|
||||
"label": "Notas"
|
||||
},
|
||||
"selectNone": "Remover seleção",
|
||||
"selectAll": "Selecionar todos(as)",
|
||||
"shares": "parte(s)",
|
||||
"advancedOptions": "Opções avançadas de divisão…",
|
||||
"SplitModeField": {
|
||||
"label": "Modo de divisão",
|
||||
"evenly": "Igualmente",
|
||||
"byShares": "Desigualmente - Por partes",
|
||||
"byPercentage": "Desigualmente - Por porcentagem",
|
||||
"byAmount": "Desigualmente - Por valor",
|
||||
"saveAsDefault": "Salvar como opções de divisão padrão"
|
||||
},
|
||||
"DeletePopup": {
|
||||
"label": "Excluir",
|
||||
"title": "Excluir esta despesa?",
|
||||
"description": "Você realmente deseja excluir esta despesa? Esta ação é irreversível.",
|
||||
"yes": "Sim",
|
||||
"cancel": "Cancelar"
|
||||
},
|
||||
"attachDocuments": "Anexar documentos",
|
||||
"create": "Criar",
|
||||
"creating": "Criando…",
|
||||
"save": "Salvar",
|
||||
"saving": "Salvando…",
|
||||
"cancel": "Cancelar",
|
||||
"reimbursement": "Reembolso"
|
||||
},
|
||||
"ExpenseDocumentsInput": {
|
||||
"TooBigToast": {
|
||||
"title": "O arquivo é muito grande",
|
||||
"description": "O tamanho máximo de arquivo que você pode enviar é {maxSize}. O seu é ${size}."
|
||||
},
|
||||
"ErrorToast": {
|
||||
"title": "Erro ao enviar documento",
|
||||
"description": "Algo deu errado ao enviar o documento. Por favor, tente novamente mais tarde ou selecione um arquivo diferente.",
|
||||
"retry": "Tentar novamente"
|
||||
}
|
||||
},
|
||||
"CreateFromReceipt": {
|
||||
"Dialog": {
|
||||
"triggerTitle": "Criar despesa a partir de recibo",
|
||||
"title": "Criar a partir de recibo",
|
||||
"description": "Extraia as informações da despesa a partir de uma foto de recibo.",
|
||||
"body": "Faça upload da foto de um recibo, e vamos escaneá-la para extrair as informações da despesa, se possível.",
|
||||
"selectImage": "Selecionar imagem…",
|
||||
"titleLabel": "Título:",
|
||||
"categoryLabel": "Categoria:",
|
||||
"amountLabel": "Valor:",
|
||||
"dateLabel": "Data:",
|
||||
"editNext": "Você poderá editar as informações da despesa a seguir.",
|
||||
"continue": "Continuar"
|
||||
},
|
||||
"unknown": "Desconhecido",
|
||||
"TooBigToast": {
|
||||
"title": "O arquivo é muito grande",
|
||||
"description": "O tamanho máximo de arquivo que você pode enviar é {maxSize}. O seu é ${size}."
|
||||
},
|
||||
"ErrorToast": {
|
||||
"title": "Erro ao enviar documento",
|
||||
"description": "Algo deu errado ao enviar o documento. Por favor, tente novamente mais tarde ou selecione um arquivo diferente.",
|
||||
"retry": "Tentar novamente"
|
||||
}
|
||||
},
|
||||
"Balances": {
|
||||
"title": "Saldos",
|
||||
"description": "Este é o valor que cada participante pagou ou recebeu.",
|
||||
"Reimbursements": {
|
||||
"title": "Reembolsos sugeridos",
|
||||
"description": "Aqui estão sugestões para reembolsos otimizados entre os participantes.",
|
||||
"noImbursements": "Parece que seu grupo não precisa de nenhum reembolso 😁",
|
||||
"owes": "<strong>{from}</strong> deve <strong>{to}</strong>",
|
||||
"markAsPaid": "Marcar como pago"
|
||||
}
|
||||
},
|
||||
"Stats": {
|
||||
"title": "Estatísticas",
|
||||
"Totals": {
|
||||
"title": "Totais",
|
||||
"description": "Resumo de gastos de todo o grupo.",
|
||||
"groupSpendings": "Total de gastos do grupo",
|
||||
"groupEarnings": "Total de receitas do grupo",
|
||||
"yourSpendings": "Seus gastos totais",
|
||||
"yourEarnings": "Suas receitas totais",
|
||||
"yourShare": "Sua participação total"
|
||||
}
|
||||
},
|
||||
"Activity": {
|
||||
"title": "Atividade",
|
||||
"description": "Visão geral de toda a atividade neste grupo.",
|
||||
"noActivity": "Ainda não há atividades no seu grupo.",
|
||||
"someone": "Alguém",
|
||||
"settingsModified": "As configurações do grupo foram modificadas por <strong>{participant}</strong>.",
|
||||
"expenseCreated": "Despesa {expense} criada por <strong>{participant}</strong>.",
|
||||
"expenseUpdated": "Despesa {expense} atualizada por <strong>{participant}</strong>.",
|
||||
"expenseDeleted": "Despesa {expense} excluída por <strong>{participant}</strong>.",
|
||||
"Groups": {
|
||||
"today": "Hoje",
|
||||
"yesterday": "Ontem",
|
||||
"earlierThisWeek": "Anteriormente nesta semana",
|
||||
"lastWeek": "Semana passada",
|
||||
"earlierThisMonth": "Anteriormente neste mês",
|
||||
"lastMonth": "Mês passado",
|
||||
"earlierThisYear": "Anteriormente neste ano",
|
||||
"lastYear": "Ano passado",
|
||||
"older": "Mais antigas"
|
||||
}
|
||||
},
|
||||
"Information": {
|
||||
"title": "Informação",
|
||||
"description": "Use este espaço para adicionar qualquer informação que possa ser relevante para os participantes do grupo.",
|
||||
"empty": "Nenhuma informação do grupo ainda."
|
||||
},
|
||||
"Settings": {
|
||||
"title": "Configurações"
|
||||
},
|
||||
"Share": {
|
||||
"title": "Compartilhar",
|
||||
"description": "Para que outros participantes vejam o grupo e adicionem despesas, compartilhe o link com eles.",
|
||||
"warning": "Aviso!",
|
||||
"warningHelp": "Toda pessoa com o link do grupo poderá ver e editar despesas. Compartilhe com cautela!"
|
||||
},
|
||||
"SchemaErrors": {
|
||||
"min1": "Digite pelo menos um caractere.",
|
||||
"min2": "Digite pelo menos dois caracteres.",
|
||||
"max5": "Digite no máximo cinco caracteres.",
|
||||
"max50": "Digite no máximo 50 caracteres.",
|
||||
"duplicateParticipantName": "Outro participante já tem este nome.",
|
||||
"titleRequired": "Por favor, insira um título.",
|
||||
"invalidNumber": "Número inválido.",
|
||||
"amountRequired": "Você deve inserir um valor.",
|
||||
"amountNotZero": "O valor não deve ser zero.",
|
||||
"amountTenMillion": "O valor deve ser inferior a 10.000.000.",
|
||||
"paidByRequired": "Você deve selecionar um participante.",
|
||||
"paidForMin1": "A despesa deve ser paga para pelo menos um participante.",
|
||||
"noZeroShares": "Todas as partes devem ser maiores que 0.",
|
||||
"amountSum": "A soma dos valores deve ser igual ao valor da despesa.",
|
||||
"percentageSum": "A soma das porcentagens deve ser igual a 100."
|
||||
},
|
||||
"Categories": {
|
||||
"search": "Pesquisar categoria...",
|
||||
"noCategory": "Nenhuma categoria encontrada.",
|
||||
"Uncategorized": {
|
||||
"heading": "Sem categoria",
|
||||
"General": "Geral",
|
||||
"Payment": "Pagamento"
|
||||
},
|
||||
"Entertainment": {
|
||||
"heading": "Entretenimento",
|
||||
"Entertainment": "Entretenimento",
|
||||
"Games": "Jogos",
|
||||
"Movies": "Filmes",
|
||||
"Music": "Música",
|
||||
"Sports": "Esportes"
|
||||
},
|
||||
"Food and Drink": {
|
||||
"heading": "Comida e Bebida",
|
||||
"Food and Drink": "Comida e Bebida",
|
||||
"Dining Out": "Jantar fora",
|
||||
"Groceries": "Mercearia",
|
||||
"Liquor": "Bebidas alcoólicas"
|
||||
},
|
||||
"Home": {
|
||||
"heading": "Casa",
|
||||
"Home": "Casa",
|
||||
"Electronics": "Eletrônicos",
|
||||
"Furniture": "Móveis",
|
||||
"Household Supplies": "Suprimentos domésticos",
|
||||
"Maintenance": "Manutenção",
|
||||
"Mortgage": "Financiamento Habitacional",
|
||||
"Pets": "Animais de estimação",
|
||||
"Rent": "Aluguel",
|
||||
"Services": "Serviços"
|
||||
},
|
||||
"Life": {
|
||||
"heading": "Vida",
|
||||
"Childcare": "Cuidados infantis",
|
||||
"Clothing": "Roupas",
|
||||
"Education": "Educação",
|
||||
"Gifts": "Presentes",
|
||||
"Insurance": "Seguro",
|
||||
"Medical Expenses": "Despesas médicas",
|
||||
"Taxes": "Impostos"
|
||||
},
|
||||
"Transportation": {
|
||||
"heading": "Transporte",
|
||||
"Transportation": "Transporte",
|
||||
"Bicycle": "Bicicleta",
|
||||
"Bus/Train": "Ônibus/Trem",
|
||||
"Car": "Carro",
|
||||
"Gas/Fuel": "Gasolina/Combustível",
|
||||
"Hotel": "Hotel",
|
||||
"Parking": "Estacionamento",
|
||||
"Plane": "Avião",
|
||||
"Taxi": "Táxi"
|
||||
},
|
||||
"Utilities": {
|
||||
"heading": "Utilitários",
|
||||
"Utilities": "Utilitários",
|
||||
"Cleaning": "Limpeza",
|
||||
"Electricity": "Eletricidade",
|
||||
"Heat/Gas": "Calor/Gás",
|
||||
"Trash": "Lixo",
|
||||
"TV/Phone/Internet": "TV/Telefone/Internet",
|
||||
"Water": "Água"
|
||||
}
|
||||
}
|
||||
}
|
||||
398
messages/ro.json
Normal file
@@ -0,0 +1,398 @@
|
||||
{
|
||||
"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",
|
||||
"exportCsv": "Salvează în CSV",
|
||||
"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",
|
||||
"lastYear": "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."
|
||||
},
|
||||
"recurrenceRule": {
|
||||
"label": "Expense Recurrence",
|
||||
"description": "Select how often the expense should repeat.",
|
||||
|
||||
"none": "None",
|
||||
"daily": "Daily",
|
||||
"weekly": "Weekly",
|
||||
"monthly": "Monthly"
|
||||
},
|
||||
"DateField": {
|
||||
"label": "Data venitului",
|
||||
"description": "Adaugă data la care venitul a fost primit."
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"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ă"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@
|
||||
"createFirst": "Создать первый расход",
|
||||
"noExpenses": "У вашей группы пока что нет расходов.",
|
||||
"exportJson": "Экспортировать в JSON",
|
||||
"exportCsv": "Экспортировать в CSV",
|
||||
"searchPlaceholder": "Поиск расходов…",
|
||||
"ActiveUserModal": {
|
||||
"title": "Кто вы?",
|
||||
@@ -35,7 +36,7 @@
|
||||
"earlierThisMonth": "Ранее в этом месяце",
|
||||
"lastMonth": "В прошлом месяце",
|
||||
"earlierThisYear": "Ранее в этом году",
|
||||
"lastYera": "В прошлом году",
|
||||
"lastYear": "В прошлом году",
|
||||
"older": "Очень давно"
|
||||
}
|
||||
},
|
||||
@@ -136,6 +137,15 @@
|
||||
"label": "Получивший",
|
||||
"description": "Выберите участника, который получил этот доход."
|
||||
},
|
||||
"recurrenceRule": {
|
||||
"label": "Expense Recurrence",
|
||||
"description": "Select how often the expense should repeat.",
|
||||
|
||||
"none": "None",
|
||||
"daily": "Daily",
|
||||
"weekly": "Weekly",
|
||||
"monthly": "Monthly"
|
||||
},
|
||||
"paidFor": {
|
||||
"title": "Участники",
|
||||
"description": "Выберите тех, между кем этот доход будет распределен."
|
||||
@@ -203,12 +213,13 @@
|
||||
"creating": "Создание…",
|
||||
"save": "Сохранить",
|
||||
"saving": "Сохранение…",
|
||||
"cancel": "Отмена"
|
||||
"cancel": "Отмена",
|
||||
"reimbursement": "Возмещение"
|
||||
},
|
||||
"ExpenseDocumentsInput": {
|
||||
"TooBigToast": {
|
||||
"title": "Файл слишком большой",
|
||||
"description": "Максимальный размер файла, который можно загрузить — {maxSize}. Размер вашего файла — ${size}."
|
||||
"description": "Максимальный размер файла, который можно загрузить — {maxSize}. Размер вашего файла — {size}."
|
||||
},
|
||||
"ErrorToast": {
|
||||
"title": "Ошибка при загрузке документа",
|
||||
@@ -233,7 +244,7 @@
|
||||
"unknown": "Неизвестно",
|
||||
"TooBigToast": {
|
||||
"title": "Файл слишком большой",
|
||||
"description": "Максимальный размер файла, который можно загрузить — {maxSize}. Размер вашего файла — ${size}."
|
||||
"description": "Максимальный размер файла, который можно загрузить — {maxSize}. Размер вашего файла — {size}."
|
||||
},
|
||||
"ErrorToast": {
|
||||
"title": "Ошибка при загрузке документа",
|
||||
@@ -293,15 +304,6 @@
|
||||
"Settings": {
|
||||
"title": "Настройки"
|
||||
},
|
||||
"Locale": {
|
||||
"en-US": "English",
|
||||
"fi": "Suomi",
|
||||
"fr-FR": "Français",
|
||||
"es": "Español",
|
||||
"de-DE": "Deutsch",
|
||||
"zh-CN": "Chinese (Simplified)",
|
||||
"ru-RU": "Русский"
|
||||
},
|
||||
"Share": {
|
||||
"title": "Поделиться",
|
||||
"description": "Чтобы другие участники получили доступ к этой группе и смогли добавлять расходы, отправьте им этот URL.",
|
||||
@@ -393,4 +395,4 @@
|
||||
"Water": "Вода"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
389
messages/tr-TR.json
Normal file
@@ -0,0 +1,389 @@
|
||||
{
|
||||
"Homepage": {
|
||||
"title": "<strong>Masrafları</strong> <strong>Arkadaşlar ve Aile</strong> ile paylaş",
|
||||
"description": "Yeni <strong>Spliit</strong> kurulumunuza hoş geldiniz !",
|
||||
"button": {
|
||||
"groups": "Gruplara git",
|
||||
"github": "GitHub"
|
||||
}
|
||||
},
|
||||
"Header": {
|
||||
"groups": "Gruplar"
|
||||
},
|
||||
"Footer": {
|
||||
"madeIn": "Montréal, Québec 🇨🇦'da yapıldı",
|
||||
"builtBy": "<author>Sebastien Castiel</author> ve <source>katkıda bulunanlar</source> tarafından geliştirildi"
|
||||
},
|
||||
"Expenses": {
|
||||
"title": "Masraflar",
|
||||
"description": "Grubunuz için oluşturduğunuz masraflar burada.",
|
||||
"create": "Masraf oluştur",
|
||||
"createFirst": "İlk masrafı oluştur",
|
||||
"noExpenses": "Grubunuzda henüz herhangi bir masraf yok.",
|
||||
"exportJson": "JSON olarak dışa aktar",
|
||||
"exportCsv": "CSV olarak dışa aktar",
|
||||
"searchPlaceholder": "Bir masraf arayın…",
|
||||
"ActiveUserModal": {
|
||||
"title": "Kimsiniz?",
|
||||
"description": "Bilgilerin nasıl görüntüleneceğini özelleştirebilmemiz için hangi katılımcı olduğunuzu belirtin.",
|
||||
"nobody": "Kimseyi seçmek istemiyorum",
|
||||
"save": "Değişiklikleri kaydet",
|
||||
"footer": "Bu ayar daha sonra grup ayarlarında değiştirilebilir."
|
||||
},
|
||||
"Groups": {
|
||||
"upcoming": "Yaklaşan",
|
||||
"thisWeek": "Bu hafta",
|
||||
"earlierThisMonth": "Bu ayın başlarında",
|
||||
"lastMonth": "Geçen ay",
|
||||
"earlierThisYear": "Bu yılın başlarında",
|
||||
"lastYear": "Geçen yıl",
|
||||
"older": "Daha eski"
|
||||
}
|
||||
},
|
||||
"ExpenseCard": {
|
||||
"paidBy": "<strong>{paidBy}</strong> tarafından ödendi, <paidFor></paidFor> için",
|
||||
"receivedBy": "<strong>{paidBy}</strong> tarafından alındı, <paidFor></paidFor> için",
|
||||
"yourBalance": "Bakiyeniz:"
|
||||
},
|
||||
"Groups": {
|
||||
"myGroups": "Gruplarım",
|
||||
"create": "Oluştur",
|
||||
"loadingRecent": "Son gruplar yükleniyor…",
|
||||
"NoRecent": {
|
||||
"description": "Son zamanlarda hiç grup ziyaret etmediniz.",
|
||||
"create": "Bir tane oluştur",
|
||||
"orAsk": "ya da bir arkadaşınızdan mevcut bir grubun bağlantısını göndermesini isteyin."
|
||||
},
|
||||
"recent": "Son gruplar",
|
||||
"starred": "Yıldızlı gruplar",
|
||||
"archived": "Arşivlenmiş gruplar",
|
||||
"archive": "Grubu arşivle",
|
||||
"unarchive": "Arşivden çıkar",
|
||||
"removeRecent": "Son gruplardan kaldır",
|
||||
"RecentRemovedToast": {
|
||||
"title": "Grup kaldırıldı",
|
||||
"description": "Grup son gruplar listenizden kaldırıldı.",
|
||||
"undoAlt": "Grup kaldırma işlemini geri al",
|
||||
"undo": "Geri al"
|
||||
},
|
||||
"AddByURL": {
|
||||
"button": "URL ile ekle",
|
||||
"title": "URL ile grup ekle",
|
||||
"description": "Bir grup sizinle paylaşıldıysa, URL'sini buraya yapıştırarak listeye ekleyebilirsiniz.",
|
||||
"error": "Üzgünüz, sağladığınız URL'den bir grup bulamadık…"
|
||||
},
|
||||
"NotFound": {
|
||||
"text": "Bu grup mevcut değil.",
|
||||
"link": "Son ziyaret ettiğiniz gruplara dön"
|
||||
}
|
||||
},
|
||||
"GroupForm": {
|
||||
"title": "Grup bilgileri",
|
||||
"NameField": {
|
||||
"label": "Grup adı",
|
||||
"placeholder": "Yaz tatili",
|
||||
"description": "Grubunuz için bir ad girin."
|
||||
},
|
||||
"InformationField": {
|
||||
"label": "Grup bilgisi",
|
||||
"placeholder": "Grup katılımcıları için hangi bilgiler önemli?"
|
||||
},
|
||||
"CurrencyField": {
|
||||
"label": "Para birimi simgesi",
|
||||
"placeholder": "$, €, £…",
|
||||
"description": "Tutarları göstermek için kullanacağız."
|
||||
},
|
||||
"Participants": {
|
||||
"title": "Katılımcılar",
|
||||
"description": "Her katılımcı için bir ad girin.",
|
||||
"protectedParticipant": "Bu katılımcı masraflara dahil olduğundan kaldırılamaz.",
|
||||
"new": "Yeni",
|
||||
"add": "Katılımcı ekle",
|
||||
"John": "John",
|
||||
"Jane": "Jane",
|
||||
"Jack": "Jack"
|
||||
},
|
||||
"Settings": {
|
||||
"title": "Yerel ayarlar",
|
||||
"description": "Bu ayarlar cihaz bazında belirlenir ve deneyiminizi özelleştirmek için kullanılır.",
|
||||
"ActiveUserField": {
|
||||
"label": "Aktif kullanıcı",
|
||||
"placeholder": "Bir katılımcı seçin",
|
||||
"none": "Yok",
|
||||
"description": "Masrafların varsayılan olarak kimin adına ekleneceği."
|
||||
},
|
||||
"save": "Kaydet",
|
||||
"saving": "Kaydediliyor…",
|
||||
"create": "Oluştur",
|
||||
"creating": "Oluşturuluyor…",
|
||||
"cancel": "İptal"
|
||||
}
|
||||
},
|
||||
"ExpenseForm": {
|
||||
"Income": {
|
||||
"create": "Gelir oluştur",
|
||||
"edit": "Geliri düzenle",
|
||||
"TitleField": {
|
||||
"label": "Gelir başlığı",
|
||||
"placeholder": "Pazartesi akşamı restoran",
|
||||
"description": "Gelir için bir açıklama girin."
|
||||
},
|
||||
"DateField": {
|
||||
"label": "Gelir tarihi",
|
||||
"description": "Gelirin alındığı tarihi girin."
|
||||
},
|
||||
"categoryFieldDescription": "Gelir kategorisini seçin.",
|
||||
"paidByField": {
|
||||
"label": "Geliri alan",
|
||||
"description": "Geliri alan katılımcıyı seçin."
|
||||
},
|
||||
"paidFor": {
|
||||
"title": "Gelirin alındığı kişiler",
|
||||
"description": "Gelirin kim(ler) için alındığını seçin."
|
||||
},
|
||||
"splitModeDescription": "Gelirin nasıl paylaştırılacağını seçin.",
|
||||
"attachDescription": "Gelire makbuz ekleyin ve görüntüleyin."
|
||||
},
|
||||
"Expense": {
|
||||
"create": "Masraf oluştur",
|
||||
"edit": "Masrafı düzenle",
|
||||
"TitleField": {
|
||||
"label": "Masraf başlığı",
|
||||
"placeholder": "Pazartesi akşamı restoran",
|
||||
"description": "Masraf için bir açıklama girin."
|
||||
},
|
||||
"DateField": {
|
||||
"label": "Masraf tarihi",
|
||||
"description": "Masrafın ödendiği tarihi girin."
|
||||
},
|
||||
"categoryFieldDescription": "Masraf kategorisini seçin.",
|
||||
"paidByField": {
|
||||
"label": "Ödeyen",
|
||||
"description": "Masrafı ödeyen katılımcıyı seçin."
|
||||
},
|
||||
"paidFor": {
|
||||
"title": "Masraf kimin için ödendi",
|
||||
"description": "Masrafın kim(ler) için ödendiğini seçin."
|
||||
},
|
||||
"splitModeDescription": "Masrafın nasıl paylaştırılacağını seçin.",
|
||||
"attachDescription": "Masrafa makbuz ekleyin ve görüntüleyin."
|
||||
},
|
||||
"amountField": {
|
||||
"label": "Tutar"
|
||||
},
|
||||
"isReimbursementField": {
|
||||
"label": "Bu bir geri ödeme"
|
||||
},
|
||||
"categoryField": {
|
||||
"label": "Kategori"
|
||||
},
|
||||
"notesField": {
|
||||
"label": "Notlar"
|
||||
},
|
||||
"selectNone": "Hiçbirini seçme",
|
||||
"selectAll": "Hepsini seç",
|
||||
"shares": "pay",
|
||||
"advancedOptions": "Gelişmiş paylaşım seçenekleri…",
|
||||
"SplitModeField": {
|
||||
"label": "Paylaşım modu",
|
||||
"evenly": "Eşit pay",
|
||||
"byShares": "Eşit olmayan – Pay adedine göre",
|
||||
"byPercentage": "Eşit olmayan – Yüzdeye göre",
|
||||
"byAmount": "Eşit olmayan – Tutar bazında",
|
||||
"saveAsDefault": "Varsayılan paylaşım ayarları olarak kaydet"
|
||||
},
|
||||
"DeletePopup": {
|
||||
"label": "Sil",
|
||||
"title": "Bu masraf silinsin mi?",
|
||||
"description": "Bu masrafı gerçekten silmek istiyor musunuz? Bu işlem geri alınamaz.",
|
||||
"yes": "Evet",
|
||||
"cancel": "İptal"
|
||||
},
|
||||
"attachDocuments": "Belge ekle",
|
||||
"create": "Oluştur",
|
||||
"creating": "Oluşturuluyor…",
|
||||
"save": "Kaydet",
|
||||
"saving": "Kaydediliyor…",
|
||||
"cancel": "İptal",
|
||||
"reimbursement": "Geri ödeme"
|
||||
},
|
||||
"ExpenseDocumentsInput": {
|
||||
"TooBigToast": {
|
||||
"title": "Dosya çok büyük",
|
||||
"description": "Yükleyebileceğiniz maksimum dosya boyutu {maxSize}. Dosyanız {size} boyutunda."
|
||||
},
|
||||
"ErrorToast": {
|
||||
"title": "Belge yüklenirken hata oluştu",
|
||||
"description": "Belge yüklenirken bir sorun oluştu. Lütfen daha sonra tekrar deneyin veya farklı bir dosya seçin.",
|
||||
"retry": "Tekrar dene"
|
||||
}
|
||||
},
|
||||
"CreateFromReceipt": {
|
||||
"Dialog": {
|
||||
"triggerTitle": "Makbuzdan masraf oluştur",
|
||||
"title": "Makbuzdan oluştur",
|
||||
"description": "Bir makbuz fotoğrafındaki masraf bilgilerini çekin.",
|
||||
"body": "Bir makbuz fotoğrafı yükleyin, mümkünse masraf bilgilerini otomatik olarak çıkaracağız.",
|
||||
"selectImage": "Resim seç…",
|
||||
"titleLabel": "Başlık:",
|
||||
"categoryLabel": "Kategori:",
|
||||
"amountLabel": "Tutar:",
|
||||
"dateLabel": "Tarih:",
|
||||
"editNext": "Masraf bilgilerini sonraki adımda düzenleyebileceksiniz.",
|
||||
"continue": "Devam et"
|
||||
},
|
||||
"unknown": "Bilinmiyor",
|
||||
"TooBigToast": {
|
||||
"title": "Dosya çok büyük",
|
||||
"description": "Yükleyebileceğiniz maksimum dosya boyutu {maxSize}. Dosyanız {size} boyutunda."
|
||||
},
|
||||
"ErrorToast": {
|
||||
"title": "Belge yüklenirken hata oluştu",
|
||||
"description": "Belge yüklenirken bir sorun oluştu. Lütfen daha sonra tekrar deneyin veya farklı bir dosya seçin.",
|
||||
"retry": "Tekrar dene"
|
||||
}
|
||||
},
|
||||
"Balances": {
|
||||
"title": "Bakiyeler",
|
||||
"description": "Her katılımcının ödediği veya kendisi için ödenen tutar burada gösterilir.",
|
||||
"Reimbursements": {
|
||||
"title": "Önerilen geri ödemeler",
|
||||
"description": "Katılımcılar arasındaki en uygun geri ödeme önerileri aşağıdadır.",
|
||||
"noImbursements": "Görünüşe göre grubunuzun hiçbir geri ödemeye ihtiyacı yok 😁",
|
||||
"owes": "<strong>{from}</strong>, <strong>{to}</strong>'ya borçlu",
|
||||
"markAsPaid": "Ödendi olarak işaretle"
|
||||
}
|
||||
},
|
||||
"Stats": {
|
||||
"title": "İstatistikler",
|
||||
"Totals": {
|
||||
"title": "Toplamlar",
|
||||
"description": "Grubun tüm harcama özeti.",
|
||||
"groupSpendings": "Grubun toplam harcamaları",
|
||||
"groupEarnings": "Grubun toplam gelirleri",
|
||||
"yourSpendings": "Sizin toplam harcamalarınız",
|
||||
"yourEarnings": "Sizin toplam gelirleriniz",
|
||||
"yourShare": "Sizin toplam payınız"
|
||||
}
|
||||
},
|
||||
"Activity": {
|
||||
"title": "Etkinlik",
|
||||
"description": "Bu gruptaki tüm etkinliklerin genel görünümü.",
|
||||
"noActivity": "Grubunuzda henüz bir etkinlik yok.",
|
||||
"someone": "Birisi",
|
||||
"settingsModified": "Grup ayarları <strong>{participant}</strong> tarafından değiştirildi.",
|
||||
"expenseCreated": "Masraf <em>{expense}</em>, <strong>{participant}</strong> tarafından oluşturuldu.",
|
||||
"expenseUpdated": "Masraf <em>{expense}</em>, <strong>{participant}</strong> tarafından güncellendi.",
|
||||
"expenseDeleted": "Masraf <em>{expense}</em>, <strong>{participant}</strong> tarafından silindi.",
|
||||
"Groups": {
|
||||
"today": "Bugün",
|
||||
"yesterday": "Dün",
|
||||
"earlierThisWeek": "Bu haftanın başlarında",
|
||||
"lastWeek": "Geçen hafta",
|
||||
"earlierThisMonth": "Bu ayın başlarında",
|
||||
"lastMonth": "Geçen ay",
|
||||
"earlierThisYear": "Bu yılın başlarında",
|
||||
"lastYear": "Geçen yıl",
|
||||
"older": "Daha eski"
|
||||
}
|
||||
},
|
||||
"Information": {
|
||||
"title": "Bilgi",
|
||||
"description": "Grup katılımcıları için yararlı olabilecek bilgileri buraya ekleyebilirsiniz.",
|
||||
"empty": "Henüz grup bilgisi bulunmuyor."
|
||||
},
|
||||
"Settings": {
|
||||
"title": "Ayarlar"
|
||||
},
|
||||
"Share": {
|
||||
"title": "Paylaş",
|
||||
"description": "Diğer katılımcıların grubu görmesi ve masraf ekleyebilmesi için onlarla bu grubun URL'sini paylaşın.",
|
||||
"warning": "Uyarı!",
|
||||
"warningHelp": "Grubun URL'sine sahip olan herkes masrafları görebilir ve düzenleyebilir. Lütfen paylaşırken dikkatli olun!"
|
||||
},
|
||||
"SchemaErrors": {
|
||||
"min1": "En az bir karakter girin.",
|
||||
"min2": "En az iki karakter girin.",
|
||||
"max5": "En fazla beş karakter girin.",
|
||||
"max50": "En fazla 50 karakter girin.",
|
||||
"duplicateParticipantName": "Başka bir katılımcı zaten bu ada sahip.",
|
||||
"titleRequired": "Lütfen bir başlık girin.",
|
||||
"invalidNumber": "Geçersiz numara.",
|
||||
"amountRequired": "Bir tutar girmelisiniz.",
|
||||
"amountNotZero": "Tutar sıfır olamaz.",
|
||||
"amountTenMillion": "Tutar 10.000.000'dan düşük olmalı.",
|
||||
"paidByRequired": "Bir katılımcı seçmelisiniz.",
|
||||
"paidForMin1": "Masraf en az bir katılımcı için ödenmiş olmalıdır.",
|
||||
"noZeroShares": "Tüm paylar 0'dan büyük olmalıdır.",
|
||||
"amountSum": "Tutarların toplamı masraf tutarına eşit olmalıdır.",
|
||||
"percentageSum": "Yüzdelerin toplamı 100 olmalıdır."
|
||||
},
|
||||
"Categories": {
|
||||
"search": "Kategori ara...",
|
||||
"noCategory": "Kategori bulunamadı.",
|
||||
"Uncategorized": {
|
||||
"heading": "Kategorize Edilmemiş",
|
||||
"General": "Genel",
|
||||
"Payment": "Ödeme"
|
||||
},
|
||||
"Entertainment": {
|
||||
"heading": "Eğlence",
|
||||
"Entertainment": "Eğlence",
|
||||
"Games": "Oyunlar",
|
||||
"Movies": "Filmler",
|
||||
"Music": "Müzik",
|
||||
"Sports": "Spor"
|
||||
},
|
||||
"Food and Drink": {
|
||||
"heading": "Yiyecek ve İçecek",
|
||||
"Food and Drink": "Yiyecek ve İçecek",
|
||||
"Dining Out": "Dışarıda Yemek",
|
||||
"Groceries": "Market Alışverişi",
|
||||
"Liquor": "Alkollü İçecekler"
|
||||
},
|
||||
"Home": {
|
||||
"heading": "Ev",
|
||||
"Home": "Ev",
|
||||
"Electronics": "Elektronik",
|
||||
"Furniture": "Mobilya",
|
||||
"Household Supplies": "Ev İhtiyaçları",
|
||||
"Maintenance": "Bakım",
|
||||
"Mortgage": "Mortgage",
|
||||
"Pets": "Evcil Hayvanlar",
|
||||
"Rent": "Kira",
|
||||
"Services": "Hizmetler"
|
||||
},
|
||||
"Life": {
|
||||
"heading": "Yaşam",
|
||||
"Childcare": "Çocuk Bakımı",
|
||||
"Clothing": "Giyim",
|
||||
"Education": "Eğitim",
|
||||
"Gifts": "Hediyeler",
|
||||
"Insurance": "Sigorta",
|
||||
"Medical Expenses": "Sağlık Giderleri",
|
||||
"Taxes": "Vergiler"
|
||||
},
|
||||
"Transportation": {
|
||||
"heading": "Ulaşım",
|
||||
"Transportation": "Ulaşım",
|
||||
"Bicycle": "Bisiklet",
|
||||
"Bus/Train": "Otobüs/Tren",
|
||||
"Car": "Araba",
|
||||
"Gas/Fuel": "Benzin/Yakıt",
|
||||
"Hotel": "Otel",
|
||||
"Parking": "Otopark",
|
||||
"Plane": "Uçak",
|
||||
"Taxi": "Taksi"
|
||||
},
|
||||
"Utilities": {
|
||||
"heading": "Faturalar",
|
||||
"Utilities": "Faturalar",
|
||||
"Cleaning": "Temizlik",
|
||||
"Electricity": "Elektrik",
|
||||
"Heat/Gas": "Isınma/Gaz",
|
||||
"Trash": "Çöp",
|
||||
"TV/Phone/Internet": "TV/Telefon/İnternet",
|
||||
"Water": "Su"
|
||||
}
|
||||
}
|
||||
}
|
||||
398
messages/ua-UA.json
Normal file
@@ -0,0 +1,398 @@
|
||||
{
|
||||
"Homepage": {
|
||||
"title": "Ділися <strong>витратами</strong> з <strong>друзями та родиною</strong>",
|
||||
"description": "Ласкаво просимо у ваш новий <strong>Spliit</strong>!",
|
||||
"button": {
|
||||
"groups": "Перейти до груп",
|
||||
"github": "GitHub"
|
||||
}
|
||||
},
|
||||
"Header": {
|
||||
"groups": "Групи"
|
||||
},
|
||||
"Footer": {
|
||||
"madeIn": "Зроблено в Монреалі, Квебек 🇨🇦",
|
||||
"builtBy": "Створено <author>Себастіаном Кастіелем</author> та <source>учасниками</source>"
|
||||
},
|
||||
"Expenses": {
|
||||
"title": "Витрати",
|
||||
"description": "Тут знаходяться витрати вашої групи",
|
||||
"create": "Створити витрату",
|
||||
"createFirst": "Створіть першу витрату",
|
||||
"noExpenses": "У вашій групі ще немає витрат",
|
||||
"exportJson": "Експортувати у JSON",
|
||||
"exportCsv": "Експортувати у CSV",
|
||||
"searchPlaceholder": "Пошук витрат...",
|
||||
"ActiveUserModal": {
|
||||
"title": "Хто ви?",
|
||||
"description": "Скажіть нам, хто ви серед учасників, щоб ми могли налаштувати відображення інформації під вас",
|
||||
"nobody": "Я не хочу нікого обирати",
|
||||
"save": "Зберегти зміни",
|
||||
"footer": "Це налаштування можна змінити пізніше в налаштуваннях групи"
|
||||
},
|
||||
"Groups": {
|
||||
"upcoming": "Майбутні",
|
||||
"thisWeek": "Цього тижня",
|
||||
"earlierThisMonth": "Раніше цього місяця",
|
||||
"lastMonth": "Минулого місяця",
|
||||
"earlierThisYear": "Раніше цього року",
|
||||
"lastYear": "Минулого року",
|
||||
"older": "Старіші"
|
||||
}
|
||||
},
|
||||
"ExpenseCard": {
|
||||
"paidBy": "Сплачено <strong>{paidBy}</strong> за <paidFor></paidFor>",
|
||||
"receivedBy": "Отримано <strong>{paidBy}</strong> за <paidFor></paidFor>",
|
||||
"yourBalance": "Ваш баланс:"
|
||||
},
|
||||
"Groups": {
|
||||
"myGroups": "Мої групи",
|
||||
"create": "Створити",
|
||||
"loadingRecent": "Завантаження нещодавніх груп...",
|
||||
"NoRecent": {
|
||||
"description": "Ви не відвідували жодних груп останнім часом",
|
||||
"create": "Створіть групу",
|
||||
"orAsk": "або попросіть друга надіслати вам посилання на існуючу"
|
||||
},
|
||||
"recent": "Нещодавні групи",
|
||||
"starred": "Обрані групи",
|
||||
"archived": "Архівовані групи",
|
||||
"archive": "Архівувати групу",
|
||||
"unarchive": "Розархівувати групу",
|
||||
"removeRecent": "Видалити з останніх груп",
|
||||
"RecentRemovedToast": {
|
||||
"title": "Група була видалена",
|
||||
"description": "Група видалена зі списку ваших нещодавніх груп",
|
||||
"undoAlt": "Скасувати видалення групи",
|
||||
"undo": "Скасувати"
|
||||
},
|
||||
"AddByURL": {
|
||||
"button": "Додати за URL",
|
||||
"title": "Додати групу за URL",
|
||||
"description": "Якщо з вами поділились групою, ви можете вставити її URL тут, щоб додати до свого списку",
|
||||
"error": "На жаль, ми не змогли знайти групу за наданим URL"
|
||||
},
|
||||
"NotFound": {
|
||||
"text": "Цієї групи не існує",
|
||||
"link": "Перейти до нещодавно відвіданих груп"
|
||||
}
|
||||
},
|
||||
"GroupForm": {
|
||||
"title": "Інформація про групу",
|
||||
"NameField": {
|
||||
"label": "Назва групи",
|
||||
"placeholder": "Літні канікули",
|
||||
"description": "Введіть назву для вашої групи"
|
||||
},
|
||||
"InformationField": {
|
||||
"label": "Інформація про групу",
|
||||
"placeholder": "Яка інформація важлива для учасників групи?"
|
||||
},
|
||||
"CurrencyField": {
|
||||
"label": "Символ валюти",
|
||||
"placeholder": "₴, $, €, £..",
|
||||
"description": "Ми будемо використовувати його для відображення сум"
|
||||
},
|
||||
"Participants": {
|
||||
"title": "Учасники",
|
||||
"description": "Введіть ім'я кожного учасника",
|
||||
"protectedParticipant": "Цей учасник бере участь у витратах і не може бути видалений",
|
||||
"new": "Новий",
|
||||
"add": "Додати учасника",
|
||||
"John": "Андрій",
|
||||
"Jane": "Оксана",
|
||||
"Jack": "Василь"
|
||||
},
|
||||
"Settings": {
|
||||
"title": "Локальні налаштування",
|
||||
"description": "Ці налаштування встановлюються на кожному пристрої окремо і використовуються для налаштування інтерфейсу під вас",
|
||||
"ActiveUserField": {
|
||||
"label": "Активний користувач",
|
||||
"placeholder": "Обрати учасника",
|
||||
"none": "Ніхто",
|
||||
"description": "Користувач використовується за замовчуванням для оплати витрат"
|
||||
},
|
||||
"save": "Зберегти",
|
||||
"saving": "Збереження...",
|
||||
"create": "Створити",
|
||||
"creating": "Створення...",
|
||||
"cancel": "Скасувати"
|
||||
}
|
||||
},
|
||||
"ExpenseForm": {
|
||||
"Income": {
|
||||
"create": "Створити дохід",
|
||||
"edit": "Редагувати дохід",
|
||||
"TitleField": {
|
||||
"label": "Назва доходу",
|
||||
"placeholder": "Ресторан в понеділок ввечері",
|
||||
"description": "Введіть опис для доходу"
|
||||
},
|
||||
"DateField": {
|
||||
"label": "Дата доходу",
|
||||
"description": "Введіть дату, коли було отримано дохід"
|
||||
},
|
||||
"categoryFieldDescription": "Оберіть категорію доходу",
|
||||
"paidByField": {
|
||||
"label": "Отримав",
|
||||
"description": "Оберіть учасника, який отримав дохід"
|
||||
},
|
||||
"recurrenceRule": {
|
||||
"label": "Expense Recurrence",
|
||||
"description": "Select how often the expense should repeat.",
|
||||
|
||||
"none": "None",
|
||||
"daily": "Daily",
|
||||
"weekly": "Weekly",
|
||||
"monthly": "Monthly"
|
||||
},
|
||||
"paidFor": {
|
||||
"title": "Учасники",
|
||||
"description": "Виберіть тих, між ким цей дохід буде розподілено"
|
||||
},
|
||||
"splitModeDescription": "Оберіть, як розділити дохід між учасниками",
|
||||
"attachDescription": "Перегляньте та прикріпіть чеки до доходу"
|
||||
},
|
||||
"Expense": {
|
||||
"create": "Створити витрату",
|
||||
"edit": "Редагувати витрату",
|
||||
"TitleField": {
|
||||
"label": "Назва витрати",
|
||||
"placeholder": "Ресторан в понеділок ввечері",
|
||||
"description": "Введіть опис для витрати"
|
||||
},
|
||||
"DateField": {
|
||||
"label": "Дата витрати",
|
||||
"description": "Введіть дату, коли було сплачено"
|
||||
},
|
||||
"categoryFieldDescription": "Оберіть категорію витрати",
|
||||
"paidByField": {
|
||||
"label": "Сплатив",
|
||||
"description": "Оберіть учасника, який сплатив"
|
||||
},
|
||||
"paidFor": {
|
||||
"title": "Учасники",
|
||||
"description": "Оберіть тих, між ким цю витрату буде розподілено. Якщо ця витрата - відшкодування учаснику (учасникам), виберіть тільки його (їх)."
|
||||
},
|
||||
"splitModeDescription": "Оберіть, як розділити витрату",
|
||||
"attachDescription": "Перегляньте та прикріпіть чеки до витрати"
|
||||
},
|
||||
"amountField": {
|
||||
"label": "Сума"
|
||||
},
|
||||
"isReimbursementField": {
|
||||
"label": "Це відшкодування"
|
||||
},
|
||||
"categoryField": {
|
||||
"label": "Категорія"
|
||||
},
|
||||
"notesField": {
|
||||
"label": "Примітки"
|
||||
},
|
||||
"selectNone": "Обрати жодного",
|
||||
"selectAll": "Обрати всіх",
|
||||
"shares": "частка(и)",
|
||||
"advancedOptions": "Розширені опції поділу..",
|
||||
"SplitModeField": {
|
||||
"label": "Режим поділу",
|
||||
"evenly": "Рівномірно",
|
||||
"byShares": "Нерівномірно – за частками",
|
||||
"byPercentage": "Нерівномірно – за відсотками",
|
||||
"byAmount": "Нерівномірно – за сумами",
|
||||
"saveAsDefault": "Зберегти як параметри поділу за замовчуванням"
|
||||
},
|
||||
"DeletePopup": {
|
||||
"label": "Видалити",
|
||||
"title": "Видалити цю витрату?",
|
||||
"description": "Ви дійсно хочете видалити цю витрату? Ця дія не може бути скасована",
|
||||
"yes": "Так",
|
||||
"cancel": "Скасувати"
|
||||
},
|
||||
"attachDocuments": "Прикріпити документи",
|
||||
"create": "Створити",
|
||||
"creating": "Створення..",
|
||||
"save": "Зберегти",
|
||||
"saving": "Збереження..",
|
||||
"cancel": "Скасувати",
|
||||
"reimbursement": "Відшкодування"
|
||||
},
|
||||
"ExpenseDocumentsInput": {
|
||||
"TooBigToast": {
|
||||
"title": "Файл занадто великий",
|
||||
"description": "Максимальний розмір файлу, який можна завантажити, становить {maxSize}. Ваш файл {size}"
|
||||
},
|
||||
"ErrorToast": {
|
||||
"title": "Помилка під час завантаження документа",
|
||||
"description": "Виникла помилка під час завантаження документа. Будь ласка, спробуйте ще раз пізніше або виберіть інший файл",
|
||||
"retry": "Спробувати ще раз"
|
||||
}
|
||||
},
|
||||
"CreateFromReceipt": {
|
||||
"Dialog": {
|
||||
"triggerTitle": "Створити витрату з чека",
|
||||
"title": "Створити з чека",
|
||||
"description": "Отримайте інформацію про витрати з фото чека",
|
||||
"body": "Завантажте фото чека, і ми спробуємо витягнути інформацію про витрати, якщо це можливо",
|
||||
"selectImage": "Вибрати зображення..",
|
||||
"titleLabel": "Назва:",
|
||||
"categoryLabel": "Категорія:",
|
||||
"amountLabel": "Сума:",
|
||||
"dateLabel": "Дата:",
|
||||
"editNext": "Ви зможете відредагувати інформацію про витрати пізніше",
|
||||
"continue": "Продовжити"
|
||||
},
|
||||
"unknown": "Невідомо",
|
||||
"TooBigToast": {
|
||||
"title": "Файл занадто великий",
|
||||
"description": "Максимальний розмір файлу, який можна завантажити, становить {maxSize}. Ваш файл {size}"
|
||||
},
|
||||
"ErrorToast": {
|
||||
"title": "Помилка під час завантаження документа",
|
||||
"description": "Виникла помилка під час завантаження документа. Будь ласка, спробуйте ще раз пізніше або виберіть інший файл",
|
||||
"retry": "Спробувати ще раз"
|
||||
}
|
||||
},
|
||||
"Balances": {
|
||||
"title": "Баланси",
|
||||
"description": "Це список балансів всіх учасників групи. Баланс збільшується у тих, хто слачує витрату, і зменшується в тих, між ким вона була розподілена",
|
||||
"Reimbursements": {
|
||||
"title": "Запропоновані відшкодування",
|
||||
"description": "Ось пропозиції для оптимізованих відшкодувань між учасниками",
|
||||
"noImbursements": "Схоже, ніхто нікому не винен 😁",
|
||||
"owes": "<strong>{from}</strong> винен <strong>{to}</strong>",
|
||||
"markAsPaid": "Позначити як сплачене"
|
||||
}
|
||||
},
|
||||
"Stats": {
|
||||
"title": "Статистика",
|
||||
"Totals": {
|
||||
"title": "Загальні дані",
|
||||
"description": "Загальний огляд витрат групи",
|
||||
"groupSpendings": "Загальні витрати групи",
|
||||
"groupEarnings": "Загальні доходи групи",
|
||||
"yourSpendings": "Ваші загальні витрати",
|
||||
"yourEarnings": "Ваші загальні доходи",
|
||||
"yourShare": "Ваша частка"
|
||||
}
|
||||
},
|
||||
"Activity": {
|
||||
"title": "Активність",
|
||||
"description": "Огляд усієї активності в цій групі",
|
||||
"noActivity": "У вашій групі ще немає активності",
|
||||
"someone": "Хтось",
|
||||
"settingsModified": "Налаштування групи змінені <strong>{participant}</strong>",
|
||||
"expenseCreated": "Витрата <em>{expense}</em> створена <strong>{participant}</strong>",
|
||||
"expenseUpdated": "Витрата <em>{expense}</em> оновлена <strong>{participant}</strong>",
|
||||
"expenseDeleted": "Витрата <em>{expense}</em> видалена <strong>{participant}</strong>",
|
||||
"Groups": {
|
||||
"today": "Сьогодні",
|
||||
"yesterday": "Вчора",
|
||||
"earlierThisWeek": "Раніше цього тижня",
|
||||
"lastWeek": "Минулого тижня",
|
||||
"earlierThisMonth": "Раніше цього місяця",
|
||||
"lastMonth": "Минулого місяця",
|
||||
"earlierThisYear": "Раніше цього року",
|
||||
"lastYear": "Минулого року",
|
||||
"older": "Старіші"
|
||||
}
|
||||
},
|
||||
"Information": {
|
||||
"title": "Інформація",
|
||||
"description": "Використовуйте це місце, щоб додати будь-яку інформацію, яка може бути корисною для учасників групи",
|
||||
"empty": "Ще немає інформації про групу"
|
||||
},
|
||||
"Settings": {
|
||||
"title": "Налаштування"
|
||||
},
|
||||
"Share": {
|
||||
"title": "Поділитися",
|
||||
"description": "Щоб інші учасники могли побачити групу і додати витрати, поділіться з ними її URL",
|
||||
"warning": "Попередження!",
|
||||
"warningHelp": "Кожна людина з URL групи зможе переглядати та редагувати витрати. Діліться з обережністю!"
|
||||
},
|
||||
"SchemaErrors": {
|
||||
"min1": "Введіть принаймні один символ",
|
||||
"min2": "Введіть принаймні два символи",
|
||||
"max5": "Введіть не більше п'яти символів",
|
||||
"max50": "Введіть не більше 50 символів",
|
||||
"duplicateParticipantName": "Інший учасник уже має це ім'я",
|
||||
"titleRequired": "Будь ласка, введіть назву",
|
||||
"invalidNumber": "Невірний номер",
|
||||
"amountRequired": "Необхідно ввести суму",
|
||||
"amountNotZero": "Сума не повинна дорівнювати нулю",
|
||||
"amountTenMillion": "Сума повинна бути меншою за 10,000,000",
|
||||
"paidByRequired": "Ви повинні обрати учасника",
|
||||
"paidForMin1": "Витрата повинна бути сплачена принаймні для одного учасника",
|
||||
"noZeroShares": "Усі частки повинні бути більшими за 0",
|
||||
"amountSum": "Сума повинна відповідати витраті",
|
||||
"percentageSum": "Сума відсотків повинна дорівнювати 100"
|
||||
},
|
||||
"Categories": {
|
||||
"search": "Шукати категорію..",
|
||||
"noCategory": "Категорії не знайдено",
|
||||
"Uncategorized": {
|
||||
"heading": "Без категорії",
|
||||
"General": "Загальне",
|
||||
"Payment": "Оплата"
|
||||
},
|
||||
"Entertainment": {
|
||||
"heading": "Розваги",
|
||||
"Entertainment": "Розваги",
|
||||
"Games": "Ігри",
|
||||
"Movies": "Фільми",
|
||||
"Music": "Музика",
|
||||
"Sports": "Спорт"
|
||||
},
|
||||
"Food and Drink": {
|
||||
"heading": "Їжа та напої",
|
||||
"Food and Drink": "Їжа та напої",
|
||||
"Dining Out": "Ресторани",
|
||||
"Groceries": "Продукти",
|
||||
"Liquor": "Алкоголь"
|
||||
},
|
||||
"Home": {
|
||||
"heading": "Дім",
|
||||
"Home": "Дім",
|
||||
"Electronics": "Електроніка",
|
||||
"Furniture": "Меблі",
|
||||
"Household Supplies": "Домашні потреби",
|
||||
"Maintenance": "Обслуговування",
|
||||
"Mortgage": "Іпотека",
|
||||
"Pets": "Домашні тварини",
|
||||
"Rent": "Оренда",
|
||||
"Services": "Послуги"
|
||||
},
|
||||
"Life": {
|
||||
"heading": "Життя",
|
||||
"Childcare": "Догляд за дітьми",
|
||||
"Clothing": "Одяг",
|
||||
"Education": "Освіта",
|
||||
"Gifts": "Подарунки",
|
||||
"Insurance": "Страхування",
|
||||
"Medical Expenses": "Медичні витрати",
|
||||
"Taxes": "Податки"
|
||||
},
|
||||
"Transportation": {
|
||||
"heading": "Транспорт",
|
||||
"Transportation": "Транспорт",
|
||||
"Bicycle": "Велосипед",
|
||||
"Bus/Train": "Автобус/Поїзд",
|
||||
"Car": "Автомобіль",
|
||||
"Gas/Fuel": "Паливо",
|
||||
"Hotel": "Готель",
|
||||
"Parking": "Паркінг",
|
||||
"Plane": "Літак",
|
||||
"Taxi": "Таксі"
|
||||
},
|
||||
"Utilities": {
|
||||
"heading": "Комунальні послуги",
|
||||
"Utilities": "Комунальні послуги",
|
||||
"Cleaning": "Прибирання",
|
||||
"Electricity": "Електроенергія",
|
||||
"Heat/Gas": "Опалення/Газ",
|
||||
"Trash": "Сміття",
|
||||
"TV/Phone/Internet": "ТБ/Телефон/Інтернет",
|
||||
"Water": "Вода"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@
|
||||
"createFirst": "创建首个消费",
|
||||
"noExpenses": "你的群组内目前没有任何消费。",
|
||||
"exportJson": "导出到JSON",
|
||||
"exportCsv": "导出到CSV",
|
||||
"searchPlaceholder": "查找消费……",
|
||||
"ActiveUserModal": {
|
||||
"title": "你是哪位?",
|
||||
@@ -35,7 +36,7 @@
|
||||
"earlierThisMonth": "本月早些时候",
|
||||
"lastMonth": "上个月",
|
||||
"earlierThisYear": "本年早些时候",
|
||||
"lastYera": "去年",
|
||||
"lastYear": "去年",
|
||||
"older": "更早"
|
||||
}
|
||||
},
|
||||
@@ -136,6 +137,15 @@
|
||||
"label": "接收到",
|
||||
"description": "选择接收到这笔收入的群组成员。"
|
||||
},
|
||||
"recurrenceRule": {
|
||||
"label": "Expense Recurrence",
|
||||
"description": "Select how often the expense should repeat.",
|
||||
|
||||
"none": "None",
|
||||
"daily": "Daily",
|
||||
"weekly": "Weekly",
|
||||
"monthly": "Monthly"
|
||||
},
|
||||
"paidFor": {
|
||||
"title": "接收给",
|
||||
"description": "选择收入是为谁而收。"
|
||||
@@ -203,12 +213,13 @@
|
||||
"creating": "创建中……",
|
||||
"save": "保存",
|
||||
"saving": "保存中……",
|
||||
"cancel": "取消"
|
||||
"cancel": "取消",
|
||||
"reimbursement": "报销"
|
||||
},
|
||||
"ExpenseDocumentsInput": {
|
||||
"TooBigToast": {
|
||||
"title": "文件过大",
|
||||
"description": "可上传的最大文件大小为{maxSize},你的文件为${size}。"
|
||||
"description": "可上传的最大文件大小为{maxSize},你的文件为{size}。"
|
||||
},
|
||||
"ErrorToast": {
|
||||
"title": "上传文档时发生错误",
|
||||
@@ -233,7 +244,7 @@
|
||||
"unknown": "未知",
|
||||
"TooBigToast": {
|
||||
"title": "文件过大",
|
||||
"description": "可上传的最大文件大小为{maxSize},你的文件为${size}。"
|
||||
"description": "可上传的最大文件大小为{maxSize},你的文件为{size}。"
|
||||
},
|
||||
"ErrorToast": {
|
||||
"title": "上传文档时发生错误",
|
||||
@@ -293,15 +304,6 @@
|
||||
"Settings": {
|
||||
"title": "设定"
|
||||
},
|
||||
"Locale": {
|
||||
"en-US": "English",
|
||||
"fi": "Suomi",
|
||||
"fr-FR": "Français",
|
||||
"es": "Español",
|
||||
"de-DE": "Deutsch",
|
||||
"zh-CN": "Chinese (Simplified)",
|
||||
"ru-RU": "Русский"
|
||||
},
|
||||
"Share": {
|
||||
"title": "分享",
|
||||
"description": "请将此URL分享给其他群组成员,以使其可以查看群组并添加消费。",
|
||||
@@ -393,4 +395,4 @@
|
||||
"Water": "水"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
389
messages/zh-TW.json
Normal file
@@ -0,0 +1,389 @@
|
||||
{
|
||||
"Homepage": {
|
||||
"title": "跟<strong>朋友和家人</strong>一起共享<strong>消費紀錄</strong>",
|
||||
"description": "歡迎開始全新的<strong>Spliit</strong>",
|
||||
"button": {
|
||||
"groups": "前往群組",
|
||||
"github": "GitHub"
|
||||
}
|
||||
},
|
||||
"Header": {
|
||||
"groups": "群組"
|
||||
},
|
||||
"Footer": {
|
||||
"madeIn": "來自 🇨🇦 加拿大魁北克蒙特婁",
|
||||
"builtBy": "由 <author>Sebastien Castiel</author> 以及 <source>社群貢獻者</source> 共同創建維護"
|
||||
},
|
||||
"Expenses": {
|
||||
"title": "消費",
|
||||
"description": "這裡是您為群組建立的消費。",
|
||||
"create": "新增消費紀錄",
|
||||
"createFirst": "新增第一筆消費紀錄",
|
||||
"noExpenses": "你的群組內目前沒有任何消費紀錄。",
|
||||
"exportJson": "匯出為 JSON",
|
||||
"exportCsv": "匯出為 CSV",
|
||||
"searchPlaceholder": "搜尋消費紀錄……",
|
||||
"ActiveUserModal": {
|
||||
"title": "你是誰?",
|
||||
"description": "告訴我們您在群組中的身份,以調整我們顯示資訊的方式。",
|
||||
"nobody": "我不想選擇任何人",
|
||||
"save": "儲存更改",
|
||||
"footer": "此設定可稍後在群組設定中更改。"
|
||||
},
|
||||
"Groups": {
|
||||
"upcoming": "即將到來",
|
||||
"thisWeek": "本週",
|
||||
"earlierThisMonth": "本月稍早",
|
||||
"lastMonth": "上個月",
|
||||
"earlierThisYear": "今年稍早",
|
||||
"lastYear": "去年",
|
||||
"older": "更早"
|
||||
}
|
||||
},
|
||||
"ExpenseCard": {
|
||||
"paidBy": "由 <strong>{paidBy}</strong> 支付 <paidFor></paidFor>。",
|
||||
"receivedBy": "由 <strong>{paidBy}</strong> 收取 <paidFor></paidFor>。",
|
||||
"yourBalance": "你的餘額:"
|
||||
},
|
||||
"Groups": {
|
||||
"myGroups": "我的群組",
|
||||
"create": "建立",
|
||||
"loadingRecent": "讀取最近的群組……",
|
||||
"NoRecent": {
|
||||
"description": "你最近沒有訪問過任何群組。",
|
||||
"create": "建立一個新群組",
|
||||
"orAsk": "或請朋友發送已建立的群組鏈接。"
|
||||
},
|
||||
"recent": "最近的群組",
|
||||
"starred": "已加星標的群組",
|
||||
"archived": "已封存的群組",
|
||||
"archive": "將群組封存",
|
||||
"unarchive": "取消封存群組",
|
||||
"removeRecent": "從最近的群組中移除",
|
||||
"RecentRemovedToast": {
|
||||
"title": "群組已被移除",
|
||||
"description": "該群組已從您的最近群組列表中移除。",
|
||||
"undoAlt": "撤銷移除群組",
|
||||
"undo": "取消操作"
|
||||
},
|
||||
"AddByURL": {
|
||||
"button": "透過連結加入",
|
||||
"title": "透過連結加入群組",
|
||||
"description": "如果某個群組已與您分享,您可以在此處貼上其網址以添加到群組列表中。",
|
||||
"error": "哇哇,我們無法從您提供的網址中找到有效群組……"
|
||||
},
|
||||
"NotFound": {
|
||||
"text": "該群組不存在。",
|
||||
"link": "前往最近訪問的群組"
|
||||
}
|
||||
},
|
||||
"GroupForm": {
|
||||
"title": "群組資訊",
|
||||
"NameField": {
|
||||
"label": "群組名稱",
|
||||
"placeholder": "暑假出遊",
|
||||
"description": "輸入群組的名稱。"
|
||||
},
|
||||
"InformationField": {
|
||||
"label": "群組資訊",
|
||||
"placeholder": "對群組成員有關的資訊是什麼?"
|
||||
},
|
||||
"CurrencyField": {
|
||||
"label": "貨幣符號",
|
||||
"placeholder": "$, €, £…",
|
||||
"description": "我們根據它來顯示相應的金額。"
|
||||
},
|
||||
"Participants": {
|
||||
"title": "群組成員",
|
||||
"description": "輸入每位成員的名稱。",
|
||||
"protectedParticipant": "此成員已有登記支出,無法刪除。",
|
||||
"new": "新增",
|
||||
"add": "新增群組成員",
|
||||
"John": "林俊凱",
|
||||
"Jane": "陳怡婷",
|
||||
"Jack": "張文傑"
|
||||
},
|
||||
"Settings": {
|
||||
"title": "客製化設定",
|
||||
"description": "這些設定是針對每台設備設置的,用於客製化您的體驗。",
|
||||
"ActiveUserField": {
|
||||
"label": "當前使用者",
|
||||
"placeholder": "選擇一位群組成員",
|
||||
"none": "無",
|
||||
"description": "用於支付消費的預設用戶"
|
||||
},
|
||||
"save": "保存",
|
||||
"saving": "保存中……",
|
||||
"create": "建立",
|
||||
"creating": "建立中……",
|
||||
"cancel": "取消"
|
||||
}
|
||||
},
|
||||
"ExpenseForm": {
|
||||
"Income": {
|
||||
"create": "新增收入",
|
||||
"edit": "編輯收入",
|
||||
"TitleField": {
|
||||
"label": "收入標題",
|
||||
"placeholder": "禮拜一晚餐",
|
||||
"description": "輸入此筆收入的描述。"
|
||||
},
|
||||
"DateField": {
|
||||
"label": "收入日期",
|
||||
"description": "輸入收到這筆收入的日期。"
|
||||
},
|
||||
"categoryFieldDescription": "選擇收入類別。",
|
||||
"paidByField": {
|
||||
"label": "接收人",
|
||||
"description": "選擇接收這筆收入的成員。"
|
||||
},
|
||||
"paidFor": {
|
||||
"title": "應接收人",
|
||||
"description": "選擇應參與此筆收入的成員。"
|
||||
},
|
||||
"splitModeDescription": "選擇如何分配此筆收入。",
|
||||
"attachDescription": "查看/附上此筆收入的收據。"
|
||||
},
|
||||
"Expense": {
|
||||
"create": "新增消費紀錄",
|
||||
"edit": "編輯消費紀錄",
|
||||
"TitleField": {
|
||||
"label": "支出標題",
|
||||
"placeholder": "週一晚餐",
|
||||
"description": "輸入此筆消費的描述。"
|
||||
},
|
||||
"DateField": {
|
||||
"label": "消費日期",
|
||||
"description": "輸入支付此消費的日期。"
|
||||
},
|
||||
"categoryFieldDescription": "選擇消費類別。",
|
||||
"paidByField": {
|
||||
"label": "支付人",
|
||||
"description": "选择支付这笔消费的群组成员。"
|
||||
},
|
||||
"paidFor": {
|
||||
"title": "應支付人",
|
||||
"description": "選擇需參與此筆消費的成員。"
|
||||
},
|
||||
"splitModeDescription": "選擇如何分配此筆消費。",
|
||||
"attachDescription": "查看/附上此筆消費的收據。"
|
||||
},
|
||||
"amountField": {
|
||||
"label": "金額"
|
||||
},
|
||||
"isReimbursementField": {
|
||||
"label": "這是一筆報銷款"
|
||||
},
|
||||
"categoryField": {
|
||||
"label": "類別"
|
||||
},
|
||||
"notesField": {
|
||||
"label": "備註"
|
||||
},
|
||||
"selectNone": "取消全選",
|
||||
"selectAll": "全選",
|
||||
"shares": "份額",
|
||||
"advancedOptions": "進階分帳選項……",
|
||||
"SplitModeField": {
|
||||
"label": "分帳方式",
|
||||
"evenly": "平均分配",
|
||||
"byShares": "自訂份額",
|
||||
"byPercentage": "自訂百分比",
|
||||
"byAmount": "自訂金額",
|
||||
"saveAsDefault": "儲存為預設分帳方式"
|
||||
},
|
||||
"DeletePopup": {
|
||||
"label": "刪除",
|
||||
"title": "要刪除這筆消費嗎?",
|
||||
"description": "確定要刪除這筆消費嗎?刪除後無法回復哦。",
|
||||
"yes": "確定",
|
||||
"cancel": "取消"
|
||||
},
|
||||
"attachDocuments": "附件",
|
||||
"create": "新增",
|
||||
"creating": "新增中……",
|
||||
"save": "儲存",
|
||||
"saving": "儲存中……",
|
||||
"cancel": "取消",
|
||||
"reimbursement": "報銷"
|
||||
},
|
||||
"ExpenseDocumentsInput": {
|
||||
"TooBigToast": {
|
||||
"title": "文件過大",
|
||||
"description": "可以上傳的最大文件大小為 {maxSize}。這份文件大小為 {size}。"
|
||||
},
|
||||
"ErrorToast": {
|
||||
"title": "上傳文件時發生錯誤",
|
||||
"description": "上傳文件時發生錯誤,請再試一次或更換文件。",
|
||||
"retry": "重試"
|
||||
}
|
||||
},
|
||||
"CreateFromReceipt": {
|
||||
"Dialog": {
|
||||
"triggerTitle": "從收據中新增消費紀錄",
|
||||
"title": "從收據中新增消費紀錄",
|
||||
"description": "從收據照片上抓取消費明細。",
|
||||
"body": "上傳收據的圖片,我們會試圖解析其中的支出",
|
||||
"selectImage": "選擇圖片……",
|
||||
"titleLabel": "標題:",
|
||||
"categoryLabel": "類別:",
|
||||
"amountLabel": "金額:",
|
||||
"dateLabel": "日期:",
|
||||
"editNext": "可於後續編輯消費明細。",
|
||||
"continue": "繼續"
|
||||
},
|
||||
"unknown": "未知",
|
||||
"TooBigToast": {
|
||||
"title": "文件過大",
|
||||
"description": "可以上傳的最大文件大小為 {maxSize}。這份文件大小為 {size}。"
|
||||
},
|
||||
"ErrorToast": {
|
||||
"title": "上傳文件時發生錯誤",
|
||||
"description": "上傳文件時發生錯誤,請再試一次或更換文件。",
|
||||
"retry": "重試"
|
||||
}
|
||||
},
|
||||
"Balances": {
|
||||
"title": "總覽",
|
||||
"description": "這是每個成員已支付及需支付的金額",
|
||||
"Reimbursements": {
|
||||
"title": "建議核銷",
|
||||
"description": "這是建議的銷帳方式",
|
||||
"noImbursements": "看起來你的群組目前不需要銷帳😁",
|
||||
"owes": "<strong>{from}</strong> 欠 <strong>{to}</strong>",
|
||||
"markAsPaid": "標記為已支付"
|
||||
}
|
||||
},
|
||||
"Stats": {
|
||||
"title": "統計",
|
||||
"Totals": {
|
||||
"title": "總計",
|
||||
"description": "整個群組的花費總計。",
|
||||
"groupSpendings": "群組總開銷",
|
||||
"groupEarnings": "群組總收入",
|
||||
"yourSpendings": "你的總開銷",
|
||||
"yourEarnings": "你的總收入",
|
||||
"yourShare": "你的總計份額"
|
||||
}
|
||||
},
|
||||
"Activity": {
|
||||
"title": "明細",
|
||||
"description": "群組所有活動總覽",
|
||||
"noActivity": "你的全組目前沒有任何活動",
|
||||
"someone": "某人",
|
||||
"settingsModified": "群組設定已被<strong>{participant}</strong>更改。",
|
||||
"expenseCreated": "消費 <em>{expense}</em> 由 <strong>{participant}</strong> 新增。",
|
||||
"expenseUpdated": "消費 <em>{expense}</em> 由 <strong>{participant}</strong> 更新。",
|
||||
"expenseDeleted": "消費 <em>{expense}</em> 由 <strong>{participant}</strong> 刪除。",
|
||||
"Groups": {
|
||||
"today": "今天",
|
||||
"yesterday": "昨天",
|
||||
"earlierThisWeek": "本週稍早",
|
||||
"lastWeek": "上週",
|
||||
"earlierThisMonth": "本月稍早",
|
||||
"lastMonth": "上個月",
|
||||
"earlierThisYear": "今年稍早",
|
||||
"lastYear": "去年",
|
||||
"older": "更早"
|
||||
}
|
||||
},
|
||||
"Information": {
|
||||
"title": "資訊",
|
||||
"description": "可在此添加群組相關資訊、公告及說明等。",
|
||||
"empty": "目前沒有群組資訊。"
|
||||
},
|
||||
"Settings": {
|
||||
"title": "設定"
|
||||
},
|
||||
"Share": {
|
||||
"title": "分享",
|
||||
"description": "將此網址分享給其他人以加入群組並查看及新增消費紀錄",
|
||||
"warning": "警告!",
|
||||
"warningHelp": "任何有此連結的人都可以看到及編輯消費紀錄。請小心使用!"
|
||||
},
|
||||
"SchemaErrors": {
|
||||
"min1": "請輸入至少 1 個字。",
|
||||
"min2": "請輸入至少 2 個字。",
|
||||
"max5": "請輸入至少 5 個字。",
|
||||
"max50": "請輸入至少 50 個字。",
|
||||
"duplicateParticipantName": "此名稱已被使用",
|
||||
"titleRequired": "請輸入標題。",
|
||||
"invalidNumber": "數值無效。",
|
||||
"amountRequired": "必須輸入一個金額。",
|
||||
"amountNotZero": "金額不可為 0。",
|
||||
"amountTenMillion": "金額需小於 10,000,000。",
|
||||
"paidByRequired": "必須選擇一個成員。",
|
||||
"paidForMin1": "這筆消費必須包含至少一個成員。",
|
||||
"noZeroShares": "份額需大於 0。",
|
||||
"amountSum": "金額總計必須等於消費金額。",
|
||||
"percentageSum": "百分比加總必須等於 100。"
|
||||
},
|
||||
"Categories": {
|
||||
"search": "搜尋類別……",
|
||||
"noCategory": "未找到類別。",
|
||||
"Uncategorized": {
|
||||
"heading": "未分類",
|
||||
"General": "一般",
|
||||
"Payment": "支付"
|
||||
},
|
||||
"Entertainment": {
|
||||
"heading": "娛樂",
|
||||
"Entertainment": "娛樂",
|
||||
"Games": "遊戲",
|
||||
"Movies": "電影",
|
||||
"Music": "音樂",
|
||||
"Sports": "運動"
|
||||
},
|
||||
"Food and Drink": {
|
||||
"heading": "飲食",
|
||||
"Food and Drink": "飲食",
|
||||
"Dining Out": "外食",
|
||||
"Groceries": "食材",
|
||||
"Liquor": "酒水"
|
||||
},
|
||||
"Home": {
|
||||
"heading": "居家",
|
||||
"Home": "居家",
|
||||
"Electronics": "電子產品",
|
||||
"Furniture": "家具",
|
||||
"Household Supplies": "日用品",
|
||||
"Maintenance": "維護",
|
||||
"Mortgage": "貸款",
|
||||
"Pets": "寵物",
|
||||
"Rent": "租金",
|
||||
"Services": "服務"
|
||||
},
|
||||
"Life": {
|
||||
"heading": "生活",
|
||||
"Childcare": "育兒",
|
||||
"Clothing": "衣服",
|
||||
"Education": "教育",
|
||||
"Gifts": "禮物",
|
||||
"Insurance": "保險",
|
||||
"Medical Expenses": "醫療支出",
|
||||
"Taxes": "稅"
|
||||
},
|
||||
"Transportation": {
|
||||
"heading": "交通",
|
||||
"Transportation": "交通",
|
||||
"Bicycle": "自行車",
|
||||
"Bus/Train": "公車/火車",
|
||||
"Car": "汽車",
|
||||
"Gas/Fuel": "油錢/燃料",
|
||||
"Hotel": "旅館/住宿",
|
||||
"Parking": "停車",
|
||||
"Plane": "飛機",
|
||||
"Taxi": "計程車"
|
||||
},
|
||||
"Utilities": {
|
||||
"heading": "日常帳單",
|
||||
"Utilities": "日常帳單",
|
||||
"Cleaning": "清潔費",
|
||||
"Electricity": "電費",
|
||||
"Heat/Gas": "暖氣/瓦斯",
|
||||
"Trash": "垃圾費",
|
||||
"TV/Phone/Internet": "電視/電話/網路",
|
||||
"Water": "水費"
|
||||
}
|
||||
}
|
||||
}
|
||||
464
package-lock.json
generated
@@ -11,6 +11,7 @@
|
||||
"dependencies": {
|
||||
"@formatjs/intl-localematcher": "^0.5.4",
|
||||
"@hookform/resolvers": "^3.3.2",
|
||||
"@json2csv/plainjs": "^7.0.6",
|
||||
"@prisma/client": "^5.6.0",
|
||||
"@radix-ui/react-checkbox": "^1.0.4",
|
||||
"@radix-ui/react-collapsible": "^1.0.3",
|
||||
@@ -26,13 +27,18 @@
|
||||
"@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",
|
||||
"dayjs": "^1.11.10",
|
||||
"embla-carousel-react": "^8.0.0-rc21",
|
||||
"lucide-react": "^0.290.0",
|
||||
"lucide-react": "^0.501.0",
|
||||
"nanoid": "^5.0.4",
|
||||
"negotiator": "^0.6.3",
|
||||
"next": "^14.2.5",
|
||||
@@ -47,13 +53,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",
|
||||
@@ -3968,12 +3977,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.24.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.24.7.tgz",
|
||||
"integrity": "sha512-BcYH1CVJBO9tvyIZ2jVeXgSIMvGZ2FDRvDdOIVQyuklNKSsx+eppDEBq/g47Ayw+RqNFE+URvOShmf+f/qwAlA==",
|
||||
"version": "7.26.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz",
|
||||
"integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/highlight": "^7.24.7",
|
||||
"@babel/helper-validator-identifier": "^7.25.9",
|
||||
"js-tokens": "^4.0.0",
|
||||
"picocolors": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
@@ -4119,19 +4130,21 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-string-parser": {
|
||||
"version": "7.24.8",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.24.8.tgz",
|
||||
"integrity": "sha512-pO9KhhRcuUyGnJWwyEgnRJTSIZHiT+vMD0kPeD+so0l7mxkMT19g3pjY9GTnHySck/hDzq+dtW/4VgnMkippsQ==",
|
||||
"version": "7.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz",
|
||||
"integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-validator-identifier": {
|
||||
"version": "7.24.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.7.tgz",
|
||||
"integrity": "sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w==",
|
||||
"version": "7.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz",
|
||||
"integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
@@ -4146,111 +4159,27 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helpers": {
|
||||
"version": "7.25.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.25.0.tgz",
|
||||
"integrity": "sha512-MjgLZ42aCm0oGjJj8CtSM3DB8NOOf8h2l7DCTePJs29u+v7yO/RBX9nShlKMgFnRks/Q4tBAe7Hxnov9VkGwLw==",
|
||||
"version": "7.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.0.tgz",
|
||||
"integrity": "sha512-U5eyP/CTFPuNE3qk+WZMxFkp/4zUzdceQlfzf7DdGdhp+Fezd7HD+i8Y24ZuTMKX3wQBld449jijbGq6OdGNQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/template": "^7.25.0",
|
||||
"@babel/types": "^7.25.0"
|
||||
"@babel/template": "^7.27.0",
|
||||
"@babel/types": "^7.27.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/highlight": {
|
||||
"version": "7.24.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.24.7.tgz",
|
||||
"integrity": "sha512-EStJpq4OuY8xYfhGVXngigBJRWxftKX9ksiGDnmlY3o7B/V7KIAc9X4oiK87uPJSc/vs5L869bem5fhZa8caZw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@babel/helper-validator-identifier": "^7.24.7",
|
||||
"chalk": "^2.4.2",
|
||||
"js-tokens": "^4.0.0",
|
||||
"picocolors": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/highlight/node_modules/ansi-styles": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
|
||||
"integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"color-convert": "^1.9.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/highlight/node_modules/chalk": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
|
||||
"integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"ansi-styles": "^3.2.1",
|
||||
"escape-string-regexp": "^1.0.5",
|
||||
"supports-color": "^5.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/highlight/node_modules/color-convert": {
|
||||
"version": "1.9.3",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
|
||||
"integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"color-name": "1.1.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/highlight/node_modules/color-name": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
|
||||
"integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@babel/highlight/node_modules/escape-string-regexp": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
|
||||
"integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/highlight/node_modules/has-flag": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
|
||||
"integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/highlight/node_modules/supports-color": {
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
|
||||
"integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"has-flag": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/parser": {
|
||||
"version": "7.25.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.25.3.tgz",
|
||||
"integrity": "sha512-iLTJKDbJ4hMvFPgQwwsVoxtHyWpKKPBrxkANrSYewDPaPpT5py5yeVkgPIJ7XYXhndxJpaA3PyALSXQ7u8e/Dw==",
|
||||
"version": "7.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.0.tgz",
|
||||
"integrity": "sha512-iaepho73/2Pz7w2eMS0Q5f83+0RKI7i4xmiYeBmDzfRVbQtTOG7Ts0S4HzJVsTMGI9keU8rNfuZr8DKfSt7Yyg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.25.2"
|
||||
"@babel/types": "^7.27.0"
|
||||
},
|
||||
"bin": {
|
||||
"parser": "bin/babel-parser.js"
|
||||
@@ -4482,9 +4411,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.23.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.2.tgz",
|
||||
"integrity": "sha512-mM8eg4yl5D6i3lu2QKPuPH4FArvJ8KhTofbE7jwMUv9KX5mBvwPAqnV3MlyBNqdp9RyRKP6Yck8TrfYrPvX3bg==",
|
||||
"version": "7.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz",
|
||||
"integrity": "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"regenerator-runtime": "^0.14.0"
|
||||
@@ -4494,14 +4423,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/template": {
|
||||
"version": "7.25.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.0.tgz",
|
||||
"integrity": "sha512-aOOgh1/5XzKvg1jvVz7AVrx2piJ2XBi227DHmbY6y+bM9H2FlN+IfecYu4Xl0cNiiVejlsCri89LUsbj8vJD9Q==",
|
||||
"version": "7.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.0.tgz",
|
||||
"integrity": "sha512-2ncevenBqXI6qRMukPlXwHKHchC7RyMuu4xv5JBXRfOGVcTy1mXCD12qrp7Jsoxll1EV3+9sE4GugBVRjT2jFA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.24.7",
|
||||
"@babel/parser": "^7.25.0",
|
||||
"@babel/types": "^7.25.0"
|
||||
"@babel/code-frame": "^7.26.2",
|
||||
"@babel/parser": "^7.27.0",
|
||||
"@babel/types": "^7.27.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -4535,14 +4465,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/types": {
|
||||
"version": "7.25.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.25.2.tgz",
|
||||
"integrity": "sha512-YTnYtra7W9e6/oAZEHj0bJehPRUlLH9/fbpT5LfB0NhQXyALCRkRs3zH9v07IYhkgpqX6Z78FnuccZr/l4Fs4Q==",
|
||||
"version": "7.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.0.tgz",
|
||||
"integrity": "sha512-H45s8fVLYjbhFH62dIJ3WtmJ6RSPt/3DRO0ZcT2SUiYiQyz3BLVb9ADEnLl91m74aQPS3AzzeajZHYOalWe3bg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-string-parser": "^7.24.8",
|
||||
"@babel/helper-validator-identifier": "^7.24.7",
|
||||
"to-fast-properties": "^2.0.0"
|
||||
"@babel/helper-string-parser": "^7.25.9",
|
||||
"@babel/helper-validator-identifier": "^7.25.9"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -5312,10 +5242,27 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@json2csv/formatters": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@json2csv/formatters/-/formatters-7.0.6.tgz",
|
||||
"integrity": "sha512-hjIk1H1TR4ydU5ntIENEPgoMGW+Q7mJ+537sDFDbsk+Y3EPl2i4NfFVjw0NJRgT+ihm8X30M67mA8AS6jPidSA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@json2csv/plainjs": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@json2csv/plainjs/-/plainjs-7.0.6.tgz",
|
||||
"integrity": "sha512-4Md7RPDCSYpmW1HWIpWBOqCd4vWfIqm53S3e/uzQ62iGi7L3r34fK/8nhOMEe+/eVfCx8+gdSCt1d74SlacQHw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@json2csv/formatters": "^7.0.6",
|
||||
"@streamparser/json": "^0.0.20"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/env": {
|
||||
"version": "14.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.5.tgz",
|
||||
"integrity": "sha512-/zZGkrTOsraVfYjGP8uM0p6r0BDT6xWpkjdVbcz66PJVSpwXX3yNiRycxAuDfBKGWBrZBXRuK/YVlkNgxHGwmA=="
|
||||
"version": "14.2.28",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.28.tgz",
|
||||
"integrity": "sha512-PAmWhJfJQlP+kxZwCjrVd9QnR5x0R3u0mTXTiZDgSd4h5LdXmjxCCWbN9kq6hkZBOax8Rm3xDW5HagWyJuT37g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@next/eslint-plugin-next": {
|
||||
"version": "14.1.0",
|
||||
@@ -5373,12 +5320,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-arm64": {
|
||||
"version": "14.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.5.tgz",
|
||||
"integrity": "sha512-/9zVxJ+K9lrzSGli1///ujyRfon/ZneeZ+v4ptpiPoOU+GKZnm8Wj8ELWU1Pm7GHltYRBklmXMTUqM/DqQ99FQ==",
|
||||
"version": "14.2.28",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.28.tgz",
|
||||
"integrity": "sha512-kzGChl9setxYWpk3H6fTZXXPFFjg7urptLq5o5ZgYezCrqlemKttwMT5iFyx/p1e/JeglTwDFRtb923gTJ3R1w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
@@ -5388,12 +5336,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-x64": {
|
||||
"version": "14.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.5.tgz",
|
||||
"integrity": "sha512-vXHOPCwfDe9qLDuq7U1OYM2wUY+KQ4Ex6ozwsKxp26BlJ6XXbHleOUldenM67JRyBfVjv371oneEvYd3H2gNSA==",
|
||||
"version": "14.2.28",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.28.tgz",
|
||||
"integrity": "sha512-z6FXYHDJlFOzVEOiiJ/4NG8aLCeayZdcRSMjPDysW297Up6r22xw6Ea9AOwQqbNsth8JNgIK8EkWz2IDwaLQcw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
@@ -5403,12 +5352,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-gnu": {
|
||||
"version": "14.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.5.tgz",
|
||||
"integrity": "sha512-vlhB8wI+lj8q1ExFW8lbWutA4M2ZazQNvMWuEDqZcuJJc78iUnLdPPunBPX8rC4IgT6lIx/adB+Cwrl99MzNaA==",
|
||||
"version": "14.2.28",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.28.tgz",
|
||||
"integrity": "sha512-9ARHLEQXhAilNJ7rgQX8xs9aH3yJSj888ssSjJLeldiZKR4D7N08MfMqljk77fAwZsWwsrp8ohHsMvurvv9liQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -5418,12 +5368,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-musl": {
|
||||
"version": "14.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.5.tgz",
|
||||
"integrity": "sha512-NpDB9NUR2t0hXzJJwQSGu1IAOYybsfeB+LxpGsXrRIb7QOrYmidJz3shzY8cM6+rO4Aojuef0N/PEaX18pi9OA==",
|
||||
"version": "14.2.28",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.28.tgz",
|
||||
"integrity": "sha512-p6gvatI1nX41KCizEe6JkF0FS/cEEF0u23vKDpl+WhPe/fCTBeGkEBh7iW2cUM0rvquPVwPWdiUR6Ebr/kQWxQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -5433,12 +5384,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-gnu": {
|
||||
"version": "14.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.5.tgz",
|
||||
"integrity": "sha512-8XFikMSxWleYNryWIjiCX+gU201YS+erTUidKdyOVYi5qUQo/gRxv/3N1oZFCgqpesN6FPeqGM72Zve+nReVXQ==",
|
||||
"version": "14.2.28",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.28.tgz",
|
||||
"integrity": "sha512-nsiSnz2wO6GwMAX2o0iucONlVL7dNgKUqt/mDTATGO2NY59EO/ZKnKEr80BJFhuA5UC1KZOMblJHWZoqIJddpA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -5448,12 +5400,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-musl": {
|
||||
"version": "14.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.5.tgz",
|
||||
"integrity": "sha512-6QLwi7RaYiQDcRDSU/os40r5o06b5ue7Jsk5JgdRBGGp8l37RZEh9JsLSM8QF0YDsgcosSeHjglgqi25+m04IQ==",
|
||||
"version": "14.2.28",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.28.tgz",
|
||||
"integrity": "sha512-+IuGQKoI3abrXFqx7GtlvNOpeExUH1mTIqCrh1LGFf8DnlUcTmOOCApEnPJUSLrSbzOdsF2ho2KhnQoO0I1RDw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -5463,12 +5416,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-arm64-msvc": {
|
||||
"version": "14.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.5.tgz",
|
||||
"integrity": "sha512-1GpG2VhbspO+aYoMOQPQiqc/tG3LzmsdBH0LhnDS3JrtDx2QmzXe0B6mSZZiN3Bq7IOMXxv1nlsjzoS1+9mzZw==",
|
||||
"version": "14.2.28",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.28.tgz",
|
||||
"integrity": "sha512-l61WZ3nevt4BAnGksUVFKy2uJP5DPz2E0Ma/Oklvo3sGj9sw3q7vBWONFRgz+ICiHpW5mV+mBrkB3XEubMrKaA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
@@ -5478,12 +5432,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-ia32-msvc": {
|
||||
"version": "14.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.5.tgz",
|
||||
"integrity": "sha512-Igh9ZlxwvCDsu6438FXlQTHlRno4gFpJzqPjSIBZooD22tKeI4fE/YMRoHVJHmrQ2P5YL1DoZ0qaOKkbeFWeMg==",
|
||||
"version": "14.2.28",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.28.tgz",
|
||||
"integrity": "sha512-+Kcp1T3jHZnJ9v9VTJ/yf1t/xmtFAc/Sge4v7mVc1z+NYfYzisi8kJ9AsY8itbgq+WgEwMtOpiLLJsUy2qnXZw==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
@@ -5493,12 +5448,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-x64-msvc": {
|
||||
"version": "14.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.5.tgz",
|
||||
"integrity": "sha512-tEQ7oinq1/CjSG9uSTerca3v4AZ+dFa+4Yu6ihaG8Ud8ddqLQgFGcnwYls13H5X5CPDPZJdYxyeMui6muOLd4g==",
|
||||
"version": "14.2.28",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.28.tgz",
|
||||
"integrity": "sha512-1gCmpvyhz7DkB1srRItJTnmR2UwQPAUXXIg9r0/56g3O8etGmwlX68skKXJOp9EejW3hhv7nSQUJ2raFiz4MoA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
@@ -8922,6 +8878,12 @@
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@streamparser/json": {
|
||||
"version": "0.0.20",
|
||||
"resolved": "https://registry.npmjs.org/@streamparser/json/-/json-0.0.20.tgz",
|
||||
"integrity": "sha512-VqAAkydywPpkw63WQhPVKCD3SdwXuihCUVZbbiY3SfSTGQyHmwRoq27y4dmJdZuJwd5JIlQoMPyGvMbUPY0RKQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@swc/counter": {
|
||||
"version": "0.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
|
||||
@@ -8962,6 +8924,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 +9051,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 +10813,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",
|
||||
@@ -10816,9 +10856,9 @@
|
||||
"devOptional": true
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.3",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
|
||||
"integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -13155,6 +13195,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",
|
||||
@@ -14481,12 +14533,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "0.290.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.290.0.tgz",
|
||||
"integrity": "sha512-CBDPRLOPjdo+bVlxhaa7FVWaB8OrZZQ34mwm0Fsz9ut6JltN/Td55640ur8bRWSJuz6+nX2klKrpBpV7ktwD3Q==",
|
||||
"version": "0.501.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.501.0.tgz",
|
||||
"integrity": "sha512-E2KoyhW59fCb/yUbR3rbDer83fqn7a8NG91ZhIot2yWaPHjPyGzzsNKh40N//GezYShAuycf3TcQksRQznIsRw==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0"
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lz-string": {
|
||||
@@ -14566,12 +14618,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/micromatch": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz",
|
||||
"integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==",
|
||||
"version": "4.0.8",
|
||||
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
||||
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"braces": "^3.0.2",
|
||||
"braces": "^3.0.3",
|
||||
"picomatch": "^2.3.1"
|
||||
},
|
||||
"engines": {
|
||||
@@ -14663,15 +14715,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.0.4.tgz",
|
||||
"integrity": "sha512-vAjmBf13gsmhXSgBrtIclinISzFFy22WwCYoyilZlsrRXNIHSwgFQ1bEdjRwMT3aoadeIF6HMuDRlOxzfXV8ig==",
|
||||
"version": "5.1.5",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.5.tgz",
|
||||
"integrity": "sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"nanoid": "bin/nanoid.js"
|
||||
},
|
||||
@@ -14695,11 +14748,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/next": {
|
||||
"version": "14.2.5",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-14.2.5.tgz",
|
||||
"integrity": "sha512-0f8aRfBVL+mpzfBjYfQuLWh2WyAwtJXCRfkPF4UJ5qd2YwrHczsrSzXU4tRMV0OAxR8ZJZWPFn6uhSC56UTsLA==",
|
||||
"version": "14.2.28",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-14.2.28.tgz",
|
||||
"integrity": "sha512-QLEIP/kYXynIxtcKB6vNjtWLVs3Y4Sb+EClTC/CSVzdLD1gIuItccpu/n1lhmduffI32iPGEK2cLLxxt28qgYA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@next/env": "14.2.5",
|
||||
"@next/env": "14.2.28",
|
||||
"@swc/helpers": "0.5.5",
|
||||
"busboy": "1.6.0",
|
||||
"caniuse-lite": "^1.0.30001579",
|
||||
@@ -14714,15 +14768,15 @@
|
||||
"node": ">=18.17.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@next/swc-darwin-arm64": "14.2.5",
|
||||
"@next/swc-darwin-x64": "14.2.5",
|
||||
"@next/swc-linux-arm64-gnu": "14.2.5",
|
||||
"@next/swc-linux-arm64-musl": "14.2.5",
|
||||
"@next/swc-linux-x64-gnu": "14.2.5",
|
||||
"@next/swc-linux-x64-musl": "14.2.5",
|
||||
"@next/swc-win32-arm64-msvc": "14.2.5",
|
||||
"@next/swc-win32-ia32-msvc": "14.2.5",
|
||||
"@next/swc-win32-x64-msvc": "14.2.5"
|
||||
"@next/swc-darwin-arm64": "14.2.28",
|
||||
"@next/swc-darwin-x64": "14.2.28",
|
||||
"@next/swc-linux-arm64-gnu": "14.2.28",
|
||||
"@next/swc-linux-arm64-musl": "14.2.28",
|
||||
"@next/swc-linux-x64-gnu": "14.2.28",
|
||||
"@next/swc-linux-x64-musl": "14.2.28",
|
||||
"@next/swc-win32-arm64-msvc": "14.2.28",
|
||||
"@next/swc-win32-ia32-msvc": "14.2.28",
|
||||
"@next/swc-win32-x64-msvc": "14.2.28"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": "^1.1.0",
|
||||
@@ -15606,15 +15660,16 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/postcss/node_modules/nanoid": {
|
||||
"version": "3.3.7",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
|
||||
"integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==",
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"nanoid": "bin/nanoid.cjs"
|
||||
},
|
||||
@@ -16298,6 +16353,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 +16872,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",
|
||||
@@ -16968,15 +17041,6 @@
|
||||
"integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/to-fast-properties": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz",
|
||||
"integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/to-regex-range": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
@@ -17319,6 +17383,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 +17895,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"
|
||||
|
||||
13
package.json
@@ -18,6 +18,7 @@
|
||||
"dependencies": {
|
||||
"@formatjs/intl-localematcher": "^0.5.4",
|
||||
"@hookform/resolvers": "^3.3.2",
|
||||
"@json2csv/plainjs": "^7.0.6",
|
||||
"@prisma/client": "^5.6.0",
|
||||
"@radix-ui/react-checkbox": "^1.0.4",
|
||||
"@radix-ui/react-collapsible": "^1.0.3",
|
||||
@@ -33,13 +34,18 @@
|
||||
"@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",
|
||||
"dayjs": "^1.11.10",
|
||||
"embla-carousel-react": "^8.0.0-rc21",
|
||||
"lucide-react": "^0.290.0",
|
||||
"lucide-react": "^0.501.0",
|
||||
"nanoid": "^5.0.4",
|
||||
"negotiator": "^0.6.3",
|
||||
"next": "^14.2.5",
|
||||
@@ -54,13 +60,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",
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "RecurrenceRule" AS ENUM ('NONE', 'DAILY', 'WEEKLY', 'MONTHLY');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Expense" ADD COLUMN "recurrenceRule" "RecurrenceRule" DEFAULT 'NONE',
|
||||
ADD COLUMN "recurringExpenseLinkId" TEXT;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "RecurringExpenseLink" (
|
||||
"id" TEXT NOT NULL,
|
||||
"groupId" TEXT NOT NULL,
|
||||
"currentFrameExpenseId" TEXT NOT NULL,
|
||||
"nextExpenseCreatedAt" TIMESTAMP(3),
|
||||
"nextExpenseDate" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "RecurringExpenseLink_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "RecurringExpenseLink_currentFrameExpenseId_key" ON "RecurringExpenseLink"("currentFrameExpenseId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "RecurringExpenseLink_groupId_idx" ON "RecurringExpenseLink"("groupId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "RecurringExpenseLink_groupId_nextExpenseCreatedAt_nextExpen_idx" ON "RecurringExpenseLink"("groupId", "nextExpenseCreatedAt", "nextExpenseDate" DESC);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "RecurringExpenseLink" ADD CONSTRAINT "RecurringExpenseLink_currentFrameExpenseId_fkey" FOREIGN KEY ("currentFrameExpenseId") REFERENCES "Expense"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1 @@
|
||||
INSERT INTO "Category" ("id", "grouping", "name") VALUES (43, 'Life', 'Donation');
|
||||
@@ -55,6 +55,10 @@ model Expense {
|
||||
createdAt DateTime @default(now())
|
||||
documents ExpenseDocument[]
|
||||
notes String?
|
||||
|
||||
recurrenceRule RecurrenceRule? @default(NONE)
|
||||
recurringExpenseLink RecurringExpenseLink?
|
||||
recurringExpenseLinkId String?
|
||||
}
|
||||
|
||||
model ExpenseDocument {
|
||||
@@ -73,6 +77,29 @@ enum SplitMode {
|
||||
BY_AMOUNT
|
||||
}
|
||||
|
||||
model RecurringExpenseLink {
|
||||
id String @id
|
||||
groupId String
|
||||
currentFrameExpense Expense @relation(fields: [currentFrameExpenseId], references: [id], onDelete: Cascade)
|
||||
currentFrameExpenseId String @unique
|
||||
|
||||
// Note: We do not want to link to the next expense because once it is created, it should be
|
||||
// treated as it's own independent entity. This means that if a user wants to delete an Expense
|
||||
// and any prior related recurring expenses, they'll need to delete them one by one.
|
||||
nextExpenseCreatedAt DateTime?
|
||||
nextExpenseDate DateTime
|
||||
|
||||
@@index([groupId])
|
||||
@@index([groupId, nextExpenseCreatedAt, nextExpenseDate(sort: Desc)])
|
||||
}
|
||||
|
||||
enum RecurrenceRule {
|
||||
NONE
|
||||
DAILY
|
||||
WEEKLY
|
||||
MONTHLY
|
||||
}
|
||||
|
||||
model ExpensePaidFor {
|
||||
expense Expense @relation(fields: [expenseId], references: [id], onDelete: Cascade)
|
||||
participant Participant @relation(fields: [participantId], references: [id], onDelete: Cascade)
|
||||
|
||||
|
Before Width: | Height: | Size: 28 KiB |
BIN
public/logo/128x128.png
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
BIN
public/logo/144x144.png
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
BIN
public/logo/192x192.png
Normal file
|
After Width: | Height: | Size: 6.6 KiB |
BIN
public/logo/256x256.png
Normal file
|
After Width: | Height: | Size: 8.8 KiB |
BIN
public/logo/48x48.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
public/logo/512x512-maskable.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
public/logo/512x512.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
public/logo/64x64.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
@@ -3,4 +3,4 @@
|
||||
set -euxo pipefail
|
||||
|
||||
npx prisma migrate deploy
|
||||
npm run start
|
||||
exec npm run start
|
||||
|
||||
13
src/app/api/trpc/[trpc]/route.ts
Normal 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 }
|
||||
@@ -41,7 +41,8 @@
|
||||
--muted-foreground: 240 5% 64.9%;
|
||||
--accent: 12 6.5% 15.1%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
/* --destructive: 0 62.8% 30.6%; */
|
||||
--destructive: 0 87% 47%;
|
||||
--destructive-foreground: 0 85.7% 97.3%;
|
||||
--border: 240 3.7% 15.9%;
|
||||
--input: 240 3.7% 15.9%;
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
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'
|
||||
import { useCurrentGroup } from '../current-group-context'
|
||||
|
||||
type Props = {
|
||||
groupId: string
|
||||
participants: Participant[]
|
||||
expenses: Awaited<ReturnType<typeof getGroupExpenses>>
|
||||
activities: Activity[]
|
||||
}
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
const DATE_GROUPS = {
|
||||
TODAY: 'today',
|
||||
@@ -48,23 +50,62 @@ 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() {
|
||||
const t = useTranslations('Activity')
|
||||
const { group, groupId } = useCurrentGroup()
|
||||
|
||||
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 || !group) return <ActivitiesLoading />
|
||||
|
||||
const groupedActivitiesByDate = getGroupedActivitiesByDate(activities)
|
||||
|
||||
return activities.length > 0 ? (
|
||||
@@ -86,27 +127,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)
|
||||
? 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>
|
||||
)
|
||||
}
|
||||
|
||||
32
src/app/groups/[groupId]/activity/page.client.tsx
Normal 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() {
|
||||
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 />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,51 +1,10 @@
|
||||
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',
|
||||
}
|
||||
|
||||
export default async function ActivityPage({
|
||||
params: { groupId },
|
||||
}: {
|
||||
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>
|
||||
</>
|
||||
)
|
||||
export default async function ActivityPage() {
|
||||
return <ActivityPageClient />
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
'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'
|
||||
import { match } from 'ts-pattern'
|
||||
import { useCurrentGroup } from '../current-group-context'
|
||||
|
||||
export default function BalancesAndReimbursements() {
|
||||
const utils = trpc.useUtils()
|
||||
const { groupId, group } = useCurrentGroup()
|
||||
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 || !group
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="mb-4">
|
||||
<CardHeader>
|
||||
<CardTitle>{t('title')}</CardTitle>
|
||||
<CardDescription>{t('description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<BalancesLoading participantCount={group?.participants.length} />
|
||||
) : (
|
||||
<BalancesList
|
||||
balances={balancesData.balances}
|
||||
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>
|
||||
{isLoading ? (
|
||||
<ReimbursementsLoading
|
||||
participantCount={group?.participants.length}
|
||||
/>
|
||||
) : (
|
||||
<ReimbursementList
|
||||
reimbursements={balancesData.reimbursements}
|
||||
participants={group?.participants}
|
||||
currency={group?.currency}
|
||||
groupId={groupId}
|
||||
/>
|
||||
)}
|
||||
</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
|
||||
}) => {
|
||||
const barWidth = (index: number) =>
|
||||
match(index % 3)
|
||||
.with(0, () => 'w-1/3')
|
||||
.with(1, () => 'w-2/3')
|
||||
.otherwise(() => 'w-full')
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 py-1 gap-y-2">
|
||||
{Array(participantCount)
|
||||
.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 ${barWidth(index)} rounded-l-none`} />
|
||||
</div>
|
||||
</Fragment>
|
||||
) : (
|
||||
<Fragment key={index}>
|
||||
<div className="flex items-center justify-end">
|
||||
<Skeleton className={`h-7 ${barWidth(index)} rounded-r-none`} />
|
||||
</div>
|
||||
<div className="flex items-center pl-2">
|
||||
<Skeleton className="h-3 w-16" />
|
||||
</div>
|
||||
</Fragment>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,70 +1,10 @@
|
||||
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 = {
|
||||
title: 'Balances',
|
||||
}
|
||||
|
||||
export default async function GroupPage({
|
||||
params: { groupId },
|
||||
}: {
|
||||
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>
|
||||
</>
|
||||
)
|
||||
export default async function GroupPage() {
|
||||
return <BalancesAndReimbursements />
|
||||
}
|
||||
|
||||
30
src/app/groups/[groupId]/current-group-context.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { AppRouterOutput } from '@/trpc/routers/_app'
|
||||
import { PropsWithChildren, createContext, useContext } from 'react'
|
||||
|
||||
type Group = NonNullable<AppRouterOutput['groups']['get']['group']>
|
||||
|
||||
type GroupContext =
|
||||
| { isLoading: false; groupId: string; group: Group }
|
||||
| { isLoading: true; groupId: string; group: undefined }
|
||||
|
||||
const CurrentGroupContext = createContext<GroupContext | null>(null)
|
||||
|
||||
export const useCurrentGroup = () => {
|
||||
const context = useContext(CurrentGroupContext)
|
||||
if (!context)
|
||||
throw new Error(
|
||||
'Missing context. Should be called inside a CurrentGroupProvider.',
|
||||
)
|
||||
return context
|
||||
}
|
||||
|
||||
export const CurrentGroupProvider = ({
|
||||
children,
|
||||
...props
|
||||
}: PropsWithChildren<GroupContext>) => {
|
||||
return (
|
||||
<CurrentGroupContext.Provider value={props}>
|
||||
{children}
|
||||
</CurrentGroupContext.Provider>
|
||||
)
|
||||
}
|
||||
25
src/app/groups/[groupId]/edit/edit-group.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
'use client'
|
||||
|
||||
import { GroupForm } from '@/components/group-form'
|
||||
import { trpc } from '@/trpc/client'
|
||||
import { useCurrentGroup } from '../current-group-context'
|
||||
|
||||
export const EditGroup = () => {
|
||||
const { groupId } = useCurrentGroup()
|
||||
const { data, isLoading } = trpc.groups.getDetails.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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,35 +1,10 @@
|
||||
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',
|
||||
}
|
||||
|
||||
export default async function EditGroupPage({
|
||||
params: { groupId },
|
||||
}: {
|
||||
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}
|
||||
/>
|
||||
)
|
||||
export default async function EditGroupPage() {
|
||||
return <EditGroup />
|
||||
}
|
||||
|
||||
@@ -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()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
FerrisWheel,
|
||||
Fuel,
|
||||
Gift,
|
||||
HandHelping,
|
||||
Home,
|
||||
Hotel,
|
||||
Lamp,
|
||||
@@ -96,6 +97,8 @@ function getCategoryIcon(category: string): LucideIcon {
|
||||
return Baby
|
||||
case 'Life/Clothing':
|
||||
return Shirt
|
||||
case 'Life/Donation':
|
||||
return HandHelping
|
||||
case 'Life/Education':
|
||||
return LibraryBig
|
||||
case 'Life/Gifts':
|
||||
|
||||
45
src/app/groups/[groupId]/expenses/create-expense-form.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -22,7 +22,7 @@ export async function extractExpenseInformationFromImage(imageUrl: string) {
|
||||
text: `
|
||||
This image contains a receipt.
|
||||
Read the total amount and store it as a non-formatted number without any other text or currency.
|
||||
Then guess the category for this receipt amoung the following categories and store its ID: ${categories.map(
|
||||
Then guess the category for this receipt among the following categories and store its ID: ${categories.map(
|
||||
(category) => formatCategoryForAIPrompt(category),
|
||||
)}.
|
||||
Guess the expense’s date and store it as yyyy-mm-dd.
|
||||
|
||||
@@ -27,27 +27,56 @@ 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'
|
||||
import Image from 'next/image'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { PropsWithChildren, ReactNode, useState } from 'react'
|
||||
|
||||
type Props = {
|
||||
groupId: string
|
||||
groupCurrency: string
|
||||
categories: Category[]
|
||||
}
|
||||
import { useCurrentGroup } from '../current-group-context'
|
||||
|
||||
const MAX_FILE_SIZE = 5 * 1024 ** 2
|
||||
|
||||
export function CreateFromReceiptButton({
|
||||
groupId,
|
||||
groupCurrency,
|
||||
categories,
|
||||
}: Props) {
|
||||
export function CreateFromReceiptButton() {
|
||||
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 />
|
||||
</DialogOrDrawer>
|
||||
)
|
||||
}
|
||||
|
||||
function ReceiptDialogContent() {
|
||||
const { group } = useCurrentGroup()
|
||||
const { data: categoriesData } = trpc.categories.list.useQuery()
|
||||
const categories = categoriesData?.categories
|
||||
|
||||
const locale = useLocale()
|
||||
const t = useTranslations('CreateFromReceipt')
|
||||
const [pending, setPending] = useState(false)
|
||||
@@ -58,7 +87,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 +135,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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
11
src/app/groups/[groupId]/expenses/documents-count.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Paperclip } from 'lucide-react'
|
||||
|
||||
export function DocumentsCount({ count }: { count: number }) {
|
||||
if (count === 0) return <></>
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<Paperclip className="w-3.5 h-3.5 mr-1 mt-0.5 text-muted-foreground" />
|
||||
<span>{count}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
65
src/app/groups/[groupId]/expenses/edit-expense-form.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
import { ActiveUserBalance } from '@/app/groups/[groupId]/expenses/active-user-balance'
|
||||
import { CategoryIcon } from '@/app/groups/[groupId]/expenses/category-icon'
|
||||
import { DocumentsCount } from '@/app/groups/[groupId]/expenses/documents-count'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { getGroupExpenses } from '@/lib/api'
|
||||
import { cn, formatCurrency, formatDate } from '@/lib/utils'
|
||||
@@ -75,6 +76,9 @@ export function ExpenseCard({ expense, currency, groupId }: Props) {
|
||||
>
|
||||
{formatCurrency(currency, expense.amount, locale)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<DocumentsCount count={expense._count.documents} />
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{formatDate(expense.expenseDate, locale, { dateStyle: 'medium' })}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,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 {
|
||||
@@ -41,8 +40,11 @@ import {
|
||||
SplittingOptions,
|
||||
expenseFormSchema,
|
||||
} from '@/lib/schemas'
|
||||
import { calculateShare } from '@/lib/totals'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { AppRouterOutput } from '@/trpc/routers/_app'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { RecurrenceRule } from '@prisma/client'
|
||||
import { Save } from 'lucide-react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import Link from 'next/link'
|
||||
@@ -50,18 +52,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 +65,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: NonNullable<AppRouterOutput['groups']['get']['group']>,
|
||||
) => {
|
||||
const defaultValue = {
|
||||
splitMode: 'EVENLY' as const,
|
||||
paidFor: group.participants.map(({ id }) => ({
|
||||
@@ -146,15 +141,23 @@ async function persistDefaultSplittingOptions(
|
||||
|
||||
export function ExpenseForm({
|
||||
group,
|
||||
expense,
|
||||
categories,
|
||||
expense,
|
||||
onSubmit,
|
||||
onDelete,
|
||||
runtimeFeatureFlags,
|
||||
}: Props) {
|
||||
}: {
|
||||
group: NonNullable<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`)
|
||||
@@ -164,6 +167,10 @@ export function ExpenseForm({
|
||||
}
|
||||
return field?.value
|
||||
}
|
||||
|
||||
const getSelectedRecurrenceRule = (field?: { value: string }) => {
|
||||
return field?.value as RecurrenceRule
|
||||
}
|
||||
const defaultSplittingOptions = getDefaultSplittingOptions(group)
|
||||
const form = useForm<ExpenseFormValues>({
|
||||
resolver: zodResolver(expenseFormSchema),
|
||||
@@ -183,10 +190,11 @@ export function ExpenseForm({
|
||||
isReimbursement: expense.isReimbursement,
|
||||
documents: expense.documents,
|
||||
notes: expense.notes ?? '',
|
||||
recurrenceRule: expense.recurrenceRule ?? undefined,
|
||||
}
|
||||
: searchParams.get('reimbursement')
|
||||
? {
|
||||
title: 'Reimbursement',
|
||||
title: t('reimbursement'),
|
||||
expenseDate: new Date(),
|
||||
amount: String(
|
||||
(Number(searchParams.get('amount')) || 0) / 100,
|
||||
@@ -206,6 +214,7 @@ export function ExpenseForm({
|
||||
saveDefaultSplittingOptions: false,
|
||||
documents: [],
|
||||
notes: '',
|
||||
recurrenceRule: RecurrenceRule.NONE,
|
||||
}
|
||||
: {
|
||||
title: searchParams.get('title') ?? '',
|
||||
@@ -233,6 +242,7 @@ export function ExpenseForm({
|
||||
]
|
||||
: [],
|
||||
notes: '',
|
||||
recurrenceRule: RecurrenceRule.NONE,
|
||||
},
|
||||
})
|
||||
const [isCategoryLoading, setCategoryLoading] = useState(false)
|
||||
@@ -249,7 +259,6 @@ export function ExpenseForm({
|
||||
>(new Set())
|
||||
|
||||
const sExpense = isIncome ? 'Income' : 'Expense'
|
||||
const sPaid = isIncome ? 'received' : 'paid'
|
||||
|
||||
useEffect(() => {
|
||||
setManuallyEditedParticipants(new Set())
|
||||
@@ -494,6 +503,43 @@ export function ExpenseForm({
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="recurrenceRule"
|
||||
render={({ field }) => (
|
||||
<FormItem className="sm:order-5">
|
||||
<FormLabel>{t(`${sExpense}.recurrenceRule.label`)}</FormLabel>
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
form.setValue('recurrenceRule', value as RecurrenceRule)
|
||||
}}
|
||||
defaultValue={getSelectedRecurrenceRule(field)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="NONE" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="NONE">
|
||||
{t(`${sExpense}.recurrenceRule.none`)}
|
||||
</SelectItem>
|
||||
<SelectItem value="DAILY">
|
||||
{t(`${sExpense}.recurrenceRule.daily`)}
|
||||
</SelectItem>
|
||||
<SelectItem value="WEEKLY">
|
||||
{t(`${sExpense}.recurrenceRule.weekly`)}
|
||||
</SelectItem>
|
||||
<SelectItem value="MONTHLY">
|
||||
{t(`${sExpense}.recurrenceRule.monthly`)}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
{t(`${sExpense}.recurrenceRule.description`)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -591,6 +637,42 @@ export function ExpenseForm({
|
||||
</FormControl>
|
||||
<FormLabel className="text-sm font-normal flex-1">
|
||||
{name}
|
||||
{field.value?.some(
|
||||
({ participant }) => participant === id,
|
||||
) &&
|
||||
!form.watch('isReimbursement') && (
|
||||
<span className="text-muted-foreground ml-2">
|
||||
({group.currency}{' '}
|
||||
{(
|
||||
calculateShare(id, {
|
||||
amount:
|
||||
Number(form.watch('amount')) * 100, // Convert to cents
|
||||
paidFor: field.value.map(
|
||||
({ participant, shares }) => ({
|
||||
participant: {
|
||||
id: participant,
|
||||
name: '',
|
||||
groupId: '',
|
||||
},
|
||||
shares:
|
||||
form.watch('splitMode') ===
|
||||
'BY_PERCENTAGE' ||
|
||||
form.watch('splitMode') ===
|
||||
'BY_AMOUNT'
|
||||
? Number(shares) * 100 // Convert percentage to basis points (e.g., 50% -> 5000)
|
||||
: shares,
|
||||
expenseId: '',
|
||||
participantId: '',
|
||||
}),
|
||||
),
|
||||
splitMode: form.watch('splitMode'),
|
||||
isReimbursement:
|
||||
form.watch('isReimbursement'),
|
||||
}) / 100
|
||||
).toFixed(2)}
|
||||
)
|
||||
</span>
|
||||
)}
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
{form.getValues().splitMode !== 'EVENLY' && (
|
||||
@@ -4,26 +4,21 @@ 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'
|
||||
import { useCurrentGroup } from '../current-group-context'
|
||||
|
||||
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 +57,16 @@ function getGroupedExpensesByDate(expenses: ExpensesType) {
|
||||
}, {})
|
||||
}
|
||||
|
||||
export function ExpenseList({
|
||||
expensesFirstPage,
|
||||
expenseCount,
|
||||
currency,
|
||||
participants,
|
||||
groupId,
|
||||
}: Props) {
|
||||
const firstLen = expensesFirstPage.length
|
||||
export function ExpenseList() {
|
||||
const { groupId, group } = useCurrentGroup()
|
||||
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 = group?.participants
|
||||
|
||||
useEffect(() => {
|
||||
if (!participants) return
|
||||
|
||||
const activeUser = localStorage.getItem('newGroup-activeUser')
|
||||
const newUser = localStorage.getItem(`${groupId}-newUser`)
|
||||
if (activeUser || newUser) {
|
||||
@@ -98,57 +85,77 @@ 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()
|
||||
const { group } = useCurrentGroup()
|
||||
|
||||
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 isLoading = expensesAreLoading || !expenses || !group
|
||||
|
||||
if (inView && hasMoreData && !isFetching) fetchNextPage()
|
||||
}, [
|
||||
dataIndex,
|
||||
dataLen,
|
||||
expenseCount,
|
||||
expenses,
|
||||
groupId,
|
||||
hasMoreData,
|
||||
inView,
|
||||
isFetching,
|
||||
])
|
||||
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 +170,41 @@ export function ExpenseList({
|
||||
<ExpenseCard
|
||||
key={expense.id}
|
||||
expense={expense}
|
||||
currency={currency}
|
||||
currency={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'
|
||||
|
||||
142
src/app/groups/[groupId]/expenses/export/csv/route.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { Parser } from '@json2csv/plainjs'
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
import contentDisposition from 'content-disposition'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
const splitModeLabel = {
|
||||
EVENLY: 'Evenly',
|
||||
BY_SHARES: 'Unevenly – By shares',
|
||||
BY_PERCENTAGE: 'Unevenly – By percentage',
|
||||
BY_AMOUNT: 'Unevenly – By amount',
|
||||
}
|
||||
|
||||
function formatDate(isoDateString: Date): string {
|
||||
const date = new Date(isoDateString)
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0') // Months are zero-based
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}` // YYYY-MM-DD format
|
||||
}
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
export async function GET(
|
||||
req: Request,
|
||||
{ params: { groupId } }: { params: { groupId: string } },
|
||||
) {
|
||||
const group = await prisma.group.findUnique({
|
||||
where: { id: groupId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
currency: true,
|
||||
expenses: {
|
||||
select: {
|
||||
expenseDate: true,
|
||||
title: true,
|
||||
category: { select: { name: true } },
|
||||
amount: true,
|
||||
paidById: true,
|
||||
paidFor: { select: { participantId: true, shares: true } },
|
||||
isReimbursement: true,
|
||||
splitMode: true,
|
||||
},
|
||||
},
|
||||
participants: { select: { id: true, name: true } },
|
||||
},
|
||||
})
|
||||
|
||||
if (!group) {
|
||||
return NextResponse.json({ error: 'Invalid group ID' }, { status: 404 })
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
CSV Structure:
|
||||
|
||||
--------------------------------------------------------------
|
||||
| Date | Description | Category | Currency | Cost
|
||||
--------------------------------------------------------------
|
||||
| Is Reimbursement | Split mode | UserA | UserB
|
||||
--------------------------------------------------------------
|
||||
|
||||
Columns:
|
||||
- Date: The date of the expense.
|
||||
- Description: A brief description of the expense.
|
||||
- Category: The category of the expense (e.g., Food, Travel, etc.).
|
||||
- Currency: The currency in which the expense is recorded.
|
||||
- Cost: The amount spent.
|
||||
- Is Reimbursement: Whether the expense is a reimbursement or not.
|
||||
- Split mode: The method used to split the expense (e.g., Evenly, By shares, By percentage, By amount).
|
||||
- UserA, UserB: User-specific data or balances (e.g., amount owed or contributed by each user).
|
||||
|
||||
Example Row:
|
||||
------------------------------------------------------------------------------------------
|
||||
| 2025-01-06 | Dinner with team | Food | ₹ | 5000 | No | Evenly | John | Jane
|
||||
------------------------------------------------------------------------------------------
|
||||
|
||||
*/
|
||||
|
||||
const fields = [
|
||||
{ label: 'Date', value: 'date' },
|
||||
{ label: 'Description', value: 'title' },
|
||||
{ label: 'Category', value: 'categoryName' },
|
||||
{ label: 'Currency', value: 'currency' },
|
||||
{ label: 'Cost', value: 'amount' },
|
||||
{ label: 'Is Reimbursement', value: 'isReimbursement' },
|
||||
{ label: 'Split mode', value: 'splitMode' },
|
||||
...group.participants.map((participant) => ({
|
||||
label: participant.name,
|
||||
value: participant.name,
|
||||
})),
|
||||
]
|
||||
|
||||
const expenses = group.expenses.map((expense) => ({
|
||||
date: formatDate(expense.expenseDate),
|
||||
title: expense.title,
|
||||
categoryName: expense.category?.name || '',
|
||||
currency: group.currency,
|
||||
amount: (expense.amount / 100).toFixed(2),
|
||||
isReimbursement: expense.isReimbursement ? 'Yes' : 'No',
|
||||
splitMode: splitModeLabel[expense.splitMode],
|
||||
...Object.fromEntries(
|
||||
group.participants.map((participant) => {
|
||||
const { totalShares, participantShare } = expense.paidFor.reduce(
|
||||
(acc, { participantId, shares }) => {
|
||||
acc.totalShares += shares
|
||||
if (participantId === participant.id) {
|
||||
acc.participantShare = shares
|
||||
}
|
||||
return acc
|
||||
},
|
||||
{ totalShares: 0, participantShare: 0 },
|
||||
)
|
||||
|
||||
const isPaidByParticipant = expense.paidById === participant.id
|
||||
const participantAmountShare = +(
|
||||
((expense.amount / totalShares) * participantShare) /
|
||||
100
|
||||
).toFixed(2)
|
||||
|
||||
return [
|
||||
participant.name,
|
||||
participantAmountShare * (isPaidByParticipant ? 1 : -1),
|
||||
]
|
||||
}),
|
||||
),
|
||||
}))
|
||||
|
||||
const json2csvParser = new Parser({ fields })
|
||||
const csv = json2csvParser.parse(expenses)
|
||||
|
||||
const date = new Date().toISOString().split('T')[0]
|
||||
const filename = `Spliit Export - ${group.name} - ${date}.csv`
|
||||
|
||||
// \uFEFF character is added at the beginning of the CSV content to ensure that it is interpreted as UTF-8 with BOM (Byte Order Mark), which helps some applications correctly interpret the encoding.
|
||||
return new NextResponse(`\uFEFF${csv}`, {
|
||||
headers: {
|
||||
'Content-Type': 'text/csv; charset=utf-8',
|
||||
'Content-Disposition': contentDisposition(filename),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -14,6 +14,7 @@ export async function GET(
|
||||
currency: true,
|
||||
expenses: {
|
||||
select: {
|
||||
createdAt: true,
|
||||
expenseDate: true,
|
||||
title: true,
|
||||
category: { select: { grouping: true, name: true } },
|
||||
@@ -22,7 +23,9 @@ export async function GET(
|
||||
paidFor: { select: { participantId: true, shares: true } },
|
||||
isReimbursement: true,
|
||||
splitMode: true,
|
||||
recurrenceRule: true,
|
||||
},
|
||||
orderBy: [{ expenseDate: 'asc' }, { createdAt: 'asc' }],
|
||||
},
|
||||
participants: { select: { id: true, name: true } },
|
||||
},
|
||||
@@ -31,7 +34,7 @@ export async function GET(
|
||||
return NextResponse.json({ error: 'Invalid group ID' }, { status: 404 })
|
||||
|
||||
const date = new Date().toISOString().split('T')[0]
|
||||
const filename = `Spliit Export - ${group.name} - ${date}`
|
||||
const filename = `Spliit Export - ${date}`
|
||||
return NextResponse.json(group, {
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
|
||||
65
src/app/groups/[groupId]/expenses/page.client.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
'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 ExportButton from '@/app/groups/[groupId]/export-button'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { Metadata } from 'next'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import Link from 'next/link'
|
||||
import { useCurrentGroup } from '../current-group-context'
|
||||
|
||||
export const revalidate = 3600
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Expenses',
|
||||
}
|
||||
|
||||
export default function GroupExpensesPageClient({
|
||||
enableReceiptExtract,
|
||||
}: {
|
||||
enableReceiptExtract: boolean
|
||||
}) {
|
||||
const t = useTranslations('Expenses')
|
||||
const { groupId } = useCurrentGroup()
|
||||
|
||||
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">
|
||||
<ExportButton groupId={groupId} />
|
||||
{enableReceiptExtract && <CreateFromReceiptButton />}
|
||||
<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 />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<ActiveUserModal groupId={groupId} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -30,100 +8,10 @@ export const metadata: Metadata = {
|
||||
title: 'Expenses',
|
||||
}
|
||||
|
||||
export default async function GroupExpensesPage({
|
||||
params: { groupId },
|
||||
}: {
|
||||
params: { groupId: string }
|
||||
}) {
|
||||
const t = await getTranslations('Expenses')
|
||||
const group = await cached.getGroup(groupId)
|
||||
if (!group) notFound()
|
||||
|
||||
const categories = await getCategories()
|
||||
|
||||
export default async function GroupExpensesPage() {
|
||||
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
|
||||
enableReceiptExtract={env.NEXT_PUBLIC_ENABLE_RECEIPT_EXTRACT}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
53
src/app/groups/[groupId]/export-button.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
'use client'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Download, FileDown, FileJson } from 'lucide-react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import Link from 'next/link'
|
||||
|
||||
export default function ExportButton({ groupId }: { groupId: string }) {
|
||||
const t = useTranslations('Expenses')
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button title={t('export')} variant="secondary" size="icon">
|
||||
<Download className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
prefetch={false}
|
||||
href={`/groups/${groupId}/expenses/export/json`}
|
||||
target="_blank"
|
||||
title={t('exportJson')}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FileJson className="w-4 h-4" />
|
||||
<p>{t('exportJson')}</p>
|
||||
</div>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
prefetch={false}
|
||||
href={`/groups/${groupId}/expenses/export/csv`}
|
||||
target="_blank"
|
||||
title={t('exportCsv')}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FileDown className="w-4 h-4" />
|
||||
<p>{t('exportCsv')}</p>
|
||||
</div>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
30
src/app/groups/[groupId]/group-header.tsx
Normal 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 Link from 'next/link'
|
||||
import { useCurrentGroup } from './current-group-context'
|
||||
|
||||
export const GroupHeader = () => {
|
||||
const { isLoading, groupId, group } = useCurrentGroup()
|
||||
|
||||
return (
|
||||
<div className="flex flex-col justify-between gap-3">
|
||||
<h1 className="font-bold text-2xl">
|
||||
<Link href={`/groups/${groupId}`}>
|
||||
{isLoading ? (
|
||||
<Skeleton className="mt-1.5 mb-1.5 h-5 w-32" />
|
||||
) : (
|
||||
<div className="flex">{group.name}</div>
|
||||
)}
|
||||
</Link>
|
||||
</h1>
|
||||
|
||||
<div className="flex gap-2 justify-between">
|
||||
<GroupTabs groupId={groupId} />
|
||||
{group && <ShareButton group={group} />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
52
src/app/groups/[groupId]/information/group-information.tsx
Normal 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 { Pencil } from 'lucide-react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import Link from 'next/link'
|
||||
import { useCurrentGroup } from '../current-group-context'
|
||||
|
||||
export default function GroupInformation({ groupId }: { groupId: string }) {
|
||||
const t = useTranslations('Information')
|
||||
const { isLoading, group } = useCurrentGroup()
|
||||
|
||||
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 ? (
|
||||
<div className="py-1 flex flex-col gap-2">
|
||||
<Skeleton className="h-3 w-3/4" />
|
||||
<Skeleton className="h-3 w-1/2" />
|
||||
</div>
|
||||
) : group.information ? (
|
||||
<p className="text-foreground">{group.information}</p>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">{t('empty')}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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} />
|
||||
}
|
||||
|
||||
49
src/app/groups/[groupId]/layout.client.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
'use client'
|
||||
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { trpc } from '@/trpc/client'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { PropsWithChildren, useEffect } from 'react'
|
||||
import { CurrentGroupProvider } from './current-group-context'
|
||||
import { GroupHeader } from './group-header'
|
||||
import { SaveGroupLocally } from './save-recent-group'
|
||||
|
||||
export function GroupLayoutClient({
|
||||
groupId,
|
||||
children,
|
||||
}: PropsWithChildren<{ groupId: string }>) {
|
||||
const { data, isLoading } = trpc.groups.get.useQuery({ groupId })
|
||||
const t = useTranslations('Groups.NotFound')
|
||||
const { toast } = useToast()
|
||||
|
||||
useEffect(() => {
|
||||
if (data && !data.group) {
|
||||
toast({
|
||||
description: t('text'),
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
}, [data])
|
||||
|
||||
const props =
|
||||
isLoading || !data?.group
|
||||
? { isLoading: true as const, groupId, group: undefined }
|
||||
: { isLoading: false as const, groupId, group: data.group }
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<CurrentGroupProvider {...props}>
|
||||
<GroupHeader />
|
||||
{children}
|
||||
</CurrentGroupProvider>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<CurrentGroupProvider {...props}>
|
||||
<GroupHeader />
|
||||
{children}
|
||||
<SaveGroupLocally />
|
||||
</CurrentGroupProvider>
|
||||
)
|
||||
}
|
||||
@@ -1,11 +1,7 @@
|
||||
import { cached } from '@/app/cached-functions'
|
||||
import { GroupTabs } from '@/app/groups/[groupId]/group-tabs'
|
||||
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'
|
||||
import { GroupLayoutClient } from './layout.client'
|
||||
|
||||
type Props = {
|
||||
params: {
|
||||
@@ -26,31 +22,9 @@ export async function generateMetadata({
|
||||
}
|
||||
}
|
||||
|
||||
export default async function GroupLayout({
|
||||
export default function GroupLayout({
|
||||
children,
|
||||
params: { groupId },
|
||||
}: PropsWithChildren<Props>) {
|
||||
const group = await cached.getGroup(groupId)
|
||||
if (!group) notFound()
|
||||
|
||||
return (
|
||||
<>
|
||||
<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>
|
||||
|
||||
{children}
|
||||
|
||||
<SaveGroupLocally group={{ id: group.id, name: group.name }} />
|
||||
</>
|
||||
)
|
||||
return <GroupLayoutClient groupId={groupId}>{children}</GroupLayoutClient>
|
||||
}
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
'use client'
|
||||
import {
|
||||
RecentGroup,
|
||||
saveRecentGroup,
|
||||
} from '@/app/groups/recent-groups-helpers'
|
||||
import { saveRecentGroup } from '@/app/groups/recent-groups-helpers'
|
||||
import { useEffect } from 'react'
|
||||
import { useCurrentGroup } from './current-group-context'
|
||||
|
||||
type Props = {
|
||||
group: RecentGroup
|
||||
}
|
||||
export function SaveGroupLocally() {
|
||||
const { group } = useCurrentGroup()
|
||||
|
||||
export function SaveGroupLocally({ group }: Props) {
|
||||
useEffect(() => {
|
||||
saveRecentGroup(group)
|
||||
if (group) saveRecentGroup({ id: group.id, name: group.name })
|
||||
}, [group])
|
||||
|
||||
return null
|
||||
|
||||
27
src/app/groups/[groupId]/stats/page.client.tsx
Normal 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() {
|
||||
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 />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,49 +1,10 @@
|
||||
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',
|
||||
}
|
||||
|
||||
export default async function TotalsPage({
|
||||
params: { groupId },
|
||||
}: {
|
||||
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>
|
||||
</>
|
||||
)
|
||||
export default async function TotalsPage() {
|
||||
return <TotalsPageClient />
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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'
|
||||
import { useCurrentGroup } from '../current-group-context'
|
||||
|
||||
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() {
|
||||
const { groupId, group } = useCurrentGroup()
|
||||
const activeUser = useActiveUser(groupId)
|
||||
|
||||
const participantId =
|
||||
activeUser && activeUser !== 'None' ? activeUser : undefined
|
||||
const { data } = trpc.groups.stats.get.useQuery({ groupId, participantId })
|
||||
|
||||
if (!data || !group)
|
||||
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
|
||||
|
||||
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}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
'use server'
|
||||
import { getGroups } from '@/lib/api'
|
||||
|
||||
export async function getGroupsAction(groupIds: string[]) {
|
||||
'use server'
|
||||
return getGroups(groupIds)
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
'use server'
|
||||
|
||||
import { getGroup } from '@/lib/api'
|
||||
|
||||
export async function getGroupInfoAction(groupId: string) {
|
||||
'use server'
|
||||
return getGroup(groupId)
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import { getGroupInfoAction } from '@/app/groups/add-group-by-url-button-actions'
|
||||
import { saveRecentGroup } from '@/app/groups/recent-groups-helpers'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
@@ -8,6 +7,7 @@ import {
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import { useMediaQuery } from '@/lib/hooks'
|
||||
import { trpc } from '@/trpc/client'
|
||||
import { Loader2, Plus } from 'lucide-react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useState } from 'react'
|
||||
@@ -23,14 +23,12 @@ export function AddGroupByUrlButton({ reload }: Props) {
|
||||
const [error, setError] = useState(false)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [pending, setPending] = useState(false)
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="secondary">
|
||||
{/* <Plus className="w-4 h-4 mr-2" /> */}
|
||||
{t('button')}
|
||||
</Button>
|
||||
<Button variant="secondary">{t('button')}</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align={isDesktop ? 'end' : 'start'}
|
||||
@@ -47,15 +45,17 @@ export function AddGroupByUrlButton({ reload }: Props) {
|
||||
new RegExp(`${window.location.origin}/groups/([^/]+)`),
|
||||
) ?? []
|
||||
setPending(true)
|
||||
const group = groupId ? await getGroupInfoAction(groupId) : null
|
||||
setPending(false)
|
||||
if (!group) {
|
||||
setError(true)
|
||||
} else {
|
||||
const { group } = await utils.groups.get.fetch({
|
||||
groupId: groupId,
|
||||
})
|
||||
if (group) {
|
||||
saveRecentGroup({ id: group.id, name: group.name })
|
||||
reload()
|
||||
setUrl('')
|
||||
setOpen(false)
|
||||
} else {
|
||||
setError(true)
|
||||
setPending(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
21
src/app/groups/create/create-group.tsx
Normal 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}`)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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 />
|
||||
}
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
'use client'
|
||||
import { RecentGroupsState } from '@/app/groups/recent-group-list'
|
||||
import {
|
||||
RecentGroup,
|
||||
archiveGroup,
|
||||
deleteRecentGroup,
|
||||
getArchivedGroups,
|
||||
getStarredGroups,
|
||||
saveRecentGroup,
|
||||
starGroup,
|
||||
unarchiveGroup,
|
||||
unstarGroup,
|
||||
@@ -19,46 +14,32 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { ToastAction } from '@/components/ui/toast'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { AppRouterOutput } from '@/trpc/routers/_app'
|
||||
import { StarFilledIcon } from '@radix-ui/react-icons'
|
||||
import { Calendar, MoreHorizontal, Star, Users } from 'lucide-react'
|
||||
import { useLocale, useTranslations } from 'next-intl'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { SetStateAction } from 'react'
|
||||
|
||||
export function RecentGroupListCard({
|
||||
group,
|
||||
state,
|
||||
setState,
|
||||
groupDetail,
|
||||
isStarred,
|
||||
isArchived,
|
||||
refreshGroupsFromStorage,
|
||||
}: {
|
||||
group: RecentGroup
|
||||
state: RecentGroupsState
|
||||
setState: (state: SetStateAction<RecentGroupsState>) => void
|
||||
groupDetail?: AppRouterOutput['groups']['list']['groups'][number]
|
||||
isStarred: boolean
|
||||
isArchived: boolean
|
||||
refreshGroupsFromStorage: () => void
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const locale = useLocale()
|
||||
const toast = useToast()
|
||||
const t = useTranslations('Groups')
|
||||
|
||||
const details =
|
||||
state.status === 'complete'
|
||||
? state.groupsDetails.find((d) => d.id === group.id)
|
||||
: null
|
||||
|
||||
if (state.status === 'pending') return null
|
||||
|
||||
const refreshGroupsFromStorage = () =>
|
||||
setState({
|
||||
...state,
|
||||
starredGroups: getStarredGroups(),
|
||||
archivedGroups: getArchivedGroups(),
|
||||
})
|
||||
|
||||
const isStarred = state.starredGroups.includes(group.id)
|
||||
const isArchived = state.archivedGroups.includes(group.id)
|
||||
|
||||
return (
|
||||
<li key={group.id}>
|
||||
<Button
|
||||
@@ -116,27 +97,11 @@ export function RecentGroupListCard({
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
deleteRecentGroup(group)
|
||||
setState({
|
||||
...state,
|
||||
groups: state.groups.filter((g) => g.id !== group.id),
|
||||
})
|
||||
refreshGroupsFromStorage()
|
||||
|
||||
toast.toast({
|
||||
title: t('RecentRemovedToast.title'),
|
||||
description: t('RecentRemovedToast.description'),
|
||||
action: (
|
||||
<ToastAction
|
||||
altText={t('RecentRemovedToast.undoAlt')}
|
||||
onClick={() => {
|
||||
saveRecentGroup(group)
|
||||
setState({
|
||||
...state,
|
||||
groups: state.groups,
|
||||
})
|
||||
}}
|
||||
>
|
||||
{t('RecentRemovedToast.undo')}
|
||||
</ToastAction>
|
||||
),
|
||||
})
|
||||
}}
|
||||
>
|
||||
@@ -161,18 +126,21 @@ export function RecentGroupListCard({
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground font-normal text-xs">
|
||||
{details ? (
|
||||
{groupDetail ? (
|
||||
<div className="w-full flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<Users className="w-3 h-3 inline mr-1" />
|
||||
<span>{details._count.participants}</span>
|
||||
<span>{groupDetail._count.participants}</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Calendar className="w-3 h-3 inline mx-1" />
|
||||
<span>
|
||||
{new Date(details.createdAt).toLocaleDateString(locale, {
|
||||
dateStyle: 'medium',
|
||||
})}
|
||||
{new Date(groupDetail.createdAt).toLocaleDateString(
|
||||
locale,
|
||||
{
|
||||
dateStyle: 'medium',
|
||||
},
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
'use client'
|
||||
import { getGroupsAction } from '@/app/groups/actions'
|
||||
import { AddGroupByUrlButton } from '@/app/groups/add-group-by-url-button'
|
||||
import {
|
||||
RecentGroups,
|
||||
@@ -9,10 +8,12 @@ import {
|
||||
} from '@/app/groups/recent-groups-helpers'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { getGroups } from '@/lib/api'
|
||||
import { trpc } from '@/trpc/client'
|
||||
import { AppRouterOutput } from '@/trpc/routers/_app'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import Link from 'next/link'
|
||||
import { PropsWithChildren, SetStateAction, useEffect, useState } from 'react'
|
||||
import { PropsWithChildren, useEffect, useState } from 'react'
|
||||
import { RecentGroupListCard } from './recent-group-list-card'
|
||||
|
||||
export type RecentGroupsState =
|
||||
@@ -31,16 +32,22 @@ export type RecentGroupsState =
|
||||
archivedGroups: string[]
|
||||
}
|
||||
|
||||
function sortGroups(
|
||||
state: RecentGroupsState & { status: 'complete' | 'partial' },
|
||||
) {
|
||||
function sortGroups({
|
||||
groups,
|
||||
starredGroups,
|
||||
archivedGroups,
|
||||
}: {
|
||||
groups: RecentGroups
|
||||
starredGroups: string[]
|
||||
archivedGroups: string[]
|
||||
}) {
|
||||
const starredGroupInfo = []
|
||||
const groupInfo = []
|
||||
const archivedGroupInfo = []
|
||||
for (const group of state.groups) {
|
||||
if (state.starredGroups.includes(group.id)) {
|
||||
for (const group of groups) {
|
||||
if (starredGroups.includes(group.id)) {
|
||||
starredGroupInfo.push(group)
|
||||
} else if (state.archivedGroups.includes(group.id)) {
|
||||
} else if (archivedGroups.includes(group.id)) {
|
||||
archivedGroupInfo.push(group)
|
||||
} else {
|
||||
groupInfo.push(group)
|
||||
@@ -54,7 +61,6 @@ function sortGroups(
|
||||
}
|
||||
|
||||
export function RecentGroupList() {
|
||||
const t = useTranslations('Groups')
|
||||
const [state, setState] = useState<RecentGroupsState>({ status: 'pending' })
|
||||
|
||||
function loadGroups() {
|
||||
@@ -67,24 +73,43 @@ export function RecentGroupList() {
|
||||
starredGroups,
|
||||
archivedGroups,
|
||||
})
|
||||
getGroupsAction(groupsInStorage.map((g) => g.id)).then((groupsDetails) => {
|
||||
setState({
|
||||
status: 'complete',
|
||||
groups: groupsInStorage,
|
||||
groupsDetails,
|
||||
starredGroups,
|
||||
archivedGroups,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadGroups()
|
||||
}, [])
|
||||
|
||||
if (state.status === 'pending') {
|
||||
if (state.status === 'pending') return null
|
||||
|
||||
return (
|
||||
<RecentGroupList_
|
||||
groups={state.groups}
|
||||
starredGroups={state.starredGroups}
|
||||
archivedGroups={state.archivedGroups}
|
||||
refreshGroupsFromStorage={() => loadGroups()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function RecentGroupList_({
|
||||
groups,
|
||||
starredGroups,
|
||||
archivedGroups,
|
||||
refreshGroupsFromStorage,
|
||||
}: {
|
||||
groups: RecentGroups
|
||||
starredGroups: string[]
|
||||
archivedGroups: string[]
|
||||
refreshGroupsFromStorage: () => void
|
||||
}) {
|
||||
const t = useTranslations('Groups')
|
||||
const { data, isLoading } = trpc.groups.list.useQuery({
|
||||
groupIds: groups.map((group) => group.id),
|
||||
})
|
||||
|
||||
if (isLoading || !data) {
|
||||
return (
|
||||
<GroupsPage reload={loadGroups}>
|
||||
<GroupsPage reload={refreshGroupsFromStorage}>
|
||||
<p>
|
||||
<Loader2 className="w-4 m-4 mr-2 inline animate-spin" />{' '}
|
||||
{t('loadingRecent')}
|
||||
@@ -93,9 +118,9 @@ export function RecentGroupList() {
|
||||
)
|
||||
}
|
||||
|
||||
if (state.groups.length === 0) {
|
||||
if (data.groups.length === 0) {
|
||||
return (
|
||||
<GroupsPage reload={loadGroups}>
|
||||
<GroupsPage reload={refreshGroupsFromStorage}>
|
||||
<div className="text-sm space-y-2">
|
||||
<p>{t('NoRecent.description')}</p>
|
||||
<p>
|
||||
@@ -109,17 +134,23 @@ export function RecentGroupList() {
|
||||
)
|
||||
}
|
||||
|
||||
const { starredGroupInfo, groupInfo, archivedGroupInfo } = sortGroups(state)
|
||||
const { starredGroupInfo, groupInfo, archivedGroupInfo } = sortGroups({
|
||||
groups,
|
||||
starredGroups,
|
||||
archivedGroups,
|
||||
})
|
||||
|
||||
return (
|
||||
<GroupsPage reload={loadGroups}>
|
||||
<GroupsPage reload={refreshGroupsFromStorage}>
|
||||
{starredGroupInfo.length > 0 && (
|
||||
<>
|
||||
<h2 className="mb-2">{t('starred')}</h2>
|
||||
<GroupList
|
||||
groups={starredGroupInfo}
|
||||
state={state}
|
||||
setState={setState}
|
||||
groupDetails={data.groups}
|
||||
archivedGroups={archivedGroups}
|
||||
starredGroups={starredGroups}
|
||||
refreshGroupsFromStorage={refreshGroupsFromStorage}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@@ -127,7 +158,13 @@ export function RecentGroupList() {
|
||||
{groupInfo.length > 0 && (
|
||||
<>
|
||||
<h2 className="mt-6 mb-2">{t('recent')}</h2>
|
||||
<GroupList groups={groupInfo} state={state} setState={setState} />
|
||||
<GroupList
|
||||
groups={groupInfo}
|
||||
groupDetails={data.groups}
|
||||
archivedGroups={archivedGroups}
|
||||
starredGroups={starredGroups}
|
||||
refreshGroupsFromStorage={refreshGroupsFromStorage}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -137,8 +174,10 @@ export function RecentGroupList() {
|
||||
<div className="opacity-50">
|
||||
<GroupList
|
||||
groups={archivedGroupInfo}
|
||||
state={state}
|
||||
setState={setState}
|
||||
groupDetails={data.groups}
|
||||
archivedGroups={archivedGroups}
|
||||
starredGroups={starredGroups}
|
||||
refreshGroupsFromStorage={refreshGroupsFromStorage}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
@@ -149,12 +188,16 @@ export function RecentGroupList() {
|
||||
|
||||
function GroupList({
|
||||
groups,
|
||||
state,
|
||||
setState,
|
||||
groupDetails,
|
||||
starredGroups,
|
||||
archivedGroups,
|
||||
refreshGroupsFromStorage,
|
||||
}: {
|
||||
groups: RecentGroups
|
||||
state: RecentGroupsState
|
||||
setState: (state: SetStateAction<RecentGroupsState>) => void
|
||||
groupDetails?: AppRouterOutput['groups']['list']['groups']
|
||||
starredGroups: string[]
|
||||
archivedGroups: string[]
|
||||
refreshGroupsFromStorage: () => void
|
||||
}) {
|
||||
return (
|
||||
<ul className="grid gap-2 sm:grid-cols-2">
|
||||
@@ -162,8 +205,12 @@ function GroupList({
|
||||
<RecentGroupListCard
|
||||
key={group.id}
|
||||
group={group}
|
||||
state={state}
|
||||
setState={setState}
|
||||
groupDetail={groupDetails?.find(
|
||||
(groupDetail) => groupDetail.id === group.id,
|
||||
)}
|
||||
isStarred={starredGroups.includes(group.id)}
|
||||
isArchived={archivedGroups.includes(group.id)}
|
||||
refreshGroupsFromStorage={refreshGroupsFromStorage}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -7,22 +7,48 @@ export default function manifest(): MetadataRoute.Manifest {
|
||||
description:
|
||||
'A minimalist web application to share expenses with friends and family. No ads, no account, no problem.',
|
||||
start_url: '/groups',
|
||||
id: '/groups',
|
||||
display: 'standalone',
|
||||
background_color: '#fff',
|
||||
theme_color: '#047857',
|
||||
icons: [
|
||||
{
|
||||
src: '/android-chrome-192x192.png',
|
||||
src: '/logo/48x48.png',
|
||||
sizes: '48x48',
|
||||
type: 'image/png',
|
||||
},
|
||||
{
|
||||
src: '/logo/64x64.png',
|
||||
sizes: '64x64',
|
||||
type: 'image/png',
|
||||
},
|
||||
{
|
||||
src: '/logo/128x128.png',
|
||||
sizes: '128x128',
|
||||
type: 'image/png',
|
||||
},
|
||||
{
|
||||
src: '/logo/144x144.png',
|
||||
sizes: '144x144',
|
||||
type: 'image/png',
|
||||
},
|
||||
{
|
||||
src: '/logo/192x192.png',
|
||||
sizes: '192x192',
|
||||
type: 'image/png',
|
||||
},
|
||||
{
|
||||
src: '/android-chrome-512x512.png',
|
||||
src: '/logo/256x256.png',
|
||||
sizes: '256x256',
|
||||
type: 'image/png',
|
||||
},
|
||||
{
|
||||
src: '/logo/512x512.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png',
|
||||
},
|
||||
{
|
||||
src: '/logo-512x512-maskable.png',
|
||||
src: '/logo/512x512-maskable.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png',
|
||||
purpose: 'maskable',
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
'use client'
|
||||
|
||||
import { Trash2 } from 'lucide-react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { AsyncButton } from './async-button'
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
'use client'
|
||||
import { SubmitButton } from '@/components/submit-button'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
@@ -68,7 +67,7 @@ export function GroupForm({
|
||||
: {
|
||||
name: '',
|
||||
information: '',
|
||||
currency: '',
|
||||
currency: process.env.NEXT_PUBLIC_DEFAULT_CURRENCY_SYMBOL || '',
|
||||
participants: [
|
||||
{ name: t('Participants.John') },
|
||||
{ name: t('Participants.Jane') },
|
||||
@@ -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')}
|
||||
|
||||
@@ -7,24 +7,26 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { locales } from '@/i18n'
|
||||
import { Locale, localeLabels } from '@/i18n'
|
||||
import { setUserLocale } from '@/lib/locale'
|
||||
import { useLocale, useTranslations } from 'next-intl'
|
||||
import { useLocale } from 'next-intl'
|
||||
|
||||
export function LocaleSwitcher() {
|
||||
const t = useTranslations('Locale')
|
||||
const locale = useLocale()
|
||||
const locale = useLocale() as Locale
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="-my-3 text-primary">
|
||||
<span>{t(locale)}</span>
|
||||
<span>{localeLabels[locale]}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
{locales.map((locale) => (
|
||||
<DropdownMenuItem key={locale} onClick={() => setUserLocale(locale)}>
|
||||
{t(locale)}
|
||||
{Object.entries(localeLabels).map(([locale, label]) => (
|
||||
<DropdownMenuItem
|
||||
key={locale}
|
||||
onClick={() => setUserLocale(locale as Locale)}
|
||||
>
|
||||
{label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
|
||||
32
src/i18n.ts
@@ -1,16 +1,28 @@
|
||||
import { getRequestConfig } from 'next-intl/server'
|
||||
import { getUserLocale } from './lib/locale'
|
||||
|
||||
export const locales = [
|
||||
'en-US',
|
||||
'fi',
|
||||
'fr-FR',
|
||||
'es',
|
||||
'de-DE',
|
||||
'zh-CN',
|
||||
'ru-RU',
|
||||
] as const
|
||||
export type Locale = (typeof locales)[number]
|
||||
export const localeLabels = {
|
||||
'en-US': 'English',
|
||||
fi: 'Suomi',
|
||||
'fr-FR': 'Français',
|
||||
es: 'Español',
|
||||
'de-DE': 'Deutsch',
|
||||
'zh-CN': '简体中文',
|
||||
'zh-TW': '正體中文',
|
||||
'pl-PL': 'Polski',
|
||||
'ru-RU': 'Русский',
|
||||
'it-IT': 'Italiano',
|
||||
'ua-UA': 'Українська',
|
||||
ro: 'Română',
|
||||
'tr-TR': 'Türkçe',
|
||||
'pt-BR': 'Português Brasileiro',
|
||||
'nl-NL': 'Nederlands',
|
||||
} as const
|
||||
|
||||
export const locales: (keyof typeof localeLabels)[] = Object.keys(
|
||||
localeLabels,
|
||||
) as any
|
||||
export type Locale = keyof typeof localeLabels
|
||||
export type Locales = ReadonlyArray<Locale>
|
||||
export const defaultLocale: Locale = 'en-US'
|
||||
|
||||
|
||||
315
src/lib/api.ts
@@ -1,6 +1,11 @@
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { ExpenseFormValues, GroupFormValues } from '@/lib/schemas'
|
||||
import { ActivityType, Expense } from '@prisma/client'
|
||||
import {
|
||||
ActivityType,
|
||||
Expense,
|
||||
RecurrenceRule,
|
||||
RecurringExpenseLink,
|
||||
} from '@prisma/client'
|
||||
import { nanoid } from 'nanoid'
|
||||
|
||||
export function randomId() {
|
||||
@@ -50,6 +55,14 @@ export async function createExpense(
|
||||
data: expenseFormValues.title,
|
||||
})
|
||||
|
||||
const isCreateRecurrence =
|
||||
expenseFormValues.recurrenceRule !== RecurrenceRule.NONE
|
||||
const recurringExpenseLinkPayload = createPayloadForNewRecurringExpenseLink(
|
||||
expenseFormValues.recurrenceRule as RecurrenceRule,
|
||||
expenseFormValues.expenseDate,
|
||||
groupId,
|
||||
)
|
||||
|
||||
return prisma.expense.create({
|
||||
data: {
|
||||
id: expenseId,
|
||||
@@ -60,6 +73,14 @@ export async function createExpense(
|
||||
title: expenseFormValues.title,
|
||||
paidById: expenseFormValues.paidBy,
|
||||
splitMode: expenseFormValues.splitMode,
|
||||
recurrenceRule: expenseFormValues.recurrenceRule,
|
||||
recurringExpenseLink: {
|
||||
...(isCreateRecurrence
|
||||
? {
|
||||
create: recurringExpenseLinkPayload,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
paidFor: {
|
||||
createMany: {
|
||||
data: expenseFormValues.paidFor.map((paidFor) => ({
|
||||
@@ -152,6 +173,33 @@ export async function updateExpense(
|
||||
data: expenseFormValues.title,
|
||||
})
|
||||
|
||||
const isDeleteRecurrenceExpenseLink =
|
||||
existingExpense.recurrenceRule !== RecurrenceRule.NONE &&
|
||||
expenseFormValues.recurrenceRule === RecurrenceRule.NONE &&
|
||||
// Delete the existing RecurrenceExpenseLink only if it has not been acted upon yet
|
||||
existingExpense.recurringExpenseLink?.nextExpenseCreatedAt === null
|
||||
|
||||
const isUpdateRecurrenceExpenseLink =
|
||||
existingExpense.recurrenceRule !== expenseFormValues.recurrenceRule &&
|
||||
// Update the exisiting RecurrenceExpenseLink only if it has not been acted upon yet
|
||||
existingExpense.recurringExpenseLink?.nextExpenseCreatedAt === null
|
||||
const isCreateRecurrenceExpenseLink =
|
||||
existingExpense.recurrenceRule === RecurrenceRule.NONE &&
|
||||
expenseFormValues.recurrenceRule !== RecurrenceRule.NONE &&
|
||||
// Create a new RecurrenceExpenseLink only if one does not already exist for the expense
|
||||
existingExpense.recurringExpenseLink === null
|
||||
|
||||
const newRecurringExpenseLink = createPayloadForNewRecurringExpenseLink(
|
||||
expenseFormValues.recurrenceRule as RecurrenceRule,
|
||||
expenseFormValues.expenseDate,
|
||||
groupId,
|
||||
)
|
||||
|
||||
const updatedRecurrenceExpenseLinkNextExpenseDate = calculateNextDate(
|
||||
expenseFormValues.recurrenceRule as RecurrenceRule,
|
||||
existingExpense.expenseDate,
|
||||
)
|
||||
|
||||
return prisma.expense.update({
|
||||
where: { id: expenseId },
|
||||
data: {
|
||||
@@ -161,6 +209,7 @@ export async function updateExpense(
|
||||
categoryId: expenseFormValues.category,
|
||||
paidById: expenseFormValues.paidBy,
|
||||
splitMode: expenseFormValues.splitMode,
|
||||
recurrenceRule: expenseFormValues.recurrenceRule,
|
||||
paidFor: {
|
||||
create: expenseFormValues.paidFor
|
||||
.filter(
|
||||
@@ -191,6 +240,21 @@ export async function updateExpense(
|
||||
),
|
||||
),
|
||||
},
|
||||
recurringExpenseLink: {
|
||||
...(isCreateRecurrenceExpenseLink
|
||||
? {
|
||||
create: newRecurringExpenseLink,
|
||||
}
|
||||
: {}),
|
||||
...(isUpdateRecurrenceExpenseLink
|
||||
? {
|
||||
update: {
|
||||
nextExpenseDate: updatedRecurrenceExpenseLinkNextExpenseDate,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
delete: isDeleteRecurrenceExpenseLink,
|
||||
},
|
||||
isReimbursement: expenseFormValues.isReimbursement,
|
||||
documents: {
|
||||
connectOrCreate: expenseFormValues.documents.map((doc) => ({
|
||||
@@ -267,8 +331,10 @@ export async function getCategories() {
|
||||
|
||||
export async function getGroupExpenses(
|
||||
groupId: string,
|
||||
options?: { offset: number; length: number },
|
||||
options?: { offset?: number; length?: number; filter?: string },
|
||||
) {
|
||||
await createRecurringExpenses()
|
||||
|
||||
return prisma.expense.findMany({
|
||||
select: {
|
||||
amount: true,
|
||||
@@ -285,9 +351,16 @@ export async function getGroupExpenses(
|
||||
},
|
||||
},
|
||||
splitMode: true,
|
||||
recurrenceRule: true,
|
||||
title: true,
|
||||
_count: { select: { documents: true } },
|
||||
},
|
||||
where: {
|
||||
groupId,
|
||||
title: options?.filter
|
||||
? { contains: options.filter, mode: 'insensitive' }
|
||||
: undefined,
|
||||
},
|
||||
where: { groupId },
|
||||
orderBy: [{ expenseDate: 'desc' }, { createdAt: 'desc' }],
|
||||
skip: options && options.offset,
|
||||
take: options && options.length,
|
||||
@@ -301,15 +374,44 @@ export async function getGroupExpenseCount(groupId: string) {
|
||||
export async function getExpense(groupId: string, expenseId: string) {
|
||||
return prisma.expense.findUnique({
|
||||
where: { id: expenseId },
|
||||
include: { paidBy: true, paidFor: true, category: true, documents: true },
|
||||
include: {
|
||||
paidBy: true,
|
||||
paidFor: true,
|
||||
category: true,
|
||||
documents: true,
|
||||
recurringExpenseLink: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
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(
|
||||
@@ -326,3 +428,204 @@ export async function logActivity(
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function createRecurringExpenses() {
|
||||
const localDate = new Date() // Current local date
|
||||
const utcDateFromLocal = new Date(
|
||||
Date.UTC(
|
||||
localDate.getUTCFullYear(),
|
||||
localDate.getUTCMonth(),
|
||||
localDate.getUTCDate(),
|
||||
// More precision beyond date is required to ensure that recurring Expenses are created within <most precises unit> of when expected
|
||||
localDate.getUTCHours(),
|
||||
localDate.getUTCMinutes(),
|
||||
),
|
||||
)
|
||||
|
||||
const recurringExpenseLinksWithExpensesToCreate =
|
||||
await prisma.recurringExpenseLink.findMany({
|
||||
where: {
|
||||
nextExpenseCreatedAt: null,
|
||||
nextExpenseDate: {
|
||||
lte: utcDateFromLocal,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
currentFrameExpense: {
|
||||
include: {
|
||||
paidBy: true,
|
||||
paidFor: true,
|
||||
category: true,
|
||||
documents: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
for (const recurringExpenseLink of recurringExpenseLinksWithExpensesToCreate) {
|
||||
let newExpenseDate = recurringExpenseLink.nextExpenseDate
|
||||
|
||||
let currentExpenseRecord = recurringExpenseLink.currentFrameExpense
|
||||
let currentReccuringExpenseLinkId = recurringExpenseLink.id
|
||||
|
||||
while (newExpenseDate < utcDateFromLocal) {
|
||||
const newExpenseId = randomId()
|
||||
const newRecurringExpenseLinkId = randomId()
|
||||
|
||||
const newRecurringExpenseNextExpenseDate = calculateNextDate(
|
||||
currentExpenseRecord.recurrenceRule as RecurrenceRule,
|
||||
newExpenseDate,
|
||||
)
|
||||
|
||||
const {
|
||||
category,
|
||||
paidBy,
|
||||
paidFor,
|
||||
documents,
|
||||
...destructeredCurrentExpenseRecord
|
||||
} = currentExpenseRecord
|
||||
|
||||
// Use a transacton to ensure that the only one expense is created for the RecurringExpenseLink
|
||||
// just in case two clients are processing the same RecurringExpenseLink at the same time
|
||||
const newExpense = await prisma
|
||||
.$transaction(async (transaction) => {
|
||||
const newExpense = await transaction.expense.create({
|
||||
data: {
|
||||
...destructeredCurrentExpenseRecord,
|
||||
categoryId: currentExpenseRecord.categoryId,
|
||||
paidById: currentExpenseRecord.paidById,
|
||||
paidFor: {
|
||||
createMany: {
|
||||
data: currentExpenseRecord.paidFor.map((paidFor) => ({
|
||||
participantId: paidFor.participantId,
|
||||
shares: paidFor.shares,
|
||||
})),
|
||||
},
|
||||
},
|
||||
documents: {
|
||||
connect: currentExpenseRecord.documents.map(
|
||||
(documentRecord) => ({
|
||||
id: documentRecord.id,
|
||||
}),
|
||||
),
|
||||
},
|
||||
id: newExpenseId,
|
||||
expenseDate: newExpenseDate,
|
||||
recurringExpenseLink: {
|
||||
create: {
|
||||
groupId: currentExpenseRecord.groupId,
|
||||
id: newRecurringExpenseLinkId,
|
||||
nextExpenseDate: newRecurringExpenseNextExpenseDate,
|
||||
},
|
||||
},
|
||||
},
|
||||
// Ensure that the same information is available on the returned record that was created
|
||||
include: {
|
||||
paidFor: true,
|
||||
documents: true,
|
||||
category: true,
|
||||
paidBy: true,
|
||||
},
|
||||
})
|
||||
|
||||
// Mark the RecurringExpenseLink as being "completed" since the new Expense was created
|
||||
// if an expense hasn't been created for this RecurringExpenseLink yet
|
||||
await transaction.recurringExpenseLink.update({
|
||||
where: {
|
||||
id: currentReccuringExpenseLinkId,
|
||||
nextExpenseCreatedAt: null,
|
||||
},
|
||||
data: {
|
||||
nextExpenseCreatedAt: newExpense.createdAt,
|
||||
},
|
||||
})
|
||||
|
||||
return newExpense
|
||||
})
|
||||
.catch(() => {
|
||||
console.error(
|
||||
'Failed to created recurringExpense for expenseId: %s',
|
||||
currentExpenseRecord.id,
|
||||
)
|
||||
return null
|
||||
})
|
||||
|
||||
// If the new expense failed to be created, break out of the while-loop
|
||||
if (newExpense === null) break
|
||||
|
||||
// Set the values for the next iteration of the for-loop in case multiple recurring Expenses need to be created
|
||||
currentExpenseRecord = newExpense
|
||||
currentReccuringExpenseLinkId = newRecurringExpenseLinkId
|
||||
newExpenseDate = newRecurringExpenseNextExpenseDate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createPayloadForNewRecurringExpenseLink(
|
||||
recurrenceRule: RecurrenceRule,
|
||||
priorDateToNextRecurrence: Date,
|
||||
groupId: String,
|
||||
): RecurringExpenseLink {
|
||||
const nextExpenseDate = calculateNextDate(
|
||||
recurrenceRule,
|
||||
priorDateToNextRecurrence,
|
||||
)
|
||||
|
||||
const recurringExpenseLinkId = randomId()
|
||||
const recurringExpenseLinkPayload = {
|
||||
id: recurringExpenseLinkId,
|
||||
groupId: groupId,
|
||||
nextExpenseDate: nextExpenseDate,
|
||||
}
|
||||
|
||||
return recurringExpenseLinkPayload as RecurringExpenseLink
|
||||
}
|
||||
|
||||
// TODO: Modify this function to use a more comprehensive recurrence Rule library like rrule (https://github.com/jkbrzt/rrule)
|
||||
//
|
||||
// Current limitations:
|
||||
// - If a date is intended to be repeated monthly on the 29th, 30th or 31st, it will change to repeating on the smallest
|
||||
// date that the reccurence has encountered. Ex. If a recurrence is created for Jan 31st on 2025, the recurring expense
|
||||
// will be created for Feb 28th, March 28, etc. until it is cancelled or fixed
|
||||
function calculateNextDate(
|
||||
recurrenceRule: RecurrenceRule,
|
||||
priorDateToNextRecurrence: Date,
|
||||
): Date {
|
||||
const nextDate = new Date(priorDateToNextRecurrence)
|
||||
switch (recurrenceRule) {
|
||||
case RecurrenceRule.DAILY:
|
||||
nextDate.setUTCDate(nextDate.getUTCDate() + 1)
|
||||
break
|
||||
case RecurrenceRule.WEEKLY:
|
||||
nextDate.setUTCDate(nextDate.getUTCDate() + 7)
|
||||
break
|
||||
case RecurrenceRule.MONTHLY:
|
||||
const nextYear = nextDate.getUTCFullYear()
|
||||
const nextMonth = nextDate.getUTCMonth() + 1
|
||||
let nextDay = nextDate.getUTCDate()
|
||||
|
||||
// Reduce the next day until it is within the direct next month
|
||||
while (!isDateInNextMonth(nextYear, nextMonth, nextDay)) {
|
||||
nextDay -= 1
|
||||
}
|
||||
nextDate.setUTCMonth(nextMonth, nextDay)
|
||||
break
|
||||
}
|
||||
|
||||
return nextDate
|
||||
}
|
||||
|
||||
function isDateInNextMonth(
|
||||
utcYear: number,
|
||||
utcMonth: number,
|
||||
utcDate: number,
|
||||
): Boolean {
|
||||
const testDate = new Date(Date.UTC(utcYear, utcMonth, utcDate))
|
||||
|
||||
// We're not concerned if the year or month changes. We only want to make sure that the date is our target date
|
||||
if (testDate.getUTCDate() !== utcDate) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ const envSchema = z
|
||||
interpretEnvVarAsBool,
|
||||
z.boolean().default(false),
|
||||
),
|
||||
NEXT_PUBLIC_DEFAULT_CURRENCY_SYMBOL: z.string().optional(),
|
||||
S3_UPLOAD_KEY: z.string().optional(),
|
||||
S3_UPLOAD_SECRET: z.string().optional(),
|
||||
S3_UPLOAD_BUCKET: z.string().optional(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SplitMode } from '@prisma/client'
|
||||
import { RecurrenceRule, SplitMode } from '@prisma/client'
|
||||
import * as z from 'zod'
|
||||
|
||||
export const groupFormSchema = z
|
||||
@@ -52,7 +52,7 @@ export const expenseFormSchema = z
|
||||
],
|
||||
{ required_error: 'amountRequired' },
|
||||
)
|
||||
.refine((amount) => amount != 1, 'amountNotZero')
|
||||
.refine((amount) => amount != 0, 'amountNotZero')
|
||||
.refine((amount) => amount <= 10_000_000_00, 'amountTenMillion'),
|
||||
paidBy: z.string({ required_error: 'paidByRequired' }),
|
||||
paidFor: z
|
||||
@@ -105,6 +105,11 @@ export const expenseFormSchema = z
|
||||
)
|
||||
.default([]),
|
||||
notes: z.string().optional(),
|
||||
recurrenceRule: z
|
||||
.enum<RecurrenceRule, [RecurrenceRule, ...RecurrenceRule[]]>(
|
||||
Object.values(RecurrenceRule) as any,
|
||||
)
|
||||
.default('NONE'),
|
||||
})
|
||||
.superRefine((expense, ctx) => {
|
||||
let sum = 0
|
||||
|
||||
@@ -23,48 +23,56 @@ export function getTotalActiveUserPaidFor(
|
||||
)
|
||||
}
|
||||
|
||||
type Expense = NonNullable<Awaited<ReturnType<typeof getGroupExpenses>>>[number]
|
||||
|
||||
export function calculateShare(
|
||||
participantId: string | null,
|
||||
expense: Pick<
|
||||
Expense,
|
||||
'amount' | 'paidFor' | 'splitMode' | 'isReimbursement'
|
||||
>,
|
||||
): number {
|
||||
if (expense.isReimbursement) return 0
|
||||
|
||||
const paidFors = expense.paidFor
|
||||
const userPaidFor = paidFors.find(
|
||||
(paidFor) => paidFor.participant.id === participantId,
|
||||
)
|
||||
|
||||
if (!userPaidFor) return 0
|
||||
|
||||
const shares = Number(userPaidFor.shares)
|
||||
|
||||
switch (expense.splitMode) {
|
||||
case 'EVENLY':
|
||||
// Divide the total expense evenly among all participants
|
||||
return expense.amount / paidFors.length
|
||||
case 'BY_AMOUNT':
|
||||
// Directly add the user's share if the split mode is BY_AMOUNT
|
||||
return shares
|
||||
case 'BY_PERCENTAGE':
|
||||
// Calculate the user's share based on their percentage of the total expense
|
||||
return (expense.amount * shares) / 10000 // Assuming shares are out of 10000 for percentage
|
||||
case 'BY_SHARES':
|
||||
// Calculate the user's share based on their shares relative to the total shares
|
||||
const totalShares = paidFors.reduce(
|
||||
(sum, paidFor) => sum + Number(paidFor.shares),
|
||||
0,
|
||||
)
|
||||
return (expense.amount * shares) / totalShares
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
export function getTotalActiveUserShare(
|
||||
activeUserId: string | null,
|
||||
expenses: NonNullable<Awaited<ReturnType<typeof getGroupExpenses>>>,
|
||||
): number {
|
||||
let total = 0
|
||||
|
||||
expenses.forEach((expense) => {
|
||||
if (expense.isReimbursement) return
|
||||
|
||||
const paidFors = expense.paidFor
|
||||
const userPaidFor = paidFors.find(
|
||||
(paidFor) => paidFor.participant.id === activeUserId,
|
||||
)
|
||||
|
||||
if (!userPaidFor) {
|
||||
// If the active user is not involved in the expense, skip it
|
||||
return
|
||||
}
|
||||
|
||||
switch (expense.splitMode) {
|
||||
case 'EVENLY':
|
||||
// Divide the total expense evenly among all participants
|
||||
total += expense.amount / paidFors.length
|
||||
break
|
||||
case 'BY_AMOUNT':
|
||||
// Directly add the user's share if the split mode is BY_AMOUNT
|
||||
total += userPaidFor.shares
|
||||
break
|
||||
case 'BY_PERCENTAGE':
|
||||
// Calculate the user's share based on their percentage of the total expense
|
||||
total += (expense.amount * userPaidFor.shares) / 10000 // Assuming shares are out of 10000 for percentage
|
||||
break
|
||||
case 'BY_SHARES':
|
||||
// Calculate the user's share based on their shares relative to the total shares
|
||||
const totalShares = paidFors.reduce(
|
||||
(sum, paidFor) => sum + paidFor.shares,
|
||||
0,
|
||||
)
|
||||
total += (expense.amount * userPaidFor.shares) / totalShares
|
||||
break
|
||||
}
|
||||
})
|
||||
const total = expenses.reduce(
|
||||
(sum, expense) => sum + calculateShare(activeUserId, expense),
|
||||
0,
|
||||
)
|
||||
|
||||
return parseFloat(total.toFixed(2))
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ export function formatDate(
|
||||
) {
|
||||
return date.toLocaleString(locale, {
|
||||
...options,
|
||||
timeZone: 'UTC',
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
62
src/trpc/client.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
'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())
|
||||
}
|
||||
|
||||
export const trpcClient = getQueryClient()
|
||||
|
||||
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
@@ -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
@@ -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
@@ -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>
|
||||
6
src/trpc/routers/categories/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createTRPCRouter } from '@/trpc/init'
|
||||
import { listCategoriesProcedure } from '@/trpc/routers/categories/list.procedure'
|
||||
|
||||
export const categoriesRouter = createTRPCRouter({
|
||||
list: listCategoriesProcedure,
|
||||
})
|
||||
6
src/trpc/routers/categories/list.procedure.ts
Normal 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() }
|
||||
})
|
||||
6
src/trpc/routers/groups/activities/index.ts
Normal 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,
|
||||
})
|
||||
23
src/trpc/routers/groups/activities/list.procedure.ts
Normal 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,
|
||||
}
|
||||
})
|
||||