55 Commits

Author SHA1 Message Date
Ryan Moon
4ef7f1977c fix: start postgres and valkey via docker run in e2e to avoid service networking issues 2026-04-01 21:25:30 -05:00
Ryan Moon
bc2f39c208 fix: revert service hostnames to localhost for host network mode 2026-04-01 21:15:52 -05:00
Ryan Moon
41037af4f6 fix: use service hostnames for e2e postgres and valkey connections 2026-04-01 21:13:04 -05:00
Ryan Moon
77e155b8c3 feat: add e2e api-test job to CI 2026-04-01 21:09:40 -05:00
Ryan Moon
c01d19215d fix: skip test failure when no test files exist in backend 2026-04-01 21:08:01 -05:00
Ryan Moon
744256ae9f fix: pass with no tests in backend until unit tests are added 2026-04-01 21:06:15 -05:00
Ryan Moon
5993f8b370 fix: remove unused postgres/valkey services from CI — tests are pure unit tests 2026-04-01 21:01:33 -05:00
Ryan Moon
4c971f90eb fix: run CI on host runner to fix service container networking 2026-04-01 21:00:15 -05:00
Ryan Moon
05f926c0dc fix: remove unused imports and dead code to clear ESLint errors 2026-04-01 20:34:56 -05:00
Ryan Moon
a73c2de26e feat: add frontend nginx image and update build workflow for both images 2026-04-01 20:32:34 -05:00
Ryan Moon
0f8aff9426 fix: resolve ESLint errors — remove unused imports and dead code 2026-04-01 20:18:13 -05:00
Ryan Moon
c3de66e554 fix: skip build workflow on version bump commits 2026-04-01 20:05:50 -05:00
Ryan Moon
7987818ae7 fix: use node script for version bump instead of npm version 2026-04-01 19:57:09 -05:00
Ryan Moon
c2b1073fef feat: add CI/CD pipeline, production Dockerfile, and deployment architecture
- Add production Dockerfile with bun build --compile, multi-stage Alpine build
- Add .dockerignore
- Swap bcrypt -> bcryptjs (pure JS, no native addons)
- Add programmatic migrations on startup via drizzle migrator
- Add /v1/version endpoint with APP_VERSION baked in at build time
- Add .gitea/workflows/ci.yml (lint + test with postgres/valkey services)
- Add .gitea/workflows/build.yml (version bump, build, push to registry)
- Update CLAUDE.md and docs/architecture.md to remove multi-tenancy
- Add docs/deployment.md covering DOKS + ArgoCD architecture

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 19:50:37 -05:00
Ryan Moon
ffef4c8727 Remove infra folder — moved to lunarfront-infra repo 2026-03-31 08:11:37 -05:00
Ryan Moon
d18d431bd0 Add terraform lock file 2026-03-31 06:12:44 -05:00
Ryan Moon
41b6f076cb Remove terraform provider binaries from git, add .gitignore 2026-03-31 06:12:25 -05:00
Ryan Moon
fe3c7646d6 Add infra setup: Terraform for DO droplet + Cloudflare DNS, Ansible roles for Gitea, Vaultwarden, and Gitea runner 2026-03-31 06:08:21 -05:00
Ryan Moon
bde3ad64fd Fix code review items: atomic qty increment, unit updatedAt, suppliers/all endpoint, SKU unique index 2026-03-31 05:08:01 -05:00
Ryan Moon
5f5ba9e4a2 Build inventory frontend and stock management features
- Full inventory UI: product list with search/filter, product detail with
  tabs (details, units, suppliers, stock receipts, price history)
- Product filters: category, type (serialized/rental/repair), low stock,
  active/inactive — all server-side with URL-synced state
- Product-supplier junction: link products to multiple suppliers with
  preferred flag, joined supplier details in UI
- Stock receipts: record incoming stock with supplier, qty, cost per unit,
  invoice number; auto-increments qty_on_hand for non-serialized products
- Price history tab on product detail page
- categories/all endpoint to avoid pagination limit on dropdown fetches
- categoryId filter on product list endpoint
- Repair parts and additional inventory items in music store seed data
- isDualUseRepair corrected: instruments set to false, strings/parts true
- Product-supplier links and stock receipts in seed data
- Price history seed data simulating cost increases over past year
- 37 API tests covering categories, suppliers, products, units,
  product-suppliers, and stock receipts
- alert-dialog and checkbox UI components
- sync-and-deploy.sh script for rsync + remote deploy
2026-03-30 20:12:07 -05:00
Ryan Moon
ec09e319ed Update accounting planning doc v2 — fix AP/safe accounts, add periods, tax rates, gift cards, consignment, posting service 2026-03-30 19:41:23 -05:00
Ryan Moon
89b412374a Expand POS planning — discounts, returns, cash management, training mode, customer display, quick keys 2026-03-30 19:15:13 -05:00
Ryan Moon
07f199b69d Add PIN unlock flow to POS frontend planning 2026-03-30 19:12:17 -05:00
Ryan Moon
ae3c85fee0 Add frontend strategy planning doc — admin, POS, floor app 2026-03-30 19:08:16 -05:00
Ryan Moon
5ad27bc196 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
2026-03-30 18:52:57 -05:00
Ryan Moon
7680a73d88 Add Phase 8: lesson plan templates with deep-copy instantiation
- New tables: lesson_plan_template, lesson_plan_template_section, lesson_plan_template_item
- skill_level enum: beginner, intermediate, advanced, all_levels
- Templates are reusable curriculum definitions independent of any member/enrollment
- POST /lesson-plan-templates/:id/create-plan deep-copies the template into a member plan
- Instantiation uses template name as default plan title, accepts custom title override
- Instantiation deactivates any existing active plan on the enrollment (one-active rule)
- Plan items are independent copies — renaming the template does not affect existing plans
- 11 new integration tests
2026-03-30 10:37:30 -05:00
Ryan Moon
2cc8f24535 Add Phase 7: grade history and session-plan item linking
- New tables: lesson_plan_item_grade_history (append-only), lesson_session_plan_item
- Grading an item updates current_grade_value and creates immutable history record
- Grading a not_started item auto-transitions it to in_progress
- Linking items to a session also auto-transitions not_started items
- Link operation is idempotent — re-linking same items produces no duplicates
- Endpoints: POST/GET /lesson-plan-items/:id/grades, GET /lesson-plan-items/:id/grade-history
- Endpoints: POST/GET /lesson-sessions/:id/plan-items
- 8 new integration tests
2026-03-30 10:33:21 -05:00
Ryan Moon
5cd2d05983 Add Phase 4b: instructor blocked dates, store closures, and substitute instructors
- New tables: instructor_blocked_date, store_closure (migration 0034)
- substitute_instructor_id column added to lesson_session
- Session generation skips blocked instructor dates and store closure periods
- Substitute assignment validates sub is not blocked and has no conflicting slot
- Routes: POST/GET/DELETE /instructors/:id/blocked-dates, POST/GET/DELETE /store-closures
- 15 new integration tests covering blocked dates, store closures, and sub validation
2026-03-30 10:29:13 -05:00
Ryan Moon
aae5a022a8 Add lessons Phase 6: lesson plans with curriculum tracking
Structured lesson plans with nested sections and items per enrollment.
Deep create in one request, one-active-per-enrollment constraint,
auto-set startedDate/masteredDate on status transitions, progress %
calculation (skipped items excluded). 8 new tests (84 total).
2026-03-30 09:40:41 -05:00
Ryan Moon
31f661ff4f Add lessons Phase 5: grading scales with nested levels
Custom grading scales with ordered levels (value, label, numeric score,
color). Supports one-default-per-store constraint, deep create with
nested levels, lookup endpoint for dropdowns, and search/pagination.
12 new tests (76 total lessons tests).
2026-03-30 09:36:48 -05:00
Ryan Moon
73360cd478 Add lessons Phase 4: lesson sessions with hybrid calendar generation
Individual lesson occurrences generated from schedule slot patterns.
Idempotent session generation with configurable rolling window.
Post-lesson notes workflow with auto-set notesCompletedAt. Status
tracking (scheduled/attended/missed/makeup/cancelled) and date/time
filtering. 13 new tests (64 total lessons tests).
2026-03-30 09:29:03 -05:00
Ryan Moon
93405af3b2 Add lessons Phase 3: enrollments with capacity and time conflict checks
Links members to schedule slots via enrollments. Enforces max_students
capacity on slots and prevents members from double-booking the same
day/time. Supports status transitions and filtering. 11 new tests
(51 total lessons tests).
2026-03-30 09:23:43 -05:00
Ryan Moon
f777ce5184 Add lessons Phase 2: schedule slots with conflict detection
Recurring weekly time slots linking instructors to lesson types.
Includes day/time overlap detection, instructor and day-of-week
filtering, and 17 new integration tests (40 total lessons tests).
2026-03-30 09:20:03 -05:00
Ryan Moon
5dbe837c08 Add lessons domain Phase 1: instructor and lesson type entities
Foundation tables for the lessons module with full CRUD, pagination,
search, and sorting. Includes migration, Drizzle schema, Zod validation,
services, routes, and 23 integration tests.
2026-03-30 09:17:32 -05:00
Ryan Moon
145eb0efce Improve sidebar: collapsible, grouped sections, scrollable nav, pinned footer
- Sidebar collapses to icon-only mode with toggle button
- Nav items grouped into sections (Customers, Repairs, Storage, Admin)
- Each section is independently collapsible
- Middle nav area scrolls when items overflow
- Help, profile, and sign out pinned to bottom
2026-03-30 08:52:23 -05:00
Ryan Moon
7176c1471e Add music store seed preset and repair data reset script
- music-store-seed.ts: 52 templates covering strings, brass, woodwinds,
  guitar, plus music-specific tickets and a school band batch
