feat: replace company switcher button with searchable combobox
Add cmdk-based combobox to portal header for inline company switching with search by name or Epicor ID. Also update select-company page to use the same pattern. Fix admin company switching by allowing admins to switch to any active company without requiring a quest_user_company link. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
87c1cdf8da
commit
56ce4ba940
10 changed files with 747 additions and 185 deletions
319
CLAUDE.md
319
CLAUDE.md
|
|
@ -11,7 +11,7 @@ Rebuilding the Vorteq Quest customer portal from a legacy Laravel 9 / Windows Se
|
|||
- **Language:** TypeScript (strict mode)
|
||||
- **ORM (App DB):** Prisma with PostgreSQL 16
|
||||
- **ORM (Epicor):** `mssql` package (read-only connection to SQL Server)
|
||||
- **Auth:** Better Auth with credentials provider
|
||||
- **Auth:** Better Auth with credentials provider + custom session enrichment
|
||||
- **UI:** shadcn/ui + Tailwind CSS
|
||||
- **Data Tables:** TanStack Table v8
|
||||
- **State:** React Server Components by default, client components only when needed
|
||||
|
|
@ -24,23 +24,19 @@ Rebuilding the Vorteq Quest customer portal from a legacy Laravel 9 / Windows Se
|
|||
- **CI/CD:** Forgejo Actions with self-hosted runner
|
||||
- **Registry:** Forgejo built-in OCI container registry
|
||||
|
||||
## Reference Documentation\
|
||||
\
|
||||
All legacy system documentation is in the `docs/` directory:\
|
||||
\
|
||||
- **`vorteqcoil-portal-documentation.md`** — Complete interface documentation of the legacy portal: navigation, sections, column layouts, business logic. Reference when building UI pages.\
|
||||
- **`vorteqcoil-visual-documentation.html`** — Visual reference guide with color coding system and UI patterns. Reference for design decisions.\
|
||||
- **`VorteqDiscovery_20260206_205453.txt`** — Server infrastructure discovery: IIS config, scheduled tasks, services.\
|
||||
- **`VorteqAppDiscovery_20260206_210519.txt`** — Application code discovery: Laravel routes, controllers, models, views, Blade templates. Reference when implementing features.\
|
||||
- **`VorteqEpicorDB_20260210_211850.txt`** — Epicor database schema: tables, stored procedures, views. Reference when writing Epicor queries.\
|
||||
- **`EpicorDatabase.php`** — Legacy PHP database class with all Epicor query implementations. Primary reference for porting queries to TypeScript.\
|
||||
- **`_FILE_LISTING.txt`** — Complete file listing of the legacy Laravel application.\
|
||||
- **`_MODEL_MAP.txt`** — Legacy Eloquent model-to-table mappings. Reference for Prisma schema design.\
|
||||
- **`_SQL_QUERIES_FOUND.txt`** — All SQL queries extracted from the legacy codebase. Reference for service layer implementation.\
|
||||
\
|
||||
' CLAUDE.md
|
||||
## Reference Documentation
|
||||
|
||||
All legacy system documentation is in the `docs/` directory:
|
||||
|
||||
- **`vorteqcoil-portal-documentation.md`** — Complete interface documentation of the legacy portal: navigation, sections, column layouts, business logic. Reference when building UI pages.
|
||||
- **`vorteqcoil-visual-documentation.html`** — Visual reference guide with color coding system and UI patterns. Reference for design decisions.
|
||||
- **`VorteqDiscovery_20260206_205453.txt`** — Server infrastructure discovery: IIS config, scheduled tasks, services.
|
||||
- **`VorteqAppDiscovery_20260206_210519.txt`** — Application code discovery: Laravel routes, controllers, models, views, Blade templates. Reference when implementing features.
|
||||
- **`VorteqEpicorDB_20260210_211850.txt`** — Epicor database schema: tables, stored procedures, views. Reference when writing Epicor queries.
|
||||
- **`EpicorDatabase.php`** — Legacy PHP database class with all Epicor query implementations. Primary reference for porting queries to TypeScript.
|
||||
- **`_FILE_LISTING.txt`** — Complete file listing of the legacy Laravel application.
|
||||
- **`_MODEL_MAP.txt`** — Legacy Eloquent model-to-table mappings. Reference for Prisma schema design.
|
||||
- **`_SQL_QUERIES_FOUND.txt`** — All SQL queries extracted from the legacy codebase. Reference for service layer implementation.
|
||||
|
||||
## Infrastructure
|
||||
|
||||
|
|
@ -78,7 +74,7 @@ quest-vorteq/
|
|||
├── CLAUDE.md # This file
|
||||
├── TASKS.md # Task tracking
|
||||
├── docker-compose.yml # Infrastructure: traefik, postgres, redis, app containers
|
||||
├── Dockerfile # Multi-stage Next.js build
|
||||
├── Dockerfile # Multi-stage Next.js build (node:22-alpine, standalone output)
|
||||
├── .forgejo/
|
||||
│ └── workflows/
|
||||
│ └── deploy.yml # CI/CD pipeline
|
||||
|
|
@ -87,67 +83,97 @@ quest-vorteq/
|
|||
│ ├── acme.json # SSL certificates (gitignored)
|
||||
│ └── config/ # Dynamic Traefik config
|
||||
├── prisma/
|
||||
│ ├── schema.prisma # PostgreSQL schema
|
||||
│ ├── schema.prisma # PostgreSQL schema (auth + quest + ship/alloc/finance/paint/doc/wave/lts domains)
|
||||
│ ├── migrations/
|
||||
│ └── seed.ts # Seed data / migration scripts
|
||||
├── src/
|
||||
│ ├── app/ # Next.js App Router
|
||||
│ │ ├── (auth)/ # Auth route group (login, forgot-password, account-request)
|
||||
│ │ ├── (auth)/ # Auth route group
|
||||
│ │ │ ├── login/
|
||||
│ │ │ ├── forgot-password/
|
||||
│ │ │ └── account-request/
|
||||
│ │ ├── (portal)/ # Authenticated route group
|
||||
│ │ │ ├── dashboard/
|
||||
│ │ │ ├── inventory/
|
||||
│ │ │ ├── inventory/ # Summary + 6 category pages + detail drill-down
|
||||
│ │ │ ├── orders/
|
||||
│ │ │ ├── shipments/
|
||||
│ │ │ ├── coil-activity/
|
||||
│ │ │ ├── invoices/
|
||||
│ │ │ ├── shipment-requests/
|
||||
│ │ │ ├── allocation-requests/
|
||||
│ │ │ ├── jobs/
|
||||
│ │ │ └── admin/ # Admin-only pages
|
||||
│ │ ├── api/ # API routes
|
||||
│ │ └── layout.tsx
|
||||
│ │ │ └── select-company/
|
||||
│ │ ├── api/
|
||||
│ │ │ ├── auth/ # Better Auth catch-all + custom login + set-active-company
|
||||
│ │ │ ├── health/
|
||||
│ │ │ └── account-request/
|
||||
│ │ ├── layout.tsx
|
||||
│ │ ├── globals.css
|
||||
│ │ └── page.tsx
|
||||
│ ├── components/
|
||||
│ │ ├── ui/ # shadcn/ui components
|
||||
│ │ ├── data-table/ # Reusable TanStack Table wrapper
|
||||
│ │ ├── layout/ # Navigation, header, sidebar
|
||||
│ │ └── forms/ # Shared form components
|
||||
│ │ ├── ui/ # shadcn/ui primitives (button, card, input, table, toast, etc.)
|
||||
│ │ ├── dashboard/ # summary-card, quick-nav-card, recent-orders-table, recent-shipments-table
|
||||
│ │ ├── inventory/ # inventory-summary-table, inventory-detail-table
|
||||
│ │ ├── orders/ # orders-table
|
||||
│ │ ├── layout/ # portal-sidebar, portal-header, breadcrumb
|
||||
│ │ └── company-selector.tsx
|
||||
│ ├── hooks/
|
||||
│ │ └── use-toast.ts
|
||||
│ ├── lib/
|
||||
│ │ ├── db.ts # Prisma client singleton
|
||||
│ │ ├── epicor.ts # MSSQL connection + query helpers
|
||||
│ │ ├── auth.ts # Better Auth configuration
|
||||
│ │ ├── email.ts # Microsoft Graph email service
|
||||
│ │ ├── pdf.ts # PDF generation service
|
||||
│ │ ├── permissions.ts # RBAC helpers
|
||||
│ │ └── utils.ts # General utilities
|
||||
│ │ ├── epicor.ts # MSSQL connection pool + query helpers + typed errors
|
||||
│ │ ├── auth.ts # Better Auth config (Prisma adapter, credentials provider)
|
||||
│ │ ├── auth-client.ts # Better Auth client-side helpers
|
||||
│ │ ├── session.ts # Quest-specific session data (cookie-based)
|
||||
│ │ ├── permissions.ts # RBAC: getQuestSession, requirePermission, hasPermission, isAdmin, isSubUser, getActiveCompany, getUserCompanies, PermissionRules constants
|
||||
│ │ └── utils.ts # General utilities (cn, etc.)
|
||||
│ ├── services/
|
||||
│ │ ├── inventory.ts # Inventory data access (calls Epicor SPs)
|
||||
│ │ ├── orders.ts # Order data access
|
||||
│ │ ├── shipments.ts # Shipment data access
|
||||
│ │ ├── coil-activity.ts # Coil activity data access
|
||||
│ │ ├── ship-requests.ts # Shipment request business logic
|
||||
│ │ ├── alloc-requests.ts # Allocation request business logic
|
||||
│ │ └── invoices.ts # Invoice processing
|
||||
│ ├── jobs/
|
||||
│ │ ├── worker.ts # BullMQ worker setup
|
||||
│ │ ├── shipment-nag.ts
|
||||
│ │ ├── order-ack-email.ts
|
||||
│ │ ├── invoice-cleanup.ts
|
||||
│ │ ├── shipping-reports.ts
|
||||
│ │ └── wave-edi.ts
|
||||
│ └── types/
|
||||
│ ├── epicor.ts # Epicor query result types
|
||||
│ ├── portal.ts # Portal-specific types
|
||||
│ └── index.ts
|
||||
│ │ ├── inventory.ts # Epicor SP calls for 6 inventory categories (summary + detail)
|
||||
│ │ ├── orders.ts # portal_Orders view queries, HDC/HDM exception handling
|
||||
│ │ └── dashboard.ts # Dashboard data aggregation
|
||||
│ ├── types/
|
||||
│ │ ├── epicor.ts # Epicor query result types
|
||||
│ │ ├── portal.ts # Portal-specific types
|
||||
│ │ └── index.ts
|
||||
│ └── middleware.ts # Rate limiting, public route allowlist, auth check (currently disabled in dev)
|
||||
├── tests/
|
||||
│ ├── unit/
|
||||
│ ├── integration/
|
||||
│ └── e2e/
|
||||
├── scripts/
|
||||
│ └── migrate-data.ts # SQL Server → PostgreSQL migration
|
||||
└── docs/
|
||||
└── Vorteq_Quest_PRD_v1.0.docx
|
||||
└── docs/ # Legacy system documentation (see Reference Documentation above)
|
||||
```
|
||||
|
||||
### Not Yet Created (Planned)
|
||||
```
|
||||
src/app/(portal)/shipments/
|
||||
src/app/(portal)/coil-activity/
|
||||
src/app/(portal)/invoices/
|
||||
src/app/(portal)/shipment-requests/
|
||||
src/app/(portal)/allocation-requests/
|
||||
src/app/(portal)/jobs/
|
||||
src/app/(portal)/admin/
|
||||
src/services/shipments.ts # Shipment data access
|
||||
src/services/coil-activity.ts # Coil activity data access
|
||||
src/services/ship-requests.ts # Shipment request business logic
|
||||
src/services/alloc-requests.ts # Allocation request business logic
|
||||
src/services/invoices.ts # Invoice processing
|
||||
src/lib/email.ts # Microsoft Graph email service
|
||||
src/lib/pdf.ts # PDF generation service
|
||||
src/jobs/ # BullMQ workers (shipment-nag, order-ack-email, etc.)
|
||||
src/components/data-table/ # Reusable TanStack Table wrapper
|
||||
src/components/forms/ # Shared form components
|
||||
```
|
||||
|
||||
## Current Development State
|
||||
|
||||
**Phase 1 (Foundation):** Complete — project scaffold, Docker infra, Prisma schema (all domains), Epicor connection, Better Auth, middleware, permissions, company selector.
|
||||
|
||||
**Phase 2 (Core Features):** In progress
|
||||
- Dashboard: implemented (summary cards, quick nav, recent orders/shipments tables)
|
||||
- Inventory: implemented (all 6 category summaries + detail drill-down)
|
||||
- Orders: implemented (list page with search and CSV export, HDC exception)
|
||||
- Remaining: shipments, coil-activity, invoices, shipment-requests, allocation-requests, jobs, admin pages
|
||||
|
||||
**Auth status:** Better Auth is configured with Prisma adapter. A custom login endpoint exists at `/api/auth/login`. Auth middleware is temporarily disabled in dev mode (`DEV_MODE=true` bypasses to a mock admin session). The dev domain (`dev.quest.vorteq.wulf.cloud`) also bypasses auth in middleware.
|
||||
|
||||
**Git:** Currently only `main` branch. No `development` branch yet.
|
||||
|
||||
## Coding Conventions
|
||||
|
||||
### General
|
||||
|
|
@ -166,46 +192,18 @@ quest-vorteq/
|
|||
- API routes: kebab-case (`/api/shipment-requests`)
|
||||
- Environment variables: SCREAMING_SNAKE_CASE
|
||||
|
||||
### Formatting (Prettier)
|
||||
- Single quotes, semicolons, trailing commas (es5)
|
||||
- 2-space indentation, 80 char print width
|
||||
- Tailwind class sorting via `prettier-plugin-tailwindcss`
|
||||
|
||||
### Database
|
||||
- Prisma schema uses snake_case for all fields
|
||||
- Map legacy PascalCase column names with `@map("LegacyName")` where needed for migration clarity
|
||||
- Map legacy PascalCase column names with `@map("LegacyName")` where needed
|
||||
- Always use parameterized queries for Epicor MSSQL — never string interpolation
|
||||
- Epicor queries go in `src/services/` with typed return values
|
||||
- PostgreSQL queries go through Prisma client exclusively
|
||||
|
||||
### Epicor Connection Pattern
|
||||
```typescript
|
||||
// src/lib/epicor.ts
|
||||
import sql from 'mssql';
|
||||
|
||||
const epicorPool = new sql.ConnectionPool({
|
||||
server: process.env.MSSQL_HOST!,
|
||||
database: process.env.MSSQL_DATABASE!,
|
||||
user: process.env.MSSQL_USER!,
|
||||
password: process.env.MSSQL_PASSWORD!,
|
||||
options: { encrypt: false, trustServerCertificate: true },
|
||||
});
|
||||
|
||||
// Always use parameterized queries
|
||||
export async function execStoredProc(name: string, params: Record<string, unknown>) {
|
||||
const pool = await epicorPool.connect();
|
||||
const request = pool.request();
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
request.input(key, value);
|
||||
}
|
||||
return request.execute(name);
|
||||
}
|
||||
```
|
||||
|
||||
### Inventory Query Pattern
|
||||
The Epicor stored procedures require three standard parameters:
|
||||
- `CustID`: The selected company's EpicorCustID
|
||||
- `DBNAME`: The VorteqPortal database name wrapped in brackets, e.g., `[VorteqPortal]`
|
||||
- `sub`: 0 or 1, indicating if the current user is a sub-user (IsSubUser flag)
|
||||
|
||||
V6 stored procedures (WIP, Finished Goods, Processed Other) use the `sub` parameter.
|
||||
Older procedures (Unprocessed, R&R variants) block sub-users entirely — return empty array if `isSubUser`.
|
||||
|
||||
### Component Pattern
|
||||
```typescript
|
||||
// Server component by default
|
||||
|
|
@ -219,56 +217,131 @@ export async function InventorySummary({ category }: { category: string }) {
|
|||
export function ShipmentRequestCart() { ... }
|
||||
```
|
||||
|
||||
### Auth Pattern (Better Auth)
|
||||
```typescript
|
||||
// src/lib/auth.ts
|
||||
import { betterAuth } from 'better-auth';
|
||||
## Epicor Connection Patterns
|
||||
|
||||
export const auth = betterAuth({
|
||||
secret: process.env.BETTER_AUTH_SECRET,
|
||||
baseURL: process.env.BETTER_AUTH_URL,
|
||||
database: {
|
||||
type: 'postgres',
|
||||
url: process.env.DATABASE_URL,
|
||||
},
|
||||
// Credentials provider: validate against auth_user table (bcrypt)
|
||||
// Session includes: userId, questUserId, activeCompanyId, isSubUser, permissionRules[]
|
||||
});
|
||||
```
|
||||
### Connection (`src/lib/epicor.ts`)
|
||||
- Singleton connection pool via `mssql` package
|
||||
- Typed error classes: `EpicorConnectionError`, `EpicorQueryError`, `EpicorTimeoutError`
|
||||
- `execStoredProc<T>(name, params)` — execute stored procedure, returns `T`
|
||||
- `execQuery<T>(query, params)` — execute parameterized SQL, returns `T`
|
||||
- `execPortalStoredProc<T>(name, custId, dbName, sub, additionalParams)` — convenience wrapper with standard portal params
|
||||
- `getPortalDbName()` — returns `[VorteqPortal]` (bracketed DB name for cross-DB SP parameter)
|
||||
- `checkConnection()` — health check
|
||||
- Graceful shutdown on SIGINT/SIGTERM
|
||||
|
||||
### Middleware Pattern
|
||||
### Inventory Stored Procedure Parameters
|
||||
|
||||
**V6 procedures** (WIP, Finished Goods, Processed Other):
|
||||
- `@Customer` — the selected company's EpicorCustID
|
||||
- `@DBNAME` — portal database name in brackets, e.g., `[VorteqPortal]`
|
||||
- `@SUBUSER` — 0 or 1 (sub-user flag)
|
||||
- Detail variants also accept `@PART`, `@PLANT`, `@WAREHOUSE` for filtering
|
||||
|
||||
**Non-V6 procedures** (Unprocessed, R&R variants):
|
||||
- `@CUSTID` — the selected company's EpicorCustID
|
||||
- `@DBNAME` — portal database name in brackets
|
||||
- No sub-user parameter — sub-users are blocked entirely (return empty array)
|
||||
- Detail variants accept `@part`, `@plant`, `@warehouse` (lowercase)
|
||||
|
||||
### Orders Pattern
|
||||
- Uses `execQuery` with parameterized SQL against Epicor views
|
||||
- Standard view: `dbo.portal_Orders WHERE CustomerID = @CustID`
|
||||
- HDC exception: uses `dbo.portal_OrdersHDC` view and maps CustID `HDC` → `HDM`
|
||||
|
||||
### Column Mapping
|
||||
Epicor SP results use PascalCase column names. Services map them to snake_case:
|
||||
```typescript
|
||||
// src/middleware.ts - protect all (portal) routes
|
||||
// src/lib/permissions.ts - check specific permission rules
|
||||
export function requirePermission(rule: string) {
|
||||
// Check user's permission groups contain the rule
|
||||
function mapSummaryRow(raw: Record<string, unknown>): InventorySummaryRow {
|
||||
return {
|
||||
part_num: String(raw.VorteqPartNum ?? ''),
|
||||
description: String(raw.VorteqPartDesc ?? ''),
|
||||
on_hand_qty: Number(raw.OnHandQty ?? 0),
|
||||
// ...
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Auth & Session Pattern
|
||||
|
||||
### Better Auth (`src/lib/auth.ts`)
|
||||
- Prisma adapter connecting to `auth_user`, `auth_session`, `auth_account` tables
|
||||
- Email + password enabled, email verification disabled
|
||||
- 7-day session expiry
|
||||
|
||||
### Quest Session (`src/lib/permissions.ts`)
|
||||
`getQuestSession()` enriches the Better Auth session with:
|
||||
- `questUserId` — from `quest_user` table
|
||||
- `activeCompanyId` — from cookie-based session store (`src/lib/session.ts`)
|
||||
- `isSubUser` — from `quest_user.is_sub_user`
|
||||
- `permissionRules[]` — collected from user type → permission groups → permission rules
|
||||
- `userType` — e.g., "Admin", "Super Admin", "Customer User"
|
||||
|
||||
In dev mode (`DEV_MODE=true`), returns a mock admin session with all permissions.
|
||||
|
||||
### Permission Helpers (`src/lib/permissions.ts`)
|
||||
- `requirePermission(rule)` — throws if user lacks permission
|
||||
- `hasPermission(rule)` — returns boolean
|
||||
- `isAdmin()` / `requireAdmin()` — check for Admin or Super Admin user type
|
||||
- `isSubUser()` — check sub-user status
|
||||
- `requireActiveCompany()` — throws if no company selected
|
||||
- `getActiveCompany()` — returns the active `quest_company` record
|
||||
- `getUserCompanies()` — returns accessible companies (admins see all active)
|
||||
- `PermissionRules` — constant object with all permission rule names
|
||||
|
||||
## Important Business Logic
|
||||
|
||||
### Company Context
|
||||
After login, the user's active company is stored in the session. All Epicor queries are scoped to that company's `EpicorCustID`. Admin users can switch companies.
|
||||
After login, the user's active company is stored in a cookie (`quest_session`). All Epicor queries are scoped to that company's `EpicorCustID`. Admin users can switch companies and see all active companies.
|
||||
|
||||
### Sub-User Restrictions
|
||||
Users with `IsSubUser=true` on their `quest_User` record:
|
||||
- See filtered inventory data (sub=1 parameter to V6 stored procedures)
|
||||
Users with `is_sub_user=true` on their `quest_user` record:
|
||||
- See filtered inventory data (SUBUSER=1 parameter to V6 stored procedures)
|
||||
- See filtered ship-to addresses (only those matching '%NB HANDY%')
|
||||
- Cannot access Unprocessed inventory or R&R data at all
|
||||
|
||||
### HDC/HDM Customer Exception
|
||||
Customer 'HDC' uses a special order view (`portal_OrdersHDC`) and maps to CustID 'HDM' in queries. This needs to be handled in the order service.
|
||||
Customer 'HDC' uses a special order view (`portal_OrdersHDC`) and maps to CustID 'HDM' in queries. Handled in `src/services/orders.ts`.
|
||||
|
||||
### VGL Customer Exception
|
||||
Customer 'VGL' uses a special SQL query for coil receipts instead of the standard view.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Development
|
||||
npm run dev # Start Next.js dev server
|
||||
npm run build # Production build
|
||||
npm run start # Start production server
|
||||
npm run lint # ESLint
|
||||
npm run typecheck # TypeScript type checking (tsc --noEmit)
|
||||
npm test # Vitest
|
||||
npm run test:e2e # Playwright E2E tests
|
||||
|
||||
# Database
|
||||
npx prisma migrate dev # Create + apply migration (dev)
|
||||
npx prisma migrate deploy # Apply migrations (prod)
|
||||
npx prisma db push # Push schema without migration
|
||||
npx prisma generate # Regenerate Prisma client
|
||||
npx prisma studio # Database GUI
|
||||
tsx prisma/seed.ts # Run seed script
|
||||
|
||||
# Docker
|
||||
docker compose up -d # Start all services
|
||||
docker compose ps # Check service status
|
||||
docker compose logs -f vorteq-dev # Tail dev container logs
|
||||
|
||||
# Pre-commit checks
|
||||
npm run lint && npm run typecheck && npm test
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
```env
|
||||
# Application
|
||||
NODE_ENV=development|test|production
|
||||
DEV_MODE=true # Bypasses auth, uses mock admin session
|
||||
BETTER_AUTH_URL=https://dev.quest.vorteq.wulf.cloud
|
||||
BETTER_AUTH_SECRET=<generated-by-setup-script>
|
||||
BETTER_AUTH_SECRET=<generated>
|
||||
|
||||
# PostgreSQL (per-environment, set in docker-compose.yml)
|
||||
DATABASE_URL=postgresql://postgres:<password>@postgres:5432/vorteq_dev
|
||||
|
|
@ -280,7 +353,7 @@ REDIS_URL=redis://redis:6379/0
|
|||
MSSQL_HOST=wi-e10test
|
||||
MSSQL_DATABASE=Epicor10Live
|
||||
MSSQL_USER=portal
|
||||
MSSQL_PASSWORD=<from-current-env>
|
||||
MSSQL_PASSWORD=<from-env>
|
||||
MSSQL_PORT=1433
|
||||
|
||||
# Portal database name (for cross-DB stored procedure parameter)
|
||||
|
|
@ -289,12 +362,12 @@ PORTAL_DB_NAME=VorteqPortal
|
|||
# Microsoft Graph (Email)
|
||||
MS_GRAPH_CLIENT_ID=629eca61-d554-4763-b14a-565aee7b7ab8
|
||||
MS_GRAPH_TENANT_ID=291e541e-003c-463c-8834-863b9e7c1ebe
|
||||
MS_GRAPH_CLIENT_SECRET=<from-current-env>
|
||||
MS_GRAPH_CLIENT_SECRET=<from-env>
|
||||
MAIL_FROM=quest@vorteqcoil.com
|
||||
|
||||
# reCAPTCHA
|
||||
RECAPTCHA_SITE_KEY=<from-current-env>
|
||||
RECAPTCHA_SECRET_KEY=<from-current-env>
|
||||
RECAPTCHA_SITE_KEY=<from-env>
|
||||
RECAPTCHA_SECRET_KEY=<from-env>
|
||||
|
||||
# Cloudflare (Traefik SSL - set in .env at stack level)
|
||||
CLOUDFLARE_DNS_API_TOKEN=<from-cloudflare>
|
||||
|
|
@ -310,7 +383,7 @@ CLOUDFLARE_DNS_API_TOKEN=<from-cloudflare>
|
|||
## Git Workflow
|
||||
|
||||
- `main` branch: production-ready code → deploys to `quest.vorteq.wulf.cloud`
|
||||
- `development` branch: integration branch → deploys to `dev.quest.vorteq.wulf.cloud`
|
||||
- `development` branch: integration branch → deploys to `dev.quest.vorteq.wulf.cloud` (not yet created)
|
||||
- Feature branches: `feature/F-001-project-scaffold`
|
||||
- Commit messages: `feat(F-001): scaffold Next.js project with TypeScript + Tailwind`
|
||||
- Run `npm run lint && npm run typecheck && npm test` before committing
|
||||
|
|
|
|||
21
package-lock.json
generated
21
package-lock.json
generated
|
|
@ -15,10 +15,10 @@
|
|||
"@radix-ui/react-alert-dialog": "^1.1.4",
|
||||
"@radix-ui/react-avatar": "^1.1.2",
|
||||
"@radix-ui/react-checkbox": "^1.1.3",
|
||||
"@radix-ui/react-dialog": "^1.1.4",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-popover": "^1.1.4",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-select": "^2.1.4",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
|
|
@ -34,6 +34,7 @@
|
|||
"bullmq": "^5.30.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"lucide-react": "^0.468.0",
|
||||
"mssql": "^11.0.1",
|
||||
|
|
@ -6225,6 +6226,22 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cmdk": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz",
|
||||
"integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "^1.1.1",
|
||||
"@radix-ui/react-dialog": "^1.1.6",
|
||||
"@radix-ui/react-id": "^1.1.0",
|
||||
"@radix-ui/react-primitive": "^2.0.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18 || ^19 || ^19.0.0-rc",
|
||||
"react-dom": "^18 || ^19 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
|
|
|
|||
|
|
@ -24,10 +24,10 @@
|
|||
"@radix-ui/react-alert-dialog": "^1.1.4",
|
||||
"@radix-ui/react-avatar": "^1.1.2",
|
||||
"@radix-ui/react-checkbox": "^1.1.3",
|
||||
"@radix-ui/react-dialog": "^1.1.4",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-popover": "^1.1.4",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-select": "^2.1.4",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
|
|
@ -43,6 +43,7 @@
|
|||
"bullmq": "^5.30.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"lucide-react": "^0.468.0",
|
||||
"mssql": "^11.0.1",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
import { getQuestSession, getActiveCompany } from '@/lib/permissions';
|
||||
import {
|
||||
getQuestSession,
|
||||
getActiveCompany,
|
||||
getUserCompanies,
|
||||
} from '@/lib/permissions';
|
||||
import { PortalSidebar } from '@/components/layout/portal-sidebar';
|
||||
import { PortalHeader } from '@/components/layout/portal-header';
|
||||
import { Breadcrumb } from '@/components/layout/breadcrumb';
|
||||
|
|
@ -32,12 +36,21 @@ export default async function PortalLayout({
|
|||
const isAdmin =
|
||||
session.userType === 'Admin' || session.userType === 'Super Admin';
|
||||
|
||||
// Get available companies for the switcher
|
||||
const companies = await getUserCompanies();
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen">
|
||||
<PortalSidebar isAdmin={isAdmin} permissions={session.permissionRules} />
|
||||
<div className="flex-1 pl-64">
|
||||
<PortalHeader
|
||||
companyName={activeCompany?.display_name}
|
||||
activeCompanyId={activeCompany?.id}
|
||||
companies={companies.map((c) => ({
|
||||
id: c.id,
|
||||
display_name: c.display_name,
|
||||
epicor_cust_id: c.epicor_cust_id,
|
||||
}))}
|
||||
userEmail={session.user.email}
|
||||
isAdmin={isAdmin}
|
||||
unreadNotifications={unreadNotifications}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,18 @@ export async function POST(request: NextRequest) {
|
|||
);
|
||||
}
|
||||
|
||||
const isAdminUser =
|
||||
session.userType === 'Admin' || session.userType === 'Super Admin';
|
||||
|
||||
let company;
|
||||
|
||||
if (isAdminUser) {
|
||||
// Admins can switch to any active company
|
||||
company = await db.quest_company.findFirst({
|
||||
where: { id: companyId, is_active: true },
|
||||
});
|
||||
} else {
|
||||
// Regular users must have an explicit company link
|
||||
const userCompany = await db.quest_user_company.findFirst({
|
||||
where: {
|
||||
quest_user_id: session.questUserId,
|
||||
|
|
@ -41,8 +53,10 @@ export async function POST(request: NextRequest) {
|
|||
company: true,
|
||||
},
|
||||
});
|
||||
company = userCompany?.company.is_active ? userCompany.company : null;
|
||||
}
|
||||
|
||||
if (!userCompany || !userCompany.company.is_active) {
|
||||
if (!company) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Company not accessible', code: 'FORBIDDEN' },
|
||||
{ status: 403 }
|
||||
|
|
@ -54,8 +68,8 @@ export async function POST(request: NextRequest) {
|
|||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
companyId: userCompany.company.id,
|
||||
companyName: userCompany.company.display_name,
|
||||
companyId: company.id,
|
||||
companyName: company.display_name,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Check, ChevronsUpDown } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
|
|
@ -10,8 +10,20 @@ import {
|
|||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type Company = {
|
||||
id: string;
|
||||
|
|
@ -24,28 +36,26 @@ type CompanySelectorProps = {
|
|||
};
|
||||
|
||||
export function CompanySelector({ companies }: CompanySelectorProps) {
|
||||
const [selectedCompanyId, setSelectedCompanyId] = useState<string>(
|
||||
companies[0]?.id || ''
|
||||
);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selectedCompanyId, setSelectedCompanyId] = useState<string>('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const selectedCompany = companies.find((c) => c.id === selectedCompanyId);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!selectedCompanyId) return;
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// TODO: Make API call to set active company in session
|
||||
const response = await fetch('/api/auth/set-active-company', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ companyId: selectedCompanyId }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
router.push('/dashboard');
|
||||
window.location.href = '/dashboard';
|
||||
} else {
|
||||
console.error('Failed to set active company');
|
||||
}
|
||||
|
|
@ -60,28 +70,65 @@ export function CompanySelector({ companies }: CompanySelectorProps) {
|
|||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Select Company</CardTitle>
|
||||
<CardDescription>Choose the company you want to access</CardDescription>
|
||||
<CardDescription>
|
||||
Search by company name or code to get started
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<CardContent className="space-y-4">
|
||||
<RadioGroup
|
||||
value={selectedCompanyId}
|
||||
onValueChange={setSelectedCompanyId}
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="w-full justify-between"
|
||||
>
|
||||
<span className="truncate">
|
||||
{selectedCompany
|
||||
? selectedCompany.display_name
|
||||
: 'Search companies...'}
|
||||
</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[--radix-popover-trigger-width] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="Type name or code..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No company found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{companies.map((company) => (
|
||||
<div key={company.id} className="flex items-center space-x-2">
|
||||
<RadioGroupItem value={company.id} id={company.id} />
|
||||
<Label htmlFor={company.id} className="flex-1 cursor-pointer">
|
||||
<div className="font-medium">{company.display_name}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{company.epicor_cust_id}
|
||||
</div>
|
||||
</Label>
|
||||
</div>
|
||||
<CommandItem
|
||||
key={company.id}
|
||||
value={`${company.epicor_cust_id} ${company.display_name}`}
|
||||
onSelect={() => {
|
||||
setSelectedCompanyId(company.id);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
'mr-2 h-4 w-4',
|
||||
selectedCompanyId === company.id
|
||||
? 'opacity-100'
|
||||
: 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
<span className="truncate">{company.display_name}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={isLoading || !selectedCompanyId}
|
||||
>
|
||||
{isLoading ? 'Loading...' : 'Continue'}
|
||||
</Button>
|
||||
</CardContent>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,23 @@
|
|||
'use client';
|
||||
|
||||
import { Bell, ChevronDown, LogOut, User } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { Bell, Check, ChevronDown, ChevronsUpDown, LogOut, User } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { signOut } from '@/lib/auth-client';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
|
|
@ -13,9 +27,18 @@ import {
|
|||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type Company = {
|
||||
id: string;
|
||||
display_name: string;
|
||||
epicor_cust_id: string;
|
||||
};
|
||||
|
||||
type PortalHeaderProps = {
|
||||
companyName?: string;
|
||||
activeCompanyId?: string;
|
||||
companies?: Company[];
|
||||
userEmail?: string;
|
||||
isAdmin?: boolean;
|
||||
unreadNotifications?: number;
|
||||
|
|
@ -23,10 +46,14 @@ type PortalHeaderProps = {
|
|||
|
||||
export function PortalHeader({
|
||||
companyName,
|
||||
activeCompanyId,
|
||||
companies = [],
|
||||
userEmail,
|
||||
isAdmin,
|
||||
unreadNotifications = 0,
|
||||
}: PortalHeaderProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [switching, setSwitching] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const handleSignOut = async () => {
|
||||
|
|
@ -38,24 +65,88 @@ export function PortalHeader({
|
|||
router.push('/notifications');
|
||||
};
|
||||
|
||||
const handleCompanySwitch = async (companyId: string) => {
|
||||
if (companyId === activeCompanyId) {
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
setSwitching(true);
|
||||
setOpen(false);
|
||||
try {
|
||||
const response = await fetch('/api/auth/set-active-company', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ companyId }),
|
||||
});
|
||||
if (response.ok) {
|
||||
window.location.href = '/dashboard';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error switching company:', error);
|
||||
} finally {
|
||||
setSwitching(false);
|
||||
}
|
||||
};
|
||||
|
||||
const showSwitcher = companies.length > 1;
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="flex h-16 items-center justify-between px-6">
|
||||
<div className="flex items-center space-x-4">
|
||||
{showSwitcher ? (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="w-[350px] justify-between"
|
||||
disabled={switching}
|
||||
>
|
||||
<span className="truncate">
|
||||
{switching
|
||||
? 'Switching...'
|
||||
: companyName || 'Select Company'}
|
||||
</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[350px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search by name or code..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No company found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{companies.map((company) => (
|
||||
<CommandItem
|
||||
key={company.id}
|
||||
value={`${company.epicor_cust_id} ${company.display_name}`}
|
||||
onSelect={() => handleCompanySwitch(company.id)}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
'mr-2 h-4 w-4',
|
||||
activeCompanyId === company.id
|
||||
? 'opacity-100'
|
||||
: 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
<span className="truncate">{company.display_name}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Current Company</p>
|
||||
<p className="font-semibold">
|
||||
{companyName || 'No Company Selected'}
|
||||
</p>
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => router.push('/select-company')}
|
||||
>
|
||||
Switch Company
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
|
|||
153
src/components/ui/command.tsx
Normal file
153
src/components/ui/command.tsx
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { type DialogProps } from "@radix-ui/react-dialog"
|
||||
import { Command as CommandPrimitive } from "cmdk"
|
||||
import { Search } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Dialog, DialogContent } from "@/components/ui/dialog"
|
||||
|
||||
const Command = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Command.displayName = CommandPrimitive.displayName
|
||||
|
||||
const CommandDialog = ({ children, ...props }: DialogProps) => {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogContent className="overflow-hidden p-0 shadow-lg">
|
||||
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
const CommandInput = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Input>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
|
||||
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
|
||||
CommandInput.displayName = CommandPrimitive.Input.displayName
|
||||
|
||||
const CommandList = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.List
|
||||
ref={ref}
|
||||
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandList.displayName = CommandPrimitive.List.displayName
|
||||
|
||||
const CommandEmpty = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Empty>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
|
||||
>((props, ref) => (
|
||||
<CommandPrimitive.Empty
|
||||
ref={ref}
|
||||
className="py-6 text-center text-sm"
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
|
||||
|
||||
const CommandGroup = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Group>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Group
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandGroup.displayName = CommandPrimitive.Group.displayName
|
||||
|
||||
const CommandSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
|
||||
|
||||
const CommandItem = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected='true']:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandItem.displayName = CommandPrimitive.Item.displayName
|
||||
|
||||
const CommandShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
CommandShortcut.displayName = "CommandShortcut"
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
}
|
||||
122
src/components/ui/dialog.tsx
Normal file
122
src/components/ui/dialog.tsx
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = "DialogFooter"
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
31
src/components/ui/popover.tsx
Normal file
31
src/components/ui/popover.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Popover = PopoverPrimitive.Root
|
||||
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger
|
||||
|
||||
const PopoverContent = React.forwardRef<
|
||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-popover-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
))
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent }
|
||||
Loading…
Add table
Add a link
Reference in a new issue