From 882f4fe7504a4058f04fa30cfea19f6c413cfcd9 Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Tue, 27 Jan 2026 16:22:17 -0500 Subject: [PATCH 1/3] chore(vendor): add comments and tasks tabs to vendor detail view --- .../components/VendorDetailTabs.tsx | 16 +++- .../[vendorId]/components/VendorHeader.tsx | 88 +++++++++---------- .../components/VendorPageClient.tsx | 7 -- .../secondary-fields/secondary-fields.tsx | 10 +-- .../comments/CommentRichTextField.tsx | 32 +++---- .../risks/charts/RiskMatrixChart.tsx | 20 +++-- 6 files changed, 88 insertions(+), 85 deletions(-) diff --git a/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/components/VendorDetailTabs.tsx b/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/components/VendorDetailTabs.tsx index 93f3d5c71..07b33859f 100644 --- a/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/components/VendorDetailTabs.tsx +++ b/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/components/VendorDetailTabs.tsx @@ -1,6 +1,8 @@ 'use client'; +import { Comments } from '@/components/comments/Comments'; import { VendorRiskAssessmentView } from '@/components/vendor-risk-assessment/VendorRiskAssessmentView'; +import { CommentEntityType } from '@db'; import type { Member, User, Vendor } from '@db'; import type { Prisma } from '@prisma/client'; import { @@ -16,6 +18,7 @@ import { import { useState } from 'react'; import { VendorActions } from './VendorActions'; import { VendorPageClient } from './VendorPageClient'; +import { TaskItems } from '@/components/task-items/TaskItems'; // Vendor with risk assessment data merged from GlobalVendors type VendorWithRiskAssessment = Vendor & { @@ -73,6 +76,8 @@ export function VendorDetailTabs({ Overview Risk Assessment + Tasks + Comments ) } @@ -90,7 +95,6 @@ export function VendorDetailTabs({ onEditSheetOpenChange={setIsEditSheetOpen} /> - {riskAssessmentData ? ( @@ -115,6 +119,16 @@ export function VendorDetailTabs({ )} + {!isViewingTask && ( + + + + )} + {!isViewingTask && ( + + + + )} ); diff --git a/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/components/VendorHeader.tsx b/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/components/VendorHeader.tsx index 63e112456..87b3d1b66 100644 --- a/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/components/VendorHeader.tsx +++ b/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/components/VendorHeader.tsx @@ -93,55 +93,51 @@ export function VendorHeader({ vendor, isEditSheetOpen, onEditSheetOpenChange }: return ( <> -
-
-

{vendor.name}

- {certifications.filter((cert) => cert.status === 'verified').length > 0 && ( -
- {certifications - .filter((cert) => { - // Only show verified certifications - return cert.status === 'verified'; - }) - .map((cert, index) => { - const IconComponent = getCertificationIcon(cert); - - if (!IconComponent) return null; - - const iconContent = ( -
+ {certifications.filter((cert) => cert.status === 'verified').length > 0 && ( +
+ {certifications + .filter((cert) => { + // Only show verified certifications + return cert.status === 'verified'; + }) + .map((cert, index) => { + const IconComponent = getCertificationIcon(cert); + + if (!IconComponent) return null; + + const iconContent = ( +
+ +
+ ); + + if (cert.url) { + return ( + - -
+ {iconContent} + ); + } - if (cert.url) { - return ( - - {iconContent} - - ); - } - - return
{iconContent}
; - })} -
- )} -
- {vendor.description &&

{vendor.description}

} + return
{iconContent}
; + })} +
+ )} {links.length > 0 && (
{links.map((link, index) => { diff --git a/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/components/VendorPageClient.tsx b/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/components/VendorPageClient.tsx index 41cfae33d..1b8879647 100644 --- a/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/components/VendorPageClient.tsx +++ b/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/components/VendorPageClient.tsx @@ -1,9 +1,6 @@ 'use client'; -import { Comments } from '@/components/comments/Comments'; -import { TaskItems } from '@/components/task-items/TaskItems'; import { useVendor, type VendorResponse } from '@/hooks/use-vendors'; -import { CommentEntityType } from '@db'; import type { Member, User, Vendor } from '@db'; import type { Prisma } from '@prisma/client'; import { useMemo } from 'react'; @@ -102,10 +99,6 @@ export function VendorPageClient({
)} - - {!isViewingTask && ( - - )}
); diff --git a/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/components/secondary-fields/secondary-fields.tsx b/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/components/secondary-fields/secondary-fields.tsx index f098df2b9..313960633 100644 --- a/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/components/secondary-fields/secondary-fields.tsx +++ b/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/components/secondary-fields/secondary-fields.tsx @@ -1,7 +1,7 @@ 'use client'; -import { Card, CardContent } from '@comp/ui/card'; import type { Member, User, Vendor } from '@db'; +import { Section } from '@trycompai/design-system'; import { UpdateSecondaryFieldsForm } from './update-secondary-fields-form'; export function SecondaryFields({ @@ -12,10 +12,8 @@ export function SecondaryFields({ assignees: (Member & { user: User })[]; }) { return ( - - - - - +
+ +
); } diff --git a/apps/app/src/components/comments/CommentRichTextField.tsx b/apps/app/src/components/comments/CommentRichTextField.tsx index d08fd646f..c9198e423 100644 --- a/apps/app/src/components/comments/CommentRichTextField.tsx +++ b/apps/app/src/components/comments/CommentRichTextField.tsx @@ -1,17 +1,17 @@ 'use client'; +import { createMentionExtension, type MentionUser } from '@comp/ui/editor'; +import { defaultExtensions } from '@comp/ui/editor/extensions'; import type { JSONContent } from '@tiptap/react'; -import { useEditor, EditorContent } from '@tiptap/react'; -import { useMemo, useEffect, useCallback } from 'react'; +import { EditorContent, useEditor } from '@tiptap/react'; import type { CSSProperties } from 'react'; +import { useCallback, useEffect, useMemo } from 'react'; +import { useDebouncedCallback } from 'use-debounce'; type EditorSizeStyle = CSSProperties & { '--editor-min-height': string; '--editor-height': string; }; -import { createMentionExtension, type MentionUser } from '@comp/ui/editor'; -import { useDebouncedCallback } from 'use-debounce'; -import { defaultExtensions } from '@comp/ui/editor/extensions'; interface CommentRichTextFieldProps { value: JSONContent | null; @@ -30,19 +30,16 @@ export function CommentRichTextField({ placeholder = 'Leave a comment... Mention users with @', onMentionSelect, }: CommentRichTextFieldProps) { - const editorSizeStyles: EditorSizeStyle = useMemo( - () => ({ - '--editor-min-height': '120px', - '--editor-height': 'auto', - }), - [], - ); + const editorSizeStyles: EditorSizeStyle = { + '--editor-min-height': '80px', + '--editor-height': 'auto', + }; // Search members for mention suggestions - use the members prop directly const searchMembers = useCallback( (query: string): MentionUser[] => { if (!members || members.length === 0) return []; - + // Show first 20 members immediately when query is empty if (!query || query.trim() === '') { return members.slice(0, 20); @@ -108,7 +105,8 @@ export function CommentRichTextField({ editorProps: { attributes: { class: - 'comment-editor prose-sm max-w-none focus:outline-none px-3 py-2 text-sm [&_p]:m-0 [&_p]:p-0 [&_p]:text-sm [&_p]:leading-normal', + 'comment-editor prose-sm max-w-none focus:outline-none p-6 text-sm [&_p]:m-0 [&_p]:p-0 [&_p]:text-sm [&_p]:leading-normal', + style: 'min-height: var(--editor-min-height, 240px);', }, }, }, @@ -131,9 +129,11 @@ export function CommentRichTextField({ }, [value, editor]); return ( -
+
); } - diff --git a/apps/app/src/components/risks/charts/RiskMatrixChart.tsx b/apps/app/src/components/risks/charts/RiskMatrixChart.tsx index 1a0caf1b0..eed49630e 100644 --- a/apps/app/src/components/risks/charts/RiskMatrixChart.tsx +++ b/apps/app/src/components/risks/charts/RiskMatrixChart.tsx @@ -63,7 +63,6 @@ const getRiskColor = (level: string) => { }; const probabilityLevels = ['Very Likely', 'Likely', 'Possible', 'Unlikely', 'Very Unlikely']; -const probabilityNumbers = ['5', '4', '3', '2', '1']; const probabilityLabels = [ 'Very Likely (5)', 'Likely (4)', @@ -183,7 +182,10 @@ export function RiskMatrixChart({
-
+
+
+ Likelihood +
@@ -194,11 +196,8 @@ export function RiskMatrixChart({ ))} {probabilityLevels.map((probability, rowIdx) => (
-
- {probabilityNumbers[rowIdx]} +
+ {probabilityLevels[rowIdx]}
{impactLevels.map((impact, colIdx) => { const cell = riskData.find( @@ -230,8 +229,11 @@ export function RiskMatrixChart({
))}
-
- {'Impact'} +
+
+
+ Impact +
From 7f0511fd9f3ee54e811a21920349023ffe316eb5 Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Wed, 28 Jan 2026 09:33:16 -0500 Subject: [PATCH 2/3] feat(organization): add onboarding status retrieval endpoint and responses --- .../organization/organization.controller.ts | 23 ++ .../src/organization/organization.service.ts | 17 + .../get-organization-onboarding.responses.ts | 45 +++ .../schemas/organization-operations.ts | 5 + .../schemas/get-all-vendors.responses.ts | 15 + apps/api/src/vendors/vendors.controller.ts | 37 +- apps/api/src/vendors/vendors.service.ts | 79 +++- .../(overview)/components/VendorCells.tsx | 67 ++++ .../(overview)/components/VendorsFilters.tsx | 180 +++++++++ .../(overview)/components/VendorsTable.tsx | 375 ++++++++---------- .../components/vendors-table-constants.ts | 40 ++ .../vendors/(overview)/data/validations.ts | 5 +- .../(app)/[orgId]/vendors/(overview)/page.tsx | 59 ++- .../[vendorId]/components/VendorActions.tsx | 2 +- .../components/VendorDetailTabs.tsx | 46 ++- .../[vendorId]/components/VendorHeader.tsx | 9 +- .../components/VendorInherentRiskChart.tsx | 21 +- .../components/VendorPageClient.tsx | 76 +--- .../components/VendorResidualRiskChart.tsx | 21 +- .../secondary-fields/secondary-fields.tsx | 15 +- .../update-secondary-fields-form.tsx | 114 +++--- .../tasks/create-vendor-task-form.tsx | 99 ++--- .../update-title-and-description-form.tsx | 69 ++-- .../update-title-and-description-sheet.tsx | 5 +- .../[vendorId]/components/vendor-utils.ts | 25 ++ .../(app)/[orgId]/vendors/[vendorId]/page.tsx | 123 +----- .../review/components/VendorReviewClient.tsx | 3 +- .../vendors/components/create-vendor-form.tsx | 6 +- .../components/create-vendor-sheet.tsx | 4 +- .../(app)/[orgId]/vendors/utils/assignees.ts | 25 ++ apps/app/src/components/SelectAssignee.tsx | 15 +- apps/app/src/hooks/use-api-swr.ts | 9 +- apps/app/src/hooks/use-vendor.ts | 85 ++++ apps/app/src/hooks/use-vendors.ts | 166 ++------ apps/app/src/lib/server-api-client.ts | 64 ++- apps/app/src/lib/vendors-query.ts | 32 ++ packages/docs/openapi.json | 157 ++++++++ 37 files changed, 1393 insertions(+), 745 deletions(-) create mode 100644 apps/api/src/organization/schemas/get-organization-onboarding.responses.ts create mode 100644 apps/app/src/app/(app)/[orgId]/vendors/(overview)/components/VendorCells.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/vendors/(overview)/components/VendorsFilters.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/vendors/(overview)/components/vendors-table-constants.ts create mode 100644 apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/components/vendor-utils.ts create mode 100644 apps/app/src/app/(app)/[orgId]/vendors/utils/assignees.ts create mode 100644 apps/app/src/hooks/use-vendor.ts create mode 100644 apps/app/src/lib/vendors-query.ts diff --git a/apps/api/src/organization/organization.controller.ts b/apps/api/src/organization/organization.controller.ts index 73f1ed3ff..f1b1d3c0b 100644 --- a/apps/api/src/organization/organization.controller.ts +++ b/apps/api/src/organization/organization.controller.ts @@ -33,6 +33,7 @@ import { UPDATE_ORGANIZATION_RESPONSES } from './schemas/update-organization.res import { DELETE_ORGANIZATION_RESPONSES } from './schemas/delete-organization.responses'; import { TRANSFER_OWNERSHIP_RESPONSES } from './schemas/transfer-ownership.responses'; import { GET_ORGANIZATION_PRIMARY_COLOR_RESPONSES } from './schemas/get-organization-primary-color'; +import { GET_ORGANIZATION_ONBOARDING_RESPONSES } from './schemas/get-organization-onboarding.responses'; import { UPDATE_ORGANIZATION_BODY, TRANSFER_OWNERSHIP_BODY, @@ -223,4 +224,26 @@ export class OrganizationController { }), }; } + + @Get('onboarding') + @ApiOperation(ORGANIZATION_OPERATIONS.getOnboardingStatus) + @ApiResponse(GET_ORGANIZATION_ONBOARDING_RESPONSES[200]) + @ApiResponse(GET_ORGANIZATION_ONBOARDING_RESPONSES[401]) + async getOnboardingStatus( + @OrganizationId() organizationId: string, + @AuthContext() authContext: AuthContextType, + ) { + const onboarding = await this.organizationService.getOnboardingStatus(organizationId); + + return { + ...onboarding, + authType: authContext.authType, + ...(authContext.userId && { + authenticatedUser: { + id: authContext.userId, + email: authContext.userEmail, + }, + }), + }; + } } diff --git a/apps/api/src/organization/organization.service.ts b/apps/api/src/organization/organization.service.ts index b484e7d25..609fb0b57 100644 --- a/apps/api/src/organization/organization.service.ts +++ b/apps/api/src/organization/organization.service.ts @@ -332,4 +332,21 @@ export class OrganizationService { throw error; } } + + async getOnboardingStatus(organizationId: string) { + try { + const onboarding = await db.onboarding.findFirst({ + where: { organizationId }, + select: { triggerJobId: true }, + }); + + return { triggerJobId: onboarding?.triggerJobId ?? null }; + } catch (error) { + this.logger.error( + `Failed to retrieve onboarding status for organization ${organizationId}:`, + error, + ); + throw error; + } + } } diff --git a/apps/api/src/organization/schemas/get-organization-onboarding.responses.ts b/apps/api/src/organization/schemas/get-organization-onboarding.responses.ts new file mode 100644 index 000000000..5e4fd234b --- /dev/null +++ b/apps/api/src/organization/schemas/get-organization-onboarding.responses.ts @@ -0,0 +1,45 @@ +import { ApiResponseOptions } from '@nestjs/swagger'; + +export const GET_ORGANIZATION_ONBOARDING_RESPONSES: Record = { + 200: { + status: 200, + description: 'Organization onboarding status retrieved successfully', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + triggerJobId: { + type: 'string', + nullable: true, + description: 'Trigger.dev onboarding run ID if active', + example: 'trg_abc123def456', + }, + authType: { + type: 'string', + enum: ['api-key', 'session'], + description: 'How the request was authenticated', + }, + }, + }, + }, + }, + }, + 401: { + status: 401, + description: 'Unauthorized - Invalid authentication or insufficient permissions', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + message: { + type: 'string', + example: 'Invalid or expired API key', + }, + }, + }, + }, + }, + }, +}; diff --git a/apps/api/src/organization/schemas/organization-operations.ts b/apps/api/src/organization/schemas/organization-operations.ts index 770e31637..f01c2b8fc 100644 --- a/apps/api/src/organization/schemas/organization-operations.ts +++ b/apps/api/src/organization/schemas/organization-operations.ts @@ -26,4 +26,9 @@ export const ORGANIZATION_OPERATIONS: Record = { description: 'Returns the primary color of the organization. Supports three access methods: 1) API key authentication (X-API-Key header), 2) Session authentication (cookies + X-Organization-Id header), or 3) Public access using an access token query parameter (?token=tok_xxx). When using an access token, no authentication is required.', }, + getOnboardingStatus: { + summary: 'Get organization onboarding status', + description: + 'Returns the current onboarding trigger run ID for the organization, if any. Supports both API key authentication (X-API-Key header) and session authentication (cookies + X-Organization-Id header).', + }, }; diff --git a/apps/api/src/vendors/schemas/get-all-vendors.responses.ts b/apps/api/src/vendors/schemas/get-all-vendors.responses.ts index b8b59395c..8dc702205 100644 --- a/apps/api/src/vendors/schemas/get-all-vendors.responses.ts +++ b/apps/api/src/vendors/schemas/get-all-vendors.responses.ts @@ -123,6 +123,21 @@ export const GET_ALL_VENDORS_RESPONSES: Record = { description: 'Total number of vendors', example: 12, }, + page: { + type: 'number', + description: 'Current page number', + example: 1, + }, + perPage: { + type: 'number', + description: 'Number of vendors per page', + example: 50, + }, + pageCount: { + type: 'number', + description: 'Total number of pages', + example: 3, + }, authType: { type: 'string', enum: ['api-key', 'session'], diff --git a/apps/api/src/vendors/vendors.controller.ts b/apps/api/src/vendors/vendors.controller.ts index 04194938b..61810eae5 100644 --- a/apps/api/src/vendors/vendors.controller.ts +++ b/apps/api/src/vendors/vendors.controller.ts @@ -6,6 +6,7 @@ import { Delete, Body, Param, + Query, UseGuards, } from '@nestjs/common'; import { @@ -54,13 +55,41 @@ export class VendorsController { async getAllVendors( @OrganizationId() organizationId: string, @AuthContext() authContext: AuthContextType, + @Query('page') page?: string, + @Query('perPage') perPage?: string, + @Query('name') name?: string, + @Query('status') status?: string, + @Query('category') category?: string, + @Query('department') department?: string, + @Query('assigneeId') assigneeId?: string, + @Query('sortId') sortId?: string, + @Query('sortDesc') sortDesc?: string, ) { - const vendors = - await this.vendorsService.findAllByOrganization(organizationId); + const parsedPage = Math.max(1, Number(page) || 1); + const parsedPerPage = Math.min(100, Math.max(1, Number(perPage) || 50)); + const parsedSortId = + sortId === 'updatedAt' || sortId === 'createdAt' ? sortId : 'name'; + const parsedSortDesc = sortDesc === 'true'; + + const { data, count, pageCount } = + await this.vendorsService.findAllByOrganization(organizationId, { + page: parsedPage, + perPage: parsedPerPage, + name, + status, + category, + department, + assigneeId, + sortId: parsedSortId, + sortDesc: parsedSortDesc, + }); return { - data: vendors, - count: vendors.length, + data, + count, + page: parsedPage, + perPage: parsedPerPage, + pageCount, authType: authContext.authType, ...(authContext.userId && authContext.userEmail && { diff --git a/apps/api/src/vendors/vendors.service.ts b/apps/api/src/vendors/vendors.service.ts index ea2d8c71e..2e500b25b 100644 --- a/apps/api/src/vendors/vendors.service.ts +++ b/apps/api/src/vendors/vendors.service.ts @@ -1,5 +1,12 @@ import { Injectable, NotFoundException, Logger } from '@nestjs/common'; -import { db, TaskItemPriority, TaskItemStatus } from '@trycompai/db'; +import { + db, + TaskItemPriority, + TaskItemStatus, + VendorStatus, + VendorCategory, + Departments, +} from '@trycompai/db'; import { CreateVendorDto } from './dto/create-vendor.dto'; import { UpdateVendorDto } from './dto/update-vendor.dto'; import { tasks } from '@trigger.dev/sdk'; @@ -59,17 +66,75 @@ const VERIFY_RISK_ASSESSMENT_TASK_TITLE = 'Verify risk assessment' as const; export class VendorsService { private readonly logger = new Logger(VendorsService.name); - async findAllByOrganization(organizationId: string) { + async findAllByOrganization( + organizationId: string, + params: { + page: number; + perPage: number; + name?: string; + status?: string; + category?: string; + department?: string; + assigneeId?: string; + sortId: 'name' | 'updatedAt' | 'createdAt'; + sortDesc: boolean; + }, + ) { try { - const vendors = await db.vendor.findMany({ - where: { organizationId }, - orderBy: { createdAt: 'desc' }, - }); + const { page, perPage, name, status, category, department, assigneeId, sortId, sortDesc } = params; + const parsedStatus = + status && Object.values(VendorStatus).includes(status as VendorStatus) + ? (status as VendorStatus) + : undefined; + const parsedCategory = + category && Object.values(VendorCategory).includes(category as VendorCategory) + ? (category as VendorCategory) + : undefined; + const parsedDepartment = + department && Object.values(Departments).includes(department as Departments) + ? (department as Departments) + : undefined; + const parsedAssigneeId = assigneeId === 'unassigned' ? null : assigneeId || undefined; + const whereClause = { + organizationId, + ...(parsedStatus ? { status: parsedStatus } : {}), + ...(parsedCategory ? { category: parsedCategory } : {}), + ...(parsedDepartment ? { department: parsedDepartment } : {}), + ...(parsedAssigneeId === null + ? { assigneeId: null } + : parsedAssigneeId + ? { assigneeId: parsedAssigneeId } + : {}), + ...(name + ? { + name: { + contains: name, + mode: Prisma.QueryMode.insensitive, + }, + } + : {}), + }; + + const [count, vendors] = await Promise.all([ + db.vendor.count({ where: whereClause }), + db.vendor.findMany({ + where: whereClause, + orderBy: { + [sortId]: sortDesc ? 'desc' : 'asc', + }, + skip: (page - 1) * perPage, + take: perPage, + }), + ]); this.logger.log( `Retrieved ${vendors.length} vendors for organization ${organizationId}`, ); - return vendors; + return { + data: vendors, + count, + pageCount: Math.max(1, Math.ceil(count / perPage)), + }; } catch (error) { this.logger.error( `Failed to retrieve vendors for organization ${organizationId}:`, diff --git a/apps/app/src/app/(app)/[orgId]/vendors/(overview)/components/VendorCells.tsx b/apps/app/src/app/(app)/[orgId]/vendors/(overview)/components/VendorCells.tsx new file mode 100644 index 000000000..9a445b17d --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/vendors/(overview)/components/VendorCells.tsx @@ -0,0 +1,67 @@ +import { VendorStatus } from '@/components/vendor-status'; +import { + HStack, + Spinner, + Text, +} from '@trycompai/design-system'; +import { useVendorOnboardingStatus } from './vendor-onboarding-context'; +import type { VendorRow } from './VendorsTable'; + +interface VendorNameCellProps { + vendor: VendorRow; +} + +export function VendorNameCell({ vendor }: VendorNameCellProps) { + const onboardingStatus = useVendorOnboardingStatus(); + const status = onboardingStatus[vendor.id]; + const isPending = vendor.isPending || status === 'pending' || status === 'processing'; + const isAssessing = vendor.isAssessing || status === 'assessing'; + const isResolved = vendor.status === 'assessed'; + + if ((isPending || isAssessing) && !isResolved) { + return ( + + + {vendor.name} + + ); + } + + return {vendor.name}; +} + +interface VendorStatusCellProps { + vendor: VendorRow; +} + +export function VendorStatusCell({ vendor }: VendorStatusCellProps) { + const onboardingStatus = useVendorOnboardingStatus(); + const status = onboardingStatus[vendor.id]; + const isPending = vendor.isPending || status === 'pending' || status === 'processing'; + const isAssessing = vendor.isAssessing || status === 'assessing'; + const isResolved = vendor.status === 'assessed'; + + if (isPending && !isResolved) { + return ( + + + + Creating... + + + ); + } + + if (isAssessing && !isResolved) { + return ( + + + + Assessing... + + + ); + } + + return ; +} diff --git a/apps/app/src/app/(app)/[orgId]/vendors/(overview)/components/VendorsFilters.tsx b/apps/app/src/app/(app)/[orgId]/vendors/(overview)/components/VendorsFilters.tsx new file mode 100644 index 000000000..bcbc2732e --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/vendors/(overview)/components/VendorsFilters.tsx @@ -0,0 +1,180 @@ +import type { AssigneeOption } from '@/components/SelectAssignee'; +import { VendorStatus } from '@/components/vendor-status'; +import { + Avatar, + AvatarFallback, + AvatarImage, + HStack, + InputGroup, + InputGroupAddon, + InputGroupInput, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@trycompai/design-system'; +import { Search } from '@trycompai/design-system/icons'; +import type { VendorCategory, VendorStatus as VendorStatusEnum } from '@db'; +import { CATEGORY_MAP, VENDOR_STATUS_LABELS } from './vendors-table-constants'; +import { UserIcon } from 'lucide-react'; +import { useDebouncedCallback } from 'use-debounce'; +import { useEffect, useRef, useState } from 'react'; + +interface VendorsFiltersProps { + searchQuery: string; + statusFilter: VendorStatusEnum | 'all'; + categoryFilter: VendorCategory | 'all'; + assigneeFilter: string; + assignees: AssigneeOption[]; + onSearchChange: (value: string) => void; + onStatusChange: (value: string | null) => void; + onCategoryChange: (value: string | null) => void; + onAssigneeChange: (value: string | null) => void; +} + +export function VendorsFilters({ + searchQuery, + statusFilter, + categoryFilter, + assigneeFilter, + assignees, + onSearchChange, + onStatusChange, + onCategoryChange, + onAssigneeChange, +}: VendorsFiltersProps) { + const [localSearch, setLocalSearch] = useState(searchQuery); + const isUserTypingRef = useRef(false); + const debouncedSearchChange = useDebouncedCallback((value: string) => { + isUserTypingRef.current = false; + onSearchChange(value); + }, 300); + + useEffect(() => { + if (!isUserTypingRef.current && searchQuery !== localSearch) { + setLocalSearch(searchQuery); + } + }, [searchQuery]); + + return ( +
+
+ + + + + { + const nextValue = e.target.value; + setLocalSearch(nextValue); + isUserTypingRef.current = true; + debouncedSearchChange(nextValue); + }} + /> + +
+
+
+ +
+
+ +
+
+ +
+
+
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/vendors/(overview)/components/VendorsTable.tsx b/apps/app/src/app/(app)/[orgId]/vendors/(overview)/components/VendorsTable.tsx index 110e384fd..ca87c6e2e 100644 --- a/apps/app/src/app/(app)/[orgId]/vendors/(overview)/components/VendorsTable.tsx +++ b/apps/app/src/app/(app)/[orgId]/vendors/(overview)/components/VendorsTable.tsx @@ -1,7 +1,7 @@ 'use client'; import { OnboardingLoadingAnimation } from '@/components/onboarding-loading-animation'; -import { VendorStatus } from '@/components/vendor-status'; +import type { AssigneeOption } from '@/components/SelectAssignee'; import { AlertDialog, AlertDialogAction, @@ -24,9 +24,6 @@ import { EmptyHeader, EmptyTitle, HStack, - InputGroup, - InputGroupAddon, - InputGroupInput, Spinner, Stack, Table, @@ -37,171 +34,128 @@ import { TableRow, Text, } from '@trycompai/design-system'; -import { OverflowMenuVertical, Search, TrashCan } from '@trycompai/design-system/icons'; +import { OverflowMenuVertical, TrashCan } from '@trycompai/design-system/icons'; import { ArrowDown, ArrowUp, ArrowUpDown, Loader2, UserIcon } from 'lucide-react'; -import { useRouter } from 'next/navigation'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { usePathname, useRouter, useSearchParams } from 'next/navigation'; +import { useMemo, useState } from 'react'; import { toast } from 'sonner'; -import useSWR from 'swr'; -import { deleteVendor } from '../actions/deleteVendor'; -import { getVendorsAction, type GetVendorsActionInput } from '../actions/get-vendors-action'; -import type { GetAssigneesResult, GetVendorsResult } from '../data/queries'; -import type { GetVendorsSchema } from '../data/validations'; +import { useVendors, type Vendor as ApiVendor } from '@/hooks/use-vendors'; import { useOnboardingStatus } from '../hooks/use-onboarding-status'; -import { VendorOnboardingProvider, useVendorOnboardingStatus } from './vendor-onboarding-context'; - -export type VendorRow = GetVendorsResult['data'][number] & { +import { VendorOnboardingProvider } from './vendor-onboarding-context'; +import type { VendorCategory, VendorStatus as VendorStatusEnum } from '@db'; +import { ACTIVE_STATUSES, CATEGORY_MAP, VENDOR_STATUS_LABELS } from './vendors-table-constants'; +import { VendorsFilters } from './VendorsFilters'; +import { VendorNameCell, VendorStatusCell } from './VendorCells'; + +export type VendorRow = Omit & { + createdAt: Date; + updatedAt: Date; + assignee: AssigneeOption | null; isPending?: boolean; isAssessing?: boolean; }; -const callGetVendorsAction = getVendorsAction as unknown as ( - input: GetVendorsActionInput, -) => Promise; - -const ACTIVE_STATUSES: Array<'pending' | 'processing' | 'created' | 'assessing'> = [ - 'pending', - 'processing', - 'created', - 'assessing', -]; - -const CATEGORY_MAP: Record = { - cloud: 'Cloud', - infrastructure: 'Infrastructure', - software_as_a_service: 'SaaS', - finance: 'Finance', - marketing: 'Marketing', - sales: 'Sales', - hr: 'HR', - other: 'Other', -}; - interface VendorsTableProps { - vendors: GetVendorsResult['data']; - pageCount: number; - assignees: GetAssigneesResult; + vendors: ApiVendor[]; + assignees: AssigneeOption[]; onboardingRunId?: string | null; - searchParams: GetVendorsSchema; orgId: string; } -function VendorNameCell({ vendor, orgId }: { vendor: VendorRow; orgId: string }) { - const onboardingStatus = useVendorOnboardingStatus(); - const status = onboardingStatus[vendor.id]; - const isPending = vendor.isPending || status === 'pending' || status === 'processing'; - const isAssessing = vendor.isAssessing || status === 'assessing'; - const isResolved = vendor.status === 'assessed'; - - if ((isPending || isAssessing) && !isResolved) { - return ( - - - {vendor.name} - - ); - } - - return {vendor.name}; -} - -function VendorStatusCell({ vendor }: { vendor: VendorRow }) { - const onboardingStatus = useVendorOnboardingStatus(); - const status = onboardingStatus[vendor.id]; - const isPending = vendor.isPending || status === 'pending' || status === 'processing'; - const isAssessing = vendor.isAssessing || status === 'assessing'; - const isResolved = vendor.status === 'assessed'; - - if (isPending && !isResolved) { - return ( - - - - Creating... - - - ); - } - - if (isAssessing && !isResolved) { - return ( - - - - Assessing... - - - ); - } - - return ; -} - export function VendorsTable({ vendors: initialVendors, - pageCount: initialPageCount, assignees, onboardingRunId, orgId, }: VendorsTableProps) { const router = useRouter(); + const pathname = usePathname(); + const urlSearchParams = useSearchParams(); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [vendorToDelete, setVendorToDelete] = useState(null); const [isDeleting, setIsDeleting] = useState(false); - // Local state for search, sorting, and pagination - const [searchQuery, setSearchQuery] = useState(''); - const [sort, setSort] = useState<{ id: 'name' | 'updatedAt'; desc: boolean }>({ - id: 'name', - desc: false, - }); - const [page, setPage] = useState(1); - const [perPage, setPerPage] = useState(25); + const searchQuery = urlSearchParams.get('name') ?? ''; + const statusFilter = (urlSearchParams.get('status') as VendorStatusEnum | null) ?? 'all'; + const categoryFilter = (urlSearchParams.get('category') as VendorCategory | null) ?? 'all'; + const assigneeFilter = urlSearchParams.get('assigneeId') ?? 'all'; + const page = Math.max(1, Number(urlSearchParams.get('page')) || 1); + const perPage = Math.min(100, Math.max(1, Number(urlSearchParams.get('perPage')) || 10)); + const sort = useMemo<{ id: 'name' | 'updatedAt'; desc: boolean }>(() => { + const rawSort = urlSearchParams.get('sort'); + if (!rawSort) return { id: 'name' as const, desc: false }; + try { + const parsed = JSON.parse(rawSort) as Array<{ id: string; desc: boolean }>; + const first = parsed?.[0]; + if (first?.id === 'name' || first?.id === 'updatedAt') { + return { id: first.id, desc: Boolean(first.desc) }; + } + } catch { + // ignore parse errors + } + return { id: 'name' as const, desc: false }; + }, [urlSearchParams]); const pageSizeOptions = [10, 25, 50, 100]; + const updateParams = (updates: Record) => { + const next = new URLSearchParams(urlSearchParams.toString()); + Object.entries(updates).forEach(([key, value]) => { + if (!value || value === 'all') { + next.delete(key); + } else { + next.set(key, value); + } + }); + const queryString = next.toString(); + router.replace(queryString ? `${pathname}?${queryString}` : pathname, { scroll: false }); + }; + + const { itemStatuses, progress, itemsInfo, isActive, isLoading } = useOnboardingStatus( onboardingRunId, 'vendors', ); - // Build search params for API - const currentSearchParams = useMemo(() => { - return { + const vendorsQuery = useMemo( + () => ({ page, perPage, - name: '', - status: null, - department: null, - assigneeId: null, - sort: [{ id: sort.id, desc: sort.desc }], - filters: [], - joinOperator: 'and', - }; - }, [page, perPage, sort]); - - // Create stable SWR key - const swrKey = useMemo(() => { - if (!orgId) return null; - const key = JSON.stringify(currentSearchParams); - return ['vendors', orgId, key] as const; - }, [orgId, currentSearchParams]); - - // Fetcher function for SWR - const fetcher = useCallback(async () => { - if (!orgId) return { data: [], pageCount: 0 }; - return await callGetVendorsAction({ orgId, searchParams: currentSearchParams }); - }, [orgId, currentSearchParams]); - - // Use SWR to fetch vendors with polling for real-time updates - const { data: vendorsData } = useSWR(swrKey, fetcher, { - fallbackData: { data: initialVendors, pageCount: initialPageCount }, + name: searchQuery || undefined, + status: statusFilter === 'all' ? undefined : statusFilter, + category: categoryFilter === 'all' ? undefined : categoryFilter, + assigneeId: assigneeFilter === 'all' ? undefined : assigneeFilter, + sortId: sort.id, + sortDesc: sort.desc, + }), + [page, perPage, searchQuery, statusFilter, categoryFilter, assigneeFilter, sort], + ); + + const { data: vendorsResponse, mutate: refreshVendors, deleteVendor } = useVendors({ + organizationId: orgId, + initialData: initialVendors, + query: vendorsQuery, refreshInterval: isActive ? 1000 : 5000, revalidateOnFocus: false, revalidateOnReconnect: true, keepPreviousData: true, }); - const vendors = vendorsData?.data || initialVendors; + const apiVendors = vendorsResponse?.data?.data ?? initialVendors; + const totalCount = vendorsResponse?.data?.count ?? apiVendors.length; + const pageCount = + vendorsResponse?.data?.pageCount ?? + Math.max(1, Math.ceil(totalCount / Math.max(1, perPage))); + const assigneeMap = useMemo(() => { + return new Map(assignees.map((assignee) => [assignee.id, assignee])); + }, [assignees]); + const vendors = useMemo(() => { + return apiVendors.map((vendor) => ({ + ...vendor, + createdAt: new Date(vendor.createdAt), + updatedAt: new Date(vendor.updatedAt), + assignee: vendor.assigneeId ? assigneeMap.get(vendor.assigneeId) ?? null : null, + })); + }, [apiVendors, assigneeMap]); // Check if all vendors are done assessing const allVendorsDoneAssessing = useMemo(() => { @@ -303,56 +257,29 @@ export function VendorsTable({ return [...vendorsWithStatus, ...pendingVendors, ...tempVendors]; }, [vendors, itemsInfo, itemStatuses, orgId, isActive, onboardingRunId]); - // Reset to page 1 when search changes - useEffect(() => { - setPage(1); - }, [searchQuery]); - - // Client-side filtering and sorting - const filteredAndSortedVendors = useMemo(() => { + const filteredVendors = useMemo(() => { let result = [...mergedVendors]; - - // Filter by search query if (searchQuery) { const query = searchQuery.toLowerCase(); result = result.filter((vendor) => vendor.name.toLowerCase().includes(query)); } + if (statusFilter !== 'all') { + result = result.filter((vendor) => vendor.status === statusFilter); + } + if (categoryFilter !== 'all') { + result = result.filter((vendor) => vendor.category === categoryFilter); + } + if (assigneeFilter === 'unassigned') { + result = result.filter((vendor) => !vendor.assigneeId); + } else if (assigneeFilter !== 'all') { + result = result.filter((vendor) => vendor.assigneeId === assigneeFilter); + } + return result; + }, [mergedVendors, searchQuery, statusFilter, categoryFilter, assigneeFilter]); - // Sort - result.sort((a, b) => { - const aValue = sort.id === 'name' ? a.name : a.updatedAt; - const bValue = sort.id === 'name' ? b.name : b.updatedAt; - - if (sort.id === 'name') { - const comparison = (aValue as string).localeCompare(bValue as string); - return sort.desc ? -comparison : comparison; - } - const comparison = new Date(aValue as Date).getTime() - new Date(bValue as Date).getTime(); - return sort.desc ? -comparison : comparison; - }); + const paginatedVendors = filteredVendors; - return result; - }, [mergedVendors, searchQuery, sort]); - - // Calculate pageCount from filtered data and paginate - // When searching locally, calculate pageCount from filtered data - // When not searching, use server's pageCount (server handles pagination) - const filteredPageCount = searchQuery - ? Math.max(1, Math.ceil(filteredAndSortedVendors.length / perPage)) - : Math.max(1, vendorsData?.pageCount ?? initialPageCount); - - // When searching locally, slice the data for client-side pagination - // When not searching, server returns the correct page, but slice to enforce perPage - // (avoids extra rows from onboarding pending/temp vendors) - const startIndex = searchQuery ? (page - 1) * perPage : 0; - const paginatedVendors = filteredAndSortedVendors.slice(startIndex, startIndex + perPage); - - // Keep page in bounds when pageCount changes - useEffect(() => { - if (page > filteredPageCount) { - setPage(filteredPageCount); - } - }, [page, filteredPageCount]); + const resolvedPage = page > pageCount ? pageCount : page; // Calculate assessment progress const assessmentProgress = useMemo(() => { @@ -390,11 +317,11 @@ export function VendorsTable({ }; const handleSort = (columnId: 'name' | 'updatedAt') => { - if (sort.id === columnId) { - setSort({ id: columnId, desc: !sort.desc }); - } else { - setSort({ id: columnId, desc: false }); - } + const nextSort = sort.id === columnId ? { id: columnId, desc: !sort.desc } : { id: columnId, desc: false }; + updateParams({ + sort: JSON.stringify([nextSort]), + page: '1', + }); }; const getSortIcon = (columnId: 'name' | 'updatedAt') => { @@ -418,28 +345,28 @@ export function VendorsTable({ setIsDeleting(true); try { - const result = await deleteVendor({ vendorId: vendorToDelete.id }); - if (result?.data?.success) { - toast.success('Vendor deleted successfully'); - setDeleteDialogOpen(false); - setVendorToDelete(null); - } else { - const errorMsg = - typeof result?.data?.error === 'string' ? result.data.error : 'Failed to delete vendor'; - toast.error(errorMsg); - } - } catch { - toast.error('Failed to delete vendor'); + await deleteVendor(vendorToDelete.id); + toast.success('Vendor deleted successfully'); + setDeleteDialogOpen(false); + setVendorToDelete(null); + await refreshVendors(); + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to delete vendor'); } finally { setIsDeleting(false); } }; - const isEmpty = mergedVendors.length === 0; + const isEmpty = filteredVendors.length === 0; const showEmptyState = isEmpty && onboardingRunId && isActive; - const emptyTitle = searchQuery ? 'No vendors found' : 'No vendors yet'; - const emptyDescription = searchQuery - ? 'Try adjusting your search.' + const hasActiveFilters = + Boolean(searchQuery) || + statusFilter !== 'all' || + categoryFilter !== 'all' || + assigneeFilter !== 'all'; + const emptyTitle = hasActiveFilters ? 'No results' : 'No vendors yet'; + const emptyDescription = hasActiveFilters + ? 'No results match these filters.' : 'Create your first vendor to get started.'; if (showEmptyState) { @@ -455,19 +382,37 @@ export function VendorsTable({ return ( - {/* Search Bar */} -
- - - - - setSearchQuery(e.target.value)} - /> - -
+ + updateParams({ + name: value || null, + page: '1', + }) + } + onStatusChange={(value) => + updateParams({ + status: value || null, + page: '1', + }) + } + onCategoryChange={(value) => + updateParams({ + category: value || null, + page: '1', + }) + } + onAssigneeChange={(value) => + updateParams({ + assigneeId: value || null, + page: '1', + }) + } + /> {/* Onboarding Progress Banner */} {isActive && !allVendorsDoneAssessing && ( @@ -514,14 +459,16 @@ export function VendorsTable({ updateParams({ page: String(nextPage) }), pageSize: perPage, pageSizeOptions: pageSizeOptions, onPageSizeChange: (size) => { - setPerPage(size); - setPage(1); + updateParams({ + perPage: String(size), + page: '1', + }); }, }} > @@ -554,7 +501,7 @@ export function VendorsTable({ data-state={blocked ? 'disabled' : undefined} > - + diff --git a/apps/app/src/app/(app)/[orgId]/vendors/(overview)/components/vendors-table-constants.ts b/apps/app/src/app/(app)/[orgId]/vendors/(overview)/components/vendors-table-constants.ts new file mode 100644 index 000000000..4a8d9954d --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/vendors/(overview)/components/vendors-table-constants.ts @@ -0,0 +1,40 @@ +export const ACTIVE_STATUSES: Array<'pending' | 'processing' | 'created' | 'assessing'> = [ + 'pending', + 'processing', + 'created', + 'assessing', +]; + +import { VendorCategory, VendorStatus } from '@db'; + +const titleCase = (value: string) => { + return value + .split('_') + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' '); +}; + +const CATEGORY_LABEL_OVERRIDES: Partial> = { + [VendorCategory.software_as_a_service]: 'SaaS', + [VendorCategory.hr]: 'HR', +}; + +const STATUS_LABEL_OVERRIDES: Partial> = {}; + +export const CATEGORY_MAP: Record = Object.values(VendorCategory).reduce( + (acc, category) => { + acc[category] = CATEGORY_LABEL_OVERRIDES[category] ?? titleCase(category); + return acc; + }, + {} as Record, +); + +export const VENDOR_STATUS_LABELS: Record = Object.values( + VendorStatus, +).reduce( + (acc, status) => { + acc[status] = STATUS_LABEL_OVERRIDES[status] ?? titleCase(status); + return acc; + }, + {} as Record, +); diff --git a/apps/app/src/app/(app)/[orgId]/vendors/(overview)/data/validations.ts b/apps/app/src/app/(app)/[orgId]/vendors/(overview)/data/validations.ts index d284f0a13..0ddeb5365 100644 --- a/apps/app/src/app/(app)/[orgId]/vendors/(overview)/data/validations.ts +++ b/apps/app/src/app/(app)/[orgId]/vendors/(overview)/data/validations.ts @@ -1,5 +1,5 @@ import { getFiltersStateParser, getSortingStateParser } from '@/lib/parsers'; -import { Departments, Vendor, VendorStatus } from '@db'; +import { Departments, Vendor, VendorCategory, VendorStatus } from '@db'; import { createSearchParamsCache, parseAsInteger, @@ -9,13 +9,14 @@ import { export const vendorsSearchParamsCache = createSearchParamsCache({ page: parseAsInteger.withDefault(1), - perPage: parseAsInteger.withDefault(50), + perPage: parseAsInteger.withDefault(10), sort: getSortingStateParser().withDefault([ { id: 'name', desc: false }, // Default sort by name ascending ]), // Basic filters (can be extended) name: parseAsString.withDefault(''), // For potential name search filter status: parseAsStringEnum(Object.values(VendorStatus)), + category: parseAsStringEnum(Object.values(VendorCategory)), department: parseAsStringEnum(Object.values(Departments)), assigneeId: parseAsString, diff --git a/apps/app/src/app/(app)/[orgId]/vendors/(overview)/page.tsx b/apps/app/src/app/(app)/[orgId]/vendors/(overview)/page.tsx index 1d9ab2c2b..690f993c0 100644 --- a/apps/app/src/app/(app)/[orgId]/vendors/(overview)/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/vendors/(overview)/page.tsx @@ -1,12 +1,15 @@ import { AppOnboarding } from '@/components/app-onboarding'; import type { SearchParams } from '@/types'; -import { db } from '@db'; import { PageHeader, PageLayout } from '@trycompai/design-system'; +import { serverApi } from '@/lib/server-api-client'; +import type { VendorResponse } from '@/hooks/use-vendors'; +import type { PeopleResponseDto } from '@/hooks/use-people-api'; import { CreateVendorSheet } from '../components/create-vendor-sheet'; import { VendorsTable } from './components/VendorsTable'; -import { getAssignees, getVendors } from './data/queries'; import type { GetVendorsSchema } from './data/validations'; import { vendorsSearchParamsCache } from './data/validations'; +import { toAssigneeOptions } from '../utils/assignees'; +import { buildVendorsQueryString } from '@/lib/vendors-query'; export default async function Page({ searchParams, @@ -18,21 +21,48 @@ export default async function Page({ const { orgId } = await params; const parsedSearchParams = await vendorsSearchParamsCache.parse(searchParams); + const primarySort = parsedSearchParams.sort?.[0]; + const sortId = + primarySort?.id === 'name' || primarySort?.id === 'updatedAt' || primarySort?.id === 'createdAt' + ? primarySort.id + : undefined; + const vendorsQuery = buildVendorsQueryString({ + page: parsedSearchParams.page, + perPage: parsedSearchParams.perPage, + name: parsedSearchParams.name || undefined, + status: parsedSearchParams.status ?? undefined, + category: parsedSearchParams.category ?? undefined, + department: parsedSearchParams.department ?? undefined, + assigneeId: parsedSearchParams.assigneeId ?? undefined, + sortId, + sortDesc: typeof primarySort?.desc === 'boolean' ? primarySort.desc : undefined, + }); - const [vendorsResult, assignees, onboarding] = await Promise.all([ - getVendors(orgId, parsedSearchParams), - getAssignees(orgId), - db.onboarding.findFirst({ - where: { organizationId: orgId }, - select: { triggerJobId: true }, - }), + const [vendorsResponse, peopleResponse, onboardingResponse] = await Promise.all([ + serverApi.get<{ + data: VendorResponse[]; + count: number; + page?: number; + perPage?: number; + pageCount?: number; + }>(`/v1/vendors${vendorsQuery}`, orgId), + serverApi.get<{ data: PeopleResponseDto[]; count: number }>(`/v1/people`, orgId), + serverApi.get<{ triggerJobId: string | null }>(`/v1/organization/onboarding`, orgId), ]); + if (!vendorsResponse.data) { + throw new Error(vendorsResponse.error ?? 'Failed to load vendors'); + } + + const vendors = vendorsResponse.data.data ?? []; + const assignees = toAssigneeOptions(peopleResponse.data?.data ?? []); + // Helper function to check if the current view is the default, unfiltered one function isDefaultView(params: GetVendorsSchema): boolean { return ( params.filters.length === 0 && !params.status && + !params.category && !params.department && !params.assigneeId && params.page === 1 && @@ -40,9 +70,10 @@ export default async function Page({ ); } - const isEmpty = vendorsResult.data.length === 0; + const isEmpty = vendors.length === 0; const isDefault = isDefaultView(parsedSearchParams); - const isOnboardingActive = Boolean(onboarding?.triggerJobId); + const onboardingRunId = onboardingResponse.data?.triggerJobId ?? null; + const isOnboardingActive = Boolean(onboardingRunId); // Show AppOnboarding only if empty, default view, AND onboarding is not active if (isEmpty && isDefault && !isOnboardingActive) { @@ -95,11 +126,9 @@ export default async function Page({ } > diff --git a/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/components/VendorActions.tsx b/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/components/VendorActions.tsx index e5b594135..d56452b92 100644 --- a/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/components/VendorActions.tsx +++ b/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/components/VendorActions.tsx @@ -1,7 +1,7 @@ 'use client'; import { regenerateVendorMitigationAction } from '@/app/(app)/[orgId]/vendors/[vendorId]/actions/regenerate-vendor-mitigation'; -import { useVendor } from '@/hooks/use-vendors'; +import { useVendor } from '@/hooks/use-vendor'; import { AlertDialog, AlertDialogAction, diff --git a/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/components/VendorDetailTabs.tsx b/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/components/VendorDetailTabs.tsx index 07b33859f..b2694c3a9 100644 --- a/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/components/VendorDetailTabs.tsx +++ b/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/components/VendorDetailTabs.tsx @@ -3,8 +3,6 @@ import { Comments } from '@/components/comments/Comments'; import { VendorRiskAssessmentView } from '@/components/vendor-risk-assessment/VendorRiskAssessmentView'; import { CommentEntityType } from '@db'; -import type { Member, User, Vendor } from '@db'; -import type { Prisma } from '@prisma/client'; import { PageHeader, PageLayout, @@ -15,24 +13,20 @@ import { TabsTrigger, Text, } from '@trycompai/design-system'; -import { useState } from 'react'; +import { useMemo, useState } from 'react'; import { VendorActions } from './VendorActions'; import { VendorPageClient } from './VendorPageClient'; import { TaskItems } from '@/components/task-items/TaskItems'; - -// Vendor with risk assessment data merged from GlobalVendors -type VendorWithRiskAssessment = Vendor & { - assignee: { user: User | null } | null; - riskAssessmentData?: Prisma.InputJsonValue | null; - riskAssessmentVersion?: string | null; - riskAssessmentUpdatedAt?: Date | null; -}; +import type { VendorResponse } from '@/hooks/use-vendors'; +import type { AssigneeOption } from '@/components/SelectAssignee'; +import { normalizeVendor } from './vendor-utils'; +import { useVendor } from '@/hooks/use-vendor'; interface VendorDetailTabsProps { vendorId: string; orgId: string; - vendor: VendorWithRiskAssessment; - assignees: (Member & { user: User })[]; + vendor: VendorResponse; + assignees: AssigneeOption[]; isViewingTask: boolean; } @@ -44,25 +38,33 @@ export function VendorDetailTabs({ isViewingTask, }: VendorDetailTabsProps) { const [isEditSheetOpen, setIsEditSheetOpen] = useState(false); + const { vendor: swrVendor, mutate: refreshVendor, updateVendor } = useVendor(vendorId, { + organizationId: orgId, + initialData: vendor, + }); + const normalizedVendor = useMemo( + () => normalizeVendor(swrVendor ?? vendor), + [swrVendor, vendor], + ); const breadcrumbs = [ { label: 'Vendors', href: `/${orgId}/vendors` }, { - label: vendor?.name ?? '', + label: normalizedVendor?.name ?? '', href: isViewingTask ? `/${orgId}/vendors/${vendorId}` : undefined, isCurrent: !isViewingTask, }, ]; - const riskAssessmentData = vendor.riskAssessmentData; - const riskAssessmentUpdatedAt = vendor.riskAssessmentUpdatedAt ?? null; + const riskAssessmentData = normalizedVendor.riskAssessmentData; + const riskAssessmentUpdatedAt = normalizedVendor.riskAssessmentUpdatedAt ?? null; return ( @@ -102,7 +104,7 @@ export function VendorDetailTabs({ source={{ title: 'Risk Assessment', description: JSON.stringify(riskAssessmentData), - createdAt: (riskAssessmentUpdatedAt ?? vendor.updatedAt).toISOString(), + createdAt: (riskAssessmentUpdatedAt ?? normalizedVendor.updatedAt).toISOString(), entityType: 'vendor', createdByName: null, createdByEmail: null, @@ -111,7 +113,7 @@ export function VendorDetailTabs({ ) : (
- {vendor.status === 'in_progress' + {normalizedVendor.status === 'in_progress' ? 'Risk assessment is being generated. Please check back soon.' : 'No risk assessment found yet.'} diff --git a/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/components/VendorHeader.tsx b/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/components/VendorHeader.tsx index 87b3d1b66..8d0d91408 100644 --- a/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/components/VendorHeader.tsx +++ b/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/components/VendorHeader.tsx @@ -30,6 +30,7 @@ interface VendorHeaderProps { vendor: VendorWithRiskAssessment; isEditSheetOpen: boolean; onEditSheetOpenChange: (open: boolean) => void; + onVendorUpdated: () => void; } /** @@ -74,7 +75,12 @@ function getCertificationIcon(cert: VendorRiskAssessmentCertification) { return null; } -export function VendorHeader({ vendor, isEditSheetOpen, onEditSheetOpenChange }: VendorHeaderProps) { +export function VendorHeader({ + vendor, + isEditSheetOpen, + onEditSheetOpenChange, + onVendorUpdated, +}: VendorHeaderProps) { // Parse risk assessment data to get certifications and links // Note: This should come from GlobalVendors, but we're reading from vendor for now // TODO: Update to fetch from GlobalVendors via vendor.website lookup @@ -160,6 +166,7 @@ export function VendorHeader({ vendor, isEditSheetOpen, onEditSheetOpenChange }: vendor={vendor} open={isEditSheetOpen} onOpenChange={onEditSheetOpenChange} + onVendorUpdated={onVendorUpdated} /> ); diff --git a/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/components/VendorInherentRiskChart.tsx b/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/components/VendorInherentRiskChart.tsx index 886f636b8..76b24762b 100644 --- a/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/components/VendorInherentRiskChart.tsx +++ b/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/components/VendorInherentRiskChart.tsx @@ -2,13 +2,15 @@ import { RiskMatrixChart } from '@/components/risks/charts/RiskMatrixChart'; import type { Vendor } from '@db'; -import { updateVendorInherentRisk } from '../actions/update-vendor-inherent-risk'; +import type { UpdateVendorData } from '@/hooks/use-vendors'; +import { toast } from 'sonner'; interface InherentRiskChartProps { vendor: Vendor; + updateVendor: (vendorId: string, data: UpdateVendorData) => Promise; } -export function VendorInherentRiskChart({ vendor }: InherentRiskChartProps) { +export function VendorInherentRiskChart({ vendor, updateVendor }: InherentRiskChartProps) { return ( { - return updateVendorInherentRisk({ - vendorId: id, - inherentProbability: probability, - inherentImpact: impact, - }); + try { + await updateVendor(id, { + inherentProbability: probability, + inherentImpact: impact, + }); + toast.success('Inherent risk updated'); + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to update inherent risk'); + throw error; + } }} /> ); diff --git a/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/components/VendorPageClient.tsx b/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/components/VendorPageClient.tsx index 1b8879647..908f64878 100644 --- a/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/components/VendorPageClient.tsx +++ b/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/components/VendorPageClient.tsx @@ -1,51 +1,22 @@ 'use client'; -import { useVendor, type VendorResponse } from '@/hooks/use-vendors'; -import type { Member, User, Vendor } from '@db'; -import type { Prisma } from '@prisma/client'; import { useMemo } from 'react'; import { SecondaryFields } from './secondary-fields/secondary-fields'; import { VendorHeader } from './VendorHeader'; import { VendorInherentRiskChart } from './VendorInherentRiskChart'; import { VendorResidualRiskChart } from './VendorResidualRiskChart'; - -// Vendor with risk assessment data merged from GlobalVendors -type VendorWithRiskAssessment = Vendor & { - assignee: { user: User | null } | null; - riskAssessmentData?: Prisma.InputJsonValue | null; - riskAssessmentVersion?: string | null; - riskAssessmentUpdatedAt?: Date | null; -}; - -/** - * Normalize API response to match Prisma types - * API returns dates as strings, Prisma returns Date objects - */ -function normalizeVendor(apiVendor: VendorResponse): VendorWithRiskAssessment { - return { - ...apiVendor, - createdAt: new Date(apiVendor.createdAt), - updatedAt: new Date(apiVendor.updatedAt), - riskAssessmentUpdatedAt: apiVendor.riskAssessmentUpdatedAt - ? new Date(apiVendor.riskAssessmentUpdatedAt) - : null, - assignee: apiVendor.assignee - ? { - ...apiVendor.assignee, - user: apiVendor.assignee.user as User | null, - } - : null, - } as unknown as VendorWithRiskAssessment; -} +import type { AssigneeOption } from '@/components/SelectAssignee'; +import type { VendorWithRiskAssessment } from './vendor-utils'; +import type { UpdateVendorData } from '@/hooks/use-vendors'; interface VendorPageClientProps { - vendorId: string; - orgId: string; - initialVendor: VendorWithRiskAssessment; - assignees: (Member & { user: User })[]; + vendor: VendorWithRiskAssessment; + assignees: AssigneeOption[]; isViewingTask: boolean; isEditSheetOpen: boolean; onEditSheetOpenChange: (open: boolean) => void; + onVendorUpdated: () => void; + updateVendor: (vendorId: string, data: UpdateVendorData) => Promise; } /** @@ -58,27 +29,15 @@ interface VendorPageClientProps { * - Mutations trigger automatic refresh via mutate() */ export function VendorPageClient({ - vendorId, - orgId, - initialVendor, + vendor: initialVendor, assignees, isViewingTask, isEditSheetOpen, onEditSheetOpenChange, + onVendorUpdated, + updateVendor, }: VendorPageClientProps) { - // Use SWR for real-time updates with polling - const { vendor: swrVendor } = useVendor(vendorId, { - organizationId: orgId, - }); - - // Normalize and memoize the vendor data - // Use SWR data when available, fall back to initial data - const vendor = useMemo(() => { - if (swrVendor) { - return normalizeVendor(swrVendor); - } - return initialVendor; - }, [swrVendor, initialVendor]); + const vendor = useMemo(() => initialVendor, [initialVendor]); return ( <> @@ -87,15 +46,20 @@ export function VendorPageClient({ vendor={vendor} isEditSheetOpen={isEditSheetOpen} onEditSheetOpenChange={onEditSheetOpenChange} + onVendorUpdated={onVendorUpdated} /> )}
{!isViewingTask && ( <> - +
- - + +
)} @@ -108,4 +72,4 @@ export function VendorPageClient({ * Export the vendor mutate function for use by mutation components * Call this after updating vendor data to trigger SWR revalidation */ -export { useVendor } from '@/hooks/use-vendors'; +export { useVendor } from '@/hooks/use-vendor'; diff --git a/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/components/VendorResidualRiskChart.tsx b/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/components/VendorResidualRiskChart.tsx index 5a02945d8..0cabecce4 100644 --- a/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/components/VendorResidualRiskChart.tsx +++ b/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/components/VendorResidualRiskChart.tsx @@ -2,13 +2,15 @@ import { RiskMatrixChart } from '@/components/risks/charts/RiskMatrixChart'; import type { Vendor } from '@db'; -import { updateVendorResidualRisk } from '../actions/update-vendor-residual-risk'; +import type { UpdateVendorData } from '@/hooks/use-vendors'; +import { toast } from 'sonner'; interface ResidualRiskChartProps { vendor: Vendor; + updateVendor: (vendorId: string, data: UpdateVendorData) => Promise; } -export function VendorResidualRiskChart({ vendor }: ResidualRiskChartProps) { +export function VendorResidualRiskChart({ vendor, updateVendor }: ResidualRiskChartProps) { return ( { - return updateVendorResidualRisk({ - vendorId: id, - residualProbability: probability, - residualImpact: impact, - }); + try { + await updateVendor(id, { + residualProbability: probability, + residualImpact: impact, + }); + toast.success('Residual risk updated'); + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to update residual risk'); + throw error; + } }} /> ); diff --git a/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/components/secondary-fields/secondary-fields.tsx b/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/components/secondary-fields/secondary-fields.tsx index 313960633..1211eed2b 100644 --- a/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/components/secondary-fields/secondary-fields.tsx +++ b/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/components/secondary-fields/secondary-fields.tsx @@ -1,19 +1,26 @@ 'use client'; -import type { Member, User, Vendor } from '@db'; import { Section } from '@trycompai/design-system'; import { UpdateSecondaryFieldsForm } from './update-secondary-fields-form'; +import type { AssigneeOption } from '@/components/SelectAssignee'; +import type { VendorResponse } from '@/hooks/use-vendors'; export function SecondaryFields({ vendor, assignees, + onVendorUpdated, }: { - vendor: Vendor & { assignee: { user: User | null } | null }; - assignees: (Member & { user: User })[]; + vendor: Pick; + assignees: AssigneeOption[]; + onVendorUpdated: () => void; }) { return (
- +
); } diff --git a/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/components/secondary-fields/update-secondary-fields-form.tsx b/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/components/secondary-fields/update-secondary-fields-form.tsx index 4e030e3bc..b5163aa45 100644 --- a/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/components/secondary-fields/update-secondary-fields-form.tsx +++ b/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/components/secondary-fields/update-secondary-fields-form.tsx @@ -1,65 +1,65 @@ 'use client'; -import { SelectAssignee } from '@/components/SelectAssignee'; +import { SelectAssignee, type AssigneeOption } from '@/components/SelectAssignee'; import { VENDOR_STATUS_TYPES, VendorStatus } from '@/components/vendor-status'; -import { Button } from '@comp/ui/button'; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@comp/ui/form'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@comp/ui/select'; -import { Member, type User, type Vendor, VendorCategory } from '@db'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { Loader2 } from 'lucide-react'; -import { useAction } from 'next-safe-action/hooks'; +import { VendorCategory, type VendorStatus as VendorStatusEnum } from '@db'; +import { useVendor } from '@/hooks/use-vendor'; +import type { VendorResponse } from '@/hooks/use-vendors'; import { useForm } from 'react-hook-form'; import { toast } from 'sonner'; -import type { z } from 'zod'; -import { updateVendorSchema } from '../../actions/schema'; -import { updateVendorAction } from '../../actions/update-vendor-action'; +import { useCallback } from 'react'; export function UpdateSecondaryFieldsForm({ vendor, assignees, + onMutate, }: { - vendor: Vendor; - assignees: (Member & { user: User })[]; + vendor: Pick; + assignees: AssigneeOption[]; + onMutate?: () => void; }) { - const updateVendor = useAction(updateVendorAction, { - onSuccess: () => { - toast.success('Vendor updated successfully'); - }, - onError: () => { - toast.error('Failed to update vendor'); - }, - }); + const { updateVendor, isUpdating } = useVendor(vendor.id, { enabled: false }); - const form = useForm>({ - resolver: zodResolver(updateVendorSchema), + const form = useForm<{ + assigneeId: string | null; + category: VendorCategory; + status: VendorStatusEnum; + }>({ defaultValues: { - id: vendor.id, - name: vendor.name, - description: vendor.description, assigneeId: vendor.assigneeId, category: vendor.category, status: vendor.status, }, }); - const onSubmit = (data: z.infer) => { - // Explicitly set assigneeId to null if it's an empty string (representing "None") - const finalAssigneeId = data.assigneeId === '' ? null : data.assigneeId; + const executeSave = useCallback( + async (data: { + assigneeId?: string | null; + category?: VendorCategory; + status?: VendorStatusEnum; + }) => { + const finalAssigneeId = data.assigneeId === '' ? null : data.assigneeId; + try { + await updateVendor(vendor.id, { + ...(data.assigneeId !== undefined ? { assigneeId: finalAssigneeId } : {}), + ...(data.category ? { category: data.category } : {}), + ...(data.status ? { status: data.status } : {}), + }); + onMutate?.(); + toast.success('Vendor updated'); + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to update vendor'); + } + }, + [updateVendor, vendor.id], + ); - updateVendor.execute({ - id: data.id, - name: data.name, - description: data.description, - assigneeId: finalAssigneeId, // Use the potentially nulled value - category: data.category, - status: data.status, - }); - }; return (
- +
{'Assignee'} { + field.onChange(value); + if (isUpdating) return; + executeSave({ assigneeId: value === '' ? null : value }); + }} /> @@ -87,7 +91,16 @@ export function UpdateSecondaryFieldsForm({ {'Status'} - { + const status = value as VendorStatusEnum; + field.onChange(status); + if (isUpdating) return; + executeSave({ status }); + }} + disabled={isUpdating} + > {field.value && } @@ -113,7 +126,17 @@ export function UpdateSecondaryFieldsForm({ {'Category'} - { + const category = value as VendorCategory; + field.onChange(category); + if (isUpdating) return; + executeSave({ category }); + }} + disabled={isUpdating} + > @@ -138,15 +161,6 @@ export function UpdateSecondaryFieldsForm({ )} />
-
- -
); diff --git a/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/components/tasks/create-vendor-task-form.tsx b/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/components/tasks/create-vendor-task-form.tsx index 718b1d4f4..b06d443c3 100644 --- a/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/components/tasks/create-vendor-task-form.tsx +++ b/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/components/tasks/create-vendor-task-form.tsx @@ -3,52 +3,60 @@ import { SelectAssignee } from '@/components/SelectAssignee'; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@comp/ui/accordion'; import { Button } from '@comp/ui/button'; -import { Calendar } from '@comp/ui/calendar'; import { cn } from '@comp/ui/cn'; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@comp/ui/form'; import { Input } from '@comp/ui/input'; -import { Popover, PopoverContent, PopoverTrigger } from '@comp/ui/popover'; import { Textarea } from '@comp/ui/textarea'; import { Member, User } from '@db'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { format } from 'date-fns'; import { ArrowRightIcon, CalendarIcon } from 'lucide-react'; -import { useAction } from 'next-safe-action/hooks'; import { useParams } from 'next/navigation'; import { useQueryState } from 'nuqs'; import { useForm } from 'react-hook-form'; import { toast } from 'sonner'; -import type { z } from 'zod'; -import { createVendorTaskSchema } from '../../actions/schema'; -import { createVendorTaskAction } from '../../actions/task/create-task-action'; +import { useTaskItemActions } from '@/hooks/use-task-items'; +import { useState } from 'react'; export function CreateVendorTaskForm({ assignees }: { assignees: (Member & { user: User })[] }) { const [_, setCreateVendorTaskSheet] = useQueryState('create-vendor-task-sheet'); const params = useParams<{ vendorId: string }>(); + const [isSubmitting, setIsSubmitting] = useState(false); + const { createTaskItem } = useTaskItemActions(); - const createTask = useAction(createVendorTaskAction, { - onSuccess: () => { - toast.success('Task created successfully'); - setCreateVendorTaskSheet(null); - }, - onError: () => { - toast.error('Failed to create task'); - }, - }); - - const form = useForm>({ - resolver: zodResolver(createVendorTaskSchema), + const form = useForm<{ + title: string; + description: string; + assigneeId: string; + }>({ defaultValues: { title: '', description: '', - dueDate: new Date(), assigneeId: '', - vendorId: params.vendorId, }, }); - const onSubmit = (data: z.infer) => { - createTask.execute(data); + const onSubmit = async (data: { title: string; description: string; assigneeId: string }) => { + if (!params.vendorId) { + toast.error('Vendor ID is missing'); + return; + } + + setIsSubmitting(true); + try { + await createTaskItem({ + title: data.title.trim(), + description: data.description.trim() || undefined, + entityId: params.vendorId, + entityType: 'vendor', + assigneeId: data.assigneeId || undefined, + }); + toast.success('Task created successfully'); + setCreateVendorTaskSheet(null); + form.reset({ title: '', description: '', assigneeId: '' }); + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to create task'); + } finally { + setIsSubmitting(false); + } }; return ( @@ -64,6 +72,7 @@ export function CreateVendorTaskForm({ assignees }: { assignees: (Member & { use ( {'Task Title'} @@ -101,46 +110,6 @@ export function CreateVendorTaskForm({ assignees }: { assignees: (Member & { use )} /> - ( - - {'Due Date'} - - - - - - - - date <= new Date()} - initialFocus - /> - - - - - )} - /> -
- -
+
+ +
); diff --git a/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/components/title-and-description/update-title-and-description-sheet.tsx b/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/components/title-and-description/update-title-and-description-sheet.tsx index 04d3f93b8..cecca9086 100644 --- a/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/components/title-and-description/update-title-and-description-sheet.tsx +++ b/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/components/title-and-description/update-title-and-description-sheet.tsx @@ -22,18 +22,21 @@ interface UpdateTitleAndDescriptionSheetProps { vendor: Vendor; open: boolean; onOpenChange: (open: boolean) => void; + onVendorUpdated: () => void; } export function UpdateTitleAndDescriptionSheet({ vendor, open, onOpenChange, + onVendorUpdated, }: UpdateTitleAndDescriptionSheetProps) { const isDesktop = useMediaQuery('(min-width: 768px)'); const handleSuccess = useCallback(() => { + onVendorUpdated(); onOpenChange(false); - }, [onOpenChange]); + }, [onOpenChange, onVendorUpdated]); if (isDesktop) { return ( diff --git a/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/components/vendor-utils.ts b/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/components/vendor-utils.ts new file mode 100644 index 000000000..18e2d94c5 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/components/vendor-utils.ts @@ -0,0 +1,25 @@ +import type { VendorResponse } from '@/hooks/use-vendors'; + +export type VendorWithRiskAssessment = Omit< + VendorResponse, + 'createdAt' | 'updatedAt' | 'riskAssessmentUpdatedAt' +> & { + createdAt: Date; + updatedAt: Date; + riskAssessmentUpdatedAt?: Date | null; +}; + +/** + * Normalize API response to match Prisma types + * API returns dates as strings, Prisma returns Date objects + */ +export function normalizeVendor(apiVendor: VendorResponse): VendorWithRiskAssessment { + return { + ...apiVendor, + createdAt: new Date(apiVendor.createdAt), + updatedAt: new Date(apiVendor.updatedAt), + riskAssessmentUpdatedAt: apiVendor.riskAssessmentUpdatedAt + ? new Date(apiVendor.riskAssessmentUpdatedAt) + : null, + }; +} diff --git a/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/page.tsx b/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/page.tsx index 2a567db28..6f26256af 100644 --- a/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/page.tsx @@ -1,11 +1,10 @@ -import { auth } from '@/utils/auth'; -import { extractDomain } from '@/utils/normalize-website'; -import { db } from '@db'; +import type { PeopleResponseDto } from '@/hooks/use-people-api'; +import type { VendorResponse } from '@/hooks/use-vendors'; +import { serverApi } from '@/lib/server-api-client'; import type { Metadata } from 'next'; -import { headers } from 'next/headers'; -import { redirect } from 'next/navigation'; -import { cache } from 'react'; +import { notFound } from 'next/navigation'; import { VendorDetailTabs } from './components/VendorDetailTabs'; +import { toAssigneeOptions } from '../utils/assignees'; interface PageProps { params: Promise<{ vendorId: string; locale: string; orgId: string }>; @@ -23,16 +22,24 @@ export default async function VendorPage({ params, searchParams }: PageProps) { const { vendorId, orgId } = await params; const { taskItemId } = (await searchParams) ?? {}; - // Fetch data in parallel for faster loading - const [vendorData, assignees] = await Promise.all([ - getVendor({ vendorId, organizationId: orgId }), - getAssignees(orgId), + const [vendorResponse, peopleResponse] = await Promise.all([ + serverApi.get(`/v1/vendors/${vendorId}`, orgId), + serverApi.get<{ data: PeopleResponseDto[]; count: number }>(`/v1/people`, orgId), ]); - if (!vendorData || !vendorData.vendor) { - redirect('/'); + if (!vendorResponse.data) { + console.error('[VendorPage] vendor fetch failed', { + status: vendorResponse.status, + error: vendorResponse.error, + }); + if (vendorResponse.status === 404) { + notFound(); + } + throw new Error(vendorResponse.error ?? 'Failed to load vendor'); } + const assignees = toAssigneeOptions(peopleResponse.data?.data ?? []); + // Hide vendor-level content when viewing a task in focus mode const isViewingTask = Boolean(taskItemId); @@ -40,102 +47,12 @@ export default async function VendorPage({ params, searchParams }: PageProps) { ); } - -const getVendor = cache(async (params: { vendorId: string; organizationId: string }) => { - const { vendorId, organizationId } = params; - const session = await auth.api.getSession({ - headers: await headers(), - }); - - if (!session?.user?.id) { - return null; - } - - const vendor = await db.vendor.findUnique({ - where: { - id: vendorId, - organizationId, - }, - include: { - assignee: { - include: { - user: true, - }, - }, - }, - }); - - // Fetch risk assessment from GlobalVendors if vendor has a website - // Find ALL duplicates and prefer the one WITH risk assessment data (most recent) - const domain = extractDomain(vendor?.website ?? null); - let globalVendor = null; - if (domain) { - const duplicates = await db.globalVendors.findMany({ - where: { - website: { - contains: domain, - }, - }, - select: { - website: true, - riskAssessmentData: true, - riskAssessmentVersion: true, - riskAssessmentUpdatedAt: true, - }, - orderBy: [{ riskAssessmentUpdatedAt: 'desc' }, { createdAt: 'desc' }], - }); - - // Prefer record WITH risk assessment data (most recent) - globalVendor = duplicates.find((gv) => gv.riskAssessmentData !== null) ?? duplicates[0] ?? null; - } - - // Merge GlobalVendors risk assessment data into vendor object for backward compatibility - const vendorWithRiskAssessment = vendor - ? { - ...vendor, - riskAssessmentData: globalVendor?.riskAssessmentData ?? null, - riskAssessmentVersion: globalVendor?.riskAssessmentVersion ?? null, - riskAssessmentUpdatedAt: globalVendor?.riskAssessmentUpdatedAt ?? null, - } - : null; - - return { - vendor: vendorWithRiskAssessment, - globalVendor, - }; -}); - -const getAssignees = cache(async (organizationId: string) => { - const session = await auth.api.getSession({ - headers: await headers(), - }); - - if (!session?.user?.id) { - return []; - } - - const assignees = await db.member.findMany({ - where: { - organizationId, - role: { - notIn: ['employee', 'contractor'], - }, - deactivated: false, - }, - include: { - user: true, - }, - }); - - return assignees; -}); - export async function generateMetadata(): Promise { return { title: 'Vendors', diff --git a/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/review/components/VendorReviewClient.tsx b/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/review/components/VendorReviewClient.tsx index bc52b80f0..18a64c57a 100644 --- a/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/review/components/VendorReviewClient.tsx +++ b/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/review/components/VendorReviewClient.tsx @@ -2,7 +2,8 @@ import { VendorRiskAssessmentView } from '@/components/vendor-risk-assessment/VendorRiskAssessmentView'; import { useTaskItems } from '@/hooks/use-task-items'; -import { useVendor, type VendorResponse } from '@/hooks/use-vendors'; +import { useVendor } from '@/hooks/use-vendor'; +import type { VendorResponse } from '@/hooks/use-vendors'; import { useEffect, useMemo } from 'react'; interface VendorReviewClientProps { diff --git a/apps/app/src/app/(app)/[orgId]/vendors/components/create-vendor-form.tsx b/apps/app/src/app/(app)/[orgId]/vendors/components/create-vendor-form.tsx index 193a3b3a3..6cecf494a 100644 --- a/apps/app/src/app/(app)/[orgId]/vendors/components/create-vendor-form.tsx +++ b/apps/app/src/app/(app)/[orgId]/vendors/components/create-vendor-form.tsx @@ -2,13 +2,13 @@ import { researchVendorAction } from '@/actions/research-vendor'; import type { ActionResponse } from '@/types/actions'; -import { SelectAssignee } from '@/components/SelectAssignee'; +import { SelectAssignee, type AssigneeOption } from '@/components/SelectAssignee'; import { Button } from '@comp/ui/button'; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@comp/ui/form'; import { Input } from '@comp/ui/input'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@comp/ui/select'; import { Textarea } from '@comp/ui/textarea'; -import { type Member, type User, type Vendor, VendorCategory, VendorStatus } from '@db'; +import { type Vendor, VendorCategory, VendorStatus } from '@db'; import { zodResolver } from '@hookform/resolvers/zod'; import { ArrowRightIcon } from 'lucide-react'; import { useAction } from 'next-safe-action/hooks'; @@ -25,7 +25,7 @@ export function CreateVendorForm({ organizationId, onSuccess, }: { - assignees: (Member & { user: User })[]; + assignees: AssigneeOption[]; organizationId: string; onSuccess?: () => void; }) { diff --git a/apps/app/src/app/(app)/[orgId]/vendors/components/create-vendor-sheet.tsx b/apps/app/src/app/(app)/[orgId]/vendors/components/create-vendor-sheet.tsx index 78fd7f7a4..2a03160a3 100644 --- a/apps/app/src/app/(app)/[orgId]/vendors/components/create-vendor-sheet.tsx +++ b/apps/app/src/app/(app)/[orgId]/vendors/components/create-vendor-sheet.tsx @@ -1,7 +1,7 @@ 'use client'; import { useMediaQuery } from '@comp/ui/hooks'; -import type { Member, User } from '@db'; +import type { AssigneeOption } from '@/components/SelectAssignee'; import { Button, Drawer, @@ -23,7 +23,7 @@ export function CreateVendorSheet({ assignees, organizationId, }: { - assignees: (Member & { user: User })[]; + assignees: AssigneeOption[]; organizationId: string; }) { const isDesktop = useMediaQuery('(min-width: 768px)'); diff --git a/apps/app/src/app/(app)/[orgId]/vendors/utils/assignees.ts b/apps/app/src/app/(app)/[orgId]/vendors/utils/assignees.ts new file mode 100644 index 000000000..0c6c8aaa7 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/vendors/utils/assignees.ts @@ -0,0 +1,25 @@ +import type { AssigneeOption } from '@/components/SelectAssignee'; +import type { PeopleResponseDto } from '@/hooks/use-people-api'; + +const ALLOWED_ASSIGNEE_ROLES = new Set(['admin', 'owner']); + +export const toAssigneeOptions = (people: PeopleResponseDto[]): AssigneeOption[] => { + return people + .filter((member) => member.isActive) + .filter((member) => { + const roles = member.role + .split(',') + .map((role) => role.trim().toLowerCase()) + .filter(Boolean); + return roles.some((role) => ALLOWED_ASSIGNEE_ROLES.has(role)); + }) + .map((member) => ({ + id: member.id, + user: { + id: member.user.id, + name: member.user.name ?? null, + email: member.user.email, + image: member.user.image ?? null, + }, + })); +}; diff --git a/apps/app/src/components/SelectAssignee.tsx b/apps/app/src/components/SelectAssignee.tsx index b82c0f18d..ceef5533a 100644 --- a/apps/app/src/components/SelectAssignee.tsx +++ b/apps/app/src/components/SelectAssignee.tsx @@ -1,14 +1,23 @@ import { authClient } from '@/utils/auth-client'; import { Avatar, AvatarFallback, AvatarImage } from '@comp/ui/avatar'; import { Select, SelectContent, SelectItem, SelectTrigger } from '@comp/ui/select'; -import { Member, User } from '@db'; import { UserIcon } from 'lucide-react'; import { useEffect, useState } from 'react'; +export interface AssigneeOption { + id: string; + user: { + id: string; + name: string | null; + email: string; + image: string | null; + }; +} + interface SelectAssigneeProps { assigneeId: string | null; disabled?: boolean; - assignees: (Member & { user: User })[]; + assignees: AssigneeOption[]; onAssigneeChange: (value: string | null) => void; withTitle?: boolean; } @@ -21,7 +30,7 @@ export const SelectAssignee = ({ withTitle = true, }: SelectAssigneeProps) => { const { data: activeMember } = authClient.useActiveMember(); - const [selectedAssignee, setSelectedAssignee] = useState<(Member & { user: User }) | null>(null); + const [selectedAssignee, setSelectedAssignee] = useState(null); // Initialize selectedAssignee based on assigneeId prop useEffect(() => { diff --git a/apps/app/src/hooks/use-api-swr.ts b/apps/app/src/hooks/use-api-swr.ts index 772dde491..0accca65d 100644 --- a/apps/app/src/hooks/use-api-swr.ts +++ b/apps/app/src/hooks/use-api-swr.ts @@ -1,7 +1,7 @@ 'use client'; import { apiClient, ApiResponse } from '@/lib/api-client'; -import { useActiveOrganization } from '@/utils/auth-client'; +import { useParams } from 'next/navigation'; import { useMemo } from 'react'; import useSWR, { SWRConfiguration, SWRResponse } from 'swr'; @@ -20,11 +20,12 @@ export function useApiSWR( ): SWRResponse, Error> & { organizationId?: string; } { - const activeOrg = useActiveOrganization(); + const params = useParams(); + const orgIdFromParams = params?.orgId as string | undefined; const { organizationId: explicitOrgId, enabled = true, ...swrOptions } = options; - // Determine organization context - const organizationId = explicitOrgId || activeOrg.data?.id; + // Determine organization context (prefer URL params) + const organizationId = orgIdFromParams || explicitOrgId; // Create stable key for SWR const swrKey = useMemo(() => { diff --git a/apps/app/src/hooks/use-vendor.ts b/apps/app/src/hooks/use-vendor.ts new file mode 100644 index 000000000..27684d420 --- /dev/null +++ b/apps/app/src/hooks/use-vendor.ts @@ -0,0 +1,85 @@ +'use client'; + +import { useApi } from '@/hooks/use-api'; +import { apiClient, type ApiResponse } from '@/lib/api-client'; +import { useParams } from 'next/navigation'; +import { useCallback, useMemo, useState } from 'react'; +import useSWR from 'swr'; +import type { UpdateVendorData, UseVendorOptions, Vendor, VendorResponse } from './use-vendors'; + +// Default polling interval for real-time updates (5 seconds) +const DEFAULT_POLLING_INTERVAL = 5000; + +/** + * Hook to fetch a single vendor by ID using SWR + * Provides real-time updates via polling + * + * @example + * // With server-side initial data (recommended for detail pages) + * const { data, mutate } = useVendor(vendorId, { initialData: serverVendor }); + * + * @example + * // Without initial data (shows loading state) + * const { data, isLoading, mutate } = useVendor(vendorId); + */ +export function useVendor(vendorId: string | null, options: UseVendorOptions = {}) { + const { initialData, ...restOptions } = options; + const api = useApi(); + const [isUpdating, setIsUpdating] = useState(false); + const params = useParams(); + const orgIdFromParams = params?.orgId as string | undefined; + const { organizationId: explicitOrgId, enabled = true, ...swrOptions } = restOptions; + const organizationId = orgIdFromParams || explicitOrgId; + const endpoint = vendorId ? `/v1/vendors/${vendorId}` : null; + + const swrKey = useMemo(() => { + if (!endpoint || !organizationId || !enabled) return null; + return [endpoint, organizationId] as const; + }, [endpoint, organizationId, enabled]); + + const fetcher = async ([url, orgId]: readonly [string, string]) => { + return apiClient.get(url, orgId); + }; + + const swrResult = useSWR>(swrKey, fetcher, { + // Enable polling for real-time updates (when trigger.dev tasks complete) + refreshInterval: swrOptions.refreshInterval ?? DEFAULT_POLLING_INTERVAL, + // Continue polling even when window is not focused + refreshWhenHidden: false, + // Use initial data as fallback for instant render + ...(initialData && { + fallbackData: { + data: initialData, + status: 200, + } as ApiResponse, + }), + ...swrOptions, + }); + + // Extract vendor data from response + const vendor = swrResult.data?.data ?? null; + + const updateVendor = useCallback( + async (nextVendorId: string, data: UpdateVendorData) => { + setIsUpdating(true); + try { + const response = await api.patch(`/v1/vendors/${nextVendorId}`, data); + if (response.error) { + throw new Error(response.error); + } + await swrResult.mutate(); + return response.data!; + } finally { + setIsUpdating(false); + } + }, + [api, swrResult], + ); + + return { + ...swrResult, + vendor, + updateVendor, + isUpdating, + }; +} diff --git a/apps/app/src/hooks/use-vendors.ts b/apps/app/src/hooks/use-vendors.ts index ef90609c7..924d6db10 100644 --- a/apps/app/src/hooks/use-vendors.ts +++ b/apps/app/src/hooks/use-vendors.ts @@ -1,19 +1,16 @@ 'use client'; import { useApi } from '@/hooks/use-api'; -import { useApiSWR, UseApiSWROptions } from '@/hooks/use-api-swr'; -import { ApiResponse } from '@/lib/api-client'; -import { useCallback } from 'react'; -import type { - VendorCategory, - VendorStatus, - Likelihood, - Impact, -} from '@db'; +import { UseApiSWROptions } from '@/hooks/use-api-swr'; +import { ApiResponse, apiClient } from '@/lib/api-client'; +import { useCallback, useMemo } from 'react'; +import { useParams } from 'next/navigation'; +import useSWR from 'swr'; +import type { VendorCategory, VendorStatus, Likelihood, Impact } from '@db'; import type { JsonValue } from '@prisma/client/runtime/library'; +import type { VendorsQuery } from '@/lib/vendors-query'; +import { buildVendorsQueryString } from '@/lib/vendors-query'; -// Default polling interval for real-time updates (5 seconds) -const DEFAULT_POLLING_INTERVAL = 5000; export interface VendorAssignee { id: string; @@ -46,6 +43,9 @@ export interface Vendor { export interface VendorsResponse { data: Vendor[]; count: number; + page?: number; + perPage?: number; + pageCount?: number; } /** @@ -66,7 +66,7 @@ interface CreateVendorData { assigneeId?: string; } -interface UpdateVendorData { +export interface UpdateVendorData { name?: string; description?: string; category?: VendorCategory; @@ -82,6 +82,7 @@ interface UpdateVendorData { export interface UseVendorsOptions extends UseApiSWROptions { /** Initial data from server for hydration - avoids loading state on first render */ initialData?: Vendor[]; + query?: VendorsQuery; } export interface UseVendorOptions extends UseApiSWROptions { @@ -102,12 +103,27 @@ export interface UseVendorOptions extends UseApiSWROptions { * const { vendors, isLoading, mutate } = useVendors(); */ export function useVendors(options: UseVendorsOptions = {}) { - const { initialData, ...restOptions } = options; + const api = useApi(); + const params = useParams(); + const orgIdFromParams = params?.orgId as string | undefined; + const { initialData, query, ...restOptions } = options; + + const endpoint = `/v1/vendors${buildVendorsQueryString(query ?? {})}`; + const { organizationId: explicitOrgId, enabled = true, ...swrOptions } = restOptions; + const organizationId = orgIdFromParams || explicitOrgId; + + const swrKey = useMemo(() => { + if (!endpoint || !organizationId || !enabled) return null; + return [endpoint, organizationId] as const; + }, [endpoint, organizationId, enabled]); - const swrResponse = useApiSWR('/v1/vendors', { - ...restOptions, + const fetcher = async ([url, orgId]: readonly [string, string]) => { + return apiClient.get(url, orgId); + }; + + const swrResponse = useSWR>(swrKey, fetcher, { // Refresh vendors periodically for real-time updates - refreshInterval: restOptions.refreshInterval ?? 30000, + refreshInterval: swrOptions.refreshInterval ?? 30000, // Use initial data as fallback for instant render ...(initialData && { fallbackData: { @@ -115,72 +131,19 @@ export function useVendors(options: UseVendorsOptions = {}) { status: 200, } as ApiResponse, }), + ...swrOptions, }); - return swrResponse; -} - -/** - * Hook to fetch a single vendor by ID using SWR - * Provides real-time updates via polling - * - * @example - * // With server-side initial data (recommended for detail pages) - * const { data, mutate } = useVendor(vendorId, { initialData: serverVendor }); - * - * @example - * // Without initial data (shows loading state) - * const { data, isLoading, mutate } = useVendor(vendorId); - */ -export function useVendor( - vendorId: string | null, - options: UseVendorOptions = {}, -) { - const { initialData, ...restOptions } = options; - - const swrResult = useApiSWR( - vendorId ? `/v1/vendors/${vendorId}` : null, - { - ...restOptions, - // Enable polling for real-time updates (when trigger.dev tasks complete) - refreshInterval: restOptions.refreshInterval ?? DEFAULT_POLLING_INTERVAL, - // Continue polling even when window is not focused - refreshWhenHidden: false, - // Use initial data as fallback for instant render - ...(initialData && { - fallbackData: { - data: initialData, - status: 200, - } as ApiResponse, - }), - }, - ); - - // Extract vendor data from response - const vendor = swrResult.data?.data ?? null; - - return { - ...swrResult, - vendor, - }; -} - -/** - * Hook for vendor CRUD operations (mutations) - * Use alongside useVendors/useVendor and call mutate() after mutations - */ -export function useVendorActions() { - const api = useApi(); - const createVendor = useCallback( async (data: CreateVendorData) => { const response = await api.post('/v1/vendors', data); if (response.error) { throw new Error(response.error); } + await swrResponse.mutate(); return response.data!; }, - [api], + [api, swrResponse], ); const updateVendor = useCallback( @@ -189,9 +152,10 @@ export function useVendorActions() { if (response.error) { throw new Error(response.error); } + await swrResponse.mutate(); return response.data!; }, - [api], + [api, swrResponse], ); const deleteVendor = useCallback( @@ -200,65 +164,19 @@ export function useVendorActions() { if (response.error) { throw new Error(response.error); } + await swrResponse.mutate(); return { success: true, status: response.status }; }, - [api], + [api, swrResponse], ); return { + ...swrResponse, + vendors: swrResponse.data?.data?.data ?? [], + count: swrResponse.data?.data?.count ?? 0, createVendor, updateVendor, deleteVendor, }; } -/** - * Combined hook for vendors with data fetching and mutations - * Provides a complete solution for vendor management with optimistic updates - */ -export function useVendorsWithMutations(options: UseApiSWROptions = {}) { - const { data, error, isLoading, mutate } = useVendors(options); - const { createVendor, updateVendor, deleteVendor } = useVendorActions(); - - const create = useCallback( - async (vendorData: CreateVendorData) => { - const result = await createVendor(vendorData); - // Revalidate the vendors list after creation - await mutate(); - return result; - }, - [createVendor, mutate], - ); - - const update = useCallback( - async (vendorId: string, vendorData: UpdateVendorData) => { - const result = await updateVendor(vendorId, vendorData); - // Revalidate the vendors list after update - await mutate(); - return result; - }, - [updateVendor, mutate], - ); - - const remove = useCallback( - async (vendorId: string) => { - const result = await deleteVendor(vendorId); - // Revalidate the vendors list after deletion - await mutate(); - return result; - }, - [deleteVendor, mutate], - ); - - return { - vendors: data?.data?.data ?? [], - count: data?.data?.count ?? 0, - isLoading, - error, - mutate, - createVendor: create, - updateVendor: update, - deleteVendor: remove, - }; -} - diff --git a/apps/app/src/lib/server-api-client.ts b/apps/app/src/lib/server-api-client.ts index 41b4fbbb8..353cf7d01 100644 --- a/apps/app/src/lib/server-api-client.ts +++ b/apps/app/src/lib/server-api-client.ts @@ -1,6 +1,10 @@ import { headers } from 'next/headers'; +import { cache } from 'react'; const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3333'; +const resolveBetterAuthBaseUrl = (): string => { + return process.env.NEXT_PUBLIC_BETTER_AUTH_URL || 'http://localhost:3000'; +}; interface ApiResponse { data?: T; @@ -11,26 +15,56 @@ interface ApiResponse { interface CallOptions { method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; body?: unknown; + organizationId?: string; } +const getJwtForRequest = cache(async (): Promise => { + try { + const cookieHeader = (await headers()).get('cookie'); + if (!cookieHeader) { + console.warn('[serverApi] No cookie header present'); + } + if (!cookieHeader) return null; + + const baseUrl = resolveBetterAuthBaseUrl(); + const response = await fetch(`${baseUrl}/api/auth/get-session`, { + method: 'GET', + headers: { + Cookie: cookieHeader, + }, + cache: 'no-store', + }); + + const jwtToken = response.headers.get('set-auth-jwt'); + console.log('[serverApi] Session JWT header present:', Boolean(jwtToken)); + return jwtToken ?? null; + } catch (error) { + console.error('[serverApi] Failed to fetch JWT token', error); + return null; + } +}); + /** * Server-side API client for calling our internal NestJS API from server components - * Forwards cookies for authentication - API handles auth via better-auth + * Uses Better Auth session to mint a JWT for API auth */ async function call( endpoint: string, options: CallOptions = {}, ): Promise> { - const { method = 'GET', body } = options; + const { method = 'GET', body, organizationId } = options; const requestHeaders: Record = { 'Content-Type': 'application/json', }; - // Forward cookies for auth - better-auth handles session validation - const cookieHeader = (await headers()).get('cookie'); - if (cookieHeader) { - requestHeaders['Cookie'] = cookieHeader; + if (organizationId) { + requestHeaders['X-Organization-Id'] = organizationId; + } + + const jwtToken = await getJwtForRequest(); + if (jwtToken) { + requestHeaders['Authorization'] = `Bearer ${jwtToken}`; } try { @@ -67,16 +101,18 @@ async function call( } export const serverApi = { - get: (endpoint: string) => call(endpoint, { method: 'GET' }), + get: (endpoint: string, organizationId?: string) => + call(endpoint, { method: 'GET', organizationId }), - post: (endpoint: string, body?: unknown) => - call(endpoint, { method: 'POST', body }), + post: (endpoint: string, body?: unknown, organizationId?: string) => + call(endpoint, { method: 'POST', body, organizationId }), - put: (endpoint: string, body?: unknown) => - call(endpoint, { method: 'PUT', body }), + put: (endpoint: string, body?: unknown, organizationId?: string) => + call(endpoint, { method: 'PUT', body, organizationId }), - patch: (endpoint: string, body?: unknown) => - call(endpoint, { method: 'PATCH', body }), + patch: (endpoint: string, body?: unknown, organizationId?: string) => + call(endpoint, { method: 'PATCH', body, organizationId }), - delete: (endpoint: string) => call(endpoint, { method: 'DELETE' }), + delete: (endpoint: string, organizationId?: string) => + call(endpoint, { method: 'DELETE', organizationId }), }; diff --git a/apps/app/src/lib/vendors-query.ts b/apps/app/src/lib/vendors-query.ts new file mode 100644 index 000000000..39292ad8c --- /dev/null +++ b/apps/app/src/lib/vendors-query.ts @@ -0,0 +1,32 @@ +import type { Departments, VendorCategory, VendorStatus } from '@db'; + +export type VendorsQuery = { + page?: number; + perPage?: number; + name?: string; + status?: VendorStatus | null; + category?: VendorCategory | null; + department?: Departments | null; + assigneeId?: string | null; + sortId?: 'name' | 'updatedAt' | 'createdAt'; + sortDesc?: boolean; +}; + +export const buildVendorsQueryString = (params: VendorsQuery): string => { + const query = new URLSearchParams(); + + if (params.page) query.set('page', String(params.page)); + if (params.perPage) query.set('perPage', String(params.perPage)); + if (params.name) query.set('name', params.name); + if (params.status) query.set('status', params.status); + if (params.category) query.set('category', params.category); + if (params.department) query.set('department', params.department); + if (params.assigneeId) query.set('assigneeId', params.assigneeId); + if (params.sortId) query.set('sortId', params.sortId); + if (typeof params.sortDesc === 'boolean') { + query.set('sortDesc', params.sortDesc ? 'true' : 'false'); + } + + const queryString = query.toString(); + return queryString ? `?${queryString}` : ''; +}; diff --git a/packages/docs/openapi.json b/packages/docs/openapi.json index 44a4af016..74180f63b 100644 --- a/packages/docs/openapi.json +++ b/packages/docs/openapi.json @@ -678,6 +678,76 @@ ] } }, + "/v1/organization/onboarding": { + "get": { + "description": "Returns the current onboarding trigger run ID for the organization, if any. Supports both API key authentication (X-API-Key header) and session authentication (cookies + X-Organization-Id header).", + "operationId": "OrganizationController_getOnboardingStatus_v1", + "parameters": [ + { + "name": "X-Organization-Id", + "in": "header", + "description": "Organization ID (required for session auth, optional for API key auth)", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Organization onboarding status retrieved successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "triggerJobId": { + "type": "string", + "nullable": true, + "description": "Trigger.dev onboarding run ID if active", + "example": "trg_abc123def456" + }, + "authType": { + "type": "string", + "enum": [ + "api-key", + "session" + ], + "description": "How the request was authenticated" + } + } + } + } + } + }, + "401": { + "description": "Unauthorized - Invalid authentication or insufficient permissions", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "Invalid or expired API key" + } + } + } + } + } + } + }, + "security": [ + { + "apikey": [] + } + ], + "summary": "Get organization onboarding status", + "tags": [ + "Organization" + ] + } + }, "/v1/people": { "get": { "description": "Returns all members for the authenticated organization with their user information. Supports both API key authentication (X-API-Key header) and session authentication (cookies + X-Organization-Id header).", @@ -2903,6 +2973,78 @@ "schema": { "type": "string" } + }, + { + "name": "page", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "perPage", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "name", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "status", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "category", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "department", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "assigneeId", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "sortId", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "sortDesc", + "required": true, + "in": "query", + "schema": { + "type": "string" + } } ], "responses": { @@ -3029,6 +3171,21 @@ "description": "Total number of vendors", "example": 12 }, + "page": { + "type": "number", + "description": "Current page number", + "example": 1 + }, + "perPage": { + "type": "number", + "description": "Number of vendors per page", + "example": 50 + }, + "pageCount": { + "type": "number", + "description": "Total number of pages", + "example": 3 + }, "authType": { "type": "string", "enum": [ From 494da966337df48a19bb0cb938a24381b68a08eb Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Wed, 28 Jan 2026 13:45:11 -0500 Subject: [PATCH 3/3] chore(deps): bump three and @radix-ui/react-alert-dialog versions --- bun.lock | 22 ++++++---------------- 1 file changed, 6 insertions(+), 16 deletions(-) diff --git a/bun.lock b/bun.lock index 2528e1de4..281f830e4 100644 --- a/bun.lock +++ b/bun.lock @@ -267,7 +267,7 @@ "sonner": "^2.0.5", "stripe": "^20.0.0", "swr": "^2.3.4", - "three": "^0.177.0", + "three": "^0.182.0", "ts-pattern": "^5.7.0", "use-debounce": "^10.0.4", "use-long-press": "^3.3.0", @@ -484,7 +484,7 @@ "dependencies": { "@floating-ui/dom": "^1.6.0", "@radix-ui/react-accordion": "1.2.12", - "@radix-ui/react-alert-dialog": "1.1.14", + "@radix-ui/react-alert-dialog": "1.1.15", "@radix-ui/react-avatar": "1.1.10", "@radix-ui/react-checkbox": "1.3.2", "@radix-ui/react-collapsible": "^1.1.12", @@ -501,7 +501,7 @@ "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.8", - "@radix-ui/react-slider": "1.3.5", + "@radix-ui/react-slider": "1.3.6", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-switch": "1.2.5", "@radix-ui/react-tabs": "1.1.12", @@ -1591,7 +1591,7 @@ "@radix-ui/react-accordion": ["@radix-ui/react-accordion@1.2.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collapsible": "1.1.12", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA=="], - "@radix-ui/react-alert-dialog": ["@radix-ui/react-alert-dialog@1.1.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dialog": "1.1.14", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-IOZfZ3nPvN6lXpJTBCunFQPRSvK8MDgSc1FB85xnIpUKOw9en0dJj8JmCAxV7BiZdtYlUpmrQjoTFkVYtdoWzQ=="], + "@radix-ui/react-alert-dialog": ["@radix-ui/react-alert-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw=="], "@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="], @@ -1657,7 +1657,7 @@ "@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g=="], - "@radix-ui/react-slider": ["@radix-ui/react-slider@1.3.5", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-rkfe2pU2NBAYfGaxa3Mqosi7VZEWX5CxKaanRv0vZd4Zhl9fvQrg0VM93dv3xGLGfrHuoTRF3JXH8nb9g+B3fw=="], + "@radix-ui/react-slider": ["@radix-ui/react-slider@1.3.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw=="], "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="], @@ -5413,7 +5413,7 @@ "thread-stream": ["thread-stream@3.1.0", "", { "dependencies": { "real-require": "^0.2.0" } }, "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A=="], - "three": ["three@0.177.0", "", {}, "sha512-EiXv5/qWAaGI+Vz2A+JfavwYCMdGjxVsrn3oBwllUoqYeaBO75J63ZfyaQKoiLrqNHoTlUc6PFgMXnS0kI45zg=="], + "three": ["three@0.182.0", "", {}, "sha512-GbHabT+Irv+ihI1/f5kIIsZ+Ef9Sl5A1Y7imvS5RQjWgtTPfPnZ43JmlYI7NtCRDK9zir20lQpfg8/9Yd02OvQ=="], "three-mesh-bvh": ["three-mesh-bvh@0.8.3", "", { "peerDependencies": { "three": ">= 0.159.0" } }, "sha512-4G5lBaF+g2auKX3P0yqx+MJC6oVt6sB5k+CchS6Ob0qvH0YIhuUk1eYr7ktsIpY+albCqE80/FVQGV190PmiAg=="], @@ -6047,10 +6047,6 @@ "@prisma/get-platform/@prisma/debug": ["@prisma/debug@6.18.0", "", {}, "sha512-PMVPMmxPj0ps1VY75DIrT430MoOyQx9hmm174k6cmLZpcI95rAPXOQ+pp8ANQkJtNyLVDxnxVJ0QLbrm/ViBcg=="], - "@radix-ui/react-alert-dialog/@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="], - - "@radix-ui/react-alert-dialog/@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-focus-guards": "1.1.2", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-+CpweKjqpzTmwRwcYECQcNYbI8V9VSQt0SNFKeEBLgfucbsLssU6Ppq7wUdNXEGb573bMjFhVjKVll8rmV6zMw=="], - "@radix-ui/react-alert-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], "@radix-ui/react-checkbox/@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="], @@ -6107,8 +6103,6 @@ "@radix-ui/react-separator/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="], - "@radix-ui/react-slider/@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="], - "@radix-ui/react-switch/@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="], "@radix-ui/react-tabs/@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="], @@ -7321,10 +7315,6 @@ "@playwright/experimental-ct-core/vite/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], - "@radix-ui/react-alert-dialog/@radix-ui/react-dialog/@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ=="], - - "@radix-ui/react-alert-dialog/@radix-ui/react-dialog/@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-fyjAACV62oPV925xFCrH8DR5xWhg9KYtJT4s3u54jxp+L/hbpTY2kIeEFFbFe+a/HCE94zGQMZLIpVTPVZDhaA=="], - "@radix-ui/react-dropdown-menu/@radix-ui/react-menu/@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="], "@radix-ui/react-dropdown-menu/@radix-ui/react-menu/@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="],