This commit is contained in:
mr-shortman 2025-04-18 11:39:03 +02:00
parent 62f1689c1a
commit 76f1685c71
23 changed files with 1286 additions and 157 deletions

View File

@ -5,6 +5,7 @@
import "./src/env.js"; import "./src/env.js";
import pwa from "next-pwa"; import pwa from "next-pwa";
import { env } from "./src/env.js"; import { env } from "./src/env.js";
import { type NextConfig } from "next";
const withPWA = pwa({ const withPWA = pwa({
dest: "public", dest: "public",
@ -13,6 +14,11 @@ const withPWA = pwa({
skipWaiting: true, skipWaiting: true,
}); });
export default withPWA({ const nextConfig = {
eslint: { ignoreDuringBuilds: true }, eslint: { ignoreDuringBuilds: true },
}); experimental: {
turbo: {},
},
} satisfies NextConfig;
export default withPWA(nextConfig);

View File

@ -20,6 +20,9 @@
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"android": "pnpm build && pnpm export && pnpm dlx cap sync android && pnpm dlx cap open android" "android": "pnpm build && pnpm export && pnpm dlx cap sync android && pnpm dlx cap open android"
}, },
"overrides": {
"react-is": "^19.0.0-rc-69d4b800-20241021"
},
"dependencies": { "dependencies": {
"@auth/drizzle-adapter": "^1.7.2", "@auth/drizzle-adapter": "^1.7.2",
"@clerk/nextjs": "^6.14.3", "@clerk/nextjs": "^6.14.3",
@ -54,6 +57,7 @@
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-hook-form": "^7.55.0", "react-hook-form": "^7.55.0",
"recharts": "^2.15.2",
"server-only": "^0.0.1", "server-only": "^0.0.1",
"sonner": "^2.0.3", "sonner": "^2.0.3",
"superjson": "^2.2.1", "superjson": "^2.2.1",

274
pnpm-lock.yaml generated
View File

@ -107,6 +107,9 @@ importers:
react-hook-form: react-hook-form:
specifier: ^7.55.0 specifier: ^7.55.0
version: 7.55.0(react@19.1.0) version: 7.55.0(react@19.1.0)
recharts:
specifier: ^2.15.2
version: 2.15.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
server-only: server-only:
specifier: ^0.0.1 specifier: ^0.0.1
version: 0.0.1 version: 0.0.1
@ -2049,6 +2052,33 @@ packages:
peerDependencies: peerDependencies:
typescript: '>=5.7.2' typescript: '>=5.7.2'
'@types/d3-array@3.2.1':
resolution: {integrity: sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==}
'@types/d3-color@3.1.3':
resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
'@types/d3-ease@3.0.2':
resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==}
'@types/d3-interpolate@3.0.4':
resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==}
'@types/d3-path@3.1.1':
resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==}
'@types/d3-scale@4.0.9':
resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==}
'@types/d3-shape@3.1.7':
resolution: {integrity: sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==}
'@types/d3-time@3.0.4':
resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==}
'@types/d3-timer@3.0.2':
resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
'@types/eslint-scope@3.7.7': '@types/eslint-scope@3.7.7':
resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==}
@ -2363,6 +2393,50 @@ packages:
csstype@3.1.3: csstype@3.1.3:
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
d3-array@3.2.4:
resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==}
engines: {node: '>=12'}
d3-color@3.1.0:
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
engines: {node: '>=12'}
d3-ease@3.0.1:
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
engines: {node: '>=12'}
d3-format@3.1.0:
resolution: {integrity: sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==}
engines: {node: '>=12'}
d3-interpolate@3.0.1:
resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
engines: {node: '>=12'}
d3-path@3.1.0:
resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==}
engines: {node: '>=12'}
d3-scale@4.0.2:
resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
engines: {node: '>=12'}
d3-shape@3.2.0:
resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==}
engines: {node: '>=12'}
d3-time-format@4.1.0:
resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==}
engines: {node: '>=12'}
d3-time@3.1.0:
resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==}
engines: {node: '>=12'}
d3-timer@3.0.1:
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
engines: {node: '>=12'}
data-view-buffer@1.0.2: data-view-buffer@1.0.2:
resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@ -2384,6 +2458,9 @@ packages:
supports-color: supports-color:
optional: true optional: true
decimal.js-light@2.5.1:
resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
deepmerge@4.3.1: deepmerge@4.3.1:
resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@ -2415,6 +2492,9 @@ packages:
resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==}
engines: {node: '>=8'} engines: {node: '>=8'}
dom-helpers@5.2.1:
resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==}
dot-case@3.0.4: dot-case@3.0.4:
resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==} resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==}
@ -2616,6 +2696,9 @@ packages:
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
eventemitter3@4.0.7:
resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==}
events@3.3.0: events@3.3.0:
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
engines: {node: '>=0.8.x'} engines: {node: '>=0.8.x'}
@ -2623,6 +2706,10 @@ packages:
fast-deep-equal@3.1.3: fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
fast-equals@5.2.2:
resolution: {integrity: sha512-V7/RktU11J3I36Nwq2JnZEM7tNm17eBJz+u25qdxBZeCKiX6BkVSZQjwWIr+IobgnZy+ag73tTZgZi7tr0LrBw==}
engines: {node: '>=6.0.0'}
fast-glob@3.3.3: fast-glob@3.3.3:
resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}
engines: {node: '>=8.6.0'} engines: {node: '>=8.6.0'}
@ -2790,6 +2877,10 @@ packages:
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
internmap@2.0.3:
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
engines: {node: '>=12'}
is-array-buffer@3.0.5: is-array-buffer@3.0.5:
resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@ -3077,6 +3168,10 @@ packages:
lodash@4.17.21: lodash@4.17.21:
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
loose-envify@1.4.0:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true
lower-case@2.0.2: lower-case@2.0.2:
resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==} resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==}
@ -3317,6 +3412,9 @@ packages:
resolution: {integrity: sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==} resolution: {integrity: sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==}
engines: {node: '>=6'} engines: {node: '>=6'}
prop-types@15.8.1:
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
punycode@2.3.1: punycode@2.3.1:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'} engines: {node: '>=6'}
@ -3341,6 +3439,12 @@ packages:
peerDependencies: peerDependencies:
react: ^16.8.0 || ^17 || ^18 || ^19 react: ^16.8.0 || ^17 || ^18 || ^19
react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
react-is@18.3.1:
resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==}
react-remove-scroll-bar@2.3.8: react-remove-scroll-bar@2.3.8:
resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==}
engines: {node: '>=10'} engines: {node: '>=10'}
@ -3361,6 +3465,12 @@ packages:
'@types/react': '@types/react':
optional: true optional: true
react-smooth@4.0.4:
resolution: {integrity: sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-style-singleton@2.2.3: react-style-singleton@2.2.3:
resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==}
engines: {node: '>=10'} engines: {node: '>=10'}
@ -3371,10 +3481,26 @@ packages:
'@types/react': '@types/react':
optional: true optional: true
react-transition-group@4.4.5:
resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==}
peerDependencies:
react: '>=16.6.0'
react-dom: '>=16.6.0'
react@19.1.0: react@19.1.0:
resolution: {integrity: sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==} resolution: {integrity: sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
recharts-scale@0.4.5:
resolution: {integrity: sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==}
recharts@2.15.2:
resolution: {integrity: sha512-xv9lVztv3ingk7V3Jf05wfAZbM9Q2umJzu5t/cfnAK7LUslNrGT7LPBr74G+ok8kSCeFMaePmWMg0rcYOnczTw==}
engines: {node: '>=14'}
peerDependencies:
react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
reflect.getprototypeof@1.0.10: reflect.getprototypeof@1.0.10:
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@ -3690,6 +3816,9 @@ packages:
engines: {node: '>=10'} engines: {node: '>=10'}
hasBin: true hasBin: true
tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
to-regex-range@5.0.1: to-regex-range@5.0.1:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
engines: {node: '>=8.0'} engines: {node: '>=8.0'}
@ -3827,6 +3956,9 @@ packages:
react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
victory-vendor@36.9.2:
resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==}
watchpack@2.4.0: watchpack@2.4.0:
resolution: {integrity: sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg==} resolution: {integrity: sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg==}
engines: {node: '>=10.13.0'} engines: {node: '>=10.13.0'}
@ -5678,6 +5810,30 @@ snapshots:
dependencies: dependencies:
typescript: 5.8.2 typescript: 5.8.2
'@types/d3-array@3.2.1': {}
'@types/d3-color@3.1.3': {}
'@types/d3-ease@3.0.2': {}
'@types/d3-interpolate@3.0.4':
dependencies:
'@types/d3-color': 3.1.3
'@types/d3-path@3.1.1': {}
'@types/d3-scale@4.0.9':
dependencies:
'@types/d3-time': 3.0.4
'@types/d3-shape@3.1.7':
dependencies:
'@types/d3-path': 3.1.1
'@types/d3-time@3.0.4': {}
'@types/d3-timer@3.0.2': {}
'@types/eslint-scope@3.7.7': '@types/eslint-scope@3.7.7':
dependencies: dependencies:
'@types/eslint': 9.6.1 '@types/eslint': 9.6.1
@ -6048,6 +6204,44 @@ snapshots:
csstype@3.1.3: {} csstype@3.1.3: {}
d3-array@3.2.4:
dependencies:
internmap: 2.0.3
d3-color@3.1.0: {}
d3-ease@3.0.1: {}
d3-format@3.1.0: {}
d3-interpolate@3.0.1:
dependencies:
d3-color: 3.1.0
d3-path@3.1.0: {}
d3-scale@4.0.2:
dependencies:
d3-array: 3.2.4
d3-format: 3.1.0
d3-interpolate: 3.0.1
d3-time: 3.1.0
d3-time-format: 4.1.0
d3-shape@3.2.0:
dependencies:
d3-path: 3.1.0
d3-time-format@4.1.0:
dependencies:
d3-time: 3.1.0
d3-time@3.1.0:
dependencies:
d3-array: 3.2.4
d3-timer@3.0.1: {}
data-view-buffer@1.0.2: data-view-buffer@1.0.2:
dependencies: dependencies:
call-bound: 1.0.4 call-bound: 1.0.4
@ -6070,6 +6264,8 @@ snapshots:
dependencies: dependencies:
ms: 2.1.3 ms: 2.1.3
decimal.js-light@2.5.1: {}
deepmerge@4.3.1: {} deepmerge@4.3.1: {}
define-data-property@1.1.4: define-data-property@1.1.4:
@ -6104,6 +6300,11 @@ snapshots:
dependencies: dependencies:
path-type: 4.0.0 path-type: 4.0.0
dom-helpers@5.2.1:
dependencies:
'@babel/runtime': 7.27.0
csstype: 3.1.3
dot-case@3.0.4: dot-case@3.0.4:
dependencies: dependencies:
no-case: 3.0.4 no-case: 3.0.4
@ -6331,10 +6532,14 @@ snapshots:
esutils@2.0.3: {} esutils@2.0.3: {}
eventemitter3@4.0.7: {}
events@3.3.0: {} events@3.3.0: {}
fast-deep-equal@3.1.3: {} fast-deep-equal@3.1.3: {}
fast-equals@5.2.2: {}
fast-glob@3.3.3: fast-glob@3.3.3:
dependencies: dependencies:
'@nodelib/fs.stat': 2.0.5 '@nodelib/fs.stat': 2.0.5
@ -6528,6 +6733,8 @@ snapshots:
hasown: 2.0.2 hasown: 2.0.2
side-channel: 1.1.0 side-channel: 1.1.0
internmap@2.0.3: {}
is-array-buffer@3.0.5: is-array-buffer@3.0.5:
dependencies: dependencies:
call-bind: 1.0.8 call-bind: 1.0.8
@ -6774,6 +6981,10 @@ snapshots:
lodash@4.17.21: {} lodash@4.17.21: {}
loose-envify@1.4.0:
dependencies:
js-tokens: 4.0.0
lower-case@2.0.2: lower-case@2.0.2:
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
@ -7002,6 +7213,12 @@ snapshots:
pretty-bytes@5.6.0: {} pretty-bytes@5.6.0: {}
prop-types@15.8.1:
dependencies:
loose-envify: 1.4.0
object-assign: 4.1.1
react-is: 16.13.1
punycode@2.3.1: {} punycode@2.3.1: {}
querystringify@2.2.0: {} querystringify@2.2.0: {}
@ -7021,6 +7238,10 @@ snapshots:
dependencies: dependencies:
react: 19.1.0 react: 19.1.0
react-is@16.13.1: {}
react-is@18.3.1: {}
react-remove-scroll-bar@2.3.8(@types/react@19.1.0)(react@19.1.0): react-remove-scroll-bar@2.3.8(@types/react@19.1.0)(react@19.1.0):
dependencies: dependencies:
react: 19.1.0 react: 19.1.0
@ -7040,6 +7261,14 @@ snapshots:
optionalDependencies: optionalDependencies:
'@types/react': 19.1.0 '@types/react': 19.1.0
react-smooth@4.0.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
dependencies:
fast-equals: 5.2.2
prop-types: 15.8.1
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
react-transition-group: 4.4.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
react-style-singleton@2.2.3(@types/react@19.1.0)(react@19.1.0): react-style-singleton@2.2.3(@types/react@19.1.0)(react@19.1.0):
dependencies: dependencies:
get-nonce: 1.0.1 get-nonce: 1.0.1
@ -7048,8 +7277,34 @@ snapshots:
optionalDependencies: optionalDependencies:
'@types/react': 19.1.0 '@types/react': 19.1.0
react-transition-group@4.4.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
dependencies:
'@babel/runtime': 7.27.0
dom-helpers: 5.2.1
loose-envify: 1.4.0
prop-types: 15.8.1
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
react@19.1.0: {} react@19.1.0: {}
recharts-scale@0.4.5:
dependencies:
decimal.js-light: 2.5.1
recharts@2.15.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
dependencies:
clsx: 2.1.1
eventemitter3: 4.0.7
lodash: 4.17.21
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
react-is: 18.3.1
react-smooth: 4.0.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
recharts-scale: 0.4.5
tiny-invariant: 1.3.3
victory-vendor: 36.9.2
reflect.getprototypeof@1.0.10: reflect.getprototypeof@1.0.10:
dependencies: dependencies:
call-bind: 1.0.8 call-bind: 1.0.8
@ -7436,6 +7691,8 @@ snapshots:
commander: 2.20.3 commander: 2.20.3
source-map-support: 0.5.21 source-map-support: 0.5.21
tiny-invariant@1.3.3: {}
to-regex-range@5.0.1: to-regex-range@5.0.1:
dependencies: dependencies:
is-number: 7.0.0 is-number: 7.0.0
@ -7573,6 +7830,23 @@ snapshots:
- '@types/react' - '@types/react'
- '@types/react-dom' - '@types/react-dom'
victory-vendor@36.9.2:
dependencies:
'@types/d3-array': 3.2.1
'@types/d3-ease': 3.0.2
'@types/d3-interpolate': 3.0.4
'@types/d3-scale': 4.0.9
'@types/d3-shape': 3.1.7
'@types/d3-time': 3.0.4
'@types/d3-timer': 3.0.2
d3-array: 3.2.4
d3-ease: 3.0.1
d3-interpolate: 3.0.1
d3-scale: 4.0.2
d3-shape: 3.2.0
d3-time: 3.1.0
d3-timer: 3.0.1
watchpack@2.4.0: watchpack@2.4.0:
dependencies: dependencies:
glob-to-regexp: 0.4.1 glob-to-regexp: 0.4.1

