mirror of
https://github.com/spliit-app/spliit.git
synced 2026-02-14 19:46:12 +01:00
Compare commits
54 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9302a32f4c | ||
|
|
98e2345bb9 | ||
|
|
5732f78e80 | ||
|
|
72ad0a4c90 | ||
|
|
2c973f976f | ||
|
|
5374d9e9c7 | ||
|
|
5111f3574f | ||
|
|
4db788680e | ||
|
|
39c1a2ffc6 | ||
|
|
f5154393e2 | ||
|
|
e9d583113a | ||
|
|
21d0c02687 | ||
|
|
2281316d58 | ||
|
|
210c12b7ef | ||
|
|
66e15e419e | ||
|
|
727803ea5c | ||
|
|
7add7efea2 | ||
|
|
a7c80f65c3 | ||
|
|
1e4edf7504 | ||
|
|
24053ca5ab | ||
|
|
343363d54f | ||
|
|
8742bd59da | ||
|
|
8eea062218 | ||
|
|
9a5674e239 | ||
|
|
50b3a2e431 | ||
|
|
e8d46cd4f3 | ||
|
|
8f896f7412 | ||
|
|
504631454a | ||
|
|
345f3716c9 | ||
|
|
5fff8da08d | ||
|
|
07e24f7fcb | ||
|
|
5dfe03b3f1 | ||
|
|
26bed11116 | ||
|
|
972bb9dadb | ||
|
|
4f5e124ff0 | ||
|
|
c392c06b39 | ||
|
|
002e867bc4 | ||
|
|
9b8f716a6a | ||
|
|
853f1791d2 | ||
|
|
7145cb6f30 | ||
|
|
e990e00a75 | ||
|
|
0c05499107 | ||
|
|
3887efd9ee | ||
|
|
e619c1a5b4 | ||
|
|
10e13d1f6b | ||
|
|
f9d915378b | ||
|
|
74465c0565 | ||
|
|
d3fd8027a5 | ||
|
|
833237b613 | ||
|
|
1cd2b273f9 | ||
|
|
1ad470309b | ||
|
|
2fd38aadd9 | ||
|
|
b61d1836ea | ||
|
|
c3903849ec |
42
.devcontainer/devcontainer.json
Normal file
42
.devcontainer/devcontainer.json
Normal file
@@ -0,0 +1,42 @@
|
||||
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
|
||||
// README at: https://github.com/devcontainers/templates/tree/main/src/javascript-node-postgres
|
||||
{
|
||||
"name": "spliit",
|
||||
"dockerComposeFile": "docker-compose.yml",
|
||||
"service": "app",
|
||||
|
||||
// Features to add to the dev container. More info: https://containers.dev/features.
|
||||
// "features": {
|
||||
// "ghcr.io/frntn/devcontainers-features/prism:1": {}
|
||||
// },
|
||||
|
||||
// Use 'postCreateCommand' to run commands after the container is created.
|
||||
"postCreateCommand": "cp container.env.example .env && npm install",
|
||||
"postAttachCommand": {
|
||||
"npm": "npm run dev"
|
||||
},
|
||||
|
||||
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||
// This can be used to network with other containers or with the host.
|
||||
"forwardPorts": [3000, 5432],
|
||||
"portsAttributes": {
|
||||
"3000": {
|
||||
"label": "App"
|
||||
},
|
||||
"5432": {
|
||||
"label": "PostgreSQL"
|
||||
}
|
||||
},
|
||||
|
||||
// Configure tool-specific properties.
|
||||
"customizations": {
|
||||
"codespaces": {
|
||||
"openFiles": [
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
|
||||
// "remoteUser": "root"
|
||||
}
|
||||
33
.devcontainer/docker-compose.yml
Normal file
33
.devcontainer/docker-compose.yml
Normal file
@@ -0,0 +1,33 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
app:
|
||||
image: mcr.microsoft.com/devcontainers/typescript-node:latest
|
||||
|
||||
volumes:
|
||||
- ../..:/workspaces:cached
|
||||
|
||||
# Overrides default command so things don't shut down after the process ends.
|
||||
command: sleep infinity
|
||||
|
||||
# Runs app on the same network as the database container, allows "forwardPorts" in devcontainer.json function.
|
||||
network_mode: service:db
|
||||
|
||||
# Use "forwardPorts" in **devcontainer.json** to forward an app port locally.
|
||||
# (Adding the "ports" property to this file will not forward from a Codespace.)
|
||||
|
||||
db:
|
||||
image: postgres:latest
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
environment:
|
||||
POSTGRES_PASSWORD: 1234
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_DB: postgres
|
||||
|
||||
# Add "forwardPorts": ["5432"] to **devcontainer.json** to forward PostgreSQL locally.
|
||||
# (Adding the "ports" property to this file will not forward from a Codespace.)
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
@@ -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
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 ./
|
||||
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 ./
|
||||
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
|
||||
|
||||
18
jest.config.ts
Normal file
18
jest.config.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { Config } from 'jest'
|
||||
import nextJest from 'next/jest.js'
|
||||
|
||||
const createJestConfig = nextJest({
|
||||
// Provide the path to your Next.js app to load next.config.js and .env files in your test environment
|
||||
dir: './',
|
||||
})
|
||||
|
||||
// Add any custom config to be passed to Jest
|
||||
const config: Config = {
|
||||
coverageProvider: 'v8',
|
||||
testEnvironment: 'jsdom',
|
||||
// Add more setup options before each test is run
|
||||
// setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
|
||||
}
|
||||
|
||||
// createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async
|
||||
export default createJestConfig(config)
|
||||
388
messages/de-DE.json
Normal file
388
messages/de-DE.json
Normal file
@@ -0,0 +1,388 @@
|
||||
{
|
||||
"Homepage": {
|
||||
"title": "Teile <strong>Ausgaben</strong> mit <strong>Freunden & Familie</strong>",
|
||||
"description": "Willkommen zu deiner neuen <strong>Spliit</strong>-Instanz!",
|
||||
"button": {
|
||||
"groups": "Zu den Gruppen",
|
||||
"github": "GitHub"
|
||||
}
|
||||
},
|
||||
"Header": {
|
||||
"groups": "Gruppen"
|
||||
},
|
||||
"Footer": {
|
||||
"madeIn": "Entwickelt in Montréal, Québec 🇨🇦",
|
||||
"builtBy": "Erstellt von <author>Sebastien Castiel</author> und <source>Mitwirkenden</source>"
|
||||
},
|
||||
"Expenses": {
|
||||
"title": "Ausgaben",
|
||||
"description": "Hier sind die Ausgaben, die du für deine Gruppe erstellt hast.",
|
||||
"create": "Ausgabe hinzufügen",
|
||||
"createFirst": "Erstelle die Erste",
|
||||
"noExpenses": "Deine Gruppe hat noch keine Ausgaben.",
|
||||
"exportJson": "Als JSON exportieren",
|
||||
"searchPlaceholder": "Suche nach einer Ausgabe…",
|
||||
"ActiveUserModal": {
|
||||
"title": "Wer bist du?",
|
||||
"description": "Sag uns, welcher Teilnehmer du bist, um die angezeigten Informationen auf dich anzupassen.",
|
||||
"nobody": "Ich will niemanden auswählen",
|
||||
"save": "Änderungen speichern",
|
||||
"footer": "Diese Einstellung kann später in den Gruppeneinstellungen geändert werden."
|
||||
},
|
||||
"Groups": {
|
||||
"upcoming": "Bevorstehend",
|
||||
"thisWeek": "Diese Woche",
|
||||
"earlierThisMonth": "Diesen Monat",
|
||||
"lastMonth": "Letzten Monat",
|
||||
"earlierThisYear": "Dieses Jahr",
|
||||
"lastYera": "Letztes Jahr",
|
||||
"older": "Älter"
|
||||
}
|
||||
},
|
||||
"ExpenseCard": {
|
||||
"paidBy": "Gezahlt von <strong>{paidBy}</strong> für <paidFor></paidFor>",
|
||||
"receivedBy": "Empfangen von <strong>{paidBy}</strong> für <paidFor></paidFor>",
|
||||
"yourBalance": "Deine Bilanz:"
|
||||
},
|
||||
"Groups": {
|
||||
"myGroups": "Meine Gruppen",
|
||||
"create": "Erstellen",
|
||||
"loadingRecent": "Lade letzte Gruppen…",
|
||||
"NoRecent": {
|
||||
"description": "Du hast in der letzten Zeit keine Gruppe besucht.",
|
||||
"create": "Erstelle eine",
|
||||
"orAsk": "oder bitte einen Freund, dir einen Link zu einer Existierenden zu schicken."
|
||||
},
|
||||
"recent": "Letzte Gruppen",
|
||||
"starred": "Favorisierte Gruppen",
|
||||
"archived": "Archivierte Gruppen",
|
||||
"archive": "Gruppe archivieren",
|
||||
"unarchive": "Gruppe wiederherstellen",
|
||||
"removeRecent": "Aus letzten Gruppen entfernen",
|
||||
"RecentRemovedToast": {
|
||||
"title": "Gruppe wurde entfernt",
|
||||
"description": "Die Gruppe wurde von deiner Liste der letzten Gruppen entfernt.",
|
||||
"undoAlt": "Gruppe entfernen rückgängig machen",
|
||||
"undo": "Rückgängig machen"
|
||||
},
|
||||
"AddByURL": {
|
||||
"button": "Mit URL hinzufügen",
|
||||
"title": "Gruppe mit URL hinzufügen",
|
||||
"description": "Wenn eine Gruppe mit dir geteilt wurde, kannst du ihre URL hier einfügen, um sie zu deiner Liste hinzuzufügen.",
|
||||
"error": "Ups, wir können die Gruppe mit der angegebenen URL nicht finden…"
|
||||
},
|
||||
"NotFound": {
|
||||
"text": "Diese Gruppe existiert nicht.",
|
||||
"link": "Gehe zu zuletzt besuchten Gruppen"
|
||||
}
|
||||
},
|
||||
"GroupForm": {
|
||||
"title": "Gruppeninformationen",
|
||||
"NameField": {
|
||||
"label": "Gruppenname",
|
||||
"placeholder": "Sommerurlaub",
|
||||
"description": "Gib deiner Gruppe einen Namen."
|
||||
},
|
||||
"InformationField": {
|
||||
"label": "Gruppeninformationen",
|
||||
"placeholder": "Welche Informationen sind relevant für Gruppenmitglieder?"
|
||||
},
|
||||
"CurrencyField": {
|
||||
"label": "Währungssymbol",
|
||||
"placeholder": "€, $, £…",
|
||||
"description": "Wir benutzen es, um Beträge anzuzeigen."
|
||||
},
|
||||
"Participants": {
|
||||
"title": "Mitglieder",
|
||||
"description": "Füge einen Namen für jedes Gruppenmitglied hinzu.",
|
||||
"protectedParticipant": "Dieses Mitglied ist Teil der Ausgaben und kann nicht entfernt werden.",
|
||||
"new": "Neu",
|
||||
"add": "Mitglied hinzufügen",
|
||||
"John": "Johannes",
|
||||
"Jane": "Janina",
|
||||
"Jack": "Jakob"
|
||||
},
|
||||
"Settings": {
|
||||
"title": "Lokale Einstellungen",
|
||||
"description": "Dies sind Einstellungen pro Gerät, die verwendet werden, um deine Benutzererfahrung zu verbessern.",
|
||||
"ActiveUserField": {
|
||||
"label": "Aktiver Nutzer",
|
||||
"placeholder": "Wähle ein Mitglied",
|
||||
"none": "Keiner",
|
||||
"description": "Standardnutzer, der die Ausgaben übernimmt."
|
||||
},
|
||||
"save": "Speichern",
|
||||
"saving": "Speichert…",
|
||||
"create": "Erstellen",
|
||||
"creating": "Erstellt…",
|
||||
"cancel": "Abbrechen"
|
||||
}
|
||||
},
|
||||
"ExpenseForm": {
|
||||
"Income": {
|
||||
"create": "Einnahme erstellen",
|
||||
"edit": "Einnahme bearbeiten",
|
||||
"TitleField": {
|
||||
"label": "Titel der Einnahme",
|
||||
"placeholder": "Montagabend Restaurant",
|
||||
"description": "Füge eine Beschreibung für die Einnahme hinzu."
|
||||
},
|
||||
"DateField": {
|
||||
"label": "Datum der Einnahme",
|
||||
"description": "Füge ein Datum hinzu für wann die Einnahme erhalten wurde."
|
||||
},
|
||||
"categoryFieldDescription": "Wähle die Kategorie der Einnahme.",
|
||||
"paidByField": {
|
||||
"label": "Empfangen von",
|
||||
"description": "Wähle das Mitglied, das die Einnahme erhalten hat."
|
||||
},
|
||||
"paidFor": {
|
||||
"title": "Empfangen für",
|
||||
"description": "Wähle für wen die Einnahme empfangen wurde."
|
||||
},
|
||||
"splitModeDescription": "Wähle, wie die Einnahme aufgeteilt werden soll.",
|
||||
"attachDescription": "Füge der Einnahme einen Beleg hinzu."
|
||||
},
|
||||
"Expense": {
|
||||
"create": "Augabe erstellen",
|
||||
"edit": "Ausgabe bearbeiten",
|
||||
"TitleField": {
|
||||
"label": "Titel der Ausgabe",
|
||||
"placeholder": "Montagabend Restaurant",
|
||||
"description": "Füge eine Beschreibung für die Ausgabe hinzu."
|
||||
},
|
||||
"DateField": {
|
||||
"label": "Datum der Ausgabe",
|
||||
"description": "Füge das Datum ein, zu dem die Ausgabe getätigt wurde."
|
||||
},
|
||||
"categoryFieldDescription": "Wähle eine Kategorie für die Ausgabe.",
|
||||
"paidByField": {
|
||||
"label": "Gezahlt von",
|
||||
"description": "Wähle das Mitglied, das die Ausgabe bezahlt hat."
|
||||
},
|
||||
"paidFor": {
|
||||
"title": "Gezahlt für",
|
||||
"description": "Wähle für wen die Ausgabe gezahlt wurde."
|
||||
},
|
||||
"splitModeDescription": "Wähle, wie die Ausgabe aufgeteilt werden soll.",
|
||||
"attachDescription": "Füge der Ausgabe einen Beleg hinzu."
|
||||
},
|
||||
"amountField": {
|
||||
"label": "Betrag"
|
||||
},
|
||||
"isReimbursementField": {
|
||||
"label": "Das ist eine Rückzahlung"
|
||||
},
|
||||
"categoryField": {
|
||||
"label": "Kategorie"
|
||||
},
|
||||
"notesField": {
|
||||
"label": "Notizen"
|
||||
},
|
||||
"selectNone": "Keine auswählen",
|
||||
"selectAll": "Alle auswählen",
|
||||
"shares": "Anteil(e)",
|
||||
"advancedOptions": "Fortgeschrittene Aufteilungsoptionen…",
|
||||
"SplitModeField": {
|
||||
"label": "Aufteilungsart",
|
||||
"evenly": "Gleich verteilt",
|
||||
"byShares": "Ungleich – Nach Anteilen",
|
||||
"byPercentage": "Ungleich – Prozentual",
|
||||
"byAmount": "Ungleich – Nach Betrag",
|
||||
"saveAsDefault": "Als Standardoptionen zur Aufteilung speichern"
|
||||
},
|
||||
"DeletePopup": {
|
||||
"label": "Löschen",
|
||||
"title": "Diese Ausgabe löschen?",
|
||||
"description": "Willst du diese Ausgabe wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"yes": "Ja",
|
||||
"cancel": "Abbrechen"
|
||||
},
|
||||
"attachDocuments": "Dokument hinzufügen",
|
||||
"create": "Erstellen",
|
||||
"creating": "Erstellt…",
|
||||
"save": "Speichern",
|
||||
"saving": "Speichert…",
|
||||
"cancel": "Abbrechen",
|
||||
"reimbursement": "Rückzahlung"
|
||||
},
|
||||
"ExpenseDocumentsInput": {
|
||||
"TooBigToast": {
|
||||
"title": "Die Datei ist zu groß",
|
||||
"description": "Die maximale Dateigröße ist {maxSize}. Deine ist ${size}."
|
||||
},
|
||||
"ErrorToast": {
|
||||
"title": "Fehler beim Hochladen der Datei",
|
||||
"description": "Beim Hochladen der Datei ist etwas schiefgelaufen. Versuche es später nochmal oder wähle eine andere Datei.",
|
||||
"retry": "Wiederholen"
|
||||
}
|
||||
},
|
||||
"CreateFromReceipt": {
|
||||
"Dialog": {
|
||||
"triggerTitle": "Ausgabe von Rechnungsbeleg erstellen",
|
||||
"title": "Von Rechnungsbeleg erstellen",
|
||||
"description": "Ausgabeninformationen von einem Foto einer Rechnung lesen.",
|
||||
"body": "Lade ein Foto der Rechnung hoch und wir versuchen die Ausgabeinformationen zu extrahieren",
|
||||
"selectImage": "Bild wählen…",
|
||||
"titleLabel": "Titel:",
|
||||
"categoryLabel": "Kategorie:",
|
||||
"amountLabel": "Betrag:",
|
||||
"dateLabel": "Datum:",
|
||||
"editNext": "Als nächstes kannst du die Informationen zur Ausgabe editieren.",
|
||||
"continue": "Weiter"
|
||||
},
|
||||
"unknown": "Unbekannt",
|
||||
"TooBigToast": {
|
||||
"title": "Die Datei ist zu groß",
|
||||
"description": "Die maximale Dateigröße ist {maxSize}. Deine ist ${size}."
|
||||
},
|
||||
"ErrorToast": {
|
||||
"title": "Fehler beim Hochladen der Datei",
|
||||
"description": "Beim Hochladen der Datei ist etwas schiefgelaufen. Versuche es später nochmal oder wähle eine andere Datei.",
|
||||
"retry": "Wiederholen"
|
||||
}
|
||||
},
|
||||
"Balances": {
|
||||
"title": "Bilanz",
|
||||
"description": "Das sind die Beträge, die jedes Mitglied bezahlt oder empfangen hat.",
|
||||
"Reimbursements": {
|
||||
"title": "Vorgeschlagene Rückzahlungen",
|
||||
"description": "Hier sind Vorschläge für optimierte Rückzahlungen zwischen Mitgliedern.",
|
||||
"noImbursements": "Es sieht aus, als seien in der Gruppe keine Rückzahlungen nötig 😁",
|
||||
"owes": "<strong>{from}</strong> schuldet <strong>{to}</strong>",
|
||||
"markAsPaid": "Als gezahlt markieren"
|
||||
}
|
||||
},
|
||||
"Stats": {
|
||||
"title": "Statistiken",
|
||||
"Totals": {
|
||||
"title": "Gesamtausgaben",
|
||||
"description": "Zusammenfassung der Ausgaben der gesamten Gruppe.",
|
||||
"groupSpendings": "Gesamte Ausgaben der Gruppe",
|
||||
"groupEarnings": "Gesamte Einnahmen der Gruppe",
|
||||
"yourSpendings": "Deine gesamten Ausgaben",
|
||||
"yourEarnings": "Deine gesamten Einnahmen",
|
||||
"yourShare": "Dein gesamter Anteil"
|
||||
}
|
||||
},
|
||||
"Activity": {
|
||||
"title": "Aktivitäten",
|
||||
"description": "Zusammenfassung aller Aktivitäten in dieser Gruppe.",
|
||||
"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.",
|
||||
"expenseUpdated": "Ausgabe <em>{expense}</em> wurde von <strong>{participant}</strong> aktualisiert.",
|
||||
"expenseDeleted": "Ausgabe <em>{expense}</em> wurde von <strong>{participant}</strong> gelöscht.",
|
||||
"Groups": {
|
||||
"today": "Heute",
|
||||
"yesterday": "Gestern",
|
||||
"earlierThisWeek": "Diese Woche",
|
||||
"lastWeek": "Letze Woche",
|
||||
"earlierThisMonth": "Diesen Monat",
|
||||
"lastMonth": "Letzen Monat",
|
||||
"earlierThisYear": "Dieses Jahr",
|
||||
"lastYear": "Letztes Jahr",
|
||||
"older": "Älter"
|
||||
}
|
||||
},
|
||||
"Information": {
|
||||
"title": "Informationen",
|
||||
"description": "Nutze diesen Ort, um Informationen hinzuzufügen, die für die Gruppenmitglieder wichtig sein könnten.",
|
||||
"empty": "Noch keine Gruppeninformationen vorhanden."
|
||||
},
|
||||
"Settings": {
|
||||
"title": "Einstellungen"
|
||||
},
|
||||
"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!"
|
||||
},
|
||||
"SchemaErrors": {
|
||||
"min1": "Gib mindestens ein Zeichen ein.",
|
||||
"min2": "Gib mindestens zwei Zeichen ein.",
|
||||
"max5": "Gib maximal fünf Zeichen ein.",
|
||||
"max50": "Gib maximal 50 Zeichen ein.",
|
||||
"duplicateParticipantName": "Der Name ist bereits an ein anderes Gruppenmitglied vergeben.",
|
||||
"titleRequired": "Bitte gib einen Titel an.",
|
||||
"invalidNumber": "Zahl nicht valide.",
|
||||
"amountRequired": "Du musst einen Betrag angeben.",
|
||||
"amountNotZero": "Der Betrag darf nicht 0 sein.",
|
||||
"amountTenMillion": "Der Betrag muss kleiner als 10.000.000 sein",
|
||||
"paidByRequired": "Du musst ein Mitglied auswählen.",
|
||||
"paidForMin1": "Die Ausgabe muss mindestens für ein Mitglied bezahlt werden.",
|
||||
"noZeroShares": "Alle Anteile müssen größer als 0 sein.",
|
||||
"amountSum": "Die Summe der Beträge muss dem Betrag der Ausgabe entsprechen.",
|
||||
"percentageSum": "Die Summe der prozentualen Anteile muss 100 ergeben."
|
||||
},
|
||||
"Categories": {
|
||||
"search": "Nach Kategorie suchen...",
|
||||
"noCategory": "Keine Kategorie gefunden.",
|
||||
"Uncategorized": {
|
||||
"heading": "Nicht kategorisiert",
|
||||
"General": "Allgemein",
|
||||
"Payment": "Zahlung"
|
||||
},
|
||||
"Entertainment": {
|
||||
"heading": "Vergnügen",
|
||||
"Entertainment": "Vergnügen",
|
||||
"Games": "Spiele",
|
||||
"Movies": "Filme",
|
||||
"Music": "Musik",
|
||||
"Sports": "Sport"
|
||||
},
|
||||
"Food and Drink": {
|
||||
"heading": "Essen und Trinken",
|
||||
"Food and Drink": "Essen und Trinken",
|
||||
"Dining Out": "Essen gehen",
|
||||
"Groceries": "Lebensmittel",
|
||||
"Liquor": "Alkohol"
|
||||
},
|
||||
"Home": {
|
||||
"heading": "Zuhause",
|
||||
"Home": "Zuhause",
|
||||
"Electronics": "Elektronik",
|
||||
"Furniture": "Möbel",
|
||||
"Household Supplies": "Haushaltsgegenstände",
|
||||
"Maintenance": "Wartung",
|
||||
"Mortgage": "Hypothek",
|
||||
"Pets": "Haustiere",
|
||||
"Rent": "Miete",
|
||||
"Services": "Dienstleistungen"
|
||||
},
|
||||
"Life": {
|
||||
"heading": "Leben",
|
||||
"Childcare": "Kinderversorgung",
|
||||
"Clothing": "Kleidung",
|
||||
"Education": "Bildung",
|
||||
"Gifts": "Geschenke",
|
||||
"Insurance": "Versicherung",
|
||||
"Medical Expenses": "Medizinische Ausgaben",
|
||||
"Taxes": "Steuern"
|
||||
},
|
||||
"Transportation": {
|
||||
"heading": "Transport",
|
||||
"Transportation": "Transport",
|
||||
"Bicycle": "Fahrrad",
|
||||
"Bus/Train": "Bus/Bahn",
|
||||
"Car": "Auto",
|
||||
"Gas/Fuel": "Tanken",
|
||||
"Hotel": "Hotel",
|
||||
"Parking": "Parken",
|
||||
"Plane": "Flugzeug",
|
||||
"Taxi": "Taxi"
|
||||
},
|
||||
"Utilities": {
|
||||
"heading": "Versorgung",
|
||||
"Utilities": "Versorgung",
|
||||
"Cleaning": "Reinigung/Putzen",
|
||||
"Electricity": "Strom",
|
||||
"Heat/Gas": "Heizung",
|
||||
"Trash": "Müll",
|
||||
"TV/Phone/Internet": "TV/Internet/Telefonie",
|
||||
"Water": "Wasser"
|
||||
}
|
||||
}
|
||||
}
|
||||
388
messages/en-US.json
Normal file
388
messages/en-US.json
Normal file
@@ -0,0 +1,388 @@
|
||||
{
|
||||
"Homepage": {
|
||||
"title": "Share <strong>Expenses</strong> with <strong>Friends & Family</strong>",
|
||||
"description": "Welcome to your new <strong>Spliit</strong> instance !",
|
||||
"button": {
|
||||
"groups": "Go to groups",
|
||||
"github": "GitHub"
|
||||
}
|
||||
},
|
||||
"Header": {
|
||||
"groups": "Groups"
|
||||
},
|
||||
"Footer": {
|
||||
"madeIn": "Made in Montréal, Québec 🇨🇦",
|
||||
"builtBy": "Built by <author>Sebastien Castiel</author> and <source>contributors</source>"
|
||||
},
|
||||
"Expenses": {
|
||||
"title": "Expenses",
|
||||
"description": "Here are the expenses that you created for your group.",
|
||||
"create": "Create expense",
|
||||
"createFirst": "Create the first one",
|
||||
"noExpenses": "Your group doesn’t contain any expense yet.",
|
||||
"exportJson": "Export to JSON",
|
||||
"searchPlaceholder": "Search for an expense…",
|
||||
"ActiveUserModal": {
|
||||
"title": "Who are you?",
|
||||
"description": "Tell us which participant you are to let us customize how the information is displayed.",
|
||||
"nobody": "I don’t want to select anyone",
|
||||
"save": "Save changes",
|
||||
"footer": "This setting can be changed later in the group settings."
|
||||
},
|
||||
"Groups": {
|
||||
"upcoming": "Upcoming",
|
||||
"thisWeek": "This week",
|
||||
"earlierThisMonth": "Earlier this month",
|
||||
"lastMonth": "Last month",
|
||||
"earlierThisYear": "Earlier this year",
|
||||
"lastYera": "Last year",
|
||||
"older": "Older"
|
||||
}
|
||||
},
|
||||
"ExpenseCard": {
|
||||
"paidBy": "Paid by <strong>{paidBy}</strong> for <paidFor></paidFor>",
|
||||
"receivedBy": "Received by <strong>{paidBy}</strong> for <paidFor></paidFor>",
|
||||
"yourBalance": "Your balance:"
|
||||
},
|
||||
"Groups": {
|
||||
"myGroups": "My groups",
|
||||
"create": "Create",
|
||||
"loadingRecent": "Loading recent groups…",
|
||||
"NoRecent": {
|
||||
"description": "You have not visited any group recently.",
|
||||
"create": "Create one",
|
||||
"orAsk": "or ask a friend to send you the link to an existing one."
|
||||
},
|
||||
"recent": "Recent groups",
|
||||
"starred": "Starred groups",
|
||||
"archived": "Archived groups",
|
||||
"archive": "Archive group",
|
||||
"unarchive": "Unarchive group",
|
||||
"removeRecent": "Remove from recent groups",
|
||||
"RecentRemovedToast": {
|
||||
"title": "Group has been removed",
|
||||
"description": "The group was removed from your recent groups list.",
|
||||
"undoAlt": "Undo group removal",
|
||||
"undo": "Undo"
|
||||
},
|
||||
"AddByURL": {
|
||||
"button": "Add by URL",
|
||||
"title": "Add a group by URL",
|
||||
"description": "If a group was shared with you, you can paste its URL here to add it to your list.",
|
||||
"error": "Oops, we are not able to find the group from the URL you provided…"
|
||||
},
|
||||
"NotFound": {
|
||||
"text": "This group does not exist.",
|
||||
"link": "Go to recently visited groups"
|
||||
}
|
||||
},
|
||||
"GroupForm": {
|
||||
"title": "Group information",
|
||||
"NameField": {
|
||||
"label": "Group name",
|
||||
"placeholder": "Summer vacations",
|
||||
"description": "Enter a name for your group."
|
||||
},
|
||||
"InformationField": {
|
||||
"label": "Group information",
|
||||
"placeholder": "What information is relevant to group participants?"
|
||||
},
|
||||
"CurrencyField": {
|
||||
"label": "Currency symbol",
|
||||
"placeholder": "$, €, £…",
|
||||
"description": "We’ll use it to display amounts."
|
||||
},
|
||||
"Participants": {
|
||||
"title": "Participants",
|
||||
"description": "Enter the name for each participant.",
|
||||
"protectedParticipant": "This participant is part of expenses, and can not be removed.",
|
||||
"new": "New",
|
||||
"add": "Add participant",
|
||||
"John": "John",
|
||||
"Jane": "Jane",
|
||||
"Jack": "Jack"
|
||||
},
|
||||
"Settings": {
|
||||
"title": "Local settings",
|
||||
"description": "These settings are set per-device, and are used to customize your experience.",
|
||||
"ActiveUserField": {
|
||||
"label": "Active user",
|
||||
"placeholder": "Select a participant",
|
||||
"none": "None",
|
||||
"description": "User used as default for paying expenses."
|
||||
},
|
||||
"save": "Save",
|
||||
"saving": "Saving…",
|
||||
"create": "Create",
|
||||
"creating": "Creating…",
|
||||
"cancel": "Cancel"
|
||||
}
|
||||
},
|
||||
"ExpenseForm": {
|
||||
"Income": {
|
||||
"create": "Create income",
|
||||
"edit": "Edit income",
|
||||
"TitleField": {
|
||||
"label": "Income title",
|
||||
"placeholder": "Monday evening restaurant",
|
||||
"description": "Enter a description for the income."
|
||||
},
|
||||
"DateField": {
|
||||
"label": "Income date",
|
||||
"description": "Enter the date the income was received."
|
||||
},
|
||||
"categoryFieldDescription": "Select the income category.",
|
||||
"paidByField": {
|
||||
"label": "Received by",
|
||||
"description": "Select the participant who received the income."
|
||||
},
|
||||
"paidFor": {
|
||||
"title": "Received for",
|
||||
"description": "Select who the income was received for."
|
||||
},
|
||||
"splitModeDescription": "Select how to split the income.",
|
||||
"attachDescription": "See and attach receipts to the income."
|
||||
},
|
||||
"Expense": {
|
||||
"create": "Create expense",
|
||||
"edit": "Edit expense",
|
||||
"TitleField": {
|
||||
"label": "Expense title",
|
||||
"placeholder": "Monday evening restaurant",
|
||||
"description": "Enter a description for the expense."
|
||||
},
|
||||
"DateField": {
|
||||
"label": "Expense date",
|
||||
"description": "Enter the date the expense was paid."
|
||||
},
|
||||
"categoryFieldDescription": "Select the expense category.",
|
||||
"paidByField": {
|
||||
"label": "Paid by",
|
||||
"description": "Select the participant who paid the expense."
|
||||
},
|
||||
"paidFor": {
|
||||
"title": "Paid for",
|
||||
"description": "Select who the expense was paid for."
|
||||
},
|
||||
"splitModeDescription": "Select how to split the expense.",
|
||||
"attachDescription": "See and attach receipts to the expense."
|
||||
},
|
||||
"amountField": {
|
||||
"label": "Amount"
|
||||
},
|
||||
"isReimbursementField": {
|
||||
"label": "This is a reimbursement"
|
||||
},
|
||||
"categoryField": {
|
||||
"label": "Category"
|
||||
},
|
||||
"notesField": {
|
||||
"label": "Notes"
|
||||
},
|
||||
"selectNone": "Select none",
|
||||
"selectAll": "Select all",
|
||||
"shares": "share(s)",
|
||||
"advancedOptions": "Advanced splitting options…",
|
||||
"SplitModeField": {
|
||||
"label": "Split mode",
|
||||
"evenly": "Evenly",
|
||||
"byShares": "Unevenly – By shares",
|
||||
"byPercentage": "Unevenly – By percentage",
|
||||
"byAmount": "Unevenly – By amount",
|
||||
"saveAsDefault": "Save as default splitting options"
|
||||
},
|
||||
"DeletePopup": {
|
||||
"label": "Delete",
|
||||
"title": "Delete this expense?",
|
||||
"description": "Do you really want to delete this expense? This action is irreversible.",
|
||||
"yes": "Yes",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"attachDocuments": "Attach documents",
|
||||
"create": "Create",
|
||||
"creating": "Creating…",
|
||||
"save": "Save",
|
||||
"saving": "Saving…",
|
||||
"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}."
|
||||
},
|
||||
"ErrorToast": {
|
||||
"title": "Error while uploading document",
|
||||
"description": "Something wrong happened when uploading the document. Please retry later or select a different file.",
|
||||
"retry": "Retry"
|
||||
}
|
||||
},
|
||||
"CreateFromReceipt": {
|
||||
"Dialog": {
|
||||
"triggerTitle": "Create expense from receipt",
|
||||
"title": "Create from receipt",
|
||||
"description": "Extract the expense information from a receipt photo.",
|
||||
"body": "Upload the photo of a receipt, and we’ll scan it to extract the expense information if we can.",
|
||||
"selectImage": "Select image…",
|
||||
"titleLabel": "Title:",
|
||||
"categoryLabel": "Category:",
|
||||
"amountLabel": "Amount:",
|
||||
"dateLabel": "Date:",
|
||||
"editNext": "You’ll be able to edit the expense information next.",
|
||||
"continue": "Continue"
|
||||
},
|
||||
"unknown": "Unknown",
|
||||
"TooBigToast": {
|
||||
"title": "The file is too big",
|
||||
"description": "The maximum file size you can upload is {maxSize}. Yours is ${size}."
|
||||
},
|
||||
"ErrorToast": {
|
||||
"title": "Error while uploading document",
|
||||
"description": "Something wrong happened when uploading the document. Please retry later or select a different file.",
|
||||
"retry": "Retry"
|
||||
}
|
||||
},
|
||||
"Balances": {
|
||||
"title": "Balances",
|
||||
"description": "This is the amount that each participant paid or was paid for.",
|
||||
"Reimbursements": {
|
||||
"title": "Suggested reimbursements",
|
||||
"description": "Here are suggestions for optimized reimbursements between participants.",
|
||||
"noImbursements": "It looks like your group doesn’t need any reimbursement 😁",
|
||||
"owes": "<strong>{from}</strong> owes <strong>{to}</strong>",
|
||||
"markAsPaid": "Mark as paid"
|
||||
}
|
||||
},
|
||||
"Stats": {
|
||||
"title": "Stats",
|
||||
"Totals": {
|
||||
"title": "Totals",
|
||||
"description": "Spending summary of the entire group.",
|
||||
"groupSpendings": "Total group spendings",
|
||||
"groupEarnings": "Total group earnings",
|
||||
"yourSpendings": "Your total spendings",
|
||||
"yourEarnings": "Your total earnings",
|
||||
"yourShare": "Your total share"
|
||||
}
|
||||
},
|
||||
"Activity": {
|
||||
"title": "Activity",
|
||||
"description": "Overview of all activity in this group.",
|
||||
"noActivity": "There is not yet any activity in your group.",
|
||||
"someone": "Someone",
|
||||
"settingsModified": "Group settings were modified by <strong>{participant}</strong>.",
|
||||
"expenseCreated": "Expense <em>{expense}</em> created by <strong>{participant}</strong>.",
|
||||
"expenseUpdated": "Expense <em>{expense}</em> updated by <strong>{participant}</strong>.",
|
||||
"expenseDeleted": "Expense <em>{expense}</em> deleted by <strong>{participant}</strong>.",
|
||||
"Groups": {
|
||||
"today": "Today",
|
||||
"yesterday": "Yesterday",
|
||||
"earlierThisWeek": "Earlier this week",
|
||||
"lastWeek": "Last week",
|
||||
"earlierThisMonth": "Earlier this month",
|
||||
"lastMonth": "Last month",
|
||||
"earlierThisYear": "Earlier this year",
|
||||
"lastYear": "Last year",
|
||||
"older": "Older"
|
||||
}
|
||||
},
|
||||
"Information": {
|
||||
"title": "Information",
|
||||
"description": "Use this place to add any information that can be relevant to the group participants.",
|
||||
"empty": "No group information yet."
|
||||
},
|
||||
"Settings": {
|
||||
"title": "Settings"
|
||||
},
|
||||
"Share": {
|
||||
"title": "Share",
|
||||
"description": "For other participants to see the group and add expenses, share its URL with them.",
|
||||
"warning": "Warning!",
|
||||
"warningHelp": "Every person with the group URL will be able to see and edit expenses. Share with caution!"
|
||||
},
|
||||
"SchemaErrors": {
|
||||
"min1": "Enter at least one character.",
|
||||
"min2": "Enter at least two characters.",
|
||||
"max5": "Enter at most five characters.",
|
||||
"max50": "Enter at most 50 characters.",
|
||||
"duplicateParticipantName": "Another participant already has this name.",
|
||||
"titleRequired": "Please enter a title.",
|
||||
"invalidNumber": "Invalid number.",
|
||||
"amountRequired": "You must enter an amount.",
|
||||
"amountNotZero": "The amount must not be zero.",
|
||||
"amountTenMillion": "The amount must be lower than 10,000,000.",
|
||||
"paidByRequired": "You must select a participant.",
|
||||
"paidForMin1": "The expense must be paid for at least one participant.",
|
||||
"noZeroShares": "All shares must be higher than 0.",
|
||||
"amountSum": "Sum of amounts must equal the expense amount.",
|
||||
"percentageSum": "Sum of percentages must equal 100."
|
||||
},
|
||||
"Categories": {
|
||||
"search": "Search category...",
|
||||
"noCategory": "No category found.",
|
||||
"Uncategorized": {
|
||||
"heading": "Uncategorized",
|
||||
"General": "General",
|
||||
"Payment": "Payment"
|
||||
},
|
||||
"Entertainment": {
|
||||
"heading": "Entertainment",
|
||||
"Entertainment": "Entertainment",
|
||||
"Games": "Games",
|
||||
"Movies": "Movies",
|
||||
"Music": "Music",
|
||||
"Sports": "Sports"
|
||||
},
|
||||
"Food and Drink": {
|
||||
"heading": "Food and Drink",
|
||||
"Food and Drink": "Food and Drink",
|
||||
"Dining Out": "Dining Out",
|
||||
"Groceries": "Groceries",
|
||||
"Liquor": "Liquor"
|
||||
},
|
||||
"Home": {
|
||||
"heading": "Home",
|
||||
"Home": "Home",
|
||||
"Electronics": "Electronics",
|
||||
"Furniture": "Furniture",
|
||||
"Household Supplies": "Household Supplies",
|
||||
"Maintenance": "Maintenance",
|
||||
"Mortgage": "Mortgage",
|
||||
"Pets": "Pets",
|
||||
"Rent": "Rent",
|
||||
"Services": "Services"
|
||||
},
|
||||
"Life": {
|
||||
"heading": "Life",
|
||||
"Childcare": "Childcare",
|
||||
"Clothing": "Clothing",
|
||||
"Education": "Education",
|
||||
"Gifts": "Gifts",
|
||||
"Insurance": "Insurance",
|
||||
"Medical Expenses": "Medical Expenses",
|
||||
"Taxes": "Taxes"
|
||||
},
|
||||
"Transportation": {
|
||||
"heading": "Transportation",
|
||||
"Transportation": "Transportation",
|
||||
"Bicycle": "Bicycle",
|
||||
"Bus/Train": "Bus/Train",
|
||||
"Car": "Car",
|
||||
"Gas/Fuel": "Gas/Fuel",
|
||||
"Hotel": "Hotel",
|
||||
"Parking": "Parking",
|
||||
"Plane": "Plane",
|
||||
"Taxi": "Taxi"
|
||||
},
|
||||
"Utilities": {
|
||||
"heading": "Utilities",
|
||||
"Utilities": "Utilities",
|
||||
"Cleaning": "Cleaning",
|
||||
"Electricity": "Electricity",
|
||||
"Heat/Gas": "Heat/Gas",
|
||||
"Trash": "Trash",
|
||||
"TV/Phone/Internet": "TV/Phone/Internet",
|
||||
"Water": "Water"
|
||||
}
|
||||
}
|
||||
}
|
||||
388
messages/es.json
Normal file
388
messages/es.json
Normal file
@@ -0,0 +1,388 @@
|
||||
{
|
||||
"Homepage": {
|
||||
"title": "Comparte <strong>Gastos</strong> con <strong>Amigos y Familia</strong>",
|
||||
"description": "¡Bienvenido a tu nueva instancia de <strong>Spliit</strong>!",
|
||||
"button": {
|
||||
"groups": "Ir a grupos",
|
||||
"github": "GitHub"
|
||||
}
|
||||
},
|
||||
"Header": {
|
||||
"groups": "Grupos"
|
||||
},
|
||||
"Footer": {
|
||||
"madeIn": "Hecho en Montréal, Québec 🇨🇦",
|
||||
"builtBy": "Construido por <author>Sebastien Castiel</author> y <source>colaboradores</source>"
|
||||
},
|
||||
"Expenses": {
|
||||
"title": "Gastos",
|
||||
"description": "Aqui encontraras los gastos que has creado para tu grupo.",
|
||||
"create": "Crear gasto",
|
||||
"createFirst": "Crea el primero",
|
||||
"noExpenses": "Tu grupo aun no tiene gastos.",
|
||||
"exportJson": "Exportar a JSON",
|
||||
"searchPlaceholder": "Busca un gasto…",
|
||||
"ActiveUserModal": {
|
||||
"title": "¿Quién es usted?",
|
||||
"description": "Dinos qué participante eres para que podamos personalizar cómo se muestra la información.",
|
||||
"nobody": "No quiero seleccionar a nadie",
|
||||
"save": "Guardar cambios",
|
||||
"footer": "Esta configuración puede modificarse posteriormente en los ajustes del grupo."
|
||||
},
|
||||
"Groups": {
|
||||
"upcoming": "Próximamente",
|
||||
"thisWeek": "Esta semana",
|
||||
"earlierThisMonth": "A principios de este mes",
|
||||
"lastMonth": "El mes pasado",
|
||||
"earlierThisYear": "A principios de este año",
|
||||
"lastYera": "El año pasado",
|
||||
"older": "Más antiguos"
|
||||
}
|
||||
},
|
||||
"ExpenseCard": {
|
||||
"paidBy": "Pagado por <strong>{paidBy}</strong> para <paidFor></paidFor>",
|
||||
"receivedBy": "Recibido por <strong>{paidBy}</strong> para <paidFor></paidFor>",
|
||||
"yourBalance": "Tu balance:"
|
||||
},
|
||||
"Groups": {
|
||||
"myGroups": "Mis grupos",
|
||||
"create": "Crear",
|
||||
"loadingRecent": "Cargando grupos recientes…",
|
||||
"NoRecent": {
|
||||
"description": "No has visitado ningun grupo recientemente.",
|
||||
"create": "Crea uno",
|
||||
"orAsk": "o pídele a un amigo que te envíe el enlace a uno ya existente.."
|
||||
},
|
||||
"recent": "Grupos recientes",
|
||||
"starred": "Grupos favoritos",
|
||||
"archived": "Grupos archivados",
|
||||
"archive": "Archivar grupo",
|
||||
"unarchive": "Desarchivar groupo",
|
||||
"removeRecent": "Remove from recent groups",
|
||||
"RecentRemovedToast": {
|
||||
"title": "El grupo fue eliminado",
|
||||
"description": "El grupo ha sido eliminado de tu lista de grupos recientes.",
|
||||
"undoAlt": "Deshacer la eliminación del grupo",
|
||||
"undo": "Deshacer"
|
||||
},
|
||||
"AddByURL": {
|
||||
"button": "Añadir mediante url",
|
||||
"title": "Añadir grupo mediante url",
|
||||
"description": "Si te han compartido un grupo, puedes pegar aquí su URL para añadirlo a tu lista.",
|
||||
"error": "Oops, no somos capaces de encontrar el grupo desde la URL que has proporcionado..."
|
||||
},
|
||||
"NotFound": {
|
||||
"text": "Este grupo no existe.",
|
||||
"link": "Ir a los últimos grupos visitados"
|
||||
}
|
||||
},
|
||||
"GroupForm": {
|
||||
"title": "Información del grupo",
|
||||
"NameField": {
|
||||
"label": "Nombre del grupo",
|
||||
"placeholder": "Vacaciones en Barcelona",
|
||||
"description": "Inserta un nombre para tu nuevo grupo."
|
||||
},
|
||||
"InformationField": {
|
||||
"label": "Información del grupo",
|
||||
"placeholder": "Qué información es relevante para los participantes del grupo?"
|
||||
},
|
||||
"CurrencyField": {
|
||||
"label": "Símbolo de divisa",
|
||||
"placeholder": "$, €, £…",
|
||||
"description": "Lo usaremos para mostrar balances."
|
||||
},
|
||||
"Participants": {
|
||||
"title": "Participantes",
|
||||
"description": "Ingresa el nombre de cada participante.",
|
||||
"protectedParticipant": "Estos participantes forman parte de gastos y no pueden ser eliminados.",
|
||||
"new": "Nuevo",
|
||||
"add": "Añadir participante",
|
||||
"John": "Juan",
|
||||
"Jane": "Maria",
|
||||
"Jack": "Sergio"
|
||||
},
|
||||
"Settings": {
|
||||
"title": "Ajustes locales",
|
||||
"description": "Estos ajustes se establecen por dispositivo y se utilizan para personalizar su experiencia.",
|
||||
"ActiveUserField": {
|
||||
"label": "Usuario activo",
|
||||
"placeholder": "Selecciona un participante...",
|
||||
"none": "Ninguno",
|
||||
"description": "Usuario que paga los gastos por defecto."
|
||||
},
|
||||
"save": "Guardar",
|
||||
"saving": "Guardando",
|
||||
"create": "Crear",
|
||||
"creating": "Creando",
|
||||
"cancel": "Cancelar"
|
||||
}
|
||||
},
|
||||
"ExpenseForm": {
|
||||
"Income": {
|
||||
"create": "Crear ingreso",
|
||||
"edit": "Editar ingreso",
|
||||
"TitleField": {
|
||||
"label": "Título del ingreso",
|
||||
"placeholder": "Comida Hamburgeseria",
|
||||
"description": "Introduce una descripción para este ingreso."
|
||||
},
|
||||
"DateField": {
|
||||
"label": "Fecha del ingreso",
|
||||
"description": "Ingresa la fecha en que se recibio el ingreso."
|
||||
},
|
||||
"categoryFieldDescription": "Seleccione la categoría de ingresos.",
|
||||
"paidByField": {
|
||||
"label": "Recibido por",
|
||||
"description": "Seleccione el participante que recibió los ingresos."
|
||||
},
|
||||
"paidFor": {
|
||||
"title": "Recibido para for",
|
||||
"description": "Seleccione para quién se recibió el ingreso."
|
||||
},
|
||||
"splitModeDescription": "Seleccione como quieres dividir el ingreso.",
|
||||
"attachDescription": "Ver y adjuntar tickets para el ingreso."
|
||||
},
|
||||
"Expense": {
|
||||
"create": "Crear gasto",
|
||||
"edit": "Editar gasto",
|
||||
"TitleField": {
|
||||
"label": "Título del gasto",
|
||||
"placeholder": "Monday evening restaurant",
|
||||
"description": "Enter a description for the expense."
|
||||
},
|
||||
"DateField": {
|
||||
"label": "Fecha del gasto",
|
||||
"description": "Ingresa la fecha en que se recibio el gasto."
|
||||
},
|
||||
"categoryFieldDescription": "Select the expense category.",
|
||||
"paidByField": {
|
||||
"label": "Pagado por",
|
||||
"description": "Seleccione el participante que pagó el gasto."
|
||||
},
|
||||
"paidFor": {
|
||||
"title": "Pagado para",
|
||||
"description": "Seleccione para quién se pagó el gasto."
|
||||
},
|
||||
"splitModeDescription": "Seleccione como quieres dividir el gasto.",
|
||||
"attachDescription": "Ver y adjuntar tickets para el gasto."
|
||||
},
|
||||
"amountField": {
|
||||
"label": "Cantidad"
|
||||
},
|
||||
"isReimbursementField": {
|
||||
"label": "Esto es un reembolso"
|
||||
},
|
||||
"categoryField": {
|
||||
"label": "Categoria"
|
||||
},
|
||||
"notesField": {
|
||||
"label": "Notas"
|
||||
},
|
||||
"selectNone": "Seleccionar ninguno",
|
||||
"selectAll": "Seleccionar todos",
|
||||
"shares": "partes",
|
||||
"advancedOptions": "Opciones avanzadas",
|
||||
"SplitModeField": {
|
||||
"label": "Modo de división",
|
||||
"evenly": "Uniformemente",
|
||||
"byShares": "Desigualmente – Por partes",
|
||||
"byPercentage": "Desigualmente – por porcentaje",
|
||||
"byAmount": "Desigualmente – por cantidad",
|
||||
"saveAsDefault": "Guardar como modo preferido"
|
||||
},
|
||||
"DeletePopup": {
|
||||
"label": "Borrar",
|
||||
"title": "Borrar gasto?",
|
||||
"description": "Seguro que quieres borrar este gasto? Esta acción es irreversible.",
|
||||
"yes": "Si",
|
||||
"cancel": "Cancelar"
|
||||
},
|
||||
"attachDocuments": "Adjuntar documentos",
|
||||
"create": "Crear",
|
||||
"creating": "Creando",
|
||||
"save": "Guardar",
|
||||
"saving": "Guardando",
|
||||
"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}."
|
||||
},
|
||||
"ErrorToast": {
|
||||
"title": "Error al cargar el documento",
|
||||
"description": "Ha ocurrido un error al cargar el documento. Vuelva a intentarlo más tarde o seleccione otro archivo.",
|
||||
"retry": "Reintentar"
|
||||
}
|
||||
},
|
||||
"CreateFromReceipt": {
|
||||
"Dialog": {
|
||||
"triggerTitle": "Crear gasto desde ticket",
|
||||
"title": "Crear desde ticket",
|
||||
"description": "Extraer la información de gastos de una foto de recibo.",
|
||||
"body": "Sube la foto de un recibo y lo escanearemos para extraer la información del gasto si podemos.",
|
||||
"selectImage": "Seleccionar imagen…",
|
||||
"titleLabel": "Titulo:",
|
||||
"categoryLabel": "Categoria:",
|
||||
"amountLabel": "Cantidad:",
|
||||
"dateLabel": "Fecha:",
|
||||
"editNext": "A continuación podrá editar la información de los gastos.",
|
||||
"continue": "Continuar"
|
||||
},
|
||||
"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}."
|
||||
},
|
||||
"ErrorToast": {
|
||||
"title": "Error al cargar el documento",
|
||||
"description": "Ha ocurrido un error al cargar el documento. Vuelva a intentarlo más tarde o seleccione otro archivo.",
|
||||
"retry": "Reintentar"
|
||||
}
|
||||
},
|
||||
"Balances": {
|
||||
"title": "Balances",
|
||||
"description": "Se trata del importe que ha pagado o ha recibido cada participante.",
|
||||
"Reimbursements": {
|
||||
"title": "Reembolsos propuestos",
|
||||
"description": "He aquí algunas sugerencias para optimizar los reembolsos entre los participantes.",
|
||||
"noImbursements": "Parece que tu grupo no necesita ningún reembolso 😁",
|
||||
"owes": "<strong>{from}</strong> debe <strong>{to}</strong>",
|
||||
"markAsPaid": "Marcar como pagado"
|
||||
}
|
||||
},
|
||||
"Stats": {
|
||||
"title": "Estadísticas",
|
||||
"Totals": {
|
||||
"title": "Totales",
|
||||
"description": "Resumen de gastos de todo el grupo.",
|
||||
"groupSpendings": "Gastos de todo el grupo",
|
||||
"groupEarnings": "Ingresos de todo el grupo",
|
||||
"yourSpendings": "Tus gastos totales",
|
||||
"yourEarnings": "Tus ingresos totales",
|
||||
"yourShare": "Tu parte final"
|
||||
}
|
||||
},
|
||||
"Activity": {
|
||||
"title": "Actividad",
|
||||
"description": "Aquí encontrarás todas las actividades recientes en tu grupo.",
|
||||
"noActivity": "No hay actividad reciente en este grupo.",
|
||||
"someone": "Alguien",
|
||||
"settingsModified": "Los ajustes del grupo fueron modificados por <strong>{participant}</strong>.",
|
||||
"expenseCreated": "Gasto <em>{expense}</em> creado por <strong>{participant}</strong>.",
|
||||
"expenseUpdated": "Gasto <em>{expense}</em> actualizado por <strong>{participant}</strong>.",
|
||||
"expenseDeleted": "Gasto <em>{expense}</em> borrado por <strong>{participant}</strong>.",
|
||||
"Groups": {
|
||||
"today": "Hoy",
|
||||
"yesterday": "Ayer",
|
||||
"earlierThisWeek": "A principios de esta semana",
|
||||
"lastWeek": "La semana pasada",
|
||||
"earlierThisMonth": "A principios de este mes",
|
||||
"lastMonth": "El mes pasado",
|
||||
"earlierThisYear": "A principios de este año",
|
||||
"lastYear": "El ultimo año",
|
||||
"older": "Más antiguos"
|
||||
}
|
||||
},
|
||||
"Information": {
|
||||
"title": "Información",
|
||||
"description": "Utilice este lugar para añadir cualquier información que pueda ser relevante para los participantes del grupo.",
|
||||
"empty": "Aún no hay información sobre el grupo."
|
||||
},
|
||||
"Settings": {
|
||||
"title": "Ajustes"
|
||||
},
|
||||
"Share": {
|
||||
"title": "Compartir",
|
||||
"description": "Para que otros participantes puedan ver el grupo y añadir gastos, compárteles su URL.",
|
||||
"warning": "Cuidado!",
|
||||
"warningHelp": "Todas las personas que tengan la URL del grupo podrán ver y editar los gastos. ¡Comparte con precaución!"
|
||||
},
|
||||
"SchemaErrors": {
|
||||
"min1": "Introduzca al menos un carácter.",
|
||||
"min2": "Introduzca al menos dos carácter.",
|
||||
"max5": "Introduzca al menos cinco carácter.",
|
||||
"max50": "Introduzca al menos treinta carácter.",
|
||||
"duplicateParticipantName": "Otro participante ya tiene este nombre",
|
||||
"titleRequired": "Por favor, introduzca un título",
|
||||
"invalidNumber": "Número inválido",
|
||||
"amountRequired": "Debe introducir un importe",
|
||||
"amountNotZero": "El importe no debe ser cero.",
|
||||
"amountTenMillion": "El importe debe ser inferior a 10.000.000.",
|
||||
"paidByRequired": "Debe seleccionar un participante",
|
||||
"paidForMin1": "El gasto debe ser pagado por al menos un participante",
|
||||
"noZeroShares": "Todas las participaciones deben ser superiores a 0",
|
||||
"amountSum": "La suma de los importes debe ser igual al importe del gasto",
|
||||
"percentageSum": "Suma de porcentajes debe ser igual a 100"
|
||||
},
|
||||
"Categories": {
|
||||
"search": "Buscar categoría...",
|
||||
"noCategory": "Categoría no encontrada!",
|
||||
"Uncategorized": {
|
||||
"heading": "Sin categoría",
|
||||
"General": "General",
|
||||
"Payment": "Pago"
|
||||
},
|
||||
"Entertainment": {
|
||||
"heading": "Ocio",
|
||||
"Entertainment": "Ocio",
|
||||
"Games": "Juegos",
|
||||
"Movies": "Películas",
|
||||
"Music": "Musica",
|
||||
"Sports": "Deportes"
|
||||
},
|
||||
"Food and Drink": {
|
||||
"heading": "Comida y bebida",
|
||||
"Food and Drink": "Comida y bebida",
|
||||
"Dining Out": "Comer fuera",
|
||||
"Groceries": "Comestibles",
|
||||
"Liquor": "Licores"
|
||||
},
|
||||
"Home": {
|
||||
"heading": "Hogar",
|
||||
"Home": "Hogar",
|
||||
"Electronics": "Electrónica",
|
||||
"Furniture": "Muebles",
|
||||
"Household Supplies": "Suministros del hogar",
|
||||
"Maintenance": "Mantenimiento",
|
||||
"Mortgage": "Hipoteca",
|
||||
"Pets": "Mascotas",
|
||||
"Rent": "Alquiler",
|
||||
"Services": "Servicios"
|
||||
},
|
||||
"Life": {
|
||||
"heading": "Vida",
|
||||
"Childcare": "Cuidado de niños",
|
||||
"Clothing": "Ropa",
|
||||
"Education": "Educación",
|
||||
"Gifts": "Regalos",
|
||||
"Insurance": "Seguro",
|
||||
"Medical Expenses": "Gastos médicos",
|
||||
"Taxes": "Impuestos"
|
||||
},
|
||||
"Transportation": {
|
||||
"heading": "Transporte",
|
||||
"Transportation": "Transporte",
|
||||
"Bicycle": "Bicicleta",
|
||||
"Bus/Train": "Autobús/Tren",
|
||||
"Car": "Coche",
|
||||
"Gas/Fuel": "Gasolina/Combustible",
|
||||
"Hotel": "Hotel",
|
||||
"Parking": "Parking",
|
||||
"Plane": "Avión",
|
||||
"Taxi": "Taxi"
|
||||
},
|
||||
"Utilities": {
|
||||
"heading": "Utilidades",
|
||||
"Utilities": "Utilidades",
|
||||
"Cleaning": "Limpieza",
|
||||
"Electricity": "Electricidad",
|
||||
"Heat/Gas": "Calefacción/Gas",
|
||||
"Trash": "Basura",
|
||||
"TV/Phone/Internet": "TV/Teléfono/Internet",
|
||||
"Water": "Agua"
|
||||
}
|
||||
}
|
||||
}
|
||||
388
messages/fi.json
Normal file
388
messages/fi.json
Normal file
@@ -0,0 +1,388 @@
|
||||
{
|
||||
"Homepage": {
|
||||
"title": "Jaa kulut ystävien ja perheen kanssa",
|
||||
"description": "Tervetuloa uuteen Spliit-instanssiisi!",
|
||||
"button": {
|
||||
"groups": "Siirry ryhmiin",
|
||||
"github": "GitHub"
|
||||
}
|
||||
},
|
||||
"Header": {
|
||||
"groups": "Ryhmät"
|
||||
},
|
||||
"Footer": {
|
||||
"madeIn": "Made in Montréal, Québec 🇨🇦",
|
||||
"builtBy": "Tekijät: <author>Sebastien Castiel</author> ja <source>muut osallistujat</source>"
|
||||
},
|
||||
"Expenses": {
|
||||
"title": "Kulut",
|
||||
"description": "Tässä ovat ryhmässä luodut kulut.",
|
||||
"create": "Lisää kulu",
|
||||
"createFirst": "Lisää ensimmäinen kulu",
|
||||
"noExpenses": "Ryhmälläsi ei ole vielä yhtään kulua.",
|
||||
"exportJson": "Vie JSON-tiedostoon",
|
||||
"searchPlaceholder": "Etsi kulua…",
|
||||
"ActiveUserModal": {
|
||||
"title": "Kuka olet?",
|
||||
"description": "Valitse kuka osallistujista olet, jotta tiedot näkyvät oikein.",
|
||||
"nobody": "En halua valita ketään",
|
||||
"save": "Tallenna muutokset",
|
||||
"footer": "Tämän asetuksen voi vaihtaa myöhemmin ryhmän asetuksista."
|
||||
},
|
||||
"Groups": {
|
||||
"upcoming": "Tulevat",
|
||||
"thisWeek": "Tällä viikolla",
|
||||
"earlierThisMonth": "Aikaisemmin tässä kuussa",
|
||||
"lastMonth": "Viime kuussa",
|
||||
"earlierThisYear": "Aikaisemmin tänä vuonna",
|
||||
"lastYear": "Viime vuonna",
|
||||
"older": "Vanhemmat"
|
||||
}
|
||||
},
|
||||
"ExpenseCard": {
|
||||
"paidBy": "<strong>{paidBy}</strong> maksoi {forCount, plural, =1 {henkilön} other {henkilöiden}} <paidFor></paidFor> puolesta",
|
||||
"receivedBy": "<strong>{paidBy}</strong> sai rahaa {forCount, plural, =1 {henkilön} other {henkilöiden}} <paidFor></paidFor> puolesta",
|
||||
"yourBalance": "Saldosi:"
|
||||
},
|
||||
"Groups": {
|
||||
"myGroups": "Omat ryhmät",
|
||||
"create": "Luo ryhmä",
|
||||
"loadingRecent": "Ladataan äskettäisiä ryhmiä…",
|
||||
"NoRecent": {
|
||||
"description": "Et ole ollut missään ryhmässä äskettäin.",
|
||||
"create": "Luo uusi ryhmä",
|
||||
"orAsk": "tai pyydä ystävää lähettämään linkki olemassaolevaan ryhmään."
|
||||
},
|
||||
"recent": "Äskettäiset",
|
||||
"starred": "Suosikit",
|
||||
"archived": "Arkistoidut",
|
||||
"archive": "Arkistoi ryhmä",
|
||||
"unarchive": "Palauta ryhmä arkistosta",
|
||||
"removeRecent": "Poista äskettäisistä",
|
||||
"RecentRemovedToast": {
|
||||
"title": "Ryhmä poistettu",
|
||||
"description": "Ryhmä poistettu äskettäisten listaltasi.",
|
||||
"undoAlt": "Peruuta ryhmän poisto",
|
||||
"undo": "Peruuta"
|
||||
},
|
||||
"AddByURL": {
|
||||
"button": "Lisää URLilla",
|
||||
"title": "Lisää ryhmä URL-osoitteella",
|
||||
"description": "Jos ryhmä on jaettu sinulle, voit lisätä sen listaasi liittämällä URL-osoitteen tähän.",
|
||||
"error": "Hups, emme löytäneet ryhmää antamastasi URL-osoitteesta…"
|
||||
},
|
||||
"NotFound": {
|
||||
"text": "Tätä ryhmää ei löydy.",
|
||||
"link": "Siirry äskettäisiin ryhmiin"
|
||||
}
|
||||
},
|
||||
"GroupForm": {
|
||||
"title": "Ryhmän tiedot",
|
||||
"NameField": {
|
||||
"label": "Ryhmän nimi",
|
||||
"placeholder": "Kesälomareissu",
|
||||
"description": "Syötä ryhmäsi nimi."
|
||||
},
|
||||
"InformationField": {
|
||||
"label": "Ryhmän tiedot",
|
||||
"placeholder": "Mitkä tiedot ovat merkityksellisiä ryhmän osallistujille?"
|
||||
},
|
||||
"CurrencyField": {
|
||||
"label": "Valuuttamerkki",
|
||||
"placeholder": "$, €, £…",
|
||||
"description": "Näytetään rahasummien yhteydessä."
|
||||
},
|
||||
"Participants": {
|
||||
"title": "Osallistujat",
|
||||
"description": "Syötä jokaisen osallistujan nimi.",
|
||||
"protectedParticipant": "Tätä osallistujaa ei voida poistaa, koska hän osallistuu kuluihin.",
|
||||
"add": "Lisää osallistuja",
|
||||
"new": "Uusi",
|
||||
"John": "Antti",
|
||||
"Jane": "Laura",
|
||||
"Jack": "Jussi"
|
||||
},
|
||||
"Settings": {
|
||||
"title": "Paikalliset asetukset",
|
||||
"description": "Nämä asetukset ovat laitekohtaisia. Voit muokata niillä käytettävyyttä.",
|
||||
"ActiveUserField": {
|
||||
"label": "Aktiivinen käyttäjä",
|
||||
"placeholder": "Valitse osallistuja",
|
||||
"none": "Ei kukaan",
|
||||
"description": "Käytetään kulujen oletusmaksajana."
|
||||
},
|
||||
"save": "Tallenna",
|
||||
"saving": "Tallennetaan…",
|
||||
"create": "Luo ryhmä",
|
||||
"creating": "Luodaan…",
|
||||
"cancel": "Peruuta"
|
||||
}
|
||||
},
|
||||
"ExpenseForm": {
|
||||
"Income": {
|
||||
"create": "Lisää tulo",
|
||||
"edit": "Muokkaa tuloa",
|
||||
"TitleField": {
|
||||
"label": "Otsikko",
|
||||
"placeholder": "Maanantain ravintola",
|
||||
"description": "Anna lyhyt kuvaus tulolle."
|
||||
},
|
||||
"DateField": {
|
||||
"label": "Päivä",
|
||||
"description": "Valitse päivä jolloin tulo saatiin."
|
||||
},
|
||||
"categoryFieldDescription": "Valitse tulokategoria.",
|
||||
"paidByField": {
|
||||
"label": "Vastaanottaja",
|
||||
"description": "Valitse kuka vastaanotti tulon."
|
||||
},
|
||||
"paidFor": {
|
||||
"title": "Tulon jakaminen",
|
||||
"description": "Valitse kenelle tulo jaetaan."
|
||||
},
|
||||
"splitModeDescription": "Valitse miten tulo jaetaan osallistujien kesken.",
|
||||
"attachDescription": "Katso ja liitä tuloon liittyviä kuitteja."
|
||||
},
|
||||
"Expense": {
|
||||
"create": "Lisää kulu",
|
||||
"edit": "Muokkaa kulua",
|
||||
"TitleField": {
|
||||
"label": "Otsikko",
|
||||
"placeholder": "Maanantain ravintola",
|
||||
"description": "Anna lyhyt kuvaus kululle."
|
||||
},
|
||||
"DateField": {
|
||||
"label": "Päivä",
|
||||
"description": "Valitse päivä jolloin kulu maksettiin."
|
||||
},
|
||||
"categoryFieldDescription": "Valitse kulukategoria.",
|
||||
"paidByField": {
|
||||
"label": "Maksaja",
|
||||
"description": "Valitse kuka maksoi kulun."
|
||||
},
|
||||
"paidFor": {
|
||||
"title": "Kulun jakaminen",
|
||||
"description": "Valitse ketkä osallistuvat kuluun."
|
||||
},
|
||||
"splitModeDescription": "Valitse miten kulu jaetaan osallistujien kesken.",
|
||||
"attachDescription": "Katso ja liitä kuluun liittyviä kuitteja."
|
||||
},
|
||||
"amountField": {
|
||||
"label": "Summa"
|
||||
},
|
||||
"isReimbursementField": {
|
||||
"label": "Tämä on velanmaksu"
|
||||
},
|
||||
"categoryField": {
|
||||
"label": "Kategoria"
|
||||
},
|
||||
"notesField": {
|
||||
"label": "Muistiinpanot"
|
||||
},
|
||||
"selectNone": "Tyhjennä valinnat",
|
||||
"selectAll": "Valitse kaikki",
|
||||
"shares": "osuutta",
|
||||
"advancedOptions": "Lisäasetuksia jakamiseen…",
|
||||
"SplitModeField": {
|
||||
"label": "Jakamistapa",
|
||||
"evenly": "Tasan",
|
||||
"byShares": "Epätasan – osuuksien mukaan",
|
||||
"byPercentage": "Epätasan – prosenttien mukaan",
|
||||
"byAmount": "Epätasan – summan mukaan",
|
||||
"saveAsDefault": "Tallenna oletustavaksi"
|
||||
},
|
||||
"DeletePopup": {
|
||||
"label": "Poista",
|
||||
"title": "Poistetaanko tämä kulu?",
|
||||
"description": "Haluatko varmasti poistaa tämän kulun? Poistoa ei voi peruuttaa.",
|
||||
"yes": "Kyllä",
|
||||
"cancel": "Peruuta"
|
||||
},
|
||||
"attachDocuments": "Liitä dokumenttejä",
|
||||
"create": "Lisää kulu",
|
||||
"creating": "Luodaan kulua…",
|
||||
"save": "Tallenna",
|
||||
"saving": "Tallennetaan…",
|
||||
"cancel": "Peruuta",
|
||||
"reimbursement": "Velanmaksu"
|
||||
},
|
||||
"ExpenseDocumentsInput": {
|
||||
"TooBigToast": {
|
||||
"title": "Tiedosto on liian suuri",
|
||||
"description": "Maksimikoko ladattavalle tiedostolle on {maxSize}. Tiedostosi on ${size}."
|
||||
},
|
||||
"ErrorToast": {
|
||||
"title": "Virhe tiedostoa ladattaessa",
|
||||
"description": "Jokin meni vikaan dokumentin lataamisessa. Yritä myöhemmin uudelleen tai valitse toinen tiedosto.",
|
||||
"retry": "Yritä uudelleen"
|
||||
}
|
||||
},
|
||||
"CreateFromReceipt": {
|
||||
"Dialog": {
|
||||
"triggerTitle": "Luo kulu kuitista",
|
||||
"title": "Luo kuitista",
|
||||
"description": "Lue kuitin valokuvasta kulun tiedot.",
|
||||
"body": "Lataa kuitista valokuva. Siitä skannataan tiedot kulua varten.",
|
||||
"selectImage": "Valitse kuva…",
|
||||
"titleLabel": "Otsikko:",
|
||||
"categoryLabel": "Kategoria:",
|
||||
"amountLabel": "Summa:",
|
||||
"dateLabel": "Päivä:",
|
||||
"editNext": "Voit muokata kulun tietoja seuraavaksi.",
|
||||
"continue": "Jatka"
|
||||
},
|
||||
"unknown": "Unknown",
|
||||
"TooBigToast": {
|
||||
"title": "The file is too big",
|
||||
"description": "The maximum file size you can upload is {maxSize}. Yours is ${size}."
|
||||
},
|
||||
"ErrorToast": {
|
||||
"title": "Error while uploading document",
|
||||
"description": "Something wrong happened when uploading the document. Please retry later or select a different file.",
|
||||
"retry": "Retry"
|
||||
}
|
||||
},
|
||||
"Balances": {
|
||||
"title": "Saldo",
|
||||
"description": "Osallistujien saatavat tai velat.",
|
||||
"Reimbursements": {
|
||||
"title": "Maksuehdotus",
|
||||
"description": "Optimoitu ehdotus kuka maksaa kenellekin.",
|
||||
"noImbursements": "Näyttää siltä, että kaikki ovat sujut 😁",
|
||||
"owes": "<strong>{from}</strong> maksaa henkilölle <strong>{to}</strong>",
|
||||
"markAsPaid": "Merkitse maksetuksi"
|
||||
}
|
||||
},
|
||||
"Stats": {
|
||||
"title": "Tilastot",
|
||||
"Totals": {
|
||||
"title": "Yhteenveto",
|
||||
"description": "Koko ryhmän kulut.",
|
||||
"groupSpendings": "Koko ryhmän kulutus",
|
||||
"groupEarnings": "Koko ryhmän saatavat",
|
||||
"yourSpendings": "Kulutuksesi",
|
||||
"yourEarnings": "Saatavasi",
|
||||
"yourShare": "Osuutesi"
|
||||
}
|
||||
},
|
||||
"Activity": {
|
||||
"title": "Tapahtumat",
|
||||
"description": "Yleisnäkymä ryhmän kaikista tapahtumista.",
|
||||
"noActivity": "Ryhmässäsi ei ole vielä tapahtumia.",
|
||||
"someone": "Tuntematon",
|
||||
"settingsModified": "<strong>{participant}</strong> muokkasi ryhmän asetuksia.",
|
||||
"expenseCreated": "<strong>{participant}</strong> lisäsi kulun <em>{expense}</em>.",
|
||||
"expenseUpdated": "<strong>{participant}</strong> muokkasi kulua <em>{expense}</em>.",
|
||||
"expenseDeleted": "<strong>{participant}</strong> poisti kulun <em>{expense}</em>.",
|
||||
"Groups": {
|
||||
"today": "Tänään",
|
||||
"yesterday": "Eilen",
|
||||
"earlierThisWeek": "Tällä viikolla",
|
||||
"lastWeek": "Viime viikolla",
|
||||
"earlierThisMonth": "Tässä kuussa",
|
||||
"lastMonth": "Viime kuussa",
|
||||
"earlierThisYear": "Tänä vuonna",
|
||||
"lastYear": "Viime vuonna",
|
||||
"older": "Vanhemmat"
|
||||
}
|
||||
},
|
||||
"Information": {
|
||||
"title": "Tiedot",
|
||||
"description": "Käytä tätä paikkaa lisätäksesi kaikki tiedot, joilla voi olla merkitystä ryhmän osallistujille.",
|
||||
"empty": "Ryhmätietoja ei vielä ole."
|
||||
},
|
||||
"Settings": {
|
||||
"title": "Asetukset"
|
||||
},
|
||||
"Share": {
|
||||
"title": "Jaa",
|
||||
"description": "Jaa ryhmän URL muille jäsenille, jotta he voivat nähdä sen ja lisätä kuluja.",
|
||||
"warning": "Varoitus!",
|
||||
"warningHelp": "Tällä URLilla kuka tahansa pääsee näkemään ja muokkaamaan kuluja. Jaa harkiten!"
|
||||
},
|
||||
"SchemaErrors": {
|
||||
"min1": "Syötä vähintään yksi merkki.",
|
||||
"min2": "Syötä vähintään kaksi merkkiä.",
|
||||
"max5": "Syötä enintään viisi merkkiä.",
|
||||
"max50": "Syötä enintään 50 merkkiä.",
|
||||
"duplicateParticipantName": "Tämä nimi on jo toisella osallistujalla.",
|
||||
"titleRequired": "Otsikko puuttuu.",
|
||||
"invalidNumber": "Epäkelpo numero.",
|
||||
"amountRequired": "Summa puuttuu.",
|
||||
"amountNotZero": "Summa ei voi olla nolla.",
|
||||
"amountTenMillion": "Summan pitää olla pienempi kuin 10 000 000.",
|
||||
"paidByRequired": "Osallistuja puuttuu.",
|
||||
"paidForMin1": "Valitse vähintään yksi osallistuja.",
|
||||
"noZeroShares": "Jokaisen osuuden täytyy olla suurempi kuin 0.",
|
||||
"amountSum": "Osuuksien summan täytyy vastata kulun summaa.",
|
||||
"percentageSum": "Prosenttiosuuksien summan täytyy olla 100."
|
||||
},
|
||||
"Categories": {
|
||||
"search": "Etsi kategoriaa...",
|
||||
"noCategory": "Kategoriaa ei löydy.",
|
||||
"Uncategorized": {
|
||||
"heading": "Yleiset",
|
||||
"General": "Yleinen",
|
||||
"Payment": "Maksu"
|
||||
},
|
||||
"Entertainment": {
|
||||
"heading": "Viihde",
|
||||
"Entertainment": "Viihde",
|
||||
"Games": "Pelit",
|
||||
"Movies": "Elokuvat",
|
||||
"Music": "Musiikki",
|
||||
"Sports": "Urheilu"
|
||||
},
|
||||
"Food and Drink": {
|
||||
"heading": "Ruoka ja juoma",
|
||||
"Food and Drink": "Ruoka ja juoma",
|
||||
"Dining Out": "Ulkona syöminen",
|
||||
"Groceries": "Marketti",
|
||||
"Liquor": "Alkoholi"
|
||||
},
|
||||
"Home": {
|
||||
"heading": "Koti",
|
||||
"Home": "Koti",
|
||||
"Electronics": "Elektroniikka",
|
||||
"Furniture": "Huonekalut",
|
||||
"Household Supplies": "Taloustavarat",
|
||||
"Maintenance": "Huolto",
|
||||
"Mortgage": "Laina",
|
||||
"Pets": "Lemmikit",
|
||||
"Rent": "Vuokra",
|
||||
"Services": "Palvelut"
|
||||
},
|
||||
"Life": {
|
||||
"heading": "Elämä",
|
||||
"Childcare": "Lastenhoito",
|
||||
"Clothing": "Vaatteet",
|
||||
"Education": "Opiskelu",
|
||||
"Gifts": "Lahjat",
|
||||
"Insurance": "Vakuutukset",
|
||||
"Medical Expenses": "Terveydenhoito",
|
||||
"Taxes": "Verot"
|
||||
},
|
||||
"Transportation": {
|
||||
"heading": "Liikenne",
|
||||
"Transportation": "Liikenne",
|
||||
"Bicycle": "Polkupyörä",
|
||||
"Bus/Train": "Bussi/juna",
|
||||
"Car": "Auto",
|
||||
"Gas/Fuel": "Polttoaine",
|
||||
"Hotel": "Hotelli",
|
||||
"Parking": "Pysäköinti",
|
||||
"Plane": "Lentäminen",
|
||||
"Taxi": "Taksi"
|
||||
},
|
||||
"Utilities": {
|
||||
"heading": "Sekalaiset",
|
||||
"Utilities": "Sekalaiset",
|
||||
"Cleaning": "Siivous",
|
||||
"Electricity": "Sähkö",
|
||||
"Heat/Gas": "Lämmitys",
|
||||
"Trash": "Jätehuolto",
|
||||
"TV/Phone/Internet": "TV/Puhelin/Internet",
|
||||
"Water": "Vesi"
|
||||
}
|
||||
}
|
||||
}
|
||||
388
messages/fr-FR.json
Normal file
388
messages/fr-FR.json
Normal file
@@ -0,0 +1,388 @@
|
||||
{
|
||||
"Homepage": {
|
||||
"title": "Partagez <strong>vos dépenses</strong> avec <strong>vos amis</strong> & <strong>votre famille :)</strong>",
|
||||
"description": "Bienvenue sur votre instance <strong>Spliit</strong> !",
|
||||
"button": {
|
||||
"groups": "Accéder aux groupes",
|
||||
"github": "GitHub"
|
||||
}
|
||||
},
|
||||
"Header": {
|
||||
"groups": "Groupes"
|
||||
},
|
||||
"Footer": {
|
||||
"madeIn": "Made in Montréal, Québec 🇨🇦",
|
||||
"builtBy": "Développé par <author>Sebastien Castiel</author> et <source>contributeurs</source>"
|
||||
},
|
||||
"Expenses": {
|
||||
"title": "Dépenses",
|
||||
"description": "Voici les dépenses que vous avez créées pour votre groupe.",
|
||||
"create": "Créer une dépense",
|
||||
"createFirst": "Créer la première :)",
|
||||
"noExpenses": "Votre groupe n'a effectué aucune dépense pour le moment.",
|
||||
"exportJson": "Exporter en JSON",
|
||||
"searchPlaceholder": "Rechercher une dépense…",
|
||||
"ActiveUserModal": {
|
||||
"title": "Qui êtes-vous ?",
|
||||
"description": "Dites-nous quel participant vous êtes pour personnaliser l'affichage des informations.",
|
||||
"nobody": "Je ne veux sélectionner personne",
|
||||
"save": "Sauvegarder les modifications",
|
||||
"footer": "Ce paramètre peut être modifié plus tard dans les paramètres du groupe."
|
||||
},
|
||||
"Groups": {
|
||||
"upcoming": "À venir",
|
||||
"thisWeek": "Cette semaine",
|
||||
"earlierThisMonth": "Plus tôt ce mois-ci",
|
||||
"lastMonth": "Le mois dernier",
|
||||
"earlierThisYear": "Plus tôt cette année",
|
||||
"lastYera": "L'année dernière",
|
||||
"older": "Plus ancien"
|
||||
}
|
||||
},
|
||||
"ExpenseCard": {
|
||||
"paidBy": "Payé par <strong>{paidBy}</strong> pour <paidFor></paidFor>",
|
||||
"receivedBy": "Reçu par <strong>{paidBy}</strong> pour <paidFor></paidFor>",
|
||||
"yourBalance": "Votre solde :"
|
||||
},
|
||||
"Groups": {
|
||||
"myGroups": "Mes groupes",
|
||||
"create": "Créer",
|
||||
"loadingRecent": "Chargement des groupes récents…",
|
||||
"NoRecent": {
|
||||
"description": "Vous n'avez visité aucun groupe récemment.",
|
||||
"create": "Créer un groupe",
|
||||
"orAsk": "ou demandez à un ami de vous envoyer le lien d'un groupe existant."
|
||||
},
|
||||
"recent": "Groupes récents",
|
||||
"starred": "Groupes favoris",
|
||||
"archived": "Groupes archivés",
|
||||
"archive": "Archiver le groupe",
|
||||
"unarchive": "Désarchiver le groupe",
|
||||
"removeRecent": "Supprimer des groupes récents",
|
||||
"RecentRemovedToast": {
|
||||
"title": "Le groupe a été supprimé",
|
||||
"description": "Le groupe a été supprimé de votre liste de groupes récents.",
|
||||
"undoAlt": "Annuler la suppression du groupe",
|
||||
"undo": "Annuler"
|
||||
},
|
||||
"AddByURL": {
|
||||
"button": "Ajouter par URL",
|
||||
"title": "Ajouter un groupe par URL",
|
||||
"description": "Si un groupe a été partagé avec vous, vous pouvez coller son URL ici pour l'ajouter à votre liste.",
|
||||
"error": "Oups, nous ne pouvons pas trouver le groupe à partir de l'URL que vous avez fournie…"
|
||||
},
|
||||
"NotFound": {
|
||||
"text": "Ce groupe n'existe pas.",
|
||||
"link": "Aller aux groupes récemment visités"
|
||||
}
|
||||
},
|
||||
"GroupForm": {
|
||||
"title": "Informations sur le groupe",
|
||||
"NameField": {
|
||||
"label": "Nom du groupe",
|
||||
"placeholder": "Vacances d'été",
|
||||
"description": "Entrez un nom pour votre groupe."
|
||||
},
|
||||
"InformationField": {
|
||||
"label": "Informations sur le groupe",
|
||||
"placeholder": "Quelles informations sont pertinentes pour les participants du groupe ?"
|
||||
},
|
||||
"CurrencyField": {
|
||||
"label": "Symbole monétaire",
|
||||
"placeholder": "$, €, £…",
|
||||
"description": "Nous l'utiliserons pour afficher les montants."
|
||||
},
|
||||
"Participants": {
|
||||
"title": "Participants",
|
||||
"description": "Entrez le nom de chaque participant.",
|
||||
"protectedParticipant": "Ce participant fait partie des dépenses et ne peut pas être supprimé.",
|
||||
"new": "Nouveau",
|
||||
"add": "Ajouter un participant",
|
||||
"John": "John",
|
||||
"Jane": "Jane",
|
||||
"Jack": "Jack"
|
||||
},
|
||||
"Settings": {
|
||||
"title": "Paramètres locaux",
|
||||
"description": "Ces paramètres sont définis par appareil et sont utilisés pour personnaliser votre expérience.",
|
||||
"ActiveUserField": {
|
||||
"label": "Utilisateur actif",
|
||||
"placeholder": "Sélectionner un participant",
|
||||
"none": "Aucun",
|
||||
"description": "Utilisateur utilisé comme défaut pour payer les dépenses."
|
||||
},
|
||||
"save": "Sauvegarder",
|
||||
"saving": "Sauvegarde…",
|
||||
"create": "Créer",
|
||||
"creating": "Création…",
|
||||
"cancel": "Annuler"
|
||||
}
|
||||
},
|
||||
"ExpenseForm": {
|
||||
"Income": {
|
||||
"create": "Créer un revenu",
|
||||
"edit": "Modifier le revenu",
|
||||
"TitleField": {
|
||||
"label": "Titre du revenu",
|
||||
"placeholder": "Restaurant du lundi soir",
|
||||
"description": "Entrez une description pour le revenu."
|
||||
},
|
||||
"DateField": {
|
||||
"label": "Date du revenu",
|
||||
"description": "Entrez la date à laquelle le revenu a été reçu."
|
||||
},
|
||||
"categoryFieldDescription": "Sélectionnez la catégorie de revenu.",
|
||||
"paidByField": {
|
||||
"label": "Reçu par",
|
||||
"description": "Sélectionnez le participant qui a reçu le revenu."
|
||||
},
|
||||
"paidFor": {
|
||||
"title": "Reçu pour",
|
||||
"description": "Sélectionnez pour qui le revenu a été reçu."
|
||||
},
|
||||
"splitModeDescription": "Sélectionnez comment diviser le revenu.",
|
||||
"attachDescription": "Voir et joindre des reçus au revenu."
|
||||
},
|
||||
"Expense": {
|
||||
"create": "Créer une dépense",
|
||||
"edit": "Modifier la dépense",
|
||||
"TitleField": {
|
||||
"label": "Titre de la dépense",
|
||||
"placeholder": "Restaurant du lundi soir",
|
||||
"description": "Entrez une description pour la dépense."
|
||||
},
|
||||
"DateField": {
|
||||
"label": "Date de la dépense",
|
||||
"description": "Entrez la date à laquelle la dépense a été payée."
|
||||
},
|
||||
"categoryFieldDescription": "Sélectionnez la catégorie de dépense.",
|
||||
"paidByField": {
|
||||
"label": "Payé par",
|
||||
"description": "Sélectionnez le participant qui a réglé la dépense."
|
||||
},
|
||||
"paidFor": {
|
||||
"title": "Payé pour",
|
||||
"description": "Sélectionnez les participants concernés"
|
||||
},
|
||||
"splitModeDescription": "Sélectionnez comment diviser la dépense.",
|
||||
"attachDescription": "Voir et joindre des reçus à la dépense."
|
||||
},
|
||||
"amountField": {
|
||||
"label": "Montant"
|
||||
},
|
||||
"isReimbursementField": {
|
||||
"label": "C'est un remboursement"
|
||||
},
|
||||
"categoryField": {
|
||||
"label": "Catégorie"
|
||||
},
|
||||
"notesField": {
|
||||
"label": "Notes"
|
||||
},
|
||||
"selectNone": "Tout désélectionner",
|
||||
"selectAll": "Tout sélectionner",
|
||||
"shares": "part(s)",
|
||||
"advancedOptions": "Options de répartition avancées…",
|
||||
"SplitModeField": {
|
||||
"label": "Mode de répartition",
|
||||
"evenly": "Également",
|
||||
"byShares": "Inégalement – Par parts",
|
||||
"byPercentage": "Inégalement – Par pourcentage",
|
||||
"byAmount": "Inégalement – Par montant",
|
||||
"saveAsDefault": "Enregistrer comme options de répartition par défaut"
|
||||
},
|
||||
"DeletePopup": {
|
||||
"label": "Supprimer",
|
||||
"title": "Supprimer cette dépense ?",
|
||||
"description": "Voulez-vous vraiment supprimer cette dépense ? Cette action est irréversible.",
|
||||
"yes": "Oui",
|
||||
"cancel": "Annuler"
|
||||
},
|
||||
"attachDocuments": "Joindre des documents",
|
||||
"create": "Créer",
|
||||
"creating": "Création…",
|
||||
"save": "Sauvegarder",
|
||||
"saving": "Sauvegarde…",
|
||||
"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}."
|
||||
},
|
||||
"ErrorToast": {
|
||||
"title": "Erreur lors du téléchargement du document",
|
||||
"description": "Un problème est survenu lors du téléchargement du document. Veuillez réessayer plus tard ou sélectionner un fichier différent.",
|
||||
"retry": "Réessayer"
|
||||
}
|
||||
},
|
||||
"CreateFromReceipt": {
|
||||
"Dialog": {
|
||||
"triggerTitle": "Créer une dépense à partir du reçu",
|
||||
"title": "Créer à partir du reçu",
|
||||
"description": "Extraire les informations de la dépense à partir d'une photo de reçu.",
|
||||
"body": "Téléchargez la photo d'un reçu, et nous l'analyserons pour extraire les informations de la dépense si possible.",
|
||||
"selectImage": "Sélectionner une image…",
|
||||
"titleLabel": "Titre :",
|
||||
"categoryLabel": "Catégorie :",
|
||||
"amountLabel": "Montant :",
|
||||
"dateLabel": "Date :",
|
||||
"editNext": "Vous pourrez modifier les informations de la dépense ensuite.",
|
||||
"continue": "Continuer"
|
||||
},
|
||||
"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}."
|
||||
},
|
||||
"ErrorToast": {
|
||||
"title": "Erreur lors du téléchargement du document",
|
||||
"description": "Un problème est survenu lors du téléchargement du document. Veuillez réessayer plus tard ou sélectionner un fichier différent.",
|
||||
"retry": "Réessayer"
|
||||
}
|
||||
},
|
||||
"Balances": {
|
||||
"title": "Équilibres",
|
||||
"description": "Voici le montant que chaque participant a payé ou doit rembourser.",
|
||||
"Reimbursements": {
|
||||
"title": "Remboursements suggérés",
|
||||
"description": "Voici des suggestions pour des remboursements optimisés entre les participants.",
|
||||
"noImbursements": "Les dépenses effectuées ne nécessitent pas d'équilibrage 😁",
|
||||
"owes": "<strong>{from}</strong> doit à <strong>{to}</strong>",
|
||||
"markAsPaid": "Marquer comme payé"
|
||||
}
|
||||
},
|
||||
"Stats": {
|
||||
"title": "Statistiques",
|
||||
"Totals": {
|
||||
"title": "Totaux",
|
||||
"description": "Résumé des dépenses du groupe entier.",
|
||||
"groupSpendings": "Total des dépenses du groupe",
|
||||
"groupEarnings": "Total des revenus du groupe",
|
||||
"yourSpendings": "Vos dépenses totales",
|
||||
"yourEarnings": "Vos revenus totaux",
|
||||
"yourShare": "Votre part totale"
|
||||
}
|
||||
},
|
||||
"Activity": {
|
||||
"title": "Activité",
|
||||
"description": "Vue d'ensemble de toute l'activité dans ce groupe.",
|
||||
"noActivity": "Il n'y a pas encore d'activité dans votre groupe.",
|
||||
"someone": "Quelqu'un",
|
||||
"settingsModified": "Les paramètres du groupe ont été modifiés par <strong>{participant}</strong>.",
|
||||
"expenseCreated": "Dépense <em>{expense}</em> créée par <strong>{participant}</strong>.",
|
||||
"expenseUpdated": "Dépense <em>{expense}</em> mise à jour par <strong>{participant}</strong>.",
|
||||
"expenseDeleted": "Dépense <em>{expense}</em> supprimée par <strong>{participant}</strong>.",
|
||||
"Groups": {
|
||||
"today": "Aujourd'hui",
|
||||
"yesterday": "Hier",
|
||||
"earlierThisWeek": "Plus tôt cette semaine",
|
||||
"lastWeek": "La semaine dernière",
|
||||
"earlierThisMonth": "Plus tôt ce mois-ci",
|
||||
"lastMonth": "Le mois dernier",
|
||||
"earlierThisYear": "Plus tôt cette année",
|
||||
"lastYear": "L'année dernière",
|
||||
"older": "Plus ancien"
|
||||
}
|
||||
},
|
||||
"Information": {
|
||||
"title": "Information",
|
||||
"description": "Utilisez cet espace pour ajouter toute information qui pourrait être pertinente pour les participants du groupe.",
|
||||
"empty": "Aucune information pour le moment."
|
||||
},
|
||||
"Settings": {
|
||||
"title": "Paramètres"
|
||||
},
|
||||
"Share": {
|
||||
"title": "Partager",
|
||||
"description": "Pour que d'autres participants puissent voir le groupe et ajouter des dépenses, partagez son URL avec eux.",
|
||||
"warning": "Avertissement !",
|
||||
"warningHelp": "Toute personne ayant l'URL du groupe pourra voir et modifier les dépenses. Partagez avec prudence !"
|
||||
},
|
||||
"SchemaErrors": {
|
||||
"min1": "Entrez au moins un caractère.",
|
||||
"min2": "Entrez au moins deux caractères.",
|
||||
"max5": "Entrez au maximum cinq caractères.",
|
||||
"max50": "Entrez au maximum 50 caractères.",
|
||||
"duplicateParticipantName": "Un autre participant a déjà ce nom.",
|
||||
"titleRequired": "Veuillez entrer un titre.",
|
||||
"invalidNumber": "Nombre invalide.",
|
||||
"amountRequired": "Vous devez entrer un montant.",
|
||||
"amountNotZero": "Le montant ne doit pas être zéro.",
|
||||
"amountTenMillion": "Le montant doit être inférieur à 10 000 000.",
|
||||
"paidByRequired": "Vous devez sélectionner un participant.",
|
||||
"paidForMin1": "La dépense doit concerner au moins un participant.",
|
||||
"noZeroShares": "Toutes les parts doivent être supérieures à 0.",
|
||||
"amountSum": "La somme des montants doit être égale au montant de la dépense.",
|
||||
"percentageSum": "La somme des pourcentages doit être égale à 100."
|
||||
},
|
||||
"Categories": {
|
||||
"search": "Rechercher une catégorie…",
|
||||
"noCategory": "Aucune catégorie trouvée.",
|
||||
"Uncategorized": {
|
||||
"heading": "Non classé",
|
||||
"General": "Général",
|
||||
"Payment": "Paiement"
|
||||
},
|
||||
"Entertainment": {
|
||||
"heading": "Divertissement",
|
||||
"Entertainment": "Divertissement",
|
||||
"Games": "Jeux",
|
||||
"Movies": "Films",
|
||||
"Music": "Musique",
|
||||
"Sports": "Sports"
|
||||
},
|
||||
"Food and Drink": {
|
||||
"heading": "Nourriture et boissons",
|
||||
"Food and Drink": "Nourriture et boissons",
|
||||
"Dining Out": "Repas au restaurant",
|
||||
"Groceries": "Épicerie",
|
||||
"Liquor": "Alcool"
|
||||
},
|
||||
"Home": {
|
||||
"heading": "Maison",
|
||||
"Home": "Maison",
|
||||
"Electronics": "Électronique",
|
||||
"Furniture": "Mobilier",
|
||||
"Household Supplies": "Fournitures ménagères",
|
||||
"Maintenance": "Entretien",
|
||||
"Mortgage": "Hypothèque",
|
||||
"Pets": "Animaux",
|
||||
"Rent": "Loyer",
|
||||
"Services": "Services"
|
||||
},
|
||||
"Life": {
|
||||
"heading": "Vie",
|
||||
"Childcare": "Garde d'enfants",
|
||||
"Clothing": "Vêtements",
|
||||
"Education": "Éducation",
|
||||
"Gifts": "Cadeaux",
|
||||
"Insurance": "Assurance",
|
||||
"Medical Expenses": "Dépenses médicales",
|
||||
"Taxes": "Impôts"
|
||||
},
|
||||
"Transportation": {
|
||||
"heading": "Transport",
|
||||
"Transportation": "Transport",
|
||||
"Bicycle": "Bicyclette",
|
||||
"Bus/Train": "Bus/Train",
|
||||
"Car": "Voiture",
|
||||
"Gas/Fuel": "Essence/Carburant",
|
||||
"Hotel": "Hôtel",
|
||||
"Parking": "Parking",
|
||||
"Plane": "Avion",
|
||||
"Taxi": "Taxi"
|
||||
},
|
||||
"Utilities": {
|
||||
"heading": "Services publics",
|
||||
"Utilities": "Services publics",
|
||||
"Cleaning": "Nettoyage",
|
||||
"Electricity": "Électricité",
|
||||
"Heat/Gas": "Chauffage/Gaz",
|
||||
"Trash": "Poubelle",
|
||||
"TV/Phone/Internet": "TV/Téléphone/Internet",
|
||||
"Water": "Eau"
|
||||
}
|
||||
}
|
||||
}
|
||||
388
messages/it-IT.json
Normal file
388
messages/it-IT.json
Normal file
@@ -0,0 +1,388 @@
|
||||
{
|
||||
"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": "Made in Montréal, Québec 🇨🇦",
|
||||
"builtBy": "Built by <author>Sebastien Castiel</author> and <source>contributors</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",
|
||||
"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",
|
||||
"lastYera": "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": "John",
|
||||
"Jane": "Jane",
|
||||
"Jack": "Jack"
|
||||
},
|
||||
"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."
|
||||
},
|
||||
"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": "Unknown",
|
||||
"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": "Games",
|
||||
"Movies": "Film",
|
||||
"Music": "Musica",
|
||||
"Sports": "Sports"
|
||||
},
|
||||
"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": "Educazione",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
387
messages/pl-PL.json
Normal file
387
messages/pl-PL.json
Normal file
@@ -0,0 +1,387 @@
|
||||
{
|
||||
"Homepage": {
|
||||
"title": "Podziel <strong>Wydatki</strong> z <strong>Rodziną i Przyjaciółmi</strong>",
|
||||
"description": "Witaj na twojej nowej instancji <strong>Spliita</strong> !",
|
||||
"button": {
|
||||
"groups": "Przejdź do grup",
|
||||
"github": "GitHub"
|
||||
}
|
||||
},
|
||||
"Header": {
|
||||
"groups": "Grupy"
|
||||
},
|
||||
"Footer": {
|
||||
"madeIn": "Stworzone Montréalu, Québec 🇨🇦",
|
||||
"builtBy": "Napisane przez <author>Sebastien Castiela</author> i <source>kontrybutorów</source>"
|
||||
},
|
||||
"Expenses": {
|
||||
"title": "Wydatki",
|
||||
"description": "Tutaj są wydatki, które utworzyłeś dla twojej grupy.",
|
||||
"create": "Dodaj wydatek",
|
||||
"createFirst": "Stwórz swój pierwszy",
|
||||
"noExpenses": "Twoja grupa nie ma jeszcze żadnych wydatków.",
|
||||
"exportJson": "Eksportuj do JSONa",
|
||||
"searchPlaceholder": "Szukaj wydatku...",
|
||||
"ActiveUserModal": {
|
||||
"title": "Kim jesteś?",
|
||||
"description": "Podaj, którym uczestnikiem jesteś aby pozwolić nam określić jakie informacje mają być wyświetlane.",
|
||||
"nobody": "Nie chcę wybierać nikogo",
|
||||
"save": "Zapisz zmiany",
|
||||
"footer": "To ustawienie może być potem zmienione w ustawieniach grupy."
|
||||
},
|
||||
"Groups": {
|
||||
"upcoming": "Nadchodzące",
|
||||
"thisWeek": "Ten tydzień",
|
||||
"earlierThisMonth": "Wcześniej w tym miesiącu",
|
||||
"lastMonth": "Ostatni miesiąc",
|
||||
"earlierThisYear": "Wcześniej w tym roku",
|
||||
"lastYera": "Poprzedni rok",
|
||||
"older": "Starsze"
|
||||
}
|
||||
},
|
||||
"ExpenseCard": {
|
||||
"paidBy": "Opłacone przez <strong>{paidBy}</strong> dla <paidFor></paidFor>",
|
||||
"receivedBy": "Otrzymane przez <strong>{paidBy}</strong> od <paidFor></paidFor>",
|
||||
"yourBalance": "Twjoje saldo:"
|
||||
},
|
||||
"Groups": {
|
||||
"myGroups": "Moje grupy",
|
||||
"create": "Stwórz",
|
||||
"loadingRecent": "Wczytywanie ostatnich grup...",
|
||||
"NoRecent": {
|
||||
"description": "Nie odwiedzałeś ostatnio żadnych grup.",
|
||||
"create": "Stwórz",
|
||||
"orAsk": "albo poproś przyjaciela, aby ci wysłał link do już istniejącej."
|
||||
},
|
||||
"recent": "Ostatnie grupy",
|
||||
"starred": "Ogwiazdkowane grupy",
|
||||
"archived": "Zarchiwizowane grupy",
|
||||
"archive": "Zarchiwizuj grupę",
|
||||
"unarchive": "Odarchwiruj grupę",
|
||||
"removeRecent": "Usuń z ostatnich grup",
|
||||
"RecentRemovedToast": {
|
||||
"title": "Grupa została usunięta",
|
||||
"description": "Grupa została usunięta z listy twoich ostatnich grup.",
|
||||
"undoAlt": "Cofnij usunięcie grupy",
|
||||
"undo": "Cofnij"
|
||||
},
|
||||
"AddByURL": {
|
||||
"button": "Dodaj poprzez link URL",
|
||||
"title": "Dodaj grupę poprzez link URL",
|
||||
"description": "Jeśli grupa została ci udostępniona możesz wkleić jej link tutaj, aby dodać ją do twojej listy.",
|
||||
"error": "Ups, nie możemy znaleźć grupy z podanego linka..."
|
||||
},
|
||||
"NotFound": {
|
||||
"text": "Ta grupa nie istnieje.",
|
||||
"link": "Idź do ostatnio odwiedzanych grup"
|
||||
}
|
||||
},
|
||||
"GroupForm": {
|
||||
"title": "Informacje o grupie",
|
||||
"NameField": {
|
||||
"label": "Nazwa grupy",
|
||||
"placeholder": "Letni wyjazd",
|
||||
"description": "Podaj nazwę dla twojej grupy."
|
||||
},
|
||||
"InformationField": {
|
||||
"label": "Informacje o grupie",
|
||||
"placeholder": "Jakie informacje mogą być ważne dla członków grupy?"
|
||||
},
|
||||
"CurrencyField": {
|
||||
"label": "Symbol waluty",
|
||||
"placeholder": "PLN, zł, $, €, £…",
|
||||
"description": "Użyjemy go do wyświetlania ilości."
|
||||
},
|
||||
"Participants": {
|
||||
"title": "Członkowie",
|
||||
"description": "Podaj nazwę dla każdego członka.",
|
||||
"protectedParticipant": "Ten członek wciąż bierze udział w rozliczeniach i nie może być usunięty.",
|
||||
"new": "Nowy",
|
||||
"add": "Dodaj członka",
|
||||
"John": "Jan",
|
||||
"Jane": "Joanna",
|
||||
"Jack": "Jacek"
|
||||
},
|
||||
"Settings": {
|
||||
"title": "Ustawienia lokalne",
|
||||
"description": "Te ustawienia są ustawiane dla konkretnego urządzenia i służą do dostosowania twoich doświadczeń z aplikacją.",
|
||||
"ActiveUserField": {
|
||||
"label": "Aktywny użytkownik",
|
||||
"placeholder": "Wybierz członka",
|
||||
"none": "Brak",
|
||||
"description": "Użytkownik używany domyślnie do wprowadzania wydatków."
|
||||
},
|
||||
"save": "Zapisz",
|
||||
"saving": "Zapisywanie…",
|
||||
"create": "Stwórz",
|
||||
"creating": "Tworzenie…",
|
||||
"cancel": "Anuluj"
|
||||
}
|
||||
},
|
||||
"ExpenseForm": {
|
||||
"Income": {
|
||||
"create": "Dodaj wpływ",
|
||||
"edit": "Edytuj wpływ",
|
||||
"TitleField": {
|
||||
"label": "Tytuł wpływu",
|
||||
"placeholder": "Zwrot kaucji",
|
||||
"description": "Podaj opis wpływu."
|
||||
},
|
||||
"DateField": {
|
||||
"label": "Data wpływu",
|
||||
"description": "Podaj datę otrzymania wpływu."
|
||||
},
|
||||
"categoryFieldDescription": "Wybierz typ wpływu.",
|
||||
"paidByField": {
|
||||
"label": "Otrzymane przez",
|
||||
"description": "Wybierz członka, który otrzymał wpływ."
|
||||
},
|
||||
"paidFor": {
|
||||
"title": "Otrzymany dla",
|
||||
"description": "Podaj dla kogo wpływ był przeznaczony."
|
||||
},
|
||||
"splitModeDescription": "Wybierz jak podzielić wpływ.",
|
||||
"attachDescription": "Zobacz i załącz rachunki do wpływu."
|
||||
},
|
||||
"Expense": {
|
||||
"create": "Stwórz wydatek",
|
||||
"edit": "Edytuj wydatek",
|
||||
"TitleField": {
|
||||
"label": "Tytuł wydatku",
|
||||
"placeholder": "Poniedziałkowe wyjście do restauracji",
|
||||
"description": "Podaj opis wydatku."
|
||||
},
|
||||
"DateField": {
|
||||
"label": "Data wydatku",
|
||||
"description": "Podaj datę opłacenia wydatku."
|
||||
},
|
||||
"categoryFieldDescription": "Podaj kategorię wydatku.",
|
||||
"paidByField": {
|
||||
"label": "Opłacone przez",
|
||||
"description": "Wybierz członka, który zapłacił."
|
||||
},
|
||||
"paidFor": {
|
||||
"title": "Opłacone dla",
|
||||
"description": "Wybierz kogo dotyczył wydatek."
|
||||
},
|
||||
"splitModeDescription": "Wybierz jak podzielić wydatek.",
|
||||
"attachDescription": "Zobacz i załącz rachunki do wydatku."
|
||||
},
|
||||
"amountField": {
|
||||
"label": "Ilość"
|
||||
},
|
||||
"isReimbursementField": {
|
||||
"label": "To jest zwrot kosztów"
|
||||
},
|
||||
"categoryField": {
|
||||
"label": "Kategoria"
|
||||
},
|
||||
"notesField": {
|
||||
"label": "Notatki"
|
||||
},
|
||||
"selectNone": "Nie wybieraj żadnego",
|
||||
"selectAll": "Wybierz wszystkie",
|
||||
"shares": "udział(y)",
|
||||
"advancedOptions": "Zaawansowane opcje podziału...",
|
||||
"SplitModeField": {
|
||||
"label": "Typ podziału",
|
||||
"evenly": "Równy",
|
||||
"byShares": "Nierówny – Poprzez udziały",
|
||||
"byPercentage": "Nierówny – Procentowo",
|
||||
"byAmount": "Nierówny – Na konkretne sumy",
|
||||
"saveAsDefault": "Wybierz jako domyślny typ podziału"
|
||||
},
|
||||
"DeletePopup": {
|
||||
"label": "Usuń",
|
||||
"title": "Usunąć ten wydatek?",
|
||||
"description": "Czy na pewno chcesz usunąć ten wydatek? Ta akcja jest nieodwracalna.",
|
||||
"yes": "Tak",
|
||||
"cancel": "Anuluj"
|
||||
},
|
||||
"attachDocuments": "Załącz dokumenty",
|
||||
"create": "Stwórz",
|
||||
"creating": "Tworzenie…",
|
||||
"save": "Zapisz",
|
||||
"saving": "Zapisywanie…",
|
||||
"cancel": "Anuluj"
|
||||
},
|
||||
"ExpenseDocumentsInput": {
|
||||
"TooBigToast": {
|
||||
"title": "Ten plik jest zbyt duży",
|
||||
"description": "Maksymalny rozmiar pliku to: {maxSize}. Twój plik ma: ${size}."
|
||||
},
|
||||
"ErrorToast": {
|
||||
"title": "Błąd podczas wysyłania dokumentu",
|
||||
"description": "Coś poszło nie tak podczas wysyłania dokumentu. Proszę spróbuj ponownie później, albo wybierz inny plik.",
|
||||
"retry": "Ponów"
|
||||
}
|
||||
},
|
||||
"CreateFromReceipt": {
|
||||
"Dialog": {
|
||||
"triggerTitle": "Utwórz wydatek z paragonu",
|
||||
"title": "Utwórz z paragonu",
|
||||
"description": "Wyodrębnianie informacji o wydatkach ze zdjęcia paragonu.",
|
||||
"body": "Prześlij zdjęcie paragonu, a my zeskanujemy je, aby wyodrębnić informacje o wydatkach, jeśli to możliwe.",
|
||||
"selectImage": "Wybierz obraz...",
|
||||
"titleLabel": "Tytuł:",
|
||||
"categoryLabel": "Kategoria:",
|
||||
"amountLabel": "Ilość:",
|
||||
"dateLabel": "Data:",
|
||||
"editNext": "Następnie będziesz mógł edytować informacje o wydatkach.",
|
||||
"continue": "Kontynuuj"
|
||||
},
|
||||
"unknown": "Nieznany",
|
||||
"TooBigToast": {
|
||||
"title": "Ten plik jest zbyt duży",
|
||||
"description": "Maksymalny rozmiar pliku to: {maxSize}. Twój plik ma: ${size}."
|
||||
},
|
||||
"ErrorToast": {
|
||||
"title": "Błąd podczas wysyłania dokumentu",
|
||||
"description": "Coś poszło nie tak podczas wysyłania dokumentu. Proszę spróbuj ponownie później, albo wybierz inny plik.",
|
||||
"retry": "Ponów"
|
||||
}
|
||||
},
|
||||
"Balances": {
|
||||
"title": "Salda",
|
||||
"description": "Jest to kwota, którą każdy członek zapłacił lub za którą otrzymał zapłatę.",
|
||||
"Reimbursements": {
|
||||
"title": "Sugerowane zwroty",
|
||||
"description": "Oto sugestie dotyczące optymalizacji zwrotów między uczestnikami.",
|
||||
"noImbursements": "Wygląda na to, że w twojej grupie nie ma potrzeby żadnych zwrotów 😁",
|
||||
"owes": "<strong>{from}</strong> jest winny dla <strong>{to}</strong>",
|
||||
"markAsPaid": "Zaznacz jako opłacone"
|
||||
}
|
||||
},
|
||||
"Stats": {
|
||||
"title": "Statystyki",
|
||||
"Totals": {
|
||||
"title": "Podsumowanie",
|
||||
"description": "Podsumowanie wydatków dla całej grupy.",
|
||||
"groupSpendings": "Wydatki grupy",
|
||||
"groupEarnings": "Wpływy grupy",
|
||||
"yourSpendings": "Twoje wydatki",
|
||||
"yourEarnings": "Twoje wpływy",
|
||||
"yourShare": "Twoje udziały"
|
||||
}
|
||||
},
|
||||
"Activity": {
|
||||
"title": "Aktywność",
|
||||
"description": "Przegląd wszystkich działań w tej grupie.",
|
||||
"noActivity": "W grupie nie ma jeszcze żadnej aktywności.",
|
||||
"someone": "Ktoś",
|
||||
"settingsModified": "Ustawienia grupy zostały zmienione przez <strong>{participant}</strong>.",
|
||||
"expenseCreated": "Wydatek <em>{expense}</em> stworzony przez <strong>{participant}</strong>.",
|
||||
"expenseUpdated": "Wydatek <em>{expense}</em> zaktualizowany przez <strong>{participant}</strong>.",
|
||||
"expenseDeleted": "Wydatek <em>{expense}</em> usunięty przez <strong>{participant}</strong>.",
|
||||
"Groups": {
|
||||
"today": "Dzisiaj",
|
||||
"yesterday": "Wczoraj",
|
||||
"earlierThisWeek": "Wcześniej w tym tygodniu",
|
||||
"lastWeek": "W zeszłym tygodniu",
|
||||
"earlierThisMonth": "Wcześniej w tym miesiącu",
|
||||
"lastMonth": "Ostatni miesiąc",
|
||||
"earlierThisYear": "Wcześniej w tym roku",
|
||||
"lastYera": "Poprzedni rok",
|
||||
"older": "Starsze"
|
||||
}
|
||||
},
|
||||
"Information": {
|
||||
"title": "Informacje",
|
||||
"description": "Użyj tego miejsca, aby dodać wszelkie informacje, które mogą być istotne dla uczestników grupy..",
|
||||
"empty": "Jeszcze nic tu nie ma."
|
||||
},
|
||||
"Settings": {
|
||||
"title": "Ustawienia"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
388
messages/ro.json
Normal file
388
messages/ro.json
Normal file
@@ -0,0 +1,388 @@
|
||||
{
|
||||
"Homepage": {
|
||||
"title": "Distribuie <strong>Cheltuielile</strong> cu <strong>Prietenii & Familia</strong>",
|
||||
"description": "Bine ai venit pe noua ta instanță de <strong>Spliit</strong> !",
|
||||
"button": {
|
||||
"groups": "Mergi la grupuri",
|
||||
"github": "GitHub"
|
||||
}
|
||||
},
|
||||
"Header": {
|
||||
"groups": "Grupuri"
|
||||
},
|
||||
"Footer": {
|
||||
"madeIn": "Dezvoltat în Montréal, Québec 🇨🇦",
|
||||
"builtBy": "Dezvoltat de către <author>Sebastien Castiel</author> și <source>contribuitori</source>"
|
||||
},
|
||||
"Expenses": {
|
||||
"title": "Cheltuieli",
|
||||
"description": "Aici sunt cheltuielile pe care le-ai creat pentru grupul tău.",
|
||||
"create": "Adaugă o cheltuială",
|
||||
"createFirst": "Adaug-o pe prima",
|
||||
"noExpenses": "Grupul tău nu conține nicio cheltuială încă.",
|
||||
"exportJson": "Salvează în JSON",
|
||||
"searchPlaceholder": "Caută o cheltuială…",
|
||||
"ActiveUserModal": {
|
||||
"title": "Cum te numești?",
|
||||
"description": "Spune-ne cine ești ca să putem îți afișăm informațiile relevante.",
|
||||
"nobody": "Nu doresc să aleg pe nimeni",
|
||||
"save": "Salvează",
|
||||
"footer": "Această setare se poate schimba mai târziu din setările grupului."
|
||||
},
|
||||
"Groups": {
|
||||
"upcoming": "Urmează",
|
||||
"thisWeek": "În această săptămână",
|
||||
"earlierThisMonth": "La începutul lunii",
|
||||
"lastMonth": "Luna trecută",
|
||||
"earlierThisYear": "La începutul anului",
|
||||
"lastYera": "Anul trecut",
|
||||
"older": "Mai vechi"
|
||||
}
|
||||
},
|
||||
"ExpenseCard": {
|
||||
"paidBy": "Plătit de <strong>{paidBy}</strong> pentru <paidFor></paidFor>",
|
||||
"receivedBy": "Primit de <strong>{paidBy}</strong> pentru <paidFor></paidFor>",
|
||||
"yourBalance": "Soldul tău:"
|
||||
},
|
||||
"Groups": {
|
||||
"myGroups": "Grupurile mele",
|
||||
"create": "Adaugă",
|
||||
"loadingRecent": "Se încarcă ultimele tale grupuri…",
|
||||
"NoRecent": {
|
||||
"description": "Nu ai accesat niciun grup recent.",
|
||||
"create": "Adaugă unul",
|
||||
"orAsk": "sau roagă un prieten să îți trimită un link către unul deja existent."
|
||||
},
|
||||
"recent": "Ultimele grupuri",
|
||||
"starred": "Grupuri favorite",
|
||||
"archived": "Grupuri arhivate",
|
||||
"archive": "Arhivează grupul",
|
||||
"unarchive": "Dezarhivează grupul",
|
||||
"removeRecent": "Șterge din ultimele grupuri",
|
||||
"RecentRemovedToast": {
|
||||
"title": "Grupul a fost șters.",
|
||||
"description": "Grupul a fost șters din lista ta de grupuri recente.",
|
||||
"undoAlt": "Anulează ștergerea grupului",
|
||||
"undo": "Anulează"
|
||||
},
|
||||
"AddByURL": {
|
||||
"button": "Adaugă folosind un URL",
|
||||
"title": "Adaugă un grup folosind un URL",
|
||||
"description": "Dacă un grup a fost distribuit cu tine, poți atașa URL-ul acestuia aici pentru a-l adăuga în listă.",
|
||||
"error": "Ups, nu am găsit grupul folosind URL-ul primit de la tine…"
|
||||
},
|
||||
"NotFound": {
|
||||
"text": "Acest grup nu există.",
|
||||
"link": "Mergi la ultimele grupuri vizitate"
|
||||
}
|
||||
},
|
||||
"GroupForm": {
|
||||
"title": "Informații despre grup",
|
||||
"NameField": {
|
||||
"label": "Numele grupului",
|
||||
"placeholder": "Vacanță de vară",
|
||||
"description": "Adaugă un nume pentru grupul tău."
|
||||
},
|
||||
"InformationField": {
|
||||
"label": "Informații despre grup",
|
||||
"placeholder": "Ce informație este relevantă pentru membrii grupului?"
|
||||
},
|
||||
"CurrencyField": {
|
||||
"label": "Monedă",
|
||||
"placeholder": "$, €, £, RON …",
|
||||
"description": "O vom folosi pentru a afișa sume."
|
||||
},
|
||||
"Participants": {
|
||||
"title": "Membri",
|
||||
"description": "Adaugă numele fiecărui membru.",
|
||||
"protectedParticipant": "Acest membru a luat parte la cheltuieli și nu poate să fie șters.",
|
||||
"new": "Nou",
|
||||
"add": "Adaugă membru",
|
||||
"John": "John",
|
||||
"Jane": "Jane",
|
||||
"Jack": "Jack"
|
||||
},
|
||||
"Settings": {
|
||||
"title": "Setări locale",
|
||||
"description": "Aceste setări sunt făcute pentru fiecare dispozitiv și sunt folosite pentru a-ți personaliza experiența.",
|
||||
"ActiveUserField": {
|
||||
"label": "Utilizator activ",
|
||||
"placeholder": "Selectează un membru",
|
||||
"none": "Niciunul",
|
||||
"description": "Utilizatorul implicit pentru plata cheltuielilor."
|
||||
},
|
||||
"save": "Salvează",
|
||||
"saving": "Se salvează…",
|
||||
"create": "Adaugă",
|
||||
"creating": "Se adaugă…",
|
||||
"cancel": "Anulează"
|
||||
}
|
||||
},
|
||||
"ExpenseForm": {
|
||||
"Income": {
|
||||
"create": "Adaugă un venit",
|
||||
"edit": "Modifică venitul",
|
||||
"TitleField": {
|
||||
"label": "Titlul venitului",
|
||||
"placeholder": "Cina de luni seară",
|
||||
"description": "Adaugă o descriere pentru venit."
|
||||
},
|
||||
"DateField": {
|
||||
"label": "Data venitului",
|
||||
"description": "Adaugă data la care venitul a fost primit."
|
||||
},
|
||||
"categoryFieldDescription": "Selectează categoria venitului.",
|
||||
"paidByField": {
|
||||
"label": "Primit de către",
|
||||
"description": "Selectează membrul care a primit venitul."
|
||||
},
|
||||
"paidFor": {
|
||||
"title": "Primit pentru",
|
||||
"description": "Selectează pentru cine a fost primit venitul."
|
||||
},
|
||||
"splitModeDescription": "Selectează cum să fie împărțit venitul.",
|
||||
"attachDescription": "Vizualizează și atașează bonul pentru venit."
|
||||
},
|
||||
"Expense": {
|
||||
"create": "Adaugă o cheltuială",
|
||||
"edit": "Modifică cheltuiala",
|
||||
"TitleField": {
|
||||
"label": "Titlul cheltuielii",
|
||||
"placeholder": "Cina de luni seară",
|
||||
"description": "Adaugă o descriere pentru cheltuială."
|
||||
},
|
||||
"DateField": {
|
||||
"label": "Data cheltuielii",
|
||||
"description": "Adaugă data la care cheltuiala a fost facută."
|
||||
},
|
||||
"categoryFieldDescription": "Selectează categoria cheltuielii.",
|
||||
"paidByField": {
|
||||
"label": "Plătit de către",
|
||||
"description": "Selectează membrul care a plătit cheltuiala."
|
||||
},
|
||||
"paidFor": {
|
||||
"title": "Plătit pentru",
|
||||
"description": "Selectează pentru cine a fost platită cheltuiala."
|
||||
},
|
||||
"splitModeDescription": "Selectează cum să fie împărțită cheltuiala.",
|
||||
"attachDescription": "Vizualizează și atașează bonul pentru cheltuială."
|
||||
},
|
||||
"amountField": {
|
||||
"label": "Sumă"
|
||||
},
|
||||
"isReimbursementField": {
|
||||
"label": "Aceasta este o rambursare."
|
||||
},
|
||||
"categoryField": {
|
||||
"label": "Categorie"
|
||||
},
|
||||
"notesField": {
|
||||
"label": "Notițe"
|
||||
},
|
||||
"selectNone": "Nu selectez nimic",
|
||||
"selectAll": "Selectez tot",
|
||||
"shares": "distribuiri",
|
||||
"advancedOptions": "Opțiuni avansate de împărțire…",
|
||||
"SplitModeField": {
|
||||
"label": "Împărțire",
|
||||
"evenly": "Egal",
|
||||
"byShares": "Inegal – În funcție de parte",
|
||||
"byPercentage": "Inegal – În funcție de procentaj",
|
||||
"byAmount": "Inegal – În funcție de sumă",
|
||||
"saveAsDefault": "Salvează ca și implicite opțiunile de împărțire"
|
||||
},
|
||||
"DeletePopup": {
|
||||
"label": "Șterge",
|
||||
"title": "Ștergi această cheltuială?",
|
||||
"description": "Ești sigur că vrei să ștergi această cheltuială? Această acțiune este ireversibilă.",
|
||||
"yes": "Da",
|
||||
"cancel": "Anulează"
|
||||
},
|
||||
"attachDocuments": "Atașează documente",
|
||||
"create": "Adaugă",
|
||||
"creating": "Se adaugă…",
|
||||
"save": "Salvează",
|
||||
"saving": "Se salvează…",
|
||||
"cancel": "Anulează",
|
||||
"reimbursement": "Rambursare"
|
||||
},
|
||||
"ExpenseDocumentsInput": {
|
||||
"TooBigToast": {
|
||||
"title": "Fișierul este prea mare",
|
||||
"description": "Dimensiunea maximă a fișierului pe care îl poți atașa este {maxSize}. Fișierul tău are ${size}."
|
||||
},
|
||||
"ErrorToast": {
|
||||
"title": "Eroare la adăugarea documentului.",
|
||||
"description": "Ceva a mers greșit la adăugarea fișierului. Încearcă mai târziu sau cum un alt fișier.",
|
||||
"retry": "Reîncearcă"
|
||||
}
|
||||
},
|
||||
"CreateFromReceipt": {
|
||||
"Dialog": {
|
||||
"triggerTitle": "Adaugă o cheltuială dintr-un bon",
|
||||
"title": "Adaugă din bon",
|
||||
"description": "Extrage informații despre cheltuială dintr-o poză cu bonul.",
|
||||
"body": "Adaugă o poză cu bonul și vom încerca să o scanăm pentru a extrage informații despre cheltuială.",
|
||||
"selectImage": "Selectează o imagine…",
|
||||
"titleLabel": "Titlu:",
|
||||
"categoryLabel": "Categorie:",
|
||||
"amountLabel": "Sumă:",
|
||||
"dateLabel": "Data:",
|
||||
"editNext": "Vei putea sa modifici informațiile despre cheltuială în continuare.",
|
||||
"continue": "Continuă"
|
||||
},
|
||||
"unknown": "Necunoscut",
|
||||
"TooBigToast": {
|
||||
"title": "Fișierul este prea mare",
|
||||
"description": "Dimensiunea maximă a fișierului pe care il poți atașa este {maxSize}. Fișierul tău are ${size}."
|
||||
},
|
||||
"ErrorToast": {
|
||||
"title": "Eroare la adăugarea documentului.",
|
||||
"description": "Ceva a mers greșit la adăugarea fișierului. Încearcă mai târziu sau cum un alt fișier.",
|
||||
"retry": "Reîncearcă"
|
||||
}
|
||||
},
|
||||
"Balances": {
|
||||
"title": "Solduri",
|
||||
"description": "Aceasta este suma pe care fiecare membru a plătit-o sau cu care a fost plătit.",
|
||||
"Reimbursements": {
|
||||
"title": "Rambursări sugerate",
|
||||
"description": "Acestea sunt sugestiile pentru rambursări optimizate între membrii.",
|
||||
"noImbursements": "Se pare că grupul tău nu are nevoie de rambursări 😁",
|
||||
"owes": "<strong>{from}</strong> datorează <strong>{to}</strong>",
|
||||
"markAsPaid": "Bifează ca plătit"
|
||||
}
|
||||
},
|
||||
"Stats": {
|
||||
"title": "Statistici",
|
||||
"Totals": {
|
||||
"title": "Totaluri",
|
||||
"description": "Sumarul cheltuielior pentru întregul grup.",
|
||||
"groupSpendings": "Totalul cheltuielilor din grup",
|
||||
"groupEarnings": "Totalul veniturilor din grup",
|
||||
"yourSpendings": "Totalul cheltuielilor tale",
|
||||
"yourEarnings": "Totalul veniturilor tale",
|
||||
"yourShare": "Partea ta"
|
||||
}
|
||||
},
|
||||
"Activity": {
|
||||
"title": "Activități",
|
||||
"description": "Rezumatul întregii activități a grupului.",
|
||||
"noActivity": "Nu este nicio activitate în grupul tău încă.",
|
||||
"someone": "Cineva",
|
||||
"settingsModified": "Setările grupului au fost modificate de <strong>{participant}</strong>.",
|
||||
"expenseCreated": "Cheltuială <em>{expense}</em> adăugată de <strong>{participant}</strong>.",
|
||||
"expenseUpdated": "Cheltuială <em>{expense}</em> modificată de <strong>{participant}</strong>.",
|
||||
"expenseDeleted": "Cheltuială <em>{expense}</em> ștearsă de <strong>{participant}</strong>.",
|
||||
"Groups": {
|
||||
"today": "Azi",
|
||||
"yesterday": "Ieri",
|
||||
"earlierThisWeek": "La începutul săptămânii",
|
||||
"lastWeek": "Săptămâna trecută",
|
||||
"earlierThisMonth": "La începutul lunii",
|
||||
"lastMonth": "Luna trecuta",
|
||||
"earlierThisYear": "La începutul anului",
|
||||
"lastYear": "Anul trecut",
|
||||
"older": "Mai vechi"
|
||||
}
|
||||
},
|
||||
"Information": {
|
||||
"title": "Informații",
|
||||
"description": "Adaugă aici orice informație care poate să fie relevantă pentru membrii grupului.",
|
||||
"empty": "Nicio informație de grup încă."
|
||||
},
|
||||
"Settings": {
|
||||
"title": "Setări"
|
||||
},
|
||||
"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ă"
|
||||
}
|
||||
}
|
||||
}
|
||||
388
messages/ru-RU.json
Normal file
388
messages/ru-RU.json
Normal file
@@ -0,0 +1,388 @@
|
||||
{
|
||||
"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",
|
||||
"searchPlaceholder": "Поиск расходов…",
|
||||
"ActiveUserModal": {
|
||||
"title": "Кто вы?",
|
||||
"description": "Скажите нам, кто вы из этого списка, чтобы мы могли подстроить интерфейс под вас.",
|
||||
"nobody": "Не хочу выбирать",
|
||||
"save": "Сохранить изменения",
|
||||
"footer": "Вы сможете изменить это в настройках группы."
|
||||
},
|
||||
"Groups": {
|
||||
"upcoming": "Будущее",
|
||||
"thisWeek": "На этой неделе",
|
||||
"earlierThisMonth": "Ранее в этом месяце",
|
||||
"lastMonth": "В прошлом месяце",
|
||||
"earlierThisYear": "Ранее в этом году",
|
||||
"lastYera": "В прошлом году",
|
||||
"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": "Если с вами поделились ссылкой на группу, вставьте ее сюда, чтобы добавить ее в ваш список.",
|
||||
"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": "Выберите участника, который получил этот доход."
|
||||
},
|
||||
"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": "Любой человек с доступом к этой ссылке сможет просматривать и редактировать расходы. Будьте осторожны!"
|
||||
},
|
||||
"SchemaErrors": {
|
||||
"min1": "Введите как минимум один символ.",
|
||||
"min2": "Введите как минимум два символа.",
|
||||
"max5": "Введите максимум 5 символов.",
|
||||
"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": "Вода"
|
||||
}
|
||||
}
|
||||
}
|
||||
388
messages/ua-UA.json
Normal file
388
messages/ua-UA.json
Normal file
@@ -0,0 +1,388 @@
|
||||
{
|
||||
"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",
|
||||
"searchPlaceholder": "Пошук витрат...",
|
||||
"ActiveUserModal": {
|
||||
"title": "Хто ви?",
|
||||
"description": "Скажіть нам, хто ви серед учасників, щоб ми могли налаштувати відображення інформації під вас",
|
||||
"nobody": "Я не хочу нікого обирати",
|
||||
"save": "Зберегти зміни",
|
||||
"footer": "Це налаштування можна змінити пізніше в налаштуваннях групи"
|
||||
},
|
||||
"Groups": {
|
||||
"upcoming": "Майбутні",
|
||||
"thisWeek": "Цього тижня",
|
||||
"earlierThisMonth": "Раніше цього місяця",
|
||||
"lastMonth": "Минулого місяця",
|
||||
"earlierThisYear": "Раніше цього року",
|
||||
"lastYera": "Минулого року",
|
||||
"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": "Оберіть учасника, який отримав дохід"
|
||||
},
|
||||
"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": "Вода"
|
||||
}
|
||||
}
|
||||
}
|
||||
388
messages/zh-CN.json
Normal file
388
messages/zh-CN.json
Normal file
@@ -0,0 +1,388 @@
|
||||
{
|
||||
"Homepage": {
|
||||
"title": "与<strong>朋友和家人</strong>共享<strong>开支</strong>",
|
||||
"description": "欢迎使用你的全新<strong>Spliit</strong>实例!",
|
||||
"button": {
|
||||
"groups": "前往群组",
|
||||
"github": "GitHub"
|
||||
}
|
||||
},
|
||||
"Header": {
|
||||
"groups": "群组"
|
||||
},
|
||||
"Footer": {
|
||||
"madeIn": "Made in Montréal, Québec 🇨🇦",
|
||||
"builtBy": "由 <author>Sebastien Castiel</author> 以及 <source>社区贡献者们</source> 共同构建"
|
||||
},
|
||||
"Expenses": {
|
||||
"title": "消费",
|
||||
"description": "这里有你为你的群组创建的消费。",
|
||||
"create": "创建消费",
|
||||
"createFirst": "创建首个消费",
|
||||
"noExpenses": "你的群组内目前没有任何消费。",
|
||||
"exportJson": "导出到JSON",
|
||||
"searchPlaceholder": "查找消费……",
|
||||
"ActiveUserModal": {
|
||||
"title": "你是哪位?",
|
||||
"description": "告诉我们你在群组中的身份,以便定制你的信息呈现方式。",
|
||||
"nobody": "我不想选择任何人",
|
||||
"save": "保存变更",
|
||||
"footer": "此项设定之后可以在群组设定中修改。"
|
||||
},
|
||||
"Groups": {
|
||||
"upcoming": "即将到来",
|
||||
"thisWeek": "本周",
|
||||
"earlierThisMonth": "本月早些时候",
|
||||
"lastMonth": "上个月",
|
||||
"earlierThisYear": "本年早些时候",
|
||||
"lastYera": "去年",
|
||||
"older": "更早"
|
||||
}
|
||||
},
|
||||
"ExpenseCard": {
|
||||
"paidBy": "<strong>{paidBy}</strong> 为 <paidFor></paidFor> 支付。",
|
||||
"receivedBy": "<strong>{paidBy}</strong> 为 <paidFor></paidFor> 接收。",
|
||||
"yourBalance": "你的余额:"
|
||||
},
|
||||
"Groups": {
|
||||
"myGroups": "我的群组",
|
||||
"create": "创建",
|
||||
"loadingRecent": "加载最近的群组……",
|
||||
"NoRecent": {
|
||||
"description": "你最近没有访问任何群组。",
|
||||
"create": "创建一个群组",
|
||||
"orAsk": "或者让你的朋友发给你现有群组的链接。"
|
||||
},
|
||||
"recent": "最近的群组",
|
||||
"starred": "已收藏的群组",
|
||||
"archived": "已归档的群组",
|
||||
"archive": "归档群组",
|
||||
"unarchive": "取消归档群组",
|
||||
"removeRecent": "从最近的群组中删除",
|
||||
"RecentRemovedToast": {
|
||||
"title": "群组已删除",
|
||||
"description": "群组已从你的最近群组列表中删除。",
|
||||
"undoAlt": "撤销群组删除",
|
||||
"undo": "撤销"
|
||||
},
|
||||
"AddByURL": {
|
||||
"button": "使用URL添加",
|
||||
"title": "使用URL添加一个群组",
|
||||
"description": "如果你被分享了一个群组,你可以将群组的URL粘贴到这里以添加这个群组。",
|
||||
"error": "哎呀,我们没办法根据你的URL找到相应的群组……"
|
||||
},
|
||||
"NotFound": {
|
||||
"text": "该群组不存在。",
|
||||
"link": "跳转到最近访问过的群组"
|
||||
}
|
||||
},
|
||||
"GroupForm": {
|
||||
"title": "群组信息",
|
||||
"NameField": {
|
||||
"label": "群组名",
|
||||
"placeholder": "暑假",
|
||||
"description": "为你的群组输入一个名字。"
|
||||
},
|
||||
"InformationField": {
|
||||
"label": "群组信息",
|
||||
"placeholder": "哪些信息与群组成员有关?"
|
||||
},
|
||||
"CurrencyField": {
|
||||
"label": "货币标志",
|
||||
"placeholder": "$, €, £…",
|
||||
"description": "我们根据这个显示相应的货币金额。"
|
||||
},
|
||||
"Participants": {
|
||||
"title": "群组成员",
|
||||
"description": "输入每位成员的名字。",
|
||||
"protectedParticipant": "群组成员是消费的一部分,不可删除。",
|
||||
"new": "新建",
|
||||
"add": "添加群组成员",
|
||||
"John": "John",
|
||||
"Jane": "Jane",
|
||||
"Jack": "Jack"
|
||||
},
|
||||
"Settings": {
|
||||
"title": "本地设定",
|
||||
"description": "这些设定是按设备设定的,用于定制你的使用体验。",
|
||||
"ActiveUserField": {
|
||||
"label": "当前用户",
|
||||
"placeholder": "选择一个群组成员",
|
||||
"none": "无",
|
||||
"description": "用于支付消费的默认用户。"
|
||||
},
|
||||
"save": "保存",
|
||||
"saving": "保存中……",
|
||||
"create": "创建",
|
||||
"creating": "创建中",
|
||||
"cancel": "取消"
|
||||
}
|
||||
},
|
||||
"ExpenseForm": {
|
||||
"Income": {
|
||||
"create": "创建收入",
|
||||
"edit": "编辑收入",
|
||||
"TitleField": {
|
||||
"label": "收入标题",
|
||||
"placeholder": "周一晚上的餐厅",
|
||||
"description": "描述这个收入。"
|
||||
},
|
||||
"DateField": {
|
||||
"label": "收入日期",
|
||||
"description": "输入收到这笔收入的日期。"
|
||||
},
|
||||
"categoryFieldDescription": "选择收入类别。",
|
||||
"paidByField": {
|
||||
"label": "接收到",
|
||||
"description": "选择接收到这笔收入的群组成员。"
|
||||
},
|
||||
"paidFor": {
|
||||
"title": "接收给",
|
||||
"description": "选择收入是为谁而收。"
|
||||
},
|
||||
"splitModeDescription": "选择如何划分这笔收入。",
|
||||
"attachDescription": "查看并为这笔收入附加收据。"
|
||||
},
|
||||
"Expense": {
|
||||
"create": "创建消费",
|
||||
"edit": "编辑消费",
|
||||
"TitleField": {
|
||||
"label": "消费标题",
|
||||
"placeholder": "周一晚上的餐厅",
|
||||
"description": "描述这个消费。"
|
||||
},
|
||||
"DateField": {
|
||||
"label": "消费日期",
|
||||
"description": "输入支付这笔消费的日期。"
|
||||
},
|
||||
"categoryFieldDescription": "选择消费类别。",
|
||||
"paidByField": {
|
||||
"label": "支付自",
|
||||
"description": "选择支付这笔消费的群组成员。"
|
||||
},
|
||||
"paidFor": {
|
||||
"title": "支付给",
|
||||
"description": "选择消费是为谁而支出。"
|
||||
},
|
||||
"splitModeDescription": "选择如何划分这笔消费。",
|
||||
"attachDescription": "查看并为这笔消费附加收据。"
|
||||
},
|
||||
"amountField": {
|
||||
"label": "金额"
|
||||
},
|
||||
"isReimbursementField": {
|
||||
"label": "这是一笔报销款"
|
||||
},
|
||||
"categoryField": {
|
||||
"label": "类别"
|
||||
},
|
||||
"notesField": {
|
||||
"label": "附注"
|
||||
},
|
||||
"selectNone": "取消选中",
|
||||
"selectAll": "全选",
|
||||
"shares": "份额",
|
||||
"advancedOptions": "高级分账选项……",
|
||||
"SplitModeField": {
|
||||
"label": "分账模式",
|
||||
"evenly": "平均分配",
|
||||
"byShares": "按份额分配",
|
||||
"byPercentage": "按百分比分配",
|
||||
"byAmount": "按金额分配",
|
||||
"saveAsDefault": "保存为默认分账设置"
|
||||
},
|
||||
"DeletePopup": {
|
||||
"label": "删除",
|
||||
"title": "要删除这项消费吗?",
|
||||
"description": "你真的确定要删除这项消费吗?此行动不可撤销。",
|
||||
"yes": "确定",
|
||||
"cancel": "取消"
|
||||
},
|
||||
"attachDocuments": "附加文档",
|
||||
"create": "创建",
|
||||
"creating": "创建中……",
|
||||
"save": "保存",
|
||||
"saving": "保存中……",
|
||||
"cancel": "取消",
|
||||
"reimbursement": "报销"
|
||||
},
|
||||
"ExpenseDocumentsInput": {
|
||||
"TooBigToast": {
|
||||
"title": "文件过大",
|
||||
"description": "可上传的最大文件大小为{maxSize},你的文件为${size}。"
|
||||
},
|
||||
"ErrorToast": {
|
||||
"title": "上传文档时发生错误",
|
||||
"description": "上传文档时发生了一些错误。请稍后重试或更换文件。",
|
||||
"retry": "重试"
|
||||
}
|
||||
},
|
||||
"CreateFromReceipt": {
|
||||
"Dialog": {
|
||||
"triggerTitle": "从收据中创建消费",
|
||||
"title": "从收据中创建",
|
||||
"description": "从收据照片上提取消费信息。",
|
||||
"body": "上传收据的图片,我们会尽可能地从中扫描出消费信息。",
|
||||
"selectImage": "选择图片……",
|
||||
"titleLabel": "标题:",
|
||||
"categoryLabel": "类别:",
|
||||
"amountLabel": "金额:",
|
||||
"dateLabel": "日期:",
|
||||
"editNext": "你之后可以修改消费的信息。",
|
||||
"continue": "继续"
|
||||
},
|
||||
"unknown": "未知",
|
||||
"TooBigToast": {
|
||||
"title": "文件过大",
|
||||
"description": "可上传的最大文件大小为{maxSize},你的文件为${size}。"
|
||||
},
|
||||
"ErrorToast": {
|
||||
"title": "上传文档时发生错误",
|
||||
"description": "上传文档时发生了一些错误。请稍后重试或更换文件。",
|
||||
"retry": "重试"
|
||||
}
|
||||
},
|
||||
"Balances": {
|
||||
"title": "余额",
|
||||
"description": "这是每位群组成员支付或被支付的金额。",
|
||||
"Reimbursements": {
|
||||
"title": "建议报销",
|
||||
"description": "这里是优化群组成员之间报销的建议。",
|
||||
"noImbursements": "看起来你的群组不需要任何报销😁",
|
||||
"owes": "<strong>{from}</strong> 欠 <strong>{to}</strong>",
|
||||
"markAsPaid": "标记为已支付"
|
||||
}
|
||||
},
|
||||
"Stats": {
|
||||
"title": "统计",
|
||||
"Totals": {
|
||||
"title": "总计",
|
||||
"description": "整个群组的花费合计。",
|
||||
"groupSpendings": "群组总计开销",
|
||||
"groupEarnings": "群组总计收入",
|
||||
"yourSpendings": "你的总计开销",
|
||||
"yourEarnings": "你的总计收入",
|
||||
"yourShare": "你的总计份额"
|
||||
}
|
||||
},
|
||||
"Activity": {
|
||||
"title": "活动",
|
||||
"description": "该群组所有活动总览",
|
||||
"noActivity": "你的群组目前没有任何活动。",
|
||||
"someone": "某人",
|
||||
"settingsModified": "群组设定已被<strong>{participant}</strong>更改。",
|
||||
"expenseCreated": "消费 <em>{expense}</em> 由 <strong>{participant}</strong> 创建。",
|
||||
"expenseUpdated": "消费 <em>{expense}</em> 由 <strong>{participant}</strong> 更新。",
|
||||
"expenseDeleted": "消费 <em>{expense}</em> 由 <strong>{participant}</strong> 删除。",
|
||||
"Groups": {
|
||||
"today": "今天",
|
||||
"yesterday": "昨天",
|
||||
"earlierThisWeek": "这周早些时候",
|
||||
"lastWeek": "上周",
|
||||
"earlierThisMonth": "这月早些时候",
|
||||
"lastMonth": "上月",
|
||||
"earlierThisYear": "这年早些时候",
|
||||
"lastYear": "上年",
|
||||
"older": "更早"
|
||||
}
|
||||
},
|
||||
"Information": {
|
||||
"title": "信息",
|
||||
"description": "使用此处以添加与群组成员相关的任何信息。",
|
||||
"empty": "当前没有群组信息。"
|
||||
},
|
||||
"Settings": {
|
||||
"title": "设定"
|
||||
},
|
||||
"Share": {
|
||||
"title": "分享",
|
||||
"description": "请将此URL分享给其他群组成员,以使其可以查看群组并添加消费。",
|
||||
"warning": "警告!",
|
||||
"warningHelp": "任何持有群组URL的个体都有能够查看并编辑消费。请谨慎分享!"
|
||||
},
|
||||
"SchemaErrors": {
|
||||
"min1": "输入至少1个字符。",
|
||||
"min2": "输入至少2个字符。",
|
||||
"max5": "输入至少5个字符。",
|
||||
"max50": "输入至少50个字符。",
|
||||
"duplicateParticipantName": "此名字已被另一位群组成员占用。",
|
||||
"titleRequired": "请输入标题。",
|
||||
"invalidNumber": "无效数值。",
|
||||
"amountRequired": "你必须输入一个金额。",
|
||||
"amountNotZero": "金额不可以为0。",
|
||||
"amountTenMillion": "金额必须小于10,000,000。",
|
||||
"paidByRequired": "你必须选择一个群组成员。",
|
||||
"paidForMin1": "这项消费必须支付给至少1名群组成员。",
|
||||
"noZeroShares": "所有份额必须大于0。",
|
||||
"amountSum": "金额之和必须等于消费的金额。",
|
||||
"percentageSum": "百分比之和必须等于100。"
|
||||
},
|
||||
"Categories": {
|
||||
"search": "搜寻类别……",
|
||||
"noCategory": "未找到类别。",
|
||||
"Uncategorized": {
|
||||
"heading": "未分类",
|
||||
"General": "一般",
|
||||
"Payment": "支付"
|
||||
},
|
||||
"Entertainment": {
|
||||
"heading": "娱乐",
|
||||
"Entertainment": "娱乐",
|
||||
"Games": "游戏",
|
||||
"Movies": "电影",
|
||||
"Music": "音乐",
|
||||
"Sports": "运动"
|
||||
},
|
||||
"Food and Drink": {
|
||||
"heading": "饮食",
|
||||
"Food and Drink": "饮食",
|
||||
"Dining Out": "下馆子",
|
||||
"Groceries": "便利店",
|
||||
"Liquor": "酒水"
|
||||
},
|
||||
"Home": {
|
||||
"heading": "居家",
|
||||
"Home": "居家",
|
||||
"Electronics": "电费",
|
||||
"Furniture": "家具",
|
||||
"Household Supplies": "家庭日用品",
|
||||
"Maintenance": "维护",
|
||||
"Mortgage": "贷款",
|
||||
"Pets": "宠物",
|
||||
"Rent": "租金",
|
||||
"Services": "服务"
|
||||
},
|
||||
"Life": {
|
||||
"heading": "生活",
|
||||
"Childcare": "儿童保育",
|
||||
"Clothing": "衣物",
|
||||
"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": "水"
|
||||
}
|
||||
}
|
||||
}
|
||||
388
messages/zh-TW.json
Normal file
388
messages/zh-TW.json
Normal file
@@ -0,0 +1,388 @@
|
||||
{
|
||||
"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",
|
||||
"searchPlaceholder": "搜尋消費紀錄……",
|
||||
"ActiveUserModal": {
|
||||
"title": "你是誰?",
|
||||
"description": "告訴我們您在群組中的身份,以調整我們顯示資訊的方式。",
|
||||
"nobody": "我不想選擇任何人",
|
||||
"save": "儲存更改",
|
||||
"footer": "此設定可稍後在群組設定中更改。"
|
||||
},
|
||||
"Groups": {
|
||||
"upcoming": "即將到來",
|
||||
"thisWeek": "本週",
|
||||
"earlierThisMonth": "本月稍早",
|
||||
"lastMonth": "上個月",
|
||||
"earlierThisYear": "今年稍早",
|
||||
"lastYera": "去年",
|
||||
"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": "水費"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,8 @@
|
||||
/**
|
||||
import createNextIntlPlugin from 'next-intl/plugin'
|
||||
|
||||
const withNextIntl = createNextIntlPlugin()
|
||||
|
||||
/**
|
||||
* Undefined entries are not supported. Push optional patterns to this array only if defined.
|
||||
* @type {import('next/dist/shared/lib/image-config').RemotePattern}
|
||||
*/
|
||||
@@ -23,6 +27,12 @@ const nextConfig = {
|
||||
images: {
|
||||
remotePatterns
|
||||
},
|
||||
// Required to run in a codespace (see https://github.com/vercel/next.js/issues/58019)
|
||||
experimental: {
|
||||
serverActions: {
|
||||
allowedOrigins: ['localhost:3000'],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
module.exports = nextConfig
|
||||
export default withNextIntl(nextConfig)
|
||||
10524
package-lock.json
generated
10524
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
33
package.json
33
package.json
@@ -12,9 +12,11 @@
|
||||
"prettier": "prettier -w src",
|
||||
"postinstall": "prisma migrate deploy && prisma generate",
|
||||
"build-image": "./scripts/build-image.sh",
|
||||
"start-container": "docker compose --env-file container.env up"
|
||||
"start-container": "docker compose --env-file container.env up",
|
||||
"test": "jest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@formatjs/intl-localematcher": "^0.5.4",
|
||||
"@hookform/resolvers": "^3.3.2",
|
||||
"@prisma/client": "^5.6.0",
|
||||
"@radix-ui/react-checkbox": "^1.0.4",
|
||||
@@ -31,7 +33,12 @@
|
||||
"@radix-ui/react-tabs": "^1.0.4",
|
||||
"@radix-ui/react-toast": "^1.1.5",
|
||||
"@tailwindcss/typography": "^0.5.10",
|
||||
"@tanstack/react-query": "^5.59.15",
|
||||
"@trpc/client": "^11.0.0-rc.586",
|
||||
"@trpc/react-query": "^11.0.0-rc.586",
|
||||
"@trpc/server": "^11.0.0-rc.586",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"client-only": "^0.0.1",
|
||||
"clsx": "^2.0.0",
|
||||
"cmdk": "^0.2.0",
|
||||
"content-disposition": "^0.5.4",
|
||||
@@ -39,27 +46,38 @@
|
||||
"embla-carousel-react": "^8.0.0-rc21",
|
||||
"lucide-react": "^0.290.0",
|
||||
"nanoid": "^5.0.4",
|
||||
"next": "^14.1.0",
|
||||
"negotiator": "^0.6.3",
|
||||
"next": "^14.2.5",
|
||||
"next-intl": "^3.17.2",
|
||||
"next-s3-upload": "^0.3.4",
|
||||
"next-themes": "^0.2.1",
|
||||
"next13-progressbar": "^1.1.1",
|
||||
"openai": "^4.25.0",
|
||||
"pg": "^8.11.3",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"prisma": "^5.7.0",
|
||||
"react": "^18.3.1",
|
||||
"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",
|
||||
"prisma": "^5.7.0"
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/jest-dom": "^6.4.8",
|
||||
"@testing-library/react": "^16.0.0",
|
||||
"@total-typescript/ts-reset": "^0.5.1",
|
||||
"@types/content-disposition": "^0.5.8",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/negotiator": "^0.6.3",
|
||||
"@types/node": "^20",
|
||||
"@types/pg": "^8.10.9",
|
||||
"@types/react": "^18.2.48",
|
||||
@@ -69,10 +87,13 @@
|
||||
"dotenv": "^16.3.1",
|
||||
"eslint": "^8",
|
||||
"eslint-config-next": "^14.1.0",
|
||||
"jest": "^29.7.0",
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"postcss": "^8",
|
||||
"prettier": "^3.0.3",
|
||||
"prettier-plugin-organize-imports": "^3.2.3",
|
||||
"tailwindcss": "^3",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Expense" ADD COLUMN "notes" TEXT;
|
||||
@@ -0,0 +1,18 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ActivityType" AS ENUM ('UPDATE_GROUP', 'CREATE_EXPENSE', 'UPDATE_EXPENSE', 'DELETE_EXPENSE');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Activity" (
|
||||
"id" TEXT NOT NULL,
|
||||
"groupId" TEXT NOT NULL,
|
||||
"time" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"activityType" "ActivityType" NOT NULL,
|
||||
"participantId" TEXT,
|
||||
"expenseId" TEXT,
|
||||
"data" TEXT,
|
||||
|
||||
CONSTRAINT "Activity_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Activity" ADD CONSTRAINT "Activity_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "Group"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Group" ADD COLUMN "information" TEXT;
|
||||
@@ -14,9 +14,11 @@ datasource db {
|
||||
model Group {
|
||||
id String @id
|
||||
name String
|
||||
information String? @db.Text
|
||||
currency String @default("$")
|
||||
participants Participant[]
|
||||
expenses Expense[]
|
||||
activities Activity[]
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
@@ -52,6 +54,7 @@ model Expense {
|
||||
splitMode SplitMode @default(EVENLY)
|
||||
createdAt DateTime @default(now())
|
||||
documents ExpenseDocument[]
|
||||
notes String?
|
||||
}
|
||||
|
||||
model ExpenseDocument {
|
||||
@@ -79,3 +82,21 @@ model ExpensePaidFor {
|
||||
|
||||
@@id([expenseId, participantId])
|
||||
}
|
||||
|
||||
model Activity {
|
||||
id String @id
|
||||
group Group @relation(fields: [groupId], references: [id])
|
||||
groupId String
|
||||
time DateTime @default(now())
|
||||
activityType ActivityType
|
||||
participantId String?
|
||||
expenseId String?
|
||||
data String?
|
||||
}
|
||||
|
||||
enum ActivityType {
|
||||
UPDATE_GROUP
|
||||
CREATE_EXPENSE
|
||||
UPDATE_EXPENSE
|
||||
DELETE_EXPENSE
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
result=$(docker ps | grep postgres)
|
||||
result=$(docker ps | grep spliit-db)
|
||||
if [ $? -eq 0 ];
|
||||
then
|
||||
echo "postgres is already running, doing nothing"
|
||||
@@ -6,6 +6,6 @@ else
|
||||
echo "postgres is not running, starting it"
|
||||
docker rm postgres --force
|
||||
mkdir -p postgres-data
|
||||
docker run --name postgres -d -p 5432:5432 -e POSTGRES_PASSWORD=1234 -v ./postgres-data:/var/lib/postgresql/data postgres
|
||||
docker run --name spliit-db -d -p 5432:5432 -e POSTGRES_PASSWORD=1234 -v "/$(pwd)/postgres-data:/var/lib/postgresql/data" postgres
|
||||
sleep 5 # Wait for postgres to start
|
||||
fi
|
||||
|
||||
13
src/app/api/trpc/[trpc]/route.ts
Normal file
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%;
|
||||
|
||||
96
src/app/groups/[groupId]/activity/activity-item.tsx
Normal file
96
src/app/groups/[groupId]/activity/activity-item.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
'use client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DateTimeStyle, cn, formatDate } from '@/lib/utils'
|
||||
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
|
||||
dateStyle: DateTimeStyle
|
||||
}
|
||||
|
||||
function useSummary(activity: Activity, participantName?: string) {
|
||||
const t = useTranslations('Activity')
|
||||
const participant = participantName ?? t('someone')
|
||||
const expense = activity.data ?? ''
|
||||
|
||||
const tr = (key: string) =>
|
||||
t.rich(key, {
|
||||
expense,
|
||||
participant,
|
||||
em: (chunks) => <em>“{chunks}”</em>,
|
||||
strong: (chunks) => <strong>{chunks}</strong>,
|
||||
})
|
||||
|
||||
if (activity.activityType == ActivityType.UPDATE_GROUP) {
|
||||
return <>{tr('settingsModified')}</>
|
||||
} else if (activity.activityType == ActivityType.CREATE_EXPENSE) {
|
||||
return <>{tr('expenseCreated')}</>
|
||||
} else if (activity.activityType == ActivityType.UPDATE_EXPENSE) {
|
||||
return <>{tr('expenseUpdated')}</>
|
||||
} else if (activity.activityType == ActivityType.DELETE_EXPENSE) {
|
||||
return <>{tr('expenseDeleted')}</>
|
||||
}
|
||||
}
|
||||
|
||||
export function ActivityItem({
|
||||
groupId,
|
||||
activity,
|
||||
participant,
|
||||
dateStyle,
|
||||
}: Props) {
|
||||
const router = useRouter()
|
||||
const locale = useLocale()
|
||||
|
||||
const expenseExists = activity.expense !== undefined
|
||||
const summary = useSummary(activity, participant?.name)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex justify-between sm:rounded-lg px-2 sm:pr-1 sm:pl-2 py-2 text-sm hover:bg-accent gap-1 items-stretch',
|
||||
expenseExists && 'cursor-pointer',
|
||||
)}
|
||||
onClick={() => {
|
||||
if (expenseExists) {
|
||||
router.push(`/groups/${groupId}/expenses/${activity.expenseId}/edit`)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col justify-between items-start">
|
||||
{dateStyle !== undefined && (
|
||||
<div className="mt-1 text-xs/5 text-muted-foreground">
|
||||
{formatDate(activity.time, locale, { dateStyle })}
|
||||
</div>
|
||||
)}
|
||||
<div className="my-1 text-xs/5 text-muted-foreground">
|
||||
{formatDate(activity.time, locale, { timeStyle: 'short' })}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="m-1">{summary}</div>
|
||||
</div>
|
||||
{expenseExists && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="link"
|
||||
className="self-center hidden sm:flex w-5 h-5"
|
||||
asChild
|
||||
>
|
||||
<Link href={`/groups/${groupId}/expenses/${activity.expenseId}/edit`}>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
155
src/app/groups/[groupId]/activity/activity-list.tsx
Normal file
155
src/app/groups/[groupId]/activity/activity-list.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
'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'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
const DATE_GROUPS = {
|
||||
TODAY: 'today',
|
||||
YESTERDAY: 'yesterday',
|
||||
EARLIER_THIS_WEEK: 'earlierThisWeek',
|
||||
LAST_WEEK: 'lastWeek',
|
||||
EARLIER_THIS_MONTH: 'earlierThisMonth',
|
||||
LAST_MONTH: 'lastMonth',
|
||||
EARLIER_THIS_YEAR: 'earlierThisYear',
|
||||
LAST_YEAR: 'lastYear',
|
||||
OLDER: 'older',
|
||||
}
|
||||
|
||||
function getDateGroup(date: Dayjs, today: Dayjs) {
|
||||
if (today.isSame(date, 'day')) {
|
||||
return DATE_GROUPS.TODAY
|
||||
} else if (today.subtract(1, 'day').isSame(date, 'day')) {
|
||||
return DATE_GROUPS.YESTERDAY
|
||||
} else if (today.isSame(date, 'week')) {
|
||||
return DATE_GROUPS.EARLIER_THIS_WEEK
|
||||
} else if (today.subtract(1, 'week').isSame(date, 'week')) {
|
||||
return DATE_GROUPS.LAST_WEEK
|
||||
} else if (today.isSame(date, 'month')) {
|
||||
return DATE_GROUPS.EARLIER_THIS_MONTH
|
||||
} else if (today.subtract(1, 'month').isSame(date, 'month')) {
|
||||
return DATE_GROUPS.LAST_MONTH
|
||||
} else if (today.isSame(date, 'year')) {
|
||||
return DATE_GROUPS.EARLIER_THIS_YEAR
|
||||
} else if (today.subtract(1, 'year').isSame(date, 'year')) {
|
||||
return DATE_GROUPS.LAST_YEAR
|
||||
} else {
|
||||
return DATE_GROUPS.OLDER
|
||||
}
|
||||
}
|
||||
|
||||
function getGroupedActivitiesByDate(activities: Activity[]) {
|
||||
const today = dayjs()
|
||||
return activities.reduce(
|
||||
(result, activity) => {
|
||||
const activityGroup = getDateGroup(dayjs(activity.time), today)
|
||||
result[activityGroup] = result[activityGroup] ?? []
|
||||
result[activityGroup].push(activity)
|
||||
return result
|
||||
},
|
||||
{} as {
|
||||
[key: string]: Activity[]
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
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 ? (
|
||||
<>
|
||||
{Object.values(DATE_GROUPS).map((dateGroup: string) => {
|
||||
let groupActivities = groupedActivitiesByDate[dateGroup]
|
||||
if (!groupActivities || groupActivities.length === 0) return null
|
||||
const dateStyle =
|
||||
dateGroup == DATE_GROUPS.TODAY || dateGroup == DATE_GROUPS.YESTERDAY
|
||||
? undefined
|
||||
: 'medium'
|
||||
|
||||
return (
|
||||
<div key={dateGroup}>
|
||||
<div
|
||||
className={
|
||||
'text-muted-foreground text-xs py-1 font-semibold sticky top-16 bg-white dark:bg-[#1b1917]'
|
||||
}
|
||||
>
|
||||
{t(`Groups.${dateGroup}`)}
|
||||
</div>
|
||||
{groupActivities.map((activity) => {
|
||||
const participant =
|
||||
activity.participantId !== null
|
||||
? group.participants.find(
|
||||
(p) => p.id === activity.participantId,
|
||||
)
|
||||
: undefined
|
||||
return (
|
||||
<ActivityItem
|
||||
key={activity.id}
|
||||
groupId={groupId}
|
||||
activity={activity}
|
||||
participant={participant}
|
||||
dateStyle={dateStyle}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{hasMore && <ActivitiesLoading ref={loadingRef} />}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm py-6">{t('noActivity')}</p>
|
||||
)
|
||||
}
|
||||
32
src/app/groups/[groupId]/activity/page.client.tsx
Normal file
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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
10
src/app/groups/[groupId]/activity/page.tsx
Normal file
10
src/app/groups/[groupId]/activity/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { ActivityPageClient } from '@/app/groups/[groupId]/activity/page.client'
|
||||
import { Metadata } from 'next'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Activity',
|
||||
}
|
||||
|
||||
export default async function ActivityPage() {
|
||||
return <ActivityPageClient />
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Balances } from '@/lib/balances'
|
||||
import { cn, formatCurrency } from '@/lib/utils'
|
||||
import { Participant } from '@prisma/client'
|
||||
import { useLocale } from 'next-intl'
|
||||
|
||||
type Props = {
|
||||
balances: Balances
|
||||
@@ -9,6 +10,7 @@ type Props = {
|
||||
}
|
||||
|
||||
export function BalancesList({ balances, participants, currency }: Props) {
|
||||
const locale = useLocale()
|
||||
const maxBalance = Math.max(
|
||||
...Object.values(balances).map((b) => Math.abs(b.total)),
|
||||
)
|
||||
@@ -28,7 +30,7 @@ export function BalancesList({ balances, participants, currency }: Props) {
|
||||
</div>
|
||||
<div className={cn('w-1/2 relative', isLeft || 'text-right')}>
|
||||
<div className="absolute inset-0 p-2 z-20">
|
||||
{formatCurrency(currency, balance)}
|
||||
{formatCurrency(currency, balance, locale)}
|
||||
</div>
|
||||
{balance !== 0 && (
|
||||
<div
|
||||
|
||||
@@ -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,73 +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 { notFound } from 'next/navigation'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Balances',
|
||||
}
|
||||
|
||||
export default async function GroupPage({
|
||||
params: { groupId },
|
||||
}: {
|
||||
params: { groupId: string }
|
||||
}) {
|
||||
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>Balances</CardTitle>
|
||||
<CardDescription>
|
||||
This is the amount that each participant paid or was paid for.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<BalancesList
|
||||
balances={publicBalances}
|
||||
participants={group.participants}
|
||||
currency={group.currency}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="mb-4">
|
||||
<CardHeader>
|
||||
<CardTitle>Suggested reimbursements</CardTitle>
|
||||
<CardDescription>
|
||||
Here are suggestions for optimized reimbursements between
|
||||
participants.
|
||||
</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
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
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) {
|
||||
'use server'
|
||||
const groupFormValues = groupFormSchema.parse(values)
|
||||
const group = await updateGroup(groupId, groupFormValues)
|
||||
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) {
|
||||
'use server'
|
||||
const expenseFormValues = expenseFormSchema.parse(values)
|
||||
await updateExpense(groupId, expenseId, expenseFormValues)
|
||||
redirect(`/groups/${groupId}`)
|
||||
}
|
||||
|
||||
async function deleteExpenseAction() {
|
||||
'use server'
|
||||
await deleteExpense(expenseId)
|
||||
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()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
45
src/app/groups/[groupId]/expenses/active-user-balance.tsx
Normal file
45
src/app/groups/[groupId]/expenses/active-user-balance.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
'use client'
|
||||
import { Money } from '@/components/money'
|
||||
import { getBalances } from '@/lib/balances'
|
||||
import { useActiveUser } from '@/lib/hooks'
|
||||
import { useTranslations } from 'next-intl'
|
||||
|
||||
type Props = {
|
||||
groupId: string
|
||||
currency: string
|
||||
expense: Parameters<typeof getBalances>[0][number]
|
||||
}
|
||||
|
||||
export function ActiveUserBalance({ groupId, currency, expense }: Props) {
|
||||
const t = useTranslations('ExpenseCard')
|
||||
const activeUserId = useActiveUser(groupId)
|
||||
if (activeUserId === null || activeUserId === '' || activeUserId === 'None') {
|
||||
return null
|
||||
}
|
||||
|
||||
const balances = getBalances([expense])
|
||||
let fmtBalance = <>You are not involved</>
|
||||
if (Object.hasOwn(balances, activeUserId)) {
|
||||
const balance = balances[activeUserId]
|
||||
let balanceDetail = <></>
|
||||
if (balance.paid > 0 && balance.paidFor > 0) {
|
||||
balanceDetail = (
|
||||
<>
|
||||
{' ('}
|
||||
<Money {...{ currency, amount: balance.paid }} />
|
||||
{' - '}
|
||||
<Money {...{ currency, amount: balance.paidFor }} />
|
||||
{')'}
|
||||
</>
|
||||
)
|
||||
}
|
||||
fmtBalance = (
|
||||
<>
|
||||
{t('yourBalance')}{' '}
|
||||
<Money {...{ currency, amount: balance.total }} bold colored />
|
||||
{balanceDetail}
|
||||
</>
|
||||
)
|
||||
}
|
||||
return <div className="text-xs text-muted-foreground">{fmtBalance}</div>
|
||||
}
|
||||
@@ -12,27 +12,30 @@ import {
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
} 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) {
|
||||
@@ -41,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')
|
||||
}
|
||||
@@ -52,16 +57,13 @@ export function ActiveUserModal({ group }: Props) {
|
||||
<Dialog open={open} onOpenChange={updateOpen}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Who are you?</DialogTitle>
|
||||
<DialogDescription>
|
||||
Tell us which participant you are to let us customize how the
|
||||
information is displayed.
|
||||
</DialogDescription>
|
||||
<DialogTitle>{t('title')}</DialogTitle>
|
||||
<DialogDescription>{t('description')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ActiveUserForm group={group} close={() => setOpen(false)} />
|
||||
<DialogFooter className="sm:justify-center">
|
||||
<p className="text-sm text-center text-muted-foreground">
|
||||
This setting can be changed later in the group settings.
|
||||
{t('footer')}
|
||||
</p>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
@@ -73,11 +75,8 @@ export function ActiveUserModal({ group }: Props) {
|
||||
<Drawer open={open} onOpenChange={updateOpen}>
|
||||
<DrawerContent>
|
||||
<DrawerHeader className="text-left">
|
||||
<DrawerTitle>Who are you?</DrawerTitle>
|
||||
<DrawerDescription>
|
||||
Tell us which participant you are to let us customize how the
|
||||
information is displayed.
|
||||
</DrawerDescription>
|
||||
<DrawerTitle>{t('title')}</DrawerTitle>
|
||||
<DialogDescription>{t('description')}</DialogDescription>
|
||||
</DrawerHeader>
|
||||
<ActiveUserForm
|
||||
className="px-4"
|
||||
@@ -86,7 +85,7 @@ export function ActiveUserModal({ group }: Props) {
|
||||
/>
|
||||
<DrawerFooter className="pt-2">
|
||||
<p className="text-sm text-center text-muted-foreground">
|
||||
This setting can be changed later in the group settings.
|
||||
{t('footer')}
|
||||
</p>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
@@ -98,13 +97,19 @@ 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')
|
||||
|
||||
return (
|
||||
<form
|
||||
className={cn('grid items-start gap-4', className)}
|
||||
onSubmit={(event) => {
|
||||
if (!group) return
|
||||
|
||||
event.preventDefault()
|
||||
localStorage.setItem(`${group.id}-activeUser`, selected)
|
||||
close()
|
||||
@@ -115,10 +120,10 @@ function ActiveUserForm({
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="none" id="none" />
|
||||
<Label htmlFor="none" className="italic font-normal flex-1">
|
||||
I don’t want to select anyone
|
||||
{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">
|
||||
@@ -128,7 +133,7 @@ function ActiveUserForm({
|
||||
))}
|
||||
</div>
|
||||
</RadioGroup>
|
||||
<Button type="submit">Save changes</Button>
|
||||
<Button type="submit">{t('save')}</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
45
src/app/groups/[groupId]/expenses/create-expense-form.tsx
Normal file
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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -12,7 +12,7 @@ export async function extractExpenseInformationFromImage(imageUrl: string) {
|
||||
const categories = await getCategories()
|
||||
|
||||
const body: ChatCompletionCreateParamsNonStreaming = {
|
||||
model: 'gpt-4-vision-preview',
|
||||
model: 'gpt-4-turbo',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
|
||||
@@ -26,27 +26,59 @@ import {
|
||||
import { ToastAction } from '@/components/ui/toast'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { useMediaQuery } from '@/lib/hooks'
|
||||
import { formatCurrency, formatExpenseDate, formatFileSize } from '@/lib/utils'
|
||||
import { Category } from '@prisma/client'
|
||||
import { formatCurrency, formatDate, formatFileSize } from '@/lib/utils'
|
||||
import { trpc } from '@/trpc/client'
|
||||
import { ChevronRight, FileQuestion, Loader2, Receipt } from 'lucide-react'
|
||||
import { useLocale, useTranslations } from 'next-intl'
|
||||
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)
|
||||
const { uploadToS3, FileInput, openFileDialog } = usePresignedUpload()
|
||||
const { toast } = useToast()
|
||||
@@ -55,15 +87,15 @@ 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) {
|
||||
toast({
|
||||
title: 'The file is too big',
|
||||
description: `The maximum file size you can upload is ${formatFileSize(
|
||||
MAX_FILE_SIZE,
|
||||
)}. Yours is ${formatFileSize(file.size)}.`,
|
||||
title: t('TooBigToast.title'),
|
||||
description: t('TooBigToast.description', {
|
||||
maxSize: formatFileSize(MAX_FILE_SIZE, locale),
|
||||
size: formatFileSize(file.size, locale),
|
||||
}),
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
@@ -82,13 +114,15 @@ export function CreateFromReceiptButton({
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
toast({
|
||||
title: 'Error while uploading document',
|
||||
description:
|
||||
'Something wrong happened when uploading the document. Please retry later or select a different file.',
|
||||
title: t('ErrorToast.title'),
|
||||
description: t('ErrorToast.description'),
|
||||
variant: 'destructive',
|
||||
action: (
|
||||
<ToastAction altText="Retry" onClick={() => upload()}>
|
||||
Retry
|
||||
<ToastAction
|
||||
altText={t('ErrorToast.retry')}
|
||||
onClick={() => upload()}
|
||||
>
|
||||
{t('ErrorToast.retry')}
|
||||
</ToastAction>
|
||||
),
|
||||
})
|
||||
@@ -101,162 +135,139 @@ 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="Create expense from receipt"
|
||||
>
|
||||
<Receipt className="w-4 h-4" />
|
||||
</Button>
|
||||
}
|
||||
title={
|
||||
<>
|
||||
<span>Create from receipt</span>
|
||||
<Badge className="bg-pink-700 hover:bg-pink-600 dark:bg-pink-500 dark:hover:bg-pink-600">
|
||||
Beta
|
||||
</Badge>
|
||||
</>
|
||||
}
|
||||
description={<>Extract the expense information from a receipt photo.</>}
|
||||
>
|
||||
<div className="prose prose-sm dark:prose-invert">
|
||||
<p>
|
||||
Upload the photo of a receipt, and we’ll scan it to extract the
|
||||
expense information if we can.
|
||||
</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">
|
||||
Select image…
|
||||
</span>
|
||||
'' || '…'
|
||||
)}
|
||||
</Button>
|
||||
<div className="col-span-2">
|
||||
<strong>Title:</strong>
|
||||
<div>{receiptInfo ? receiptInfo.title ?? <Unknown /> : '…'}</div>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<strong>Category:</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>Amount:</strong>
|
||||
<div>
|
||||
{receiptInfo ? (
|
||||
receiptInfo.amount ? (
|
||||
<>{formatCurrency(groupCurrency, receiptInfo.amount)}</>
|
||||
) : (
|
||||
<Unknown />
|
||||
)
|
||||
{receiptInfo && group ? (
|
||||
receiptInfo.amount ? (
|
||||
<>
|
||||
{formatCurrency(
|
||||
group.currency,
|
||||
receiptInfo.amount,
|
||||
locale,
|
||||
true,
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
'…'
|
||||
)}
|
||||
</div>
|
||||
<Unknown />
|
||||
)
|
||||
) : (
|
||||
'…'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{t('Dialog.dateLabel')}</strong>
|
||||
<div>
|
||||
<strong>Date:</strong>
|
||||
<div>
|
||||
{receiptInfo ? (
|
||||
receiptInfo.date ? (
|
||||
formatExpenseDate(
|
||||
new Date(`${receiptInfo?.date}T12:00:00.000Z`),
|
||||
)
|
||||
) : (
|
||||
<Unknown />
|
||||
{receiptInfo ? (
|
||||
receiptInfo.date ? (
|
||||
formatDate(
|
||||
new Date(`${receiptInfo?.date}T12:00:00.000Z`),
|
||||
locale,
|
||||
{ dateStyle: 'medium' },
|
||||
)
|
||||
) : (
|
||||
'…'
|
||||
)}
|
||||
</div>
|
||||
<Unknown />
|
||||
)
|
||||
) : (
|
||||
'…'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p>You’ll be able to edit the expense information next.</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}`,
|
||||
)
|
||||
}}
|
||||
>
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
function Unknown() {
|
||||
const t = useTranslations('CreateFromReceipt')
|
||||
return (
|
||||
<div className="flex gap-1 items-center text-muted-foreground">
|
||||
<FileQuestion className="w-4 h-4" />
|
||||
<em>Unknown</em>
|
||||
<em>{t('unknown')}</em>
|
||||
</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) {
|
||||
'use server'
|
||||
const expenseFormValues = expenseFormSchema.parse(values)
|
||||
await createExpense(expenseFormValues, groupId)
|
||||
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
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
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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
98
src/app/groups/[groupId]/expenses/expense-card.tsx
Normal file
98
src/app/groups/[groupId]/expenses/expense-card.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
'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'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import { useLocale, useTranslations } from 'next-intl'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Fragment } from 'react'
|
||||
|
||||
type Expense = Awaited<ReturnType<typeof getGroupExpenses>>[number]
|
||||
|
||||
function Participants({ expense }: { expense: Expense }) {
|
||||
const t = useTranslations('ExpenseCard')
|
||||
const key = expense.amount > 0 ? 'paidBy' : 'receivedBy'
|
||||
const paidFor = expense.paidFor.map((paidFor, index) => (
|
||||
<Fragment key={index}>
|
||||
{index !== 0 && <>, </>}
|
||||
<strong>{paidFor.participant.name}</strong>
|
||||
</Fragment>
|
||||
))
|
||||
const participants = t.rich(key, {
|
||||
strong: (chunks) => <strong>{chunks}</strong>,
|
||||
paidBy: expense.paidBy.name,
|
||||
paidFor: () => paidFor,
|
||||
forCount: expense.paidFor.length,
|
||||
})
|
||||
return <>{participants}</>
|
||||
}
|
||||
|
||||
type Props = {
|
||||
expense: Expense
|
||||
currency: string
|
||||
groupId: string
|
||||
}
|
||||
|
||||
export function ExpenseCard({ expense, currency, groupId }: Props) {
|
||||
const router = useRouter()
|
||||
const locale = useLocale()
|
||||
|
||||
return (
|
||||
<div
|
||||
key={expense.id}
|
||||
className={cn(
|
||||
'flex justify-between sm:mx-6 px-4 sm:rounded-lg sm:pr-2 sm:pl-4 py-4 text-sm cursor-pointer hover:bg-accent gap-1 items-stretch',
|
||||
expense.isReimbursement && 'italic',
|
||||
)}
|
||||
onClick={() => {
|
||||
router.push(`/groups/${groupId}/expenses/${expense.id}/edit`)
|
||||
}}
|
||||
>
|
||||
<CategoryIcon
|
||||
category={expense.category}
|
||||
className="w-4 h-4 mr-2 mt-0.5 text-muted-foreground"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className={cn('mb-1', expense.isReimbursement && 'italic')}>
|
||||
{expense.title}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<Participants expense={expense} />
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<ActiveUserBalance {...{ groupId, currency, expense }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col justify-between items-end">
|
||||
<div
|
||||
className={cn(
|
||||
'tabular-nums whitespace-nowrap',
|
||||
expense.isReimbursement ? 'italic' : 'font-bold',
|
||||
)}
|
||||
>
|
||||
{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>
|
||||
</div>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="link"
|
||||
className="self-center hidden sm:flex"
|
||||
asChild
|
||||
>
|
||||
<Link href={`/groups/${groupId}/expenses/${expense.id}/edit`}>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</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,45 +32,40 @@ 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 {
|
||||
ExpenseFormValues,
|
||||
SplittingOptions,
|
||||
expenseFormSchema,
|
||||
} from '@/lib/schemas'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { AppRouterOutput } from '@/trpc/routers/_app'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { Save } from 'lucide-react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import Link from 'next/link'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { useState } from 'react'
|
||||
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'
|
||||
|
||||
export type Props = {
|
||||
group: NonNullable<Awaited<ReturnType<typeof getGroup>>>
|
||||
expense?: NonNullable<Awaited<ReturnType<typeof getExpense>>>
|
||||
categories: NonNullable<Awaited<ReturnType<typeof getCategories>>>
|
||||
onSubmit: (values: ExpenseFormValues) => Promise<void>
|
||||
onDelete?: () => 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
|
||||
// replace first comma with #
|
||||
.replace(/[.,]/, '#')
|
||||
// remove all other commas
|
||||
.replace(/[.,]/g, '')
|
||||
// change back # to dot
|
||||
.replace(/#/, '.')
|
||||
// remove all non-numeric and non-dot characters
|
||||
.replace(/[^\d.]/g, '')
|
||||
.replace(/^\s*-/, '_') // replace leading minus with _
|
||||
.replace(/[.,]/, '#') // replace first comma with #
|
||||
.replace(/[-.,]/g, '') // remove other minus and commas characters
|
||||
.replace(/_/, '-') // change back _ to minus
|
||||
.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 }) => ({
|
||||
@@ -145,18 +139,27 @@ 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`)
|
||||
if (activeUser && activeUser !== 'None') {
|
||||
if (activeUser && activeUser !== 'None' && field?.value === undefined) {
|
||||
return activeUser
|
||||
}
|
||||
}
|
||||
@@ -180,10 +183,11 @@ export function ExpenseForm({
|
||||
saveDefaultSplittingOptions: false,
|
||||
isReimbursement: expense.isReimbursement,
|
||||
documents: expense.documents,
|
||||
notes: expense.notes ?? '',
|
||||
}
|
||||
: searchParams.get('reimbursement')
|
||||
? {
|
||||
title: 'Reimbursement',
|
||||
title: t('reimbursement'),
|
||||
expenseDate: new Date(),
|
||||
amount: String(
|
||||
(Number(searchParams.get('amount')) || 0) / 100,
|
||||
@@ -202,6 +206,7 @@ export function ExpenseForm({
|
||||
splitMode: defaultSplittingOptions.splitMode,
|
||||
saveDefaultSplittingOptions: false,
|
||||
documents: [],
|
||||
notes: '',
|
||||
}
|
||||
: {
|
||||
title: searchParams.get('title') ?? '',
|
||||
@@ -228,22 +233,89 @@ export function ExpenseForm({
|
||||
},
|
||||
]
|
||||
: [],
|
||||
notes: '',
|
||||
},
|
||||
})
|
||||
const [isCategoryLoading, setCategoryLoading] = useState(false)
|
||||
const activeUserId = useActiveUser(group.id)
|
||||
|
||||
const submit = async (values: ExpenseFormValues) => {
|
||||
await persistDefaultSplittingOptions(group.id, values)
|
||||
return onSubmit(values)
|
||||
return onSubmit(values, activeUserId ?? undefined)
|
||||
}
|
||||
|
||||
const [isIncome, setIsIncome] = useState(Number(form.getValues().amount) < 0)
|
||||
const [manuallyEditedParticipants, setManuallyEditedParticipants] = useState<
|
||||
Set<string>
|
||||
>(new Set())
|
||||
|
||||
const sExpense = isIncome ? 'Income' : 'Expense'
|
||||
|
||||
useEffect(() => {
|
||||
setManuallyEditedParticipants(new Set())
|
||||
}, [form.watch('splitMode'), form.watch('amount')])
|
||||
|
||||
useEffect(() => {
|
||||
const splitMode = form.getValues().splitMode
|
||||
|
||||
// Only auto-balance for split mode 'Unevenly - By amount'
|
||||
if (
|
||||
splitMode === 'BY_AMOUNT' &&
|
||||
(form.getFieldState('paidFor').isDirty ||
|
||||
form.getFieldState('amount').isDirty)
|
||||
) {
|
||||
const totalAmount = Number(form.getValues().amount) || 0
|
||||
const paidFor = form.getValues().paidFor
|
||||
let newPaidFor = [...paidFor]
|
||||
|
||||
const editedParticipants = Array.from(manuallyEditedParticipants)
|
||||
let remainingAmount = totalAmount
|
||||
let remainingParticipants = newPaidFor.length - editedParticipants.length
|
||||
|
||||
newPaidFor = newPaidFor.map((participant) => {
|
||||
if (editedParticipants.includes(participant.participant)) {
|
||||
const participantShare = Number(participant.shares) || 0
|
||||
if (splitMode === 'BY_AMOUNT') {
|
||||
remainingAmount -= participantShare
|
||||
}
|
||||
return participant
|
||||
}
|
||||
return participant
|
||||
})
|
||||
|
||||
if (remainingParticipants > 0) {
|
||||
let amountPerRemaining = 0
|
||||
if (splitMode === 'BY_AMOUNT') {
|
||||
amountPerRemaining = remainingAmount / remainingParticipants
|
||||
}
|
||||
|
||||
newPaidFor = newPaidFor.map((participant) => {
|
||||
if (!editedParticipants.includes(participant.participant)) {
|
||||
return {
|
||||
...participant,
|
||||
shares: String(
|
||||
Number(amountPerRemaining.toFixed(2)),
|
||||
) as unknown as number,
|
||||
}
|
||||
}
|
||||
return participant
|
||||
})
|
||||
}
|
||||
form.setValue('paidFor', newPaidFor, { shouldValidate: true })
|
||||
}
|
||||
}, [
|
||||
manuallyEditedParticipants,
|
||||
form.watch('amount'),
|
||||
form.watch('splitMode'),
|
||||
])
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(submit)}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{isCreate ? <>Create expense</> : <>Edit expense</>}
|
||||
{t(`${sExpense}.${isCreate ? 'create' : 'edit'}`)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid sm:grid-cols-2 gap-6">
|
||||
@@ -252,10 +324,10 @@ export function ExpenseForm({
|
||||
name="title"
|
||||
render={({ field }) => (
|
||||
<FormItem className="">
|
||||
<FormLabel>Expense title</FormLabel>
|
||||
<FormLabel>{t(`${sExpense}.TitleField.label`)}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Monday evening restaurant"
|
||||
placeholder={t(`${sExpense}.TitleField.placeholder`)}
|
||||
className="text-base"
|
||||
{...field}
|
||||
onBlur={async () => {
|
||||
@@ -272,7 +344,7 @@ export function ExpenseForm({
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Enter a description for the expense.
|
||||
{t(`${sExpense}.TitleField.description`)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -284,7 +356,7 @@ export function ExpenseForm({
|
||||
name="expenseDate"
|
||||
render={({ field }) => (
|
||||
<FormItem className="sm:order-1">
|
||||
<FormLabel>Expense date</FormLabel>
|
||||
<FormLabel>{t(`${sExpense}.DateField.label`)}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="date-base"
|
||||
@@ -296,7 +368,7 @@ export function ExpenseForm({
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Enter the date the expense was made.
|
||||
{t(`${sExpense}.DateField.description`)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -308,44 +380,54 @@ export function ExpenseForm({
|
||||
name="amount"
|
||||
render={({ field: { onChange, ...field } }) => (
|
||||
<FormItem className="sm:order-3">
|
||||
<FormLabel>Amount</FormLabel>
|
||||
<FormLabel>{t('amountField.label')}</FormLabel>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span>{group.currency}</span>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
className="text-base max-w-[120px]"
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
step={0.01}
|
||||
placeholder="0.00"
|
||||
onChange={(event) =>
|
||||
onChange(enforceCurrencyPattern(event.target.value))
|
||||
}
|
||||
onClick={(e) => e.currentTarget.select()}
|
||||
onChange={(event) => {
|
||||
const v = enforceCurrencyPattern(event.target.value)
|
||||
const income = Number(v) < 0
|
||||
setIsIncome(income)
|
||||
if (income) form.setValue('isReimbursement', false)
|
||||
onChange(v)
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
// we're adding a small delay to get around safaris issue with onMouseUp deselecting things again
|
||||
const target = e.currentTarget
|
||||
setTimeout(() => target.select(), 1)
|
||||
}}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
<FormMessage />
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="isReimbursement"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row gap-2 items-center space-y-0 pt-2">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<div>
|
||||
<FormLabel>This is a reimbursement</FormLabel>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{!isIncome && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="isReimbursement"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row gap-2 items-center space-y-0 pt-2">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<div>
|
||||
<FormLabel>
|
||||
{t('isReimbursementField.label')}
|
||||
</FormLabel>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
@@ -355,7 +437,7 @@ export function ExpenseForm({
|
||||
name="category"
|
||||
render={({ field }) => (
|
||||
<FormItem className="order-3 sm:order-2">
|
||||
<FormLabel>Category</FormLabel>
|
||||
<FormLabel>{t('categoryField.label')}</FormLabel>
|
||||
<CategorySelector
|
||||
categories={categories}
|
||||
defaultValue={
|
||||
@@ -365,7 +447,7 @@ export function ExpenseForm({
|
||||
isLoading={isCategoryLoading}
|
||||
/>
|
||||
<FormDescription>
|
||||
Select the expense category.
|
||||
{t(`${sExpense}.categoryFieldDescription`)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -377,7 +459,7 @@ export function ExpenseForm({
|
||||
name="paidBy"
|
||||
render={({ field }) => (
|
||||
<FormItem className="sm:order-5">
|
||||
<FormLabel>Paid by</FormLabel>
|
||||
<FormLabel>{t(`${sExpense}.paidByField.label`)}</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={getSelectedPayer(field)}
|
||||
@@ -394,19 +476,31 @@ export function ExpenseForm({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
Select the participant who paid the expense.
|
||||
{t(`${sExpense}.paidByField.description`)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="notes"
|
||||
render={({ field }) => (
|
||||
<FormItem className="sm:order-6">
|
||||
<FormLabel>{t('notesField.label')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea className="text-base" {...field} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="mt-4">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex justify-between">
|
||||
<span>Paid for</span>
|
||||
<span>{t(`${sExpense}.paidFor.title`)}</span>
|
||||
<Button
|
||||
variant="link"
|
||||
type="button"
|
||||
@@ -432,14 +526,14 @@ export function ExpenseForm({
|
||||
>
|
||||
{form.getValues().paidFor.length ===
|
||||
group.participants.length ? (
|
||||
<>Select none</>
|
||||
<>{t('selectNone')}</>
|
||||
) : (
|
||||
<>Select all</>
|
||||
<>{t('selectAll')}</>
|
||||
)}
|
||||
</Button>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Select who the expense was paid for.
|
||||
{t(`${sExpense}.paidFor.description`)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -468,18 +562,29 @@ export function ExpenseForm({
|
||||
({ participant }) => participant === id,
|
||||
)}
|
||||
onCheckedChange={(checked) => {
|
||||
return checked
|
||||
? field.onChange([
|
||||
...field.value,
|
||||
{
|
||||
participant: id,
|
||||
shares: '1',
|
||||
},
|
||||
])
|
||||
: field.onChange(
|
||||
const options = {
|
||||
shouldDirty: true,
|
||||
shouldTouch: true,
|
||||
shouldValidate: true,
|
||||
}
|
||||
checked
|
||||
? form.setValue(
|
||||
'paidFor',
|
||||
[
|
||||
...field.value,
|
||||
{
|
||||
participant: id,
|
||||
shares: '1' as unknown as number,
|
||||
},
|
||||
],
|
||||
options,
|
||||
)
|
||||
: form.setValue(
|
||||
'paidFor',
|
||||
field.value?.filter(
|
||||
(value) => value.participant !== id,
|
||||
),
|
||||
options,
|
||||
)
|
||||
}}
|
||||
/>
|
||||
@@ -504,7 +609,9 @@ export function ExpenseForm({
|
||||
})}
|
||||
>
|
||||
{match(form.getValues().splitMode)
|
||||
.with('BY_SHARES', () => <>share(s)</>)
|
||||
.with('BY_SHARES', () => (
|
||||
<>{t('shares')}</>
|
||||
))
|
||||
.with('BY_PERCENTAGE', () => <>%</>)
|
||||
.with('BY_AMOUNT', () => (
|
||||
<>{group.currency}</>
|
||||
@@ -541,7 +648,7 @@ export function ExpenseForm({
|
||||
participant === id,
|
||||
)?.shares
|
||||
}
|
||||
onChange={(event) =>
|
||||
onChange={(event) => {
|
||||
field.onChange(
|
||||
field.value.map((p) =>
|
||||
p.participant === id
|
||||
@@ -555,7 +662,10 @@ export function ExpenseForm({
|
||||
: p,
|
||||
),
|
||||
)
|
||||
}
|
||||
setManuallyEditedParticipants(
|
||||
(prev) => new Set(prev).add(id),
|
||||
)
|
||||
}}
|
||||
inputMode={
|
||||
form.getValues().splitMode ===
|
||||
'BY_AMOUNT'
|
||||
@@ -599,7 +709,7 @@ export function ExpenseForm({
|
||||
>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="link" className="-mx-4">
|
||||
Advanced splitting options…
|
||||
{t('advancedOptions')}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
@@ -609,7 +719,7 @@ export function ExpenseForm({
|
||||
name="splitMode"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Split mode</FormLabel>
|
||||
<FormLabel>{t('SplitModeField.label')}</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
@@ -625,21 +735,23 @@ export function ExpenseForm({
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="EVENLY">Evenly</SelectItem>
|
||||
<SelectItem value="EVENLY">
|
||||
{t('SplitModeField.evenly')}
|
||||
</SelectItem>
|
||||
<SelectItem value="BY_SHARES">
|
||||
Unevenly – By shares
|
||||
{t('SplitModeField.byShares')}
|
||||
</SelectItem>
|
||||
<SelectItem value="BY_PERCENTAGE">
|
||||
Unevenly – By percentage
|
||||
{t('SplitModeField.byPercentage')}
|
||||
</SelectItem>
|
||||
<SelectItem value="BY_AMOUNT">
|
||||
Unevenly – By amount
|
||||
{t('SplitModeField.byAmount')}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Select how to split the expense.
|
||||
{t(`${sExpense}.splitModeDescription`)}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -657,7 +769,7 @@ export function ExpenseForm({
|
||||
</FormControl>
|
||||
<div>
|
||||
<FormLabel>
|
||||
Save as default splitting options
|
||||
{t('SplitModeField.saveAsDefault')}
|
||||
</FormLabel>
|
||||
</div>
|
||||
</FormItem>
|
||||
@@ -673,10 +785,10 @@ export function ExpenseForm({
|
||||
<Card className="mt-4">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex justify-between">
|
||||
<span>Attach documents</span>
|
||||
<span>{t('attachDocuments')}</span>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
See and attach receipts to the expense.
|
||||
{t(`${sExpense}.attachDescription`)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -695,17 +807,17 @@ export function ExpenseForm({
|
||||
)}
|
||||
|
||||
<div className="flex mt-4 gap-2">
|
||||
<SubmitButton
|
||||
loadingContent={isCreate ? <>Creating…</> : <>Saving…</>}
|
||||
>
|
||||
<SubmitButton loadingContent={t(isCreate ? 'creating' : 'saving')}>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
{isCreate ? <>Create</> : <>Save</>}
|
||||
{t(isCreate ? 'create' : 'save')}
|
||||
</SubmitButton>
|
||||
{!isCreate && onDelete && (
|
||||
<DeletePopup onDelete={onDelete}></DeletePopup>
|
||||
<DeletePopup
|
||||
onDelete={() => onDelete(activeUserId ?? undefined)}
|
||||
></DeletePopup>
|
||||
)}
|
||||
<Button variant="ghost" asChild>
|
||||
<Link href={`/groups/${group.id}`}>Cancel</Link>
|
||||
<Link href={`/groups/${group.id}`}>{t('cancel')}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -0,0 +1,16 @@
|
||||
'use server'
|
||||
|
||||
import { getGroupExpenses } from '@/lib/api'
|
||||
|
||||
export async function getGroupExpensesAction(
|
||||
groupId: string,
|
||||
options?: { offset: number; length: number },
|
||||
) {
|
||||
'use server'
|
||||
|
||||
try {
|
||||
return getGroupExpenses(groupId, options)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -1,31 +1,32 @@
|
||||
'use client'
|
||||
import { CategoryIcon } from '@/app/groups/[groupId]/expenses/category-icon'
|
||||
import { ExpenseCard } from '@/app/groups/[groupId]/expenses/expense-card'
|
||||
import { getGroupExpensesAction } from '@/app/groups/[groupId]/expenses/expense-list-fetch-action'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { SearchBar } from '@/components/ui/search-bar'
|
||||
import { getGroupExpenses } from '@/lib/api'
|
||||
import { cn, formatCurrency, formatExpenseDate } from '@/lib/utils'
|
||||
import { Expense, Participant } from '@prisma/client'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { trpc } from '@/trpc/client'
|
||||
import dayjs, { type Dayjs } from 'dayjs'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Fragment, useEffect, 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'
|
||||
|
||||
type Props = {
|
||||
expenses: Awaited<ReturnType<typeof getGroupExpenses>>
|
||||
participants: Participant[]
|
||||
currency: string
|
||||
groupId: string
|
||||
}
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
type ExpensesType = NonNullable<
|
||||
Awaited<ReturnType<typeof getGroupExpensesAction>>
|
||||
>
|
||||
|
||||
const EXPENSE_GROUPS = {
|
||||
UPCOMING: 'Upcoming',
|
||||
THIS_WEEK: 'This week',
|
||||
EARLIER_THIS_MONTH: 'Earlier this month',
|
||||
LAST_MONTH: 'Last month',
|
||||
EARLIER_THIS_YEAR: 'Earlier this year',
|
||||
LAST_YEAR: 'Last year',
|
||||
OLDER: 'Older',
|
||||
UPCOMING: 'upcoming',
|
||||
THIS_WEEK: 'thisWeek',
|
||||
EARLIER_THIS_MONTH: 'earlierThisMonth',
|
||||
LAST_MONTH: 'lastMonth',
|
||||
EARLIER_THIS_YEAR: 'earlierThisYear',
|
||||
LAST_YEAR: 'lastYear',
|
||||
OLDER: 'older',
|
||||
}
|
||||
|
||||
function getExpenseGroup(date: Dayjs, today: Dayjs) {
|
||||
@@ -46,29 +47,26 @@ function getExpenseGroup(date: Dayjs, today: Dayjs) {
|
||||
}
|
||||
}
|
||||
|
||||
function getGroupedExpensesByDate(
|
||||
expenses: Awaited<ReturnType<typeof getGroupExpenses>>,
|
||||
) {
|
||||
function getGroupedExpensesByDate(expenses: ExpensesType) {
|
||||
const today = dayjs()
|
||||
return expenses.reduce(
|
||||
(result: { [key: string]: Expense[] }, expense: Expense) => {
|
||||
const expenseGroup = getExpenseGroup(dayjs(expense.expenseDate), today)
|
||||
result[expenseGroup] = result[expenseGroup] ?? []
|
||||
result[expenseGroup].push(expense)
|
||||
return result
|
||||
},
|
||||
{},
|
||||
)
|
||||
return expenses.reduce((result: { [key: string]: ExpensesType }, expense) => {
|
||||
const expenseGroup = getExpenseGroup(dayjs(expense.expenseDate), today)
|
||||
result[expenseGroup] = result[expenseGroup] ?? []
|
||||
result[expenseGroup].push(expense)
|
||||
return result
|
||||
}, {})
|
||||
}
|
||||
|
||||
export function ExpenseList({
|
||||
expenses,
|
||||
currency,
|
||||
participants,
|
||||
groupId,
|
||||
}: Props) {
|
||||
export function ExpenseList() {
|
||||
const { groupId, group } = useCurrentGroup()
|
||||
const [searchText, setSearchText] = useState('')
|
||||
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) {
|
||||
@@ -87,22 +85,77 @@ export function ExpenseList({
|
||||
}
|
||||
}, [groupId, participants])
|
||||
|
||||
const getParticipant = (id: string) => participants.find((p) => p.id === id)
|
||||
const router = useRouter()
|
||||
|
||||
const groupedExpensesByDate = getGroupedExpensesByDate(expenses)
|
||||
return expenses.length > 0 ? (
|
||||
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(() => {
|
||||
// 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 t = useTranslations('Expenses')
|
||||
const { ref: loadingRef, inView } = useInView()
|
||||
|
||||
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
|
||||
|
||||
const isLoading = expensesAreLoading || !expenses || !group
|
||||
|
||||
useEffect(() => {
|
||||
if (inView && hasMore && !isLoading) fetchNextPage()
|
||||
}, [fetchNextPage, hasMore, inView, isLoading])
|
||||
|
||||
const groupedExpensesByDate = useMemo(
|
||||
() => (expenses ? getGroupedExpensesByDate(expenses) : {}),
|
||||
[expenses],
|
||||
)
|
||||
|
||||
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 onChange={(e) => setSearchText(e.target.value)} />
|
||||
{Object.values(EXPENSE_GROUPS).map((expenseGroup: string) => {
|
||||
let groupExpenses = groupedExpensesByDate[expenseGroup]
|
||||
if (!groupExpenses) return null
|
||||
|
||||
groupExpenses = groupExpenses.filter(({ title }) =>
|
||||
title.toLowerCase().includes(searchText.toLowerCase()),
|
||||
)
|
||||
|
||||
if (groupExpenses.length === 0) return null
|
||||
if (!groupExpenses || groupExpenses.length === 0) return null
|
||||
|
||||
return (
|
||||
<div key={expenseGroup}>
|
||||
@@ -111,84 +164,47 @@ export function ExpenseList({
|
||||
'text-muted-foreground text-xs pl-4 sm:pl-6 py-1 font-semibold sticky top-16 bg-white dark:bg-[#1b1917]'
|
||||
}
|
||||
>
|
||||
{expenseGroup}
|
||||
{t(`Groups.${expenseGroup}`)}
|
||||
</div>
|
||||
{groupExpenses.map((expense: any) => (
|
||||
<div
|
||||
{groupExpenses.map((expense) => (
|
||||
<ExpenseCard
|
||||
key={expense.id}
|
||||
className={cn(
|
||||
'flex justify-between sm:mx-6 px-4 sm:rounded-lg sm:pr-2 sm:pl-4 py-4 text-sm cursor-pointer hover:bg-accent gap-1 items-stretch',
|
||||
expense.isReimbursement && 'italic',
|
||||
)}
|
||||
onClick={() => {
|
||||
router.push(`/groups/${groupId}/expenses/${expense.id}/edit`)
|
||||
}}
|
||||
>
|
||||
<CategoryIcon
|
||||
category={expense.category}
|
||||
className="w-4 h-4 mr-2 mt-0.5 text-muted-foreground"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div
|
||||
className={cn('mb-1', expense.isReimbursement && 'italic')}
|
||||
>
|
||||
{expense.title}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Paid by{' '}
|
||||
<strong>{getParticipant(expense.paidById)?.name}</strong>{' '}
|
||||
for{' '}
|
||||
{expense.paidFor.map((paidFor: any, index: number) => (
|
||||
<Fragment key={index}>
|
||||
{index !== 0 && <>, </>}
|
||||
<strong>
|
||||
{
|
||||
participants.find(
|
||||
(p) => p.id === paidFor.participantId,
|
||||
)?.name
|
||||
}
|
||||
</strong>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col justify-between items-end">
|
||||
<div
|
||||
className={cn(
|
||||
'tabular-nums whitespace-nowrap',
|
||||
expense.isReimbursement ? 'italic' : 'font-bold',
|
||||
)}
|
||||
>
|
||||
{formatCurrency(currency, expense.amount)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{formatExpenseDate(expense.expenseDate)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="link"
|
||||
className="self-center hidden sm:flex"
|
||||
asChild
|
||||
>
|
||||
<Link href={`/groups/${groupId}/expenses/${expense.id}/edit`}>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
expense={expense}
|
||||
currency={group.currency}
|
||||
groupId={groupId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{hasMore && <ExpensesLoading ref={loadingRef} />}
|
||||
</>
|
||||
) : (
|
||||
<p className="px-6 text-sm py-6">
|
||||
Your group doesn’t contain any expense yet.{' '}
|
||||
<Button variant="link" asChild className="-m-4">
|
||||
<Link href={`/groups/${groupId}/expenses/create`}>
|
||||
Create the first one
|
||||
</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'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getPrisma } from '@/lib/prisma'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import contentDisposition from 'content-disposition'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
@@ -6,7 +6,6 @@ export async function GET(
|
||||
req: Request,
|
||||
{ params: { groupId } }: { params: { groupId: string } },
|
||||
) {
|
||||
const prisma = await getPrisma()
|
||||
const group = await prisma.group.findUnique({
|
||||
where: { id: groupId },
|
||||
select: {
|
||||
@@ -32,7 +31,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',
|
||||
|
||||
73
src/app/groups/[groupId]/expenses/page.client.tsx
Normal file
73
src/app/groups/[groupId]/expenses/page.client.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
'use client'
|
||||
|
||||
import { ActiveUserModal } from '@/app/groups/[groupId]/expenses/active-user-modal'
|
||||
import { CreateFromReceiptButton } from '@/app/groups/[groupId]/expenses/create-from-receipt-button'
|
||||
import { ExpenseList } from '@/app/groups/[groupId]/expenses/expense-list'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Download, Plus } from 'lucide-react'
|
||||
import { Metadata } from 'next'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import Link from 'next/link'
|
||||
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">
|
||||
<Button variant="secondary" size="icon" asChild>
|
||||
<Link
|
||||
prefetch={false}
|
||||
href={`/groups/${groupId}/expenses/export/json`}
|
||||
target="_blank"
|
||||
title={t('exportJson')}
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
{enableReceiptExtract && <CreateFromReceiptButton />}
|
||||
<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,23 +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, 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 Link from 'next/link'
|
||||
import { notFound } from 'next/navigation'
|
||||
import { Suspense } from 'react'
|
||||
|
||||
export const revalidate = 3600
|
||||
|
||||
@@ -25,89 +8,10 @@ export const metadata: Metadata = {
|
||||
title: 'Expenses',
|
||||
}
|
||||
|
||||
export default async function GroupExpensesPage({
|
||||
params: { groupId },
|
||||
}: {
|
||||
params: { groupId: string }
|
||||
}) {
|
||||
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>Expenses</CardTitle>
|
||||
<CardDescription>
|
||||
Here are the expenses that you created for your group.
|
||||
</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"
|
||||
>
|
||||
<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`}>
|
||||
<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 groupId={groupId} />
|
||||
</Suspense>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<ActiveUserModal group={group} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
async function Expenses({ groupId }: { groupId: string }) {
|
||||
const group = await cached.getGroup(groupId)
|
||||
if (!group) notFound()
|
||||
const expenses = await getGroupExpenses(group.id)
|
||||
|
||||
return (
|
||||
<ExpenseList
|
||||
expenses={expenses}
|
||||
groupId={group.id}
|
||||
currency={group.currency}
|
||||
participants={group.participants}
|
||||
<GroupExpensesPageClient
|
||||
enableReceiptExtract={env.NEXT_PUBLIC_ENABLE_RECEIPT_EXTRACT}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
30
src/app/groups/[groupId]/group-header.tsx
Normal file
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>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
|
||||
type Props = {
|
||||
@@ -7,6 +8,7 @@ type Props = {
|
||||
}
|
||||
|
||||
export function GroupTabs({ groupId }: Props) {
|
||||
const t = useTranslations()
|
||||
const pathname = usePathname()
|
||||
const value =
|
||||
pathname.replace(/\/groups\/[^\/]+\/([^/]+).*/, '$1') || 'expenses'
|
||||
@@ -15,16 +17,18 @@ export function GroupTabs({ groupId }: Props) {
|
||||
return (
|
||||
<Tabs
|
||||
value={value}
|
||||
className="[&>*]:border"
|
||||
className="[&>*]:border overflow-x-auto"
|
||||
onValueChange={(value) => {
|
||||
router.push(`/groups/${groupId}/${value}`)
|
||||
}}
|
||||
>
|
||||
<TabsList>
|
||||
<TabsTrigger value="expenses">Expenses</TabsTrigger>
|
||||
<TabsTrigger value="balances">Balances</TabsTrigger>
|
||||
<TabsTrigger value="stats">Stats</TabsTrigger>
|
||||
<TabsTrigger value="edit">Settings</TabsTrigger>
|
||||
<TabsTrigger value="expenses">{t('Expenses.title')}</TabsTrigger>
|
||||
<TabsTrigger value="balances">{t('Balances.title')}</TabsTrigger>
|
||||
<TabsTrigger value="information">{t('Information.title')}</TabsTrigger>
|
||||
<TabsTrigger value="stats">{t('Stats.title')}</TabsTrigger>
|
||||
<TabsTrigger value="activity">{t('Activity.title')}</TabsTrigger>
|
||||
<TabsTrigger value="edit">{t('Settings.title')}</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
)
|
||||
|
||||
52
src/app/groups/[groupId]/information/group-information.tsx
Normal file
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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
14
src/app/groups/[groupId]/information/page.tsx
Normal file
14
src/app/groups/[groupId]/information/page.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import GroupInformation from '@/app/groups/[groupId]/information/group-information'
|
||||
import { Metadata } from 'next'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Group Information',
|
||||
}
|
||||
|
||||
export default function InformationPage({
|
||||
params: { groupId },
|
||||
}: {
|
||||
params: { groupId: string }
|
||||
}) {
|
||||
return <GroupInformation groupId={groupId} />
|
||||
}
|
||||
49
src/app/groups/[groupId]/layout.client.tsx
Normal file
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 sm:flex-row justify-between sm:items-center 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>
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { Reimbursement } from '@/lib/balances'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
import { Participant } from '@prisma/client'
|
||||
import { useLocale, useTranslations } from 'next-intl'
|
||||
import Link from 'next/link'
|
||||
|
||||
type Props = {
|
||||
@@ -17,33 +18,34 @@ export function ReimbursementList({
|
||||
currency,
|
||||
groupId,
|
||||
}: Props) {
|
||||
const locale = useLocale()
|
||||
const t = useTranslations('Balances.Reimbursements')
|
||||
if (reimbursements.length === 0) {
|
||||
return (
|
||||
<p className="px-6 text-sm pb-6">
|
||||
It looks like your group doesn’t need any reimbursement 😁
|
||||
</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>
|
||||
<strong>{getParticipant(reimbursement.from)?.name}</strong> owes{' '}
|
||||
<strong>{getParticipant(reimbursement.to)?.name}</strong>
|
||||
{t.rich('owes', {
|
||||
from: getParticipant(reimbursement.from)?.name,
|
||||
to: getParticipant(reimbursement.to)?.name,
|
||||
strong: (chunks) => <strong>{chunks}</strong>,
|
||||
})}
|
||||
</div>
|
||||
<Button variant="link" asChild className="-mx-4 -my-3">
|
||||
<Link
|
||||
href={`/groups/${groupId}/expenses/create?reimbursement=yes&from=${reimbursement.from}&to=${reimbursement.to}&amount=${reimbursement.amount}`}
|
||||
>
|
||||
Mark as paid
|
||||
{t('markAsPaid')}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<div>{formatCurrency(currency, reimbursement.amount)}</div>
|
||||
<div>{formatCurrency(currency, reimbursement.amount, locale)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -11,27 +11,26 @@ import {
|
||||
import { useBaseUrl } from '@/lib/hooks'
|
||||
import { Group } from '@prisma/client'
|
||||
import { Share } from 'lucide-react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
|
||||
type Props = {
|
||||
group: Group
|
||||
}
|
||||
|
||||
export function ShareButton({ group }: Props) {
|
||||
const t = useTranslations('Share')
|
||||
const baseUrl = useBaseUrl()
|
||||
const url = baseUrl && `${baseUrl}/groups/${group.id}/expenses?ref=share`
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button size="icon">
|
||||
<Button title={t('title')} size="icon" className="flex-shrink-0">
|
||||
<Share className="w-4 h-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="[&_p]:text-sm flex flex-col gap-3">
|
||||
<p>
|
||||
For other participants to see the group and add expenses, share its
|
||||
URL with them.
|
||||
</p>
|
||||
<p>{t('description')}</p>
|
||||
{url && (
|
||||
<div className="flex gap-2">
|
||||
<Input className="flex-1" defaultValue={url} readOnly />
|
||||
@@ -43,8 +42,7 @@ export function ShareButton({ group }: Props) {
|
||||
</div>
|
||||
)}
|
||||
<p>
|
||||
<strong>Warning!</strong> Every person with the group URL will be able
|
||||
to see and edit expenses. Share with caution!
|
||||
<strong>{t('warning')}</strong> {t('warningHelp')}
|
||||
</p>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
27
src/app/groups/[groupId]/stats/page.client.tsx
Normal file
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 { notFound } from 'next/navigation'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Totals',
|
||||
}
|
||||
|
||||
export default async function TotalsPage({
|
||||
params: { groupId },
|
||||
}: {
|
||||
params: { groupId: string }
|
||||
}) {
|
||||
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>Totals</CardTitle>
|
||||
<CardDescription>
|
||||
Spending summary of the entire group.
|
||||
</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,4 +1,5 @@
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
import { useLocale, useTranslations } from 'next-intl'
|
||||
|
||||
type Props = {
|
||||
totalGroupSpendings: number
|
||||
@@ -6,11 +7,14 @@ type Props = {
|
||||
}
|
||||
|
||||
export function TotalsGroupSpending({ totalGroupSpendings, currency }: Props) {
|
||||
const locale = useLocale()
|
||||
const t = useTranslations('Stats.Totals')
|
||||
const balance = totalGroupSpendings < 0 ? 'groupEarnings' : 'groupSpendings'
|
||||
return (
|
||||
<div>
|
||||
<div className="text-muted-foreground">Total group spendings</div>
|
||||
<div className="text-muted-foreground">{t(balance)}</div>
|
||||
<div className="text-lg">
|
||||
{formatCurrency(currency, totalGroupSpendings)}
|
||||
{formatCurrency(currency, Math.abs(totalGroupSpendings), locale)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,33 +1,27 @@
|
||||
'use client'
|
||||
import { getGroup, getGroupExpenses } from '@/lib/api'
|
||||
import { getTotalActiveUserShare } from '@/lib/totals'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
import { useEffect, useState } from 'react'
|
||||
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 TotalsYourShare({ group, expenses }: Props) {
|
||||
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
|
||||
export function TotalsYourShare({
|
||||
totalParticipantShare = 0,
|
||||
currency,
|
||||
}: {
|
||||
totalParticipantShare?: number
|
||||
currency: string
|
||||
}) {
|
||||
const locale = useLocale()
|
||||
const t = useTranslations('Stats.Totals')
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="text-muted-foreground">Your total share</div>
|
||||
<div className="text-lg">
|
||||
{formatCurrency(currency, totalActiveUserShare)}
|
||||
<div className="text-muted-foreground">{t('yourShare')}</div>
|
||||
<div
|
||||
className={cn(
|
||||
'text-lg',
|
||||
totalParticipantShare < 0 ? 'text-green-600' : 'text-red-600',
|
||||
)}
|
||||
>
|
||||
{formatCurrency(currency, Math.abs(totalParticipantShare), locale)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,29 +1,31 @@
|
||||
'use client'
|
||||
import { getGroup, getGroupExpenses } from '@/lib/api'
|
||||
import { useActiveUser } from '@/lib/hooks'
|
||||
import { getTotalActiveUserPaidFor } from '@/lib/totals'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
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({
|
||||
totalParticipantSpendings = 0,
|
||||
currency,
|
||||
}: {
|
||||
totalParticipantSpendings?: number
|
||||
currency: string
|
||||
}) {
|
||||
const locale = useLocale()
|
||||
const t = useTranslations('Stats.Totals')
|
||||
|
||||
export function TotalsYourSpendings({ group, expenses }: Props) {
|
||||
const activeUser = useActiveUser(group.id)
|
||||
|
||||
const totalYourSpendings =
|
||||
activeUser === '' || activeUser === 'None'
|
||||
? 0
|
||||
: getTotalActiveUserPaidFor(activeUser, expenses)
|
||||
const currency = group.currency
|
||||
const balance =
|
||||
totalParticipantSpendings < 0 ? 'yourEarnings' : 'yourSpendings'
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="text-muted-foreground">Total you paid for</div>
|
||||
<div className="text-muted-foreground">{t(balance)}</div>
|
||||
|
||||
<div className="text-lg">
|
||||
{formatCurrency(currency, totalYourSpendings)}
|
||||
<div
|
||||
className={cn(
|
||||
'text-lg',
|
||||
totalParticipantSpendings < 0 ? 'text-green-600' : 'text-red-600',
|
||||
)}
|
||||
>
|
||||
{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,7 +7,9 @@ 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'
|
||||
|
||||
type Props = {
|
||||
@@ -16,29 +17,25 @@ type Props = {
|
||||
}
|
||||
|
||||
export function AddGroupByUrlButton({ reload }: Props) {
|
||||
const t = useTranslations('Groups.AddByURL')
|
||||
const isDesktop = useMediaQuery('(min-width: 640px)')
|
||||
const [url, setUrl] = useState('')
|
||||
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" /> */}
|
||||
<>Add by URL</>
|
||||
</Button>
|
||||
<Button variant="secondary">{t('button')}</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align={isDesktop ? 'end' : 'start'}
|
||||
className="[&_p]:text-sm flex flex-col gap-3"
|
||||
>
|
||||
<h3 className="font-bold">Add a group by URL</h3>
|
||||
<p>
|
||||
If a group was shared with you, you can paste its URL here to add it
|
||||
to your list.
|
||||
</p>
|
||||
<h3 className="font-bold">{t('title')}</h3>
|
||||
<p>{t('description')}</p>
|
||||
<form
|
||||
className="flex gap-2"
|
||||
onSubmit={async (event) => {
|
||||
@@ -48,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)
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -80,11 +79,7 @@ export function AddGroupByUrlButton({ reload }: Props) {
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
{error && (
|
||||
<p className="text-destructive">
|
||||
Oops, we are not able to find the group from the URL you provided…
|
||||
</p>
|
||||
)}
|
||||
{error && <p className="text-destructive">{t('error')}</p>}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
|
||||
21
src/app/groups/create/create-group.tsx
Normal file
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,13 +1,15 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import Link from 'next/link'
|
||||
|
||||
export default function NotFound() {
|
||||
const t = useTranslations('Groups.NotFound')
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p>This group does not exist.</p>
|
||||
<p>{t('text')}</p>
|
||||
<p>
|
||||
<Button asChild variant="secondary">
|
||||
<Link href="/groups">Go to recently visited groups</Link>
|
||||
<Link href="/groups">{t('link')}</Link>
|
||||
</Button>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -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,42 +14,31 @@ 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 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)
|
||||
const t = useTranslations('Groups')
|
||||
|
||||
return (
|
||||
<li key={group.id}>
|
||||
@@ -113,32 +97,15 @@ export function RecentGroupListCard({
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
deleteRecentGroup(group)
|
||||
setState({
|
||||
...state,
|
||||
groups: state.groups.filter((g) => g.id !== group.id),
|
||||
})
|
||||
refreshGroupsFromStorage()
|
||||
|
||||
toast.toast({
|
||||
title: 'Group has been removed',
|
||||
description:
|
||||
'The group was removed from your recent groups list.',
|
||||
action: (
|
||||
<ToastAction
|
||||
altText="Undo group removal"
|
||||
onClick={() => {
|
||||
saveRecentGroup(group)
|
||||
setState({
|
||||
...state,
|
||||
groups: state.groups,
|
||||
})
|
||||
}}
|
||||
>
|
||||
Undo
|
||||
</ToastAction>
|
||||
),
|
||||
title: t('RecentRemovedToast.title'),
|
||||
description: t('RecentRemovedToast.description'),
|
||||
})
|
||||
}}
|
||||
>
|
||||
Remove from recent groups
|
||||
{t('removeRecent')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(event) => {
|
||||
@@ -152,25 +119,28 @@ export function RecentGroupListCard({
|
||||
refreshGroupsFromStorage()
|
||||
}}
|
||||
>
|
||||
{isArchived ? <>Unarchive group</> : <>Archive group</>}
|
||||
{t(isArchived ? 'unarchive' : 'archive')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</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('en-US', {
|
||||
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,9 +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 =
|
||||
@@ -30,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)
|
||||
@@ -65,78 +73,111 @@ 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" /> Loading
|
||||
recent groups…
|
||||
<Loader2 className="w-4 m-4 mr-2 inline animate-spin" />{' '}
|
||||
{t('loadingRecent')}
|
||||
</p>
|
||||
</GroupsPage>
|
||||
)
|
||||
}
|
||||
|
||||
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>You have not visited any group recently.</p>
|
||||
<p>{t('NoRecent.description')}</p>
|
||||
<p>
|
||||
<Button variant="link" asChild className="-m-4">
|
||||
<Link href={`/groups/create`}>Create one</Link>
|
||||
<Link href={`/groups/create`}>{t('NoRecent.create')}</Link>
|
||||
</Button>{' '}
|
||||
or ask a friend to send you the link to an existing one.
|
||||
{t('NoRecent.orAsk')}
|
||||
</p>
|
||||
</div>
|
||||
</GroupsPage>
|
||||
)
|
||||
}
|
||||
|
||||
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">Starred groups</h2>
|
||||
<h2 className="mb-2">{t('starred')}</h2>
|
||||
<GroupList
|
||||
groups={starredGroupInfo}
|
||||
state={state}
|
||||
setState={setState}
|
||||
groupDetails={data.groups}
|
||||
archivedGroups={archivedGroups}
|
||||
starredGroups={starredGroups}
|
||||
refreshGroupsFromStorage={refreshGroupsFromStorage}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{groupInfo.length > 0 && (
|
||||
<>
|
||||
<h2 className="mt-6 mb-2">Recent groups</h2>
|
||||
<GroupList groups={groupInfo} state={state} setState={setState} />
|
||||
<h2 className="mt-6 mb-2">{t('recent')}</h2>
|
||||
<GroupList
|
||||
groups={groupInfo}
|
||||
groupDetails={data.groups}
|
||||
archivedGroups={archivedGroups}
|
||||
starredGroups={starredGroups}
|
||||
refreshGroupsFromStorage={refreshGroupsFromStorage}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{archivedGroupInfo.length > 0 && (
|
||||
<>
|
||||
<h2 className="mt-6 mb-2 opacity-50">Archived groups</h2>
|
||||
<h2 className="mt-6 mb-2 opacity-50">{t('archived')}</h2>
|
||||
<div className="opacity-50">
|
||||
<GroupList
|
||||
groups={archivedGroupInfo}
|
||||
state={state}
|
||||
setState={setState}
|
||||
groupDetails={data.groups}
|
||||
archivedGroups={archivedGroups}
|
||||
starredGroups={starredGroups}
|
||||
refreshGroupsFromStorage={refreshGroupsFromStorage}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
@@ -147,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">
|
||||
@@ -160,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>
|
||||
@@ -172,18 +221,19 @@ function GroupsPage({
|
||||
children,
|
||||
reload,
|
||||
}: PropsWithChildren<{ reload: () => void }>) {
|
||||
const t = useTranslations('Groups')
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
|
||||
<h1 className="font-bold text-2xl flex-1">
|
||||
<Link href="/groups">My groups</Link>
|
||||
<Link href="/groups">{t('myGroups')}</Link>
|
||||
</h1>
|
||||
<div className="flex gap-2">
|
||||
<AddGroupByUrlButton reload={reload} />
|
||||
<Button asChild>
|
||||
<Link href="/groups/create">
|
||||
{/* <Plus className="w-4 h-4 mr-2" /> */}
|
||||
<>Create</>
|
||||
{t('create')}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
'use client'
|
||||
import { AddGroupByUrlButton } from '@/app/groups/add-group-by-url-button'
|
||||
import { RecentGroupList } from '@/app/groups/recent-group-list'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import Link from 'next/link'
|
||||
|
||||
export function RecentGroupsPage() {
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
|
||||
<h1 className="font-bold text-2xl flex-1">
|
||||
<Link href="/groups">My groups</Link>
|
||||
</h1>
|
||||
<div className="flex gap-2">
|
||||
<AddGroupByUrlButton reload={() => {}} />
|
||||
<Button asChild>
|
||||
<Link href="/groups/create">
|
||||
{/* <Plus className="w-4 h-4 mr-2" /> */}
|
||||
<>Create</>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<RecentGroupList />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,11 +1,15 @@
|
||||
import { ApplePwaSplash } from '@/app/apple-pwa-splash'
|
||||
import { LocaleSwitcher } from '@/components/locale-switcher'
|
||||
import { ProgressBar } from '@/components/progress-bar'
|
||||
import { ThemeProvider } from '@/components/theme-provider'
|
||||
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'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { Suspense } from 'react'
|
||||
@@ -59,93 +63,114 @@ export const viewport: Viewport = {
|
||||
themeColor: '#047857',
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
function Content({ children }: { children: React.ReactNode }) {
|
||||
const t = useTranslations()
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<ApplePwaSplash icon="/logo-with-text.png" color="#027756" />
|
||||
<body className="pt-16 min-h-[100dvh] flex flex-col items-stretch bg-slate-50 bg-opacity-30 dark:bg-background">
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
<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"
|
||||
href="/"
|
||||
>
|
||||
<Suspense>
|
||||
<ProgressBar />
|
||||
</Suspense>
|
||||
<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"
|
||||
href="/"
|
||||
>
|
||||
<h1>
|
||||
<Image
|
||||
src="/logo-with-text.png"
|
||||
className="m-1 h-auto w-auto"
|
||||
width={(35 * 522) / 180}
|
||||
height={35}
|
||||
alt="Spliit"
|
||||
/>
|
||||
</h1>
|
||||
<h1>
|
||||
<Image
|
||||
src="/logo-with-text.png"
|
||||
className="m-1 h-auto w-auto"
|
||||
width={(35 * 522) / 180}
|
||||
height={35}
|
||||
alt="Spliit"
|
||||
/>
|
||||
</h1>
|
||||
</Link>
|
||||
<div role="navigation" aria-label="Menu" className="flex">
|
||||
<ul className="flex items-center text-sm">
|
||||
<li>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
asChild
|
||||
className="-my-3 text-primary"
|
||||
>
|
||||
<Link href="/groups">{t('Header.groups')}</Link>
|
||||
</Button>
|
||||
</li>
|
||||
<li>
|
||||
<LocaleSwitcher />
|
||||
</li>
|
||||
<li>
|
||||
<ThemeToggle />
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 flex flex-col">{children}</div>
|
||||
|
||||
<footer className="sm:p-8 md:p-16 sm:mt-16 sm:text-sm md:text-base md:mt-32 bg-slate-50 dark:bg-card border-t p-6 mt-8 flex flex-col sm:flex-row sm:justify-between gap-4 text-xs [&_a]:underline">
|
||||
<div className="flex flex-col space-y-2">
|
||||
<div className="sm:text-lg font-semibold text-base flex space-x-2 items-center">
|
||||
<Link className="flex items-center gap-2" href="/">
|
||||
<Image
|
||||
src="/logo-with-text.png"
|
||||
className="m-1 h-auto w-auto"
|
||||
width={(35 * 522) / 180}
|
||||
height={35}
|
||||
alt="Spliit"
|
||||
/>
|
||||
</Link>
|
||||
<div role="navigation" aria-label="Menu" className="flex">
|
||||
<ul className="flex items-center text-sm">
|
||||
<li>
|
||||
<Button
|
||||
variant="ghost"
|
||||
asChild
|
||||
className="-my-3 text-primary"
|
||||
>
|
||||
<Link href="/groups">Groups</Link>
|
||||
</Button>
|
||||
</li>
|
||||
<li>
|
||||
<ThemeToggle />
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 flex flex-col">{children}</div>
|
||||
|
||||
<footer className="sm:p-8 md:p-16 sm:mt-16 sm:text-sm md:text-base md:mt-32 bg-slate-50 dark:bg-card border-t p-6 mt-8 flex flex-col sm:flex-row sm:justify-between gap-4 text-xs [&_a]:underline">
|
||||
<div className="flex flex-col space-y-2">
|
||||
<div className="sm:text-lg font-semibold text-base flex space-x-2 items-center">
|
||||
<Link className="flex items-center gap-2" href="/">
|
||||
<Image
|
||||
src="/logo-with-text.png"
|
||||
className="m-1 h-auto w-auto"
|
||||
width={(35 * 522) / 180}
|
||||
height={35}
|
||||
alt="Spliit"
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-col space-y a--no-underline-text-white">
|
||||
<span>Made in Montréal, Québec 🇨🇦</span>
|
||||
<span>
|
||||
Built by{' '}
|
||||
</div>
|
||||
<div className="flex flex-col space-y a--no-underline-text-white">
|
||||
<span>{t('Footer.madeIn')}</span>
|
||||
<span>
|
||||
{t.rich('Footer.builtBy', {
|
||||
author: (txt) => (
|
||||
<a href="https://scastiel.dev" target="_blank" rel="noopener">
|
||||
Sebastien Castiel
|
||||
</a>{' '}
|
||||
and{' '}
|
||||
{txt}
|
||||
</a>
|
||||
),
|
||||
source: (txt) => (
|
||||
<a
|
||||
href="https://github.com/spliit-app/spliit/graphs/contributors"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
contributors
|
||||
{txt}
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
<Toaster />
|
||||
</ThemeProvider>
|
||||
),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
<Toaster />
|
||||
</TRPCProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const locale = await getLocale()
|
||||
const messages = await getMessages()
|
||||
return (
|
||||
<html lang={locale} suppressHydrationWarning>
|
||||
<ApplePwaSplash icon="/logo-with-text.png" color="#027756" />
|
||||
<body className="pt-16 min-h-[100dvh] flex flex-col items-stretch bg-slate-50 bg-opacity-30 dark:bg-background">
|
||||
<NextIntlClientProvider messages={messages}>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<Suspense>
|
||||
<ProgressBar />
|
||||
</Suspense>
|
||||
<Content>{children}</Content>
|
||||
</ThemeProvider>
|
||||
</NextIntlClientProvider>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
|
||||
@@ -1,31 +1,35 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Github } from 'lucide-react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import Link from 'next/link'
|
||||
|
||||
// FIX for https://github.com/vercel/next.js/issues/58615
|
||||
// export const dynamic = 'force-dynamic'
|
||||
|
||||
export default function HomePage() {
|
||||
const t = useTranslations()
|
||||
return (
|
||||
<main>
|
||||
<section className="py-16 md:py-24 lg:py-32">
|
||||
<div className="container flex max-w-screen-md flex-col items-center gap-4 text-center">
|
||||
<h1 className="!leading-none font-bold text-3xl sm:text-5xl md:text-6xl lg:text-7xl landing-header py-2">
|
||||
Share <strong>Expenses</strong> <br /> with <strong>Friends</strong>{' '}
|
||||
& <strong>Family</strong>
|
||||
<h1 className="!leading-none font-bold text-2xl sm:text-3xl md:text-4xl lg:text-5xl landing-header py-2">
|
||||
{t.rich('Homepage.title', {
|
||||
strong: (chunks) => <strong>{chunks}</strong>,
|
||||
})}
|
||||
</h1>
|
||||
<p className="max-w-[42rem] leading-normal text-muted-foreground sm:text-xl sm:leading-8">
|
||||
Welcome to your new <strong>Spliit</strong> instance! <br />
|
||||
Customize this page by editing <em>src/app/page.tsx</em>.
|
||||
{t.rich('Homepage.description', {
|
||||
strong: (chunks) => <strong>{chunks}</strong>,
|
||||
})}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button asChild>
|
||||
<Link href="/groups">Go to groups</Link>
|
||||
<Link href="/groups">{t('Homepage.button.groups')}</Link>
|
||||
</Button>
|
||||
<Button asChild variant="secondary">
|
||||
<Link href="https://github.com/spliit-app/spliit">
|
||||
<Github className="w-4 h-4 mr-2" />
|
||||
GitHub
|
||||
{t('Homepage.button.github')}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from '@/components/ui/popover'
|
||||
import { useMediaQuery } from '@/lib/hooks'
|
||||
import { Category } from '@prisma/client'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { forwardRef, useEffect, useState } from 'react'
|
||||
|
||||
type Props = {
|
||||
@@ -100,6 +101,7 @@ function CategoryCommand({
|
||||
categories: Category[]
|
||||
onValueChange: (categoryId: Category['id']) => void
|
||||
}) {
|
||||
const t = useTranslations('Categories')
|
||||
const categoriesByGroup = categories.reduce<Record<string, Category[]>>(
|
||||
(acc, category) => ({
|
||||
...acc,
|
||||
@@ -110,16 +112,18 @@ function CategoryCommand({
|
||||
|
||||
return (
|
||||
<Command>
|
||||
<CommandInput placeholder="Search category..." className="text-base" />
|
||||
<CommandEmpty>No category found.</CommandEmpty>
|
||||
<CommandInput placeholder={t('search')} className="text-base" />
|
||||
<CommandEmpty>{t('noCategory')}</CommandEmpty>
|
||||
<div className="w-full max-h-[300px] overflow-y-auto">
|
||||
{Object.entries(categoriesByGroup).map(
|
||||
([group, groupCategories], index) => (
|
||||
<CommandGroup key={index} heading={group}>
|
||||
<CommandGroup key={index} heading={t(`${group}.heading`)}>
|
||||
{groupCategories.map((category) => (
|
||||
<CommandItem
|
||||
key={category.id}
|
||||
value={`${category.id} ${category.grouping} ${category.name}`}
|
||||
value={`${category.id} ${t(
|
||||
`${category.grouping}.heading`,
|
||||
)} ${t(`${category.grouping}.${category.name}`)}`}
|
||||
onSelect={(currentValue) => {
|
||||
const id = Number(currentValue.split(' ')[0])
|
||||
onValueChange(id)
|
||||
@@ -169,10 +173,11 @@ const CategoryButton = forwardRef<HTMLButtonElement, CategoryButtonProps>(
|
||||
CategoryButton.displayName = 'CategoryButton'
|
||||
|
||||
function CategoryLabel({ category }: { category: Category }) {
|
||||
const t = useTranslations('Categories')
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<CategoryIcon category={category} className="w-4 h-4" />
|
||||
{category.name}
|
||||
{t(`${category.grouping}.${category.name}`)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
'use client'
|
||||
|
||||
import { Trash2 } from 'lucide-react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { AsyncButton } from './async-button'
|
||||
import { Button } from './ui/button'
|
||||
import {
|
||||
@@ -14,20 +13,18 @@ import {
|
||||
} from './ui/dialog'
|
||||
|
||||
export function DeletePopup({ onDelete }: { onDelete: () => Promise<void> }) {
|
||||
const t = useTranslations('ExpenseForm.DeletePopup')
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="destructive">
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Delete
|
||||
{t('label')}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogTitle>Delete this expense?</DialogTitle>
|
||||
<DialogDescription>
|
||||
Do you really want to delete this expense? This action is
|
||||
irreversible.
|
||||
</DialogDescription>
|
||||
<DialogTitle>{t('title')}</DialogTitle>
|
||||
<DialogDescription>{t('description')}</DialogDescription>
|
||||
<DialogFooter className="flex flex-col gap-2">
|
||||
<AsyncButton
|
||||
type="button"
|
||||
@@ -35,10 +32,10 @@ export function DeletePopup({ onDelete }: { onDelete: () => Promise<void> }) {
|
||||
loadingContent="Deleting…"
|
||||
action={onDelete}
|
||||
>
|
||||
Yes
|
||||
{t('yes')}
|
||||
</AsyncButton>
|
||||
<DialogClose asChild>
|
||||
<Button variant={'secondary'}>Cancel</Button>
|
||||
<Button variant={'secondary'}>{t('cancel')}</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -19,6 +19,7 @@ import { randomId } from '@/lib/api'
|
||||
import { ExpenseFormValues } from '@/lib/schemas'
|
||||
import { formatFileSize } from '@/lib/utils'
|
||||
import { Loader2, Plus, Trash, X } from 'lucide-react'
|
||||
import { useLocale, useTranslations } from 'next-intl'
|
||||
import { getImageData, usePresignedUpload } from 'next-s3-upload'
|
||||
import Image from 'next/image'
|
||||
import { useEffect, useState } from 'react'
|
||||
@@ -31,6 +32,8 @@ type Props = {
|
||||
const MAX_FILE_SIZE = 5 * 1024 ** 2
|
||||
|
||||
export function ExpenseDocumentsInput({ documents, updateDocuments }: Props) {
|
||||
const locale = useLocale()
|
||||
const t = useTranslations('ExpenseDocumentsInput')
|
||||
const [pending, setPending] = useState(false)
|
||||
const { FileInput, openFileDialog, uploadToS3 } = usePresignedUpload() // use presigned uploads to addtionally support providers other than AWS
|
||||
const { toast } = useToast()
|
||||
@@ -38,10 +41,11 @@ export function ExpenseDocumentsInput({ documents, updateDocuments }: Props) {
|
||||
const handleFileChange = async (file: File) => {
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
toast({
|
||||
title: 'The file is too big',
|
||||
description: `The maximum file size you can upload is ${formatFileSize(
|
||||
MAX_FILE_SIZE,
|
||||
)}. Yours is ${formatFileSize(file.size)}.`,
|
||||
title: t('TooBigToast.title'),
|
||||
description: t('TooBigToast.description', {
|
||||
maxSize: formatFileSize(MAX_FILE_SIZE, locale),
|
||||
size: formatFileSize(file.size, locale),
|
||||
}),
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
@@ -57,13 +61,15 @@ export function ExpenseDocumentsInput({ documents, updateDocuments }: Props) {
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
toast({
|
||||
title: 'Error while uploading document',
|
||||
description:
|
||||
'Something wrong happened when uploading the document. Please retry later or select a different file.',
|
||||
title: t('ErrorToast.title'),
|
||||
description: t('ErrorToast.description'),
|
||||
variant: 'destructive',
|
||||
action: (
|
||||
<ToastAction altText="Retry" onClick={() => upload()}>
|
||||
Retry
|
||||
<ToastAction
|
||||
altText={t('ErrorToast.retry')}
|
||||
onClick={() => upload()}
|
||||
>
|
||||
{t('ErrorToast.retry')}
|
||||
</ToastAction>
|
||||
),
|
||||
})
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
'use client'
|
||||
import { SubmitButton } from '@/components/submit-button'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
@@ -35,13 +34,18 @@ import { getGroup } from '@/lib/api'
|
||||
import { GroupFormValues, groupFormSchema } from '@/lib/schemas'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { Save, Trash2 } from 'lucide-react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import Link from 'next/link'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useFieldArray, useForm } from 'react-hook-form'
|
||||
import { Textarea } from './ui/textarea'
|
||||
|
||||
export type Props = {
|
||||
group?: NonNullable<Awaited<ReturnType<typeof getGroup>>>
|
||||
onSubmit: (groupFormValues: GroupFormValues) => Promise<void>
|
||||
onSubmit: (
|
||||
groupFormValues: GroupFormValues,
|
||||
participantId?: string,
|
||||
) => Promise<void>
|
||||
protectedParticipantIds?: string[]
|
||||
}
|
||||
|
||||
@@ -50,18 +54,25 @@ export function GroupForm({
|
||||
onSubmit,
|
||||
protectedParticipantIds = [],
|
||||
}: Props) {
|
||||
const t = useTranslations('GroupForm')
|
||||
const form = useForm<GroupFormValues>({
|
||||
resolver: zodResolver(groupFormSchema),
|
||||
defaultValues: group
|
||||
? {
|
||||
name: group.name,
|
||||
information: group.information ?? '',
|
||||
currency: group.currency,
|
||||
participants: group.participants,
|
||||
}
|
||||
: {
|
||||
name: '',
|
||||
currency: '',
|
||||
participants: [{ name: 'John' }, { name: 'Jane' }, { name: 'Jack' }],
|
||||
information: '',
|
||||
currency: process.env.NEXT_PUBLIC_DEFAULT_CURRENCY_SYMBOL || '',
|
||||
participants: [
|
||||
{ name: t('Participants.John') },
|
||||
{ name: t('Participants.Jane') },
|
||||
{ name: t('Participants.Jack') },
|
||||
],
|
||||
},
|
||||
})
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
@@ -76,10 +87,10 @@ export function GroupForm({
|
||||
const currentActiveUser =
|
||||
fields.find(
|
||||
(f) => f.id === localStorage.getItem(`${group?.id}-activeUser`),
|
||||
)?.name || 'None'
|
||||
)?.name || t('Settings.ActiveUserField.none')
|
||||
setActiveUser(currentActiveUser)
|
||||
}
|
||||
}, [activeUser, fields, group?.id])
|
||||
}, [t, activeUser, fields, group?.id])
|
||||
|
||||
const updateActiveUser = () => {
|
||||
if (!activeUser) return
|
||||
@@ -99,12 +110,16 @@ export function GroupForm({
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(async (values) => {
|
||||
await onSubmit(values)
|
||||
await onSubmit(
|
||||
values,
|
||||
group?.participants.find((p) => p.name === activeUser)?.id ??
|
||||
undefined,
|
||||
)
|
||||
})}
|
||||
>
|
||||
<Card className="mb-4">
|
||||
<CardHeader>
|
||||
<CardTitle>Group information</CardTitle>
|
||||
<CardTitle>{t('title')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<FormField
|
||||
@@ -112,16 +127,16 @@ export function GroupForm({
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Group name</FormLabel>
|
||||
<FormLabel>{t('NameField.label')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="text-base"
|
||||
placeholder="Summer vacations"
|
||||
placeholder={t('NameField.placeholder')}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Enter a name for your group.
|
||||
{t('NameField.description')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -133,31 +148,50 @@ export function GroupForm({
|
||||
name="currency"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Currency symbol</FormLabel>
|
||||
<FormLabel>{t('CurrencyField.label')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="text-base"
|
||||
placeholder="$, €, £…"
|
||||
placeholder={t('CurrencyField.placeholder')}
|
||||
max={5}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
We’ll use it to display amounts.
|
||||
{t('CurrencyField.description')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="col-span-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="information"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('InformationField.label')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
rows={2}
|
||||
className="text-base"
|
||||
{...field}
|
||||
placeholder={t('InformationField.placeholder')}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="mb-4">
|
||||
<CardHeader>
|
||||
<CardTitle>Participants</CardTitle>
|
||||
<CardDescription>
|
||||
Enter the name for each participant
|
||||
</CardDescription>
|
||||
<CardTitle>{t('Participants.title')}</CardTitle>
|
||||
<CardDescription>{t('Participants.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="flex flex-col gap-2">
|
||||
@@ -173,7 +207,11 @@ export function GroupForm({
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<div className="flex gap-2">
|
||||
<Input className="text-base" {...field} />
|
||||
<Input
|
||||
className="text-base"
|
||||
{...field}
|
||||
placeholder={t('Participants.new')}
|
||||
/>
|
||||
{item.id &&
|
||||
protectedParticipantIds.includes(item.id) ? (
|
||||
<HoverCard>
|
||||
@@ -192,8 +230,7 @@ export function GroupForm({
|
||||
align="end"
|
||||
className="text-sm"
|
||||
>
|
||||
This participant is part of expenses, and can
|
||||
not be removed.
|
||||
{t('Participants.protectedParticipant')}
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
) : (
|
||||
@@ -221,28 +258,25 @@ export function GroupForm({
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
append({ name: 'New' })
|
||||
append({ name: '' })
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Add participant
|
||||
{t('Participants.add')}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
<Card className="mb-4">
|
||||
<CardHeader>
|
||||
<CardTitle>Local settings</CardTitle>
|
||||
<CardDescription>
|
||||
These settings are set per-device, and are used to customize your
|
||||
experience.
|
||||
</CardDescription>
|
||||
<CardTitle>{t('Settings.title')}</CardTitle>
|
||||
<CardDescription>{t('Settings.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid sm:grid-cols-2 gap-4">
|
||||
{activeUser !== null && (
|
||||
<FormItem>
|
||||
<FormLabel>Active user</FormLabel>
|
||||
<FormLabel>{t('Settings.ActiveUserField.label')}</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
@@ -251,10 +285,17 @@ export function GroupForm({
|
||||
defaultValue={activeUser}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a participant" />
|
||||
<SelectValue
|
||||
placeholder={t(
|
||||
'Settings.ActiveUserField.placeholder',
|
||||
)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{[{ name: 'None' }, ...form.watch('participants')]
|
||||
{[
|
||||
{ name: t('Settings.ActiveUserField.none') },
|
||||
...form.watch('participants'),
|
||||
]
|
||||
.filter((item) => item.name.length > 0)
|
||||
.map(({ name }) => (
|
||||
<SelectItem key={name} value={name}>
|
||||
@@ -265,7 +306,7 @@ export function GroupForm({
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
User used as default for paying expenses.
|
||||
{t('Settings.ActiveUserField.description')}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -275,14 +316,15 @@ export function GroupForm({
|
||||
|
||||
<div className="flex mt-4 gap-2">
|
||||
<SubmitButton
|
||||
loadingContent={group ? 'Saving…' : 'Creating…'}
|
||||
loadingContent={t(group ? 'Settings.saving' : 'Settings.creating')}
|
||||
onClick={updateActiveUser}
|
||||
>
|
||||
<Save className="w-4 h-4 mr-2" /> {group ? <>Save</> : <> Create</>}
|
||||
<Save className="w-4 h-4 mr-2" />{' '}
|
||||
{t(group ? 'Settings.save' : 'Settings.create')}
|
||||
</SubmitButton>
|
||||
{!group && (
|
||||
<Button variant="ghost" asChild>
|
||||
<Link href="/groups">Cancel</Link>
|
||||
<Link href="/groups">{t('Settings.cancel')}</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
35
src/components/locale-switcher.tsx
Normal file
35
src/components/locale-switcher.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
'use client'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Locale, localeLabels } from '@/i18n'
|
||||
import { setUserLocale } from '@/lib/locale'
|
||||
import { useLocale } from 'next-intl'
|
||||
|
||||
export function LocaleSwitcher() {
|
||||
const locale = useLocale() as Locale
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="-my-3 text-primary">
|
||||
<span>{localeLabels[locale]}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
{Object.entries(localeLabels).map(([locale, label]) => (
|
||||
<DropdownMenuItem
|
||||
key={locale}
|
||||
onClick={() => setUserLocale(locale as Locale)}
|
||||
>
|
||||
{label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
33
src/components/money.tsx
Normal file
33
src/components/money.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
'use client'
|
||||
import { cn, formatCurrency } from '@/lib/utils'
|
||||
import { useLocale } from 'next-intl'
|
||||
|
||||
type Props = {
|
||||
currency: string
|
||||
amount: number
|
||||
bold?: boolean
|
||||
colored?: boolean
|
||||
}
|
||||
|
||||
export function Money({
|
||||
currency,
|
||||
amount,
|
||||
bold = false,
|
||||
colored = false,
|
||||
}: Props) {
|
||||
const locale = useLocale()
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
colored && amount <= 1
|
||||
? 'text-red-600'
|
||||
: colored && amount >= 1
|
||||
? 'text-green-600'
|
||||
: '',
|
||||
bold && 'font-bold',
|
||||
)}
|
||||
>
|
||||
{formatCurrency(currency, amount, locale)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { useMessages } from "next-intl"
|
||||
|
||||
const Form = FormProvider
|
||||
|
||||
@@ -78,7 +79,7 @@ const FormItem = React.forwardRef<
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div ref={ref} className={cn("space-y-2", className)} {...props} />
|
||||
<div ref={ref} className={cn("col-span-2 md:col-span-1 space-y-2", className)} {...props} />
|
||||
</FormItemContext.Provider>
|
||||
)
|
||||
})
|
||||
@@ -144,8 +145,18 @@ const FormMessage = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
const messages = useMessages()
|
||||
const { error, formMessageId } = useFormField()
|
||||
const body = error ? String(error?.message) : children
|
||||
let body
|
||||
if (error) {
|
||||
body = String(error?.message)
|
||||
const translation = (messages.SchemaErrors as any)[body]
|
||||
if (translation) {
|
||||
body = translation
|
||||
}
|
||||
} else {
|
||||
body = children
|
||||
}
|
||||
|
||||
if (!body) {
|
||||
return null
|
||||
|
||||
@@ -1,33 +1,51 @@
|
||||
import * as React from "react"
|
||||
import * as React from 'react'
|
||||
|
||||
import {Input} from "@/components/ui/input";
|
||||
import {cn} from "@/lib/utils";
|
||||
import {
|
||||
Search
|
||||
} from 'lucide-react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Search, XCircle } from 'lucide-react'
|
||||
|
||||
export interface InputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
onValueChange?: (value: string) => void
|
||||
}
|
||||
|
||||
const SearchBar = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
({ className, type, onValueChange, ...props }, ref) => {
|
||||
const t = useTranslations('Expenses')
|
||||
const [value, _setValue] = React.useState('')
|
||||
|
||||
const setValue = (v: string) => {
|
||||
_setValue(v)
|
||||
onValueChange && onValueChange(v)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-4 sm:mx-6 flex relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
type={type}
|
||||
className={cn(
|
||||
"pl-10 text-sm focus:text-base bg-muted border-none text-muted-foreground",
|
||||
className
|
||||
'pl-10 text-sm focus:text-base bg-muted border-none text-muted-foreground',
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
placeholder="Search for an expense…"
|
||||
placeholder={t("searchPlaceholder")}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
{...props}
|
||||
/>
|
||||
<XCircle
|
||||
className={cn(
|
||||
'absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 cursor-pointer',
|
||||
!value && 'hidden',
|
||||
)}
|
||||
onClick={() => setValue('')}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
SearchBar.displayName = "SearchBar"
|
||||
SearchBar.displayName = 'SearchBar'
|
||||
|
||||
export { SearchBar }
|
||||
|
||||
33
src/i18n.ts
Normal file
33
src/i18n.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { getRequestConfig } from 'next-intl/server'
|
||||
import { getUserLocale } from './lib/locale'
|
||||
|
||||
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ă',
|
||||
} 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'
|
||||
|
||||
export default getRequestConfig(async () => {
|
||||
const locale = await getUserLocale()
|
||||
|
||||
return {
|
||||
locale,
|
||||
messages: (await import(`../messages/${locale}.json`)).default,
|
||||
}
|
||||
})
|
||||
137
src/lib/api.ts
137
src/lib/api.ts
@@ -1,6 +1,6 @@
|
||||
import { getPrisma } from '@/lib/prisma'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { ExpenseFormValues, GroupFormValues } from '@/lib/schemas'
|
||||
import { Expense } from '@prisma/client'
|
||||
import { ActivityType, Expense } from '@prisma/client'
|
||||
import { nanoid } from 'nanoid'
|
||||
|
||||
export function randomId() {
|
||||
@@ -8,11 +8,11 @@ export function randomId() {
|
||||
}
|
||||
|
||||
export async function createGroup(groupFormValues: GroupFormValues) {
|
||||
const prisma = await getPrisma()
|
||||
return prisma.group.create({
|
||||
data: {
|
||||
id: randomId(),
|
||||
name: groupFormValues.name,
|
||||
information: groupFormValues.information,
|
||||
currency: groupFormValues.currency,
|
||||
participants: {
|
||||
createMany: {
|
||||
@@ -30,6 +30,7 @@ export async function createGroup(groupFormValues: GroupFormValues) {
|
||||
export async function createExpense(
|
||||
expenseFormValues: ExpenseFormValues,
|
||||
groupId: string,
|
||||
participantId?: string,
|
||||
): Promise<Expense> {
|
||||
const group = await getGroup(groupId)
|
||||
if (!group) throw new Error(`Invalid group ID: ${groupId}`)
|
||||
@@ -42,10 +43,16 @@ export async function createExpense(
|
||||
throw new Error(`Invalid participant ID: ${participant}`)
|
||||
}
|
||||
|
||||
const prisma = await getPrisma()
|
||||
const expenseId = randomId()
|
||||
await logActivity(groupId, ActivityType.CREATE_EXPENSE, {
|
||||
participantId,
|
||||
expenseId,
|
||||
data: expenseFormValues.title,
|
||||
})
|
||||
|
||||
return prisma.expense.create({
|
||||
data: {
|
||||
id: randomId(),
|
||||
id: expenseId,
|
||||
groupId,
|
||||
expenseDate: expenseFormValues.expenseDate,
|
||||
categoryId: expenseFormValues.category,
|
||||
@@ -72,12 +79,23 @@ export async function createExpense(
|
||||
})),
|
||||
},
|
||||
},
|
||||
notes: expenseFormValues.notes,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteExpense(expenseId: string) {
|
||||
const prisma = await getPrisma()
|
||||
export async function deleteExpense(
|
||||
groupId: string,
|
||||
expenseId: string,
|
||||
participantId?: string,
|
||||
) {
|
||||
const existingExpense = await getExpense(groupId, expenseId)
|
||||
await logActivity(groupId, ActivityType.DELETE_EXPENSE, {
|
||||
participantId,
|
||||
expenseId,
|
||||
data: existingExpense?.title,
|
||||
})
|
||||
|
||||
await prisma.expense.delete({
|
||||
where: { id: expenseId },
|
||||
include: { paidFor: true, paidBy: true },
|
||||
@@ -89,15 +107,14 @@ export async function getGroupExpensesParticipants(groupId: string) {
|
||||
return Array.from(
|
||||
new Set(
|
||||
expenses.flatMap((e) => [
|
||||
e.paidById,
|
||||
...e.paidFor.map((pf) => pf.participantId),
|
||||
e.paidBy.id,
|
||||
...e.paidFor.map((pf) => pf.participant.id),
|
||||
]),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
export async function getGroups(groupIds: string[]) {
|
||||
const prisma = await getPrisma()
|
||||
return (
|
||||
await prisma.group.findMany({
|
||||
where: { id: { in: groupIds } },
|
||||
@@ -113,6 +130,7 @@ export async function updateExpense(
|
||||
groupId: string,
|
||||
expenseId: string,
|
||||
expenseFormValues: ExpenseFormValues,
|
||||
participantId?: string,
|
||||
) {
|
||||
const group = await getGroup(groupId)
|
||||
if (!group) throw new Error(`Invalid group ID: ${groupId}`)
|
||||
@@ -128,7 +146,12 @@ export async function updateExpense(
|
||||
throw new Error(`Invalid participant ID: ${participant}`)
|
||||
}
|
||||
|
||||
const prisma = await getPrisma()
|
||||
await logActivity(groupId, ActivityType.UPDATE_EXPENSE, {
|
||||
participantId,
|
||||
expenseId,
|
||||
data: expenseFormValues.title,
|
||||
})
|
||||
|
||||
return prisma.expense.update({
|
||||
where: { id: expenseId },
|
||||
data: {
|
||||
@@ -185,6 +208,7 @@ export async function updateExpense(
|
||||
id: doc.id,
|
||||
})),
|
||||
},
|
||||
notes: expenseFormValues.notes,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -192,15 +216,18 @@ export async function updateExpense(
|
||||
export async function updateGroup(
|
||||
groupId: string,
|
||||
groupFormValues: GroupFormValues,
|
||||
participantId?: string,
|
||||
) {
|
||||
const existingGroup = await getGroup(groupId)
|
||||
if (!existingGroup) throw new Error('Invalid group ID')
|
||||
|
||||
const prisma = await getPrisma()
|
||||
await logActivity(groupId, ActivityType.UPDATE_GROUP, { participantId })
|
||||
|
||||
return prisma.group.update({
|
||||
where: { id: groupId },
|
||||
data: {
|
||||
name: groupFormValues.name,
|
||||
information: groupFormValues.information,
|
||||
currency: groupFormValues.currency,
|
||||
participants: {
|
||||
deleteMany: existingGroup.participants.filter(
|
||||
@@ -228,7 +255,6 @@ export async function updateGroup(
|
||||
}
|
||||
|
||||
export async function getGroup(groupId: string) {
|
||||
const prisma = await getPrisma()
|
||||
return prisma.group.findUnique({
|
||||
where: { id: groupId },
|
||||
include: { participants: true },
|
||||
@@ -236,27 +262,96 @@ export async function getGroup(groupId: string) {
|
||||
}
|
||||
|
||||
export async function getCategories() {
|
||||
const prisma = await getPrisma()
|
||||
return prisma.category.findMany()
|
||||
}
|
||||
|
||||
export async function getGroupExpenses(groupId: string) {
|
||||
const prisma = await getPrisma()
|
||||
export async function getGroupExpenses(
|
||||
groupId: string,
|
||||
options?: { offset?: number; length?: number; filter?: string },
|
||||
) {
|
||||
return prisma.expense.findMany({
|
||||
where: { groupId },
|
||||
include: {
|
||||
paidFor: { include: { participant: true } },
|
||||
paidBy: true,
|
||||
select: {
|
||||
amount: true,
|
||||
category: true,
|
||||
createdAt: true,
|
||||
expenseDate: true,
|
||||
id: true,
|
||||
isReimbursement: true,
|
||||
paidBy: { select: { id: true, name: true } },
|
||||
paidFor: {
|
||||
select: {
|
||||
participant: { select: { id: true, name: true } },
|
||||
shares: true,
|
||||
},
|
||||
},
|
||||
splitMode: true,
|
||||
title: true,
|
||||
_count: { select: { documents: true } },
|
||||
},
|
||||
where: {
|
||||
groupId,
|
||||
title: options?.filter
|
||||
? { contains: options.filter, mode: 'insensitive' }
|
||||
: undefined,
|
||||
},
|
||||
orderBy: [{ expenseDate: 'desc' }, { createdAt: 'desc' }],
|
||||
skip: options && options.offset,
|
||||
take: options && options.length,
|
||||
})
|
||||
}
|
||||
|
||||
export async function getGroupExpenseCount(groupId: string) {
|
||||
return prisma.expense.count({ where: { groupId } })
|
||||
}
|
||||
|
||||
export async function getExpense(groupId: string, expenseId: string) {
|
||||
const prisma = await getPrisma()
|
||||
return prisma.expense.findUnique({
|
||||
where: { id: expenseId },
|
||||
include: { paidBy: true, paidFor: true, category: true, documents: true },
|
||||
})
|
||||
}
|
||||
|
||||
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(
|
||||
groupId: string,
|
||||
activityType: ActivityType,
|
||||
extra?: { participantId?: string; expenseId?: string; data?: string },
|
||||
) {
|
||||
return prisma.activity.create({
|
||||
data: {
|
||||
id: randomId(),
|
||||
groupId,
|
||||
activityType,
|
||||
...extra,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ export function getBalances(
|
||||
const balances: Balances = {}
|
||||
|
||||
for (const expense of expenses) {
|
||||
const paidBy = expense.paidById
|
||||
const paidBy = expense.paidBy.id
|
||||
const paidFors = expense.paidFor
|
||||
|
||||
if (!balances[paidBy]) balances[paidBy] = { paid: 0, paidFor: 0, total: 0 }
|
||||
@@ -31,8 +31,8 @@ export function getBalances(
|
||||
)
|
||||
let remaining = expense.amount
|
||||
paidFors.forEach((paidFor, index) => {
|
||||
if (!balances[paidFor.participantId])
|
||||
balances[paidFor.participantId] = { paid: 0, paidFor: 0, total: 0 }
|
||||
if (!balances[paidFor.participant.id])
|
||||
balances[paidFor.participant.id] = { paid: 0, paidFor: 0, total: 0 }
|
||||
|
||||
const isLast = index === paidFors.length - 1
|
||||
|
||||
@@ -47,7 +47,7 @@ export function getBalances(
|
||||
? remaining
|
||||
: (expense.amount * shares) / totalShares
|
||||
remaining -= dividedAmount
|
||||
balances[paidFor.participantId].paidFor += dividedAmount
|
||||
balances[paidFor.participant.id].paidFor += dividedAmount
|
||||
})
|
||||
}
|
||||
|
||||
@@ -82,13 +82,29 @@ export function getPublicBalances(reimbursements: Reimbursement[]): Balances {
|
||||
return balances
|
||||
}
|
||||
|
||||
/**
|
||||
* A comparator that is stable across reimbursements.
|
||||
* This ensures that a participant executing a suggested reimbursement
|
||||
* does not result in completely new repayment suggestions.
|
||||
*/
|
||||
function compareBalancesForReimbursements(b1: any, b2: any): number {
|
||||
// positive balances come before negative balances
|
||||
if (b1.total > 0 && 0 > b2.total) {
|
||||
return -1
|
||||
} else if (b2.total > 0 && 0 > b1.total) {
|
||||
return 1
|
||||
}
|
||||
// if signs match, sort based on userid
|
||||
return b1.participantId < b2.participantId ? -1 : 1
|
||||
}
|
||||
|
||||
export function getSuggestedReimbursements(
|
||||
balances: Balances,
|
||||
): Reimbursement[] {
|
||||
const balancesArray = Object.entries(balances)
|
||||
.map(([participantId, { total }]) => ({ participantId, total }))
|
||||
.filter((b) => b.total !== 0)
|
||||
balancesArray.sort((b1, b2) => b2.total - b1.total)
|
||||
balancesArray.sort(compareBalancesForReimbursements)
|
||||
const reimbursements: Reimbursement[] = []
|
||||
while (balancesArray.length > 1) {
|
||||
const first = balancesArray[0]
|
||||
|
||||
@@ -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
|
||||
|
||||
42
src/lib/locale.ts
Normal file
42
src/lib/locale.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
'use server'
|
||||
|
||||
import { Locale, Locales, defaultLocale, locales } from '@/i18n'
|
||||
import { match } from '@formatjs/intl-localematcher'
|
||||
import Negotiator from 'negotiator'
|
||||
import { cookies, headers } from 'next/headers'
|
||||
|
||||
const COOKIE_NAME = 'NEXT_LOCALE'
|
||||
|
||||
function getAcceptLanguageLocale(requestHeaders: Headers, locales: Locales) {
|
||||
let locale
|
||||
const languages = new Negotiator({
|
||||
headers: {
|
||||
'accept-language': requestHeaders.get('accept-language') || undefined,
|
||||
},
|
||||
}).languages()
|
||||
try {
|
||||
locale = match(languages, locales, defaultLocale)
|
||||
} catch (e) {
|
||||
// invalid language
|
||||
}
|
||||
return locale
|
||||
}
|
||||
|
||||
export async function getUserLocale() {
|
||||
let locale
|
||||
|
||||
// Prio 1: use existing cookie
|
||||
locale = cookies().get(COOKIE_NAME)?.value
|
||||
|
||||
// Prio 2: use `accept-language` header
|
||||
// Prio 3: use default locale
|
||||
if (!locale) {
|
||||
locale = getAcceptLanguageLocale(headers(), locales)
|
||||
}
|
||||
|
||||
return locale
|
||||
}
|
||||
|
||||
export async function setUserLocale(locale: Locale) {
|
||||
cookies().set(COOKIE_NAME, locale)
|
||||
}
|
||||
@@ -1,20 +1,21 @@
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
|
||||
let prisma: PrismaClient
|
||||
declare const global: Global & { prisma?: PrismaClient }
|
||||
|
||||
export async function getPrisma() {
|
||||
export let p: PrismaClient = undefined as any as PrismaClient
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
// await delay(1000)
|
||||
if (!prisma) {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
prisma = new PrismaClient()
|
||||
} else {
|
||||
if (!(global as any).prisma) {
|
||||
;(global as any).prisma = new PrismaClient({
|
||||
// log: [{ emit: 'stdout', level: 'query' }],
|
||||
})
|
||||
}
|
||||
prisma = (global as any).prisma
|
||||
if (process.env['NODE_ENV'] === 'production') {
|
||||
p = new PrismaClient()
|
||||
} else {
|
||||
if (!global.prisma) {
|
||||
global.prisma = new PrismaClient({
|
||||
// log: [{ emit: 'stdout', level: 'query' }],
|
||||
})
|
||||
}
|
||||
p = global.prisma
|
||||
}
|
||||
return prisma
|
||||
}
|
||||
|
||||
export const prisma = p
|
||||
|
||||
@@ -3,22 +3,14 @@ import * as z from 'zod'
|
||||
|
||||
export const groupFormSchema = z
|
||||
.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(2, 'Enter at least two characters.')
|
||||
.max(50, 'Enter at most 50 characters.'),
|
||||
currency: z
|
||||
.string()
|
||||
.min(1, 'Enter at least one character.')
|
||||
.max(5, 'Enter at most five characters.'),
|
||||
name: z.string().min(2, 'min2').max(50, 'max50'),
|
||||
information: z.string().optional(),
|
||||
currency: z.string().min(1, 'min1').max(5, 'max5'),
|
||||
participants: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string().optional(),
|
||||
name: z
|
||||
.string()
|
||||
.min(2, 'Enter at least two characters.')
|
||||
.max(50, 'Enter at most 50 characters.'),
|
||||
name: z.string().min(2, 'min2').max(50, 'max50'),
|
||||
}),
|
||||
)
|
||||
.min(1),
|
||||
@@ -29,7 +21,7 @@ export const groupFormSchema = z
|
||||
if (otherParticipant.name === participant.name) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message: 'Another participant already has this name.',
|
||||
message: 'duplicateParticipantName',
|
||||
path: ['participants', i, 'name'],
|
||||
})
|
||||
}
|
||||
@@ -42,9 +34,7 @@ export type GroupFormValues = z.infer<typeof groupFormSchema>
|
||||
export const expenseFormSchema = z
|
||||
.object({
|
||||
expenseDate: z.coerce.date(),
|
||||
title: z
|
||||
.string({ required_error: 'Please enter a title.' })
|
||||
.min(2, 'Enter at least two characters.'),
|
||||
title: z.string({ required_error: 'titleRequired' }).min(2, 'min2'),
|
||||
category: z.coerce.number().default(0),
|
||||
amount: z
|
||||
.union(
|
||||
@@ -55,19 +45,16 @@ export const expenseFormSchema = z
|
||||
if (Number.isNaN(valueAsNumber))
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Invalid number.',
|
||||
message: 'invalidNumber',
|
||||
})
|
||||
return Math.round(valueAsNumber * 100)
|
||||
}),
|
||||
],
|
||||
{ required_error: 'You must enter an amount.' },
|
||||
{ required_error: 'amountRequired' },
|
||||
)
|
||||
.refine((amount) => amount >= 1, 'The amount must be higher than 0.01.')
|
||||
.refine(
|
||||
(amount) => amount <= 10_000_000_00,
|
||||
'The amount must be lower than 10,000,000.',
|
||||
),
|
||||
paidBy: z.string({ required_error: 'You must select a participant.' }),
|
||||
.refine((amount) => amount != 1, 'amountNotZero')
|
||||
.refine((amount) => amount <= 10_000_000_00, 'amountTenMillion'),
|
||||
paidBy: z.string({ required_error: 'paidByRequired' }),
|
||||
paidFor: z
|
||||
.array(
|
||||
z.object({
|
||||
@@ -80,14 +67,14 @@ export const expenseFormSchema = z
|
||||
if (Number.isNaN(valueAsNumber))
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Invalid number.',
|
||||
message: 'invalidNumber',
|
||||
})
|
||||
return Math.round(valueAsNumber * 100)
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
)
|
||||
.min(1, 'The expense must be paid for at least one participant.')
|
||||
.min(1, 'paidForMin1')
|
||||
.superRefine((paidFor, ctx) => {
|
||||
let sum = 0
|
||||
for (const { shares } of paidFor) {
|
||||
@@ -95,7 +82,7 @@ export const expenseFormSchema = z
|
||||
if (shares < 1) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'All shares must be higher than 0.',
|
||||
message: 'noZeroShares',
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -117,6 +104,7 @@ export const expenseFormSchema = z
|
||||
}),
|
||||
)
|
||||
.default([]),
|
||||
notes: z.string().optional(),
|
||||
})
|
||||
.superRefine((expense, ctx) => {
|
||||
let sum = 0
|
||||
@@ -137,7 +125,7 @@ export const expenseFormSchema = z
|
||||
: `${((sum - expense.amount) / 100).toFixed(2)} surplus`
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `Sum of amounts must equal the expense amount (${detail}).`,
|
||||
message: 'amountSum',
|
||||
path: ['paidFor'],
|
||||
})
|
||||
}
|
||||
@@ -151,7 +139,7 @@ export const expenseFormSchema = z
|
||||
: `${((sum - 10000) / 100).toFixed(0)}% surplus`
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `Sum of percentages must equal 100 (${detail})`,
|
||||
message: 'percentageSum',
|
||||
path: ['paidFor'],
|
||||
})
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ export function getTotalActiveUserShare(
|
||||
|
||||
const paidFors = expense.paidFor
|
||||
const userPaidFor = paidFors.find(
|
||||
(paidFor) => paidFor.participantId === activeUserId,
|
||||
(paidFor) => paidFor.participant.id === activeUserId,
|
||||
)
|
||||
|
||||
if (!userPaidFor) {
|
||||
|
||||
71
src/lib/utils.test.ts
Normal file
71
src/lib/utils.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { formatCurrency } from './utils'
|
||||
|
||||
describe('formatCurrency', () => {
|
||||
const currency = 'CUR'
|
||||
/** For testing decimals */
|
||||
const partialAmount = 1.23
|
||||
/** For testing small full amounts */
|
||||
const smallAmount = 1
|
||||
/** For testing large full amounts */
|
||||
const largeAmount = 10000
|
||||
|
||||
/** Non-breaking space */
|
||||
const nbsp = '\xa0'
|
||||
|
||||
interface variation {
|
||||
amount: number
|
||||
locale: string
|
||||
result: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Variations to be tested, chosen as follows
|
||||
* - `en-US` is a very common i18n fallback
|
||||
* - `de-DE` exhibited faulty behavior in previous versions
|
||||
*/
|
||||
const variations: variation[] = [
|
||||
{
|
||||
amount: partialAmount,
|
||||
locale: `en-US`,
|
||||
result: `${currency}1.23`,
|
||||
},
|
||||
{
|
||||
amount: smallAmount,
|
||||
locale: `en-US`,
|
||||
result: `${currency}1.00`,
|
||||
},
|
||||
{
|
||||
amount: largeAmount,
|
||||
locale: `en-US`,
|
||||
result: `${currency}10,000.00`,
|
||||
},
|
||||
{
|
||||
amount: partialAmount,
|
||||
locale: `de-DE`,
|
||||
result: `1,23${nbsp}${currency}`,
|
||||
},
|
||||
{
|
||||
amount: smallAmount,
|
||||
locale: `de-DE`,
|
||||
result: `1,00${nbsp}${currency}`,
|
||||
},
|
||||
{
|
||||
amount: largeAmount,
|
||||
locale: `de-DE`,
|
||||
result: `10.000,00${nbsp}${currency}`,
|
||||
},
|
||||
]
|
||||
|
||||
for (const variation of variations) {
|
||||
it(`formats ${variation.amount} in ${variation.locale} without fractions`, () => {
|
||||
expect(
|
||||
formatCurrency(currency, variation.amount * 100, variation.locale),
|
||||
).toBe(variation.result)
|
||||
})
|
||||
it(`formats ${variation.amount} in ${variation.locale} with fractions`, () => {
|
||||
expect(
|
||||
formatCurrency(currency, variation.amount, variation.locale, true),
|
||||
).toBe(variation.result)
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -10,10 +10,16 @@ export function delay(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
export function formatExpenseDate(date: Date) {
|
||||
return date.toLocaleDateString('en-US', {
|
||||
dateStyle: 'medium',
|
||||
timeZone: 'UTC',
|
||||
export type DateTimeStyle = NonNullable<
|
||||
ConstructorParameters<typeof Intl.DateTimeFormat>[1]
|
||||
>['dateStyle']
|
||||
export function formatDate(
|
||||
date: Date,
|
||||
locale: string,
|
||||
options: { dateStyle?: DateTimeStyle; timeStyle?: DateTimeStyle } = {},
|
||||
) {
|
||||
return date.toLocaleString(locale, {
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -21,18 +27,31 @@ export function formatCategoryForAIPrompt(category: Category) {
|
||||
return `"${category.grouping}/${category.name}" (ID: ${category.id})`
|
||||
}
|
||||
|
||||
export function formatCurrency(currency: string, amount: number) {
|
||||
const format = new Intl.NumberFormat('en-US', {
|
||||
/**
|
||||
* @param fractions Financial values in this app are generally processed in cents (or equivalent).
|
||||
* They are are therefore integer representations of the amount (e.g. 100 for USD 1.00).
|
||||
* Set this to `true` if you need to pass a value with decimal fractions instead (e.g. 1.00 for USD 1.00).
|
||||
*/
|
||||
export function formatCurrency(
|
||||
currency: string,
|
||||
amount: number,
|
||||
locale: string,
|
||||
fractions?: boolean,
|
||||
) {
|
||||
const format = new Intl.NumberFormat(locale, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
style: 'currency',
|
||||
// '€' will be placed in correct position
|
||||
currency: 'EUR',
|
||||
})
|
||||
const formattedAmount = format.format(amount / 100)
|
||||
return `${currency} ${formattedAmount}`
|
||||
const formattedAmount = format.format(fractions ? amount : amount / 100)
|
||||
return formattedAmount.replace('€', currency)
|
||||
}
|
||||
|
||||
export function formatFileSize(size: number) {
|
||||
export function formatFileSize(size: number, locale: string) {
|
||||
const formatNumber = (num: number) =>
|
||||
num.toLocaleString('en-US', {
|
||||
num.toLocaleString(locale, {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 1,
|
||||
})
|
||||
@@ -42,3 +61,13 @@ export function formatFileSize(size: number) {
|
||||
if (size > 1024) return `${formatNumber(size / 1024)} kB`
|
||||
return `${formatNumber(size)} B`
|
||||
}
|
||||
|
||||
export function normalizeString(input: string): string {
|
||||
// Replaces special characters
|
||||
// Input: áäåèéę
|
||||
// Output: aaaeee
|
||||
return input
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @ts-nocheck
|
||||
import { randomId } from '@/lib/api'
|
||||
import { getPrisma } from '@/lib/prisma'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { Prisma } from '@prisma/client'
|
||||
import { Client } from 'pg'
|
||||
|
||||
@@ -8,8 +8,6 @@ process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'
|
||||
|
||||
async function main() {
|
||||
withClient(async (client) => {
|
||||
const prisma = await getPrisma()
|
||||
|
||||
// console.log('Deleting all groups…')
|
||||
// await prisma.group.deleteMany({})
|
||||
|
||||
|
||||
62
src/trpc/client.tsx
Normal file
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>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user