mirror of
https://github.com/spliit-app/spliit.git
synced 2026-02-16 12:36:13 +01:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9302a32f4c | ||
|
|
98e2345bb9 | ||
|
|
5732f78e80 | ||
|
|
72ad0a4c90 | ||
|
|
2c973f976f | ||
|
|
5374d9e9c7 | ||
|
|
5111f3574f | ||
|
|
4db788680e | ||
|
|
39c1a2ffc6 |
@@ -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=""
|
||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
388
messages/zh-TW.json
Normal file
@@ -0,0 +1,388 @@
|
|||||||
|
{
|
||||||
|
"Homepage": {
|
||||||
|
"title": "跟<strong>朋友和家人</strong>一起共享<strong>消費紀錄</strong>",
|
||||||
|
"description": "歡迎開始全新的<strong>Spliit</strong>",
|
||||||
|
"button": {
|
||||||
|
"groups": "前往群組",
|
||||||
|
"github": "GitHub"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Header": {
|
||||||
|
"groups": "群組"
|
||||||
|
},
|
||||||
|
"Footer": {
|
||||||
|
"madeIn": "來自 🇨🇦 加拿大魁北克蒙特婁",
|
||||||
|
"builtBy": "由 <author>Sebastien Castiel</author> 以及 <source>社群貢獻者</source> 共同創建維護"
|
||||||
|
},
|
||||||
|
"Expenses": {
|
||||||
|
"title": "消費",
|
||||||
|
"description": "這裡是您為群組建立的消費。",
|
||||||
|
"create": "新增消費紀錄",
|
||||||
|
"createFirst": "新增第一筆消費紀錄",
|
||||||
|
"noExpenses": "你的群組內目前沒有任何消費紀錄。",
|
||||||
|
"exportJson": "匯出為 JSON",
|
||||||
|
"searchPlaceholder": "搜尋消費紀錄……",
|
||||||
|
"ActiveUserModal": {
|
||||||
|
"title": "你是誰?",
|
||||||
|
"description": "告訴我們您在群組中的身份,以調整我們顯示資訊的方式。",
|
||||||
|
"nobody": "我不想選擇任何人",
|
||||||
|
"save": "儲存更改",
|
||||||
|
"footer": "此設定可稍後在群組設定中更改。"
|
||||||
|
},
|
||||||
|
"Groups": {
|
||||||
|
"upcoming": "即將到來",
|
||||||
|
"thisWeek": "本週",
|
||||||
|
"earlierThisMonth": "本月稍早",
|
||||||
|
"lastMonth": "上個月",
|
||||||
|
"earlierThisYear": "今年稍早",
|
||||||
|
"lastYera": "去年",
|
||||||
|
"older": "更早"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ExpenseCard": {
|
||||||
|
"paidBy": "由 <strong>{paidBy}</strong> 支付 <paidFor></paidFor>。",
|
||||||
|
"receivedBy": "由 <strong>{paidBy}</strong> 收取 <paidFor></paidFor>。",
|
||||||
|
"yourBalance": "你的餘額:"
|
||||||
|
},
|
||||||
|
"Groups": {
|
||||||
|
"myGroups": "我的群組",
|
||||||
|
"create": "建立",
|
||||||
|
"loadingRecent": "讀取最近的群組……",
|
||||||
|
"NoRecent": {
|
||||||
|
"description": "你最近沒有訪問過任何群組。",
|
||||||
|
"create": "建立一個新群組",
|
||||||
|
"orAsk": "或請朋友發送已建立的群組鏈接。"
|
||||||
|
},
|
||||||
|
"recent": "最近的群組",
|
||||||
|
"starred": "已加星標的群組",
|
||||||
|
"archived": "已封存的群組",
|
||||||
|
"archive": "將群組封存",
|
||||||
|
"unarchive": "取消封存群組",
|
||||||
|
"removeRecent": "從最近的群組中移除",
|
||||||
|
"RecentRemovedToast": {
|
||||||
|
"title": "群組已被移除",
|
||||||
|
"description": "該群組已從您的最近群組列表中移除。",
|
||||||
|
"undoAlt": "撤銷移除群組",
|
||||||
|
"undo": "取消操作"
|
||||||
|
},
|
||||||
|
"AddByURL": {
|
||||||
|
"button": "透過連結加入",
|
||||||
|
"title": "透過連結加入群組",
|
||||||
|
"description": "如果某個群組已與您分享,您可以在此處貼上其網址以添加到群組列表中。",
|
||||||
|
"error": "哇哇,我們無法從您提供的網址中找到有效群組……"
|
||||||
|
},
|
||||||
|
"NotFound": {
|
||||||
|
"text": "該群組不存在。",
|
||||||
|
"link": "前往最近訪問的群組"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"GroupForm": {
|
||||||
|
"title": "群組資訊",
|
||||||
|
"NameField": {
|
||||||
|
"label": "群組名稱",
|
||||||
|
"placeholder": "暑假出遊",
|
||||||
|
"description": "輸入群組的名稱。"
|
||||||
|
},
|
||||||
|
"InformationField": {
|
||||||
|
"label": "群組資訊",
|
||||||
|
"placeholder": "對群組成員有關的資訊是什麼?"
|
||||||
|
},
|
||||||
|
"CurrencyField": {
|
||||||
|
"label": "貨幣符號",
|
||||||
|
"placeholder": "$, €, £…",
|
||||||
|
"description": "我們根據它來顯示相應的金額。"
|
||||||
|
},
|
||||||
|
"Participants": {
|
||||||
|
"title": "群組成員",
|
||||||
|
"description": "輸入每位成員的名稱。",
|
||||||
|
"protectedParticipant": "此成員已有登記支出,無法刪除。",
|
||||||
|
"new": "新增",
|
||||||
|
"add": "新增群組成員",
|
||||||
|
"John": "林俊凱",
|
||||||
|
"Jane": "陳怡婷",
|
||||||
|
"Jack": "張文傑"
|
||||||
|
},
|
||||||
|
"Settings": {
|
||||||
|
"title": "客製化設定",
|
||||||
|
"description": "這些設定是針對每台設備設置的,用於客製化您的體驗。",
|
||||||
|
"ActiveUserField": {
|
||||||
|
"label": "當前使用者",
|
||||||
|
"placeholder": "選擇一位群組成員",
|
||||||
|
"none": "無",
|
||||||
|
"description": "用於支付消費的預設用戶"
|
||||||
|
},
|
||||||
|
"save": "保存",
|
||||||
|
"saving": "保存中……",
|
||||||
|
"create": "建立",
|
||||||
|
"creating": "建立中……",
|
||||||
|
"cancel": "取消"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ExpenseForm": {
|
||||||
|
"Income": {
|
||||||
|
"create": "新增收入",
|
||||||
|
"edit": "編輯收入",
|
||||||
|
"TitleField": {
|
||||||
|
"label": "收入標題",
|
||||||
|
"placeholder": "禮拜一晚餐",
|
||||||
|
"description": "輸入此筆收入的描述。"
|
||||||
|
},
|
||||||
|
"DateField": {
|
||||||
|
"label": "收入日期",
|
||||||
|
"description": "輸入收到這筆收入的日期。"
|
||||||
|
},
|
||||||
|
"categoryFieldDescription": "選擇收入類別。",
|
||||||
|
"paidByField": {
|
||||||
|
"label": "接收人",
|
||||||
|
"description": "選擇接收這筆收入的成員。"
|
||||||
|
},
|
||||||
|
"paidFor": {
|
||||||
|
"title": "應接收人",
|
||||||
|
"description": "選擇應參與此筆收入的成員。"
|
||||||
|
},
|
||||||
|
"splitModeDescription": "選擇如何分配此筆收入。",
|
||||||
|
"attachDescription": "查看/附上此筆收入的收據。"
|
||||||
|
},
|
||||||
|
"Expense": {
|
||||||
|
"create": "新增消費紀錄",
|
||||||
|
"edit": "編輯消費紀錄",
|
||||||
|
"TitleField": {
|
||||||
|
"label": "支出標題",
|
||||||
|
"placeholder": "週一晚餐",
|
||||||
|
"description": "輸入此筆消費的描述。"
|
||||||
|
},
|
||||||
|
"DateField": {
|
||||||
|
"label": "消費日期",
|
||||||
|
"description": "輸入支付此消費的日期。"
|
||||||
|
},
|
||||||
|
"categoryFieldDescription": "選擇消費類別。",
|
||||||
|
"paidByField": {
|
||||||
|
"label": "支付人",
|
||||||
|
"description": "选择支付这笔消费的群组成员。"
|
||||||
|
},
|
||||||
|
"paidFor": {
|
||||||
|
"title": "應支付人",
|
||||||
|
"description": "選擇需參與此筆消費的成員。"
|
||||||
|
},
|
||||||
|
"splitModeDescription": "選擇如何分配此筆消費。",
|
||||||
|
"attachDescription": "查看/附上此筆消費的收據。"
|
||||||
|
},
|
||||||
|
"amountField": {
|
||||||
|
"label": "金額"
|
||||||
|
},
|
||||||
|
"isReimbursementField": {
|
||||||
|
"label": "這是一筆報銷款"
|
||||||
|
},
|
||||||
|
"categoryField": {
|
||||||
|
"label": "類別"
|
||||||
|
},
|
||||||
|
"notesField": {
|
||||||
|
"label": "備註"
|
||||||
|
},
|
||||||
|
"selectNone": "取消全選",
|
||||||
|
"selectAll": "全選",
|
||||||
|
"shares": "份額",
|
||||||
|
"advancedOptions": "進階分帳選項……",
|
||||||
|
"SplitModeField": {
|
||||||
|
"label": "分帳方式",
|
||||||
|
"evenly": "平均分配",
|
||||||
|
"byShares": "自訂份額",
|
||||||
|
"byPercentage": "自訂百分比",
|
||||||
|
"byAmount": "自訂金額",
|
||||||
|
"saveAsDefault": "儲存為預設分帳方式"
|
||||||
|
},
|
||||||
|
"DeletePopup": {
|
||||||
|
"label": "刪除",
|
||||||
|
"title": "要刪除這筆消費嗎?",
|
||||||
|
"description": "確定要刪除這筆消費嗎?刪除後無法回復哦。",
|
||||||
|
"yes": "確定",
|
||||||
|
"cancel": "取消"
|
||||||
|
},
|
||||||
|
"attachDocuments": "附件",
|
||||||
|
"create": "新增",
|
||||||
|
"creating": "新增中……",
|
||||||
|
"save": "儲存",
|
||||||
|
"saving": "儲存中……",
|
||||||
|
"cancel": "取消",
|
||||||
|
"reimbursement": "報銷"
|
||||||
|
},
|
||||||
|
"ExpenseDocumentsInput": {
|
||||||
|
"TooBigToast": {
|
||||||
|
"title": "文件過大",
|
||||||
|
"description": "可以上傳的最大文件大小為 {maxSize}。這份文件大小為 ${size}。"
|
||||||
|
},
|
||||||
|
"ErrorToast": {
|
||||||
|
"title": "上傳文件時發生錯誤",
|
||||||
|
"description": "上傳文件時發生錯誤,請再試一次或更換文件。",
|
||||||
|
"retry": "重試"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"CreateFromReceipt": {
|
||||||
|
"Dialog": {
|
||||||
|
"triggerTitle": "從收據中新增消費紀錄",
|
||||||
|
"title": "從收據中新增消費紀錄",
|
||||||
|
"description": "從收據照片上抓取消費明細。",
|
||||||
|
"body": "上傳收據的圖片,我們會試圖解析其中的支出",
|
||||||
|
"selectImage": "選擇圖片……",
|
||||||
|
"titleLabel": "標題:",
|
||||||
|
"categoryLabel": "類別:",
|
||||||
|
"amountLabel": "金額:",
|
||||||
|
"dateLabel": "日期:",
|
||||||
|
"editNext": "可於後續編輯消費明細。",
|
||||||
|
"continue": "繼續"
|
||||||
|
},
|
||||||
|
"unknown": "未知",
|
||||||
|
"TooBigToast": {
|
||||||
|
"title": "文件過大",
|
||||||
|
"description": "可以上傳的最大文件大小為 {maxSize}。這份文件大小為 ${size}。"
|
||||||
|
},
|
||||||
|
"ErrorToast": {
|
||||||
|
"title": "上傳文件時發生錯誤",
|
||||||
|
"description": "上傳文件時發生錯誤,請再試一次或更換文件。",
|
||||||
|
"retry": "重試"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Balances": {
|
||||||
|
"title": "總覽",
|
||||||
|
"description": "這是每個成員已支付及需支付的金額",
|
||||||
|
"Reimbursements": {
|
||||||
|
"title": "建議核銷",
|
||||||
|
"description": "這是建議的銷帳方式",
|
||||||
|
"noImbursements": "看起來你的群組目前不需要銷帳😁",
|
||||||
|
"owes": "<strong>{from}</strong> 欠 <strong>{to}</strong>",
|
||||||
|
"markAsPaid": "標記為已支付"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Stats": {
|
||||||
|
"title": "統計",
|
||||||
|
"Totals": {
|
||||||
|
"title": "總計",
|
||||||
|
"description": "整個群組的花費總計。",
|
||||||
|
"groupSpendings": "群組總開銷",
|
||||||
|
"groupEarnings": "群組總收入",
|
||||||
|
"yourSpendings": "你的總開銷",
|
||||||
|
"yourEarnings": "你的總收入",
|
||||||
|
"yourShare": "你的總計份額"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Activity": {
|
||||||
|
"title": "明細",
|
||||||
|
"description": "群組所有活動總覽",
|
||||||
|
"noActivity": "你的全組目前沒有任何活動",
|
||||||
|
"someone": "某人",
|
||||||
|
"settingsModified": "群組設定已被<strong>{participant}</strong>更改。",
|
||||||
|
"expenseCreated": "消費 <em>{expense}</em> 由 <strong>{participant}</strong> 新增。",
|
||||||
|
"expenseUpdated": "消費 <em>{expense}</em> 由 <strong>{participant}</strong> 更新。",
|
||||||
|
"expenseDeleted": "消費 <em>{expense}</em> 由 <strong>{participant}</strong> 刪除。",
|
||||||
|
"Groups": {
|
||||||
|
"today": "今天",
|
||||||
|
"yesterday": "昨天",
|
||||||
|
"earlierThisWeek": "本週稍早",
|
||||||
|
"lastWeek": "上週",
|
||||||
|
"earlierThisMonth": "本月稍早",
|
||||||
|
"lastMonth": "上個月",
|
||||||
|
"earlierThisYear": "今年稍早",
|
||||||
|
"lastYear": "去年",
|
||||||
|
"older": "更早"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Information": {
|
||||||
|
"title": "資訊",
|
||||||
|
"description": "可在此添加群組相關資訊、公告及說明等。",
|
||||||
|
"empty": "目前沒有群組資訊。"
|
||||||
|
},
|
||||||
|
"Settings": {
|
||||||
|
"title": "設定"
|
||||||
|
},
|
||||||
|
"Share": {
|
||||||
|
"title": "分享",
|
||||||
|
"description": "將此網址分享給其他人以加入群組並查看及新增消費紀錄",
|
||||||
|
"warning": "警告!",
|
||||||
|
"warningHelp": "任何有此連結的人都可以看到及編輯消費紀錄。請小心使用!"
|
||||||
|
},
|
||||||
|
"SchemaErrors": {
|
||||||
|
"min1": "請輸入至少 1 個字。",
|
||||||
|
"min2": "請輸入至少 2 個字。",
|
||||||
|
"max5": "請輸入至少 5 個字。",
|
||||||
|
"max50": "請輸入至少 50 個字。",
|
||||||
|
"duplicateParticipantName": "此名稱已被使用",
|
||||||
|
"titleRequired": "請輸入標題。",
|
||||||
|
"invalidNumber": "數值無效。",
|
||||||
|
"amountRequired": "必須輸入一個金額。",
|
||||||
|
"amountNotZero": "金額不可為 0。",
|
||||||
|
"amountTenMillion": "金額需小於 10,000,000。",
|
||||||
|
"paidByRequired": "必須選擇一個成員。",
|
||||||
|
"paidForMin1": "這筆消費必須包含至少一個成員。",
|
||||||
|
"noZeroShares": "份額需大於 0。",
|
||||||
|
"amountSum": "金額總計必須等於消費金額。",
|
||||||
|
"percentageSum": "百分比加總必須等於 100。"
|
||||||
|
},
|
||||||
|
"Categories": {
|
||||||
|
"search": "搜尋類別……",
|
||||||
|
"noCategory": "未找到類別。",
|
||||||
|
"Uncategorized": {
|
||||||
|
"heading": "未分類",
|
||||||
|
"General": "一般",
|
||||||
|
"Payment": "支付"
|
||||||
|
},
|
||||||
|
"Entertainment": {
|
||||||
|
"heading": "娛樂",
|
||||||
|
"Entertainment": "娛樂",
|
||||||
|
"Games": "遊戲",
|
||||||
|
"Movies": "電影",
|
||||||
|
"Music": "音樂",
|
||||||
|
"Sports": "運動"
|
||||||
|
},
|
||||||
|
"Food and Drink": {
|
||||||
|
"heading": "飲食",
|
||||||
|
"Food and Drink": "飲食",
|
||||||
|
"Dining Out": "外食",
|
||||||
|
"Groceries": "食材",
|
||||||
|
"Liquor": "酒水"
|
||||||
|
},
|
||||||
|
"Home": {
|
||||||
|
"heading": "居家",
|
||||||
|
"Home": "居家",
|
||||||
|
"Electronics": "電子產品",
|
||||||
|
"Furniture": "家具",
|
||||||
|
"Household Supplies": "日用品",
|
||||||
|
"Maintenance": "維護",
|
||||||
|
"Mortgage": "貸款",
|
||||||
|
"Pets": "寵物",
|
||||||
|
"Rent": "租金",
|
||||||
|
"Services": "服務"
|
||||||
|
},
|
||||||
|
"Life": {
|
||||||
|
"heading": "生活",
|
||||||
|
"Childcare": "育兒",
|
||||||
|
"Clothing": "衣服",
|
||||||
|
"Education": "教育",
|
||||||
|
"Gifts": "禮物",
|
||||||
|
"Insurance": "保險",
|
||||||
|
"Medical Expenses": "醫療支出",
|
||||||
|
"Taxes": "稅"
|
||||||
|
},
|
||||||
|
"Transportation": {
|
||||||
|
"heading": "交通",
|
||||||
|
"Transportation": "交通",
|
||||||
|
"Bicycle": "自行車",
|
||||||
|
"Bus/Train": "公車/火車",
|
||||||
|
"Car": "汽車",
|
||||||
|
"Gas/Fuel": "油錢/燃料",
|
||||||
|
"Hotel": "旅館/住宿",
|
||||||
|
"Parking": "停車",
|
||||||
|
"Plane": "飛機",
|
||||||
|
"Taxi": "計程車"
|
||||||
|
},
|
||||||
|
"Utilities": {
|
||||||
|
"heading": "日常帳單",
|
||||||
|
"Utilities": "日常帳單",
|
||||||
|
"Cleaning": "清潔費",
|
||||||
|
"Electricity": "電費",
|
||||||
|
"Heat/Gas": "暖氣/瓦斯",
|
||||||
|
"Trash": "垃圾費",
|
||||||
|
"TV/Phone/Internet": "電視/電話/網路",
|
||||||
|
"Water": "水費"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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%;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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} />
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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} />
|
|
||||||
}
|
}
|
||||||
|
|||||||
30
src/app/groups/[groupId]/current-group-context.tsx
Normal file
30
src/app/groups/[groupId]/current-group-context.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { AppRouterOutput } from '@/trpc/routers/_app'
|
||||||
|
import { PropsWithChildren, createContext, useContext } from 'react'
|
||||||
|
|
||||||
|
type Group = NonNullable<AppRouterOutput['groups']['get']['group']>
|
||||||
|
|
||||||
|
type GroupContext =
|
||||||
|
| { isLoading: false; groupId: string; group: Group }
|
||||||
|
| { isLoading: true; groupId: string; group: undefined }
|
||||||
|
|
||||||
|
const CurrentGroupContext = createContext<GroupContext | null>(null)
|
||||||
|
|
||||||
|
export const useCurrentGroup = () => {
|
||||||
|
const context = useContext(CurrentGroupContext)
|
||||||
|
if (!context)
|
||||||
|
throw new Error(
|
||||||
|
'Missing context. Should be called inside a CurrentGroupProvider.',
|
||||||
|
)
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CurrentGroupProvider = ({
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: PropsWithChildren<GroupContext>) => {
|
||||||
|
return (
|
||||||
|
<CurrentGroupContext.Provider value={props}>
|
||||||
|
{children}
|
||||||
|
</CurrentGroupContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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} />
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
11
src/app/groups/[groupId]/expenses/documents-count.tsx
Normal file
11
src/app/groups/[groupId]/expenses/documents-count.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { Paperclip } from 'lucide-react'
|
||||||
|
|
||||||
|
export function DocumentsCount({ count }: { count: number }) {
|
||||||
|
if (count === 0) return <></>
|
||||||
|
return (
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Paperclip className="w-3.5 h-3.5 mr-1 mt-0.5 text-muted-foreground" />
|
||||||
|
<span>{count}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
49
src/app/groups/[groupId]/layout.client.tsx
Normal file
49
src/app/groups/[groupId]/layout.client.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useToast } from '@/components/ui/use-toast'
|
||||||
|
import { trpc } from '@/trpc/client'
|
||||||
|
import { useTranslations } from 'next-intl'
|
||||||
|
import { PropsWithChildren, useEffect } from 'react'
|
||||||
|
import { CurrentGroupProvider } from './current-group-context'
|
||||||
|
import { GroupHeader } from './group-header'
|
||||||
|
import { SaveGroupLocally } from './save-recent-group'
|
||||||
|
|
||||||
|
export function GroupLayoutClient({
|
||||||
|
groupId,
|
||||||
|
children,
|
||||||
|
}: PropsWithChildren<{ groupId: string }>) {
|
||||||
|
const { data, isLoading } = trpc.groups.get.useQuery({ groupId })
|
||||||
|
const t = useTranslations('Groups.NotFound')
|
||||||
|
const { toast } = useToast()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data && !data.group) {
|
||||||
|
toast({
|
||||||
|
description: t('text'),
|
||||||
|
variant: 'destructive',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [data])
|
||||||
|
|
||||||
|
const props =
|
||||||
|
isLoading || !data?.group
|
||||||
|
? { isLoading: true as const, groupId, group: undefined }
|
||||||
|
: { isLoading: false as const, groupId, group: data.group }
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<CurrentGroupProvider {...props}>
|
||||||
|
<GroupHeader />
|
||||||
|
{children}
|
||||||
|
</CurrentGroupProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CurrentGroupProvider {...props}>
|
||||||
|
<GroupHeader />
|
||||||
|
{children}
|
||||||
|
<SaveGroupLocally />
|
||||||
|
</CurrentGroupProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,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 }} />
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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} />
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
'use server'
|
|
||||||
import { getGroups } from '@/lib/api'
|
|
||||||
|
|
||||||
export async function getGroupsAction(groupIds: string[]) {
|
|
||||||
'use server'
|
|
||||||
return getGroups(groupIds)
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
'use server'
|
|
||||||
|
|
||||||
import { getGroup } from '@/lib/api'
|
|
||||||
|
|
||||||
export async function getGroupInfoAction(groupId: string) {
|
|
||||||
'use server'
|
|
||||||
return getGroup(groupId)
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import { getGroupInfoAction } from '@/app/groups/add-group-by-url-button-actions'
|
|
||||||
import { saveRecentGroup } from '@/app/groups/recent-groups-helpers'
|
import { 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)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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') },
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
32
src/i18n.ts
32
src/i18n.ts
@@ -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'
|
||||||
|
|
||||||
|
|||||||
@@ -286,6 +286,7 @@ export async function getGroupExpenses(
|
|||||||
},
|
},
|
||||||
splitMode: true,
|
splitMode: true,
|
||||||
title: true,
|
title: true,
|
||||||
|
_count: { select: { documents: true } },
|
||||||
},
|
},
|
||||||
where: {
|
where: {
|
||||||
groupId,
|
groupId,
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ export function formatDate(
|
|||||||
) {
|
) {
|
||||||
return date.toLocaleString(locale, {
|
return date.toLocaleString(locale, {
|
||||||
...options,
|
...options,
|
||||||
timeZone: 'UTC',
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 ''
|
||||||
|
|||||||
@@ -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 }
|
|
||||||
})
|
})
|
||||||
|
|||||||
19
src/trpc/routers/groups/getDetails.procedure.ts
Normal file
19
src/trpc/routers/groups/getDetails.procedure.ts
Normal 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 }
|
||||||
|
})
|
||||||
@@ -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,
|
||||||
})
|
})
|
||||||
|
|||||||
14
src/trpc/routers/groups/list.procedure.ts
Normal file
14
src/trpc/routers/groups/list.procedure.ts
Normal 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 }
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user