- reset-repairs.ts: clears all repair data for switching between presets
- New scripts: bun run db:seed-music, bun run db:seed-reset-repairs
2026-03-30 08:52:09 -05:00
Ryan Moon
701e15ea6d Remove DB-dependent unit tests, use api-tests for integration testing
All tests in __tests__/ were hitting the database directly. Integration
testing is handled by the api-tests/ suite instead.
2026-03-30 08:52:01 -05:00
Ryan Moon
9400828f62 Rename Forte to LunarFront, generalize for any small business
Rebrand from Forte (music-store-specific) to LunarFront (any small business):
- Package namespace @forte/* → @lunarfront/*
- Database forte/forte_test → lunarfront/lunarfront_test
- Docker containers, volumes, connection strings
- UI branding, localStorage keys, test emails
- All documentation and planning docs

Generalize music-specific terminology:
- instrumentDescription → itemDescription
- instrumentCount → itemCount
- instrumentType → itemCategory (on service templates)
- New migration 0027_generalize_terminology for column renames
- Seed data updated with generic examples
- RBAC descriptions updated
2026-03-30 08:51:54 -05:00
Ryan Moon
535446696c Include accessLevel in vault categories list response 2026-03-30 07:36:23 -05:00
Ryan Moon
a51f1f5141 Add vault category permissions dialog with role/user management 2026-03-30 07:23:23 -05:00
Ryan Moon
4438188362 Fix vault dialog autofill: prevent browser password manager from overriding dark theme colors 2026-03-30 07:18:58 -05:00
Ryan Moon
1510133074 Seed default roles on startup alongside permissions 2026-03-30 07:16:25 -05:00
Ryan Moon
328b4a1f7b Fix dev seed for single-company schema, sync RBAC on startup
- Remove all company_id references from dev-seed.ts (removed in 0021)
- seedPermissions now syncs role-permission assignments for system roles
  when new permissions are added (e.g., vault.view assigned to admin)
- Fix enum migration: use text cast workaround for PostgreSQL's
  "unsafe use of new enum value" error on fresh DB creation
2026-03-30 07:10:20 -05:00
Ryan Moon
e346e072b8 Add module management system for enabling/disabling features
Stores can enable/disable feature modules from Settings. When disabled,
nav links are hidden and API routes return 403. Designed as the
foundation for future license-based gating (licensed + enabled flags).

Core modules (Accounts, Members, Users, Roles, Settings) are always on.

- module_config table with slug, name, description, licensed, enabled
- In-memory cache for fast per-request module checks
- requireModule middleware wraps route groups in main.ts
- Settings page Modules card with toggle switches
- Sidebar hides nav links for disabled modules
- Default modules seeded: inventory, pos, repairs, rentals, lessons,
  files, vault, email, reports
2026-03-30 06:52:27 -05:00
Ryan Moon
1f9297f533 Add vault secret manager frontend UI
Three-state page: not initialized → locked → unlocked.
Any user with vault.view can unlock (for store opening).
Admins can lock and change master password.

- Two-panel layout: categories on left, entries on right
- Entry reveal button shows decrypted value for 30s with copy
- Add/edit/delete entries and categories
- KeyRound icon in sidebar navigation
2026-03-30 06:17:58 -05:00
Ryan Moon
7246587955 Add vault secret manager backend with AES-256-GCM encryption
Secrets are encrypted at rest in the database. The derived encryption
key is held in memory only — on reboot, an authorized user must enter
the master password to unlock. Admins can also manually lock the vault.

- vault_config, vault_category, vault_category_permission, vault_entry tables
- AES-256-GCM encryption with PBKDF2-derived key + per-entry IV
- Master password initialize/unlock/lock/change lifecycle
- Category CRUD with role/user permission model (view/edit/admin)
- Entry CRUD with reveal endpoint (POST to avoid caching)
- Secret values never returned in list/detail responses
- vault.view/edit/admin RBAC permissions seeded
- 19 API integration tests covering full lifecycle
2026-03-30 06:11:33 -05:00
Ryan Moon
748ea59c80 Harden storage permissions and WebDAV security
Permission service:
- Add hasAccess() with explicit minLevel param, deprecate canAccess()
- Cycle protection + depth limit (50) on all parent traversal
- Pick highest access level across multiple roles (was using first match)
- isPublic only grants view on directly requested folder, not inherited
- Sanitize file extension from content-type
- Clean up orphaned traverse perms when removing permissions
- Add getPermissionById() for authz checks on permission deletion

Storage routes:
- All write ops require edit via hasAccess() — traverse can no longer
  create folders, upload files, rename, toggle isPublic, or delete
- Permission delete requires admin access on the folder
- Permission list requires admin access on the folder
- Folder children listing filtered by user access
- File search results filtered by user access (was returning all)
- Signed URL requires view (was using canAccess which allows traverse)

WebDAV:
- 100MB upload size limit (was unbounded — OOM risk)
- PROPFIND root filters folders by user access (was listing all)
- COPY uses hasAccess('view') not canAccess (traverse bypass)
- All writes use hasAccess('edit') consistently
- MKCOL at root requires files.delete permission
- Lock ownership enforced on UNLOCK (was allowing any user)
- Lock conflict check on LOCK (423 if locked by another user)
- Lock enforcement on PUT and DELETE (423 if locked by another)
- Max 100 locks per user, periodic expired lock cleanup
- Path traversal protection: reject .. and null bytes in segments
- Brute-force protection: 10 failed attempts per IP, 5min lockout
2026-03-29 18:21:19 -05:00
Ryan Moon
f998b16a3f Add traverse access level for folder navigation without file access
When a permission is set on a nested folder, traverse is automatically
granted on all ancestor folders so users can navigate to it. Traverse
only shows subfolders in listings — files are hidden. This prevents
orphaned permissions where a user has access to a nested folder but
can't reach it.

Hierarchy: traverse < view < edit < admin
2026-03-29 18:04:24 -05:00
Ryan Moon
51ca2ca683 Add folder permissions UI and WebDAV protocol support
Permissions UI:
- FolderPermissionsDialog component with public/private toggle,
  role/user permission management, and access level badges
- Integrated into file manager toolbar (visible for folder admins)
- Backend returns accessLevel in folder detail endpoint

WebDAV server:
- Full WebDAV protocol at /webdav/ with Basic Auth (existing credentials)
- PROPFIND, GET, PUT, DELETE, MKCOL, COPY, MOVE, LOCK/UNLOCK support
- Permission-checked against existing folder access model
- In-memory lock stubs for Windows client compatibility
- 22 API integration tests covering all operations

Also fixes canAccess to check folder creator (was missing).
2026-03-29 17:38:57 -05:00
Ryan Moon
cbbf2713a1 Enlarge logo upload area to 256x128 for better preview 2026-03-29 16:39:36 -05:00
Ryan Moon
1002117610 Show store logo in sidebar with Amplified by Forte branding
Sidebar header now loads the store logo from the files API and
displays it scaled to fit. Below the logo: "Amplified by Forte"
in subtle text. Falls back to store name as text if no logo is
uploaded. Logo fetched via authenticated request with blob URL
and proper cleanup.
2026-03-29 16:32:18 -05:00
Ryan Moon
f9bf1c9bff Add rectangular logo upload to settings, support SVG content type
Settings page now shows a rectangular upload area for the store logo
instead of circular avatar. Uses authenticated image fetching with
blob URL cleanup. Accepts SVG in addition to JPEG/PNG/WebP. SVG
added to file serve content type map. Simplified to single logo
image (used on PDFs, sidebar, and login).
2026-03-29 16:27:02 -05:00
Ryan Moon
8d75586f8b Add store logo and app icon uploads to settings page
AvatarUpload component now supports custom category and placeholder
icon props. Settings page shows two upload circles: Store Logo (for
PDFs/invoices, uses ImageIcon placeholder) and App Icon (for sidebar/
login, uses Building placeholder). Added 'company' to allowed file
entity types.
2026-03-29 16:14:08 -05:00
Ryan Moon
653fff6ce2 Add store settings page with location management
Company table gains address and logo_file_id columns. New store
settings API: GET/PATCH /store for company info, full CRUD for
/locations. Settings page shows store name, phone, email, address,
timezone with inline edit. Location cards with add/edit/delete.
Settings link in admin sidebar. Fixes leftover company_id on
location table and seed files.
2026-03-29 15:56:02 -05:00
Ryan Moon
0f6cc104d2 Add shared file storage with folder tree, permissions, and file manager UI
New document hub for centralized file storage — replaces scattered
drives and USB sticks for non-technical SMBs. Three new tables:
storage_folder (nested hierarchy), storage_folder_permission (role
and user-level access control), storage_file.

Backend: folder CRUD with nested paths, file upload/download via
signed URLs, permission checks (view/edit/admin with inheritance
from parent folders), public/private toggle, breadcrumb navigation,
file search.

Frontend: two-panel file manager — collapsible folder tree on left,
icon grid view on right. Folder icons by type, file size display,
upload button, context menu for download/delete. Breadcrumb nav.
Files sidebar link added.
2026-03-29 15:31:20 -05:00
217 changed files with 22531 additions and 4309 deletions

11
.dockerignore Normal file
View File

@@ -0,0 +1,11 @@
node_modules
.git
.gitea
docs
planning
deploy
infra
packages/admin
Dockerfile*
docker-compose*
*.md

View File

@@ -1,9 +1,9 @@
# Forte — Environment Variables
# LunarFront — Environment Variables
# Copy to .env and adjust values for your setup.
# Docker Compose overrides host values (postgres, valkey) automatically.
# Database (PostgreSQL 16)
DATABASE_URL=postgresql://forte:forte@localhost:5432/forte
DATABASE_URL=postgresql://lunarfront:lunarfront@localhost:5432/lunarfront
# Valkey / Redis
REDIS_URL=redis://localhost:6379
@@ -20,7 +20,7 @@ NODE_ENV=development
# Logging (optional)
# LOG_LEVEL=info
# LOG_FILE=./logs/forte.log
# LOG_FILE=./logs/lunarfront.log
# File Storage (optional — defaults to local)
# STORAGE_PROVIDER=local

View File

@@ -0,0 +1,99 @@
name: Build & Release
on:
push:
branches: [main]
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
if: "!startsWith(github.event.head_commit.message, 'chore: bump version')"
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.BOT_TOKEN }}
- name: Determine version bump
id: bump
run: |
COMMIT_MSG=$(git log -1 --pretty=%s)
if echo "$COMMIT_MSG" | grep -qiE "^breaking(\(.+\))?:|^.+!:"; then
echo "type=major" >> $GITHUB_OUTPUT
elif echo "$COMMIT_MSG" | grep -qiE "^feat(\(.+\))?:"; then
echo "type=minor" >> $GITHUB_OUTPUT
else
echo "type=patch" >> $GITHUB_OUTPUT
fi
- name: Bump version in package.json
id: version
run: |
VERSION=$(node -e "
const fs = require('fs');
const pkg = JSON.parse(fs.readFileSync('packages/backend/package.json', 'utf8'));
const [major, minor, patch] = pkg.version.split('.').map(Number);
const type = '${{ steps.bump.outputs.type }}';
if (type === 'major') pkg.version = \`\${major + 1}.0.0\`;
else if (type === 'minor') pkg.version = \`\${major}.\${minor + 1}.0\`;
else pkg.version = \`\${major}.\${minor}.\${patch + 1}\`;
fs.writeFileSync('packages/backend/package.json', JSON.stringify(pkg, null, 2) + '\n');
console.log(pkg.version);
")
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Commit version bump
run: |
git config user.name "lunarfront-bot"
git config user.email "bot@lunarfront.tech"
git remote set-url origin https://lunarfront-bot:${{ secrets.BOT_TOKEN }}@git.lunarfront.tech/ryan/lunarfront-app.git
git add packages/backend/package.json
git commit -m "chore: bump version to v${{ steps.version.outputs.version }}"
git push origin main
- name: Install Docker CLI
run: |
apt-get update -qq
apt-get install -y ca-certificates curl
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian $(. /etc/os-release && echo $VERSION_CODENAME) stable" > /etc/apt/sources.list.d/docker.list
apt-get update -qq
apt-get install -y docker-ce-cli
- name: Login to registry
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login registry.lunarfront.tech -u ryan --password-stdin
- name: Build and push backend
run: |
VERSION=${{ steps.version.outputs.version }}
SHA=$(git rev-parse --short HEAD)
docker build \
--build-arg APP_VERSION=$VERSION \
-t registry.lunarfront.tech/ryan/lunarfront-app:$VERSION \
-t registry.lunarfront.tech/ryan/lunarfront-app:$SHA \
-t registry.lunarfront.tech/ryan/lunarfront-app:latest \
-f Dockerfile .
docker push registry.lunarfront.tech/ryan/lunarfront-app:$VERSION
docker push registry.lunarfront.tech/ryan/lunarfront-app:$SHA
docker push registry.lunarfront.tech/ryan/lunarfront-app:latest
- name: Build and push frontend
run: |
VERSION=${{ steps.version.outputs.version }}
SHA=$(git rev-parse --short HEAD)
docker build \
-t registry.lunarfront.tech/ryan/lunarfront-frontend:$VERSION \
-t registry.lunarfront.tech/ryan/lunarfront-frontend:$SHA \
-t registry.lunarfront.tech/ryan/lunarfront-frontend:latest \
-f Dockerfile.frontend .
docker push registry.lunarfront.tech/ryan/lunarfront-frontend:$VERSION
docker push registry.lunarfront.tech/ryan/lunarfront-frontend:$SHA
docker push registry.lunarfront.tech/ryan/lunarfront-frontend:latest
- name: Logout
if: always()
run: docker logout registry.lunarfront.tech

70
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,70 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
jobs:
ci:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Bun
run: |
curl -fsSL https://bun.sh/install | bash
echo "$HOME/.bun/bin" >> $GITHUB_PATH
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Lint
run: bun run lint
- name: Test
run: bun run test
e2e:
runs-on: ubuntu-latest
needs: ci
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Start services
run: |
docker run -d --name postgres \
-e POSTGRES_USER=lunarfront \
-e POSTGRES_PASSWORD=lunarfront \
-e POSTGRES_DB=postgres \
-p 5432:5432 \
postgres:16
docker run -d --name valkey \
-p 6379:6379 \
valkey/valkey:8
until docker exec postgres pg_isready -U lunarfront; do sleep 1; done
until docker exec valkey valkey-cli ping; do sleep 1; done
- name: Install Bun
run: |
curl -fsSL https://bun.sh/install | bash
echo "$HOME/.bun/bin" >> $GITHUB_PATH
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Run API tests
working-directory: packages/backend
run: bun run api-test
- name: Stop services
if: always()
run: |
docker stop postgres valkey || true
docker rm postgres valkey || true

7
.gitignore vendored
View File

@@ -17,6 +17,13 @@ out/
*.o
*.pyc
__pycache__/
*.tsbuildinfo
# Turbo cache
.turbo/
# Infra (moved to lunarfront-infra repo)
infra/
# IDE
.idea/

View File

@@ -1,8 +1,8 @@
# Forte — Project Conventions
# LunarFront — Project Conventions
## App
- **Name:** Forte
- **Purpose:** Music store management platform (POS, inventory, rentals, lessons, repairs, accounting)
- **Name:** LunarFront
- **Purpose:** Small business management platform (POS, inventory, rentals, scheduling, repairs, accounting)
- **Company:** Lunarfront Tech LLC
## Tech Stack
@@ -18,14 +18,13 @@
- **Linting:** ESLint 9 flat config + Prettier
## Package Namespace
- `@forte/shared` — types, Zod schemas, business logic, utils
- `@forte/backend` — Fastify API server
- `@lunarfront/shared` — types, Zod schemas, business logic, utils
- `@lunarfront/backend` — Fastify API server
## Database
- Dev: `forte` on localhost:5432
- Test: `forte_test` on localhost:5432
- Multi-tenant: `company_id` (uuid FK) on all domain tables for tenant isolation
- `location_id` (uuid FK) on tables that need per-location scoping (inventory, transactions, drawer)
- Dev: `lunarfront` on localhost:5432
- Test: `lunarfront_test` on localhost:5432
- Each deployed instance has its own isolated database — no multi-tenancy, no `company_id`
- Migrations via Drizzle Kit (`bunx drizzle-kit generate`, `bunx drizzle-kit migrate`)
## Key Entity Names
@@ -46,7 +45,7 @@
- `?sort=name&order=asc` — sorting by field name, asc or desc
- List responses always return `{ data: [...], pagination: { page, limit, total, totalPages } }`
- Search and filtering is ALWAYS server-side, never client-side
- Use `PaginationSchema` from `@forte/shared/schemas` to parse query params
- Use `PaginationSchema` from `@lunarfront/shared/schemas` to parse query params
- Use pagination helpers from `packages/backend/src/utils/pagination.ts`
- **Lookup endpoints** (e.g., `/roles/all`, `/statuses/all`) are the exception — these return a flat unpaginated list for populating dropdowns/selects. Use a `/all` suffix to distinguish from the paginated list endpoint for the same resource.
@@ -60,7 +59,7 @@
## Conventions
- Shared Zod schemas are the single source of truth for validation (used on both frontend and backend)
- Business logic lives in `@forte/shared`, not in individual app packages
- Business logic lives in `@lunarfront/shared`, not in individual app packages
- API routes are thin — validate with Zod, call a service, return result
- All financial events must be auditable (append-only audit records)
- JSON structured logging with request IDs on every log line

32
Dockerfile Normal file
View File

@@ -0,0 +1,32 @@
FROM oven/bun:1.3.11-alpine AS deps
WORKDIR /app
COPY package.json bun.lock ./
COPY packages/shared/package.json packages/shared/
COPY packages/backend/package.json packages/backend/
RUN bun install --frozen-lockfile
FROM oven/bun:1.3.11-alpine AS build
ARG APP_VERSION=dev
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/packages/shared/node_modules ./packages/shared/node_modules
COPY --from=deps /app/packages/backend/node_modules ./packages/backend/node_modules
COPY packages/shared ./packages/shared
COPY packages/backend ./packages/backend
COPY package.json ./
COPY tsconfig.base.json ./
WORKDIR /app/packages/backend
RUN bun build src/main.ts --compile --outfile /app/server \
--define "process.env.APP_VERSION='${APP_VERSION}'"
FROM alpine:3.21
RUN addgroup -S app && adduser -S app -G app
WORKDIR /app
COPY --from=build /app/server ./server
COPY --from=build /app/packages/backend/src/db/migrations ./migrations
ENV MIGRATIONS_DIR=/app/migrations
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD wget -qO- http://localhost:8000/v1/health || exit 1
USER app
CMD ["./server"]

24
Dockerfile.frontend Normal file
View File

@@ -0,0 +1,24 @@
FROM oven/bun:1.3.11-alpine AS deps
WORKDIR /app
COPY package.json bun.lock ./
COPY packages/shared/package.json packages/shared/
COPY packages/admin/package.json packages/admin/
RUN bun install --frozen-lockfile
FROM oven/bun:1.3.11-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/packages/shared/node_modules ./packages/shared/node_modules
COPY --from=deps /app/packages/admin/node_modules ./packages/admin/node_modules
COPY packages/shared ./packages/shared
COPY packages/admin ./packages/admin
COPY package.json ./
COPY tsconfig.base.json ./
WORKDIR /app/packages/admin
RUN bun run build
FROM nginx:alpine
COPY --from=build /app/packages/admin/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -0,0 +1,11 @@
node_modules
.git
.gitea
docs
planning
deploy
infra
packages/backend
Dockerfile*
docker-compose*
*.md

View File

@@ -1,6 +1,6 @@
# Forte
# LunarFront
Music store management platform — POS, inventory, rentals, lessons, repairs, and accounting.
Small business management platform — POS, inventory, rentals, scheduling, repairs, and accounting.
Built by [Lunarfront Tech LLC](https://lunarfront.com).

175
bun.lock
View File

@@ -3,7 +3,7 @@
"configVersion": 1,
"workspaces": {
"": {
"name": "forte",
"name": "lunarfront",
"dependencies": {
"zod": "^4.3.6",
},
@@ -16,11 +16,11 @@
},
},
"packages/admin": {
"name": "@forte/admin",
"name": "@lunarfront/admin",
"version": "0.0.1",
"dependencies": {
"@forte/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",
@@ -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",
@@ -55,15 +58,16 @@
},
},
"packages/backend": {
"name": "@forte/backend",
"name": "@lunarfront/backend",
"version": "0.0.1",
"dependencies": {
"@fastify/cors": "^10",
"@fastify/jwt": "^9",
"@fastify/multipart": "^9.4.0",
"@fastify/rate-limit": "^10.3.0",
"@forte/shared": "workspace:*",
"bcrypt": "^6",
"@lunarfront/shared": "workspace:*",
"@types/bcryptjs": "^3.0.0",
"bcryptjs": "^3.0.3",
"drizzle-orm": "^0.38",
"fastify": "^5",
"fastify-plugin": "^5",
@@ -72,7 +76,6 @@
"zod": "^4",
},
"devDependencies": {
"@types/bcrypt": "^5",
"@types/node": "^22",
"drizzle-kit": "^0.30",
"pino-pretty": "^13",
@@ -80,7 +83,7 @@
},
},
"packages/shared": {
"name": "@forte/shared",
"name": "@lunarfront/shared",
"version": "0.0.1",
"dependencies": {
"zod": "^4",
@@ -243,12 +246,6 @@
"@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="],
"@forte/admin": ["@forte/admin@workspace:packages/admin"],
"@forte/backend": ["@forte/backend@workspace:packages/backend"],
"@forte/shared": ["@forte/shared@workspace:packages/shared"],
"@hookform/resolvers": ["@hookform/resolvers@5.2.2", "", { "dependencies": { "@standard-schema/utils": "^0.3.0" }, "peerDependencies": { "react-hook-form": "^7.55.0" } }, "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA=="],
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
@@ -273,10 +270,18 @@
"@lukeed/ms": ["@lukeed/ms@2.0.2", "", {}, "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA=="],
"@lunarfront/admin": ["@lunarfront/admin@workspace:packages/admin"],
"@lunarfront/backend": ["@lunarfront/backend@workspace:packages/backend"],
"@lunarfront/shared": ["@lunarfront/shared@workspace:packages/shared"],
"@petamoriken/float16": ["@petamoriken/float16@3.9.3", "", {}, "sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g=="],
"@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,57 +402,59 @@
"@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.0", "", { "os": "android", "cpu": "arm" }, "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.1", "", { "os": "android", "cpu": "arm" }, "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.0", "", { "os": "android", "cpu": "arm64" }, "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.1", "", { "os": "android", "cpu": "arm64" }, "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA=="],
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.60.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA=="],
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.60.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw=="],
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.60.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw=="],
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.60.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.60.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.60.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w=="],
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.60.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA=="],
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.60.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.60.0", "", { "os": "linux", "cpu": "arm" }, "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.60.1", "", { "os": "linux", "cpu": "arm" }, "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.0", "", { "os": "linux", "cpu": "arm" }, "sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.1", "", { "os": "linux", "cpu": "arm" }, "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA=="],
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.60.0", "", { "os": "linux", "cpu": "none" }, "sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw=="],
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ=="],
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.60.0", "", { "os": "linux", "cpu": "none" }, "sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog=="],
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw=="],
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.60.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ=="],
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.60.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw=="],
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.60.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg=="],
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.60.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.60.0", "", { "os": "linux", "cpu": "none" }, "sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.60.0", "", { "os": "linux", "cpu": "none" }, "sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.60.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.60.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.60.0", "", { "os": "linux", "cpu": "x64" }, "sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.60.1", "", { "os": "linux", "cpu": "x64" }, "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.60.0", "", { "os": "linux", "cpu": "x64" }, "sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.60.1", "", { "os": "linux", "cpu": "x64" }, "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w=="],
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.60.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw=="],
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.60.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw=="],
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.60.0", "", { "os": "none", "cpu": "arm64" }, "sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA=="],
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.60.1", "", { "os": "none", "cpu": "arm64" }, "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.60.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.60.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.60.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.60.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg=="],
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.60.0", "", { "os": "win32", "cpu": "x64" }, "sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA=="],
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.60.1", "", { "os": "win32", "cpu": "x64" }, "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.0", "", { "os": "win32", "cpu": "x64" }, "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.1", "", { "os": "win32", "cpu": "x64" }, "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ=="],
"@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="],
@@ -487,15 +494,15 @@
"@tanstack/react-query": ["@tanstack/react-query@5.95.2", "", { "dependencies": { "@tanstack/query-core": "5.95.2" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-/wGkvLj/st5Ud1Q76KF1uFxScV7WeqN1slQx5280ycwAyYkIPGaRZAEgHxe3bjirSd5Zpwkj6zNcR4cqYni/ZA=="],
"@tanstack/react-router": ["@tanstack/react-router@1.168.7", "", { "dependencies": { "@tanstack/history": "1.161.6", "@tanstack/react-store": "^0.9.3", "@tanstack/router-core": "1.168.6", "isbot": "^5.1.22" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-fW/HvQja4PQeu9lsoyh+pXpZ0UXezbpQkkJvCuH6tHAaW3jvPkjh14lfadrNNiY+pXT7WiMTB3afGhTCC78PDQ=="],
"@tanstack/react-router": ["@tanstack/react-router@1.168.8", "", { "dependencies": { "@tanstack/history": "1.161.6", "@tanstack/react-store": "^0.9.3", "@tanstack/router-core": "1.168.7", "isbot": "^5.1.22" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-t0S0QueXubBKmI9eLPcN/A1sLQgTu8/yHerjrvvsGeD12zMdw0uJPKwEKpStQF2OThQtw64cs34uUSYXBUTSNw=="],
"@tanstack/react-store": ["@tanstack/react-store@0.9.3", "", { "dependencies": { "@tanstack/store": "0.9.3", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg=="],
"@tanstack/router-core": ["@tanstack/router-core@1.168.6", "", { "dependencies": { "@tanstack/history": "1.161.6", "cookie-es": "^2.0.0", "seroval": "^1.4.2", "seroval-plugins": "^1.4.2" }, "bin": { "intent": "bin/intent.js" } }, "sha512-okCno3pImpLFQMJ/4zqEIGjIV5yhxLGj0JByrzQDQehORN1y1q6lJUezT0KPK5qCQiKUApeWaboLPjgBVx1kaQ=="],
"@tanstack/router-core": ["@tanstack/router-core@1.168.7", "", { "dependencies": { "@tanstack/history": "1.161.6", "cookie-es": "^2.0.0", "seroval": "^1.4.2", "seroval-plugins": "^1.4.2" }, "bin": { "intent": "bin/intent.js" } }, "sha512-z4UEdlzMrFaKBsG4OIxlZEm+wsYBtEp//fnX6kW18jhQpETNcM6u2SXNdX+bcIYp6AaR7ERS3SBENzjC/xxwQQ=="],
"@tanstack/router-generator": ["@tanstack/router-generator@1.166.21", "", { "dependencies": { "@tanstack/router-core": "1.168.6", "@tanstack/router-utils": "1.161.6", "@tanstack/virtual-file-routes": "1.161.7", "prettier": "^3.5.0", "recast": "^0.23.11", "source-map": "^0.7.4", "tsx": "^4.19.2", "zod": "^3.24.2" } }, "sha512-pJWsP6HaGrkIkfkcg6vzKyCBMbf1vV1BrQH+bFAVzXj3T/afmix3IPV2hiAj4zzjMxuddJD1on0Hn5+WDYA7zQ=="],
"@tanstack/router-generator": ["@tanstack/router-generator@1.166.22", "", { "dependencies": { "@tanstack/router-core": "1.168.7", "@tanstack/router-utils": "1.161.6", "@tanstack/virtual-file-routes": "1.161.7", "prettier": "^3.5.0", "recast": "^0.23.11", "source-map": "^0.7.4", "tsx": "^4.19.2", "zod": "^3.24.2" } }, "sha512-wQ7H8/Q2rmSPuaxWnurJ3DATNnqWV2tajxri9TSiW4QHsG7cWPD34+goeIinKG+GajJyEdfVpz6w/gRJXfbAPw=="],
"@tanstack/router-plugin": ["@tanstack/router-plugin@1.167.8", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5", "@tanstack/router-core": "1.168.6", "@tanstack/router-generator": "1.166.21", "@tanstack/router-utils": "1.161.6", "@tanstack/virtual-file-routes": "1.161.7", "chokidar": "^3.6.0", "unplugin": "^2.1.2", "zod": "^3.24.2" }, "peerDependencies": { "@rsbuild/core": ">=1.0.2", "@tanstack/react-router": "^1.168.7", "vite": ">=5.0.0 || >=6.0.0 || >=7.0.0", "vite-plugin-solid": "^2.11.10", "webpack": ">=5.92.0" }, "optionalPeers": ["@rsbuild/core", "@tanstack/react-router", "vite", "vite-plugin-solid", "webpack"], "bin": { "intent": "bin/intent.js" } }, "sha512-/X4ACYsSX4bRmomj5X2TBU75cHuIVI99Fsax6DWnP6hPb4PaSjPUHVBfHhk2NemJzEOZu1L31UQ9QDlbHU4ZTQ=="],
"@tanstack/router-plugin": ["@tanstack/router-plugin@1.167.9", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5", "@tanstack/router-core": "1.168.7", "@tanstack/router-generator": "1.166.22", "@tanstack/router-utils": "1.161.6", "@tanstack/virtual-file-routes": "1.161.7", "chokidar": "^3.6.0", "unplugin": "^2.1.2", "zod": "^3.24.2" }, "peerDependencies": { "@rsbuild/core": ">=1.0.2", "@tanstack/react-router": "^1.168.8", "vite": ">=5.0.0 || >=6.0.0 || >=7.0.0", "vite-plugin-solid": "^2.11.10", "webpack": ">=5.92.0" }, "optionalPeers": ["@rsbuild/core", "@tanstack/react-router", "vite", "vite-plugin-solid", "webpack"], "bin": { "intent": "bin/intent.js" } }, "sha512-h/VV05FEHd4PVyc5Zy8B3trWLcdLt/Pmp+mfifmBKGRw+MUtvdQKbBHhmy4ouOf67s5zDJMc+n8R3xgU7bDwFA=="],
"@tanstack/router-utils": ["@tanstack/router-utils@1.161.6", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/generator": "^7.28.5", "@babel/parser": "^7.28.5", "@babel/types": "^7.28.5", "ansis": "^4.1.0", "babel-dead-code-elimination": "^1.0.12", "diff": "^8.0.2", "pathe": "^2.0.3", "tinyglobby": "^0.2.15" } }, "sha512-nRcYw+w2OEgK6VfjirYvGyPLOK+tZQz1jkYcmH5AjMamQ9PycnlxZF2aEZtPpNoUsaceX2bHptn6Ub5hGXqNvw=="],
@@ -503,17 +510,17 @@
"@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.161.7", "", { "bin": { "intent": "bin/intent.js" } }, "sha512-olW33+Cn+bsCsZKPwEGhlkqS6w3M2slFv11JIobdnCFKMLG97oAI2kWKdx5/zsywTL8flpnoIgaZZPlQTFYhdQ=="],
"@turbo/darwin-64": ["@turbo/darwin-64@2.8.20", "", { "os": "darwin", "cpu": "x64" }, "sha512-FQ9EX1xMU5nbwjxXxM3yU88AQQ6Sqc6S44exPRroMcx9XZHqqppl5ymJF0Ig/z3nvQNwDmz1Gsnvxubo+nXWjQ=="],
"@turbo/darwin-64": ["@turbo/darwin-64@2.8.21", "", { "os": "darwin", "cpu": "x64" }, "sha512-kfGoM0Iw8ZNZpbds+4IzOe0hjvHldqJwUPRAjXJi3KBxg/QOZL95N893SRoMtf2aJ+jJ3dk32yPkp8rvcIjP9g=="],
"@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.8.20", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Gpyh9ATFGThD6/s9L95YWY54cizg/VRWl2B67h0yofG8BpHf67DFAh9nuJVKG7bY0+SBJDAo5cMur+wOl9YOYw=="],
"@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.8.21", "", { "os": "darwin", "cpu": "arm64" }, "sha512-o9HEflxUEyr987x0cTUzZBhDOyL6u95JmdmlkH2VyxAw7zq2sdtM5e72y9ufv2N5SIoOBw1fVn9UES5VY5H6vQ=="],
"@turbo/linux-64": ["@turbo/linux-64@2.8.20", "", { "os": "linux", "cpu": "x64" }, "sha512-p2QxWUYyYUgUFG0b0kR+pPi8t7c9uaVlRtjTTI1AbCvVqkpjUfCcReBn6DgG/Hu8xrWdKLuyQFaLYFzQskZbcA=="],
"@turbo/linux-64": ["@turbo/linux-64@2.8.21", "", { "os": "linux", "cpu": "x64" }, "sha512-uTxlCcXWy5h1fSSymP8XSJ+AudzEHMDV3IDfKX7+DGB8kgJ+SLoTUAH7z4OFA7I/l2sznz0upPdbNNZs91YMag=="],
"@turbo/linux-arm64": ["@turbo/linux-arm64@2.8.20", "", { "os": "linux", "cpu": "arm64" }, "sha512-Gn5yjlZGLRZWarLWqdQzv0wMqyBNIdq1QLi48F1oY5Lo9kiohuf7BPQWtWxeNVS2NgJ1+nb/DzK1JduYC4AWOA=="],
"@turbo/linux-arm64": ["@turbo/linux-arm64@2.8.21", "", { "os": "linux", "cpu": "arm64" }, "sha512-cdHIcxNcihHHkCHp0Y4Zb60K4Qz+CK4xw1gb6s/t/9o4SMeMj+hTBCtoW6QpPnl9xPYmxuTou8Zw6+cylTnREg=="],
"@turbo/windows-64": ["@turbo/windows-64@2.8.20", "", { "os": "win32", "cpu": "x64" }, "sha512-vyaDpYk/8T6Qz5V/X+ihKvKFEZFUoC0oxYpC1sZanK6gaESJlmV3cMRT3Qhcg4D2VxvtC2Jjs9IRkrZGL+exLw=="],
"@turbo/windows-64": ["@turbo/windows-64@2.8.21", "", { "os": "win32", "cpu": "x64" }, "sha512-/iBj4OzbqEY8CX+eaeKbBTMZv2CLXNrt0692F7HnK7LcyYwyDecaAiSET6ZzL4opT7sbwkKvzAC/fhqT3Quu1A=="],
"@turbo/windows-arm64": ["@turbo/windows-arm64@2.8.20", "", { "os": "win32", "cpu": "arm64" }, "sha512-voicVULvUV5yaGXo0Iue13BcHGYW3u0VgqSbfQwBaHbpj1zLjYV4KIe+7fYIo6DO8FVUJzxFps3ODCQG/Wy2Qw=="],
"@turbo/windows-arm64": ["@turbo/windows-arm64@2.8.21", "", { "os": "win32", "cpu": "arm64" }, "sha512-95tMA/ZbIidJFUUtkmqioQ1gf3n3I1YbRP3ZgVdWTVn2qVbkodcIdGXBKRHHrIbRsLRl99SiHi/L7IxhpZDagQ=="],
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
@@ -523,7 +530,9 @@
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
"@types/bcrypt": ["@types/bcrypt@5.0.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-6atioO8Y75fNcbmj0G7UjI9lXN2pQ/IGJ2FWT4a/btd0Lk9lQalHLKhkgKVZ3r+spnmWUKfbMi1GEe9wyHQfNQ=="],
"@types/bcryptjs": ["@types/bcryptjs@3.0.0", "", { "dependencies": { "bcryptjs": "*" } }, "sha512-WRZOuCuaz8UcZZE4R5HXTco2goQSI2XxjGY3hbM/xDvwmqFWd4ivooImsMx65OKM6CtNKbnZ5YL+YwAwK7c1dg=="],
"@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=="],
@@ -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=="],
@@ -597,9 +612,9 @@
"base64-arraybuffer": ["base64-arraybuffer@1.0.2", "", {}, "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.11", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-DAKrHphkJyiGuau/cFieRYhcTFeK/lBuD++C7cZ6KZHbMhBrisoi+EvhQ5RZrIfV5qwsW8kgQ07JIC+MDJRAhg=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.12", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-qyq26DxfY4awP2gIRXhhLWfwzwI+N5Nxk6iQi8EFizIaWIjqicQTE4sLnZZVdeKPRcVNoJOkkpfzoIYuvCKaIQ=="],
"bcrypt": ["bcrypt@6.0.0", "", { "dependencies": { "node-addon-api": "^8.3.0", "node-gyp-build": "^4.8.4" } }, "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg=="],
"bcryptjs": ["bcryptjs@3.0.3", "", { "bin": { "bcrypt": "bin/bcrypt" } }, "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g=="],
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
@@ -615,7 +630,7 @@
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
"caniuse-lite": ["caniuse-lite@1.0.30001781", "", {}, "sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw=="],
"caniuse-lite": ["caniuse-lite@1.0.30001782", "", {}, "sha512-dZcaJLJeDMh4rELYFw1tvSn1bhZWYFOt468FcbHHxx/Z/dFidd1I6ciyFdi3iwfQCyOjqo9upF6lGQYtMiJWxw=="],
"canvg": ["canvg@3.0.11", "", { "dependencies": { "@babel/runtime": "^7.12.5", "@types/raf": "^3.4.0", "core-js": "^3.8.3", "raf": "^3.4.1", "regenerator-runtime": "^0.13.7", "rgbcolor": "^1.0.1", "stackblur-canvas": "^2.0.0", "svg-pathdata": "^6.0.3" } }, "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA=="],
@@ -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,20 +918,22 @@
"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=="],
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
"node-addon-api": ["node-addon-api@8.7.0", "", {}, "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA=="],
"node-gyp-build": ["node-gyp-build@4.8.4", "", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="],
"node-releases": ["node-releases@2.0.36", "", {}, "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA=="],
"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 +980,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 +994,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=="],
@@ -991,7 +1040,7 @@
"rgbcolor": ["rgbcolor@1.0.1", "", {}, "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw=="],
"rollup": ["rollup@4.60.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.0", "@rollup/rollup-android-arm64": "4.60.0", "@rollup/rollup-darwin-arm64": "4.60.0", "@rollup/rollup-darwin-x64": "4.60.0", "@rollup/rollup-freebsd-arm64": "4.60.0", "@rollup/rollup-freebsd-x64": "4.60.0", "@rollup/rollup-linux-arm-gnueabihf": "4.60.0", "@rollup/rollup-linux-arm-musleabihf": "4.60.0", "@rollup/rollup-linux-arm64-gnu": "4.60.0", "@rollup/rollup-linux-arm64-musl": "4.60.0", "@rollup/rollup-linux-loong64-gnu": "4.60.0", "@rollup/rollup-linux-loong64-musl": "4.60.0", "@rollup/rollup-linux-ppc64-gnu": "4.60.0", "@rollup/rollup-linux-ppc64-musl": "4.60.0", "@rollup/rollup-linux-riscv64-gnu": "4.60.0", "@rollup/rollup-linux-riscv64-musl": "4.60.0", "@rollup/rollup-linux-s390x-gnu": "4.60.0", "@rollup/rollup-linux-x64-gnu": "4.60.0", "@rollup/rollup-linux-x64-musl": "4.60.0", "@rollup/rollup-openbsd-x64": "4.60.0", "@rollup/rollup-openharmony-arm64": "4.60.0", "@rollup/rollup-win32-arm64-msvc": "4.60.0", "@rollup/rollup-win32-ia32-msvc": "4.60.0", "@rollup/rollup-win32-x64-gnu": "4.60.0", "@rollup/rollup-win32-x64-msvc": "4.60.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ=="],
"rollup": ["rollup@4.60.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.1", "@rollup/rollup-android-arm64": "4.60.1", "@rollup/rollup-darwin-arm64": "4.60.1", "@rollup/rollup-darwin-x64": "4.60.1", "@rollup/rollup-freebsd-arm64": "4.60.1", "@rollup/rollup-freebsd-x64": "4.60.1", "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", "@rollup/rollup-linux-arm-musleabihf": "4.60.1", "@rollup/rollup-linux-arm64-gnu": "4.60.1", "@rollup/rollup-linux-arm64-musl": "4.60.1", "@rollup/rollup-linux-loong64-gnu": "4.60.1", "@rollup/rollup-linux-loong64-musl": "4.60.1", "@rollup/rollup-linux-ppc64-gnu": "4.60.1", "@rollup/rollup-linux-ppc64-musl": "4.60.1", "@rollup/rollup-linux-riscv64-gnu": "4.60.1", "@rollup/rollup-linux-riscv64-musl": "4.60.1", "@rollup/rollup-linux-s390x-gnu": "4.60.1", "@rollup/rollup-linux-x64-gnu": "4.60.1", "@rollup/rollup-linux-x64-musl": "4.60.1", "@rollup/rollup-openbsd-x64": "4.60.1", "@rollup/rollup-openharmony-arm64": "4.60.1", "@rollup/rollup-win32-arm64-msvc": "4.60.1", "@rollup/rollup-win32-ia32-msvc": "4.60.1", "@rollup/rollup-win32-x64-gnu": "4.60.1", "@rollup/rollup-win32-x64-msvc": "4.60.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w=="],
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
@@ -1067,7 +1116,7 @@
"tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="],
"turbo": ["turbo@2.8.20", "", { "optionalDependencies": { "@turbo/darwin-64": "2.8.20", "@turbo/darwin-arm64": "2.8.20", "@turbo/linux-64": "2.8.20", "@turbo/linux-arm64": "2.8.20", "@turbo/windows-64": "2.8.20", "@turbo/windows-arm64": "2.8.20" }, "bin": { "turbo": "bin/turbo" } }, "sha512-Rb4qk5YT8RUwwdXtkLpkVhNEe/lor6+WV7S5tTlLpxSz6MjV5Qi8jGNn4gS6NAvrYGA/rNrE6YUQM85sCZUDbQ=="],
"turbo": ["turbo@2.8.21", "", { "optionalDependencies": { "@turbo/darwin-64": "2.8.21", "@turbo/darwin-arm64": "2.8.21", "@turbo/linux-64": "2.8.21", "@turbo/linux-arm64": "2.8.21", "@turbo/windows-64": "2.8.21", "@turbo/windows-arm64": "2.8.21" }, "bin": { "turbo": "bin/turbo" } }, "sha512-FlJ8OD5Qcp0jTAM7E4a/RhUzRNds2GzKlyxHKA6N247VLy628rrxAGlMpIXSz6VB430+TiQDJ/SMl6PL1lu6wQ=="],
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
@@ -1075,6 +1124,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 +1144,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=="],
@@ -1153,7 +1206,7 @@
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg=="],
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.2", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw=="],
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
@@ -1189,6 +1242,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..."
sudo systemctl restart lunarfront
echo "==> Done! Checking status..."
sleep 2
sudo 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 "========================================================"

25
deploy/sync-and-deploy.sh Executable file
View File

@@ -0,0 +1,25 @@
#!/usr/bin/env bash
# LunarFront — Sync local source to EC2 and redeploy
# Usage: bash deploy/sync-and-deploy.sh
set -euo pipefail
EC2_HOST="18.217.233.214"
EC2_USER="ubuntu"
SSH_KEY="$HOME/.ssh/lunarfront-dev.pem"
APP_DIR="/opt/lunarfront"
echo "==> Syncing source to ${EC2_USER}@${EC2_HOST}:${APP_DIR} ..."
rsync -az --delete \
--exclude='.git' \
--exclude='node_modules' \
--exclude='packages/*/node_modules' \
--exclude='packages/admin/dist' \
--exclude='packages/backend/dist' \
--exclude='*.env' \
-e "ssh -i ${SSH_KEY} -o StrictHostKeyChecking=no" \
/home/ryan/pos/ \
"${EC2_USER}@${EC2_HOST}:${APP_DIR}/"
echo "==> Running deploy script on server..."
ssh -i "${SSH_KEY}" -o StrictHostKeyChecking=no "${EC2_USER}@${EC2_HOST}" \
"sudo bash ${APP_DIR}/deploy/deploy.sh"

View File

@@ -3,7 +3,7 @@ services:
build:
context: .
dockerfile: Dockerfile.dev
container_name: forte-api
container_name: lunarfront-api
restart: unless-stopped
ports:
- "8000:8000"
@@ -14,7 +14,7 @@ services:
- /app/packages/backend/node_modules
- /app/packages/shared/node_modules
environment:
DATABASE_URL: postgresql://forte:forte@postgres:5432/forte
DATABASE_URL: postgresql://lunarfront:lunarfront@postgres:5432/lunarfront
REDIS_URL: redis://valkey:6379
JWT_SECRET: dev-secret-do-not-use-in-production
NODE_ENV: development
@@ -28,30 +28,30 @@ services:
postgres:
image: postgres:16
container_name: forte-postgres
container_name: lunarfront-postgres
restart: unless-stopped
environment:
POSTGRES_USER: forte
POSTGRES_PASSWORD: forte
POSTGRES_DB: forte
POSTGRES_USER: lunarfront
POSTGRES_PASSWORD: lunarfront
POSTGRES_DB: lunarfront
ports:
- "5432:5432"
volumes:
- forte-pgdata:/var/lib/postgresql/data
- lunarfront-pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U forte"]
test: ["CMD-SHELL", "pg_isready -U lunarfront"]
interval: 5s
timeout: 3s
retries: 5
valkey:
image: valkey/valkey:8
container_name: forte-valkey
container_name: lunarfront-valkey
restart: unless-stopped
ports:
- "6379:6379"
volumes:
- forte-valkey:/data
- lunarfront-valkey:/data
healthcheck:
test: ["CMD", "valkey-cli", "ping"]
interval: 5s
@@ -59,5 +59,5 @@ services:
retries: 5
volumes:
forte-pgdata:
forte-valkey:
lunarfront-pgdata:
lunarfront-valkey:

View File

@@ -3,16 +3,16 @@
## Monorepo Structure
```
forte/
lunarfront/
packages/
shared/ @forte/shared — Zod schemas, types, business logic, utils
backend/ @forte/backend — Fastify API server
admin/ @forte/admin — Admin UI (React + Vite)
shared/ @lunarfront/shared — Zod schemas, types, business logic, utils
backend/ @lunarfront/backend — Fastify API server
admin/ @lunarfront/admin — Admin UI (React + Vite)
planning/ Domain planning docs (01-26)
docs/ Technical documentation
```
Managed with Turborepo and Bun workspaces. `@forte/shared` is a dependency of both `backend` and `admin`.
Managed with Turborepo and Bun workspaces. `@lunarfront/shared` is a dependency of both `backend` and `admin`.
## Backend
@@ -56,11 +56,10 @@ src/
### Request Flow
1. Fastify receives request
2. `onRequest` hook sets `companyId` from header
3. `authenticate` preHandler verifies JWT, loads permissions, checks `is_active`
4. `requirePermission` preHandler checks user has required permission slug
5. Route handler validates input with Zod, calls service, returns response
6. Error handler catches typed errors and maps to HTTP status codes
2. `authenticate` preHandler verifies JWT, loads permissions, checks `is_active`
3. `requirePermission` preHandler checks user has required permission slug
4. Route handler validates input with Zod, calls service, returns response
5. Error handler catches typed errors and maps to HTTP status codes
### Permission Inheritance
@@ -109,9 +108,9 @@ src/
| Theme | Zustand store, persisted to localStorage |
| Component state | React `useState` |
## Multi-Tenancy
## Deployment Model
Every domain table has a `company_id` column. All queries filter by the authenticated user's company. Location-scoped tables (inventory, transactions) additionally filter by `location_id`.
Each customer runs as a fully isolated deployment — their own Kubernetes namespace on DOKS, their own database on the shared managed Postgres instance. There is no multi-tenancy in the application layer. No `company_id`, no row-level isolation. One instance = one customer.
## Database

View File

