From e994bbe4365530fa8e98833e0339d3faae9da04d Mon Sep 17 00:00:00 2001 From: Matt Cooper Date: Sun, 4 May 2025 15:12:39 -0300 Subject: [PATCH 01/19] Enhance realtime agent app with support engagement selection and interview management --- .gitignore | 5 +- README.md | 17 + package-lock.json | 126 ++++++- package.json | 4 +- src/app/App.tsx | 74 +++- src/app/agentConfigs/index.ts | 2 + src/app/agentConfigs/supportFeedback.ts | 117 ++++++ src/app/api/companies/[id]/contacts/route.ts | 70 ++++ .../api/companies/[id]/engagements/route.ts | 49 +++ src/app/api/companies/route.ts | 45 +++ src/app/api/engagement/route.ts | 64 ++++ src/app/api/engagements/[id]/people/route.ts | 95 +++++ src/app/api/interviews/create/route.ts | 97 +++++ src/app/api/interviews/route.ts | 57 +++ src/app/app/page.tsx | 13 + src/app/components/CompanySelector.tsx | 277 ++++++++++++++ src/app/components/ContactSelector.tsx | 307 ++++++++++++++++ src/app/components/EngagementSelector.tsx | 331 +++++++++++++++++ src/app/components/InterviewList.tsx | 128 +++++++ src/app/components/MainNav.tsx | 45 +++ src/app/components/SupportPersonSelector.tsx | 312 ++++++++++++++++ src/app/globals.css | 7 +- src/app/interviews/create/page.tsx | 344 ++++++++++++++++++ src/app/interviews/page.tsx | 22 ++ src/app/layout.tsx | 13 +- src/app/lib/engagementHelpers.ts | 48 +++ src/app/lib/supabase-server.ts | 20 + src/app/lib/supabase.ts | 15 + src/app/lib/types.ts | 56 +++ src/app/page.tsx | 46 ++- 30 files changed, 2783 insertions(+), 23 deletions(-) create mode 100644 src/app/agentConfigs/supportFeedback.ts create mode 100644 src/app/api/companies/[id]/contacts/route.ts create mode 100644 src/app/api/companies/[id]/engagements/route.ts create mode 100644 src/app/api/companies/route.ts create mode 100644 src/app/api/engagement/route.ts create mode 100644 src/app/api/engagements/[id]/people/route.ts create mode 100644 src/app/api/interviews/create/route.ts create mode 100644 src/app/api/interviews/route.ts create mode 100644 src/app/app/page.tsx create mode 100644 src/app/components/CompanySelector.tsx create mode 100644 src/app/components/ContactSelector.tsx create mode 100644 src/app/components/EngagementSelector.tsx create mode 100644 src/app/components/InterviewList.tsx create mode 100644 src/app/components/MainNav.tsx create mode 100644 src/app/components/SupportPersonSelector.tsx create mode 100644 src/app/interviews/create/page.tsx create mode 100644 src/app/interviews/page.tsx create mode 100644 src/app/lib/engagementHelpers.ts create mode 100644 src/app/lib/supabase-server.ts create mode 100644 src/app/lib/supabase.ts create mode 100644 src/app/lib/types.ts diff --git a/.gitignore b/.gitignore index 047dc407..e607e403 100644 --- a/.gitignore +++ b/.gitignore @@ -37,4 +37,7 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts -todo.md \ No newline at end of file +todo.md + +# MCP +mcp.json diff --git a/README.md b/README.md index 4e75c1a5..422a4e2d 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,23 @@ You should be able to use this repo to prototype your own multi-agent realtime v - Start the server with `npm run dev` - Open your browser to [http://localhost:3000](http://localhost:3000) to see the app. It should automatically connect to the `simpleExample` Agent Set. +## Supabase Integration + +This demo app includes integration with Supabase for retrieving real support engagement data. To use this functionality: + +1. Create a Supabase project at [https://supabase.com](https://supabase.com) +2. Set up your database with the following tables: + - `companies` - Information about companies receiving support + - `support_engagements` - Details about support engagements + +3. Update your `.env.development.local` file with your Supabase credentials: +``` +NEXT_PUBLIC_SUPABASE_URL=https://your-project-id.supabase.co +NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key +``` + +4. The app will automatically fetch and use real data when you select the "startupInterviewer" agent. + ## Configuring Agents Configuration in `src/app/agentConfigs/simpleExample.ts` ```javascript diff --git a/package-lock.json b/package-lock.json index 58237105..42948f80 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,12 +8,14 @@ "name": "realtime-examples", "version": "0.1.0", "dependencies": { + "@supabase/supabase-js": "^2.49.4", + "@types/uuid": "^10.0.0", "next": "15.1.4", "openai": "^4.77.3", "react": "^19.0.0", "react-dom": "^19.0.0", "react-markdown": "^9.0.3", - "uuid": "^11.0.4" + "uuid": "^11.1.0" }, "devDependencies": { "@eslint/eslintrc": "^3", @@ -892,6 +894,80 @@ "dev": true, "license": "MIT" }, + "node_modules/@supabase/auth-js": { + "version": "2.69.1", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.69.1.tgz", + "integrity": "sha512-FILtt5WjCNzmReeRLq5wRs3iShwmnWgBvxHfqapC/VoljJl+W8hDAyFmf1NVw3zH+ZjZ05AKxiKxVeb0HNWRMQ==", + "license": "MIT", + "dependencies": { + "@supabase/node-fetch": "^2.6.14" + } + }, + "node_modules/@supabase/functions-js": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.4.4.tgz", + "integrity": "sha512-WL2p6r4AXNGwop7iwvul2BvOtuJ1YQy8EbOd0dhG1oN1q8el/BIRSFCFnWAMM/vJJlHWLi4ad22sKbKr9mvjoA==", + "license": "MIT", + "dependencies": { + "@supabase/node-fetch": "^2.6.14" + } + }, + "node_modules/@supabase/node-fetch": { + "version": "2.6.15", + "resolved": "https://registry.npmjs.org/@supabase/node-fetch/-/node-fetch-2.6.15.tgz", + "integrity": "sha512-1ibVeYUacxWYi9i0cf5efil6adJ9WRyZBLivgjs+AUpewx1F3xPi7gLgaASI2SmIQxPoCEjAsLAzKPgMJVgOUQ==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + } + }, + "node_modules/@supabase/postgrest-js": { + "version": "1.19.4", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-1.19.4.tgz", + "integrity": "sha512-O4soKqKtZIW3olqmbXXbKugUtByD2jPa8kL2m2c1oozAO11uCcGrRhkZL0kVxjBLrXHE0mdSkFsMj7jDSfyNpw==", + "license": "MIT", + "dependencies": { + "@supabase/node-fetch": "^2.6.14" + } + }, + "node_modules/@supabase/realtime-js": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.11.2.tgz", + "integrity": "sha512-u/XeuL2Y0QEhXSoIPZZwR6wMXgB+RQbJzG9VErA3VghVt7uRfSVsjeqd7m5GhX3JR6dM/WRmLbVR8URpDWG4+w==", + "license": "MIT", + "dependencies": { + "@supabase/node-fetch": "^2.6.14", + "@types/phoenix": "^1.5.4", + "@types/ws": "^8.5.10", + "ws": "^8.18.0" + } + }, + "node_modules/@supabase/storage-js": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.7.1.tgz", + "integrity": "sha512-asYHcyDR1fKqrMpytAS1zjyEfvxuOIp1CIXX7ji4lHHcJKqyk+sLl/Vxgm4sN6u8zvuUtae9e4kDxQP2qrwWBA==", + "license": "MIT", + "dependencies": { + "@supabase/node-fetch": "^2.6.14" + } + }, + "node_modules/@supabase/supabase-js": { + "version": "2.49.4", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.49.4.tgz", + "integrity": "sha512-jUF0uRUmS8BKt37t01qaZ88H9yV1mbGYnqLeuFWLcdV+x1P4fl0yP9DGtaEhFPZcwSom7u16GkLEH9QJZOqOkw==", + "license": "MIT", + "dependencies": { + "@supabase/auth-js": "2.69.1", + "@supabase/functions-js": "2.4.4", + "@supabase/node-fetch": "2.6.15", + "@supabase/postgrest-js": "1.19.4", + "@supabase/realtime-js": "2.11.2", + "@supabase/storage-js": "2.7.1" + } + }, "node_modules/@swc/counter": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", @@ -988,6 +1064,12 @@ "form-data": "^4.0.0" } }, + "node_modules/@types/phoenix": { + "version": "1.6.6", + "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.6.tgz", + "integrity": "sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==", + "license": "MIT" + }, "node_modules/@types/react": { "version": "19.0.3", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.3.tgz", @@ -1013,6 +1095,21 @@ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "license": "MIT" }, + "node_modules/@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.19.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.19.1.tgz", @@ -6997,9 +7094,9 @@ "license": "MIT" }, "node_modules/uuid": { - "version": "11.0.4", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.4.tgz", - "integrity": "sha512-IzL6VtTTYcAhA/oghbFJ1Dkmqev+FpQWnCBaKq/gUluLxliWvO8DPFWfIviRmYbtaavtSQe4WBL++rFjdcGWEg==", + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" @@ -7271,6 +7368,27 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/ws": { + "version": "8.18.2", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.2.tgz", + "integrity": "sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/yaml": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.0.tgz", diff --git a/package.json b/package.json index df3110c1..6b7c7787 100644 --- a/package.json +++ b/package.json @@ -9,12 +9,14 @@ "lint": "next lint" }, "dependencies": { + "@supabase/supabase-js": "^2.49.4", + "@types/uuid": "^10.0.0", "next": "15.1.4", "openai": "^4.77.3", "react": "^19.0.0", "react-dom": "^19.0.0", "react-markdown": "^9.0.3", - "uuid": "^11.0.4" + "uuid": "^11.1.0" }, "devDependencies": { "@eslint/eslintrc": "^3", diff --git a/src/app/App.tsx b/src/app/App.tsx index e785ca74..a8c54963 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -21,6 +21,7 @@ import { useHandleServerEvent } from "./hooks/useHandleServerEvent"; // Utilities import { createRealtimeConnection } from "./lib/realtimeConnection"; +import { createUpdatedAgentConfig } from "./lib/engagementHelpers"; // Agent configs import { allAgentSets, defaultAgentSetKey } from "@/app/agentConfigs"; @@ -51,6 +52,10 @@ function App() { const [isAudioPlaybackEnabled, setIsAudioPlaybackEnabled] = useState(true); + const [engagementData, setEngagementData] = useState(null); + const [isLoadingEngagementData, setIsLoadingEngagementData] = useState(false); + const [engagementError, setEngagementError] = useState(null); + const sendClientEvent = (eventObj: any, eventNameSuffix = "") => { if (dcRef.current && dcRef.current.readyState === "open") { logClientEvent(eventObj, eventNameSuffix); @@ -153,6 +158,7 @@ function App() { if (!audioElementRef.current) { audioElementRef.current = document.createElement("audio"); } + audioElementRef.current.playbackRate = 1.25; audioElementRef.current.autoplay = isAudioPlaybackEnabled; const { pc, dc } = await createRealtimeConnection( @@ -235,10 +241,8 @@ function App() { const turnDetection = isPTTActive ? null : { - type: "server_vad", - threshold: 0.5, - prefix_padding_ms: 300, - silence_duration_ms: 200, + type: "semantic_vad", + eagerness: "high", create_response: true, }; @@ -250,7 +254,7 @@ function App() { session: { modalities: ["text", "audio"], instructions, - voice: "coral", + voice: "shimmer", input_audio_format: "pcm16", output_audio_format: "pcm16", input_audio_transcription: { model: "whisper-1" }, @@ -391,6 +395,7 @@ function App() { useEffect(() => { if (audioElementRef.current) { + audioElementRef.current.playbackRate = 1.25; if (isAudioPlaybackEnabled) { audioElementRef.current.play().catch((err) => { console.warn("Autoplay may be blocked by browser:", err); @@ -401,6 +406,53 @@ function App() { } }, [isAudioPlaybackEnabled]); + useEffect(() => { + if (selectedAgentName === "startupInterviewer" && selectedAgentConfigSet) { + setIsLoadingEngagementData(true); + setEngagementError(null); + + // Fetch real engagement data from our API + fetch(`/api/engagement?id=08ea46fc-f85f-4176-a139-54caa44fda7e`) + .then(res => { + if (!res.ok) { + throw new Error(`Failed to fetch engagement data: ${res.status}`); + } + return res.json(); + }) + .then(data => { + setEngagementData(data); + + // Update agent config with real data + const originalAgent = selectedAgentConfigSet.find(a => a.name === "startupInterviewer"); + const updatedAgent = createUpdatedAgentConfig(originalAgent, data); + + if (updatedAgent) { + // Replace the agent with our data-filled version + setSelectedAgentConfigSet(prevSet => + prevSet?.map(a => a.name === "startupInterviewer" ? updatedAgent : a) || null + ); + + addTranscriptBreadcrumb( + `Updated Agent: ${selectedAgentName} with real data`, + { engagement: data.engagement.id, company: data.company.business_name } + ); + } + + setIsLoadingEngagementData(false); + }) + .catch(err => { + console.error("Error fetching engagement data:", err); + setEngagementError(err.message); + setIsLoadingEngagementData(false); + + addTranscriptBreadcrumb( + `Error loading data for ${selectedAgentName}`, + { error: err.message } + ); + }); + } + }, [selectedAgentName, selectedAgentConfigSet]); + const agentSetKey = searchParams.get("agentConfig") || "default"; return ( @@ -510,6 +562,18 @@ function App() { isAudioPlaybackEnabled={isAudioPlaybackEnabled} setIsAudioPlaybackEnabled={setIsAudioPlaybackEnabled} /> + + {isLoadingEngagementData && ( +
+ Loading engagement data... +
+ )} + + {engagementError && ( +
+ Error: {engagementError} +
+ )} ); } diff --git a/src/app/agentConfigs/index.ts b/src/app/agentConfigs/index.ts index e4c8787b..d7ffd0ea 100644 --- a/src/app/agentConfigs/index.ts +++ b/src/app/agentConfigs/index.ts @@ -2,11 +2,13 @@ import { AllAgentConfigsType } from "@/app/types"; import frontDeskAuthentication from "./frontDeskAuthentication"; import customerServiceRetail from "./customerServiceRetail"; import simpleExample from "./simpleExample"; +import startupInterviewer from "./supportFeedback"; export const allAgentSets: AllAgentConfigsType = { frontDeskAuthentication, customerServiceRetail, simpleExample, + startupInterviewer, }; export const defaultAgentSetKey = "simpleExample"; diff --git a/src/app/agentConfigs/supportFeedback.ts b/src/app/agentConfigs/supportFeedback.ts new file mode 100644 index 00000000..c0d73c07 --- /dev/null +++ b/src/app/agentConfigs/supportFeedback.ts @@ -0,0 +1,117 @@ +import { AgentConfig } from "@/app/types"; + +// Expert qualitative research agent for startup support engagement feedback +const startupInterviewerAgent: AgentConfig = { + name: "startupInterviewer", + publicDescription: + "Volta's Startup Success Research Agent that interviews companies to gather detailed feedback on support engagements.", + instructions: ` +# Personality & Tone +You are Volta's Startup Success Research Agent, an expert qualitative researcher skilled at collecting detailed feedback. You're friendly yet professional, with the expertise to probe for thorough, insightful responses. Your goal is to help the interviewee feel comfortable while ensuring you capture rich, detailed answers through thoughtful follow-up questions. + +# Core Objective +You are having a conversation with the founder of Starluv Inc. Your task is to collect comprehensive details about the support engagement provided. You must ask whatever questions are needed to get thorough, detailed answers from each question area provided. Don't be satisfied with surface-level responses - ask follow-up questions to get specifics and examples. + +# Support Engagement Context +- Company: Starluv Inc +- Support Type: Investment Planning +- Title: Investment Readiness +- Engagement Identified: March 26, 2025 +- Status: Identified (not yet completed) + +## Background Information +Starluv Inc is an early-stage company (early adopters traction level) established in 2021. They're based in Newfoundland. + +The engagement was focused on providing legal support to organize essential documents ahead of fundraising, including structuring their cap table, formalizing previous informal investments, creating a stock option pool, and organizing contracts. The team previously raised money informally from friends and family without proper documentation. They're planning to raise a $200K angel round soon, with potential seed round in winter 2025. + +# High-Level Flow +1. Introduce yourself as Volta's Startup Success Research Agent and set expectations (that you'll ask a few questions about their recent Investment Readiness support experience). This intervewee is the founder of Starluv Inc. +2. Ask three key questions, confirming each answer sounds complete before progressing: + • Q1 – Context leading up to needing investment planning support (organizing documents, cap table, etc.) + • Q2 – Challenges encountered during the support interaction. + • Q3 – Impacts or outcomes experienced after the support (even anecdotal). +3. After all questions are answered, thank them and close the call. + +# Conversation States +[ + { + "id": "1_intro", + "description": "Introduce yourself, gain permission to proceed.", + "instructions": [ + "Introduce yourself as 'Volta's Startup Success Research Agent' specializing in collecting detailed feedback about startup support.", + "Explain that you'll ask a few questions about their recent Investment Readiness support engagement (08ea46fc-f85f-4176-a139-54caa44fda7e) with Starluv Inc to capture feedback.", + "Mention that you understand they sought help with organizing legal documents and agreements ahead of fundraising efforts.", + "Emphasize that you're looking for detailed responses to help improve support services.", + "Ask for confirmation that they are ready to begin." + ], + "transitions": [ + { + "next_step": "2_q1_context", + "condition": "The interviewee confirms they are ready." + } + ] + }, + { + "id": "2_q1_context", + "description": "Ask what was happening that led them to seek support.", + "instructions": [ + "Ask: 'To start, could you tell me more about what was happening at Starluv Inc that led you to request support with investment planning and readiness? I understand there were needs around organizing documents, cap table structuring, and formalizing investments. Please share as much detail as you're comfortable with.'", + "Listen actively and encourage elaboration if the answer is short.", + "Ask follow-up questions to get specific examples and details about their situation before the support engagement." + ], + "transitions": [ + { + "next_step": "3_q2_challenges", + "condition": "A thorough answer describing the context is given." + } + ] + }, + { + "id": "3_q2_challenges", + "description": "Ask whether any challenges were encountered during support.", + "instructions": [ + "Ask: 'Were there any challenges you encountered while receiving the investment readiness support? For example, anything that didn't go as expected or could be improved in how we helped organize your legal documentation and prepare for fundraising?'", + "Probe for specifics if the answer is vague.", + "Ask follow-up questions about particular aspects of the support process to identify concrete examples of challenges or friction points." + ], + "transitions": [ + { + "next_step": "4_q3_impact", + "condition": "A meaningful answer about challenges is captured." + } + ] + }, + { + "id": "4_q3_impact", + "description": "Ask about the impacts or outcomes after the support.", + "instructions": [ + "Ask: 'Since the investment readiness support was provided, what impact has it had on Starluv Inc's fundraising readiness or overall business operations? Even subjective impressions about your increased confidence or ability to approach investors are helpful.'", + "Invite both positive and constructive feedback.", + "If they mention they're still in progress, ask about preliminary impacts or partial outcomes achieved so far.", + "Ask for specific examples of how the support has changed their approach or operations." + ], + "transitions": [ + { + "next_step": "5_wrap_up", + "condition": "A clear answer on impacts is provided." + } + ] + }, + { + "id": "5_wrap_up", + "description": "Thank and conclude the interview.", + "instructions": [ + "Summarize the three answers back to the interviewee in 1–2 sentences each to show you've captured them.", + "Thank them sincerely for their time and detailed feedback about the Investment Readiness support for Starluv Inc.", + "Mention that their feedback will help improve Volta's support services for early-stage companies preparing for fundraising.", + "Identify yourself again as Volta's Startup Success Research Agent.", + "Politely end the conversation." + ] + } +] +`, + tools: [], +}; + +const agents = [startupInterviewerAgent]; +export default agents; \ No newline at end of file diff --git a/src/app/api/companies/[id]/contacts/route.ts b/src/app/api/companies/[id]/contacts/route.ts new file mode 100644 index 00000000..96b3a5a7 --- /dev/null +++ b/src/app/api/companies/[id]/contacts/route.ts @@ -0,0 +1,70 @@ +import { NextResponse } from "next/server"; +import supabaseServer from "@/app/lib/supabase-server"; + +export async function GET( + request: Request, + { params }: { params: { id: string } } +) { + try { + const companyId = params.id; + + if (!companyId) { + return NextResponse.json( + { error: "Company ID is required" }, + { status: 400 } + ); + } + + console.log(`Fetching contacts for company ${companyId} using service role key...`); + + // First get the person IDs associated with this company + const { data: peopleCompanies, error: pcError } = await supabaseServer + .from('people_companies') + .select('person_id') + .eq('company_id', companyId); + + if (pcError) { + console.error(`Error fetching people_companies for company ${companyId}:`, pcError); + throw pcError; + } + + // Extract the person_ids + const personIds = peopleCompanies.map(pc => pc.person_id); + + if (personIds.length === 0) { + console.log(`No contacts found for company ${companyId}`); + return NextResponse.json([]); + } + + console.log(`Found ${personIds.length} person IDs for company ${companyId}`); + + // Get contact details for these people + const { data: contacts, error } = await supabaseServer + .from('people') + .select(` + id, + first_name, + last_name, + title, + photo + `) + .eq('is_active', true) + .in('id', personIds) + .order('first_name', { ascending: true }); + + if (error) { + console.error(`Error fetching contacts for company ${companyId}:`, error); + throw error; + } + + console.log(`Successfully fetched ${contacts?.length || 0} contacts for company ${companyId}`); + + return NextResponse.json(contacts || []); + } catch (error) { + console.error("Error fetching contacts:", error); + return NextResponse.json( + { error: "Failed to fetch contacts" }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/src/app/api/companies/[id]/engagements/route.ts b/src/app/api/companies/[id]/engagements/route.ts new file mode 100644 index 00000000..716bf299 --- /dev/null +++ b/src/app/api/companies/[id]/engagements/route.ts @@ -0,0 +1,49 @@ +import { NextResponse } from "next/server"; +import supabaseServer from "@/app/lib/supabase-server"; + +export async function GET( + request: Request, + { params }: { params: { id: string } } +) { + try { + const companyId = params.id; + + if (!companyId) { + return NextResponse.json( + { error: "Company ID is required" }, + { status: 400 } + ); + } + + console.log(`Fetching support engagements for company ${companyId} using service role key...`); + + // Get support engagements for this company + const { data: engagements, error } = await supabaseServer + .from('support_engagements') + .select(` + id, + title, + status, + created_at, + updated_at, + description + `) + .eq('company_id', companyId) + .order('created_at', { ascending: false }); + + if (error) { + console.error(`Error fetching support engagements for company ${companyId}:`, error); + throw error; + } + + console.log(`Successfully fetched ${engagements?.length || 0} support engagements for company ${companyId}`); + + return NextResponse.json(engagements || []); + } catch (error) { + console.error("Error fetching support engagements:", error); + return NextResponse.json( + { error: "Failed to fetch support engagements" }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/src/app/api/companies/route.ts b/src/app/api/companies/route.ts new file mode 100644 index 00000000..ef32fda2 --- /dev/null +++ b/src/app/api/companies/route.ts @@ -0,0 +1,45 @@ +import { NextResponse } from "next/server"; +import supabaseServer from "@/app/lib/supabase-server"; + +export async function GET(request: Request) { + try { + // Log environment variables (without exposing full key values) + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; + const serviceKeyExists = !!process.env.NEXT_PUBLIC_SERVICE_ROLE_KEY; + + console.log(`Supabase URL exists: ${!!supabaseUrl}`); + console.log(`Service role key exists: ${serviceKeyExists}`); + + if (!supabaseUrl || !serviceKeyExists) { + throw new Error("Missing Supabase credentials. Check environment variables."); + } + + console.log("Fetching companies from Supabase using service role key..."); + + // Get all active companies, sorted by name + const { data: companies, error } = await supabaseServer + .from('companies') + .select('id, business_name, logo, operating_status, website, traction_level') + .eq('is_active', true) + .order('business_name', { ascending: true }); + + if (error) { + console.error("Error fetching companies:", error); + throw error; + } + + console.log(`Successfully fetched ${companies?.length || 0} companies from Supabase`); + console.log("First few companies:", companies?.slice(0, 3)); + + return NextResponse.json(companies); + } catch (error: any) { + console.error("Detailed error fetching company data:", error); + console.error("Error message:", error.message); + console.error("Error stack:", error.stack); + + return NextResponse.json( + { error: error.message || "Failed to fetch company data" }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/src/app/api/engagement/route.ts b/src/app/api/engagement/route.ts new file mode 100644 index 00000000..a614250e --- /dev/null +++ b/src/app/api/engagement/route.ts @@ -0,0 +1,64 @@ +import { NextResponse } from "next/server"; +import supabaseServer from "@/app/lib/supabase-server"; + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const id = searchParams.get('id'); + + if (!id) { + return NextResponse.json( + { error: "Engagement ID is required" }, + { status: 400 } + ); + } + + try { + console.log(`Fetching engagement data for ID: ${id} using service role key...`); + + // Get engagement data + const { data: engagement, error: engagementError } = await supabaseServer + .from('support_engagements') + .select('*') + .eq('id', id) + .single(); + + if (engagementError) { + console.error("Error fetching engagement:", engagementError); + throw engagementError; + } + + if (!engagement) { + return NextResponse.json( + { error: "Engagement not found" }, + { status: 404 } + ); + } + + console.log(`Successfully fetched engagement data for ID: ${id}`); + + // Get company data + const { data: company, error: companyError } = await supabaseServer + .from('companies') + .select('*') + .eq('id', engagement.company_id) + .single(); + + if (companyError) { + console.error("Error fetching company:", companyError); + throw companyError; + } + + console.log(`Successfully fetched company data with ID: ${engagement.company_id}`); + + return NextResponse.json({ + engagement, + company + }); + } catch (error) { + console.error("Error fetching data:", error); + return NextResponse.json( + { error: "Failed to fetch data" }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/src/app/api/engagements/[id]/people/route.ts b/src/app/api/engagements/[id]/people/route.ts new file mode 100644 index 00000000..cef103c8 --- /dev/null +++ b/src/app/api/engagements/[id]/people/route.ts @@ -0,0 +1,95 @@ +import { NextResponse } from "next/server"; +import supabaseServer from "@/app/lib/supabase-server"; + +export async function GET( + request: Request, + { params }: { params: { id: string } } +) { + try { + const engagementId = params.id; + + if (!engagementId) { + return NextResponse.json( + { error: "Engagement ID is required" }, + { status: 400 } + ); + } + + console.log(`Fetching support personnel for engagement ${engagementId} using service role key...`); + + // First get the engagement details to get the company_id + const { data: engagement, error: engagementError } = await supabaseServer + .from('support_engagements') + .select('company_id') + .eq('id', engagementId) + .single(); + + if (engagementError) { + console.error(`Error fetching engagement details:`, engagementError); + throw engagementError; + } + + if (!engagement) { + return NextResponse.json( + { error: "Engagement not found" }, + { status: 404 } + ); + } + + const companyId = engagement.company_id; + console.log(`Found engagement with company_id: ${companyId}`); + + // Now get all contacts associated with this company + const { data: peopleCompanies, error: pcError } = await supabaseServer + .from('people_companies') + .select('person_id') + .eq('company_id', companyId); + + if (pcError) { + console.error(`Error fetching people_companies:`, pcError); + throw pcError; + } + + // Extract the person IDs + const personIds = peopleCompanies?.map(pc => pc.person_id) || []; + + if (personIds.length === 0) { + console.log(`No contacts found for company ${companyId}`); + return NextResponse.json([]); + } + + console.log(`Found ${personIds.length} person IDs for company ${companyId}`); + + // Get contact details for these people + const { data: contacts, error } = await supabaseServer + .from('people') + .select(` + id, + first_name, + last_name, + title, + photo + `) + .eq('is_active', true) + .in('id', personIds) + .order('first_name', { ascending: true }); + + if (error) { + console.error(`Error fetching contacts:`, error); + throw error; + } + + console.log(`Successfully fetched ${contacts?.length || 0} contacts for company ${companyId} and engagement ${engagementId}`); + + return NextResponse.json(contacts || []); + } catch (error: any) { + console.error("Error fetching contacts:", error); + console.error("Error message:", error.message); + console.error("Error stack:", error.stack); + + return NextResponse.json( + { error: error.message || "Failed to fetch contacts" }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/src/app/api/interviews/create/route.ts b/src/app/api/interviews/create/route.ts new file mode 100644 index 00000000..223130f7 --- /dev/null +++ b/src/app/api/interviews/create/route.ts @@ -0,0 +1,97 @@ +import { NextResponse } from "next/server"; +import supabaseServer from "@/app/lib/supabase-server"; +import { v4 as uuidv4 } from "uuid"; + +export async function POST(request: Request) { + try { + const body = await request.json(); + const { adminNotes, questions, companyId, personId, engagementId } = body; + + if (!adminNotes) { + return NextResponse.json( + { error: "Interview name/admin notes are required" }, + { status: 400 } + ); + } + + if (!questions || !questions.length || questions.some((q: any) => !q.text)) { + return NextResponse.json( + { error: "At least one question with text is required" }, + { status: 400 } + ); + } + + console.log("Creating new interview using service role key..."); + + // Create a new interview + const interviewId = uuidv4(); + const inviteToken = uuidv4(); + + const interviewData: any = { + id: interviewId, + invite_token: inviteToken, + status: 'pending', + admin_notes: adminNotes + }; + + // Add company, person, and engagement if provided + if (companyId) { + interviewData.company_id = companyId; + } + + if (personId) { + interviewData.person_id = personId; + } + + if (engagementId) { + interviewData.support_engagement_id = engagementId; + } + + const { error: interviewError } = await supabaseServer + .from('interviews') + .insert(interviewData); + + if (interviewError) { + console.error("Error creating interview:", interviewError); + throw interviewError; + } + + console.log(`Successfully created interview with ID: ${interviewId}`); + + // Create questions + const questionsToInsert = questions.map((q: any) => ({ + id: uuidv4(), + interview_id: interviewId, + ordinal: q.ordinal, + text: q.text, + context: q.context || null + })); + + console.log(`Creating ${questionsToInsert.length} questions for interview ${interviewId}`); + + const { error: questionsError } = await supabaseServer + .from('questions') + .insert(questionsToInsert); + + if (questionsError) { + console.error("Error creating questions:", questionsError); + throw questionsError; + } + + console.log(`Successfully created ${questionsToInsert.length} questions`); + + return NextResponse.json({ + id: interviewId, + invite_token: inviteToken, + company_id: companyId, + person_id: personId, + support_engagement_id: engagementId + }); + } catch (error) { + console.error("Error creating interview:", error); + return NextResponse.json( + { error: "Failed to create interview" }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/src/app/api/interviews/route.ts b/src/app/api/interviews/route.ts new file mode 100644 index 00000000..1d39f9fa --- /dev/null +++ b/src/app/api/interviews/route.ts @@ -0,0 +1,57 @@ +import { NextResponse } from "next/server"; +import supabaseServer from "@/app/lib/supabase-server"; + +export async function GET(request: Request) { + try { + console.log("Fetching interviews from Supabase using service role key..."); + + // Get all interviews + const { data: interviews, error: interviewsError } = await supabaseServer + .from('interviews') + .select('*') + .order('created_at', { ascending: false }); + + if (interviewsError) { + console.error("Error fetching interviews:", interviewsError); + throw interviewsError; + } + + console.log(`Successfully fetched ${interviews?.length || 0} interviews`); + + // Get all questions (we'll organize them by interview_id) + const { data: questions, error: questionsError } = await supabaseServer + .from('questions') + .select('*') + .order('ordinal', { ascending: true }); + + if (questionsError) { + console.error("Error fetching questions:", questionsError); + throw questionsError; + } + + console.log(`Successfully fetched ${questions?.length || 0} questions`); + + // Group questions by interview_id + const questionsByInterview = questions.reduce((acc, question) => { + if (!acc[question.interview_id]) { + acc[question.interview_id] = []; + } + acc[question.interview_id].push(question); + return acc; + }, {}); + + // Enrich interviews with their questions + const enrichedInterviews = interviews.map(interview => ({ + ...interview, + questions: questionsByInterview[interview.id] || [] + })); + + return NextResponse.json(enrichedInterviews); + } catch (error) { + console.error("Error fetching interview data:", error); + return NextResponse.json( + { error: "Failed to fetch interview data" }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/src/app/app/page.tsx b/src/app/app/page.tsx new file mode 100644 index 00000000..376a26aa --- /dev/null +++ b/src/app/app/page.tsx @@ -0,0 +1,13 @@ +import { TranscriptProvider } from "@/app/contexts/TranscriptContext"; +import { EventProvider } from "@/app/contexts/EventContext"; +import App from "../App"; + +export default function AppPage() { + return ( + + + + + + ); +} \ No newline at end of file diff --git a/src/app/components/CompanySelector.tsx b/src/app/components/CompanySelector.tsx new file mode 100644 index 00000000..719171f0 --- /dev/null +++ b/src/app/components/CompanySelector.tsx @@ -0,0 +1,277 @@ +"use client"; + +import { useEffect, useState, useRef, useCallback } from "react"; +import { Company } from "../lib/types"; + +interface CompanySelectorProps { + onCompanySelected: (company: Company | null) => void; + selectedCompanyId: string | null; +} + +export default function CompanySelector({ + onCompanySelected, + selectedCompanyId +}: CompanySelectorProps) { + const [companies, setCompanies] = useState([]); + const [filteredCompanies, setFilteredCompanies] = useState([]); + const [searchQuery, setSearchQuery] = useState(""); + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [activeIndex, setActiveIndex] = useState(-1); + + const dropdownRef = useRef(null); + const inputRef = useRef(null); + const debounceTimerRef = useRef(null); + + useEffect(() => { + const fetchCompanies = async () => { + try { + setLoading(true); + console.log("Fetching companies from API..."); + const response = await fetch("/api/companies"); + + if (!response.ok) { + throw new Error(`Error: ${response.status}`); + } + + const data = await response.json(); + console.log(`Received ${data?.length || 0} companies from API`); + console.log("Sample data:", data?.slice(0, 3)); + + setCompanies(data); + setFilteredCompanies(data); + setLoading(false); + } catch (err: any) { + console.error("Failed to fetch companies:", err); + setError(err.message || "Failed to load companies"); + setLoading(false); + } + }; + + fetchCompanies(); + }, []); + + // Debounced search function + const debouncedSearch = useCallback((query: string) => { + // Clear any existing debounce timer + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + + // Set a new debounce timer + debounceTimerRef.current = setTimeout(() => { + if (query.trim() === "") { + setFilteredCompanies(companies); + } else { + const searchTerm = query.toLowerCase(); + const filtered = companies.filter(company => + company.business_name.toLowerCase().includes(searchTerm) + ); + setFilteredCompanies(filtered); + } + setActiveIndex(-1); + }, 300); // 300ms debounce + }, [companies]); + + // Update search when query changes + useEffect(() => { + debouncedSearch(searchQuery); + + // Cleanup timer on unmount + return () => { + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + }; + }, [searchQuery, debouncedSearch]); + + // Close dropdown when clicking outside + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setIsDropdownOpen(false); + } + } + + document.addEventListener("mousedown", handleClickOutside); + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, []); + + const handleCompanySelect = (company: Company | null) => { + onCompanySelected(company); + setSearchQuery(""); + setIsDropdownOpen(false); + setActiveIndex(-1); + }; + + const handleInputChange = (e: React.ChangeEvent) => { + setSearchQuery(e.target.value); + setIsDropdownOpen(true); + }; + + const handleInputFocus = () => { + setIsDropdownOpen(true); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + // Don't handle keyboard navigation when dropdown is closed + if (!isDropdownOpen) return; + + switch (e.key) { + case "ArrowDown": + e.preventDefault(); + setActiveIndex(prev => + prev < filteredCompanies.length - 1 ? prev + 1 : prev + ); + break; + case "ArrowUp": + e.preventDefault(); + setActiveIndex(prev => (prev > 0 ? prev - 1 : prev)); + break; + case "Enter": + e.preventDefault(); + if (activeIndex >= 0 && activeIndex < filteredCompanies.length) { + handleCompanySelect(filteredCompanies[activeIndex]); + } + break; + case "Escape": + e.preventDefault(); + setIsDropdownOpen(false); + inputRef.current?.blur(); + break; + default: + break; + } + }; + + if (loading) { + return ( +
Loading companies...
+ ); + } + + if (error) { + return ( +
Error: {error}
+ ); + } + + // Find the selected company + const selectedCompany = selectedCompanyId + ? companies.find(c => c.id === selectedCompanyId) + : null; + + return ( +
+ {selectedCompany ? ( +
+
+ {selectedCompany.logo && ( + {selectedCompany.business_name} + )} +
+

{selectedCompany.business_name}

+ {selectedCompany.website && ( +

{selectedCompany.website}

+ )} +
+
+ +
+ ) : ( + <> +
+ + {searchQuery && ( + + )} +
+ + {isDropdownOpen && ( +
+ {filteredCompanies.length > 0 ? ( + filteredCompanies.map((company, index) => ( +
handleCompanySelect(company)} + onMouseEnter={() => setActiveIndex(index)} + className={`flex items-center gap-3 p-3 border-b border-gray-100 hover:bg-gray-50 cursor-pointer ${ + activeIndex === index ? 'bg-blue-50' : '' + }`} + role="option" + aria-selected={activeIndex === index} + tabIndex={-1} + > + {company.logo ? ( + + ) : ( +
+ {company.business_name.charAt(0)} +
+ )} +
+

{company.business_name}

+ {company.website && ( +

{company.website}

+ )} +
+
+ )) + ) : ( +
+ {searchQuery ? "No companies found matching your search" : "No companies available"} +
+ )} +
+ )} + + )} +
+ ); +} \ No newline at end of file diff --git a/src/app/components/ContactSelector.tsx b/src/app/components/ContactSelector.tsx new file mode 100644 index 00000000..d4a406d1 --- /dev/null +++ b/src/app/components/ContactSelector.tsx @@ -0,0 +1,307 @@ +"use client"; + +import { useEffect, useState, useRef, useCallback } from "react"; +import { Person } from "../lib/types"; + +interface ContactSelectorProps { + companyId: string | null; + onContactSelected: (contact: Person | null) => void; + selectedContactId: string | null; +} + +export default function ContactSelector({ + companyId, + onContactSelected, + selectedContactId +}: ContactSelectorProps) { + const [contacts, setContacts] = useState([]); + const [filteredContacts, setFilteredContacts] = useState([]); + const [searchQuery, setSearchQuery] = useState(""); + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [activeIndex, setActiveIndex] = useState(-1); + + const dropdownRef = useRef(null); + const inputRef = useRef(null); + const debounceTimerRef = useRef(null); + + useEffect(() => { + const fetchContacts = async () => { + if (!companyId) { + setContacts([]); + setFilteredContacts([]); + return; + } + + try { + setLoading(true); + setError(null); + + const response = await fetch(`/api/companies/${companyId}/contacts`); + + if (!response.ok) { + throw new Error(`Error: ${response.status}`); + } + + const data = await response.json(); + setContacts(data); + setFilteredContacts(data); + + // If previously selected contact is not in the new contacts list, clear selection + if (selectedContactId && !data.some((contact: Person) => contact.id === selectedContactId)) { + onContactSelected(null); + } + + setLoading(false); + } catch (err: any) { + console.error("Failed to fetch contacts:", err); + setError(err.message || "Failed to load contacts"); + setLoading(false); + } + }; + + fetchContacts(); + }, [companyId, selectedContactId, onContactSelected]); + + // Debounced search function + const debouncedSearch = useCallback((query: string) => { + // Clear any existing debounce timer + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + + // Set a new debounce timer + debounceTimerRef.current = setTimeout(() => { + if (query.trim() === "") { + setFilteredContacts(contacts); + } else { + const searchTerm = query.toLowerCase(); + const filtered = contacts.filter(contact => + `${contact.first_name} ${contact.last_name}`.toLowerCase().includes(searchTerm) + ); + setFilteredContacts(filtered); + } + setActiveIndex(-1); + }, 300); // 300ms debounce + }, [contacts]); + + // Update search when query changes + useEffect(() => { + debouncedSearch(searchQuery); + + // Cleanup timer on unmount + return () => { + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + }; + }, [searchQuery, debouncedSearch]); + + // Close dropdown when clicking outside + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setIsDropdownOpen(false); + } + } + + document.addEventListener("mousedown", handleClickOutside); + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, []); + + const handleContactSelect = (contact: Person | null) => { + onContactSelected(contact); + setSearchQuery(""); + setIsDropdownOpen(false); + setActiveIndex(-1); + }; + + const handleInputChange = (e: React.ChangeEvent) => { + setSearchQuery(e.target.value); + setIsDropdownOpen(true); + }; + + const handleInputFocus = () => { + setIsDropdownOpen(true); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + // Don't handle keyboard navigation when dropdown is closed + if (!isDropdownOpen) return; + + switch (e.key) { + case "ArrowDown": + e.preventDefault(); + setActiveIndex(prev => + prev < filteredContacts.length - 1 ? prev + 1 : prev + ); + break; + case "ArrowUp": + e.preventDefault(); + setActiveIndex(prev => (prev > 0 ? prev - 1 : prev)); + break; + case "Enter": + e.preventDefault(); + if (activeIndex >= 0 && activeIndex < filteredContacts.length) { + handleContactSelect(filteredContacts[activeIndex]); + } + break; + case "Escape": + e.preventDefault(); + setIsDropdownOpen(false); + inputRef.current?.blur(); + break; + default: + break; + } + }; + + if (!companyId) { + return ( +
+ Please select a company first to view available contacts. +
+ ); + } + + if (loading) { + return ( +
Loading contacts...
+ ); + } + + if (error) { + return ( +
Error: {error}
+ ); + } + + // Find the selected contact + const selectedContact = selectedContactId + ? contacts.find(c => c.id === selectedContactId) + : null; + + return ( +
+ {selectedContact ? ( +
+
+ {selectedContact.photo ? ( + {`${selectedContact.first_name} + ) : ( +
+ {selectedContact.first_name.charAt(0)} +
+ )} +
+

+ {selectedContact.first_name} {selectedContact.last_name} +

+ {selectedContact.title && ( +

{selectedContact.title}

+ )} +
+
+ +
+ ) : ( + <> +
+ + {searchQuery && ( + + )} +
+ + {isDropdownOpen && ( +
+ {filteredContacts.length > 0 ? ( + filteredContacts.map((contact, index) => ( +
handleContactSelect(contact)} + onMouseEnter={() => setActiveIndex(index)} + className={`flex items-center gap-3 p-3 border-b border-gray-100 hover:bg-gray-50 cursor-pointer ${ + activeIndex === index ? 'bg-green-50' : '' + }`} + role="option" + aria-selected={activeIndex === index} + tabIndex={-1} + > + {contact.photo ? ( + + ) : ( +
+ {contact.first_name.charAt(0)} +
+ )} +
+

+ {contact.first_name} {contact.last_name} +

+ {contact.title && ( +

{contact.title}

+ )} +
+
+ )) + ) : ( +
+ {searchQuery + ? "No contacts found matching your search" + : "No contacts available for this company"} +
+ )} +
+ )} + + )} +
+ ); +} \ No newline at end of file diff --git a/src/app/components/EngagementSelector.tsx b/src/app/components/EngagementSelector.tsx new file mode 100644 index 00000000..c7ee5fbb --- /dev/null +++ b/src/app/components/EngagementSelector.tsx @@ -0,0 +1,331 @@ +"use client"; + +import { useEffect, useState, useRef, useCallback } from "react"; +import { SupportEngagement } from "../lib/types"; + +interface EngagementSelectorProps { + companyId: string | null; + onEngagementSelected: (engagement: SupportEngagement | null) => void; + selectedEngagementId: string | null; +} + +// Helper function to strip HTML tags from text +const stripHtmlTags = (html: string | null | undefined): string => { + if (!html) return ""; + + // First replace common HTML tags with appropriate spacing + const withSpaces = html + .replace(/<\/p>/g, ' ') + .replace(//g, ' ') + .replace(/<\/div>/g, ' ') + .replace(/<\/li>/g, ' '); + + // Then remove all remaining HTML tags + return withSpaces + .replace(/<[^>]*>/g, '') + .replace(/\s+/g, ' ') // Replace multiple spaces with a single space + .trim(); +}; + +export default function EngagementSelector({ + companyId, + onEngagementSelected, + selectedEngagementId +}: EngagementSelectorProps) { + const [engagements, setEngagements] = useState([]); + const [filteredEngagements, setFilteredEngagements] = useState([]); + const [searchQuery, setSearchQuery] = useState(""); + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [activeIndex, setActiveIndex] = useState(-1); + + const dropdownRef = useRef(null); + const inputRef = useRef(null); + const debounceTimerRef = useRef(null); + + useEffect(() => { + const fetchEngagements = async () => { + if (!companyId) { + setEngagements([]); + setFilteredEngagements([]); + return; + } + + try { + setLoading(true); + setError(null); + console.log("Fetching engagements from API..."); + + const response = await fetch(`/api/companies/${companyId}/engagements`); + + if (!response.ok) { + throw new Error(`Error: ${response.status}`); + } + + const data = await response.json(); + console.log(`Received ${data?.length || 0} engagements from API`); + console.log("Sample data:", data?.slice(0, 3)); + + setEngagements(data); + setFilteredEngagements(data); + + // If previously selected engagement is not in the new engagements list, clear selection + if (selectedEngagementId && !data.some((engagement: SupportEngagement) => engagement.id === selectedEngagementId)) { + onEngagementSelected(null); + } + + setLoading(false); + } catch (err: any) { + console.error("Failed to fetch engagements:", err); + setError(err.message || "Failed to load engagements"); + setLoading(false); + } + }; + + fetchEngagements(); + }, [companyId, selectedEngagementId, onEngagementSelected]); + + // Debounced search function + const debouncedSearch = useCallback((query: string) => { + // Clear any existing debounce timer + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + + // Set a new debounce timer + debounceTimerRef.current = setTimeout(() => { + if (query.trim() === "") { + setFilteredEngagements(engagements); + } else { + const searchTerm = query.toLowerCase(); + const filtered = engagements.filter(engagement => { + const descriptionText = stripHtmlTags(engagement.description).toLowerCase(); + return engagement.title.toLowerCase().includes(searchTerm) || + descriptionText.includes(searchTerm); + }); + setFilteredEngagements(filtered); + } + setActiveIndex(-1); + }, 300); // 300ms debounce + }, [engagements]); + + // Update search when query changes + useEffect(() => { + debouncedSearch(searchQuery); + + // Cleanup timer on unmount + return () => { + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + }; + }, [searchQuery, debouncedSearch]); + + // Close dropdown when clicking outside + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setIsDropdownOpen(false); + } + } + + document.addEventListener("mousedown", handleClickOutside); + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, []); + + const handleEngagementSelect = (engagement: SupportEngagement | null) => { + onEngagementSelected(engagement); + setSearchQuery(""); + setIsDropdownOpen(false); + setActiveIndex(-1); + }; + + const handleInputChange = (e: React.ChangeEvent) => { + setSearchQuery(e.target.value); + setIsDropdownOpen(true); + }; + + const handleInputFocus = () => { + setIsDropdownOpen(true); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + // Don't handle keyboard navigation when dropdown is closed + if (!isDropdownOpen) return; + + switch (e.key) { + case "ArrowDown": + e.preventDefault(); + setActiveIndex(prev => + prev < filteredEngagements.length - 1 ? prev + 1 : prev + ); + break; + case "ArrowUp": + e.preventDefault(); + setActiveIndex(prev => (prev > 0 ? prev - 1 : prev)); + break; + case "Enter": + e.preventDefault(); + if (activeIndex >= 0 && activeIndex < filteredEngagements.length) { + handleEngagementSelect(filteredEngagements[activeIndex]); + } + break; + case "Escape": + e.preventDefault(); + setIsDropdownOpen(false); + inputRef.current?.blur(); + break; + default: + break; + } + }; + + if (!companyId) { + return ( +
+ Please select a company first to view support engagements. +
+ ); + } + + if (loading) { + return ( +
Loading support engagements...
+ ); + } + + if (error) { + return ( +
Error: {error}
+ ); + } + + // Find the selected engagement + const selectedEngagement = selectedEngagementId + ? engagements.find(e => e.id === selectedEngagementId) + : null; + + return ( +
+ {selectedEngagement ? ( +
+
+

{selectedEngagement.title}

+ {selectedEngagement.description && ( +

+ {stripHtmlTags(selectedEngagement.description)} +

+ )} +
+ + {selectedEngagement.status} + + + {new Date(selectedEngagement.created_at).toLocaleDateString()} + +
+
+ +
+ ) : ( + <> +
+ + {searchQuery && ( + + )} +
+ + {isDropdownOpen && ( +
+ {filteredEngagements.length > 0 ? ( + filteredEngagements.map((engagement, index) => ( +
handleEngagementSelect(engagement)} + onMouseEnter={() => setActiveIndex(index)} + className={`flex flex-col gap-1 p-3 border-b border-gray-100 hover:bg-gray-50 cursor-pointer ${ + activeIndex === index ? 'bg-purple-50' : '' + }`} + role="option" + aria-selected={activeIndex === index} + tabIndex={-1} + > +
+

{engagement.title}

+ + {new Date(engagement.created_at).toLocaleDateString()} + +
+ {engagement.description && ( +

+ {stripHtmlTags(engagement.description)} +

+ )} +
+ + {engagement.status} + +
+
+ )) + ) : ( +
+ {searchQuery + ? "No support engagements found matching your search" + : "No support engagements available for this company"} +
+ )} +
+ )} + + )} +
+ ); +} \ No newline at end of file diff --git a/src/app/components/InterviewList.tsx b/src/app/components/InterviewList.tsx new file mode 100644 index 00000000..fee88a44 --- /dev/null +++ b/src/app/components/InterviewList.tsx @@ -0,0 +1,128 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Interview } from "../lib/types"; +import Link from "next/link"; + +export default function InterviewList() { + const [interviews, setInterviews] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchInterviews = async () => { + try { + setLoading(true); + const response = await fetch("/api/interviews"); + + if (!response.ok) { + throw new Error(`Error: ${response.status}`); + } + + const data = await response.json(); + setInterviews(data); + setLoading(false); + } catch (err: any) { + console.error("Failed to fetch interviews:", err); + setError(err.message || "Failed to load interviews"); + setLoading(false); + } + }; + + fetchInterviews(); + }, []); + + if (loading) { + return ( +
+
Loading interviews...
+
+ ); + } + + if (error) { + return ( +
+

Error loading interviews

+

{error}

+
+ ); + } + + if (interviews.length === 0) { + return ( +
+

No interviews found. Create an interview to get started.

+ + Create New Interview + +
+ ); + } + + return ( +
+

Interviews

+
+ {interviews.map((interview) => ( +
+
+
+

{interview.admin_notes || "Untitled Interview"}

+

+ Created: {new Date(interview.created_at).toLocaleDateString()} +

+

+ + {interview.status === "completed" ? "Completed" : "Pending"} + +

+
+
+ + View + + {interview.status !== "completed" && ( + + Conduct + + )} +
+
+ + {interview.questions && interview.questions.length > 0 && ( +
+

Questions:

+
    + {interview.questions.slice(0, 3).map((question) => ( +
  • + {question.text} +
  • + ))} + {interview.questions.length > 3 && ( +
  • + +{interview.questions.length - 3} more questions +
  • + )} +
+
+ )} +
+ ))} +
+
+ ); +} \ No newline at end of file diff --git a/src/app/components/MainNav.tsx b/src/app/components/MainNav.tsx new file mode 100644 index 00000000..67786948 --- /dev/null +++ b/src/app/components/MainNav.tsx @@ -0,0 +1,45 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; + +export default function MainNav() { + const pathname = usePathname(); + + const navItems = [ + { label: "Home", href: "/" }, + { label: "Interviews", href: "/interviews" }, + ]; + + return ( + + ); +} \ No newline at end of file diff --git a/src/app/components/SupportPersonSelector.tsx b/src/app/components/SupportPersonSelector.tsx new file mode 100644 index 00000000..b885adb6 --- /dev/null +++ b/src/app/components/SupportPersonSelector.tsx @@ -0,0 +1,312 @@ +"use client"; + +import { useEffect, useState, useRef, useCallback } from "react"; +import { Person } from "../lib/types"; + +interface SupportPersonSelectorProps { + engagementId: string | null; + onPersonSelected: (person: Person | null) => void; + selectedPersonId: string | null; +} + +export default function SupportPersonSelector({ + engagementId, + onPersonSelected, + selectedPersonId +}: SupportPersonSelectorProps) { + const [people, setPeople] = useState([]); + const [filteredPeople, setFilteredPeople] = useState([]); + const [searchQuery, setSearchQuery] = useState(""); + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [activeIndex, setActiveIndex] = useState(-1); + + const dropdownRef = useRef(null); + const inputRef = useRef(null); + const debounceTimerRef = useRef(null); + + useEffect(() => { + const fetchSupportPeople = async () => { + if (!engagementId) { + setPeople([]); + setFilteredPeople([]); + return; + } + + try { + setLoading(true); + setError(null); + console.log("Fetching support people from API..."); + + const response = await fetch(`/api/engagements/${engagementId}/people`); + + if (!response.ok) { + throw new Error(`Error: ${response.status}`); + } + + const data = await response.json(); + console.log(`Received ${data?.length || 0} support people from API`); + console.log("Sample data:", data?.slice(0, 3)); + + setPeople(data); + setFilteredPeople(data); + + // If previously selected person is not in the new people list, clear selection + if (selectedPersonId && !data.some((person: Person) => person.id === selectedPersonId)) { + onPersonSelected(null); + } + + setLoading(false); + } catch (err: any) { + console.error("Failed to fetch support people:", err); + setError(err.message || "Failed to load support people"); + setLoading(false); + } + }; + + fetchSupportPeople(); + }, [engagementId, selectedPersonId, onPersonSelected]); + + // Debounced search function + const debouncedSearch = useCallback((query: string) => { + // Clear any existing debounce timer + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + + // Set a new debounce timer + debounceTimerRef.current = setTimeout(() => { + if (query.trim() === "") { + setFilteredPeople(people); + } else { + const searchTerm = query.toLowerCase(); + const filtered = people.filter(person => + `${person.first_name} ${person.last_name}`.toLowerCase().includes(searchTerm) || + (person.title && person.title.toLowerCase().includes(searchTerm)) + ); + setFilteredPeople(filtered); + } + setActiveIndex(-1); + }, 300); // 300ms debounce + }, [people]); + + // Update search when query changes + useEffect(() => { + debouncedSearch(searchQuery); + + // Cleanup timer on unmount + return () => { + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + }; + }, [searchQuery, debouncedSearch]); + + // Close dropdown when clicking outside + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setIsDropdownOpen(false); + } + } + + document.addEventListener("mousedown", handleClickOutside); + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, []); + + const handlePersonSelect = (person: Person | null) => { + onPersonSelected(person); + setSearchQuery(""); + setIsDropdownOpen(false); + setActiveIndex(-1); + }; + + const handleInputChange = (e: React.ChangeEvent) => { + setSearchQuery(e.target.value); + setIsDropdownOpen(true); + }; + + const handleInputFocus = () => { + setIsDropdownOpen(true); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + // Don't handle keyboard navigation when dropdown is closed + if (!isDropdownOpen) return; + + switch (e.key) { + case "ArrowDown": + e.preventDefault(); + setActiveIndex(prev => + prev < filteredPeople.length - 1 ? prev + 1 : prev + ); + break; + case "ArrowUp": + e.preventDefault(); + setActiveIndex(prev => (prev > 0 ? prev - 1 : prev)); + break; + case "Enter": + e.preventDefault(); + if (activeIndex >= 0 && activeIndex < filteredPeople.length) { + handlePersonSelect(filteredPeople[activeIndex]); + } + break; + case "Escape": + e.preventDefault(); + setIsDropdownOpen(false); + inputRef.current?.blur(); + break; + default: + break; + } + }; + + if (!engagementId) { + return ( +
+ Please select a support engagement first to view support personnel. +
+ ); + } + + if (loading) { + return ( +
Loading support personnel...
+ ); + } + + if (error) { + return ( +
Error: {error}
+ ); + } + + // Find the selected person + const selectedPerson = selectedPersonId + ? people.find(p => p.id === selectedPersonId) + : null; + + return ( +
+ {selectedPerson ? ( +
+
+ {selectedPerson.photo ? ( + {`${selectedPerson.first_name} + ) : ( +
+ {selectedPerson.first_name.charAt(0)} +
+ )} +
+

+ {selectedPerson.first_name} {selectedPerson.last_name} +

+ {selectedPerson.title && ( +

{selectedPerson.title}

+ )} +
+
+ +
+ ) : ( + <> +
+ + {searchQuery && ( + + )} +
+ + {isDropdownOpen && ( +
+ {filteredPeople.length > 0 ? ( + filteredPeople.map((person, index) => ( +
handlePersonSelect(person)} + onMouseEnter={() => setActiveIndex(index)} + className={`flex items-center gap-3 p-3 border-b border-gray-100 hover:bg-gray-50 cursor-pointer ${ + activeIndex === index ? 'bg-amber-50' : '' + }`} + role="option" + aria-selected={activeIndex === index} + tabIndex={-1} + > + {person.photo ? ( + + ) : ( +
+ {person.first_name.charAt(0)} +
+ )} +
+

+ {person.first_name} {person.last_name} +

+ {person.title && ( +

{person.title}

+ )} +
+
+ )) + ) : ( +
+ {searchQuery + ? "No support personnel found matching your search" + : "No support personnel available for this engagement"} +
+ )} +
+ )} + + )} +
+ ); +} \ No newline at end of file diff --git a/src/app/globals.css b/src/app/globals.css index 30f9f4b6..5927f687 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -3,16 +3,17 @@ @tailwind utilities; :root { - --background: #fafafa; + --background: #ffffff; --foreground: #171717; } -@media (prefers-color-scheme: dark) { +/* Removing dark mode preference */ +/* @media (prefers-color-scheme: dark) { :root { --background: #0a0a0a; --foreground: #ededed; } -} +} */ body { color: var(--foreground); diff --git a/src/app/interviews/create/page.tsx b/src/app/interviews/create/page.tsx new file mode 100644 index 00000000..98493f7b --- /dev/null +++ b/src/app/interviews/create/page.tsx @@ -0,0 +1,344 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import CompanySelector from "@/app/components/CompanySelector"; +import ContactSelector from "@/app/components/ContactSelector"; +import EngagementSelector from "@/app/components/EngagementSelector"; +import SupportPersonSelector from "@/app/components/SupportPersonSelector"; +import { Company, Person, SupportEngagement } from "@/app/lib/types"; + +export default function CreateInterviewPage() { + const router = useRouter(); + const [adminNotes, setAdminNotes] = useState(""); + const [isAdminNotesManuallyEdited, setIsAdminNotesManuallyEdited] = useState(false); + const [questions, setQuestions] = useState([ + { ordinal: 1, text: "", context: "" } + ]); + + const [selectedCompany, setSelectedCompany] = useState(null); + const [selectedEngagement, setSelectedEngagement] = useState(null); + const [selectedPerson, setSelectedPerson] = useState(null); + + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); + + // Generate interview name based on selections + useEffect(() => { + // Skip if user has manually edited the name + if (isAdminNotesManuallyEdited) return; + + // Generate name only when all three are selected + if (selectedCompany && selectedEngagement && selectedPerson) { + const engagementTitle = selectedEngagement.title || "Support Engagement"; + const companyName = selectedCompany.business_name; + const personName = `${selectedPerson.first_name} ${selectedPerson.last_name}`; + + const generatedName = `${engagementTitle} Review - ${companyName} with ${personName}`; + setAdminNotes(generatedName); + } else if (selectedCompany && selectedPerson) { + // Fallback if only company and person are selected + const companyName = selectedCompany.business_name; + const personName = `${selectedPerson.first_name} ${selectedPerson.last_name}`; + + const generatedName = `Interview - ${companyName} with ${personName}`; + setAdminNotes(generatedName); + } + }, [selectedCompany, selectedEngagement, selectedPerson, isAdminNotesManuallyEdited]); + + const handleAdminNotesChange = (e: React.ChangeEvent) => { + setAdminNotes(e.target.value); + setIsAdminNotesManuallyEdited(true); + }; + + const resetInterviewName = () => { + setIsAdminNotesManuallyEdited(false); + // This will trigger the useEffect to regenerate the name + if (selectedCompany && selectedEngagement && selectedPerson) { + const engagementTitle = selectedEngagement.title || "Support Engagement"; + const companyName = selectedCompany.business_name; + const personName = `${selectedPerson.first_name} ${selectedPerson.last_name}`; + + const generatedName = `${engagementTitle} Review - ${companyName} with ${personName}`; + setAdminNotes(generatedName); + } + }; + + const addQuestion = () => { + setQuestions([ + ...questions, + { ordinal: questions.length + 1, text: "", context: "" } + ]); + }; + + const updateQuestion = (index: number, field: string, value: string) => { + const updatedQuestions = [...questions]; + updatedQuestions[index] = { ...updatedQuestions[index], [field]: value }; + setQuestions(updatedQuestions); + }; + + const removeQuestion = (index: number) => { + if (questions.length > 1) { + const updatedQuestions = questions.filter((_, i) => i !== index); + // Recalculate ordinals + const reorderedQuestions = updatedQuestions.map((q, i) => ({ + ...q, + ordinal: i + 1 + })); + setQuestions(reorderedQuestions); + } + }; + + const handleCompanySelected = (company: Company | null) => { + setSelectedCompany(company); + // Clear downstream selections when company changes + setSelectedEngagement(null); + setSelectedPerson(null); + }; + + const handleEngagementSelected = (engagement: SupportEngagement | null) => { + setSelectedEngagement(engagement); + // Clear person selection when engagement changes + setSelectedPerson(null); + }; + + const handlePersonSelected = (person: Person | null) => { + setSelectedPerson(person); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setIsSubmitting(true); + setError(null); + + // Validate + if (!adminNotes.trim()) { + setError("Interview name/notes are required"); + setIsSubmitting(false); + return; + } + + if (questions.some(q => !q.text.trim())) { + setError("All questions must have text"); + setIsSubmitting(false); + return; + } + + try { + const response = await fetch("/api/interviews/create", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + adminNotes, + questions, + companyId: selectedCompany?.id, + personId: selectedPerson?.id, + engagementId: selectedEngagement?.id || null + }), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || "Failed to create interview"); + } + + router.push("/interviews"); + router.refresh(); + } catch (err: any) { + console.error("Error creating interview:", err); + setError(err.message || "Failed to create interview"); + setIsSubmitting(false); + } + }; + + // Check if all fields are selected for the interview name + const allSelectionsComplete = !!(selectedCompany && selectedPerson); + const allDetailedSelectionsComplete = !!(selectedCompany && selectedEngagement && selectedPerson); + + return ( +
+
+

Create New Interview

+

Set up an interview with questions to collect feedback.

+
+ + {error && ( +
+

{error}

+
+ )} + +
+
+
+ + {allSelectionsComplete && isAdminNotesManuallyEdited && ( + + )} +
+ + {allSelectionsComplete ? ( +
+ + {!isAdminNotesManuallyEdited && ( +
+ Auto-generated +
+ )} +
+ ) : ( +
+

+ Complete company, engagement, and contact selection to auto-generate name +

+
+ )} +
+ +
+

Company & Support Information

+ +
+
+ + +
+ +
+ + +
+ +
+ + {selectedEngagement ? ( + + ) : ( + + )} +
+
+
+ +
+
+

Questions

+ +
+ +
+ {questions.map((question, index) => ( +
+
+
Question {question.ordinal}
+ {questions.length > 1 && ( + + )} +
+ +
+
+ + updateQuestion(index, "text", e.target.value)} + className="w-full px-4 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500" + placeholder="e.g., How would you describe your experience with this support engagement?" + /> +
+ +
+ +