View File

@ -1,23 +1,9 @@
import ExpenseForm from "@/app/_components/expense/expense-form"; import ExpensePage from "@/app/_components/expense/create-expense-page";
import Header from "@/components/header"; import { api } from "@/trpc/server";
import { Button } from "@/components/ui/button";
import { api, HydrateClient } from "@/trpc/server";
import { currentUser } from "@clerk/nextjs/server";
import React from "react"; import React from "react";
export default async function Page() { export default async function Page() {
const user = await currentUser();
const sessionUser = await api.user.getSessionUser(); const sessionUser = await api.user.getSessionUser();
return ( return <ExpensePage sessionUser={sessionUser!} />;
<HydrateClient>
<Header text="Add Expense">
<Button type="submit" form="expense-form" size={"sm"}>
Create Expense
</Button>
</Header>
<ExpenseForm hideSubmit sessionUser={sessionUser!} />
</HydrateClient>
);
} }

View File

@ -0,0 +1,20 @@
import { AnalyticsRadarChart } from "@/components/charts/radar-chart";
import Header from "@/components/header";
import Section from "@/components/section";
import { api } from "@/trpc/server";
import React from "react";
async function AnalyticsPage() {
const analyticsData = await api.expense.getAnalytics();
return (
<>
<Header text="Analytics"></Header>
<Section>
<AnalyticsRadarChart chartData={analyticsData!} />
</Section>
</>
);
}
export default AnalyticsPage;