@@ -6,8 +6,8 @@ PostgreSQL 16. Two databases:
| Database | Port | Usage |
|----------|------|-------|
| `forte` | 5432 | Development |
| `forte_api_test` | 5432 | API integration tests (auto-created by test runner) |
| `lunarfront` | 5432 | Development |
| `lunarfront_api_test` | 5432 | API integration tests (auto-created by test runner) |
## Migrations
@@ -35,7 +35,7 @@ All domain tables include `company_id` (uuid FK to `company`). Every query filte
| Table | Description |
|-------|-------------|
| `company` | Tenant (music store business) |
| `company` | Tenant (tenant business) |
| `location` | Physical store location |
| `user` | Staff/admin user account |

193
docs/deployment.md Normal file
View File

@@ -0,0 +1,193 @@
# Infrastructure & Deployment Architecture
## Overview
LunarFront runs on DigitalOcean. Each customer is a fully isolated deployment — their own Kubernetes namespace, their own database. There is no multi-tenancy in the application layer.
**Stack:** DOKS (Kubernetes) · ArgoCD · Helm · Gitea · Terraform · Ansible
The guiding principle is simplicity — Docker Compose on Droplets for Gitea, Kubernetes for customer app instances. No surprise bills.
---
## Monthly Cost
| Resource | Spec | Cost |
|---|---|---|
| DOKS cluster | Control plane | $12/mo |
| DOKS nodes (2x s-2vcpu-4gb) | Runs all customer apps | ~$48/mo |
| Managed Postgres (shared) | All customer databases | $15-25/mo |
| Managed Redis (shared) | All customer queues/cache | $15/mo |
| Gitea Droplet | 1 vCPU, 1GB RAM | $6/mo |
| Gitea Postgres | Dedicated managed Postgres | $15/mo |
| Spaces | Registry, backups, files | ~$21/mo |
| **Fixed total** | | **~$132-142/mo** |
| **Per customer** | New database in shared Postgres | **~$0 marginal** |
---
## Environments
| Environment | Hostname | Purpose |
|---|---|---|
| Production (per customer) | `{customer}.app.lunarfront.tech` | Live customer instance on DOKS |
| Gitea | `git.lunarfront.tech` | Source control, CI/CD, container registry |
| Dev | Local / feature namespace on DOKS | Testing and staging |
---
## DNS
Managed through Cloudflare. Records defined in Terraform.
| Record | Type | Points To |
|---|---|---|
| `git.lunarfront.tech` | A | Gitea Droplet |
| `git-ssh.lunarfront.tech` | A | Gitea Droplet (SSH port 2222) |
| `registry.lunarfront.tech` | A | Gitea Droplet (container registry) |
| `{customer}.app.lunarfront.tech` | CNAME | DOKS load balancer |
---
## Infrastructure (Terraform)
Terraform manages all DigitalOcean and Cloudflare resources. State stored in Spaces.
```
/terraform
main.tf providers, backend (Spaces state)
variables.tf
cluster.tf DOKS cluster + node pools
databases.tf shared Postgres, shared Redis
gitea.tf Gitea Droplet
spaces.tf files, backups, tf-state buckets
dns.tf Cloudflare DNS records
outputs.tf cluster endpoint, DB URLs
terraform.tfvars secrets (gitignored)
```
### State Backend
```hcl
terraform {
backend "s3" {
endpoint = "https://nyc3.digitaloceanspaces.com"
bucket = "lunarfront-terraform-state"
key = "terraform.tfstate"
region = "us-east-1"
skip_credentials_validation = true
skip_metadata_api_check = true
skip_region_validation = true
force_path_style = true
}
}
```
---
## Gitea (Source Control + CI/CD)
Gitea runs on its own Droplet with a dedicated managed Postgres. It hosts:
- Source code repositories
- Gitea Actions CI/CD pipelines
- Docker container registry (`registry.lunarfront.tech`)
Managed by Ansible. See `lunarfront-infra/ansible/roles/gitea/`.
### CI Pipeline (Gitea Actions)
On push to `main`:
1. Run lint + unit tests using the shared `ci-runner` image
2. Build Docker image, push to `registry.lunarfront.tech/ryan/lunarfront-app:{sha}`
3. Update the Helm chart `values.yaml` with the new image tag
4. ArgoCD detects the change and syncs all customer deployments
---
## Kubernetes (DOKS + ArgoCD)
All customer app instances run on a single DOKS cluster managed by ArgoCD (GitOps).
### Repository Structure
```
/gitops
apps/
customer-acme/
values.yaml
customer-foo/
values.yaml
chart/
Chart.yaml
templates/
deployment.yaml
service.yaml
ingress.yaml
job-migrate.yaml # runs drizzle-kit migrate on deploy
secret.yaml
```
### Per-Customer values.yaml
```yaml
customer: acme
subdomain: acme
image:
repository: registry.lunarfront.tech/ryan/lunarfront-app
tag: "abc123"
database:
url: "postgresql://lunarfront:pass@db-host:25060/customer_acme?sslmode=require"
redis:
url: "rediss://..."
env:
JWT_SECRET: "..."
NODE_ENV: production
```
### Adding a New Customer
1. `CREATE DATABASE customer_x;` on the shared managed Postgres
2. Add `gitops/apps/customer-x/values.yaml`
3. Push — ArgoCD syncs, migration Job runs, instance is live
4. Add DNS CNAME in Terraform
---
## Database Strategy
One managed Postgres cluster shared across all customers. Each customer gets their own isolated database (`CREATE DATABASE customer_x`). No cross-customer queries are possible at the database level.
- New customer = new database, no new infrastructure cost
- Managed DO Postgres handles backups, failover, and SSL
- Resize the cluster as total load grows
---
## Day-to-Day Workflow
```bash
# Provision infrastructure from scratch
terraform init && terraform apply
# Configure Gitea server
ansible-playbook ansible/gitea.yml -i ansible/inventory.ini
# Normal deploy flow — push to main
git push origin main
# → Gitea Actions: lint + test + build image
# → Image pushed to registry
# → Helm chart values updated
# → ArgoCD syncs all customer deployments automatically
# Add a new customer
# 1. Create database
psql $DATABASE_URL -c "CREATE DATABASE customer_x;"
# 2. Add values file, push
git add gitops/apps/customer-x/values.yaml && git push
# Done — ArgoCD handles the rest
```

View File

