diff --git a/eslint.config.mjs b/eslint.config.mjs index f7768c06c..87f29108c 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -10,6 +10,8 @@ const eslintConfig = defineConfig([ 'out/**', 'build/**', 'next-env.d.ts', + 'postgres-data/**', + 'node_modules/**', ]), { rules: { diff --git a/messages/de-DE.json b/messages/de-DE.json index 9dace19fb..e091a033b 100644 --- a/messages/de-DE.json +++ b/messages/de-DE.json @@ -52,6 +52,15 @@ "myGroups": "Meine Gruppen", "create": "Erstellen", "loadingRecent": "Lade letzte Gruppen…", + "CreateOptions": { + "openMenu": "Erstelloptionen öffnen", + "newGroup": "Neue Gruppe", + "importFromFile": "Aus Datei importieren" + }, + "ImportDialog": { + "successTitle": "Gruppe erstellt", + "successDescription": "Gruppe {name} wurde aus dem Import erstellt." + }, "NoRecent": { "description": "Du hast in der letzten Zeit keine Gruppe besucht.", "create": "Erstelle eine", @@ -456,4 +465,47 @@ "heading": "Geläufigste" } } + , + "FileImport": { + "title": "Gruppe aus Datei importieren", + "buttonLabel": "Import-Dialog öffnen", + "errorTitle": "Fehler", + "processing": "Verarbeite…", + "fileLabel": "Datei auswählen", + "uploadDragTitle": "Datei hier ablegen oder klicken, um auszuwählen", + "uploadDragDescription": "Unterstützt: JSON (Spliit-JSON) und Debug-Dateien", + "fileReadError": "Die ausgewählte Datei konnte nicht gelesen werden.", + "newGroupNameLabel": "Name der neuen Gruppe", + "newGroupNamePlaceholder": "Gruppennamen eingeben (optional)", + "analysisAwaiting": "Warte auf Datei-Analyse…", + "analysisExplanation": "Wähle eine Datei, um sie vor dem Import zu analysieren.", + "previewErrorTitle": "Die Datei konnte nicht analysiert werden", + "generalInfoTitle": "Allgemeine Informationen", + "generalInfoFormat": "Format", + "generalInfoRows": "Zeilen", + "generalInfoInvalidRows": "Ungültige Zeilen", + "generalInfoTotal": "Gesamtbetrag", + "generalInfoParticipants": "Teilnehmer und Salden", + "generalInfoParticipantsEmpty": "Keine Teilnehmerinformationen erkannt.", + "generalInfoUnknown": "Unbekannt", + "analysisHeaderTitle": "Probleme im Header", + "analysisGeneralWarningsTitle": "Allgemeine Warnungen", + "analysisCategoryTitle": "Kategorie-Zuordnungswarnungen", + "analysisErrorsTitle": "Zeilenfehler", + "analysisFatalHint": "Behebe diese Fehler, um den Import zu aktivieren.", + "import": "Importieren", + "importing": "Importiere…", + "importProgressLabel": "Importiere Ausgaben", + "importCancel": "Import abbrechen", + "importCanceling": "Breche ab…", + "importResultCompleted": "{created} von {total} Zeilen importiert", + "importResultCancelledSimple": "Import abgebrochen", + "importResultConfirm": "Weiter" + }, + "FileImportErrors": { + "noParticipants": "Keine Teilnehmer in der Datei definiert.", + "fileEmpty": "Die hochgeladene Datei war leer.", + "invalidAmount": "Ungültiger Betrag in einer oder mehreren Zeilen.", + "invalidDate": "Ungültiges Ausgabedatum in einer oder mehreren Zeilen." + } } diff --git a/messages/en-US.json b/messages/en-US.json index 10f5b7464..3eb7f4355 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -52,6 +52,15 @@ "myGroups": "My groups", "create": "Create", "loadingRecent": "Loading recent groups…", + "CreateOptions": { + "openMenu": "Open create options", + "newGroup": "New group", + "importFromFile": "Import from file" + }, + "ImportDialog": { + "successTitle": "Group created", + "successDescription": "Group {name} was created from import." + }, "NoRecent": { "description": "You have not visited any group recently.", "create": "Create one", @@ -449,4 +458,47 @@ "heading": "Other currencies" } } + , + "FileImport": { + "title": "Import group from file", + "buttonLabel": "Open import dialog", + "errorTitle": "Error", + "processing": "Processing…", + "fileLabel": "Select a file", + "uploadDragTitle": "Drop a file here or click to select", + "uploadDragDescription": "Supported: JSON (Spliit-JSON) and debug files", + "fileReadError": "Could not read the selected file.", + "newGroupNameLabel": "New group name", + "newGroupNamePlaceholder": "Enter a group name (optional)", + "analysisAwaiting": "Awaiting file analysis…", + "analysisExplanation": "Select a file to analyze its content before importing.", + "previewErrorTitle": "We couldn't analyze your file", + "generalInfoTitle": "General information", + "generalInfoFormat": "Format", + "generalInfoRows": "Rows", + "generalInfoInvalidRows": "Invalid rows", + "generalInfoTotal": "Total amount", + "generalInfoParticipants": "Participants and balances", + "generalInfoParticipantsEmpty": "No participant information detected.", + "generalInfoUnknown": "Unknown", + "analysisHeaderTitle": "Header issues", + "analysisGeneralWarningsTitle": "General warnings", + "analysisCategoryTitle": "Category mapping warnings", + "analysisErrorsTitle": "Row errors", + "analysisFatalHint": "Fix these errors to enable import.", + "import": "Import", + "importing": "Importing…", + "importProgressLabel": "Importing expenses", + "importCancel": "Cancel import", + "importCanceling": "Cancelling…", + "importResultCompleted": "Imported {created} of {total} rows", + "importResultCancelledSimple": "Import cancelled", + "importResultConfirm": "Continue" + }, + "FileImportErrors": { + "noParticipants": "No participants defined in file.", + "fileEmpty": "The uploaded file was empty.", + "invalidAmount": "Invalid amount in one or more rows.", + "invalidDate": "Invalid expense date in one or more rows." + } } diff --git a/messages/es.json b/messages/es.json index 6b5a36114..9b968a139 100644 --- a/messages/es.json +++ b/messages/es.json @@ -52,6 +52,15 @@ "myGroups": "Mis grupos", "create": "Crear", "loadingRecent": "Cargando grupos recientes…", + "CreateOptions": { + "openMenu": "Abrir opciones de creación", + "newGroup": "Nuevo grupo", + "importFromFile": "Importar desde archivo" + }, + "ImportDialog": { + "successTitle": "Grupo creado", + "successDescription": "El grupo {name} se creó a partir de la importación." + }, "NoRecent": { "description": "No has visitado ningun grupo recientemente.", "create": "Crea uno", @@ -448,4 +457,47 @@ "heading": "Otras monedas" } } + , + "FileImport": { + "title": "Importar grupo desde archivo", + "buttonLabel": "Abrir diálogo de importación", + "errorTitle": "Error", + "processing": "Procesando…", + "fileLabel": "Seleccionar un archivo", + "uploadDragTitle": "Suelta un archivo aquí o haz clic para seleccionar", + "uploadDragDescription": "Compatible: JSON (Spliit‑JSON) y archivos de depuración", + "fileReadError": "No se pudo leer el archivo seleccionado.", + "newGroupNameLabel": "Nombre del nuevo grupo", + "newGroupNamePlaceholder": "Introduce un nombre de grupo (opcional)", + "analysisAwaiting": "Esperando el análisis del archivo…", + "analysisExplanation": "Selecciona un archivo para analizar su contenido antes de importar.", + "previewErrorTitle": "No pudimos analizar tu archivo", + "generalInfoTitle": "Información general", + "generalInfoFormat": "Formato", + "generalInfoRows": "Filas", + "generalInfoInvalidRows": "Filas inválidas", + "generalInfoTotal": "Monto total", + "generalInfoParticipants": "Participantes y saldos", + "generalInfoParticipantsEmpty": "No se detectó información de participantes.", + "generalInfoUnknown": "Desconocido", + "analysisHeaderTitle": "Problemas en el encabezado", + "analysisGeneralWarningsTitle": "Advertencias generales", + "analysisCategoryTitle": "Advertencias de categorías", + "analysisErrorsTitle": "Errores por fila", + "analysisFatalHint": "Corrige estos errores para habilitar la importación.", + "import": "Importar", + "importing": "Importando…", + "importProgressLabel": "Importando gastos", + "importCancel": "Cancelar importación", + "importCanceling": "Cancelando…", + "importResultCompleted": "{created} de {total} filas importadas", + "importResultCancelledSimple": "Importación cancelada", + "importResultConfirm": "Continuar" + }, + "FileImportErrors": { + "noParticipants": "No hay participantes definidos en el archivo.", + "fileEmpty": "El archivo subido estaba vacío.", + "invalidAmount": "Monto inválido en una o más filas.", + "invalidDate": "Fecha de gasto inválida en una o más filas." + } } diff --git a/messages/fr-FR.json b/messages/fr-FR.json index 3e69f676b..4fc48c4b1 100644 --- a/messages/fr-FR.json +++ b/messages/fr-FR.json @@ -52,6 +52,15 @@ "myGroups": "Mes groupes", "create": "Créer", "loadingRecent": "Chargement des groupes récents…", + "CreateOptions": { + "openMenu": "Ouvrir les options de création", + "newGroup": "Nouveau groupe", + "importFromFile": "Importer depuis un fichier" + }, + "ImportDialog": { + "successTitle": "Groupe créé", + "successDescription": "Le groupe {name} a été créé à partir de l'import." + }, "NoRecent": { "description": "Vous n'avez visité aucun groupe récemment.", "create": "Créer un groupe", @@ -456,4 +465,47 @@ "heading": "Autres devises" } } + , + "FileImport": { + "title": "Importer un groupe depuis un fichier", + "buttonLabel": "Ouvrir la boîte d'import", + "errorTitle": "Erreur", + "processing": "Traitement…", + "fileLabel": "Sélectionner un fichier", + "uploadDragTitle": "Déposez un fichier ici ou cliquez pour sélectionner", + "uploadDragDescription": "Pris en charge : JSON (Spliit‑JSON) et fichiers de débogage", + "fileReadError": "Impossible de lire le fichier sélectionné.", + "newGroupNameLabel": "Nom du nouveau groupe", + "newGroupNamePlaceholder": "Entrez un nom de groupe (optionnel)", + "analysisAwaiting": "En attente de l'analyse du fichier…", + "analysisExplanation": "Sélectionnez un fichier pour analyser son contenu avant l'import.", + "previewErrorTitle": "Nous n'avons pas pu analyser votre fichier", + "generalInfoTitle": "Informations générales", + "generalInfoFormat": "Format", + "generalInfoRows": "Lignes", + "generalInfoInvalidRows": "Lignes invalides", + "generalInfoTotal": "Montant total", + "generalInfoParticipants": "Participants et soldes", + "generalInfoParticipantsEmpty": "Aucune information de participant détectée.", + "generalInfoUnknown": "Inconnu", + "analysisHeaderTitle": "Problèmes d'en‑tête", + "analysisGeneralWarningsTitle": "Avertissements généraux", + "analysisCategoryTitle": "Avertissements de catégorisation", + "analysisErrorsTitle": "Erreurs de lignes", + "analysisFatalHint": "Corrigez ces erreurs pour activer l'import.", + "import": "Importer", + "importing": "Importation…", + "importProgressLabel": "Import des dépenses", + "importCancel": "Annuler l'import", + "importCanceling": "Annulation…", + "importResultCompleted": "{created} sur {total} lignes importées", + "importResultCancelledSimple": "Import annulé", + "importResultConfirm": "Continuer" + }, + "FileImportErrors": { + "noParticipants": "Aucun participant défini dans le fichier.", + "fileEmpty": "Le fichier téléchargé était vide.", + "invalidAmount": "Montant invalide dans une ou plusieurs lignes.", + "invalidDate": "Date de dépense invalide dans une ou plusieurs lignes." + } } diff --git a/package-lock.json b/package-lock.json index 17f239100..51f339d80 100644 --- a/package-lock.json +++ b/package-lock.json @@ -764,6 +764,7 @@ "version": "3.622.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso-oidc/-/client-sso-oidc-3.622.0.tgz", "integrity": "sha512-dwWDfN+S98npeY77Ugyv8VIHKRHN+n/70PWE4EgolcjaMrTINjvUh9a/SypFEs5JmBOAeCQt8S2QpM3Wvzp+pQ==", + "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -1424,6 +1425,7 @@ "version": "3.622.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.622.0.tgz", "integrity": "sha512-Yqtdf/wn3lcFVS42tR+zbz4HLyWxSmztjVW9L/yeMlvS7uza5nSkWqP/7ca+RxZnXLyrnA4jJtSHqykcErlhyg==", + "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -4007,6 +4009,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.25.2.tgz", "integrity": "sha512-BBt3opiCOxUr9euZ5/ro/Xv8/V7yJ5bjYMqG/C1YAo8MIKAnumZalCN+msbci3Pigy4lIQfPUpfMM27HMGaYEA==", "dev": true, + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.24.7", @@ -9837,6 +9840,7 @@ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.59.15.tgz", "integrity": "sha512-QbVlAkTI78wB4Mqgf2RDmgC0AOiJqer2c5k9STOOSXGv1S6ZkY37r/6UpE8DbQ2Du0ohsdoXgFNEyv+4eDoPEw==", "license": "MIT", + "peer": true, "dependencies": { "@tanstack/query-core": "5.59.13" }, @@ -9853,6 +9857,7 @@ "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz", "integrity": "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==", "dev": true, + "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -9958,6 +9963,7 @@ "https://trpc.io/sponsor" ], "license": "MIT", + "peer": true, "peerDependencies": { "@trpc/server": "11.0.0-rc.586+3388c9691" } @@ -9985,7 +9991,8 @@ "funding": [ "https://trpc.io/sponsor" ], - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@tsconfig/node10": { "version": "1.0.11", @@ -10193,6 +10200,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.8.9.tgz", "integrity": "sha512-UzykFsT3FhHb1h7yD4CA4YhBHq545JC0YnEz41xkipN88eKQtL6rSgocL5tbAP6Ola9Izm/Aw4Ora8He4x0BHg==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~5.26.4" } @@ -10291,6 +10299,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.48.tgz", "integrity": "sha512-qboRCl6Ie70DQQG9hhNREz81jqC1cs9EVNcjQ1AU+jH6NFfSAhVVbrrY/+nSF+Bsk4AOwm9Qa61InvMCyV+H3w==", "devOptional": true, + "peer": true, "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", @@ -10302,6 +10311,7 @@ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.18.tgz", "integrity": "sha512-TJxDm6OfAX2KJWJdMEVTwWke5Sc/E/RlnPGvGfS0W7+6ocy2xhDVQVh/KvC2Uf7kACs+gDytdusDSdWfWkaNzw==", "devOptional": true, + "peer": true, "dependencies": { "@types/react": "*" } @@ -10393,6 +10403,7 @@ "integrity": "sha512-PC0PDZfJg8sP7cmKe6L3QIL8GZwU5aRvUFedqSIpw3B+QjRSUZeeITC2M5XKeMXEzL6wccN196iy3JLwKNvDVA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.48.1", "@typescript-eslint/types": "8.48.1", @@ -10923,6 +10934,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "devOptional": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -11559,6 +11571,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001646", "electron-to-chromium": "^1.5.4", @@ -12575,7 +12588,8 @@ "version": "8.6.0", "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz", "integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/embla-carousel-react": { "version": "8.6.0", @@ -12876,6 +12890,7 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -13100,6 +13115,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -13263,6 +13279,7 @@ "integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -15836,6 +15853,7 @@ "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.20.0.tgz", "integrity": "sha512-3TV69ZbrvV6U5DfQimop50jE9Dl6J8O1ja1dvBbMba/sZ3YBEQqJ2VZRoQPVnhlzjNtU1vaXRZVrVjU4qtm8yA==", "license": "MIT", + "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -16380,6 +16398,7 @@ "resolved": "https://registry.npmjs.org/next/-/next-16.0.7.tgz", "integrity": "sha512-3mBRJyPxT4LOxAJI6IsXeFtKfiJUbjCLgvXO02fV8Wy/lIhPvP94Fe7dGhUgHXcQy4sSuYwQNcOLhIfOm0rL0A==", "license": "MIT", + "peer": true, "dependencies": { "@next/env": "16.0.7", "@swc/helpers": "0.5.15", @@ -16501,17 +16520,6 @@ } } }, - "node_modules/next-intl/node_modules/@swc/helpers": { - "version": "0.5.17", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz", - "integrity": "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==", - "license": "Apache-2.0", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.8.0" - } - }, "node_modules/next-intl/node_modules/negotiator": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", @@ -17488,6 +17496,7 @@ "version": "8.11.3", "resolved": "https://registry.npmjs.org/pg/-/pg-8.11.3.tgz", "integrity": "sha512-+9iuvG8QfaaUrrph+kpF24cXkH1YOOUeArRNYIxq1viYHZagBxrTno7cecY1Fa44tJeZvaoG+Djpkc3JwehN5g==", + "peer": true, "dependencies": { "buffer-writer": "2.0.0", "packet-reader": "1.0.0", @@ -17721,6 +17730,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", @@ -17908,6 +17918,7 @@ "integrity": "sha512-L/4pUDMxcNa8R/EthV08Zt42WBO4h1rarVtK0K+QJG0X187OLo7l699jWw0GKuwzkPQ//jMFA/8Xm6Fh3J/DAg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -17977,6 +17988,7 @@ "integrity": "sha512-bXWy3vTk8mnRmT+SLyZBQoC2vtV9Z8u7OHvEu+aULYxwiop/CPiFZ+F56KsNRNf35jw+8wcu8pmLsjxpBxAO9g==", "hasInstallScript": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@prisma/config": "6.18.0", "@prisma/engines": "6.18.0" @@ -18092,6 +18104,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz", "integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -18101,6 +18114,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.1.tgz", "integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -18113,6 +18127,7 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.68.0.tgz", "integrity": "sha512-oNN3fjrZ/Xo40SWlHf1yCjlMK417JxoSJVUXQjGdvdRCU07NTFei1i1f8ApUAts+IVh14e4EdakeLEA+BEAs/Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=18.0.0" }, @@ -19171,6 +19186,7 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.3.5.tgz", "integrity": "sha512-5SEZU4J7pxZgSkv7FP1zY8i2TIAOooNZ1e/OGtxIEv6GltpoiXUqWvLy89+a10qYTB1N5Ifkuw9lqQkN9sscvA==", "license": "MIT", + "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -19306,6 +19322,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -19374,6 +19391,7 @@ "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "devOptional": true, + "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -19548,6 +19566,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", "devOptional": true, + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml index fbffa92c2..044d57cdb 100644 --- a/prisma/migrations/migration_lock.toml +++ b/prisma/migrations/migration_lock.toml @@ -1,3 +1,3 @@ # Please do not edit this file manually -# It should be added in your version-control system (i.e. Git) -provider = "postgresql" \ No newline at end of file +# It should be added in your version-control system (e.g., Git) +provider = "postgresql" diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b20aa0e24..8f515c39d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -131,3 +131,4 @@ enum ActivityType { UPDATE_EXPENSE DELETE_EXPENSE } + diff --git a/src/app/groups/[groupId]/expenses/create-from-receipt-button.tsx b/src/app/groups/[groupId]/expenses/create-from-receipt-button.tsx index 53a6d72c8..2853482e4 100644 --- a/src/app/groups/[groupId]/expenses/create-from-receipt-button.tsx +++ b/src/app/groups/[groupId]/expenses/create-from-receipt-button.tsx @@ -176,7 +176,7 @@ function ReceiptDialogContent() {
{t('Dialog.titleLabel')} -
{receiptInfo ? receiptInfo.title ?? : '…'}
+
{receiptInfo ? (receiptInfo.title ?? ) : '…'}
{t('Dialog.categoryLabel')} diff --git a/src/app/groups/[groupId]/expenses/expense-form.tsx b/src/app/groups/[groupId]/expenses/expense-form.tsx index ebf2c883c..4f3f921bc 100644 --- a/src/app/groups/[groupId]/expenses/expense-form.tsx +++ b/src/app/groups/[groupId]/expenses/expense-form.tsx @@ -209,64 +209,64 @@ export function ExpenseForm({ recurrenceRule: expense.recurrenceRule ?? undefined, } : searchParams.get('reimbursement') - ? { - title: t('reimbursement'), - expenseDate: new Date(), - amount: amountAsDecimal( - Number(searchParams.get('amount')) || 0, - groupCurrency, - ), - originalCurrency: group.currencyCode, - originalAmount: undefined, - conversionRate: undefined, - category: 1, // category with Id 1 is Payment - paidBy: searchParams.get('from') ?? undefined, - paidFor: [ - searchParams.get('to') - ? { - participant: searchParams.get('to')!, - shares: '1' as any, // String for consistent form handling - } - : undefined, - ], - isReimbursement: true, - splitMode: defaultSplittingOptions.splitMode, - saveDefaultSplittingOptions: false, - documents: [], - notes: '', - recurrenceRule: RecurrenceRule.NONE, - } - : { - title: searchParams.get('title') ?? '', - expenseDate: searchParams.get('date') - ? new Date(searchParams.get('date') as string) - : new Date(), - amount: Number(searchParams.get('amount')) || 0, - originalCurrency: group.currencyCode ?? undefined, - originalAmount: undefined, - conversionRate: undefined, - category: searchParams.get('categoryId') - ? Number(searchParams.get('categoryId')) - : 0, // category with Id 0 is General - // paid for all, split evenly - paidFor: defaultSplittingOptions.paidFor, - paidBy: getSelectedPayer(), - isReimbursement: false, - splitMode: defaultSplittingOptions.splitMode, - saveDefaultSplittingOptions: false, - documents: searchParams.get('imageUrl') - ? [ - { - id: randomId(), - url: searchParams.get('imageUrl') as string, - width: Number(searchParams.get('imageWidth')), - height: Number(searchParams.get('imageHeight')), - }, - ] - : [], - notes: '', - recurrenceRule: RecurrenceRule.NONE, - }, + ? { + title: t('reimbursement'), + expenseDate: new Date(), + amount: amountAsDecimal( + Number(searchParams.get('amount')) || 0, + groupCurrency, + ), + originalCurrency: group.currencyCode, + originalAmount: undefined, + conversionRate: undefined, + category: 1, // category with Id 1 is Payment + paidBy: searchParams.get('from') ?? undefined, + paidFor: [ + searchParams.get('to') + ? { + participant: searchParams.get('to')!, + shares: '1' as any, // String for consistent form handling + } + : undefined, + ], + isReimbursement: true, + splitMode: defaultSplittingOptions.splitMode, + saveDefaultSplittingOptions: false, + documents: [], + notes: '', + recurrenceRule: RecurrenceRule.NONE, + } + : { + title: searchParams.get('title') ?? '', + expenseDate: searchParams.get('date') + ? new Date(searchParams.get('date') as string) + : new Date(), + amount: Number(searchParams.get('amount')) || 0, + originalCurrency: group.currencyCode ?? undefined, + originalAmount: undefined, + conversionRate: undefined, + category: searchParams.get('categoryId') + ? Number(searchParams.get('categoryId')) + : 0, // category with Id 0 is General + // paid for all, split evenly + paidFor: defaultSplittingOptions.paidFor, + paidBy: getSelectedPayer(), + isReimbursement: false, + splitMode: defaultSplittingOptions.splitMode, + saveDefaultSplittingOptions: false, + documents: searchParams.get('imageUrl') + ? [ + { + id: randomId(), + url: searchParams.get('imageUrl') as string, + width: Number(searchParams.get('imageWidth')), + height: Number(searchParams.get('imageHeight')), + }, + ] + : [], + notes: '', + recurrenceRule: RecurrenceRule.NONE, + }, }) const [isCategoryLoading, setCategoryLoading] = useState(false) const activeUserId = useActiveUser(group.id) @@ -927,12 +927,12 @@ export function ExpenseForm({ 'BY_PERCENTAGE' ? Number(shares) * 100 // Convert percentage to basis points (e.g., 50% -> 5000) : form.watch('splitMode') === - 'BY_AMOUNT' - ? amountAsMinorUnits( - shares, - groupCurrency, - ) - : shares, + 'BY_AMOUNT' + ? amountAsMinorUnits( + shares, + groupCurrency, + ) + : shares, expenseId: '', participantId: '', }), diff --git a/src/app/groups/create/create-group.tsx b/src/app/groups/create/create-group.tsx index c98e296d4..2753a06c7 100644 --- a/src/app/groups/create/create-group.tsx +++ b/src/app/groups/create/create-group.tsx @@ -12,7 +12,7 @@ export const CreateGroup = () => { return ( { - const { groupId } = await mutateAsync({ groupFormValues }) + const { id: groupId } = await mutateAsync({ groupFormValues }) await utils.groups.invalidate() router.push(`/groups/${groupId}`) }} diff --git a/src/app/groups/recent-group-list.tsx b/src/app/groups/recent-group-list.tsx index 3d6465e67..a7f8dd184 100644 --- a/src/app/groups/recent-group-list.tsx +++ b/src/app/groups/recent-group-list.tsx @@ -5,14 +5,23 @@ import { getArchivedGroups, getRecentGroups, getStarredGroups, + saveRecentGroup, } from '@/app/groups/recent-groups-helpers' +import { FileImportModal } from '@/components/file-import-modal' import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' import { getGroups } from '@/lib/api' import { trpc } from '@/trpc/client' import { AppRouterOutput } from '@/trpc/routers/_app' -import { Loader2 } from 'lucide-react' +import { ChevronDown, Loader2 } from 'lucide-react' import { useTranslations } from 'next-intl' import Link from 'next/link' +import { useRouter } from 'next/navigation' import { PropsWithChildren, useEffect, useState } from 'react' import { RecentGroupListCard } from './recent-group-list-card' @@ -222,6 +231,8 @@ function GroupsPage({ reload, }: PropsWithChildren<{ reload: () => void }>) { const t = useTranslations('Groups') + const router = useRouter() + const [importOpen, setImportOpen] = useState(false) return ( <>
@@ -230,14 +241,39 @@ function GroupsPage({
- +
+ + + + + + + setImportOpen(true)}> + {t('CreateOptions.importFromFile')} + + + +
+ { + saveRecentGroup({ id: groupId, name: groupName }) + reload() + router.push(`/groups/${groupId}`) + }} + />
{children}
) diff --git a/src/components/file-import-modal.tsx b/src/components/file-import-modal.tsx new file mode 100644 index 000000000..3b306d130 --- /dev/null +++ b/src/components/file-import-modal.tsx @@ -0,0 +1,287 @@ +'use client' + +// File import modal used from the Groups overview. +// It provides: +// - Upload + drag/drop area for a Spliit JSON export +// - Preview (detected format, totals, warnings) +// - Scroll-to-confirm gate before import +// - Chunked import with a progress bar +// - Cancel deletes the created group and any imported expenses +// - Finalize returns the new group id/name to the caller for navigation + +import { ChangeEvent, useCallback, useEffect, useRef, useState } from 'react' + +import { ImportAnalysisPanel } from '@/components/import/import-analysis-panel' +import { UploadDropzone } from '@/components/import/upload-dropzone' +import { useFileImportProcess } from '@/components/import/use-import-process' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog' +import { Label } from '@/components/ui/label' +import { Upload } from 'lucide-react' +import { useTranslations } from 'next-intl' +import { Input } from './ui/input' + +export function FileImportModal({ + open: controlledOpen, + onOpenChange, + hideTrigger, + onCreateSuccess, +}: { + open?: boolean + onOpenChange?: (open: boolean) => void + hideTrigger?: boolean + onCreateSuccess?: (result: { groupId: string; groupName: string }) => void +}) { + const t = useTranslations('FileImport') + + const [dialogOpen, setDialogOpen] = useState(controlledOpen ?? false) + const setOpen = onOpenChange ?? setDialogOpen + const open = controlledOpen ?? dialogOpen + + const { + processState, + fileName, + groupName, + setGroupName, + previewResult, + previewError, + importProgress, + importResult, + resultActionLoading, + analyzeFile, + startImport, + requestCancel, + finalizeImport, + resetProcess, + } = useFileImportProcess({ + onImportSuccess: onCreateSuccess, + onClose: () => setOpen(false), + }) + + const scrollContainerRef = useRef(null) + const [hasReachedBottom, setHasReachedBottom] = useState(false) + + const handleFileRead = (file: File) => { + analyzeFile(file) + } + + const handleFileChange = (event: ChangeEvent) => { + const file = event.target.files?.[0] + if (!file) return + handleFileRead(file) + event.target.value = '' + } + + const handleDropSelect = (file: File) => handleFileRead(file) + + const hasFatalErrors = (previewResult?.errors.length ?? 0) > 0 + const canImport = Boolean(previewResult) && !hasFatalErrors + const jobRunning = processState === 'importing' + const importLoading = + processState === 'analyzing' || processState === 'importing' + + // Reset process when modal closes + const prevOpenRef = useRef(open) + useEffect(() => { + if (prevOpenRef.current && !open) { + resetProcess() + } + prevOpenRef.current = open + }, [open, resetProcess]) + + const handleScroll = useCallback(() => { + const element = scrollContainerRef.current + if (!element) return + const reachedBottom = + element.scrollTop + element.clientHeight >= element.scrollHeight - 8 + setHasReachedBottom(reachedBottom) + }, []) + + useEffect(() => { + handleScroll() + }, [handleScroll, previewResult, importProgress]) + + const renderContent = () => { + if (jobRunning) { + return ( +
+
+

{t('importProgressLabel')}

+

+ {importProgress.processed}/{importProgress.total} +

+
+
+
+
+
+

{t('importing')}

+
+ +
+ ) + } + + if (importResult) { + return ( +
+
+

+ {importResult.status === 'completed' + ? t('importResultCompleted', { + created: importResult.created, + total: importResult.total, + }) + : t('importResultCancelledSimple')} +

+
+
+ {importResult.status === 'completed' && ( + + )} + {importResult.status === 'cancelled' && ( + + )} +
+
+ ) + } + + return ( +
+ + + {previewResult && ( +
+ + setGroupName(event.target.value)} + /> +
+ )} + + + +
+ +
+
+ ) + } + return ( + { + if (next === open) return + if (!next && jobRunning) { + // Request cancel and keep the dialog open until the loop completes and shows the result. + requestCancel() + return + } + setOpen(next) + }} + > + {!hideTrigger && ( + + + + )} + { + // Do not allow closing by clicking outside while a job runs + if (jobRunning) e.preventDefault() + }} + onEscapeKeyDown={(e) => { + // Block ESC close while importing; users should press the header X to cancel + if (jobRunning) e.preventDefault() + }} + > + + {t('title')} + + {t('analysisExplanation')} + + +
+
+ {renderContent()} +
+
+
+
+ ) +} diff --git a/src/components/import/import-analysis-panel.tsx b/src/components/import/import-analysis-panel.tsx new file mode 100644 index 000000000..44825313d --- /dev/null +++ b/src/components/import/import-analysis-panel.tsx @@ -0,0 +1,212 @@ +'use client' + +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' +import { + Currency as CurrencyType, + getCurrency as lookupCurrency, +} from '@/lib/currency' +import { ImportBuildResult } from '@/lib/imports/file-import' +import { formatCurrency } from '@/lib/utils' +import { useLocale, useTranslations } from 'next-intl' + +export function ImportAnalysisPanel({ + previewResult, + previewError, +}: { + previewResult: ImportBuildResult | null + previewError: string | null +}) { + const t = useTranslations('FileImport') + const locale = useLocale() + + const totalRows = + (previewResult?.expenses.length ?? 0) + (previewResult?.errors.length ?? 0) + const headerErrors: string[] = [] + const generalWarnings: string[] = [] + const rowErrors = previewResult?.errors ?? [] + const categoryWarnings: { row: number; label: string; message: string }[] = [] + const participantSummaries = previewResult?.participantSummaries ?? [] + + let currency: CurrencyType | null = null + const currencyCode = previewResult?.group?.currencyCode + const currencySymbol = previewResult?.group?.currency + if (currencyCode) { + try { + currency = lookupCurrency(currencyCode) + } catch { + currency = null + } + } else if (currencySymbol) { + currency = { + name: 'Custom', + symbol_native: currencySymbol, + symbol: currencySymbol, + code: '', + name_plural: '', + rounding: 0, + decimal_digits: 2, + } + } + + const formatSignedAmount = (amount: number) => { + if (!currency) return t('generalInfoUnknown') + if (amount === 0) return formatCurrency(currency, amount, locale) + const formatted = formatCurrency(currency, Math.abs(amount), locale) + const sign = amount > 0 ? '+' : '-' + return `${sign}${formatted}` + } + + const participantList = participantSummaries.map((participant, index) => ( + + {participant.name}{' '} + = 0 ? 'text-emerald-600' : 'text-destructive' + } + > + ({formatSignedAmount(participant.balance)}) + + {index < participantSummaries.length - 1 ? , : null} + + )) + + const totalAmount = + previewResult?.expenses.reduce((sum, e) => sum + e.amount, 0) ?? 0 + const formattedTotalAmount = + currency && previewResult + ? formatCurrency(currency, totalAmount, locale) + : t('generalInfoUnknown') + + return ( +
+ {previewResult ? ( + <> +
+
+

{t('generalInfoTitle')}

+
+
+
+ {t('generalInfoFormat')} +
+
+ {previewResult.format?.label ?? t('generalInfoUnknown')} +
+
+ {/* Language not available in simplified import result */} +
+
+ {t('generalInfoRows')} +
+
{totalRows}
+
+
+
+ {t('generalInfoInvalidRows')} +
+
{rowErrors.length}
+
+
+
+ {t('generalInfoTotal')} +
+
+ {formattedTotalAmount} +
+
+
+
+
+

+ {t('generalInfoParticipants')} +

+ {participantSummaries.length > 0 ? ( +

+ {participantList} +

+ ) : ( +

+ {t('generalInfoParticipantsEmpty')} +

+ )} +
+
+ + {headerErrors.length > 0 && ( + + {t('analysisHeaderTitle')} + +
    + {headerErrors.map((error) => ( +
  • {error}
  • + ))} +
+
+
+ )} + + {generalWarnings.length > 0 && ( + + + {t('analysisGeneralWarningsTitle')} + + +
    + {generalWarnings.map((warning, index) => ( +
  • {warning}
  • + ))} +
+
+
+ )} + + {categoryWarnings.length > 0 && ( + + {t('analysisCategoryTitle')} + +
    + {categoryWarnings.map((warning) => ( +
  • + {warning.label}: {warning.message} +
  • + ))} +
+
+
+ )} + + {rowErrors.length > 0 && ( + + + {t('analysisErrorsTitle')} + + +
    + {rowErrors.map((error) => ( +
  • + #{error.row}: {error.message} +
  • + ))} +
+

+ {t('analysisFatalHint')} +

+
+
+ )} + + ) : ( + + + {previewError ? t('previewErrorTitle') : t('analysisAwaiting')} + + +

+ {previewError ? previewError : t('analysisExplanation')} +

+
+
+ )} +
+ ) +} diff --git a/src/components/import/import-progress-view.tsx b/src/components/import/import-progress-view.tsx new file mode 100644 index 000000000..676110028 --- /dev/null +++ b/src/components/import/import-progress-view.tsx @@ -0,0 +1,50 @@ +'use client' + +import { Button } from '@/components/ui/button' + +export function ImportProgressView({ + label, + importingText, + processed, + total, + onCancel, + cancelLabel, + cancelling, +}: { + label: string + importingText: string + processed: number + total: number + onCancel: () => void + cancelLabel: string + cancelling: boolean +}) { + const percent = Math.min(100, (processed / Math.max(total, 1)) * 100) + return ( +
+
+

{label}

+

+ {processed}/{total} +

+
+
+
+
+
+

{importingText}

+
+ +
+ ) +} diff --git a/src/components/import/import-result-view.tsx b/src/components/import/import-result-view.tsx new file mode 100644 index 000000000..6c0c4356c --- /dev/null +++ b/src/components/import/import-result-view.tsx @@ -0,0 +1,37 @@ +'use client' + +import { Button } from '@/components/ui/button' + +export type ImportResultStatus = 'completed' | 'cancelled' + +export function ImportResultView({ + status, + title, + confirmLabel, + onConfirm, + confirmDisabled, +}: { + status: ImportResultStatus + title: string + confirmLabel: string + onConfirm: () => void + confirmDisabled?: boolean +}) { + return ( +
+
+

{title}

+
+
+ +
+
+ ) +} diff --git a/src/components/import/upload-dropzone.tsx b/src/components/import/upload-dropzone.tsx new file mode 100644 index 000000000..2c7e1a25e --- /dev/null +++ b/src/components/import/upload-dropzone.tsx @@ -0,0 +1,72 @@ +'use client' + +import { Label } from '@/components/ui/label' +import { Upload } from 'lucide-react' +import { useRef, useState } from 'react' + +export function UploadDropzone({ + inputId, + label, + title, + description, + accept = '.csv,.json,text/csv,application/json', + onSelect, +}: { + inputId: string + label: string + title: string + description?: string + accept?: string + onSelect: (file: File) => void +}) { + const inputRef = useRef(null) + const [isDragging, setIsDragging] = useState(false) + + const handleFileChange: React.ChangeEventHandler = (e) => { + const file = e.target.files?.[0] + if (file) onSelect(file) + e.currentTarget.value = '' + } + + return ( +
+ + +
inputRef.current?.click()} + onDragOver={(e) => { + e.preventDefault() + setIsDragging(true) + }} + onDragLeave={(e) => { + e.preventDefault() + setIsDragging(false) + }} + onDrop={(e) => { + e.preventDefault() + setIsDragging(false) + const file = e.dataTransfer.files?.[0] + if (file) onSelect(file) + }} + className={`flex cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed p-6 text-center transition ${ + isDragging + ? 'border-primary bg-primary/10' + : 'border-muted bg-muted/20' + }`} + > + +

{title}

+ {description ? ( +

{description}

+ ) : null} +
+
+ ) +} diff --git a/src/components/import/use-import-process.ts b/src/components/import/use-import-process.ts new file mode 100644 index 000000000..aa7575cab --- /dev/null +++ b/src/components/import/use-import-process.ts @@ -0,0 +1,319 @@ +'use client' + +import { useCallback, useRef, useState } from 'react' + +import { useToast } from '@/components/ui/use-toast' +import { + buildExpensesFromFileImport, + type ImportBuildResult, +} from '@/lib/imports/file-import' +import { ExpenseFormValues } from '@/lib/schemas' +import { trpc } from '@/trpc/client' +import { useTranslations } from 'next-intl' + +// Minimal shape of the result state used by the UI. +type ImportResultState = null | { + status: 'completed' | 'cancelled' + created: number + total: number + resultId: string + groupId?: string + groupName?: string +} + +export type FileImportProcessState = + | 'idle' + | 'analyzing' + | 'preview' + | 'importing' + | 'completed' + | 'cancelled' + | 'error' + +const BATCH_SIZE = 50 +const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10MB + +export function useFileImportProcess(options?: { + onImportSuccess?: (result: { groupId: string; groupName: string }) => void + onClose?: () => void +}) { + const t = useTranslations('FileImport') + const tErrors = useTranslations('FileImportErrors') + const { toast } = useToast() + + const [processState, setProcessState] = + useState('idle') + const [fileContent, setFileContent] = useState('') + const [fileName, setFileName] = useState(null) + const [groupName, setGroupName] = useState('') + const [previewResult, setPreviewResult] = useState( + null, + ) + const [previewError, setPreviewError] = useState(null) + + // Ref to stop the loop + const cancelRequestedRef = useRef(false) + + const [importResult, setImportResult] = useState(null) + const [resultActionLoading, setResultActionLoading] = useState(false) + const [importProgress, setImportProgress] = useState<{ + processed: number + total: number + }>({ + processed: 0, + total: 0, + }) + + const localizeErrorMessage = useCallback( + (message: string) => { + const normalized = message.toLowerCase() + if (normalized.includes('no participants')) + return tErrors('noParticipants') + if (normalized.includes('uploaded file was empty')) + return tErrors('fileEmpty') + if (normalized.includes('invalid amount')) return tErrors('invalidAmount') + if (normalized.includes('invalid expense date')) + return tErrors('invalidDate') + if (normalized.includes('unsupported file format')) + return tErrors('unsupportedFormat') + return message + }, + [tErrors], + ) + + const utils = trpc.useUtils() + + // Mutations + const createGroupMutation = trpc.groups.create.useMutation() + const processBatchMutation = trpc.groups.importProcessBatch.useMutation() + + const analyzeFile = useCallback( + async (file: File) => { + if (file.size > MAX_FILE_SIZE) { + toast({ + title: t('errorTitle'), + description: t('fileTooLarge', { size: '10MB' }), + variant: 'destructive', + }) + return + } + + setFileName(file.name) + setProcessState('analyzing') + setPreviewResult(null) + setPreviewError(null) + + try { + const content = await new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => resolve(String(reader.result ?? '')) + reader.onerror = () => reject(new Error('Failed to read file')) + reader.readAsText(file, 'utf-8') + }) + + if (!content.trim()) { + setPreviewResult(null) + setPreviewError(null) + setProcessState('idle') + return + } + + setFileContent(content) + const result = await buildExpensesFromFileImport(content) + setPreviewResult(result) + setProcessState('preview') + if (result.group?.name) { + setGroupName(result.group.name) + } + } catch (error) { + setPreviewResult(null) + const msg = error instanceof Error ? error.message : 'Analysis failed' + setPreviewError(localizeErrorMessage(msg)) + setProcessState('error') + } + }, + [localizeErrorMessage, t, toast], + ) + + const handleStartImport = useCallback(async () => { + if ( + !fileContent || + !previewResult || + previewResult.errors.length > 0 || + processState !== 'preview' + ) + return + + cancelRequestedRef.current = false + setProcessState('importing') + setImportResult(null) + + const totalExpenses = previewResult.expenses.length + setImportProgress({ processed: 0, total: totalExpenses }) + + try { + // 1. Create Group + let importParticipants = previewResult.group?.participants || [] + if ( + importParticipants.length === 0 && + previewResult.participants.length > 0 + ) { + importParticipants = previewResult.participants.map((name) => ({ + id: name, + name, + })) + } + + const finalGroupName = + groupName.trim() || previewResult.group?.name || 'Imported Group' + + const createdGroup = await createGroupMutation.mutateAsync({ + groupFormValues: { + name: finalGroupName, + currency: previewResult.group?.currency || '$', + currencyCode: previewResult.group?.currencyCode, + participants: importParticipants.map((p) => ({ name: p.name })), + information: `Imported from ${fileName || 'file'}`, + }, + }) + + const groupId = createdGroup.id + const createdParticipants = createdGroup.participants + + // Create a map of ImportID -> RealID + // We map by Name because createGroup creates them in order or we match by name. + // Assuming unique names for simplicity in this context. + const nameToRealId = new Map() + for (const p of createdParticipants) { + nameToRealId.set(p.name, p.id) + } + + const importIdToRealId = new Map() + for (const ip of importParticipants) { + const realId = nameToRealId.get(ip.name) + if (realId && ip.id) { + importIdToRealId.set(ip.id, realId) + } + } + + // Helper to remap expense + const remapExpense = (exp: ExpenseFormValues): ExpenseFormValues => ({ + ...exp, + paidBy: + importIdToRealId.get(exp.paidBy) ?? + nameToRealId.get(exp.paidBy) ?? + exp.paidBy, + paidFor: exp.paidFor.map((pf) => ({ + ...pf, + participant: + importIdToRealId.get(pf.participant) ?? + nameToRealId.get(pf.participant) ?? + pf.participant, + })), + }) + + // 2. Process Batches + const expenses = previewResult.expenses + let processedCount = 0 + + for (let i = 0; i < expenses.length; i += BATCH_SIZE) { + if (cancelRequestedRef.current) break + + const chunk = expenses.slice(i, i + BATCH_SIZE).map(remapExpense) + + await processBatchMutation.mutateAsync({ + groupId, + expenses: chunk, + }) + + processedCount += chunk.length + setImportProgress({ processed: processedCount, total: totalExpenses }) + } + + if (cancelRequestedRef.current) { + // Cancelled + setImportResult({ + status: 'cancelled', + created: processedCount, + total: totalExpenses, + resultId: groupId, // Using groupId as resultId + groupId, + groupName: finalGroupName, + }) + setProcessState('cancelled') + } else { + // Completed + setImportResult({ + status: 'completed', + created: processedCount, + total: totalExpenses, + resultId: groupId, + groupId, + groupName: finalGroupName, + }) + setProcessState('completed') + options?.onImportSuccess?.({ groupId, groupName: finalGroupName }) + } + } catch (error) { + console.error(error) + setProcessState('error') + toast({ + title: t('errorTitle'), + description: error instanceof Error ? error.message : 'Unknown error', + variant: 'destructive', + }) + } + }, [ + fileContent, + fileName, + groupName, + previewResult, + processState, + createGroupMutation, + processBatchMutation, + options, + toast, + t, + ]) + + const requestCancel = useCallback(() => { + cancelRequestedRef.current = true + }, []) + + const resetProcess = useCallback(() => { + setProcessState('idle') + setFileContent('') + setFileName(null) + setGroupName('') + setPreviewResult(null) + setPreviewError(null) + cancelRequestedRef.current = false + setImportResult(null) + setResultActionLoading(false) + setImportProgress({ processed: 0, total: 0 }) + }, []) + + const finalizeImport = useCallback(async () => { + // Just close the modal, as the import is already done client-side. + options?.onClose?.() + }, [options]) + + return { + processState, + fileContent, + fileName, + groupName, + previewResult, + previewError, + importProgress, + importResult, + resultActionLoading, + + setGroupName, + analyzeFile, + startImport: handleStartImport, + requestCancel, + finalizeImport, + resetProcess, + } +} diff --git a/src/components/money.tsx b/src/components/money.tsx index e3cac1d4d..4bd0d2ce4 100644 --- a/src/components/money.tsx +++ b/src/components/money.tsx @@ -23,8 +23,8 @@ export function Money({ colored && amount <= 1 ? 'text-red-600' : colored && amount >= 1 - ? 'text-green-600' - : '', + ? 'text-green-600' + : '', bold && 'font-bold', )} > diff --git a/src/lib/imports/file-import.ts b/src/lib/imports/file-import.ts new file mode 100644 index 000000000..e684ae26e --- /dev/null +++ b/src/lib/imports/file-import.ts @@ -0,0 +1,94 @@ +import { getBalances } from '@/lib/balances' +import { parseWithDetectionInternal } from '@/lib/imports/registry' +import { ExpenseFormValues } from '@/lib/schemas' + +// Return type of the import builder. +export type ImportParticipantSummary = { name: string; balance: number } +export type ImportBuildResult = { + expenses: ExpenseFormValues[] + errors: { row: number; message: string }[] + participantSummaries: ImportParticipantSummary[] + participants: string[] + group?: import('@/lib/imports/types').ImportParsedGroupInfo + format: { id: string; label: string } +} + +// Compute net balance per participant from internal expenses for preview purposes. +// +// This leverages the existing getBalances() algorithm to ensure consistency with +// app-wide balance logic (split modes, rounding behavior). We adapt the +// internal form values into the minimal structure expected by getBalances. +const buildParticipantSummariesFromInternal = ( + expenses: ExpenseFormValues[], + group?: import('@/lib/imports/types').ImportParsedGroupInfo, +) => { + // Adapt form values to the shape consumed by getBalances. + const previewExpenses = expenses.map((e) => ({ + amount: Math.round(e.amount), + splitMode: e.splitMode, + isReimbursement: e.isReimbursement, + paidBy: { id: e.paidBy }, + paidFor: e.paidFor.map((p) => ({ + participant: { id: p.participant }, + shares: Number(p.shares), + })), + })) as any + + const balances = getBalances(previewExpenses) + + // Build a mapping from external participant id -> display name if provided by adapter. + const idToName = new Map() + if (group?.participants) { + for (const p of group.participants) { + const pid = (p.id ?? '').trim() + const pname = p.name?.trim() + if (pid && pname) idToName.set(pid, pname) + } + } + + // Map balances by participant id to {name, balance} entries, prefer nice names. + return Object.entries(balances).map(([participantId, { total }]) => ({ + name: idToName.get(participantId) ?? participantId, + balance: total, + })) +} + +// Convert a parsed/normalized import file into ExpenseFormValues for a group. +// Design notes: +// - Focused on the creation flow. +// - Omits deduplication and category-mapping heuristics. +export async function buildExpensesFromFileImport( + fileContent: string, +): Promise { + const trimmed = fileContent.trim() + if (!trimmed) throw new Error('Uploaded file was empty.') + + const { expenses, errors, group, format } = + await parseWithDetectionInternal(trimmed) + + // Derive definitive participant list for group creation. + // Prefer participants supplied by the adapter; otherwise derive from expenses. + let participantNames: string[] | undefined = group?.participants?.map( + (p) => p.name, + ) + if (!participantNames || participantNames.length === 0) { + const set = new Set() + for (const e of expenses) { + set.add(e.paidBy) + for (const pf of e.paidFor) set.add(pf.participant) + } + participantNames = Array.from(set) + } + + return { + expenses, + errors, + participantSummaries: buildParticipantSummariesFromInternal( + expenses, + group, + ), + participants: participantNames, + group, + format: { id: format.id, label: format.label }, + } +} diff --git a/src/lib/imports/fixtures/debug-errors.txt b/src/lib/imports/fixtures/debug-errors.txt new file mode 100644 index 000000000..6ea9947a0 --- /dev/null +++ b/src/lib/imports/fixtures/debug-errors.txt @@ -0,0 +1,6 @@ +DEBUG_ERRORS + +Invalid amount in row 3 +Missing paidById +Invalid expense date: 2024-99-99 +Participant ID not found: user-xyz diff --git a/src/lib/imports/formats/debug-format.ts b/src/lib/imports/formats/debug-format.ts new file mode 100644 index 000000000..8de46ae33 --- /dev/null +++ b/src/lib/imports/formats/debug-format.ts @@ -0,0 +1,61 @@ +import { importFormats, type ImportFormat } from '@/lib/imports/types' + +// Extremely simple debug format to aid manual testing. +// +// Expected content: +// DEBUG_IMPORT\n +// { ...Spliit-JSON payload... } +// +// Rationale: +// - Keeps detection unambiguous (hard prefix), avoiding conflicts with real formats +// - Reuses Spliit-JSON parsing for convenience when a JSON payload follows the marker +// - Allows devs to quickly craft debug fixtures without changing the main adapters + +export class DebugImportFormat implements ImportFormat { + id = 'debug' + label = 'Debug Import' + priority = 5 + + private stripPrefix(content: string) { + const trimmed = content.trimStart() + const prefixes = ['DEBUG_IMPORT', 'DEBUG_ERRORS'] + for (const prefix of prefixes) { + if (trimmed.startsWith(prefix)) { + const idx = trimmed.indexOf('\n') + return idx >= 0 ? trimmed.slice(idx + 1) : '' + } + } + return content + } + + async detect(content: string): Promise { + const trimmed = content.trimStart() + if ( + trimmed.startsWith('DEBUG_IMPORT') || + trimmed.startsWith('DEBUG_ERRORS') + ) + return 0.99 + return 0 + } + + async parseToInternal(content: string) { + // Debug format: only emit errors from payload lines after marker. + // Usage: + // DEBUG_ERRORS\n + // + // or: + // DEBUG_IMPORT\n + // + const body = this.stripPrefix(content) + const errors = body + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .map((message, index) => ({ row: index + 1, message })) + + return { expenses: [], group: undefined, errors } + } +} + +// Self-register +importFormats.register(new DebugImportFormat()) diff --git a/src/lib/imports/registry.ts b/src/lib/imports/registry.ts new file mode 100644 index 000000000..4b887e060 --- /dev/null +++ b/src/lib/imports/registry.ts @@ -0,0 +1,43 @@ +import { + ImportFormat, + importFormats, + type ImportParsedGroupInfo, +} from '@/lib/imports/types' +// Self-registering formats (side effect imports) +import '@/lib/imports/formats/debug-format' +import '@/lib/imports/spliit-json' + +// Registry entry points +// +// This module exposes small helpers that orchestrate the format registry. +// Responsibilities: +// - Collect all self‑registering formats +// - Run detection on content + context to find the best matching adapter +// - Delegate parsing to the selected adapter into internal ExpenseFormValues + +// Choose the most likely format for given content. +// Returns the selected ImportFormat instance or null if none match. +export async function detectFormat( + content: string, +): Promise { + return importFormats.detect(content) +} + +// Parse a file into internal form values using the detected format. +// +// - Detection: calls detectFormat(...) to select an adapter based on content head and context. +// - Parsing: invokes the adapter's parseToInternal(...) to produce ExpenseFormValues. +// - Meta: adapters may optionally return meta information (e.g., source currency/name). +export async function parseWithDetectionInternal(content: string): Promise<{ + expenses: import('@/lib/schemas').ExpenseFormValues[] + format: ImportFormat + group?: ImportParsedGroupInfo + errors: { row: number; message: string }[] +}> { + const format = await detectFormat(content) + if (!format) throw new Error('Unsupported file format.') + if (!format.parseToInternal) + throw new Error('Format does not support internal parsing.') + const { expenses, group, errors } = await format.parseToInternal(content) + return { expenses, group, format, errors: errors ?? [] } +} diff --git a/src/lib/imports/spliit-json.test.ts b/src/lib/imports/spliit-json.test.ts new file mode 100644 index 000000000..7238a7cec --- /dev/null +++ b/src/lib/imports/spliit-json.test.ts @@ -0,0 +1,218 @@ +import { + CATEGORY_LOOKUP, + SpliitJsonFormat, + resolveCategoryId, +} from './spliit-json' + +describe('SpliitJsonFormat', () => { + const format = new SpliitJsonFormat() + + describe('detect', () => { + it('should return 0 for invalid JSON', async () => { + expect(await format.detect('invalid json')).toBe(0) + }) + + it('should return 0 for JSON without required fields', async () => { + expect(await format.detect('{}')).toBe(0) + expect(await format.detect('{"foo": "bar"}')).toBe(0) + }) + + it('should return a high score for valid Spliit JSON', async () => { + const content = JSON.stringify({ + participants: [{ name: 'Alice' }, { name: 'Bob' }], + expenses: [ + { + paidById: '1', + paidFor: [{ participantId: '2', shares: 1 }], + amount: 100, + expenseDate: '2023-01-01', + title: 'Test', + }, + ], + }) + expect(await format.detect(content)).toBeGreaterThan(0.8) + }) + }) + + describe('parseToInternal', () => { + it('should parse a valid export correctly', async () => { + const content = JSON.stringify({ + participants: [ + { id: 'p1', name: 'Alice' }, + { id: 'p2', name: 'Bob' }, + ], + expenses: [ + { + paidById: 'p1', + paidFor: [ + { participantId: 'p2', shares: 1 }, + { participantId: 'p1', shares: 2 }, + ], + amount: 1234, // 12.34 + expenseDate: '2023-05-20', + title: 'Lunch', + category: 'Food', + }, + ], + name: 'My Group', + currency: '$', + }) + + const result = await format.parseToInternal(content) + + expect(result.errors).toHaveLength(0) + expect(result.group).toEqual({ + name: 'My Group', + currency: '$', + currencyCode: undefined, + participants: [{ name: 'Alice' }, { name: 'Bob' }], + }) + + expect(result.expenses).toHaveLength(1) + const expense = result.expenses[0] + expect(expense.title).toBe('Lunch') + expect(expense.amount).toBe(1234) + expect(expense.paidBy).toBe('Alice') // ID resolved to name + expect(expense.paidFor).toHaveLength(2) + expect(expense.paidFor).toContainEqual( + expect.objectContaining({ participant: 'Bob', shares: 1 }), + ) + expect(expense.paidFor).toContainEqual( + expect.objectContaining({ participant: 'Alice', shares: 2 }), + ) + expect(expense.expenseDate).toBeInstanceOf(Date) + expect(expense.expenseDate.toISOString()).toContain('2023-05-20') + }) + + it('should handle missing participant names by falling back to ID or index', async () => { + const content = JSON.stringify({ + participants: [ + { id: 'p1' }, // No name + {}, // No ID or name + ], + expenses: [], + }) + + const result = await format.parseToInternal(content) + const names = result.group?.participants?.map((p) => p.name) + expect(names).toEqual(['p1', 'Participant 2']) + }) + + it('should collect errors for invalid expenses', async () => { + const content = JSON.stringify({ + participants: [{ id: 'p1', name: 'Alice' }], + expenses: [ + { + paidById: 'p1', + amount: 'invalid', + title: 'Bad Amount', + expenseDate: '2023-01-01', + }, + ], + }) + + // Note: The current implementation is quite lenient. + // - "invalid" amount might throw during coercion. + + const result = await format.parseToInternal(content) + expect(result.errors).toHaveLength(1) + expect(result.errors?.[0].message).toContain('Invalid amount') + }) + }) + + describe('CATEGORY_LOOKUP and resolveCategoryId', () => { + // Exact mapping from DB migration/seeds + const expectedCategories = [ + { id: 0, grouping: 'uncategorized', name: 'general' }, + { id: 1, grouping: 'uncategorized', name: 'payment' }, + { id: 2, grouping: 'entertainment', name: 'entertainment' }, + { id: 3, grouping: 'entertainment', name: 'games' }, + { id: 4, grouping: 'entertainment', name: 'movies' }, + { id: 5, grouping: 'entertainment', name: 'music' }, + { id: 6, grouping: 'entertainment', name: 'sports' }, + { id: 7, grouping: 'food and drink', name: 'food and drink' }, + { id: 8, grouping: 'food and drink', name: 'dining out' }, + { id: 9, grouping: 'food and drink', name: 'groceries' }, + { id: 10, grouping: 'food and drink', name: 'liquor' }, + { id: 11, grouping: 'home', name: 'home' }, + { id: 12, grouping: 'home', name: 'electronics' }, + { id: 13, grouping: 'home', name: 'furniture' }, + { id: 14, grouping: 'home', name: 'household supplies' }, + { id: 15, grouping: 'home', name: 'maintenance' }, + { id: 16, grouping: 'home', name: 'mortgage' }, + { id: 17, grouping: 'home', name: 'pets' }, + { id: 18, grouping: 'home', name: 'rent' }, + { id: 19, grouping: 'home', name: 'services' }, + { id: 20, grouping: 'life', name: 'childcare' }, + { id: 21, grouping: 'life', name: 'clothing' }, + { id: 22, grouping: 'life', name: 'education' }, + { id: 23, grouping: 'life', name: 'gifts' }, + { id: 24, grouping: 'life', name: 'insurance' }, + { id: 25, grouping: 'life', name: 'medical expenses' }, + { id: 26, grouping: 'life', name: 'taxes' }, + { id: 27, grouping: 'transportation', name: 'transportation' }, + { id: 28, grouping: 'transportation', name: 'bicycle' }, + { id: 29, grouping: 'transportation', name: 'bus/train' }, + { id: 30, grouping: 'transportation', name: 'car' }, + { id: 31, grouping: 'transportation', name: 'gas/fuel' }, + { id: 32, grouping: 'transportation', name: 'hotel' }, + { id: 33, grouping: 'transportation', name: 'parking' }, + { id: 34, grouping: 'transportation', name: 'plane' }, + { id: 35, grouping: 'transportation', name: 'taxi' }, + { id: 36, grouping: 'utilities', name: 'utilities' }, + { id: 37, grouping: 'utilities', name: 'cleaning' }, + { id: 38, grouping: 'utilities', name: 'electricity' }, + { id: 39, grouping: 'utilities', name: 'heat/gas' }, + { id: 40, grouping: 'utilities', name: 'trash' }, + { id: 41, grouping: 'utilities', name: 'tv/phone/internet' }, + { id: 42, grouping: 'utilities', name: 'water' }, + { id: 43, grouping: 'life', name: 'donation' }, + ] + + it('should have all expected categories in lookup', () => { + // Ensure we haven't missed any + expect(Object.keys(CATEGORY_LOOKUP).length).toBe( + expectedCategories.length, + ) + + expectedCategories.forEach((cat) => { + const key = `${cat.grouping}|${cat.name}` + expect(CATEGORY_LOOKUP).toHaveProperty(key, cat.id) + }) + }) + + // Validate resolution logic for each category + expectedCategories.forEach((cat) => { + const { id, grouping, name } = cat + + it(`should resolve category [${id}] ${grouping}|${name} correctly`, () => { + // 1. By ID (number) + expect(resolveCategoryId(id)).toBe(id) + + // 2. By ID (string) + expect(resolveCategoryId(String(id))).toBe(id) + + // 3. By full object + expect( + resolveCategoryId({ + grouping: grouping.toUpperCase(), + name: name.toUpperCase(), + }), + ).toBe(id) + + // 4. By name only (fallback) + expect(resolveCategoryId({ name: name.toUpperCase() })).toBe(id) + }) + }) + + it('should return default (0) for unknown categories or invalid input', () => { + expect(resolveCategoryId(null)).toBe(0) + expect(resolveCategoryId(undefined)).toBe(0) + expect(resolveCategoryId('unknown')).toBe(0) + expect( + resolveCategoryId({ grouping: 'NonExistent', name: 'Category' }), + ).toBe(0) + expect(resolveCategoryId({ name: 'NonExistent' })).toBe(0) + }) + }) +}) diff --git a/src/lib/imports/spliit-json.ts b/src/lib/imports/spliit-json.ts new file mode 100644 index 000000000..d6942dfbf --- /dev/null +++ b/src/lib/imports/spliit-json.ts @@ -0,0 +1,294 @@ +import { importFormats, type ImportFormat } from '@/lib/imports/types' +import { expenseFormSchema, type ExpenseFormValues } from '@/lib/schemas' + +// Detection on full content: parse JSON and validate minimal structure. +// Requirements: +// - top-level object with arrays: participants[], expenses[] (arrays can be empty) +// - participant objects should have at least one of: name or id (string) +// - expense objects should include: paidById (string), paidFor (array), amount, expenseDate, title +export const looksLikeSpliitJson = (content: string) => { + try { + const root = JSON.parse(content) + if (!root || typeof root !== 'object') return false + const participants = (root as any).participants + const expenses = (root as any).expenses + if (!Array.isArray(participants) || !Array.isArray(expenses)) return false + + // Check a few participant entries (or pass if empty) + if (participants.length > 0) { + const p: any = participants[0] + const hasName = typeof p?.name === 'string' + const hasId = typeof p?.id === 'string' + if (!(hasName || hasId)) return false + } + + // Check a few expense entries (or pass if empty) + if (expenses.length > 0) { + const e: any = expenses[0] + const ok = + typeof e?.paidById === 'string' && + Array.isArray(e?.paidFor) && + (typeof e?.amount === 'number' || typeof e?.amount === 'string') && + (typeof e?.expenseDate === 'string' || + typeof e?.expenseDate === 'number') && + typeof e?.title === 'string' + if (!ok) return false + } + return true + } catch { + return false + } +} + +// Small helpers for coercion with clear error messages (used within parseToInternal) +const coerceNumber = (value: unknown, label: string): number => { + const n = typeof value === 'string' ? Number(value) : value + if (typeof n !== 'number' || !Number.isFinite(n)) { + throw new Error(`Invalid ${label} value in export`) + } + return n +} +const normalize = (value: unknown) => + String(value ?? '') + .trim() + .toLowerCase() + +// Seeded category ids (see prisma/migrations/20240108194443_add_categories/migration.sql) +export const CATEGORY_LOOKUP: Record = { + 'uncategorized|general': 0, + 'uncategorized|payment': 1, + 'entertainment|entertainment': 2, + 'entertainment|games': 3, + 'entertainment|movies': 4, + 'entertainment|music': 5, + 'entertainment|sports': 6, + 'food and drink|food and drink': 7, + 'food and drink|dining out': 8, + 'food and drink|groceries': 9, + 'food and drink|liquor': 10, + 'home|home': 11, + 'home|electronics': 12, + 'home|furniture': 13, + 'home|household supplies': 14, + 'home|maintenance': 15, + 'home|mortgage': 16, + 'home|pets': 17, + 'home|rent': 18, + 'home|services': 19, + 'life|childcare': 20, + 'life|clothing': 21, + 'life|education': 22, + 'life|gifts': 23, + 'life|insurance': 24, + 'life|medical expenses': 25, + 'life|taxes': 26, + 'transportation|transportation': 27, + 'transportation|bicycle': 28, + 'transportation|bus/train': 29, + 'transportation|car': 30, + 'transportation|gas/fuel': 31, + 'transportation|hotel': 32, + 'transportation|parking': 33, + 'transportation|plane': 34, + 'transportation|taxi': 35, + 'utilities|utilities': 36, + 'utilities|cleaning': 37, + 'utilities|electricity': 38, + 'utilities|heat/gas': 39, + 'utilities|trash': 40, + 'utilities|tv/phone/internet': 41, + 'utilities|water': 42, + 'life|donation': 43, // see follow-up migration 20250308000000_add_category_donation +} + +export const resolveCategoryId = (value: unknown): number => { + // Numeric ids pass through. + if (typeof value === 'number' && Number.isFinite(value)) + return Math.max(0, Math.trunc(value)) + + // Strings that are numeric -> numeric id. + if (typeof value === 'string' && value.trim() !== '') { + const parsed = Number(value) + if (Number.isFinite(parsed)) return Math.max(0, Math.trunc(parsed)) + } + + // Object shape with grouping/name (as exported by Spliit). + if (value && typeof value === 'object') { + const grouping = normalize((value as any).grouping) + const name = normalize((value as any).name) + const key = `${grouping}|${name}` + if (CATEGORY_LOOKUP[key] !== undefined) return CATEGORY_LOOKUP[key] + // Fallback: try by name only + const byName = Object.entries(CATEGORY_LOOKUP).find( + ([k]) => k.split('|')[1] === name, + ) + if (byName) return byName[1] + } + + // Default: uncategorized + return 0 +} +const coerceDate = (value: unknown): Date => { + if (value instanceof Date) return value + const str = String(value ?? '').trim() + if (!str) throw new Error('Missing expense date in export') + const date = new Date(str) + if (Number.isNaN(date.getTime())) + throw new Error('Invalid expense date in export') + return date +} + +export class SpliitJsonFormat implements ImportFormat { + id = 'spliit-json' + label = 'Spliit JSON' + priority = 100 + + async detect(content: string): Promise { + return looksLikeSpliitJson(content) ? 0.95 : 0 + } + + async parseToInternal(content: string): Promise<{ + expenses: ExpenseFormValues[] + group?: import('@/lib/imports/types').ImportParsedGroupInfo + errors?: { row: number; message: string }[] + }> { + let raw: any + try { + raw = JSON.parse(content) + } catch { + throw new Error('Invalid JSON: unable to parse file contents.') + } + + const { externalIdToName, participantNames } = this.extractParticipants( + raw?.participants, + ) + const { expenses, errors } = this.extractExpenses( + raw?.expenses, + externalIdToName, + ) + + const group = { + name: raw?.name ?? undefined, + currency: raw?.currency ?? undefined, + currencyCode: raw?.currencyCode ?? undefined, + participants: participantNames.length + ? participantNames.map((name) => ({ name })) + : undefined, + } + + return { expenses, group, errors } + } + + private extractParticipants(rawParticipants: any[]) { + const externalIdToName = new Map() + const participantNames: string[] = [] + + if (Array.isArray(rawParticipants)) { + rawParticipants.forEach((p: any, i: number) => { + const id = typeof p?.id === 'string' ? p.id.trim() : '' + const nameRaw = typeof p?.name === 'string' ? p.name.trim() : '' + const name = nameRaw || id || `Participant ${i + 1}` + participantNames.push(name) + if (id) externalIdToName.set(id, name) + }) + } + return { externalIdToName, participantNames } + } + + private extractExpenses( + rawExpenses: any[], + externalIdToName: Map, + ) { + const expenses: ExpenseFormValues[] = [] + const errors: { row: number; message: string }[] = [] + + const list = Array.isArray(rawExpenses) ? rawExpenses : [] + + list.forEach((e: any, index: number) => { + try { + const paidByRaw = String(e?.paidById ?? '').trim() + if (!paidByRaw) throw new Error('Missing paidById') + const paidBy = externalIdToName.get(paidByRaw) ?? paidByRaw + + const paidFor = this.extractPaidFor( + Array.isArray(e?.paidFor) ? e.paidFor : [], + externalIdToName, + ) + + const base = this.mapToFormBase(e, index, paidBy, paidFor) + const result = expenseFormSchema.safeParse(base) + if (result.success) { + expenses.push(result.data) + } else { + const message = + result.error.issues.map((i) => i.message).join(', ') || + 'Invalid expense' + errors.push({ row: index + 1, message }) + } + } catch (err: any) { + errors.push({ + row: index + 1, + message: String(err?.message ?? 'Invalid expense'), + }) + } + }) + + return { expenses, errors } + } + + private extractPaidFor( + entries: any[], + externalIdToName: Map, + ): ExpenseFormValues['paidFor'] { + const seen = new Set() + const paidFor: ExpenseFormValues['paidFor'] = [] + entries.forEach((pf: any) => { + const rawId = String(pf?.participantId ?? '').trim() + if (!rawId) throw new Error('paidFor without participantId') + if (seen.has(rawId)) return + seen.add(rawId) + const shares = Math.max( + 1, + Math.trunc(coerceNumber(pf?.shares ?? 1, 'shares')), + ) + const name = externalIdToName.get(rawId) ?? rawId + paidFor.push({ participant: name, shares, originalAmount: undefined }) + }) + return paidFor + } + + private mapToFormBase( + e: any, + index: number, + paidBy: string, + paidFor: ExpenseFormValues['paidFor'], + ) { + return { + expenseDate: coerceDate(e?.expenseDate), + title: String(e?.title ?? '').trim() || `Expense ${index + 1}`, + category: resolveCategoryId(e?.categoryId ?? e?.category), + amount: Math.round(coerceNumber(e?.amount, 'amount')), + originalAmount: + e?.originalAmount != null + ? coerceNumber(e.originalAmount, 'originalAmount') + : undefined, + originalCurrency: e?.originalCurrency ?? undefined, + conversionRate: + e?.conversionRate != null + ? coerceNumber(e.conversionRate, 'conversionRate') + : undefined, + paidBy, + paidFor, + splitMode: (e?.splitMode as ExpenseFormValues['splitMode']) ?? 'EVENLY', + saveDefaultSplittingOptions: false, + isReimbursement: e?.isReimbursement ?? false, + documents: [], + notes: e?.notes ?? undefined, + recurrenceRule: + (e?.recurrenceRule as ExpenseFormValues['recurrenceRule']) ?? 'NONE', + } + } +} + +// Self-register the format with the global registry. +importFormats.register(new SpliitJsonFormat()) diff --git a/src/lib/imports/types.ts b/src/lib/imports/types.ts new file mode 100644 index 000000000..decbec1ea --- /dev/null +++ b/src/lib/imports/types.ts @@ -0,0 +1,67 @@ +import type { ExpenseFormValues } from '@/lib/schemas' + +// Interface a format adapter must implement. +// +// Each adapter provides: +// - id/label/priority: identity + tie‑breaks +// - detect: structural detection on full content (0..1) +// - parseToInternal: full parse → ExpenseFormValues +export type ImportParsedGroupInfo = { + // Optional group name from the file (if present) + name?: string + // Display currency symbol (e.g., "€") and ISO code (e.g., "EUR") if available + currency?: string + currencyCode?: string + // Optional participant list provided by the file (names preferred for display) + participants?: { id?: string; name: string }[] +} + +export interface ImportFormat { + // Unique, stable identifier (e.g. "spliit-json"). + id: string + // Human-readable label (e.g. "Spliit JSON"). + label: string + // Priority used to break ties between formats with equal detection scores. + priority: number + // Detection on the full content. Return 0 to opt out. + // Implementations may do a fast check or a full parse depending on format. + detect(content: string): Promise + // Parse full content into internal values. + parseToInternal?: (content: string) => Promise<{ + expenses: ExpenseFormValues[] + group?: ImportParsedGroupInfo + errors?: { row: number; message: string }[] + }> +} + +// Simple in-memory registry of available formats. +export class ImportFormatRegistry { + private formats: ImportFormat[] = [] + register(format: ImportFormat) { + this.formats.push(format) + } + list(): ImportFormat[] { + return [...this.formats].sort((a, b) => b.priority - a.priority) + } + // Selection strategy for detect(): + // - Provide full content to each format. + // - Each format returns a score in [0..1]; 0 means "not a candidate". + // - Rank by score (desc), then by format.priority (desc) for deterministic tie‑breaks. + // - Return the top candidate or null if no format opts in. + async detect(content: string) { + const scored = await Promise.all( + this.list().map(async (f) => ({ + format: f, + score: await f.detect(content), + })), + ) + const candidates = scored + .filter((x) => x.score > 0) + .sort( + (a, b) => b.score - a.score || b.format.priority - a.format.priority, + ) + return candidates[0]?.format ?? null + } +} + +export const importFormats = new ImportFormatRegistry() diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts index 2fa8629be..d66a5e864 100644 --- a/src/lib/schemas.ts +++ b/src/lib/schemas.ts @@ -119,9 +119,10 @@ export const expenseFormSchema = z } }), splitMode: z - .enum( - Object.values(SplitMode) as any, - ) + .enum< + SplitMode, + [SplitMode, ...SplitMode[]] + >(Object.values(SplitMode) as any) .default('EVENLY'), saveDefaultSplittingOptions: z.boolean(), isReimbursement: z.boolean(), @@ -137,9 +138,10 @@ export const expenseFormSchema = z .default([]), notes: z.string().optional(), recurrenceRule: z - .enum( - Object.values(RecurrenceRule) as any, - ) + .enum< + RecurrenceRule, + [RecurrenceRule, ...RecurrenceRule[]] + >(Object.values(RecurrenceRule) as any) .default('NONE'), }) .superRefine((expense, ctx) => { diff --git a/src/trpc/routers/groups/create.procedure.ts b/src/trpc/routers/groups/create.procedure.ts index 9cf31a424..2fc433cba 100644 --- a/src/trpc/routers/groups/create.procedure.ts +++ b/src/trpc/routers/groups/create.procedure.ts @@ -11,5 +11,5 @@ export const createGroupProcedure = baseProcedure ) .mutation(async ({ input: { groupFormValues } }) => { const group = await createGroup(groupFormValues) - return { groupId: group.id } + return group }) diff --git a/src/trpc/routers/groups/import/import-group.procedure.ts b/src/trpc/routers/groups/import/import-group.procedure.ts new file mode 100644 index 000000000..536a7a066 --- /dev/null +++ b/src/trpc/routers/groups/import/import-group.procedure.ts @@ -0,0 +1,76 @@ +import { createExpense, createGroup } from '@/lib/api' +import { buildExpensesFromFileImport } from '@/lib/imports/file-import' +import { baseProcedure } from '@/trpc/init' +import { z } from 'zod' + +// Creates a new group from a file in a single request (no progress tracking). +// Kept for potential future use; the UI currently prefers the chunked job flow. +export const importGroupFromFileProcedure = baseProcedure + .input( + z.object({ + fileContent: z.string().min(1), + groupName: z.string().trim().optional(), + fileName: z.string().trim().optional(), + }), + ) + .mutation(async ({ input: { fileContent, groupName } }) => { + const trimmed = fileContent.trim() + if (!trimmed) { + throw new Error('Uploaded file was empty.') + } + + // Parse the file into internal expenses first. + const result = await buildExpensesFromFileImport(trimmed) + + if (result.errors.length > 0) { + throw new Error( + 'Unable to import file. Please fix the reported issues and try again.', + ) + } + + // Prefer participants supplied by the adapter; otherwise derive from expenses. + let participantNames: string[] | undefined = + result.group?.participants?.map((p) => p.name) + if (!participantNames || participantNames.length === 0) { + const set = new Set() + for (const e of result.expenses) { + set.add(e.paidBy) + for (const pf of e.paidFor) set.add(pf.participant) + } + participantNames = Array.from(set) + } + if (participantNames.length === 0) + throw new Error('No participants found in file.') + + const currency = + (result.group?.currency as string | undefined)?.trim() || '€' + const currencyCode = + (result.group?.currencyCode as string | undefined)?.trim() || '' + const inferredName = (result.group?.name as string | undefined)?.trim() + const group = await createGroup({ + name: groupName?.trim() || inferredName || 'Imported group', + information: undefined, + currency, + currencyCode, + participants: participantNames.map((name) => ({ name })), + }) + + // Simple name-based remapping: adapters set participants to display names. + const createdNameToId = new Map( + group.participants.map((p) => [p.name, p.id] as const), + ) + const remapExpense = (exp: (typeof result.expenses)[number]) => ({ + ...exp, + paidBy: createdNameToId.get(exp.paidBy) ?? exp.paidBy, + paidFor: exp.paidFor.map((pf) => ({ + ...pf, + participant: createdNameToId.get(pf.participant) ?? pf.participant, + })), + }) + + for (const expense of result.expenses.map(remapExpense)) { + await createExpense(expense, group.id) + } + + return { groupId: group.id, groupName: group.name } + }) diff --git a/src/trpc/routers/groups/import/index.ts b/src/trpc/routers/groups/import/index.ts new file mode 100644 index 000000000..451965c82 --- /dev/null +++ b/src/trpc/routers/groups/import/index.ts @@ -0,0 +1,2 @@ +export { importGroupFromFileProcedure } from './import-group.procedure' +export { processBatchProcedure } from './process-batch.procedure' diff --git a/src/trpc/routers/groups/import/process-batch.procedure.ts b/src/trpc/routers/groups/import/process-batch.procedure.ts new file mode 100644 index 000000000..f6c0e76a8 --- /dev/null +++ b/src/trpc/routers/groups/import/process-batch.procedure.ts @@ -0,0 +1,89 @@ +import { prisma } from '@/lib/prisma' +import { expenseFormSchema } from '@/lib/schemas' +import { baseProcedure } from '@/trpc/init' +import { RecurrenceRule } from '@prisma/client' +import { nanoid } from 'nanoid' +import { z } from 'zod' + +export const processBatchProcedure = baseProcedure + .input( + z.object({ + groupId: z.string(), + expenses: z.array(expenseFormSchema), + }), + ) + .mutation(async ({ input: { groupId, expenses } }) => { + const group = await prisma.group.findUnique({ + where: { id: groupId }, + include: { participants: true }, + }) + if (!group) throw new Error('Group not found') + + const participantIds = new Set(group.participants.map((p) => p.id)) + const createdIds: string[] = [] + + // Validate all participants before starting transaction + for (const expense of expenses) { + if (!participantIds.has(expense.paidBy)) { + throw new Error(`Invalid payer ID: ${expense.paidBy}`) + } + for (const pf of expense.paidFor) { + if (!participantIds.has(pf.participant)) { + throw new Error(`Invalid receiver ID: ${pf.participant}`) + } + } + } + + await prisma.$transaction(async (tx) => { + for (const expense of expenses) { + const expenseId = nanoid() + createdIds.push(expenseId) + + // Note: We skip individual activity logging for performance and noise reduction. + // We also skip complex recurrence creation for imports as they are usually historical. + + await tx.expense.create({ + data: { + id: expenseId, + groupId, + expenseDate: expense.expenseDate, + categoryId: expense.category, + amount: expense.amount, + originalAmount: expense.originalAmount, + originalCurrency: expense.originalCurrency, + conversionRate: expense.conversionRate, + title: expense.title, + paidById: expense.paidBy, + splitMode: expense.splitMode, + recurrenceRule: expense.recurrenceRule || RecurrenceRule.NONE, + isReimbursement: expense.isReimbursement, + notes: expense.notes, + paidFor: { + createMany: { + data: expense.paidFor.map((pf) => ({ + participantId: pf.participant, + shares: pf.shares, + })), + }, + }, + documents: { + createMany: { + data: expense.documents.map((doc) => ({ + id: nanoid(), + url: doc.url, + width: doc.width, + height: doc.height, + })), + }, + }, + }, + }) + } + + // Optional: Add a single activity log for the batch? + // Or just rely on the UI to show success. + // For now, no activity log to keep it simple and fast. + }) + + return createdIds + }) diff --git a/src/trpc/routers/groups/import/shared.ts b/src/trpc/routers/groups/import/shared.ts new file mode 100644 index 000000000..441bf2e1d --- /dev/null +++ b/src/trpc/routers/groups/import/shared.ts @@ -0,0 +1,8 @@ +export const DEFAULT_IMPORT_CHUNK_SIZE = 50 +export const getImportChunkSize = () => { + const envValue = process.env.IMPORT_CHUNK_SIZE + if (!envValue) return DEFAULT_IMPORT_CHUNK_SIZE + const parsed = Number(envValue) + if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_IMPORT_CHUNK_SIZE + return Math.floor(parsed) +} diff --git a/src/trpc/routers/groups/index.ts b/src/trpc/routers/groups/index.ts index 132228835..e4ad41413 100644 --- a/src/trpc/routers/groups/index.ts +++ b/src/trpc/routers/groups/index.ts @@ -7,6 +7,7 @@ import { getGroupProcedure } from '@/trpc/routers/groups/get.procedure' import { groupStatsRouter } from '@/trpc/routers/groups/stats' import { updateGroupProcedure } from '@/trpc/routers/groups/update.procedure' import { getGroupDetailsProcedure } from './getDetails.procedure' +import { importGroupFromFileProcedure, processBatchProcedure } from './import' import { listGroupsProcedure } from './list.procedure' export const groupsRouter = createTRPCRouter({ @@ -19,5 +20,7 @@ export const groupsRouter = createTRPCRouter({ getDetails: getGroupDetailsProcedure, list: listGroupsProcedure, create: createGroupProcedure, + importFromFile: importGroupFromFileProcedure, + importProcessBatch: processBatchProcedure, update: updateGroupProcedure, })