9 Commits

Author SHA1 Message Date
Sebastien Castiel
9302a32f4c Fix destructive color in dark mode (Fixes #268) 2024-12-07 12:14:08 -05:00
Sebastien Castiel
98e2345bb9 Fix group export when name contains non-ASCII characters 2024-12-07 12:03:32 -05:00
Sébastien Beaury
5732f78e80 Fix UTC timezone used in activity tracker (#265) 2024-12-07 11:55:02 -05:00
Yuvaraj Sai
72ad0a4c90 feat(expense-list): Display the attachment count only when the expense includes attachments (#267)
* feat(expense-list): Display the attachment count only when the expense includes attachments

* handle attachments - singular & plural

* move documents count between amount and date

* Remove label

* Use document count only instead of whole document list

---------

Co-authored-by: Sebastien Castiel <sebastien@castiel.me>
2024-12-07 11:53:14 -05:00
Sebastien Castiel
2c973f976f Put locale labels outside of translations 2024-12-07 11:37:39 -05:00
icarusxxy
5374d9e9c7 [Translation] Add Traditional Chinese (zh-TW) (#260)
* Add zh-TW translation file

* Add zh-TW to other translations

Co-authored-by: Yutung Chung <yutung.chung@d8ai.com>
2024-12-07 11:36:48 -05:00
Sebastian Goscinski
5111f3574f Feature: Default currency symbol (#259)
* Added a env parameter to define a default currency symbol

* Fixed prettier formatting
2024-12-07 11:07:54 -05:00
Sebastien Castiel
4db788680e Use tRPC for recent groups page (#253)
* Use tRPC for recent groups page

* Use tRPC for adding group by URL

* Use tRPC for saving visited group

* Group context
2024-10-20 17:50:52 -04:00
Sebastien Castiel
39c1a2ffc6 Fix languages in Romanian translation 2024-10-20 11:51:04 -04:00
54 changed files with 755 additions and 429 deletions

View File

@@ -1,2 +1,4 @@
POSTGRES_PRISMA_URL=postgresql://postgres:1234@localhost 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=""

View File

@@ -294,19 +294,6 @@
"Settings": { "Settings": {
"title": "Einstellungen" "title": "Einstellungen"
}, },
"Locale": {
"en-US": "English",
"fi": "Suomi",
"fr-FR": "Français",
"es": "Español",
"de-DE": "Deutsch",
"zh-CN": "Chinese (Simplified)",
"pl-PL": "Polski",
"ru-RU": "Русский",
"it-IT": "Italiano",
"ua-UA": "Українська",
"ro": "Română"
},
"Share": { "Share": {
"title": "Teilen", "title": "Teilen",
"description": "Teile die URL, damit andere Mitglieder die Gruppe sehen und Ausgaben hinzufügen können.", "description": "Teile die URL, damit andere Mitglieder die Gruppe sehen und Ausgaben hinzufügen können.",

View File

@@ -294,19 +294,6 @@
"Settings": { "Settings": {
"title": "Settings" "title": "Settings"
}, },
"Locale": {
"en-US": "English",
"fi": "Suomi",
"fr-FR": "Français",
"es": "Español",
"de-DE": "Deutsch",
"zh-CN": "Chinese (Simplified)",
"pl-PL": "Polski",
"ru-RU": "Русский",
"it-IT": "Italiano",
"ua-UA": "Українська",
"ro": "Română"
},
"Share": { "Share": {
"title": "Share", "title": "Share",
"description": "For other participants to see the group and add expenses, share its URL with them.", "description": "For other participants to see the group and add expenses, share its URL with them.",

View File

@@ -294,19 +294,6 @@
"Settings": { "Settings": {
"title": "Ajustes" "title": "Ajustes"
}, },
"Locale": {
"en-US": "English",
"fi": "Suomi",
"fr-FR": "Français",
"es": "Español",
"de-DE": "Deutsch",
"zh-CN": "Chinese (Simplified)",
"pl-PL": "Polski",
"ru-RU": "Русский",
"it-IT": "Italiano",
"ua-UA": "Українська",
"ro": "Română"
},
"Share": { "Share": {
"title": "Compartir", "title": "Compartir",
"description": "Para que otros participantes puedan ver el grupo y añadir gastos, compárteles su URL.", "description": "Para que otros participantes puedan ver el grupo y añadir gastos, compárteles su URL.",

View File

@@ -294,19 +294,6 @@
"Settings": { "Settings": {
"title": "Asetukset" "title": "Asetukset"
}, },
"Locale": {
"en-US": "English",
"fi": "Suomi",
"fr-FR": "Français",
"es": "Español",
"de-DE": "Deutsch",
"zh-CN": "Chinese (Simplified)",
"pl-PL": "Polski",
"ru-RU": "Русский",
"it-IT": "Italiano",
"ua-UA": "Українська",
"ro": "Română"
},
"Share": { "Share": {
"title": "Jaa", "title": "Jaa",
"description": "Jaa ryhmän URL muille jäsenille, jotta he voivat nähdä sen ja lisätä kuluja.", "description": "Jaa ryhmän URL muille jäsenille, jotta he voivat nähdä sen ja lisätä kuluja.",

View File

@@ -294,19 +294,6 @@
"Settings": { "Settings": {
"title": "Paramètres" "title": "Paramètres"
}, },
"Locale": {
"en-US": "English",
"fi": "Suomi",
"fr-FR": "Français",
"es": "Español",
"de-DE": "Deutsch",
"zh-CN": "Chinese (Simplified)",
"pl-PL": "Polski",
"ru-RU": "Русский",
"it-IT": "Italiano",
"ua-UA": "Українська",
"ro": "Română"
},
"Share": { "Share": {
"title": "Partager", "title": "Partager",
"description": "Pour que d'autres participants puissent voir le groupe et ajouter des dépenses, partagez son URL avec eux.", "description": "Pour que d'autres participants puissent voir le groupe et ajouter des dépenses, partagez son URL avec eux.",

View File

@@ -294,19 +294,6 @@
"Settings": { "Settings": {
"title": "Impostazioni" "title": "Impostazioni"
}, },
"Locale": {
"en-US": "English",
"fi": "Suomi",
"fr-FR": "Français",
"es": "Español",
"de-DE": "Deutsch",
"zh-CN": "Chinese (Simplified)",
"pl-PL": "Polski",
"ru-RU": "Русский",
"it-IT": "Italiano",
"ua-UA": "Українська",
"ro": "Română"
},
"Share": { "Share": {
"title": "Condividi", "title": "Condividi",
"description": "Per consentire agli altri partecipanti di vedere il gruppo e aggiungere spese, condividi il suo URL con loro.", "description": "Per consentire agli altri partecipanti di vedere il gruppo e aggiungere spese, condividi il suo URL con loro.",

View File

@@ -293,19 +293,6 @@
"Settings": { "Settings": {
"title": "Ustawienia" "title": "Ustawienia"
}, },
"Locale": {
"en-US": "English",
"fi": "Suomi",
"fr-FR": "Français",
"es": "Español",
"de-DE": "Deutsch",
"zh-CN": "Chinese (Simplified)",
"pl-PL": "Polski",
"ru-RU": "Русский",
"it-IT": "Italiano",
"ua-UA": "Українська",
"ro": "Română"
},
"Share": { "Share": {
"title": "Udostępnij", "title": "Udostępnij",
"description": "Aby inni uczestnicy mogli zobaczyć grupę i dodać wydatki, udostępnij im jej adres URL.", "description": "Aby inni uczestnicy mogli zobaczyć grupę i dodać wydatki, udostępnij im jej adres URL.",

View File

@@ -294,19 +294,6 @@
"Settings": { "Settings": {
"title": "Setări" "title": "Setări"
}, },
"Locale": {
"en-US": "Engleză",
"fi": "Finlandeză",
"fr-FR": "Franceză",
"es": "Spaniolă",
"de-DE": "Germană",
"zh-CN": "Chineză (Simplificată)",
"pl-PL": "Poloneză",
"ru-RU": "Rusă",
"it-IT": "Italiană",
"ua-UA": "Ucraineană",
"ro": "Română"
},
"Share": { "Share": {
"title": "Distribuie", "title": "Distribuie",
"description": "Pentru ca ceilalți participanți să poată vedea grupul și cheltuielile adăugate, distribuie URL-ul acestuia cu ei.", "description": "Pentru ca ceilalți participanți să poată vedea grupul și cheltuielile adăugate, distribuie URL-ul acestuia cu ei.",

View File

@@ -294,19 +294,6 @@
"Settings": { "Settings": {
"title": "Настройки" "title": "Настройки"
}, },
"Locale": {
"en-US": "English",
"fi": "Suomi",
"fr-FR": "Français",
"es": "Español",
"de-DE": "Deutsch",
"zh-CN": "Chinese (Simplified)",
"pl-PL": "Polski",
"ru-RU": "Русский",
"it-IT": "Italiano",
"ua-UA": "Українська",
"ro": "Română"
},
"Share": { "Share": {
"title": "Поделиться", "title": "Поделиться",
"description": "Чтобы другие участники получили доступ к этой группе и смогли добавлять расходы, отправьте им этот URL.", "description": "Чтобы другие участники получили доступ к этой группе и смогли добавлять расходы, отправьте им этот URL.",

View File

@@ -294,19 +294,6 @@
"Settings": { "Settings": {
"title": "Налаштування" "title": "Налаштування"
}, },
"Locale": {
"en-US": "English",
"fi": "Suomi",
"fr-FR": "Français",
"es": "Español",
"de-DE": "Deutsch",
"zh-CN": "Chinese (Simplified)",
"pl-PL": "Polski",
"ru-RU": "Русский",
"it-IT": "Italiano",
"ua-UA": "Українська",
"ro": "Română"
},
"Share": { "Share": {
"title": "Поділитися", "title": "Поділитися",
"description": "Щоб інші учасники могли побачити групу і додати витрати, поділіться з ними її URL", "description": "Щоб інші учасники могли побачити групу і додати витрати, поділіться з ними її URL",

View File

@@ -294,19 +294,6 @@
"Settings": { "Settings": {
"title": "设定" "title": "设定"
}, },
"Locale": {
"en-US": "English",
"fi": "Suomi",
"fr-FR": "Français",
"es": "Español",
"de-DE": "Deutsch",
"zh-CN": "Chinese (Simplified)",
"pl-PL": "Polski",
"ru-RU": "Русский",
"it-IT": "Italiano",
"ua-UA": "Українська",
"ro": "Română"
},
"Share": { "Share": {
"title": "分享", "title": "分享",
"description": "请将此URL分享给其他群组成员以使其可以查看群组并添加消费。", "description": "请将此URL分享给其他群组成员以使其可以查看群组并添加消费。",

388
messages/zh-TW.json Normal file
View 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": "水費"
}
}
}

View File

@@ -41,7 +41,8 @@
--muted-foreground: 240 5% 64.9%; --muted-foreground: 240 5% 64.9%;
--accent: 12 6.5% 15.1%; --accent: 12 6.5% 15.1%;
--accent-foreground: 0 0% 98%; --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%; --destructive-foreground: 0 85.7% 97.3%;
--border: 240 3.7% 15.9%; --border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%; --input: 240 3.7% 15.9%;

View File

@@ -9,6 +9,7 @@ import dayjs, { type Dayjs } from 'dayjs'
import { useTranslations } from 'next-intl' import { useTranslations } from 'next-intl'
import { forwardRef, useEffect } from 'react' import { forwardRef, useEffect } from 'react'
import { useInView } from 'react-intersection-observer' import { useInView } from 'react-intersection-observer'
import { useCurrentGroup } from '../current-group-context'
const PAGE_SIZE = 20 const PAGE_SIZE = 20
@@ -82,11 +83,9 @@ const ActivitiesLoading = forwardRef<HTMLDivElement>((_, ref) => {
}) })
ActivitiesLoading.displayName = 'ActivitiesLoading' ActivitiesLoading.displayName = 'ActivitiesLoading'
export function ActivityList({ groupId }: { groupId: string }) { export function ActivityList() {
const t = useTranslations('Activity') const t = useTranslations('Activity')
const { group, groupId } = useCurrentGroup()
const { data: groupData, isLoading: groupIsLoading } =
trpc.groups.get.useQuery({ groupId })
const { const {
data: activitiesData, data: activitiesData,
@@ -105,7 +104,7 @@ export function ActivityList({ groupId }: { groupId: string }) {
if (inView && hasMore && !isLoading) fetchNextPage() if (inView && hasMore && !isLoading) fetchNextPage()
}, [fetchNextPage, hasMore, inView, isLoading]) }, [fetchNextPage, hasMore, inView, isLoading])
if (isLoading || !activities || !groupData) return <ActivitiesLoading /> if (isLoading || !activities || !group) return <ActivitiesLoading />
const groupedActivitiesByDate = getGroupedActivitiesByDate(activities) const groupedActivitiesByDate = getGroupedActivitiesByDate(activities)
@@ -131,7 +130,7 @@ export function ActivityList({ groupId }: { groupId: string }) {
{groupActivities.map((activity) => { {groupActivities.map((activity) => {
const participant = const participant =
activity.participantId !== null activity.participantId !== null
? groupData.group.participants.find( ? group.participants.find(
(p) => p.id === activity.participantId, (p) => p.id === activity.participantId,
) )
: undefined : undefined

View File

@@ -13,7 +13,7 @@ export const metadata: Metadata = {
title: 'Activity', title: 'Activity',
} }
export function ActivityPageClient({ groupId }: { groupId: string }) { export function ActivityPageClient() {
const t = useTranslations('Activity') const t = useTranslations('Activity')
return ( return (
@@ -24,7 +24,7 @@ export function ActivityPageClient({ groupId }: { groupId: string }) {
<CardDescription>{t('description')}</CardDescription> <CardDescription>{t('description')}</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="flex flex-col space-y-4"> <CardContent className="flex flex-col space-y-4">
<ActivityList groupId={groupId} /> <ActivityList />
</CardContent> </CardContent>
</Card> </Card>
</> </>

View File

@@ -5,10 +5,6 @@ export const metadata: Metadata = {
title: 'Activity', title: 'Activity',
} }
export default async function ActivityPage({ export default async function ActivityPage() {
params: { groupId }, return <ActivityPageClient />
}: {
params: { groupId: string }
}) {
return <ActivityPageClient groupId={groupId} />
} }

View File

@@ -13,15 +13,12 @@ import { Skeleton } from '@/components/ui/skeleton'
import { trpc } from '@/trpc/client' import { trpc } from '@/trpc/client'
import { useTranslations } from 'next-intl' import { useTranslations } from 'next-intl'
import { Fragment, useEffect } from 'react' import { Fragment, useEffect } from 'react'
import { match } from 'ts-pattern'
import { useCurrentGroup } from '../current-group-context'
export default function BalancesAndReimbursements({ export default function BalancesAndReimbursements() {
groupId,
}: {
groupId: string
}) {
const utils = trpc.useUtils() const utils = trpc.useUtils()
const { data: groupData, isLoading: groupIsLoading } = const { groupId, group } = useCurrentGroup()
trpc.groups.get.useQuery({ groupId })
const { data: balancesData, isLoading: balancesAreLoading } = const { data: balancesData, isLoading: balancesAreLoading } =
trpc.groups.balances.list.useQuery({ trpc.groups.balances.list.useQuery({
groupId, groupId,
@@ -34,8 +31,7 @@ export default function BalancesAndReimbursements({
utils.groups.balances.invalidate() utils.groups.balances.invalidate()
}, [utils]) }, [utils])
const isLoading = const isLoading = balancesAreLoading || !balancesData || !group
balancesAreLoading || !balancesData || groupIsLoading || !groupData?.group
return ( return (
<> <>
@@ -46,14 +42,12 @@ export default function BalancesAndReimbursements({
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{isLoading ? ( {isLoading ? (
<BalancesLoading <BalancesLoading participantCount={group?.participants.length} />
participantCount={groupData?.group.participants.length}
/>
) : ( ) : (
<BalancesList <BalancesList
balances={balancesData.balances} balances={balancesData.balances}
participants={groupData.group.participants} participants={group?.participants}
currency={groupData.group.currency} currency={group?.currency}
/> />
)} )}
</CardContent> </CardContent>
@@ -66,14 +60,14 @@ export default function BalancesAndReimbursements({
<CardContent> <CardContent>
{isLoading ? ( {isLoading ? (
<ReimbursementsLoading <ReimbursementsLoading
participantCount={groupData?.group.participants.length} participantCount={group?.participants.length}
/> />
) : ( ) : (
<ReimbursementList <ReimbursementList
reimbursements={balancesData.reimbursements} reimbursements={balancesData.reimbursements}
participants={groupData.group.participants} participants={group?.participants}
currency={groupData.group.currency} currency={group?.currency}
groupId={groupData.group.id} groupId={groupId}
/> />
)} )}
</CardContent> </CardContent>
@@ -109,6 +103,12 @@ const BalancesLoading = ({
}: { }: {
participantCount?: number participantCount?: number
}) => { }) => {
const barWidth = (index: number) =>
match(index % 3)
.with(0, () => 'w-1/3')
.with(1, () => 'w-2/3')
.otherwise(() => 'w-full')
return ( return (
<div className="grid grid-cols-2 py-1 gap-y-2"> <div className="grid grid-cols-2 py-1 gap-y-2">
{Array(participantCount) {Array(participantCount)
@@ -120,17 +120,13 @@ const BalancesLoading = ({
<Skeleton className="h-3 w-16" /> <Skeleton className="h-3 w-16" />
</div> </div>
<div className="self-start"> <div className="self-start">
<Skeleton <Skeleton className={`h-7 ${barWidth(index)} rounded-l-none`} />
className={`h-7 w-${(index % 3) + 1}/3 rounded-l-none`}
/>
</div> </div>
</Fragment> </Fragment>
) : ( ) : (
<Fragment key={index}> <Fragment key={index}>
<div className="flex items-center justify-end"> <div className="flex items-center justify-end">
<Skeleton <Skeleton className={`h-7 ${barWidth(index)} rounded-r-none`} />
className={`h-7 w-${(index % 3) + 1}/3 rounded-r-none`}
/>
</div> </div>
<div className="flex items-center pl-2"> <div className="flex items-center pl-2">
<Skeleton className="h-3 w-16" /> <Skeleton className="h-3 w-16" />

View File

@@ -1,19 +1,10 @@
import { cached } from '@/app/cached-functions'
import BalancesAndReimbursements from '@/app/groups/[groupId]/balances/balances-and-reimbursements' import BalancesAndReimbursements from '@/app/groups/[groupId]/balances/balances-and-reimbursements'
import { Metadata } from 'next' import { Metadata } from 'next'
import { notFound } from 'next/navigation'
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Balances', title: 'Balances',
} }
export default async function GroupPage({ export default async function GroupPage() {
params: { groupId }, return <BalancesAndReimbursements />
}: {
params: { groupId: string }
}) {
const group = await cached.getGroup(groupId)
if (!group) notFound()
return <BalancesAndReimbursements groupId={groupId} />
} }

View 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>
)
}

View File

@@ -2,9 +2,11 @@
import { GroupForm } from '@/components/group-form' import { GroupForm } from '@/components/group-form'
import { trpc } from '@/trpc/client' import { trpc } from '@/trpc/client'
import { useCurrentGroup } from '../current-group-context'
export const EditGroup = ({ groupId }: { groupId: string }) => { export const EditGroup = () => {
const { data, isLoading } = trpc.groups.get.useQuery({ groupId }) const { groupId } = useCurrentGroup()
const { data, isLoading } = trpc.groups.getDetails.useQuery({ groupId })
const { mutateAsync } = trpc.groups.update.useMutation() const { mutateAsync } = trpc.groups.update.useMutation()
const utils = trpc.useUtils() const utils = trpc.useUtils()

View File

@@ -5,10 +5,6 @@ export const metadata: Metadata = {
title: 'Settings', title: 'Settings',
} }
export default async function EditGroupPage({ export default async function EditGroupPage() {
params: { groupId }, return <EditGroup />
}: {
params: { groupId: string }
}) {
return <EditGroup groupId={groupId} />
} }

View File

@@ -34,14 +34,11 @@ import { getImageData, usePresignedUpload } from 'next-s3-upload'
import Image from 'next/image' import Image from 'next/image'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { PropsWithChildren, ReactNode, useState } from 'react' import { PropsWithChildren, ReactNode, useState } from 'react'
import { useCurrentGroup } from '../current-group-context'
const MAX_FILE_SIZE = 5 * 1024 ** 2 const MAX_FILE_SIZE = 5 * 1024 ** 2
export function CreateFromReceiptButton({ groupId }: { groupId: string }) { export function CreateFromReceiptButton() {
return <CreateFromReceiptButton_ groupId={groupId} />
}
function CreateFromReceiptButton_({ groupId }: { groupId: string }) {
const t = useTranslations('CreateFromReceipt') const t = useTranslations('CreateFromReceipt')
const isDesktop = useMediaQuery('(min-width: 640px)') const isDesktop = useMediaQuery('(min-width: 640px)')
@@ -70,15 +67,14 @@ function CreateFromReceiptButton_({ groupId }: { groupId: string }) {
} }
description={<>{t('Dialog.description')}</>} description={<>{t('Dialog.description')}</>}
> >
<ReceiptDialogContent groupId={groupId} /> <ReceiptDialogContent />
</DialogOrDrawer> </DialogOrDrawer>
) )
} }
function ReceiptDialogContent({ groupId }: { groupId: string }) { function ReceiptDialogContent() {
const { data: groupData } = trpc.groups.get.useQuery({ groupId }) const { group } = useCurrentGroup()
const { data: categoriesData } = trpc.categories.list.useQuery() const { data: categoriesData } = trpc.categories.list.useQuery()
const group = groupData?.group
const categories = categoriesData?.categories const categories = categoriesData?.categories
const locale = useLocale() const locale = useLocale()

View 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>
)
}

View File

@@ -1,6 +1,7 @@
'use client' 'use client'
import { ActiveUserBalance } from '@/app/groups/[groupId]/expenses/active-user-balance' import { ActiveUserBalance } from '@/app/groups/[groupId]/expenses/active-user-balance'
import { CategoryIcon } from '@/app/groups/[groupId]/expenses/category-icon' 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 { Button } from '@/components/ui/button'
import { getGroupExpenses } from '@/lib/api' import { getGroupExpenses } from '@/lib/api'
import { cn, formatCurrency, formatDate } from '@/lib/utils' import { cn, formatCurrency, formatDate } from '@/lib/utils'
@@ -75,6 +76,9 @@ export function ExpenseCard({ expense, currency, groupId }: Props) {
> >
{formatCurrency(currency, expense.amount, locale)} {formatCurrency(currency, expense.amount, locale)}
</div> </div>
<div className="text-xs text-muted-foreground">
<DocumentsCount count={expense._count.documents} />
</div>
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
{formatDate(expense.expenseDate, locale, { dateStyle: 'medium' })} {formatDate(expense.expenseDate, locale, { dateStyle: 'medium' })}
</div> </div>

View File

@@ -64,7 +64,7 @@ const enforceCurrencyPattern = (value: string) =>
.replace(/[^-\d.]/g, '') // remove all non-numeric characters .replace(/[^-\d.]/g, '') // remove all non-numeric characters
const getDefaultSplittingOptions = ( const getDefaultSplittingOptions = (
group: AppRouterOutput['groups']['get']['group'], group: NonNullable<AppRouterOutput['groups']['get']['group']>,
) => { ) => {
const defaultValue = { const defaultValue = {
splitMode: 'EVENLY' as const, splitMode: 'EVENLY' as const,
@@ -145,7 +145,7 @@ export function ExpenseForm({
onDelete, onDelete,
runtimeFeatureFlags, runtimeFeatureFlags,
}: { }: {
group: AppRouterOutput['groups']['get']['group'] group: NonNullable<AppRouterOutput['groups']['get']['group']>
categories: AppRouterOutput['categories']['list']['categories'] categories: AppRouterOutput['categories']['list']['categories']
expense?: AppRouterOutput['groups']['expenses']['get']['expense'] expense?: AppRouterOutput['groups']['expenses']['get']['expense']
onSubmit: (value: ExpenseFormValues, participantId?: string) => Promise<void> onSubmit: (value: ExpenseFormValues, participantId?: string) => Promise<void>
@@ -250,7 +250,6 @@ export function ExpenseForm({
>(new Set()) >(new Set())
const sExpense = isIncome ? 'Income' : 'Expense' const sExpense = isIncome ? 'Income' : 'Expense'
const sPaid = isIncome ? 'received' : 'paid'
useEffect(() => { useEffect(() => {
setManuallyEditedParticipants(new Set()) setManuallyEditedParticipants(new Set())

View File

@@ -11,6 +11,7 @@ import Link from 'next/link'
import { forwardRef, useEffect, useMemo, useState } from 'react' import { forwardRef, useEffect, useMemo, useState } from 'react'
import { useInView } from 'react-intersection-observer' import { useInView } from 'react-intersection-observer'
import { useDebounce } from 'use-debounce' import { useDebounce } from 'use-debounce'
import { useCurrentGroup } from '../current-group-context'
const PAGE_SIZE = 20 const PAGE_SIZE = 20
@@ -56,12 +57,12 @@ function getGroupedExpensesByDate(expenses: ExpensesType) {
}, {}) }, {})
} }
export function ExpenseList({ groupId }: { groupId: string }) { export function ExpenseList() {
const { data: groupData } = trpc.groups.get.useQuery({ groupId }) const { groupId, group } = useCurrentGroup()
const [searchText, setSearchText] = useState('') const [searchText, setSearchText] = useState('')
const [debouncedSearchText] = useDebounce(searchText, 300) const [debouncedSearchText] = useDebounce(searchText, 300)
const participants = groupData?.group.participants const participants = group?.participants
useEffect(() => { useEffect(() => {
if (!participants) return if (!participants) return
@@ -103,6 +104,7 @@ const ExpenseListForSearch = ({
searchText: string searchText: string
}) => { }) => {
const utils = trpc.useUtils() const utils = trpc.useUtils()
const { group } = useCurrentGroup()
useEffect(() => { useEffect(() => {
// Until we use tRPC more widely and can invalidate the cache on expense // Until we use tRPC more widely and can invalidate the cache on expense
@@ -124,11 +126,7 @@ const ExpenseListForSearch = ({
const expenses = data?.pages.flatMap((page) => page.expenses) const expenses = data?.pages.flatMap((page) => page.expenses)
const hasMore = data?.pages.at(-1)?.hasMore ?? false const hasMore = data?.pages.at(-1)?.hasMore ?? false
const { data: groupData, isLoading: groupIsLoading } = const isLoading = expensesAreLoading || !expenses || !group
trpc.groups.get.useQuery({ groupId })
const isLoading =
expensesAreLoading || !expenses || groupIsLoading || !groupData
useEffect(() => { useEffect(() => {
if (inView && hasMore && !isLoading) fetchNextPage() if (inView && hasMore && !isLoading) fetchNextPage()
@@ -172,7 +170,7 @@ const ExpenseListForSearch = ({
<ExpenseCard <ExpenseCard
key={expense.id} key={expense.id}
expense={expense} expense={expense}
currency={groupData.group.currency} currency={group.currency}
groupId={groupId} groupId={groupId}
/> />
))} ))}

View File

@@ -31,7 +31,7 @@ export async function GET(
return NextResponse.json({ error: 'Invalid group ID' }, { status: 404 }) return NextResponse.json({ error: 'Invalid group ID' }, { status: 404 })
const date = new Date().toISOString().split('T')[0] const date = new Date().toISOString().split('T')[0]
const filename = `Spliit Export - ${group.name} - ${date}` const filename = `Spliit Export - ${date}`
return NextResponse.json(group, { return NextResponse.json(group, {
headers: { headers: {
'content-type': 'application/json', 'content-type': 'application/json',

View File

@@ -15,6 +15,7 @@ import { Download, Plus } from 'lucide-react'
import { Metadata } from 'next' import { Metadata } from 'next'
import { useTranslations } from 'next-intl' import { useTranslations } from 'next-intl'
import Link from 'next/link' import Link from 'next/link'
import { useCurrentGroup } from '../current-group-context'
export const revalidate = 3600 export const revalidate = 3600
@@ -23,13 +24,12 @@ export const metadata: Metadata = {
} }
export default function GroupExpensesPageClient({ export default function GroupExpensesPageClient({
groupId,
enableReceiptExtract, enableReceiptExtract,
}: { }: {
groupId: string
enableReceiptExtract: boolean enableReceiptExtract: boolean
}) { }) {
const t = useTranslations('Expenses') const t = useTranslations('Expenses')
const { groupId } = useCurrentGroup()
return ( return (
<> <>
@@ -50,9 +50,7 @@ export default function GroupExpensesPageClient({
<Download className="w-4 h-4" /> <Download className="w-4 h-4" />
</Link> </Link>
</Button> </Button>
{enableReceiptExtract && ( {enableReceiptExtract && <CreateFromReceiptButton />}
<CreateFromReceiptButton groupId={groupId} />
)}
<Button asChild size="icon"> <Button asChild size="icon">
<Link <Link
href={`/groups/${groupId}/expenses/create`} href={`/groups/${groupId}/expenses/create`}
@@ -65,7 +63,7 @@ export default function GroupExpensesPageClient({
</div> </div>
<CardContent className="p-0 pt-2 pb-4 sm:pb-6 flex flex-col gap-4 relative"> <CardContent className="p-0 pt-2 pb-4 sm:pb-6 flex flex-col gap-4 relative">
<ExpenseList groupId={groupId} /> <ExpenseList />
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -8,14 +8,9 @@ export const metadata: Metadata = {
title: 'Expenses', title: 'Expenses',
} }
export default async function GroupExpensesPage({ export default async function GroupExpensesPage() {
params: { groupId },
}: {
params: { groupId: string }
}) {
return ( return (
<GroupExpensesPageClient <GroupExpensesPageClient
groupId={groupId}
enableReceiptExtract={env.NEXT_PUBLIC_ENABLE_RECEIPT_EXTRACT} enableReceiptExtract={env.NEXT_PUBLIC_ENABLE_RECEIPT_EXTRACT}
/> />
) )

View File

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

View File

@@ -9,14 +9,14 @@ import {
CardTitle, CardTitle,
} from '@/components/ui/card' } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { trpc } from '@/trpc/client'
import { Pencil } from 'lucide-react' import { Pencil } from 'lucide-react'
import { useTranslations } from 'next-intl' import { useTranslations } from 'next-intl'
import Link from 'next/link' import Link from 'next/link'
import { useCurrentGroup } from '../current-group-context'
export default function GroupInformation({ groupId }: { groupId: string }) { export default function GroupInformation({ groupId }: { groupId: string }) {
const t = useTranslations('Information') const t = useTranslations('Information')
const { data, isLoading } = trpc.groups.get.useQuery({ groupId }) const { isLoading, group } = useCurrentGroup()
return ( return (
<> <>
@@ -35,13 +35,13 @@ export default function GroupInformation({ groupId }: { groupId: string }) {
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="prose prose-sm sm:prose-base max-w-full whitespace-break-spaces"> <CardContent className="prose prose-sm sm:prose-base max-w-full whitespace-break-spaces">
{isLoading || !data ? ( {isLoading ? (
<div className="py-1 flex flex-col gap-2"> <div className="py-1 flex flex-col gap-2">
<Skeleton className="h-3 w-3/4" /> <Skeleton className="h-3 w-3/4" />
<Skeleton className="h-3 w-1/2" /> <Skeleton className="h-3 w-1/2" />
</div> </div>
) : data.group.information ? ( ) : group.information ? (
<p className="text-foreground">{data.group.information}</p> <p className="text-foreground">{group.information}</p>
) : ( ) : (
<p className="text-muted-foreground text-sm">{t('empty')}</p> <p className="text-muted-foreground text-sm">{t('empty')}</p>
)} )}

View 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>
)
}

View File

@@ -1,9 +1,7 @@
import { cached } from '@/app/cached-functions' import { cached } from '@/app/cached-functions'
import { GroupHeader } from '@/app/groups/[groupId]/group-header'
import { SaveGroupLocally } from '@/app/groups/[groupId]/save-recent-group'
import { Metadata } from 'next' import { Metadata } from 'next'
import { notFound } from 'next/navigation'
import { PropsWithChildren } from 'react' import { PropsWithChildren } from 'react'
import { GroupLayoutClient } from './layout.client'
type Props = { type Props = {
params: { params: {
@@ -24,20 +22,9 @@ export async function generateMetadata({
} }
} }
export default async function GroupLayout({ export default function GroupLayout({
children, children,
params: { groupId }, params: { groupId },
}: PropsWithChildren<Props>) { }: PropsWithChildren<Props>) {
const group = await cached.getGroup(groupId) return <GroupLayoutClient groupId={groupId}>{children}</GroupLayoutClient>
if (!group) notFound()
return (
<>
<GroupHeader groupId={groupId} />
{children}
<SaveGroupLocally group={{ id: group.id, name: group.name }} />
</>
)
} }

View File

@@ -1,17 +1,13 @@
'use client' 'use client'
import { import { saveRecentGroup } from '@/app/groups/recent-groups-helpers'
RecentGroup,
saveRecentGroup,
} from '@/app/groups/recent-groups-helpers'
import { useEffect } from 'react' import { useEffect } from 'react'
import { useCurrentGroup } from './current-group-context'
type Props = { export function SaveGroupLocally() {
group: RecentGroup const { group } = useCurrentGroup()
}
export function SaveGroupLocally({ group }: Props) {
useEffect(() => { useEffect(() => {
saveRecentGroup(group) if (group) saveRecentGroup({ id: group.id, name: group.name })
}, [group]) }, [group])
return null return null

View File

@@ -8,7 +8,7 @@ import {
} from '@/components/ui/card' } from '@/components/ui/card'
import { useTranslations } from 'next-intl' import { useTranslations } from 'next-intl'
export function TotalsPageClient({ groupId }: { groupId: string }) { export function TotalsPageClient() {
const t = useTranslations('Stats') const t = useTranslations('Stats')
return ( return (
@@ -19,7 +19,7 @@ export function TotalsPageClient({ groupId }: { groupId: string }) {
<CardDescription>{t('Totals.description')}</CardDescription> <CardDescription>{t('Totals.description')}</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="flex flex-col space-y-4"> <CardContent className="flex flex-col space-y-4">
<Totals groupId={groupId} /> <Totals />
</CardContent> </CardContent>
</Card> </Card>
</> </>

View File

@@ -5,10 +5,6 @@ export const metadata: Metadata = {
title: 'Totals', title: 'Totals',
} }
export default async function TotalsPage({ export default async function TotalsPage() {
params: { groupId }, return <TotalsPageClient />
}: {
params: { groupId: string }
}) {
return <TotalsPageClient groupId={groupId} />
} }

View File

@@ -5,16 +5,17 @@ import { TotalsYourSpendings } from '@/app/groups/[groupId]/stats/totals-your-sp
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { useActiveUser } from '@/lib/hooks' import { useActiveUser } from '@/lib/hooks'
import { trpc } from '@/trpc/client' import { trpc } from '@/trpc/client'
import { useCurrentGroup } from '../current-group-context'
export function Totals({ groupId }: { groupId: string }) { export function Totals() {
const { groupId, group } = useCurrentGroup()
const activeUser = useActiveUser(groupId) const activeUser = useActiveUser(groupId)
const participantId = const participantId =
activeUser && activeUser !== 'None' ? activeUser : undefined activeUser && activeUser !== 'None' ? activeUser : undefined
const { data } = trpc.groups.stats.get.useQuery({ groupId, participantId }) const { data } = trpc.groups.stats.get.useQuery({ groupId, participantId })
const { data: groupData } = trpc.groups.get.useQuery({ groupId })
if (!data || !groupData) if (!data || !group)
return ( return (
<div className="flex flex-col gap-7"> <div className="flex flex-col gap-7">
{[0, 1, 2].map((index) => ( {[0, 1, 2].map((index) => (
@@ -31,7 +32,6 @@ export function Totals({ groupId }: { groupId: string }) {
totalParticipantShare, totalParticipantShare,
totalParticipantSpendings, totalParticipantSpendings,
} = data } = data
const { group } = groupData
return ( return (
<> <>

View File

@@ -1,7 +0,0 @@
'use server'
import { getGroups } from '@/lib/api'
export async function getGroupsAction(groupIds: string[]) {
'use server'
return getGroups(groupIds)
}

View File

@@ -1,8 +0,0 @@
'use server'
import { getGroup } from '@/lib/api'
export async function getGroupInfoAction(groupId: string) {
'use server'
return getGroup(groupId)
}

View File

@@ -1,4 +1,3 @@
import { getGroupInfoAction } from '@/app/groups/add-group-by-url-button-actions'
import { saveRecentGroup } from '@/app/groups/recent-groups-helpers' import { saveRecentGroup } from '@/app/groups/recent-groups-helpers'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
@@ -8,6 +7,7 @@ import {
PopoverTrigger, PopoverTrigger,
} from '@/components/ui/popover' } from '@/components/ui/popover'
import { useMediaQuery } from '@/lib/hooks' import { useMediaQuery } from '@/lib/hooks'
import { trpc } from '@/trpc/client'
import { Loader2, Plus } from 'lucide-react' import { Loader2, Plus } from 'lucide-react'
import { useTranslations } from 'next-intl' import { useTranslations } from 'next-intl'
import { useState } from 'react' import { useState } from 'react'
@@ -23,14 +23,12 @@ export function AddGroupByUrlButton({ reload }: Props) {
const [error, setError] = useState(false) const [error, setError] = useState(false)
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [pending, setPending] = useState(false) const [pending, setPending] = useState(false)
const utils = trpc.useUtils()
return ( return (
<Popover open={open} onOpenChange={setOpen}> <Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button variant="secondary"> <Button variant="secondary">{t('button')}</Button>
{/* <Plus className="w-4 h-4 mr-2" /> */}
{t('button')}
</Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent <PopoverContent
align={isDesktop ? 'end' : 'start'} align={isDesktop ? 'end' : 'start'}
@@ -47,15 +45,17 @@ export function AddGroupByUrlButton({ reload }: Props) {
new RegExp(`${window.location.origin}/groups/([^/]+)`), new RegExp(`${window.location.origin}/groups/([^/]+)`),
) ?? [] ) ?? []
setPending(true) setPending(true)
const group = groupId ? await getGroupInfoAction(groupId) : null const { group } = await utils.groups.get.fetch({
setPending(false) groupId: groupId,
if (!group) { })
setError(true) if (group) {
} else {
saveRecentGroup({ id: group.id, name: group.name }) saveRecentGroup({ id: group.id, name: group.name })
reload() reload()
setUrl('') setUrl('')
setOpen(false) setOpen(false)
} else {
setError(true)
setPending(false)
} }
}} }}
> >

View File

@@ -1,12 +1,7 @@
'use client'
import { RecentGroupsState } from '@/app/groups/recent-group-list'
import { import {
RecentGroup, RecentGroup,
archiveGroup, archiveGroup,
deleteRecentGroup, deleteRecentGroup,
getArchivedGroups,
getStarredGroups,
saveRecentGroup,
starGroup, starGroup,
unarchiveGroup, unarchiveGroup,
unstarGroup, unstarGroup,
@@ -19,46 +14,32 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { ToastAction } from '@/components/ui/toast'
import { useToast } from '@/components/ui/use-toast' import { useToast } from '@/components/ui/use-toast'
import { AppRouterOutput } from '@/trpc/routers/_app'
import { StarFilledIcon } from '@radix-ui/react-icons' import { StarFilledIcon } from '@radix-ui/react-icons'
import { Calendar, MoreHorizontal, Star, Users } from 'lucide-react' import { Calendar, MoreHorizontal, Star, Users } from 'lucide-react'
import { useLocale, useTranslations } from 'next-intl' import { useLocale, useTranslations } from 'next-intl'
import Link from 'next/link' import Link from 'next/link'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { SetStateAction } from 'react'
export function RecentGroupListCard({ export function RecentGroupListCard({
group, group,
state, groupDetail,
setState, isStarred,
isArchived,
refreshGroupsFromStorage,
}: { }: {
group: RecentGroup group: RecentGroup
state: RecentGroupsState groupDetail?: AppRouterOutput['groups']['list']['groups'][number]
setState: (state: SetStateAction<RecentGroupsState>) => void isStarred: boolean
isArchived: boolean
refreshGroupsFromStorage: () => void
}) { }) {
const router = useRouter() const router = useRouter()
const locale = useLocale() const locale = useLocale()
const toast = useToast() const toast = useToast()
const t = useTranslations('Groups') const t = useTranslations('Groups')
const details =
state.status === 'complete'
? state.groupsDetails.find((d) => d.id === group.id)
: null
if (state.status === 'pending') return null
const refreshGroupsFromStorage = () =>
setState({
...state,
starredGroups: getStarredGroups(),
archivedGroups: getArchivedGroups(),
})
const isStarred = state.starredGroups.includes(group.id)
const isArchived = state.archivedGroups.includes(group.id)
return ( return (
<li key={group.id}> <li key={group.id}>
<Button <Button
@@ -116,27 +97,11 @@ export function RecentGroupListCard({
onClick={(event) => { onClick={(event) => {
event.stopPropagation() event.stopPropagation()
deleteRecentGroup(group) deleteRecentGroup(group)
setState({ refreshGroupsFromStorage()
...state,
groups: state.groups.filter((g) => g.id !== group.id),
})
toast.toast({ toast.toast({
title: t('RecentRemovedToast.title'), title: t('RecentRemovedToast.title'),
description: t('RecentRemovedToast.description'), description: t('RecentRemovedToast.description'),
action: (
<ToastAction
altText={t('RecentRemovedToast.undoAlt')}
onClick={() => {
saveRecentGroup(group)
setState({
...state,
groups: state.groups,
})
}}
>
{t('RecentRemovedToast.undo')}
</ToastAction>
),
}) })
}} }}
> >
@@ -161,18 +126,21 @@ export function RecentGroupListCard({
</span> </span>
</div> </div>
<div className="text-muted-foreground font-normal text-xs"> <div className="text-muted-foreground font-normal text-xs">
{details ? ( {groupDetail ? (
<div className="w-full flex items-center justify-between"> <div className="w-full flex items-center justify-between">
<div className="flex items-center"> <div className="flex items-center">
<Users className="w-3 h-3 inline mr-1" /> <Users className="w-3 h-3 inline mr-1" />
<span>{details._count.participants}</span> <span>{groupDetail._count.participants}</span>
</div> </div>
<div className="flex items-center"> <div className="flex items-center">
<Calendar className="w-3 h-3 inline mx-1" /> <Calendar className="w-3 h-3 inline mx-1" />
<span> <span>
{new Date(details.createdAt).toLocaleDateString(locale, { {new Date(groupDetail.createdAt).toLocaleDateString(
dateStyle: 'medium', locale,
})} {
dateStyle: 'medium',
},
)}
</span> </span>
</div> </div>
</div> </div>

View File

@@ -1,5 +1,4 @@
'use client' 'use client'
import { getGroupsAction } from '@/app/groups/actions'
import { AddGroupByUrlButton } from '@/app/groups/add-group-by-url-button' import { AddGroupByUrlButton } from '@/app/groups/add-group-by-url-button'
import { import {
RecentGroups, RecentGroups,
@@ -9,10 +8,12 @@ import {
} from '@/app/groups/recent-groups-helpers' } from '@/app/groups/recent-groups-helpers'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { getGroups } from '@/lib/api' import { getGroups } from '@/lib/api'
import { trpc } from '@/trpc/client'
import { AppRouterOutput } from '@/trpc/routers/_app'
import { Loader2 } from 'lucide-react' import { Loader2 } from 'lucide-react'
import { useTranslations } from 'next-intl' import { useTranslations } from 'next-intl'
import Link from 'next/link' 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' import { RecentGroupListCard } from './recent-group-list-card'
export type RecentGroupsState = export type RecentGroupsState =
@@ -31,16 +32,22 @@ export type RecentGroupsState =
archivedGroups: string[] archivedGroups: string[]
} }
function sortGroups( function sortGroups({
state: RecentGroupsState & { status: 'complete' | 'partial' }, groups,
) { starredGroups,
archivedGroups,
}: {
groups: RecentGroups
starredGroups: string[]
archivedGroups: string[]
}) {
const starredGroupInfo = [] const starredGroupInfo = []
const groupInfo = [] const groupInfo = []
const archivedGroupInfo = [] const archivedGroupInfo = []
for (const group of state.groups) { for (const group of groups) {
if (state.starredGroups.includes(group.id)) { if (starredGroups.includes(group.id)) {
starredGroupInfo.push(group) starredGroupInfo.push(group)
} else if (state.archivedGroups.includes(group.id)) { } else if (archivedGroups.includes(group.id)) {
archivedGroupInfo.push(group) archivedGroupInfo.push(group)
} else { } else {
groupInfo.push(group) groupInfo.push(group)
@@ -54,7 +61,6 @@ function sortGroups(
} }
export function RecentGroupList() { export function RecentGroupList() {
const t = useTranslations('Groups')
const [state, setState] = useState<RecentGroupsState>({ status: 'pending' }) const [state, setState] = useState<RecentGroupsState>({ status: 'pending' })
function loadGroups() { function loadGroups() {
@@ -67,24 +73,43 @@ export function RecentGroupList() {
starredGroups, starredGroups,
archivedGroups, archivedGroups,
}) })
getGroupsAction(groupsInStorage.map((g) => g.id)).then((groupsDetails) => {
setState({
status: 'complete',
groups: groupsInStorage,
groupsDetails,
starredGroups,
archivedGroups,
})
})
} }
useEffect(() => { useEffect(() => {
loadGroups() 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 ( return (
<GroupsPage reload={loadGroups}> <GroupsPage reload={refreshGroupsFromStorage}>
<p> <p>
<Loader2 className="w-4 m-4 mr-2 inline animate-spin" />{' '} <Loader2 className="w-4 m-4 mr-2 inline animate-spin" />{' '}
{t('loadingRecent')} {t('loadingRecent')}
@@ -93,9 +118,9 @@ export function RecentGroupList() {
) )
} }
if (state.groups.length === 0) { if (data.groups.length === 0) {
return ( return (
<GroupsPage reload={loadGroups}> <GroupsPage reload={refreshGroupsFromStorage}>
<div className="text-sm space-y-2"> <div className="text-sm space-y-2">
<p>{t('NoRecent.description')}</p> <p>{t('NoRecent.description')}</p>
<p> <p>
@@ -109,17 +134,23 @@ export function RecentGroupList() {
) )
} }
const { starredGroupInfo, groupInfo, archivedGroupInfo } = sortGroups(state) const { starredGroupInfo, groupInfo, archivedGroupInfo } = sortGroups({
groups,
starredGroups,
archivedGroups,
})
return ( return (
<GroupsPage reload={loadGroups}> <GroupsPage reload={refreshGroupsFromStorage}>
{starredGroupInfo.length > 0 && ( {starredGroupInfo.length > 0 && (
<> <>
<h2 className="mb-2">{t('starred')}</h2> <h2 className="mb-2">{t('starred')}</h2>
<GroupList <GroupList
groups={starredGroupInfo} groups={starredGroupInfo}
state={state} groupDetails={data.groups}
setState={setState} archivedGroups={archivedGroups}
starredGroups={starredGroups}
refreshGroupsFromStorage={refreshGroupsFromStorage}
/> />
</> </>
)} )}
@@ -127,7 +158,13 @@ export function RecentGroupList() {
{groupInfo.length > 0 && ( {groupInfo.length > 0 && (
<> <>
<h2 className="mt-6 mb-2">{t('recent')}</h2> <h2 className="mt-6 mb-2">{t('recent')}</h2>
<GroupList groups={groupInfo} state={state} setState={setState} /> <GroupList
groups={groupInfo}
groupDetails={data.groups}
archivedGroups={archivedGroups}
starredGroups={starredGroups}
refreshGroupsFromStorage={refreshGroupsFromStorage}
/>
</> </>
)} )}
@@ -137,8 +174,10 @@ export function RecentGroupList() {
<div className="opacity-50"> <div className="opacity-50">
<GroupList <GroupList
groups={archivedGroupInfo} groups={archivedGroupInfo}
state={state} groupDetails={data.groups}
setState={setState} archivedGroups={archivedGroups}
starredGroups={starredGroups}
refreshGroupsFromStorage={refreshGroupsFromStorage}
/> />
</div> </div>
</> </>
@@ -149,12 +188,16 @@ export function RecentGroupList() {
function GroupList({ function GroupList({
groups, groups,
state, groupDetails,
setState, starredGroups,
archivedGroups,
refreshGroupsFromStorage,
}: { }: {
groups: RecentGroups groups: RecentGroups
state: RecentGroupsState groupDetails?: AppRouterOutput['groups']['list']['groups']
setState: (state: SetStateAction<RecentGroupsState>) => void starredGroups: string[]
archivedGroups: string[]
refreshGroupsFromStorage: () => void
}) { }) {
return ( return (
<ul className="grid gap-2 sm:grid-cols-2"> <ul className="grid gap-2 sm:grid-cols-2">
@@ -162,8 +205,12 @@ function GroupList({
<RecentGroupListCard <RecentGroupListCard
key={group.id} key={group.id}
group={group} group={group}
state={state} groupDetail={groupDetails?.find(
setState={setState} (groupDetail) => groupDetail.id === group.id,
)}
isStarred={starredGroups.includes(group.id)}
isArchived={archivedGroups.includes(group.id)}
refreshGroupsFromStorage={refreshGroupsFromStorage}
/> />
))} ))}
</ul> </ul>

View File

@@ -67,7 +67,7 @@ export function GroupForm({
: { : {
name: '', name: '',
information: '', information: '',
currency: '', currency: process.env.NEXT_PUBLIC_DEFAULT_CURRENCY_SYMBOL || '',
participants: [ participants: [
{ name: t('Participants.John') }, { name: t('Participants.John') },
{ name: t('Participants.Jane') }, { name: t('Participants.Jane') },

View File

@@ -7,24 +7,26 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import { locales } from '@/i18n' import { Locale, localeLabels } from '@/i18n'
import { setUserLocale } from '@/lib/locale' import { setUserLocale } from '@/lib/locale'
import { useLocale, useTranslations } from 'next-intl' import { useLocale } from 'next-intl'
export function LocaleSwitcher() { export function LocaleSwitcher() {
const t = useTranslations('Locale') const locale = useLocale() as Locale
const locale = useLocale()
return ( return (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="-my-3 text-primary"> <Button variant="ghost" size="sm" className="-my-3 text-primary">
<span>{t(locale)}</span> <span>{localeLabels[locale]}</span>
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent> <DropdownMenuContent>
{locales.map((locale) => ( {Object.entries(localeLabels).map(([locale, label]) => (
<DropdownMenuItem key={locale} onClick={() => setUserLocale(locale)}> <DropdownMenuItem
{t(locale)} key={locale}
onClick={() => setUserLocale(locale as Locale)}
>
{label}
</DropdownMenuItem> </DropdownMenuItem>
))} ))}
</DropdownMenuContent> </DropdownMenuContent>

View File

@@ -1,19 +1,25 @@
import { getRequestConfig } from 'next-intl/server' import { getRequestConfig } from 'next-intl/server'
import { getUserLocale } from './lib/locale' import { getUserLocale } from './lib/locale'
export const locales = [ export const localeLabels = {
'en-US', 'en-US': 'English',
'fi', fi: 'Suomi',
'fr-FR', 'fr-FR': 'Français',
'es', es: 'Español',
'de-DE', 'de-DE': 'Deutsch',
'zh-CN', 'zh-CN': '简体中文',
'ru-RU', 'zh-TW': '正體中文',
'it-IT', 'pl-PL': 'Polski',
'ua-UA', 'ru-RU': 'Русский',
'ro', 'it-IT': 'Italiano',
] as const 'ua-UA': 'Українська',
export type Locale = (typeof locales)[number] 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 type Locales = ReadonlyArray<Locale>
export const defaultLocale: Locale = 'en-US' export const defaultLocale: Locale = 'en-US'

View File

@@ -286,6 +286,7 @@ export async function getGroupExpenses(
}, },
splitMode: true, splitMode: true,
title: true, title: true,
_count: { select: { documents: true } },
}, },
where: { where: {
groupId, groupId,

View File

@@ -21,6 +21,7 @@ const envSchema = z
interpretEnvVarAsBool, interpretEnvVarAsBool,
z.boolean().default(false), z.boolean().default(false),
), ),
NEXT_PUBLIC_DEFAULT_CURRENCY_SYMBOL: z.string().optional(),
S3_UPLOAD_KEY: z.string().optional(), S3_UPLOAD_KEY: z.string().optional(),
S3_UPLOAD_SECRET: z.string().optional(), S3_UPLOAD_SECRET: z.string().optional(),
S3_UPLOAD_BUCKET: z.string().optional(), S3_UPLOAD_BUCKET: z.string().optional(),

View File

@@ -20,7 +20,6 @@ export function formatDate(
) { ) {
return date.toLocaleString(locale, { return date.toLocaleString(locale, {
...options, ...options,
timeZone: 'UTC',
}) })
} }

View File

@@ -21,6 +21,8 @@ function getQueryClient() {
return (clientQueryClientSingleton ??= makeQueryClient()) return (clientQueryClientSingleton ??= makeQueryClient())
} }
export const trpcClient = getQueryClient()
function getUrl() { function getUrl() {
const base = (() => { const base = (() => {
if (typeof window !== 'undefined') return '' if (typeof window !== 'undefined') return ''

View File

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

View File

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

View File

@@ -6,6 +6,8 @@ import { groupExpensesRouter } from '@/trpc/routers/groups/expenses'
import { getGroupProcedure } from '@/trpc/routers/groups/get.procedure' import { getGroupProcedure } from '@/trpc/routers/groups/get.procedure'
import { groupStatsRouter } from '@/trpc/routers/groups/stats' import { groupStatsRouter } from '@/trpc/routers/groups/stats'
import { updateGroupProcedure } from '@/trpc/routers/groups/update.procedure' import { updateGroupProcedure } from '@/trpc/routers/groups/update.procedure'
import { getGroupDetailsProcedure } from './getDetails.procedure'
import { listGroupsProcedure } from './list.procedure'
export const groupsRouter = createTRPCRouter({ export const groupsRouter = createTRPCRouter({
expenses: groupExpensesRouter, expenses: groupExpensesRouter,
@@ -14,6 +16,8 @@ export const groupsRouter = createTRPCRouter({
activities: activitiesRouter, activities: activitiesRouter,
get: getGroupProcedure, get: getGroupProcedure,
getDetails: getGroupDetailsProcedure,
list: listGroupsProcedure,
create: createGroupProcedure, create: createGroupProcedure,
update: updateGroupProcedure, update: updateGroupProcedure,
}) })

View File

@@ -0,0 +1,14 @@
import { getGroups } from '@/lib/api'
import { baseProcedure } from '@/trpc/init'
import { z } from 'zod'
export const listGroupsProcedure = baseProcedure
.input(
z.object({
groupIds: z.array(z.string().min(1)),
}),
)
.query(async ({ input: { groupIds } }) => {
const groups = await getGroups(groupIds)
return { groups }
})