@@ -9,7 +9,7 @@
## Installation
```bash
git clone <repo-url> && cd forte
git clone <repo-url> && cd lunarfront
bun install
```
@@ -18,7 +18,7 @@ bun install
Create a `.env` file in the project root:
```env
DATABASE_URL=postgresql://forte:forte@localhost:5432/forte
DATABASE_URL=postgresql://lunarfront:lunarfront@localhost:5432/lunarfront
REDIS_URL=redis://localhost:6379
JWT_SECRET=your-secret-here
NODE_ENV=development
@@ -28,7 +28,7 @@ NODE_ENV=development
| Variable | Default | Description |
|----------|---------|-------------|
| `DATABASE_URL` | `postgresql://forte:forte@localhost:5432/forte` | PostgreSQL connection string |
| `DATABASE_URL` | `postgresql://lunarfront:lunarfront@localhost:5432/lunarfront` | PostgreSQL connection string |
| `REDIS_URL` | `redis://localhost:6379` | Valkey/Redis connection string |
| `JWT_SECRET` | (auto-generated in dev) | Secret for signing JWTs. **Required in production.** |
| `PORT` | `8000` | Backend API port |
@@ -45,7 +45,7 @@ NODE_ENV=development
```bash
# Create the database
createdb forte
createdb lunarfront
# Run migrations
cd packages/backend

View File

@@ -4,7 +4,7 @@
The primary test suite lives at `packages/backend/api-tests/`. It uses a custom runner that:
1. Creates/migrates a `forte_api_test` database
1. Creates/migrates a `lunarfront_api_test` database
2. Seeds company, lookup tables, RBAC permissions/roles
3. Starts the backend on port 8001
4. Registers a test user with admin role

29
nginx.conf Normal file
View File

@@ -0,0 +1,29 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
# Proxy API and WebDAV to 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;
}
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;
}
# SPA fallback — all other routes serve index.html
location / {
try_files $uri $uri/ /index.html;
}
}

View File

@@ -1,6 +1,7 @@
{
"name": "forte",
"name": "lunarfront",
"private": true,
"packageManager": "bun@1.3.11",
"workspaces": ["packages/*"],
"scripts": {
"dev": "turbo run dev",

View File

@@ -1,473 +0,0 @@
/* eslint-disable */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as LoginRouteImport } from './routes/login'
import { Route as AuthenticatedRouteImport } from './routes/_authenticated'
import { Route as AuthenticatedIndexRouteImport } from './routes/_authenticated/index'
import { Route as AuthenticatedUsersRouteImport } from './routes/_authenticated/users'
import { Route as AuthenticatedProfileRouteImport } from './routes/_authenticated/profile'
import { Route as AuthenticatedHelpRouteImport } from './routes/_authenticated/help'
import { Route as AuthenticatedRolesIndexRouteImport } from './routes/_authenticated/roles/index'
import { Route as AuthenticatedMembersIndexRouteImport } from './routes/_authenticated/members/index'
import { Route as AuthenticatedAccountsIndexRouteImport } from './routes/_authenticated/accounts/index'
import { Route as AuthenticatedRolesNewRouteImport } from './routes/_authenticated/roles/new'
import { Route as AuthenticatedRolesRoleIdRouteImport } from './routes/_authenticated/roles/$roleId'
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 AuthenticatedAccountsAccountIdIndexRouteImport } from './routes/_authenticated/accounts/$accountId/index'
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'
const LoginRoute = LoginRouteImport.update({
id: '/login',
path: '/login',
getParentRoute: () => rootRouteImport,
} as any)
const AuthenticatedRoute = AuthenticatedRouteImport.update({
id: '/_authenticated',
getParentRoute: () => rootRouteImport,
} as any)
const AuthenticatedIndexRoute = AuthenticatedIndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedUsersRoute = AuthenticatedUsersRouteImport.update({
id: '/users',
path: '/users',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedProfileRoute = AuthenticatedProfileRouteImport.update({
id: '/profile',
path: '/profile',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedHelpRoute = AuthenticatedHelpRouteImport.update({
id: '/help',
path: '/help',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedRolesIndexRoute = AuthenticatedRolesIndexRouteImport.update({
id: '/roles/',
path: '/roles/',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedMembersIndexRoute =
AuthenticatedMembersIndexRouteImport.update({
id: '/members/',
path: '/members/',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedAccountsIndexRoute =
AuthenticatedAccountsIndexRouteImport.update({
id: '/accounts/',
path: '/accounts/',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedRolesNewRoute = AuthenticatedRolesNewRouteImport.update({
id: '/roles/new',
path: '/roles/new',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedRolesRoleIdRoute =
AuthenticatedRolesRoleIdRouteImport.update({
id: '/roles/$roleId',
path: '/roles/$roleId',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedMembersMemberIdRoute =
AuthenticatedMembersMemberIdRouteImport.update({
id: '/members/$memberId',
path: '/members/$memberId',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedAccountsNewRoute =
AuthenticatedAccountsNewRouteImport.update({
id: '/accounts/new',
path: '/accounts/new',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedAccountsAccountIdRoute =
AuthenticatedAccountsAccountIdRouteImport.update({
id: '/accounts/$accountId',
path: '/accounts/$accountId',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedAccountsAccountIdIndexRoute =
AuthenticatedAccountsAccountIdIndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => AuthenticatedAccountsAccountIdRoute,
} as any)
const AuthenticatedAccountsAccountIdTaxExemptionsRoute =
AuthenticatedAccountsAccountIdTaxExemptionsRouteImport.update({
id: '/tax-exemptions',
path: '/tax-exemptions',
getParentRoute: () => AuthenticatedAccountsAccountIdRoute,
} as any)
const AuthenticatedAccountsAccountIdProcessorLinksRoute =
AuthenticatedAccountsAccountIdProcessorLinksRouteImport.update({
id: '/processor-links',
path: '/processor-links',
getParentRoute: () => AuthenticatedAccountsAccountIdRoute,
} as any)
const AuthenticatedAccountsAccountIdPaymentMethodsRoute =
AuthenticatedAccountsAccountIdPaymentMethodsRouteImport.update({
id: '/payment-methods',
path: '/payment-methods',
getParentRoute: () => AuthenticatedAccountsAccountIdRoute,
} as any)
const AuthenticatedAccountsAccountIdMembersRoute =
AuthenticatedAccountsAccountIdMembersRouteImport.update({
id: '/members',
path: '/members',
getParentRoute: () => AuthenticatedAccountsAccountIdRoute,
} as any)
export interface FileRoutesByFullPath {
'/': typeof AuthenticatedIndexRoute
'/login': typeof LoginRoute
'/help': typeof AuthenticatedHelpRoute
'/profile': typeof AuthenticatedProfileRoute
'/users': typeof AuthenticatedUsersRoute
'/accounts/$accountId': typeof AuthenticatedAccountsAccountIdRouteWithChildren
'/accounts/new': typeof AuthenticatedAccountsNewRoute
'/members/$memberId': typeof AuthenticatedMembersMemberIdRoute
'/roles/$roleId': typeof AuthenticatedRolesRoleIdRoute
'/roles/new': typeof AuthenticatedRolesNewRoute
'/accounts/': typeof AuthenticatedAccountsIndexRoute
'/members/': typeof AuthenticatedMembersIndexRoute
'/roles/': typeof AuthenticatedRolesIndexRoute
'/accounts/$accountId/members': typeof AuthenticatedAccountsAccountIdMembersRoute
'/accounts/$accountId/payment-methods': typeof AuthenticatedAccountsAccountIdPaymentMethodsRoute
'/accounts/$accountId/processor-links': typeof AuthenticatedAccountsAccountIdProcessorLinksRoute
'/accounts/$accountId/tax-exemptions': typeof AuthenticatedAccountsAccountIdTaxExemptionsRoute
'/accounts/$accountId/': typeof AuthenticatedAccountsAccountIdIndexRoute
}
export interface FileRoutesByTo {
'/login': typeof LoginRoute
'/help': typeof AuthenticatedHelpRoute
'/profile': typeof AuthenticatedProfileRoute
'/users': typeof AuthenticatedUsersRoute
'/': typeof AuthenticatedIndexRoute
'/accounts/new': typeof AuthenticatedAccountsNewRoute
'/members/$memberId': typeof AuthenticatedMembersMemberIdRoute
'/roles/$roleId': typeof AuthenticatedRolesRoleIdRoute
'/roles/new': typeof AuthenticatedRolesNewRoute
'/accounts': typeof AuthenticatedAccountsIndexRoute
'/members': typeof AuthenticatedMembersIndexRoute
'/roles': typeof AuthenticatedRolesIndexRoute
'/accounts/$accountId/members': typeof AuthenticatedAccountsAccountIdMembersRoute
'/accounts/$accountId/payment-methods': typeof AuthenticatedAccountsAccountIdPaymentMethodsRoute
'/accounts/$accountId/processor-links': typeof AuthenticatedAccountsAccountIdProcessorLinksRoute
'/accounts/$accountId/tax-exemptions': typeof AuthenticatedAccountsAccountIdTaxExemptionsRoute
'/accounts/$accountId': typeof AuthenticatedAccountsAccountIdIndexRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/_authenticated': typeof AuthenticatedRouteWithChildren
'/login': typeof LoginRoute
'/_authenticated/help': typeof AuthenticatedHelpRoute
'/_authenticated/profile': typeof AuthenticatedProfileRoute
'/_authenticated/users': typeof AuthenticatedUsersRoute
'/_authenticated/': typeof AuthenticatedIndexRoute
'/_authenticated/accounts/$accountId': typeof AuthenticatedAccountsAccountIdRouteWithChildren
'/_authenticated/accounts/new': typeof AuthenticatedAccountsNewRoute
'/_authenticated/members/$memberId': typeof AuthenticatedMembersMemberIdRoute
'/_authenticated/roles/$roleId': typeof AuthenticatedRolesRoleIdRoute
'/_authenticated/roles/new': typeof AuthenticatedRolesNewRoute
'/_authenticated/accounts/': typeof AuthenticatedAccountsIndexRoute
'/_authenticated/members/': typeof AuthenticatedMembersIndexRoute
'/_authenticated/roles/': typeof AuthenticatedRolesIndexRoute
'/_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/accounts/$accountId/': typeof AuthenticatedAccountsAccountIdIndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
| '/login'
| '/help'
| '/profile'
| '/users'
| '/accounts/$accountId'
| '/accounts/new'
| '/members/$memberId'
| '/roles/$roleId'
| '/roles/new'
| '/accounts/'
| '/members/'
| '/roles/'
| '/accounts/$accountId/members'
| '/accounts/$accountId/payment-methods'
| '/accounts/$accountId/processor-links'
| '/accounts/$accountId/tax-exemptions'
| '/accounts/$accountId/'
fileRoutesByTo: FileRoutesByTo
to:
| '/login'
| '/help'
| '/profile'
| '/users'
| '/'
| '/accounts/new'
| '/members/$memberId'
| '/roles/$roleId'
| '/roles/new'
| '/accounts'
| '/members'
| '/roles'
| '/accounts/$accountId/members'
| '/accounts/$accountId/payment-methods'
| '/accounts/$accountId/processor-links'
| '/accounts/$accountId/tax-exemptions'
| '/accounts/$accountId'
id:
| '__root__'
| '/_authenticated'
| '/login'
| '/_authenticated/help'
| '/_authenticated/profile'
| '/_authenticated/users'
| '/_authenticated/'
| '/_authenticated/accounts/$accountId'
| '/_authenticated/accounts/new'
| '/_authenticated/members/$memberId'
| '/_authenticated/roles/$roleId'
| '/_authenticated/roles/new'
| '/_authenticated/accounts/'
| '/_authenticated/members/'
| '/_authenticated/roles/'
| '/_authenticated/accounts/$accountId/members'
| '/_authenticated/accounts/$accountId/payment-methods'
| '/_authenticated/accounts/$accountId/processor-links'
| '/_authenticated/accounts/$accountId/tax-exemptions'
| '/_authenticated/accounts/$accountId/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
AuthenticatedRoute: typeof AuthenticatedRouteWithChildren
LoginRoute: typeof LoginRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/login': {
id: '/login'
path: '/login'
fullPath: '/login'
preLoaderRoute: typeof LoginRouteImport
parentRoute: typeof rootRouteImport
}
'/_authenticated': {
id: '/_authenticated'
path: ''
fullPath: '/'
preLoaderRoute: typeof AuthenticatedRouteImport
parentRoute: typeof rootRouteImport
}
'/_authenticated/': {
id: '/_authenticated/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof AuthenticatedIndexRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/users': {
id: '/_authenticated/users'
path: '/users'
fullPath: '/users'
preLoaderRoute: typeof AuthenticatedUsersRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/profile': {
id: '/_authenticated/profile'
path: '/profile'
fullPath: '/profile'
preLoaderRoute: typeof AuthenticatedProfileRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/help': {
id: '/_authenticated/help'
path: '/help'
fullPath: '/help'
preLoaderRoute: typeof AuthenticatedHelpRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/roles/': {
id: '/_authenticated/roles/'
path: '/roles'
fullPath: '/roles/'
preLoaderRoute: typeof AuthenticatedRolesIndexRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/members/': {
id: '/_authenticated/members/'
path: '/members'
fullPath: '/members/'
preLoaderRoute: typeof AuthenticatedMembersIndexRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/accounts/': {
id: '/_authenticated/accounts/'
path: '/accounts'
fullPath: '/accounts/'
preLoaderRoute: typeof AuthenticatedAccountsIndexRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/roles/new': {
id: '/_authenticated/roles/new'
path: '/roles/new'
fullPath: '/roles/new'
preLoaderRoute: typeof AuthenticatedRolesNewRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/roles/$roleId': {
id: '/_authenticated/roles/$roleId'
path: '/roles/$roleId'
fullPath: '/roles/$roleId'
preLoaderRoute: typeof AuthenticatedRolesRoleIdRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/members/$memberId': {
id: '/_authenticated/members/$memberId'
path: '/members/$memberId'
fullPath: '/members/$memberId'
preLoaderRoute: typeof AuthenticatedMembersMemberIdRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/accounts/new': {
id: '/_authenticated/accounts/new'
path: '/accounts/new'
fullPath: '/accounts/new'
preLoaderRoute: typeof AuthenticatedAccountsNewRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/accounts/$accountId': {
id: '/_authenticated/accounts/$accountId'
path: '/accounts/$accountId'
fullPath: '/accounts/$accountId'
preLoaderRoute: typeof AuthenticatedAccountsAccountIdRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/accounts/$accountId/': {
id: '/_authenticated/accounts/$accountId/'
path: '/'
fullPath: '/accounts/$accountId/'
preLoaderRoute: typeof AuthenticatedAccountsAccountIdIndexRouteImport
parentRoute: typeof AuthenticatedAccountsAccountIdRoute
}
'/_authenticated/accounts/$accountId/tax-exemptions': {
id: '/_authenticated/accounts/$accountId/tax-exemptions'
path: '/tax-exemptions'
fullPath: '/accounts/$accountId/tax-exemptions'
preLoaderRoute: typeof AuthenticatedAccountsAccountIdTaxExemptionsRouteImport
parentRoute: typeof AuthenticatedAccountsAccountIdRoute
}
'/_authenticated/accounts/$accountId/processor-links': {
id: '/_authenticated/accounts/$accountId/processor-links'
path: '/processor-links'
fullPath: '/accounts/$accountId/processor-links'
preLoaderRoute: typeof AuthenticatedAccountsAccountIdProcessorLinksRouteImport
parentRoute: typeof AuthenticatedAccountsAccountIdRoute
}
'/_authenticated/accounts/$accountId/payment-methods': {
id: '/_authenticated/accounts/$accountId/payment-methods'
path: '/payment-methods'
fullPath: '/accounts/$accountId/payment-methods'
preLoaderRoute: typeof AuthenticatedAccountsAccountIdPaymentMethodsRouteImport
parentRoute: typeof AuthenticatedAccountsAccountIdRoute
}
'/_authenticated/accounts/$accountId/members': {
id: '/_authenticated/accounts/$accountId/members'
path: '/members'
fullPath: '/accounts/$accountId/members'
preLoaderRoute: typeof AuthenticatedAccountsAccountIdMembersRouteImport
parentRoute: typeof AuthenticatedAccountsAccountIdRoute
}
}
}
interface AuthenticatedAccountsAccountIdRouteChildren {
AuthenticatedAccountsAccountIdMembersRoute: typeof AuthenticatedAccountsAccountIdMembersRoute
AuthenticatedAccountsAccountIdPaymentMethodsRoute: typeof AuthenticatedAccountsAccountIdPaymentMethodsRoute
AuthenticatedAccountsAccountIdProcessorLinksRoute: typeof AuthenticatedAccountsAccountIdProcessorLinksRoute
AuthenticatedAccountsAccountIdTaxExemptionsRoute: typeof AuthenticatedAccountsAccountIdTaxExemptionsRoute
AuthenticatedAccountsAccountIdIndexRoute: typeof AuthenticatedAccountsAccountIdIndexRoute
}
const AuthenticatedAccountsAccountIdRouteChildren: AuthenticatedAccountsAccountIdRouteChildren =
{
AuthenticatedAccountsAccountIdMembersRoute:
AuthenticatedAccountsAccountIdMembersRoute,
AuthenticatedAccountsAccountIdPaymentMethodsRoute:
AuthenticatedAccountsAccountIdPaymentMethodsRoute,
AuthenticatedAccountsAccountIdProcessorLinksRoute:
AuthenticatedAccountsAccountIdProcessorLinksRoute,
AuthenticatedAccountsAccountIdTaxExemptionsRoute:
AuthenticatedAccountsAccountIdTaxExemptionsRoute,
AuthenticatedAccountsAccountIdIndexRoute:
AuthenticatedAccountsAccountIdIndexRoute,
}
const AuthenticatedAccountsAccountIdRouteWithChildren =
AuthenticatedAccountsAccountIdRoute._addFileChildren(
AuthenticatedAccountsAccountIdRouteChildren,
)
interface AuthenticatedRouteChildren {
AuthenticatedHelpRoute: typeof AuthenticatedHelpRoute
AuthenticatedProfileRoute: typeof AuthenticatedProfileRoute
AuthenticatedUsersRoute: typeof AuthenticatedUsersRoute
AuthenticatedIndexRoute: typeof AuthenticatedIndexRoute
AuthenticatedAccountsAccountIdRoute: typeof AuthenticatedAccountsAccountIdRouteWithChildren
AuthenticatedAccountsNewRoute: typeof AuthenticatedAccountsNewRoute
AuthenticatedMembersMemberIdRoute: typeof AuthenticatedMembersMemberIdRoute
AuthenticatedRolesRoleIdRoute: typeof AuthenticatedRolesRoleIdRoute
AuthenticatedRolesNewRoute: typeof AuthenticatedRolesNewRoute
AuthenticatedAccountsIndexRoute: typeof AuthenticatedAccountsIndexRoute
AuthenticatedMembersIndexRoute: typeof AuthenticatedMembersIndexRoute
AuthenticatedRolesIndexRoute: typeof AuthenticatedRolesIndexRoute
}
const AuthenticatedRouteChildren: AuthenticatedRouteChildren = {
AuthenticatedHelpRoute: AuthenticatedHelpRoute,
AuthenticatedProfileRoute: AuthenticatedProfileRoute,
AuthenticatedUsersRoute: AuthenticatedUsersRoute,
AuthenticatedIndexRoute: AuthenticatedIndexRoute,
AuthenticatedAccountsAccountIdRoute:
AuthenticatedAccountsAccountIdRouteWithChildren,
AuthenticatedAccountsNewRoute: AuthenticatedAccountsNewRoute,
AuthenticatedMembersMemberIdRoute: AuthenticatedMembersMemberIdRoute,
AuthenticatedRolesRoleIdRoute: AuthenticatedRolesRoleIdRoute,
AuthenticatedRolesNewRoute: AuthenticatedRolesNewRoute,
AuthenticatedAccountsIndexRoute: AuthenticatedAccountsIndexRoute,
AuthenticatedMembersIndexRoute: AuthenticatedMembersIndexRoute,
AuthenticatedRolesIndexRoute: AuthenticatedRolesIndexRoute,
}
const AuthenticatedRouteWithChildren = AuthenticatedRoute._addFileChildren(
AuthenticatedRouteChildren,
)
const rootRouteChildren: RootRouteChildren = {
AuthenticatedRoute: AuthenticatedRouteWithChildren,
LoginRoute: LoginRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()

View File

@@ -3,13 +3,13 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Forte Admin</title>
<title>LunarFront Admin</title>
</head>
<body>
<script>
// Apply mode before React renders to prevent flash
(function() {
var mode = localStorage.getItem('forte-mode') || 'system';
var mode = localStorage.getItem('lunarfront-mode') || 'system';
var isDark = mode === 'dark' || (mode === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
if (isDark) document.documentElement.classList.add('dark');
})();

View File

@@ -1,5 +1,5 @@
{
"name": "@forte/admin",
"name": "@lunarfront/admin",
"version": "0.0.1",
"private": true,
"type": "module",
@@ -10,8 +10,8 @@
"lint": "eslint ."
},
"dependencies": {
"@forte/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

@@ -1,7 +1,7 @@
import { queryOptions } from '@tanstack/react-query'
import { api } from '@/lib/api-client'
import type { Account } from '@/types/account'
import type { PaginatedResponse, PaginationInput } from '@forte/shared/schemas'
import type { PaginatedResponse, PaginationInput } from '@lunarfront/shared/schemas'
export const accountKeys = {
all: ['accounts'] as const,

View File

@@ -1,7 +1,7 @@
import { queryOptions } from '@tanstack/react-query'
import { api } from '@/lib/api-client'
import type { MemberIdentifier } from '@/types/account'
import type { PaginatedResponse } from '@forte/shared/schemas'
import type { PaginatedResponse } from '@lunarfront/shared/schemas'
export const identifierKeys = {
all: (memberId: string) => ['members', memberId, 'identifiers'] as const,

View File

@@ -0,0 +1,170 @@
import { queryOptions } from '@tanstack/react-query'
import { api } from '@/lib/api-client'
import type { Category, Supplier, Product, InventoryUnit, ProductSupplier, StockReceipt, PriceHistory } from '@/types/inventory'
import type { PaginatedResponse, PaginationInput } from '@lunarfront/shared/schemas'
// ─── Categories ──────────────────────────────────────────────────────────────
export const categoryKeys = {
all: ['categories'] as const,
list: (params: PaginationInput) => [...categoryKeys.all, 'list', params] as const,
allCategories: [...['categories'], 'all-flat'] as const,
detail: (id: string) => [...categoryKeys.all, 'detail', id] as const,
}
export function categoryListOptions(params: PaginationInput) {
return queryOptions({
queryKey: categoryKeys.list(params),
queryFn: () => api.get<PaginatedResponse<Category>>('/v1/categories', params as Record<string, unknown>),
})
}
export function categoryAllOptions() {
return queryOptions({
queryKey: categoryKeys.allCategories,
queryFn: () => api.get<{ data: Category[] }>('/v1/categories/all'),
})
}
export const categoryMutations = {
create: (data: Record<string, unknown>) => api.post<Category>('/v1/categories', data),
update: (id: string, data: Record<string, unknown>) => api.patch<Category>(`/v1/categories/${id}`, data),
delete: (id: string) => api.del<Category>(`/v1/categories/${id}`),
}
// ─── Suppliers ───────────────────────────────────────────────────────────────
export const supplierKeys = {
all: ['suppliers'] as const,
allSuppliers: ['suppliers', 'all'] as const,
list: (params: PaginationInput) => [...supplierKeys.all, 'list', params] as const,
detail: (id: string) => [...supplierKeys.all, 'detail', id] as const,
}
export function supplierListOptions(params: PaginationInput) {
return queryOptions({
queryKey: supplierKeys.list(params),
queryFn: () => api.get<PaginatedResponse<Supplier>>('/v1/suppliers', params as Record<string, unknown>),
})
}
export function supplierAllOptions() {
return queryOptions({
queryKey: supplierKeys.allSuppliers,
queryFn: () => api.get<{ data: Supplier[] }>('/v1/suppliers/all'),
})
}
export const supplierMutations = {
create: (data: Record<string, unknown>) => api.post<Supplier>('/v1/suppliers', data),
update: (id: string, data: Record<string, unknown>) => api.patch<Supplier>(`/v1/suppliers/${id}`, data),
delete: (id: string) => api.del<Supplier>(`/v1/suppliers/${id}`),
}
// ─── Products ────────────────────────────────────────────────────────────────
export const productKeys = {
all: ['products'] as const,
list: (params: Record<string, unknown>) => [...productKeys.all, 'list', params] as const,
detail: (id: string) => [...productKeys.all, 'detail', id] as const,
}
export function productListOptions(params: Record<string, unknown>) {
return queryOptions({
queryKey: productKeys.list(params),
queryFn: () => api.get<PaginatedResponse<Product>>('/v1/products', params),
})
}
export function productDetailOptions(id: string) {
return queryOptions({
queryKey: productKeys.detail(id),
queryFn: () => api.get<Product>(`/v1/products/${id}`),
enabled: !!id,
})
}
export const productMutations = {
create: (data: Record<string, unknown>) => api.post<Product>('/v1/products', data),
update: (id: string, data: Record<string, unknown>) => api.patch<Product>(`/v1/products/${id}`, data),
delete: (id: string) => api.del<Product>(`/v1/products/${id}`),
}
// ─── Inventory Units ─────────────────────────────────────────────────────────
export const unitKeys = {
all: ['inventory-units'] as const,
byProduct: (productId: string) => [...unitKeys.all, 'product', productId] as const,
detail: (id: string) => [...unitKeys.all, 'detail', id] as const,
}
export function unitListOptions(productId: string) {
return queryOptions({
queryKey: unitKeys.byProduct(productId),
queryFn: () => api.get<{ data: InventoryUnit[] }>(`/v1/products/${productId}/units`),
enabled: !!productId,
})
}
export const unitMutations = {
create: (productId: string, data: Record<string, unknown>) =>
api.post<InventoryUnit>(`/v1/products/${productId}/units`, data),
update: (id: string, data: Record<string, unknown>) =>
api.patch<InventoryUnit>(`/v1/units/${id}`, data),
}
// ─── Product Suppliers ───────────────────────────────────────────────────────
export const productSupplierKeys = {
byProduct: (productId: string) => ['products', productId, 'suppliers'] as const,
}
export function productSupplierListOptions(productId: string) {
return queryOptions({
queryKey: productSupplierKeys.byProduct(productId),
queryFn: () => api.get<{ data: ProductSupplier[] }>(`/v1/products/${productId}/suppliers`),
enabled: !!productId,
})
}
export const productSupplierMutations = {
create: (productId: string, data: Record<string, unknown>) =>
api.post<ProductSupplier>(`/v1/products/${productId}/suppliers`, data),
update: (productId: string, id: string, data: Record<string, unknown>) =>
api.patch<ProductSupplier>(`/v1/products/${productId}/suppliers/${id}`, data),
delete: (productId: string, id: string) =>
api.del(`/v1/products/${productId}/suppliers/${id}`),
}
// ─── Stock Receipts ──────────────────────────────────────────────────────────
export const stockReceiptKeys = {
byProduct: (productId: string) => ['products', productId, 'stock-receipts'] as const,
}
export function stockReceiptListOptions(productId: string) {
return queryOptions({
queryKey: stockReceiptKeys.byProduct(productId),
queryFn: () => api.get<{ data: StockReceipt[] }>(`/v1/products/${productId}/stock-receipts`),
enabled: !!productId,
})
}
export const stockReceiptMutations = {
create: (productId: string, data: Record<string, unknown>) =>
api.post<StockReceipt>(`/v1/products/${productId}/stock-receipts`, data),
}
// ─── Price History ───────────────────────────────────────────────────────────
export const priceHistoryKeys = {
byProduct: (productId: string) => ['products', productId, 'price-history'] as const,
}
export function priceHistoryOptions(productId: string) {
return queryOptions({
queryKey: priceHistoryKeys.byProduct(productId),
queryFn: () => api.get<{ data: PriceHistory[] }>(`/v1/products/${productId}/price-history`),
enabled: !!productId,
})
}

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

@@ -1,7 +1,7 @@
import { queryOptions } from '@tanstack/react-query'
import { api } from '@/lib/api-client'
import type { Member } from '@/types/account'
import type { PaginatedResponse, PaginationInput } from '@forte/shared/schemas'
import type { PaginatedResponse, PaginationInput } from '@lunarfront/shared/schemas'
interface MemberWithAccount extends Member {
accountName: string | null

View File

@@ -0,0 +1,28 @@
import { queryOptions } from '@tanstack/react-query'
import { api } from '@/lib/api-client'
export interface ModuleConfig {
id: string
slug: string
name: string
description: string | null
licensed: boolean
enabled: boolean
createdAt: string
updatedAt: string
}
export const moduleKeys = {
list: ['modules'] as const,
}
export function moduleListOptions() {
return queryOptions({
queryKey: moduleKeys.list,
queryFn: () => api.get<{ data: ModuleConfig[] }>('/v1/modules'),
})
}
export const moduleMutations = {
toggle: (slug: string, enabled: boolean) => api.patch<ModuleConfig>(`/v1/modules/${slug}`, { enabled }),
}

View File

@@ -1,7 +1,7 @@
import { queryOptions } from '@tanstack/react-query'
import { api } from '@/lib/api-client'
import type { PaymentMethod } from '@/types/account'
import type { PaginatedResponse, PaginationInput } from '@forte/shared/schemas'
import type { PaginatedResponse, PaginationInput } from '@lunarfront/shared/schemas'
export const paymentMethodKeys = {
all: (accountId: string) => ['accounts', accountId, 'payment-methods'] as const,

View File

@@ -1,7 +1,7 @@
import { queryOptions } from '@tanstack/react-query'
import { api } from '@/lib/api-client'
import type { ProcessorLink } from '@/types/account'
import type { PaginatedResponse, PaginationInput } from '@forte/shared/schemas'
import type { PaginatedResponse, PaginationInput } from '@lunarfront/shared/schemas'
export const processorLinkKeys = {
all: (accountId: string) => ['accounts', accountId, 'processor-links'] as const,

View File

@@ -1,7 +1,7 @@
import { queryOptions } from '@tanstack/react-query'
import { api } from '@/lib/api-client'
import type { Permission, Role } from '@/types/rbac'
import type { PaginationInput, PaginatedResponse } from '@forte/shared/schemas'
import type { PaginationInput, PaginatedResponse } from '@lunarfront/shared/schemas'
export const rbacKeys = {
permissions: ['permissions'] as const,

View File

@@ -1,7 +1,7 @@
import { queryOptions } from '@tanstack/react-query'
import { api } from '@/lib/api-client'
import type { RepairTicket, RepairLineItem, RepairBatch, RepairNote, RepairServiceTemplate } from '@/types/repair'
import type { PaginatedResponse, PaginationInput } from '@forte/shared/schemas'
import type { PaginatedResponse, PaginationInput } from '@lunarfront/shared/schemas'
// --- Repair Tickets ---

View File

@@ -0,0 +1,73 @@
import { queryOptions } from '@tanstack/react-query'
import { api } from '@/lib/api-client'
import type { StorageFolder, StorageFolderPermission, StorageFile } from '@/types/storage'
import type { PaginatedResponse, PaginationInput } from '@lunarfront/shared/schemas'
// --- Folders ---
export const storageFolderKeys = {
all: ['storage-folders'] as const,
tree: ['storage-folders', 'tree'] as const,
children: (parentId: string | null) => ['storage-folders', 'children', parentId] as const,
detail: (id: string) => ['storage-folders', 'detail', id] as const,
permissions: (id: string) => ['storage-folders', id, 'permissions'] as const,
}
export function storageFolderTreeOptions() {
return queryOptions({
queryKey: storageFolderKeys.tree,
queryFn: () => api.get<{ data: StorageFolder[] }>('/v1/storage/folders/tree'),
})
}
export function storageFolderChildrenOptions(parentId: string | null) {
return queryOptions({
queryKey: storageFolderKeys.children(parentId),
queryFn: () => api.get<{ data: StorageFolder[] }>('/v1/storage/folders', parentId ? { parentId } : {}),
})
}
export function storageFolderDetailOptions(id: string) {
return queryOptions({
queryKey: storageFolderKeys.detail(id),
queryFn: () => api.get<StorageFolder & { breadcrumbs: { id: string; name: string }[] }>(`/v1/storage/folders/${id}`),
enabled: !!id,
})
}
export function storageFolderPermissionsOptions(id: string) {
return queryOptions({
queryKey: storageFolderKeys.permissions(id),
queryFn: () => api.get<{ data: StorageFolderPermission[] }>(`/v1/storage/folders/${id}/permissions`),
enabled: !!id,
})
}
export const storageFolderMutations = {
create: (data: Record<string, unknown>) => api.post<StorageFolder>('/v1/storage/folders', data),
update: (id: string, data: Record<string, unknown>) => api.patch<StorageFolder>(`/v1/storage/folders/${id}`, data),
delete: (id: string) => api.del<StorageFolder>(`/v1/storage/folders/${id}`),
addPermission: (folderId: string, data: Record<string, unknown>) => api.post<StorageFolderPermission>(`/v1/storage/folders/${folderId}/permissions`, data),
removePermission: (permId: string) => api.del<StorageFolderPermission>(`/v1/storage/folder-permissions/${permId}`),
}
// --- Files ---
export const storageFileKeys = {
all: (folderId: string) => ['storage-files', folderId] as const,
list: (folderId: string, params: PaginationInput) => ['storage-files', folderId, params] as const,
search: (q: string) => ['storage-files', 'search', q] as const,
}
export function storageFileListOptions(folderId: string, params: PaginationInput) {
return queryOptions({
queryKey: storageFileKeys.list(folderId, params),
queryFn: () => api.get<PaginatedResponse<StorageFile>>(`/v1/storage/folders/${folderId}/files`, params),
enabled: !!folderId,
})
}
export const storageFileMutations = {
delete: (id: string) => api.del<StorageFile>(`/v1/storage/files/${id}`),
getSignedUrl: (id: string) => api.get<{ url: string }>(`/v1/storage/files/${id}/signed-url`),
}

View File

@@ -1,7 +1,7 @@
import { queryOptions } from '@tanstack/react-query'
import { api } from '@/lib/api-client'
import type { TaxExemption } from '@/types/account'
import type { PaginatedResponse, PaginationInput } from '@forte/shared/schemas'
import type { PaginatedResponse, PaginationInput } from '@lunarfront/shared/schemas'
export const taxExemptionKeys = {
all: (accountId: string) => ['accounts', accountId, 'tax-exemptions'] as const,

View File

@@ -1,6 +1,6 @@
import { queryOptions } from '@tanstack/react-query'
import { api } from '@/lib/api-client'
import type { PaginationInput, PaginatedResponse } from '@forte/shared/schemas'
import type { PaginationInput, PaginatedResponse } from '@lunarfront/shared/schemas'
export interface UserRole {
id: string

View File

@@ -0,0 +1,91 @@
import { queryOptions } from '@tanstack/react-query'
import { api } from '@/lib/api-client'
import type { VaultStatus, VaultCategory, VaultCategoryPermission, VaultEntry } from '@/types/vault'
import type { PaginatedResponse, PaginationInput } from '@lunarfront/shared/schemas'
// --- Keys ---
export const vaultKeys = {
status: ['vault-status'] as const,
categories: ['vault-categories'] as const,
categoryDetail: (id: string) => ['vault-categories', id] as const,
categoryPermissions: (id: string) => ['vault-categories', id, 'permissions'] as const,
entries: (categoryId: string) => ['vault-entries', categoryId] as const,
entryList: (categoryId: string, params: PaginationInput) => ['vault-entries', categoryId, params] as const,
entryDetail: (id: string) => ['vault-entry', id] as const,
}
// --- Queries ---
export function vaultStatusOptions() {
return queryOptions({
queryKey: vaultKeys.status,
queryFn: () => api.get<VaultStatus>('/v1/vault/status'),
})
}
export function vaultCategoryListOptions() {
return queryOptions({
queryKey: vaultKeys.categories,
queryFn: () => api.get<{ data: VaultCategory[] }>('/v1/vault/categories'),
})
}
export function vaultCategoryDetailOptions(id: string) {
return queryOptions({
queryKey: vaultKeys.categoryDetail(id),
queryFn: () => api.get<VaultCategory>(`/v1/vault/categories/${id}`),
enabled: !!id,
})
}
export function vaultCategoryPermissionsOptions(id: string) {
return queryOptions({
queryKey: vaultKeys.categoryPermissions(id),
queryFn: () => api.get<{ data: VaultCategoryPermission[] }>(`/v1/vault/categories/${id}/permissions`),
enabled: !!id,
})
}
export function vaultEntryListOptions(categoryId: string, params: PaginationInput) {
return queryOptions({
queryKey: vaultKeys.entryList(categoryId, params),
queryFn: () => api.get<PaginatedResponse<VaultEntry>>(`/v1/vault/categories/${categoryId}/entries`, params),
enabled: !!categoryId,
})
}
export function vaultEntryDetailOptions(id: string) {
return queryOptions({
queryKey: vaultKeys.entryDetail(id),
queryFn: () => api.get<VaultEntry>(`/v1/vault/entries/${id}`),
enabled: !!id,
})
}
// --- Mutations ---
export const vaultMutations = {
initialize: (masterPassword: string) => api.post('/v1/vault/initialize', { masterPassword }),
unlock: (masterPassword: string) => api.post('/v1/vault/unlock', { masterPassword }),
lock: () => api.post('/v1/vault/lock', {}),
changeMasterPassword: (currentPassword: string, newPassword: string) =>
api.post('/v1/vault/change-master-password', { currentPassword, newPassword }),
}
export const vaultCategoryMutations = {
create: (data: Record<string, unknown>) => api.post<VaultCategory>('/v1/vault/categories', data),
update: (id: string, data: Record<string, unknown>) => api.patch<VaultCategory>(`/v1/vault/categories/${id}`, data),
delete: (id: string) => api.del<VaultCategory>(`/v1/vault/categories/${id}`),
addPermission: (categoryId: string, data: Record<string, unknown>) =>
api.post<VaultCategoryPermission>(`/v1/vault/categories/${categoryId}/permissions`, data),
removePermission: (permId: string) => api.del<VaultCategoryPermission>(`/v1/vault/category-permissions/${permId}`),
}
export const vaultEntryMutations = {
create: (categoryId: string, data: Record<string, unknown>) =>
api.post<VaultEntry>(`/v1/vault/categories/${categoryId}/entries`, data),
update: (id: string, data: Record<string, unknown>) => api.patch<VaultEntry>(`/v1/vault/entries/${id}`, data),
delete: (id: string) => api.del<VaultEntry>(`/v1/vault/entries/${id}`),
reveal: (id: string) => api.post<{ value: string | null }>(`/v1/vault/entries/${id}/reveal`, {}),
}

View File

@@ -88,3 +88,36 @@ body {
border-color: #2a3a52 !important;
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,
input:-webkit-autofill:focus {
-webkit-box-shadow: 0 0 0 1000px hsl(var(--background)) inset !important;
-webkit-text-fill-color: hsl(var(--foreground)) !important;
transition: background-color 5000s ease-in-out 0s;
}

View File

@@ -1,7 +1,7 @@
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { AccountCreateSchema } from '@forte/shared/schemas'
import { AccountCreateSchema } from '@lunarfront/shared/schemas'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'

View File

@@ -1,7 +1,6 @@
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { PaymentMethodCreateSchema } from '@forte/shared/schemas'
import type { PaymentMethodCreateInput } from '@forte/shared/schemas'
import { PaymentMethodCreateSchema } from '@lunarfront/shared/schemas'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'

View File

@@ -1,7 +1,7 @@
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { ProcessorLinkCreateSchema } from '@forte/shared/schemas'
import type { ProcessorLinkCreateInput } from '@forte/shared/schemas'
import { ProcessorLinkCreateSchema } from '@lunarfront/shared/schemas'
import type { ProcessorLinkCreateInput } from '@lunarfront/shared/schemas'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'

View File

@@ -1,7 +1,6 @@
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { TaxExemptionCreateSchema } from '@forte/shared/schemas'
import type { TaxExemptionCreateInput } from '@forte/shared/schemas'
import { TaxExemptionCreateSchema } from '@lunarfront/shared/schemas'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'

View File

@@ -0,0 +1,91 @@
import { useForm } from 'react-hook-form'
import { useQuery } from '@tanstack/react-query'
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 { categoryAllOptions } from '@/api/inventory'
import type { Category } from '@/types/inventory'
interface Props {
defaultValues?: Partial<Category>
onSubmit: (data: Record<string, unknown>) => void
onDelete?: () => void
loading?: boolean
deleteLoading?: boolean
}
export function CategoryForm({ defaultValues, onSubmit, onDelete, loading, deleteLoading }: Props) {
const { data: allCats } = useQuery(categoryAllOptions())
const categories = (allCats?.data ?? []).filter((c) => c.id !== defaultValues?.id && c.isActive)
const { register, handleSubmit, setValue, watch } = useForm({
defaultValues: {
name: defaultValues?.name ?? '',
parentId: defaultValues?.parentId ?? '',
sortOrder: defaultValues?.sortOrder ?? 0,
isActive: defaultValues?.isActive ?? true,
},
})
const parentId = watch('parentId')
const isActive = watch('isActive')
function handleFormSubmit(data: { name: string; parentId: string; sortOrder: number; isActive: boolean }) {
onSubmit({
name: data.name,
parentId: data.parentId || undefined,
sortOrder: Number(data.sortOrder),
isActive: data.isActive,
})
}
return (
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="cat-name">Name *</Label>
<Input id="cat-name" {...register('name')} placeholder="e.g. Guitars, Accessories" required />
</div>
<div className="space-y-2">
<Label>Parent Category</Label>
<Select value={parentId || 'none'} onValueChange={(v) => setValue('parentId', v === 'none' ? '' : v)}>
<SelectTrigger>
<SelectValue placeholder="None (Top Level)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">None (Top Level)</SelectItem>
{categories.map((c) => (
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="cat-order">Sort Order</Label>
<Input id="cat-order" type="number" min={0} {...register('sortOrder')} />
</div>
<div className="flex items-center gap-2 pt-7">
<input
id="cat-active"
type="checkbox"
checked={isActive}
onChange={(e) => setValue('isActive', e.target.checked)}
className="h-4 w-4"
/>
<Label htmlFor="cat-active">Active</Label>
</div>
</div>
<div className="flex gap-2 pt-2">
<Button type="submit" disabled={loading} className="flex-1">
{loading ? 'Saving...' : defaultValues?.id ? 'Save Changes' : 'Create Category'}
</Button>
{onDelete && (
<Button type="button" variant="destructive" disabled={deleteLoading} onClick={onDelete}>
{deleteLoading ? 'Deleting...' : 'Delete'}
</Button>
)}
</div>
</form>
)
}

View File

@@ -0,0 +1,118 @@
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 { InventoryUnit, UnitCondition, UnitStatus } from '@/types/inventory'
const CONDITIONS: { value: UnitCondition; label: string }[] = [
{ value: 'new', label: 'New' },
{ value: 'excellent', label: 'Excellent' },
{ value: 'good', label: 'Good' },
{ value: 'fair', label: 'Fair' },
{ value: 'poor', label: 'Poor' },
]
const STATUSES: { value: UnitStatus; label: string }[] = [
{ value: 'available', label: 'Available' },
{ value: 'sold', label: 'Sold' },
{ value: 'rented', label: 'Rented' },
{ value: 'on_trial', label: 'On Trial' },
{ value: 'in_repair', label: 'In Repair' },
{ value: 'layaway', label: 'Layaway' },
{ value: 'lost', label: 'Lost' },
{ value: 'retired', label: 'Retired' },
]
interface Props {
defaultValues?: Partial<InventoryUnit>
onSubmit: (data: Record<string, unknown>) => void
loading?: boolean
}
export function InventoryUnitForm({ defaultValues, onSubmit, loading }: Props) {
const { register, handleSubmit, setValue, watch } = useForm({
defaultValues: {
serialNumber: defaultValues?.serialNumber ?? '',
condition: (defaultValues?.condition ?? 'new') as UnitCondition,
status: (defaultValues?.status ?? 'available') as UnitStatus,
purchaseDate: defaultValues?.purchaseDate ?? '',
purchaseCost: defaultValues?.purchaseCost ?? '',
notes: defaultValues?.notes ?? '',
},
})
const condition = watch('condition')
const status = watch('status')
function handleFormSubmit(data: Record<string, unknown>) {
onSubmit({
serialNumber: (data.serialNumber as string) || undefined,
condition: data.condition,
status: data.status,
purchaseDate: (data.purchaseDate as string) || undefined,
purchaseCost: (data.purchaseCost as string) || undefined,
notes: (data.notes as string) || undefined,
})
}
return (
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="unit-serial">Serial Number</Label>
<Input id="unit-serial" {...register('serialNumber')} placeholder="e.g. US22041234" />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Condition</Label>
<Select value={condition} onValueChange={(v) => setValue('condition', v as UnitCondition)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{CONDITIONS.map((c) => (
<SelectItem key={c.value} value={c.value}>{c.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Status</Label>
<Select value={status} onValueChange={(v) => setValue('status', v as UnitStatus)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{STATUSES.map((s) => (
<SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="unit-date">Purchase Date</Label>
<Input id="unit-date" type="date" {...register('purchaseDate')} />
</div>
<div className="space-y-2">
<Label htmlFor="unit-cost">Purchase Cost</Label>
<Input id="unit-cost" type="number" step="0.01" min="0" {...register('purchaseCost')} placeholder="0.00" />
</div>
</div>
<div className="space-y-2">
<Label htmlFor="unit-notes">Notes</Label>
<textarea
id="unit-notes"
{...register('notes')}
rows={2}
placeholder="Any notes about this unit..."
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring resize-none"
/>
</div>
<Button type="submit" disabled={loading} className="w-full">
{loading ? 'Saving...' : defaultValues?.id ? 'Save Changes' : 'Add Unit'}
</Button>
</form>
)
}

View File

@@ -0,0 +1,172 @@
import { useForm } from 'react-hook-form'
import { useQuery } from '@tanstack/react-query'
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 { categoryAllOptions } from '@/api/inventory'
import type { Product } from '@/types/inventory'
interface Props {
defaultValues?: Partial<Product>
onSubmit: (data: Record<string, unknown>) => void
loading?: boolean
}
export function ProductForm({ defaultValues, onSubmit, loading }: Props) {
const { data: allCats } = useQuery(categoryAllOptions())
const categories = (allCats?.data ?? []).filter((c) => c.isActive)
const { register, handleSubmit, setValue, watch } = useForm({
defaultValues: {
name: defaultValues?.name ?? '',
sku: defaultValues?.sku ?? '',
upc: defaultValues?.upc ?? '',
brand: defaultValues?.brand ?? '',
model: defaultValues?.model ?? '',
description: defaultValues?.description ?? '',
categoryId: defaultValues?.categoryId ?? '',
price: defaultValues?.price ?? '',
minPrice: defaultValues?.minPrice ?? '',
rentalRateMonthly: defaultValues?.rentalRateMonthly ?? '',
qtyOnHand: defaultValues?.qtyOnHand ?? 0,
qtyReorderPoint: defaultValues?.qtyReorderPoint ?? '',
isSerialized: defaultValues?.isSerialized ?? false,
isRental: defaultValues?.isRental ?? false,
isDualUseRepair: defaultValues?.isDualUseRepair ?? false,
isActive: defaultValues?.isActive ?? true,
},
})
const categoryId = watch('categoryId')
const isRental = watch('isRental')
const isSerialized = watch('isSerialized')
const isDualUseRepair = watch('isDualUseRepair')
const isActive = watch('isActive')
function handleFormSubmit(data: Record<string, unknown>) {
onSubmit({
name: data.name,
sku: (data.sku as string) || undefined,
upc: (data.upc as string) || undefined,
brand: (data.brand as string) || undefined,
model: (data.model as string) || undefined,
description: (data.description as string) || undefined,
categoryId: (data.categoryId as string) || undefined,
price: (data.price as string) ? Number(data.price) : undefined,
minPrice: (data.minPrice as string) ? Number(data.minPrice) : undefined,
rentalRateMonthly: isRental && (data.rentalRateMonthly as string) ? Number(data.rentalRateMonthly) : undefined,
qtyOnHand: Number(data.qtyOnHand),
qtyReorderPoint: (data.qtyReorderPoint as string) ? Number(data.qtyReorderPoint) : undefined,
isSerialized: data.isSerialized,
isRental: data.isRental,
isDualUseRepair: data.isDualUseRepair,
isActive: data.isActive,
})
}
return (
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="p-name">Name *</Label>
<Input id="p-name" {...register('name')} placeholder="e.g. Fender Player Stratocaster" required />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="p-sku">SKU</Label>
<Input id="p-sku" {...register('sku')} placeholder="STR-001" />
</div>
<div className="space-y-2">
<Label htmlFor="p-upc">UPC / Barcode</Label>
<Input id="p-upc" {...register('upc')} placeholder="0123456789" />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="p-brand">Brand</Label>
<Input id="p-brand" {...register('brand')} placeholder="Fender" />
</div>
<div className="space-y-2">
<Label htmlFor="p-model">Model</Label>
<Input id="p-model" {...register('model')} placeholder="Player Stratocaster" />
</div>
</div>
<div className="space-y-2">
<Label>Category</Label>
<Select value={categoryId || 'none'} onValueChange={(v) => setValue('categoryId', v === 'none' ? '' : v)}>
<SelectTrigger>
<SelectValue placeholder="Select category..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">No Category</SelectItem>
{categories.map((c) => (
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="p-price">Price</Label>
<Input id="p-price" type="number" step="0.01" min="0" {...register('price')} placeholder="0.00" />
</div>
<div className="space-y-2">
<Label htmlFor="p-min-price">Min Price</Label>
<Input id="p-min-price" type="number" step="0.01" min="0" {...register('minPrice')} placeholder="0.00" />
</div>
{isRental && (
<div className="space-y-2">
<Label htmlFor="p-rental-rate">Rental / Month</Label>
<Input id="p-rental-rate" type="number" step="0.01" min="0" {...register('rentalRateMonthly')} placeholder="0.00" />
</div>
)}
</div>
{!isSerialized && (
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="p-qty">Qty On Hand</Label>
<Input id="p-qty" type="number" min="0" {...register('qtyOnHand')} />
</div>
<div className="space-y-2">
<Label htmlFor="p-reorder">Reorder Point</Label>
<Input id="p-reorder" type="number" min="0" {...register('qtyReorderPoint')} placeholder="—" />
</div>
</div>
)}
<div className="space-y-2">
<Label htmlFor="p-desc">Description</Label>
<textarea
id="p-desc"
{...register('description')}
rows={3}
placeholder="Product description..."
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring resize-none"
/>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium">Options</Label>
<div className="flex flex-wrap gap-4">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox" checked={isSerialized} onChange={(e) => setValue('isSerialized', e.target.checked)} className="h-4 w-4" />
Serialized (track individual units)
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox" checked={isRental} onChange={(e) => setValue('isRental', e.target.checked)} className="h-4 w-4" />
Available for Rental
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox" checked={isDualUseRepair} onChange={(e) => setValue('isDualUseRepair', e.target.checked)} className="h-4 w-4" />
Available as Repair Line Item
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox" checked={isActive} onChange={(e) => setValue('isActive', e.target.checked)} className="h-4 w-4" />
Active
</label>
</div>
</div>
<Button type="submit" disabled={loading} className="w-full">
{loading ? 'Saving...' : defaultValues?.id ? 'Save Changes' : 'Create Product'}
</Button>
</form>
)
}

View File

@@ -0,0 +1,88 @@
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 type { Supplier } from '@/types/inventory'
interface Props {
defaultValues?: Partial<Supplier>
onSubmit: (data: Record<string, unknown>) => void
onDelete?: () => void
loading?: boolean
deleteLoading?: boolean
}
export function SupplierForm({ defaultValues, onSubmit, onDelete, loading, deleteLoading }: Props) {
const { register, handleSubmit } = useForm({
defaultValues: {
name: defaultValues?.name ?? '',
contactName: defaultValues?.contactName ?? '',
email: defaultValues?.email ?? '',
phone: defaultValues?.phone ?? '',
website: defaultValues?.website ?? '',
accountNumber: defaultValues?.accountNumber ?? '',
paymentTerms: defaultValues?.paymentTerms ?? '',
},
})
function handleFormSubmit(data: Record<string, string>) {
onSubmit({
name: data.name,
contactName: data.contactName || undefined,
email: data.email || undefined,
phone: data.phone || undefined,
website: data.website || undefined,
accountNumber: data.accountNumber || undefined,
paymentTerms: data.paymentTerms || undefined,
})
}
return (
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="sup-name">Name *</Label>
<Input id="sup-name" {...register('name')} placeholder="e.g. Fender Musical Instruments" required />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="sup-contact">Contact Name</Label>
<Input id="sup-contact" {...register('contactName')} placeholder="Jane Smith" />
</div>
<div className="space-y-2">
<Label htmlFor="sup-email">Email</Label>
<Input id="sup-email" type="email" {...register('email')} placeholder="orders@supplier.com" />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="sup-phone">Phone</Label>
<Input id="sup-phone" {...register('phone')} placeholder="555-0100" />
</div>
<div className="space-y-2">
<Label htmlFor="sup-website">Website</Label>
<Input id="sup-website" {...register('website')} placeholder="https://supplier.com" />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="sup-acct">Account Number</Label>
<Input id="sup-acct" {...register('accountNumber')} placeholder="ACC-12345" />
</div>
<div className="space-y-2">
<Label htmlFor="sup-terms">Payment Terms</Label>
<Input id="sup-terms" {...register('paymentTerms')} placeholder="Net 30" />
</div>
</div>
<div className="flex gap-2 pt-2">
<Button type="submit" disabled={loading} className="flex-1">
{loading ? 'Saving...' : defaultValues?.id ? 'Save Changes' : 'Create Supplier'}
</Button>
{onDelete && (
<Button type="button" variant="destructive" disabled={deleteLoading} onClick={onDelete}>
{deleteLoading ? 'Deleting...' : 'Delete'}
</Button>
)}
</div>
</form>
)
}

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,161 @@
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
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

@@ -1,5 +1,4 @@
import jsPDF from 'jspdf'
import { api } from '@/lib/api-client'
import type { RepairTicket, RepairLineItem, RepairNote } from '@/types/repair'
const STATUS_LABELS: Record<string, string> = {
@@ -24,7 +23,7 @@ interface GeneratePdfOptions {
companyName?: string
}
export function generateRepairPdf({ ticket, lineItems, notes, includeNotes, companyName = 'Forte Music' }: GeneratePdfOptions): jsPDF {
export function generateRepairPdf({ ticket, lineItems, notes, includeNotes, companyName = 'LunarFront' }: GeneratePdfOptions): jsPDF {
const doc = new jsPDF()
let y = 20
@@ -57,11 +56,11 @@ export function generateRepairPdf({ ticket, lineItems, notes, includeNotes, comp
doc.setFontSize(10)
doc.setFont('helvetica', 'bold')
doc.text('Customer', 14, y)
doc.text('Instrument', 110, y)
doc.text('Item', 110, y)
y += 5
doc.setFont('helvetica', 'normal')
doc.text(ticket.customerName, 14, y)
doc.text(ticket.instrumentDescription ?? '-', 110, y)
doc.text(ticket.itemDescription ?? '-', 110, y)
y += 5
if (ticket.customerPhone) { doc.text(ticket.customerPhone, 14, y); y += 5 }
if (ticket.serialNumber) { doc.text(`S/N: ${ticket.serialNumber}`, 110, y - 5) }

View File

@@ -9,9 +9,9 @@ import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import { FileText, Download, Check, Eye, Lock } from 'lucide-react'
import { FileText, Download, Check, Eye } from 'lucide-react'
import { toast } from 'sonner'
import type { RepairTicket, RepairLineItem, RepairNote } from '@/types/repair'
import type { RepairTicket, RepairLineItem } from '@/types/repair'
interface FileRecord {
id: string
@@ -65,7 +65,7 @@ export function PdfModal({ ticket, lineItems, ticketId }: PdfModalProps) {
function toggleNote(id: string) {
setSelectedNoteIds((prev) => {
const next = new Set(prev)
next.has(id) ? next.delete(id) : next.add(id)
if (next.has(id)) { next.delete(id) } else { next.add(id) }
return next
})
}
@@ -73,7 +73,7 @@ export function PdfModal({ ticket, lineItems, ticketId }: PdfModalProps) {
function togglePhoto(id: string) {
setSelectedPhotoIds((prev) => {
const next = new Set(prev)
next.has(id) ? next.delete(id) : next.add(id)
if (next.has(id)) { next.delete(id) } else { next.add(id) }
return next
})
}
@@ -102,7 +102,7 @@ export function PdfModal({ ticket, lineItems, ticketId }: PdfModalProps) {
)
toast.success('PDF generated and saved to documents')
setOpen(false)
} catch (err) {
} catch {
toast.error('Failed to generate PDF')
} finally {
setGenerating(false)

View File

@@ -1,4 +1,4 @@
import { Check, Truck, ClipboardList, Search, Clock, ThumbsUp, Wrench, Package, HandMetal, Ban, FilePlus } from 'lucide-react'
import { Check, ClipboardList, Search, Clock, ThumbsUp, Wrench, Package, HandMetal, Ban, FilePlus } from 'lucide-react'
const STEPS = [
{ key: 'new', label: 'New', icon: FilePlus },

View File

@@ -4,7 +4,7 @@ import { queryOptions } from '@tanstack/react-query'
import { api } from '@/lib/api-client'
import { useAuthStore } from '@/stores/auth.store'
import { Button } from '@/components/ui/button'
import { FileText, ImageIcon, Plus, Trash2 } from 'lucide-react'
import { FileText, Plus, Trash2 } from 'lucide-react'
import { toast } from 'sonner'
function AuthImage({ path, alt, className, onClick }: { path: string; alt: string; className?: string; onClick?: () => void }) {

View File

@@ -1,5 +1,5 @@
import { useRef, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { queryOptions } from '@tanstack/react-query'
import { api } from '@/lib/api-client'
import { useAuthStore } from '@/stores/auth.store'
@@ -23,9 +23,11 @@ function entityFilesOptions(entityType: string, entityId: string) {
}
interface AvatarUploadProps {
entityType: 'user' | 'member'
entityType: 'user' | 'member' | 'company'
entityId: string
size?: 'sm' | 'md' | 'lg'
category?: string
placeholderIcon?: React.ComponentType<{ className?: string }>
}
const sizeClasses = {
@@ -40,16 +42,17 @@ const iconSizes = {
lg: 'h-12 w-12',
}
export function AvatarUpload({ entityType, entityId, size = 'lg' }: AvatarUploadProps) {
export function AvatarUpload({ entityType, entityId, size = 'lg', category = 'profile', placeholderIcon: PlaceholderIcon }: AvatarUploadProps) {
const queryClient = useQueryClient()
const token = useAuthStore((s) => s.token)
const fileInputRef = useRef<HTMLInputElement>(null)
const [uploading, setUploading] = useState(false)
const IconComponent = PlaceholderIcon ?? User
const { data: filesData } = useQuery(entityFilesOptions(entityType, entityId))
// Find profile image from files
const profileFile = filesData?.data?.find((f) => f.path.includes('/profile-'))
// Find image by category
const profileFile = filesData?.data?.find((f) => f.path.includes(`/${category}-`))
const imageUrl = profileFile ? `/v1/files/serve/${profileFile.path}` : null
async function handleUpload(file: File) {
@@ -59,7 +62,7 @@ export function AvatarUpload({ entityType, entityId, size = 'lg' }: AvatarUpload
formData.append('file', file)
formData.append('entityType', entityType)
formData.append('entityId', entityId)
formData.append('category', 'profile')
formData.append('category', category)
// Delete existing profile image first
if (profileFile) {
@@ -105,7 +108,7 @@ export function AvatarUpload({ entityType, entityId, size = 'lg' }: AvatarUpload
className="h-full w-full object-cover"
/>
) : (
<User className={`${iconSizes[size]} text-muted-foreground`} />
<IconComponent className={`${iconSizes[size]} text-muted-foreground`} />
)}
</div>
<Button

View File

@@ -0,0 +1,29 @@
import { FileText, Image, FileSpreadsheet, File, FileType, Film } from 'lucide-react'
const ICON_MAP: Record<string, { icon: typeof FileText; color: string }> = {
'application/pdf': { icon: FileText, color: 'text-red-500' },
'image/jpeg': { icon: Image, color: 'text-blue-500' },
'image/png': { icon: Image, color: 'text-blue-500' },
'image/webp': { icon: Image, color: 'text-blue-500' },
'image/gif': { icon: Image, color: 'text-blue-500' },
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': { icon: FileType, color: 'text-blue-600' },
'application/msword': { icon: FileType, color: 'text-blue-600' },
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': { icon: FileSpreadsheet, color: 'text-green-600' },
'application/vnd.ms-excel': { icon: FileSpreadsheet, color: 'text-green-600' },
'text/csv': { icon: FileSpreadsheet, color: 'text-green-600' },
'text/plain': { icon: FileText, color: 'text-muted-foreground' },
'video/mp4': { icon: Film, color: 'text-purple-500' },
}
export function FileIcon({ contentType, className = 'h-8 w-8' }: { contentType: string; className?: string }) {
const match = ICON_MAP[contentType] ?? { icon: File, color: 'text-muted-foreground' }
const Icon = match.icon
return <Icon className={`${className} ${match.color}`} />
}
export function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`
}

