diff --git a/bun.lock b/bun.lock index 30a95c6..a584e4a 100644 --- a/bun.lock +++ b/bun.lock @@ -30,12 +30,15 @@ "@radix-ui/react-tabs": "^1.1.12", "@tanstack/react-query": "^5.75.5", "@tanstack/react-router": "^1.121.0", + "@types/react-big-calendar": "^1.16.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "date-fns": "^4.1.0", "jspdf": "^4.2.1", "lucide-react": "^1.7.0", "radix-ui": "^1.4.3", "react": "^19.1.0", + "react-big-calendar": "^1.19.4", "react-dom": "^19.1.0", "react-hook-form": "^7.72.0", "sonner": "^2.0.3", @@ -277,6 +280,8 @@ "@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="], + "@popperjs/core": ["@popperjs/core@2.11.8", "", {}, "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A=="], + "@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="], "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], @@ -397,6 +402,8 @@ "@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="], + "@restart/hooks": ["@restart/hooks@0.4.16", "", { "dependencies": { "dequal": "^2.0.3" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-f7aCv7c+nU/3mF7NWLtVVr0Ra80RqsO89hO72r+Y/nvQr5+q0UFGkocElTH6MJApvReVh6JHUFYn2cw1WdHF3w=="], + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="], "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.1", "", { "os": "android", "cpu": "arm" }, "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA=="], @@ -525,6 +532,8 @@ "@types/bcrypt": ["@types/bcrypt@5.0.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-6atioO8Y75fNcbmj0G7UjI9lXN2pQ/IGJ2FWT4a/btd0Lk9lQalHLKhkgKVZ3r+spnmWUKfbMi1GEe9wyHQfNQ=="], + "@types/date-arithmetic": ["@types/date-arithmetic@4.1.4", "", {}, "sha512-p9eZ2X9B80iKiTW4ukVj8B4K6q9/+xFtQ5MGYA5HWToY9nL4EkhV9+6ftT2VHpVMEZb5Tv00Iel516bVdO+yRw=="], + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], @@ -533,14 +542,20 @@ "@types/pako": ["@types/pako@2.0.4", "", {}, "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw=="], + "@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="], + "@types/raf": ["@types/raf@3.4.3", "", {}, "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw=="], "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], + "@types/react-big-calendar": ["@types/react-big-calendar@1.16.3", "", { "dependencies": { "@types/date-arithmetic": "*", "@types/prop-types": "*", "@types/react": "*" } }, "sha512-CR+5BKMhlr/wPgsp+sXOeNKNkoU1h/+6H1XoWuL7xnurvzGRQv/EnM8jPS9yxxBvXI8pjQBaJcI7RTSGiewG/Q=="], + "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], + "@types/warning": ["@types/warning@3.0.4", "", {}, "sha512-CqN8MnISMwQbLJXO3doBAV4Yw9hx9/Pyr2rZ78+NfaCnhyRA/nKrpyk6E7mKw17ZOaQdLpK9GiUjrqLzBlN3sg=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.57.2", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.57.2", "@typescript-eslint/type-utils": "8.57.2", "@typescript-eslint/utils": "8.57.2", "@typescript-eslint/visitor-keys": "8.57.2", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.57.2", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-NZZgp0Fm2IkD+La5PR81sd+g+8oS6JwJje+aRWsDocxHkjyRw0J5L5ZTlN3LI1LlOcGL7ph3eaIUmTXMIjLk0w=="], "@typescript-eslint/parser": ["@typescript-eslint/parser@8.57.2", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.57.2", "@typescript-eslint/types": "8.57.2", "@typescript-eslint/typescript-estree": "8.57.2", "@typescript-eslint/visitor-keys": "8.57.2", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-30ScMRHIAD33JJQkgfGW1t8CURZtjc2JpTrq5n2HFhOefbAhb7ucc7xJwdWcrEtqUIYJ73Nybpsggii6GtAHjA=="], @@ -651,8 +666,14 @@ "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + "date-arithmetic": ["date-arithmetic@4.1.0", "", {}, "sha512-QWxYLR5P/6GStZcdem+V1xoto6DMadYWpMXU82ES3/RfR3Wdwr3D0+be7mgOJ+Ov0G9D5Dmb9T17sNLQYj9XOg=="], + + "date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="], + "dateformat": ["dateformat@4.6.3", "", {}, "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA=="], + "dayjs": ["dayjs@1.11.20", "", {}, "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ=="], + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], @@ -667,6 +688,8 @@ "diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="], + "dom-helpers": ["dom-helpers@5.2.1", "", { "dependencies": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" } }, "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA=="], + "dompurify": ["dompurify@3.3.3", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA=="], "drizzle-kit": ["drizzle-kit@0.30.6", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.19.7", "esbuild-register": "^3.5.0", "gel": "^2.0.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-U4wWit0fyZuGuP7iNmRleQyK2V8wCuv57vf5l3MnG4z4fzNTjY/U13M8owyQ5RavqvqxBifWORaR3wIUzlN64g=="], @@ -771,6 +794,8 @@ "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], + "globalize": ["globalize@0.1.1", "", {}, "sha512-5e01v8eLGfuQSOvx2MsDMOWS0GFtCx1wPzQSmcHw4hkxFzrQDBO3Xwg/m8Hr/7qXMrHeOIE29qWVzyv06u1TZA=="], + "globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], @@ -789,6 +814,8 @@ "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + "invariant": ["invariant@2.2.4", "", { "dependencies": { "loose-envify": "^1.0.0" } }, "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA=="], + "iobuffer": ["iobuffer@5.4.0", "", {}, "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA=="], "ioredis": ["ioredis@5.10.1", "", { "dependencies": { "@ioredis/commands": "1.5.1", "cluster-key-slot": "^1.1.0", "debug": "^4.3.4", "denque": "^2.1.0", "lodash.defaults": "^4.2.0", "lodash.isarguments": "^3.1.0", "redis-errors": "^1.2.0", "redis-parser": "^3.0.0", "standard-as-callback": "^2.1.0" } }, "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA=="], @@ -861,18 +888,28 @@ "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], + "lodash": ["lodash@4.17.23", "", {}, "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="], + + "lodash-es": ["lodash-es@4.17.23", "", {}, "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg=="], + "lodash.defaults": ["lodash.defaults@4.2.0", "", {}, "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="], "lodash.isarguments": ["lodash.isarguments@3.1.0", "", {}, "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg=="], "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], + "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], + "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], "lucide-react": ["lucide-react@1.7.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-yI7BeItCLZJTXikmK4KNUGCKoGzSvbKlfCvw44bU4fXAL6v3gYS4uHD1jzsLkfwODYwI6Drw5Tu9Z5ulDe0TSg=="], + "luxon": ["luxon@3.7.2", "", {}, "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew=="], + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + "memoize-one": ["memoize-one@6.0.0", "", {}, "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw=="], + "minimalistic-assert": ["minimalistic-assert@1.0.1", "", {}, "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="], "minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], @@ -881,6 +918,10 @@ "mnemonist": ["mnemonist@0.40.0", "", { "dependencies": { "obliterator": "^2.0.4" } }, "sha512-kdd8AFNig2AD5Rkih7EPCXhu/iMvwevQFX/uEiGhZyPZi7fHqOoF4V4kHLpCfysxXMgQ4B52kdPMCwARshKvEg=="], + "moment": ["moment@2.30.1", "", {}, "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how=="], + + "moment-timezone": ["moment-timezone@0.5.48", "", { "dependencies": { "moment": "^2.29.4" } }, "sha512-f22b8LV1gbTO2ms2j2z13MuPogNoh5UzxL3nzNAYKGraILnbGc9NEE6dyiiiLv46DGRb8A4kg8UKWLjPthxBHw=="], + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], @@ -895,6 +936,8 @@ "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + "obliterator": ["obliterator@2.0.5", "", {}, "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw=="], "on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="], @@ -941,6 +984,8 @@ "process-warning": ["process-warning@5.0.0", "", {}, "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA=="], + "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], + "pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="], "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], @@ -953,10 +998,18 @@ "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], + "react-big-calendar": ["react-big-calendar@1.19.4", "", { "dependencies": { "@babel/runtime": "^7.20.7", "clsx": "^1.2.1", "date-arithmetic": "^4.1.0", "dayjs": "^1.11.7", "dom-helpers": "^5.2.1", "globalize": "^0.1.1", "invariant": "^2.2.4", "lodash": "^4.17.21", "lodash-es": "^4.17.21", "luxon": "^3.2.1", "memoize-one": "^6.0.0", "moment": "^2.29.4", "moment-timezone": "^0.5.40", "prop-types": "^15.8.1", "react-overlays": "^5.2.1", "uncontrollable": "^7.2.1" }, "peerDependencies": { "react": "^16.14.0 || ^17 || ^18 || ^19", "react-dom": "^16.14.0 || ^17 || ^18 || ^19" } }, "sha512-FrvbDx2LF6JAWFD96LU1jjloppC5OgIvMYUYIPzAw5Aq+ArYFPxAjLqXc4DyxfsQDN0TJTMuS/BIbcSB7Pg0YA=="], + "react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="], "react-hook-form": ["react-hook-form@7.72.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-V4v6jubaf6JAurEaVnT9aUPKFbNtDgohj5CIgVGyPHvT9wRx5OZHVjz31GsxnPNI278XMu+ruFz+wGOscHaLKw=="], + "react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], + + "react-lifecycles-compat": ["react-lifecycles-compat@3.0.4", "", {}, "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA=="], + + "react-overlays": ["react-overlays@5.2.1", "", { "dependencies": { "@babel/runtime": "^7.13.8", "@popperjs/core": "^2.11.6", "@restart/hooks": "^0.4.7", "@types/warning": "^3.0.0", "dom-helpers": "^5.2.0", "prop-types": "^15.7.2", "uncontrollable": "^7.2.1", "warning": "^4.0.3" }, "peerDependencies": { "react": ">=16.3.0", "react-dom": ">=16.3.0" } }, "sha512-GLLSOLWr21CqtJn8geSwQfoJufdt3mfdsnIiQswouuQ2MMPns+ihZklxvsTDKD3cR2tF8ELbi5xUsvqVhR6WvA=="], + "react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], "react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="], @@ -1075,6 +1128,8 @@ "typescript-eslint": ["typescript-eslint@8.57.2", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.57.2", "@typescript-eslint/parser": "8.57.2", "@typescript-eslint/typescript-estree": "8.57.2", "@typescript-eslint/utils": "8.57.2" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-VEPQ0iPgWO/sBaZOU1xo4nuNdODVOajPnTIbog2GKYr31nIlZ0fWPoCQgGfF3ETyBl1vn63F/p50Um9Z4J8O8A=="], + "uncontrollable": ["uncontrollable@7.2.1", "", { "dependencies": { "@babel/runtime": "^7.6.3", "@types/react": ">=16.9.11", "invariant": "^2.2.4", "react-lifecycles-compat": "^3.0.4" }, "peerDependencies": { "react": ">=15.0.0" } }, "sha512-svtcfoTADIB0nT9nltgjujTi7BzVmwjZClOmskKu/E8FW9BXzg9os8OLr4f8Dlnk0rYWJIWr4wv9eKUXiQvQwQ=="], + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="], @@ -1093,6 +1148,8 @@ "vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="], + "warning": ["warning@4.0.3", "", { "dependencies": { "loose-envify": "^1.0.0" } }, "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w=="], + "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], @@ -1189,6 +1246,8 @@ "radix-ui/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "react-big-calendar/clsx": ["clsx@1.2.1", "", {}, "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg=="], + "readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], diff --git a/deploy/deploy.sh b/deploy/deploy.sh new file mode 100755 index 0000000..c6e01d7 --- /dev/null +++ b/deploy/deploy.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# LunarFront — Redeploy script (run after pushing changes to main) +# Usage: sudo bash deploy/deploy.sh +set -euo pipefail + +APP_DIR="/opt/lunarfront" +APP_USER="ubuntu" +BUN_BIN="/home/${APP_USER}/.bun/bin/bun" + +cd "$APP_DIR" + +echo "==> Installing dependencies..." +sudo -u "$APP_USER" "$BUN_BIN" install --frozen-lockfile + +echo "==> Building admin frontend..." +sudo -u "$APP_USER" bash -c "cd ${APP_DIR}/packages/admin && ${BUN_BIN} run build" + +echo "==> Running migrations..." +sudo -u "$APP_USER" bash -c \ + "cd ${APP_DIR}/packages/backend && ${BUN_BIN} x drizzle-kit migrate" + +echo "==> Restarting backend..." +systemctl restart lunarfront + +echo "==> Done! Checking status..." +sleep 2 +systemctl status lunarfront --no-pager diff --git a/deploy/lunarfront.service b/deploy/lunarfront.service new file mode 100644 index 0000000..850d04e --- /dev/null +++ b/deploy/lunarfront.service @@ -0,0 +1,18 @@ +[Unit] +Description=LunarFront API Server +After=network.target postgresql.service + +[Service] +Type=simple +User=ubuntu +WorkingDirectory=/opt/lunarfront/packages/backend +EnvironmentFile=/opt/lunarfront/.env +ExecStart=/home/ubuntu/.bun/bin/bun run src/main.ts +Restart=always +RestartSec=5 +StandardOutput=journal +StandardError=journal +SyslogIdentifier=lunarfront + +[Install] +WantedBy=multi-user.target diff --git a/deploy/nginx.conf b/deploy/nginx.conf new file mode 100644 index 0000000..84d2392 --- /dev/null +++ b/deploy/nginx.conf @@ -0,0 +1,47 @@ +server { + listen 80; + server_name YOUR_DOMAIN www.YOUR_DOMAIN; + # Certbot will automatically add HTTPS redirect and SSL config below this line + + root /opt/lunarfront/packages/admin/dist; + index index.html; + + # Proxy API requests to Bun backend + location /v1/ { + proxy_pass http://localhost:8000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 60s; + client_max_body_size 20M; + } + + # WebDAV passthrough (all HTTP methods) + location /webdav/ { + proxy_pass http://localhost:8000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 120s; + client_max_body_size 100M; + } + + # SPA fallback — serve index.html for all unmatched paths + location / { + try_files $uri $uri/ /index.html; + } + + # Cache hashed static assets aggressively + location /assets/ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + gzip on; + gzip_vary on; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml image/svg+xml; +} diff --git a/deploy/setup.sh b/deploy/setup.sh new file mode 100755 index 0000000..a5082c1 --- /dev/null +++ b/deploy/setup.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash +# LunarFront — One-time EC2 provisioning script +# Run as root (or with sudo) on a fresh Ubuntu 24.04 instance. +# Usage: sudo bash deploy/setup.sh +set -euo pipefail + +REPO_URL="git@github.com:YOUR_ORG/YOUR_REPO.git" +APP_DIR="/opt/lunarfront" +APP_USER="ubuntu" +DB_USER="lunarfront" +DB_NAME="lunarfront" +DB_PASS="$(openssl rand -hex 16)" # auto-generated; written to .env + +# ── 1. System packages ──────────────────────────────────────────────────────── +echo "==> Updating system packages..." +apt-get update -y && apt-get upgrade -y +apt-get install -y curl git build-essential nginx certbot python3-certbot-nginx unzip + +# ── 2. Bun runtime ──────────────────────────────────────────────────────────── +echo "==> Installing Bun..." +sudo -u "$APP_USER" bash -c 'curl -fsSL https://bun.sh/install | bash' +BUN_BIN="/home/${APP_USER}/.bun/bin/bun" + +# ── 3. PostgreSQL 16 ────────────────────────────────────────────────────────── +echo "==> Installing PostgreSQL 16..." +apt-get install -y postgresql-16 postgresql-contrib-16 +systemctl enable --now postgresql + +echo "==> Creating database user and database..." +sudo -u postgres psql -tc "SELECT 1 FROM pg_roles WHERE rolname='${DB_USER}'" | grep -q 1 || \ + sudo -u postgres psql -c "CREATE USER ${DB_USER} WITH PASSWORD '${DB_PASS}';" +sudo -u postgres psql -tc "SELECT 1 FROM pg_database WHERE datname='${DB_NAME}'" | grep -q 1 || \ + sudo -u postgres psql -c "CREATE DATABASE ${DB_NAME} OWNER ${DB_USER};" + +# ── 4. Valkey 8 ─────────────────────────────────────────────────────────────── +echo "==> Installing Valkey..." +# Try official Valkey apt repo first; fall back to Redis 7 if unavailable +if curl -fsSL https://packages.valkey.io/ubuntu/gpg.asc 2>/dev/null | \ + gpg --dearmor -o /usr/share/keyrings/valkey.gpg; then + echo "deb [signed-by=/usr/share/keyrings/valkey.gpg] https://packages.valkey.io/ubuntu noble main" \ + > /etc/apt/sources.list.d/valkey.list + apt-get update -y && apt-get install -y valkey + REDIS_SERVICE="valkey" +else + echo "Valkey repo unavailable, falling back to Redis 7..." + apt-get install -y redis-server + REDIS_SERVICE="redis-server" +fi +systemctl enable --now "$REDIS_SERVICE" + +# ── 5. Clone repository ─────────────────────────────────────────────────────── +echo "==> Cloning repository to ${APP_DIR}..." +if [ -d "$APP_DIR" ]; then + echo " ${APP_DIR} already exists, skipping clone." +else + git clone "$REPO_URL" "$APP_DIR" + chown -R "$APP_USER:$APP_USER" "$APP_DIR" +fi +cd "$APP_DIR" + +# ── 6. Environment file ─────────────────────────────────────────────────────── +if [ ! -f "${APP_DIR}/.env" ]; then + echo "==> Generating .env..." + JWT_SECRET=$(openssl rand -hex 32) + cat > "${APP_DIR}/.env" < Installing dependencies..." +sudo -u "$APP_USER" "$BUN_BIN" install --frozen-lockfile + +echo "==> Building admin frontend..." +sudo -u "$APP_USER" bash -c "cd ${APP_DIR}/packages/admin && ${BUN_BIN} run build" + +# ── 8. Run database migrations ──────────────────────────────────────────────── +echo "==> Running database migrations..." +sudo -u "$APP_USER" bash -c \ + "cd ${APP_DIR}/packages/backend && ${BUN_BIN} x drizzle-kit migrate" + +# ── 9. Create file storage directory ───────────────────────────────────────── +mkdir -p "${APP_DIR}/data/files" +chown -R "$APP_USER:$APP_USER" "${APP_DIR}/data" + +# ── 10. Systemd service ─────────────────────────────────────────────────────── +echo "==> Installing systemd service..." +# Substitute real Bun path into service file +sed "s|/home/ubuntu/.bun/bin/bun|${BUN_BIN}|g" \ + "${APP_DIR}/deploy/lunarfront.service" > /etc/systemd/system/lunarfront.service +systemctl daemon-reload +systemctl enable lunarfront +systemctl restart lunarfront + +# ── 11. Nginx ───────────────────────────────────────────────────────────────── +echo "==> Configuring Nginx..." +cp "${APP_DIR}/deploy/nginx.conf" /etc/nginx/sites-available/lunarfront +ln -sf /etc/nginx/sites-available/lunarfront /etc/nginx/sites-enabled/lunarfront +rm -f /etc/nginx/sites-enabled/default +nginx -t && systemctl reload nginx + +echo "" +echo "========================================================" +echo " Setup complete!" +echo "" +echo " Next steps:" +echo " 1. Verify .env has correct values:" +echo " nano ${APP_DIR}/.env" +echo " 2. Restart backend after editing .env:" +echo " sudo systemctl restart lunarfront" +echo " 3. Set up HTTPS (after pointing DNS to this IP):" +echo " sudo certbot --nginx -d YOUR_DOMAIN -d www.YOUR_DOMAIN" +echo " 4. Check logs:" +echo " journalctl -u lunarfront -f" +echo "========================================================" diff --git a/packages/admin/package.json b/packages/admin/package.json index 7e65f0f..7ec737f 100644 --- a/packages/admin/package.json +++ b/packages/admin/package.json @@ -10,8 +10,8 @@ "lint": "eslint ." }, "dependencies": { - "@lunarfront/shared": "workspace:*", "@hookform/resolvers": "^5.2.2", + "@lunarfront/shared": "workspace:*", "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-label": "^2.1.7", @@ -21,12 +21,15 @@ "@radix-ui/react-tabs": "^1.1.12", "@tanstack/react-query": "^5.75.5", "@tanstack/react-router": "^1.121.0", + "@types/react-big-calendar": "^1.16.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "date-fns": "^4.1.0", "jspdf": "^4.2.1", "lucide-react": "^1.7.0", "radix-ui": "^1.4.3", "react": "^19.1.0", + "react-big-calendar": "^1.19.4", "react-dom": "^19.1.0", "react-hook-form": "^7.72.0", "sonner": "^2.0.3", diff --git a/packages/admin/src/api/lessons.ts b/packages/admin/src/api/lessons.ts new file mode 100644 index 0000000..2fc99b3 --- /dev/null +++ b/packages/admin/src/api/lessons.ts @@ -0,0 +1,341 @@ +import { queryOptions } from '@tanstack/react-query' +import { api } from '@/lib/api-client' +import type { + Instructor, + InstructorBlockedDate, + LessonType, + ScheduleSlot, + Enrollment, + LessonSession, + GradingScale, + LessonPlan, + LessonPlanItem, + LessonPlanItemGradeHistory, + LessonPlanTemplate, + StoreClosure, + SessionPlanItem, +} from '@/types/lesson' +import type { PaginatedResponse, PaginationInput } from '@lunarfront/shared/schemas' + +// --- Instructors --- + +export const instructorKeys = { + all: ['instructors'] as const, + list: (params: PaginationInput) => [...instructorKeys.all, 'list', params] as const, + detail: (id: string) => [...instructorKeys.all, 'detail', id] as const, + blockedDates: (id: string) => [...instructorKeys.all, id, 'blocked-dates'] as const, +} + +export function instructorListOptions(params: PaginationInput) { + return queryOptions({ + queryKey: instructorKeys.list(params), + queryFn: () => api.get>('/v1/instructors', params), + }) +} + +export function instructorDetailOptions(id: string) { + return queryOptions({ + queryKey: instructorKeys.detail(id), + queryFn: () => api.get(`/v1/instructors/${id}`), + enabled: !!id, + }) +} + +export function instructorBlockedDatesOptions(instructorId: string) { + return queryOptions({ + queryKey: instructorKeys.blockedDates(instructorId), + queryFn: () => api.get(`/v1/instructors/${instructorId}/blocked-dates`), + enabled: !!instructorId, + }) +} + +export const instructorMutations = { + create: (data: Record) => + api.post('/v1/instructors', data), + update: (id: string, data: Record) => + api.patch(`/v1/instructors/${id}`, data), + delete: (id: string) => + api.del(`/v1/instructors/${id}`), + addBlockedDate: (instructorId: string, data: Record) => + api.post(`/v1/instructors/${instructorId}/blocked-dates`, data), + deleteBlockedDate: (instructorId: string, id: string) => + api.del(`/v1/instructors/${instructorId}/blocked-dates/${id}`), +} + +// --- Lesson Types --- + +export const lessonTypeKeys = { + all: ['lesson-types'] as const, + list: (params: PaginationInput) => [...lessonTypeKeys.all, 'list', params] as const, + detail: (id: string) => [...lessonTypeKeys.all, 'detail', id] as const, +} + +export function lessonTypeListOptions(params: PaginationInput) { + return queryOptions({ + queryKey: lessonTypeKeys.list(params), + queryFn: () => api.get>('/v1/lesson-types', params), + }) +} + +export const lessonTypeMutations = { + create: (data: Record) => + api.post('/v1/lesson-types', data), + update: (id: string, data: Record) => + api.patch(`/v1/lesson-types/${id}`, data), + delete: (id: string) => + api.del(`/v1/lesson-types/${id}`), +} + +// --- Schedule Slots --- + +export const scheduleSlotKeys = { + all: ['schedule-slots'] as const, + list: (params: PaginationInput) => [...scheduleSlotKeys.all, 'list', params] as const, + byInstructor: (instructorId: string, params: PaginationInput) => + [...scheduleSlotKeys.all, 'instructor', instructorId, params] as const, + detail: (id: string) => [...scheduleSlotKeys.all, 'detail', id] as const, +} + +export function scheduleSlotListOptions(params: PaginationInput, filters?: { instructorId?: string; dayOfWeek?: number }) { + const query = { ...params, ...filters } + return queryOptions({ + queryKey: filters?.instructorId + ? scheduleSlotKeys.byInstructor(filters.instructorId, params) + : scheduleSlotKeys.list(params), + queryFn: () => api.get>('/v1/schedule-slots', query), + }) +} + +export const scheduleSlotMutations = { + create: (data: Record) => + api.post('/v1/schedule-slots', data), + update: (id: string, data: Record) => + api.patch(`/v1/schedule-slots/${id}`, data), + delete: (id: string) => + api.del(`/v1/schedule-slots/${id}`), +} + +// --- Enrollments --- + +export const enrollmentKeys = { + all: ['enrollments'] as const, + list: (params: Record) => [...enrollmentKeys.all, 'list', params] as const, + detail: (id: string) => [...enrollmentKeys.all, 'detail', id] as const, +} + +export function enrollmentListOptions(params: Record) { + return queryOptions({ + queryKey: enrollmentKeys.list(params), + queryFn: () => api.get>('/v1/enrollments', params), + }) +} + +export function enrollmentDetailOptions(id: string) { + return queryOptions({ + queryKey: enrollmentKeys.detail(id), + queryFn: () => api.get(`/v1/enrollments/${id}`), + enabled: !!id, + }) +} + +export const enrollmentMutations = { + create: (data: Record) => + api.post('/v1/enrollments', data), + update: (id: string, data: Record) => + api.patch(`/v1/enrollments/${id}`, data), + updateStatus: (id: string, status: string) => + api.post(`/v1/enrollments/${id}/status`, { status }), + generateSessions: (id: string, weeks?: number) => + api.post<{ generated: number; sessions: LessonSession[] }>( + `/v1/enrollments/${id}/generate-sessions${weeks ? `?weeks=${weeks}` : ''}`, + {}, + ), +} + +// --- Lesson Sessions --- + +export const sessionKeys = { + all: ['lesson-sessions'] as const, + list: (params: Record) => [...sessionKeys.all, 'list', params] as const, + detail: (id: string) => [...sessionKeys.all, 'detail', id] as const, + planItems: (id: string) => [...sessionKeys.all, id, 'plan-items'] as const, +} + +export function sessionListOptions(params: Record) { + return queryOptions({ + queryKey: sessionKeys.list(params), + queryFn: () => api.get>('/v1/lesson-sessions', params), + }) +} + +export function sessionDetailOptions(id: string) { + return queryOptions({ + queryKey: sessionKeys.detail(id), + queryFn: () => api.get(`/v1/lesson-sessions/${id}`), + enabled: !!id, + }) +} + +export function sessionPlanItemsOptions(sessionId: string) { + return queryOptions({ + queryKey: sessionKeys.planItems(sessionId), + queryFn: () => api.get(`/v1/lesson-sessions/${sessionId}/plan-items`), + enabled: !!sessionId, + }) +} + +export const sessionMutations = { + update: (id: string, data: Record) => + api.patch(`/v1/lesson-sessions/${id}`, data), + updateStatus: (id: string, status: string) => + api.post(`/v1/lesson-sessions/${id}/status`, { status }), + updateNotes: (id: string, data: Record) => + api.post(`/v1/lesson-sessions/${id}/notes`, data), + linkPlanItems: (id: string, lessonPlanItemIds: string[]) => + api.post<{ linked: number; items: SessionPlanItem[] }>(`/v1/lesson-sessions/${id}/plan-items`, { lessonPlanItemIds }), +} + +// --- Grading Scales --- + +export const gradingScaleKeys = { + all: ['grading-scales'] as const, + list: (params: PaginationInput) => [...gradingScaleKeys.all, 'list', params] as const, + allScales: [...['grading-scales'], 'all'] as const, + detail: (id: string) => [...gradingScaleKeys.all, 'detail', id] as const, +} + +export function gradingScaleListOptions(params: PaginationInput) { + return queryOptions({ + queryKey: gradingScaleKeys.list(params), + queryFn: () => api.get>('/v1/grading-scales', params), + }) +} + +export function gradingScaleAllOptions() { + return queryOptions({ + queryKey: gradingScaleKeys.allScales, + queryFn: () => api.get('/v1/grading-scales/all'), + }) +} + +export function gradingScaleDetailOptions(id: string) { + return queryOptions({ + queryKey: gradingScaleKeys.detail(id), + queryFn: () => api.get(`/v1/grading-scales/${id}`), + enabled: !!id, + }) +} + +export const gradingScaleMutations = { + create: (data: Record) => + api.post('/v1/grading-scales', data), + update: (id: string, data: Record) => + api.patch(`/v1/grading-scales/${id}`, data), + delete: (id: string) => + api.del(`/v1/grading-scales/${id}`), +} + +// --- Lesson Plans --- + +export const lessonPlanKeys = { + all: ['lesson-plans'] as const, + list: (params: Record) => [...lessonPlanKeys.all, 'list', params] as const, + detail: (id: string) => [...lessonPlanKeys.all, 'detail', id] as const, +} + +export function lessonPlanListOptions(params: Record) { + return queryOptions({ + queryKey: lessonPlanKeys.list(params), + queryFn: () => api.get>('/v1/lesson-plans', params), + }) +} + +export function lessonPlanDetailOptions(id: string) { + return queryOptions({ + queryKey: lessonPlanKeys.detail(id), + queryFn: () => api.get(`/v1/lesson-plans/${id}`), + enabled: !!id, + }) +} + +export const lessonPlanMutations = { + create: (data: Record) => + api.post('/v1/lesson-plans', data), + update: (id: string, data: Record) => + api.patch(`/v1/lesson-plans/${id}`, data), +} + +// --- Lesson Plan Items --- + +export const lessonPlanItemKeys = { + gradeHistory: (itemId: string) => ['lesson-plan-items', itemId, 'grade-history'] as const, +} + +export function lessonPlanItemGradeHistoryOptions(itemId: string) { + return queryOptions({ + queryKey: lessonPlanItemKeys.gradeHistory(itemId), + queryFn: () => api.get(`/v1/lesson-plan-items/${itemId}/grade-history`), + enabled: !!itemId, + }) +} + +export const lessonPlanItemMutations = { + update: (id: string, data: Record) => + api.patch(`/v1/lesson-plan-items/${id}`, data), + addGrade: (id: string, data: Record) => + api.post<{ record: LessonPlanItemGradeHistory; item: LessonPlanItem }>(`/v1/lesson-plan-items/${id}/grades`, data), +} + +// --- Lesson Plan Templates --- + +export const lessonPlanTemplateKeys = { + all: ['lesson-plan-templates'] as const, + list: (params: PaginationInput) => [...lessonPlanTemplateKeys.all, 'list', params] as const, + detail: (id: string) => [...lessonPlanTemplateKeys.all, 'detail', id] as const, +} + +export function lessonPlanTemplateListOptions(params: PaginationInput) { + return queryOptions({ + queryKey: lessonPlanTemplateKeys.list(params), + queryFn: () => api.get>('/v1/lesson-plan-templates', params), + }) +} + +export function lessonPlanTemplateDetailOptions(id: string) { + return queryOptions({ + queryKey: lessonPlanTemplateKeys.detail(id), + queryFn: () => api.get(`/v1/lesson-plan-templates/${id}`), + enabled: !!id, + }) +} + +export const lessonPlanTemplateMutations = { + create: (data: Record) => + api.post('/v1/lesson-plan-templates', data), + update: (id: string, data: Record) => + api.patch(`/v1/lesson-plan-templates/${id}`, data), + delete: (id: string) => + api.del(`/v1/lesson-plan-templates/${id}`), + createPlan: (templateId: string, data: Record) => + api.post(`/v1/lesson-plan-templates/${templateId}/create-plan`, data), +} + +// --- Store Closures --- + +export const storeClosureKeys = { + all: ['store-closures'] as const, +} + +export function storeClosureListOptions() { + return queryOptions({ + queryKey: storeClosureKeys.all, + queryFn: () => api.get('/v1/store-closures'), + }) +} + +export const storeClosureMutations = { + create: (data: Record) => + api.post('/v1/store-closures', data), + delete: (id: string) => + api.del(`/v1/store-closures/${id}`), +} diff --git a/packages/admin/src/app.css b/packages/admin/src/app.css index dbcbca2..f211d88 100644 --- a/packages/admin/src/app.css +++ b/packages/admin/src/app.css @@ -89,6 +89,30 @@ body { transition: background-color 5000s ease-in-out 0s; } +/* Scrollbars — themed to match sidebar/app palette */ +* { + scrollbar-width: thin; + scrollbar-color: var(--border) transparent; +} + +*::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +*::-webkit-scrollbar-track { + background: transparent; +} + +*::-webkit-scrollbar-thumb { + background-color: var(--border); + border-radius: 9999px; +} + +*::-webkit-scrollbar-thumb:hover { + background-color: var(--muted-foreground); +} + /* Prevent browser autofill from overriding dark theme input colors */ input:-webkit-autofill, input:-webkit-autofill:hover, diff --git a/packages/admin/src/components/lessons/blocked-date-form.tsx b/packages/admin/src/components/lessons/blocked-date-form.tsx new file mode 100644 index 0000000..ef8ff79 --- /dev/null +++ b/packages/admin/src/components/lessons/blocked-date-form.tsx @@ -0,0 +1,45 @@ +import { useForm } from 'react-hook-form' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' + +interface Props { + onSubmit: (data: Record) => void + loading?: boolean +} + +export function BlockedDateForm({ onSubmit, loading }: Props) { + const { register, handleSubmit } = useForm({ + defaultValues: { startDate: '', endDate: '', reason: '' }, + }) + + function handleFormSubmit(data: { startDate: string; endDate: string; reason: string }) { + onSubmit({ + startDate: data.startDate, + endDate: data.endDate, + reason: data.reason || undefined, + }) + } + + return ( +
+
+
+ + +
+
+ + +
+
+
+ + +
+ +
+ ) +} diff --git a/packages/admin/src/components/lessons/grade-entry-dialog.tsx b/packages/admin/src/components/lessons/grade-entry-dialog.tsx new file mode 100644 index 0000000..76d35e6 --- /dev/null +++ b/packages/admin/src/components/lessons/grade-entry-dialog.tsx @@ -0,0 +1,127 @@ +import { useState } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { gradingScaleAllOptions, lessonPlanItemMutations, lessonPlanItemGradeHistoryOptions, lessonPlanItemKeys } from '@/api/lessons' +import { Button } from '@/components/ui/button' +import { Label } from '@/components/ui/label' +import { Textarea } from '@/components/ui/textarea' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { toast } from 'sonner' +import type { LessonPlanItem } from '@/types/lesson' + +interface Props { + item: LessonPlanItem + open: boolean + onClose: () => void +} + +export function GradeEntryDialog({ item, open, onClose }: Props) { + const queryClient = useQueryClient() + const [selectedScaleId, setSelectedScaleId] = useState(item.gradingScaleId ?? '') + const [selectedValue, setSelectedValue] = useState('') + const [gradeNotes, setGradeNotes] = useState('') + + const { data: scales } = useQuery(gradingScaleAllOptions()) + const { data: history } = useQuery(lessonPlanItemGradeHistoryOptions(item.id)) + + const selectedScale = scales?.find((s) => s.id === selectedScaleId) + + const gradeMutation = useMutation({ + mutationFn: () => + lessonPlanItemMutations.addGrade(item.id, { + gradingScaleId: selectedScaleId || undefined, + gradeValue: selectedValue, + notes: gradeNotes || undefined, + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: lessonPlanItemKeys.gradeHistory(item.id) }) + toast.success('Grade recorded') + setSelectedValue('') + setGradeNotes('') + }, + onError: (err) => toast.error(err.message), + }) + + return ( + { if (!o) onClose() }}> + + + Grade: {item.title} + + +
+
+ + +
+ +
+ + {selectedScale ? ( + + ) : ( + setSelectedValue(e.target.value)} + placeholder="Enter grade (e.g. A, Pass, 85)" + /> + )} +
+ +
+ +