diff --git a/.cursor/mcp.json b/.cursor/mcp.json index 43e26b028..8b88be494 100644 --- a/.cursor/mcp.json +++ b/.cursor/mcp.json @@ -2,11 +2,11 @@ "mcpServers": { "trigger": { "command": "npx", - "args": [ - "trigger.dev@latest", - "mcp", - "--dev-only" - ] + "args": ["trigger.dev@latest", "mcp", "--dev-only"] + }, + "trycompai": { + "command": "npx", + "args": ["-y", "@trycompai/design-system-mcp@latest"] } } -} \ No newline at end of file +} diff --git a/ENTERPRISE_API_AUTOMATION_VERSIONING.md b/ENTERPRISE_API_AUTOMATION_VERSIONING.md deleted file mode 100644 index 4b0a56c01..000000000 --- a/ENTERPRISE_API_AUTOMATION_VERSIONING.md +++ /dev/null @@ -1,184 +0,0 @@ -# Enterprise API - Automation Versioning Endpoints - -## Overview - -Implement versioning for automation scripts. The Next.js app handles database operations (storing version metadata), while the Enterprise API handles S3 operations (copying/managing script files) and Redis operations (chat history). - -## Context - -### Current S3 Structure - -- **Draft script**: `{orgId}/{taskId}/{automationId}.automation.js` -- Scripts are stored in S3 via the enterprise API - -### New S3 Structure for Versions - -- **Draft script**: `{orgId}/{taskId}/{automationId}.draft.js` -- **Published versions**: `{orgId}/{taskId}/{automationId}.v{version}.js` - -**Migration Note**: Existing scripts at `{automationId}.automation.js` should be moved to `{automationId}.draft.js` - -### Database (handled by Next.js app) - -- `EvidenceAutomationVersion` table stores version metadata -- Next.js app creates version records after enterprise API copies files - -## Endpoints to Implement - -### 1. Publish Draft Script - -**Endpoint**: `POST /api/tasks-automations/publish` - -**Purpose**: Create a new version by copying current draft script to a versioned S3 key. - -**Request Body**: - -```typescript -{ - orgId: string; - taskId: string; - automationId: string; -} -``` - -**Process**: - -1. Construct draft S3 key: `{orgId}/{taskId}/{automationId}.draft.js` -2. Check if draft script exists in S3 -3. If not found, return error: `{ success: false, error: 'No draft script found to publish' }` -4. Query database to get the next version number: - - Find highest existing version for this `automationId` - - Increment by 1 (or start at 1 if no versions exist) -5. Construct version S3 key: `{orgId}/{taskId}/{automationId}.v{nextVersion}.js` -6. Copy draft script to version key in S3 -7. Return success with the version number and scriptKey - -**Response**: - -```typescript -{ - success: boolean; - version?: number; // e.g., 1, 2, 3 - scriptKey?: string; // e.g., "org_xxx/tsk_xxx/aut_xxx.v1.js" - error?: string; -} -``` - -**Note**: Enterprise API determines the version number server-side by querying the database, not from client input. This prevents version conflicts. - -**Error Cases**: - -- Draft script not found in S3 -- S3 copy operation fails -- Invalid orgId/taskId/automationId - ---- - -### 2. Restore Version to Draft - -**Endpoint**: `POST /api/tasks-automations/restore-version` - -**Purpose**: Replace current draft script with a published version's script. Chat history is preserved. - -**Request Body**: - -```typescript -{ - orgId: string; - taskId: string; - automationId: string; - version: number; // Which version to restore (e.g., 1, 2, 3) -} -``` - -**Process**: - -1. Construct version S3 key: `{orgId}/{taskId}/{automationId}.v{version}.js` -2. Check if version script exists in S3 -3. If not found, return error: `{ success: false, error: 'Version not found' }` -4. Construct draft S3 key: `{orgId}/{taskId}/{automationId}.draft.js` -5. Copy version script to draft key in S3 (overwrites current draft) -6. Do NOT touch Redis chat history - it should persist -7. Return success - -**Response**: - -```typescript -{ - success: boolean; - error?: string; -} -``` - -**Error Cases**: - -- Version script not found in S3 -- S3 copy operation fails -- Invalid version number - ---- - -## Implementation Notes - -### S3 Operations - -- Use AWS S3 SDK's `copyObject` method to copy between keys -- Bucket name should come from environment variables -- Ensure proper error handling for S3 operations - -### Authentication - -- These endpoints should require authentication (API key or session) -- Validate that the user has access to the organization/task/automation - -### Redis Chat History - -- **Important**: Do NOT clear or modify chat history when restoring versions -- Chat history key format: `automation:{automationId}:chat` -- Chat history persists regardless of which version is in the draft - -### Example S3 Keys - -For automation `aut_68e6a70803cf925eac17896a` in task `tsk_68e6a5c1e0b762e741c2e020`: - -- **Draft**: `org_68e6a5c1d30338b3981c2104/tsk_68e6a5c1e0b762e741c2e020/aut_68e6a70803cf925eac17896a.draft.js` -- **Version 1**: `org_68e6a5c1d30338b3981c2104/tsk_68e6a5c1e0b762e741c2e020/aut_68e6a70803cf925eac17896a.v1.js` -- **Version 2**: `org_68e6a5c1d30338b3981c2104/tsk_68e6a5c1e0b762e741c2e020/aut_68e6a70803cf925eac17896a.v2.js` - -### Integration Flow - -#### Publishing a Version - -1. User clicks "Publish" in Next.js UI with optional changelog -2. Next.js calls `POST /api/tasks-automations/publish` (no version number in request) -3. Enterprise API: - - Queries database to get next version number - - Copies draft → versioned S3 key - - Returns version number and scriptKey -4. Next.js saves version record to database with returned version number, scriptKey, and changelog - -#### Restoring a Version - -1. User clicks "Restore Version X" in Next.js UI -2. Shows confirmation dialog warning current draft will be lost -3. Next.js calls `POST /api/tasks-automations/restore-version` -4. Enterprise API copies version script → draft S3 key -5. Enterprise API returns success -6. Next.js shows success message -7. User can continue editing in builder with restored script - -### Error Handling - -- Return proper HTTP status codes (404 for not found, 400 for bad request, 500 for S3 errors) -- Include descriptive error messages in response body -- Log errors for debugging - -### Testing Checklist - -- [ ] Can publish a draft script as version 1 -- [ ] Can publish multiple versions (1, 2, 3...) -- [ ] Cannot publish if no draft exists -- [ ] Can restore version 1 to draft -- [ ] Restoring doesn't affect chat history -- [ ] S3 keys follow correct naming convention -- [ ] Proper error messages when scripts don't exist diff --git a/apps/api/src/attachments/attachments.service.ts b/apps/api/src/attachments/attachments.service.ts index 67ef446e7..deb1376ee 100644 --- a/apps/api/src/attachments/attachments.service.ts +++ b/apps/api/src/attachments/attachments.service.ts @@ -29,7 +29,9 @@ export class AttachmentsService { this.bucketName = process.env.APP_AWS_BUCKET_NAME!; if (!s3Client) { - console.error('S3 Client is not initialized. Check AWS S3 configuration.'); + console.error( + 'S3 Client is not initialized. Check AWS S3 configuration.', + ); throw new Error( 'S3 Client is not initialized. Check AWS S3 configuration.', ); diff --git a/apps/api/src/policies/policies.service.ts b/apps/api/src/policies/policies.service.ts index 1227074cf..b8edc1eba 100644 --- a/apps/api/src/policies/policies.service.ts +++ b/apps/api/src/policies/policies.service.ts @@ -40,6 +40,16 @@ export class PoliciesService { assigneeId: true, approverId: true, policyTemplateId: true, + assignee: { + select: { + id: true, + user: { + select: { + name: true, + }, + }, + }, + }, }, orderBy: { createdAt: 'desc' }, }); @@ -419,7 +429,8 @@ export class PoliciesService { const { width, height } = originalFirstPage.getSize(); const headerHeight = isFirst ? 120 : 60; - const embeddedFirstPage = await mergedPdf.embedPage(originalFirstPage); + const embeddedFirstPage = + await mergedPdf.embedPage(originalFirstPage); const rebuiltFirstPage = mergedPdf.addPage([ width, height + headerHeight, diff --git a/apps/api/src/tasks/attachments.service.ts b/apps/api/src/tasks/attachments.service.ts index 868ba040c..9e2fe9005 100644 --- a/apps/api/src/tasks/attachments.service.ts +++ b/apps/api/src/tasks/attachments.service.ts @@ -28,9 +28,11 @@ export class AttachmentsService { // AWS configuration is validated at startup via ConfigModule // Safe to access environment variables directly since they're validated this.bucketName = process.env.APP_AWS_BUCKET_NAME!; - + if (!s3Client) { - console.error('S3 Client is not initialized. Check AWS S3 configuration.'); + console.error( + 'S3 Client is not initialized. Check AWS S3 configuration.', + ); throw new Error( 'S3 Client is not initialized. Check AWS S3 configuration.', ); diff --git a/apps/api/src/trust-portal/trust-access.service.ts b/apps/api/src/trust-portal/trust-access.service.ts index 0dd829860..01f12a25f 100644 --- a/apps/api/src/trust-portal/trust-access.service.ts +++ b/apps/api/src/trust-portal/trust-access.service.ts @@ -2059,7 +2059,8 @@ export class TrustAccessService { const { width, height } = originalFirstPage.getSize(); const headerHeight = isFirst ? 120 : 60; - const embeddedFirstPage = await mergedPdf.embedPage(originalFirstPage); + const embeddedFirstPage = + await mergedPdf.embedPage(originalFirstPage); const rebuiltFirstPage = mergedPdf.addPage([ width, height + headerHeight, diff --git a/apps/api/src/vector-store/lib/sync/sync-organization.ts b/apps/api/src/vector-store/lib/sync/sync-organization.ts index 28dfbd8fa..ec76b24ad 100644 --- a/apps/api/src/vector-store/lib/sync/sync-organization.ts +++ b/apps/api/src/vector-store/lib/sync/sync-organization.ts @@ -218,35 +218,43 @@ async function verifyEmbeddingIsReady( // Now query using the embedding's own vector to verify it's INDEXED // If the embedding is indexed, querying with its own vector should return itself const queryResults = await vectorIndex.query({ - vector: fetchedEmbedding.vector as number[], + vector: fetchedEmbedding.vector, topK: 1, filter: `organizationId = "${organizationId}"`, includeMetadata: true, }); // Check if our embedding appears in the query results - const isIndexed = queryResults.some((result) => result.id === embeddingId); + const isIndexed = queryResults.some( + (result) => result.id === embeddingId, + ); if (isIndexed) { - logger.info('Embedding verification succeeded - indexed and queryable', { - organizationId, - embeddingId, - attempt, - totalWaitMs, - }); + logger.info( + 'Embedding verification succeeded - indexed and queryable', + { + organizationId, + embeddingId, + attempt, + totalWaitMs, + }, + ); return { success: true, attempts: attempt, totalWaitMs }; } // Embedding is stored but not yet indexed for search if (attempt < maxRetries) { const delay = initialDelay * Math.pow(2, attempt - 1); // 300ms, 600ms, 1200ms, 2400ms... - logger.info('Embedding stored but not yet indexed, waiting before retry', { - organizationId, - embeddingId, - attempt, - nextDelayMs: delay, - queryResultCount: queryResults.length, - }); + logger.info( + 'Embedding stored but not yet indexed, waiting before retry', + { + organizationId, + embeddingId, + attempt, + nextDelayMs: delay, + queryResultCount: queryResults.length, + }, + ); await new Promise((resolve) => setTimeout(resolve, delay)); totalWaitMs += delay; } diff --git a/apps/app/agents.md b/apps/app/agents.md new file mode 100644 index 000000000..ab953de9c --- /dev/null +++ b/apps/app/agents.md @@ -0,0 +1,247 @@ +# UI Component Usage Rules (Design System) + +## Core Principle + +**ONLY use components from `@trycompai/design-system`.** Do not use shadcn/ui, Radix primitives, or custom components when a design system component exists. + +**Components do NOT accept `className`. Use variants and props only.** + +This design system enforces strict styling through `class-variance-authority` (cva). +The `className` prop has been removed from all components to prevent style overrides. + +## Component Priority + +1. **First choice:** `@trycompai/design-system` +2. **Never:** Custom implementations when DS has the component + +```tsx +// ✅ ALWAYS - Use design system +import { Button, Table, Badge, Tabs } from '@trycompai/design-system'; + +// ❌ NEVER - Don't use @comp/ui when DS has the component +import { Button } from '@comp/ui/button'; +import { Table } from '@comp/ui/table'; +``` + +## Server vs Client Components + +**Layouts should be server-side rendered.** Any client-side logic (hooks, state, event handlers) must be wrapped in its own `'use client'` component. + +```tsx +// ✅ Server layout with client component for interactivity +// layout.tsx (server) +import { ClientTabs } from './components/ClientTabs'; + +export default function Layout({ children }) { + return ( + + + {/* Client component for interactive tabs */} + {children} + + ); +} + +// components/ClientTabs.tsx (client) +'use client'; +export function ClientTabs() { + const router = useRouter(); + // ... client logic +} + +// ❌ NEVER - Don't make entire layout a client component +'use client'; +export default function Layout({ children }) { ... } +``` + +## Avoid nuqs + +Don't use `nuqs` for query state. Use standard Next.js patterns: + +- `useRouter().push()` for navigation +- `useSearchParams()` for reading query params +- Server-side `searchParams` prop for initial state + +## ❌ These Will NOT Compile + +```tsx +// className is not a valid prop - TypeScript will error + +Content +Status +Content +``` + +## ✅ ALWAYS Do This + +```tsx +// Use component variants + + +Status + +// Use component props +Content +Title +Section Title +Content +``` + +## Layout & Positioning + +For layout concerns (width, grid positioning, margins), use wrapper elements: + +```tsx +// ✅ Wrapper div for layout +
+ +
+ +// ✅ Grid/flex positioning with wrapper +
+ Spanning Card +
+ +// ✅ Use Stack/Grid for spacing + + + + +``` + +## Available Components & Their APIs + +### Layout Primitives + +```tsx +// Stack - flex layout + + {children} + + +// Grid - responsive grid + + {children} + + +// Container - max-width wrapper + + {children} + + +// PageLayout - full page structure + + {children} + +``` + +### Typography + +```tsx +// Heading - h1-h6 with consistent styles +Page Title +Subtitle + +// Text - body text +Description +Important text +``` + +### Interactive + +```tsx +// Button variants: default, outline, secondary, ghost, destructive, link +// Button sizes: default, xs, sm, lg, icon, icon-xs, icon-sm, icon-lg + + + + +// Badge variants: default, secondary, destructive, outline +Active +``` + +### Layout Components + +```tsx +// Card with maxWidth control +Content + +// Section with title/description +
+ + Settings + Manage your preferences + + {children} +
+``` + +## If a Variant Doesn't Exist + +1. **Check the component file** - it might exist and you missed it +2. **Add a new variant** to the component's `cva` definition +3. **Create a new component** if it's a genuinely new pattern + +```tsx +// Example: Adding a variant to button.tsx +const buttonVariants = cva('...base classes...', { + variants: { + variant: { + // existing variants... + newVariant: 'bg-teal-500 text-white hover:bg-teal-600', // ADD HERE + }, + }, +}); +``` + +**NEVER use wrapper divs to apply styles that should be component variants.** + +## Import Pattern + +```tsx +import { + Button, + Card, + CardHeader, + CardContent, + Stack, + Heading, + Text, + Badge, + // ... etc +} from '@trycompai/design-system'; +``` + +## Icons + +Import icons from `@trycompai/design-system/icons` (re-exports `@carbon/icons-react`): + +```tsx +// ✅ ALWAYS - Use design system icons +import { Add, Download, Settings, ChevronDown } from '@trycompai/design-system/icons'; + +// Carbon icons use size prop, not className + + + +// ❌ NEVER - Don't use lucide-react +import { Plus, Download } from 'lucide-react'; + +``` + +Common icon mappings from lucide-react to Carbon: + +| lucide-react | @carbon/icons-react | +|--------------|---------------------| +| Plus | Add | +| X | Close | +| Check | Checkmark | +| ChevronDown | ChevronDown | +| ChevronRight | ChevronRight | +| Settings | Settings | +| Trash | TrashCan | +| Edit | Edit | +| Search | Search | +| Loader2 | (use Button loading prop) | diff --git a/apps/app/e2e/fixtures/auth.ts b/apps/app/e2e/fixtures/auth.ts index 8ff6f4f3a..740f736c8 100644 --- a/apps/app/e2e/fixtures/auth.ts +++ b/apps/app/e2e/fixtures/auth.ts @@ -9,7 +9,7 @@ type AuthFixtures = { export const test = base.extend({ // This fixture provides an authenticated page - authenticatedPage: async ({ browser }, use) => { + authenticatedPage: async ({ browser }, providePage) => { // Check if we have saved auth state const authFile = path.join(__dirname, '../auth/user.json'); let context: BrowserContext; @@ -78,7 +78,7 @@ export const test = base.extend({ } const page = await context.newPage(); - await use(page); + await providePage(page); await context.close(); }, }); diff --git a/apps/app/eslint.config.mjs b/apps/app/eslint.config.mjs new file mode 100644 index 000000000..377010027 --- /dev/null +++ b/apps/app/eslint.config.mjs @@ -0,0 +1,34 @@ +import { FlatCompat } from '@eslint/eslintrc'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const compat = new FlatCompat({ + baseDirectory: __dirname, +}); + +export default [ + { + ignores: [ + '**/.next/**', + '**/dist/**', + '**/node_modules/**', + '**/coverage/**', + '**/playwright-report/**', + '**/.turbo/**', + '**/out/**', + ], + }, + ...compat.extends('next/core-web-vitals', 'next/typescript'), + { + rules: { + // This repo has existing violations; keep lint actionable while we migrate. + '@typescript-eslint/no-explicit-any': 'off', + 'react/no-unescaped-entities': 'off', + 'prefer-const': 'off', + }, + }, +]; + diff --git a/apps/app/next.config.ts b/apps/app/next.config.ts index 4cb4b03ef..4369872e2 100644 --- a/apps/app/next.config.ts +++ b/apps/app/next.config.ts @@ -18,6 +18,7 @@ const config: NextConfig = { }, }, }, + webpack: (config, { isServer }) => { if (isServer) { // Very important, DO NOT REMOVE, it's needed for Prisma to work in the server bundle @@ -40,7 +41,12 @@ const config: NextConfig = { ? `${process.env.STATIC_ASSETS_URL}/app` : '', reactStrictMode: false, - transpilePackages: ['@trycompai/db', '@prisma/client'], + transpilePackages: [ + '@trycompai/db', + '@prisma/client', + '@trycompai/design-system', + '@carbon/icons-react', + ], images: { remotePatterns: [ { diff --git a/apps/app/package.json b/apps/app/package.json index 34882d05c..c74b97706 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -57,6 +57,7 @@ "@trigger.dev/react-hooks": "4.0.6", "@trigger.dev/sdk": "4.0.6", "@trycompai/db": "1.3.21", + "@trycompai/design-system": "^1.0.28", "@trycompai/email": "workspace:*", "@types/canvas-confetti": "^1.9.0", "@types/react-syntax-highlighter": "^15.5.13", @@ -170,7 +171,7 @@ "db:migrate": "cd ../../packages/db && bunx prisma migrate dev && cd ../../apps/app", "deploy:trigger-prod": "npx trigger.dev@4.0.6 deploy", "dev": "bun i && bunx concurrently --kill-others --names \"next,trigger\" --prefix-colors \"yellow,blue\" \"next dev --turbo -p 3000\" \"bunx trigger.dev@4.0.6 dev\"", - "lint": "next lint && prettier --check .", + "lint": "eslint . && prettier --check .", "prebuild": "bun run db:generate", "postinstall": "prisma generate --schema=./prisma/schema.prisma || exit 0", "start": "next start", diff --git a/apps/app/scripts/update-imports.js b/apps/app/scripts/update-imports.js index 6ab96d825..1cdc4f487 100755 --- a/apps/app/scripts/update-imports.js +++ b/apps/app/scripts/update-imports.js @@ -1,12 +1,12 @@ #!/usr/bin/env node -const fs = require('fs'); -const glob = require('glob'); +import fs from 'node:fs'; +import { globSync } from 'glob'; console.log('🔄 Updating database imports to use local client...'); // Find all TypeScript/TSX files in src directory -const files = glob.sync('src/**/*.{ts,tsx}', { +const files = globSync('src/**/*.{ts,tsx}', { cwd: process.cwd(), absolute: true, }); diff --git a/apps/app/src/actions/policies/archive-policy.ts b/apps/app/src/actions/policies/archive-policy.ts index cc2b0ab01..850bc3efc 100644 --- a/apps/app/src/actions/policies/archive-policy.ts +++ b/apps/app/src/actions/policies/archive-policy.ts @@ -58,7 +58,7 @@ export const archivePolicyAction = authActionClient }); revalidatePath(`/${activeOrganizationId}/policies/${id}`); - revalidatePath(`/${activeOrganizationId}/policies/all`); + revalidatePath(`/${activeOrganizationId}/policies`); revalidatePath(`/${activeOrganizationId}/policies`); revalidateTag('policies', 'max'); diff --git a/apps/app/src/actions/policies/create-new-policy.ts b/apps/app/src/actions/policies/create-new-policy.ts index 419543af4..64cca38ea 100644 --- a/apps/app/src/actions/policies/create-new-policy.ts +++ b/apps/app/src/actions/policies/create-new-policy.ts @@ -104,7 +104,7 @@ export const createPolicyAction = authActionClient // ); // } - revalidatePath(`/${activeOrganizationId}/policies/all`); + revalidatePath(`/${activeOrganizationId}/policies`); revalidatePath(`/${activeOrganizationId}/policies`); revalidateTag('policies', 'max'); diff --git a/apps/app/src/actions/policies/delete-policy.ts b/apps/app/src/actions/policies/delete-policy.ts index ed4d9a179..405e2ba7c 100644 --- a/apps/app/src/actions/policies/delete-policy.ts +++ b/apps/app/src/actions/policies/delete-policy.ts @@ -52,7 +52,7 @@ export const deletePolicyAction = authActionClient }); // Revalidate paths to update UI - revalidatePath(`/${activeOrganizationId}/policies/all`); + revalidatePath(`/${activeOrganizationId}/policies`); revalidateTag('policies', 'max'); } catch (error) { console.error(error); diff --git a/apps/app/src/actions/policies/update-policy-overview-action.ts b/apps/app/src/actions/policies/update-policy-overview-action.ts index 9445d40c1..515e65694 100644 --- a/apps/app/src/actions/policies/update-policy-overview-action.ts +++ b/apps/app/src/actions/policies/update-policy-overview-action.ts @@ -56,7 +56,7 @@ export const updatePolicyOverviewAction = authActionClient }); revalidatePath(`/${session.activeOrganizationId}/policies/${id}`); - revalidatePath(`/${session.activeOrganizationId}/policies/all`); + revalidatePath(`/${session.activeOrganizationId}/policies`); revalidatePath(`/${session.activeOrganizationId}/policies`); return { diff --git a/apps/app/src/app/(app)/[orgId]/cloud-tests/layout.tsx b/apps/app/src/app/(app)/[orgId]/cloud-tests/layout.tsx deleted file mode 100644 index 4e40cc87b..000000000 --- a/apps/app/src/app/(app)/[orgId]/cloud-tests/layout.tsx +++ /dev/null @@ -1,7 +0,0 @@ -export default function TestsDashboardLayout({ children }: { children: React.ReactNode }) { - return ( -
-
{children}
-
- ); -} diff --git a/apps/app/src/app/(app)/[orgId]/cloud-tests/loading.tsx b/apps/app/src/app/(app)/[orgId]/cloud-tests/loading.tsx index 4f38f9a92..12fead15d 100644 --- a/apps/app/src/app/(app)/[orgId]/cloud-tests/loading.tsx +++ b/apps/app/src/app/(app)/[orgId]/cloud-tests/loading.tsx @@ -1,9 +1,5 @@ -import Loader from '@/components/ui/loader'; +import { PageHeader, PageLayout } from '@trycompai/design-system'; export default function Loading() { - return ( -
- -
- ); + return } />; } diff --git a/apps/app/src/app/(app)/[orgId]/components/AppShellRailNav.tsx b/apps/app/src/app/(app)/[orgId]/components/AppShellRailNav.tsx new file mode 100644 index 000000000..306a90847 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/components/AppShellRailNav.tsx @@ -0,0 +1,96 @@ +'use client'; + +import { AppShellRailItem } from '@trycompai/design-system'; +import { + FlaskConical, + Gauge, + ListCheck, + NotebookText, + Settings, + Store, + Users, + Zap, +} from 'lucide-react'; +import { usePathname, useRouter } from 'next/navigation'; + +interface AppShellRailNavProps { + organizationId: string; +} + +export function AppShellRailNav({ organizationId }: AppShellRailNavProps) { + const router = useRouter(); + const pathname = usePathname() ?? ''; + + const orgBase = `/${organizationId}`; + + const isActivePrefix = (prefix: string): boolean => { + return pathname === prefix || pathname.startsWith(`${prefix}/`); + }; + + const items = [ + { + href: `${orgBase}/frameworks`, + label: 'Overview', + icon: , + isActive: isActivePrefix(`${orgBase}/frameworks`), + }, + { + href: `${orgBase}/policies`, + label: 'Policies', + icon: , + isActive: isActivePrefix(`${orgBase}/policies`), + }, + { + href: `${orgBase}/tasks`, + label: 'Evidence', + icon: , + isActive: isActivePrefix(`${orgBase}/tasks`), + }, + { + href: `${orgBase}/people/all`, + label: 'People', + icon: , + isActive: isActivePrefix(`${orgBase}/people`), + }, + { + href: `${orgBase}/vendors`, + label: 'Vendors', + icon: , + isActive: isActivePrefix(`${orgBase}/vendors`), + }, + { + href: `${orgBase}/integrations`, + label: 'Integrations', + icon: , + isActive: isActivePrefix(`${orgBase}/integrations`), + }, + { + href: `${orgBase}/cloud-tests`, + label: 'Cloud Tests', + icon: , + isActive: isActivePrefix(`${orgBase}/cloud-tests`), + }, + { + href: `${orgBase}/settings`, + label: 'Settings', + icon: , + isActive: isActivePrefix(`${orgBase}/settings`), + }, + ] as const; + + return ( + <> + {items.map((item) => ( + router.push(item.href)} + /> + ))} + + ); +} + diff --git a/apps/app/src/app/(app)/[orgId]/components/AppShellWrapper.tsx b/apps/app/src/app/(app)/[orgId]/components/AppShellWrapper.tsx new file mode 100644 index 000000000..058b1afa6 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/components/AppShellWrapper.tsx @@ -0,0 +1,240 @@ +'use client'; + +import Chat from '@/components/ai/chat'; +import { CheckoutCompleteDialog } from '@/components/dialogs/checkout-complete-dialog'; +import { NotificationBell } from '@/components/notifications/notification-bell'; +import { OrganizationSwitcher } from '@/components/organization-switcher'; +import { updateSidebarState } from '@/actions/sidebar'; +import { SidebarProvider, useSidebar } from '@/context/sidebar-context'; +import { signOut } from '@/utils/auth-client'; +import { + CertificateCheck, + Logout, + Settings, +} from '@carbon/icons-react'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@comp/ui/dropdown-menu'; +import type { Onboarding, Organization } from '@db'; +import { + AppShell, + AppShellAIChatTrigger, + AppShellBody, + AppShellContent, + AppShellMain, + AppShellNavbar, + AppShellRail, + AppShellRailItem, + AppShellSidebar, + AppShellSidebarHeader, + AppShellUserMenu, + Avatar, + AvatarFallback, + AvatarImage, + CommandSearch, + HStack, + Logo, + Text, + ThemeToggle, +} from '@trycompai/design-system'; +import { useAction } from 'next-safe-action/hooks'; +import { useTheme } from 'next-themes'; +import Link from 'next/link'; +import { usePathname, useRouter } from 'next/navigation'; +import { Suspense, useCallback, useRef } from 'react'; +import { AppSidebar } from './AppSidebar'; +import { getAppShellSearchGroups } from './app-shell-search-groups'; +import { ConditionalOnboardingTracker } from './ConditionalOnboardingTracker'; + +interface AppShellWrapperProps { + children: React.ReactNode; + organization: Organization; + organizations: Organization[]; + logoUrls: Record; + onboarding: Onboarding | null; + isCollapsed: boolean; + isQuestionnaireEnabled: boolean; + isTrustNdaEnabled: boolean; + hasAuditorRole: boolean; + isOnlyAuditor: boolean; + user: { + name: string | null; + email: string; + image: string | null; + }; +} + +type AppShellWrapperContentProps = Omit; + +export function AppShellWrapper({ isCollapsed, ...props }: AppShellWrapperProps) { + return ( + + + + ); +} + +function AppShellWrapperContent({ + children, + organization, + organizations, + onboarding, + isQuestionnaireEnabled, + isTrustNdaEnabled, + hasAuditorRole, + isOnlyAuditor, + user, +}: AppShellWrapperContentProps) { + const { theme, setTheme } = useTheme(); + const pathname = usePathname(); + const router = useRouter(); + const { isCollapsed, setIsCollapsed } = useSidebar(); + const previousIsCollapsedRef = useRef(isCollapsed); + const isSettingsActive = pathname?.startsWith(`/${organization.id}/settings`); + + const { execute } = useAction(updateSidebarState, { + onError: () => { + setIsCollapsed(previousIsCollapsedRef.current); + }, + }); + + const handleSidebarOpenChange = useCallback( + (open: boolean) => { + const nextIsCollapsed = !open; + previousIsCollapsedRef.current = isCollapsed; + setIsCollapsed(nextIsCollapsed); + execute({ isCollapsed: nextIsCollapsed }); + }, + [execute, isCollapsed, setIsCollapsed], + ); + + const searchGroups = getAppShellSearchGroups({ + organizationId: organization.id, + router, + hasAuditorRole, + isOnlyAuditor, + isQuestionnaireEnabled, + isTrustNdaEnabled, + isAdvancedModeEnabled: organization.advancedModeEnabled, + }); + + return ( + } + sidebarOpen={!isCollapsed} + onSidebarOpenChange={handleSidebarOpenChange} + > + + + + + / + + + } + centerContent={} + endContent={ + + + + + + + {user.image && } + + {user.name?.charAt(0)?.toUpperCase() || user.email?.charAt(0)?.toUpperCase()} + + + + +
+ + {user.name} + + + {user.email} + +
+ + + + + + Settings + + + + +
+ Theme + setTheme(isDark ? 'dark' : 'light')} + /> +
+ + signOut()}> + + Log out + +
+
+
+ } + /> + + + + } + label="Compliance" + /> + + {!isOnlyAuditor && ( + + } + label="Settings" + /> + + )} + + + + + + + + + {onboarding?.triggerJobId && } + {children} + + + + + + + +
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/components/AppSidebar.tsx b/apps/app/src/app/(app)/[orgId]/components/AppSidebar.tsx new file mode 100644 index 000000000..9edb856c0 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/components/AppSidebar.tsx @@ -0,0 +1,157 @@ +'use client'; + +import { + Chemistry, + Dashboard, + Document, + Group, + Integration, + ListChecked, + Policy, + Security, + ShoppingBag, + Task, + TaskComplete, + Warning, +} from '@carbon/icons-react'; +import type { Organization } from '@db'; +import { AppShellNav, AppShellNavItem } from '@trycompai/design-system'; +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; + +interface NavItem { + id: string; + path: string; + name: string; + icon: React.ReactNode; + hidden?: boolean; +} + +interface AppSidebarProps { + organization: Organization; + isQuestionnaireEnabled: boolean; + isTrustNdaEnabled: boolean; + hasAuditorRole: boolean; + isOnlyAuditor: boolean; +} + +export function AppSidebar({ + organization, + isQuestionnaireEnabled, + isTrustNdaEnabled, + hasAuditorRole, + isOnlyAuditor, +}: AppSidebarProps) { + const pathname = usePathname() ?? ''; + + const navItems: NavItem[] = [ + { + id: 'frameworks', + path: `/${organization.id}/frameworks`, + name: 'Overview', + icon: , + }, + { + id: 'auditor', + path: `/${organization.id}/auditor`, + name: 'Auditor View', + icon: , + hidden: !hasAuditorRole, + }, + { + id: 'controls', + path: `/${organization.id}/controls`, + name: 'Controls', + icon: , + hidden: !organization.advancedModeEnabled, + }, + { + id: 'policies', + path: `/${organization.id}/policies`, + name: 'Policies', + icon: , + }, + { + id: 'tasks', + path: `/${organization.id}/tasks`, + name: 'Evidence', + icon: , + }, + { + id: 'trust', + path: `/${organization.id}/trust`, + name: 'Trust', + icon: , + hidden: !isTrustNdaEnabled, + }, + { + id: 'people', + path: `/${organization.id}/people/all`, + name: 'People', + icon: , + }, + { + id: 'risk', + path: `/${organization.id}/risk`, + name: 'Risks', + icon: , + }, + { + id: 'vendors', + path: `/${organization.id}/vendors`, + name: 'Vendors', + icon: , + }, + { + id: 'questionnaire', + path: `/${organization.id}/questionnaire`, + name: 'Questionnaire', + icon: , + hidden: !isQuestionnaireEnabled, + }, + { + id: 'integrations', + path: `/${organization.id}/integrations`, + name: 'Integrations', + icon: , + hidden: isOnlyAuditor, + }, + { + id: 'tests', + path: `/${organization.id}/cloud-tests`, + name: 'Cloud Tests', + icon: , + }, + ]; + + const isPathActive = (itemPath: string) => { + const itemPathParts = itemPath.split('/').filter(Boolean); + const itemBaseSegment = itemPathParts.length > 1 ? itemPathParts[1] : ''; + + const currentPathParts = pathname.split('/').filter(Boolean); + const currentBaseSegment = currentPathParts.length > 1 ? currentPathParts[1] : ''; + + if (itemPath === `/${organization.id}` || itemPath === `/${organization.id}/implementation`) { + return ( + pathname === `/${organization.id}` || + pathname?.startsWith(`/${organization.id}/implementation`) + ); + } + + return itemBaseSegment === currentBaseSegment; + }; + + const visibleItems = navItems.filter((item) => !item.hidden); + + return ( + + {visibleItems.map((item) => ( + + + {item.name} + + + ))} + + ); +} diff --git a/apps/app/src/app/(app)/[orgId]/components/app-shell-search-groups.tsx b/apps/app/src/app/(app)/[orgId]/components/app-shell-search-groups.tsx new file mode 100644 index 000000000..5d260829b --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/components/app-shell-search-groups.tsx @@ -0,0 +1,210 @@ +import { + Chemistry, + Dashboard, + Document, + Group, + Integration, + ListChecked, + Policy, + Security, + Settings, + ShoppingBag, + Task, + TaskComplete, + Warning, +} from '@carbon/icons-react'; +import type { CommandSearchGroup } from '@trycompai/design-system'; +import type { ReactNode } from 'react'; + +interface AppShellSearchGroupsParams { + organizationId: string; + router: { + push: (href: string) => void; + }; + hasAuditorRole: boolean; + isOnlyAuditor: boolean; + isQuestionnaireEnabled: boolean; + isTrustNdaEnabled: boolean; + isAdvancedModeEnabled: boolean; +} + +interface NavigationItemParams { + id: string; + label: string; + icon: ReactNode; + path: string; + keywords: string[]; + router: { + push: (href: string) => void; + }; +} + +const createNavItem = ({ + id, + label, + icon, + path, + keywords, + router, +}: NavigationItemParams): CommandSearchGroup['items'][number] => ({ + id, + label, + icon, + keywords, + onSelect: () => router.push(path), +}); + +export const getAppShellSearchGroups = ({ + organizationId, + router, + hasAuditorRole, + isOnlyAuditor, + isQuestionnaireEnabled, + isTrustNdaEnabled, + isAdvancedModeEnabled, +}: AppShellSearchGroupsParams): CommandSearchGroup[] => { + const baseItems = [ + createNavItem({ + id: 'overview', + label: 'Overview', + icon: , + path: `/${organizationId}/frameworks`, + keywords: ['dashboard', 'home', 'frameworks'], + router, + }), + ...(hasAuditorRole + ? [ + createNavItem({ + id: 'auditor', + label: 'Auditor View', + icon: , + path: `/${organizationId}/auditor`, + keywords: ['audit', 'review'], + router, + }), + ] + : []), + ...(isAdvancedModeEnabled + ? [ + createNavItem({ + id: 'controls', + label: 'Controls', + icon: , + path: `/${organizationId}/controls`, + keywords: ['security', 'compliance'], + router, + }), + ] + : []), + createNavItem({ + id: 'policies', + label: 'Policies', + icon: , + path: `/${organizationId}/policies`, + keywords: ['policy', 'documents'], + router, + }), + createNavItem({ + id: 'evidence', + label: 'Evidence', + icon: , + path: `/${organizationId}/tasks`, + keywords: ['tasks', 'evidence', 'artifacts'], + router, + }), + ...(isTrustNdaEnabled + ? [ + createNavItem({ + id: 'trust', + label: 'Trust', + icon: , + path: `/${organizationId}/trust`, + keywords: ['trust center', 'portal'], + router, + }), + ] + : []), + createNavItem({ + id: 'people', + label: 'People', + icon: , + path: `/${organizationId}/people/all`, + keywords: ['users', 'team', 'members', 'employees'], + router, + }), + createNavItem({ + id: 'risks', + label: 'Risks', + icon: , + path: `/${organizationId}/risk`, + keywords: ['risk management', 'assessment'], + router, + }), + createNavItem({ + id: 'vendors', + label: 'Vendors', + icon: , + path: `/${organizationId}/vendors`, + keywords: ['suppliers', 'third party'], + router, + }), + ...(isQuestionnaireEnabled + ? [ + createNavItem({ + id: 'questionnaire', + label: 'Questionnaire', + icon: , + path: `/${organizationId}/questionnaire`, + keywords: ['survey', 'questions'], + router, + }), + ] + : []), + ...(!isOnlyAuditor + ? [ + createNavItem({ + id: 'integrations', + label: 'Integrations', + icon: , + path: `/${organizationId}/integrations`, + keywords: ['connect', 'apps', 'services'], + router, + }), + ] + : []), + createNavItem({ + id: 'cloud-tests', + label: 'Cloud Tests', + icon: , + path: `/${organizationId}/cloud-tests`, + keywords: ['testing', 'cloud', 'infrastructure'], + router, + }), + ]; + + return [ + { + id: 'navigation', + label: 'Navigation', + items: baseItems, + }, + ...(!isOnlyAuditor + ? [ + { + id: 'settings', + label: 'Settings', + items: [ + createNavItem({ + id: 'settings-general', + label: 'General Settings', + icon: , + path: `/${organizationId}/settings`, + keywords: ['preferences', 'configuration'], + router, + }), + ], + }, + ] + : []), + ]; +}; diff --git a/apps/app/src/app/(app)/[orgId]/controls/[controlId]/components/table/ControlRequirementsTable.tsx b/apps/app/src/app/(app)/[orgId]/controls/[controlId]/components/table/ControlRequirementsTable.tsx index 64e52dbea..e9e73b20b 100644 --- a/apps/app/src/app/(app)/[orgId]/controls/[controlId]/components/table/ControlRequirementsTable.tsx +++ b/apps/app/src/app/(app)/[orgId]/controls/[controlId]/components/table/ControlRequirementsTable.tsx @@ -32,7 +32,7 @@ export function ControlRequirementsTable({ data }: DataTableProps) { switch (requirement.policy ? 'policy' : 'task') { case 'policy': if (requirement.policy?.id) { - router.push(`/${orgId}/policies/all/${requirement.policy.id}`); + router.push(`/${orgId}/policies/${requirement.policy.id}`); } break; case 'task': diff --git a/apps/app/src/app/(app)/[orgId]/controls/page.tsx b/apps/app/src/app/(app)/[orgId]/controls/page.tsx index 0d9360a04..4216c89a2 100644 --- a/apps/app/src/app/(app)/[orgId]/controls/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/controls/page.tsx @@ -1,7 +1,7 @@ -import PageWithBreadcrumb from '@/components/pages/PageWithBreadcrumb'; import { getValidFilters } from '@/lib/data-table'; import { auth } from '@/utils/auth'; import { db } from '@db'; +import { PageHeader, PageLayout, Stack } from '@trycompai/design-system'; import { Metadata } from 'next'; import { headers } from 'next/headers'; import { SearchParams } from 'nuqs'; @@ -36,14 +36,17 @@ export default async function ControlsPage({ ...props }: ControlTableProps) { const requirements = await getRequirements(); return ( - - - + + + + + + ); } diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/components/ComplianceProgressChart.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/components/ComplianceProgressChart.tsx index 03acf2135..59fbe5ba9 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/components/ComplianceProgressChart.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/components/ComplianceProgressChart.tsx @@ -22,8 +22,8 @@ interface ComplianceProgressChartProps { } const CHART_COLORS = { - score: 'hsl(var(--chart-primary))', - remaining: 'hsl(var(--muted))', + score: 'var(--color-primary)', + remaining: 'var(--color-muted)', }; export function ComplianceProgressChart({ data }: ComplianceProgressChartProps) { @@ -128,7 +128,7 @@ export function ComplianceProgressChart({ data }: ComplianceProgressChartProps) cy={viewBox.cy} r={32} fill="none" - stroke="hsl(var(--border))" + stroke="var(--color-border)" strokeWidth={1} strokeDasharray="2,2" /> diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/components/PeopleChart.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/components/PeopleChart.tsx index d34afa091..e995ef5cc 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/components/PeopleChart.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/components/PeopleChart.tsx @@ -22,8 +22,8 @@ interface PeopleChartProps { } const CHART_COLORS = { - completed: 'hsl(var(--chart-primary))', - remaining: 'hsl(var(--muted))', + completed: 'var(--color-primary)', + remaining: 'var(--color-muted)', }; export function PeopleChart({ data }: PeopleChartProps) { @@ -126,7 +126,7 @@ export function PeopleChart({ data }: PeopleChartProps) { cy={viewBox.cy} r={32} fill="none" - stroke="hsl(var(--border))" + stroke="var(--color-border)" strokeWidth={1} strokeDasharray="2,2" /> diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/components/PoliciesChart.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/components/PoliciesChart.tsx index 2272645ac..b375b9ef4 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/components/PoliciesChart.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/components/PoliciesChart.tsx @@ -22,8 +22,8 @@ interface PoliciesChartProps { } const CHART_COLORS = { - score: 'hsl(var(--chart-primary))', - remaining: 'hsl(var(--muted))', + score: 'var(--color-primary)', + remaining: 'var(--color-muted)', }; // Custom tooltip component for the pie chart @@ -142,7 +142,7 @@ export function PoliciesChart({ data }: PoliciesChartProps) { cy={viewBox.cy} r={32} fill="none" - stroke="hsl(var(--border))" + stroke="var(--color-border)" strokeWidth={1} strokeDasharray="2,2" /> diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/components/TasksChart.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/components/TasksChart.tsx index f582c33ee..d739e0e84 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/components/TasksChart.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/components/TasksChart.tsx @@ -22,8 +22,8 @@ interface TasksChartProps { } const CHART_COLORS = { - done: 'hsl(var(--chart-primary))', - remaining: 'hsl(var(--muted))', + done: 'var(--color-primary)', + remaining: 'var(--color-muted)', }; export function TasksChart({ data }: TasksChartProps) { @@ -126,7 +126,7 @@ export function TasksChart({ data }: TasksChartProps) { cy={viewBox.cy} r={32} fill="none" - stroke="hsl(var(--border))" + stroke="var(--color-border)" strokeWidth={1} strokeDasharray="2,2" /> diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/layout.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/layout.tsx index 9f7cd2347..d453d36df 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/layout.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/layout.tsx @@ -1,7 +1,9 @@ +import { PageHeader, PageLayout } from '@trycompai/design-system'; + export default async function Layout({ children }: { children: React.ReactNode }) { return ( -
-
{children}
-
+ } padding="default"> + {children} + ); } diff --git a/apps/app/src/app/(app)/[orgId]/integrations/layout.tsx b/apps/app/src/app/(app)/[orgId]/integrations/layout.tsx deleted file mode 100644 index e11597e51..000000000 --- a/apps/app/src/app/(app)/[orgId]/integrations/layout.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function IntegrationsLayout({ children }: { children: React.ReactNode }) { - return children; -} diff --git a/apps/app/src/app/(app)/[orgId]/integrations/page.tsx b/apps/app/src/app/(app)/[orgId]/integrations/page.tsx index fd70dd3b2..50de6920d 100644 --- a/apps/app/src/app/(app)/[orgId]/integrations/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/integrations/page.tsx @@ -1,4 +1,5 @@ import { db } from '@db'; +import { PageHeader, PageLayout, Stack } from '@trycompai/design-system'; import { PlatformIntegrations } from './components/PlatformIntegrations'; export default async function IntegrationsPage() { @@ -15,20 +16,11 @@ export default async function IntegrationsPage() { }); return ( -
- {/* Header */} -
-
-

Integrations

- -
-

- Connect your tools to automate compliance checks and evidence collection. -

-
- - {/* Unified Integrations List */} - -
+ + + + + + ); } diff --git a/apps/app/src/app/(app)/[orgId]/layout.tsx b/apps/app/src/app/(app)/[orgId]/layout.tsx index d14f95951..237edeb67 100644 --- a/apps/app/src/app/(app)/[orgId]/layout.tsx +++ b/apps/app/src/app/(app)/[orgId]/layout.tsx @@ -1,19 +1,15 @@ -import { AnimatedLayout } from '@/components/animated-layout'; -import { CheckoutCompleteDialog } from '@/components/dialogs/checkout-complete-dialog'; -import { Header } from '@/components/header'; -import { AssistantSheet } from '@/components/sheets/assistant-sheet'; -import { Sidebar } from '@/components/sidebar'; +import { getFeatureFlags } from '@/app/posthog'; +import { APP_AWS_ORG_ASSETS_BUCKET, s3Client } from '@/app/s3'; import { TriggerTokenProvider } from '@/components/trigger-token-provider'; -import { SidebarProvider } from '@/context/sidebar-context'; +import { getOrganizations } from '@/data/getOrganizations'; import { auth } from '@/utils/auth'; +import { GetObjectCommand } from '@aws-sdk/client-s3'; +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import { db, Role } from '@db'; import dynamic from 'next/dynamic'; import { cookies, headers } from 'next/headers'; import { redirect } from 'next/navigation'; -import { Suspense } from 'react'; -import { ConditionalOnboardingTracker } from './components/ConditionalOnboardingTracker'; -import { ConditionalPaddingWrapper } from './components/ConditionalPaddingWrapper'; -import { DynamicMinHeight } from './components/DynamicMinHeight'; +import { AppShellWrapper } from './components/AppShellWrapper'; // Helper to safely parse comma-separated roles string function parseRolesString(rolesStr: string | null | undefined): Role[] { @@ -39,7 +35,7 @@ export default async function Layout({ const cookieStore = await cookies(); const isCollapsed = cookieStore.get('sidebar-collapsed')?.value === 'true'; - let publicAccessToken = cookieStore.get('publicAccessToken')?.value || undefined; + const publicAccessToken = cookieStore.get('publicAccessToken')?.value || undefined; // Get headers once to avoid multiple async calls const requestHeaders = await headers(); @@ -118,25 +114,70 @@ export default async function Layout({ }, }); + // Fetch organizations and feature flags for sidebar + const { organizations } = await getOrganizations(); + + // Generate logo URLs for all organizations + const logoUrls: Record = {}; + if (s3Client && APP_AWS_ORG_ASSETS_BUCKET) { + await Promise.all( + organizations.map(async (org) => { + if (org.logo) { + try { + const command = new GetObjectCommand({ + Bucket: APP_AWS_ORG_ASSETS_BUCKET, + Key: org.logo, + }); + logoUrls[org.id] = await getSignedUrl(s3Client, command, { expiresIn: 3600 }); + } catch { + // Logo not available + } + } + }), + ); + } + + // Check feature flags for menu items + let isQuestionnaireEnabled = false; + let isTrustNdaEnabled = false; + if (session?.user?.id) { + const flags = await getFeatureFlags(session.user.id); + isQuestionnaireEnabled = flags['ai-vendor-questionnaire'] === true; + isTrustNdaEnabled = + flags['is-trust-nda-enabled'] === true || flags['is-trust-nda-enabled'] === 'true'; + } + + // Check auditor role + const hasAuditorRole = roles.includes(Role.auditor); + const isOnlyAuditor = hasAuditorRole && roles.length === 1; + + // User data for navbar + const user = { + name: session.user.name, + email: session.user.email, + image: session.user.image, + }; + return ( - - } isCollapsed={isCollapsed}> - {onboarding?.triggerJobId && } -
- - {children} - - - - - - - - + + {children} + + ); } diff --git a/apps/app/src/app/(app)/[orgId]/loading.tsx b/apps/app/src/app/(app)/[orgId]/loading.tsx index e8354955a..834c94b27 100644 --- a/apps/app/src/app/(app)/[orgId]/loading.tsx +++ b/apps/app/src/app/(app)/[orgId]/loading.tsx @@ -1,9 +1,5 @@ -import Loader from '@/components/ui/loader'; +import { PageLayout } from '@trycompai/design-system'; export default function Loading() { - return ( -
- -
- ); + return ; } diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Employee.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Employee.tsx index 25bb950b2..0f7eea54f 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Employee.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Employee.tsx @@ -2,6 +2,7 @@ import type { TrainingVideo } from '@/lib/data/training-videos'; import type { EmployeeTrainingVideoCompletion, Member, Policy, User } from '@db'; +import { Stack } from '@trycompai/design-system'; import type { FleetPolicy, Host } from '../../devices/types'; import { EmployeeDetails } from './EmployeeDetails'; import { EmployeeTasks } from './EmployeeTasks'; @@ -28,7 +29,7 @@ export function Employee({ canEdit, }: EmployeeDetailsProps) { return ( -
+ -
+ ); } diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeDetails.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeDetails.tsx index e12d1f074..d9a16b72e 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeDetails.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeDetails.tsx @@ -1,10 +1,10 @@ 'use client'; import { Button } from '@comp/ui/button'; -import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@comp/ui/card'; import { Form } from '@comp/ui/form'; import type { Departments, Member, User } from '@db'; import { zodResolver } from '@hookform/resolvers/zod'; +import { Section, Stack } from '@trycompai/design-system'; import { Save } from 'lucide-react'; import { useAction } from 'next-safe-action/hooks'; import { useForm } from 'react-hook-form'; @@ -102,16 +102,10 @@ export const EmployeeDetails = ({ }; return ( - - - Employee Details -