View File

@@ -0,0 +1,236 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import {
storageFolderPermissionsOptions, storageFolderMutations, storageFolderKeys,
} from '@/api/storage'
import { roleListOptions } from '@/api/rbac'
import { userListOptions } from '@/api/users'
import type { UserRecord } from '@/api/users'
import type { Role } from '@/types/rbac'
import type { StorageFolderPermission } from '@/types/storage'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select'
import { Trash2, Shield, Users, User } from 'lucide-react'
import { toast } from 'sonner'
interface FolderPermissionsDialogProps {
folderId: string
folderName: string
isPublic: boolean
open: boolean
onOpenChange: (open: boolean) => void
}
const ACCESS_LEVELS = [
{ value: 'traverse', label: 'Traverse', variant: 'outline' as const },
{ value: 'view', label: 'View', variant: 'secondary' as const },
{ value: 'edit', label: 'Edit', variant: 'default' as const },
{ value: 'admin', label: 'Admin', variant: 'destructive' as const },
]
export function FolderPermissionsDialog({ folderId, folderName, isPublic, open, onOpenChange }: FolderPermissionsDialogProps) {
const queryClient = useQueryClient()
const [assigneeType, setAssigneeType] = useState<'role' | 'user'>('role')
const [assigneeId, setAssigneeId] = useState('')
const [accessLevel, setAccessLevel] = useState('view')
const { data: permissionsData, isLoading: permsLoading } = useQuery({
...storageFolderPermissionsOptions(folderId),
enabled: open && !!folderId,
})
const { data: rolesData } = useQuery({ ...roleListOptions(), enabled: open })
const { data: usersData } = useQuery({
...userListOptions({ page: 1, limit: 100, order: 'asc' }),
enabled: open && assigneeType === 'user',
})
const permissions = permissionsData?.data ?? []
const roles = rolesData?.data ?? []
const users = usersData?.data ?? []
const togglePublicMutation = useMutation({
mutationFn: (newIsPublic: boolean) => storageFolderMutations.update(folderId, { isPublic: newIsPublic }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: storageFolderKeys.all })
queryClient.invalidateQueries({ queryKey: storageFolderKeys.detail(folderId) })
toast.success(isPublic ? 'Folder set to private' : 'Folder set to public')
},
onError: (err) => toast.error(err.message),
})
const addPermissionMutation = useMutation({
mutationFn: () => storageFolderMutations.addPermission(folderId, {
...(assigneeType === 'role' ? { roleId: assigneeId } : { userId: assigneeId }),
accessLevel,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: storageFolderKeys.permissions(folderId) })
setAssigneeId('')
setAccessLevel('view')
toast.success('Permission added')
},
onError: (err) => toast.error(err.message),
})
const removePermissionMutation = useMutation({
mutationFn: (permId: string) => storageFolderMutations.removePermission(permId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: storageFolderKeys.permissions(folderId) })
toast.success('Permission removed')
},
onError: (err) => toast.error(err.message),
})
function getPermissionLabel(perm: StorageFolderPermission): { icon: typeof Shield; name: string } {
if (perm.roleId) {
const role = roles.find((r: Role) => r.id === perm.roleId)
return { icon: Users, name: role?.name ?? 'Unknown role' }
}
const user = users.find((u: UserRecord) => u.id === perm.userId)
return { icon: User, name: user ? `${user.firstName} ${user.lastName}` : 'Unknown user' }
}
function handleAdd(e: React.FormEvent) {
e.preventDefault()
if (!assigneeId) return
addPermissionMutation.mutate()
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Shield className="h-4 w-4" />
Permissions {folderName}
</DialogTitle>
</DialogHeader>
<div className="space-y-5">
{/* Public toggle */}
<div className="flex items-center justify-between rounded-lg border p-3">
<div>
<Label className="text-sm font-medium">Public folder</Label>
<p className="text-xs text-muted-foreground mt-0.5">
Public folders are viewable by all users with file access
</p>
</div>
<Switch
checked={isPublic}
onCheckedChange={(checked) => togglePublicMutation.mutate(checked)}
disabled={togglePublicMutation.isPending}
/>
</div>
{/* Current permissions */}
<div>
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Permissions
</Label>
<div className="mt-2 space-y-1.5">
{permsLoading ? (
<p className="text-sm text-muted-foreground">Loading...</p>
) : permissions.length === 0 ? (
<p className="text-sm text-muted-foreground">No specific permissions assigned</p>
) : (
permissions.map((perm) => {
const { icon: Icon, name } = getPermissionLabel(perm)
const level = ACCESS_LEVELS.find((l) => l.value === perm.accessLevel)
return (
<div key={perm.id} className="flex items-center justify-between rounded-md border px-3 py-2">
<div className="flex items-center gap-2 min-w-0">
<Icon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="text-sm truncate">{name}</span>
</div>
<div className="flex items-center gap-2 shrink-0">
<Badge variant={level?.variant ?? 'secondary'} className="text-xs">
{level?.label ?? perm.accessLevel}
</Badge>
<button
type="button"
onClick={() => removePermissionMutation.mutate(perm.id)}
className="text-muted-foreground hover:text-destructive transition-colors"
disabled={removePermissionMutation.isPending}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
)
})
)}
</div>
</div>
{/* Add permission form */}
<form onSubmit={handleAdd} className="space-y-3">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Add Permission
</Label>
{/* Role / User toggle */}
<div className="flex gap-1 rounded-md border p-0.5">
<button
type="button"
onClick={() => { setAssigneeType('role'); setAssigneeId('') }}
className={`flex-1 rounded-sm px-3 py-1.5 text-sm font-medium transition-colors ${assigneeType === 'role' ? 'bg-primary text-primary-foreground' : 'hover:bg-muted'}`}
>
Role
</button>
<button
type="button"
onClick={() => { setAssigneeType('user'); setAssigneeId('') }}
className={`flex-1 rounded-sm px-3 py-1.5 text-sm font-medium transition-colors ${assigneeType === 'user' ? 'bg-primary text-primary-foreground' : 'hover:bg-muted'}`}
>
User
</button>
</div>
<div className="flex gap-2">
{/* Assignee select */}
<Select value={assigneeId} onValueChange={setAssigneeId}>
<SelectTrigger className="flex-1">
<SelectValue placeholder={assigneeType === 'role' ? 'Select role...' : 'Select user...'} />
</SelectTrigger>
<SelectContent>
{assigneeType === 'role'
? roles.map((role: Role) => (
<SelectItem key={role.id} value={role.id}>{role.name}</SelectItem>
))
: users.map((user: UserRecord) => (
<SelectItem key={user.id} value={user.id}>
{user.firstName} {user.lastName}
</SelectItem>
))
}
</SelectContent>
</Select>
{/* Access level select */}
<Select value={accessLevel} onValueChange={setAccessLevel}>
<SelectTrigger className="w-28">
<SelectValue />
</SelectTrigger>
<SelectContent>
{ACCESS_LEVELS.map((level) => (
<SelectItem key={level.value} value={level.value}>{level.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button type="submit" size="sm" disabled={!assigneeId || addPermissionMutation.isPending} className="w-full">
{addPermissionMutation.isPending ? 'Adding...' : 'Add Permission'}
</Button>
</form>
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,91 @@
import { useState } from 'react'
import { ChevronRight, ChevronDown, Folder, FolderOpen, Lock } from 'lucide-react'
import type { StorageFolder } from '@/types/storage'
interface FolderTreeProps {
folders: StorageFolder[]
selectedFolderId: string | null
onSelect: (folderId: string | null) => void
}
interface TreeNode {
folder: StorageFolder
children: TreeNode[]
}
function buildTree(folders: StorageFolder[]): TreeNode[] {
const map = new Map<string, TreeNode>()
const roots: TreeNode[] = []
for (const folder of folders) {
map.set(folder.id, { folder, children: [] })
}
for (const folder of folders) {
const node = map.get(folder.id)!
if (folder.parentId && map.has(folder.parentId)) {
map.get(folder.parentId)!.children.push(node)
} else {
roots.push(node)
}
}
return roots
}
export function FolderTree({ folders, selectedFolderId, onSelect }: FolderTreeProps) {
const tree = buildTree(folders)
return (
<div className="space-y-0.5">
<button
type="button"
onClick={() => onSelect(null)}
className={`flex items-center gap-2 w-full text-left px-2 py-1.5 rounded-md text-sm transition-colors ${
selectedFolderId === null ? 'bg-accent text-accent-foreground font-medium' : 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
}`}
>
<Folder className="h-4 w-4 shrink-0" />
<span>All Files</span>
</button>
{tree.map((node) => (
<TreeItem key={node.folder.id} node={node} depth={0} selectedFolderId={selectedFolderId} onSelect={onSelect} />
))}
</div>
)
}
function TreeItem({ node, depth, selectedFolderId, onSelect }: { node: TreeNode; depth: number; selectedFolderId: string | null; onSelect: (id: string) => void }) {
const [expanded, setExpanded] = useState(depth < 2)
const hasChildren = node.children.length > 0
const isSelected = selectedFolderId === node.folder.id
return (
<div>
<button
type="button"
onClick={() => { onSelect(node.folder.id); if (hasChildren) setExpanded(!expanded) }}
className={`flex items-center gap-1 w-full text-left px-2 py-1.5 rounded-md text-sm transition-colors ${
isSelected ? 'bg-accent text-accent-foreground font-medium' : 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
}`}
style={{ paddingLeft: `${8 + depth * 16}px` }}
>
{hasChildren ? (
expanded ? <ChevronDown className="h-3 w-3 shrink-0" /> : <ChevronRight className="h-3 w-3 shrink-0" />
) : (
<span className="w-3 shrink-0" />
)}
{isSelected ? <FolderOpen className="h-4 w-4 shrink-0 text-primary" /> : <Folder className="h-4 w-4 shrink-0" />}
<span className="truncate">{node.folder.name}</span>
{!node.folder.isPublic && <Lock className="h-3 w-3 shrink-0 text-muted-foreground/50" />}
</button>
{expanded && hasChildren && (
<div>
{node.children.map((child) => (
<TreeItem key={child.folder.id} node={child} depth={depth + 1} selectedFolderId={selectedFolderId} onSelect={onSelect} />
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,142 @@
import * as React from "react"
import { AlertDialog as AlertDialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return <AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return <AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className
)}
{...props}
/>
)
}
function AlertDialogContent({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
className={cn(
"fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function AlertDialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn("text-lg font-semibold", className)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
return (
<AlertDialogPrimitive.Action
className={cn(buttonVariants(), className)}
{...props}
/>
)
}
function AlertDialogCancel({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
return (
<AlertDialogPrimitive.Cancel
className={cn(buttonVariants({ variant: "outline" }), className)}
{...props}
/>
)
}
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}

View File

@@ -0,0 +1,30 @@
import * as React from "react"
import { Checkbox as CheckboxPrimitive } from "radix-ui"
import { CheckIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer size-4 shrink-0 rounded-[4px] border border-input shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=checked]:border-primary dark:bg-input/30 dark:data-[state=checked]:bg-primary",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="flex items-center justify-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

View File

@@ -0,0 +1,33 @@
import * as React from "react"
import { Switch as SwitchPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Switch({
className,
size = "default",
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root> & {
size?: "sm" | "default"
}) {
return (
<SwitchPrimitive.Root
data-slot="switch"
data-size={size}
className={cn(
"peer group/switch inline-flex shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-[1.15rem] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input dark:data-[state=unchecked]:bg-input/80",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0 dark:data-[state=checked]:bg-primary-foreground dark:data-[state=unchecked]:bg-foreground"
)}
/>
</SwitchPrimitive.Root>
)
}
export { Switch }

View File

@@ -0,0 +1,174 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { vaultCategoryPermissionsOptions, vaultCategoryMutations, vaultKeys } from '@/api/vault'
import { roleListOptions } from '@/api/rbac'
import { userListOptions } from '@/api/users'
import type { UserRecord } from '@/api/users'
import type { Role } from '@/types/rbac'
import type { VaultCategoryPermission } from '@/types/vault'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Trash2, Shield, Users, User } from 'lucide-react'
import { toast } from 'sonner'
interface Props {
categoryId: string
categoryName: string
isPublic: boolean
open: boolean
onOpenChange: (open: boolean) => void
}
const ACCESS_LEVELS = [
{ value: 'view', label: 'View', variant: 'secondary' as const },
{ value: 'edit', label: 'Edit', variant: 'default' as const },
{ value: 'admin', label: 'Admin', variant: 'destructive' as const },
]
export function CategoryPermissionsDialog({ categoryId, categoryName, isPublic, open, onOpenChange }: Props) {
const queryClient = useQueryClient()
const [assigneeType, setAssigneeType] = useState<'role' | 'user'>('role')
const [assigneeId, setAssigneeId] = useState('')
const [accessLevel, setAccessLevel] = useState('view')
const { data: permissionsData, isLoading: permsLoading } = useQuery({
...vaultCategoryPermissionsOptions(categoryId),
enabled: open && !!categoryId,
})
const { data: rolesData } = useQuery({ ...roleListOptions(), enabled: open })
const { data: usersData } = useQuery({
...userListOptions({ page: 1, limit: 100, order: 'asc' }),
enabled: open && assigneeType === 'user',
})
const permissions = permissionsData?.data ?? []
const roles = rolesData?.data ?? []
const users = usersData?.data ?? []
const togglePublicMutation = useMutation({
mutationFn: (newIsPublic: boolean) => vaultCategoryMutations.update(categoryId, { isPublic: newIsPublic }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: vaultKeys.categories })
queryClient.invalidateQueries({ queryKey: vaultKeys.categoryDetail(categoryId) })
toast.success(isPublic ? 'Category set to private' : 'Category set to public')
},
onError: (err) => toast.error(err.message),
})
const addPermissionMutation = useMutation({
mutationFn: () => vaultCategoryMutations.addPermission(categoryId, {
...(assigneeType === 'role' ? { roleId: assigneeId } : { userId: assigneeId }),
accessLevel,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: vaultKeys.categoryPermissions(categoryId) })
setAssigneeId('')
setAccessLevel('view')
toast.success('Permission added')
},
onError: (err) => toast.error(err.message),
})
const removePermissionMutation = useMutation({
mutationFn: (permId: string) => vaultCategoryMutations.removePermission(permId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: vaultKeys.categoryPermissions(categoryId) })
toast.success('Permission removed')
},
onError: (err) => toast.error(err.message),
})
function getPermissionLabel(perm: VaultCategoryPermission) {
if (perm.roleId) {
const role = roles.find((r: Role) => r.id === perm.roleId)
return { icon: Users, name: role?.name ?? 'Unknown role' }
}
const user = users.find((u: UserRecord) => u.id === perm.userId)
return { icon: User, name: user ? `${user.firstName} ${user.lastName}` : 'Unknown user' }
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Shield className="h-4 w-4" />
Permissions {categoryName}
</DialogTitle>
</DialogHeader>
<div className="space-y-5">
<div className="flex items-center justify-between rounded-lg border p-3">
<div>
<Label className="text-sm font-medium">Public category</Label>
<p className="text-xs text-muted-foreground mt-0.5">Public categories are viewable by all users with vault access</p>
</div>
<Switch checked={isPublic} onCheckedChange={(checked) => togglePublicMutation.mutate(checked)} disabled={togglePublicMutation.isPending} />
</div>
<div>
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Permissions</Label>
<div className="mt-2 space-y-1.5">
{permsLoading ? (
<p className="text-sm text-muted-foreground">Loading...</p>
) : permissions.length === 0 ? (
<p className="text-sm text-muted-foreground">No specific permissions assigned</p>
) : (
permissions.map((perm) => {
const { icon: Icon, name } = getPermissionLabel(perm)
const level = ACCESS_LEVELS.find((l) => l.value === perm.accessLevel)
return (
<div key={perm.id} className="flex items-center justify-between rounded-md border px-3 py-2">
<div className="flex items-center gap-2 min-w-0">
<Icon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="text-sm truncate">{name}</span>
</div>
<div className="flex items-center gap-2 shrink-0">
<Badge variant={level?.variant ?? 'secondary'} className="text-xs">{level?.label ?? perm.accessLevel}</Badge>
<button type="button" onClick={() => removePermissionMutation.mutate(perm.id)} className="text-muted-foreground hover:text-destructive transition-colors" disabled={removePermissionMutation.isPending}>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
)
})
)}
</div>
</div>
<form onSubmit={(e) => { e.preventDefault(); if (assigneeId) addPermissionMutation.mutate() }} className="space-y-3">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Add Permission</Label>
<div className="flex gap-1 rounded-md border p-0.5">
<button type="button" onClick={() => { setAssigneeType('role'); setAssigneeId('') }} className={`flex-1 rounded-sm px-3 py-1.5 text-sm font-medium transition-colors ${assigneeType === 'role' ? 'bg-primary text-primary-foreground' : 'hover:bg-muted'}`}>Role</button>
<button type="button" onClick={() => { setAssigneeType('user'); setAssigneeId('') }} className={`flex-1 rounded-sm px-3 py-1.5 text-sm font-medium transition-colors ${assigneeType === 'user' ? 'bg-primary text-primary-foreground' : 'hover:bg-muted'}`}>User</button>
</div>
<div className="flex gap-2">
<Select value={assigneeId} onValueChange={setAssigneeId}>
<SelectTrigger className="flex-1"><SelectValue placeholder={assigneeType === 'role' ? 'Select role...' : 'Select user...'} /></SelectTrigger>
<SelectContent>
{assigneeType === 'role'
? roles.map((role: Role) => <SelectItem key={role.id} value={role.id}>{role.name}</SelectItem>)
: users.map((user: UserRecord) => <SelectItem key={user.id} value={user.id}>{user.firstName} {user.lastName}</SelectItem>)
}
</SelectContent>
</Select>
<Select value={accessLevel} onValueChange={setAccessLevel}>
<SelectTrigger className="w-28"><SelectValue /></SelectTrigger>
<SelectContent>
{ACCESS_LEVELS.map((level) => <SelectItem key={level.value} value={level.value}>{level.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
<Button type="submit" size="sm" disabled={!assigneeId || addPermissionMutation.isPending} className="w-full">
{addPermissionMutation.isPending ? 'Adding...' : 'Add Permission'}
</Button>
</form>
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,5 +1,5 @@
import { useNavigate, useSearch } from '@tanstack/react-router'
import type { PaginationInput } from '@forte/shared/schemas'
import type { PaginationInput } from '@lunarfront/shared/schemas'
interface PaginationSearch {
page?: number

View File

@@ -13,12 +13,16 @@ import { Route as LoginRouteImport } from './routes/login'
import { Route as AuthenticatedRouteImport } from './routes/_authenticated'
import { Route as AuthenticatedIndexRouteImport } from './routes/_authenticated/index'
import { Route as AuthenticatedUsersRouteImport } from './routes/_authenticated/users'
import { Route as AuthenticatedSettingsRouteImport } from './routes/_authenticated/settings'
import { Route as AuthenticatedProfileRouteImport } from './routes/_authenticated/profile'
import { Route as AuthenticatedHelpRouteImport } from './routes/_authenticated/help'
import { Route as AuthenticatedVaultIndexRouteImport } from './routes/_authenticated/vault/index'
import { Route as AuthenticatedRolesIndexRouteImport } from './routes/_authenticated/roles/index'
import { Route as AuthenticatedRepairsIndexRouteImport } from './routes/_authenticated/repairs/index'
import { Route as AuthenticatedRepairBatchesIndexRouteImport } from './routes/_authenticated/repair-batches/index'
import { Route as AuthenticatedMembersIndexRouteImport } from './routes/_authenticated/members/index'
import { Route as AuthenticatedInventoryIndexRouteImport } from './routes/_authenticated/inventory/index'
import { Route as AuthenticatedFilesIndexRouteImport } from './routes/_authenticated/files/index'
import { Route as AuthenticatedAccountsIndexRouteImport } from './routes/_authenticated/accounts/index'
import { Route as AuthenticatedRolesNewRouteImport } from './routes/_authenticated/roles/new'
import { Route as AuthenticatedRolesRoleIdRouteImport } from './routes/_authenticated/roles/$roleId'
@@ -28,13 +32,29 @@ import { Route as AuthenticatedRepairsTicketIdRouteImport } from './routes/_auth
import { Route as AuthenticatedRepairBatchesNewRouteImport } from './routes/_authenticated/repair-batches/new'
import { Route as AuthenticatedRepairBatchesBatchIdRouteImport } from './routes/_authenticated/repair-batches/$batchId'
import { Route as AuthenticatedMembersMemberIdRouteImport } from './routes/_authenticated/members/$memberId'
import { Route as AuthenticatedInventoryCategoriesRouteImport } from './routes/_authenticated/inventory/categories'
import { Route as AuthenticatedInventoryProductIdRouteImport } from './routes/_authenticated/inventory/$productId'
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 AuthenticatedInventorySuppliersIndexRouteImport } from './routes/_authenticated/inventory/suppliers/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',
@@ -55,6 +75,11 @@ const AuthenticatedUsersRoute = AuthenticatedUsersRouteImport.update({
path: '/users',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedSettingsRoute = AuthenticatedSettingsRouteImport.update({
id: '/settings',
path: '/settings',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedProfileRoute = AuthenticatedProfileRouteImport.update({
id: '/profile',
path: '/profile',
@@ -65,6 +90,11 @@ const AuthenticatedHelpRoute = AuthenticatedHelpRouteImport.update({
path: '/help',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedVaultIndexRoute = AuthenticatedVaultIndexRouteImport.update({
id: '/vault/',
path: '/vault/',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedRolesIndexRoute = AuthenticatedRolesIndexRouteImport.update({
id: '/roles/',
path: '/roles/',
@@ -88,6 +118,17 @@ const AuthenticatedMembersIndexRoute =
path: '/members/',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedInventoryIndexRoute =
AuthenticatedInventoryIndexRouteImport.update({
id: '/inventory/',
path: '/inventory/',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedFilesIndexRoute = AuthenticatedFilesIndexRouteImport.update({
id: '/files/',
path: '/files/',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedAccountsIndexRoute =
AuthenticatedAccountsIndexRouteImport.update({
id: '/accounts/',
@@ -140,6 +181,18 @@ const AuthenticatedMembersMemberIdRoute =
path: '/members/$memberId',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedInventoryCategoriesRoute =
AuthenticatedInventoryCategoriesRouteImport.update({
id: '/inventory/categories',
path: '/inventory/categories',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedInventoryProductIdRoute =
AuthenticatedInventoryProductIdRouteImport.update({
id: '/inventory/$productId',
path: '/inventory/$productId',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedAccountsNewRoute =
AuthenticatedAccountsNewRouteImport.update({
id: '/accounts/new',
@@ -152,12 +205,84 @@ 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 AuthenticatedInventorySuppliersIndexRoute =
AuthenticatedInventorySuppliersIndexRouteImport.update({
id: '/inventory/suppliers/',
path: '/inventory/suppliers/',
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',
@@ -182,15 +307,30 @@ 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
'/login': typeof LoginRoute
'/help': typeof AuthenticatedHelpRoute
'/profile': typeof AuthenticatedProfileRoute
'/settings': typeof AuthenticatedSettingsRoute
'/users': typeof AuthenticatedUsersRoute
'/accounts/$accountId': typeof AuthenticatedAccountsAccountIdRouteWithChildren
'/accounts/new': typeof AuthenticatedAccountsNewRoute
'/inventory/$productId': typeof AuthenticatedInventoryProductIdRoute
'/inventory/categories': typeof AuthenticatedInventoryCategoriesRoute
'/members/$memberId': typeof AuthenticatedMembersMemberIdRoute
'/repair-batches/$batchId': typeof AuthenticatedRepairBatchesBatchIdRoute
'/repair-batches/new': typeof AuthenticatedRepairBatchesNewRoute
@@ -200,23 +340,43 @@ export interface FileRoutesByFullPath {
'/roles/$roleId': typeof AuthenticatedRolesRoleIdRoute
'/roles/new': typeof AuthenticatedRolesNewRoute
'/accounts/': typeof AuthenticatedAccountsIndexRoute
'/files/': typeof AuthenticatedFilesIndexRoute
'/inventory/': typeof AuthenticatedInventoryIndexRoute
'/members/': typeof AuthenticatedMembersIndexRoute
'/repair-batches/': typeof AuthenticatedRepairBatchesIndexRoute
'/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
'/inventory/suppliers/': typeof AuthenticatedInventorySuppliersIndexRoute
'/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
'/help': typeof AuthenticatedHelpRoute
'/profile': typeof AuthenticatedProfileRoute
'/settings': typeof AuthenticatedSettingsRoute
'/users': typeof AuthenticatedUsersRoute
'/': typeof AuthenticatedIndexRoute
'/accounts/new': typeof AuthenticatedAccountsNewRoute
'/inventory/$productId': typeof AuthenticatedInventoryProductIdRoute
'/inventory/categories': typeof AuthenticatedInventoryCategoriesRoute
'/members/$memberId': typeof AuthenticatedMembersMemberIdRoute
'/repair-batches/$batchId': typeof AuthenticatedRepairBatchesBatchIdRoute
'/repair-batches/new': typeof AuthenticatedRepairBatchesNewRoute
@@ -226,15 +386,32 @@ export interface FileRoutesByTo {
'/roles/$roleId': typeof AuthenticatedRolesRoleIdRoute
'/roles/new': typeof AuthenticatedRolesNewRoute
'/accounts': typeof AuthenticatedAccountsIndexRoute
'/files': typeof AuthenticatedFilesIndexRoute
'/inventory': typeof AuthenticatedInventoryIndexRoute
'/members': typeof AuthenticatedMembersIndexRoute
'/repair-batches': typeof AuthenticatedRepairBatchesIndexRoute
'/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
'/inventory/suppliers': typeof AuthenticatedInventorySuppliersIndexRoute
'/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
@@ -242,10 +419,13 @@ export interface FileRoutesById {
'/login': typeof LoginRoute
'/_authenticated/help': typeof AuthenticatedHelpRoute
'/_authenticated/profile': typeof AuthenticatedProfileRoute
'/_authenticated/settings': typeof AuthenticatedSettingsRoute
'/_authenticated/users': typeof AuthenticatedUsersRoute
'/_authenticated/': typeof AuthenticatedIndexRoute
'/_authenticated/accounts/$accountId': typeof AuthenticatedAccountsAccountIdRouteWithChildren
'/_authenticated/accounts/new': typeof AuthenticatedAccountsNewRoute
'/_authenticated/inventory/$productId': typeof AuthenticatedInventoryProductIdRoute
'/_authenticated/inventory/categories': typeof AuthenticatedInventoryCategoriesRoute
'/_authenticated/members/$memberId': typeof AuthenticatedMembersMemberIdRoute
'/_authenticated/repair-batches/$batchId': typeof AuthenticatedRepairBatchesBatchIdRoute
'/_authenticated/repair-batches/new': typeof AuthenticatedRepairBatchesNewRoute
@@ -255,15 +435,32 @@ export interface FileRoutesById {
'/_authenticated/roles/$roleId': typeof AuthenticatedRolesRoleIdRoute
'/_authenticated/roles/new': typeof AuthenticatedRolesNewRoute
'/_authenticated/accounts/': typeof AuthenticatedAccountsIndexRoute
'/_authenticated/files/': typeof AuthenticatedFilesIndexRoute
'/_authenticated/inventory/': typeof AuthenticatedInventoryIndexRoute
'/_authenticated/members/': typeof AuthenticatedMembersIndexRoute
'/_authenticated/repair-batches/': typeof AuthenticatedRepairBatchesIndexRoute
'/_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/inventory/suppliers/': typeof AuthenticatedInventorySuppliersIndexRoute
'/_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
@@ -272,9 +469,12 @@ export interface FileRouteTypes {
| '/login'
| '/help'
| '/profile'
| '/settings'
| '/users'
| '/accounts/$accountId'
| '/accounts/new'
| '/inventory/$productId'
| '/inventory/categories'
| '/members/$memberId'
| '/repair-batches/$batchId'
| '/repair-batches/new'
@@ -284,23 +484,43 @@ export interface FileRouteTypes {
| '/roles/$roleId'
| '/roles/new'
| '/accounts/'
| '/files/'
| '/inventory/'
| '/members/'
| '/repair-batches/'
| '/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/'
| '/inventory/suppliers/'
| '/lessons/enrollments/'
| '/lessons/plans/'
| '/lessons/schedule/'
| '/lessons/sessions/'
| '/lessons/templates/'
| '/lessons/schedule/instructors/$instructorId'
fileRoutesByTo: FileRoutesByTo
to:
| '/login'
| '/help'
| '/profile'
| '/settings'
| '/users'
| '/'
| '/accounts/new'
| '/inventory/$productId'
| '/inventory/categories'
| '/members/$memberId'
| '/repair-batches/$batchId'
| '/repair-batches/new'
@@ -310,25 +530,45 @@ export interface FileRouteTypes {
| '/roles/$roleId'
| '/roles/new'
| '/accounts'
| '/files'
| '/inventory'
| '/members'
| '/repair-batches'
| '/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'
| '/inventory/suppliers'
| '/lessons/enrollments'
| '/lessons/plans'
| '/lessons/schedule'
| '/lessons/sessions'
| '/lessons/templates'
| '/lessons/schedule/instructors/$instructorId'
id:
| '__root__'
| '/_authenticated'
| '/login'
| '/_authenticated/help'
| '/_authenticated/profile'
| '/_authenticated/settings'
| '/_authenticated/users'
| '/_authenticated/'
| '/_authenticated/accounts/$accountId'
| '/_authenticated/accounts/new'
| '/_authenticated/inventory/$productId'
| '/_authenticated/inventory/categories'
| '/_authenticated/members/$memberId'
| '/_authenticated/repair-batches/$batchId'
| '/_authenticated/repair-batches/new'
@@ -338,15 +578,32 @@ export interface FileRouteTypes {
| '/_authenticated/roles/$roleId'
| '/_authenticated/roles/new'
| '/_authenticated/accounts/'
| '/_authenticated/files/'
| '/_authenticated/inventory/'
| '/_authenticated/members/'
| '/_authenticated/repair-batches/'
| '/_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/inventory/suppliers/'
| '/_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 {
@@ -384,6 +641,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthenticatedUsersRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/settings': {
id: '/_authenticated/settings'
path: '/settings'
fullPath: '/settings'
preLoaderRoute: typeof AuthenticatedSettingsRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/profile': {
id: '/_authenticated/profile'
path: '/profile'
@@ -398,6 +662,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthenticatedHelpRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/vault/': {
id: '/_authenticated/vault/'
path: '/vault'
fullPath: '/vault/'
preLoaderRoute: typeof AuthenticatedVaultIndexRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/roles/': {
id: '/_authenticated/roles/'
path: '/roles'
@@ -426,6 +697,20 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthenticatedMembersIndexRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/inventory/': {
id: '/_authenticated/inventory/'
path: '/inventory'
fullPath: '/inventory/'
preLoaderRoute: typeof AuthenticatedInventoryIndexRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/files/': {
id: '/_authenticated/files/'
path: '/files'
fullPath: '/files/'
preLoaderRoute: typeof AuthenticatedFilesIndexRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/accounts/': {
id: '/_authenticated/accounts/'
path: '/accounts'
@@ -489,6 +774,20 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthenticatedMembersMemberIdRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/inventory/categories': {
id: '/_authenticated/inventory/categories'
path: '/inventory/categories'
fullPath: '/inventory/categories'
preLoaderRoute: typeof AuthenticatedInventoryCategoriesRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/inventory/$productId': {
id: '/_authenticated/inventory/$productId'
path: '/inventory/$productId'
fullPath: '/inventory/$productId'
preLoaderRoute: typeof AuthenticatedInventoryProductIdRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/accounts/new': {
id: '/_authenticated/accounts/new'
path: '/accounts/new'
@@ -503,6 +802,48 @@ 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/inventory/suppliers/': {
id: '/_authenticated/inventory/suppliers/'
path: '/inventory/suppliers'
fullPath: '/inventory/suppliers/'
preLoaderRoute: typeof AuthenticatedInventorySuppliersIndexRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/accounts/$accountId/': {
id: '/_authenticated/accounts/$accountId/'
path: '/'
@@ -510,6 +851,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'
@@ -538,10 +921,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
@@ -551,6 +949,8 @@ interface AuthenticatedAccountsAccountIdRouteChildren {
const AuthenticatedAccountsAccountIdRouteChildren: AuthenticatedAccountsAccountIdRouteChildren =
{
AuthenticatedAccountsAccountIdEnrollmentsRoute:
AuthenticatedAccountsAccountIdEnrollmentsRoute,
AuthenticatedAccountsAccountIdMembersRoute:
AuthenticatedAccountsAccountIdMembersRoute,
AuthenticatedAccountsAccountIdPaymentMethodsRoute:
@@ -571,10 +971,13 @@ const AuthenticatedAccountsAccountIdRouteWithChildren =
interface AuthenticatedRouteChildren {
AuthenticatedHelpRoute: typeof AuthenticatedHelpRoute
AuthenticatedProfileRoute: typeof AuthenticatedProfileRoute
AuthenticatedSettingsRoute: typeof AuthenticatedSettingsRoute
AuthenticatedUsersRoute: typeof AuthenticatedUsersRoute
AuthenticatedIndexRoute: typeof AuthenticatedIndexRoute
AuthenticatedAccountsAccountIdRoute: typeof AuthenticatedAccountsAccountIdRouteWithChildren
AuthenticatedAccountsNewRoute: typeof AuthenticatedAccountsNewRoute
AuthenticatedInventoryProductIdRoute: typeof AuthenticatedInventoryProductIdRoute
AuthenticatedInventoryCategoriesRoute: typeof AuthenticatedInventoryCategoriesRoute
AuthenticatedMembersMemberIdRoute: typeof AuthenticatedMembersMemberIdRoute
AuthenticatedRepairBatchesBatchIdRoute: typeof AuthenticatedRepairBatchesBatchIdRoute
AuthenticatedRepairBatchesNewRoute: typeof AuthenticatedRepairBatchesNewRoute
@@ -584,20 +987,39 @@ interface AuthenticatedRouteChildren {
AuthenticatedRolesRoleIdRoute: typeof AuthenticatedRolesRoleIdRoute
AuthenticatedRolesNewRoute: typeof AuthenticatedRolesNewRoute
AuthenticatedAccountsIndexRoute: typeof AuthenticatedAccountsIndexRoute
AuthenticatedFilesIndexRoute: typeof AuthenticatedFilesIndexRoute
AuthenticatedInventoryIndexRoute: typeof AuthenticatedInventoryIndexRoute
AuthenticatedMembersIndexRoute: typeof AuthenticatedMembersIndexRoute
AuthenticatedRepairBatchesIndexRoute: typeof AuthenticatedRepairBatchesIndexRoute
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
AuthenticatedInventorySuppliersIndexRoute: typeof AuthenticatedInventorySuppliersIndexRoute
AuthenticatedLessonsEnrollmentsIndexRoute: typeof AuthenticatedLessonsEnrollmentsIndexRoute
AuthenticatedLessonsPlansIndexRoute: typeof AuthenticatedLessonsPlansIndexRoute
AuthenticatedLessonsScheduleIndexRoute: typeof AuthenticatedLessonsScheduleIndexRoute
AuthenticatedLessonsSessionsIndexRoute: typeof AuthenticatedLessonsSessionsIndexRoute
AuthenticatedLessonsTemplatesIndexRoute: typeof AuthenticatedLessonsTemplatesIndexRoute
AuthenticatedLessonsScheduleInstructorsInstructorIdRoute: typeof AuthenticatedLessonsScheduleInstructorsInstructorIdRoute
}
const AuthenticatedRouteChildren: AuthenticatedRouteChildren = {
AuthenticatedHelpRoute: AuthenticatedHelpRoute,
AuthenticatedProfileRoute: AuthenticatedProfileRoute,
AuthenticatedSettingsRoute: AuthenticatedSettingsRoute,
AuthenticatedUsersRoute: AuthenticatedUsersRoute,
AuthenticatedIndexRoute: AuthenticatedIndexRoute,
AuthenticatedAccountsAccountIdRoute:
AuthenticatedAccountsAccountIdRouteWithChildren,
AuthenticatedAccountsNewRoute: AuthenticatedAccountsNewRoute,
AuthenticatedInventoryProductIdRoute: AuthenticatedInventoryProductIdRoute,
AuthenticatedInventoryCategoriesRoute: AuthenticatedInventoryCategoriesRoute,
AuthenticatedMembersMemberIdRoute: AuthenticatedMembersMemberIdRoute,
AuthenticatedRepairBatchesBatchIdRoute:
AuthenticatedRepairBatchesBatchIdRoute,
@@ -608,10 +1030,36 @@ const AuthenticatedRouteChildren: AuthenticatedRouteChildren = {
AuthenticatedRolesRoleIdRoute: AuthenticatedRolesRoleIdRoute,
AuthenticatedRolesNewRoute: AuthenticatedRolesNewRoute,
AuthenticatedAccountsIndexRoute: AuthenticatedAccountsIndexRoute,
AuthenticatedFilesIndexRoute: AuthenticatedFilesIndexRoute,
AuthenticatedInventoryIndexRoute: AuthenticatedInventoryIndexRoute,
AuthenticatedMembersIndexRoute: AuthenticatedMembersIndexRoute,
AuthenticatedRepairBatchesIndexRoute: AuthenticatedRepairBatchesIndexRoute,
AuthenticatedRepairsIndexRoute: AuthenticatedRepairsIndexRoute,
AuthenticatedRolesIndexRoute: AuthenticatedRolesIndexRoute,
AuthenticatedVaultIndexRoute: AuthenticatedVaultIndexRoute,
AuthenticatedLessonsEnrollmentsEnrollmentIdRoute:
AuthenticatedLessonsEnrollmentsEnrollmentIdRoute,
AuthenticatedLessonsEnrollmentsNewRoute:
AuthenticatedLessonsEnrollmentsNewRoute,
AuthenticatedLessonsPlansPlanIdRoute: AuthenticatedLessonsPlansPlanIdRoute,
AuthenticatedLessonsSessionsSessionIdRoute:
AuthenticatedLessonsSessionsSessionIdRoute,
AuthenticatedLessonsTemplatesTemplateIdRoute:
AuthenticatedLessonsTemplatesTemplateIdRoute,
AuthenticatedLessonsTemplatesNewRoute: AuthenticatedLessonsTemplatesNewRoute,
AuthenticatedInventorySuppliersIndexRoute:
AuthenticatedInventorySuppliersIndexRoute,
AuthenticatedLessonsEnrollmentsIndexRoute:
AuthenticatedLessonsEnrollmentsIndexRoute,
AuthenticatedLessonsPlansIndexRoute: AuthenticatedLessonsPlansIndexRoute,
AuthenticatedLessonsScheduleIndexRoute:
AuthenticatedLessonsScheduleIndexRoute,
AuthenticatedLessonsSessionsIndexRoute:
AuthenticatedLessonsSessionsIndexRoute,
AuthenticatedLessonsTemplatesIndexRoute:
AuthenticatedLessonsTemplatesIndexRoute,
AuthenticatedLessonsScheduleInstructorsInstructorIdRoute:
AuthenticatedLessonsScheduleInstructorsInstructorIdRoute,
}
const AuthenticatedRouteWithChildren = AuthenticatedRoute._addFileChildren(

View File

@@ -1,11 +1,14 @@
import { createFileRoute, Outlet, Link, redirect, useRouter } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { useEffect } from 'react'
import { queryOptions } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import { api } from '@/lib/api-client'
import { useAuthStore } from '@/stores/auth.store'
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 } from 'lucide-react'
import { Users, UserRound, HelpCircle, Shield, UserCog, LogOut, User, Wrench, Package, ClipboardList, FolderOpen, KeyRound, Settings, PanelLeftClose, PanelLeft, CalendarDays, GraduationCap, CalendarRange, BookOpen, BookMarked, Package2, Tag, Truck } from 'lucide-react'
export const Route = createFileRoute('/_authenticated')({
beforeLoad: () => {
@@ -17,20 +20,90 @@ export const Route = createFileRoute('/_authenticated')({
component: AuthenticatedLayout,
})
function NavLink({ to, icon, label }: { to: string; icon: React.ReactNode; label: string }) {
function StoreLogo() {
const token = useAuthStore((s) => s.token)
const [src, setSrc] = useState<string | null>(null)
const { data: storeData } = useQuery(queryOptions({
queryKey: ['store'],
queryFn: () => api.get<{ id: string; name: string }>('/v1/store'),
enabled: !!token,
}))
const { data: filesData } = useQuery(queryOptions({
queryKey: ['files', 'company', storeData?.id ?? ''],
queryFn: () => api.get<{ data: { id: string; path: string }[] }>('/v1/files', { entityType: 'company', entityId: storeData?.id }),
enabled: !!storeData?.id,
}))
const logoFile = filesData?.data?.find((f) => f.path.includes('/logo-'))
useEffect(() => {
if (!logoFile || !token) { setSrc(null); return }
let cancelled = false
let blobUrl: string | null = null
async function load() {
try {
const res = await fetch(`/v1/files/serve/${logoFile!.path}`, {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok || cancelled) return
const blob = await res.blob()
if (!cancelled) { blobUrl = URL.createObjectURL(blob); setSrc(blobUrl) }
} catch { /* ignore */ }
}
load()
return () => { cancelled = true; if (blobUrl) URL.revokeObjectURL(blobUrl) }
}, [logoFile?.path, token])
if (src) {
return <img src={src} alt={storeData?.name ?? 'Store'} className="max-h-10 max-w-[180px] object-contain" />
}
return <h2 className="text-lg font-semibold text-sidebar-foreground">{storeData?.name ?? 'LunarFront'}</h2>
}
function NavLink({ to, icon, label, collapsed }: { to: string; icon: React.ReactNode; label: string; collapsed?: boolean }) {
return (
<Link
to={to as '/accounts'}
search={{} as any}
className="flex items-center gap-2 px-3 py-2 rounded-md text-sm text-sidebar-foreground hover:bg-sidebar-accent"
activeProps={{ className: 'flex items-center gap-2 px-3 py-2 rounded-md text-sm bg-sidebar-accent text-sidebar-accent-foreground' }}
title={collapsed ? label : undefined}
>
{icon}
{label}
{!collapsed && label}
</Link>
)
}
function NavGroup({ label, children, collapsed }: { label: string; children: React.ReactNode; collapsed?: boolean }) {
const [open, setOpen] = useState(true)
if (collapsed) return <div className="space-y-1">{children}</div>
return (
<div>
<button
onClick={() => setOpen(!open)}
className="flex items-center justify-between w-full px-3 mb-1 mt-3 group cursor-pointer"
>
<span className="text-xs font-semibold text-sidebar-foreground/50 uppercase tracking-wide group-hover:text-sidebar-foreground/70">{label}</span>
<svg
className={`h-3 w-3 text-sidebar-foreground/40 transition-transform ${open ? '' : '-rotate-90'}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
{open && <div className="space-y-1">{children}</div>}
</div>
)
}
function AuthenticatedLayout() {
const router = useRouter()
const user = useAuthStore((s) => s.user)
@@ -45,6 +118,17 @@ function AuthenticatedLayout() {
enabled: !!useAuthStore.getState().token,
})
// Fetch enabled modules
const { data: modulesData } = useQuery({
...moduleListOptions(),
enabled: !!useAuthStore.getState().token,
})
const enabledModules = new Set(
(modulesData?.data ?? []).filter((m) => m.enabled && m.licensed).map((m) => m.slug),
)
const isModuleEnabled = (slug: string) => enabledModules.has(slug)
useEffect(() => {
if (permData?.permissions) {
setPermissions(permData.permissions)
@@ -58,70 +142,114 @@ function AuthenticatedLayout() {
const canViewAccounts = !permissionsLoaded || hasPermission('accounts.view')
const canViewRepairs = !permissionsLoaded || hasPermission('repairs.view')
const canViewLessons = !permissionsLoaded || hasPermission('lessons.view')
const canViewInventory = !permissionsLoaded || hasPermission('inventory.view')
const canViewUsers = !permissionsLoaded || hasPermission('users.view')
const [collapsed, setCollapsed] = useState(false)
return (
<div className="min-h-screen bg-background text-foreground">
<div className="flex">
<nav className="w-56 border-r border-border bg-sidebar min-h-screen flex flex-col">
<div className="p-4">
<h2 className="text-lg font-semibold text-sidebar-foreground">Forte</h2>
</div>
{/* Sidebar links use `as any` on search because TanStack Router
requires the full validated search shape, but these links just
navigate to the page with default params. */}
<div className="flex-1 px-3 space-y-1">
{canViewAccounts && (
<>
<NavLink to="/accounts" icon={<Users className="h-4 w-4" />} label="Accounts" />
<NavLink to="/members" icon={<UserRound className="h-4 w-4" />} label="Members" />
</>
)}
{canViewRepairs && (
<>
<NavLink to="/repairs" icon={<Wrench className="h-4 w-4" />} label="Repairs" />
<NavLink to="/repair-batches" icon={<Package className="h-4 w-4" />} label="Repair Batches" />
{hasPermission('repairs.admin') && (
<NavLink to="/repairs/templates" icon={<ClipboardList className="h-4 w-4" />} label="Repair Templates" />
)}
</>
)}
{canViewUsers && (
<div className="mt-4 mb-1 px-3">
<span className="text-xs font-semibold text-sidebar-foreground/50 uppercase tracking-wide">Admin</span>
<nav className={`${collapsed ? 'w-14' : 'w-56'} border-r border-border bg-sidebar h-screen flex flex-col sticky top-0 transition-[width] duration-200`}>
{/* Header — logo & collapse toggle */}
<div className={`p-3 flex items-center ${collapsed ? 'justify-center' : 'justify-between'}`}>
{!collapsed && (
<div className="min-w-0">
<StoreLogo />
<p className="text-[10px] text-sidebar-foreground/40 tracking-wide">Powered by <span className="font-semibold text-sidebar-foreground/60">LunarFront</span></p>
</div>
)}
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0 text-sidebar-foreground/50 hover:text-sidebar-foreground"
onClick={() => setCollapsed(!collapsed)}
title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
>
{collapsed ? <PanelLeft className="h-4 w-4" /> : <PanelLeftClose className="h-4 w-4" />}
</Button>
</div>
{/* Scrollable nav links */}
<div className="flex-1 overflow-y-auto px-2 space-y-1 scrollbar-thin">
{canViewAccounts && (
<NavGroup label="Customers" collapsed={collapsed}>
<NavLink to="/accounts" icon={<Users className="h-4 w-4" />} label="Accounts" collapsed={collapsed} />
<NavLink to="/members" icon={<UserRound className="h-4 w-4" />} label="Members" collapsed={collapsed} />
</NavGroup>
)}
{isModuleEnabled('inventory') && canViewInventory && (
<NavGroup label="Inventory" collapsed={collapsed}>
<NavLink to="/inventory" icon={<Package2 className="h-4 w-4" />} label="Products" collapsed={collapsed} />
<NavLink to="/inventory/categories" icon={<Tag className="h-4 w-4" />} label="Categories" collapsed={collapsed} />
<NavLink to="/inventory/suppliers" icon={<Truck className="h-4 w-4" />} label="Suppliers" collapsed={collapsed} />
</NavGroup>
)}
{isModuleEnabled('repairs') && canViewRepairs && (
<NavGroup label="Repairs" collapsed={collapsed}>
<NavLink to="/repairs" icon={<Wrench className="h-4 w-4" />} label="Tickets" collapsed={collapsed} />
<NavLink to="/repair-batches" icon={<Package className="h-4 w-4" />} label="Batches" collapsed={collapsed} />
{hasPermission('repairs.admin') && (
<NavLink to="/repairs/templates" icon={<ClipboardList className="h-4 w-4" />} label="Templates" collapsed={collapsed} />
)}
</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') && (
<NavLink to="/files" icon={<FolderOpen className="h-4 w-4" />} label="Files" collapsed={collapsed} />
)}
{isModuleEnabled('vault') && (
<NavLink to="/vault" icon={<KeyRound className="h-4 w-4" />} label="Vault" collapsed={collapsed} />
)}
</NavGroup>
)}
{canViewUsers && (
<>
<NavLink to="/users" icon={<UserCog className="h-4 w-4" />} label="Users" />
<NavLink to="/roles" icon={<Shield className="h-4 w-4" />} label="Roles" />
</>
<NavGroup label="Admin" collapsed={collapsed}>
<NavLink to="/users" icon={<UserCog className="h-4 w-4" />} label="Users" collapsed={collapsed} />
<NavLink to="/roles" icon={<Shield className="h-4 w-4" />} label="Roles" collapsed={collapsed} />
<NavLink to="/settings" icon={<Settings className="h-4 w-4" />} label="Settings" collapsed={collapsed} />
</NavGroup>
)}
</div>
<div className="p-3 border-t border-sidebar-border space-y-1">
<NavLink to="/help" icon={<HelpCircle className="h-4 w-4" />} label="Help" />
{/* Pinned footer — help, profile, sign out */}
<div className="shrink-0 p-2 border-t border-sidebar-border space-y-1">
<NavLink to="/help" icon={<HelpCircle className="h-4 w-4" />} label="Help" collapsed={collapsed} />
<Link
to="/profile"
className="flex items-center gap-2 px-3 py-2 rounded-md text-sm text-sidebar-foreground hover:bg-sidebar-accent w-full"
activeProps={{ className: 'flex items-center gap-2 px-3 py-2 rounded-md text-sm bg-sidebar-accent text-sidebar-accent-foreground w-full' }}
title={collapsed ? `${user?.firstName} ${user?.lastName}` : undefined}
>
{user?.id ? <Avatar entityType="user" entityId={user.id} size="sm" /> : <User className="h-4 w-4" />}
<span className="truncate">{user?.firstName} {user?.lastName}</span>
{!collapsed && <span className="truncate">{user?.firstName} {user?.lastName}</span>}
</Link>
<Button
variant="ghost"
size="sm"
className="w-full justify-start gap-2 text-sm text-sidebar-foreground/70 hover:text-sidebar-foreground"
className={`w-full justify-start gap-2 text-sm text-sidebar-foreground/70 hover:text-sidebar-foreground ${collapsed ? 'px-3' : ''}`}
onClick={handleLogout}
title={collapsed ? 'Sign out' : undefined}
>
<LogOut className="h-4 w-4" />
Sign out
{!collapsed && 'Sign out'}
</Button>
</div>
</nav>
<main className="flex-1 p-6">
<main className="flex-1 p-6 min-h-screen">
<Outlet />
</main>
</div>

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,62 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { enrollmentListOptions } 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 { 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)
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

@@ -24,7 +24,8 @@ function NewAccountPage() {
const memberIsMinor = data.memberIsMinor as boolean | undefined
// Create account (without member fields)
const { memberFirstName: _, memberLastName: __, memberEmail: ___, memberPhone: ____, memberDateOfBirth: _____, memberIsMinor: ______, ...accountData } = data
const memberKeys = new Set(['memberFirstName', 'memberLastName', 'memberEmail', 'memberPhone', 'memberDateOfBirth', 'memberIsMinor'])
const accountData: Record<string, unknown> = Object.fromEntries(Object.entries(data).filter(([k]) => !memberKeys.has(k)))
// Auto-generate account name from member if not provided
if (!accountData.name && memberFirstName && memberLastName) {

View File

@@ -0,0 +1,294 @@
import { useState, useRef } from 'react'
import { createFileRoute } from '@tanstack/react-router'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import {
storageFolderTreeOptions, storageFolderDetailOptions, storageFolderMutations, storageFolderKeys,
storageFileListOptions, storageFileMutations, storageFileKeys,
} from '@/api/storage'
import { usePagination } from '@/hooks/use-pagination'
import { FolderTree } from '@/components/storage/folder-tree'
import { FileIcon, formatFileSize } from '@/components/storage/file-icons'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Skeleton } from '@/components/ui/skeleton'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import { Label } from '@/components/ui/label'
import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { FolderPlus, Upload, Folder, ChevronRight, MoreVertical, Trash2, Download, Shield } from 'lucide-react'
import { toast } from 'sonner'
import { useAuthStore } from '@/stores/auth.store'
import { FolderPermissionsDialog } from '@/components/storage/folder-permissions-dialog'
import type { StorageFile } from '@/types/storage'
export const Route = createFileRoute('/_authenticated/files/')({
validateSearch: (search: Record<string, unknown>) => ({
page: Number(search.page) || 1,
limit: Number(search.limit) || 50,
q: (search.q as string) || undefined,
sort: (search.sort as string) || undefined,
order: (search.order as 'asc' | 'desc') || 'asc',
}),
component: FileManagerPage,
})
function FileManagerPage() {
const queryClient = useQueryClient()
const token = useAuthStore((s) => s.token)
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null)
const [newFolderOpen, setNewFolderOpen] = useState(false)
const [newFolderName, setNewFolderName] = useState('')
const [permissionsOpen, setPermissionsOpen] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const { params } = usePagination()
const { data: treeData, isLoading: treeLoading } = useQuery(storageFolderTreeOptions())
const { data: folderDetail } = useQuery(storageFolderDetailOptions(selectedFolderId ?? ''))
const { data: filesData, isLoading: filesLoading } = useQuery(
storageFileListOptions(selectedFolderId ?? '', { ...params, limit: 50 }),
)
const { data: subFoldersData } = useQuery({
queryKey: storageFolderKeys.children(selectedFolderId),
queryFn: () => {
const allFolders = treeData?.data ?? []
return { data: allFolders.filter((f) => f.parentId === selectedFolderId) }
},
enabled: !!treeData,
})
const createFolderMutation = useMutation({
mutationFn: storageFolderMutations.create,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: storageFolderKeys.all })
toast.success('Folder created')
setNewFolderOpen(false)
setNewFolderName('')
},
onError: (err) => toast.error(err.message),
})
const deleteFileMutation = useMutation({
mutationFn: storageFileMutations.delete,
onSuccess: () => {
if (selectedFolderId) queryClient.invalidateQueries({ queryKey: storageFileKeys.all(selectedFolderId) })
toast.success('File deleted')
},
onError: (err) => toast.error(err.message),
})
async function handleFileUpload(e: React.ChangeEvent<HTMLInputElement>) {
if (!selectedFolderId || !e.target.files?.length) return
const files = Array.from(e.target.files)
for (const file of files) {
const formData = new FormData()
formData.append('file', file)
try {
const res = await fetch(`/v1/storage/folders/${selectedFolderId}/files`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
body: formData,
})
if (!res.ok) {
const err = await res.json().catch(() => ({}))
toast.error(`Upload failed: ${(err as any).error?.message ?? file.name}`)
}
} catch {
toast.error(`Upload failed: ${file.name}`)
}
}
queryClient.invalidateQueries({ queryKey: storageFileKeys.all(selectedFolderId) })
toast.success(`${files.length} file(s) uploaded`)
e.target.value = ''
}
async function handleDownload(file: StorageFile) {
try {
const { url } = await storageFileMutations.getSignedUrl(file.id)
window.open(url, '_blank')
} catch {
toast.error('Download failed')
}
}
function handleCreateFolder(e: React.FormEvent) {
e.preventDefault()
if (!newFolderName.trim()) return
createFolderMutation.mutate({ name: newFolderName.trim(), parentId: selectedFolderId ?? undefined })
}
const folders = treeData?.data ?? []
const files = filesData?.data ?? []
const subFolders = subFoldersData?.data ?? []
const breadcrumbs = folderDetail?.breadcrumbs ?? []
return (
<div className="flex gap-0 h-[calc(100vh-6rem)]">
{/* Left Panel — Folder Tree */}
<div className="w-60 shrink-0 border-r overflow-y-auto p-3">
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-semibold">Folders</h2>
<Dialog open={newFolderOpen} onOpenChange={setNewFolderOpen}>
<DialogTrigger asChild>
<Button variant="ghost" size="sm" className="h-7 w-7 p-0">
<FolderPlus className="h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>New Folder</DialogTitle></DialogHeader>
<form onSubmit={handleCreateFolder} className="space-y-4">
<div className="space-y-2">
<Label>Folder Name</Label>
<Input value={newFolderName} onChange={(e) => setNewFolderName(e.target.value)} placeholder="e.g. HR Documents" autoFocus />
</div>
{selectedFolderId && (
<p className="text-xs text-muted-foreground">
Creating inside: {breadcrumbs.map((b) => b.name).join(' / ') || 'Root'}
</p>
)}
<Button type="submit" disabled={createFolderMutation.isPending || !newFolderName.trim()}>
{createFolderMutation.isPending ? 'Creating...' : 'Create Folder'}
</Button>
</form>
</DialogContent>
</Dialog>
</div>
{treeLoading ? (
<div className="space-y-2">{Array.from({ length: 5 }).map((_, i) => <Skeleton key={i} className="h-7 w-full" />)}</div>
) : (
<FolderTree folders={folders} selectedFolderId={selectedFolderId} onSelect={setSelectedFolderId} />
)}
</div>
{/* Right Panel — Files */}
<div className="flex-1 overflow-y-auto">
{/* Toolbar */}
<div className="flex items-center gap-2 p-3 border-b">
{/* Breadcrumbs */}
<div className="flex items-center gap-1 text-sm flex-1 min-w-0">
<button type="button" onClick={() => setSelectedFolderId(null)} className="text-muted-foreground hover:text-foreground">
Files
</button>
{breadcrumbs.map((crumb) => (
<span key={crumb.id} className="flex items-center gap-1">
<ChevronRight className="h-3 w-3 text-muted-foreground" />
<button type="button" onClick={() => setSelectedFolderId(crumb.id)} className="text-muted-foreground hover:text-foreground truncate max-w-[120px]">
{crumb.name}
</button>
</span>
))}
</div>
{selectedFolderId && (
<>
{folderDetail?.accessLevel === 'admin' && (
<Button variant="outline" size="sm" onClick={() => setPermissionsOpen(true)}>
<Shield className="mr-2 h-4 w-4" />Permissions
</Button>
)}
<Button variant="outline" size="sm" onClick={() => fileInputRef.current?.click()}>
<Upload className="mr-2 h-4 w-4" />Upload
</Button>
<input ref={fileInputRef} type="file" multiple className="hidden" onChange={handleFileUpload} />
</>
)}
</div>
{/* Content */}
<div className="p-4">
{!selectedFolderId ? (
<div className="text-center py-12 text-muted-foreground">
<Folder className="h-12 w-12 mx-auto mb-3 opacity-30" />
<p>Select a folder to view files</p>
<p className="text-xs mt-1">Or create a new folder to get started</p>
</div>
) : filesLoading ? (
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
{Array.from({ length: 8 }).map((_, i) => <Skeleton key={i} className="h-28 w-full rounded-lg" />)}
</div>
) : (
<>
{/* Sub-folders */}
{subFolders.length > 0 && (
<div className="mb-4">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-2">Folders</p>
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
{subFolders.map((folder) => (
<button
key={folder.id}
type="button"
onClick={() => setSelectedFolderId(folder.id)}
className="flex flex-col items-center gap-1.5 p-3 rounded-lg border hover:bg-accent transition-colors group"
>
<Folder className="h-8 w-8 text-amber-500" />
<span className="text-xs font-medium text-center truncate w-full">{folder.name}</span>
</button>
))}
</div>
</div>
)}
{/* Files */}
{files.length > 0 && (
<div>
{subFolders.length > 0 && <p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-2">Files</p>}
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
{files.map((file) => (
<div
key={file.id}
className="flex flex-col items-center gap-1.5 p-3 rounded-lg border hover:bg-accent transition-colors group relative"
>
<button type="button" onClick={() => handleDownload(file)} className="flex flex-col items-center gap-1.5 w-full">
<FileIcon contentType={file.contentType} />
<span className="text-xs font-medium text-center truncate w-full">{file.filename}</span>
<span className="text-[10px] text-muted-foreground">{formatFileSize(file.sizeBytes)}</span>
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="absolute top-1 right-1 h-6 w-6 rounded-md flex items-center justify-center opacity-0 group-hover:opacity-100 hover:bg-muted transition-opacity">
<MoreVertical className="h-3 w-3" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleDownload(file)}>
<Download className="mr-2 h-4 w-4" />Download
</DropdownMenuItem>
<DropdownMenuItem className="text-destructive" onClick={() => deleteFileMutation.mutate(file.id)}>
<Trash2 className="mr-2 h-4 w-4" />Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
))}
</div>
</div>
)}
{files.length === 0 && subFolders.length === 0 && (
<div className="text-center py-12 text-muted-foreground">
<Upload className="h-10 w-10 mx-auto mb-3 opacity-30" />
<p>This folder is empty</p>
<p className="text-xs mt-1">Upload files or create sub-folders</p>
</div>
)}
</>
)}
</div>
</div>
{selectedFolderId && folderDetail && (
<FolderPermissionsDialog
folderId={selectedFolderId}
folderName={folderDetail.name ?? ''}
isPublic={folderDetail.isPublic ?? true}
open={permissionsOpen}
onOpenChange={setPermissionsOpen}
/>
)}
</div>
)
}

View File

@@ -1,8 +1,8 @@
import { createFileRoute } from '@tanstack/react-router'
import { useState } from 'react'
import { getWikiCategories, getWikiPage, type WikiPage } from '@/wiki'
import { getWikiCategories, getWikiPage } 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,786 @@
import { useState } from 'react'
import { createFileRoute, useParams, useNavigate } from '@tanstack/react-router'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import {
productDetailOptions, productMutations, productKeys,
unitListOptions, unitMutations, unitKeys,
productSupplierListOptions, productSupplierMutations, productSupplierKeys,
priceHistoryOptions, supplierListOptions,
stockReceiptListOptions, stockReceiptMutations, stockReceiptKeys,
} from '@/api/inventory'
import { ProductForm } from '@/components/inventory/product-form'
import { InventoryUnitForm } from '@/components/inventory/inventory-unit-form'
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 { Skeleton } from '@/components/ui/skeleton'
import { Checkbox } from '@/components/ui/checkbox'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog'
import { ArrowLeft, Plus, Pencil, Star, Trash2 } from 'lucide-react'
import { toast } from 'sonner'
import { cn } from '@/lib/utils'
import { useAuthStore } from '@/stores/auth.store'
import type { InventoryUnit, ProductSupplier, UnitCondition, UnitStatus } from '@/types/inventory'
const CONDITION_CLASSES: Record<UnitCondition, string> = {
new: 'bg-blue-100 text-blue-800 border border-blue-300',
excellent: 'bg-green-100 text-green-800 border border-green-300',
good: 'bg-emerald-100 text-emerald-800 border border-emerald-300',
fair: 'bg-yellow-100 text-yellow-800 border border-yellow-300',
poor: 'bg-red-100 text-red-800 border border-red-300',
}
const STATUS_CLASSES: Record<UnitStatus, string> = {
available: 'bg-green-100 text-green-800 border border-green-300',
sold: 'bg-gray-100 text-gray-600 border border-gray-300',
rented: 'bg-purple-100 text-purple-800 border border-purple-300',
on_trial: 'bg-cyan-100 text-cyan-800 border border-cyan-300',
in_repair: 'bg-orange-100 text-orange-800 border border-orange-300',
layaway: 'bg-indigo-100 text-indigo-800 border border-indigo-300',
lost: 'bg-red-100 text-red-800 border border-red-300',
retired: 'bg-gray-100 text-gray-400 border border-gray-200',
}
const STATUS_LABELS: Record<UnitStatus, string> = {
available: 'Available', sold: 'Sold', rented: 'Rented', on_trial: 'On Trial',
in_repair: 'In Repair', layaway: 'Layaway', lost: 'Lost', retired: 'Retired',
}
export const Route = createFileRoute('/_authenticated/inventory/$productId')({
validateSearch: (search: Record<string, unknown>) => ({
tab: (search.tab as string) || 'details',
}),
component: ProductDetailPage,
})
function ProductDetailPage() {
const { productId } = useParams({ from: '/_authenticated/inventory/$productId' })
const search = Route.useSearch()
const navigate = useNavigate()
const queryClient = useQueryClient()
const hasPermission = useAuthStore((s) => s.hasPermission)
const tab = search.tab ?? 'details'
const [addUnitOpen, setAddUnitOpen] = useState(false)
const [editUnit, setEditUnit] = useState<InventoryUnit | null>(null)
const [qtyEdit, setQtyEdit] = useState<string>('')
const [addSupplierOpen, setAddSupplierOpen] = useState(false)
const [editSupplier, setEditSupplier] = useState<ProductSupplier | null>(null)
const [addReceiptOpen, setAddReceiptOpen] = useState(false)
const { data: product, isLoading } = useQuery(productDetailOptions(productId))
const { data: unitsData } = useQuery({ ...unitListOptions(productId), enabled: tab === 'units' })
const units = unitsData?.data ?? []
const { data: suppliersData } = useQuery({ ...productSupplierListOptions(productId), enabled: tab === 'suppliers' })
const linkedSuppliers = suppliersData?.data ?? []
const { data: priceHistoryData } = useQuery({ ...priceHistoryOptions(productId), enabled: tab === 'price-history' })
const priceHistoryRows = priceHistoryData?.data ?? []
const { data: stockReceiptsData } = useQuery({ ...stockReceiptListOptions(productId), enabled: tab === 'stock-receipts' })
const stockReceiptRows = stockReceiptsData?.data ?? []
const updateMutation = useMutation({
mutationFn: (data: Record<string, unknown>) => productMutations.update(productId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: productKeys.detail(productId) })
queryClient.invalidateQueries({ queryKey: productKeys.all })
toast.success('Product updated')
},
onError: (err) => toast.error(err instanceof Error ? err.message : 'Update failed'),
})
const createUnitMutation = useMutation({
mutationFn: (data: Record<string, unknown>) => unitMutations.create(productId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: unitKeys.byProduct(productId) })
toast.success('Unit added')
setAddUnitOpen(false)
},
onError: (err) => toast.error(err.message),
})
const updateUnitMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
unitMutations.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: unitKeys.byProduct(productId) })
toast.success('Unit updated')
setEditUnit(null)
},
onError: (err) => toast.error(err.message),
})
const addSupplierMutation = useMutation({
mutationFn: (data: Record<string, unknown>) => productSupplierMutations.create(productId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: productSupplierKeys.byProduct(productId) })
toast.success('Supplier linked')
setAddSupplierOpen(false)
},
onError: (err) => toast.error(err instanceof Error ? err.message : 'Failed'),
})
const updateSupplierMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
productSupplierMutations.update(productId, id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: productSupplierKeys.byProduct(productId) })
toast.success('Supplier updated')
setEditSupplier(null)
},
onError: (err) => toast.error(err instanceof Error ? err.message : 'Failed'),
})
const removeSupplierMutation = useMutation({
mutationFn: (id: string) => productSupplierMutations.delete(productId, id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: productSupplierKeys.byProduct(productId) })
toast.success('Supplier removed')
setEditSupplier(null)
},
onError: (err) => toast.error(err instanceof Error ? err.message : 'Failed'),
})
const createReceiptMutation = useMutation({
mutationFn: (data: Record<string, unknown>) => stockReceiptMutations.create(productId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: stockReceiptKeys.byProduct(productId) })
queryClient.invalidateQueries({ queryKey: productKeys.detail(productId) })
toast.success('Stock receipt recorded')
setAddReceiptOpen(false)
},
onError: (err) => toast.error(err instanceof Error ? err.message : 'Failed'),
})
function setTab(t: string) {
navigate({ to: '/inventory/$productId', params: { productId }, search: { tab: t } as any })
}
function handleQtySave() {
const qty = parseInt(qtyEdit, 10)
if (isNaN(qty) || qty < 0) return
updateMutation.mutate({ qtyOnHand: qty })
}
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 (!product) return <p className="text-muted-foreground">Product not found</p>
const tabs = [
{ key: 'details', label: 'Details' },
{ key: 'units', label: product.isSerialized ? 'Units' : 'Quantity' },
{ key: 'suppliers', label: 'Suppliers' },
{ key: 'stock-receipts', label: 'Stock Receipts' },
{ key: 'price-history', label: 'Price History' },
]
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-3">
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/inventory', search: {} as any })}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h1 className="text-2xl font-bold">{product.name}</h1>
{product.sku && <span className="font-mono text-sm text-muted-foreground bg-muted px-2 py-0.5 rounded">{product.sku}</span>}
{product.isActive ? <Badge>Active</Badge> : <Badge variant="secondary">Inactive</Badge>}
</div>
<p className="text-sm text-muted-foreground mt-0.5">
{[product.brand, product.model].filter(Boolean).join(' · ') || 'No brand/model'}
</p>
</div>
</div>
{/* 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">
<ProductForm
defaultValues={product}
onSubmit={updateMutation.mutate}
loading={updateMutation.isPending}
/>
</div>
)}
{/* Units / Quantity tab */}
{tab === 'units' && (
<div className="space-y-4">
{product.isSerialized ? (
<>
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">{units.length} unit(s) on file</p>
{hasPermission('inventory.edit') && (
<Dialog open={addUnitOpen} onOpenChange={setAddUnitOpen}>
<DialogTrigger asChild>
<Button size="sm"><Plus className="mr-2 h-4 w-4" />Add Unit</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>Add Unit</DialogTitle></DialogHeader>
<InventoryUnitForm
onSubmit={createUnitMutation.mutate}
loading={createUnitMutation.isPending}
/>
</DialogContent>
</Dialog>
)}
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Serial #</TableHead>
<TableHead>Condition</TableHead>
<TableHead>Status</TableHead>
<TableHead>Purchased</TableHead>
<TableHead>Cost</TableHead>
<TableHead>Notes</TableHead>
{hasPermission('inventory.edit') && <TableHead className="w-10" />}
</TableRow>
</TableHeader>
<TableBody>
{units.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">
No units yet add the first unit above
</TableCell>
</TableRow>
) : (
units.map((u) => (
<TableRow key={u.id}>
<TableCell className="font-mono text-sm">{u.serialNumber ?? '—'}</TableCell>
<TableCell>
<span className={cn('inline-flex items-center px-2 py-0.5 rounded text-xs font-medium', CONDITION_CLASSES[u.condition])}>
{u.condition}
</span>
</TableCell>
<TableCell>
<span className={cn('inline-flex items-center px-2 py-0.5 rounded text-xs font-medium', STATUS_CLASSES[u.status])}>
{STATUS_LABELS[u.status]}
</span>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{u.purchaseDate ? new Date(u.purchaseDate + 'T00:00:00').toLocaleDateString() : '—'}
</TableCell>
<TableCell className="text-sm">
{u.purchaseCost ? `$${Number(u.purchaseCost).toFixed(2)}` : '—'}
</TableCell>
<TableCell className="text-sm text-muted-foreground max-w-[200px] truncate">
{u.notes ?? '—'}
</TableCell>
{hasPermission('inventory.edit') && (
<TableCell>
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => setEditUnit(u)}>
<Pencil className="h-3 w-3" />
</Button>
</TableCell>
)}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</>
) : (
<div className="max-w-xs space-y-4">
<p className="text-sm text-muted-foreground">
This product is not serialized track quantity as a single number.
</p>
<div className="space-y-2">
<Label htmlFor="qty-edit">Quantity On Hand</Label>
<div className="flex gap-2">
<Input
id="qty-edit"
type="number"
min={0}
defaultValue={product.qtyOnHand}
onChange={(e) => setQtyEdit(e.target.value)}
className="w-32"
/>
<Button onClick={handleQtySave} disabled={updateMutation.isPending}>
{updateMutation.isPending ? 'Saving...' : 'Save'}
</Button>
</div>
</div>
{product.qtyReorderPoint !== null && (
<p className="text-xs text-muted-foreground">Reorder point: {product.qtyReorderPoint}</p>
)}
</div>
)}
</div>
)}
{/* Suppliers tab */}
{tab === 'suppliers' && (
<SuppliersTab
productId={productId}
linkedSuppliers={linkedSuppliers}
addOpen={addSupplierOpen}
setAddOpen={setAddSupplierOpen}
editTarget={editSupplier}
setEditTarget={setEditSupplier}
addMutation={addSupplierMutation}
updateMutation={updateSupplierMutation}
removeMutation={removeSupplierMutation}
canEdit={hasPermission('inventory.edit')}
/>
)}
{/* Stock Receipts tab */}
{tab === 'stock-receipts' && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">{stockReceiptRows.length} receipt(s) on record</p>
{hasPermission('inventory.edit') && (
<Dialog open={addReceiptOpen} onOpenChange={setAddReceiptOpen}>
<DialogTrigger asChild>
<Button size="sm"><Plus className="h-4 w-4 mr-1" />Receive Stock</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>Receive Stock</DialogTitle></DialogHeader>
<StockReceiptForm
linkedSuppliers={linkedSuppliers}
loading={createReceiptMutation.isPending}
onSubmit={(data) => createReceiptMutation.mutate(data)}
/>
</DialogContent>
</Dialog>
)}
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Date</TableHead>
<TableHead>Supplier</TableHead>
<TableHead>Invoice #</TableHead>
<TableHead className="text-right">Qty</TableHead>
<TableHead className="text-right">Cost / Unit</TableHead>
<TableHead className="text-right">Total Cost</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{stockReceiptRows.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center py-8 text-muted-foreground">
No stock receipts yet
</TableCell>
</TableRow>
) : (
stockReceiptRows.map((r) => (
<TableRow key={r.id}>
<TableCell className="text-sm text-muted-foreground">
{new Date(r.receivedDate + 'T12:00:00').toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })}
</TableCell>
<TableCell className="text-sm">{r.supplierName ?? <span className="text-muted-foreground"></span>}</TableCell>
<TableCell className="text-sm font-mono">{r.invoiceNumber ?? <span className="text-muted-foreground"></span>}</TableCell>
<TableCell className="text-sm text-right">{r.qty}</TableCell>
<TableCell className="text-sm text-right">${Number(r.costPerUnit).toFixed(2)}</TableCell>
<TableCell className="text-sm text-right font-medium">${Number(r.totalCost).toFixed(2)}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{stockReceiptRows.length > 0 && (
<p className="text-xs text-muted-foreground text-right">
Total invested: ${stockReceiptRows.reduce((sum, r) => sum + Number(r.totalCost), 0).toFixed(2)}
</p>
)}
</div>
)}
{/* Price History tab */}
{tab === 'price-history' && (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">{priceHistoryRows.length} price change(s) on record</p>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Date</TableHead>
<TableHead>Previous Price</TableHead>
<TableHead>New Price</TableHead>
<TableHead>Previous Min</TableHead>
<TableHead>New Min</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{priceHistoryRows.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center py-8 text-muted-foreground">
No price changes recorded yet
</TableCell>
</TableRow>
) : (
priceHistoryRows.map((h) => (
<TableRow key={h.id}>
<TableCell className="text-sm text-muted-foreground">
{new Date(h.createdAt).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })}
</TableCell>
<TableCell className="text-sm">
{h.previousPrice ? `$${Number(h.previousPrice).toFixed(2)}` : '—'}
</TableCell>
<TableCell className="text-sm font-medium">
${Number(h.newPrice).toFixed(2)}
</TableCell>
<TableCell className="text-sm">
{h.previousMinPrice ? `$${Number(h.previousMinPrice).toFixed(2)}` : '—'}
</TableCell>
<TableCell className="text-sm">
{h.newMinPrice ? `$${Number(h.newMinPrice).toFixed(2)}` : '—'}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
)}
{/* Edit unit dialog */}
<Dialog open={!!editUnit} onOpenChange={(o) => !o && setEditUnit(null)}>
<DialogContent>
<DialogHeader><DialogTitle>Edit Unit</DialogTitle></DialogHeader>
{editUnit && (
<InventoryUnitForm
defaultValues={editUnit}
onSubmit={(data) => updateUnitMutation.mutate({ id: editUnit.id, data })}
loading={updateUnitMutation.isPending}
/>
)}
</DialogContent>
</Dialog>
</div>
)
}
// ── Suppliers tab ─────────────────────────────────────────────────────────────
function SuppliersTab({
productId: _productId,
linkedSuppliers,
addOpen, setAddOpen,
editTarget, setEditTarget,
addMutation, updateMutation, removeMutation,
canEdit,
}: {
productId: string
linkedSuppliers: ProductSupplier[]
addOpen: boolean
setAddOpen: (v: boolean) => void
editTarget: ProductSupplier | null
setEditTarget: (v: ProductSupplier | null) => void
addMutation: any
updateMutation: any
removeMutation: any
canEdit: boolean
}) {
const { data: allSuppliersData } = useQuery(supplierListOptions({ page: 1, limit: 500, order: 'asc', sort: 'name' } as any))
const allSuppliers = allSuppliersData?.data ?? []
const linkedIds = new Set(linkedSuppliers.map((s) => s.supplierId))
const availableSuppliers = allSuppliers.filter((s) => !linkedIds.has(s.id))
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">{linkedSuppliers.length} supplier(s) linked</p>
{canEdit && availableSuppliers.length > 0 && (
<Dialog open={addOpen} onOpenChange={setAddOpen}>
<DialogTrigger asChild>
<Button size="sm"><Plus className="mr-2 h-4 w-4" />Link Supplier</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>Link Supplier</DialogTitle></DialogHeader>
<SupplierLinkForm
suppliers={availableSuppliers}
onSubmit={addMutation.mutate}
loading={addMutation.isPending}
hasExisting={linkedSuppliers.length > 0}
/>
</DialogContent>
</Dialog>
)}
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Supplier</TableHead>
<TableHead>Their SKU</TableHead>
<TableHead>Contact</TableHead>
<TableHead>Terms</TableHead>
<TableHead>Preferred</TableHead>
{canEdit && <TableHead className="w-10" />}
</TableRow>
</TableHeader>
<TableBody>
{linkedSuppliers.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center py-8 text-muted-foreground">
No suppliers linked add the first supplier above
</TableCell>
</TableRow>
) : (
linkedSuppliers.map((s) => (
<TableRow key={s.id}>
<TableCell>
<div>
<p className="font-medium text-sm">{s.supplierName}</p>
{s.supplierEmail && (
<a href={`mailto:${s.supplierEmail}`} className="text-xs text-muted-foreground hover:underline" onClick={(e) => e.stopPropagation()}>
{s.supplierEmail}
</a>
)}
</div>
</TableCell>
<TableCell className="font-mono text-sm">{s.supplierSku ?? <span className="text-muted-foreground"></span>}</TableCell>
<TableCell className="text-sm text-muted-foreground">{s.supplierContactName ?? '—'}</TableCell>
<TableCell className="text-sm text-muted-foreground">{s.supplierPaymentTerms ?? '—'}</TableCell>
<TableCell>
{s.isPreferred && (
<span className="inline-flex items-center gap-1 text-xs font-medium text-amber-700 bg-amber-50 border border-amber-200 px-2 py-0.5 rounded">
<Star className="h-3 w-3 fill-amber-500 text-amber-500" />Preferred
</span>
)}
</TableCell>
{canEdit && (
<TableCell>
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => setEditTarget(s)}>
<Pencil className="h-3 w-3" />
</Button>
</TableCell>
)}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* Edit/remove supplier link dialog */}
<Dialog open={!!editTarget} onOpenChange={(o) => !o && setEditTarget(null)}>
<DialogContent>
<DialogHeader><DialogTitle>Edit Supplier Link</DialogTitle></DialogHeader>
{editTarget && (
<div className="space-y-4">
<p className="text-sm font-medium">{editTarget.supplierName}</p>
<SupplierLinkEditForm
defaultValues={editTarget}
onSubmit={(data) => updateMutation.mutate({ id: editTarget.id, data })}
loading={updateMutation.isPending}
/>
<div className="border-t pt-4">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" size="sm" disabled={removeMutation.isPending}>
<Trash2 className="h-4 w-4 mr-2" />Remove Supplier
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove supplier?</AlertDialogTitle>
<AlertDialogDescription>
This will unlink {editTarget.supplierName} from this product. You can re-add it later.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={() => removeMutation.mutate(editTarget.id)}>Remove</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
)}
</DialogContent>
</Dialog>
</div>
)
}
function SupplierLinkForm({
suppliers, onSubmit, loading, hasExisting,
}: {
suppliers: { id: string; name: string }[]
onSubmit: (data: Record<string, unknown>) => void
loading: boolean
hasExisting: boolean
}) {
const [supplierId, setSupplierId] = useState('')
const [supplierSku, setSupplierSku] = useState('')
const [isPreferred, setIsPreferred] = useState(!hasExisting)
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!supplierId) return
onSubmit({ supplierId, supplierSku: supplierSku || undefined, isPreferred })
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label>Supplier</Label>
<Select value={supplierId} onValueChange={setSupplierId}>
<SelectTrigger><SelectValue placeholder="Select supplier…" /></SelectTrigger>
<SelectContent>
{suppliers.map((s) => <SelectItem key={s.id} value={s.id}>{s.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="supplier-sku">Their SKU / Part #</Label>
<Input id="supplier-sku" value={supplierSku} onChange={(e) => setSupplierSku(e.target.value)} placeholder="e.g. VLN-44-EA" />
</div>
<div className="flex items-center gap-2">
<Checkbox id="is-preferred" checked={isPreferred} onCheckedChange={(v: boolean | 'indeterminate') => setIsPreferred(!!v)} />
<Label htmlFor="is-preferred">Preferred supplier for this product</Label>
</div>
<Button type="submit" disabled={!supplierId || loading}>{loading ? 'Linking…' : 'Link Supplier'}</Button>
</form>
)
}
function StockReceiptForm({
linkedSuppliers, onSubmit, loading,
}: {
linkedSuppliers: ProductSupplier[]
onSubmit: (data: Record<string, unknown>) => void
loading: boolean
}) {
const today = new Date().toISOString().split('T')[0]
const [supplierId, setSupplierId] = useState('')
const [qty, setQty] = useState('1')
const [costPerUnit, setCostPerUnit] = useState('')
const [receivedDate, setReceivedDate] = useState(today)
const [invoiceNumber, setInvoiceNumber] = useState('')
const [notes, setNotes] = useState('')
const total = (parseFloat(qty) || 0) * (parseFloat(costPerUnit) || 0)
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
onSubmit({
supplierId: supplierId || undefined,
qty: parseInt(qty, 10),
costPerUnit: parseFloat(costPerUnit),
receivedDate,
invoiceNumber: invoiceNumber || undefined,
notes: notes || undefined,
})
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label>Supplier</Label>
<Select value={supplierId || 'none'} onValueChange={(v) => setSupplierId(v === 'none' ? '' : v)}>
<SelectTrigger><SelectValue placeholder="Select supplier…" /></SelectTrigger>
<SelectContent>
<SelectItem value="none">No supplier</SelectItem>
{linkedSuppliers.map((s) => (
<SelectItem key={s.supplierId} value={s.supplierId}>{s.supplierName}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="rcpt-qty">Qty Received *</Label>
<Input id="rcpt-qty" type="number" min="1" value={qty} onChange={(e) => setQty(e.target.value)} required />
</div>
<div className="space-y-2">
<Label htmlFor="rcpt-cost">Cost / Unit *</Label>
<Input id="rcpt-cost" type="number" step="0.01" min="0" value={costPerUnit} onChange={(e) => setCostPerUnit(e.target.value)} placeholder="0.00" required />
</div>
</div>
{costPerUnit && qty && (
<p className="text-sm text-muted-foreground">Total cost: <span className="font-medium text-foreground">${total.toFixed(2)}</span></p>
)}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="rcpt-date">Received Date *</Label>
<Input id="rcpt-date" type="date" value={receivedDate} onChange={(e) => setReceivedDate(e.target.value)} required />
</div>
<div className="space-y-2">
<Label htmlFor="rcpt-inv">Invoice #</Label>
<Input id="rcpt-inv" value={invoiceNumber} onChange={(e) => setInvoiceNumber(e.target.value)} placeholder="INV-001" />
</div>
</div>
<div className="space-y-2">
<Label htmlFor="rcpt-notes">Notes</Label>
<Input id="rcpt-notes" value={notes} onChange={(e) => setNotes(e.target.value)} placeholder="Optional notes" />
</div>
<Button type="submit" disabled={loading || !costPerUnit || !qty} className="w-full">
{loading ? 'Recording…' : 'Record Receipt'}
</Button>
</form>
)
}
function SupplierLinkEditForm({
defaultValues, onSubmit, loading,
}: {
defaultValues: ProductSupplier
onSubmit: (data: Record<string, unknown>) => void
loading: boolean
}) {
const [supplierSku, setSupplierSku] = useState(defaultValues.supplierSku ?? '')
const [isPreferred, setIsPreferred] = useState(defaultValues.isPreferred)
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
onSubmit({ supplierSku: supplierSku || undefined, isPreferred })
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="edit-sku">Their SKU / Part #</Label>
<Input id="edit-sku" value={supplierSku} onChange={(e) => setSupplierSku(e.target.value)} placeholder="e.g. VLN-44-EA" />
</div>
<div className="flex items-center gap-2">
<Checkbox id="edit-preferred" checked={isPreferred} onCheckedChange={(v: boolean | 'indeterminate') => setIsPreferred(!!v)} />
<Label htmlFor="edit-preferred">Preferred supplier for this product</Label>
</div>
<Button type="submit" disabled={loading}>{loading ? 'Saving…' : 'Save Changes'}</Button>
</form>
)
}

View File

@@ -0,0 +1,170 @@
import { useState } from 'react'
import { createFileRoute } from '@tanstack/react-router'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { categoryListOptions, categoryMutations, categoryKeys } from '@/api/inventory'
import { CategoryForm } from '@/components/inventory/category-form'
import { usePagination } from '@/hooks/use-pagination'
import { DataTable, type Column } from '@/components/shared/data-table'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Search, Plus } from 'lucide-react'
import { toast } from 'sonner'
import { useAuthStore } from '@/stores/auth.store'
import type { Category } from '@/types/inventory'
export const Route = createFileRoute('/_authenticated/inventory/categories')({
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: CategoriesPage,
})
function CategoriesPage() {
const queryClient = useQueryClient()
const hasPermission = useAuthStore((s) => s.hasPermission)
const { params, setPage, setSearch, setSort } = usePagination()
const [searchInput, setSearchInput] = useState(params.q ?? '')
const [createOpen, setCreateOpen] = useState(false)
const [editTarget, setEditTarget] = useState<Category | null>(null)
const { data, isLoading } = useQuery(categoryListOptions(params))
const createMutation = useMutation({
mutationFn: categoryMutations.create,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: categoryKeys.all })
toast.success('Category created')
setCreateOpen(false)
},
onError: (err) => toast.error(err.message),
})
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
categoryMutations.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: categoryKeys.all })
toast.success('Category updated')
setEditTarget(null)
},
onError: (err) => toast.error(err.message),
})
const deleteMutation = useMutation({
mutationFn: categoryMutations.delete,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: categoryKeys.all })
toast.success('Category deleted')
setEditTarget(null)
},
onError: (err) => toast.error(err.message),
})
function handleSearchSubmit(e: React.FormEvent) {
e.preventDefault()
setSearch(searchInput)
}
const columns: Column<Category>[] = [
{
key: 'name',
header: 'Name',
sortable: true,
render: (c) => <span className="font-medium">{c.name}</span>,
},
{
key: 'sort_order',
header: 'Order',
sortable: true,
render: (c) => <span className="text-muted-foreground text-sm">{c.sortOrder}</span>,
},
{
key: 'is_active',
header: 'Status',
sortable: true,
render: (c) => c.isActive
? <Badge>Active</Badge>
: <Badge variant="secondary">Inactive</Badge>,
},
{
key: 'actions',
header: '',
render: (c) => hasPermission('inventory.edit') ? (
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); setEditTarget(c) }}>
Edit
</Button>
) : null,
},
]
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Categories</h1>
{hasPermission('inventory.edit') && (
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogTrigger asChild>
<Button><Plus className="mr-2 h-4 w-4" />New Category</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>New Category</DialogTitle></DialogHeader>
<CategoryForm
onSubmit={createMutation.mutate}
loading={createMutation.isPending}
/>
</DialogContent>
</Dialog>
)}
</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 categories..."
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={hasPermission('inventory.edit') ? setEditTarget : undefined}
/>
{/* Edit dialog */}
<Dialog open={!!editTarget} onOpenChange={(o) => !o && setEditTarget(null)}>
<DialogContent>
<DialogHeader><DialogTitle>Edit Category</DialogTitle></DialogHeader>
{editTarget && (
<CategoryForm
defaultValues={editTarget}
onSubmit={(data) => updateMutation.mutate({ id: editTarget.id, data })}
loading={updateMutation.isPending}
onDelete={hasPermission('inventory.admin') ? () => deleteMutation.mutate(editTarget.id) : undefined}
deleteLoading={deleteMutation.isPending}
/>
)}
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -0,0 +1,253 @@
import { useState } from 'react'
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { productListOptions, productMutations, productKeys, categoryAllOptions } from '@/api/inventory'
import { ProductForm } from '@/components/inventory/product-form'
import { usePagination } from '@/hooks/use-pagination'
import { DataTable, type Column } from '@/components/shared/data-table'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import { Search, Plus } from 'lucide-react'
import { toast } from 'sonner'
import { useAuthStore } from '@/stores/auth.store'
import type { Product } from '@/types/inventory'
export const Route = createFileRoute('/_authenticated/inventory/')({
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',
categoryId: (search.categoryId as string) || undefined,
isActive: (search.isActive as string) || undefined,
type: (search.type as string) || undefined,
lowStock: (search.lowStock as string) || undefined,
}),
component: InventoryPage,
})
function qtyBadge(qty: number, reorderPoint: number | null) {
if (qty === 0) return <Badge variant="destructive">{qty}</Badge>
if (reorderPoint !== null && qty <= reorderPoint)
return <Badge variant="secondary" className="bg-amber-100 text-amber-800 border-amber-300">{qty} Low</Badge>
return <span className="text-sm">{qty}</span>
}
function InventoryPage() {
const navigate = useNavigate()
const queryClient = useQueryClient()
const hasPermission = useAuthStore((s) => s.hasPermission)
const { params, setPage, setSearch, setSort } = usePagination()
const [searchInput, setSearchInput] = useState(params.q ?? '')
const [createOpen, setCreateOpen] = useState(false)
const search = Route.useSearch()
const [categoryFilter, setCategoryFilter] = useState(search.categoryId ?? '')
const [activeFilter, setActiveFilter] = useState(search.isActive ?? '')
const [typeFilter, setTypeFilter] = useState(search.type ?? '')
const [lowStockFilter, setLowStockFilter] = useState(search.lowStock === 'true')
const { data: categoriesData } = useQuery(categoryAllOptions())
const categories = categoriesData?.data ?? []
const categoryMap = new Map(categories.map((c) => [c.id, c.name]))
const queryParams: Record<string, unknown> = { ...params }
if (categoryFilter) queryParams.categoryId = categoryFilter
if (activeFilter) queryParams.isActive = activeFilter === 'true'
if (typeFilter === 'serialized') queryParams.isSerialized = true
if (typeFilter === 'rental') queryParams.isRental = true
if (typeFilter === 'repair') queryParams.isDualUseRepair = true
if (lowStockFilter) queryParams.lowStock = true
const { data, isLoading } = useQuery(productListOptions(queryParams))
const createMutation = useMutation({
mutationFn: productMutations.create,
onSuccess: (product) => {
queryClient.invalidateQueries({ queryKey: productKeys.all })
toast.success('Product created')
setCreateOpen(false)
navigate({ to: '/inventory/$productId', params: { productId: product.id }, search: {} as any })
},
onError: (err) => toast.error(err.message),
})
function handleSearchSubmit(e: React.FormEvent) {
e.preventDefault()
setSearch(searchInput)
}
function handleCategoryChange(v: string) {
setCategoryFilter(v === 'all' ? '' : v)
navigate({ to: '/inventory', search: { ...search, categoryId: v === 'all' ? undefined : v, page: 1 } as any })
}
function handleActiveChange(v: string) {
setActiveFilter(v === 'all' ? '' : v)
navigate({ to: '/inventory', search: { ...search, isActive: v === 'all' ? undefined : v, page: 1 } as any })
}
function handleTypeChange(v: string) {
setTypeFilter(v === 'all' ? '' : v)
navigate({ to: '/inventory', search: { ...search, type: v === 'all' ? undefined : v, page: 1 } as any })
}
function handleLowStockChange(v: string) {
const on = v === 'true'
setLowStockFilter(on)
navigate({ to: '/inventory', search: { ...search, lowStock: on ? 'true' : undefined, page: 1 } as any })
}
const columns: Column<Product>[] = [
{
key: 'name',
header: 'Name',
sortable: true,
render: (p) => <span className="font-medium">{p.name}</span>,
},
{
key: 'sku',
header: 'SKU',
sortable: true,
render: (p) => p.sku
? <span className="font-mono text-sm">{p.sku}</span>
: <span className="text-muted-foreground"></span>,
},
{
key: 'brand',
header: 'Brand',
sortable: true,
render: (p) => p.brand ?? <span className="text-muted-foreground"></span>,
},
{
key: 'category',
header: 'Category',
render: (p) => p.categoryId
? (categoryMap.get(p.categoryId) ?? <span className="text-muted-foreground"></span>)
: <span className="text-muted-foreground"></span>,
},
{
key: 'price',
header: 'Price',
sortable: true,
render: (p) => p.price ? `$${Number(p.price).toFixed(2)}` : <span className="text-muted-foreground"></span>,
},
{
key: 'qty_on_hand',
header: 'Qty',
sortable: true,
render: (p) => qtyBadge(p.qtyOnHand, p.qtyReorderPoint),
},
{
key: 'flags',
header: 'Type',
render: (p) => (
<div className="flex gap-1">
{p.isSerialized && <Badge variant="outline" className="text-xs">Serial</Badge>}
{p.isRental && <Badge variant="outline" className="text-xs">Rental</Badge>}
{p.isDualUseRepair && <Badge variant="outline" className="text-xs">Repair</Badge>}
</div>
),
},
{
key: 'is_active',
header: 'Status',
sortable: true,
render: (p) => p.isActive
? <Badge>Active</Badge>
: <Badge variant="secondary">Inactive</Badge>,
},
]
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Products</h1>
{hasPermission('inventory.edit') && (
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogTrigger asChild>
<Button><Plus className="mr-2 h-4 w-4" />New Product</Button>
</DialogTrigger>
<DialogContent className="max-w-xl max-h-[90vh] overflow-y-auto">
<DialogHeader><DialogTitle>New Product</DialogTitle></DialogHeader>
<ProductForm onSubmit={createMutation.mutate} loading={createMutation.isPending} />
</DialogContent>
</Dialog>
)}
</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 products..."
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
className="pl-9 w-64"
/>
</div>
<Button type="submit" variant="secondary">Search</Button>
</form>
<Select value={categoryFilter || 'all'} onValueChange={handleCategoryChange}>
<SelectTrigger className="w-44">
<SelectValue placeholder="All Categories" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Categories</SelectItem>
{categories.filter((c) => c.isActive).map((c) => (
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={typeFilter || 'all'} onValueChange={handleTypeChange}>
<SelectTrigger className="w-36">
<SelectValue placeholder="All Types" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Types</SelectItem>
<SelectItem value="serialized">Serialized</SelectItem>
<SelectItem value="rental">Rental</SelectItem>
<SelectItem value="repair">Repair Parts</SelectItem>
</SelectContent>
</Select>
<Select value={lowStockFilter ? 'true' : 'all'} onValueChange={handleLowStockChange}>
<SelectTrigger className="w-36">
<SelectValue placeholder="All Stock" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Stock</SelectItem>
<SelectItem value="true">Low / Out of Stock</SelectItem>
</SelectContent>
</Select>
<Select value={activeFilter || 'all'} onValueChange={handleActiveChange}>
<SelectTrigger className="w-32">
<SelectValue placeholder="Active" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Active</SelectItem>
<SelectItem value="false">Inactive</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={(p) => navigate({ to: '/inventory/$productId', params: { productId: p.id }, search: {} as any })}
/>
</div>
)
}

View File

@@ -0,0 +1,185 @@
import { useState } from 'react'
import { createFileRoute } from '@tanstack/react-router'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { supplierListOptions, supplierMutations, supplierKeys } from '@/api/inventory'
import { SupplierForm } from '@/components/inventory/supplier-form'
import { usePagination } from '@/hooks/use-pagination'
import { DataTable, type Column } from '@/components/shared/data-table'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import { Search, Plus } from 'lucide-react'
import { toast } from 'sonner'
import { useAuthStore } from '@/stores/auth.store'
import type { Supplier } from '@/types/inventory'
export const Route = createFileRoute('/_authenticated/inventory/suppliers/')({
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: SuppliersPage,
})
function SuppliersPage() {
const queryClient = useQueryClient()
const hasPermission = useAuthStore((s) => s.hasPermission)
const { params, setPage, setSearch, setSort } = usePagination()
const [searchInput, setSearchInput] = useState(params.q ?? '')
const [createOpen, setCreateOpen] = useState(false)
const [editTarget, setEditTarget] = useState<Supplier | null>(null)
const { data, isLoading } = useQuery(supplierListOptions(params))
const createMutation = useMutation({
mutationFn: supplierMutations.create,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: supplierKeys.all })
toast.success('Supplier created')
setCreateOpen(false)
},
onError: (err) => toast.error(err.message),
})
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
supplierMutations.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: supplierKeys.all })
toast.success('Supplier updated')
setEditTarget(null)
},
onError: (err) => toast.error(err.message),
})
const deleteMutation = useMutation({
mutationFn: supplierMutations.delete,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: supplierKeys.all })
toast.success('Supplier deleted')
setEditTarget(null)
},
onError: (err) => toast.error(err.message),
})
function handleSearchSubmit(e: React.FormEvent) {
e.preventDefault()
setSearch(searchInput)
}
const columns: Column<Supplier>[] = [
{
key: 'name',
header: 'Name',
sortable: true,
render: (s) => <span className="font-medium">{s.name}</span>,
},
{
key: 'contact_name',
header: 'Contact',
render: (s) => s.contactName ?? <span className="text-muted-foreground"></span>,
},
{
key: 'email',
header: 'Email',
sortable: true,
render: (s) => s.email
? <a href={`mailto:${s.email}`} className="hover:underline" onClick={(e) => e.stopPropagation()}>{s.email}</a>
: <span className="text-muted-foreground"></span>,
},
{
key: 'phone',
header: 'Phone',
render: (s) => s.phone ?? <span className="text-muted-foreground"></span>,
},
{
key: 'account_number',
header: 'Account #',
render: (s) => s.accountNumber
? <span className="font-mono text-sm">{s.accountNumber}</span>
: <span className="text-muted-foreground"></span>,
},
{
key: 'payment_terms',
header: 'Terms',
render: (s) => s.paymentTerms ?? <span className="text-muted-foreground"></span>,
},
{
key: 'actions',
header: '',
render: (s) => hasPermission('inventory.edit') ? (
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); setEditTarget(s) }}>
Edit
</Button>
) : null,
},
]
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Suppliers</h1>
{hasPermission('inventory.edit') && (
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogTrigger asChild>
<Button><Plus className="mr-2 h-4 w-4" />New Supplier</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>New Supplier</DialogTitle></DialogHeader>
<SupplierForm
onSubmit={supplierMutations.create.bind(null) as any}
loading={createMutation.isPending}
/>
</DialogContent>
</Dialog>
)}
</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 suppliers..."
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={hasPermission('inventory.edit') ? setEditTarget : undefined}
/>
{/* Edit dialog */}
<Dialog open={!!editTarget} onOpenChange={(o) => !o && setEditTarget(null)}>
<DialogContent>
<DialogHeader><DialogTitle>Edit Supplier</DialogTitle></DialogHeader>
{editTarget && (
<SupplierForm
defaultValues={editTarget}
onSubmit={(data) => updateMutation.mutate({ id: editTarget.id, data })}
loading={updateMutation.isPending}
onDelete={hasPermission('inventory.admin') ? () => deleteMutation.mutate(editTarget.id) : undefined}
deleteLoading={deleteMutation.isPending}
/>
)}
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -0,0 +1,450 @@
import { useState } from 'react'
import { createFileRoute, useNavigate } 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: _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: _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: _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,
})
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,277 @@
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 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,25 @@
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 type { Member, MemberIdentifier } from '@/types/account'
import { useState } from 'react'
import { queryOptions } from '@tanstack/react-query'
import { cn } from '@/lib/utils'
import type { Member } from '@/types/account'
import type { Enrollment } from '@/types/lesson'
function memberDetailOptions(id: string) {
return queryOptions({
@@ -26,9 +29,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 +45,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 +63,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 +118,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 +140,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 +160,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 +196,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 +237,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 +274,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 +284,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

@@ -1,13 +1,13 @@
import { createFileRoute, useParams, useNavigate } from '@tanstack/react-router'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { repairBatchDetailOptions, repairBatchMutations, repairBatchKeys, repairBatchTicketsOptions, repairLineItemListOptions } from '@/api/repairs'
import { repairBatchDetailOptions, repairBatchMutations, repairBatchKeys, repairBatchTicketsOptions } from '@/api/repairs'
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 { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { ArrowLeft, Check, X, Plus, FileText, Download } from 'lucide-react'
import { ArrowLeft, Check, X, Plus, FileText } from 'lucide-react'
import { BatchStatusProgress } from '@/components/repairs/batch-status-progress'
import { toast } from 'sonner'
import { useAuthStore } from '@/stores/auth.store'
@@ -34,7 +34,7 @@ const STATUS_LABELS: Record<string, string> = {
const ticketColumns: Column<RepairTicket>[] = [
{ key: 'ticket_number', header: 'Ticket #', sortable: true, render: (t) => <span className="font-mono text-sm">{t.ticketNumber}</span> },
{ key: 'instrument', header: 'Instrument', render: (t) => <>{t.instrumentDescription ?? '-'}</> },
{ key: 'item_description', header: 'Item', render: (t) => <>{t.itemDescription ?? '-'}</> },
{ key: 'problem', header: 'Problem', render: (t) => <span className="truncate max-w-[200px] block">{t.problemDescription}</span> },
{ key: 'status', header: 'Status', sortable: true, render: (t) => <Badge variant="outline">{STATUS_LABELS[t.status] ?? t.status}</Badge> },
{
@@ -111,7 +111,7 @@ function RepairBatchDetailPage() {
doc.setFontSize(18)
doc.setFont('helvetica', 'bold')
doc.text('Forte Music', 14, y)
doc.text('LunarFront', 14, y)
y += 8
doc.setFontSize(12)
doc.setFont('helvetica', 'normal')
@@ -169,7 +169,7 @@ function RepairBatchDetailPage() {
doc.setFillColor(245, 245, 245)
doc.rect(14, y - 3, 182, 6, 'F')
doc.text('Ticket #', 16, y)
doc.text('Instrument', 40, y)
doc.text('Item', 40, y)
doc.text('Problem', 100, y)
doc.text('Status', 155, y)
doc.text('Estimate', 190, y, { align: 'right' })
@@ -179,7 +179,7 @@ function RepairBatchDetailPage() {
for (const ticket of tickets) {
if (y > 270) { doc.addPage(); y = 20 }
doc.text(ticket.ticketNumber ?? '-', 16, y)
doc.text((ticket.instrumentDescription ?? '-').slice(0, 30), 40, y)
doc.text((ticket.itemDescription ?? '-').slice(0, 30), 40, y)
doc.text(ticket.problemDescription.slice(0, 28), 100, y)
doc.text(STATUS_LABELS[ticket.status] ?? ticket.status, 155, y)
doc.text(ticket.estimatedCost ? `$${ticket.estimatedCost}` : '-', 190, y, { align: 'right' })

View File

@@ -49,9 +49,9 @@ const columns: Column<RepairBatch>[] = [
},
},
{
key: 'instruments',
header: 'Instruments',
render: (b) => <>{b.receivedCount}/{b.instrumentCount}</>,
key: 'items',
header: 'Items',
render: (b) => <>{b.receivedCount}/{b.itemCount}</>,
},
{
key: 'due_date',

Some files were not shown because too many files have changed in this diff Show More