Add lessons module, rate cycles, EC2 deploy scripts, and help content
- Lessons module: lesson types, instructors, schedule slots, enrollments, sessions (list + week grid view), lesson plans, grading scales, templates - Rate cycles: replace monthly_rate with billing_interval + billing_unit on enrollments; add weekly/monthly/quarterly rate presets to lesson types and schedule slots with auto-fill on enrollment form - Member detail page: tabbed layout for details, identity documents, enrollments - Sessions week view: custom 7-column grid replacing react-big-calendar - Music store seed: instructors, lesson types, slots, enrollments, sessions, grading scale, lesson plan template - Scrollbar styling: themed to match sidebar/app palette - deploy/: EC2 setup and redeploy scripts, nginx config, systemd service - Help: add Lessons category (overview, types, instructors, slots, enrollments, sessions, plans/grading); collapsible sidebar with independent scroll; remove POS/accounting references from docs
This commit is contained in:
59
bun.lock
59
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=="],
|
||||
|
||||
27
deploy/deploy.sh
Executable file
27
deploy/deploy.sh
Executable file
@@ -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
|
||||
18
deploy/lunarfront.service
Normal file
18
deploy/lunarfront.service
Normal file
@@ -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
|
||||
47
deploy/nginx.conf
Normal file
47
deploy/nginx.conf
Normal file
@@ -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;
|
||||
}
|
||||
128
deploy/setup.sh
Executable file
128
deploy/setup.sh
Executable file
@@ -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" <<EOF
|
||||
DATABASE_URL=postgresql://${DB_USER}:${DB_PASS}@localhost:5432/${DB_NAME}
|
||||
REDIS_URL=redis://localhost:6379
|
||||
JWT_SECRET=${JWT_SECRET}
|
||||
PORT=8000
|
||||
HOST=0.0.0.0
|
||||
NODE_ENV=production
|
||||
CORS_ORIGINS=http://$(curl -s http://169.254.169.254/latest/meta-data/public-ipv4)
|
||||
STORAGE_LOCAL_PATH=${APP_DIR}/data/files
|
||||
EOF
|
||||
chown "$APP_USER:$APP_USER" "${APP_DIR}/.env"
|
||||
chmod 600 "${APP_DIR}/.env"
|
||||
echo " Generated JWT secret and wrote .env"
|
||||
echo " NOTE: Update CORS_ORIGINS once you have a domain."
|
||||
else
|
||||
echo " .env already exists, skipping generation."
|
||||
fi
|
||||
|
||||
# ── 7. Install dependencies + build frontend ──────────────────────────────────
|
||||
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"
|
||||
|
||||
# ── 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 "========================================================"
|
||||
@@ -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",
|
||||
|
||||
341
packages/admin/src/api/lessons.ts
Normal file
341
packages/admin/src/api/lessons.ts
Normal file
@@ -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<PaginatedResponse<Instructor>>('/v1/instructors', params),
|
||||
})
|
||||
}
|
||||
|
||||
export function instructorDetailOptions(id: string) {
|
||||
return queryOptions({
|
||||
queryKey: instructorKeys.detail(id),
|
||||
queryFn: () => api.get<Instructor>(`/v1/instructors/${id}`),
|
||||
enabled: !!id,
|
||||
})
|
||||
}
|
||||
|
||||
export function instructorBlockedDatesOptions(instructorId: string) {
|
||||
return queryOptions({
|
||||
queryKey: instructorKeys.blockedDates(instructorId),
|
||||
queryFn: () => api.get<InstructorBlockedDate[]>(`/v1/instructors/${instructorId}/blocked-dates`),
|
||||
enabled: !!instructorId,
|
||||
})
|
||||
}
|
||||
|
||||
export const instructorMutations = {
|
||||
create: (data: Record<string, unknown>) =>
|
||||
api.post<Instructor>('/v1/instructors', data),
|
||||
update: (id: string, data: Record<string, unknown>) =>
|
||||
api.patch<Instructor>(`/v1/instructors/${id}`, data),
|
||||
delete: (id: string) =>
|
||||
api.del<Instructor>(`/v1/instructors/${id}`),
|
||||
addBlockedDate: (instructorId: string, data: Record<string, unknown>) =>
|
||||
api.post<InstructorBlockedDate>(`/v1/instructors/${instructorId}/blocked-dates`, data),
|
||||
deleteBlockedDate: (instructorId: string, id: string) =>
|
||||
api.del<InstructorBlockedDate>(`/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<PaginatedResponse<LessonType>>('/v1/lesson-types', params),
|
||||
})
|
||||
}
|
||||
|
||||
export const lessonTypeMutations = {
|
||||
create: (data: Record<string, unknown>) =>
|
||||
api.post<LessonType>('/v1/lesson-types', data),
|
||||
update: (id: string, data: Record<string, unknown>) =>
|
||||
api.patch<LessonType>(`/v1/lesson-types/${id}`, data),
|
||||
delete: (id: string) =>
|
||||
api.del<LessonType>(`/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<PaginatedResponse<ScheduleSlot>>('/v1/schedule-slots', query),
|
||||
})
|
||||
}
|
||||
|
||||
export const scheduleSlotMutations = {
|
||||
create: (data: Record<string, unknown>) =>
|
||||
api.post<ScheduleSlot>('/v1/schedule-slots', data),
|
||||
update: (id: string, data: Record<string, unknown>) =>
|
||||
api.patch<ScheduleSlot>(`/v1/schedule-slots/${id}`, data),
|
||||
delete: (id: string) =>
|
||||
api.del<ScheduleSlot>(`/v1/schedule-slots/${id}`),
|
||||
}
|
||||
|
||||
// --- Enrollments ---
|
||||
|
||||
export const enrollmentKeys = {
|
||||
all: ['enrollments'] as const,
|
||||
list: (params: Record<string, unknown>) => [...enrollmentKeys.all, 'list', params] as const,
|
||||
detail: (id: string) => [...enrollmentKeys.all, 'detail', id] as const,
|
||||
}
|
||||
|
||||
export function enrollmentListOptions(params: Record<string, unknown>) {
|
||||
return queryOptions({
|
||||
queryKey: enrollmentKeys.list(params),
|
||||
queryFn: () => api.get<PaginatedResponse<Enrollment>>('/v1/enrollments', params),
|
||||
})
|
||||
}
|
||||
|
||||
export function enrollmentDetailOptions(id: string) {
|
||||
return queryOptions({
|
||||
queryKey: enrollmentKeys.detail(id),
|
||||
queryFn: () => api.get<Enrollment>(`/v1/enrollments/${id}`),
|
||||
enabled: !!id,
|
||||
})
|
||||
}
|
||||
|
||||
export const enrollmentMutations = {
|
||||
create: (data: Record<string, unknown>) =>
|
||||
api.post<Enrollment>('/v1/enrollments', data),
|
||||
update: (id: string, data: Record<string, unknown>) =>
|
||||
api.patch<Enrollment>(`/v1/enrollments/${id}`, data),
|
||||
updateStatus: (id: string, status: string) =>
|
||||
api.post<Enrollment>(`/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<string, unknown>) => [...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<string, unknown>) {
|
||||
return queryOptions({
|
||||
queryKey: sessionKeys.list(params),
|
||||
queryFn: () => api.get<PaginatedResponse<LessonSession>>('/v1/lesson-sessions', params),
|
||||
})
|
||||
}
|
||||
|
||||
export function sessionDetailOptions(id: string) {
|
||||
return queryOptions({
|
||||
queryKey: sessionKeys.detail(id),
|
||||
queryFn: () => api.get<LessonSession>(`/v1/lesson-sessions/${id}`),
|
||||
enabled: !!id,
|
||||
})
|
||||
}
|
||||
|
||||
export function sessionPlanItemsOptions(sessionId: string) {
|
||||
return queryOptions({
|
||||
queryKey: sessionKeys.planItems(sessionId),
|
||||
queryFn: () => api.get<SessionPlanItem[]>(`/v1/lesson-sessions/${sessionId}/plan-items`),
|
||||
enabled: !!sessionId,
|
||||
})
|
||||
}
|
||||
|
||||
export const sessionMutations = {
|
||||
update: (id: string, data: Record<string, unknown>) =>
|
||||
api.patch<LessonSession>(`/v1/lesson-sessions/${id}`, data),
|
||||
updateStatus: (id: string, status: string) =>
|
||||
api.post<LessonSession>(`/v1/lesson-sessions/${id}/status`, { status }),
|
||||
updateNotes: (id: string, data: Record<string, unknown>) =>
|
||||
api.post<LessonSession>(`/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<PaginatedResponse<GradingScale>>('/v1/grading-scales', params),
|
||||
})
|
||||
}
|
||||
|
||||
export function gradingScaleAllOptions() {
|
||||
return queryOptions({
|
||||
queryKey: gradingScaleKeys.allScales,
|
||||
queryFn: () => api.get<GradingScale[]>('/v1/grading-scales/all'),
|
||||
})
|
||||
}
|
||||
|
||||
export function gradingScaleDetailOptions(id: string) {
|
||||
return queryOptions({
|
||||
queryKey: gradingScaleKeys.detail(id),
|
||||
queryFn: () => api.get<GradingScale>(`/v1/grading-scales/${id}`),
|
||||
enabled: !!id,
|
||||
})
|
||||
}
|
||||
|
||||
export const gradingScaleMutations = {
|
||||
create: (data: Record<string, unknown>) =>
|
||||
api.post<GradingScale>('/v1/grading-scales', data),
|
||||
update: (id: string, data: Record<string, unknown>) =>
|
||||
api.patch<GradingScale>(`/v1/grading-scales/${id}`, data),
|
||||
delete: (id: string) =>
|
||||
api.del<GradingScale>(`/v1/grading-scales/${id}`),
|
||||
}
|
||||
|
||||
// --- Lesson Plans ---
|
||||
|
||||
export const lessonPlanKeys = {
|
||||
all: ['lesson-plans'] as const,
|
||||
list: (params: Record<string, unknown>) => [...lessonPlanKeys.all, 'list', params] as const,
|
||||
detail: (id: string) => [...lessonPlanKeys.all, 'detail', id] as const,
|
||||
}
|
||||
|
||||
export function lessonPlanListOptions(params: Record<string, unknown>) {
|
||||
return queryOptions({
|
||||
queryKey: lessonPlanKeys.list(params),
|
||||
queryFn: () => api.get<PaginatedResponse<LessonPlan>>('/v1/lesson-plans', params),
|
||||
})
|
||||
}
|
||||
|
||||
export function lessonPlanDetailOptions(id: string) {
|
||||
return queryOptions({
|
||||
queryKey: lessonPlanKeys.detail(id),
|
||||
queryFn: () => api.get<LessonPlan>(`/v1/lesson-plans/${id}`),
|
||||
enabled: !!id,
|
||||
})
|
||||
}
|
||||
|
||||
export const lessonPlanMutations = {
|
||||
create: (data: Record<string, unknown>) =>
|
||||
api.post<LessonPlan>('/v1/lesson-plans', data),
|
||||
update: (id: string, data: Record<string, unknown>) =>
|
||||
api.patch<LessonPlan>(`/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<LessonPlanItemGradeHistory[]>(`/v1/lesson-plan-items/${itemId}/grade-history`),
|
||||
enabled: !!itemId,
|
||||
})
|
||||
}
|
||||
|
||||
export const lessonPlanItemMutations = {
|
||||
update: (id: string, data: Record<string, unknown>) =>
|
||||
api.patch<LessonPlanItem>(`/v1/lesson-plan-items/${id}`, data),
|
||||
addGrade: (id: string, data: Record<string, unknown>) =>
|
||||
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<PaginatedResponse<LessonPlanTemplate>>('/v1/lesson-plan-templates', params),
|
||||
})
|
||||
}
|
||||
|
||||
export function lessonPlanTemplateDetailOptions(id: string) {
|
||||
return queryOptions({
|
||||
queryKey: lessonPlanTemplateKeys.detail(id),
|
||||
queryFn: () => api.get<LessonPlanTemplate>(`/v1/lesson-plan-templates/${id}`),
|
||||
enabled: !!id,
|
||||
})
|
||||
}
|
||||
|
||||
export const lessonPlanTemplateMutations = {
|
||||
create: (data: Record<string, unknown>) =>
|
||||
api.post<LessonPlanTemplate>('/v1/lesson-plan-templates', data),
|
||||
update: (id: string, data: Record<string, unknown>) =>
|
||||
api.patch<LessonPlanTemplate>(`/v1/lesson-plan-templates/${id}`, data),
|
||||
delete: (id: string) =>
|
||||
api.del<LessonPlanTemplate>(`/v1/lesson-plan-templates/${id}`),
|
||||
createPlan: (templateId: string, data: Record<string, unknown>) =>
|
||||
api.post<LessonPlan>(`/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<StoreClosure[]>('/v1/store-closures'),
|
||||
})
|
||||
}
|
||||
|
||||
export const storeClosureMutations = {
|
||||
create: (data: Record<string, unknown>) =>
|
||||
api.post<StoreClosure>('/v1/store-closures', data),
|
||||
delete: (id: string) =>
|
||||
api.del<StoreClosure>(`/v1/store-closures/${id}`),
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
45
packages/admin/src/components/lessons/blocked-date-form.tsx
Normal file
45
packages/admin/src/components/lessons/blocked-date-form.tsx
Normal file
@@ -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<string, unknown>) => 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 (
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bd-start">Start Date *</Label>
|
||||
<Input id="bd-start" type="date" {...register('startDate')} required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bd-end">End Date *</Label>
|
||||
<Input id="bd-end" type="date" {...register('endDate')} required />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bd-reason">Reason</Label>
|
||||
<Input id="bd-reason" {...register('reason')} placeholder="e.g. Vacation, Conference" />
|
||||
</div>
|
||||
<Button type="submit" disabled={loading} className="w-full">
|
||||
{loading ? 'Saving...' : 'Add Blocked Date'}
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
127
packages/admin/src/components/lessons/grade-entry-dialog.tsx
Normal file
127
packages/admin/src/components/lessons/grade-entry-dialog.tsx
Normal file
@@ -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 (
|
||||
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose() }}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Grade: {item.title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Grading Scale</Label>
|
||||
<Select value={selectedScaleId || 'none'} onValueChange={(v) => { setSelectedScaleId(v === 'none' ? '' : v); setSelectedValue('') }}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="No scale (freeform)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">No scale (freeform)</SelectItem>
|
||||
{(scales ?? []).map((s) => (
|
||||
<SelectItem key={s.id} value={s.id}>{s.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Grade Value *</Label>
|
||||
{selectedScale ? (
|
||||
<Select value={selectedValue} onValueChange={setSelectedValue}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select grade..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{[...selectedScale.levels].sort((a, b) => a.sortOrder - b.sortOrder).map((level) => (
|
||||
<SelectItem key={level.id} value={level.value}>
|
||||
{level.value} — {level.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<input
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
value={selectedValue}
|
||||
onChange={(e) => setSelectedValue(e.target.value)}
|
||||
placeholder="Enter grade (e.g. A, Pass, 85)"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Notes</Label>
|
||||
<Textarea value={gradeNotes} onChange={(e) => setGradeNotes(e.target.value)} rows={2} />
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => gradeMutation.mutate()}
|
||||
disabled={!selectedValue || gradeMutation.isPending}
|
||||
className="w-full"
|
||||
>
|
||||
{gradeMutation.isPending ? 'Recording...' : 'Record Grade'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Grade History */}
|
||||
{(history ?? []).length > 0 && (
|
||||
<div className="border-t pt-4 space-y-2">
|
||||
<p className="text-sm font-medium text-muted-foreground">Grade History</p>
|
||||
<div className="space-y-2 max-h-40 overflow-y-auto">
|
||||
{[...history!].reverse().map((h) => (
|
||||
<div key={h.id} className="flex items-start justify-between text-sm">
|
||||
<div>
|
||||
<span className="font-medium">{h.gradeValue}</span>
|
||||
{h.notes && <p className="text-xs text-muted-foreground">{h.notes}</p>}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{new Date(h.createdAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
127
packages/admin/src/components/lessons/grading-scale-form.tsx
Normal file
127
packages/admin/src/components/lessons/grading-scale-form.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import { useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Trash2, Plus } from 'lucide-react'
|
||||
|
||||
interface LevelRow {
|
||||
value: string
|
||||
label: string
|
||||
numericValue: string
|
||||
colorHex: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
onSubmit: (data: Record<string, unknown>) => void
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
const DEFAULT_LEVELS: LevelRow[] = [
|
||||
{ value: 'A', label: 'Excellent', numericValue: '4', colorHex: '#22c55e' },
|
||||
{ value: 'B', label: 'Good', numericValue: '3', colorHex: '#84cc16' },
|
||||
{ value: 'C', label: 'Developing', numericValue: '2', colorHex: '#eab308' },
|
||||
{ value: 'D', label: 'Beginning', numericValue: '1', colorHex: '#f97316' },
|
||||
]
|
||||
|
||||
export function GradingScaleForm({ onSubmit, loading }: Props) {
|
||||
const { register, handleSubmit } = useForm({
|
||||
defaultValues: { name: '', description: '', isDefault: false },
|
||||
})
|
||||
const [levels, setLevels] = useState<LevelRow[]>(DEFAULT_LEVELS)
|
||||
|
||||
function addLevel() {
|
||||
setLevels((prev) => [...prev, { value: '', label: '', numericValue: String(prev.length + 1), colorHex: '' }])
|
||||
}
|
||||
|
||||
function removeLevel(idx: number) {
|
||||
setLevels((prev) => prev.filter((_, i) => i !== idx))
|
||||
}
|
||||
|
||||
function updateLevel(idx: number, field: keyof LevelRow, value: string) {
|
||||
setLevels((prev) => prev.map((l, i) => (i === idx ? { ...l, [field]: value } : l)))
|
||||
}
|
||||
|
||||
function handleFormSubmit(data: { name: string; description: string; isDefault: boolean }) {
|
||||
onSubmit({
|
||||
name: data.name,
|
||||
description: data.description || undefined,
|
||||
isDefault: data.isDefault,
|
||||
levels: levels.map((l, i) => ({
|
||||
value: l.value,
|
||||
label: l.label,
|
||||
numericValue: Number(l.numericValue) || i + 1,
|
||||
colorHex: l.colorHex || undefined,
|
||||
sortOrder: i,
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="gs-name">Name *</Label>
|
||||
<Input id="gs-name" {...register('name')} placeholder="e.g. RCM Performance Scale" required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="gs-desc">Description</Label>
|
||||
<Textarea id="gs-desc" {...register('description')} rows={2} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="checkbox" id="gs-default" {...register('isDefault')} className="h-4 w-4" />
|
||||
<Label htmlFor="gs-default">Set as default scale</Label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Grade Levels</Label>
|
||||
<Button type="button" variant="outline" size="sm" onClick={addLevel}>
|
||||
<Plus className="h-3 w-3 mr-1" />Add Level
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2 max-h-64 overflow-y-auto pr-1">
|
||||
{levels.map((level, idx) => (
|
||||
<div key={idx} className="grid grid-cols-[1fr_2fr_1fr_auto_auto] gap-2 items-center">
|
||||
<Input
|
||||
placeholder="Value"
|
||||
value={level.value}
|
||||
onChange={(e) => updateLevel(idx, 'value', e.target.value)}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
placeholder="Label"
|
||||
value={level.label}
|
||||
onChange={(e) => updateLevel(idx, 'label', e.target.value)}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="Score"
|
||||
value={level.numericValue}
|
||||
onChange={(e) => updateLevel(idx, 'numericValue', e.target.value)}
|
||||
/>
|
||||
<input
|
||||
type="color"
|
||||
value={level.colorHex || '#888888'}
|
||||
onChange={(e) => updateLevel(idx, 'colorHex', e.target.value)}
|
||||
className="h-9 w-9 rounded border border-input cursor-pointer"
|
||||
title="Color"
|
||||
/>
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => removeLevel(idx)} className="h-9 w-9">
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{levels.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground text-center py-2">No levels — add at least one.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button type="submit" disabled={loading || levels.length === 0} className="w-full">
|
||||
{loading ? 'Saving...' : 'Create Grading Scale'}
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
52
packages/admin/src/components/lessons/instructor-form.tsx
Normal file
52
packages/admin/src/components/lessons/instructor-form.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import type { Instructor } from '@/types/lesson'
|
||||
|
||||
interface Props {
|
||||
defaultValues?: Partial<Instructor>
|
||||
onSubmit: (data: Record<string, unknown>) => void
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export function InstructorForm({ defaultValues, onSubmit, loading }: Props) {
|
||||
const { register, handleSubmit } = useForm({
|
||||
defaultValues: {
|
||||
displayName: defaultValues?.displayName ?? '',
|
||||
bio: defaultValues?.bio ?? '',
|
||||
instruments: defaultValues?.instruments?.join(', ') ?? '',
|
||||
},
|
||||
})
|
||||
|
||||
function handleFormSubmit(data: { displayName: string; bio: string; instruments: string }) {
|
||||
onSubmit({
|
||||
displayName: data.displayName,
|
||||
bio: data.bio || undefined,
|
||||
instruments: data.instruments
|
||||
? data.instruments.split(',').map((s) => s.trim()).filter(Boolean)
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="displayName">Display Name *</Label>
|
||||
<Input id="displayName" {...register('displayName')} required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bio">Bio</Label>
|
||||
<Textarea id="bio" {...register('bio')} rows={3} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="instruments">Instruments</Label>
|
||||
<Input id="instruments" {...register('instruments')} placeholder="Piano, Guitar, Voice (comma-separated)" />
|
||||
</div>
|
||||
<Button type="submit" disabled={loading} className="w-full">
|
||||
{loading ? 'Saving...' : defaultValues ? 'Save Changes' : 'Create Instructor'}
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
99
packages/admin/src/components/lessons/lesson-type-form.tsx
Normal file
99
packages/admin/src/components/lessons/lesson-type-form.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import type { LessonType } from '@/types/lesson'
|
||||
|
||||
interface Props {
|
||||
defaultValues?: Partial<LessonType>
|
||||
onSubmit: (data: Record<string, unknown>) => void
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export function LessonTypeForm({ defaultValues, onSubmit, loading }: Props) {
|
||||
const { register, handleSubmit, setValue, watch } = useForm({
|
||||
defaultValues: {
|
||||
name: defaultValues?.name ?? '',
|
||||
instrument: defaultValues?.instrument ?? '',
|
||||
durationMinutes: defaultValues?.durationMinutes ?? 30,
|
||||
lessonFormat: (defaultValues?.lessonFormat ?? 'private') as 'private' | 'group',
|
||||
rateWeekly: defaultValues?.rateWeekly ?? '',
|
||||
rateMonthly: defaultValues?.rateMonthly ?? '',
|
||||
rateQuarterly: defaultValues?.rateQuarterly ?? '',
|
||||
},
|
||||
})
|
||||
|
||||
const lessonFormat = watch('lessonFormat')
|
||||
|
||||
function handleFormSubmit(data: {
|
||||
name: string
|
||||
instrument: string
|
||||
durationMinutes: number
|
||||
lessonFormat: string
|
||||
rateWeekly: string
|
||||
rateMonthly: string
|
||||
rateQuarterly: string
|
||||
}) {
|
||||
onSubmit({
|
||||
name: data.name,
|
||||
instrument: data.instrument || undefined,
|
||||
durationMinutes: Number(data.durationMinutes),
|
||||
lessonFormat: data.lessonFormat,
|
||||
rateWeekly: data.rateWeekly || undefined,
|
||||
rateMonthly: data.rateMonthly || undefined,
|
||||
rateQuarterly: data.rateQuarterly || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="lt-name">Name *</Label>
|
||||
<Input id="lt-name" {...register('name')} placeholder="e.g. Piano — 30 min Private" required />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="lt-instrument">Instrument</Label>
|
||||
<Input id="lt-instrument" {...register('instrument')} placeholder="e.g. Piano, Guitar" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="lt-duration">Duration (minutes) *</Label>
|
||||
<Input id="lt-duration" type="number" min={5} step={5} {...register('durationMinutes')} required />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Format *</Label>
|
||||
<Select value={lessonFormat} onValueChange={(v) => setValue('lessonFormat', v as 'private' | 'group')}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="private">Private</SelectItem>
|
||||
<SelectItem value="group">Group</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="block mb-2">Default Rates (optional)</Label>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="lt-rate-weekly" className="text-xs text-muted-foreground">Weekly</Label>
|
||||
<Input id="lt-rate-weekly" type="number" step="0.01" min="0" {...register('rateWeekly')} placeholder="—" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="lt-rate-monthly" className="text-xs text-muted-foreground">Monthly</Label>
|
||||
<Input id="lt-rate-monthly" type="number" step="0.01" min="0" {...register('rateMonthly')} placeholder="—" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="lt-rate-quarterly" className="text-xs text-muted-foreground">Quarterly</Label>
|
||||
<Input id="lt-rate-quarterly" type="number" step="0.01" min="0" {...register('rateQuarterly')} placeholder="—" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="submit" disabled={loading} className="w-full">
|
||||
{loading ? 'Saving...' : defaultValues?.id ? 'Save Changes' : 'Create Lesson Type'}
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
124
packages/admin/src/components/lessons/schedule-slot-form.tsx
Normal file
124
packages/admin/src/components/lessons/schedule-slot-form.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import type { LessonType, ScheduleSlot } from '@/types/lesson'
|
||||
|
||||
const DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
|
||||
|
||||
interface Props {
|
||||
lessonTypes: LessonType[]
|
||||
defaultValues?: Partial<ScheduleSlot>
|
||||
onSubmit: (data: Record<string, unknown>) => void
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export function ScheduleSlotForm({ lessonTypes, defaultValues, onSubmit, loading }: Props) {
|
||||
const { register, handleSubmit, setValue, watch } = useForm({
|
||||
defaultValues: {
|
||||
dayOfWeek: String(defaultValues?.dayOfWeek ?? 1),
|
||||
startTime: defaultValues?.startTime ?? '',
|
||||
lessonTypeId: defaultValues?.lessonTypeId ?? '',
|
||||
room: defaultValues?.room ?? '',
|
||||
maxStudents: String(defaultValues?.maxStudents ?? 1),
|
||||
rateWeekly: defaultValues?.rateWeekly ?? '',
|
||||
rateMonthly: defaultValues?.rateMonthly ?? '',
|
||||
rateQuarterly: defaultValues?.rateQuarterly ?? '',
|
||||
},
|
||||
})
|
||||
|
||||
const dayOfWeek = watch('dayOfWeek')
|
||||
const lessonTypeId = watch('lessonTypeId')
|
||||
|
||||
function handleFormSubmit(data: {
|
||||
dayOfWeek: string
|
||||
startTime: string
|
||||
lessonTypeId: string
|
||||
room: string
|
||||
maxStudents: string
|
||||
rateWeekly: string
|
||||
rateMonthly: string
|
||||
rateQuarterly: string
|
||||
}) {
|
||||
onSubmit({
|
||||
dayOfWeek: Number(data.dayOfWeek),
|
||||
startTime: data.startTime,
|
||||
lessonTypeId: data.lessonTypeId,
|
||||
room: data.room || undefined,
|
||||
maxStudents: Number(data.maxStudents) || 1,
|
||||
rateWeekly: data.rateWeekly || undefined,
|
||||
rateMonthly: data.rateMonthly || undefined,
|
||||
rateQuarterly: data.rateQuarterly || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Day *</Label>
|
||||
<Select value={dayOfWeek} onValueChange={(v) => setValue('dayOfWeek', v)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DAYS.map((day, i) => (
|
||||
<SelectItem key={i} value={String(i)}>{day}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="slot-time">Start Time *</Label>
|
||||
<Input id="slot-time" type="time" {...register('startTime')} required />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Lesson Type *</Label>
|
||||
<Select value={lessonTypeId} onValueChange={(v) => setValue('lessonTypeId', v)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select lesson type..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{lessonTypes.map((lt) => (
|
||||
<SelectItem key={lt.id} value={lt.id}>
|
||||
{lt.name} ({lt.durationMinutes} min)
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="slot-room">Room</Label>
|
||||
<Input id="slot-room" {...register('room')} placeholder="e.g. Studio A" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="slot-max">Max Students</Label>
|
||||
<Input id="slot-max" type="number" min={1} {...register('maxStudents')} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="block mb-2">Instructor Rates (override lesson type defaults)</Label>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="slot-rate-weekly" className="text-xs text-muted-foreground">Weekly</Label>
|
||||
<Input id="slot-rate-weekly" type="number" step="0.01" min="0" {...register('rateWeekly')} placeholder="—" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="slot-rate-monthly" className="text-xs text-muted-foreground">Monthly</Label>
|
||||
<Input id="slot-rate-monthly" type="number" step="0.01" min="0" {...register('rateMonthly')} placeholder="—" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="slot-rate-quarterly" className="text-xs text-muted-foreground">Quarterly</Label>
|
||||
<Input id="slot-rate-quarterly" type="number" step="0.01" min="0" {...register('rateQuarterly')} placeholder="—" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="submit" disabled={loading || !lessonTypeId} className="w-full">
|
||||
{loading ? 'Saving...' : defaultValues?.id ? 'Save Changes' : 'Add Slot'}
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
41
packages/admin/src/components/lessons/store-closure-form.tsx
Normal file
41
packages/admin/src/components/lessons/store-closure-form.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
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<string, unknown>) => void
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export function StoreClosureForm({ onSubmit, loading }: Props) {
|
||||
const { register, handleSubmit } = useForm({
|
||||
defaultValues: { name: '', startDate: '', endDate: '' },
|
||||
})
|
||||
|
||||
function handleFormSubmit(data: { name: string; startDate: string; endDate: string }) {
|
||||
onSubmit({ name: data.name, startDate: data.startDate, endDate: data.endDate })
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="closure-name">Name *</Label>
|
||||
<Input id="closure-name" {...register('name')} placeholder="e.g. Thanksgiving Break" required />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="closure-start">Start Date *</Label>
|
||||
<Input id="closure-start" type="date" {...register('startDate')} required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="closure-end">End Date *</Label>
|
||||
<Input id="closure-end" type="date" {...register('endDate')} required />
|
||||
</div>
|
||||
</div>
|
||||
<Button type="submit" disabled={loading} className="w-full">
|
||||
{loading ? 'Saving...' : 'Add Closure'}
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Trash2, Plus, ChevronUp, ChevronDown } from 'lucide-react'
|
||||
|
||||
interface TemplateItemRow {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
|
||||
interface TemplateSectionRow {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
items: TemplateItemRow[]
|
||||
}
|
||||
|
||||
interface Props {
|
||||
sections: TemplateSectionRow[]
|
||||
onChange: (sections: TemplateSectionRow[]) => void
|
||||
}
|
||||
|
||||
function uid() {
|
||||
return Math.random().toString(36).slice(2)
|
||||
}
|
||||
|
||||
export function TemplateSectionBuilder({ sections, onChange }: Props) {
|
||||
function addSection() {
|
||||
onChange([...sections, { id: uid(), title: '', description: '', items: [] }])
|
||||
}
|
||||
|
||||
function removeSection(idx: number) {
|
||||
onChange(sections.filter((_, i) => i !== idx))
|
||||
}
|
||||
|
||||
function moveSection(idx: number, dir: -1 | 1) {
|
||||
const next = [...sections]
|
||||
const [removed] = next.splice(idx, 1)
|
||||
next.splice(idx + dir, 0, removed)
|
||||
onChange(next)
|
||||
}
|
||||
|
||||
function updateSection(idx: number, field: 'title' | 'description', value: string) {
|
||||
onChange(sections.map((s, i) => (i === idx ? { ...s, [field]: value } : s)))
|
||||
}
|
||||
|
||||
function addItem(sIdx: number) {
|
||||
onChange(sections.map((s, i) =>
|
||||
i === sIdx ? { ...s, items: [...s.items, { id: uid(), title: '', description: '' }] } : s,
|
||||
))
|
||||
}
|
||||
|
||||
function removeItem(sIdx: number, iIdx: number) {
|
||||
onChange(sections.map((s, i) =>
|
||||
i === sIdx ? { ...s, items: s.items.filter((_, j) => j !== iIdx) } : s,
|
||||
))
|
||||
}
|
||||
|
||||
function moveItem(sIdx: number, iIdx: number, dir: -1 | 1) {
|
||||
onChange(sections.map((s, i) => {
|
||||
if (i !== sIdx) return s
|
||||
const next = [...s.items]
|
||||
const [removed] = next.splice(iIdx, 1)
|
||||
next.splice(iIdx + dir, 0, removed)
|
||||
return { ...s, items: next }
|
||||
}))
|
||||
}
|
||||
|
||||
function updateItem(sIdx: number, iIdx: number, field: 'title' | 'description', value: string) {
|
||||
onChange(sections.map((s, i) =>
|
||||
i === sIdx
|
||||
? { ...s, items: s.items.map((item, j) => (j === iIdx ? { ...item, [field]: value } : item)) }
|
||||
: s,
|
||||
))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{sections.map((section, sIdx) => (
|
||||
<div key={section.id} className="border rounded-lg overflow-hidden">
|
||||
<div className="bg-muted/40 px-3 py-2 flex items-center gap-2">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<Button type="button" variant="ghost" size="icon" className="h-4 w-4" disabled={sIdx === 0} onClick={() => moveSection(sIdx, -1)}>
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="icon" className="h-4 w-4" disabled={sIdx === sections.length - 1} onClick={() => moveSection(sIdx, 1)}>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<Input
|
||||
className="h-7 text-sm font-medium flex-1"
|
||||
placeholder="Section title *"
|
||||
value={section.title}
|
||||
onChange={(e) => updateSection(sIdx, 'title', e.target.value)}
|
||||
required
|
||||
/>
|
||||
<Button type="button" variant="ghost" size="icon" className="h-7 w-7" onClick={() => removeSection(sIdx)}>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="p-3 space-y-2">
|
||||
<Textarea
|
||||
className="text-xs resize-none"
|
||||
placeholder="Section description (optional)"
|
||||
rows={1}
|
||||
value={section.description}
|
||||
onChange={(e) => updateSection(sIdx, 'description', e.target.value)}
|
||||
/>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
{section.items.map((item, iIdx) => (
|
||||
<div key={item.id} className="flex items-center gap-2">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<Button type="button" variant="ghost" size="icon" className="h-4 w-4" disabled={iIdx === 0} onClick={() => moveItem(sIdx, iIdx, -1)}>
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="icon" className="h-4 w-4" disabled={iIdx === section.items.length - 1} onClick={() => moveItem(sIdx, iIdx, 1)}>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<Input
|
||||
className="h-7 text-xs flex-1"
|
||||
placeholder="Item title *"
|
||||
value={item.title}
|
||||
onChange={(e) => updateItem(sIdx, iIdx, 'title', e.target.value)}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
className="h-7 text-xs flex-1"
|
||||
placeholder="Description (optional)"
|
||||
value={item.description}
|
||||
onChange={(e) => updateItem(sIdx, iIdx, 'description', e.target.value)}
|
||||
/>
|
||||
<Button type="button" variant="ghost" size="icon" className="h-7 w-7 shrink-0" onClick={() => removeItem(sIdx, iIdx)}>
|
||||
<Trash2 className="h-3 w-3 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button type="button" variant="outline" size="sm" className="text-xs h-7" onClick={() => addItem(sIdx)}>
|
||||
<Plus className="h-3 w-3 mr-1" />Add Item
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Button type="button" variant="outline" size="sm" onClick={addSection}>
|
||||
<Plus className="h-4 w-4 mr-1" />Add Section
|
||||
</Button>
|
||||
|
||||
{sections.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground text-center py-2">No sections yet — add one above.</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export type { TemplateSectionRow, TemplateItemRow }
|
||||
87
packages/admin/src/components/lessons/weekly-slot-grid.tsx
Normal file
87
packages/admin/src/components/lessons/weekly-slot-grid.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Pencil, Trash2 } from 'lucide-react'
|
||||
import type { ScheduleSlot, LessonType } from '@/types/lesson'
|
||||
|
||||
const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
|
||||
|
||||
interface Props {
|
||||
slots: ScheduleSlot[]
|
||||
lessonTypes: LessonType[]
|
||||
onEdit: (slot: ScheduleSlot) => void
|
||||
onDelete: (slot: ScheduleSlot) => void
|
||||
}
|
||||
|
||||
function formatTime(t: string) {
|
||||
const [h, m] = t.split(':').map(Number)
|
||||
const ampm = h >= 12 ? 'PM' : 'AM'
|
||||
const hour = h % 12 || 12
|
||||
return `${hour}:${String(m).padStart(2, '0')} ${ampm}`
|
||||
}
|
||||
|
||||
export function WeeklySlotGrid({ slots, lessonTypes, onEdit, onDelete }: Props) {
|
||||
const ltMap = new Map(lessonTypes.map((lt) => [lt.id, lt]))
|
||||
|
||||
const slotsByDay = DAYS.map((_, day) =>
|
||||
slots.filter((s) => s.dayOfWeek === day).sort((a, b) => a.startTime.localeCompare(b.startTime)),
|
||||
)
|
||||
|
||||
const hasAny = slots.length > 0
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-7 gap-2">
|
||||
{DAYS.map((day, idx) => (
|
||||
<div key={day} className="min-h-[120px]">
|
||||
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide text-center mb-2 py-1 border-b">
|
||||
{day}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{slotsByDay[idx].map((slot) => {
|
||||
const lt = ltMap.get(slot.lessonTypeId)
|
||||
return (
|
||||
<div
|
||||
key={slot.id}
|
||||
className="bg-sidebar-accent rounded-md p-2 text-xs group relative"
|
||||
>
|
||||
<div className="font-medium">{formatTime(slot.startTime)}</div>
|
||||
<div className="text-muted-foreground truncate">{lt?.name ?? 'Unknown'}</div>
|
||||
{slot.room && <div className="text-muted-foreground">{slot.room}</div>}
|
||||
{lt && (
|
||||
<Badge variant="outline" className="mt-1 text-[10px] py-0">
|
||||
{lt.lessonFormat}
|
||||
</Badge>
|
||||
)}
|
||||
<div className="absolute top-1 right-1 hidden group-hover:flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5"
|
||||
onClick={() => onEdit(slot)}
|
||||
title="Edit"
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5"
|
||||
onClick={() => onDelete(slot)}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="h-3 w-3 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{!hasAny && (
|
||||
<div className="col-span-7 text-center text-sm text-muted-foreground py-8">
|
||||
No schedule slots yet — add one to get started.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -33,11 +33,24 @@ import { Route as AuthenticatedRepairBatchesBatchIdRouteImport } from './routes/
|
||||
import { Route as AuthenticatedMembersMemberIdRouteImport } from './routes/_authenticated/members/$memberId'
|
||||
import { Route as AuthenticatedAccountsNewRouteImport } from './routes/_authenticated/accounts/new'
|
||||
import { Route as AuthenticatedAccountsAccountIdRouteImport } from './routes/_authenticated/accounts/$accountId'
|
||||
import { Route as AuthenticatedLessonsTemplatesIndexRouteImport } from './routes/_authenticated/lessons/templates/index'
|
||||
import { Route as AuthenticatedLessonsSessionsIndexRouteImport } from './routes/_authenticated/lessons/sessions/index'
|
||||
import { Route as AuthenticatedLessonsScheduleIndexRouteImport } from './routes/_authenticated/lessons/schedule/index'
|
||||
import { Route as AuthenticatedLessonsPlansIndexRouteImport } from './routes/_authenticated/lessons/plans/index'
|
||||
import { Route as AuthenticatedLessonsEnrollmentsIndexRouteImport } from './routes/_authenticated/lessons/enrollments/index'
|
||||
import { Route as AuthenticatedAccountsAccountIdIndexRouteImport } from './routes/_authenticated/accounts/$accountId/index'
|
||||
import { Route as AuthenticatedLessonsTemplatesNewRouteImport } from './routes/_authenticated/lessons/templates/new'
|
||||
import { Route as AuthenticatedLessonsTemplatesTemplateIdRouteImport } from './routes/_authenticated/lessons/templates/$templateId'
|
||||
import { Route as AuthenticatedLessonsSessionsSessionIdRouteImport } from './routes/_authenticated/lessons/sessions/$sessionId'
|
||||
import { Route as AuthenticatedLessonsPlansPlanIdRouteImport } from './routes/_authenticated/lessons/plans/$planId'
|
||||
import { Route as AuthenticatedLessonsEnrollmentsNewRouteImport } from './routes/_authenticated/lessons/enrollments/new'
|
||||
import { Route as AuthenticatedLessonsEnrollmentsEnrollmentIdRouteImport } from './routes/_authenticated/lessons/enrollments/$enrollmentId'
|
||||
import { Route as AuthenticatedAccountsAccountIdTaxExemptionsRouteImport } from './routes/_authenticated/accounts/$accountId/tax-exemptions'
|
||||
import { Route as AuthenticatedAccountsAccountIdProcessorLinksRouteImport } from './routes/_authenticated/accounts/$accountId/processor-links'
|
||||
import { Route as AuthenticatedAccountsAccountIdPaymentMethodsRouteImport } from './routes/_authenticated/accounts/$accountId/payment-methods'
|
||||
import { Route as AuthenticatedAccountsAccountIdMembersRouteImport } from './routes/_authenticated/accounts/$accountId/members'
|
||||
import { Route as AuthenticatedAccountsAccountIdEnrollmentsRouteImport } from './routes/_authenticated/accounts/$accountId/enrollments'
|
||||
import { Route as AuthenticatedLessonsScheduleInstructorsInstructorIdRouteImport } from './routes/_authenticated/lessons/schedule/instructors/$instructorId'
|
||||
|
||||
const LoginRoute = LoginRouteImport.update({
|
||||
id: '/login',
|
||||
@@ -170,12 +183,78 @@ const AuthenticatedAccountsAccountIdRoute =
|
||||
path: '/accounts/$accountId',
|
||||
getParentRoute: () => AuthenticatedRoute,
|
||||
} as any)
|
||||
const AuthenticatedLessonsTemplatesIndexRoute =
|
||||
AuthenticatedLessonsTemplatesIndexRouteImport.update({
|
||||
id: '/lessons/templates/',
|
||||
path: '/lessons/templates/',
|
||||
getParentRoute: () => AuthenticatedRoute,
|
||||
} as any)
|
||||
const AuthenticatedLessonsSessionsIndexRoute =
|
||||
AuthenticatedLessonsSessionsIndexRouteImport.update({
|
||||
id: '/lessons/sessions/',
|
||||
path: '/lessons/sessions/',
|
||||
getParentRoute: () => AuthenticatedRoute,
|
||||
} as any)
|
||||
const AuthenticatedLessonsScheduleIndexRoute =
|
||||
AuthenticatedLessonsScheduleIndexRouteImport.update({
|
||||
id: '/lessons/schedule/',
|
||||
path: '/lessons/schedule/',
|
||||
getParentRoute: () => AuthenticatedRoute,
|
||||
} as any)
|
||||
const AuthenticatedLessonsPlansIndexRoute =
|
||||
AuthenticatedLessonsPlansIndexRouteImport.update({
|
||||
id: '/lessons/plans/',
|
||||
path: '/lessons/plans/',
|
||||
getParentRoute: () => AuthenticatedRoute,
|
||||
} as any)
|
||||
const AuthenticatedLessonsEnrollmentsIndexRoute =
|
||||
AuthenticatedLessonsEnrollmentsIndexRouteImport.update({
|
||||
id: '/lessons/enrollments/',
|
||||
path: '/lessons/enrollments/',
|
||||
getParentRoute: () => AuthenticatedRoute,
|
||||
} as any)
|
||||
const AuthenticatedAccountsAccountIdIndexRoute =
|
||||
AuthenticatedAccountsAccountIdIndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => AuthenticatedAccountsAccountIdRoute,
|
||||
} as any)
|
||||
const AuthenticatedLessonsTemplatesNewRoute =
|
||||
AuthenticatedLessonsTemplatesNewRouteImport.update({
|
||||
id: '/lessons/templates/new',
|
||||
path: '/lessons/templates/new',
|
||||
getParentRoute: () => AuthenticatedRoute,
|
||||
} as any)
|
||||
const AuthenticatedLessonsTemplatesTemplateIdRoute =
|
||||
AuthenticatedLessonsTemplatesTemplateIdRouteImport.update({
|
||||
id: '/lessons/templates/$templateId',
|
||||
path: '/lessons/templates/$templateId',
|
||||
getParentRoute: () => AuthenticatedRoute,
|
||||
} as any)
|
||||
const AuthenticatedLessonsSessionsSessionIdRoute =
|
||||
AuthenticatedLessonsSessionsSessionIdRouteImport.update({
|
||||
id: '/lessons/sessions/$sessionId',
|
||||
path: '/lessons/sessions/$sessionId',
|
||||
getParentRoute: () => AuthenticatedRoute,
|
||||
} as any)
|
||||
const AuthenticatedLessonsPlansPlanIdRoute =
|
||||
AuthenticatedLessonsPlansPlanIdRouteImport.update({
|
||||
id: '/lessons/plans/$planId',
|
||||
path: '/lessons/plans/$planId',
|
||||
getParentRoute: () => AuthenticatedRoute,
|
||||
} as any)
|
||||
const AuthenticatedLessonsEnrollmentsNewRoute =
|
||||
AuthenticatedLessonsEnrollmentsNewRouteImport.update({
|
||||
id: '/lessons/enrollments/new',
|
||||
path: '/lessons/enrollments/new',
|
||||
getParentRoute: () => AuthenticatedRoute,
|
||||
} as any)
|
||||
const AuthenticatedLessonsEnrollmentsEnrollmentIdRoute =
|
||||
AuthenticatedLessonsEnrollmentsEnrollmentIdRouteImport.update({
|
||||
id: '/lessons/enrollments/$enrollmentId',
|
||||
path: '/lessons/enrollments/$enrollmentId',
|
||||
getParentRoute: () => AuthenticatedRoute,
|
||||
} as any)
|
||||
const AuthenticatedAccountsAccountIdTaxExemptionsRoute =
|
||||
AuthenticatedAccountsAccountIdTaxExemptionsRouteImport.update({
|
||||
id: '/tax-exemptions',
|
||||
@@ -200,6 +279,18 @@ const AuthenticatedAccountsAccountIdMembersRoute =
|
||||
path: '/members',
|
||||
getParentRoute: () => AuthenticatedAccountsAccountIdRoute,
|
||||
} as any)
|
||||
const AuthenticatedAccountsAccountIdEnrollmentsRoute =
|
||||
AuthenticatedAccountsAccountIdEnrollmentsRouteImport.update({
|
||||
id: '/enrollments',
|
||||
path: '/enrollments',
|
||||
getParentRoute: () => AuthenticatedAccountsAccountIdRoute,
|
||||
} as any)
|
||||
const AuthenticatedLessonsScheduleInstructorsInstructorIdRoute =
|
||||
AuthenticatedLessonsScheduleInstructorsInstructorIdRouteImport.update({
|
||||
id: '/lessons/schedule/instructors/$instructorId',
|
||||
path: '/lessons/schedule/instructors/$instructorId',
|
||||
getParentRoute: () => AuthenticatedRoute,
|
||||
} as any)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof AuthenticatedIndexRoute
|
||||
@@ -225,11 +316,24 @@ export interface FileRoutesByFullPath {
|
||||
'/repairs/': typeof AuthenticatedRepairsIndexRoute
|
||||
'/roles/': typeof AuthenticatedRolesIndexRoute
|
||||
'/vault/': typeof AuthenticatedVaultIndexRoute
|
||||
'/accounts/$accountId/enrollments': typeof AuthenticatedAccountsAccountIdEnrollmentsRoute
|
||||
'/accounts/$accountId/members': typeof AuthenticatedAccountsAccountIdMembersRoute
|
||||
'/accounts/$accountId/payment-methods': typeof AuthenticatedAccountsAccountIdPaymentMethodsRoute
|
||||
'/accounts/$accountId/processor-links': typeof AuthenticatedAccountsAccountIdProcessorLinksRoute
|
||||
'/accounts/$accountId/tax-exemptions': typeof AuthenticatedAccountsAccountIdTaxExemptionsRoute
|
||||
'/lessons/enrollments/$enrollmentId': typeof AuthenticatedLessonsEnrollmentsEnrollmentIdRoute
|
||||
'/lessons/enrollments/new': typeof AuthenticatedLessonsEnrollmentsNewRoute
|
||||
'/lessons/plans/$planId': typeof AuthenticatedLessonsPlansPlanIdRoute
|
||||
'/lessons/sessions/$sessionId': typeof AuthenticatedLessonsSessionsSessionIdRoute
|
||||
'/lessons/templates/$templateId': typeof AuthenticatedLessonsTemplatesTemplateIdRoute
|
||||
'/lessons/templates/new': typeof AuthenticatedLessonsTemplatesNewRoute
|
||||
'/accounts/$accountId/': typeof AuthenticatedAccountsAccountIdIndexRoute
|
||||
'/lessons/enrollments/': typeof AuthenticatedLessonsEnrollmentsIndexRoute
|
||||
'/lessons/plans/': typeof AuthenticatedLessonsPlansIndexRoute
|
||||
'/lessons/schedule/': typeof AuthenticatedLessonsScheduleIndexRoute
|
||||
'/lessons/sessions/': typeof AuthenticatedLessonsSessionsIndexRoute
|
||||
'/lessons/templates/': typeof AuthenticatedLessonsTemplatesIndexRoute
|
||||
'/lessons/schedule/instructors/$instructorId': typeof AuthenticatedLessonsScheduleInstructorsInstructorIdRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/login': typeof LoginRoute
|
||||
@@ -254,11 +358,24 @@ export interface FileRoutesByTo {
|
||||
'/repairs': typeof AuthenticatedRepairsIndexRoute
|
||||
'/roles': typeof AuthenticatedRolesIndexRoute
|
||||
'/vault': typeof AuthenticatedVaultIndexRoute
|
||||
'/accounts/$accountId/enrollments': typeof AuthenticatedAccountsAccountIdEnrollmentsRoute
|
||||
'/accounts/$accountId/members': typeof AuthenticatedAccountsAccountIdMembersRoute
|
||||
'/accounts/$accountId/payment-methods': typeof AuthenticatedAccountsAccountIdPaymentMethodsRoute
|
||||
'/accounts/$accountId/processor-links': typeof AuthenticatedAccountsAccountIdProcessorLinksRoute
|
||||
'/accounts/$accountId/tax-exemptions': typeof AuthenticatedAccountsAccountIdTaxExemptionsRoute
|
||||
'/lessons/enrollments/$enrollmentId': typeof AuthenticatedLessonsEnrollmentsEnrollmentIdRoute
|
||||
'/lessons/enrollments/new': typeof AuthenticatedLessonsEnrollmentsNewRoute
|
||||
'/lessons/plans/$planId': typeof AuthenticatedLessonsPlansPlanIdRoute
|
||||
'/lessons/sessions/$sessionId': typeof AuthenticatedLessonsSessionsSessionIdRoute
|
||||
'/lessons/templates/$templateId': typeof AuthenticatedLessonsTemplatesTemplateIdRoute
|
||||
'/lessons/templates/new': typeof AuthenticatedLessonsTemplatesNewRoute
|
||||
'/accounts/$accountId': typeof AuthenticatedAccountsAccountIdIndexRoute
|
||||
'/lessons/enrollments': typeof AuthenticatedLessonsEnrollmentsIndexRoute
|
||||
'/lessons/plans': typeof AuthenticatedLessonsPlansIndexRoute
|
||||
'/lessons/schedule': typeof AuthenticatedLessonsScheduleIndexRoute
|
||||
'/lessons/sessions': typeof AuthenticatedLessonsSessionsIndexRoute
|
||||
'/lessons/templates': typeof AuthenticatedLessonsTemplatesIndexRoute
|
||||
'/lessons/schedule/instructors/$instructorId': typeof AuthenticatedLessonsScheduleInstructorsInstructorIdRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
@@ -286,11 +403,24 @@ export interface FileRoutesById {
|
||||
'/_authenticated/repairs/': typeof AuthenticatedRepairsIndexRoute
|
||||
'/_authenticated/roles/': typeof AuthenticatedRolesIndexRoute
|
||||
'/_authenticated/vault/': typeof AuthenticatedVaultIndexRoute
|
||||
'/_authenticated/accounts/$accountId/enrollments': typeof AuthenticatedAccountsAccountIdEnrollmentsRoute
|
||||
'/_authenticated/accounts/$accountId/members': typeof AuthenticatedAccountsAccountIdMembersRoute
|
||||
'/_authenticated/accounts/$accountId/payment-methods': typeof AuthenticatedAccountsAccountIdPaymentMethodsRoute
|
||||
'/_authenticated/accounts/$accountId/processor-links': typeof AuthenticatedAccountsAccountIdProcessorLinksRoute
|
||||
'/_authenticated/accounts/$accountId/tax-exemptions': typeof AuthenticatedAccountsAccountIdTaxExemptionsRoute
|
||||
'/_authenticated/lessons/enrollments/$enrollmentId': typeof AuthenticatedLessonsEnrollmentsEnrollmentIdRoute
|
||||
'/_authenticated/lessons/enrollments/new': typeof AuthenticatedLessonsEnrollmentsNewRoute
|
||||
'/_authenticated/lessons/plans/$planId': typeof AuthenticatedLessonsPlansPlanIdRoute
|
||||
'/_authenticated/lessons/sessions/$sessionId': typeof AuthenticatedLessonsSessionsSessionIdRoute
|
||||
'/_authenticated/lessons/templates/$templateId': typeof AuthenticatedLessonsTemplatesTemplateIdRoute
|
||||
'/_authenticated/lessons/templates/new': typeof AuthenticatedLessonsTemplatesNewRoute
|
||||
'/_authenticated/accounts/$accountId/': typeof AuthenticatedAccountsAccountIdIndexRoute
|
||||
'/_authenticated/lessons/enrollments/': typeof AuthenticatedLessonsEnrollmentsIndexRoute
|
||||
'/_authenticated/lessons/plans/': typeof AuthenticatedLessonsPlansIndexRoute
|
||||
'/_authenticated/lessons/schedule/': typeof AuthenticatedLessonsScheduleIndexRoute
|
||||
'/_authenticated/lessons/sessions/': typeof AuthenticatedLessonsSessionsIndexRoute
|
||||
'/_authenticated/lessons/templates/': typeof AuthenticatedLessonsTemplatesIndexRoute
|
||||
'/_authenticated/lessons/schedule/instructors/$instructorId': typeof AuthenticatedLessonsScheduleInstructorsInstructorIdRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
@@ -318,11 +448,24 @@ export interface FileRouteTypes {
|
||||
| '/repairs/'
|
||||
| '/roles/'
|
||||
| '/vault/'
|
||||
| '/accounts/$accountId/enrollments'
|
||||
| '/accounts/$accountId/members'
|
||||
| '/accounts/$accountId/payment-methods'
|
||||
| '/accounts/$accountId/processor-links'
|
||||
| '/accounts/$accountId/tax-exemptions'
|
||||
| '/lessons/enrollments/$enrollmentId'
|
||||
| '/lessons/enrollments/new'
|
||||
| '/lessons/plans/$planId'
|
||||
| '/lessons/sessions/$sessionId'
|
||||
| '/lessons/templates/$templateId'
|
||||
| '/lessons/templates/new'
|
||||
| '/accounts/$accountId/'
|
||||
| '/lessons/enrollments/'
|
||||
| '/lessons/plans/'
|
||||
| '/lessons/schedule/'
|
||||
| '/lessons/sessions/'
|
||||
| '/lessons/templates/'
|
||||
| '/lessons/schedule/instructors/$instructorId'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
| '/login'
|
||||
@@ -347,11 +490,24 @@ export interface FileRouteTypes {
|
||||
| '/repairs'
|
||||
| '/roles'
|
||||
| '/vault'
|
||||
| '/accounts/$accountId/enrollments'
|
||||
| '/accounts/$accountId/members'
|
||||
| '/accounts/$accountId/payment-methods'
|
||||
| '/accounts/$accountId/processor-links'
|
||||
| '/accounts/$accountId/tax-exemptions'
|
||||
| '/lessons/enrollments/$enrollmentId'
|
||||
| '/lessons/enrollments/new'
|
||||
| '/lessons/plans/$planId'
|
||||
| '/lessons/sessions/$sessionId'
|
||||
| '/lessons/templates/$templateId'
|
||||
| '/lessons/templates/new'
|
||||
| '/accounts/$accountId'
|
||||
| '/lessons/enrollments'
|
||||
| '/lessons/plans'
|
||||
| '/lessons/schedule'
|
||||
| '/lessons/sessions'
|
||||
| '/lessons/templates'
|
||||
| '/lessons/schedule/instructors/$instructorId'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/_authenticated'
|
||||
@@ -378,11 +534,24 @@ export interface FileRouteTypes {
|
||||
| '/_authenticated/repairs/'
|
||||
| '/_authenticated/roles/'
|
||||
| '/_authenticated/vault/'
|
||||
| '/_authenticated/accounts/$accountId/enrollments'
|
||||
| '/_authenticated/accounts/$accountId/members'
|
||||
| '/_authenticated/accounts/$accountId/payment-methods'
|
||||
| '/_authenticated/accounts/$accountId/processor-links'
|
||||
| '/_authenticated/accounts/$accountId/tax-exemptions'
|
||||
| '/_authenticated/lessons/enrollments/$enrollmentId'
|
||||
| '/_authenticated/lessons/enrollments/new'
|
||||
| '/_authenticated/lessons/plans/$planId'
|
||||
| '/_authenticated/lessons/sessions/$sessionId'
|
||||
| '/_authenticated/lessons/templates/$templateId'
|
||||
| '/_authenticated/lessons/templates/new'
|
||||
| '/_authenticated/accounts/$accountId/'
|
||||
| '/_authenticated/lessons/enrollments/'
|
||||
| '/_authenticated/lessons/plans/'
|
||||
| '/_authenticated/lessons/schedule/'
|
||||
| '/_authenticated/lessons/sessions/'
|
||||
| '/_authenticated/lessons/templates/'
|
||||
| '/_authenticated/lessons/schedule/instructors/$instructorId'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
@@ -560,6 +729,41 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AuthenticatedAccountsAccountIdRouteImport
|
||||
parentRoute: typeof AuthenticatedRoute
|
||||
}
|
||||
'/_authenticated/lessons/templates/': {
|
||||
id: '/_authenticated/lessons/templates/'
|
||||
path: '/lessons/templates'
|
||||
fullPath: '/lessons/templates/'
|
||||
preLoaderRoute: typeof AuthenticatedLessonsTemplatesIndexRouteImport
|
||||
parentRoute: typeof AuthenticatedRoute
|
||||
}
|
||||
'/_authenticated/lessons/sessions/': {
|
||||
id: '/_authenticated/lessons/sessions/'
|
||||
path: '/lessons/sessions'
|
||||
fullPath: '/lessons/sessions/'
|
||||
preLoaderRoute: typeof AuthenticatedLessonsSessionsIndexRouteImport
|
||||
parentRoute: typeof AuthenticatedRoute
|
||||
}
|
||||
'/_authenticated/lessons/schedule/': {
|
||||
id: '/_authenticated/lessons/schedule/'
|
||||
path: '/lessons/schedule'
|
||||
fullPath: '/lessons/schedule/'
|
||||
preLoaderRoute: typeof AuthenticatedLessonsScheduleIndexRouteImport
|
||||
parentRoute: typeof AuthenticatedRoute
|
||||
}
|
||||
'/_authenticated/lessons/plans/': {
|
||||
id: '/_authenticated/lessons/plans/'
|
||||
path: '/lessons/plans'
|
||||
fullPath: '/lessons/plans/'
|
||||
preLoaderRoute: typeof AuthenticatedLessonsPlansIndexRouteImport
|
||||
parentRoute: typeof AuthenticatedRoute
|
||||
}
|
||||
'/_authenticated/lessons/enrollments/': {
|
||||
id: '/_authenticated/lessons/enrollments/'
|
||||
path: '/lessons/enrollments'
|
||||
fullPath: '/lessons/enrollments/'
|
||||
preLoaderRoute: typeof AuthenticatedLessonsEnrollmentsIndexRouteImport
|
||||
parentRoute: typeof AuthenticatedRoute
|
||||
}
|
||||
'/_authenticated/accounts/$accountId/': {
|
||||
id: '/_authenticated/accounts/$accountId/'
|
||||
path: '/'
|
||||
@@ -567,6 +771,48 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AuthenticatedAccountsAccountIdIndexRouteImport
|
||||
parentRoute: typeof AuthenticatedAccountsAccountIdRoute
|
||||
}
|
||||
'/_authenticated/lessons/templates/new': {
|
||||
id: '/_authenticated/lessons/templates/new'
|
||||
path: '/lessons/templates/new'
|
||||
fullPath: '/lessons/templates/new'
|
||||
preLoaderRoute: typeof AuthenticatedLessonsTemplatesNewRouteImport
|
||||
parentRoute: typeof AuthenticatedRoute
|
||||
}
|
||||
'/_authenticated/lessons/templates/$templateId': {
|
||||
id: '/_authenticated/lessons/templates/$templateId'
|
||||
path: '/lessons/templates/$templateId'
|
||||
fullPath: '/lessons/templates/$templateId'
|
||||
preLoaderRoute: typeof AuthenticatedLessonsTemplatesTemplateIdRouteImport
|
||||
parentRoute: typeof AuthenticatedRoute
|
||||
}
|
||||
'/_authenticated/lessons/sessions/$sessionId': {
|
||||
id: '/_authenticated/lessons/sessions/$sessionId'
|
||||
path: '/lessons/sessions/$sessionId'
|
||||
fullPath: '/lessons/sessions/$sessionId'
|
||||
preLoaderRoute: typeof AuthenticatedLessonsSessionsSessionIdRouteImport
|
||||
parentRoute: typeof AuthenticatedRoute
|
||||
}
|
||||
'/_authenticated/lessons/plans/$planId': {
|
||||
id: '/_authenticated/lessons/plans/$planId'
|
||||
path: '/lessons/plans/$planId'
|
||||
fullPath: '/lessons/plans/$planId'
|
||||
preLoaderRoute: typeof AuthenticatedLessonsPlansPlanIdRouteImport
|
||||
parentRoute: typeof AuthenticatedRoute
|
||||
}
|
||||
'/_authenticated/lessons/enrollments/new': {
|
||||
id: '/_authenticated/lessons/enrollments/new'
|
||||
path: '/lessons/enrollments/new'
|
||||
fullPath: '/lessons/enrollments/new'
|
||||
preLoaderRoute: typeof AuthenticatedLessonsEnrollmentsNewRouteImport
|
||||
parentRoute: typeof AuthenticatedRoute
|
||||
}
|
||||
'/_authenticated/lessons/enrollments/$enrollmentId': {
|
||||
id: '/_authenticated/lessons/enrollments/$enrollmentId'
|
||||
path: '/lessons/enrollments/$enrollmentId'
|
||||
fullPath: '/lessons/enrollments/$enrollmentId'
|
||||
preLoaderRoute: typeof AuthenticatedLessonsEnrollmentsEnrollmentIdRouteImport
|
||||
parentRoute: typeof AuthenticatedRoute
|
||||
}
|
||||
'/_authenticated/accounts/$accountId/tax-exemptions': {
|
||||
id: '/_authenticated/accounts/$accountId/tax-exemptions'
|
||||
path: '/tax-exemptions'
|
||||
@@ -595,10 +841,25 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AuthenticatedAccountsAccountIdMembersRouteImport
|
||||
parentRoute: typeof AuthenticatedAccountsAccountIdRoute
|
||||
}
|
||||
'/_authenticated/accounts/$accountId/enrollments': {
|
||||
id: '/_authenticated/accounts/$accountId/enrollments'
|
||||
path: '/enrollments'
|
||||
fullPath: '/accounts/$accountId/enrollments'
|
||||
preLoaderRoute: typeof AuthenticatedAccountsAccountIdEnrollmentsRouteImport
|
||||
parentRoute: typeof AuthenticatedAccountsAccountIdRoute
|
||||
}
|
||||
'/_authenticated/lessons/schedule/instructors/$instructorId': {
|
||||
id: '/_authenticated/lessons/schedule/instructors/$instructorId'
|
||||
path: '/lessons/schedule/instructors/$instructorId'
|
||||
fullPath: '/lessons/schedule/instructors/$instructorId'
|
||||
preLoaderRoute: typeof AuthenticatedLessonsScheduleInstructorsInstructorIdRouteImport
|
||||
parentRoute: typeof AuthenticatedRoute
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface AuthenticatedAccountsAccountIdRouteChildren {
|
||||
AuthenticatedAccountsAccountIdEnrollmentsRoute: typeof AuthenticatedAccountsAccountIdEnrollmentsRoute
|
||||
AuthenticatedAccountsAccountIdMembersRoute: typeof AuthenticatedAccountsAccountIdMembersRoute
|
||||
AuthenticatedAccountsAccountIdPaymentMethodsRoute: typeof AuthenticatedAccountsAccountIdPaymentMethodsRoute
|
||||
AuthenticatedAccountsAccountIdProcessorLinksRoute: typeof AuthenticatedAccountsAccountIdProcessorLinksRoute
|
||||
@@ -608,6 +869,8 @@ interface AuthenticatedAccountsAccountIdRouteChildren {
|
||||
|
||||
const AuthenticatedAccountsAccountIdRouteChildren: AuthenticatedAccountsAccountIdRouteChildren =
|
||||
{
|
||||
AuthenticatedAccountsAccountIdEnrollmentsRoute:
|
||||
AuthenticatedAccountsAccountIdEnrollmentsRoute,
|
||||
AuthenticatedAccountsAccountIdMembersRoute:
|
||||
AuthenticatedAccountsAccountIdMembersRoute,
|
||||
AuthenticatedAccountsAccountIdPaymentMethodsRoute:
|
||||
@@ -648,6 +911,18 @@ interface AuthenticatedRouteChildren {
|
||||
AuthenticatedRepairsIndexRoute: typeof AuthenticatedRepairsIndexRoute
|
||||
AuthenticatedRolesIndexRoute: typeof AuthenticatedRolesIndexRoute
|
||||
AuthenticatedVaultIndexRoute: typeof AuthenticatedVaultIndexRoute
|
||||
AuthenticatedLessonsEnrollmentsEnrollmentIdRoute: typeof AuthenticatedLessonsEnrollmentsEnrollmentIdRoute
|
||||
AuthenticatedLessonsEnrollmentsNewRoute: typeof AuthenticatedLessonsEnrollmentsNewRoute
|
||||
AuthenticatedLessonsPlansPlanIdRoute: typeof AuthenticatedLessonsPlansPlanIdRoute
|
||||
AuthenticatedLessonsSessionsSessionIdRoute: typeof AuthenticatedLessonsSessionsSessionIdRoute
|
||||
AuthenticatedLessonsTemplatesTemplateIdRoute: typeof AuthenticatedLessonsTemplatesTemplateIdRoute
|
||||
AuthenticatedLessonsTemplatesNewRoute: typeof AuthenticatedLessonsTemplatesNewRoute
|
||||
AuthenticatedLessonsEnrollmentsIndexRoute: typeof AuthenticatedLessonsEnrollmentsIndexRoute
|
||||
AuthenticatedLessonsPlansIndexRoute: typeof AuthenticatedLessonsPlansIndexRoute
|
||||
AuthenticatedLessonsScheduleIndexRoute: typeof AuthenticatedLessonsScheduleIndexRoute
|
||||
AuthenticatedLessonsSessionsIndexRoute: typeof AuthenticatedLessonsSessionsIndexRoute
|
||||
AuthenticatedLessonsTemplatesIndexRoute: typeof AuthenticatedLessonsTemplatesIndexRoute
|
||||
AuthenticatedLessonsScheduleInstructorsInstructorIdRoute: typeof AuthenticatedLessonsScheduleInstructorsInstructorIdRoute
|
||||
}
|
||||
|
||||
const AuthenticatedRouteChildren: AuthenticatedRouteChildren = {
|
||||
@@ -675,6 +950,27 @@ const AuthenticatedRouteChildren: AuthenticatedRouteChildren = {
|
||||
AuthenticatedRepairsIndexRoute: AuthenticatedRepairsIndexRoute,
|
||||
AuthenticatedRolesIndexRoute: AuthenticatedRolesIndexRoute,
|
||||
AuthenticatedVaultIndexRoute: AuthenticatedVaultIndexRoute,
|
||||
AuthenticatedLessonsEnrollmentsEnrollmentIdRoute:
|
||||
AuthenticatedLessonsEnrollmentsEnrollmentIdRoute,
|
||||
AuthenticatedLessonsEnrollmentsNewRoute:
|
||||
AuthenticatedLessonsEnrollmentsNewRoute,
|
||||
AuthenticatedLessonsPlansPlanIdRoute: AuthenticatedLessonsPlansPlanIdRoute,
|
||||
AuthenticatedLessonsSessionsSessionIdRoute:
|
||||
AuthenticatedLessonsSessionsSessionIdRoute,
|
||||
AuthenticatedLessonsTemplatesTemplateIdRoute:
|
||||
AuthenticatedLessonsTemplatesTemplateIdRoute,
|
||||
AuthenticatedLessonsTemplatesNewRoute: AuthenticatedLessonsTemplatesNewRoute,
|
||||
AuthenticatedLessonsEnrollmentsIndexRoute:
|
||||
AuthenticatedLessonsEnrollmentsIndexRoute,
|
||||
AuthenticatedLessonsPlansIndexRoute: AuthenticatedLessonsPlansIndexRoute,
|
||||
AuthenticatedLessonsScheduleIndexRoute:
|
||||
AuthenticatedLessonsScheduleIndexRoute,
|
||||
AuthenticatedLessonsSessionsIndexRoute:
|
||||
AuthenticatedLessonsSessionsIndexRoute,
|
||||
AuthenticatedLessonsTemplatesIndexRoute:
|
||||
AuthenticatedLessonsTemplatesIndexRoute,
|
||||
AuthenticatedLessonsScheduleInstructorsInstructorIdRoute:
|
||||
AuthenticatedLessonsScheduleInstructorsInstructorIdRoute,
|
||||
}
|
||||
|
||||
const AuthenticatedRouteWithChildren = AuthenticatedRoute._addFileChildren(
|
||||
|
||||
@@ -8,7 +8,7 @@ import { myPermissionsOptions } from '@/api/rbac'
|
||||
import { moduleListOptions } from '@/api/modules'
|
||||
import { Avatar } from '@/components/shared/avatar-upload'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Users, UserRound, HelpCircle, Shield, UserCog, LogOut, User, Wrench, Package, ClipboardList, FolderOpen, KeyRound, Settings, PanelLeftClose, PanelLeft } from 'lucide-react'
|
||||
import { Users, UserRound, HelpCircle, Shield, UserCog, LogOut, User, Wrench, Package, ClipboardList, FolderOpen, KeyRound, Settings, PanelLeftClose, PanelLeft, CalendarDays, GraduationCap, CalendarRange, BookOpen, BookMarked } from 'lucide-react'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated')({
|
||||
beforeLoad: () => {
|
||||
@@ -142,6 +142,7 @@ function AuthenticatedLayout() {
|
||||
|
||||
const canViewAccounts = !permissionsLoaded || hasPermission('accounts.view')
|
||||
const canViewRepairs = !permissionsLoaded || hasPermission('repairs.view')
|
||||
const canViewLessons = !permissionsLoaded || hasPermission('lessons.view')
|
||||
const canViewUsers = !permissionsLoaded || hasPermission('users.view')
|
||||
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
@@ -186,6 +187,17 @@ function AuthenticatedLayout() {
|
||||
)}
|
||||
</NavGroup>
|
||||
)}
|
||||
{isModuleEnabled('lessons') && canViewLessons && (
|
||||
<NavGroup label="Lessons" collapsed={collapsed}>
|
||||
<NavLink to="/lessons/schedule" icon={<CalendarDays className="h-4 w-4" />} label="Schedule" collapsed={collapsed} />
|
||||
<NavLink to="/lessons/enrollments" icon={<GraduationCap className="h-4 w-4" />} label="Enrollments" collapsed={collapsed} />
|
||||
<NavLink to="/lessons/sessions" icon={<CalendarRange className="h-4 w-4" />} label="Sessions" collapsed={collapsed} />
|
||||
<NavLink to="/lessons/plans" icon={<BookOpen className="h-4 w-4" />} label="Lesson Plans" collapsed={collapsed} />
|
||||
{hasPermission('lessons.admin') && (
|
||||
<NavLink to="/lessons/templates" icon={<BookMarked className="h-4 w-4" />} label="Templates" collapsed={collapsed} />
|
||||
)}
|
||||
</NavGroup>
|
||||
)}
|
||||
{(isModuleEnabled('files') || isModuleEnabled('vault')) && (
|
||||
<NavGroup label="Storage" collapsed={collapsed}>
|
||||
{isModuleEnabled('files') && (
|
||||
|
||||
@@ -12,6 +12,7 @@ export const Route = createFileRoute('/_authenticated/accounts/$accountId')({
|
||||
const tabs = [
|
||||
{ label: 'Overview', to: '/accounts/$accountId' },
|
||||
{ label: 'Members', to: '/accounts/$accountId/members' },
|
||||
{ label: 'Enrollments', to: '/accounts/$accountId/enrollments' },
|
||||
{ label: 'Payment Methods', to: '/accounts/$accountId/payment-methods' },
|
||||
{ label: 'Tax Exemptions', to: '/accounts/$accountId/tax-exemptions' },
|
||||
{ label: 'Processor Links', to: '/accounts/$accountId/processor-links' },
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { enrollmentListOptions } from '@/api/lessons'
|
||||
import { memberListOptions } from '@/api/members'
|
||||
import { DataTable, type Column } from '@/components/shared/data-table'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import type { Enrollment } from '@/types/lesson'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/accounts/$accountId/enrollments')({
|
||||
component: AccountEnrollmentsTab,
|
||||
})
|
||||
|
||||
function statusBadge(status: string) {
|
||||
const variants: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||
active: 'default', paused: 'secondary', cancelled: 'destructive', completed: 'outline',
|
||||
}
|
||||
return <Badge variant={variants[status] ?? 'outline'}>{status}</Badge>
|
||||
}
|
||||
|
||||
const columns: Column<Enrollment & { memberName?: string }>[] = [
|
||||
{ key: 'member_name', header: 'Member', sortable: true, render: (e) => <span className="font-medium">{(e as any).memberName ?? e.memberId}</span> },
|
||||
{ key: 'status', header: 'Status', sortable: true, render: (e) => statusBadge(e.status) },
|
||||
{ key: 'start_date', header: 'Start', sortable: true, render: (e) => <>{new Date(e.startDate + 'T00:00:00').toLocaleDateString()}</> },
|
||||
{ key: 'rate', header: 'Rate', render: (e) => <>{e.rate ? `$${e.rate}${e.billingInterval ? ` / ${e.billingInterval} ${e.billingUnit}` : ''}` : <span className="text-muted-foreground">—</span>}</> },
|
||||
]
|
||||
|
||||
function AccountEnrollmentsTab() {
|
||||
const { accountId } = Route.useParams()
|
||||
const navigate = useNavigate()
|
||||
const hasPermission = useAuthStore((s) => s.hasPermission)
|
||||
|
||||
// Get member IDs for this account so we can filter enrollments
|
||||
const { data: membersData } = useQuery(memberListOptions(accountId, { page: 1, limit: 100, order: 'asc' }))
|
||||
const memberIds = (membersData?.data ?? []).map((m) => m.id)
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
...enrollmentListOptions({ accountId, page: 1, limit: 100 }),
|
||||
enabled: !!accountId,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">{data?.pagination.total ?? 0} enrollment(s)</p>
|
||||
{hasPermission('lessons.edit') && (
|
||||
<Button size="sm" onClick={() => navigate({ to: '/lessons/enrollments/new', search: {} as any })}>
|
||||
<Plus className="h-4 w-4 mr-1" />Enroll a Member
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={data?.data ?? []}
|
||||
loading={isLoading}
|
||||
page={1}
|
||||
totalPages={1}
|
||||
total={data?.data?.length ?? 0}
|
||||
onPageChange={() => {}}
|
||||
onSort={() => {}}
|
||||
onRowClick={(e) => navigate({ to: '/lessons/enrollments/$enrollmentId', params: { enrollmentId: e.id }, search: {} as any })}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -281,6 +281,7 @@ function MembersTab() {
|
||||
<DropdownMenuItem onClick={() => navigate({
|
||||
to: '/members/$memberId',
|
||||
params: { memberId: m.id },
|
||||
search: {} as any,
|
||||
})}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useState } from 'react'
|
||||
import { getWikiCategories, getWikiPage, type WikiPage } from '@/wiki'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Search } from 'lucide-react'
|
||||
import { Search, ChevronDown, ChevronRight } from 'lucide-react'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/help')({
|
||||
validateSearch: (search: Record<string, unknown>) => ({
|
||||
@@ -64,6 +64,7 @@ function HelpPage() {
|
||||
const navigate = Route.useNavigate()
|
||||
const currentPage = getWikiPage(search.page)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({})
|
||||
|
||||
const allPages = categories.flatMap((c) => c.pages)
|
||||
const filteredPages = searchQuery
|
||||
@@ -79,10 +80,14 @@ function HelpPage() {
|
||||
setSearchQuery('')
|
||||
}
|
||||
|
||||
function toggleCategory(name: string) {
|
||||
setCollapsed((prev) => ({ ...prev, [name]: !prev[name] }))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-6 max-w-5xl">
|
||||
<div className="flex gap-6 max-w-5xl h-[calc(100vh-8rem)]">
|
||||
{/* Sidebar */}
|
||||
<div className="w-56 shrink-0 space-y-4">
|
||||
<div className="w-56 shrink-0 flex flex-col gap-3">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
@@ -93,9 +98,9 @@ function HelpPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{filteredPages ? (
|
||||
<div className="space-y-1">
|
||||
{filteredPages.length === 0 ? (
|
||||
<div className="overflow-y-auto flex-1 space-y-1 pr-1">
|
||||
{filteredPages ? (
|
||||
filteredPages.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground px-2">No results</p>
|
||||
) : (
|
||||
filteredPages.map((p) => (
|
||||
@@ -107,36 +112,47 @@ function HelpPage() {
|
||||
{p.title}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
categories.map((cat) => (
|
||||
<div key={cat.name}>
|
||||
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide px-2 mb-1">
|
||||
{cat.name}
|
||||
</h3>
|
||||
<div className="space-y-0.5">
|
||||
{cat.pages.map((p) => (
|
||||
)
|
||||
) : (
|
||||
categories.map((cat) => {
|
||||
const isCollapsed = collapsed[cat.name] ?? false
|
||||
return (
|
||||
<div key={cat.name}>
|
||||
<button
|
||||
key={p.slug}
|
||||
onClick={() => goToPage(p.slug)}
|
||||
className={`block w-full text-left px-2 py-1.5 text-sm rounded-md transition-colors ${
|
||||
search.page === p.slug
|
||||
? 'bg-accent text-accent-foreground font-medium'
|
||||
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
|
||||
}`}
|
||||
onClick={() => toggleCategory(cat.name)}
|
||||
className="flex items-center justify-between w-full px-2 py-1 text-xs font-semibold text-muted-foreground uppercase tracking-wide hover:text-foreground transition-colors"
|
||||
>
|
||||
{p.title}
|
||||
{cat.name}
|
||||
{isCollapsed
|
||||
? <ChevronRight className="h-3 w-3" />
|
||||
: <ChevronDown className="h-3 w-3" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
{!isCollapsed && (
|
||||
<div className="space-y-0.5 mt-0.5">
|
||||
{cat.pages.map((p) => (
|
||||
<button
|
||||
key={p.slug}
|
||||
onClick={() => goToPage(p.slug)}
|
||||
className={`block w-full text-left px-2 py-1.5 text-sm rounded-md transition-colors ${
|
||||
search.page === p.slug
|
||||
? 'bg-accent text-accent-foreground font-medium'
|
||||
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
|
||||
}`}
|
||||
>
|
||||
{p.title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex-1 min-w-0 overflow-y-auto">
|
||||
{currentPage ? (
|
||||
<div className="prose-sm">{renderMarkdown(currentPage.content)}</div>
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,450 @@
|
||||
import { useState } from 'react'
|
||||
import { createFileRoute, useNavigate, Link } from '@tanstack/react-router'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
enrollmentDetailOptions, enrollmentMutations, enrollmentKeys,
|
||||
sessionListOptions,
|
||||
lessonPlanListOptions, lessonPlanMutations, lessonPlanKeys,
|
||||
lessonPlanTemplateListOptions, lessonPlanTemplateMutations,
|
||||
instructorDetailOptions,
|
||||
scheduleSlotListOptions,
|
||||
lessonTypeListOptions,
|
||||
} from '@/api/lessons'
|
||||
import { DataTable, type Column } from '@/components/shared/data-table'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
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 { ArrowLeft, RefreshCw } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import type { LessonSession, LessonPlan, LessonPlanTemplate } from '@/types/lesson'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/lessons/enrollments/$enrollmentId')({
|
||||
validateSearch: (search: Record<string, unknown>) => ({
|
||||
tab: (search.tab as string) || 'details',
|
||||
}),
|
||||
component: EnrollmentDetailPage,
|
||||
})
|
||||
|
||||
const DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
|
||||
|
||||
function formatTime(t: string) {
|
||||
const [h, m] = t.split(':').map(Number)
|
||||
const ampm = h >= 12 ? 'PM' : 'AM'
|
||||
const hour = h % 12 || 12
|
||||
return `${hour}:${String(m).padStart(2, '0')} ${ampm}`
|
||||
}
|
||||
|
||||
function statusBadge(status: string) {
|
||||
const variants: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||
active: 'default', paused: 'secondary', cancelled: 'destructive', completed: 'outline',
|
||||
}
|
||||
return <Badge variant={variants[status] ?? 'outline'}>{status}</Badge>
|
||||
}
|
||||
|
||||
function sessionStatusBadge(status: string) {
|
||||
const variants: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||
scheduled: 'outline', attended: 'default', missed: 'destructive', makeup: 'secondary', cancelled: 'secondary',
|
||||
}
|
||||
return <Badge variant={variants[status] ?? 'outline'}>{status}</Badge>
|
||||
}
|
||||
|
||||
const sessionColumns: Column<LessonSession>[] = [
|
||||
{ key: 'scheduled_date', header: 'Date', sortable: true, render: (s) => <>{new Date(s.scheduledDate + 'T00:00:00').toLocaleDateString()}</> },
|
||||
{ key: 'scheduled_time', header: 'Time', render: (s) => <>{formatTime(s.scheduledTime)}</> },
|
||||
{ key: 'status', header: 'Status', sortable: true, render: (s) => sessionStatusBadge(s.status) },
|
||||
{
|
||||
key: 'substitute', header: 'Sub', render: (s) => s.substituteInstructorId
|
||||
? <Badge variant="outline" className="text-xs">Sub</Badge>
|
||||
: null,
|
||||
},
|
||||
{ key: 'notes', header: 'Notes', render: (s) => s.notesCompletedAt ? <Badge variant="outline" className="text-xs">Notes</Badge> : null },
|
||||
]
|
||||
|
||||
const TABS = [
|
||||
{ key: 'details', label: 'Details' },
|
||||
{ key: 'sessions', label: 'Sessions' },
|
||||
{ key: 'plan', label: 'Lesson Plan' },
|
||||
]
|
||||
|
||||
function EnrollmentDetailPage() {
|
||||
const { enrollmentId } = Route.useParams()
|
||||
const search = Route.useSearch()
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const hasPermission = useAuthStore((s) => s.hasPermission)
|
||||
const canEdit = hasPermission('lessons.edit')
|
||||
const tab = search.tab
|
||||
|
||||
function setTab(t: string) {
|
||||
navigate({ to: '/lessons/enrollments/$enrollmentId', params: { enrollmentId }, search: { tab: t } as any })
|
||||
}
|
||||
|
||||
const { data: enrollment, isLoading } = useQuery(enrollmentDetailOptions(enrollmentId))
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: Record<string, unknown>) => enrollmentMutations.update(enrollmentId, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: enrollmentKeys.detail(enrollmentId) })
|
||||
toast.success('Enrollment updated')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const statusMutation = useMutation({
|
||||
mutationFn: (status: string) => enrollmentMutations.updateStatus(enrollmentId, status),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: enrollmentKeys.detail(enrollmentId) })
|
||||
toast.success('Status updated')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const generateMutation = useMutation({
|
||||
mutationFn: () => enrollmentMutations.generateSessions(enrollmentId, 4),
|
||||
onSuccess: (res) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['lesson-sessions'] })
|
||||
toast.success(`Generated ${res.generated} sessions`)
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const { data: instructorData } = useQuery({
|
||||
...instructorDetailOptions(enrollment?.instructorId ?? ''),
|
||||
enabled: !!enrollment?.instructorId,
|
||||
})
|
||||
|
||||
const { data: slotsData } = useQuery(scheduleSlotListOptions({ page: 1, limit: 100, order: 'asc' }))
|
||||
const { data: lessonTypesData } = useQuery(lessonTypeListOptions({ page: 1, limit: 100, order: 'asc' }))
|
||||
|
||||
if (isLoading) return <div className="text-sm text-muted-foreground">Loading...</div>
|
||||
if (!enrollment) return <div className="text-sm text-destructive">Enrollment not found.</div>
|
||||
|
||||
const slot = slotsData?.data?.find((s) => s.id === enrollment.scheduleSlotId)
|
||||
const lessonType = lessonTypesData?.data?.find((lt) => lt.id === slot?.lessonTypeId)
|
||||
const slotLabel = slot ? `${DAYS[slot.dayOfWeek]} ${formatTime(slot.startTime)}${slot.room ? ` — ${slot.room}` : ''}` : '—'
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/lessons/enrollments', search: {} as any })}>
|
||||
<ArrowLeft className="h-4 w-4 mr-1" />Back
|
||||
</Button>
|
||||
<div className="flex-1">
|
||||
<h1 className="text-2xl font-bold">Enrollment</h1>
|
||||
<p className="text-sm text-muted-foreground">{instructorData?.displayName ?? enrollment.instructorId} · {slotLabel}</p>
|
||||
</div>
|
||||
{statusBadge(enrollment.status)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1 border-b">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => setTab(t.key)}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
tab === t.key ? 'border-primary text-primary' : 'border-transparent text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === 'details' && (
|
||||
<DetailsTab
|
||||
enrollment={enrollment}
|
||||
slotLabel={slotLabel}
|
||||
lessonTypeName={lessonType?.name}
|
||||
instructorName={instructorData?.displayName}
|
||||
canEdit={canEdit}
|
||||
onSave={updateMutation.mutate}
|
||||
saving={updateMutation.isPending}
|
||||
onStatusChange={statusMutation.mutate}
|
||||
statusChanging={statusMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
{tab === 'sessions' && (
|
||||
<SessionsTab
|
||||
enrollmentId={enrollmentId}
|
||||
onGenerate={generateMutation.mutate}
|
||||
generating={generateMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
{tab === 'plan' && <LessonPlanTab enrollmentId={enrollmentId} memberId={enrollment.memberId} canEdit={canEdit} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Details Tab ──────────────────────────────────────────────────────────────
|
||||
|
||||
const BILLING_UNITS = [
|
||||
{ value: 'day', label: 'Day(s)' },
|
||||
{ value: 'week', label: 'Week(s)' },
|
||||
{ value: 'month', label: 'Month(s)' },
|
||||
{ value: 'quarter', label: 'Quarter(s)' },
|
||||
{ value: 'year', label: 'Year(s)' },
|
||||
]
|
||||
|
||||
function DetailsTab({
|
||||
enrollment, slotLabel, lessonTypeName, instructorName,
|
||||
canEdit, onSave, saving, onStatusChange, statusChanging,
|
||||
}: any) {
|
||||
const [rate, setRate] = useState(enrollment.rate ?? '')
|
||||
const [billingInterval, setBillingInterval] = useState(String(enrollment.billingInterval ?? 1))
|
||||
const [billingUnit, setBillingUnit] = useState(enrollment.billingUnit ?? 'month')
|
||||
const [notes, setNotes] = useState(enrollment.notes ?? '')
|
||||
const [endDate, setEndDate] = useState(enrollment.endDate ?? '')
|
||||
|
||||
const NEXT_STATUSES: Record<string, string[]> = {
|
||||
active: ['paused', 'cancelled', 'completed'],
|
||||
paused: ['active', 'cancelled'],
|
||||
cancelled: [],
|
||||
completed: [],
|
||||
}
|
||||
|
||||
const nextStatuses = NEXT_STATUSES[enrollment.status] ?? []
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-lg">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground">Instructor</p>
|
||||
<p className="font-medium">{instructorName ?? enrollment.instructorId}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Slot</p>
|
||||
<p className="font-medium">{slotLabel}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Lesson Type</p>
|
||||
<p className="font-medium">{lessonTypeName ?? '—'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Start Date</p>
|
||||
<p className="font-medium">{new Date(enrollment.startDate + 'T00:00:00').toLocaleDateString()}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Billing Cycle</p>
|
||||
<p className="font-medium">{enrollment.billingInterval ? `${enrollment.billingInterval} ${enrollment.billingUnit}(s)` : '—'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Rate</p>
|
||||
<p className="font-medium">{enrollment.rate ? `$${enrollment.rate}` : '—'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Makeup Credits</p>
|
||||
<p className="font-medium">{enrollment.makeupCredits}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{canEdit && (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label className="block mb-2">Billing Cycle</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={billingInterval}
|
||||
onChange={(e) => setBillingInterval(e.target.value)}
|
||||
className="w-20"
|
||||
/>
|
||||
<Select value={billingUnit} onValueChange={setBillingUnit}>
|
||||
<SelectTrigger className="w-36"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{BILLING_UNITS.map((u) => (
|
||||
<SelectItem key={u.value} value={u.value}>{u.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Rate</Label>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-muted-foreground">$</span>
|
||||
<Input type="number" step="0.01" value={rate} onChange={(e) => setRate(e.target.value)} placeholder="Optional" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>End Date</Label>
|
||||
<Input type="date" value={endDate} onChange={(e) => setEndDate(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Notes</Label>
|
||||
<Textarea value={notes} onChange={(e) => setNotes(e.target.value)} rows={2} />
|
||||
</div>
|
||||
<Button onClick={() => onSave({
|
||||
rate: rate || undefined,
|
||||
billingInterval: billingInterval ? Number(billingInterval) : undefined,
|
||||
billingUnit: billingUnit || undefined,
|
||||
notes: notes || undefined,
|
||||
endDate: endDate || undefined,
|
||||
})} disabled={saving}>
|
||||
{saving ? 'Saving...' : 'Save Changes'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{nextStatuses.length > 0 && (
|
||||
<div className="border-t pt-4 space-y-2">
|
||||
<p className="text-sm font-medium">Change Status</p>
|
||||
<div className="flex gap-2">
|
||||
{nextStatuses.map((s) => (
|
||||
<Button key={s} variant={s === 'cancelled' ? 'destructive' : 'outline'} size="sm" onClick={() => onStatusChange(s)} disabled={statusChanging}>
|
||||
{s.charAt(0).toUpperCase() + s.slice(1)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Sessions Tab ─────────────────────────────────────────────────────────────
|
||||
|
||||
function SessionsTab({ enrollmentId, onGenerate, generating }: { enrollmentId: string; onGenerate: () => void; generating: boolean }) {
|
||||
const navigate = useNavigate()
|
||||
const { data, isLoading } = useQuery(sessionListOptions({ enrollmentId, page: 1, limit: 100, sort: 'scheduled_date', order: 'asc' }))
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-end">
|
||||
<Button variant="outline" size="sm" onClick={onGenerate} disabled={generating}>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${generating ? 'animate-spin' : ''}`} />
|
||||
Generate Sessions
|
||||
</Button>
|
||||
</div>
|
||||
<DataTable
|
||||
columns={sessionColumns}
|
||||
data={data?.data ?? []}
|
||||
loading={isLoading}
|
||||
page={1}
|
||||
totalPages={1}
|
||||
total={data?.data?.length ?? 0}
|
||||
onPageChange={() => {}}
|
||||
onSort={() => {}}
|
||||
onRowClick={(s) => navigate({ to: '/lessons/sessions/$sessionId', params: { sessionId: s.id }, search: {} as any })}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Lesson Plan Tab ──────────────────────────────────────────────────────────
|
||||
|
||||
function LessonPlanTab({ enrollmentId, memberId, canEdit }: { enrollmentId: string; memberId: string; canEdit: boolean }) {
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const [templatePickerOpen, setTemplatePickerOpen] = useState(false)
|
||||
const [selectedTemplateId, setSelectedTemplateId] = useState('')
|
||||
const [customTitle, setCustomTitle] = useState('')
|
||||
|
||||
const { data: plansData } = useQuery(lessonPlanListOptions({ enrollmentId, isActive: true }))
|
||||
const activePlan: LessonPlan | undefined = plansData?.data?.[0]
|
||||
|
||||
const { data: templatesData } = useQuery(lessonPlanTemplateListOptions({ page: 1, limit: 100, order: 'asc' }))
|
||||
|
||||
const createPlanMutation = useMutation({
|
||||
mutationFn: () => lessonPlanMutations.create({ memberId, enrollmentId, title: `Lesson Plan — ${new Date().toLocaleDateString()}` }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: lessonPlanKeys.all })
|
||||
toast.success('Lesson plan created')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const instantiateMutation = useMutation({
|
||||
mutationFn: () => lessonPlanTemplateMutations.createPlan(selectedTemplateId, {
|
||||
memberId,
|
||||
enrollmentId,
|
||||
title: customTitle || undefined,
|
||||
}),
|
||||
onSuccess: (plan) => {
|
||||
queryClient.invalidateQueries({ queryKey: lessonPlanKeys.all })
|
||||
toast.success('Plan created from template')
|
||||
setTemplatePickerOpen(false)
|
||||
navigate({ to: '/lessons/plans/$planId', params: { planId: plan.id }, search: {} as any })
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const templates: LessonPlanTemplate[] = templatesData?.data ?? []
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{activePlan ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium">{activePlan.title}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{Math.round(activePlan.progress)}% complete
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => navigate({ to: '/lessons/plans/$planId', params: { planId: activePlan.id }, search: {} as any })}>
|
||||
View Plan
|
||||
</Button>
|
||||
</div>
|
||||
<div className="w-full bg-muted rounded-full h-2">
|
||||
<div className="bg-primary h-2 rounded-full transition-all" style={{ width: `${activePlan.progress}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground py-4">No active lesson plan.</div>
|
||||
)}
|
||||
|
||||
{canEdit && (
|
||||
<div className="flex gap-2 pt-2 border-t">
|
||||
<Button variant="outline" size="sm" onClick={() => createPlanMutation.mutate()} disabled={createPlanMutation.isPending}>
|
||||
{createPlanMutation.isPending ? 'Creating...' : 'New Blank Plan'}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setTemplatePickerOpen(true)}>
|
||||
Use Template
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog open={templatePickerOpen} onOpenChange={setTemplatePickerOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>Create Plan from Template</DialogTitle></DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Template *</Label>
|
||||
<Select value={selectedTemplateId} onValueChange={setSelectedTemplateId}>
|
||||
<SelectTrigger><SelectValue placeholder="Choose a template..." /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{templates.map((t) => (
|
||||
<SelectItem key={t.id} value={t.id}>
|
||||
{t.name}{t.instrument ? ` — ${t.instrument}` : ''}{t.skillLevel !== 'all_levels' ? ` (${t.skillLevel})` : ''}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Custom Title</Label>
|
||||
<Input value={customTitle} onChange={(e) => setCustomTitle(e.target.value)} placeholder="Leave blank to use template name" />
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => instantiateMutation.mutate()}
|
||||
disabled={!selectedTemplateId || instantiateMutation.isPending}
|
||||
className="w-full"
|
||||
>
|
||||
{instantiateMutation.isPending ? 'Creating...' : 'Create Plan'}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useState } from 'react'
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { enrollmentListOptions } from '@/api/lessons'
|
||||
import { usePagination } from '@/hooks/use-pagination'
|
||||
import { DataTable, type Column } from '@/components/shared/data-table'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Plus, Search } from 'lucide-react'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import type { Enrollment } from '@/types/lesson'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/lessons/enrollments/')({
|
||||
validateSearch: (search: Record<string, unknown>) => ({
|
||||
page: Number(search.page) || 1,
|
||||
limit: Number(search.limit) || 25,
|
||||
q: (search.q as string) || undefined,
|
||||
sort: (search.sort as string) || undefined,
|
||||
order: (search.order as 'asc' | 'desc') || 'desc',
|
||||
status: (search.status as string) || undefined,
|
||||
instructorId: (search.instructorId as string) || undefined,
|
||||
}),
|
||||
component: EnrollmentsListPage,
|
||||
})
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
active: 'Active',
|
||||
paused: 'Paused',
|
||||
cancelled: 'Cancelled',
|
||||
completed: 'Completed',
|
||||
}
|
||||
|
||||
function statusBadge(status: string) {
|
||||
const variants: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||
active: 'default',
|
||||
paused: 'secondary',
|
||||
cancelled: 'destructive',
|
||||
completed: 'outline',
|
||||
}
|
||||
return <Badge variant={variants[status] ?? 'outline'}>{STATUS_LABELS[status] ?? status}</Badge>
|
||||
}
|
||||
|
||||
const columns: Column<Enrollment & { memberName?: string; instructorName?: string; slotInfo?: string; lessonTypeName?: string }>[] = [
|
||||
{ key: 'member_name', header: 'Member', sortable: true, render: (e) => <span className="font-medium">{(e as any).memberName ?? e.memberId}</span> },
|
||||
{ key: 'instructor_name', header: 'Instructor', render: (e) => <>{(e as any).instructorName ?? e.instructorId}</> },
|
||||
{ key: 'slot_info', header: 'Day / Time', render: (e) => <>{(e as any).slotInfo ?? '—'}</> },
|
||||
{ key: 'status', header: 'Status', sortable: true, render: (e) => statusBadge(e.status) },
|
||||
{ key: 'start_date', header: 'Start', sortable: true, render: (e) => <>{new Date(e.startDate + 'T00:00:00').toLocaleDateString()}</> },
|
||||
{ key: 'rate', header: 'Rate', render: (e) => <>{e.rate ? `$${e.rate}${e.billingInterval ? ` / ${e.billingInterval} ${e.billingUnit}` : ''}` : <span className="text-muted-foreground">—</span>}</> },
|
||||
]
|
||||
|
||||
function EnrollmentsListPage() {
|
||||
const navigate = useNavigate()
|
||||
const hasPermission = useAuthStore((s) => s.hasPermission)
|
||||
const search = Route.useSearch()
|
||||
const { params, setPage, setSearch, setSort } = usePagination()
|
||||
const [searchInput, setSearchInput] = useState(params.q ?? '')
|
||||
const [statusFilter, setStatusFilter] = useState(search.status ?? '')
|
||||
|
||||
const queryParams: Record<string, unknown> = { ...params }
|
||||
if (statusFilter) queryParams.status = statusFilter
|
||||
|
||||
const { data, isLoading } = useQuery(enrollmentListOptions(queryParams))
|
||||
|
||||
function handleSearchSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setSearch(searchInput)
|
||||
}
|
||||
|
||||
function handleStatusChange(v: string) {
|
||||
const s = v === 'all' ? '' : v
|
||||
setStatusFilter(s)
|
||||
navigate({ to: '/lessons/enrollments', search: { ...search, status: s || undefined, page: 1 } as any })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Enrollments</h1>
|
||||
{hasPermission('lessons.edit') && (
|
||||
<Button onClick={() => navigate({ to: '/lessons/enrollments/new', search: {} as any })}>
|
||||
<Plus className="mr-2 h-4 w-4" />New Enrollment
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 flex-wrap">
|
||||
<form onSubmit={handleSearchSubmit} className="flex gap-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search enrollments..."
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
className="pl-9 w-64"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" variant="secondary">Search</Button>
|
||||
</form>
|
||||
|
||||
<Select value={statusFilter || 'all'} onValueChange={handleStatusChange}>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue placeholder="All Statuses" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Statuses</SelectItem>
|
||||
<SelectItem value="active">Active</SelectItem>
|
||||
<SelectItem value="paused">Paused</SelectItem>
|
||||
<SelectItem value="cancelled">Cancelled</SelectItem>
|
||||
<SelectItem value="completed">Completed</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={data?.data ?? []}
|
||||
loading={isLoading}
|
||||
page={params.page}
|
||||
totalPages={data?.pagination.totalPages ?? 1}
|
||||
total={data?.pagination.total ?? 0}
|
||||
sort={params.sort}
|
||||
order={params.order}
|
||||
onPageChange={setPage}
|
||||
onSort={setSort}
|
||||
onRowClick={(e) => navigate({ to: '/lessons/enrollments/$enrollmentId', params: { enrollmentId: e.id }, search: {} as any })}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { useQuery, useMutation } from '@tanstack/react-query'
|
||||
import { globalMemberListOptions } from '@/api/members'
|
||||
import { scheduleSlotListOptions, enrollmentMutations, instructorListOptions, lessonTypeListOptions } from '@/api/lessons'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { ArrowLeft, Search, X } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import type { MemberWithAccount } from '@/api/members'
|
||||
import type { ScheduleSlot, LessonType, Instructor } from '@/types/lesson'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/lessons/enrollments/new')({
|
||||
validateSearch: (search: Record<string, unknown>) => ({
|
||||
memberId: (search.memberId as string) || undefined,
|
||||
accountId: (search.accountId as string) || undefined,
|
||||
}),
|
||||
component: NewEnrollmentPage,
|
||||
})
|
||||
|
||||
const DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
|
||||
|
||||
const BILLING_UNITS = [
|
||||
{ value: 'day', label: 'Day(s)' },
|
||||
{ value: 'week', label: 'Week(s)' },
|
||||
{ value: 'month', label: 'Month(s)' },
|
||||
{ value: 'quarter', label: 'Quarter(s)' },
|
||||
{ value: 'year', label: 'Year(s)' },
|
||||
] as const
|
||||
|
||||
function formatSlotLabel(slot: ScheduleSlot, instructors: Instructor[], lessonTypes: LessonType[]) {
|
||||
const instructor = instructors.find((i) => i.id === slot.instructorId)
|
||||
const lessonType = lessonTypes.find((lt) => lt.id === slot.lessonTypeId)
|
||||
const [h, m] = slot.startTime.split(':').map(Number)
|
||||
const ampm = h >= 12 ? 'PM' : 'AM'
|
||||
const hour = h % 12 || 12
|
||||
const time = `${hour}:${String(m).padStart(2, '0')} ${ampm}`
|
||||
const day = DAYS[slot.dayOfWeek]
|
||||
return `${day} ${time} — ${lessonType?.name ?? 'Unknown'} (${instructor?.displayName ?? 'Unknown'})`
|
||||
}
|
||||
|
||||
/** Returns the preset rate for a given cycle from slot (falling back to lesson type) */
|
||||
function getPresetRate(
|
||||
billingInterval: string,
|
||||
billingUnit: string,
|
||||
slot: ScheduleSlot | undefined,
|
||||
lessonType: LessonType | undefined,
|
||||
): string {
|
||||
if (!slot) return ''
|
||||
const isPreset = billingInterval === '1'
|
||||
if (!isPreset) return ''
|
||||
if (billingUnit === 'week') return slot.rateWeekly ?? lessonType?.rateWeekly ?? ''
|
||||
if (billingUnit === 'month') return slot.rateMonthly ?? lessonType?.rateMonthly ?? ''
|
||||
if (billingUnit === 'quarter') return slot.rateQuarterly ?? lessonType?.rateQuarterly ?? ''
|
||||
return ''
|
||||
}
|
||||
|
||||
function NewEnrollmentPage() {
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [memberSearch, setMemberSearch] = useState('')
|
||||
const [showMemberDropdown, setShowMemberDropdown] = useState(false)
|
||||
const [selectedMember, setSelectedMember] = useState<MemberWithAccount | null>(null)
|
||||
|
||||
const [selectedSlotId, setSelectedSlotId] = useState('')
|
||||
const [startDate, setStartDate] = useState('')
|
||||
const [billingInterval, setBillingInterval] = useState('1')
|
||||
const [billingUnit, setBillingUnit] = useState('month')
|
||||
const [rate, setRate] = useState('')
|
||||
const [rateManual, setRateManual] = useState(false)
|
||||
const [notes, setNotes] = useState('')
|
||||
|
||||
const { data: membersData } = useQuery(
|
||||
globalMemberListOptions({ page: 1, limit: 20, q: memberSearch || undefined, order: 'asc', sort: 'first_name' }),
|
||||
)
|
||||
|
||||
const { data: slotsData } = useQuery(scheduleSlotListOptions({ page: 1, limit: 100, order: 'asc' }))
|
||||
const { data: instructorsData } = useQuery(instructorListOptions({ page: 1, limit: 100, order: 'asc' }))
|
||||
const { data: lessonTypesData } = useQuery(lessonTypeListOptions({ page: 1, limit: 100, order: 'asc' }))
|
||||
|
||||
const slots = slotsData?.data?.filter((s) => s.isActive) ?? []
|
||||
const instructors = instructorsData?.data ?? []
|
||||
const lessonTypes = lessonTypesData?.data ?? []
|
||||
|
||||
const selectedSlot = slots.find((s) => s.id === selectedSlotId)
|
||||
const selectedLessonType = lessonTypes.find((lt) => lt.id === selectedSlot?.lessonTypeId)
|
||||
|
||||
// Auto-fill rate from slot/lesson-type presets when slot or cycle changes, unless user has manually edited
|
||||
useEffect(() => {
|
||||
if (rateManual) return
|
||||
const preset = getPresetRate(billingInterval, billingUnit, selectedSlot, selectedLessonType)
|
||||
setRate(preset ? String(preset) : '')
|
||||
}, [selectedSlotId, billingInterval, billingUnit, selectedSlot, selectedLessonType, rateManual])
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (data: Record<string, unknown>) => {
|
||||
const enrollment = await enrollmentMutations.create(data)
|
||||
try {
|
||||
await enrollmentMutations.generateSessions(enrollment.id, 4)
|
||||
} catch {
|
||||
// non-fatal — sessions can be generated later
|
||||
}
|
||||
return enrollment
|
||||
},
|
||||
onSuccess: (enrollment) => {
|
||||
toast.success('Enrollment created')
|
||||
navigate({ to: '/lessons/enrollments/$enrollmentId', params: { enrollmentId: enrollment.id }, search: {} as any })
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
function selectMember(member: MemberWithAccount) {
|
||||
setSelectedMember(member)
|
||||
setShowMemberDropdown(false)
|
||||
setMemberSearch('')
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!selectedMember || !selectedSlotId || !startDate) return
|
||||
|
||||
mutation.mutate({
|
||||
memberId: selectedMember.id,
|
||||
accountId: selectedMember.accountId,
|
||||
scheduleSlotId: selectedSlotId,
|
||||
instructorId: selectedSlot?.instructorId,
|
||||
startDate,
|
||||
rate: rate || undefined,
|
||||
billingInterval: billingInterval ? Number(billingInterval) : undefined,
|
||||
billingUnit: billingUnit || undefined,
|
||||
notes: notes || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const members = membersData?.data ?? []
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/lessons/enrollments', search: {} as any })}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<h1 className="text-2xl font-bold">New Enrollment</h1>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Student */}
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-lg">Student</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
{!selectedMember ? (
|
||||
<div className="relative">
|
||||
<Label>Search Member</Label>
|
||||
<div className="relative mt-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Type name to search..."
|
||||
value={memberSearch}
|
||||
onChange={(e) => { setMemberSearch(e.target.value); setShowMemberDropdown(true) }}
|
||||
onFocus={() => setShowMemberDropdown(true)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
{showMemberDropdown && memberSearch.length > 0 && (
|
||||
<div className="absolute z-50 mt-1 w-full rounded-md border bg-popover shadow-lg max-h-60 overflow-auto">
|
||||
{members.length === 0 ? (
|
||||
<div className="p-3 text-sm text-muted-foreground">No members found</div>
|
||||
) : (
|
||||
members.map((m) => (
|
||||
<button
|
||||
key={m.id}
|
||||
type="button"
|
||||
className="w-full text-left px-3 py-2 text-sm hover:bg-accent"
|
||||
onClick={() => selectMember(m)}
|
||||
>
|
||||
<span className="font-medium">{m.firstName} {m.lastName}</span>
|
||||
{m.accountName && <span className="text-muted-foreground ml-2">— {m.accountName}</span>}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between p-3 rounded-md border bg-muted/30">
|
||||
<div>
|
||||
<p className="font-medium">{selectedMember.firstName} {selectedMember.lastName}</p>
|
||||
{selectedMember.accountName && (
|
||||
<p className="text-sm text-muted-foreground">{selectedMember.accountName}</p>
|
||||
)}
|
||||
</div>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => setSelectedMember(null)}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Schedule Slot */}
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-lg">Schedule Slot</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<Label>Select Slot *</Label>
|
||||
<Select value={selectedSlotId} onValueChange={(v) => { setSelectedSlotId(v); setRateManual(false) }}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Choose a time slot..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{slots.map((slot) => (
|
||||
<SelectItem key={slot.id} value={slot.id}>
|
||||
{formatSlotLabel(slot, instructors, lessonTypes)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Terms */}
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-lg">Terms</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="startDate">Start Date *</Label>
|
||||
<Input id="startDate" type="date" value={startDate} onChange={(e) => setStartDate(e.target.value)} required className="max-w-xs" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="block mb-2">Billing Cycle</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={billingInterval}
|
||||
onChange={(e) => { setBillingInterval(e.target.value); setRateManual(false) }}
|
||||
className="w-20"
|
||||
/>
|
||||
<Select value={billingUnit} onValueChange={(v) => { setBillingUnit(v); setRateManual(false) }}>
|
||||
<SelectTrigger className="w-36">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{BILLING_UNITS.map((u) => (
|
||||
<SelectItem key={u.value} value={u.value}>{u.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="rate">Rate</Label>
|
||||
<div className="flex items-center gap-2 max-w-xs">
|
||||
<span className="text-muted-foreground">$</span>
|
||||
<Input
|
||||
id="rate"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={rate}
|
||||
onChange={(e) => { setRate(e.target.value); setRateManual(true) }}
|
||||
placeholder="Auto-filled from slot"
|
||||
/>
|
||||
</div>
|
||||
{!rateManual && rate && (
|
||||
<p className="text-xs text-muted-foreground">Auto-filled from slot rates</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="notes">Notes</Label>
|
||||
<Textarea id="notes" value={notes} onChange={(e) => setNotes(e.target.value)} rows={2} placeholder="Internal notes..." />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" disabled={mutation.isPending || !selectedMember || !selectedSlotId || !startDate} size="lg">
|
||||
{mutation.isPending ? 'Creating...' : 'Create Enrollment'}
|
||||
</Button>
|
||||
<Button variant="secondary" type="button" size="lg" onClick={() => navigate({ to: '/lessons/enrollments', search: {} as any })}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { useState } from 'react'
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { lessonPlanDetailOptions, lessonPlanMutations, lessonPlanKeys, lessonPlanItemMutations } from '@/api/lessons'
|
||||
import { GradeEntryDialog } from '@/components/lessons/grade-entry-dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { ArrowLeft, Star } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import type { LessonPlanItem } from '@/types/lesson'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/lessons/plans/$planId')({
|
||||
component: LessonPlanDetailPage,
|
||||
})
|
||||
|
||||
const STATUSES = ['not_started', 'in_progress', 'mastered', 'skipped'] as const
|
||||
type ItemStatus = typeof STATUSES[number]
|
||||
|
||||
const STATUS_LABELS: Record<ItemStatus, string> = {
|
||||
not_started: 'Not Started',
|
||||
in_progress: 'In Progress',
|
||||
mastered: 'Mastered',
|
||||
skipped: 'Skipped',
|
||||
}
|
||||
|
||||
const STATUS_VARIANTS: Record<ItemStatus, 'default' | 'secondary' | 'outline'> = {
|
||||
not_started: 'outline',
|
||||
in_progress: 'secondary',
|
||||
mastered: 'default',
|
||||
skipped: 'outline',
|
||||
}
|
||||
|
||||
function nextStatus(current: ItemStatus): ItemStatus {
|
||||
const idx = STATUSES.indexOf(current)
|
||||
return STATUSES[(idx + 1) % STATUSES.length]
|
||||
}
|
||||
|
||||
function LessonPlanDetailPage() {
|
||||
const { planId } = Route.useParams()
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const hasPermission = useAuthStore((s) => s.hasPermission)
|
||||
const canEdit = hasPermission('lessons.edit')
|
||||
|
||||
const { data: plan, isLoading } = useQuery(lessonPlanDetailOptions(planId))
|
||||
const [gradeItem, setGradeItem] = useState<LessonPlanItem | null>(null)
|
||||
const [editingTitle, setEditingTitle] = useState(false)
|
||||
const [titleInput, setTitleInput] = useState('')
|
||||
|
||||
const updatePlanMutation = useMutation({
|
||||
mutationFn: (data: Record<string, unknown>) => lessonPlanMutations.update(planId, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: lessonPlanKeys.detail(planId) })
|
||||
setEditingTitle(false)
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const updateItemMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
|
||||
lessonPlanItemMutations.update(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: lessonPlanKeys.detail(planId) })
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
if (isLoading) return <div className="text-sm text-muted-foreground">Loading...</div>
|
||||
if (!plan) return <div className="text-sm text-destructive">Plan not found.</div>
|
||||
|
||||
const totalItems = plan.sections.flatMap((s) => s.items).filter((i) => i.status !== 'skipped').length
|
||||
const masteredItems = plan.sections.flatMap((s) => s.items).filter((i) => i.status === 'mastered').length
|
||||
|
||||
function startEditTitle() {
|
||||
setTitleInput(plan!.title)
|
||||
setEditingTitle(true)
|
||||
}
|
||||
|
||||
function saveTitle() {
|
||||
if (titleInput.trim() && titleInput !== plan!.title) {
|
||||
updatePlanMutation.mutate({ title: titleInput.trim() })
|
||||
} else {
|
||||
setEditingTitle(false)
|
||||
}
|
||||
}
|
||||
|
||||
function cycleStatus(item: LessonPlanItem) {
|
||||
updateItemMutation.mutate({ id: item.id, data: { status: nextStatus(item.status as ItemStatus) } })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-3xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/lessons/plans', search: {} as any })}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex-1">
|
||||
{editingTitle ? (
|
||||
<div className="flex gap-2 items-center">
|
||||
<Input
|
||||
value={titleInput}
|
||||
onChange={(e) => setTitleInput(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') saveTitle(); if (e.key === 'Escape') setEditingTitle(false) }}
|
||||
className="text-xl font-bold h-9"
|
||||
autoFocus
|
||||
/>
|
||||
<Button size="sm" onClick={saveTitle} disabled={updatePlanMutation.isPending}>Save</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setEditingTitle(false)}>Cancel</Button>
|
||||
</div>
|
||||
) : (
|
||||
<h1
|
||||
className={`text-2xl font-bold ${canEdit ? 'cursor-pointer hover:underline decoration-dashed' : ''}`}
|
||||
onClick={canEdit ? startEditTitle : undefined}
|
||||
title={canEdit ? 'Click to edit' : undefined}
|
||||
>
|
||||
{plan.title}
|
||||
</h1>
|
||||
)}
|
||||
</div>
|
||||
<Badge variant={plan.isActive ? 'default' : 'secondary'}>{plan.isActive ? 'Active' : 'Inactive'}</Badge>
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">{masteredItems} / {totalItems} mastered</span>
|
||||
<span className="font-medium">{Math.round(plan.progress)}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-muted rounded-full h-2.5">
|
||||
<div className="bg-primary h-2.5 rounded-full transition-all" style={{ width: `${plan.progress}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sections */}
|
||||
<div className="space-y-4">
|
||||
{plan.sections.map((section) => (
|
||||
<details key={section.id} open className="border rounded-lg">
|
||||
<summary className="px-4 py-3 cursor-pointer font-semibold text-sm select-none hover:bg-muted/30 rounded-t-lg">
|
||||
{section.title}
|
||||
<span className="ml-2 text-xs font-normal text-muted-foreground">
|
||||
({section.items.filter((i) => i.status === 'mastered').length}/{section.items.length})
|
||||
</span>
|
||||
</summary>
|
||||
<div className="divide-y">
|
||||
{section.items.map((item) => (
|
||||
<div key={item.id} className="flex items-center gap-3 px-4 py-2.5">
|
||||
{canEdit ? (
|
||||
<button
|
||||
onClick={() => cycleStatus(item)}
|
||||
className="shrink-0"
|
||||
title={`Click to change: ${STATUS_LABELS[item.status as ItemStatus]}`}
|
||||
>
|
||||
<Badge
|
||||
variant={STATUS_VARIANTS[item.status as ItemStatus]}
|
||||
className={`text-xs cursor-pointer ${item.status === 'mastered' ? 'bg-green-600 text-white border-green-600' : ''}`}
|
||||
>
|
||||
{STATUS_LABELS[item.status as ItemStatus]}
|
||||
</Badge>
|
||||
</button>
|
||||
) : (
|
||||
<Badge variant={STATUS_VARIANTS[item.status as ItemStatus]} className="text-xs shrink-0">
|
||||
{STATUS_LABELS[item.status as ItemStatus]}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm">{item.title}</p>
|
||||
{item.description && <p className="text-xs text-muted-foreground">{item.description}</p>}
|
||||
</div>
|
||||
|
||||
{item.currentGradeValue && (
|
||||
<Badge variant="outline" className="text-xs shrink-0">{item.currentGradeValue}</Badge>
|
||||
)}
|
||||
|
||||
{canEdit && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 shrink-0"
|
||||
onClick={() => setGradeItem(item)}
|
||||
title="Record grade"
|
||||
>
|
||||
<Star className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{gradeItem && (
|
||||
<GradeEntryDialog
|
||||
item={gradeItem}
|
||||
open={!!gradeItem}
|
||||
onClose={() => setGradeItem(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useState } from 'react'
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { lessonPlanListOptions } from '@/api/lessons'
|
||||
import { usePagination } from '@/hooks/use-pagination'
|
||||
import { DataTable, type Column } from '@/components/shared/data-table'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Search } from 'lucide-react'
|
||||
import type { LessonPlan } from '@/types/lesson'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/lessons/plans/')({
|
||||
validateSearch: (search: Record<string, unknown>) => ({
|
||||
page: Number(search.page) || 1,
|
||||
limit: Number(search.limit) || 25,
|
||||
q: (search.q as string) || undefined,
|
||||
sort: (search.sort as string) || undefined,
|
||||
order: (search.order as 'asc' | 'desc') || 'desc',
|
||||
}),
|
||||
component: LessonPlansPage,
|
||||
})
|
||||
|
||||
const columns: Column<LessonPlan>[] = [
|
||||
{ key: 'title', header: 'Title', sortable: true, render: (p) => <span className="font-medium">{p.title}</span> },
|
||||
{
|
||||
key: 'progress', header: 'Progress', sortable: true,
|
||||
render: (p) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-24 bg-muted rounded-full h-2">
|
||||
<div className="bg-primary h-2 rounded-full" style={{ width: `${p.progress}%` }} />
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{Math.round(p.progress)}%</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'is_active', header: 'Status',
|
||||
render: (p) => <Badge variant={p.isActive ? 'default' : 'secondary'}>{p.isActive ? 'Active' : 'Inactive'}</Badge>,
|
||||
},
|
||||
{
|
||||
key: 'created_at', header: 'Created', sortable: true,
|
||||
render: (p) => <>{new Date(p.createdAt).toLocaleDateString()}</>,
|
||||
},
|
||||
]
|
||||
|
||||
function LessonPlansPage() {
|
||||
const navigate = useNavigate()
|
||||
const { params, setPage, setSearch, setSort } = usePagination()
|
||||
const [searchInput, setSearchInput] = useState(params.q ?? '')
|
||||
|
||||
const { data, isLoading } = useQuery(lessonPlanListOptions(params))
|
||||
|
||||
function handleSearchSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setSearch(searchInput)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold">Lesson Plans</h1>
|
||||
|
||||
<form onSubmit={handleSearchSubmit} className="flex gap-2 max-w-sm">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search lesson plans..."
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" variant="secondary">Search</Button>
|
||||
</form>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={data?.data ?? []}
|
||||
loading={isLoading}
|
||||
page={params.page}
|
||||
totalPages={data?.pagination.totalPages ?? 1}
|
||||
total={data?.pagination.total ?? 0}
|
||||
sort={params.sort}
|
||||
order={params.order}
|
||||
onPageChange={setPage}
|
||||
onSort={setSort}
|
||||
onRowClick={(p) => navigate({ to: '/lessons/plans/$planId', params: { planId: p.id }, search: {} as any })}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
import { useState } from 'react'
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
instructorListOptions, instructorMutations, instructorKeys,
|
||||
lessonTypeListOptions, lessonTypeMutations, lessonTypeKeys,
|
||||
gradingScaleListOptions, gradingScaleMutations, gradingScaleKeys,
|
||||
storeClosureListOptions, storeClosureMutations, storeClosureKeys,
|
||||
} from '@/api/lessons'
|
||||
import { usePagination } from '@/hooks/use-pagination'
|
||||
import { DataTable, type Column } from '@/components/shared/data-table'
|
||||
import { InstructorForm } from '@/components/lessons/instructor-form'
|
||||
import { LessonTypeForm } from '@/components/lessons/lesson-type-form'
|
||||
import { GradingScaleForm } from '@/components/lessons/grading-scale-form'
|
||||
import { StoreClosureForm } from '@/components/lessons/store-closure-form'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
||||
import { Plus, Search, Trash2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import type { Instructor, LessonType, GradingScale, StoreClosure } from '@/types/lesson'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/lessons/schedule/')({
|
||||
validateSearch: (search: Record<string, unknown>) => ({
|
||||
tab: (search.tab as string) || 'instructors',
|
||||
page: Number(search.page) || 1,
|
||||
limit: Number(search.limit) || 25,
|
||||
q: (search.q as string) || undefined,
|
||||
sort: (search.sort as string) || undefined,
|
||||
order: (search.order as 'asc' | 'desc') || 'asc',
|
||||
}),
|
||||
component: ScheduleHubPage,
|
||||
})
|
||||
|
||||
const TABS = [
|
||||
{ key: 'instructors', label: 'Instructors' },
|
||||
{ key: 'lesson-types', label: 'Lesson Types' },
|
||||
{ key: 'grading-scales', label: 'Grading Scales' },
|
||||
{ key: 'closures', label: 'Store Closures' },
|
||||
]
|
||||
|
||||
function ScheduleHubPage() {
|
||||
const navigate = useNavigate()
|
||||
const search = Route.useSearch()
|
||||
const tab = search.tab
|
||||
const hasPermission = useAuthStore((s) => s.hasPermission)
|
||||
const canAdmin = hasPermission('lessons.admin')
|
||||
|
||||
function setTab(t: string) {
|
||||
navigate({ to: '/lessons/schedule', search: { ...search, tab: t, page: 1 } as any })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold">Lessons Setup</h1>
|
||||
<div className="flex gap-1 border-b">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => setTab(t.key)}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
tab === t.key
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === 'instructors' && <InstructorsTab canAdmin={canAdmin} search={search} />}
|
||||
{tab === 'lesson-types' && <LessonTypesTab canAdmin={canAdmin} search={search} />}
|
||||
{tab === 'grading-scales' && <GradingScalesTab canAdmin={canAdmin} search={search} />}
|
||||
{tab === 'closures' && <StoreClosuresTab canAdmin={canAdmin} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Instructors Tab ──────────────────────────────────────────────────────────
|
||||
|
||||
const instructorColumns: Column<Instructor>[] = [
|
||||
{ key: 'display_name', header: 'Name', sortable: true, render: (i) => <span className="font-medium">{i.displayName}</span> },
|
||||
{ key: 'instruments', header: 'Instruments', render: (i) => <>{i.instruments?.join(', ') || <span className="text-muted-foreground">—</span>}</> },
|
||||
{
|
||||
key: 'is_active', header: 'Status', sortable: true,
|
||||
render: (i) => <Badge variant={i.isActive ? 'default' : 'secondary'}>{i.isActive ? 'Active' : 'Inactive'}</Badge>,
|
||||
},
|
||||
]
|
||||
|
||||
function InstructorsTab({ canAdmin, search }: { canAdmin: boolean; search: any }) {
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const { params, setPage, setSearch, setSort } = usePagination()
|
||||
const [searchInput, setSearchInput] = useState(params.q ?? '')
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
|
||||
const { data, isLoading } = useQuery(instructorListOptions(params))
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: instructorMutations.create,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: instructorKeys.all })
|
||||
toast.success('Instructor created')
|
||||
setCreateOpen(false)
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
function handleSearchSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setSearch(searchInput)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<form onSubmit={handleSearchSubmit} className="flex gap-2 max-w-sm">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search instructors..."
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" variant="secondary">Search</Button>
|
||||
</form>
|
||||
{canAdmin && (
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button><Plus className="mr-2 h-4 w-4" />New Instructor</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>Create Instructor</DialogTitle></DialogHeader>
|
||||
<InstructorForm onSubmit={createMutation.mutate} loading={createMutation.isPending} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
<DataTable
|
||||
columns={instructorColumns}
|
||||
data={data?.data ?? []}
|
||||
loading={isLoading}
|
||||
page={params.page}
|
||||
totalPages={data?.pagination.totalPages ?? 1}
|
||||
total={data?.pagination.total ?? 0}
|
||||
sort={params.sort}
|
||||
order={params.order}
|
||||
onPageChange={setPage}
|
||||
onSort={setSort}
|
||||
onRowClick={(i) => navigate({ to: '/lessons/schedule/instructors/$instructorId', params: { instructorId: i.id }, search: {} as any })}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Lesson Types Tab ─────────────────────────────────────────────────────────
|
||||
|
||||
const lessonTypeColumns: Column<LessonType>[] = [
|
||||
{ key: 'name', header: 'Name', sortable: true, render: (lt) => <span className="font-medium">{lt.name}</span> },
|
||||
{ key: 'instrument', header: 'Instrument', render: (lt) => <>{lt.instrument ?? <span className="text-muted-foreground">—</span>}</> },
|
||||
{ key: 'duration_minutes', header: 'Duration', sortable: true, render: (lt) => <>{lt.durationMinutes} min</> },
|
||||
{ key: 'lesson_format', header: 'Format', render: (lt) => <Badge variant="outline">{lt.lessonFormat}</Badge> },
|
||||
{ key: 'rate_monthly', header: 'Monthly Rate', render: (lt) => <>{lt.rateMonthly ? `$${lt.rateMonthly}` : <span className="text-muted-foreground">—</span>}</> },
|
||||
{ key: 'is_active', header: 'Status', render: (lt) => <Badge variant={lt.isActive ? 'default' : 'secondary'}>{lt.isActive ? 'Active' : 'Inactive'}</Badge> },
|
||||
]
|
||||
|
||||
function LessonTypesTab({ canAdmin, search }: { canAdmin: boolean; search: any }) {
|
||||
const queryClient = useQueryClient()
|
||||
const { params, setPage, setSearch, setSort } = usePagination()
|
||||
const [searchInput, setSearchInput] = useState(params.q ?? '')
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [editTarget, setEditTarget] = useState<LessonType | null>(null)
|
||||
|
||||
const { data, isLoading } = useQuery(lessonTypeListOptions(params))
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: lessonTypeMutations.create,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: lessonTypeKeys.all })
|
||||
toast.success('Lesson type created')
|
||||
setCreateOpen(false)
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) => lessonTypeMutations.update(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: lessonTypeKeys.all })
|
||||
toast.success('Lesson type updated')
|
||||
setEditTarget(null)
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: lessonTypeMutations.delete,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: lessonTypeKeys.all })
|
||||
toast.success('Lesson type removed')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
function handleSearchSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setSearch(searchInput)
|
||||
}
|
||||
|
||||
const columnsWithActions: Column<LessonType>[] = [
|
||||
...lessonTypeColumns,
|
||||
...(canAdmin ? [{
|
||||
key: 'actions' as any,
|
||||
header: '' as any,
|
||||
render: (lt: LessonType) => (
|
||||
<div className="flex gap-1 justify-end">
|
||||
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); setEditTarget(lt) }}>Edit</Button>
|
||||
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); deleteMutation.mutate(lt.id) }}>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
}] : []),
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<form onSubmit={handleSearchSubmit} className="flex gap-2 max-w-sm">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search lesson types..."
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" variant="secondary">Search</Button>
|
||||
</form>
|
||||
{canAdmin && (
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button><Plus className="mr-2 h-4 w-4" />New Lesson Type</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>Create Lesson Type</DialogTitle></DialogHeader>
|
||||
<LessonTypeForm onSubmit={createMutation.mutate} loading={createMutation.isPending} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{editTarget && (
|
||||
<Dialog open={!!editTarget} onOpenChange={(o) => { if (!o) setEditTarget(null) }}>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>Edit Lesson Type</DialogTitle></DialogHeader>
|
||||
<LessonTypeForm
|
||||
defaultValues={editTarget}
|
||||
onSubmit={(data) => updateMutation.mutate({ id: editTarget.id, data })}
|
||||
loading={updateMutation.isPending}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
<DataTable
|
||||
columns={columnsWithActions}
|
||||
data={data?.data ?? []}
|
||||
loading={isLoading}
|
||||
page={params.page}
|
||||
totalPages={data?.pagination.totalPages ?? 1}
|
||||
total={data?.pagination.total ?? 0}
|
||||
sort={params.sort}
|
||||
order={params.order}
|
||||
onPageChange={setPage}
|
||||
onSort={setSort}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Grading Scales Tab ───────────────────────────────────────────────────────
|
||||
|
||||
const gradingScaleColumns: Column<GradingScale>[] = [
|
||||
{ key: 'name', header: 'Name', sortable: true, render: (gs) => <span className="font-medium">{gs.name}</span> },
|
||||
{
|
||||
key: 'is_default', header: '', render: (gs) => gs.isDefault
|
||||
? <Badge variant="default">Default</Badge>
|
||||
: null,
|
||||
},
|
||||
{ key: 'levels', header: 'Levels', render: (gs) => <>{gs.levels?.length ?? 0}</> },
|
||||
{ key: 'is_active', header: 'Status', render: (gs) => <Badge variant={gs.isActive ? 'default' : 'secondary'}>{gs.isActive ? 'Active' : 'Inactive'}</Badge> },
|
||||
]
|
||||
|
||||
function GradingScalesTab({ canAdmin, search }: { canAdmin: boolean; search: any }) {
|
||||
const queryClient = useQueryClient()
|
||||
const { params, setPage, setSort } = usePagination()
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
|
||||
const { data, isLoading } = useQuery(gradingScaleListOptions(params))
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: gradingScaleMutations.create,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: gradingScaleKeys.all })
|
||||
toast.success('Grading scale created')
|
||||
setCreateOpen(false)
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: gradingScaleMutations.delete,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: gradingScaleKeys.all })
|
||||
toast.success('Grading scale removed')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const columnsWithActions: Column<GradingScale>[] = [
|
||||
...gradingScaleColumns,
|
||||
...(canAdmin ? [{
|
||||
key: 'actions' as any,
|
||||
header: '' as any,
|
||||
render: (gs: GradingScale) => (
|
||||
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); deleteMutation.mutate(gs.id) }}>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
),
|
||||
}] : []),
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-end">
|
||||
{canAdmin && (
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button><Plus className="mr-2 h-4 w-4" />New Grading Scale</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader><DialogTitle>Create Grading Scale</DialogTitle></DialogHeader>
|
||||
<GradingScaleForm onSubmit={createMutation.mutate} loading={createMutation.isPending} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
<DataTable
|
||||
columns={columnsWithActions}
|
||||
data={data?.data ?? []}
|
||||
loading={isLoading}
|
||||
page={params.page}
|
||||
totalPages={data?.pagination.totalPages ?? 1}
|
||||
total={data?.pagination.total ?? 0}
|
||||
sort={params.sort}
|
||||
order={params.order}
|
||||
onPageChange={setPage}
|
||||
onSort={setSort}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Store Closures Tab ───────────────────────────────────────────────────────
|
||||
|
||||
function StoreClosuresTab({ canAdmin }: { canAdmin: boolean }) {
|
||||
const queryClient = useQueryClient()
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
|
||||
const { data, isLoading } = useQuery(storeClosureListOptions())
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: storeClosureMutations.create,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: storeClosureKeys.all })
|
||||
toast.success('Store closure added')
|
||||
setCreateOpen(false)
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: storeClosureMutations.delete,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: storeClosureKeys.all })
|
||||
toast.success('Closure removed')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const closures: StoreClosure[] = data ?? []
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-end">
|
||||
{canAdmin && (
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button><Plus className="mr-2 h-4 w-4" />Add Closure</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>Add Store Closure</DialogTitle></DialogHeader>
|
||||
<StoreClosureForm onSubmit={createMutation.mutate} loading={createMutation.isPending} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-sm text-muted-foreground">Loading...</div>
|
||||
) : closures.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground text-center py-8 border rounded-md">
|
||||
No store closures configured.
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y border rounded-md">
|
||||
{closures.map((c) => (
|
||||
<div key={c.id} className="flex items-center justify-between px-4 py-3">
|
||||
<div>
|
||||
<div className="font-medium text-sm">{c.name}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{new Date(c.startDate + 'T00:00:00').toLocaleDateString()} –{' '}
|
||||
{new Date(c.endDate + 'T00:00:00').toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
{canAdmin && (
|
||||
<Button variant="ghost" size="sm" onClick={() => deleteMutation.mutate(c.id)}>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
import { useState } from 'react'
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
instructorDetailOptions, instructorMutations, instructorKeys,
|
||||
instructorBlockedDatesOptions,
|
||||
scheduleSlotListOptions, scheduleSlotMutations, scheduleSlotKeys,
|
||||
lessonTypeListOptions,
|
||||
} from '@/api/lessons'
|
||||
import { InstructorForm } from '@/components/lessons/instructor-form'
|
||||
import { ScheduleSlotForm } from '@/components/lessons/schedule-slot-form'
|
||||
import { BlockedDateForm } from '@/components/lessons/blocked-date-form'
|
||||
import { WeeklySlotGrid } from '@/components/lessons/weekly-slot-grid'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
||||
import { ArrowLeft, Plus, Trash2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import type { ScheduleSlot } from '@/types/lesson'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/lessons/schedule/instructors/$instructorId')({
|
||||
validateSearch: (search: Record<string, unknown>) => ({
|
||||
tab: (search.tab as string) || 'overview',
|
||||
}),
|
||||
component: InstructorDetailPage,
|
||||
})
|
||||
|
||||
const TABS = [
|
||||
{ key: 'overview', label: 'Overview' },
|
||||
{ key: 'slots', label: 'Schedule Slots' },
|
||||
{ key: 'blocked', label: 'Blocked Dates' },
|
||||
]
|
||||
|
||||
function InstructorDetailPage() {
|
||||
const { instructorId } = Route.useParams()
|
||||
const search = Route.useSearch()
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const hasPermission = useAuthStore((s) => s.hasPermission)
|
||||
const canAdmin = hasPermission('lessons.admin')
|
||||
const tab = search.tab
|
||||
|
||||
function setTab(t: string) {
|
||||
navigate({ to: '/lessons/schedule/instructors/$instructorId', params: { instructorId }, search: { tab: t } as any })
|
||||
}
|
||||
|
||||
const { data: instructor, isLoading } = useQuery(instructorDetailOptions(instructorId))
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: Record<string, unknown>) => instructorMutations.update(instructorId, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: instructorKeys.detail(instructorId) })
|
||||
toast.success('Instructor updated')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
if (isLoading) return <div className="text-sm text-muted-foreground">Loading...</div>
|
||||
if (!instructor) return <div className="text-sm text-destructive">Instructor not found.</div>
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/lessons/schedule', search: { tab: 'instructors' } as any })}>
|
||||
<ArrowLeft className="h-4 w-4 mr-1" />Back
|
||||
</Button>
|
||||
<div className="flex-1">
|
||||
<h1 className="text-2xl font-bold">{instructor.displayName}</h1>
|
||||
{instructor.instruments && instructor.instruments.length > 0 && (
|
||||
<p className="text-sm text-muted-foreground">{instructor.instruments.join(', ')}</p>
|
||||
)}
|
||||
</div>
|
||||
<Badge variant={instructor.isActive ? 'default' : 'secondary'}>
|
||||
{instructor.isActive ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1 border-b">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => setTab(t.key)}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
tab === t.key
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === 'overview' && (
|
||||
<div className="max-w-lg">
|
||||
<InstructorForm
|
||||
defaultValues={instructor}
|
||||
onSubmit={updateMutation.mutate}
|
||||
loading={updateMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{tab === 'slots' && <ScheduleSlotsTab instructorId={instructorId} canAdmin={canAdmin} />}
|
||||
{tab === 'blocked' && <BlockedDatesTab instructorId={instructorId} canAdmin={canAdmin} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Schedule Slots Tab ───────────────────────────────────────────────────────
|
||||
|
||||
function ScheduleSlotsTab({ instructorId, canAdmin }: { instructorId: string; canAdmin: boolean }) {
|
||||
const queryClient = useQueryClient()
|
||||
const [addOpen, setAddOpen] = useState(false)
|
||||
const [editSlot, setEditSlot] = useState<ScheduleSlot | null>(null)
|
||||
|
||||
const { data: slotsData } = useQuery(scheduleSlotListOptions({ page: 1, limit: 100, order: 'asc' }, { instructorId }))
|
||||
const { data: lessonTypesData } = useQuery(lessonTypeListOptions({ page: 1, limit: 100, order: 'asc' }))
|
||||
|
||||
const slots = slotsData?.data ?? []
|
||||
const lessonTypes = lessonTypesData?.data ?? []
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: Record<string, unknown>) => scheduleSlotMutations.create({ ...data, instructorId }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: scheduleSlotKeys.all })
|
||||
toast.success('Schedule slot added')
|
||||
setAddOpen(false)
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) => scheduleSlotMutations.update(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: scheduleSlotKeys.all })
|
||||
toast.success('Slot updated')
|
||||
setEditSlot(null)
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: scheduleSlotMutations.delete,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: scheduleSlotKeys.all })
|
||||
toast.success('Slot removed')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{canAdmin && (
|
||||
<div className="flex justify-end">
|
||||
<Dialog open={addOpen} onOpenChange={setAddOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button><Plus className="mr-2 h-4 w-4" />Add Slot</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>Add Schedule Slot</DialogTitle></DialogHeader>
|
||||
<ScheduleSlotForm
|
||||
lessonTypes={lessonTypes}
|
||||
onSubmit={createMutation.mutate}
|
||||
loading={createMutation.isPending}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editSlot && (
|
||||
<Dialog open={!!editSlot} onOpenChange={(o) => { if (!o) setEditSlot(null) }}>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>Edit Schedule Slot</DialogTitle></DialogHeader>
|
||||
<ScheduleSlotForm
|
||||
lessonTypes={lessonTypes}
|
||||
defaultValues={editSlot}
|
||||
onSubmit={(data) => updateMutation.mutate({ id: editSlot.id, data })}
|
||||
loading={updateMutation.isPending}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
<WeeklySlotGrid
|
||||
slots={slots}
|
||||
lessonTypes={lessonTypes}
|
||||
onEdit={setEditSlot}
|
||||
onDelete={(slot) => deleteMutation.mutate(slot.id)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Blocked Dates Tab ────────────────────────────────────────────────────────
|
||||
|
||||
function BlockedDatesTab({ instructorId, canAdmin }: { instructorId: string; canAdmin: boolean }) {
|
||||
const queryClient = useQueryClient()
|
||||
const [addOpen, setAddOpen] = useState(false)
|
||||
|
||||
const { data: blockedDates, isLoading } = useQuery(instructorBlockedDatesOptions(instructorId))
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: Record<string, unknown>) =>
|
||||
instructorMutations.addBlockedDate(instructorId, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: instructorKeys.blockedDates(instructorId) })
|
||||
toast.success('Blocked date added')
|
||||
setAddOpen(false)
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => instructorMutations.deleteBlockedDate(instructorId, id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: instructorKeys.blockedDates(instructorId) })
|
||||
toast.success('Blocked date removed')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const dates = blockedDates ?? []
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{canAdmin && (
|
||||
<div className="flex justify-end">
|
||||
<Dialog open={addOpen} onOpenChange={setAddOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button><Plus className="mr-2 h-4 w-4" />Add Blocked Date</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>Add Blocked Date</DialogTitle></DialogHeader>
|
||||
<BlockedDateForm onSubmit={createMutation.mutate} loading={createMutation.isPending} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-sm text-muted-foreground">Loading...</div>
|
||||
) : dates.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground text-center py-8 border rounded-md">
|
||||
No blocked dates configured.
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y border rounded-md">
|
||||
{dates.map((d) => (
|
||||
<div key={d.id} className="flex items-center justify-between px-4 py-3">
|
||||
<div>
|
||||
<div className="font-medium text-sm">
|
||||
{new Date(d.startDate + 'T00:00:00').toLocaleDateString()} –{' '}
|
||||
{new Date(d.endDate + 'T00:00:00').toLocaleDateString()}
|
||||
</div>
|
||||
{d.reason && <div className="text-xs text-muted-foreground">{d.reason}</div>}
|
||||
</div>
|
||||
{canAdmin && (
|
||||
<Button variant="ghost" size="sm" onClick={() => deleteMutation.mutate(d.id)}>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
import { useState } from 'react'
|
||||
import { createFileRoute, useNavigate, Link } from '@tanstack/react-router'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
sessionDetailOptions, sessionMutations, sessionKeys,
|
||||
sessionPlanItemsOptions,
|
||||
enrollmentDetailOptions,
|
||||
instructorDetailOptions, instructorListOptions,
|
||||
lessonPlanListOptions,
|
||||
} from '@/api/lessons'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { ArrowLeft, CheckSquare, Square } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import type { LessonPlan, LessonPlanSection } from '@/types/lesson'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/lessons/sessions/$sessionId')({
|
||||
component: SessionDetailPage,
|
||||
})
|
||||
|
||||
const STATUS_ACTIONS: Record<string, { label: string; next: string; variant: 'default' | 'destructive' | 'secondary' | 'outline' }[]> = {
|
||||
scheduled: [
|
||||
{ label: 'Mark Attended', next: 'attended', variant: 'default' },
|
||||
{ label: 'Mark Missed', next: 'missed', variant: 'destructive' },
|
||||
{ label: 'Cancel', next: 'cancelled', variant: 'secondary' },
|
||||
],
|
||||
attended: [],
|
||||
missed: [],
|
||||
makeup: [
|
||||
{ label: 'Mark Attended', next: 'attended', variant: 'default' },
|
||||
{ label: 'Cancel', next: 'cancelled', variant: 'secondary' },
|
||||
],
|
||||
cancelled: [],
|
||||
}
|
||||
|
||||
function sessionStatusBadge(status: string) {
|
||||
const variants: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||
scheduled: 'outline', attended: 'default', missed: 'destructive', makeup: 'secondary', cancelled: 'secondary',
|
||||
}
|
||||
return <Badge variant={variants[status] ?? 'outline'}>{status}</Badge>
|
||||
}
|
||||
|
||||
function formatTime(t: string) {
|
||||
const [h, m] = t.split(':').map(Number)
|
||||
const ampm = h >= 12 ? 'PM' : 'AM'
|
||||
const hour = h % 12 || 12
|
||||
return `${hour}:${String(m).padStart(2, '0')} ${ampm}`
|
||||
}
|
||||
|
||||
function SessionDetailPage() {
|
||||
const { sessionId } = Route.useParams()
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const hasPermission = useAuthStore((s) => s.hasPermission)
|
||||
const canEdit = hasPermission('lessons.edit')
|
||||
|
||||
const { data: session, isLoading } = useQuery(sessionDetailOptions(sessionId))
|
||||
|
||||
const { data: enrollment } = useQuery({
|
||||
...enrollmentDetailOptions(session?.enrollmentId ?? ''),
|
||||
enabled: !!session?.enrollmentId,
|
||||
})
|
||||
|
||||
const { data: instructorData } = useQuery({
|
||||
...instructorDetailOptions(session?.substituteInstructorId ?? enrollment?.instructorId ?? ''),
|
||||
enabled: !!(session?.substituteInstructorId ?? enrollment?.instructorId),
|
||||
})
|
||||
|
||||
const { data: instructorsList } = useQuery(instructorListOptions({ page: 1, limit: 100, order: 'asc' }))
|
||||
|
||||
const { data: planItems } = useQuery(sessionPlanItemsOptions(sessionId))
|
||||
|
||||
const { data: plansData } = useQuery({
|
||||
...lessonPlanListOptions({ enrollmentId: session?.enrollmentId ?? '', isActive: true }),
|
||||
enabled: !!session?.enrollmentId,
|
||||
})
|
||||
const activePlan: LessonPlan | undefined = plansData?.data?.[0]
|
||||
|
||||
const statusMutation = useMutation({
|
||||
mutationFn: (status: string) => sessionMutations.updateStatus(sessionId, status),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: sessionKeys.detail(sessionId) })
|
||||
toast.success('Status updated')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const notesMutation = useMutation({
|
||||
mutationFn: (data: Record<string, unknown>) => sessionMutations.updateNotes(sessionId, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: sessionKeys.detail(sessionId) })
|
||||
toast.success('Notes saved')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const subMutation = useMutation({
|
||||
mutationFn: (subId: string | null) => sessionMutations.update(sessionId, { substituteInstructorId: subId }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: sessionKeys.detail(sessionId) })
|
||||
toast.success('Substitute updated')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const linkPlanItemsMutation = useMutation({
|
||||
mutationFn: (ids: string[]) => sessionMutations.linkPlanItems(sessionId, ids),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: sessionKeys.planItems(sessionId) })
|
||||
toast.success('Plan items linked')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
if (isLoading) return <div className="text-sm text-muted-foreground">Loading...</div>
|
||||
if (!session) return <div className="text-sm text-destructive">Session not found.</div>
|
||||
|
||||
const linkedItemIds = new Set(planItems?.map((pi) => pi.lessonPlanItemId) ?? [])
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-3xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/lessons/sessions', search: {} as any })}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex-1">
|
||||
<h1 className="text-xl font-bold">
|
||||
{new Date(session.scheduledDate + 'T00:00:00').toLocaleDateString()} · {formatTime(session.scheduledTime)}
|
||||
</h1>
|
||||
{enrollment && (
|
||||
<Link
|
||||
to="/lessons/enrollments/$enrollmentId"
|
||||
params={{ enrollmentId: enrollment.id }}
|
||||
search={{} as any}
|
||||
className="text-sm text-primary hover:underline"
|
||||
>
|
||||
View Enrollment
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
{sessionStatusBadge(session.status)}
|
||||
</div>
|
||||
|
||||
{/* Status Actions */}
|
||||
{canEdit && (STATUS_ACTIONS[session.status]?.length ?? 0) > 0 && (
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex gap-2">
|
||||
{STATUS_ACTIONS[session.status].map((action) => (
|
||||
<Button
|
||||
key={action.next}
|
||||
variant={action.variant}
|
||||
size="sm"
|
||||
onClick={() => statusMutation.mutate(action.next)}
|
||||
disabled={statusMutation.isPending}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Substitute Instructor */}
|
||||
{canEdit && (
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-base">Substitute Instructor</CardTitle></CardHeader>
|
||||
<CardContent className="flex gap-3 items-center">
|
||||
<Select
|
||||
value={session.substituteInstructorId ?? 'none'}
|
||||
onValueChange={(v) => subMutation.mutate(v === 'none' ? null : v)}
|
||||
>
|
||||
<SelectTrigger className="flex-1">
|
||||
<SelectValue placeholder="No substitute" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">No substitute</SelectItem>
|
||||
{(instructorsList?.data ?? []).map((i) => (
|
||||
<SelectItem key={i.id} value={i.id}>{i.displayName}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Post-lesson Notes */}
|
||||
<NotesCard session={session} canEdit={canEdit} onSave={notesMutation.mutate} saving={notesMutation.isPending} />
|
||||
|
||||
{/* Plan Items */}
|
||||
{activePlan && (
|
||||
<PlanItemsCard
|
||||
plan={activePlan}
|
||||
linkedItemIds={linkedItemIds}
|
||||
onLink={(ids) => linkPlanItemsMutation.mutate(ids)}
|
||||
linking={linkPlanItemsMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Notes Card ───────────────────────────────────────────────────────────────
|
||||
|
||||
function NotesCard({ session, canEdit, onSave, saving }: any) {
|
||||
const [instructorNotes, setInstructorNotes] = useState(session.instructorNotes ?? '')
|
||||
const [memberNotes, setMemberNotes] = useState(session.memberNotes ?? '')
|
||||
const [homeworkAssigned, setHomeworkAssigned] = useState(session.homeworkAssigned ?? '')
|
||||
const [nextLessonGoals, setNextLessonGoals] = useState(session.nextLessonGoals ?? '')
|
||||
const [topicsCovered, setTopicsCovered] = useState((session.topicsCovered ?? []).join(', '))
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base">Post-lesson Notes</CardTitle>
|
||||
{session.notesCompletedAt && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Saved {new Date(session.notesCompletedAt).toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Instructor Notes</Label>
|
||||
<Textarea value={instructorNotes} onChange={(e) => setInstructorNotes(e.target.value)} rows={3} disabled={!canEdit} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Member Notes (shared with student)</Label>
|
||||
<Textarea value={memberNotes} onChange={(e) => setMemberNotes(e.target.value)} rows={2} disabled={!canEdit} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Homework Assigned</Label>
|
||||
<Input value={homeworkAssigned} onChange={(e) => setHomeworkAssigned(e.target.value)} disabled={!canEdit} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Next Lesson Goals</Label>
|
||||
<Input value={nextLessonGoals} onChange={(e) => setNextLessonGoals(e.target.value)} disabled={!canEdit} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Topics Covered</Label>
|
||||
<Input
|
||||
value={topicsCovered}
|
||||
onChange={(e) => setTopicsCovered(e.target.value)}
|
||||
placeholder="Comma-separated topics"
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
{canEdit && (
|
||||
<Button
|
||||
onClick={() => onSave({
|
||||
instructorNotes: instructorNotes || undefined,
|
||||
memberNotes: memberNotes || undefined,
|
||||
homeworkAssigned: homeworkAssigned || undefined,
|
||||
nextLessonGoals: nextLessonGoals || undefined,
|
||||
topicsCovered: topicsCovered ? topicsCovered.split(',').map((s: string) => s.trim()).filter(Boolean) : undefined,
|
||||
})}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save Notes'}
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Plan Items Card ──────────────────────────────────────────────────────────
|
||||
|
||||
function PlanItemsCard({ plan, linkedItemIds, onLink, linking }: {
|
||||
plan: LessonPlan
|
||||
linkedItemIds: Set<string>
|
||||
onLink: (ids: string[]) => void
|
||||
linking: boolean
|
||||
}) {
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set(linkedItemIds))
|
||||
|
||||
function toggle(id: string) {
|
||||
if (linkedItemIds.has(id)) return // already committed
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const newSelections = [...selected].filter((id) => !linkedItemIds.has(id))
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-base">Plan Items Worked On</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{(plan.sections ?? []).map((section: LessonPlanSection) => (
|
||||
<div key={section.id}>
|
||||
<p className="text-sm font-semibold text-muted-foreground mb-2">{section.title}</p>
|
||||
<div className="space-y-1">
|
||||
{(section.items ?? []).map((item) => {
|
||||
const isLinked = linkedItemIds.has(item.id)
|
||||
const isSelected = selected.has(item.id)
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className={`flex items-center gap-2 w-full text-left px-2 py-1.5 rounded text-sm transition-colors ${
|
||||
isLinked ? 'opacity-60 cursor-default' : 'hover:bg-accent cursor-pointer'
|
||||
}`}
|
||||
onClick={() => toggle(item.id)}
|
||||
disabled={isLinked}
|
||||
>
|
||||
{isLinked || isSelected
|
||||
? <CheckSquare className="h-4 w-4 text-primary shrink-0" />
|
||||
: <Square className="h-4 w-4 text-muted-foreground shrink-0" />}
|
||||
<span>{item.title}</span>
|
||||
{isLinked && <span className="text-xs text-muted-foreground ml-auto">linked</span>}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{newSelections.length > 0 && (
|
||||
<Button onClick={() => onLink(newSelections)} disabled={linking} size="sm">
|
||||
{linking ? 'Linking...' : `Link ${newSelections.length} item${newSelections.length !== 1 ? 's' : ''}`}
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
import { useState } from 'react'
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { format, startOfWeek, endOfWeek, addWeeks, subWeeks, addDays, isSameDay } from 'date-fns'
|
||||
import { sessionListOptions } from '@/api/lessons'
|
||||
import { instructorListOptions } from '@/api/lessons'
|
||||
import { usePagination } from '@/hooks/use-pagination'
|
||||
import { DataTable, type Column } from '@/components/shared/data-table'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Search, LayoutList, CalendarDays, ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import type { LessonSession } from '@/types/lesson'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/lessons/sessions/')({
|
||||
validateSearch: (search: Record<string, unknown>) => ({
|
||||
view: (search.view as 'list' | 'week') || 'list',
|
||||
page: Number(search.page) || 1,
|
||||
limit: Number(search.limit) || 25,
|
||||
q: (search.q as string) || undefined,
|
||||
sort: (search.sort as string) || undefined,
|
||||
order: (search.order as 'asc' | 'desc') || 'desc',
|
||||
status: (search.status as string) || undefined,
|
||||
instructorId: (search.instructorId as string) || undefined,
|
||||
}),
|
||||
component: SessionsPage,
|
||||
})
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
attended: 'bg-green-100 border-green-400 text-green-800',
|
||||
missed: 'bg-red-100 border-red-400 text-red-800',
|
||||
cancelled: 'bg-gray-100 border-gray-300 text-gray-500',
|
||||
makeup: 'bg-purple-100 border-purple-400 text-purple-800',
|
||||
scheduled: 'bg-blue-100 border-blue-400 text-blue-800',
|
||||
}
|
||||
|
||||
function sessionStatusBadge(status: string) {
|
||||
const variants: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||
scheduled: 'outline', attended: 'default', missed: 'destructive', makeup: 'secondary', cancelled: 'secondary',
|
||||
}
|
||||
return <Badge variant={variants[status] ?? 'outline'}>{status}</Badge>
|
||||
}
|
||||
|
||||
function formatTime(t: string) {
|
||||
const [h, m] = t.split(':').map(Number)
|
||||
const ampm = h >= 12 ? 'PM' : 'AM'
|
||||
const hour = h % 12 || 12
|
||||
return `${hour}:${String(m).padStart(2, '0')} ${ampm}`
|
||||
}
|
||||
|
||||
const listColumns: Column<LessonSession>[] = [
|
||||
{
|
||||
key: 'scheduled_date', header: 'Date', sortable: true,
|
||||
render: (s) => <>{new Date(s.scheduledDate + 'T00:00:00').toLocaleDateString()}</>,
|
||||
},
|
||||
{
|
||||
key: 'scheduled_time', header: 'Time',
|
||||
render: (s) => <>{formatTime(s.scheduledTime)}</>,
|
||||
},
|
||||
{
|
||||
key: 'member_name', header: 'Member',
|
||||
render: (s) => <span className="font-medium">{s.memberName ?? '—'}</span>,
|
||||
},
|
||||
{
|
||||
key: 'instructor_name', header: 'Instructor',
|
||||
render: (s) => <>{s.instructorName ?? '—'}</>,
|
||||
},
|
||||
{
|
||||
key: 'lesson_type', header: 'Lesson',
|
||||
render: (s) => <>{s.lessonTypeName ?? '—'}</>,
|
||||
},
|
||||
{ key: 'status', header: 'Status', sortable: true, render: (s) => sessionStatusBadge(s.status) },
|
||||
{
|
||||
key: 'notes', header: 'Notes',
|
||||
render: (s) => s.notesCompletedAt ? <Badge variant="outline" className="text-xs">Notes</Badge> : null,
|
||||
},
|
||||
]
|
||||
|
||||
const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
|
||||
|
||||
function SessionsPage() {
|
||||
const navigate = useNavigate()
|
||||
const search = Route.useSearch()
|
||||
const view = search.view ?? 'list'
|
||||
const { params, setPage, setSearch, setSort } = usePagination()
|
||||
const [searchInput, setSearchInput] = useState(params.q ?? '')
|
||||
const [statusFilter, setStatusFilter] = useState(search.status ?? '')
|
||||
const [weekStart, setWeekStart] = useState(() => startOfWeek(new Date(), { weekStartsOn: 0 }))
|
||||
const [weekInstructorId, setWeekInstructorId] = useState(search.instructorId ?? '')
|
||||
|
||||
const weekEnd = endOfWeek(weekStart, { weekStartsOn: 0 })
|
||||
|
||||
function setView(v: 'list' | 'week') {
|
||||
navigate({ to: '/lessons/sessions', search: { ...search, view: v, page: 1 } as any })
|
||||
}
|
||||
|
||||
function handleStatusChange(v: string) {
|
||||
const s = v === 'all' ? '' : v
|
||||
setStatusFilter(s)
|
||||
navigate({ to: '/lessons/sessions', search: { ...search, status: s || undefined, page: 1 } as any })
|
||||
}
|
||||
|
||||
// List query
|
||||
const listQueryParams: Record<string, unknown> = { ...params }
|
||||
if (statusFilter) listQueryParams.status = statusFilter
|
||||
const { data: listData, isLoading: listLoading } = useQuery({
|
||||
...sessionListOptions(listQueryParams),
|
||||
enabled: view === 'list',
|
||||
})
|
||||
|
||||
// Week query
|
||||
const weekQueryParams: Record<string, unknown> = {
|
||||
page: 1, limit: 100,
|
||||
sort: 'scheduled_date', order: 'asc',
|
||||
dateFrom: format(weekStart, 'yyyy-MM-dd'),
|
||||
dateTo: format(weekEnd, 'yyyy-MM-dd'),
|
||||
}
|
||||
if (weekInstructorId) weekQueryParams.instructorId = weekInstructorId
|
||||
const { data: weekData } = useQuery({
|
||||
...sessionListOptions(weekQueryParams),
|
||||
enabled: view === 'week',
|
||||
})
|
||||
|
||||
const { data: instructorsData } = useQuery({
|
||||
...instructorListOptions({ page: 1, limit: 100, order: 'asc' }),
|
||||
enabled: view === 'week',
|
||||
})
|
||||
|
||||
function handleSearchSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setSearch(searchInput)
|
||||
}
|
||||
|
||||
const weekSessions = weekData?.data ?? []
|
||||
const weekDays = Array.from({ length: 7 }, (_, i) => addDays(weekStart, i))
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Sessions</h1>
|
||||
<div className="flex gap-1 border rounded-md p-1">
|
||||
<Button variant={view === 'list' ? 'default' : 'ghost'} size="sm" onClick={() => setView('list')}>
|
||||
<LayoutList className="h-4 w-4 mr-1" />List
|
||||
</Button>
|
||||
<Button variant={view === 'week' ? 'default' : 'ghost'} size="sm" onClick={() => setView('week')}>
|
||||
<CalendarDays className="h-4 w-4 mr-1" />Week
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{view === 'list' && (
|
||||
<>
|
||||
<div className="flex gap-3 flex-wrap">
|
||||
<form onSubmit={handleSearchSubmit} className="flex gap-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search sessions..."
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
className="pl-9 w-64"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" variant="secondary">Search</Button>
|
||||
</form>
|
||||
<Select value={statusFilter || 'all'} onValueChange={handleStatusChange}>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue placeholder="All Statuses" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Statuses</SelectItem>
|
||||
<SelectItem value="scheduled">Scheduled</SelectItem>
|
||||
<SelectItem value="attended">Attended</SelectItem>
|
||||
<SelectItem value="missed">Missed</SelectItem>
|
||||
<SelectItem value="makeup">Makeup</SelectItem>
|
||||
<SelectItem value="cancelled">Cancelled</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<DataTable
|
||||
columns={listColumns}
|
||||
data={listData?.data ?? []}
|
||||
loading={listLoading}
|
||||
page={params.page}
|
||||
totalPages={listData?.pagination.totalPages ?? 1}
|
||||
total={listData?.pagination.total ?? 0}
|
||||
sort={params.sort}
|
||||
order={params.order}
|
||||
onPageChange={setPage}
|
||||
onSort={setSort}
|
||||
onRowClick={(s) => navigate({ to: '/lessons/sessions/$sessionId', params: { sessionId: s.id }, search: {} as any })}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{view === 'week' && (
|
||||
<div className="space-y-4">
|
||||
{/* Week nav + instructor filter */}
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="outline" size="icon" onClick={() => setWeekStart(subWeeks(weekStart, 1))}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setWeekStart(startOfWeek(new Date(), { weekStartsOn: 0 }))}>
|
||||
This Week
|
||||
</Button>
|
||||
<Button variant="outline" size="icon" onClick={() => setWeekStart(addWeeks(weekStart, 1))}>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
{format(weekStart, 'MMM d')} – {format(weekEnd, 'MMM d, yyyy')}
|
||||
</span>
|
||||
<Select value={weekInstructorId || 'all'} onValueChange={(v) => setWeekInstructorId(v === 'all' ? '' : v)}>
|
||||
<SelectTrigger className="w-48">
|
||||
<SelectValue placeholder="All Instructors" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Instructors</SelectItem>
|
||||
{(instructorsData?.data ?? []).map((i) => (
|
||||
<SelectItem key={i.id} value={i.id}>{i.displayName}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Week grid */}
|
||||
<div className="grid grid-cols-7 gap-px bg-border rounded-lg overflow-hidden border">
|
||||
{/* Day headers */}
|
||||
{weekDays.map((day) => {
|
||||
const isToday = isSameDay(day, new Date())
|
||||
return (
|
||||
<div key={day.toISOString()} className={`bg-muted/50 px-2 py-1.5 text-center ${isToday ? 'bg-primary/10' : ''}`}>
|
||||
<p className="text-xs font-medium text-muted-foreground">{DAYS[day.getDay()]}</p>
|
||||
<p className={`text-sm font-semibold ${isToday ? 'text-primary' : ''}`}>{format(day, 'd')}</p>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{/* Session cells */}
|
||||
{weekDays.map((day) => {
|
||||
const daySessions = weekSessions.filter((s) => s.scheduledDate === format(day, 'yyyy-MM-dd'))
|
||||
const isToday = isSameDay(day, new Date())
|
||||
return (
|
||||
<div key={day.toISOString()} className={`bg-background min-h-32 p-1.5 space-y-1 ${isToday ? 'bg-primary/5' : ''}`}>
|
||||
{daySessions.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground/40 text-center pt-4">—</p>
|
||||
)}
|
||||
{daySessions.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
onClick={() => navigate({ to: '/lessons/sessions/$sessionId', params: { sessionId: s.id }, search: {} as any })}
|
||||
className={`w-full text-left rounded border px-1.5 py-1 text-xs hover:opacity-80 transition-opacity ${STATUS_COLORS[s.status] ?? STATUS_COLORS.scheduled}`}
|
||||
>
|
||||
<p className="font-semibold">{formatTime(s.scheduledTime)}</p>
|
||||
<p className="truncate">{s.memberName ?? '—'}</p>
|
||||
{s.lessonTypeName && <p className="truncate text-[10px] opacity-70">{s.lessonTypeName}</p>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex gap-3 flex-wrap text-xs text-muted-foreground">
|
||||
{Object.entries(STATUS_COLORS).map(([status, cls]) => (
|
||||
<span key={status} className={`inline-flex items-center gap-1 px-2 py-0.5 rounded border ${cls}`}>
|
||||
{status}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
import { useState } from 'react'
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
lessonPlanTemplateDetailOptions, lessonPlanTemplateMutations, lessonPlanTemplateKeys,
|
||||
enrollmentListOptions,
|
||||
} from '@/api/lessons'
|
||||
import { globalMemberListOptions } from '@/api/members'
|
||||
import { TemplateSectionBuilder, type TemplateSectionRow } from '@/components/lessons/template-section-builder'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { ArrowLeft, Search, X, Zap } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import type { LessonPlanTemplate } from '@/types/lesson'
|
||||
import type { MemberWithAccount } from '@/api/members'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/lessons/templates/$templateId')({
|
||||
component: TemplateDetailPage,
|
||||
})
|
||||
|
||||
function TemplateDetailPage() {
|
||||
const { templateId } = Route.useParams()
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const hasPermission = useAuthStore((s) => s.hasPermission)
|
||||
const canAdmin = hasPermission('lessons.admin')
|
||||
|
||||
const { data: template, isLoading } = useQuery(lessonPlanTemplateDetailOptions(templateId))
|
||||
|
||||
const [instantiateOpen, setInstantiateOpen] = useState(false)
|
||||
|
||||
if (isLoading) return <div className="text-sm text-muted-foreground">Loading...</div>
|
||||
if (!template) return <div className="text-sm text-destructive">Template not found.</div>
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-3xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/lessons/templates', search: {} as any })}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex-1">
|
||||
<h1 className="text-2xl font-bold">{template.name}</h1>
|
||||
{template.instrument && <p className="text-sm text-muted-foreground">{template.instrument}</p>}
|
||||
</div>
|
||||
<Badge variant={template.isActive ? 'default' : 'secondary'}>{template.isActive ? 'Active' : 'Inactive'}</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={() => setInstantiateOpen(true)}>
|
||||
<Zap className="h-4 w-4 mr-2" />Instantiate for Student
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{canAdmin && (
|
||||
<EditTemplateForm template={template} templateId={templateId} queryClient={queryClient} />
|
||||
)}
|
||||
|
||||
{/* Read-only curriculum preview */}
|
||||
{!canAdmin && (
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-lg">Curriculum</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{template.sections.map((section) => (
|
||||
<div key={section.id}>
|
||||
<p className="font-semibold text-sm">{section.title}</p>
|
||||
<ul className="mt-1 space-y-0.5 pl-4">
|
||||
{section.items.map((item) => (
|
||||
<li key={item.id} className="text-sm text-muted-foreground list-disc">{item.title}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<InstantiateDialog
|
||||
template={template}
|
||||
templateId={templateId}
|
||||
open={instantiateOpen}
|
||||
onClose={() => setInstantiateOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Edit Form ────────────────────────────────────────────────────────────────
|
||||
|
||||
function EditTemplateForm({ template, templateId, queryClient }: { template: LessonPlanTemplate; templateId: string; queryClient: any }) {
|
||||
const [name, setName] = useState(template.name)
|
||||
const [description, setDescription] = useState(template.description ?? '')
|
||||
const [instrument, setInstrument] = useState(template.instrument ?? '')
|
||||
const [skillLevel, setSkillLevel] = useState<'beginner' | 'intermediate' | 'advanced' | 'all_levels'>(template.skillLevel)
|
||||
const [sections, setSections] = useState<TemplateSectionRow[]>(
|
||||
template.sections.map((s) => ({
|
||||
id: s.id,
|
||||
title: s.title,
|
||||
description: s.description ?? '',
|
||||
items: s.items.map((i) => ({ id: i.id, title: i.title, description: i.description ?? '' })),
|
||||
})),
|
||||
)
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
lessonPlanTemplateMutations.update(templateId, {
|
||||
name,
|
||||
description: description || undefined,
|
||||
instrument: instrument || undefined,
|
||||
skillLevel,
|
||||
sections: sections.map((s, sIdx) => ({
|
||||
title: s.title,
|
||||
description: s.description || undefined,
|
||||
sortOrder: sIdx,
|
||||
items: s.items.map((item, iIdx) => ({
|
||||
title: item.title,
|
||||
description: item.description || undefined,
|
||||
sortOrder: iIdx,
|
||||
})),
|
||||
})),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: lessonPlanTemplateKeys.detail(templateId) })
|
||||
toast.success('Template updated')
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const allValid = name.trim() && sections.every((s) => s.title.trim() && s.items.every((i) => i.title.trim()))
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={(e) => { e.preventDefault(); updateMutation.mutate() }}
|
||||
className="space-y-6"
|
||||
>
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-lg">Details</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Name *</Label>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Description</Label>
|
||||
<Textarea value={description} onChange={(e) => setDescription(e.target.value)} rows={2} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Instrument</Label>
|
||||
<Input value={instrument} onChange={(e) => setInstrument(e.target.value)} placeholder="e.g. Piano" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Skill Level</Label>
|
||||
<Select value={skillLevel} onValueChange={(v) => setSkillLevel(v as 'beginner' | 'intermediate' | 'advanced' | 'all_levels')}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="beginner">Beginner</SelectItem>
|
||||
<SelectItem value="intermediate">Intermediate</SelectItem>
|
||||
<SelectItem value="advanced">Advanced</SelectItem>
|
||||
<SelectItem value="all_levels">All Levels</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-lg">Curriculum</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<TemplateSectionBuilder sections={sections} onChange={setSections} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Button type="submit" disabled={updateMutation.isPending || !allValid}>
|
||||
{updateMutation.isPending ? 'Saving...' : 'Save Changes'}
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Instantiate Dialog ───────────────────────────────────────────────────────
|
||||
|
||||
function InstantiateDialog({ template, templateId, open, onClose }: {
|
||||
template: LessonPlanTemplate
|
||||
templateId: string
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}) {
|
||||
const navigate = useNavigate()
|
||||
const [memberSearch, setMemberSearch] = useState('')
|
||||
const [showDropdown, setShowDropdown] = useState(false)
|
||||
const [selectedMember, setSelectedMember] = useState<MemberWithAccount | null>(null)
|
||||
const [selectedEnrollmentId, setSelectedEnrollmentId] = useState('')
|
||||
const [customTitle, setCustomTitle] = useState('')
|
||||
|
||||
const { data: membersData } = useQuery(
|
||||
globalMemberListOptions({ page: 1, limit: 20, q: memberSearch || undefined, order: 'asc', sort: 'first_name' }),
|
||||
)
|
||||
|
||||
const { data: enrollmentsData } = useQuery({
|
||||
...enrollmentListOptions({ memberId: selectedMember?.id ?? '', status: 'active', page: 1, limit: 50 }),
|
||||
enabled: !!selectedMember?.id,
|
||||
})
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () =>
|
||||
lessonPlanTemplateMutations.createPlan(templateId, {
|
||||
memberId: selectedMember!.id,
|
||||
enrollmentId: selectedEnrollmentId || undefined,
|
||||
title: customTitle || undefined,
|
||||
}),
|
||||
onSuccess: (plan) => {
|
||||
toast.success('Plan created from template')
|
||||
navigate({ to: '/lessons/plans/$planId', params: { planId: plan.id }, search: {} as any })
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const members = membersData?.data ?? []
|
||||
const enrollments = enrollmentsData?.data ?? []
|
||||
|
||||
function reset() {
|
||||
setMemberSearch('')
|
||||
setSelectedMember(null)
|
||||
setSelectedEnrollmentId('')
|
||||
setCustomTitle('')
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => { if (!o) { reset(); onClose() } }}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Plan from "{template.name}"</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
{/* Member select */}
|
||||
{!selectedMember ? (
|
||||
<div className="relative">
|
||||
<Label>Student *</Label>
|
||||
<div className="relative mt-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search member..."
|
||||
value={memberSearch}
|
||||
onChange={(e) => { setMemberSearch(e.target.value); setShowDropdown(true) }}
|
||||
onFocus={() => setShowDropdown(true)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
{showDropdown && memberSearch && (
|
||||
<div className="absolute z-50 mt-1 w-full rounded-md border bg-popover shadow-lg max-h-48 overflow-auto">
|
||||
{members.length === 0 ? (
|
||||
<div className="p-3 text-sm text-muted-foreground">No members found</div>
|
||||
) : (
|
||||
members.map((m) => (
|
||||
<button
|
||||
key={m.id}
|
||||
type="button"
|
||||
className="w-full text-left px-3 py-2 text-sm hover:bg-accent"
|
||||
onClick={() => { setSelectedMember(m); setShowDropdown(false); setMemberSearch('') }}
|
||||
>
|
||||
<span className="font-medium">{m.firstName} {m.lastName}</span>
|
||||
{m.accountName && <span className="text-muted-foreground ml-2">— {m.accountName}</span>}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<Label>Student</Label>
|
||||
<div className="flex items-center justify-between mt-1 p-2 rounded-md border bg-muted/30">
|
||||
<p className="text-sm font-medium">{selectedMember.firstName} {selectedMember.lastName}</p>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => { setSelectedMember(null); setSelectedEnrollmentId('') }}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedMember && enrollments.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label>Enrollment (optional)</Label>
|
||||
<Select value={selectedEnrollmentId || 'none'} onValueChange={(v) => setSelectedEnrollmentId(v === 'none' ? '' : v)}>
|
||||
<SelectTrigger><SelectValue placeholder="Not linked to enrollment" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">Not linked to enrollment</SelectItem>
|
||||
{enrollments.map((e: any) => (
|
||||
<SelectItem key={e.id} value={e.id}>Enrollment {e.id.slice(-6)}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Custom Title</Label>
|
||||
<Input value={customTitle} onChange={(e) => setCustomTitle(e.target.value)} placeholder={`Leave blank to use "${template.name}"`} />
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => mutation.mutate()}
|
||||
disabled={!selectedMember || mutation.isPending}
|
||||
className="w-full"
|
||||
>
|
||||
{mutation.isPending ? 'Creating...' : 'Create Plan'}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { useState } from 'react'
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { lessonPlanTemplateListOptions, lessonPlanTemplateMutations, lessonPlanTemplateKeys } from '@/api/lessons'
|
||||
import { usePagination } from '@/hooks/use-pagination'
|
||||
import { DataTable, type Column } from '@/components/shared/data-table'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Plus, Search, Trash2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import type { LessonPlanTemplate } from '@/types/lesson'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/lessons/templates/')({
|
||||
validateSearch: (search: Record<string, unknown>) => ({
|
||||
page: Number(search.page) || 1,
|
||||
limit: Number(search.limit) || 25,
|
||||
q: (search.q as string) || undefined,
|
||||
sort: (search.sort as string) || undefined,
|
||||
order: (search.order as 'asc' | 'desc') || 'asc',
|
||||
}),
|
||||
component: TemplatesListPage,
|
||||
})
|
||||
|
||||
const SKILL_LABELS: Record<string, string> = {
|
||||
beginner: 'Beginner',
|
||||
intermediate: 'Intermediate',
|
||||
advanced: 'Advanced',
|
||||
all_levels: 'All Levels',
|
||||
}
|
||||
|
||||
const SKILL_VARIANTS: Record<string, 'default' | 'secondary' | 'outline'> = {
|
||||
beginner: 'outline',
|
||||
intermediate: 'secondary',
|
||||
advanced: 'default',
|
||||
all_levels: 'outline',
|
||||
}
|
||||
|
||||
const columns: Column<LessonPlanTemplate>[] = [
|
||||
{ key: 'name', header: 'Name', sortable: true, render: (t) => <span className="font-medium">{t.name}</span> },
|
||||
{ key: 'instrument', header: 'Instrument', render: (t) => <>{t.instrument ?? <span className="text-muted-foreground">—</span>}</> },
|
||||
{
|
||||
key: 'skill_level', header: 'Level', sortable: true,
|
||||
render: (t) => <Badge variant={SKILL_VARIANTS[t.skillLevel] ?? 'outline'}>{SKILL_LABELS[t.skillLevel] ?? t.skillLevel}</Badge>,
|
||||
},
|
||||
{
|
||||
key: 'sections', header: 'Sections',
|
||||
render: (t) => <>{t.sections?.length ?? 0} sections</>,
|
||||
},
|
||||
{
|
||||
key: 'is_active', header: 'Status',
|
||||
render: (t) => <Badge variant={t.isActive ? 'default' : 'secondary'}>{t.isActive ? 'Active' : 'Inactive'}</Badge>,
|
||||
},
|
||||
]
|
||||
|
||||
function TemplatesListPage() {
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const hasPermission = useAuthStore((s) => s.hasPermission)
|
||||
const canAdmin = hasPermission('lessons.admin')
|
||||
const { params, setPage, setSearch, setSort } = usePagination()
|
||||
const [searchInput, setSearchInput] = useState(params.q ?? '')
|
||||
|
||||
const { data, isLoading } = useQuery(lessonPlanTemplateListOptions(params))
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: lessonPlanTemplateMutations.delete,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: lessonPlanTemplateKeys.all })
|
||||
toast.success('Template deleted')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
function handleSearchSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setSearch(searchInput)
|
||||
}
|
||||
|
||||
const columnsWithActions: Column<LessonPlanTemplate>[] = [
|
||||
...columns,
|
||||
...(canAdmin ? [{
|
||||
key: 'actions' as any,
|
||||
header: '' as any,
|
||||
render: (t: LessonPlanTemplate) => (
|
||||
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); deleteMutation.mutate(t.id) }}>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
),
|
||||
}] : []),
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Lesson Plan Templates</h1>
|
||||
{canAdmin && (
|
||||
<Button onClick={() => navigate({ to: '/lessons/templates/new', search: {} as any })}>
|
||||
<Plus className="mr-2 h-4 w-4" />New Template
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSearchSubmit} className="flex gap-2 max-w-sm">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search templates..."
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" variant="secondary">Search</Button>
|
||||
</form>
|
||||
|
||||
<DataTable
|
||||
columns={columnsWithActions}
|
||||
data={data?.data ?? []}
|
||||
loading={isLoading}
|
||||
page={params.page}
|
||||
totalPages={data?.pagination.totalPages ?? 1}
|
||||
total={data?.pagination.total ?? 0}
|
||||
sort={params.sort}
|
||||
order={params.order}
|
||||
onPageChange={setPage}
|
||||
onSort={setSort}
|
||||
onRowClick={(t) => navigate({ to: '/lessons/templates/$templateId', params: { templateId: t.id }, search: {} as any })}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { useState } from 'react'
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { lessonPlanTemplateMutations } from '@/api/lessons'
|
||||
import { TemplateSectionBuilder, type TemplateSectionRow } from '@/components/lessons/template-section-builder'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/lessons/templates/new')({
|
||||
component: NewTemplatePage,
|
||||
})
|
||||
|
||||
function NewTemplatePage() {
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [name, setName] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [instrument, setInstrument] = useState('')
|
||||
const [skillLevel, setSkillLevel] = useState('all_levels')
|
||||
const [sections, setSections] = useState<TemplateSectionRow[]>([])
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () =>
|
||||
lessonPlanTemplateMutations.create({
|
||||
name,
|
||||
description: description || undefined,
|
||||
instrument: instrument || undefined,
|
||||
skillLevel,
|
||||
sections: sections.map((s, sIdx) => ({
|
||||
title: s.title,
|
||||
description: s.description || undefined,
|
||||
sortOrder: sIdx,
|
||||
items: s.items.map((item, iIdx) => ({
|
||||
title: item.title,
|
||||
description: item.description || undefined,
|
||||
sortOrder: iIdx,
|
||||
})),
|
||||
})),
|
||||
}),
|
||||
onSuccess: (template) => {
|
||||
toast.success('Template created')
|
||||
navigate({ to: '/lessons/templates/$templateId', params: { templateId: template.id }, search: {} as any })
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!name.trim()) return
|
||||
mutation.mutate()
|
||||
}
|
||||
|
||||
const allSectionsValid = sections.every(
|
||||
(s) => s.title.trim() && s.items.every((i) => i.title.trim()),
|
||||
)
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6 max-w-3xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => navigate({ to: '/lessons/templates', search: {} as any })}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<h1 className="text-2xl font-bold">New Template</h1>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-lg">Details</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Name *</Label>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="e.g. Piano Foundations — Beginner" required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Description</Label>
|
||||
<Textarea value={description} onChange={(e) => setDescription(e.target.value)} rows={2} placeholder="What this curriculum covers..." />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Instrument</Label>
|
||||
<Input value={instrument} onChange={(e) => setInstrument(e.target.value)} placeholder="e.g. Piano, Guitar" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Skill Level</Label>
|
||||
<Select value={skillLevel} onValueChange={setSkillLevel}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="beginner">Beginner</SelectItem>
|
||||
<SelectItem value="intermediate">Intermediate</SelectItem>
|
||||
<SelectItem value="advanced">Advanced</SelectItem>
|
||||
<SelectItem value="all_levels">All Levels</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-lg">Curriculum</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<TemplateSectionBuilder sections={sections} onChange={setSections} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" disabled={mutation.isPending || !name.trim() || !allSectionsValid} size="lg">
|
||||
{mutation.isPending ? 'Creating...' : 'Create Template'}
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" size="lg" onClick={() => navigate({ to: '/lessons/templates', search: {} as any })}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -1,22 +1,26 @@
|
||||
import { useState } from 'react'
|
||||
import { createFileRoute, useParams, useNavigate, Link } from '@tanstack/react-router'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { api } from '@/lib/api-client'
|
||||
import { queryOptions } from '@tanstack/react-query'
|
||||
import { identifierListOptions, identifierMutations, identifierKeys } from '@/api/identifiers'
|
||||
import { enrollmentListOptions } from '@/api/lessons'
|
||||
import { moduleListOptions } from '@/api/modules'
|
||||
import { MemberForm } from '@/components/accounts/member-form'
|
||||
import { IdentifierForm, type IdentifierFiles } from '@/components/accounts/identifier-form'
|
||||
import { DataTable, type Column } from '@/components/shared/data-table'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { ArrowLeft, Plus, Trash2, CreditCard } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { AvatarUpload } from '@/components/shared/avatar-upload'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { Member, MemberIdentifier } from '@/types/account'
|
||||
import { useState } from 'react'
|
||||
import { queryOptions } from '@tanstack/react-query'
|
||||
import type { Enrollment } from '@/types/lesson'
|
||||
|
||||
function memberDetailOptions(id: string) {
|
||||
return queryOptions({
|
||||
@@ -26,9 +30,14 @@ function memberDetailOptions(id: string) {
|
||||
}
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/members/$memberId')({
|
||||
validateSearch: (search: Record<string, unknown>) => ({
|
||||
tab: (search.tab as string) || 'details',
|
||||
}),
|
||||
component: MemberDetailPage,
|
||||
})
|
||||
|
||||
// ─── Identifier images ────────────────────────────────────────────────────────
|
||||
|
||||
function IdentifierImages({ identifierId }: { identifierId: string }) {
|
||||
const { data } = useQuery({
|
||||
queryKey: ['files', 'member_identifier', identifierId],
|
||||
@@ -37,13 +46,10 @@ function IdentifierImages({ identifierId }: { identifierId: string }) {
|
||||
entityId: identifierId,
|
||||
}),
|
||||
})
|
||||
|
||||
const files = data?.data ?? []
|
||||
const frontFile = files.find((f) => f.category === 'front')
|
||||
const backFile = files.find((f) => f.category === 'back')
|
||||
|
||||
if (!frontFile && !backFile) return null
|
||||
|
||||
return (
|
||||
<div className="flex gap-2 mt-2">
|
||||
{frontFile && <img src={`/v1/files/serve/${frontFile.path}`} alt="Front" className="h-20 rounded border object-cover" />}
|
||||
@@ -58,16 +64,45 @@ const ID_TYPE_LABELS: Record<string, string> = {
|
||||
school_id: 'School ID',
|
||||
}
|
||||
|
||||
function statusBadge(status: string) {
|
||||
const variants: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||
active: 'default', paused: 'secondary', cancelled: 'destructive', completed: 'outline',
|
||||
}
|
||||
return <Badge variant={variants[status] ?? 'outline'}>{status}</Badge>
|
||||
}
|
||||
|
||||
const enrollmentColumns: Column<Enrollment>[] = [
|
||||
{ key: 'status', header: 'Status', sortable: true, render: (e) => statusBadge(e.status) },
|
||||
{ key: 'instructor_name', header: 'Instructor', render: (e) => <>{(e as any).instructorName ?? e.instructorId}</> },
|
||||
{ key: 'slot_info', header: 'Day / Time', render: (e) => <>{(e as any).slotInfo ?? '—'}</> },
|
||||
{ key: 'lesson_type', header: 'Lesson', render: (e) => <>{(e as any).lessonTypeName ?? '—'}</> },
|
||||
{ key: 'start_date', header: 'Start', sortable: true, render: (e) => <>{new Date(e.startDate + 'T00:00:00').toLocaleDateString()}</> },
|
||||
{ key: 'rate', header: 'Rate', render: (e) => <>{e.rate ? `$${e.rate} / ${e.billingInterval} ${e.billingUnit}` : <span className="text-muted-foreground">—</span>}</> },
|
||||
]
|
||||
|
||||
// ─── Page ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
function MemberDetailPage() {
|
||||
const { memberId } = useParams({ from: '/_authenticated/members/$memberId' })
|
||||
const search = Route.useSearch()
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const [addIdOpen, setAddIdOpen] = useState(false)
|
||||
const [createLoading, setCreateLoading] = useState(false)
|
||||
const tab = search.tab ?? 'details'
|
||||
|
||||
const token = useAuthStore((s) => s.token)
|
||||
const hasPermission = useAuthStore((s) => s.hasPermission)
|
||||
|
||||
const { data: member, isLoading } = useQuery(memberDetailOptions(memberId))
|
||||
const { data: idsData } = useQuery(identifierListOptions(memberId))
|
||||
const [createLoading, setCreateLoading] = useState(false)
|
||||
const { data: idsData } = useQuery({ ...identifierListOptions(memberId), enabled: tab === 'identity' })
|
||||
const { data: modulesData } = useQuery(moduleListOptions())
|
||||
const lessonsEnabled = (modulesData?.data ?? []).some((m) => m.slug === 'lessons' && m.enabled && m.licensed)
|
||||
|
||||
const { data: enrollmentsData } = useQuery({
|
||||
...enrollmentListOptions({ memberId, page: 1, limit: 100, order: 'asc' }),
|
||||
enabled: tab === 'enrollments' && lessonsEnabled,
|
||||
})
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: Record<string, unknown>) => api.patch<Member>(`/v1/members/${memberId}`, data),
|
||||
@@ -84,23 +119,19 @@ function MemberDetailPage() {
|
||||
formData.append('entityType', 'member_identifier')
|
||||
formData.append('entityId', identifierId)
|
||||
formData.append('category', category)
|
||||
|
||||
const res = await fetch('/v1/files', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: formData,
|
||||
})
|
||||
if (!res.ok) return null
|
||||
const data = await res.json()
|
||||
return data.id
|
||||
return (await res.json()).id
|
||||
}
|
||||
|
||||
async function handleCreateIdentifier(data: Record<string, unknown>, files: IdentifierFiles) {
|
||||
setCreateLoading(true)
|
||||
try {
|
||||
const identifier = await identifierMutations.create(memberId, data)
|
||||
|
||||
// Upload images and update identifier with file IDs
|
||||
const updates: Record<string, unknown> = {}
|
||||
if (files.front) {
|
||||
const fileId = await uploadIdFile(identifier.id, files.front, 'front')
|
||||
@@ -110,11 +141,7 @@ function MemberDetailPage() {
|
||||
const fileId = await uploadIdFile(identifier.id, files.back, 'back')
|
||||
if (fileId) updates.imageBackFileId = fileId
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length > 0) {
|
||||
await identifierMutations.update(identifier.id, updates)
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length > 0) await identifierMutations.update(identifier.id, updates)
|
||||
queryClient.invalidateQueries({ queryKey: identifierKeys.all(memberId) })
|
||||
toast.success('ID added')
|
||||
setAddIdOpen(false)
|
||||
@@ -134,23 +161,33 @@ function MemberDetailPage() {
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
function setTab(t: string) {
|
||||
navigate({ to: '/members/$memberId', params: { memberId }, search: { tab: t } as any })
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-64 w-full max-w-lg" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!member) {
|
||||
return <p className="text-muted-foreground">Member not found</p>
|
||||
}
|
||||
if (!member) return <p className="text-muted-foreground">Member not found</p>
|
||||
|
||||
const identifiers = idsData?.data ?? []
|
||||
|
||||
const tabs = [
|
||||
{ key: 'details', label: 'Details' },
|
||||
{ key: 'identity', label: 'Identity Documents' },
|
||||
...(lessonsEnabled ? [{ key: 'enrollments', label: 'Enrollments' }] : []),
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/accounts/$accountId/members', params: { accountId: member.accountId }, search: {} as any })}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
@@ -160,22 +197,34 @@ function MemberDetailPage() {
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>#{member.memberNumber}</span>
|
||||
{member.isMinor && <Badge variant="secondary">Minor</Badge>}
|
||||
<Link
|
||||
to="/accounts/$accountId"
|
||||
params={{ accountId: member.accountId }}
|
||||
className="hover:underline"
|
||||
>
|
||||
<Link to="/accounts/$accountId" params={{ accountId: member.accountId }} className="hover:underline">
|
||||
View Account
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Details</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Tabs */}
|
||||
<nav className="flex gap-1 border-b">
|
||||
{tabs.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => setTab(t.key)}
|
||||
className={cn(
|
||||
'px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors',
|
||||
tab === t.key
|
||||
? 'border-primary text-foreground'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border',
|
||||
)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Details tab */}
|
||||
{tab === 'details' && (
|
||||
<div className="max-w-lg space-y-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<AvatarUpload entityType="member" entityId={memberId} size="lg" />
|
||||
<div>
|
||||
@@ -189,25 +238,26 @@ function MemberDetailPage() {
|
||||
onSubmit={(data) => updateMutation.mutate(data)}
|
||||
loading={updateMutation.isPending}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-lg">Identity Documents</CardTitle>
|
||||
<Dialog open={addIdOpen} onOpenChange={setAddIdOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm"><Plus className="mr-2 h-4 w-4" />Add ID</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>Add Identity Document</DialogTitle></DialogHeader>
|
||||
<IdentifierForm memberId={memberId} onSubmit={handleCreateIdentifier} loading={createLoading} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* Identity Documents tab */}
|
||||
{tab === 'identity' && (
|
||||
<div className="space-y-4 max-w-2xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">{identifiers.length} document(s) on file</p>
|
||||
<Dialog open={addIdOpen} onOpenChange={setAddIdOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm"><Plus className="mr-2 h-4 w-4" />Add ID</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>Add Identity Document</DialogTitle></DialogHeader>
|
||||
<IdentifierForm memberId={memberId} onSubmit={handleCreateIdentifier} loading={createLoading} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
{identifiers.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-4 text-center">No IDs on file</p>
|
||||
<p className="text-sm text-muted-foreground py-8 text-center">No IDs on file</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{identifiers.map((id) => (
|
||||
@@ -225,9 +275,7 @@ function MemberDetailPage() {
|
||||
{id.issuedDate && <span>Issued: {id.issuedDate}</span>}
|
||||
{id.expiresAt && <span>Expires: {id.expiresAt}</span>}
|
||||
</div>
|
||||
{(id.imageFrontFileId || id.imageBackFileId) && (
|
||||
<IdentifierImages identifierId={id.id} />
|
||||
)}
|
||||
{(id.imageFrontFileId || id.imageBackFileId) && <IdentifierImages identifierId={id.id} />}
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => deleteIdMutation.mutate(id.id)}>
|
||||
@@ -237,8 +285,33 @@ function MemberDetailPage() {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Enrollments tab */}
|
||||
{tab === 'enrollments' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">{enrollmentsData?.pagination.total ?? 0} enrollment(s)</p>
|
||||
{hasPermission('lessons.edit') && (
|
||||
<Button size="sm" onClick={() => navigate({ to: '/lessons/enrollments/new', search: { memberId } as any })}>
|
||||
<Plus className="h-4 w-4 mr-1" />Enroll
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<DataTable
|
||||
columns={enrollmentColumns}
|
||||
data={enrollmentsData?.data ?? []}
|
||||
loading={!enrollmentsData && tab === 'enrollments'}
|
||||
page={1}
|
||||
totalPages={1}
|
||||
total={enrollmentsData?.data?.length ?? 0}
|
||||
onPageChange={() => {}}
|
||||
onSort={() => {}}
|
||||
onRowClick={(e) => navigate({ to: '/lessons/enrollments/$enrollmentId', params: { enrollmentId: e.id }, search: {} as any })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ function MembersListPage() {
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => navigate({ to: '/members/$memberId', params: { memberId: row.id } })}>
|
||||
<DropdownMenuItem onClick={() => navigate({ to: '/members/$memberId', params: { memberId: row.id }, search: {} as any })}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
@@ -134,7 +134,7 @@ function MembersListPage() {
|
||||
order={params.order}
|
||||
onPageChange={setPage}
|
||||
onSort={setSort}
|
||||
onRowClick={(member) => navigate({ to: '/members/$memberId', params: { memberId: member.id } })}
|
||||
onRowClick={(member) => navigate({ to: '/members/$memberId', params: { memberId: member.id }, search: {} as any })}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
216
packages/admin/src/types/lesson.ts
Normal file
216
packages/admin/src/types/lesson.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
export interface Instructor {
|
||||
id: string
|
||||
userId: string | null
|
||||
displayName: string
|
||||
bio: string | null
|
||||
instruments: string[] | null
|
||||
isActive: boolean
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface InstructorBlockedDate {
|
||||
id: string
|
||||
instructorId: string
|
||||
startDate: string
|
||||
endDate: string
|
||||
reason: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface LessonType {
|
||||
id: string
|
||||
name: string
|
||||
instrument: string | null
|
||||
durationMinutes: number
|
||||
lessonFormat: 'private' | 'group'
|
||||
rateWeekly: string | null
|
||||
rateMonthly: string | null
|
||||
rateQuarterly: string | null
|
||||
isActive: boolean
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface ScheduleSlot {
|
||||
id: string
|
||||
instructorId: string
|
||||
lessonTypeId: string
|
||||
dayOfWeek: number
|
||||
startTime: string
|
||||
room: string | null
|
||||
maxStudents: number
|
||||
rateWeekly: string | null
|
||||
rateMonthly: string | null
|
||||
rateQuarterly: string | null
|
||||
isActive: boolean
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface Enrollment {
|
||||
id: string
|
||||
memberId: string
|
||||
accountId: string
|
||||
scheduleSlotId: string
|
||||
instructorId: string
|
||||
status: 'active' | 'paused' | 'cancelled' | 'completed'
|
||||
startDate: string
|
||||
endDate: string | null
|
||||
rate: string | null
|
||||
billingInterval: number | null
|
||||
billingUnit: 'day' | 'week' | 'month' | 'quarter' | 'year' | null
|
||||
makeupCredits: number
|
||||
notes: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface LessonSession {
|
||||
id: string
|
||||
enrollmentId: string
|
||||
scheduledDate: string
|
||||
scheduledTime: string
|
||||
actualStartTime: string | null
|
||||
actualEndTime: string | null
|
||||
status: 'scheduled' | 'attended' | 'missed' | 'makeup' | 'cancelled'
|
||||
instructorNotes: string | null
|
||||
memberNotes: string | null
|
||||
homeworkAssigned: string | null
|
||||
nextLessonGoals: string | null
|
||||
topicsCovered: string[] | null
|
||||
makeupForSessionId: string | null
|
||||
substituteInstructorId: string | null
|
||||
notesCompletedAt: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
// enriched fields from list endpoint
|
||||
memberName?: string
|
||||
instructorName?: string
|
||||
lessonTypeName?: string
|
||||
}
|
||||
|
||||
export interface GradingScaleLevel {
|
||||
id: string
|
||||
gradingScaleId: string
|
||||
value: string
|
||||
label: string
|
||||
numericValue: number
|
||||
colorHex: string | null
|
||||
sortOrder: number
|
||||
}
|
||||
|
||||
export interface GradingScale {
|
||||
id: string
|
||||
name: string
|
||||
description: string | null
|
||||
isDefault: boolean
|
||||
createdBy: string | null
|
||||
isActive: boolean
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
levels: GradingScaleLevel[]
|
||||
}
|
||||
|
||||
export interface LessonPlanItem {
|
||||
id: string
|
||||
sectionId: string
|
||||
title: string
|
||||
description: string | null
|
||||
status: 'not_started' | 'in_progress' | 'mastered' | 'skipped'
|
||||
gradingScaleId: string | null
|
||||
currentGradeValue: string | null
|
||||
targetGradeValue: string | null
|
||||
startedDate: string | null
|
||||
masteredDate: string | null
|
||||
notes: string | null
|
||||
sortOrder: number
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface LessonPlanSection {
|
||||
id: string
|
||||
lessonPlanId: string
|
||||
title: string
|
||||
description: string | null
|
||||
sortOrder: number
|
||||
createdAt: string
|
||||
items: LessonPlanItem[]
|
||||
}
|
||||
|
||||
export interface LessonPlan {
|
||||
id: string
|
||||
memberId: string
|
||||
enrollmentId: string
|
||||
createdBy: string | null
|
||||
title: string
|
||||
description: string | null
|
||||
isActive: boolean
|
||||
startedDate: string | null
|
||||
completedDate: string | null
|
||||
progress: number
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
sections: LessonPlanSection[]
|
||||
}
|
||||
|
||||
export interface LessonPlanItemGradeHistory {
|
||||
id: string
|
||||
lessonPlanItemId: string
|
||||
gradingScaleId: string | null
|
||||
gradeValue: string
|
||||
gradedBy: string | null
|
||||
sessionId: string | null
|
||||
notes: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface LessonPlanTemplateItem {
|
||||
id: string
|
||||
sectionId: string
|
||||
title: string
|
||||
description: string | null
|
||||
gradingScaleId: string | null
|
||||
targetGradeValue: string | null
|
||||
sortOrder: number
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface LessonPlanTemplateSection {
|
||||
id: string
|
||||
templateId: string
|
||||
title: string
|
||||
description: string | null
|
||||
sortOrder: number
|
||||
createdAt: string
|
||||
items: LessonPlanTemplateItem[]
|
||||
}
|
||||
|
||||
export interface LessonPlanTemplate {
|
||||
id: string
|
||||
name: string
|
||||
description: string | null
|
||||
instrument: string | null
|
||||
skillLevel: 'beginner' | 'intermediate' | 'advanced' | 'all_levels'
|
||||
createdBy: string | null
|
||||
isActive: boolean
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
sections: LessonPlanTemplateSection[]
|
||||
}
|
||||
|
||||
export interface StoreClosure {
|
||||
id: string
|
||||
name: string
|
||||
startDate: string
|
||||
endDate: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface SessionPlanItem {
|
||||
id: string
|
||||
sessionId: string
|
||||
lessonPlanItemId: string
|
||||
createdAt: string
|
||||
}
|
||||
@@ -50,7 +50,7 @@ If you can't find what you're looking for, contact your admin or system administ
|
||||
content: `
|
||||
# Accounts
|
||||
|
||||
An **account** is a billing entity — it could be a family, a business, a school, or an individual person. All billing, invoices, and payments are tied to an account.
|
||||
An **account** is an organizational entity — it could be a family, a business, a school, or an individual person. Members, repairs, and lessons are all linked to an account.
|
||||
|
||||
## Creating an Account
|
||||
|
||||
@@ -251,8 +251,7 @@ Permissions are organized by area:
|
||||
|
||||
- **Accounts** — view, edit, admin
|
||||
- **Inventory** — view, edit, admin
|
||||
- **POS** — view, edit, admin
|
||||
- **Rentals, Lessons, Repairs** — each has view, edit, admin
|
||||
- **Lessons, Repairs** — each has view, edit, admin
|
||||
|
||||
**Permission inheritance:** If a role has **admin** permission for an area, it automatically includes **edit** and **view** too. If it has **edit**, it includes **view**.
|
||||
|
||||
@@ -486,6 +485,249 @@ For pending approval tickets, you can:
|
||||
5. Then move the ticket to **Approved** status
|
||||
`.trim(),
|
||||
},
|
||||
{
|
||||
slug: 'lessons-overview',
|
||||
title: 'Lessons Overview',
|
||||
category: 'Lessons',
|
||||
content: `
|
||||
# Lessons
|
||||
|
||||
The Lessons module manages music instruction — instructors, schedules, student enrollments, individual sessions, and lesson plans with grading.
|
||||
|
||||
## Module Setup
|
||||
|
||||
Before using Lessons, an admin must enable the module in **Admin → Modules**.
|
||||
|
||||
## Core Concepts
|
||||
|
||||
- **Lesson Types** — the kinds of lessons you offer (e.g. "30-min Piano", "1-hr Guitar"). Each has default rates.
|
||||
- **Instructors** — staff members who teach. Each instructor has their own schedule slots.
|
||||
- **Schedule Slots** — recurring time blocks when an instructor is available (e.g. Mondays 3–5 PM). Slots can override the lesson type's default rates.
|
||||
- **Enrollments** — a student (member) assigned to a specific slot with agreed billing terms.
|
||||
- **Sessions** — individual lesson occurrences generated from enrollments. Each session is marked attended, missed, cancelled, or makeup.
|
||||
- **Lesson Plans** — per-student curriculum tracking with goals, notes, and graded progress.
|
||||
|
||||
## Typical Workflow
|
||||
|
||||
1. Create **Lesson Types** with default rates
|
||||
2. Add **Instructors**
|
||||
3. Create **Schedule Slots** for each instructor
|
||||
4. **Enroll** students into slots — set billing cycle and rate
|
||||
5. Sessions are created automatically on a weekly basis
|
||||
6. After each lesson, mark the session **Attended** (or Missed/Cancelled) and add notes
|
||||
7. Track progress via the student's **Lesson Plan**
|
||||
`.trim(),
|
||||
},
|
||||
{
|
||||
slug: 'lesson-types',
|
||||
title: 'Lesson Types',
|
||||
category: 'Lessons',
|
||||
content: `
|
||||
# Lesson Types
|
||||
|
||||
Lesson types define what you teach and your default rates. Examples: "30-min Piano", "1-hr Guitar", "Group Drum — 45 min".
|
||||
|
||||
## Creating a Lesson Type
|
||||
|
||||
1. Go to **Lessons → Lesson Types**
|
||||
2. Click **New Lesson Type**
|
||||
3. Enter a name and optional description
|
||||
4. Set default rates for each billing cycle:
|
||||
- **Weekly** — billed every week
|
||||
- **Monthly** — billed once a month
|
||||
- **Quarterly** — billed every 3 months
|
||||
5. Click **Create**
|
||||
|
||||
Rates here are defaults — individual schedule slots (and enrollments) can override them.
|
||||
|
||||
## Editing Rates
|
||||
|
||||
Click any lesson type to edit it. Rate changes only affect new enrollments — existing enrollments keep their agreed rate unless you manually update them.
|
||||
`.trim(),
|
||||
},
|
||||
{
|
||||
slug: 'instructors',
|
||||
title: 'Instructors',
|
||||
category: 'Lessons',
|
||||
content: `
|
||||
# Instructors
|
||||
|
||||
Instructors are the teachers in your system. Each instructor has a display name, contact info, and their own set of schedule slots.
|
||||
|
||||
## Adding an Instructor
|
||||
|
||||
1. Go to **Lessons → Instructors**
|
||||
2. Click **New Instructor**
|
||||
3. Enter the display name (shown everywhere in the UI), email, and optional phone
|
||||
4. Click **Create**
|
||||
|
||||
## Instructor Detail
|
||||
|
||||
Click an instructor to see their profile and all their schedule slots.
|
||||
|
||||
## Deactivating an Instructor
|
||||
|
||||
If an instructor leaves, you can mark them inactive. Their existing enrollments and session history are preserved — they just won't appear in dropdowns for new enrollments.
|
||||
`.trim(),
|
||||
},
|
||||
{
|
||||
slug: 'schedule-slots',
|
||||
title: 'Schedule Slots',
|
||||
category: 'Lessons',
|
||||
content: `
|
||||
# Schedule Slots
|
||||
|
||||
A schedule slot is a recurring time block when an instructor teaches — for example, "Mondays 4:00–4:30 PM with Sarah Chen, Piano".
|
||||
|
||||
## Creating a Slot
|
||||
|
||||
1. Go to **Lessons → Instructors** → click an instructor
|
||||
2. Click **Add Slot**, or go to **Lessons → Schedule** → **New Slot**
|
||||
3. Select:
|
||||
- **Lesson Type**
|
||||
- **Day of week**
|
||||
- **Start time** and **End time**
|
||||
4. Optionally set **instructor rate overrides** for this specific slot:
|
||||
- These override the lesson type's default rates for students in this slot
|
||||
- Leave blank to use the lesson type defaults
|
||||
5. Click **Create**
|
||||
|
||||
## Rate Override Logic
|
||||
|
||||
When enrolling a student, the rate auto-fills based on this priority:
|
||||
|
||||
1. Slot's rate for the chosen billing cycle (if set)
|
||||
2. Lesson type's rate for that cycle
|
||||
3. Manual entry (if neither is set)
|
||||
|
||||
## Blocked Dates
|
||||
|
||||
Use **Lessons → Blocked Dates** to mark days when lessons don't occur (holidays, school breaks). Sessions on blocked dates are automatically marked cancelled.
|
||||
`.trim(),
|
||||
},
|
||||
{
|
||||
slug: 'enrollments',
|
||||
title: 'Enrollments',
|
||||
category: 'Lessons',
|
||||
content: `
|
||||
# Enrollments
|
||||
|
||||
An enrollment connects a student (member) to a schedule slot with agreed billing terms.
|
||||
|
||||
## Creating an Enrollment
|
||||
|
||||
1. Go to **Lessons → Enrollments → New Enrollment**
|
||||
2. Select the **member** (student)
|
||||
3. Select the **instructor** and **schedule slot**
|
||||
4. Set the **billing cycle**:
|
||||
- Choose the interval (e.g. 1) and unit (week / month / quarter / year)
|
||||
- The rate field auto-fills from the slot or lesson type — you can override it manually
|
||||
5. Set the **start date**
|
||||
6. Click **Create Enrollment**
|
||||
|
||||
## Enrollment Statuses
|
||||
|
||||
- **Active** — student is actively taking lessons; sessions are being generated
|
||||
- **Paused** — temporarily on hold (e.g. summer break); no new sessions generated
|
||||
- **Cancelled** — permanently ended
|
||||
- **Completed** — finished the curriculum
|
||||
|
||||
## Changing Status
|
||||
|
||||
Open the enrollment and use the status dropdown. Adding a note when pausing or cancelling is recommended.
|
||||
|
||||
## Billing Terms
|
||||
|
||||
The **rate** and **billing cycle** represent what the customer agreed to pay. These don't connect to payment processing automatically — they're for reference when generating invoices.
|
||||
`.trim(),
|
||||
},
|
||||
{
|
||||
slug: 'sessions',
|
||||
title: 'Sessions',
|
||||
category: 'Lessons',
|
||||
content: `
|
||||
# Sessions
|
||||
|
||||
A session is one instance of a lesson. Sessions are generated automatically from active enrollments based on their schedule slot's day and time.
|
||||
|
||||
## Viewing Sessions
|
||||
|
||||
- **Lessons → Sessions** — list view with search and filters, or week view showing all sessions on a calendar grid
|
||||
- Use the **instructor filter** in the week view to focus on one teacher's schedule
|
||||
- Use the **status filter** in list view to find missed or upcoming sessions
|
||||
|
||||
## Session Statuses
|
||||
|
||||
- **Scheduled** — upcoming, not yet occurred
|
||||
- **Attended** — lesson took place
|
||||
- **Missed** — student didn't show
|
||||
- **Cancelled** — lesson was cancelled (instructor unavailable, holiday, etc.)
|
||||
- **Makeup** — a makeup session for a previously missed lesson
|
||||
|
||||
## Recording a Session
|
||||
|
||||
1. Click on a session (from the list or week view)
|
||||
2. Update the status (Attended, Missed, etc.)
|
||||
3. Add session notes — these feed into the student's lesson plan
|
||||
4. Click **Save**
|
||||
|
||||
## Makeup Sessions
|
||||
|
||||
If a student misses a session, you can schedule a makeup:
|
||||
|
||||
1. Open the missed session
|
||||
2. Click **Schedule Makeup**
|
||||
3. Choose a date and time
|
||||
4. A new session is created with status **Makeup** linked to the original
|
||||
|
||||
## Notes
|
||||
|
||||
Session notes are visible on the session detail page and also appear in the student's lesson plan history. Use them to record what was covered, what to practice, and any concerns.
|
||||
`.trim(),
|
||||
},
|
||||
{
|
||||
slug: 'lesson-plans',
|
||||
title: 'Lesson Plans & Grading',
|
||||
category: 'Lessons',
|
||||
content: `
|
||||
# Lesson Plans & Grading
|
||||
|
||||
Lesson plans track a student's curriculum, goals, and progress over time.
|
||||
|
||||
## Grading Scales
|
||||
|
||||
Before using grading, set up a grading scale in **Lessons → Grading Scales**. A scale defines the grades available (e.g. 1–5, A–F, or custom labels like "Needs Work / Developing / Proficient / Mastered").
|
||||
|
||||
## Lesson Plan Templates
|
||||
|
||||
Templates define a standard curriculum that can be applied to students. Go to **Lessons → Plan Templates** to create reusable outlines with goals organized by category.
|
||||
|
||||
## Student Lesson Plans
|
||||
|
||||
Each active enrollment can have a lesson plan. To view or edit a student's plan:
|
||||
|
||||
1. Go to the member's detail page → **Enrollments** tab
|
||||
2. Click the enrollment → **Lesson Plan** tab
|
||||
|
||||
Or go to **Lessons → Lesson Plans** and search by student name.
|
||||
|
||||
## Recording Progress
|
||||
|
||||
On the lesson plan detail page:
|
||||
|
||||
1. Click **Add Grade Entry**
|
||||
2. Select the goal or skill being assessed
|
||||
3. Choose the grade from your scale
|
||||
4. Add optional notes
|
||||
5. Click **Save**
|
||||
|
||||
Grade history is shown as a timeline so you can see improvement over time.
|
||||
|
||||
## Goals
|
||||
|
||||
Goals are the specific skills or pieces being tracked (e.g. "C Major Scale", "Correct bow hold", "Sight reading level 2"). Goals come from the plan template but can be customized per student.
|
||||
`.trim(),
|
||||
},
|
||||
]
|
||||
|
||||
export function getWikiPages(): WikiPage[] {
|
||||
|
||||
@@ -112,14 +112,14 @@ suite('Lessons', { tags: ['lessons'] }, (t) => {
|
||||
instrument: 'Piano',
|
||||
durationMinutes: 30,
|
||||
lessonFormat: 'private',
|
||||
baseRateMonthly: 120,
|
||||
rateMonthly: 120,
|
||||
})
|
||||
t.assert.status(res, 201)
|
||||
t.assert.equal(res.data.name, '30-min Private Piano')
|
||||
t.assert.equal(res.data.instrument, 'Piano')
|
||||
t.assert.equal(res.data.durationMinutes, 30)
|
||||
t.assert.equal(res.data.lessonFormat, 'private')
|
||||
t.assert.equal(res.data.baseRateMonthly, '120')
|
||||
t.assert.equal(res.data.rateMonthly, '120.00')
|
||||
t.assert.ok(res.data.id)
|
||||
})
|
||||
|
||||
@@ -129,12 +129,26 @@ suite('Lessons', { tags: ['lessons'] }, (t) => {
|
||||
instrument: 'Guitar',
|
||||
durationMinutes: 60,
|
||||
lessonFormat: 'group',
|
||||
baseRateMonthly: 80,
|
||||
rateMonthly: 80,
|
||||
})
|
||||
t.assert.status(res, 201)
|
||||
t.assert.equal(res.data.lessonFormat, 'group')
|
||||
})
|
||||
|
||||
t.test('creates a lesson type with all three rate presets', { tags: ['lesson-types', 'create'] }, async () => {
|
||||
const res = await t.api.post('/v1/lesson-types', {
|
||||
name: 'Multi-Rate Type',
|
||||
durationMinutes: 30,
|
||||
rateWeekly: 35,
|
||||
rateMonthly: 120,
|
||||
rateQuarterly: 330,
|
||||
})
|
||||
t.assert.status(res, 201)
|
||||
t.assert.equal(res.data.rateWeekly, '35.00')
|
||||
t.assert.equal(res.data.rateMonthly, '120.00')
|
||||
t.assert.equal(res.data.rateQuarterly, '330.00')
|
||||
})
|
||||
|
||||
t.test('rejects lesson type without required fields', { tags: ['lesson-types', 'create', 'validation'] }, async () => {
|
||||
const res = await t.api.post('/v1/lesson-types', {})
|
||||
t.assert.status(res, 400)
|
||||
@@ -162,12 +176,12 @@ suite('Lessons', { tags: ['lessons'] }, (t) => {
|
||||
const res = await t.api.patch(`/v1/lesson-types/${created.data.id}`, {
|
||||
name: 'After Update Type',
|
||||
durationMinutes: 45,
|
||||
baseRateMonthly: 150,
|
||||
rateMonthly: 150,
|
||||
})
|
||||
t.assert.status(res, 200)
|
||||
t.assert.equal(res.data.name, 'After Update Type')
|
||||
t.assert.equal(res.data.durationMinutes, 45)
|
||||
t.assert.equal(res.data.baseRateMonthly, '150')
|
||||
t.assert.equal(res.data.rateMonthly, '150.00')
|
||||
})
|
||||
|
||||
t.test('soft-deletes a lesson type', { tags: ['lesson-types', 'delete'] }, async () => {
|
||||
@@ -370,6 +384,45 @@ suite('Lessons', { tags: ['lessons'] }, (t) => {
|
||||
t.assert.equal(res.data.maxStudents, 3)
|
||||
})
|
||||
|
||||
t.test('creates a schedule slot with instructor rate overrides', { tags: ['schedule-slots', 'create'] }, async () => {
|
||||
const instructor = await t.api.post('/v1/instructors', { displayName: 'Rates Instructor' })
|
||||
const lessonType = await t.api.post('/v1/lesson-types', { name: 'Rates Slot Type', durationMinutes: 30, rateMonthly: 100 })
|
||||
|
||||
const res = await t.api.post('/v1/schedule-slots', {
|
||||
instructorId: instructor.data.id,
|
||||
lessonTypeId: lessonType.data.id,
|
||||
dayOfWeek: 1,
|
||||
startTime: '13:00',
|
||||
rateWeekly: 40,
|
||||
rateMonthly: 150,
|
||||
rateQuarterly: 400,
|
||||
})
|
||||
t.assert.status(res, 201)
|
||||
t.assert.equal(res.data.rateWeekly, '40.00')
|
||||
t.assert.equal(res.data.rateMonthly, '150.00')
|
||||
t.assert.equal(res.data.rateQuarterly, '400.00')
|
||||
})
|
||||
|
||||
t.test('updates schedule slot rates', { tags: ['schedule-slots', 'update'] }, async () => {
|
||||
const instructor = await t.api.post('/v1/instructors', { displayName: 'Update Rates Instructor' })
|
||||
const lessonType = await t.api.post('/v1/lesson-types', { name: 'Update Rates Type', durationMinutes: 30 })
|
||||
const created = await t.api.post('/v1/schedule-slots', {
|
||||
instructorId: instructor.data.id,
|
||||
lessonTypeId: lessonType.data.id,
|
||||
dayOfWeek: 3,
|
||||
startTime: '09:00',
|
||||
})
|
||||
|
||||
const res = await t.api.patch(`/v1/schedule-slots/${created.data.id}`, {
|
||||
rateWeekly: 35,
|
||||
rateMonthly: 120,
|
||||
})
|
||||
t.assert.status(res, 200)
|
||||
t.assert.equal(res.data.rateWeekly, '35.00')
|
||||
t.assert.equal(res.data.rateMonthly, '120.00')
|
||||
t.assert.equal(res.data.rateQuarterly, null)
|
||||
})
|
||||
|
||||
t.test('update detects conflict when changing time', { tags: ['schedule-slots', 'update', 'conflict'] }, async () => {
|
||||
const instructor = await t.api.post('/v1/instructors', { displayName: 'Update Conflict Instructor' })
|
||||
const lessonType = await t.api.post('/v1/lesson-types', { name: 'Update Conflict Type', durationMinutes: 30 })
|
||||
@@ -505,13 +558,17 @@ suite('Lessons', { tags: ['lessons'] }, (t) => {
|
||||
scheduleSlotId: slot.data.id,
|
||||
instructorId: instructor.data.id,
|
||||
startDate: '2026-01-15',
|
||||
monthlyRate: 120,
|
||||
rate: 120,
|
||||
billingInterval: 1,
|
||||
billingUnit: 'month',
|
||||
notes: 'Beginner piano student',
|
||||
})
|
||||
t.assert.status(res, 201)
|
||||
t.assert.equal(res.data.status, 'active')
|
||||
t.assert.equal(res.data.memberId, member.data.id)
|
||||
t.assert.equal(res.data.monthlyRate, '120.00')
|
||||
t.assert.equal(res.data.rate, '120.00')
|
||||
t.assert.equal(res.data.billingInterval, 1)
|
||||
t.assert.equal(res.data.billingUnit, 'month')
|
||||
t.assert.equal(res.data.startDate, '2026-01-15')
|
||||
t.assert.equal(res.data.makeupCredits, 0)
|
||||
})
|
||||
@@ -618,12 +675,16 @@ suite('Lessons', { tags: ['lessons'] }, (t) => {
|
||||
})
|
||||
|
||||
const res = await t.api.patch(`/v1/enrollments/${created.data.id}`, {
|
||||
monthlyRate: 150,
|
||||
rate: 150,
|
||||
billingInterval: 2,
|
||||
billingUnit: 'week',
|
||||
notes: 'Updated rate',
|
||||
endDate: '2026-06-30',
|
||||
})
|
||||
t.assert.status(res, 200)
|
||||
t.assert.equal(res.data.monthlyRate, '150.00')
|
||||
t.assert.equal(res.data.rate, '150.00')
|
||||
t.assert.equal(res.data.billingInterval, 2)
|
||||
t.assert.equal(res.data.billingUnit, 'week')
|
||||
t.assert.equal(res.data.notes, 'Updated rate')
|
||||
t.assert.equal(res.data.endDate, '2026-06-30')
|
||||
})
|
||||
|
||||
28
packages/backend/src/db/migrations/0037_rate_cycles.sql
Normal file
28
packages/backend/src/db/migrations/0037_rate_cycles.sql
Normal file
@@ -0,0 +1,28 @@
|
||||
-- Rate cycles: per-instructor rates on schedule_slot, generic billing terms on enrollment,
|
||||
-- replace base_rate_monthly on lesson_type with weekly/monthly/quarterly presets
|
||||
|
||||
CREATE TYPE billing_unit AS ENUM ('day', 'week', 'month', 'quarter', 'year');
|
||||
|
||||
-- lesson_type: replace base_rate_monthly varchar with three numeric rate columns
|
||||
ALTER TABLE lesson_type ADD COLUMN rate_weekly numeric(10,2);
|
||||
ALTER TABLE lesson_type ADD COLUMN rate_monthly numeric(10,2);
|
||||
ALTER TABLE lesson_type ADD COLUMN rate_quarterly numeric(10,2);
|
||||
UPDATE lesson_type
|
||||
SET rate_monthly = base_rate_monthly::numeric
|
||||
WHERE base_rate_monthly IS NOT NULL
|
||||
AND base_rate_monthly ~ '^\d+(\.\d+)?$';
|
||||
ALTER TABLE lesson_type DROP COLUMN base_rate_monthly;
|
||||
|
||||
-- schedule_slot: add per-instructor preset rates
|
||||
ALTER TABLE schedule_slot ADD COLUMN rate_weekly numeric(10,2);
|
||||
ALTER TABLE schedule_slot ADD COLUMN rate_monthly numeric(10,2);
|
||||
ALTER TABLE schedule_slot ADD COLUMN rate_quarterly numeric(10,2);
|
||||
|
||||
-- enrollment: replace monthly_rate with generic billing terms
|
||||
ALTER TABLE enrollment ADD COLUMN rate numeric(10,2);
|
||||
ALTER TABLE enrollment ADD COLUMN billing_interval integer;
|
||||
ALTER TABLE enrollment ADD COLUMN billing_unit billing_unit;
|
||||
UPDATE enrollment
|
||||
SET rate = monthly_rate, billing_interval = 1, billing_unit = 'month'
|
||||
WHERE monthly_rate IS NOT NULL;
|
||||
ALTER TABLE enrollment DROP COLUMN monthly_rate;
|
||||
@@ -260,6 +260,13 @@
|
||||
"when": 1774960000000,
|
||||
"tag": "0036_lesson_plan_templates",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 37,
|
||||
"version": "7",
|
||||
"when": 1774970000000,
|
||||
"tag": "0037_rate_cycles",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import { accounts, members } from './accounts.js'
|
||||
// --- Enums ---
|
||||
|
||||
export const lessonFormatEnum = pgEnum('lesson_format', ['private', 'group'])
|
||||
export const billingUnitEnum = pgEnum('billing_unit', ['day', 'week', 'month', 'quarter', 'year'])
|
||||
|
||||
// --- Tables ---
|
||||
|
||||
@@ -37,7 +38,9 @@ export const lessonTypes = pgTable('lesson_type', {
|
||||
instrument: varchar('instrument', { length: 100 }),
|
||||
durationMinutes: integer('duration_minutes').notNull(),
|
||||
lessonFormat: lessonFormatEnum('lesson_format').notNull().default('private'),
|
||||
baseRateMonthly: varchar('base_rate_monthly', { length: 20 }),
|
||||
rateWeekly: numeric('rate_weekly', { precision: 10, scale: 2 }),
|
||||
rateMonthly: numeric('rate_monthly', { precision: 10, scale: 2 }),
|
||||
rateQuarterly: numeric('rate_quarterly', { precision: 10, scale: 2 }),
|
||||
isActive: boolean('is_active').notNull().default(true),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
@@ -55,6 +58,9 @@ export const scheduleSlots = pgTable('schedule_slot', {
|
||||
startTime: time('start_time').notNull(),
|
||||
room: varchar('room', { length: 100 }),
|
||||
maxStudents: integer('max_students').notNull().default(1),
|
||||
rateWeekly: numeric('rate_weekly', { precision: 10, scale: 2 }),
|
||||
rateMonthly: numeric('rate_monthly', { precision: 10, scale: 2 }),
|
||||
rateQuarterly: numeric('rate_quarterly', { precision: 10, scale: 2 }),
|
||||
isActive: boolean('is_active').notNull().default(true),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
@@ -84,7 +90,9 @@ export const enrollments = pgTable('enrollment', {
|
||||
status: enrollmentStatusEnum('status').notNull().default('active'),
|
||||
startDate: date('start_date').notNull(),
|
||||
endDate: date('end_date'),
|
||||
monthlyRate: numeric('monthly_rate', { precision: 10, scale: 2 }),
|
||||
rate: numeric('rate', { precision: 10, scale: 2 }),
|
||||
billingInterval: integer('billing_interval'),
|
||||
billingUnit: billingUnitEnum('billing_unit'),
|
||||
makeupCredits: integer('makeup_credits').notNull().default(0),
|
||||
notes: text('notes'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
|
||||
@@ -39,16 +39,17 @@ async function seed() {
|
||||
}
|
||||
|
||||
// --- Admin user (if not exists) ---
|
||||
const adminPassword = process.env.ADMIN_PASSWORD ?? 'admin1234'
|
||||
const [adminUser] = await sql`SELECT id FROM "user" WHERE email = 'admin@lunarfront.dev'`
|
||||
if (!adminUser) {
|
||||
const bcrypt = await import('bcrypt')
|
||||
const hashedPw = await (bcrypt.default || bcrypt).hash('admin1234', 10)
|
||||
const hashedPw = await (bcrypt.default || bcrypt).hash(adminPassword, 10)
|
||||
const [user] = await sql`INSERT INTO "user" (email, password_hash, first_name, last_name, role) VALUES ('admin@lunarfront.dev', ${hashedPw}, 'Admin', 'User', 'admin') RETURNING id`
|
||||
const [adminRole] = await sql`SELECT id FROM role WHERE slug = 'admin' LIMIT 1`
|
||||
if (adminRole) {
|
||||
await sql`INSERT INTO user_role_assignment (user_id, role_id) VALUES (${user.id}, ${adminRole.id}) ON CONFLICT DO NOTHING`
|
||||
}
|
||||
console.log(' Created admin user: admin@lunarfront.dev / admin1234')
|
||||
console.log(` Created admin user: admin@lunarfront.dev / ${adminPassword}`)
|
||||
} else {
|
||||
const [adminRole] = await sql`SELECT id FROM role WHERE slug = 'admin' LIMIT 1`
|
||||
if (adminRole) {
|
||||
|
||||
@@ -162,10 +162,478 @@ async function seed() {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Enable lessons module ---
|
||||
await sql`UPDATE module_config SET enabled = true WHERE slug = 'lessons'`
|
||||
console.log(' Enabled lessons module')
|
||||
|
||||
// --- Lesson seed data ---
|
||||
await seedLessons(sql)
|
||||
|
||||
console.log('\nMusic store seed complete!')
|
||||
await sql.end()
|
||||
}
|
||||
|
||||
// Returns dates (YYYY-MM-DD) for every occurrence of `dayOfWeek` (0=Sun)
|
||||
// starting from `startDate`, for `count` weeks.
|
||||
function weeklyDates(startDate: string, dayOfWeek: number, count: number): string[] {
|
||||
const d = new Date(startDate + 'T12:00:00Z')
|
||||
while (d.getUTCDay() !== dayOfWeek) d.setUTCDate(d.getUTCDate() + 1)
|
||||
return Array.from({ length: count }, (_, i) => {
|
||||
const nd = new Date(d)
|
||||
nd.setUTCDate(nd.getUTCDate() + i * 7)
|
||||
return nd.toISOString().split('T')[0]
|
||||
})
|
||||
}
|
||||
|
||||
async function seedLessons(sql: any) {
|
||||
console.log('\nSeeding lessons data...')
|
||||
|
||||
// ── Grading scale ──────────────────────────────────────────────────────────
|
||||
const [existingScale] = await sql`SELECT id FROM grading_scale WHERE name = 'Standard Progress'`
|
||||
let scaleId: string
|
||||
if (existingScale) {
|
||||
scaleId = existingScale.id
|
||||
} else {
|
||||
const [scale] = await sql`
|
||||
INSERT INTO grading_scale (name, description, is_default, is_active)
|
||||
VALUES ('Standard Progress', 'Four-level scale used across all instruments', true, true)
|
||||
RETURNING id`
|
||||
scaleId = scale.id
|
||||
const levels = [
|
||||
{ value: '1', label: 'Introduced', numericValue: 1, colorHex: null, sortOrder: 1 },
|
||||
{ value: '2', label: 'Developing', numericValue: 2, colorHex: '#EAB308', sortOrder: 2 },
|
||||
{ value: '3', label: 'Proficient', numericValue: 3, colorHex: '#3B82F6', sortOrder: 3 },
|
||||
{ value: '4', label: 'Mastered', numericValue: 4, colorHex: '#22C55E', sortOrder: 4 },
|
||||
]
|
||||
for (const lv of levels) {
|
||||
await sql`INSERT INTO grading_scale_level (grading_scale_id, value, label, numeric_value, color_hex, sort_order)
|
||||
VALUES (${scaleId}, ${lv.value}, ${lv.label}, ${lv.numericValue}, ${lv.colorHex}, ${lv.sortOrder})`
|
||||
}
|
||||
console.log(' Grading scale: Standard Progress')
|
||||
}
|
||||
|
||||
// ── Instructors ────────────────────────────────────────────────────────────
|
||||
const instructorDefs = [
|
||||
{
|
||||
displayName: 'Sarah Mitchell',
|
||||
bio: 'Classical piano and violin instructor with 12 years of teaching experience. Suzuki-certified for violin.',
|
||||
instruments: ['Piano', 'Violin'],
|
||||
},
|
||||
{
|
||||
displayName: 'Marcus Webb',
|
||||
bio: 'Guitarist and bassist specializing in rock, blues, and jazz. 8 years of studio and teaching experience.',
|
||||
instruments: ['Guitar', 'Bass'],
|
||||
},
|
||||
{
|
||||
displayName: 'Diana Reyes',
|
||||
bio: 'Woodwind specialist with a music education degree. Teaches saxophone, clarinet, and flute at all levels.',
|
||||
instruments: ['Saxophone', 'Clarinet', 'Flute'],
|
||||
},
|
||||
]
|
||||
|
||||
const instrIds: Record<string, string> = {}
|
||||
for (const def of instructorDefs) {
|
||||
const [existing] = await sql`SELECT id FROM instructor WHERE display_name = ${def.displayName}`
|
||||
if (existing) { instrIds[def.displayName] = existing.id; continue }
|
||||
const [row] = await sql`
|
||||
INSERT INTO instructor (display_name, bio, instruments, is_active)
|
||||
VALUES (${def.displayName}, ${def.bio}, ${def.instruments}, true)
|
||||
RETURNING id`
|
||||
instrIds[def.displayName] = row.id
|
||||
console.log(` Instructor: ${def.displayName}`)
|
||||
}
|
||||
|
||||
// ── Lesson types ───────────────────────────────────────────────────────────
|
||||
const lessonTypeDefs = [
|
||||
{ name: '30-min Private Piano', instrument: 'Piano', durationMinutes: 30, lessonFormat: 'private', rateWeekly: '35.00', rateMonthly: '120.00', rateQuarterly: '330.00' },
|
||||
{ name: '45-min Private Piano', instrument: 'Piano', durationMinutes: 45, lessonFormat: 'private', rateWeekly: '50.00', rateMonthly: '175.00', rateQuarterly: '480.00' },
|
||||
{ name: '30-min Private Guitar', instrument: 'Guitar', durationMinutes: 30, lessonFormat: 'private', rateWeekly: '35.00', rateMonthly: '120.00', rateQuarterly: '330.00' },
|
||||
{ name: '30-min Private Violin', instrument: 'Violin', durationMinutes: 30, lessonFormat: 'private', rateWeekly: '35.00', rateMonthly: '120.00', rateQuarterly: '330.00' },
|
||||
{ name: '30-min Private Woodwind',instrument: null, durationMinutes: 30, lessonFormat: 'private', rateWeekly: '35.00', rateMonthly: '120.00', rateQuarterly: '330.00' },
|
||||
{ name: '45-min Group Guitar', instrument: 'Guitar', durationMinutes: 45, lessonFormat: 'group', rateWeekly: null, rateMonthly: '75.00', rateQuarterly: '200.00' },
|
||||
]
|
||||
|
||||
const ltIds: Record<string, string> = {}
|
||||
for (const lt of lessonTypeDefs) {
|
||||
const [existing] = await sql`SELECT id FROM lesson_type WHERE name = ${lt.name}`
|
||||
if (existing) { ltIds[lt.name] = existing.id; continue }
|
||||
const [row] = await sql`
|
||||
INSERT INTO lesson_type (name, instrument, duration_minutes, lesson_format, rate_weekly, rate_monthly, rate_quarterly, is_active)
|
||||
VALUES (${lt.name}, ${lt.instrument}, ${lt.durationMinutes}, ${lt.lessonFormat}, ${lt.rateWeekly}, ${lt.rateMonthly}, ${lt.rateQuarterly}, true)
|
||||
RETURNING id`
|
||||
ltIds[lt.name] = row.id
|
||||
console.log(` Lesson type: ${lt.name}`)
|
||||
}
|
||||
|
||||
// ── Schedule slots ─────────────────────────────────────────────────────────
|
||||
// dayOfWeek: 0=Sun, 1=Mon, 2=Tue, 3=Wed, 4=Thu, 5=Fri, 6=Sat
|
||||
const slotDefs = [
|
||||
// Sarah Mitchell — Piano & Violin
|
||||
{ key: 'sarah_mon_1530_piano30', instructor: 'Sarah Mitchell', lessonType: '30-min Private Piano', dayOfWeek: 1, startTime: '15:30', room: 'Studio 1', maxStudents: 1, rateMonthly: null },
|
||||
{ key: 'sarah_mon_1600_piano30', instructor: 'Sarah Mitchell', lessonType: '30-min Private Piano', dayOfWeek: 1, startTime: '16:00', room: 'Studio 1', maxStudents: 1, rateMonthly: null },
|
||||
{ key: 'sarah_mon_1700_piano45', instructor: 'Sarah Mitchell', lessonType: '45-min Private Piano', dayOfWeek: 1, startTime: '17:00', room: 'Studio 1', maxStudents: 1, rateMonthly: null },
|
||||
{ key: 'sarah_wed_1530_violin', instructor: 'Sarah Mitchell', lessonType: '30-min Private Violin', dayOfWeek: 3, startTime: '15:30', room: 'Studio 1', maxStudents: 1, rateMonthly: null },
|
||||
{ key: 'sarah_wed_1600_violin', instructor: 'Sarah Mitchell', lessonType: '30-min Private Violin', dayOfWeek: 3, startTime: '16:00', room: 'Studio 1', maxStudents: 1, rateMonthly: null },
|
||||
// Marcus Webb — Guitar (rate override: $115/mo, $400/qtr — slightly above default for experienced instructor)
|
||||
{ key: 'marcus_tue_1500_guitar', instructor: 'Marcus Webb', lessonType: '30-min Private Guitar', dayOfWeek: 2, startTime: '15:00', room: 'Studio 2', maxStudents: 1, rateMonthly: '115.00' },
|
||||
{ key: 'marcus_tue_1600_guitar', instructor: 'Marcus Webb', lessonType: '30-min Private Guitar', dayOfWeek: 2, startTime: '16:00', room: 'Studio 2', maxStudents: 1, rateMonthly: '115.00' },
|
||||
{ key: 'marcus_thu_1600_group', instructor: 'Marcus Webb', lessonType: '45-min Group Guitar', dayOfWeek: 4, startTime: '16:00', room: 'Studio 2', maxStudents: 5, rateMonthly: null },
|
||||
{ key: 'marcus_sat_1000_guitar', instructor: 'Marcus Webb', lessonType: '30-min Private Guitar', dayOfWeek: 6, startTime: '10:00', room: 'Studio 2', maxStudents: 1, rateMonthly: '115.00' },
|
||||
// Diana Reyes — Woodwind
|
||||
{ key: 'diana_mon_1630_woodwind', instructor: 'Diana Reyes', lessonType: '30-min Private Woodwind', dayOfWeek: 1, startTime: '16:30', room: 'Studio 3', maxStudents: 1, rateMonthly: null },
|
||||
{ key: 'diana_wed_1500_woodwind', instructor: 'Diana Reyes', lessonType: '30-min Private Woodwind', dayOfWeek: 3, startTime: '15:00', room: 'Studio 3', maxStudents: 1, rateMonthly: null },
|
||||
{ key: 'diana_fri_1600_woodwind', instructor: 'Diana Reyes', lessonType: '30-min Private Woodwind', dayOfWeek: 5, startTime: '16:00', room: 'Studio 3', maxStudents: 1, rateMonthly: null },
|
||||
]
|
||||
|
||||
const slotIds: Record<string, string> = {}
|
||||
for (const s of slotDefs) {
|
||||
const instrId = instrIds[s.instructor]
|
||||
const ltId = ltIds[s.lessonType]
|
||||
const [existing] = await sql`
|
||||
SELECT id FROM schedule_slot
|
||||
WHERE instructor_id = ${instrId} AND day_of_week = ${s.dayOfWeek} AND start_time = ${s.startTime + ':00'} AND is_active = true`
|
||||
if (existing) { slotIds[s.key] = existing.id; continue }
|
||||
const [row] = await sql`
|
||||
INSERT INTO schedule_slot (instructor_id, lesson_type_id, day_of_week, start_time, room, max_students, rate_monthly, is_active)
|
||||
VALUES (${instrId}, ${ltId}, ${s.dayOfWeek}, ${s.startTime}, ${s.room}, ${s.maxStudents}, ${s.rateMonthly}, true)
|
||||
RETURNING id`
|
||||
slotIds[s.key] = row.id
|
||||
console.log(` Slot: ${s.instructor} — ${s.lessonType} (${['Sun','Mon','Tue','Wed','Thu','Fri','Sat'][s.dayOfWeek]} ${s.startTime})`)
|
||||
}
|
||||
|
||||
// ── Look up member IDs ─────────────────────────────────────────────────────
|
||||
const memberRows = await sql`SELECT m.id, m.first_name, m.last_name, m.account_id FROM member m`
|
||||
const memberMap: Record<string, { id: string; accountId: string }> = {}
|
||||
for (const m of memberRows) memberMap[`${m.first_name} ${m.last_name}`] = { id: m.id, accountId: m.account_id }
|
||||
|
||||
// ── Enrollments ────────────────────────────────────────────────────────────
|
||||
// Each entry: member, slot key, start date (must match slot's day of week), rate, billingInterval, billingUnit
|
||||
const enrollmentDefs = [
|
||||
{
|
||||
member: 'Tommy Smith', slotKey: 'sarah_mon_1530_piano30', startDate: '2026-01-05',
|
||||
rate: '120.00', billingInterval: 1, billingUnit: 'month',
|
||||
notes: 'Tommy is 8 years old, complete beginner. Parents prefer Monday after school.',
|
||||
},
|
||||
{
|
||||
member: 'Jake Johnson', slotKey: 'sarah_wed_1530_violin', startDate: '2026-01-07',
|
||||
rate: '120.00', billingInterval: 1, billingUnit: 'month',
|
||||
notes: 'Jake (age 10) started Suzuki Book 1 last year at his school program.',
|
||||
},
|
||||
{
|
||||
member: 'Emily Chen', slotKey: 'marcus_tue_1500_guitar', startDate: '2026-01-06',
|
||||
rate: '115.00', billingInterval: 1, billingUnit: 'month',
|
||||
notes: 'Emily plays some chords already — looking to improve technique and learn to read music.',
|
||||
},
|
||||
{
|
||||
member: 'Mike Thompson', slotKey: 'marcus_sat_1000_guitar', startDate: '2026-02-07',
|
||||
rate: '115.00', billingInterval: 1, billingUnit: 'month',
|
||||
notes: 'Mike is an adult beginner. Prefers Saturday mornings.',
|
||||
},
|
||||
{
|
||||
member: 'Carlos Garcia', slotKey: 'diana_fri_1600_woodwind', startDate: '2026-01-09',
|
||||
rate: '120.00', billingInterval: 1, billingUnit: 'month',
|
||||
notes: 'Carlos plays alto sax — returning student after a 2-year break.',
|
||||
},
|
||||
]
|
||||
|
||||
const enrollmentIds: Record<string, string> = {}
|
||||
for (const e of enrollmentDefs) {
|
||||
const m = memberMap[e.member]
|
||||
if (!m) { console.log(` ⚠ Member not found: ${e.member} — skipping enrollment`); continue }
|
||||
const slotId = slotIds[e.slotKey]
|
||||
if (!slotId) { console.log(` ⚠ Slot not found: ${e.slotKey} — skipping enrollment`); continue }
|
||||
const [slot] = await sql`SELECT instructor_id FROM schedule_slot WHERE id = ${slotId}`
|
||||
|
||||
const [existing] = await sql`
|
||||
SELECT id FROM enrollment WHERE member_id = ${m.id} AND schedule_slot_id = ${slotId}`
|
||||
if (existing) { enrollmentIds[e.member] = existing.id; continue }
|
||||
|
||||
const [row] = await sql`
|
||||
INSERT INTO enrollment (member_id, account_id, schedule_slot_id, instructor_id, status, start_date, rate, billing_interval, billing_unit, notes)
|
||||
VALUES (${m.id}, ${m.accountId}, ${slotId}, ${slot.instructor_id}, 'active', ${e.startDate}, ${e.rate}, ${e.billingInterval}, ${e.billingUnit}, ${e.notes})
|
||||
RETURNING id`
|
||||
enrollmentIds[e.member] = row.id
|
||||
console.log(` Enrollment: ${e.member}`)
|
||||
}
|
||||
|
||||
// ── Lesson sessions ────────────────────────────────────────────────────────
|
||||
// Generate past sessions (attended/missed) and a few upcoming (scheduled)
|
||||
// Format: [date, status, instructorNotes?]
|
||||
type SessionEntry = [string, string, string?]
|
||||
|
||||
interface SessionSet {
|
||||
enrollmentKey: string
|
||||
slotKey: string
|
||||
entries: SessionEntry[]
|
||||
}
|
||||
|
||||
const today = '2026-03-30'
|
||||
|
||||
const sessionSets: SessionSet[] = [
|
||||
{
|
||||
enrollmentKey: 'Tommy Smith',
|
||||
slotKey: 'sarah_mon_1530_piano30',
|
||||
entries: [
|
||||
// Past sessions — Monday 15:30
|
||||
['2026-01-05', 'attended'],
|
||||
['2026-01-12', 'attended'],
|
||||
['2026-01-19', 'attended', 'Great progress on Clair de Lune beginner arrangement. Hand position improving.'],
|
||||
['2026-01-26', 'missed'],
|
||||
['2026-02-02', 'attended'],
|
||||
['2026-02-09', 'attended', 'Worked on legato technique. Introduced bass clef reading.'],
|
||||
['2026-02-16', 'attended'],
|
||||
['2026-02-23', 'attended'],
|
||||
['2026-03-02', 'attended', 'Tommy played "Ode to Joy" hands together for the first time — great milestone!'],
|
||||
['2026-03-09', 'attended'],
|
||||
['2026-03-16', 'attended', 'Focusing on dynamics this month. Tommy responds well to storytelling analogies.'],
|
||||
['2026-03-23', 'attended'],
|
||||
// Upcoming
|
||||
['2026-03-30', 'scheduled'],
|
||||
['2026-04-06', 'scheduled'],
|
||||
['2026-04-13', 'scheduled'],
|
||||
],
|
||||
},
|
||||
{
|
||||
enrollmentKey: 'Jake Johnson',
|
||||
slotKey: 'sarah_wed_1530_violin',
|
||||
entries: [
|
||||
['2026-01-07', 'attended'],
|
||||
['2026-01-14', 'attended'],
|
||||
['2026-01-21', 'attended', 'Jake shifted to Suzuki Book 1 Track 4. Bow hold still needs work.'],
|
||||
['2026-01-28', 'attended'],
|
||||
['2026-02-04', 'missed'],
|
||||
['2026-02-11', 'attended', 'Good session — bow hold much improved. Started Perpetual Motion.'],
|
||||
['2026-02-18', 'attended'],
|
||||
['2026-02-25', 'attended'],
|
||||
['2026-03-04', 'attended', 'Perpetual Motion up to speed. Introduced Allegretto.'],
|
||||
['2026-03-11', 'attended'],
|
||||
['2026-03-18', 'attended', 'Jake performed Twinkle Variation A at slow tempo very cleanly.'],
|
||||
['2026-03-25', 'attended'],
|
||||
['2026-04-01', 'scheduled'],
|
||||
['2026-04-08', 'scheduled'],
|
||||
['2026-04-15', 'scheduled'],
|
||||
],
|
||||
},
|
||||
{
|
||||
enrollmentKey: 'Emily Chen',
|
||||
slotKey: 'marcus_tue_1500_guitar',
|
||||
entries: [
|
||||
['2026-01-06', 'attended'],
|
||||
['2026-01-13', 'attended', 'Emily knows open chords (G, C, D, Em, Am). Introduced barre chords.'],
|
||||
['2026-01-20', 'attended'],
|
||||
['2026-01-27', 'attended', 'F barre chord coming along. Introduced power chords and palm muting.'],
|
||||
['2026-02-03', 'attended'],
|
||||
['2026-02-10', 'attended', 'Working through major scale patterns. Emily picks things up fast.'],
|
||||
['2026-02-17', 'missed'],
|
||||
['2026-02-24', 'attended'],
|
||||
['2026-03-03', 'attended', 'Started pentatonic minor scale. Playing along to simple backing track.'],
|
||||
['2026-03-10', 'attended'],
|
||||
['2026-03-17', 'attended', 'Emily improvised a 4-bar phrase over a blues backing — sounded great!'],
|
||||
['2026-03-24', 'attended'],
|
||||
['2026-03-31', 'scheduled'],
|
||||
['2026-04-07', 'scheduled'],
|
||||
['2026-04-14', 'scheduled'],
|
||||
],
|
||||
},
|
||||
{
|
||||
enrollmentKey: 'Mike Thompson',
|
||||
slotKey: 'marcus_sat_1000_guitar',
|
||||
entries: [
|
||||
['2026-02-07', 'attended', 'First lesson — covered proper posture, right/left hand basics, and open E and A strings.'],
|
||||
['2026-02-14', 'attended'],
|
||||
['2026-02-21', 'attended', 'Introduced G, C, Em chords. Switching between G and Em getting smoother.'],
|
||||
['2026-02-28', 'attended'],
|
||||
['2026-03-07', 'attended', 'Added D chord. Working on "Knockin\' on Heaven\'s Door" intro pattern.'],
|
||||
['2026-03-14', 'attended'],
|
||||
['2026-03-21', 'attended', 'Mike played the intro riff cleanly at tempo. Really motivated student.'],
|
||||
['2026-03-28', 'attended'],
|
||||
['2026-04-04', 'scheduled'],
|
||||
['2026-04-11', 'scheduled'],
|
||||
['2026-04-18', 'scheduled'],
|
||||
],
|
||||
},
|
||||
{
|
||||
enrollmentKey: 'Carlos Garcia',
|
||||
slotKey: 'diana_fri_1600_woodwind',
|
||||
entries: [
|
||||
['2026-01-09', 'attended', 'Assessment session — Carlos retained most of his fundamentals. Some embouchure drift after the break.'],
|
||||
['2026-01-16', 'attended'],
|
||||
['2026-01-23', 'attended', 'Back up to speed on low register. Working on altissimo range.'],
|
||||
['2026-01-30', 'attended'],
|
||||
['2026-02-06', 'attended', 'Started working through Charlie Parker\'s "Billie\'s Bounce" head at half tempo.'],
|
||||
['2026-02-13', 'attended'],
|
||||
['2026-02-20', 'attended', 'Tone in the upper register sounding much stronger. Vibrato control improving.'],
|
||||
['2026-02-27', 'attended'],
|
||||
['2026-03-06', 'attended', '"Billie\'s Bounce" head now solid at tempo. Moving to comping rhythms.'],
|
||||
['2026-03-13', 'missed'],
|
||||
['2026-03-20', 'attended'],
|
||||
['2026-03-27', 'attended', 'Carlos brought in his own transcription of a Cannonball Adderley lick — great initiative.'],
|
||||
['2026-04-03', 'scheduled'],
|
||||
['2026-04-10', 'scheduled'],
|
||||
['2026-04-17', 'scheduled'],
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
for (const ss of sessionSets) {
|
||||
const enrollmentId = enrollmentIds[ss.enrollmentKey]
|
||||
if (!enrollmentId) continue
|
||||
const [slot] = await sql`SELECT start_time FROM schedule_slot WHERE id = ${slotIds[ss.slotKey]}`
|
||||
const scheduledTime = slot.start_time
|
||||
|
||||
for (const [date, status, instructorNotes] of ss.entries) {
|
||||
const [existing] = await sql`
|
||||
SELECT id FROM lesson_session WHERE enrollment_id = ${enrollmentId} AND scheduled_date = ${date}`
|
||||
if (existing) continue
|
||||
|
||||
await sql`
|
||||
INSERT INTO lesson_session (enrollment_id, scheduled_date, scheduled_time, status, instructor_notes, notes_completed_at)
|
||||
VALUES (
|
||||
${enrollmentId}, ${date}, ${scheduledTime}, ${status},
|
||||
${instructorNotes ?? null},
|
||||
${instructorNotes && status !== 'scheduled' ? new Date(date + 'T20:00:00Z').toISOString() : null}
|
||||
)`
|
||||
}
|
||||
const count = ss.entries.length
|
||||
console.log(` Sessions: ${ss.enrollmentKey} (${count})`)
|
||||
}
|
||||
|
||||
// ── Lesson plan template — Beginner Piano Fundamentals ─────────────────────
|
||||
const [existingTemplate] = await sql`SELECT id FROM lesson_plan_template WHERE name = 'Beginner Piano Fundamentals'`
|
||||
if (!existingTemplate) {
|
||||
const [tmpl] = await sql`
|
||||
INSERT INTO lesson_plan_template (name, description, instrument, skill_level, is_active)
|
||||
VALUES (
|
||||
'Beginner Piano Fundamentals',
|
||||
'Core skills for students in their first year of piano study — from posture and note reading through first hands-together pieces.',
|
||||
'Piano', 'beginner', true
|
||||
) RETURNING id`
|
||||
|
||||
const sections = [
|
||||
{
|
||||
title: 'Posture & Hand Position',
|
||||
sortOrder: 0,
|
||||
items: [
|
||||
{ title: 'Bench height and sitting posture', sortOrder: 0 },
|
||||
{ title: 'Hand arch and curved fingers', sortOrder: 1 },
|
||||
{ title: 'Finger numbering (1–5)', sortOrder: 2 },
|
||||
{ title: 'Relaxed wrists and forearms', sortOrder: 3 },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Reading Music',
|
||||
sortOrder: 1,
|
||||
items: [
|
||||
{ title: 'Treble clef note names (C4–G5)', sortOrder: 0 },
|
||||
{ title: 'Bass clef note names (C3–G4)', sortOrder: 1 },
|
||||
{ title: 'Note values: whole, half, quarter', sortOrder: 2 },
|
||||
{ title: 'Time signatures: 4/4 and 3/4', sortOrder: 3 },
|
||||
{ title: 'Bar lines and repeats', sortOrder: 4 },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Keyboard Geography',
|
||||
sortOrder: 2,
|
||||
items: [
|
||||
{ title: 'All white key names across full keyboard', sortOrder: 0 },
|
||||
{ title: 'Black key groups (2s and 3s)', sortOrder: 1 },
|
||||
{ title: 'Middle C and octave identification', sortOrder: 2 },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'First Pieces',
|
||||
sortOrder: 3,
|
||||
items: [
|
||||
{ title: 'Five-finger melody (right hand only)', sortOrder: 0 },
|
||||
{ title: 'Simple left hand accompaniment', sortOrder: 1 },
|
||||
{ title: 'Hands together at slow tempo', sortOrder: 2 },
|
||||
{ title: 'Mary Had a Little Lamb', sortOrder: 3 },
|
||||
{ title: 'Ode to Joy (Beethoven)', sortOrder: 4 },
|
||||
{ title: 'Merrily We Roll Along', sortOrder: 5 },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
for (const sec of sections) {
|
||||
const [s] = await sql`
|
||||
INSERT INTO lesson_plan_template_section (template_id, title, sort_order)
|
||||
VALUES (${tmpl.id}, ${sec.title}, ${sec.sortOrder})
|
||||
RETURNING id`
|
||||
for (const item of sec.items) {
|
||||
await sql`
|
||||
INSERT INTO lesson_plan_template_item (section_id, title, grading_scale_id, sort_order)
|
||||
VALUES (${s.id}, ${item.title}, ${scaleId}, ${item.sortOrder})`
|
||||
}
|
||||
}
|
||||
console.log(' Template: Beginner Piano Fundamentals')
|
||||
}
|
||||
|
||||
// ── Lesson plan for Tommy Smith ────────────────────────────────────────────
|
||||
const tommyEnrollmentId = enrollmentIds['Tommy Smith']
|
||||
const tommyMember = memberMap['Tommy Smith']
|
||||
if (tommyEnrollmentId && tommyMember) {
|
||||
const [existingPlan] = await sql`SELECT id FROM member_lesson_plan WHERE enrollment_id = ${tommyEnrollmentId} AND is_active = true`
|
||||
if (!existingPlan) {
|
||||
const [plan] = await sql`
|
||||
INSERT INTO member_lesson_plan (member_id, enrollment_id, title, description, is_active, started_date)
|
||||
VALUES (${tommyMember.id}, ${tommyEnrollmentId}, 'Piano Fundamentals — Tommy Smith', 'Working through first-year piano curriculum.', true, '2026-01-05')
|
||||
RETURNING id`
|
||||
|
||||
// Section 1 — Posture: all mastered
|
||||
const [sec1] = await sql`
|
||||
INSERT INTO lesson_plan_section (lesson_plan_id, title, sort_order)
|
||||
VALUES (${plan.id}, 'Posture & Hand Position', 0) RETURNING id`
|
||||
const posturItems = [
|
||||
{ title: 'Bench height and sitting posture', status: 'mastered', sortOrder: 0 },
|
||||
{ title: 'Hand arch and curved fingers', status: 'mastered', sortOrder: 1 },
|
||||
{ title: 'Finger numbering (1–5)', status: 'mastered', sortOrder: 2 },
|
||||
{ title: 'Relaxed wrists and forearms', status: 'in_progress', sortOrder: 3 },
|
||||
]
|
||||
for (const it of posturItems) {
|
||||
await sql`INSERT INTO lesson_plan_item (section_id, title, status, grading_scale_id, current_grade_value, sort_order)
|
||||
VALUES (${sec1.id}, ${it.title}, ${it.status}, ${scaleId}, ${it.status === 'mastered' ? '4' : it.status === 'in_progress' ? '2' : null}, ${it.sortOrder})`
|
||||
}
|
||||
|
||||
// Section 2 — Reading Music: in progress
|
||||
const [sec2] = await sql`
|
||||
INSERT INTO lesson_plan_section (lesson_plan_id, title, sort_order)
|
||||
VALUES (${plan.id}, 'Reading Music', 1) RETURNING id`
|
||||
const readingItems = [
|
||||
{ title: 'Treble clef note names (C4–G5)', status: 'mastered', sortOrder: 0 },
|
||||
{ title: 'Bass clef note names (C3–G4)', status: 'in_progress', sortOrder: 1 },
|
||||
{ title: 'Note values: whole, half, quarter', status: 'mastered', sortOrder: 2 },
|
||||
{ title: 'Time signatures: 4/4 and 3/4', status: 'in_progress', sortOrder: 3 },
|
||||
{ title: 'Bar lines and repeats', status: 'not_started', sortOrder: 4 },
|
||||
]
|
||||
for (const it of readingItems) {
|
||||
await sql`INSERT INTO lesson_plan_item (section_id, title, status, grading_scale_id, current_grade_value, sort_order)
|
||||
VALUES (${sec2.id}, ${it.title}, ${it.status}, ${scaleId}, ${it.status === 'mastered' ? '4' : it.status === 'in_progress' ? '2' : null}, ${it.sortOrder})`
|
||||
}
|
||||
|
||||
// Section 3 — First Pieces: mix of complete and in progress
|
||||
const [sec3] = await sql`
|
||||
INSERT INTO lesson_plan_section (lesson_plan_id, title, sort_order)
|
||||
VALUES (${plan.id}, 'First Pieces', 2) RETURNING id`
|
||||
const pieceItems = [
|
||||
{ title: 'Five-finger melody (right hand only)', status: 'mastered', sortOrder: 0 },
|
||||
{ title: 'Simple left hand accompaniment', status: 'mastered', sortOrder: 1 },
|
||||
{ title: 'Hands together at slow tempo', status: 'in_progress', sortOrder: 2 },
|
||||
{ title: 'Mary Had a Little Lamb', status: 'mastered', sortOrder: 3 },
|
||||
{ title: 'Ode to Joy (Beethoven)', status: 'in_progress', sortOrder: 4 },
|
||||
{ title: 'Merrily We Roll Along', status: 'not_started', sortOrder: 5 },
|
||||
]
|
||||
for (const it of pieceItems) {
|
||||
await sql`INSERT INTO lesson_plan_item (section_id, title, status, grading_scale_id, current_grade_value, sort_order)
|
||||
VALUES (${sec3.id}, ${it.title}, ${it.status}, ${scaleId}, ${it.status === 'mastered' ? '4' : it.status === 'in_progress' ? '2' : null}, ${it.sortOrder})`
|
||||
}
|
||||
|
||||
console.log(' Lesson plan: Tommy Smith — Piano Fundamentals')
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Lessons seed complete.')
|
||||
}
|
||||
|
||||
seed().catch((err) => {
|
||||
console.error('Seed failed:', err)
|
||||
process.exit(1)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { eq, and, ne, count, gte, lte, inArray, type Column, type SQL } from 'drizzle-orm'
|
||||
import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'
|
||||
import { instructors, lessonTypes, scheduleSlots, enrollments, lessonSessions, instructorBlockedDates, storeClosures, gradingScales, gradingScaleLevels, memberLessonPlans, lessonPlanSections, lessonPlanItems, lessonPlanTemplates, lessonPlanTemplateSections, lessonPlanTemplateItems, lessonPlanItemGradeHistory, lessonSessionPlanItems } from '../db/schema/lessons.js'
|
||||
import { members } from '../db/schema/accounts.js'
|
||||
import type {
|
||||
InstructorCreateInput,
|
||||
InstructorUpdateInput,
|
||||
@@ -108,7 +109,9 @@ export const LessonTypeService = {
|
||||
instrument: input.instrument,
|
||||
durationMinutes: input.durationMinutes,
|
||||
lessonFormat: input.lessonFormat,
|
||||
baseRateMonthly: input.baseRateMonthly?.toString(),
|
||||
rateWeekly: input.rateWeekly?.toString(),
|
||||
rateMonthly: input.rateMonthly?.toString(),
|
||||
rateQuarterly: input.rateQuarterly?.toString(),
|
||||
})
|
||||
.returning()
|
||||
return lessonType
|
||||
@@ -151,7 +154,9 @@ export const LessonTypeService = {
|
||||
|
||||
async update(db: PostgresJsDatabase<any>, id: string, input: LessonTypeUpdateInput) {
|
||||
const values: Record<string, unknown> = { ...input, updatedAt: new Date() }
|
||||
if (input.baseRateMonthly !== undefined) values.baseRateMonthly = input.baseRateMonthly.toString()
|
||||
if (input.rateWeekly !== undefined) values.rateWeekly = input.rateWeekly?.toString() ?? null
|
||||
if (input.rateMonthly !== undefined) values.rateMonthly = input.rateMonthly?.toString() ?? null
|
||||
if (input.rateQuarterly !== undefined) values.rateQuarterly = input.rateQuarterly?.toString() ?? null
|
||||
|
||||
const [lessonType] = await db
|
||||
.update(lessonTypes)
|
||||
@@ -199,6 +204,9 @@ export const ScheduleSlotService = {
|
||||
startTime: input.startTime,
|
||||
room: input.room,
|
||||
maxStudents: input.maxStudents,
|
||||
rateWeekly: input.rateWeekly?.toString(),
|
||||
rateMonthly: input.rateMonthly?.toString(),
|
||||
rateQuarterly: input.rateQuarterly?.toString(),
|
||||
})
|
||||
.returning()
|
||||
return slot
|
||||
@@ -346,7 +354,9 @@ export const EnrollmentService = {
|
||||
status: 'active',
|
||||
startDate: input.startDate,
|
||||
endDate: input.endDate,
|
||||
monthlyRate: input.monthlyRate?.toString(),
|
||||
rate: input.rate?.toString(),
|
||||
billingInterval: input.billingInterval,
|
||||
billingUnit: input.billingUnit,
|
||||
notes: input.notes,
|
||||
})
|
||||
.returning()
|
||||
@@ -374,7 +384,6 @@ export const EnrollmentService = {
|
||||
if (filters?.accountId) conditions.push(eq(enrollments.accountId, filters.accountId))
|
||||
if (filters?.instructorId) conditions.push(eq(enrollments.instructorId, filters.instructorId))
|
||||
if (filters?.status?.length) {
|
||||
const { inArray } = await import('drizzle-orm')
|
||||
conditions.push(inArray(enrollments.status, filters.status as any))
|
||||
}
|
||||
|
||||
@@ -384,23 +393,65 @@ export const EnrollmentService = {
|
||||
start_date: enrollments.startDate,
|
||||
status: enrollments.status,
|
||||
created_at: enrollments.createdAt,
|
||||
member_name: members.firstName,
|
||||
}
|
||||
|
||||
let query = db.select().from(enrollments).where(where).$dynamic()
|
||||
let query = db
|
||||
.select({
|
||||
id: enrollments.id,
|
||||
memberId: enrollments.memberId,
|
||||
accountId: enrollments.accountId,
|
||||
scheduleSlotId: enrollments.scheduleSlotId,
|
||||
instructorId: enrollments.instructorId,
|
||||
status: enrollments.status,
|
||||
startDate: enrollments.startDate,
|
||||
endDate: enrollments.endDate,
|
||||
rate: enrollments.rate,
|
||||
billingInterval: enrollments.billingInterval,
|
||||
billingUnit: enrollments.billingUnit,
|
||||
makeupCredits: enrollments.makeupCredits,
|
||||
notes: enrollments.notes,
|
||||
createdAt: enrollments.createdAt,
|
||||
updatedAt: enrollments.updatedAt,
|
||||
memberName: members.firstName,
|
||||
memberLastName: members.lastName,
|
||||
instructorName: instructors.displayName,
|
||||
slotDayOfWeek: scheduleSlots.dayOfWeek,
|
||||
slotStartTime: scheduleSlots.startTime,
|
||||
lessonTypeName: lessonTypes.name,
|
||||
})
|
||||
.from(enrollments)
|
||||
.leftJoin(members, eq(enrollments.memberId, members.id))
|
||||
.leftJoin(instructors, eq(enrollments.instructorId, instructors.id))
|
||||
.leftJoin(scheduleSlots, eq(enrollments.scheduleSlotId, scheduleSlots.id))
|
||||
.leftJoin(lessonTypes, eq(scheduleSlots.lessonTypeId, lessonTypes.id))
|
||||
.where(where)
|
||||
.$dynamic()
|
||||
query = withSort(query, params.sort, params.order, sortableColumns, enrollments.createdAt)
|
||||
query = withPagination(query, params.page, params.limit)
|
||||
|
||||
const [data, [{ total }]] = await Promise.all([
|
||||
const [rows, [{ total }]] = await Promise.all([
|
||||
query,
|
||||
db.select({ total: count() }).from(enrollments).where(where),
|
||||
])
|
||||
|
||||
const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
|
||||
const data = rows.map((r) => ({
|
||||
...r,
|
||||
memberName: r.memberName && r.memberLastName ? `${r.memberName} ${r.memberLastName}` : undefined,
|
||||
slotInfo: r.slotDayOfWeek != null && r.slotStartTime
|
||||
? `${DAYS[r.slotDayOfWeek]} ${r.slotStartTime.slice(0, 5)}`
|
||||
: undefined,
|
||||
}))
|
||||
|
||||
return paginatedResponse(data, total, params.page, params.limit)
|
||||
},
|
||||
|
||||
async update(db: PostgresJsDatabase<any>, id: string, input: EnrollmentUpdateInput) {
|
||||
const values: Record<string, unknown> = { ...input, updatedAt: new Date() }
|
||||
if (input.monthlyRate !== undefined) values.monthlyRate = input.monthlyRate.toString()
|
||||
if (input.rate !== undefined) values.rate = input.rate?.toString() ?? null
|
||||
if (input.billingInterval !== undefined) values.billingInterval = input.billingInterval
|
||||
if (input.billingUnit !== undefined) values.billingUnit = input.billingUnit
|
||||
|
||||
const [enrollment] = await db
|
||||
.update(enrollments)
|
||||
@@ -542,29 +593,11 @@ export const LessonSessionService = {
|
||||
}) {
|
||||
const conditions: SQL[] = []
|
||||
|
||||
if (filters?.enrollmentId) {
|
||||
conditions.push(eq(lessonSessions.enrollmentId, filters.enrollmentId))
|
||||
}
|
||||
if (filters?.instructorId) {
|
||||
// Join through enrollment to filter by instructor
|
||||
const enrollmentIds = await db
|
||||
.select({ id: enrollments.id })
|
||||
.from(enrollments)
|
||||
.where(eq(enrollments.instructorId, filters.instructorId))
|
||||
if (enrollmentIds.length === 0) {
|
||||
return paginatedResponse([], 0, params.page, params.limit)
|
||||
}
|
||||
conditions.push(inArray(lessonSessions.enrollmentId, enrollmentIds.map((e) => e.id)))
|
||||
}
|
||||
if (filters?.status?.length) {
|
||||
conditions.push(inArray(lessonSessions.status, filters.status as any))
|
||||
}
|
||||
if (filters?.dateFrom) {
|
||||
conditions.push(gte(lessonSessions.scheduledDate, filters.dateFrom))
|
||||
}
|
||||
if (filters?.dateTo) {
|
||||
conditions.push(lte(lessonSessions.scheduledDate, filters.dateTo))
|
||||
}
|
||||
if (filters?.enrollmentId) conditions.push(eq(lessonSessions.enrollmentId, filters.enrollmentId))
|
||||
if (filters?.instructorId) conditions.push(eq(enrollments.instructorId, filters.instructorId))
|
||||
if (filters?.status?.length) conditions.push(inArray(lessonSessions.status, filters.status as any))
|
||||
if (filters?.dateFrom) conditions.push(gte(lessonSessions.scheduledDate, filters.dateFrom))
|
||||
if (filters?.dateTo) conditions.push(lte(lessonSessions.scheduledDate, filters.dateTo))
|
||||
|
||||
const where = conditions.length > 0 ? and(...conditions) : undefined
|
||||
|
||||
@@ -575,15 +608,54 @@ export const LessonSessionService = {
|
||||
created_at: lessonSessions.createdAt,
|
||||
}
|
||||
|
||||
let query = db.select().from(lessonSessions).where(where).$dynamic()
|
||||
let query = db
|
||||
.select({
|
||||
id: lessonSessions.id,
|
||||
enrollmentId: lessonSessions.enrollmentId,
|
||||
scheduledDate: lessonSessions.scheduledDate,
|
||||
scheduledTime: lessonSessions.scheduledTime,
|
||||
actualStartTime: lessonSessions.actualStartTime,
|
||||
actualEndTime: lessonSessions.actualEndTime,
|
||||
status: lessonSessions.status,
|
||||
instructorNotes: lessonSessions.instructorNotes,
|
||||
memberNotes: lessonSessions.memberNotes,
|
||||
homeworkAssigned: lessonSessions.homeworkAssigned,
|
||||
nextLessonGoals: lessonSessions.nextLessonGoals,
|
||||
topicsCovered: lessonSessions.topicsCovered,
|
||||
makeupForSessionId: lessonSessions.makeupForSessionId,
|
||||
substituteInstructorId: lessonSessions.substituteInstructorId,
|
||||
notesCompletedAt: lessonSessions.notesCompletedAt,
|
||||
createdAt: lessonSessions.createdAt,
|
||||
updatedAt: lessonSessions.updatedAt,
|
||||
memberName: members.firstName,
|
||||
memberLastName: members.lastName,
|
||||
instructorName: instructors.displayName,
|
||||
lessonTypeName: lessonTypes.name,
|
||||
})
|
||||
.from(lessonSessions)
|
||||
.leftJoin(enrollments, eq(lessonSessions.enrollmentId, enrollments.id))
|
||||
.leftJoin(members, eq(enrollments.memberId, members.id))
|
||||
.leftJoin(instructors, eq(enrollments.instructorId, instructors.id))
|
||||
.leftJoin(scheduleSlots, eq(enrollments.scheduleSlotId, scheduleSlots.id))
|
||||
.leftJoin(lessonTypes, eq(scheduleSlots.lessonTypeId, lessonTypes.id))
|
||||
.where(where)
|
||||
.$dynamic()
|
||||
query = withSort(query, params.sort, params.order, sortableColumns, lessonSessions.scheduledDate)
|
||||
query = withPagination(query, params.page, params.limit)
|
||||
|
||||
const [data, [{ total }]] = await Promise.all([
|
||||
const [rows, [{ total }]] = await Promise.all([
|
||||
query,
|
||||
db.select({ total: count() }).from(lessonSessions).where(where),
|
||||
db.select({ total: count() })
|
||||
.from(lessonSessions)
|
||||
.leftJoin(enrollments, eq(lessonSessions.enrollmentId, enrollments.id))
|
||||
.where(where),
|
||||
])
|
||||
|
||||
const data = rows.map((r) => ({
|
||||
...r,
|
||||
memberName: r.memberName && r.memberLastName ? `${r.memberName} ${r.memberLastName}` : undefined,
|
||||
}))
|
||||
|
||||
return paginatedResponse(data, total, params.page, params.limit)
|
||||
},
|
||||
|
||||
|
||||
@@ -30,7 +30,9 @@ export const LessonTypeCreateSchema = z.object({
|
||||
instrument: opt(z.string().max(100)),
|
||||
durationMinutes: z.coerce.number().int().min(5).max(480),
|
||||
lessonFormat: LessonFormat.default('private'),
|
||||
baseRateMonthly: z.coerce.number().min(0).optional(),
|
||||
rateWeekly: opt(z.coerce.number().min(0)),
|
||||
rateMonthly: opt(z.coerce.number().min(0)),
|
||||
rateQuarterly: opt(z.coerce.number().min(0)),
|
||||
})
|
||||
export type LessonTypeCreateInput = z.infer<typeof LessonTypeCreateSchema>
|
||||
|
||||
@@ -46,6 +48,9 @@ export const ScheduleSlotCreateSchema = z.object({
|
||||
startTime: z.string().regex(/^\d{2}:\d{2}$/, 'Must be HH:MM format'),
|
||||
room: opt(z.string().max(100)),
|
||||
maxStudents: z.coerce.number().int().min(1).default(1),
|
||||
rateWeekly: opt(z.coerce.number().min(0)),
|
||||
rateMonthly: opt(z.coerce.number().min(0)),
|
||||
rateQuarterly: opt(z.coerce.number().min(0)),
|
||||
})
|
||||
export type ScheduleSlotCreateInput = z.infer<typeof ScheduleSlotCreateSchema>
|
||||
|
||||
@@ -64,7 +69,9 @@ export const EnrollmentCreateSchema = z.object({
|
||||
instructorId: z.string().uuid(),
|
||||
startDate: z.string().min(1),
|
||||
endDate: opt(z.string()),
|
||||
monthlyRate: z.coerce.number().min(0).optional(),
|
||||
rate: opt(z.coerce.number().min(0)),
|
||||
billingInterval: opt(z.coerce.number().int().min(1)),
|
||||
billingUnit: opt(z.enum(['day', 'week', 'month', 'quarter', 'year'])),
|
||||
notes: opt(z.string()),
|
||||
})
|
||||
export type EnrollmentCreateInput = z.infer<typeof EnrollmentCreateSchema>
|
||||
|
||||
Reference in New Issue
Block a user