Compare commits
55 Commits
refactor/r
...
feat/ci-cd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ef7f1977c | ||
|
|
bc2f39c208 | ||
|
|
41037af4f6 | ||
|
|
77e155b8c3 | ||
|
|
c01d19215d | ||
|
|
744256ae9f | ||
|
|
5993f8b370 | ||
|
|
4c971f90eb | ||
|
|
05f926c0dc | ||
|
|
a73c2de26e | ||
|
|
0f8aff9426 | ||
|
|
c3de66e554 | ||
|
|
7987818ae7 | ||
|
|
c2b1073fef | ||
|
|
ffef4c8727 | ||
|
|
d18d431bd0 | ||
|
|
41b6f076cb | ||
|
|
fe3c7646d6 | ||
|
|
bde3ad64fd | ||
|
|
5f5ba9e4a2 | ||
|
|
ec09e319ed | ||
|
|
89b412374a | ||
|
|
07f199b69d | ||
|
|
ae3c85fee0 | ||
|
|
5ad27bc196 | ||
|
|
7680a73d88 | ||
|
|
2cc8f24535 | ||
|
|
5cd2d05983 | ||
|
|
aae5a022a8 | ||
|
|
31f661ff4f | ||
|
|
73360cd478 | ||
|
|
93405af3b2 | ||
|
|
f777ce5184 | ||
|
|
5dbe837c08 | ||
|
|
145eb0efce | ||
|
|
7176c1471e | ||
|
|
701e15ea6d | ||
|
|
9400828f62 | ||
|
|
535446696c | ||
|
|
a51f1f5141 | ||
|
|
4438188362 | ||
|
|
1510133074 | ||
|
|
328b4a1f7b | ||
|
|
e346e072b8 | ||
|
|
1f9297f533 | ||
|
|
7246587955 | ||
|
|
748ea59c80 | ||
|
|
f998b16a3f | ||
|
|
51ca2ca683 | ||
|
|
cbbf2713a1 | ||
|
|
1002117610 | ||
|
|
f9bf1c9bff | ||
|
|
8d75586f8b | ||
|
|
653fff6ce2 | ||
|
|
0f6cc104d2 |
11
.dockerignore
Normal file
11
.dockerignore
Normal file
@@ -0,0 +1,11 @@
|
||||
node_modules
|
||||
.git
|
||||
.gitea
|
||||
docs
|
||||
planning
|
||||
deploy
|
||||
infra
|
||||
packages/admin
|
||||
Dockerfile*
|
||||
docker-compose*
|
||||
*.md
|
||||
@@ -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
|
||||
|
||||
99
.gitea/workflows/build.yml
Normal file
99
.gitea/workflows/build.yml
Normal 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
70
.gitea/workflows/ci.yml
Normal 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
7
.gitignore
vendored
@@ -17,6 +17,13 @@ out/
|
||||
*.o
|
||||
*.pyc
|
||||
__pycache__/
|
||||
*.tsbuildinfo
|
||||
|
||||
# Turbo cache
|
||||
.turbo/
|
||||
|
||||
# Infra (moved to lunarfront-infra repo)
|
||||
infra/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
|
||||
21
CLAUDE.md
21
CLAUDE.md
@@ -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
32
Dockerfile
Normal 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
24
Dockerfile.frontend
Normal 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;"]
|
||||
11
Dockerfile.frontend.dockerignore
Normal file
11
Dockerfile.frontend.dockerignore
Normal file
@@ -0,0 +1,11 @@
|
||||
node_modules
|
||||
.git
|
||||
.gitea
|
||||
docs
|
||||
planning
|
||||
deploy
|
||||
infra
|
||||
packages/backend
|
||||
Dockerfile*
|
||||
docker-compose*
|
||||
*.md
|
||||
@@ -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
175
bun.lock
@@ -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
27
deploy/deploy.sh
Executable file
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env bash
|
||||
# LunarFront — Redeploy script (run after pushing changes to main)
|
||||
# Usage: sudo bash deploy/deploy.sh
|
||||
set -euo pipefail
|
||||
|
||||
APP_DIR="/opt/lunarfront"
|
||||
APP_USER="ubuntu"
|
||||
BUN_BIN="/home/${APP_USER}/.bun/bin/bun"
|
||||
|
||||
cd "$APP_DIR"
|
||||
|
||||
echo "==> Installing dependencies..."
|
||||
sudo -u "$APP_USER" "$BUN_BIN" install --frozen-lockfile
|
||||
|
||||
echo "==> Building admin frontend..."
|
||||
sudo -u "$APP_USER" bash -c "cd ${APP_DIR}/packages/admin && ${BUN_BIN} run build"
|
||||
|
||||
echo "==> Running migrations..."
|
||||
sudo -u "$APP_USER" bash -c \
|
||||
"cd ${APP_DIR}/packages/backend && ${BUN_BIN} x drizzle-kit migrate"
|
||||
|
||||
echo "==> Restarting backend..."
|
||||
sudo systemctl restart lunarfront
|
||||
|
||||
echo "==> Done! Checking status..."
|
||||
sleep 2
|
||||
sudo systemctl status lunarfront --no-pager
|
||||
18
deploy/lunarfront.service
Normal file
18
deploy/lunarfront.service
Normal file
@@ -0,0 +1,18 @@
|
||||
[Unit]
|
||||
Description=LunarFront API Server
|
||||
After=network.target postgresql.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=ubuntu
|
||||
WorkingDirectory=/opt/lunarfront/packages/backend
|
||||
EnvironmentFile=/opt/lunarfront/.env
|
||||
ExecStart=/home/ubuntu/.bun/bin/bun run src/main.ts
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=lunarfront
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
47
deploy/nginx.conf
Normal file
47
deploy/nginx.conf
Normal file
@@ -0,0 +1,47 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name YOUR_DOMAIN www.YOUR_DOMAIN;
|
||||
# Certbot will automatically add HTTPS redirect and SSL config below this line
|
||||
|
||||
root /opt/lunarfront/packages/admin/dist;
|
||||
index index.html;
|
||||
|
||||
# Proxy API requests to Bun backend
|
||||
location /v1/ {
|
||||
proxy_pass http://localhost:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 60s;
|
||||
client_max_body_size 20M;
|
||||
}
|
||||
|
||||
# WebDAV passthrough (all HTTP methods)
|
||||
location /webdav/ {
|
||||
proxy_pass http://localhost:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 120s;
|
||||
client_max_body_size 100M;
|
||||
}
|
||||
|
||||
# SPA fallback — serve index.html for all unmatched paths
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Cache hashed static assets aggressively
|
||||
location /assets/ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml image/svg+xml;
|
||||
}
|
||||
128
deploy/setup.sh
Executable file
128
deploy/setup.sh
Executable file
@@ -0,0 +1,128 @@
|
||||
#!/usr/bin/env bash
|
||||
# LunarFront — One-time EC2 provisioning script
|
||||
# Run as root (or with sudo) on a fresh Ubuntu 24.04 instance.
|
||||
# Usage: sudo bash deploy/setup.sh
|
||||
set -euo pipefail
|
||||
|
||||
REPO_URL="git@github.com:YOUR_ORG/YOUR_REPO.git"
|
||||
APP_DIR="/opt/lunarfront"
|
||||
APP_USER="ubuntu"
|
||||
DB_USER="lunarfront"
|
||||
DB_NAME="lunarfront"
|
||||
DB_PASS="$(openssl rand -hex 16)" # auto-generated; written to .env
|
||||
|
||||
# ── 1. System packages ────────────────────────────────────────────────────────
|
||||
echo "==> Updating system packages..."
|
||||
apt-get update -y && apt-get upgrade -y
|
||||
apt-get install -y curl git build-essential nginx certbot python3-certbot-nginx unzip
|
||||
|
||||
# ── 2. Bun runtime ────────────────────────────────────────────────────────────
|
||||
echo "==> Installing Bun..."
|
||||
sudo -u "$APP_USER" bash -c 'curl -fsSL https://bun.sh/install | bash'
|
||||
BUN_BIN="/home/${APP_USER}/.bun/bin/bun"
|
||||
|
||||
# ── 3. PostgreSQL 16 ──────────────────────────────────────────────────────────
|
||||
echo "==> Installing PostgreSQL 16..."
|
||||
apt-get install -y postgresql-16 postgresql-contrib-16
|
||||
systemctl enable --now postgresql
|
||||
|
||||
echo "==> Creating database user and database..."
|
||||
sudo -u postgres psql -tc "SELECT 1 FROM pg_roles WHERE rolname='${DB_USER}'" | grep -q 1 || \
|
||||
sudo -u postgres psql -c "CREATE USER ${DB_USER} WITH PASSWORD '${DB_PASS}';"
|
||||
sudo -u postgres psql -tc "SELECT 1 FROM pg_database WHERE datname='${DB_NAME}'" | grep -q 1 || \
|
||||
sudo -u postgres psql -c "CREATE DATABASE ${DB_NAME} OWNER ${DB_USER};"
|
||||
|
||||
# ── 4. Valkey 8 ───────────────────────────────────────────────────────────────
|
||||
echo "==> Installing Valkey..."
|
||||
# Try official Valkey apt repo first; fall back to Redis 7 if unavailable
|
||||
if curl -fsSL https://packages.valkey.io/ubuntu/gpg.asc 2>/dev/null | \
|
||||
gpg --dearmor -o /usr/share/keyrings/valkey.gpg; then
|
||||
echo "deb [signed-by=/usr/share/keyrings/valkey.gpg] https://packages.valkey.io/ubuntu noble main" \
|
||||
> /etc/apt/sources.list.d/valkey.list
|
||||
apt-get update -y && apt-get install -y valkey
|
||||
REDIS_SERVICE="valkey"
|
||||
else
|
||||
echo "Valkey repo unavailable, falling back to Redis 7..."
|
||||
apt-get install -y redis-server
|
||||
REDIS_SERVICE="redis-server"
|
||||
fi
|
||||
systemctl enable --now "$REDIS_SERVICE"
|
||||
|
||||
# ── 5. Clone repository ───────────────────────────────────────────────────────
|
||||
echo "==> Cloning repository to ${APP_DIR}..."
|
||||
if [ -d "$APP_DIR" ]; then
|
||||
echo " ${APP_DIR} already exists, skipping clone."
|
||||
else
|
||||
git clone "$REPO_URL" "$APP_DIR"
|
||||
chown -R "$APP_USER:$APP_USER" "$APP_DIR"
|
||||
fi
|
||||
cd "$APP_DIR"
|
||||
|
||||
# ── 6. Environment file ───────────────────────────────────────────────────────
|
||||
if [ ! -f "${APP_DIR}/.env" ]; then
|
||||
echo "==> Generating .env..."
|
||||
JWT_SECRET=$(openssl rand -hex 32)
|
||||
cat > "${APP_DIR}/.env" <<EOF
|
||||
DATABASE_URL=postgresql://${DB_USER}:${DB_PASS}@localhost:5432/${DB_NAME}
|
||||
REDIS_URL=redis://localhost:6379
|
||||
JWT_SECRET=${JWT_SECRET}
|
||||
PORT=8000
|
||||
HOST=0.0.0.0
|
||||
NODE_ENV=production
|
||||
CORS_ORIGINS=http://$(curl -s http://169.254.169.254/latest/meta-data/public-ipv4)
|
||||
STORAGE_LOCAL_PATH=${APP_DIR}/data/files
|
||||
EOF
|
||||
chown "$APP_USER:$APP_USER" "${APP_DIR}/.env"
|
||||
chmod 600 "${APP_DIR}/.env"
|
||||
echo " Generated JWT secret and wrote .env"
|
||||
echo " NOTE: Update CORS_ORIGINS once you have a domain."
|
||||
else
|
||||
echo " .env already exists, skipping generation."
|
||||
fi
|
||||
|
||||
# ── 7. Install dependencies + build frontend ──────────────────────────────────
|
||||
echo "==> Installing dependencies..."
|
||||
sudo -u "$APP_USER" "$BUN_BIN" install --frozen-lockfile
|
||||
|
||||
echo "==> Building admin frontend..."
|
||||
sudo -u "$APP_USER" bash -c "cd ${APP_DIR}/packages/admin && ${BUN_BIN} run build"
|
||||
|
||||
# ── 8. Run database migrations ────────────────────────────────────────────────
|
||||
echo "==> Running database migrations..."
|
||||
sudo -u "$APP_USER" bash -c \
|
||||
"cd ${APP_DIR}/packages/backend && ${BUN_BIN} x drizzle-kit migrate"
|
||||
|
||||
# ── 9. Create file storage directory ─────────────────────────────────────────
|
||||
mkdir -p "${APP_DIR}/data/files"
|
||||
chown -R "$APP_USER:$APP_USER" "${APP_DIR}/data"
|
||||
|
||||
# ── 10. Systemd service ───────────────────────────────────────────────────────
|
||||
echo "==> Installing systemd service..."
|
||||
# Substitute real Bun path into service file
|
||||
sed "s|/home/ubuntu/.bun/bin/bun|${BUN_BIN}|g" \
|
||||
"${APP_DIR}/deploy/lunarfront.service" > /etc/systemd/system/lunarfront.service
|
||||
systemctl daemon-reload
|
||||
systemctl enable lunarfront
|
||||
systemctl restart lunarfront
|
||||
|
||||
# ── 11. Nginx ─────────────────────────────────────────────────────────────────
|
||||
echo "==> Configuring Nginx..."
|
||||
cp "${APP_DIR}/deploy/nginx.conf" /etc/nginx/sites-available/lunarfront
|
||||
ln -sf /etc/nginx/sites-available/lunarfront /etc/nginx/sites-enabled/lunarfront
|
||||
rm -f /etc/nginx/sites-enabled/default
|
||||
nginx -t && systemctl reload nginx
|
||||
|
||||
echo ""
|
||||
echo "========================================================"
|
||||
echo " Setup complete!"
|
||||
echo ""
|
||||
echo " Next steps:"
|
||||
echo " 1. Verify .env has correct values:"
|
||||
echo " nano ${APP_DIR}/.env"
|
||||
echo " 2. Restart backend after editing .env:"
|
||||
echo " sudo systemctl restart lunarfront"
|
||||
echo " 3. Set up HTTPS (after pointing DNS to this IP):"
|
||||
echo " sudo certbot --nginx -d YOUR_DOMAIN -d www.YOUR_DOMAIN"
|
||||
echo " 4. Check logs:"
|
||||
echo " journalctl -u lunarfront -f"
|
||||
echo "========================================================"
|
||||
25
deploy/sync-and-deploy.sh
Executable file
25
deploy/sync-and-deploy.sh
Executable 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"
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
193
docs/deployment.md
Normal 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
|
||||
```
|
||||
@@ -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
|
||||
|
||||
@@ -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
29
nginx.conf
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"name": "forte",
|
||||
"name": "lunarfront",
|
||||
"private": true,
|
||||
"packageManager": "bun@1.3.11",
|
||||
"workspaces": ["packages/*"],
|
||||
"scripts": {
|
||||
"dev": "turbo run dev",
|
||||
|
||||
@@ -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>()
|
||||
@@ -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');
|
||||
})();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
170
packages/admin/src/api/inventory.ts
Normal file
170
packages/admin/src/api/inventory.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
341
packages/admin/src/api/lessons.ts
Normal file
341
packages/admin/src/api/lessons.ts
Normal file
@@ -0,0 +1,341 @@
|
||||
import { queryOptions } from '@tanstack/react-query'
|
||||
import { api } from '@/lib/api-client'
|
||||
import type {
|
||||
Instructor,
|
||||
InstructorBlockedDate,
|
||||
LessonType,
|
||||
ScheduleSlot,
|
||||
Enrollment,
|
||||
LessonSession,
|
||||
GradingScale,
|
||||
LessonPlan,
|
||||
LessonPlanItem,
|
||||
LessonPlanItemGradeHistory,
|
||||
LessonPlanTemplate,
|
||||
StoreClosure,
|
||||
SessionPlanItem,
|
||||
} from '@/types/lesson'
|
||||
import type { PaginatedResponse, PaginationInput } from '@lunarfront/shared/schemas'
|
||||
|
||||
// --- Instructors ---
|
||||
|
||||
export const instructorKeys = {
|
||||
all: ['instructors'] as const,
|
||||
list: (params: PaginationInput) => [...instructorKeys.all, 'list', params] as const,
|
||||
detail: (id: string) => [...instructorKeys.all, 'detail', id] as const,
|
||||
blockedDates: (id: string) => [...instructorKeys.all, id, 'blocked-dates'] as const,
|
||||
}
|
||||
|
||||
export function instructorListOptions(params: PaginationInput) {
|
||||
return queryOptions({
|
||||
queryKey: instructorKeys.list(params),
|
||||
queryFn: () => api.get<PaginatedResponse<Instructor>>('/v1/instructors', params),
|
||||
})
|
||||
}
|
||||
|
||||
export function instructorDetailOptions(id: string) {
|
||||
return queryOptions({
|
||||
queryKey: instructorKeys.detail(id),
|
||||
queryFn: () => api.get<Instructor>(`/v1/instructors/${id}`),
|
||||
enabled: !!id,
|
||||
})
|
||||
}
|
||||
|
||||
export function instructorBlockedDatesOptions(instructorId: string) {
|
||||
return queryOptions({
|
||||
queryKey: instructorKeys.blockedDates(instructorId),
|
||||
queryFn: () => api.get<InstructorBlockedDate[]>(`/v1/instructors/${instructorId}/blocked-dates`),
|
||||
enabled: !!instructorId,
|
||||
})
|
||||
}
|
||||
|
||||
export const instructorMutations = {
|
||||
create: (data: Record<string, unknown>) =>
|
||||
api.post<Instructor>('/v1/instructors', data),
|
||||
update: (id: string, data: Record<string, unknown>) =>
|
||||
api.patch<Instructor>(`/v1/instructors/${id}`, data),
|
||||
delete: (id: string) =>
|
||||
api.del<Instructor>(`/v1/instructors/${id}`),
|
||||
addBlockedDate: (instructorId: string, data: Record<string, unknown>) =>
|
||||
api.post<InstructorBlockedDate>(`/v1/instructors/${instructorId}/blocked-dates`, data),
|
||||
deleteBlockedDate: (instructorId: string, id: string) =>
|
||||
api.del<InstructorBlockedDate>(`/v1/instructors/${instructorId}/blocked-dates/${id}`),
|
||||
}
|
||||
|
||||
// --- Lesson Types ---
|
||||
|
||||
export const lessonTypeKeys = {
|
||||
all: ['lesson-types'] as const,
|
||||
list: (params: PaginationInput) => [...lessonTypeKeys.all, 'list', params] as const,
|
||||
detail: (id: string) => [...lessonTypeKeys.all, 'detail', id] as const,
|
||||
}
|
||||
|
||||
export function lessonTypeListOptions(params: PaginationInput) {
|
||||
return queryOptions({
|
||||
queryKey: lessonTypeKeys.list(params),
|
||||
queryFn: () => api.get<PaginatedResponse<LessonType>>('/v1/lesson-types', params),
|
||||
})
|
||||
}
|
||||
|
||||
export const lessonTypeMutations = {
|
||||
create: (data: Record<string, unknown>) =>
|
||||
api.post<LessonType>('/v1/lesson-types', data),
|
||||
update: (id: string, data: Record<string, unknown>) =>
|
||||
api.patch<LessonType>(`/v1/lesson-types/${id}`, data),
|
||||
delete: (id: string) =>
|
||||
api.del<LessonType>(`/v1/lesson-types/${id}`),
|
||||
}
|
||||
|
||||
// --- Schedule Slots ---
|
||||
|
||||
export const scheduleSlotKeys = {
|
||||
all: ['schedule-slots'] as const,
|
||||
list: (params: PaginationInput) => [...scheduleSlotKeys.all, 'list', params] as const,
|
||||
byInstructor: (instructorId: string, params: PaginationInput) =>
|
||||
[...scheduleSlotKeys.all, 'instructor', instructorId, params] as const,
|
||||
detail: (id: string) => [...scheduleSlotKeys.all, 'detail', id] as const,
|
||||
}
|
||||
|
||||
export function scheduleSlotListOptions(params: PaginationInput, filters?: { instructorId?: string; dayOfWeek?: number }) {
|
||||
const query = { ...params, ...filters }
|
||||
return queryOptions({
|
||||
queryKey: filters?.instructorId
|
||||
? scheduleSlotKeys.byInstructor(filters.instructorId, params)
|
||||
: scheduleSlotKeys.list(params),
|
||||
queryFn: () => api.get<PaginatedResponse<ScheduleSlot>>('/v1/schedule-slots', query),
|
||||
})
|
||||
}
|
||||
|
||||
export const scheduleSlotMutations = {
|
||||
create: (data: Record<string, unknown>) =>
|
||||
api.post<ScheduleSlot>('/v1/schedule-slots', data),
|
||||
update: (id: string, data: Record<string, unknown>) =>
|
||||
api.patch<ScheduleSlot>(`/v1/schedule-slots/${id}`, data),
|
||||
delete: (id: string) =>
|
||||
api.del<ScheduleSlot>(`/v1/schedule-slots/${id}`),
|
||||
}
|
||||
|
||||
// --- Enrollments ---
|
||||
|
||||
export const enrollmentKeys = {
|
||||
all: ['enrollments'] as const,
|
||||
list: (params: Record<string, unknown>) => [...enrollmentKeys.all, 'list', params] as const,
|
||||
detail: (id: string) => [...enrollmentKeys.all, 'detail', id] as const,
|
||||
}
|
||||
|
||||
export function enrollmentListOptions(params: Record<string, unknown>) {
|
||||
return queryOptions({
|
||||
queryKey: enrollmentKeys.list(params),
|
||||
queryFn: () => api.get<PaginatedResponse<Enrollment>>('/v1/enrollments', params),
|
||||
})
|
||||
}
|
||||
|
||||
export function enrollmentDetailOptions(id: string) {
|
||||
return queryOptions({
|
||||
queryKey: enrollmentKeys.detail(id),
|
||||
queryFn: () => api.get<Enrollment>(`/v1/enrollments/${id}`),
|
||||
enabled: !!id,
|
||||
})
|
||||
}
|
||||
|
||||
export const enrollmentMutations = {
|
||||
create: (data: Record<string, unknown>) =>
|
||||
api.post<Enrollment>('/v1/enrollments', data),
|
||||
update: (id: string, data: Record<string, unknown>) =>
|
||||
api.patch<Enrollment>(`/v1/enrollments/${id}`, data),
|
||||
updateStatus: (id: string, status: string) =>
|
||||
api.post<Enrollment>(`/v1/enrollments/${id}/status`, { status }),
|
||||
generateSessions: (id: string, weeks?: number) =>
|
||||
api.post<{ generated: number; sessions: LessonSession[] }>(
|
||||
`/v1/enrollments/${id}/generate-sessions${weeks ? `?weeks=${weeks}` : ''}`,
|
||||
{},
|
||||
),
|
||||
}
|
||||
|
||||
// --- Lesson Sessions ---
|
||||
|
||||
export const sessionKeys = {
|
||||
all: ['lesson-sessions'] as const,
|
||||
list: (params: Record<string, unknown>) => [...sessionKeys.all, 'list', params] as const,
|
||||
detail: (id: string) => [...sessionKeys.all, 'detail', id] as const,
|
||||
planItems: (id: string) => [...sessionKeys.all, id, 'plan-items'] as const,
|
||||
}
|
||||
|
||||
export function sessionListOptions(params: Record<string, unknown>) {
|
||||
return queryOptions({
|
||||
queryKey: sessionKeys.list(params),
|
||||
queryFn: () => api.get<PaginatedResponse<LessonSession>>('/v1/lesson-sessions', params),
|
||||
})
|
||||
}
|
||||
|
||||
export function sessionDetailOptions(id: string) {
|
||||
return queryOptions({
|
||||
queryKey: sessionKeys.detail(id),
|
||||
queryFn: () => api.get<LessonSession>(`/v1/lesson-sessions/${id}`),
|
||||
enabled: !!id,
|
||||
})
|
||||
}
|
||||
|
||||
export function sessionPlanItemsOptions(sessionId: string) {
|
||||
return queryOptions({
|
||||
queryKey: sessionKeys.planItems(sessionId),
|
||||
queryFn: () => api.get<SessionPlanItem[]>(`/v1/lesson-sessions/${sessionId}/plan-items`),
|
||||
enabled: !!sessionId,
|
||||
})
|
||||
}
|
||||
|
||||
export const sessionMutations = {
|
||||
update: (id: string, data: Record<string, unknown>) =>
|
||||
api.patch<LessonSession>(`/v1/lesson-sessions/${id}`, data),
|
||||
updateStatus: (id: string, status: string) =>
|
||||
api.post<LessonSession>(`/v1/lesson-sessions/${id}/status`, { status }),
|
||||
updateNotes: (id: string, data: Record<string, unknown>) =>
|
||||
api.post<LessonSession>(`/v1/lesson-sessions/${id}/notes`, data),
|
||||
linkPlanItems: (id: string, lessonPlanItemIds: string[]) =>
|
||||
api.post<{ linked: number; items: SessionPlanItem[] }>(`/v1/lesson-sessions/${id}/plan-items`, { lessonPlanItemIds }),
|
||||
}
|
||||
|
||||
// --- Grading Scales ---
|
||||
|
||||
export const gradingScaleKeys = {
|
||||
all: ['grading-scales'] as const,
|
||||
list: (params: PaginationInput) => [...gradingScaleKeys.all, 'list', params] as const,
|
||||
allScales: [...['grading-scales'], 'all'] as const,
|
||||
detail: (id: string) => [...gradingScaleKeys.all, 'detail', id] as const,
|
||||
}
|
||||
|
||||
export function gradingScaleListOptions(params: PaginationInput) {
|
||||
return queryOptions({
|
||||
queryKey: gradingScaleKeys.list(params),
|
||||
queryFn: () => api.get<PaginatedResponse<GradingScale>>('/v1/grading-scales', params),
|
||||
})
|
||||
}
|
||||
|
||||
export function gradingScaleAllOptions() {
|
||||
return queryOptions({
|
||||
queryKey: gradingScaleKeys.allScales,
|
||||
queryFn: () => api.get<GradingScale[]>('/v1/grading-scales/all'),
|
||||
})
|
||||
}
|
||||
|
||||
export function gradingScaleDetailOptions(id: string) {
|
||||
return queryOptions({
|
||||
queryKey: gradingScaleKeys.detail(id),
|
||||
queryFn: () => api.get<GradingScale>(`/v1/grading-scales/${id}`),
|
||||
enabled: !!id,
|
||||
})
|
||||
}
|
||||
|
||||
export const gradingScaleMutations = {
|
||||
create: (data: Record<string, unknown>) =>
|
||||
api.post<GradingScale>('/v1/grading-scales', data),
|
||||
update: (id: string, data: Record<string, unknown>) =>
|
||||
api.patch<GradingScale>(`/v1/grading-scales/${id}`, data),
|
||||
delete: (id: string) =>
|
||||
api.del<GradingScale>(`/v1/grading-scales/${id}`),
|
||||
}
|
||||
|
||||
// --- Lesson Plans ---
|
||||
|
||||
export const lessonPlanKeys = {
|
||||
all: ['lesson-plans'] as const,
|
||||
list: (params: Record<string, unknown>) => [...lessonPlanKeys.all, 'list', params] as const,
|
||||
detail: (id: string) => [...lessonPlanKeys.all, 'detail', id] as const,
|
||||
}
|
||||
|
||||
export function lessonPlanListOptions(params: Record<string, unknown>) {
|
||||
return queryOptions({
|
||||
queryKey: lessonPlanKeys.list(params),
|
||||
queryFn: () => api.get<PaginatedResponse<LessonPlan>>('/v1/lesson-plans', params),
|
||||
})
|
||||
}
|
||||
|
||||
export function lessonPlanDetailOptions(id: string) {
|
||||
return queryOptions({
|
||||
queryKey: lessonPlanKeys.detail(id),
|
||||
queryFn: () => api.get<LessonPlan>(`/v1/lesson-plans/${id}`),
|
||||
enabled: !!id,
|
||||
})
|
||||
}
|
||||
|
||||
export const lessonPlanMutations = {
|
||||
create: (data: Record<string, unknown>) =>
|
||||
api.post<LessonPlan>('/v1/lesson-plans', data),
|
||||
update: (id: string, data: Record<string, unknown>) =>
|
||||
api.patch<LessonPlan>(`/v1/lesson-plans/${id}`, data),
|
||||
}
|
||||
|
||||
// --- Lesson Plan Items ---
|
||||
|
||||
export const lessonPlanItemKeys = {
|
||||
gradeHistory: (itemId: string) => ['lesson-plan-items', itemId, 'grade-history'] as const,
|
||||
}
|
||||
|
||||
export function lessonPlanItemGradeHistoryOptions(itemId: string) {
|
||||
return queryOptions({
|
||||
queryKey: lessonPlanItemKeys.gradeHistory(itemId),
|
||||
queryFn: () => api.get<LessonPlanItemGradeHistory[]>(`/v1/lesson-plan-items/${itemId}/grade-history`),
|
||||
enabled: !!itemId,
|
||||
})
|
||||
}
|
||||
|
||||
export const lessonPlanItemMutations = {
|
||||
update: (id: string, data: Record<string, unknown>) =>
|
||||
api.patch<LessonPlanItem>(`/v1/lesson-plan-items/${id}`, data),
|
||||
addGrade: (id: string, data: Record<string, unknown>) =>
|
||||
api.post<{ record: LessonPlanItemGradeHistory; item: LessonPlanItem }>(`/v1/lesson-plan-items/${id}/grades`, data),
|
||||
}
|
||||
|
||||
// --- Lesson Plan Templates ---
|
||||
|
||||
export const lessonPlanTemplateKeys = {
|
||||
all: ['lesson-plan-templates'] as const,
|
||||
list: (params: PaginationInput) => [...lessonPlanTemplateKeys.all, 'list', params] as const,
|
||||
detail: (id: string) => [...lessonPlanTemplateKeys.all, 'detail', id] as const,
|
||||
}
|
||||
|
||||
export function lessonPlanTemplateListOptions(params: PaginationInput) {
|
||||
return queryOptions({
|
||||
queryKey: lessonPlanTemplateKeys.list(params),
|
||||
queryFn: () => api.get<PaginatedResponse<LessonPlanTemplate>>('/v1/lesson-plan-templates', params),
|
||||
})
|
||||
}
|
||||
|
||||
export function lessonPlanTemplateDetailOptions(id: string) {
|
||||
return queryOptions({
|
||||
queryKey: lessonPlanTemplateKeys.detail(id),
|
||||
queryFn: () => api.get<LessonPlanTemplate>(`/v1/lesson-plan-templates/${id}`),
|
||||
enabled: !!id,
|
||||
})
|
||||
}
|
||||
|
||||
export const lessonPlanTemplateMutations = {
|
||||
create: (data: Record<string, unknown>) =>
|
||||
api.post<LessonPlanTemplate>('/v1/lesson-plan-templates', data),
|
||||
update: (id: string, data: Record<string, unknown>) =>
|
||||
api.patch<LessonPlanTemplate>(`/v1/lesson-plan-templates/${id}`, data),
|
||||
delete: (id: string) =>
|
||||
api.del<LessonPlanTemplate>(`/v1/lesson-plan-templates/${id}`),
|
||||
createPlan: (templateId: string, data: Record<string, unknown>) =>
|
||||
api.post<LessonPlan>(`/v1/lesson-plan-templates/${templateId}/create-plan`, data),
|
||||
}
|
||||
|
||||
// --- Store Closures ---
|
||||
|
||||
export const storeClosureKeys = {
|
||||
all: ['store-closures'] as const,
|
||||
}
|
||||
|
||||
export function storeClosureListOptions() {
|
||||
return queryOptions({
|
||||
queryKey: storeClosureKeys.all,
|
||||
queryFn: () => api.get<StoreClosure[]>('/v1/store-closures'),
|
||||
})
|
||||
}
|
||||
|
||||
export const storeClosureMutations = {
|
||||
create: (data: Record<string, unknown>) =>
|
||||
api.post<StoreClosure>('/v1/store-closures', data),
|
||||
delete: (id: string) =>
|
||||
api.del<StoreClosure>(`/v1/store-closures/${id}`),
|
||||
}
|
||||
@@ -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
|
||||
|
||||
28
packages/admin/src/api/modules.ts
Normal file
28
packages/admin/src/api/modules.ts
Normal 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 }),
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
|
||||
73
packages/admin/src/api/storage.ts
Normal file
73
packages/admin/src/api/storage.ts
Normal 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`),
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
91
packages/admin/src/api/vault.ts
Normal file
91
packages/admin/src/api/vault.ts
Normal 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`, {}),
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
91
packages/admin/src/components/inventory/category-form.tsx
Normal file
91
packages/admin/src/components/inventory/category-form.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
118
packages/admin/src/components/inventory/inventory-unit-form.tsx
Normal file
118
packages/admin/src/components/inventory/inventory-unit-form.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
172
packages/admin/src/components/inventory/product-form.tsx
Normal file
172
packages/admin/src/components/inventory/product-form.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
88
packages/admin/src/components/inventory/supplier-form.tsx
Normal file
88
packages/admin/src/components/inventory/supplier-form.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
45
packages/admin/src/components/lessons/blocked-date-form.tsx
Normal file
45
packages/admin/src/components/lessons/blocked-date-form.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
|
||||
interface Props {
|
||||
onSubmit: (data: Record<string, unknown>) => void
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export function BlockedDateForm({ onSubmit, loading }: Props) {
|
||||
const { register, handleSubmit } = useForm({
|
||||
defaultValues: { startDate: '', endDate: '', reason: '' },
|
||||
})
|
||||
|
||||
function handleFormSubmit(data: { startDate: string; endDate: string; reason: string }) {
|
||||
onSubmit({
|
||||
startDate: data.startDate,
|
||||
endDate: data.endDate,
|
||||
reason: data.reason || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bd-start">Start Date *</Label>
|
||||
<Input id="bd-start" type="date" {...register('startDate')} required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bd-end">End Date *</Label>
|
||||
<Input id="bd-end" type="date" {...register('endDate')} required />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bd-reason">Reason</Label>
|
||||
<Input id="bd-reason" {...register('reason')} placeholder="e.g. Vacation, Conference" />
|
||||
</div>
|
||||
<Button type="submit" disabled={loading} className="w-full">
|
||||
{loading ? 'Saving...' : 'Add Blocked Date'}
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
127
packages/admin/src/components/lessons/grade-entry-dialog.tsx
Normal file
127
packages/admin/src/components/lessons/grade-entry-dialog.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { gradingScaleAllOptions, lessonPlanItemMutations, lessonPlanItemGradeHistoryOptions, lessonPlanItemKeys } from '@/api/lessons'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { toast } from 'sonner'
|
||||
import type { LessonPlanItem } from '@/types/lesson'
|
||||
|
||||
interface Props {
|
||||
item: LessonPlanItem
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function GradeEntryDialog({ item, open, onClose }: Props) {
|
||||
const queryClient = useQueryClient()
|
||||
const [selectedScaleId, setSelectedScaleId] = useState(item.gradingScaleId ?? '')
|
||||
const [selectedValue, setSelectedValue] = useState('')
|
||||
const [gradeNotes, setGradeNotes] = useState('')
|
||||
|
||||
const { data: scales } = useQuery(gradingScaleAllOptions())
|
||||
const { data: history } = useQuery(lessonPlanItemGradeHistoryOptions(item.id))
|
||||
|
||||
const selectedScale = scales?.find((s) => s.id === selectedScaleId)
|
||||
|
||||
const gradeMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
lessonPlanItemMutations.addGrade(item.id, {
|
||||
gradingScaleId: selectedScaleId || undefined,
|
||||
gradeValue: selectedValue,
|
||||
notes: gradeNotes || undefined,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: lessonPlanItemKeys.gradeHistory(item.id) })
|
||||
toast.success('Grade recorded')
|
||||
setSelectedValue('')
|
||||
setGradeNotes('')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose() }}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Grade: {item.title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Grading Scale</Label>
|
||||
<Select value={selectedScaleId || 'none'} onValueChange={(v) => { setSelectedScaleId(v === 'none' ? '' : v); setSelectedValue('') }}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="No scale (freeform)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">No scale (freeform)</SelectItem>
|
||||
{(scales ?? []).map((s) => (
|
||||
<SelectItem key={s.id} value={s.id}>{s.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Grade Value *</Label>
|
||||
{selectedScale ? (
|
||||
<Select value={selectedValue} onValueChange={setSelectedValue}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select grade..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{[...selectedScale.levels].sort((a, b) => a.sortOrder - b.sortOrder).map((level) => (
|
||||
<SelectItem key={level.id} value={level.value}>
|
||||
{level.value} — {level.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<input
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
value={selectedValue}
|
||||
onChange={(e) => setSelectedValue(e.target.value)}
|
||||
placeholder="Enter grade (e.g. A, Pass, 85)"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Notes</Label>
|
||||
<Textarea value={gradeNotes} onChange={(e) => setGradeNotes(e.target.value)} rows={2} />
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => gradeMutation.mutate()}
|
||||
disabled={!selectedValue || gradeMutation.isPending}
|
||||
className="w-full"
|
||||
>
|
||||
{gradeMutation.isPending ? 'Recording...' : 'Record Grade'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Grade History */}
|
||||
{(history ?? []).length > 0 && (
|
||||
<div className="border-t pt-4 space-y-2">
|
||||
<p className="text-sm font-medium text-muted-foreground">Grade History</p>
|
||||
<div className="space-y-2 max-h-40 overflow-y-auto">
|
||||
{[...history!].reverse().map((h) => (
|
||||
<div key={h.id} className="flex items-start justify-between text-sm">
|
||||
<div>
|
||||
<span className="font-medium">{h.gradeValue}</span>
|
||||
{h.notes && <p className="text-xs text-muted-foreground">{h.notes}</p>}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{new Date(h.createdAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
127
packages/admin/src/components/lessons/grading-scale-form.tsx
Normal file
127
packages/admin/src/components/lessons/grading-scale-form.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import { useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Trash2, Plus } from 'lucide-react'
|
||||
|
||||
interface LevelRow {
|
||||
value: string
|
||||
label: string
|
||||
numericValue: string
|
||||
colorHex: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
onSubmit: (data: Record<string, unknown>) => void
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
const DEFAULT_LEVELS: LevelRow[] = [
|
||||
{ value: 'A', label: 'Excellent', numericValue: '4', colorHex: '#22c55e' },
|
||||
{ value: 'B', label: 'Good', numericValue: '3', colorHex: '#84cc16' },
|
||||
{ value: 'C', label: 'Developing', numericValue: '2', colorHex: '#eab308' },
|
||||
{ value: 'D', label: 'Beginning', numericValue: '1', colorHex: '#f97316' },
|
||||
]
|
||||
|
||||
export function GradingScaleForm({ onSubmit, loading }: Props) {
|
||||
const { register, handleSubmit } = useForm({
|
||||
defaultValues: { name: '', description: '', isDefault: false },
|
||||
})
|
||||
const [levels, setLevels] = useState<LevelRow[]>(DEFAULT_LEVELS)
|
||||
|
||||
function addLevel() {
|
||||
setLevels((prev) => [...prev, { value: '', label: '', numericValue: String(prev.length + 1), colorHex: '' }])
|
||||
}
|
||||
|
||||
function removeLevel(idx: number) {
|
||||
setLevels((prev) => prev.filter((_, i) => i !== idx))
|
||||
}
|
||||
|
||||
function updateLevel(idx: number, field: keyof LevelRow, value: string) {
|
||||
setLevels((prev) => prev.map((l, i) => (i === idx ? { ...l, [field]: value } : l)))
|
||||
}
|
||||
|
||||
function handleFormSubmit(data: { name: string; description: string; isDefault: boolean }) {
|
||||
onSubmit({
|
||||
name: data.name,
|
||||
description: data.description || undefined,
|
||||
isDefault: data.isDefault,
|
||||
levels: levels.map((l, i) => ({
|
||||
value: l.value,
|
||||
label: l.label,
|
||||
numericValue: Number(l.numericValue) || i + 1,
|
||||
colorHex: l.colorHex || undefined,
|
||||
sortOrder: i,
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="gs-name">Name *</Label>
|
||||
<Input id="gs-name" {...register('name')} placeholder="e.g. RCM Performance Scale" required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="gs-desc">Description</Label>
|
||||
<Textarea id="gs-desc" {...register('description')} rows={2} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="checkbox" id="gs-default" {...register('isDefault')} className="h-4 w-4" />
|
||||
<Label htmlFor="gs-default">Set as default scale</Label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Grade Levels</Label>
|
||||
<Button type="button" variant="outline" size="sm" onClick={addLevel}>
|
||||
<Plus className="h-3 w-3 mr-1" />Add Level
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2 max-h-64 overflow-y-auto pr-1">
|
||||
{levels.map((level, idx) => (
|
||||
<div key={idx} className="grid grid-cols-[1fr_2fr_1fr_auto_auto] gap-2 items-center">
|
||||
<Input
|
||||
placeholder="Value"
|
||||
value={level.value}
|
||||
onChange={(e) => updateLevel(idx, 'value', e.target.value)}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
placeholder="Label"
|
||||
value={level.label}
|
||||
onChange={(e) => updateLevel(idx, 'label', e.target.value)}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="Score"
|
||||
value={level.numericValue}
|
||||
onChange={(e) => updateLevel(idx, 'numericValue', e.target.value)}
|
||||
/>
|
||||
<input
|
||||
type="color"
|
||||
value={level.colorHex || '#888888'}
|
||||
onChange={(e) => updateLevel(idx, 'colorHex', e.target.value)}
|
||||
className="h-9 w-9 rounded border border-input cursor-pointer"
|
||||
title="Color"
|
||||
/>
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => removeLevel(idx)} className="h-9 w-9">
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{levels.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground text-center py-2">No levels — add at least one.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button type="submit" disabled={loading || levels.length === 0} className="w-full">
|
||||
{loading ? 'Saving...' : 'Create Grading Scale'}
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
52
packages/admin/src/components/lessons/instructor-form.tsx
Normal file
52
packages/admin/src/components/lessons/instructor-form.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import type { Instructor } from '@/types/lesson'
|
||||
|
||||
interface Props {
|
||||
defaultValues?: Partial<Instructor>
|
||||
onSubmit: (data: Record<string, unknown>) => void
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export function InstructorForm({ defaultValues, onSubmit, loading }: Props) {
|
||||
const { register, handleSubmit } = useForm({
|
||||
defaultValues: {
|
||||
displayName: defaultValues?.displayName ?? '',
|
||||
bio: defaultValues?.bio ?? '',
|
||||
instruments: defaultValues?.instruments?.join(', ') ?? '',
|
||||
},
|
||||
})
|
||||
|
||||
function handleFormSubmit(data: { displayName: string; bio: string; instruments: string }) {
|
||||
onSubmit({
|
||||
displayName: data.displayName,
|
||||
bio: data.bio || undefined,
|
||||
instruments: data.instruments
|
||||
? data.instruments.split(',').map((s) => s.trim()).filter(Boolean)
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="displayName">Display Name *</Label>
|
||||
<Input id="displayName" {...register('displayName')} required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bio">Bio</Label>
|
||||
<Textarea id="bio" {...register('bio')} rows={3} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="instruments">Instruments</Label>
|
||||
<Input id="instruments" {...register('instruments')} placeholder="Piano, Guitar, Voice (comma-separated)" />
|
||||
</div>
|
||||
<Button type="submit" disabled={loading} className="w-full">
|
||||
{loading ? 'Saving...' : defaultValues ? 'Save Changes' : 'Create Instructor'}
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
99
packages/admin/src/components/lessons/lesson-type-form.tsx
Normal file
99
packages/admin/src/components/lessons/lesson-type-form.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import type { LessonType } from '@/types/lesson'
|
||||
|
||||
interface Props {
|
||||
defaultValues?: Partial<LessonType>
|
||||
onSubmit: (data: Record<string, unknown>) => void
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export function LessonTypeForm({ defaultValues, onSubmit, loading }: Props) {
|
||||
const { register, handleSubmit, setValue, watch } = useForm({
|
||||
defaultValues: {
|
||||
name: defaultValues?.name ?? '',
|
||||
instrument: defaultValues?.instrument ?? '',
|
||||
durationMinutes: defaultValues?.durationMinutes ?? 30,
|
||||
lessonFormat: (defaultValues?.lessonFormat ?? 'private') as 'private' | 'group',
|
||||
rateWeekly: defaultValues?.rateWeekly ?? '',
|
||||
rateMonthly: defaultValues?.rateMonthly ?? '',
|
||||
rateQuarterly: defaultValues?.rateQuarterly ?? '',
|
||||
},
|
||||
})
|
||||
|
||||
const lessonFormat = watch('lessonFormat')
|
||||
|
||||
function handleFormSubmit(data: {
|
||||
name: string
|
||||
instrument: string
|
||||
durationMinutes: number
|
||||
lessonFormat: string
|
||||
rateWeekly: string
|
||||
rateMonthly: string
|
||||
rateQuarterly: string
|
||||
}) {
|
||||
onSubmit({
|
||||
name: data.name,
|
||||
instrument: data.instrument || undefined,
|
||||
durationMinutes: Number(data.durationMinutes),
|
||||
lessonFormat: data.lessonFormat,
|
||||
rateWeekly: data.rateWeekly || undefined,
|
||||
rateMonthly: data.rateMonthly || undefined,
|
||||
rateQuarterly: data.rateQuarterly || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="lt-name">Name *</Label>
|
||||
<Input id="lt-name" {...register('name')} placeholder="e.g. Piano — 30 min Private" required />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="lt-instrument">Instrument</Label>
|
||||
<Input id="lt-instrument" {...register('instrument')} placeholder="e.g. Piano, Guitar" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="lt-duration">Duration (minutes) *</Label>
|
||||
<Input id="lt-duration" type="number" min={5} step={5} {...register('durationMinutes')} required />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Format *</Label>
|
||||
<Select value={lessonFormat} onValueChange={(v) => setValue('lessonFormat', v as 'private' | 'group')}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="private">Private</SelectItem>
|
||||
<SelectItem value="group">Group</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="block mb-2">Default Rates (optional)</Label>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="lt-rate-weekly" className="text-xs text-muted-foreground">Weekly</Label>
|
||||
<Input id="lt-rate-weekly" type="number" step="0.01" min="0" {...register('rateWeekly')} placeholder="—" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="lt-rate-monthly" className="text-xs text-muted-foreground">Monthly</Label>
|
||||
<Input id="lt-rate-monthly" type="number" step="0.01" min="0" {...register('rateMonthly')} placeholder="—" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="lt-rate-quarterly" className="text-xs text-muted-foreground">Quarterly</Label>
|
||||
<Input id="lt-rate-quarterly" type="number" step="0.01" min="0" {...register('rateQuarterly')} placeholder="—" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="submit" disabled={loading} className="w-full">
|
||||
{loading ? 'Saving...' : defaultValues?.id ? 'Save Changes' : 'Create Lesson Type'}
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
124
packages/admin/src/components/lessons/schedule-slot-form.tsx
Normal file
124
packages/admin/src/components/lessons/schedule-slot-form.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import type { LessonType, ScheduleSlot } from '@/types/lesson'
|
||||
|
||||
const DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
|
||||
|
||||
interface Props {
|
||||
lessonTypes: LessonType[]
|
||||
defaultValues?: Partial<ScheduleSlot>
|
||||
onSubmit: (data: Record<string, unknown>) => void
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export function ScheduleSlotForm({ lessonTypes, defaultValues, onSubmit, loading }: Props) {
|
||||
const { register, handleSubmit, setValue, watch } = useForm({
|
||||
defaultValues: {
|
||||
dayOfWeek: String(defaultValues?.dayOfWeek ?? 1),
|
||||
startTime: defaultValues?.startTime ?? '',
|
||||
lessonTypeId: defaultValues?.lessonTypeId ?? '',
|
||||
room: defaultValues?.room ?? '',
|
||||
maxStudents: String(defaultValues?.maxStudents ?? 1),
|
||||
rateWeekly: defaultValues?.rateWeekly ?? '',
|
||||
rateMonthly: defaultValues?.rateMonthly ?? '',
|
||||
rateQuarterly: defaultValues?.rateQuarterly ?? '',
|
||||
},
|
||||
})
|
||||
|
||||
const dayOfWeek = watch('dayOfWeek')
|
||||
const lessonTypeId = watch('lessonTypeId')
|
||||
|
||||
function handleFormSubmit(data: {
|
||||
dayOfWeek: string
|
||||
startTime: string
|
||||
lessonTypeId: string
|
||||
room: string
|
||||
maxStudents: string
|
||||
rateWeekly: string
|
||||
rateMonthly: string
|
||||
rateQuarterly: string
|
||||
}) {
|
||||
onSubmit({
|
||||
dayOfWeek: Number(data.dayOfWeek),
|
||||
startTime: data.startTime,
|
||||
lessonTypeId: data.lessonTypeId,
|
||||
room: data.room || undefined,
|
||||
maxStudents: Number(data.maxStudents) || 1,
|
||||
rateWeekly: data.rateWeekly || undefined,
|
||||
rateMonthly: data.rateMonthly || undefined,
|
||||
rateQuarterly: data.rateQuarterly || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Day *</Label>
|
||||
<Select value={dayOfWeek} onValueChange={(v) => setValue('dayOfWeek', v)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DAYS.map((day, i) => (
|
||||
<SelectItem key={i} value={String(i)}>{day}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="slot-time">Start Time *</Label>
|
||||
<Input id="slot-time" type="time" {...register('startTime')} required />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Lesson Type *</Label>
|
||||
<Select value={lessonTypeId} onValueChange={(v) => setValue('lessonTypeId', v)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select lesson type..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{lessonTypes.map((lt) => (
|
||||
<SelectItem key={lt.id} value={lt.id}>
|
||||
{lt.name} ({lt.durationMinutes} min)
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="slot-room">Room</Label>
|
||||
<Input id="slot-room" {...register('room')} placeholder="e.g. Studio A" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="slot-max">Max Students</Label>
|
||||
<Input id="slot-max" type="number" min={1} {...register('maxStudents')} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="block mb-2">Instructor Rates (override lesson type defaults)</Label>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="slot-rate-weekly" className="text-xs text-muted-foreground">Weekly</Label>
|
||||
<Input id="slot-rate-weekly" type="number" step="0.01" min="0" {...register('rateWeekly')} placeholder="—" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="slot-rate-monthly" className="text-xs text-muted-foreground">Monthly</Label>
|
||||
<Input id="slot-rate-monthly" type="number" step="0.01" min="0" {...register('rateMonthly')} placeholder="—" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="slot-rate-quarterly" className="text-xs text-muted-foreground">Quarterly</Label>
|
||||
<Input id="slot-rate-quarterly" type="number" step="0.01" min="0" {...register('rateQuarterly')} placeholder="—" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="submit" disabled={loading || !lessonTypeId} className="w-full">
|
||||
{loading ? 'Saving...' : defaultValues?.id ? 'Save Changes' : 'Add Slot'}
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
41
packages/admin/src/components/lessons/store-closure-form.tsx
Normal file
41
packages/admin/src/components/lessons/store-closure-form.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
|
||||
interface Props {
|
||||
onSubmit: (data: Record<string, unknown>) => void
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export function StoreClosureForm({ onSubmit, loading }: Props) {
|
||||
const { register, handleSubmit } = useForm({
|
||||
defaultValues: { name: '', startDate: '', endDate: '' },
|
||||
})
|
||||
|
||||
function handleFormSubmit(data: { name: string; startDate: string; endDate: string }) {
|
||||
onSubmit({ name: data.name, startDate: data.startDate, endDate: data.endDate })
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="closure-name">Name *</Label>
|
||||
<Input id="closure-name" {...register('name')} placeholder="e.g. Thanksgiving Break" required />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="closure-start">Start Date *</Label>
|
||||
<Input id="closure-start" type="date" {...register('startDate')} required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="closure-end">End Date *</Label>
|
||||
<Input id="closure-end" type="date" {...register('endDate')} required />
|
||||
</div>
|
||||
</div>
|
||||
<Button type="submit" disabled={loading} className="w-full">
|
||||
{loading ? 'Saving...' : 'Add Closure'}
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,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 }
|
||||
87
packages/admin/src/components/lessons/weekly-slot-grid.tsx
Normal file
87
packages/admin/src/components/lessons/weekly-slot-grid.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Pencil, Trash2 } from 'lucide-react'
|
||||
import type { ScheduleSlot, LessonType } from '@/types/lesson'
|
||||
|
||||
const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
|
||||
|
||||
interface Props {
|
||||
slots: ScheduleSlot[]
|
||||
lessonTypes: LessonType[]
|
||||
onEdit: (slot: ScheduleSlot) => void
|
||||
onDelete: (slot: ScheduleSlot) => void
|
||||
}
|
||||
|
||||
function formatTime(t: string) {
|
||||
const [h, m] = t.split(':').map(Number)
|
||||
const ampm = h >= 12 ? 'PM' : 'AM'
|
||||
const hour = h % 12 || 12
|
||||
return `${hour}:${String(m).padStart(2, '0')} ${ampm}`
|
||||
}
|
||||
|
||||
export function WeeklySlotGrid({ slots, lessonTypes, onEdit, onDelete }: Props) {
|
||||
const ltMap = new Map(lessonTypes.map((lt) => [lt.id, lt]))
|
||||
|
||||
const slotsByDay = DAYS.map((_, day) =>
|
||||
slots.filter((s) => s.dayOfWeek === day).sort((a, b) => a.startTime.localeCompare(b.startTime)),
|
||||
)
|
||||
|
||||
const hasAny = slots.length > 0
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-7 gap-2">
|
||||
{DAYS.map((day, idx) => (
|
||||
<div key={day} className="min-h-[120px]">
|
||||
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide text-center mb-2 py-1 border-b">
|
||||
{day}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{slotsByDay[idx].map((slot) => {
|
||||
const lt = ltMap.get(slot.lessonTypeId)
|
||||
return (
|
||||
<div
|
||||
key={slot.id}
|
||||
className="bg-sidebar-accent rounded-md p-2 text-xs group relative"
|
||||
>
|
||||
<div className="font-medium">{formatTime(slot.startTime)}</div>
|
||||
<div className="text-muted-foreground truncate">{lt?.name ?? 'Unknown'}</div>
|
||||
{slot.room && <div className="text-muted-foreground">{slot.room}</div>}
|
||||
{lt && (
|
||||
<Badge variant="outline" className="mt-1 text-[10px] py-0">
|
||||
{lt.lessonFormat}
|
||||
</Badge>
|
||||
)}
|
||||
<div className="absolute top-1 right-1 hidden group-hover:flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5"
|
||||
onClick={() => onEdit(slot)}
|
||||
title="Edit"
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5"
|
||||
onClick={() => onDelete(slot)}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="h-3 w-3 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{!hasAny && (
|
||||
<div className="col-span-7 text-center text-sm text-muted-foreground py-8">
|
||||
No schedule slots yet — add one to get started.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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) }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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
|
||||
|
||||
29
packages/admin/src/components/storage/file-icons.tsx
Normal file
29
packages/admin/src/components/storage/file-icons.tsx
Normal 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`
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
91
packages/admin/src/components/storage/folder-tree.tsx
Normal file
91
packages/admin/src/components/storage/folder-tree.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
142
packages/admin/src/components/ui/alert-dialog.tsx
Normal file
142
packages/admin/src/components/ui/alert-dialog.tsx
Normal 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,
|
||||
}
|
||||
30
packages/admin/src/components/ui/checkbox.tsx
Normal file
30
packages/admin/src/components/ui/checkbox.tsx
Normal 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 }
|
||||
33
packages/admin/src/components/ui/switch.tsx
Normal file
33
packages/admin/src/components/ui/switch.tsx
Normal 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 }
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -12,6 +12,7 @@ export const Route = createFileRoute('/_authenticated/accounts/$accountId')({
|
||||
const tabs = [
|
||||
{ label: 'Overview', to: '/accounts/$accountId' },
|
||||
{ label: 'Members', to: '/accounts/$accountId/members' },
|
||||
{ label: 'Enrollments', to: '/accounts/$accountId/enrollments' },
|
||||
{ label: 'Payment Methods', to: '/accounts/$accountId/payment-methods' },
|
||||
{ label: 'Tax Exemptions', to: '/accounts/$accountId/tax-exemptions' },
|
||||
{ label: 'Processor Links', to: '/accounts/$accountId/processor-links' },
|
||||
|
||||
@@ -0,0 +1,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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
294
packages/admin/src/routes/_authenticated/files/index.tsx
Normal file
294
packages/admin/src/routes/_authenticated/files/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
253
packages/admin/src/routes/_authenticated/inventory/index.tsx
Normal file
253
packages/admin/src/routes/_authenticated/inventory/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useState } from 'react'
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { enrollmentListOptions } from '@/api/lessons'
|
||||
import { usePagination } from '@/hooks/use-pagination'
|
||||
import { DataTable, type Column } from '@/components/shared/data-table'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Plus, Search } from 'lucide-react'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import type { Enrollment } from '@/types/lesson'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/lessons/enrollments/')({
|
||||
validateSearch: (search: Record<string, unknown>) => ({
|
||||
page: Number(search.page) || 1,
|
||||
limit: Number(search.limit) || 25,
|
||||
q: (search.q as string) || undefined,
|
||||
sort: (search.sort as string) || undefined,
|
||||
order: (search.order as 'asc' | 'desc') || 'desc',
|
||||
status: (search.status as string) || undefined,
|
||||
instructorId: (search.instructorId as string) || undefined,
|
||||
}),
|
||||
component: EnrollmentsListPage,
|
||||
})
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
active: 'Active',
|
||||
paused: 'Paused',
|
||||
cancelled: 'Cancelled',
|
||||
completed: 'Completed',
|
||||
}
|
||||
|
||||
function statusBadge(status: string) {
|
||||
const variants: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||
active: 'default',
|
||||
paused: 'secondary',
|
||||
cancelled: 'destructive',
|
||||
completed: 'outline',
|
||||
}
|
||||
return <Badge variant={variants[status] ?? 'outline'}>{STATUS_LABELS[status] ?? status}</Badge>
|
||||
}
|
||||
|
||||
const columns: Column<Enrollment & { memberName?: string; instructorName?: string; slotInfo?: string; lessonTypeName?: string }>[] = [
|
||||
{ key: 'member_name', header: 'Member', sortable: true, render: (e) => <span className="font-medium">{(e as any).memberName ?? e.memberId}</span> },
|
||||
{ key: 'instructor_name', header: 'Instructor', render: (e) => <>{(e as any).instructorName ?? e.instructorId}</> },
|
||||
{ key: 'slot_info', header: 'Day / Time', render: (e) => <>{(e as any).slotInfo ?? '—'}</> },
|
||||
{ key: 'status', header: 'Status', sortable: true, render: (e) => statusBadge(e.status) },
|
||||
{ key: 'start_date', header: 'Start', sortable: true, render: (e) => <>{new Date(e.startDate + 'T00:00:00').toLocaleDateString()}</> },
|
||||
{ key: 'rate', header: 'Rate', render: (e) => <>{e.rate ? `$${e.rate}${e.billingInterval ? ` / ${e.billingInterval} ${e.billingUnit}` : ''}` : <span className="text-muted-foreground">—</span>}</> },
|
||||
]
|
||||
|
||||
function EnrollmentsListPage() {
|
||||
const navigate = useNavigate()
|
||||
const hasPermission = useAuthStore((s) => s.hasPermission)
|
||||
const search = Route.useSearch()
|
||||
const { params, setPage, setSearch, setSort } = usePagination()
|
||||
const [searchInput, setSearchInput] = useState(params.q ?? '')
|
||||
const [statusFilter, setStatusFilter] = useState(search.status ?? '')
|
||||
|
||||
const queryParams: Record<string, unknown> = { ...params }
|
||||
if (statusFilter) queryParams.status = statusFilter
|
||||
|
||||
const { data, isLoading } = useQuery(enrollmentListOptions(queryParams))
|
||||
|
||||
function handleSearchSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setSearch(searchInput)
|
||||
}
|
||||
|
||||
function handleStatusChange(v: string) {
|
||||
const s = v === 'all' ? '' : v
|
||||
setStatusFilter(s)
|
||||
navigate({ to: '/lessons/enrollments', search: { ...search, status: s || undefined, page: 1 } as any })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Enrollments</h1>
|
||||
{hasPermission('lessons.edit') && (
|
||||
<Button onClick={() => navigate({ to: '/lessons/enrollments/new', search: {} as any })}>
|
||||
<Plus className="mr-2 h-4 w-4" />New Enrollment
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 flex-wrap">
|
||||
<form onSubmit={handleSearchSubmit} className="flex gap-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search enrollments..."
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
className="pl-9 w-64"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" variant="secondary">Search</Button>
|
||||
</form>
|
||||
|
||||
<Select value={statusFilter || 'all'} onValueChange={handleStatusChange}>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue placeholder="All Statuses" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Statuses</SelectItem>
|
||||
<SelectItem value="active">Active</SelectItem>
|
||||
<SelectItem value="paused">Paused</SelectItem>
|
||||
<SelectItem value="cancelled">Cancelled</SelectItem>
|
||||
<SelectItem value="completed">Completed</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={data?.data ?? []}
|
||||
loading={isLoading}
|
||||
page={params.page}
|
||||
totalPages={data?.pagination.totalPages ?? 1}
|
||||
total={data?.pagination.total ?? 0}
|
||||
sort={params.sort}
|
||||
order={params.order}
|
||||
onPageChange={setPage}
|
||||
onSort={setSort}
|
||||
onRowClick={(e) => navigate({ to: '/lessons/enrollments/$enrollmentId', params: { enrollmentId: e.id }, search: {} as any })}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { useQuery, useMutation } from '@tanstack/react-query'
|
||||
import { globalMemberListOptions } from '@/api/members'
|
||||
import { scheduleSlotListOptions, enrollmentMutations, instructorListOptions, lessonTypeListOptions } from '@/api/lessons'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { ArrowLeft, Search, X } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import type { MemberWithAccount } from '@/api/members'
|
||||
import type { ScheduleSlot, LessonType, Instructor } from '@/types/lesson'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/lessons/enrollments/new')({
|
||||
validateSearch: (search: Record<string, unknown>) => ({
|
||||
memberId: (search.memberId as string) || undefined,
|
||||
accountId: (search.accountId as string) || undefined,
|
||||
}),
|
||||
component: NewEnrollmentPage,
|
||||
})
|
||||
|
||||
const DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
|
||||
|
||||
const BILLING_UNITS = [
|
||||
{ value: 'day', label: 'Day(s)' },
|
||||
{ value: 'week', label: 'Week(s)' },
|
||||
{ value: 'month', label: 'Month(s)' },
|
||||
{ value: 'quarter', label: 'Quarter(s)' },
|
||||
{ value: 'year', label: 'Year(s)' },
|
||||
] as const
|
||||
|
||||
function formatSlotLabel(slot: ScheduleSlot, instructors: Instructor[], lessonTypes: LessonType[]) {
|
||||
const instructor = instructors.find((i) => i.id === slot.instructorId)
|
||||
const lessonType = lessonTypes.find((lt) => lt.id === slot.lessonTypeId)
|
||||
const [h, m] = slot.startTime.split(':').map(Number)
|
||||
const ampm = h >= 12 ? 'PM' : 'AM'
|
||||
const hour = h % 12 || 12
|
||||
const time = `${hour}:${String(m).padStart(2, '0')} ${ampm}`
|
||||
const day = DAYS[slot.dayOfWeek]
|
||||
return `${day} ${time} — ${lessonType?.name ?? 'Unknown'} (${instructor?.displayName ?? 'Unknown'})`
|
||||
}
|
||||
|
||||
/** Returns the preset rate for a given cycle from slot (falling back to lesson type) */
|
||||
function getPresetRate(
|
||||
billingInterval: string,
|
||||
billingUnit: string,
|
||||
slot: ScheduleSlot | undefined,
|
||||
lessonType: LessonType | undefined,
|
||||
): string {
|
||||
if (!slot) return ''
|
||||
const isPreset = billingInterval === '1'
|
||||
if (!isPreset) return ''
|
||||
if (billingUnit === 'week') return slot.rateWeekly ?? lessonType?.rateWeekly ?? ''
|
||||
if (billingUnit === 'month') return slot.rateMonthly ?? lessonType?.rateMonthly ?? ''
|
||||
if (billingUnit === 'quarter') return slot.rateQuarterly ?? lessonType?.rateQuarterly ?? ''
|
||||
return ''
|
||||
}
|
||||
|
||||
function NewEnrollmentPage() {
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [memberSearch, setMemberSearch] = useState('')
|
||||
const [showMemberDropdown, setShowMemberDropdown] = useState(false)
|
||||
const [selectedMember, setSelectedMember] = useState<MemberWithAccount | null>(null)
|
||||
|
||||
const [selectedSlotId, setSelectedSlotId] = useState('')
|
||||
const [startDate, setStartDate] = useState('')
|
||||
const [billingInterval, setBillingInterval] = useState('1')
|
||||
const [billingUnit, setBillingUnit] = useState('month')
|
||||
const [rate, setRate] = useState('')
|
||||
const [rateManual, setRateManual] = useState(false)
|
||||
const [notes, setNotes] = useState('')
|
||||
|
||||
const { data: membersData } = useQuery(
|
||||
globalMemberListOptions({ page: 1, limit: 20, q: memberSearch || undefined, order: 'asc', sort: 'first_name' }),
|
||||
)
|
||||
|
||||
const { data: slotsData } = useQuery(scheduleSlotListOptions({ page: 1, limit: 100, order: 'asc' }))
|
||||
const { data: instructorsData } = useQuery(instructorListOptions({ page: 1, limit: 100, order: 'asc' }))
|
||||
const { data: lessonTypesData } = useQuery(lessonTypeListOptions({ page: 1, limit: 100, order: 'asc' }))
|
||||
|
||||
const slots = slotsData?.data?.filter((s) => s.isActive) ?? []
|
||||
const instructors = instructorsData?.data ?? []
|
||||
const lessonTypes = lessonTypesData?.data ?? []
|
||||
|
||||
const selectedSlot = slots.find((s) => s.id === selectedSlotId)
|
||||
const selectedLessonType = lessonTypes.find((lt) => lt.id === selectedSlot?.lessonTypeId)
|
||||
|
||||
// Auto-fill rate from slot/lesson-type presets when slot or cycle changes, unless user has manually edited
|
||||
useEffect(() => {
|
||||
if (rateManual) return
|
||||
const preset = getPresetRate(billingInterval, billingUnit, selectedSlot, selectedLessonType)
|
||||
setRate(preset ? String(preset) : '')
|
||||
}, [selectedSlotId, billingInterval, billingUnit, selectedSlot, selectedLessonType, rateManual])
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (data: Record<string, unknown>) => {
|
||||
const enrollment = await enrollmentMutations.create(data)
|
||||
try {
|
||||
await enrollmentMutations.generateSessions(enrollment.id, 4)
|
||||
} catch {
|
||||
// non-fatal — sessions can be generated later
|
||||
}
|
||||
return enrollment
|
||||
},
|
||||
onSuccess: (enrollment) => {
|
||||
toast.success('Enrollment created')
|
||||
navigate({ to: '/lessons/enrollments/$enrollmentId', params: { enrollmentId: enrollment.id }, search: {} as any })
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
function selectMember(member: MemberWithAccount) {
|
||||
setSelectedMember(member)
|
||||
setShowMemberDropdown(false)
|
||||
setMemberSearch('')
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!selectedMember || !selectedSlotId || !startDate) return
|
||||
|
||||
mutation.mutate({
|
||||
memberId: selectedMember.id,
|
||||
accountId: selectedMember.accountId,
|
||||
scheduleSlotId: selectedSlotId,
|
||||
instructorId: selectedSlot?.instructorId,
|
||||
startDate,
|
||||
rate: rate || undefined,
|
||||
billingInterval: billingInterval ? Number(billingInterval) : undefined,
|
||||
billingUnit: billingUnit || undefined,
|
||||
notes: notes || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const members = membersData?.data ?? []
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/lessons/enrollments', search: {} as any })}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<h1 className="text-2xl font-bold">New Enrollment</h1>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Student */}
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-lg">Student</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
{!selectedMember ? (
|
||||
<div className="relative">
|
||||
<Label>Search Member</Label>
|
||||
<div className="relative mt-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Type name to search..."
|
||||
value={memberSearch}
|
||||
onChange={(e) => { setMemberSearch(e.target.value); setShowMemberDropdown(true) }}
|
||||
onFocus={() => setShowMemberDropdown(true)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
{showMemberDropdown && memberSearch.length > 0 && (
|
||||
<div className="absolute z-50 mt-1 w-full rounded-md border bg-popover shadow-lg max-h-60 overflow-auto">
|
||||
{members.length === 0 ? (
|
||||
<div className="p-3 text-sm text-muted-foreground">No members found</div>
|
||||
) : (
|
||||
members.map((m) => (
|
||||
<button
|
||||
key={m.id}
|
||||
type="button"
|
||||
className="w-full text-left px-3 py-2 text-sm hover:bg-accent"
|
||||
onClick={() => selectMember(m)}
|
||||
>
|
||||
<span className="font-medium">{m.firstName} {m.lastName}</span>
|
||||
{m.accountName && <span className="text-muted-foreground ml-2">— {m.accountName}</span>}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between p-3 rounded-md border bg-muted/30">
|
||||
<div>
|
||||
<p className="font-medium">{selectedMember.firstName} {selectedMember.lastName}</p>
|
||||
{selectedMember.accountName && (
|
||||
<p className="text-sm text-muted-foreground">{selectedMember.accountName}</p>
|
||||
)}
|
||||
</div>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => setSelectedMember(null)}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Schedule Slot */}
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-lg">Schedule Slot</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<Label>Select Slot *</Label>
|
||||
<Select value={selectedSlotId} onValueChange={(v) => { setSelectedSlotId(v); setRateManual(false) }}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Choose a time slot..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{slots.map((slot) => (
|
||||
<SelectItem key={slot.id} value={slot.id}>
|
||||
{formatSlotLabel(slot, instructors, lessonTypes)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Terms */}
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-lg">Terms</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="startDate">Start Date *</Label>
|
||||
<Input id="startDate" type="date" value={startDate} onChange={(e) => setStartDate(e.target.value)} required className="max-w-xs" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="block mb-2">Billing Cycle</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={billingInterval}
|
||||
onChange={(e) => { setBillingInterval(e.target.value); setRateManual(false) }}
|
||||
className="w-20"
|
||||
/>
|
||||
<Select value={billingUnit} onValueChange={(v) => { setBillingUnit(v); setRateManual(false) }}>
|
||||
<SelectTrigger className="w-36">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{BILLING_UNITS.map((u) => (
|
||||
<SelectItem key={u.value} value={u.value}>{u.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="rate">Rate</Label>
|
||||
<div className="flex items-center gap-2 max-w-xs">
|
||||
<span className="text-muted-foreground">$</span>
|
||||
<Input
|
||||
id="rate"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={rate}
|
||||
onChange={(e) => { setRate(e.target.value); setRateManual(true) }}
|
||||
placeholder="Auto-filled from slot"
|
||||
/>
|
||||
</div>
|
||||
{!rateManual && rate && (
|
||||
<p className="text-xs text-muted-foreground">Auto-filled from slot rates</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="notes">Notes</Label>
|
||||
<Textarea id="notes" value={notes} onChange={(e) => setNotes(e.target.value)} rows={2} placeholder="Internal notes..." />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" disabled={mutation.isPending || !selectedMember || !selectedSlotId || !startDate} size="lg">
|
||||
{mutation.isPending ? 'Creating...' : 'Create Enrollment'}
|
||||
</Button>
|
||||
<Button variant="secondary" type="button" size="lg" onClick={() => navigate({ to: '/lessons/enrollments', search: {} as any })}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { useState } from 'react'
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { lessonPlanDetailOptions, lessonPlanMutations, lessonPlanKeys, lessonPlanItemMutations } from '@/api/lessons'
|
||||
import { GradeEntryDialog } from '@/components/lessons/grade-entry-dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { ArrowLeft, Star } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import type { LessonPlanItem } from '@/types/lesson'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/lessons/plans/$planId')({
|
||||
component: LessonPlanDetailPage,
|
||||
})
|
||||
|
||||
const STATUSES = ['not_started', 'in_progress', 'mastered', 'skipped'] as const
|
||||
type ItemStatus = typeof STATUSES[number]
|
||||
|
||||
const STATUS_LABELS: Record<ItemStatus, string> = {
|
||||
not_started: 'Not Started',
|
||||
in_progress: 'In Progress',
|
||||
mastered: 'Mastered',
|
||||
skipped: 'Skipped',
|
||||
}
|
||||
|
||||
const STATUS_VARIANTS: Record<ItemStatus, 'default' | 'secondary' | 'outline'> = {
|
||||
not_started: 'outline',
|
||||
in_progress: 'secondary',
|
||||
mastered: 'default',
|
||||
skipped: 'outline',
|
||||
}
|
||||
|
||||
function nextStatus(current: ItemStatus): ItemStatus {
|
||||
const idx = STATUSES.indexOf(current)
|
||||
return STATUSES[(idx + 1) % STATUSES.length]
|
||||
}
|
||||
|
||||
function LessonPlanDetailPage() {
|
||||
const { planId } = Route.useParams()
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const hasPermission = useAuthStore((s) => s.hasPermission)
|
||||
const canEdit = hasPermission('lessons.edit')
|
||||
|
||||
const { data: plan, isLoading } = useQuery(lessonPlanDetailOptions(planId))
|
||||
const [gradeItem, setGradeItem] = useState<LessonPlanItem | null>(null)
|
||||
const [editingTitle, setEditingTitle] = useState(false)
|
||||
const [titleInput, setTitleInput] = useState('')
|
||||
|
||||
const updatePlanMutation = useMutation({
|
||||
mutationFn: (data: Record<string, unknown>) => lessonPlanMutations.update(planId, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: lessonPlanKeys.detail(planId) })
|
||||
setEditingTitle(false)
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const updateItemMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
|
||||
lessonPlanItemMutations.update(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: lessonPlanKeys.detail(planId) })
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
if (isLoading) return <div className="text-sm text-muted-foreground">Loading...</div>
|
||||
if (!plan) return <div className="text-sm text-destructive">Plan not found.</div>
|
||||
|
||||
const totalItems = plan.sections.flatMap((s) => s.items).filter((i) => i.status !== 'skipped').length
|
||||
const masteredItems = plan.sections.flatMap((s) => s.items).filter((i) => i.status === 'mastered').length
|
||||
|
||||
function startEditTitle() {
|
||||
setTitleInput(plan!.title)
|
||||
setEditingTitle(true)
|
||||
}
|
||||
|
||||
function saveTitle() {
|
||||
if (titleInput.trim() && titleInput !== plan!.title) {
|
||||
updatePlanMutation.mutate({ title: titleInput.trim() })
|
||||
} else {
|
||||
setEditingTitle(false)
|
||||
}
|
||||
}
|
||||
|
||||
function cycleStatus(item: LessonPlanItem) {
|
||||
updateItemMutation.mutate({ id: item.id, data: { status: nextStatus(item.status as ItemStatus) } })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-3xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/lessons/plans', search: {} as any })}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex-1">
|
||||
{editingTitle ? (
|
||||
<div className="flex gap-2 items-center">
|
||||
<Input
|
||||
value={titleInput}
|
||||
onChange={(e) => setTitleInput(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') saveTitle(); if (e.key === 'Escape') setEditingTitle(false) }}
|
||||
className="text-xl font-bold h-9"
|
||||
autoFocus
|
||||
/>
|
||||
<Button size="sm" onClick={saveTitle} disabled={updatePlanMutation.isPending}>Save</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setEditingTitle(false)}>Cancel</Button>
|
||||
</div>
|
||||
) : (
|
||||
<h1
|
||||
className={`text-2xl font-bold ${canEdit ? 'cursor-pointer hover:underline decoration-dashed' : ''}`}
|
||||
onClick={canEdit ? startEditTitle : undefined}
|
||||
title={canEdit ? 'Click to edit' : undefined}
|
||||
>
|
||||
{plan.title}
|
||||
</h1>
|
||||
)}
|
||||
</div>
|
||||
<Badge variant={plan.isActive ? 'default' : 'secondary'}>{plan.isActive ? 'Active' : 'Inactive'}</Badge>
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">{masteredItems} / {totalItems} mastered</span>
|
||||
<span className="font-medium">{Math.round(plan.progress)}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-muted rounded-full h-2.5">
|
||||
<div className="bg-primary h-2.5 rounded-full transition-all" style={{ width: `${plan.progress}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sections */}
|
||||
<div className="space-y-4">
|
||||
{plan.sections.map((section) => (
|
||||
<details key={section.id} open className="border rounded-lg">
|
||||
<summary className="px-4 py-3 cursor-pointer font-semibold text-sm select-none hover:bg-muted/30 rounded-t-lg">
|
||||
{section.title}
|
||||
<span className="ml-2 text-xs font-normal text-muted-foreground">
|
||||
({section.items.filter((i) => i.status === 'mastered').length}/{section.items.length})
|
||||
</span>
|
||||
</summary>
|
||||
<div className="divide-y">
|
||||
{section.items.map((item) => (
|
||||
<div key={item.id} className="flex items-center gap-3 px-4 py-2.5">
|
||||
{canEdit ? (
|
||||
<button
|
||||
onClick={() => cycleStatus(item)}
|
||||
className="shrink-0"
|
||||
title={`Click to change: ${STATUS_LABELS[item.status as ItemStatus]}`}
|
||||
>
|
||||
<Badge
|
||||
variant={STATUS_VARIANTS[item.status as ItemStatus]}
|
||||
className={`text-xs cursor-pointer ${item.status === 'mastered' ? 'bg-green-600 text-white border-green-600' : ''}`}
|
||||
>
|
||||
{STATUS_LABELS[item.status as ItemStatus]}
|
||||
</Badge>
|
||||
</button>
|
||||
) : (
|
||||
<Badge variant={STATUS_VARIANTS[item.status as ItemStatus]} className="text-xs shrink-0">
|
||||
{STATUS_LABELS[item.status as ItemStatus]}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm">{item.title}</p>
|
||||
{item.description && <p className="text-xs text-muted-foreground">{item.description}</p>}
|
||||
</div>
|
||||
|
||||
{item.currentGradeValue && (
|
||||
<Badge variant="outline" className="text-xs shrink-0">{item.currentGradeValue}</Badge>
|
||||
)}
|
||||
|
||||
{canEdit && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 shrink-0"
|
||||
onClick={() => setGradeItem(item)}
|
||||
title="Record grade"
|
||||
>
|
||||
<Star className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{gradeItem && (
|
||||
<GradeEntryDialog
|
||||
item={gradeItem}
|
||||
open={!!gradeItem}
|
||||
onClose={() => setGradeItem(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useState } from 'react'
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { lessonPlanListOptions } from '@/api/lessons'
|
||||
import { usePagination } from '@/hooks/use-pagination'
|
||||
import { DataTable, type Column } from '@/components/shared/data-table'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Search } from 'lucide-react'
|
||||
import type { LessonPlan } from '@/types/lesson'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/lessons/plans/')({
|
||||
validateSearch: (search: Record<string, unknown>) => ({
|
||||
page: Number(search.page) || 1,
|
||||
limit: Number(search.limit) || 25,
|
||||
q: (search.q as string) || undefined,
|
||||
sort: (search.sort as string) || undefined,
|
||||
order: (search.order as 'asc' | 'desc') || 'desc',
|
||||
}),
|
||||
component: LessonPlansPage,
|
||||
})
|
||||
|
||||
const columns: Column<LessonPlan>[] = [
|
||||
{ key: 'title', header: 'Title', sortable: true, render: (p) => <span className="font-medium">{p.title}</span> },
|
||||
{
|
||||
key: 'progress', header: 'Progress', sortable: true,
|
||||
render: (p) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-24 bg-muted rounded-full h-2">
|
||||
<div className="bg-primary h-2 rounded-full" style={{ width: `${p.progress}%` }} />
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{Math.round(p.progress)}%</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'is_active', header: 'Status',
|
||||
render: (p) => <Badge variant={p.isActive ? 'default' : 'secondary'}>{p.isActive ? 'Active' : 'Inactive'}</Badge>,
|
||||
},
|
||||
{
|
||||
key: 'created_at', header: 'Created', sortable: true,
|
||||
render: (p) => <>{new Date(p.createdAt).toLocaleDateString()}</>,
|
||||
},
|
||||
]
|
||||
|
||||
function LessonPlansPage() {
|
||||
const navigate = useNavigate()
|
||||
const { params, setPage, setSearch, setSort } = usePagination()
|
||||
const [searchInput, setSearchInput] = useState(params.q ?? '')
|
||||
|
||||
const { data, isLoading } = useQuery(lessonPlanListOptions(params))
|
||||
|
||||
function handleSearchSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setSearch(searchInput)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold">Lesson Plans</h1>
|
||||
|
||||
<form onSubmit={handleSearchSubmit} className="flex gap-2 max-w-sm">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search lesson plans..."
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" variant="secondary">Search</Button>
|
||||
</form>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={data?.data ?? []}
|
||||
loading={isLoading}
|
||||
page={params.page}
|
||||
totalPages={data?.pagination.totalPages ?? 1}
|
||||
total={data?.pagination.total ?? 0}
|
||||
sort={params.sort}
|
||||
order={params.order}
|
||||
onPageChange={setPage}
|
||||
onSort={setSort}
|
||||
onRowClick={(p) => navigate({ to: '/lessons/plans/$planId', params: { planId: p.id }, search: {} as any })}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
import { useState } from 'react'
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
instructorListOptions, instructorMutations, instructorKeys,
|
||||
lessonTypeListOptions, lessonTypeMutations, lessonTypeKeys,
|
||||
gradingScaleListOptions, gradingScaleMutations, gradingScaleKeys,
|
||||
storeClosureListOptions, storeClosureMutations, storeClosureKeys,
|
||||
} from '@/api/lessons'
|
||||
import { usePagination } from '@/hooks/use-pagination'
|
||||
import { DataTable, type Column } from '@/components/shared/data-table'
|
||||
import { InstructorForm } from '@/components/lessons/instructor-form'
|
||||
import { LessonTypeForm } from '@/components/lessons/lesson-type-form'
|
||||
import { GradingScaleForm } from '@/components/lessons/grading-scale-form'
|
||||
import { StoreClosureForm } from '@/components/lessons/store-closure-form'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
||||
import { Plus, Search, Trash2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import type { Instructor, LessonType, GradingScale, StoreClosure } from '@/types/lesson'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/lessons/schedule/')({
|
||||
validateSearch: (search: Record<string, unknown>) => ({
|
||||
tab: (search.tab as string) || 'instructors',
|
||||
page: Number(search.page) || 1,
|
||||
limit: Number(search.limit) || 25,
|
||||
q: (search.q as string) || undefined,
|
||||
sort: (search.sort as string) || undefined,
|
||||
order: (search.order as 'asc' | 'desc') || 'asc',
|
||||
}),
|
||||
component: ScheduleHubPage,
|
||||
})
|
||||
|
||||
const TABS = [
|
||||
{ key: 'instructors', label: 'Instructors' },
|
||||
{ key: 'lesson-types', label: 'Lesson Types' },
|
||||
{ key: 'grading-scales', label: 'Grading Scales' },
|
||||
{ key: 'closures', label: 'Store Closures' },
|
||||
]
|
||||
|
||||
function ScheduleHubPage() {
|
||||
const navigate = useNavigate()
|
||||
const search = Route.useSearch()
|
||||
const tab = search.tab
|
||||
const hasPermission = useAuthStore((s) => s.hasPermission)
|
||||
const canAdmin = hasPermission('lessons.admin')
|
||||
|
||||
function setTab(t: string) {
|
||||
navigate({ to: '/lessons/schedule', search: { ...search, tab: t, page: 1 } as any })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold">Lessons Setup</h1>
|
||||
<div className="flex gap-1 border-b">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => setTab(t.key)}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
tab === t.key
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === 'instructors' && <InstructorsTab canAdmin={canAdmin} search={search} />}
|
||||
{tab === 'lesson-types' && <LessonTypesTab canAdmin={canAdmin} search={search} />}
|
||||
{tab === 'grading-scales' && <GradingScalesTab canAdmin={canAdmin} search={search} />}
|
||||
{tab === 'closures' && <StoreClosuresTab canAdmin={canAdmin} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Instructors Tab ──────────────────────────────────────────────────────────
|
||||
|
||||
const instructorColumns: Column<Instructor>[] = [
|
||||
{ key: 'display_name', header: 'Name', sortable: true, render: (i) => <span className="font-medium">{i.displayName}</span> },
|
||||
{ key: 'instruments', header: 'Instruments', render: (i) => <>{i.instruments?.join(', ') || <span className="text-muted-foreground">—</span>}</> },
|
||||
{
|
||||
key: 'is_active', header: 'Status', sortable: true,
|
||||
render: (i) => <Badge variant={i.isActive ? 'default' : 'secondary'}>{i.isActive ? 'Active' : 'Inactive'}</Badge>,
|
||||
},
|
||||
]
|
||||
|
||||
function InstructorsTab({ canAdmin, search: _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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
import { useState } from 'react'
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
instructorDetailOptions, instructorMutations, instructorKeys,
|
||||
instructorBlockedDatesOptions,
|
||||
scheduleSlotListOptions, scheduleSlotMutations, scheduleSlotKeys,
|
||||
lessonTypeListOptions,
|
||||
} from '@/api/lessons'
|
||||
import { InstructorForm } from '@/components/lessons/instructor-form'
|
||||
import { ScheduleSlotForm } from '@/components/lessons/schedule-slot-form'
|
||||
import { BlockedDateForm } from '@/components/lessons/blocked-date-form'
|
||||
import { WeeklySlotGrid } from '@/components/lessons/weekly-slot-grid'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
||||
import { ArrowLeft, Plus, Trash2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import type { ScheduleSlot } from '@/types/lesson'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/lessons/schedule/instructors/$instructorId')({
|
||||
validateSearch: (search: Record<string, unknown>) => ({
|
||||
tab: (search.tab as string) || 'overview',
|
||||
}),
|
||||
component: InstructorDetailPage,
|
||||
})
|
||||
|
||||
const TABS = [
|
||||
{ key: 'overview', label: 'Overview' },
|
||||
{ key: 'slots', label: 'Schedule Slots' },
|
||||
{ key: 'blocked', label: 'Blocked Dates' },
|
||||
]
|
||||
|
||||
function InstructorDetailPage() {
|
||||
const { instructorId } = Route.useParams()
|
||||
const search = Route.useSearch()
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const hasPermission = useAuthStore((s) => s.hasPermission)
|
||||
const canAdmin = hasPermission('lessons.admin')
|
||||
const tab = search.tab
|
||||
|
||||
function setTab(t: string) {
|
||||
navigate({ to: '/lessons/schedule/instructors/$instructorId', params: { instructorId }, search: { tab: t } as any })
|
||||
}
|
||||
|
||||
const { data: instructor, isLoading } = useQuery(instructorDetailOptions(instructorId))
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: Record<string, unknown>) => instructorMutations.update(instructorId, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: instructorKeys.detail(instructorId) })
|
||||
toast.success('Instructor updated')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
if (isLoading) return <div className="text-sm text-muted-foreground">Loading...</div>
|
||||
if (!instructor) return <div className="text-sm text-destructive">Instructor not found.</div>
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/lessons/schedule', search: { tab: 'instructors' } as any })}>
|
||||
<ArrowLeft className="h-4 w-4 mr-1" />Back
|
||||
</Button>
|
||||
<div className="flex-1">
|
||||
<h1 className="text-2xl font-bold">{instructor.displayName}</h1>
|
||||
{instructor.instruments && instructor.instruments.length > 0 && (
|
||||
<p className="text-sm text-muted-foreground">{instructor.instruments.join(', ')}</p>
|
||||
)}
|
||||
</div>
|
||||
<Badge variant={instructor.isActive ? 'default' : 'secondary'}>
|
||||
{instructor.isActive ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1 border-b">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => setTab(t.key)}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
tab === t.key
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === 'overview' && (
|
||||
<div className="max-w-lg">
|
||||
<InstructorForm
|
||||
defaultValues={instructor}
|
||||
onSubmit={updateMutation.mutate}
|
||||
loading={updateMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{tab === 'slots' && <ScheduleSlotsTab instructorId={instructorId} canAdmin={canAdmin} />}
|
||||
{tab === 'blocked' && <BlockedDatesTab instructorId={instructorId} canAdmin={canAdmin} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Schedule Slots Tab ───────────────────────────────────────────────────────
|
||||
|
||||
function ScheduleSlotsTab({ instructorId, canAdmin }: { instructorId: string; canAdmin: boolean }) {
|
||||
const queryClient = useQueryClient()
|
||||
const [addOpen, setAddOpen] = useState(false)
|
||||
const [editSlot, setEditSlot] = useState<ScheduleSlot | null>(null)
|
||||
|
||||
const { data: slotsData } = useQuery(scheduleSlotListOptions({ page: 1, limit: 100, order: 'asc' }, { instructorId }))
|
||||
const { data: lessonTypesData } = useQuery(lessonTypeListOptions({ page: 1, limit: 100, order: 'asc' }))
|
||||
|
||||
const slots = slotsData?.data ?? []
|
||||
const lessonTypes = lessonTypesData?.data ?? []
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: Record<string, unknown>) => scheduleSlotMutations.create({ ...data, instructorId }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: scheduleSlotKeys.all })
|
||||
toast.success('Schedule slot added')
|
||||
setAddOpen(false)
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) => scheduleSlotMutations.update(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: scheduleSlotKeys.all })
|
||||
toast.success('Slot updated')
|
||||
setEditSlot(null)
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: scheduleSlotMutations.delete,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: scheduleSlotKeys.all })
|
||||
toast.success('Slot removed')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{canAdmin && (
|
||||
<div className="flex justify-end">
|
||||
<Dialog open={addOpen} onOpenChange={setAddOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button><Plus className="mr-2 h-4 w-4" />Add Slot</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>Add Schedule Slot</DialogTitle></DialogHeader>
|
||||
<ScheduleSlotForm
|
||||
lessonTypes={lessonTypes}
|
||||
onSubmit={createMutation.mutate}
|
||||
loading={createMutation.isPending}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editSlot && (
|
||||
<Dialog open={!!editSlot} onOpenChange={(o) => { if (!o) setEditSlot(null) }}>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>Edit Schedule Slot</DialogTitle></DialogHeader>
|
||||
<ScheduleSlotForm
|
||||
lessonTypes={lessonTypes}
|
||||
defaultValues={editSlot}
|
||||
onSubmit={(data) => updateMutation.mutate({ id: editSlot.id, data })}
|
||||
loading={updateMutation.isPending}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
<WeeklySlotGrid
|
||||
slots={slots}
|
||||
lessonTypes={lessonTypes}
|
||||
onEdit={setEditSlot}
|
||||
onDelete={(slot) => deleteMutation.mutate(slot.id)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Blocked Dates Tab ────────────────────────────────────────────────────────
|
||||
|
||||
function BlockedDatesTab({ instructorId, canAdmin }: { instructorId: string; canAdmin: boolean }) {
|
||||
const queryClient = useQueryClient()
|
||||
const [addOpen, setAddOpen] = useState(false)
|
||||
|
||||
const { data: blockedDates, isLoading } = useQuery(instructorBlockedDatesOptions(instructorId))
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: Record<string, unknown>) =>
|
||||
instructorMutations.addBlockedDate(instructorId, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: instructorKeys.blockedDates(instructorId) })
|
||||
toast.success('Blocked date added')
|
||||
setAddOpen(false)
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => instructorMutations.deleteBlockedDate(instructorId, id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: instructorKeys.blockedDates(instructorId) })
|
||||
toast.success('Blocked date removed')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const dates = blockedDates ?? []
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{canAdmin && (
|
||||
<div className="flex justify-end">
|
||||
<Dialog open={addOpen} onOpenChange={setAddOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button><Plus className="mr-2 h-4 w-4" />Add Blocked Date</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>Add Blocked Date</DialogTitle></DialogHeader>
|
||||
<BlockedDateForm onSubmit={createMutation.mutate} loading={createMutation.isPending} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-sm text-muted-foreground">Loading...</div>
|
||||
) : dates.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground text-center py-8 border rounded-md">
|
||||
No blocked dates configured.
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y border rounded-md">
|
||||
{dates.map((d) => (
|
||||
<div key={d.id} className="flex items-center justify-between px-4 py-3">
|
||||
<div>
|
||||
<div className="font-medium text-sm">
|
||||
{new Date(d.startDate + 'T00:00:00').toLocaleDateString()} –{' '}
|
||||
{new Date(d.endDate + 'T00:00:00').toLocaleDateString()}
|
||||
</div>
|
||||
{d.reason && <div className="text-xs text-muted-foreground">{d.reason}</div>}
|
||||
</div>
|
||||
{canAdmin && (
|
||||
<Button variant="ghost" size="sm" onClick={() => deleteMutation.mutate(d.id)}>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
import { useState } from 'react'
|
||||
import { createFileRoute, useNavigate, Link } from '@tanstack/react-router'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
sessionDetailOptions, sessionMutations, sessionKeys,
|
||||
sessionPlanItemsOptions,
|
||||
enrollmentDetailOptions,
|
||||
instructorDetailOptions, instructorListOptions,
|
||||
lessonPlanListOptions,
|
||||
} from '@/api/lessons'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { ArrowLeft, CheckSquare, Square } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import type { LessonPlan, LessonPlanSection } from '@/types/lesson'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/lessons/sessions/$sessionId')({
|
||||
component: SessionDetailPage,
|
||||
})
|
||||
|
||||
const STATUS_ACTIONS: Record<string, { label: string; next: string; variant: 'default' | 'destructive' | 'secondary' | 'outline' }[]> = {
|
||||
scheduled: [
|
||||
{ label: 'Mark Attended', next: 'attended', variant: 'default' },
|
||||
{ label: 'Mark Missed', next: 'missed', variant: 'destructive' },
|
||||
{ label: 'Cancel', next: 'cancelled', variant: 'secondary' },
|
||||
],
|
||||
attended: [],
|
||||
missed: [],
|
||||
makeup: [
|
||||
{ label: 'Mark Attended', next: 'attended', variant: 'default' },
|
||||
{ label: 'Cancel', next: 'cancelled', variant: 'secondary' },
|
||||
],
|
||||
cancelled: [],
|
||||
}
|
||||
|
||||
function sessionStatusBadge(status: string) {
|
||||
const variants: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||
scheduled: 'outline', attended: 'default', missed: 'destructive', makeup: 'secondary', cancelled: 'secondary',
|
||||
}
|
||||
return <Badge variant={variants[status] ?? 'outline'}>{status}</Badge>
|
||||
}
|
||||
|
||||
function formatTime(t: string) {
|
||||
const [h, m] = t.split(':').map(Number)
|
||||
const ampm = h >= 12 ? 'PM' : 'AM'
|
||||
const hour = h % 12 || 12
|
||||
return `${hour}:${String(m).padStart(2, '0')} ${ampm}`
|
||||
}
|
||||
|
||||
function SessionDetailPage() {
|
||||
const { sessionId } = Route.useParams()
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const hasPermission = useAuthStore((s) => s.hasPermission)
|
||||
const canEdit = hasPermission('lessons.edit')
|
||||
|
||||
const { data: session, isLoading } = useQuery(sessionDetailOptions(sessionId))
|
||||
|
||||
const { data: enrollment } = useQuery({
|
||||
...enrollmentDetailOptions(session?.enrollmentId ?? ''),
|
||||
enabled: !!session?.enrollmentId,
|
||||
})
|
||||
|
||||
useQuery({
|
||||
...instructorDetailOptions(session?.substituteInstructorId ?? enrollment?.instructorId ?? ''),
|
||||
enabled: !!(session?.substituteInstructorId ?? enrollment?.instructorId),
|
||||
})
|
||||
|
||||
const { data: instructorsList } = useQuery(instructorListOptions({ page: 1, limit: 100, order: 'asc' }))
|
||||
|
||||
const { data: planItems } = useQuery(sessionPlanItemsOptions(sessionId))
|
||||
|
||||
const { data: plansData } = useQuery({
|
||||
...lessonPlanListOptions({ enrollmentId: session?.enrollmentId ?? '', isActive: true }),
|
||||
enabled: !!session?.enrollmentId,
|
||||
})
|
||||
const activePlan: LessonPlan | undefined = plansData?.data?.[0]
|
||||
|
||||
const statusMutation = useMutation({
|
||||
mutationFn: (status: string) => sessionMutations.updateStatus(sessionId, status),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: sessionKeys.detail(sessionId) })
|
||||
toast.success('Status updated')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const notesMutation = useMutation({
|
||||
mutationFn: (data: Record<string, unknown>) => sessionMutations.updateNotes(sessionId, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: sessionKeys.detail(sessionId) })
|
||||
toast.success('Notes saved')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const subMutation = useMutation({
|
||||
mutationFn: (subId: string | null) => sessionMutations.update(sessionId, { substituteInstructorId: subId }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: sessionKeys.detail(sessionId) })
|
||||
toast.success('Substitute updated')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const linkPlanItemsMutation = useMutation({
|
||||
mutationFn: (ids: string[]) => sessionMutations.linkPlanItems(sessionId, ids),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: sessionKeys.planItems(sessionId) })
|
||||
toast.success('Plan items linked')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
if (isLoading) return <div className="text-sm text-muted-foreground">Loading...</div>
|
||||
if (!session) return <div className="text-sm text-destructive">Session not found.</div>
|
||||
|
||||
const linkedItemIds = new Set(planItems?.map((pi) => pi.lessonPlanItemId) ?? [])
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-3xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/lessons/sessions', search: {} as any })}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex-1">
|
||||
<h1 className="text-xl font-bold">
|
||||
{new Date(session.scheduledDate + 'T00:00:00').toLocaleDateString()} · {formatTime(session.scheduledTime)}
|
||||
</h1>
|
||||
{enrollment && (
|
||||
<Link
|
||||
to="/lessons/enrollments/$enrollmentId"
|
||||
params={{ enrollmentId: enrollment.id }}
|
||||
search={{} as any}
|
||||
className="text-sm text-primary hover:underline"
|
||||
>
|
||||
View Enrollment
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
{sessionStatusBadge(session.status)}
|
||||
</div>
|
||||
|
||||
{/* Status Actions */}
|
||||
{canEdit && (STATUS_ACTIONS[session.status]?.length ?? 0) > 0 && (
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex gap-2">
|
||||
{STATUS_ACTIONS[session.status].map((action) => (
|
||||
<Button
|
||||
key={action.next}
|
||||
variant={action.variant}
|
||||
size="sm"
|
||||
onClick={() => statusMutation.mutate(action.next)}
|
||||
disabled={statusMutation.isPending}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Substitute Instructor */}
|
||||
{canEdit && (
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-base">Substitute Instructor</CardTitle></CardHeader>
|
||||
<CardContent className="flex gap-3 items-center">
|
||||
<Select
|
||||
value={session.substituteInstructorId ?? 'none'}
|
||||
onValueChange={(v) => subMutation.mutate(v === 'none' ? null : v)}
|
||||
>
|
||||
<SelectTrigger className="flex-1">
|
||||
<SelectValue placeholder="No substitute" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">No substitute</SelectItem>
|
||||
{(instructorsList?.data ?? []).map((i) => (
|
||||
<SelectItem key={i.id} value={i.id}>{i.displayName}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Post-lesson Notes */}
|
||||
<NotesCard session={session} canEdit={canEdit} onSave={notesMutation.mutate} saving={notesMutation.isPending} />
|
||||
|
||||
{/* Plan Items */}
|
||||
{activePlan && (
|
||||
<PlanItemsCard
|
||||
plan={activePlan}
|
||||
linkedItemIds={linkedItemIds}
|
||||
onLink={(ids) => linkPlanItemsMutation.mutate(ids)}
|
||||
linking={linkPlanItemsMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Notes Card ───────────────────────────────────────────────────────────────
|
||||
|
||||
function NotesCard({ session, canEdit, onSave, saving }: any) {
|
||||
const [instructorNotes, setInstructorNotes] = useState(session.instructorNotes ?? '')
|
||||
const [memberNotes, setMemberNotes] = useState(session.memberNotes ?? '')
|
||||
const [homeworkAssigned, setHomeworkAssigned] = useState(session.homeworkAssigned ?? '')
|
||||
const [nextLessonGoals, setNextLessonGoals] = useState(session.nextLessonGoals ?? '')
|
||||
const [topicsCovered, setTopicsCovered] = useState((session.topicsCovered ?? []).join(', '))
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base">Post-lesson Notes</CardTitle>
|
||||
{session.notesCompletedAt && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Saved {new Date(session.notesCompletedAt).toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Instructor Notes</Label>
|
||||
<Textarea value={instructorNotes} onChange={(e) => setInstructorNotes(e.target.value)} rows={3} disabled={!canEdit} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Member Notes (shared with student)</Label>
|
||||
<Textarea value={memberNotes} onChange={(e) => setMemberNotes(e.target.value)} rows={2} disabled={!canEdit} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Homework Assigned</Label>
|
||||
<Input value={homeworkAssigned} onChange={(e) => setHomeworkAssigned(e.target.value)} disabled={!canEdit} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Next Lesson Goals</Label>
|
||||
<Input value={nextLessonGoals} onChange={(e) => setNextLessonGoals(e.target.value)} disabled={!canEdit} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Topics Covered</Label>
|
||||
<Input
|
||||
value={topicsCovered}
|
||||
onChange={(e) => setTopicsCovered(e.target.value)}
|
||||
placeholder="Comma-separated topics"
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
{canEdit && (
|
||||
<Button
|
||||
onClick={() => onSave({
|
||||
instructorNotes: instructorNotes || undefined,
|
||||
memberNotes: memberNotes || undefined,
|
||||
homeworkAssigned: homeworkAssigned || undefined,
|
||||
nextLessonGoals: nextLessonGoals || undefined,
|
||||
topicsCovered: topicsCovered ? topicsCovered.split(',').map((s: string) => s.trim()).filter(Boolean) : undefined,
|
||||
})}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save Notes'}
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Plan Items Card ──────────────────────────────────────────────────────────
|
||||
|
||||
function PlanItemsCard({ plan, linkedItemIds, onLink, linking }: {
|
||||
plan: LessonPlan
|
||||
linkedItemIds: Set<string>
|
||||
onLink: (ids: string[]) => void
|
||||
linking: boolean
|
||||
}) {
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set(linkedItemIds))
|
||||
|
||||
function toggle(id: string) {
|
||||
if (linkedItemIds.has(id)) return // already committed
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const newSelections = [...selected].filter((id) => !linkedItemIds.has(id))
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-base">Plan Items Worked On</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{(plan.sections ?? []).map((section: LessonPlanSection) => (
|
||||
<div key={section.id}>
|
||||
<p className="text-sm font-semibold text-muted-foreground mb-2">{section.title}</p>
|
||||
<div className="space-y-1">
|
||||
{(section.items ?? []).map((item) => {
|
||||
const isLinked = linkedItemIds.has(item.id)
|
||||
const isSelected = selected.has(item.id)
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className={`flex items-center gap-2 w-full text-left px-2 py-1.5 rounded text-sm transition-colors ${
|
||||
isLinked ? 'opacity-60 cursor-default' : 'hover:bg-accent cursor-pointer'
|
||||
}`}
|
||||
onClick={() => toggle(item.id)}
|
||||
disabled={isLinked}
|
||||
>
|
||||
{isLinked || isSelected
|
||||
? <CheckSquare className="h-4 w-4 text-primary shrink-0" />
|
||||
: <Square className="h-4 w-4 text-muted-foreground shrink-0" />}
|
||||
<span>{item.title}</span>
|
||||
{isLinked && <span className="text-xs text-muted-foreground ml-auto">linked</span>}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{newSelections.length > 0 && (
|
||||
<Button onClick={() => onLink(newSelections)} disabled={linking} size="sm">
|
||||
{linking ? 'Linking...' : `Link ${newSelections.length} item${newSelections.length !== 1 ? 's' : ''}`}
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
import { useState } from 'react'
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
lessonPlanTemplateDetailOptions, lessonPlanTemplateMutations, lessonPlanTemplateKeys,
|
||||
enrollmentListOptions,
|
||||
} from '@/api/lessons'
|
||||
import { globalMemberListOptions } from '@/api/members'
|
||||
import { TemplateSectionBuilder, type TemplateSectionRow } from '@/components/lessons/template-section-builder'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { ArrowLeft, Search, X, Zap } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import type { LessonPlanTemplate } from '@/types/lesson'
|
||||
import type { MemberWithAccount } from '@/api/members'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/lessons/templates/$templateId')({
|
||||
component: TemplateDetailPage,
|
||||
})
|
||||
|
||||
function TemplateDetailPage() {
|
||||
const { templateId } = Route.useParams()
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const hasPermission = useAuthStore((s) => s.hasPermission)
|
||||
const canAdmin = hasPermission('lessons.admin')
|
||||
|
||||
const { data: template, isLoading } = useQuery(lessonPlanTemplateDetailOptions(templateId))
|
||||
|
||||
const [instantiateOpen, setInstantiateOpen] = useState(false)
|
||||
|
||||
if (isLoading) return <div className="text-sm text-muted-foreground">Loading...</div>
|
||||
if (!template) return <div className="text-sm text-destructive">Template not found.</div>
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-3xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/lessons/templates', search: {} as any })}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex-1">
|
||||
<h1 className="text-2xl font-bold">{template.name}</h1>
|
||||
{template.instrument && <p className="text-sm text-muted-foreground">{template.instrument}</p>}
|
||||
</div>
|
||||
<Badge variant={template.isActive ? 'default' : 'secondary'}>{template.isActive ? 'Active' : 'Inactive'}</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={() => setInstantiateOpen(true)}>
|
||||
<Zap className="h-4 w-4 mr-2" />Instantiate for Student
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{canAdmin && (
|
||||
<EditTemplateForm template={template} templateId={templateId} queryClient={queryClient} />
|
||||
)}
|
||||
|
||||
{/* Read-only curriculum preview */}
|
||||
{!canAdmin && (
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-lg">Curriculum</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{template.sections.map((section) => (
|
||||
<div key={section.id}>
|
||||
<p className="font-semibold text-sm">{section.title}</p>
|
||||
<ul className="mt-1 space-y-0.5 pl-4">
|
||||
{section.items.map((item) => (
|
||||
<li key={item.id} className="text-sm text-muted-foreground list-disc">{item.title}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<InstantiateDialog
|
||||
template={template}
|
||||
templateId={templateId}
|
||||
open={instantiateOpen}
|
||||
onClose={() => setInstantiateOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Edit Form ────────────────────────────────────────────────────────────────
|
||||
|
||||
function EditTemplateForm({ template, templateId, queryClient }: { template: LessonPlanTemplate; templateId: string; queryClient: any }) {
|
||||
const [name, setName] = useState(template.name)
|
||||
const [description, setDescription] = useState(template.description ?? '')
|
||||
const [instrument, setInstrument] = useState(template.instrument ?? '')
|
||||
const [skillLevel, setSkillLevel] = useState<'beginner' | 'intermediate' | 'advanced' | 'all_levels'>(template.skillLevel)
|
||||
const [sections, setSections] = useState<TemplateSectionRow[]>(
|
||||
template.sections.map((s) => ({
|
||||
id: s.id,
|
||||
title: s.title,
|
||||
description: s.description ?? '',
|
||||
items: s.items.map((i) => ({ id: i.id, title: i.title, description: i.description ?? '' })),
|
||||
})),
|
||||
)
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
lessonPlanTemplateMutations.update(templateId, {
|
||||
name,
|
||||
description: description || undefined,
|
||||
instrument: instrument || undefined,
|
||||
skillLevel,
|
||||
sections: sections.map((s, sIdx) => ({
|
||||
title: s.title,
|
||||
description: s.description || undefined,
|
||||
sortOrder: sIdx,
|
||||
items: s.items.map((item, iIdx) => ({
|
||||
title: item.title,
|
||||
description: item.description || undefined,
|
||||
sortOrder: iIdx,
|
||||
})),
|
||||
})),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: lessonPlanTemplateKeys.detail(templateId) })
|
||||
toast.success('Template updated')
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const allValid = name.trim() && sections.every((s) => s.title.trim() && s.items.every((i) => i.title.trim()))
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={(e) => { e.preventDefault(); updateMutation.mutate() }}
|
||||
className="space-y-6"
|
||||
>
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-lg">Details</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Name *</Label>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Description</Label>
|
||||
<Textarea value={description} onChange={(e) => setDescription(e.target.value)} rows={2} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Instrument</Label>
|
||||
<Input value={instrument} onChange={(e) => setInstrument(e.target.value)} placeholder="e.g. Piano" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Skill Level</Label>
|
||||
<Select value={skillLevel} onValueChange={(v) => setSkillLevel(v as 'beginner' | 'intermediate' | 'advanced' | 'all_levels')}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="beginner">Beginner</SelectItem>
|
||||
<SelectItem value="intermediate">Intermediate</SelectItem>
|
||||
<SelectItem value="advanced">Advanced</SelectItem>
|
||||
<SelectItem value="all_levels">All Levels</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-lg">Curriculum</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<TemplateSectionBuilder sections={sections} onChange={setSections} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Button type="submit" disabled={updateMutation.isPending || !allValid}>
|
||||
{updateMutation.isPending ? 'Saving...' : 'Save Changes'}
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Instantiate Dialog ───────────────────────────────────────────────────────
|
||||
|
||||
function InstantiateDialog({ template, templateId, open, onClose }: {
|
||||
template: LessonPlanTemplate
|
||||
templateId: string
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}) {
|
||||
const navigate = useNavigate()
|
||||
const [memberSearch, setMemberSearch] = useState('')
|
||||
const [showDropdown, setShowDropdown] = useState(false)
|
||||
const [selectedMember, setSelectedMember] = useState<MemberWithAccount | null>(null)
|
||||
const [selectedEnrollmentId, setSelectedEnrollmentId] = useState('')
|
||||
const [customTitle, setCustomTitle] = useState('')
|
||||
|
||||
const { data: membersData } = useQuery(
|
||||
globalMemberListOptions({ page: 1, limit: 20, q: memberSearch || undefined, order: 'asc', sort: 'first_name' }),
|
||||
)
|
||||
|
||||
const { data: enrollmentsData } = useQuery({
|
||||
...enrollmentListOptions({ memberId: selectedMember?.id ?? '', status: 'active', page: 1, limit: 50 }),
|
||||
enabled: !!selectedMember?.id,
|
||||
})
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () =>
|
||||
lessonPlanTemplateMutations.createPlan(templateId, {
|
||||
memberId: selectedMember!.id,
|
||||
enrollmentId: selectedEnrollmentId || undefined,
|
||||
title: customTitle || undefined,
|
||||
}),
|
||||
onSuccess: (plan) => {
|
||||
toast.success('Plan created from template')
|
||||
navigate({ to: '/lessons/plans/$planId', params: { planId: plan.id }, search: {} as any })
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const members = membersData?.data ?? []
|
||||
const enrollments = enrollmentsData?.data ?? []
|
||||
|
||||
function reset() {
|
||||
setMemberSearch('')
|
||||
setSelectedMember(null)
|
||||
setSelectedEnrollmentId('')
|
||||
setCustomTitle('')
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => { if (!o) { reset(); onClose() } }}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Plan from "{template.name}"</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
{/* Member select */}
|
||||
{!selectedMember ? (
|
||||
<div className="relative">
|
||||
<Label>Student *</Label>
|
||||
<div className="relative mt-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search member..."
|
||||
value={memberSearch}
|
||||
onChange={(e) => { setMemberSearch(e.target.value); setShowDropdown(true) }}
|
||||
onFocus={() => setShowDropdown(true)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
{showDropdown && memberSearch && (
|
||||
<div className="absolute z-50 mt-1 w-full rounded-md border bg-popover shadow-lg max-h-48 overflow-auto">
|
||||
{members.length === 0 ? (
|
||||
<div className="p-3 text-sm text-muted-foreground">No members found</div>
|
||||
) : (
|
||||
members.map((m) => (
|
||||
<button
|
||||
key={m.id}
|
||||
type="button"
|
||||
className="w-full text-left px-3 py-2 text-sm hover:bg-accent"
|
||||
onClick={() => { setSelectedMember(m); setShowDropdown(false); setMemberSearch('') }}
|
||||
>
|
||||
<span className="font-medium">{m.firstName} {m.lastName}</span>
|
||||
{m.accountName && <span className="text-muted-foreground ml-2">— {m.accountName}</span>}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<Label>Student</Label>
|
||||
<div className="flex items-center justify-between mt-1 p-2 rounded-md border bg-muted/30">
|
||||
<p className="text-sm font-medium">{selectedMember.firstName} {selectedMember.lastName}</p>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => { setSelectedMember(null); setSelectedEnrollmentId('') }}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedMember && enrollments.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label>Enrollment (optional)</Label>
|
||||
<Select value={selectedEnrollmentId || 'none'} onValueChange={(v) => setSelectedEnrollmentId(v === 'none' ? '' : v)}>
|
||||
<SelectTrigger><SelectValue placeholder="Not linked to enrollment" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">Not linked to enrollment</SelectItem>
|
||||
{enrollments.map((e: any) => (
|
||||
<SelectItem key={e.id} value={e.id}>Enrollment {e.id.slice(-6)}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Custom Title</Label>
|
||||
<Input value={customTitle} onChange={(e) => setCustomTitle(e.target.value)} placeholder={`Leave blank to use "${template.name}"`} />
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => mutation.mutate()}
|
||||
disabled={!selectedMember || mutation.isPending}
|
||||
className="w-full"
|
||||
>
|
||||
{mutation.isPending ? 'Creating...' : 'Create Plan'}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { useState } from 'react'
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { lessonPlanTemplateListOptions, lessonPlanTemplateMutations, lessonPlanTemplateKeys } from '@/api/lessons'
|
||||
import { usePagination } from '@/hooks/use-pagination'
|
||||
import { DataTable, type Column } from '@/components/shared/data-table'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Plus, Search, Trash2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import type { LessonPlanTemplate } from '@/types/lesson'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/lessons/templates/')({
|
||||
validateSearch: (search: Record<string, unknown>) => ({
|
||||
page: Number(search.page) || 1,
|
||||
limit: Number(search.limit) || 25,
|
||||
q: (search.q as string) || undefined,
|
||||
sort: (search.sort as string) || undefined,
|
||||
order: (search.order as 'asc' | 'desc') || 'asc',
|
||||
}),
|
||||
component: TemplatesListPage,
|
||||
})
|
||||
|
||||
const SKILL_LABELS: Record<string, string> = {
|
||||
beginner: 'Beginner',
|
||||
intermediate: 'Intermediate',
|
||||
advanced: 'Advanced',
|
||||
all_levels: 'All Levels',
|
||||
}
|
||||
|
||||
const SKILL_VARIANTS: Record<string, 'default' | 'secondary' | 'outline'> = {
|
||||
beginner: 'outline',
|
||||
intermediate: 'secondary',
|
||||
advanced: 'default',
|
||||
all_levels: 'outline',
|
||||
}
|
||||
|
||||
const columns: Column<LessonPlanTemplate>[] = [
|
||||
{ key: 'name', header: 'Name', sortable: true, render: (t) => <span className="font-medium">{t.name}</span> },
|
||||
{ key: 'instrument', header: 'Instrument', render: (t) => <>{t.instrument ?? <span className="text-muted-foreground">—</span>}</> },
|
||||
{
|
||||
key: 'skill_level', header: 'Level', sortable: true,
|
||||
render: (t) => <Badge variant={SKILL_VARIANTS[t.skillLevel] ?? 'outline'}>{SKILL_LABELS[t.skillLevel] ?? t.skillLevel}</Badge>,
|
||||
},
|
||||
{
|
||||
key: 'sections', header: 'Sections',
|
||||
render: (t) => <>{t.sections?.length ?? 0} sections</>,
|
||||
},
|
||||
{
|
||||
key: 'is_active', header: 'Status',
|
||||
render: (t) => <Badge variant={t.isActive ? 'default' : 'secondary'}>{t.isActive ? 'Active' : 'Inactive'}</Badge>,
|
||||
},
|
||||
]
|
||||
|
||||
function TemplatesListPage() {
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const hasPermission = useAuthStore((s) => s.hasPermission)
|
||||
const canAdmin = hasPermission('lessons.admin')
|
||||
const { params, setPage, setSearch, setSort } = usePagination()
|
||||
const [searchInput, setSearchInput] = useState(params.q ?? '')
|
||||
|
||||
const { data, isLoading } = useQuery(lessonPlanTemplateListOptions(params))
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: lessonPlanTemplateMutations.delete,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: lessonPlanTemplateKeys.all })
|
||||
toast.success('Template deleted')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
function handleSearchSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setSearch(searchInput)
|
||||
}
|
||||
|
||||
const columnsWithActions: Column<LessonPlanTemplate>[] = [
|
||||
...columns,
|
||||
...(canAdmin ? [{
|
||||
key: 'actions' as any,
|
||||
header: '' as any,
|
||||
render: (t: LessonPlanTemplate) => (
|
||||
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); deleteMutation.mutate(t.id) }}>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
),
|
||||
}] : []),
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Lesson Plan Templates</h1>
|
||||
{canAdmin && (
|
||||
<Button onClick={() => navigate({ to: '/lessons/templates/new', search: {} as any })}>
|
||||
<Plus className="mr-2 h-4 w-4" />New Template
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSearchSubmit} className="flex gap-2 max-w-sm">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search templates..."
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" variant="secondary">Search</Button>
|
||||
</form>
|
||||
|
||||
<DataTable
|
||||
columns={columnsWithActions}
|
||||
data={data?.data ?? []}
|
||||
loading={isLoading}
|
||||
page={params.page}
|
||||
totalPages={data?.pagination.totalPages ?? 1}
|
||||
total={data?.pagination.total ?? 0}
|
||||
sort={params.sort}
|
||||
order={params.order}
|
||||
onPageChange={setPage}
|
||||
onSort={setSort}
|
||||
onRowClick={(t) => navigate({ to: '/lessons/templates/$templateId', params: { templateId: t.id }, search: {} as any })}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { useState } from 'react'
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { lessonPlanTemplateMutations } from '@/api/lessons'
|
||||
import { TemplateSectionBuilder, type TemplateSectionRow } from '@/components/lessons/template-section-builder'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/lessons/templates/new')({
|
||||
component: NewTemplatePage,
|
||||
})
|
||||
|
||||
function NewTemplatePage() {
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [name, setName] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [instrument, setInstrument] = useState('')
|
||||
const [skillLevel, setSkillLevel] = useState('all_levels')
|
||||
const [sections, setSections] = useState<TemplateSectionRow[]>([])
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () =>
|
||||
lessonPlanTemplateMutations.create({
|
||||
name,
|
||||
description: description || undefined,
|
||||
instrument: instrument || undefined,
|
||||
skillLevel,
|
||||
sections: sections.map((s, sIdx) => ({
|
||||
title: s.title,
|
||||
description: s.description || undefined,
|
||||
sortOrder: sIdx,
|
||||
items: s.items.map((item, iIdx) => ({
|
||||
title: item.title,
|
||||
description: item.description || undefined,
|
||||
sortOrder: iIdx,
|
||||
})),
|
||||
})),
|
||||
}),
|
||||
onSuccess: (template) => {
|
||||
toast.success('Template created')
|
||||
navigate({ to: '/lessons/templates/$templateId', params: { templateId: template.id }, search: {} as any })
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!name.trim()) return
|
||||
mutation.mutate()
|
||||
}
|
||||
|
||||
const allSectionsValid = sections.every(
|
||||
(s) => s.title.trim() && s.items.every((i) => i.title.trim()),
|
||||
)
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6 max-w-3xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => navigate({ to: '/lessons/templates', search: {} as any })}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<h1 className="text-2xl font-bold">New Template</h1>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-lg">Details</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Name *</Label>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="e.g. Piano Foundations — Beginner" required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Description</Label>
|
||||
<Textarea value={description} onChange={(e) => setDescription(e.target.value)} rows={2} placeholder="What this curriculum covers..." />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Instrument</Label>
|
||||
<Input value={instrument} onChange={(e) => setInstrument(e.target.value)} placeholder="e.g. Piano, Guitar" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Skill Level</Label>
|
||||
<Select value={skillLevel} onValueChange={setSkillLevel}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="beginner">Beginner</SelectItem>
|
||||
<SelectItem value="intermediate">Intermediate</SelectItem>
|
||||
<SelectItem value="advanced">Advanced</SelectItem>
|
||||
<SelectItem value="all_levels">All Levels</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-lg">Curriculum</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<TemplateSectionBuilder sections={sections} onChange={setSections} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" disabled={mutation.isPending || !name.trim() || !allSectionsValid} size="lg">
|
||||
{mutation.isPending ? 'Creating...' : 'Create Template'}
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" size="lg" onClick={() => navigate({ to: '/lessons/templates', search: {} as any })}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -1,22 +1,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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user