diff --git a/CLAUDE.md b/CLAUDE.md index 35549f1..fda2c4b 100644 --- a/CLAUDE.md +++ b/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) { - 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(name, params)` — execute stored procedure, returns `T` +- `execQuery(query, params)` — execute parameterized SQL, returns `T` +- `execPortalStoredProc(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): 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= +BETTER_AUTH_SECRET= # PostgreSQL (per-environment, set in docker-compose.yml) DATABASE_URL=postgresql://postgres:@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= +MSSQL_PASSWORD= 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= +MS_GRAPH_CLIENT_SECRET= MAIL_FROM=quest@vorteqcoil.com # reCAPTCHA -RECAPTCHA_SITE_KEY= -RECAPTCHA_SECRET_KEY= +RECAPTCHA_SITE_KEY= +RECAPTCHA_SECRET_KEY= # Cloudflare (Traefik SSL - set in .env at stack level) CLOUDFLARE_DNS_API_TOKEN= @@ -310,7 +383,7 @@ CLOUDFLARE_DNS_API_TOKEN= ## 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 diff --git a/package-lock.json b/package-lock.json index c9dc9ad..166d384 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 62e9867..026fb22 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/(portal)/layout.tsx b/src/app/(portal)/layout.tsx index 54292a7..578f5a1 100644 --- a/src/app/(portal)/layout.tsx +++ b/src/app/(portal)/layout.tsx @@ -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 (
({ + id: c.id, + display_name: c.display_name, + epicor_cust_id: c.epicor_cust_id, + }))} userEmail={session.user.email} isAdmin={isAdmin} unreadNotifications={unreadNotifications} diff --git a/src/app/api/auth/set-active-company/route.ts b/src/app/api/auth/set-active-company/route.ts index efcdec8..e65c197 100644 --- a/src/app/api/auth/set-active-company/route.ts +++ b/src/app/api/auth/set-active-company/route.ts @@ -32,17 +32,31 @@ export async function POST(request: NextRequest) { ); } - const userCompany = await db.quest_user_company.findFirst({ - where: { - quest_user_id: session.questUserId, - quest_company_id: companyId, - }, - include: { - company: true, - }, - }); + const isAdminUser = + session.userType === 'Admin' || session.userType === 'Super Admin'; - if (!userCompany || !userCompany.company.is_active) { + 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, + quest_company_id: companyId, + }, + include: { + company: true, + }, + }); + company = userCompany?.company.is_active ? userCompany.company : null; + } + + 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) { diff --git a/src/components/company-selector.tsx b/src/components/company-selector.tsx index d5ffd26..70271a7 100644 --- a/src/components/company-selector.tsx +++ b/src/components/company-selector.tsx @@ -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( - companies[0]?.id || '' - ); + const [open, setOpen] = useState(false); + const [selectedCompanyId, setSelectedCompanyId] = useState(''); 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) { Select Company - Choose the company you want to access + + Search by company name or code to get started +
- - {companies.map((company) => ( -
- - -
- ))} -
+ + + + + + + + + No company found. + + {companies.map((company) => ( + { + setSelectedCompanyId(company.id); + setOpen(false); + }} + > + + {company.display_name} + + ))} + + + + + -
diff --git a/src/components/layout/portal-header.tsx b/src/components/layout/portal-header.tsx index a313233..c8812e9 100644 --- a/src/components/layout/portal-header.tsx +++ b/src/components/layout/portal-header.tsx @@ -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 (
-
-

Current Company

-

- {companyName || 'No Company Selected'} -

-
- {isAdmin && ( - + {showSwitcher ? ( + + + + + + + + + No company found. + + {companies.map((company) => ( + handleCompanySwitch(company.id)} + > + + {company.display_name} + + ))} + + + + + + ) : ( +
+

Current Company

+

+ {companyName || 'No Company Selected'} +

+
)}
diff --git a/src/components/ui/command.tsx b/src/components/ui/command.tsx new file mode 100644 index 0000000..59a2645 --- /dev/null +++ b/src/components/ui/command.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +Command.displayName = CommandPrimitive.displayName + +const CommandDialog = ({ children, ...props }: DialogProps) => { + return ( + + + + {children} + + + + ) +} + +const CommandInput = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( +
+ + +
+)) + +CommandInput.displayName = CommandPrimitive.Input.displayName + +const CommandList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) + +CommandList.displayName = CommandPrimitive.List.displayName + +const CommandEmpty = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>((props, ref) => ( + +)) + +CommandEmpty.displayName = CommandPrimitive.Empty.displayName + +const CommandGroup = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) + +CommandGroup.displayName = CommandPrimitive.Group.displayName + +const CommandSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +CommandSeparator.displayName = CommandPrimitive.Separator.displayName + +const CommandItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) + +CommandItem.displayName = CommandPrimitive.Item.displayName + +const CommandShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ) +} +CommandShortcut.displayName = "CommandShortcut" + +export { + Command, + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandShortcut, + CommandSeparator, +} diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx new file mode 100644 index 0000000..f38593b --- /dev/null +++ b/src/components/ui/dialog.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)) +DialogContent.displayName = DialogPrimitive.Content.displayName + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogHeader.displayName = "DialogHeader" + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogFooter.displayName = "DialogFooter" + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogTitle.displayName = DialogPrimitive.Title.displayName + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogDescription.displayName = DialogPrimitive.Description.displayName + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogClose, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +} diff --git a/src/components/ui/popover.tsx b/src/components/ui/popover.tsx new file mode 100644 index 0000000..483dc69 --- /dev/null +++ b/src/components/ui/popover.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( + + + +)) +PopoverContent.displayName = PopoverPrimitive.Content.displayName + +export { Popover, PopoverTrigger, PopoverContent }