- Manage employee information and department assignment -

-
+
- +
@@ -119,24 +113,24 @@ export const EmployeeDetails = ({
-
- - - +
+ +
+
- +
); }; diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeTasks.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeTasks.tsx index fc96b5309..84db2e6b2 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeTasks.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeTasks.tsx @@ -1,9 +1,19 @@ +'use client'; + import type { TrainingVideo } from '@/lib/data/training-videos'; import type { EmployeeTrainingVideoCompletion, Member, Policy, User } from '@db'; import { cn } from '@/lib/utils'; import { Card, CardContent, CardHeader, CardTitle } from '@comp/ui/card'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@comp/ui/tabs'; +import { + Section, + Stack, + Tabs, + TabsContent, + TabsList, + TabsTrigger, + Text, +} from '@trycompai/design-system'; import { AlertCircle, CheckCircle2, XCircle } from 'lucide-react'; import type { FleetPolicy, Host } from '../../devices/types'; @@ -25,30 +35,20 @@ export const EmployeeTasks = ({ fleetPolicies: FleetPolicy[]; }) => { return ( - - - -
-

Employee Tasks

-

- View and manage employee tasks and their status -

-
-
-
- - - +
+ + + Policies Training Videos Device -
+ {policies.length === 0 ? ( -
-

No policies required to sign.

+
+ No policies required to sign.
) : ( policies.map((policy) => { @@ -57,28 +57,28 @@ export const EmployeeTasks = ({ return (
-

+
{isCompleted ? ( ) : ( - + )} - {policy.name} -

+ {policy.name} +
); }) )} -
+
-
+ {trainingVideos.length === 0 ? ( -
-

No training videos required to watch.

+
+ No training videos required to watch.
) : ( trainingVideos.map((video) => { @@ -87,38 +87,36 @@ export const EmployeeTasks = ({ return (
-

+
{isCompleted ? ( -
- -
+ ) : ( - + )} - {video.metadata.title} + {video.metadata.title}
{isCompleted && ( - + Completed -{' '} {video.completedAt && new Date(video.completedAt).toLocaleDateString()} - + )} -

+
); }) )} -
+
{host ? ( - {host.computer_name}'s Policies + {host.computer_name}'s Policies {fleetPolicies.map((policy) => ( @@ -126,19 +124,19 @@ export const EmployeeTasks = ({ key={policy.id} className={cn( 'hover:bg-muted/50 flex items-center justify-between rounded-md border border-l-4 p-3 shadow-sm transition-colors', - policy.response === 'pass' ? 'border-l-primary' : 'border-l-red-500', + policy.response === 'pass' ? 'border-l-primary' : 'border-l-destructive', )} > -

{policy.name}

+ {policy.name} {policy.response === 'pass' ? (
- Pass + Pass
) : ( -
+
- Fail + Fail
)}
@@ -146,13 +144,13 @@ export const EmployeeTasks = ({
) : ( -
-

No device found.

+
+ No device found.
)} - - - + + +
); }; diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Fields/Status.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Fields/Status.tsx index fe8c387cb..7368291df 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Fields/Status.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Fields/Status.tsx @@ -12,8 +12,8 @@ const STATUS_OPTIONS: { value: EmployeeStatusType; label: string }[] = [ // Status color hex values for charts export const EMPLOYEE_STATUS_HEX_COLORS: Record = { - inactive: '#ef4444', - active: 'hsl(var(--chart-primary))', + inactive: 'var(--color-destructive)', + active: 'var(--color-primary)', }; export const Status = ({ diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/layout.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/layout.tsx deleted file mode 100644 index 4855864ec..000000000 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/layout.tsx +++ /dev/null @@ -1,11 +0,0 @@ -interface LayoutProps { - children: React.ReactNode; -} - -export default async function Layout({ children }: LayoutProps) { - return ( -
-
{children}
-
- ); -} diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/loading.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/loading.tsx index 4f38f9a92..008d2c03c 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/loading.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/loading.tsx @@ -1,9 +1,10 @@ import Loader from '@/components/ui/loader'; +import { PageHeader, PageLayout } from '@trycompai/design-system'; export default function Loading() { return ( -
+ } padding="default"> -
+
); } diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/page.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/page.tsx index d7e9dc9c5..486da9a02 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/page.tsx @@ -1,6 +1,5 @@ import { auth } from '@/utils/auth'; -import PageWithBreadcrumb from '@/components/pages/PageWithBreadcrumb'; import { type TrainingVideo, trainingVideos as trainingVideosData, @@ -8,6 +7,7 @@ import { import { getFleetInstance } from '@/lib/fleet'; import type { EmployeeTrainingVideoCompletion, Member, User } from '@db'; import { db } from '@db'; +import { PageHeader, PageLayout } from '@trycompai/design-system'; import type { Metadata } from 'next'; import { headers } from 'next/headers'; import { notFound, redirect } from 'next/navigation'; @@ -50,11 +50,16 @@ export default async function EmployeeDetailsPage({ const { fleetPolicies, device } = await getFleetPolicies(employee); return ( - + } > - + ); } diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/MemberRow.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/MemberRow.tsx index 900da78c9..b529666ff 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/components/MemberRow.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/MemberRow.tsx @@ -2,7 +2,7 @@ import { Edit, Laptop, MoreHorizontal, Trash2 } from 'lucide-react'; import Link from 'next/link'; -import { useParams } from 'next/navigation'; +import { useParams, useRouter } from 'next/navigation'; import { useRef, useState } from 'react'; import { Avatar, AvatarFallback, AvatarImage } from '@comp/ui/avatar'; @@ -56,8 +56,16 @@ function getInitials(name?: string | null, email?: string | null): string { return '??'; } -export function MemberRow({ member, onRemove, onRemoveDevice, onUpdateRole, canEdit, isCurrentUserOwner }: MemberRowProps) { +export function MemberRow({ + member, + onRemove, + onRemoveDevice, + onUpdateRole, + canEdit, + isCurrentUserOwner, +}: MemberRowProps) { const params = useParams<{ orgId: string }>(); + const router = useRouter(); const { orgId } = params; const [isRemoveAlertOpen, setIsRemoveAlertOpen] = useState(false); @@ -90,6 +98,9 @@ export function MemberRow({ member, onRemove, onRemoveDevice, onUpdateRole, canE const isEmployee = currentRoles.includes('employee'); const isContractor = currentRoles.includes('contractor'); + const isDeactivated = member.deactivated; + const canViewProfile = !isDeactivated; + const profileHref = canViewProfile ? `/${orgId}/people/${memberId}` : null; const handleDialogItemSelect = () => { focusRef.current = dropdownTriggerRef.current; @@ -140,11 +151,11 @@ export function MemberRow({ member, onRemove, onRemoveDevice, onUpdateRole, canE } }; - const isDeactivated = member.deactivated; - return ( <> -
+
@@ -152,17 +163,35 @@ export function MemberRow({ member, onRemove, onRemoveDevice, onUpdateRole, canE
- - {memberName} - + {profileHref ? ( + + {memberName} + + ) : ( + + {memberName} + + )} {isDeactivated && ( - + Deactivated )} - {!isDeactivated && (isEmployee || isContractor) && ( + {profileHref && ( ({'View Profile'}) @@ -175,7 +204,11 @@ export function MemberRow({ member, onRemove, onRemoveDevice, onUpdateRole, canE
{currentRoles.map((role) => ( - + {(() => { switch (role) { case 'owner': diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembersClient.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembersClient.tsx index 6c5c7e7ea..0ecf12b94 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembersClient.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembersClient.tsx @@ -10,10 +10,10 @@ import { toast } from 'sonner'; import { authClient } from '@/utils/auth-client'; import { Button } from '@comp/ui/button'; import { Card, CardContent } from '@comp/ui/card'; -import { Input } from '@comp/ui/input'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@comp/ui/select'; import { Separator } from '@comp/ui/separator'; import type { Invitation, Role } from '@db'; +import { InputGroup, InputGroupAddon, InputGroupInput } from '@trycompai/design-system'; import { MemberRow } from './MemberRow'; import { PendingInvitationRow } from './PendingInvitationRow'; @@ -250,12 +250,16 @@ export function TeamMembersClient({
- setSearchQuery(e.target.value || null)} - leftIcon={} - /> + + + + + setSearchQuery(e.target.value || null)} + /> + {searchQuery && (
; +export default function Layout({ children }: { children: React.ReactNode }) { + return children; } diff --git a/apps/app/src/app/(app)/[orgId]/people/all/page.tsx b/apps/app/src/app/(app)/[orgId]/people/all/page.tsx index a1687716c..9399417fb 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/all/page.tsx @@ -1,17 +1,6 @@ -import PageCore from '@/components/pages/PageCore.tsx'; -import type { Metadata } from 'next'; -import { TeamMembers } from './components/TeamMembers'; +import { redirect } from 'next/navigation'; -export default async function Members() { - return ( - - - - ); -} - -export async function generateMetadata(): Promise { - return { - title: 'People', - }; +export default async function AllPeoplePage({ params }: { params: Promise<{ orgId: string }> }) { + const { orgId } = await params; + redirect(`/${orgId}/people`); } diff --git a/apps/app/src/app/(app)/[orgId]/people/components/PeoplePageTabs.tsx b/apps/app/src/app/(app)/[orgId]/people/components/PeoplePageTabs.tsx new file mode 100644 index 000000000..46b6eb0f0 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/components/PeoplePageTabs.tsx @@ -0,0 +1,52 @@ +'use client'; + +import { + PageHeader, + PageLayout, + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from '@trycompai/design-system'; +import type { ReactNode } from 'react'; + +interface PeoplePageTabsProps { + peopleContent: ReactNode; + employeeTasksContent: ReactNode | null; + devicesContent: ReactNode; + showEmployeeTasks: boolean; +} + +export function PeoplePageTabs({ + peopleContent, + employeeTasksContent, + devicesContent, + showEmployeeTasks, +}: PeoplePageTabsProps) { + return ( + + + People + {showEmployeeTasks && ( + Employee Tasks + )} + Employee Devices + + } + /> + } + > + {peopleContent} + {showEmployeeTasks && ( + {employeeTasksContent} + )} + {devicesContent} + + + ); +} diff --git a/apps/app/src/app/(app)/[orgId]/people/dashboard/components/EmployeeCompletionChart.tsx b/apps/app/src/app/(app)/[orgId]/people/dashboard/components/EmployeeCompletionChart.tsx index 166937bb0..6c42f12ea 100644 --- a/apps/app/src/app/(app)/[orgId]/people/dashboard/components/EmployeeCompletionChart.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/dashboard/components/EmployeeCompletionChart.tsx @@ -1,7 +1,7 @@ 'use client'; import { Card, CardContent, CardHeader, CardTitle } from '@comp/ui/card'; -import { Input } from '@comp/ui/input'; +import { InputGroup, InputGroupAddon, InputGroupInput } from '@trycompai/design-system'; import { ExternalLink, Search } from 'lucide-react'; import Link from 'next/link'; import { useParams } from 'next/navigation'; @@ -23,10 +23,10 @@ interface EmployeeCompletionChartProps { showAll?: boolean; } -// Define colors for the chart +// Define colors for the chart using DS semantic colors const taskColors = { - completed: 'bg-primary', // Green/Blue - incomplete: 'bg-[var(--chart-open)]', // Yellow + completed: 'bg-success', // Green - completed/good state + incomplete: 'bg-warning', // Yellow - needs action }; interface EmployeeTaskStats { @@ -191,12 +191,16 @@ export function EmployeeCompletionChart({ {'Employee Task Completion'} {showAll && (
- setSearchTerm(e.target.value)} - leftIcon={} - /> + + + + + setSearchTerm(e.target.value)} + /> +
)} @@ -243,11 +247,11 @@ export function EmployeeCompletionChart({
-
+
{'Completed'}
-
+
{'Not Completed'}
diff --git a/apps/app/src/app/(app)/[orgId]/people/dashboard/page.tsx b/apps/app/src/app/(app)/[orgId]/people/dashboard/page.tsx index 421ad19d7..dc9c10f10 100644 --- a/apps/app/src/app/(app)/[orgId]/people/dashboard/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/dashboard/page.tsx @@ -1,12 +1,6 @@ -import type { Metadata } from 'next'; -import { EmployeesOverview } from './components/EmployeesOverview'; +import { redirect } from 'next/navigation'; -export default async function PeopleOverviewPage() { - return ; -} - -export async function generateMetadata(): Promise { - return { - title: 'People', - }; +export default async function DashboardPage({ params }: { params: Promise<{ orgId: string }> }) { + const { orgId } = await params; + redirect(`/${orgId}/people`); } diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/page.tsx b/apps/app/src/app/(app)/[orgId]/people/devices/page.tsx index 39d608f43..2f5d939a1 100644 --- a/apps/app/src/app/(app)/[orgId]/people/devices/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/devices/page.tsx @@ -1,24 +1,6 @@ -import { DeviceComplianceChart } from './components/DeviceComplianceChart'; -import { EmployeeDevicesList } from './components/EmployeeDevicesList'; -import { getEmployeeDevices } from './data'; -import type { Host } from './types'; +import { redirect } from 'next/navigation'; -export default async function EmployeeDevicesPage() { - let devices: Host[] = []; - - try { - const fetchedDevices = await getEmployeeDevices(); - devices = fetchedDevices || []; - } catch (error) { - console.error('Error fetching employee devices:', error); - // Return empty array on error to render empty state - devices = []; - } - - return ( -
- - -
- ); +export default async function DevicesPage({ params }: { params: Promise<{ orgId: string }> }) { + const { orgId } = await params; + redirect(`/${orgId}/people`); } diff --git a/apps/app/src/app/(app)/[orgId]/people/layout.tsx b/apps/app/src/app/(app)/[orgId]/people/layout.tsx index 44640914e..c1492b79b 100644 --- a/apps/app/src/app/(app)/[orgId]/people/layout.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/layout.tsx @@ -1,57 +1,3 @@ -import { auth } from '@/utils/auth'; -import { SecondaryMenu } from '@comp/ui/secondary-menu'; -import { db } from '@db'; -import { headers } from 'next/headers'; -import { redirect } from 'next/navigation'; - -export default async function Layout({ children }: { children: React.ReactNode }) { - const session = await auth.api.getSession({ - headers: await headers(), - }); - const orgId = session?.session.activeOrganizationId; - - if (!orgId) { - return redirect('/'); - } - - // Fetch all members first - const allMembers = await db.member.findMany({ - where: { - organizationId: orgId, - deactivated: false, - }, - }); - - const employees = allMembers.filter((member) => { - const roles = member.role.includes(',') ? member.role.split(',') : [member.role]; - return roles.includes('employee') || roles.includes('contractor'); - }); - - return ( -
- 0 - ? [ - { - path: `/${orgId}/people/dashboard`, - label: 'Employee Tasks', - }, - ] - : []), - { - path: `/${orgId}/people/devices`, - label: 'Employee Devices', - }, - ]} - /> - -
{children}
-
- ); +export default function Layout({ children }: { children: React.ReactNode }) { + return children; } diff --git a/apps/app/src/app/(app)/[orgId]/people/loading.tsx b/apps/app/src/app/(app)/[orgId]/people/loading.tsx index 4f38f9a92..7a4aded9f 100644 --- a/apps/app/src/app/(app)/[orgId]/people/loading.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/loading.tsx @@ -1,9 +1,5 @@ -import Loader from '@/components/ui/loader'; +import { PageHeader, PageLayout } from '@trycompai/design-system'; export default function Loading() { - return ( -
- -
- ); + return } loading={true} />; } diff --git a/apps/app/src/app/(app)/[orgId]/people/page.tsx b/apps/app/src/app/(app)/[orgId]/people/page.tsx index ffd9cb2fd..b15f9784e 100644 --- a/apps/app/src/app/(app)/[orgId]/people/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/page.tsx @@ -1,10 +1,69 @@ +import { auth } from '@/utils/auth'; +import { db } from '@db'; +import type { Metadata } from 'next'; +import { headers } from 'next/headers'; import { redirect } from 'next/navigation'; +import { TeamMembers } from './all/components/TeamMembers'; +import { PeoplePageTabs } from './components/PeoplePageTabs'; +import { EmployeesOverview } from './dashboard/components/EmployeesOverview'; +import { DeviceComplianceChart } from './devices/components/DeviceComplianceChart'; +import { EmployeeDevicesList } from './devices/components/EmployeeDevicesList'; +import { getEmployeeDevices } from './devices/data'; +import type { Host } from './devices/types'; -export default async function Page({ - params, -}: { - params: Promise<{ locale: string; orgId: string }>; -}) { +export default async function PeoplePage({ params }: { params: Promise<{ orgId: string }> }) { const { orgId } = await params; - return redirect(`/${orgId}/people/all`); + + const session = await auth.api.getSession({ + headers: await headers(), + }); + + if (!session?.session.activeOrganizationId) { + return redirect('/'); + } + + // Check if there are employees to show the Employee Tasks tab + const allMembers = await db.member.findMany({ + where: { + organizationId: orgId, + deactivated: false, + }, + }); + + const employees = allMembers.filter((member) => { + const roles = member.role.includes(',') ? member.role.split(',') : [member.role]; + return roles.includes('employee') || roles.includes('contractor'); + }); + + const showEmployeeTasks = employees.length > 0; + + // Fetch devices data + let devices: Host[] = []; + try { + const fetchedDevices = await getEmployeeDevices(); + devices = fetchedDevices || []; + } catch (error) { + console.error('Error fetching employee devices:', error); + devices = []; + } + + return ( + } + employeeTasksContent={showEmployeeTasks ? : null} + devicesContent={ + <> + + + + } + showEmployeeTasks={showEmployeeTasks} + /> + ); +} + +export async function generateMetadata(): Promise { + return { + title: 'People', + }; } diff --git a/apps/app/src/app/(app)/[orgId]/policies/(overview)/components/PolicyChartsClient.tsx b/apps/app/src/app/(app)/[orgId]/policies/(overview)/components/PolicyChartsClient.tsx new file mode 100644 index 000000000..bbc67613e --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/policies/(overview)/components/PolicyChartsClient.tsx @@ -0,0 +1,31 @@ +'use client'; + +import { Grid } from '@trycompai/design-system'; +import { + type PoliciesOverview, + usePoliciesOverview, +} from '../hooks/usePoliciesOverview'; +import { PolicyAssigneeChart } from './policy-assignee-chart'; +import { PolicyStatusChart } from './policy-status-chart'; + +interface PolicyChartsClientProps { + organizationId: string; + initialData: PoliciesOverview | null; +} + +export function PolicyChartsClient({ + organizationId, + initialData, +}: PolicyChartsClientProps) { + const { overview } = usePoliciesOverview({ + organizationId, + initialData, + }); + + return ( + + + + + ); +} diff --git a/apps/app/src/app/(app)/[orgId]/policies/(overview)/components/policy-assignee-chart.tsx b/apps/app/src/app/(app)/[orgId]/policies/(overview)/components/policy-assignee-chart.tsx index ed01f8f87..93429f00b 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/(overview)/components/policy-assignee-chart.tsx +++ b/apps/app/src/app/(app)/[orgId]/policies/(overview)/components/policy-assignee-chart.tsx @@ -2,16 +2,15 @@ import * as React from 'react'; -import { Badge } from '@comp/ui/badge'; -import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@comp/ui/card'; import { type ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent, } from '@comp/ui/chart'; -import { Users } from 'lucide-react'; -import { Bar, BarChart, ResponsiveContainer, XAxis, YAxis } from 'recharts'; +import { Card, HStack, Text } from '@trycompai/design-system'; +import { UserMultiple } from '@trycompai/design-system/icons'; +import { Bar, BarChart, Cell, LabelList, ResponsiveContainer, XAxis, YAxis } from 'recharts'; interface AssigneeData { id: string; @@ -27,186 +26,127 @@ interface PolicyAssigneeChartProps { data?: AssigneeData[] | null; } -const CHART_COLORS = { - published: 'hsl(var(--chart-positive))', // green - draft: 'hsl(var(--chart-neutral))', // yellow - archived: 'hsl(var(--chart-warning))', // gray - needs_review: 'hsl(var(--chart-destructive))', // red +const BAR_COLOR = 'var(--success)'; + +const STATUS_COLORS = { + published: 'var(--success)', + draft: 'var(--warning)', + needs_review: 'var(--destructive)', + archived: 'var(--muted-foreground)', }; export function PolicyAssigneeChart({ data }: PolicyAssigneeChartProps) { - // Sort assignees by total policies (descending) + // Sort assignees by total policies (descending) and take top 5 const sortedData = React.useMemo(() => { if (!data || data.length === 0) return []; - return [...data] - .sort((a, b) => b.total - a.total) - .slice(0, 4) - .reverse(); + return [...data].sort((a, b) => b.total - a.total).slice(0, 5); }, [data]); - // Calculate total policies and top assignee - const totalPolicies = React.useMemo(() => { - if (!data || data.length === 0) return 0; - return data.reduce((sum, item) => sum + item.total, 0); - }, [data]); - - const topAssignee = React.useMemo(() => { - if (!data || data.length === 0) return null; - return data.reduce((prev, current) => (prev.total > current.total ? prev : current)); - }, [data]); + // Calculate totals for footer + const totalAssignees = data?.length ?? 0; + const totalAssignedPolicies = data?.reduce((sum, a) => sum + a.total, 0) ?? 0; if (!data || data.length === 0) { return ( - - -
- {'Policies by Assignee'} - - - Distribution - -
-
- -
-
- -
-

- No policies assigned to users -

-
-
- -
- + +
+ + + No policies assigned to users + +
); } const chartData = sortedData.map((item) => ({ - name: item.name, - published: item.published, - draft: item.draft, - archived: item.archived, - needs_review: item.needs_review, + name: item.name.split(' ')[0], // First name only for cleaner display + fullName: item.name, + total: item.total, })); const chartConfig = { - published: { - label: 'Published', - color: CHART_COLORS.published, - }, - draft: { - label: 'Draft', - color: CHART_COLORS.draft, - }, - archived: { - label: 'Archived', - color: CHART_COLORS.archived, - }, - needs_review: { - label: 'Needs Review', - color: CHART_COLORS.needs_review, + total: { + label: 'Policies', + color: BAR_COLOR, }, } satisfies ChartConfig; - return ( - - -
- {'Policies by Assignee'} -
+ // Dynamic height based on number of assignees + const barHeight = 28; + const chartHeight = Math.max(sortedData.length * barHeight, 80); -
-
0 ? 100 : 0}%`, - }} - /> -
- - -
-
- Assignee - Policy Count -
- - - - - value.split(' ')[0]} - fontSize={12} - stroke="hsl(var(--muted-foreground))" - /> - } /> - - - - 5; + + return ( + +
+ + + + + + ( + + {props.payload.fullName}: {value} policies + + )} + /> + } + /> + + {chartData.map((entry, index) => ( + + ))} + - - - -
- - -
- {Object.entries(chartConfig).map(([key, config]) => ( -
-
- {config.label} -
- ))} -
- + + + + +
+
+ + + {Object.entries(STATUS_COLORS).map(([status, color]) => ( + +
+ + {status === 'needs_review' + ? 'Review' + : status.charAt(0).toUpperCase() + status.slice(1)} + + + ))} + + + {showingLimited ? `Top 5 of ${totalAssignees}` : `${totalAssignees} assignees`} + + +
); } diff --git a/apps/app/src/app/(app)/[orgId]/policies/(overview)/components/policy-status-chart.tsx b/apps/app/src/app/(app)/[orgId]/policies/(overview)/components/policy-status-chart.tsx index e2bfbd187..71783ce8d 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/(overview)/components/policy-status-chart.tsx +++ b/apps/app/src/app/(app)/[orgId]/policies/(overview)/components/policy-status-chart.tsx @@ -3,15 +3,14 @@ import * as React from 'react'; import { Label, Pie, PieChart } from 'recharts'; -import { Badge } from '@comp/ui/badge'; -import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@comp/ui/card'; import { type ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent, } from '@comp/ui/chart'; -import { Info } from 'lucide-react'; +import { Card, HStack, Stack, Text } from '@trycompai/design-system'; +import { Information } from '@trycompai/design-system/icons'; interface PolicyOverviewData { totalPolicies: number; @@ -26,84 +25,65 @@ interface PolicyStatusChartProps { } const CHART_COLORS = { - published: 'hsl(var(--chart-positive))', // green - draft: 'hsl(var(--chart-neutral))', // yellow - archived: 'hsl(var(--chart-warning))', // gray - needs_review: 'hsl(var(--chart-destructive))', // red + published: 'var(--primary)', + draft: 'var(--warning)', + needs_review: 'var(--destructive)', + archived: 'var(--muted-foreground)', }; -// Custom tooltip component for the pie chart -const StatusTooltip = ({ active, payload }: any) => { - if (active && payload && payload.length) { - const data = payload[0].payload; - return ( -
-

{data.name}

-

- Count: {data.value} -

-
- ); - } - return null; +const STATUS_LABELS: Record = { + published: 'Published', + draft: 'Draft', + needs_review: 'Review', + archived: 'Archived', }; export function PolicyStatusChart({ data }: PolicyStatusChartProps) { - const chartData = React.useMemo(() => { + // All statuses for the legend (always show all) + const allStatuses = React.useMemo(() => { if (!data) return []; - const items = [ + return [ { + key: 'published', name: 'Published', value: data.publishedPolicies, fill: CHART_COLORS.published, }, { + key: 'draft', name: 'Draft', value: data.draftPolicies, fill: CHART_COLORS.draft, }, { + key: 'needs_review', name: 'Needs Review', value: data.needsReviewPolicies, fill: CHART_COLORS.needs_review, }, { + key: 'archived', name: 'Archived', value: data.archivedPolicies, fill: CHART_COLORS.archived, }, ]; - return items.filter((item) => item.value > 0); }, [data]); - // Calculate most common status - const mostCommonStatus = React.useMemo(() => { - if (!chartData.length) return null; - return chartData.reduce((prev, current) => (prev.value > current.value ? prev : current)); - }, [chartData]); + // Only non-zero values for the pie chart + const chartData = React.useMemo(() => { + return allStatuses.filter((item) => item.value > 0); + }, [allStatuses]); if (!data) { return ( - - -
- {'Policy by Status'} - - Overview - -
-
- -
-
- -
-

No policy data available

-
-
- -
- + +
+ + + No policy data available + +
); } @@ -115,52 +95,32 @@ export function PolicyStatusChart({ data }: PolicyStatusChartProps) { } satisfies ChartConfig; return ( - - -
- {'Policy by Status'} -
- -
-
-
- - - - - } /> - -