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:
Ryan Moon
2026-03-30 18:52:57 -05:00
parent 7680a73d88
commit 5ad27bc196
47 changed files with 6303 additions and 139 deletions

View File

@@ -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
View 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
View 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
View 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
View 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 "========================================================"

View File

@@ -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",

View 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}`),
}

View File

@@ -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,

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

@@ -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 }

View 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>
)
}

View File

@@ -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(

View File

@@ -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') && (

View File

@@ -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' },

View File

@@ -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>
)
}

View File

@@ -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

View File

@@ -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>
) : (

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)

View 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
}

View File

@@ -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 35 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:004: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. 15, AF, 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[] {

View File

@@ -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')
})

View 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;

View File

@@ -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
}
]
}

View File

@@ -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(),

View File

@@ -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) {

View File

@@ -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 (15)', sortOrder: 2 },
{ title: 'Relaxed wrists and forearms', sortOrder: 3 },
],
},
{
title: 'Reading Music',
sortOrder: 1,
items: [
{ title: 'Treble clef note names (C4G5)', sortOrder: 0 },
{ title: 'Bass clef note names (C3G4)', 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 (15)', 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 (C4G5)', status: 'mastered', sortOrder: 0 },
{ title: 'Bass clef note names (C3G4)', 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)

View File

@@ -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)
},

View File

@@ -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>