diff --git a/.gitignore b/.gitignore index 9773bb3..e20a8cb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ node_modules/ dist/ +.next/ data/*.db - diff --git a/next-env.d.ts b/next-env.d.ts new file mode 100644 index 0000000..c4b7818 --- /dev/null +++ b/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +import "./.next/dev/types/routes.d.ts"; + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/next.config.mjs b/next.config.mjs new file mode 100644 index 0000000..637686a --- /dev/null +++ b/next.config.mjs @@ -0,0 +1,20 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + typescript: { + tsconfigPath: "./tsconfig.next.json", + }, + async rewrites() { + return [ + { + source: "/api/:path*", + destination: "http://localhost:3000/api/:path*", + }, + { + source: "/health", + destination: "http://localhost:3000/health", + }, + ]; + }, +}; + +export default nextConfig; diff --git a/package-lock.json b/package-lock.json index fc89219..5579099 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,12 +12,18 @@ "better-sqlite3": "^12.9.0", "drizzle-orm": "^0.45.2", "express": "^5.2.1", + "lucide-react": "^1.14.0", + "next": "^16.2.4", + "react": "^19.2.5", + "react-dom": "^19.2.5", "zod": "^4.4.1" }, "devDependencies": { "@types/better-sqlite3": "^7.6.13", "@types/express": "^5.0.6", "@types/node": "^25.6.0", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", "drizzle-kit": "^0.31.10", "tsx": "^4.21.0", "typescript": "^6.0.3" @@ -29,6 +35,15 @@ "integrity": "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==", "dev": true }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild-kit/core-utils": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/@esbuild-kit/core-utils/-/core-utils-3.3.2.tgz", @@ -856,6 +871,580 @@ "node": ">=18" } }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@next/env": { + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.4.tgz", + "integrity": "sha512-dKkkOzOSwFYe5RX6y26fZgkSpVAlIOJKQHIiydQcrWH6y/97+RceSOAdjZ14Qa3zLduVUy0TXcn+EiM6t4rPgw==" + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.4.tgz", + "integrity": "sha512-OXTFFox5EKN1Ym08vfrz+OXxmCcEjT4SFMbNRsWZE99dMqt2Kcusl5MqPXcW232RYkMLQTy0hqgAMEsfEd/l2A==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.4.tgz", + "integrity": "sha512-XhpVnUfmYWvD3YrXu55XdcAkQtOnvaI6wtQa8fuF5fGoKoxIUZ0kWPtcOfqJEWngFF/lOS9l3+O9CcownhiQxQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.4.tgz", + "integrity": "sha512-Mx/tjlNA3G8kg14QvuGAJ4xBwPk1tUHq56JxZ8CXnZwz1Etz714soCEzGQQzVMz4bEnGPowzkV6Xrp6wAkEWOQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.4.tgz", + "integrity": "sha512-iVMMp14514u7Nup2umQS03nT/bN9HurK8ufylC3FZNykrwjtx7V1A7+4kvhbDSCeonTVqV3Txnv0Lu+m2oDXNg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.4.tgz", + "integrity": "sha512-EZOvm1aQWgnI/N/xcWOlnS3RQBk0VtVav5Zo7n4p0A7UKyTDx047k8opDbXgBpHl4CulRqRfbw3QrX2w5UOXMQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.4.tgz", + "integrity": "sha512-h9FxsngCm9cTBf71AR4fGznDEDx1hS7+kSEiIRjq5kO1oXWm07DxVGZjCvk0SGx7TSjlUqhI8oOyz7NfwAdPoA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.4.tgz", + "integrity": "sha512-3NdJV5OXMSOeJYijX+bjaLge3mJBlh4ybydbT4GFoB/2hAojWHtMhl3CYlYoMrjPuodp0nzFVi4Tj2+WaMg+Ow==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.4.tgz", + "integrity": "sha512-kMVGgsqhO5YTYODD9IPGGhA6iprWidQckK3LmPeW08PIFENRmgfb4MjXHO+p//d+ts2rpjvK5gXWzXSMrPl9cw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "dependencies": { + "tslib": "^2.8.0" + } + }, "node_modules/@types/better-sqlite3": { "version": "7.6.13", "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", @@ -934,6 +1523,24 @@ "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", "dev": true }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "dev": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, "node_modules/@types/send": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", @@ -984,6 +1591,17 @@ } ] }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.24", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.24.tgz", + "integrity": "sha512-I2NkZOOrj2XuguvWCK6OVh9GavsNjZjK908Rq3mIBK25+GD8vPX5w2WdxVqnQ7xx3SrZJiCiZFu+/Oz50oSYSA==", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/better-sqlite3": { "version": "12.9.0", "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.9.0.tgz", @@ -1102,11 +1720,35 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/caniuse-lite": { + "version": "1.0.30001791", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz", + "integrity": "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ] + }, "node_modules/chownr": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==" }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==" + }, "node_modules/content-disposition": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", @@ -1143,6 +1785,12 @@ "node": ">=6.6.0" } }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1730,6 +2378,14 @@ "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==" }, + "node_modules/lucide-react": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.14.0.tgz", + "integrity": "sha512-+1mdWcfSJVUsaTIjN9zoezmUhfXo5l0vP7ekBMPo3jcS/aIkxHnXqAPsByszMZx/Y8oQBRJxJx5xg+RH3urzxA==", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -1809,6 +2465,23 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/napi-build-utils": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", @@ -1822,6 +2495,58 @@ "node": ">= 0.6" } }, + "node_modules/next": { + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/next/-/next-16.2.4.tgz", + "integrity": "sha512-kPvz56wF5frc+FxlHI5qnklCzbq53HTwORaWBGdT0vNoKh1Aya9XC8aPauH4NJxqtzbWsS5mAbctm4cr+EkQ2Q==", + "dependencies": { + "@next/env": "16.2.4", + "@swc/helpers": "0.5.15", + "baseline-browser-mapping": "^2.9.19", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.4.31", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": ">=20.9.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "16.2.4", + "@next/swc-darwin-x64": "16.2.4", + "@next/swc-linux-arm64-gnu": "16.2.4", + "@next/swc-linux-arm64-musl": "16.2.4", + "@next/swc-linux-x64-gnu": "16.2.4", + "@next/swc-linux-x64-musl": "16.2.4", + "@next/swc-win32-arm64-msvc": "16.2.4", + "@next/swc-win32-x64-msvc": "16.2.4", + "sharp": "^0.34.5" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, "node_modules/node-abi": { "version": "3.89.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz", @@ -1880,6 +2605,38 @@ "url": "https://opencollective.com/express" } }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" + }, + "node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/prebuild-install": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", @@ -1977,6 +2734,25 @@ "rc": "cli.js" } }, + "node_modules/react": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", + "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", + "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.5" + } + }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -2038,6 +2814,11 @@ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==" + }, "node_modules/semver": { "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", @@ -2097,6 +2878,50 @@ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, "node_modules/side-channel": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", @@ -2217,6 +3042,14 @@ "node": ">=0.10.0" } }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-support": { "version": "0.5.21", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", @@ -2251,6 +3084,28 @@ "node": ">=0.10.0" } }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, "node_modules/tar-fs": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", @@ -2285,6 +3140,11 @@ "node": ">=0.6" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + }, "node_modules/tsx": { "version": "4.21.0", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", diff --git a/package.json b/package.json index c097934..7753558 100644 --- a/package.json +++ b/package.json @@ -4,8 +4,12 @@ "description": "", "main": "dist/server/index.js", "scripts": { - "dev": "tsx watch src/server/index.ts", - "build": "tsc -p tsconfig.json", + "dev": "npm run dev:api", + "dev:api": "tsx watch src/server/index.ts", + "dev:web": "next dev -p 3001", + "build": "npm run build:api", + "build:api": "tsc -p tsconfig.json", + "build:web": "next build", "start": "node dist/server/index.js", "test": "tsx --test tests/power-calculation.test.ts", "test:watch": "tsx --watch --test tests/power-calculation.test.ts", @@ -19,12 +23,18 @@ "better-sqlite3": "^12.9.0", "drizzle-orm": "^0.45.2", "express": "^5.2.1", + "lucide-react": "^1.14.0", + "next": "^16.2.4", + "react": "^19.2.5", + "react-dom": "^19.2.5", "zod": "^4.4.1" }, "devDependencies": { "@types/better-sqlite3": "^7.6.13", "@types/express": "^5.0.6", "@types/node": "^25.6.0", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", "drizzle-kit": "^0.31.10", "tsx": "^4.21.0", "typescript": "^6.0.3" diff --git a/src/app/globals.css b/src/app/globals.css new file mode 100644 index 0000000..223931a --- /dev/null +++ b/src/app/globals.css @@ -0,0 +1,279 @@ +:root { + color-scheme: light; + --bg: #f4f6f8; + --band: #ffffff; + --line: #d7dde5; + --line-strong: #b9c3d0; + --text: #17212f; + --muted: #647084; + --accent: #0f766e; + --accent-dark: #115e59; + --warn-bg: #fff4e5; + --warn-line: #f3b562; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + background: var(--bg); + color: var(--text); + font-family: Arial, Helvetica, sans-serif; +} + +button, +input, +select { + font: inherit; +} + +.workspace { + min-height: 100vh; + padding: 20px; + display: grid; + gap: 14px; +} + +.topbar, +.toolbarBand, +.entryBand, +.tableBand { + background: var(--band); + border: 1px solid var(--line); +} + +.topbar { + min-height: 82px; + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 18px; +} + +.eyebrow { + margin: 0 0 4px; + color: var(--muted); + font-size: 12px; + text-transform: uppercase; +} + +h1, +h2 { + margin: 0; + letter-spacing: 0; +} + +h1 { + font-size: 28px; +} + +h2 { + font-size: 19px; +} + +.iconButton, +.primaryButton { + border: 1px solid var(--accent); + background: var(--accent); + color: #ffffff; + min-height: 36px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + cursor: pointer; +} + +.iconButton { + width: 38px; + border-radius: 6px; +} + +.primaryButton { + border-radius: 6px; + padding: 0 12px; + white-space: nowrap; +} + +.primaryButton:hover, +.iconButton:hover { + background: var(--accent-dark); +} + +.primaryButton:disabled { + cursor: not-allowed; + opacity: 0.55; +} + +.alert { + margin: 0; + padding: 10px 12px; + border: 1px solid var(--warn-line); + background: var(--warn-bg); +} + +.toolbarBand { + display: grid; + grid-template-columns: minmax(360px, 1.25fr) minmax(320px, 1fr); + gap: 14px; + padding: 14px; +} + +.projectForm, +.consumerForm { + display: grid; + gap: 10px; + align-items: end; +} + +.projectForm { + grid-template-columns: minmax(180px, 1fr) minmax(220px, 1fr) auto; +} + +.consumerForm { + grid-template-columns: minmax(210px, 1.6fr) minmax(150px, 1fr) 90px 140px 150px 110px 110px 100px minmax(180px, 1.4fr) auto; +} + +label { + min-width: 0; + display: grid; + gap: 5px; + color: var(--muted); + font-size: 12px; +} + +input, +select { + width: 100%; + min-height: 36px; + border: 1px solid var(--line-strong); + border-radius: 6px; + padding: 6px 9px; + color: var(--text); + background: #ffffff; +} + +.summaryStrip { + display: grid; + grid-template-columns: repeat(3, minmax(120px, 1fr)); + border: 1px solid var(--line); +} + +.summaryStrip div { + padding: 11px 12px; + border-right: 1px solid var(--line); + display: grid; + gap: 4px; +} + +.summaryStrip div:last-child { + border-right: 0; +} + +.summaryStrip span, +.statusPill { + color: var(--muted); + font-size: 12px; +} + +.summaryStrip strong { + font-size: 18px; +} + +.entryBand, +.tableBand { + padding: 14px; +} + +.tableHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + margin-bottom: 12px; +} + +.statusPill, +.nameCell { + display: inline-flex; + align-items: center; + gap: 7px; +} + +.statusPill { + min-height: 30px; + border: 1px solid var(--line); + border-radius: 6px; + padding: 0 9px; +} + +.tableScroll { + overflow-x: auto; + border: 1px solid var(--line); +} + +table { + width: 100%; + min-width: 1020px; + border-collapse: collapse; + font-size: 13px; +} + +th, +td { + border-bottom: 1px solid var(--line); + padding: 9px 10px; + text-align: left; + vertical-align: middle; +} + +th { + background: #edf1f5; + color: #344054; + font-weight: 700; +} + +tbody tr:hover { + background: #f8fafc; +} + +.emptyState { + height: 92px; + color: var(--muted); + text-align: center; +} + +@media (max-width: 1180px) { + .toolbarBand, + .projectForm, + .consumerForm { + grid-template-columns: 1fr 1fr; + } + + .summaryStrip, + .wideField, + .consumerForm .primaryButton { + grid-column: 1 / -1; + } +} + +@media (max-width: 720px) { + .workspace { + padding: 10px; + } + + .topbar, + .tableHeader { + align-items: flex-start; + flex-direction: column; + } + + .toolbarBand, + .projectForm, + .consumerForm, + .summaryStrip { + grid-template-columns: 1fr; + } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx new file mode 100644 index 0000000..a86c588 --- /dev/null +++ b/src/app/layout.tsx @@ -0,0 +1,15 @@ +import type { Metadata } from "next"; +import "./globals.css"; + +export const metadata: Metadata = { + title: "Leistungsbilanz", + description: "Leistungsbilanz fuer elektrische Verbraucher", +}; + +export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) { + return ( + + {children} + + ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx new file mode 100644 index 0000000..a6839d3 --- /dev/null +++ b/src/app/page.tsx @@ -0,0 +1,5 @@ +import { PowerBalanceWorkspace } from "../frontend/components/power-balance-workspace"; + +export default function Home() { + return ; +} diff --git a/src/db/repositories/distribution-board.repository.ts b/src/db/repositories/distribution-board.repository.ts new file mode 100644 index 0000000..1cb0483 --- /dev/null +++ b/src/db/repositories/distribution-board.repository.ts @@ -0,0 +1,17 @@ +import crypto from "node:crypto"; +import { eq } from "drizzle-orm"; +import { db } from "../client.js"; +import { distributionBoards } from "../schema/distribution-boards.js"; + +export class DistributionBoardRepository { + async listByProject(projectId: string) { + return db.select().from(distributionBoards).where(eq(distributionBoards.projectId, projectId)); + } + + async create(projectId: string, name: string) { + const id = crypto.randomUUID(); + const board = { id, projectId, name }; + await db.insert(distributionBoards).values(board); + return board; + } +} diff --git a/src/frontend/components/power-balance-workspace.tsx b/src/frontend/components/power-balance-workspace.tsx new file mode 100644 index 0000000..1adb083 --- /dev/null +++ b/src/frontend/components/power-balance-workspace.tsx @@ -0,0 +1,386 @@ +"use client"; + +import { Activity, FolderPlus, Plus, RefreshCw, Zap } from "lucide-react"; +import { FormEvent, useEffect, useMemo, useState } from "react"; +import { + createConsumer, + createDistributionBoard, + createProject, + listConsumers, + listDistributionBoards, + listProjects, +} from "../utils/api"; +import type { + ConsumerWithCalculatedValues, + CreateConsumerInput, + DistributionBoardDto, + ProjectDto, +} from "../types"; + +const initialConsumerForm = { + name: "", + category: "", + quantity: "1", + installedPowerPerUnitKw: "0.1", + demandFactor: "1", + voltageV: "230", + phaseCount: "1", + powerFactor: "1", + note: "", +}; + +type ConsumerForm = typeof initialConsumerForm; + +function toOptionalNumber(value: string) { + if (value.trim() === "") { + return undefined; + } + return Number(value); +} + +function formatNumber(value: number | undefined, digits = 2) { + if (value === undefined || Number.isNaN(value)) { + return "-"; + } + return new Intl.NumberFormat("de-DE", { + maximumFractionDigits: digits, + minimumFractionDigits: digits, + }).format(value); +} + +export function PowerBalanceWorkspace() { + const [projects, setProjects] = useState([]); + const [selectedProjectId, setSelectedProjectId] = useState(""); + const [distributionBoards, setDistributionBoards] = useState([]); + const [selectedBoardId, setSelectedBoardId] = useState(""); + const [consumers, setConsumers] = useState([]); + const [projectName, setProjectName] = useState(""); + const [boardName, setBoardName] = useState(""); + const [consumerForm, setConsumerForm] = useState(initialConsumerForm); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + const [error, setError] = useState(null); + + const selectedProject = projects.find((project) => project.id === selectedProjectId); + const selectedBoard = distributionBoards.find((board) => board.id === selectedBoardId); + const visibleConsumers = selectedBoardId + ? consumers.filter((consumer) => consumer.distributionBoardId === selectedBoardId) + : consumers; + + const totals = useMemo( + () => + visibleConsumers.reduce( + (sum, consumer) => ({ + installedPowerKw: sum.installedPowerKw + consumer.installedPowerKw, + demandPowerKw: sum.demandPowerKw + consumer.demandPowerKw, + }), + { installedPowerKw: 0, demandPowerKw: 0 } + ), + [visibleConsumers] + ); + + async function refreshProjects() { + setError(null); + const loadedProjects = await listProjects(); + setProjects(loadedProjects); + setSelectedProjectId((current) => current || loadedProjects[0]?.id || ""); + } + + async function refreshConsumers(projectId: string) { + if (!projectId) { + setConsumers([]); + return; + } + setError(null); + setConsumers(await listConsumers(projectId)); + } + + async function refreshDistributionBoards(projectId: string) { + if (!projectId) { + setDistributionBoards([]); + setSelectedBoardId(""); + return; + } + + setError(null); + const boards = await listDistributionBoards(projectId); + setDistributionBoards(boards); + setSelectedBoardId((current) => (boards.some((board) => board.id === current) ? current : boards[0]?.id || "")); + } + + useEffect(() => { + refreshProjects() + .catch((err: unknown) => setError(err instanceof Error ? err.message : "Projekte konnten nicht geladen werden.")) + .finally(() => setIsLoading(false)); + }, []); + + useEffect(() => { + refreshDistributionBoards(selectedProjectId).catch((err: unknown) => + setError(err instanceof Error ? err.message : "Verteilungen konnten nicht geladen werden.") + ); + refreshConsumers(selectedProjectId).catch((err: unknown) => + setError(err instanceof Error ? err.message : "Verbraucher konnten nicht geladen werden.") + ); + }, [selectedProjectId]); + + async function handleCreateProject(event: FormEvent) { + event.preventDefault(); + const name = projectName.trim(); + if (!name) { + return; + } + + setIsSaving(true); + setError(null); + try { + const project = await createProject(name); + setProjects((current) => [...current, project]); + setSelectedProjectId(project.id); + setProjectName(""); + } catch (err) { + setError(err instanceof Error ? err.message : "Projekt konnte nicht angelegt werden."); + } finally { + setIsSaving(false); + } + } + + async function handleCreateDistributionBoard(event: FormEvent) { + event.preventDefault(); + const name = boardName.trim(); + if (!selectedProjectId || !name) { + return; + } + + setIsSaving(true); + setError(null); + try { + const board = await createDistributionBoard(selectedProjectId, name); + setDistributionBoards((current) => [...current, board]); + setSelectedBoardId(board.id); + setBoardName(""); + } catch (err) { + setError(err instanceof Error ? err.message : "Verteilung konnte nicht angelegt werden."); + } finally { + setIsSaving(false); + } + } + + async function handleCreateConsumer(event: FormEvent) { + event.preventDefault(); + if (!selectedProjectId || !consumerForm.name.trim()) { + return; + } + + const input: CreateConsumerInput = { + projectId: selectedProjectId, + distributionBoardId: selectedBoardId || undefined, + name: consumerForm.name.trim(), + category: consumerForm.category.trim() || undefined, + quantity: Number(consumerForm.quantity), + installedPowerPerUnitKw: Number(consumerForm.installedPowerPerUnitKw), + demandFactor: Number(consumerForm.demandFactor), + voltageV: toOptionalNumber(consumerForm.voltageV), + phaseCount: consumerForm.phaseCount === "3" ? 3 : 1, + powerFactor: toOptionalNumber(consumerForm.powerFactor), + note: consumerForm.note.trim() || undefined, + }; + + setIsSaving(true); + setError(null); + try { + const created = await createConsumer(input); + setConsumers((current) => [...current, created]); + setConsumerForm(initialConsumerForm); + } catch (err) { + setError(err instanceof Error ? err.message : "Verbraucher konnte nicht angelegt werden."); + } finally { + setIsSaving(false); + } + } + + function updateConsumerForm(field: keyof ConsumerForm, value: string) { + setConsumerForm((current) => ({ ...current, [field]: value })); + } + + return ( +
+
+
+

TGA / ELT Planung

+

Leistungsbilanz

+
+ +
+ + {error ?

{error}

: null} + +
+
+ + + +
+ +
+ + + +
+ +
+
+ Verbraucher + {visibleConsumers.length} +
+
+ Installierte Leistung + {formatNumber(totals.installedPowerKw)} kW +
+
+ Berechnete Leistung + {formatNumber(totals.demandPowerKw)} kW +
+
+
+ +
+
+ + + + + + + + + + +
+
+ +
+
+
+

Aktuelles Projekt

+

{selectedProject?.name || "Noch kein Projekt ausgewählt"}

+

{selectedBoard ? `Verteilung: ${selectedBoard.name}` : "Alle Verteilungen"}

+
+
+ + {isLoading ? "Lädt" : "Bereit"} +
+
+ +
+ + + + + + + + + + + + + + + + {visibleConsumers.map((consumer) => ( + + + + + + + + + + + + ))} + {!visibleConsumers.length ? ( + + + + ) : null} + +
VerbraucherKategorieAnzahlLeistung je Stück [kW]Installierte Leistung [kW]GZFBerechnete Leistung [kW]Strom [A]Bemerkung
+ + {consumer.name} + {consumer.category || "-"}{consumer.quantity}{formatNumber(consumer.installedPowerPerUnitKw)}{formatNumber(consumer.installedPowerKw)}{formatNumber(consumer.demandFactor, 2)}{formatNumber(consumer.demandPowerKw)}{formatNumber(consumer.currentA)}{consumer.note || "-"}
+ Lege eine Verteilung an oder erfasse den ersten Verbraucher. +
+
+
+
+ ); +} diff --git a/src/frontend/types.ts b/src/frontend/types.ts new file mode 100644 index 0000000..02350eb --- /dev/null +++ b/src/frontend/types.ts @@ -0,0 +1,42 @@ +export interface ProjectDto { + id: string; + name: string; +} + +export interface ConsumerWithCalculatedValues { + id: string; + projectId: string; + distributionBoardId?: string | null; + name: string; + category?: string; + quantity: number; + installedPowerPerUnitKw: number; + demandFactor: number; + voltageV?: number; + phaseCount?: 1 | 3; + powerFactor?: number; + note?: string; + installedPowerKw: number; + demandPowerKw: number; + currentA?: number; +} + +export interface DistributionBoardDto { + id: string; + projectId: string; + name: string; +} + +export interface CreateConsumerInput { + projectId: string; + distributionBoardId?: string; + name: string; + category?: string; + quantity: number; + installedPowerPerUnitKw: number; + demandFactor: number; + voltageV?: number; + phaseCount?: 1 | 3; + powerFactor?: number; + note?: string; +} diff --git a/src/frontend/utils/api.ts b/src/frontend/utils/api.ts new file mode 100644 index 0000000..60597d1 --- /dev/null +++ b/src/frontend/utils/api.ts @@ -0,0 +1,56 @@ +import type { + ConsumerWithCalculatedValues, + CreateConsumerInput, + DistributionBoardDto, + ProjectDto, +} from "../types"; + +async function request(url: string, init?: RequestInit): Promise { + const response = await fetch(url, { + ...init, + headers: { + "Content-Type": "application/json", + ...init?.headers, + }, + }); + + if (!response.ok) { + const details = await response.text(); + throw new Error(details || `Request failed with ${response.status}`); + } + + return response.json() as Promise; +} + +export function listProjects() { + return request("/api/projects"); +} + +export function createProject(name: string) { + return request("/api/projects", { + method: "POST", + body: JSON.stringify({ name }), + }); +} + +export function listDistributionBoards(projectId: string) { + return request(`/api/projects/${projectId}/distribution-boards`); +} + +export function createDistributionBoard(projectId: string, name: string) { + return request(`/api/projects/${projectId}/distribution-boards`, { + method: "POST", + body: JSON.stringify({ name }), + }); +} + +export function listConsumers(projectId: string) { + return request(`/api/consumers/projects/${projectId}`); +} + +export function createConsumer(input: CreateConsumerInput) { + return request("/api/consumers", { + method: "POST", + body: JSON.stringify(input), + }); +} diff --git a/src/server/controllers/distribution-board.controller.ts b/src/server/controllers/distribution-board.controller.ts new file mode 100644 index 0000000..1091443 --- /dev/null +++ b/src/server/controllers/distribution-board.controller.ts @@ -0,0 +1,30 @@ +import type { Request, Response } from "express"; +import { DistributionBoardRepository } from "../../db/repositories/distribution-board.repository.js"; +import { createDistributionBoardSchema } from "../../shared/validation/consumer.schemas.js"; + +const distributionBoardRepository = new DistributionBoardRepository(); + +export async function listDistributionBoardsByProject(req: Request, res: Response) { + const { projectId } = req.params; + if (typeof projectId !== "string") { + return res.status(400).json({ error: "Invalid projectId" }); + } + + const result = await distributionBoardRepository.listByProject(projectId); + return res.json(result); +} + +export async function createDistributionBoard(req: Request, res: Response) { + const { projectId } = req.params; + if (typeof projectId !== "string") { + return res.status(400).json({ error: "Invalid projectId" }); + } + + const parsed = createDistributionBoardSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: parsed.error.flatten() }); + } + + const board = await distributionBoardRepository.create(projectId, parsed.data.name); + return res.status(201).json(board); +} diff --git a/src/server/routes/project.routes.ts b/src/server/routes/project.routes.ts index 9c15caf..6e96df4 100644 --- a/src/server/routes/project.routes.ts +++ b/src/server/routes/project.routes.ts @@ -1,8 +1,13 @@ import { Router } from "express"; import { createProject, listProjects } from "../controllers/project.controller.js"; +import { + createDistributionBoard, + listDistributionBoardsByProject, +} from "../controllers/distribution-board.controller.js"; export const projectRouter = Router(); projectRouter.get("/", listProjects); projectRouter.post("/", createProject); - +projectRouter.get("/:projectId/distribution-boards", listDistributionBoardsByProject); +projectRouter.post("/:projectId/distribution-boards", createDistributionBoard); diff --git a/src/shared/validation/consumer.schemas.ts b/src/shared/validation/consumer.schemas.ts index 63fab6a..4717395 100644 --- a/src/shared/validation/consumer.schemas.ts +++ b/src/shared/validation/consumer.schemas.ts @@ -18,6 +18,10 @@ export const createProjectSchema = z.object({ name: z.string().min(1), }); +export const createDistributionBoardSchema = z.object({ + name: z.string().min(1), +}); + export type CreateConsumerInput = z.infer; export type CreateProjectInput = z.infer; - +export type CreateDistributionBoardInput = z.infer; diff --git a/tsconfig.next.json b/tsconfig.next.json new file mode 100644 index 0000000..b91ebfd --- /dev/null +++ b/tsconfig.next.json @@ -0,0 +1,24 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "allowJs": true, + "incremental": true, + "isolatedModules": true, + "jsx": "preserve", + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "module": "ESNext", + "moduleResolution": "Bundler", + "noEmit": true, + "rootDir": ".", + "plugins": [{ "name": "next" }] + }, + "include": [ + "next-env.d.ts", + "src/app/**/*.ts", + "src/app/**/*.tsx", + "src/frontend/**/*.ts", + "src/frontend/**/*.tsx", + ".next/types/**/*.ts" + ], + "exclude": ["node_modules", "dist"] +}