View File

@ -1,14 +1,17 @@
import ExpenseCard from "@/app/_components/expense/expense-card"; import ExpenseCard from "@/app/_components/expense/expense-card";
import Header from "@/components/header"; import Header from "@/components/header";
import { Icons } from "@/components/icons";
import Section from "@/components/section"; import Section from "@/components/section";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { api } from "@/trpc/server"; import { api } from "@/trpc/server";
import { currentUser } from "@clerk/nextjs/server";
import Link from "next/link"; import Link from "next/link";
import React from "react"; import React from "react";
export default async function Page() { export default async function Page() {
const splits = await api.expense.getAll(); const splits = await api.expense.getAll();
const sessionUser = await currentUser();
return ( return (
<> <>
<Header text="Expenses"> <Header text="Expenses">
@ -17,14 +20,42 @@ export default async function Page() {
</Button> </Button>
</Header> </Header>
<Section> <Section>
<div className="p-4 rounded-md flex items-center justify-center text-muted-foreground bg-card/25"> {/* STATS */}
Expense Stats {/* <div className=" rounded-md flex items-center h-20 bg-card/25">
<div className="p-4 space-y-1 text-center w-full">
<h4 className="text-muted-foreground text-sm">Expenses</h4>
<span className="text-xl font-bold">{splits.length}</span>
</div>
<Separator orientation="vertical" />
<div className="p-4 space-y-1 text-center w-full">
<h4 className="text-muted-foreground text-sm">Expenses</h4>
<span className="text-xl font-bold">{splits.length}</span>
</div>
<Separator orientation="vertical" />
<div className="p-4 space-y-1 text-center w-full">
<h4 className="text-muted-foreground text-sm ">Balance</h4>
<span className="text-xl font-bold text-success">
{getAmount(812.47)}
</span>
</div>
</div> */}
{/* FILTER BAR */}
<div className="py-4 flex items-center gap-2">
<Button
size={"icon"}
variant={"outline"}
className="text-muted-foreground bg-card/25"
>
<Icons.filter className="size-6 " />
</Button>
<Input placeholder="Search..." />
</div> </div>
<ul className="space-y-4"> <ul className="space-y-4">
{splits.map((expense) => ( {splits.map((expense) => (
<li key={expense.id}> <li key={expense.id}>
<ExpenseCard expense={expense} /> <ExpenseCard expense={expense} sessionUserId={sessionUser?.id!} />
</li> </li>
))} ))}
</ul> </ul>

View File

@ -9,6 +9,7 @@ import { Card, CardHeader, CardTitle } from "@/components/ui/card";
import { getAmount } from "@/lib/utils"; import { getAmount } from "@/lib/utils";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import Link from "next/link"; import Link from "next/link";
import { Badge } from "@/components/ui/badge";
export default async function Home() { export default async function Home() {
return ( return (
@ -32,7 +33,14 @@ export default async function Home() {
</Header> </Header>
<Section className=""> <Section className="">
<Card className="bg-card/5"> <Card className="bg-card/5 relative">
<Badge
asChild
variant={"outline"}
className="absolute bg-card -top-2 right-2"
>
<Link href={"/analytics"}>Analytics</Link>
</Badge>
<CardHeader className="flex items-center"> <CardHeader className="flex items-center">
<CardTitle className="size-full flex flex-col gap-1"> <CardTitle className="size-full flex flex-col gap-1">
<p> <p>

View File

@ -0,0 +1,57 @@
"use client";
import React from "react";
import ExpenseForm from "./expense-form";
import Header from "@/components/header";
import { Button } from "@/components/ui/button";
import type { User } from "@/server/db/schema";
import { useExpenseStore } from "@/lib/store/expense-store";
import ExpenseSplit from "./expense-split";
import ExpenseParticipants from "./expense-participants";
import { Separator } from "@/components/ui/separator";
import { useSearchParams } from "next/navigation";
import { api } from "@/trpc/react";
function ExpensePage({ sessionUser }: { sessionUser: User }) {
const addParticipants = useExpenseStore((state) => state.addParticipants);
const setPayments = useExpenseStore((state) => state.setPayments);
React.useEffect(() => {
addParticipants([sessionUser]);
setPayments([{ amount: 0, userId: sessionUser.id }]);
}, []);
const groupBadges = useExpenseStore((state) => state.groupBadges);
const addGroupBadges = useExpenseStore((state) => state.addGroupBadges);
const searchParams = useSearchParams();
const paramGroup = searchParams.get("groupId");
const paramUser = searchParams.get("userId");
// React.useEffect(() => {
// console.log("Params: ", paramGroup, paramUser);
// if (paramGroup?.length) {
// if (!groupBadges.find((group) => group.id === paramGroup)) {
// const { data: group } = api.group.get.useQuery({
// id: paramGroup,
// });
// if (group) addGroupBadges([group]);
// if (group?.members?.length)
// addParticipants(group.members.map(({ user }) => user));
// }
// }
// }, [paramGroup, paramUser]);
return (
<>
<Header text="Add Expense">
<Button type="submit" form="expense-form" size={"sm"}>
Create Expense
</Button>
</Header>
<ExpenseForm hideSubmit sessionUser={sessionUser} />
<ExpenseParticipants sessionUserId={sessionUser.id} />
<Separator />
<ExpenseSplit sessionUser={sessionUser} />
</>
);
}
export default ExpensePage;

View File

@ -1,4 +1,3 @@
import Avatar from "@/components/avatar";
import { import {
Card, Card,
CardContent, CardContent,
@ -10,28 +9,81 @@ import {
Drawer, Drawer,
DrawerClose, DrawerClose,
DrawerContent, DrawerContent,
DrawerDescription,
DrawerFooter, DrawerFooter,
DrawerHeader, DrawerHeader,
DrawerTitle, DrawerTitle,
DrawerTrigger, DrawerTrigger,
} from "@/components/ui/drawer"; } from "@/components/ui/drawer";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { getAmount } from "@/lib/utils"; import { cn, getAmount } from "@/lib/utils";
import type { Expense } from "@/server/db/schema"; import type {
Expense,
ExpenseGroupBadge,
ExpenseSplit,
} from "@/server/db/schema";
import React from "react"; import React from "react";
import { UserBadge } from "./expense-participants"; import { UserBadge } from "./expense-participants";
import { Icons } from "@/components/icons"; import { Icons } from "@/components/icons";
import ExpenseDetails from "./expense-details"; import ExpenseDetails from "./expense-details";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { calculateTotalValue } from "@/lib/utils/expense";
function ExpenseCard({ expense }: { expense: Expense }) { export const ExpenseSplitsList = ({
splits,
}: {
splits: Array<ExpenseSplit>;
}) => {
return (
<ul className="space-y-2">
{splits?.map((split, idx) => (
<li key={idx} className="text-sm flex items-center gap-1">
<UserBadge user={split.owedTo!} />
<span className="text-xs">owes</span>
<Icons.arrowRight className="size-4 " />
<UserBadge user={split.owedFrom!} />
<span className="font-semibold ml-auto ">
{getAmount(Number(split.amount))}
</span>
</li>
))}
</ul>
);
};
export const ExpenseGroupBadgesList = ({
groupBadges,
}: {
groupBadges: Array<ExpenseGroupBadge>;
}) => {
return (
<ul className="w-full flex items-center gap-2 flex-wrap">
{groupBadges.map(({ group }) => (
<li key={group?.id!}>
<Badge className="flex items-center gap-1" variant={"outline"}>
<Icons.group className="size-4" />
<span>{group!.name}</span>
</Badge>
</li>
))}
</ul>
);
};
function ExpenseCard({
expense,
sessionUserId,
}: {
expense: Expense;
sessionUserId: string;
}) {
const toatalBalance = calculateTotalValue(sessionUserId, expense.splits!);
const totalValue = toatalBalance.owed - toatalBalance.owes;
return ( return (
<Drawer> <Drawer>
<DrawerTrigger className="w-full"> <DrawerTrigger className="w-full">
<Card className=" border-0 shadow-none gap-2 pb-0"> <Card className="bg-background shadow-none gap-2 ">
<CardHeader className="px-4"> <CardHeader className="px-4">
<CardTitle className="flex items-center w-full"> <CardTitle className="flex items-center w-full">
<p className="w-full max-w-64 truncate text-start "> <p className="w-full max-w-64 truncate text-start ">
@ -43,36 +95,52 @@ function ExpenseCard({ expense }: { expense: Expense }) {
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="px-4"> {expense.groupBadges?.length ? (
{expense?.splits?.map((split, idx) => ( <div className="px-4 flex items-center w-full gap-4">
<div key={idx} className="text-sm flex items-center gap-1"> <span className="text-xs">Groups: </span>
<UserBadge user={split.owedTo!} /> <ExpenseGroupBadgesList groupBadges={expense.groupBadges} />
<Icons.brokenWallet className="size-4 " /> </div>
<UserBadge user={split.owedFrom!} /> ) : null}
<span className="font-semibold ml-auto ">
{getAmount(Number(split.amount))}
</span>
</div>
))}
</CardContent>
<Separator className="my-2" /> <Separator className="my-2" />
<CardFooter className="px-4 pb-4 flex items-center"> <CardContent className="px-4 space-y-4 ">
<span className="text-sm">You Owe/Get</span> <ExpenseSplitsList splits={expense?.splits?.slice(0, 2)!} />
<span className="ml-auto font-semibold"> </CardContent>
{getAmount(Number(expense.amount))}
<div className="flex items-center gap-2">
{expense?.splits!.length > 2 && (
<>
<div className="bg-border w-20 h-px" />
<p className="text-xs text-muted-foreground whitespace-nowrap ">
{expense?.splits!.length - 2} more
</p>
</>
)}
<Separator className="my-2 shrink" />
</div>
<CardFooter className={cn("px-4 flex items-center")}>
<span className="text-sm">
You {totalValue < 0 ? "Owe" : "Get"}
</span>
<span
className={cn(
"ml-auto font-semibold",
totalValue < 0 ? "text-destructive" : "text-success"
)}
>
{getAmount(Number(totalValue))}
</span> </span>
</CardFooter> </CardFooter>
</Card> </Card>
</DrawerTrigger> </DrawerTrigger>
<DrawerContent> <DrawerContent>
<DrawerHeader> <DrawerHeader>
<DrawerTitle>Are you absolutely sure?</DrawerTitle> <div className="flex items-center">
<DrawerDescription>This action cannot be undone.</DrawerDescription> <DrawerTitle>{expense.description}</DrawerTitle>
<span className="ml-auto">{getAmount(Number(expense.amount))}</span>
</div>
</DrawerHeader> </DrawerHeader>
<div className="p-4"> <div className="p-4 space-y-4 ">
<ExpenseDetails expense={expense} /> <ExpenseDetails expense={expense} />
</div> </div>

View File

@ -1,8 +1,14 @@
import type { Expense } from "@/server/db/schema"; import type { Expense } from "@/server/db/schema";
import React from "react"; import React from "react";
import { ExpenseGroupBadgesList, ExpenseSplitsList } from "./expense-card";
function ExpenseDetails({ expense }: { expense: Expense }) { function ExpenseDetails({ expense }: { expense: Expense }) {
return <div>ExpenseDetails for expense: {expense.id}</div>; return (
<>
<ExpenseGroupBadgesList groupBadges={expense.groupBadges!} />
<ExpenseSplitsList splits={expense.splits!} />
</>
);
} }
export default ExpenseDetails; export default ExpenseDetails;

View File

@ -17,12 +17,9 @@ import { expenseSchema } from "@/lib/validations/expense";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { NumberInput } from "@/components/number-input"; import { NumberInput } from "@/components/number-input";
import { EuroIcon } from "lucide-react"; import { EuroIcon } from "lucide-react";
import ExpenseSplit from "./expense-split";
import { toast } from "sonner"; import { toast } from "sonner";
import { api } from "@/trpc/react"; import { api } from "@/trpc/react";
import { useExpenseStore } from "@/lib/store/expense-store"; import { useExpenseStore } from "@/lib/store/expense-store";
import ExpenseParticipants from "./expense-participants";
import { Separator } from "@/components/ui/separator";
import { calculateDepts } from "@/lib/utils/expense"; import { calculateDepts } from "@/lib/utils/expense";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
@ -53,10 +50,11 @@ function ExpenseForm({
const recalculateSplits = useExpenseStore((state) => state.recalculateSplits); const recalculateSplits = useExpenseStore((state) => state.recalculateSplits);
const amount = form.watch("amount"); const amount = form.watch("amount");
const resetExpenseStore = useExpenseStore((state) => state.resetExpenseStore); const resetExpenseStore = useExpenseStore((state) => state.resetExpenseStore);
const groupBadges = useExpenseStore((state) => state.groupBadges);
const router = useRouter(); const router = useRouter();
const createExpense = api.expense.create.useMutation({ const createExpense = api.expense.create.useMutation({
onSuccess(expense) { onSuccess() {
form.reset(); form.reset();
resetExpenseStore(); resetExpenseStore();
addParticipants([sessionUser]); addParticipants([sessionUser]);
@ -73,6 +71,7 @@ function ExpenseForm({
}); });
}, },
}); });
function onSubmit(expense: z.infer<typeof expenseSchema>) { function onSubmit(expense: z.infer<typeof expenseSchema>) {
if (createExpense.isPending) return; if (createExpense.isPending) return;
if (participants.length <= 1) if (participants.length <= 1)
@ -89,14 +88,13 @@ function ExpenseForm({
payments payments
); );
createExpense.mutate({ expense, debs }); createExpense.mutate({
expense,
debs,
groupBadges: groupBadges.map(({ id }) => id),
});
} }
React.useEffect(() => {
addParticipants([sessionUser]);
setPayments([{ amount, userId: sessionUser.id }]);
}, []);
const handleAmountChange = (value: number) => { const handleAmountChange = (value: number) => {
setAmount(value); setAmount(value);
const firstUserId = payments[0]?.userId ?? sessionUser.id; const firstUserId = payments[0]?.userId ?? sessionUser.id;
@ -164,78 +162,6 @@ function ExpenseForm({
</FormItem> </FormItem>
)} )}
/> />
<ExpenseParticipants sessionUserId={sessionUser.id} />
<Separator />
<ExpenseSplit sessionUser={sessionUser} />
{/* <Tabs defaultValue="expense" className="size-full space-y-4">
<TabsList className="w-full bg-transparent p-0">
<TabsTrigger value="expense">Expense</TabsTrigger>
<TabsTrigger value="split">Split</TabsTrigger>
</TabsList>
<TabsContent value="expense" className="space-y-8 size-full">
<FormField
control={form.control}
name="amount"
render={({ field }) => (
<FormItem className="flex flex-col justify-between h-full ">
<FormLabel> Amount</FormLabel>
{/* <MoneyInput
onChange={field.onChange}
showNumpad={true}
className="absolute w-full max-w left-1/2 -translate-x-1/2 transform bottom-20 px-4"
/>
<div className="flex border-b gap-2 px-4 py-2 border-input items-center">
<NumberInput
value={field.value}
onChange={field.onChange}
className="focus-visible:ring-0 focus-visible:ring-offset-0 border-0 p-0"
/>
<EuroIcon className="size-6 text-muted-foreground" />
</div>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea
className="focus-visible:ring-0 focus-visible:ring-offset-0 border-0 border-b rounded-none resize-none"
placeholder="About this expense"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<ExpenseSplit amount={amount} session={session} />
</TabsContent>
<TabsContent value="split">
{/* <FormField
control={form.control}
name="friendId"
render={({ field }) => (
<FormItem>
<FormLabel>Select Friend</FormLabel>
<FormControl>
<FriendSelect
onSelect={field.onChange}
initialValue={field.value}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</TabsContent>
</Tabs> */}
</form> </form>
</Form> </Form>
); );

View File

@ -1,10 +1,7 @@
"use client"; "use client";
import Avatar from "@/components/avatar"; import Avatar from "@/components/avatar";
import { Input } from "@/components/ui/input";
import { useExpenseStore } from "@/lib/store/expense-store"; import { useExpenseStore } from "@/lib/store/expense-store";
import React from "react"; import React from "react";
import FriendSelect from "../friend/friend-select";
import { api } from "@/trpc/react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { XIcon } from "lucide-react"; import { XIcon } from "lucide-react";
import type { User } from "@/server/db/schema"; import type { User } from "@/server/db/schema";
@ -18,9 +15,9 @@ export const UserBadge = ({
children?: React.ReactNode; children?: React.ReactNode;
}) => { }) => {
return ( return (
<div className="border rounded-full gap-1 flex items-center w-max pr-1"> <div className="border rounded-full gap-1 flex items-center w-max ">
<Avatar src={user.image} fb={user.name} className="size-6" /> <Avatar src={user.image} fb={user.name} className="size-6" />
<span className="text-sm">{user.name}</span> <span className="text-sm mr-1">{user.name}</span>
{children} {children}
</div> </div>

View File

@ -1,5 +1,7 @@
"use client";
import Avatar from "@/components/avatar"; import Avatar from "@/components/avatar";
import { Icons } from "@/components/icons"; import { Icons } from "@/components/icons";
import { Button } from "@/components/ui/button";
import { import {
Card, Card,
CardDescription, CardDescription,
@ -7,11 +9,11 @@ import {
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import type { Group } from "@/server/db/schema"; import type { Group } from "@/server/db/schema";
import type { User } from "@clerk/nextjs/server"; import { useRouter } from "next/navigation";
import Link from "next/link";
import React from "react"; import React from "react";
function GroupCard({ group }: { group: Group }) { function GroupCard({ group }: { group: Group }) {
const router = useRouter();
return ( return (
<Card> <Card>
<CardHeader className="flex items-center justify-between"> <CardHeader className="flex items-center justify-between">
@ -52,13 +54,16 @@ function GroupCard({ group }: { group: Group }) {
<p>{group.description}</p> <p>{group.description}</p>
</CardDescription> </CardDescription>
</div> </div>
<Link <Button
className="cursor-pointer h-max flex items-center border bg-foreground text-background rounded-lg pl-4 w-24 justify-between" className=" h-max flex items-center bg-foreground text-background rounded-lg pl-4 w-24 justify-between"
href={"/add"} onClick={(e) => {
e.preventDefault();
router.push(`/add?groupId=${group.id}`);
}}
> >
<span>Add</span> <span>Add</span>
<Icons.addSquare className="size-8" /> <Icons.addSquare className="size-8" />
</Link> </Button>
</CardHeader> </CardHeader>
</Card> </Card>
); );

View File

@ -11,10 +11,13 @@ import GroupFormDrawer from "./group-form-drawer";
import Link from "next/link"; import Link from "next/link";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Icons } from "@/components/icons"; import { Icons } from "@/components/icons";
import ExpenseCard from "../expense/expense-card";
function GroupPage({ groupId }: { groupId: string }) { function GroupPage({ groupId }: { groupId: string }) {
const [group] = api.group.get.useSuspenseQuery({ id: groupId }); const [group] = api.group.get.useSuspenseQuery({ id: groupId });
if (!group) return <GroupFormDrawer />; if (!group) return <GroupFormDrawer />;
const groupExpenses = group?.groupBadges?.map(({ expense }) => expense);
return ( return (
<> <>
<Header text={group.name}> <Header text={group.name}>
@ -57,7 +60,11 @@ function GroupPage({ groupId }: { groupId: string }) {
</Link> </Link>
</Button> </Button>
</div> </div>
<ul className="space-y-2"> <ul className="space-y-2">
{groupExpenses.map((expense) => (
<ExpenseCard key={expense.id} expense={expense} />
))}
{/* {group.members.map((member) => ( {/* {group.members.map((member) => (
<li key={member.id}> <li key={member.id}>
<UserCard className="border bg-background" user={member.user!}> <UserCard className="border bg-background" user={member.user!}>

View File

@ -0,0 +1,123 @@
"use client";
import { TrendingUp } from "lucide-react";
import {
PolarAngleAxis,
PolarGrid,
Radar,
RadarChart as RadarChartComponent,
} from "recharts";
import {
Card,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
type ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart";
import { getAmount } from "@/lib/utils";
import type { User } from "@/server/db/schema";
import Avatar from "../avatar";
type ChartData = {
user: User;
owesYou: number;
youOwe: number;
total: number;
};
const chartConfig = {
owesYou: {
label: "Owes You",
color: "var(--success)",
},
youOwe: {
label: "You Owe",
color: "var(--destrctive)",
},
total: {
label: "Total",
color: "var(--primary)",
},
} satisfies ChartConfig;
export function AnalyticsRadarChart({
chartData,
}: {
chartData: Array<ChartData>;
}) {
// Transform data to match radar chart expectations
const radarData = chartData.map((data) => ({
subject: data.user.id,
owesYou: data.owesYou,
youOwe: data.youOwe,
total: data.total,
fullMark: Math.max(data.owesYou, data.youOwe) * 1.2, // Add some headroom
}));
return (
<>
<ChartContainer
config={chartConfig}
className="mx-auto aspect-square max-h-[250px] w-full"
>
<RadarChartComponent data={radarData} outerRadius="80%">
<PolarGrid />
<PolarAngleAxis
dataKey="subject"
tick={({ payload, x, y, textAnchor, ...rest }) => {
const user = chartData.find(
({ user }) => user.id === payload.value
)!.user;
return (
<text
{...rest}
x={x}
y={y}
textAnchor={textAnchor}
fontSize={12}
fontWeight={500}
className="fill-foreground"
>
<Avatar src={user.image} fb={user.name} className="size-4" />
{user.name}
</text>
);
}}
/>
{/* <Radar
name="Total"
dataKey="total"
stroke="var(--success)"
fill="var(--success)"
fillOpacity={0.6}
/> */}
<Radar
name="Owes You"
dataKey="owesYou"
stroke="var(--success)"
fill="var(--success)"
fillOpacity={0.1}
/>
<Radar
name="You Owe"
dataKey="youOwe"
stroke="var(--destructive)"
fill="var(--destructive)"
fillOpacity={0.6}
/>
<ChartTooltip
cursor={false}
content={<ChartTooltipContent indicator="dot" />}
/>
</RadarChartComponent>
</ChartContainer>
</>
);
}

View File

@ -17,6 +17,8 @@ type IconName =
| "sun" | "sun"
| "moon" | "moon"
| "display" | "display"
| "arrowRight"
| "filter"
| "loading"; | "loading";
export const Icons: Record<IconName, IconComponent> = { export const Icons: Record<IconName, IconComponent> = {
@ -398,6 +400,52 @@ export const Icons: Record<IconName, IconComponent> = {
</svg> </svg>
); );
}, },
arrowRight(props) {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M16.528 6.46731C16.2338 6.17573 15.7589 6.17784 15.4673 6.47204C15.1757 6.76624 15.1778 7.24111 15.472 7.53269L17.235 9.28C17.9505 9.98914 18.4413 10.4772 18.7734 10.8907C18.8813 11.0251 18.9655 11.1434 19.0309 11.25L4 11.25C3.58579 11.25 3.25 11.5858 3.25 12C3.25 12.4142 3.58579 12.75 4 12.75L19.0309 12.75C18.9655 12.8566 18.8813 12.9749 18.7734 13.1093C18.4413 13.5228 17.9505 14.0109 17.235 14.72L15.472 16.4673C15.1778 16.7589 15.1757 17.2338 15.4673 17.528C15.7589 17.8222 16.2338 17.8243 16.528 17.5327L18.3227 15.7539C18.9987 15.084 19.5511 14.5364 19.9429 14.0485C20.3504 13.5412 20.6453 13.0263 20.7241 12.4082C20.7414 12.2726 20.75 12.1363 20.75 12C20.75 11.8637 20.7414 11.7274 20.7241 11.5918C20.6453 10.9737 20.3504 10.4588 19.9429 9.95146C19.5511 9.46358 18.9987 8.91604 18.3227 8.24609L16.528 6.46731Z"
fill="currentColor"
/>
</svg>
);
},
filter(props) {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M13.75 7.99996C13.75 7.30961 14.3096 6.74996 15 6.74996C15.6904 6.74996 16.25 7.30961 16.25 7.99996C16.25 8.69032 15.6904 9.24996 15 9.24996C14.3096 9.24996 13.75 8.69032 13.75 7.99996Z"
fill="currentColor"
/>
<path
d="M7.75 16C7.75 15.3096 8.30964 14.75 9 14.75C9.69036 14.75 10.25 15.3096 10.25 16C10.25 16.6903 9.69036 17.25 9 17.25C8.30964 17.25 7.75 16.6903 7.75 16Z"
fill="currentColor"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M16.4635 2.37373C15.3214 2.24999 13.8818 2.24999 12.0452 2.25H11.9548C10.1182 2.24999 8.67861 2.24999 7.53648 2.37373C6.37094 2.50001 5.42656 2.76232 4.62024 3.34815C4.13209 3.70281 3.70281 4.13209 3.34815 4.62024C2.76232 5.42656 2.50001 6.37094 2.37373 7.53648C2.24999 8.67861 2.24999 10.1182 2.25 11.9548V12.0452C2.24999 13.8818 2.24999 15.3214 2.37373 16.4635C2.50001 17.6291 2.76232 18.5734 3.34815 19.3798C3.70281 19.8679 4.13209 20.2972 4.62024 20.6518C5.42656 21.2377 6.37094 21.5 7.53648 21.6263C8.67859 21.75 10.1182 21.75 11.9547 21.75H12.0453C13.8818 21.75 15.3214 21.75 16.4635 21.6263C17.6291 21.5 18.5734 21.2377 19.3798 20.6518C19.8679 20.2972 20.2972 19.8679 20.6518 19.3798C21.2377 18.5734 21.5 17.6291 21.6263 16.4635C21.75 15.3214 21.75 13.8818 21.75 12.0453V11.9547C21.75 10.1182 21.75 8.67859 21.6263 7.53648C21.5 6.37094 21.2377 5.42656 20.6518 4.62024C20.2972 4.13209 19.8679 3.70281 19.3798 3.34815C18.5734 2.76232 17.6291 2.50001 16.4635 2.37373ZM15 5.24996C13.4812 5.24996 12.25 6.48118 12.25 7.99996C12.25 9.51874 13.4812 10.75 15 10.75C16.5188 10.75 17.75 9.51875 17.75 7.99996C17.75 6.48118 16.5188 5.24996 15 5.24996ZM9.75005 6C9.75005 5.58579 9.41427 5.25 9.00005 5.25C8.58584 5.25 8.25005 5.58579 8.25005 6L8.25005 12C8.25005 12.4142 8.58584 12.75 9.00005 12.75C9.41427 12.75 9.75005 12.4142 9.75005 12L9.75005 6ZM15.7501 12C15.7501 11.5858 15.4143 11.25 15.0001 11.25C14.5858 11.25 14.2501 11.5858 14.2501 12V18C14.2501 18.4142 14.5858 18.75 15.0001 18.75C15.4143 18.75 15.7501 18.4142 15.7501 18V12ZM9 13.25C7.48122 13.25 6.25 14.4812 6.25 16C6.25 17.5187 7.48122 18.75 9 18.75C10.5188 18.75 11.75 17.5187 11.75 16C11.75 14.4812 10.5188 13.25 9 13.25Z"
fill="currentColor"
/>
</svg>
);
},
loading(props) { loading(props) {
return ( return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" {...props}> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" {...props}>

View File

@ -0,0 +1,46 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

353
src/components/ui/chart.tsx Normal file
View File

@ -0,0 +1,353 @@
"use client"
import * as React from "react"
import * as RechartsPrimitive from "recharts"
import { cn } from "@/lib/utils"
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode
icon?: React.ComponentType
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
)
}
type ChartContextProps = {
config: ChartConfig
}
const ChartContext = React.createContext<ChartContextProps | null>(null)
function useChart() {
const context = React.useContext(ChartContext)
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />")
}
return context
}
function ChartContainer({
id,
className,
children,
config,
...props
}: React.ComponentProps<"div"> & {
config: ChartConfig
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"]
}) {
const uniqueId = React.useId()
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
return (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
data-chart={chartId}
className={cn(
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
)
}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color
)
if (!colorConfig.length) {
return null
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
)
}
const ChartTooltip = RechartsPrimitive.Tooltip
function ChartTooltipContent({
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
}) {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
}
const [item] = payload
const key = `${labelKey || item?.dataKey || item?.name || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
)
}
if (!value) {
return null
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
])
if (!active || !payload?.length) {
return null
}
const nestLabel = payload.length === 1 && indicator !== "dot"
return (
<div
className={cn(
"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color || item.payload.fill || item.color
return (
<div
key={item.dataKey}
className={cn(
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
indicator === "dot" && "items-center"
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="text-foreground font-mono font-medium tabular-nums">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)
}
const ChartLegend = RechartsPrimitive.Legend
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
nameKey,
}: React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean
nameKey?: string
}) {
const { config } = useChart()
if (!payload?.length) {
return null
}
return (
<div
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<div
key={item.value}
className={cn(
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
)
})}
</div>
)
}
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string
) {
if (typeof payload !== "object" || payload === null) {
return undefined
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined
let configLabelKey: string = key
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config]
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
}

View File

@ -1,6 +1,7 @@
import type { SplitType } from "@/lib/store/expense-store"; import type { SplitType } from "@/lib/store/expense-store";
import type { z } from "zod"; import type { z } from "zod";
import { expenseSplitSchema } from "../validations/expense"; import { expenseSplitSchema } from "../validations/expense";
import type { ExpenseSplit } from "@/server/db/schema";
const debtSchema = expenseSplitSchema.pick({ const debtSchema = expenseSplitSchema.pick({
owedFromId: true, owedFromId: true,
@ -118,3 +119,24 @@ export function calculateDepts(
return debts; return debts;
} }
export const calculateTotalValue: (
userId: string,
splits: Array<ExpenseSplit>
) => {
owed: number;
owes: number;
} = (userId, splits) => {
let owed = 0;
let owes = 0;
splits.forEach((split) => {
if (split.owedToId === userId) {
owes += Number(split.amount); // You are the debitor
} else if (split.owedFromId === userId) {
owed += Number(split.amount); // You are the creditor
}
});
return { owed, owes };
};

View File

@ -2,11 +2,13 @@ import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
import { import {
expenseGroupBadges,
expenses, expenses,
expenseSplits, expenseSplits,
friendships, friendships,
groupMembers, groupMembers,
type ExpenseSplit, type ExpenseSplit,
type User,
} from "@/server/db/schema"; } from "@/server/db/schema";
import { expenseSchema, expenseSplitSchema } from "@/lib/validations/expense"; import { expenseSchema, expenseSplitSchema } from "@/lib/validations/expense";
import { and, desc, eq, inArray, not, notInArray, or } from "drizzle-orm"; import { and, desc, eq, inArray, not, notInArray, or } from "drizzle-orm";
@ -43,7 +45,15 @@ export const expenseRouter = createTRPCRouter({
with: { with: {
owedFrom: true, owedFrom: true,
owedTo: true, owedTo: true,
expense: true, expense: {
with: {
groupBadges: {
with: {
group: true,
},
},
},
},
}, },
orderBy: desc(expenseSplits.createdAt), orderBy: desc(expenseSplits.createdAt),
}); });
@ -82,11 +92,72 @@ export const expenseRouter = createTRPCRouter({
}; };
}), }),
getAnalytics: protectedProcedure.query(async ({ ctx }) => {
const currentUserId = ctx.auth.userId;
const allSplits = await ctx.db.query.expenseSplits.findMany({
where: (split, { or }) =>
or(
eq(split.owedFromId, currentUserId),
eq(split.owedToId, currentUserId)
),
with: {
owedTo: true,
owedFrom: true,
expense: true,
},
});
// Calculate debts per user
const debtMap = new Map<
string,
{ owesYou: number; youOwe: number; user: User }
>();
for (const split of allSplits) {
// Skip if both parties are the same user
if (split.owedFromId === split.owedToId) continue;
const isOwedToYou = split.owedFromId === currentUserId;
const otherUser = isOwedToYou ? split.owedTo : split.owedFrom;
const amount = parseFloat(split?.amount!);
if (!debtMap.has(otherUser.id)) {
debtMap.set(otherUser.id, {
owesYou: 0,
youOwe: 0,
user: otherUser,
});
}
const otherUserDebt = debtMap.get(otherUser.id)!;
if (isOwedToYou) {
otherUserDebt.owesYou += amount;
} else {
otherUserDebt.youOwe += amount;
}
}
// Convert to array and sort by total debt (sum of absolute values)
const debtData = Array.from(debtMap.entries())
.map(([_, amounts]) => ({
user: amounts.user,
owesYou: amounts.owesYou,
youOwe: amounts.youOwe,
total: Math.abs(amounts.owesYou) + Math.abs(amounts.youOwe),
}))
.sort((a, b) => b.total - a.total)
.slice(0, 6); // Get top 5
return debtData;
}),
// mutations // mutations
create: protectedProcedure create: protectedProcedure
.input( .input(
z.object({ z.object({
expense: expenseSchema, expense: expenseSchema,
groupBadges: z.array(z.string()),
debs: z.array( debs: z.array(
expenseSplitSchema.pick({ expenseSplitSchema.pick({
owedFromId: true, owedFromId: true,
@ -97,16 +168,25 @@ export const expenseRouter = createTRPCRouter({
}) })
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const e = {
createdById: ctx.auth.userId,
...input.expense,
amount: input.expense.amount.toString(),
};
console.log(e);
const [expense] = await ctx.db const [expense] = await ctx.db
.insert(expenses) .insert(expenses)
.values({ .values(e)
createdById: ctx.auth.userId,
...input.expense,
amount: input.expense.amount.toString(),
})
.returning({ id: expenses.id }); .returning({ id: expenses.id });
if (!expense?.id?.length) throw new Error("Expense cant get created"); if (!expense?.id?.length) throw new Error("Expense cant get created");
if (input.groupBadges.length) {
await ctx.db.insert(expenseGroupBadges).values(
input.groupBadges.map((groupId) => ({
groupId,
expenseId: expense.id,
}))
);
}
await ctx.db await ctx.db
.insert(expenseSplits) .insert(expenseSplits)
.values( .values(

View File

@ -12,6 +12,25 @@ export const groupRouter = createTRPCRouter({
const group = await ctx.db.query.groups.findFirst({ const group = await ctx.db.query.groups.findFirst({
where: eq(groups.id, input.id), where: eq(groups.id, input.id),
with: { with: {
groupBadges: {
with: {
expense: {
with: {
groupBadges: {
with: {
group: true,
},
},
splits: {
with: {
owedFrom: true,
owedTo: true,
},
},
},
},
},
},
members: { members: {
with: { with: {
user: true, user: true,
@ -19,9 +38,10 @@ export const groupRouter = createTRPCRouter({
}, },
}, },
}); });
if (group?.members?.find((member) => member.userId === ctx.auth.userId)) if (group?.members?.find((member) => member.userId === ctx.auth.userId))
return group; return group;
return undefined; return null;
}), }),
getAll: protectedProcedure getAll: protectedProcedure
.input( .input(

View File

@ -47,12 +47,14 @@ export const expenseRelations = relations(expenses, ({ many, one }) => ({
fields: [expenses.createdById], fields: [expenses.createdById],
references: [users.id], references: [users.id],
}), }),
groupBadges: many(expenseGroupBadges),
})); }));
export type Expense = typeof expenses.$inferSelect & { export type Expense = typeof expenses.$inferSelect & {
splits?: Array<ExpenseSplit>; splits?: Array<ExpenseSplit>;
group?: Group; group?: Group;
createdBy?: User; createdBy?: User;
groupBadges?: Array<ExpenseGroupBadge>;
}; };
export const expenseSplits = createTable( export const expenseSplits = createTable(
@ -144,8 +146,42 @@ export type Settlement = typeof settlements.$inferSelect & {
receiver?: User; receiver?: User;
}; };
// Groups Table export const expenseGroupBadges = createTable(
"expense_group_badge",
(d) => ({
id: d.varchar().primaryKey().$defaultFn(createId),
expenseId: d
.varchar()
.notNull()
.references(() => expenses.id, { onDelete: "cascade" }),
groupId: d
.varchar()
.notNull()
.references(() => groups.id, { onDelete: "cascade" }),
}),
(t) => [index("expense_group_badge_expense_idx").on(t.expenseId)]
);
export const expenseGroupBadgeRelations = relations(
expenseGroupBadges,
({ one }) => ({
expense: one(expenses, {
fields: [expenseGroupBadges.expenseId],
references: [expenses.id],
}),
group: one(groups, {
fields: [expenseGroupBadges.groupId],
references: [groups.id],
}),
})
);
export type ExpenseGroupBadge = typeof expenseGroupBadges.$inferSelect & {
expense?: Expense;
group?: Group;
};
// Groups Table
export const groups = createTable( export const groups = createTable(
"group", "group",
(d) => ({ (d) => ({
@ -170,11 +206,13 @@ export const groupRelations = relations(groups, ({ many, one }) => ({
references: [users.id], references: [users.id],
}), }),
members: many(groupMembers), members: many(groupMembers),
groupBadges: many(expenseGroupBadges),
})); }));
export type Group = typeof groups.$inferSelect & { export type Group = typeof groups.$inferSelect & {
createdBy?: User; createdBy?: User;
members?: Array<GroupMember>; members?: Array<GroupMember>;
groupBadges?: Array<ExpenseGroupBadge>;
}; };
// Group Members Table (Tracks who is in a group) // Group Members Table (Tracks who is in a group)

View File

@ -41,6 +41,8 @@
--sidebar-accent-foreground: oklch(0.21 0.006 285.885); --sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32); --sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.705 0.213 47.604); --sidebar-ring: oklch(0.705 0.213 47.604);
--success: oklch(0.723 0.219 149.579);
--success-foreground: oklch(0.982 0.018 155.826);
} }
.dark { .dark {
@ -113,6 +115,8 @@
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border); --color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring); --color-sidebar-ring: var(--sidebar-ring);
--color-success: var(--success);
--color-success-foreground: var(--success-foreground);
} }
@layer base { @layer base {