diff --git a/package-lock.json b/package-lock.json index 273177d..9b6d59e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "@untitledui/file-icons": "^0.0.8", "@untitledui/icons": "^0.0.21", "input-otp": "^1.4.2", + "jotai": "^2.18.1", "jssip": "^3.13.6", "motion": "^12.29.0", "qr-code-styling": "^1.9.2", @@ -68,7 +69,7 @@ "version": "7.29.0", "resolved": "http://localhost:4873/@babel/code-frame/-/code-frame-7.29.0.tgz", "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", @@ -110,7 +111,7 @@ "version": "7.27.1", "resolved": "http://localhost:4873/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -120,7 +121,7 @@ "version": "7.28.5", "resolved": "http://localhost:4873/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -130,7 +131,7 @@ "version": "7.29.0", "resolved": "http://localhost:4873/@babel/parser/-/parser-7.29.0.tgz", "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/types": "^7.29.0" @@ -146,7 +147,7 @@ "version": "7.28.6", "resolved": "http://localhost:4873/@babel/template/-/template-7.28.6.tgz", "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.28.6", @@ -180,7 +181,7 @@ "version": "7.29.0", "resolved": "http://localhost:4873/@babel/types/-/types-7.29.0.tgz", "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.27.1", @@ -3736,7 +3737,7 @@ "version": "19.2.14", "resolved": "http://localhost:4873/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -4143,7 +4144,7 @@ "version": "3.2.3", "resolved": "http://localhost:4873/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/debug": { @@ -4707,11 +4708,40 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jotai": { + "version": "2.18.1", + "resolved": "https://registry.npmjs.org/jotai/-/jotai-2.18.1.tgz", + "integrity": "sha512-e0NOzK+yRFwHo7DOp0DS0Ycq74KMEAObDWFGmfEL28PD9nLqBTt3/Ug7jf9ca72x0gC9LQZG9zH+0ISICmy3iA==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@babel/core": ">=7.0.0", + "@babel/template": ">=7.0.0", + "@types/react": ">=17.0.0", + "react": ">=17.0.0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "@babel/template": { + "optional": true + }, + "@types/react": { + "optional": true + }, + "react": { + "optional": true + } + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "http://localhost:4873/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/jsesc": { diff --git a/package.json b/package.json index 6319bfc..81183c2 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "@untitledui/file-icons": "^0.0.8", "@untitledui/icons": "^0.0.21", "input-otp": "^1.4.2", + "jotai": "^2.18.1", "jssip": "^3.13.6", "motion": "^12.29.0", "qr-code-styling": "^1.9.2", diff --git a/scripts/seed-lab-tests.ts b/scripts/seed-lab-tests.ts new file mode 100644 index 0000000..c3eb263 --- /dev/null +++ b/scripts/seed-lab-tests.ts @@ -0,0 +1,250 @@ +/** + * Seed lab tests and link them to health packages via packageTest junction. + * Run: cd helix-engage && npx tsx scripts/seed-lab-tests.ts + * + * Prerequisites: health packages already seeded via seed-new-entities.ts + * + * Platform field mapping: + * LabTest: testCategory→category, testDepartment→department, + * durationMinutes→durationMin, isActive→active, preparation→preparationInstructions + * PackageTest: position→order, isMandatory→mandatory + */ + +const GQL = 'http://localhost:4000/graphql'; +const SUB = 'fortytwo-dev'; +const ORIGIN = 'http://fortytwo-dev.localhost:4010'; + +let token = ''; + +async function gql(query: string, variables?: any) { + const h: Record = { 'Content-Type': 'application/json', 'X-Workspace-Subdomain': SUB }; + if (token) h['Authorization'] = `Bearer ${token}`; + const r = await fetch(GQL, { method: 'POST', headers: h, body: JSON.stringify({ query, variables }) }); + const d: any = await r.json(); + if (d.errors) { console.error('❌', d.errors[0].message); throw new Error(d.errors[0].message); } + return d.data; +} + +async function auth() { + const d1 = await gql(`mutation { getLoginTokenFromCredentials(email: "dev@fortytwo.dev", password: "tim@apple.dev", origin: "${ORIGIN}") { loginToken { token } } }`); + const lt = d1.getLoginTokenFromCredentials.loginToken.token; + const d2 = await gql(`mutation { getAuthTokensFromLoginToken(loginToken: "${lt}", origin: "${ORIGIN}") { tokens { accessOrWorkspaceAgnosticToken { token } } } }`); + token = d2.getAuthTokensFromLoginToken.tokens.accessOrWorkspaceAgnosticToken.token; +} + +async function mk(entity: string, data: any): Promise { + const cap = entity[0].toUpperCase() + entity.slice(1); + const d = await gql(`mutation($data: ${cap}CreateInput!) { create${cap}(data: $data) { id } }`, { data }); + return d[`create${cap}`].id; +} + +async function main() { + console.log('🧪 Seeding lab tests and package linkages...\n'); + await auth(); + console.log('✅ Auth OK\n'); + + // Fetch existing health package IDs + const pkgData = await gql(`{ healthPackages(first: 10) { edges { node { id packageName } } } }`); + const pkgs: Record = {}; + for (const e of pkgData.healthPackages.edges) { + pkgs[e.node.packageName] = e.node.id; + } + console.log(`📦 Found ${Object.keys(pkgs).length} packages: ${Object.keys(pkgs).join(', ')}\n`); + + // ═══════════════════════════════════════════ + // LAB TESTS + // ═══════════════════════════════════════════ + console.log('🧪 Lab Tests'); + + // Blood tests + const cbc = await mk('labTest', { name: 'CBC', testName: 'Complete Blood Count', testCode: 'CBC-001', category: 'BLOOD_TEST', department: 'PATHOLOGY', price: { amountMicros: 350_000_000, currencyCode: 'INR' }, durationMin: 15, sampleType: 'Blood (EDTA tube)', preparationInstructions: 'No fasting required', active: true }); + console.log(' CBC'); + + const lipid = await mk('labTest', { name: 'Lipid Profile', testName: 'Lipid Profile', testCode: 'LIP-001', category: 'BLOOD_TEST', department: 'PATHOLOGY', price: { amountMicros: 500_000_000, currencyCode: 'INR' }, durationMin: 15, sampleType: 'Blood (serum)', preparationInstructions: '12-hour fasting required', active: true }); + console.log(' Lipid Profile'); + + const lft = await mk('labTest', { name: 'Liver Function Test', testName: 'Liver Function Test', testCode: 'LFT-001', category: 'BLOOD_TEST', department: 'PATHOLOGY', price: { amountMicros: 450_000_000, currencyCode: 'INR' }, durationMin: 15, sampleType: 'Blood (serum)', preparationInstructions: '12-hour fasting recommended', active: true }); + console.log(' LFT'); + + const kft = await mk('labTest', { name: 'Kidney Function Test', testName: 'Kidney Function Test', testCode: 'KFT-001', category: 'BLOOD_TEST', department: 'PATHOLOGY', price: { amountMicros: 450_000_000, currencyCode: 'INR' }, durationMin: 15, sampleType: 'Blood (serum)', preparationInstructions: 'No fasting required', active: true }); + console.log(' KFT'); + + const thyroid = await mk('labTest', { name: 'Thyroid Panel', testName: 'Thyroid Profile (T3, T4, TSH)', testCode: 'THY-001', category: 'HORMONE', department: 'PATHOLOGY', price: { amountMicros: 600_000_000, currencyCode: 'INR' }, durationMin: 15, sampleType: 'Blood (serum)', preparationInstructions: 'No fasting required. Collect sample before thyroid medication.', active: true }); + console.log(' Thyroid Panel'); + + const bloodSugar = await mk('labTest', { name: 'Blood Sugar (Fasting + PP)', testName: 'Blood Sugar Fasting & Post-Prandial', testCode: 'GLU-001', category: 'BLOOD_TEST', department: 'PATHOLOGY', price: { amountMicros: 200_000_000, currencyCode: 'INR' }, durationMin: 10, sampleType: 'Blood (fluoride tube)', preparationInstructions: '10-12 hour fasting for FBS; 2h after meal for PP', active: true }); + console.log(' Blood Sugar'); + + const hba1c = await mk('labTest', { name: 'HbA1c', testName: 'Glycated Hemoglobin', testCode: 'HBA-001', category: 'BLOOD_TEST', department: 'PATHOLOGY', price: { amountMicros: 400_000_000, currencyCode: 'INR' }, durationMin: 15, sampleType: 'Blood (EDTA tube)', preparationInstructions: 'No fasting required', active: true }); + console.log(' HbA1c'); + + const vitD = await mk('labTest', { name: 'Vitamin D', testName: 'Vitamin D (25-Hydroxy)', testCode: 'VIT-D01', category: 'BLOOD_TEST', department: 'PATHOLOGY', price: { amountMicros: 800_000_000, currencyCode: 'INR' }, durationMin: 15, sampleType: 'Blood (serum)', preparationInstructions: 'No fasting required', active: true }); + console.log(' Vitamin D'); + + const calcium = await mk('labTest', { name: 'Calcium', testName: 'Serum Calcium', testCode: 'CAL-001', category: 'BLOOD_TEST', department: 'PATHOLOGY', price: { amountMicros: 200_000_000, currencyCode: 'INR' }, durationMin: 10, sampleType: 'Blood (serum)', preparationInstructions: 'No fasting required', active: true }); + console.log(' Calcium'); + + const uricAcid = await mk('labTest', { name: 'Uric Acid', testName: 'Serum Uric Acid', testCode: 'URA-001', category: 'BLOOD_TEST', department: 'PATHOLOGY', price: { amountMicros: 200_000_000, currencyCode: 'INR' }, durationMin: 10, sampleType: 'Blood (serum)', preparationInstructions: 'No fasting required', active: true }); + console.log(' Uric Acid'); + + const raFactor = await mk('labTest', { name: 'RA Factor', testName: 'Rheumatoid Factor', testCode: 'RAF-001', category: 'BLOOD_TEST', department: 'PATHOLOGY', price: { amountMicros: 500_000_000, currencyCode: 'INR' }, durationMin: 15, sampleType: 'Blood (serum)', preparationInstructions: 'No fasting required', active: true }); + console.log(' RA Factor'); + + const hsCrp = await mk('labTest', { name: 'hs-CRP', testName: 'High-Sensitivity C-Reactive Protein', testCode: 'CRP-001', category: 'BLOOD_TEST', department: 'PATHOLOGY', price: { amountMicros: 600_000_000, currencyCode: 'INR' }, durationMin: 15, sampleType: 'Blood (serum)', preparationInstructions: 'No fasting required', active: true }); + console.log(' hs-CRP'); + + const homocysteine = await mk('labTest', { name: 'Homocysteine', testName: 'Serum Homocysteine', testCode: 'HCY-001', category: 'BLOOD_TEST', department: 'PATHOLOGY', price: { amountMicros: 700_000_000, currencyCode: 'INR' }, durationMin: 15, sampleType: 'Blood (serum)', preparationInstructions: '12-hour fasting recommended', active: true }); + console.log(' Homocysteine'); + + await auth(); + + // Hormone panel + const amh = await mk('labTest', { name: 'AMH', testName: 'Anti-Müllerian Hormone', testCode: 'AMH-001', category: 'HORMONE', department: 'GYNECOLOGY', price: { amountMicros: 1_500_000_000, currencyCode: 'INR' }, durationMin: 15, sampleType: 'Blood (serum)', preparationInstructions: 'No fasting. Can be done on any day of cycle.', active: true }); + console.log(' AMH'); + + const fsh = await mk('labTest', { name: 'FSH', testName: 'Follicle-Stimulating Hormone', testCode: 'FSH-001', category: 'HORMONE', department: 'GYNECOLOGY', price: { amountMicros: 500_000_000, currencyCode: 'INR' }, durationMin: 15, sampleType: 'Blood (serum)', preparationInstructions: 'Day 2-3 of menstrual cycle preferred', active: true }); + console.log(' FSH'); + + const lh = await mk('labTest', { name: 'LH', testName: 'Luteinizing Hormone', testCode: 'LH-001', category: 'HORMONE', department: 'GYNECOLOGY', price: { amountMicros: 500_000_000, currencyCode: 'INR' }, durationMin: 15, sampleType: 'Blood (serum)', preparationInstructions: 'Day 2-3 of menstrual cycle preferred', active: true }); + console.log(' LH'); + + const estradiol = await mk('labTest', { name: 'Estradiol', testName: 'Estradiol (E2)', testCode: 'EST-001', category: 'HORMONE', department: 'GYNECOLOGY', price: { amountMicros: 600_000_000, currencyCode: 'INR' }, durationMin: 15, sampleType: 'Blood (serum)', preparationInstructions: 'Day 2-3 of menstrual cycle preferred', active: true }); + console.log(' Estradiol'); + + const prolactin = await mk('labTest', { name: 'Prolactin', testName: 'Serum Prolactin', testCode: 'PRL-001', category: 'HORMONE', department: 'GYNECOLOGY', price: { amountMicros: 500_000_000, currencyCode: 'INR' }, durationMin: 15, sampleType: 'Blood (serum)', preparationInstructions: 'Fasting. Morning sample preferred.', active: true }); + console.log(' Prolactin'); + + // Imaging + const ecg = await mk('labTest', { name: 'ECG', testName: 'Electrocardiogram (12-lead)', testCode: 'ECG-001', category: 'CARDIAC', department: 'CARDIOLOGY', price: { amountMicros: 300_000_000, currencyCode: 'INR' }, durationMin: 15, preparationInstructions: 'No preparation needed', active: true }); + console.log(' ECG'); + + const echo = await mk('labTest', { name: '2D Echocardiogram', testName: '2D Echocardiogram with Color Doppler', testCode: 'ECHO-001', category: 'CARDIAC', department: 'CARDIOLOGY', price: { amountMicros: 2_000_000_000, currencyCode: 'INR' }, durationMin: 30, preparationInstructions: 'No preparation needed', active: true }); + console.log(' 2D Echo'); + + const tmt = await mk('labTest', { name: 'TMT', testName: 'Treadmill Test (Stress Test)', testCode: 'TMT-001', category: 'CARDIAC', department: 'CARDIOLOGY', price: { amountMicros: 1_500_000_000, currencyCode: 'INR' }, durationMin: 45, preparationInstructions: 'Wear comfortable shoes. Light meal 2h before. Avoid caffeine.', active: true }); + console.log(' TMT'); + + const chestXray = await mk('labTest', { name: 'Chest X-ray', testName: 'Chest X-ray PA View', testCode: 'XR-001', category: 'IMAGING', department: 'RADIOLOGY', price: { amountMicros: 400_000_000, currencyCode: 'INR' }, durationMin: 10, preparationInstructions: 'Remove metal jewellery', active: true }); + console.log(' Chest X-ray'); + + const tvUltrasound = await mk('labTest', { name: 'Transvaginal Ultrasound', testName: 'Transvaginal Ultrasound', testCode: 'TVS-001', category: 'IMAGING', department: 'GYNECOLOGY', price: { amountMicros: 1_200_000_000, currencyCode: 'INR' }, durationMin: 20, preparationInstructions: 'Empty bladder preferred', active: true }); + console.log(' Transvaginal Ultrasound'); + + const pelvicUS = await mk('labTest', { name: 'Pelvic Ultrasound', testName: 'Pelvic Ultrasound', testCode: 'PUS-001', category: 'IMAGING', department: 'GYNECOLOGY', price: { amountMicros: 1_000_000_000, currencyCode: 'INR' }, durationMin: 20, preparationInstructions: 'Full bladder required — drink 4-5 glasses of water 1h before', active: true }); + console.log(' Pelvic Ultrasound'); + + const mammogram = await mk('labTest', { name: 'Mammogram', testName: 'Digital Mammogram (Bilateral)', testCode: 'MAM-001', category: 'SCREENING', department: 'RADIOLOGY', price: { amountMicros: 1_500_000_000, currencyCode: 'INR' }, durationMin: 20, preparationInstructions: 'Schedule 1 week after period. No deodorant/powder on exam day.', active: true }); + console.log(' Mammogram'); + + const boneDensity = await mk('labTest', { name: 'Bone Density Scan', testName: 'DEXA Scan (Bone Mineral Density)', testCode: 'DEXA-001', category: 'BONE_DENSITY', department: 'RADIOLOGY', price: { amountMicros: 1_800_000_000, currencyCode: 'INR' }, durationMin: 15, preparationInstructions: 'No calcium supplements 24h before', active: true }); + console.log(' Bone Density (DEXA)'); + + const jointXray = await mk('labTest', { name: 'Joint X-rays', testName: 'Joint X-rays (Knee/Hip/Spine)', testCode: 'XR-JNT01', category: 'IMAGING', department: 'ORTHOPEDICS', price: { amountMicros: 600_000_000, currencyCode: 'INR' }, durationMin: 15, preparationInstructions: 'Remove metal jewellery', active: true }); + console.log(' Joint X-rays'); + + await auth(); + + // Screening + const papSmear = await mk('labTest', { name: 'PAP Smear', testName: 'PAP Smear (Cervical Cytology)', testCode: 'PAP-001', category: 'SCREENING', department: 'GYNECOLOGY', price: { amountMicros: 800_000_000, currencyCode: 'INR' }, durationMin: 10, preparationInstructions: 'Avoid intercourse 48h before. Not during menstruation.', active: true }); + console.log(' PAP Smear'); + + const bmi = await mk('labTest', { name: 'BMI Assessment', testName: 'BMI & Body Composition', testCode: 'BMI-001', category: 'OTHER', department: 'GENERAL_MEDICINE', price: { amountMicros: 100_000_000, currencyCode: 'INR' }, durationMin: 10, preparationInstructions: 'No preparation needed', active: true }); + console.log(' BMI'); + + const vision = await mk('labTest', { name: 'Vision Test', testName: 'Visual Acuity & Eye Screening', testCode: 'VIS-001', category: 'SCREENING', department: 'OPHTHALMOLOGY', price: { amountMicros: 300_000_000, currencyCode: 'INR' }, durationMin: 15, preparationInstructions: 'Bring current glasses if any', active: true }); + console.log(' Vision Test'); + + const dental = await mk('labTest', { name: 'Dental Check', testName: 'Dental Screening & Oral Health', testCode: 'DEN-001', category: 'SCREENING', department: 'DENTAL', price: { amountMicros: 300_000_000, currencyCode: 'INR' }, durationMin: 15, preparationInstructions: 'No preparation needed', active: true }); + console.log(' Dental Check'); + + const semenAnalysis = await mk('labTest', { name: 'Semen Analysis', testName: 'Semen Analysis (Seminogram)', testCode: 'SEM-001', category: 'OTHER', department: 'PATHOLOGY', price: { amountMicros: 800_000_000, currencyCode: 'INR' }, durationMin: 30, sampleType: 'Semen', preparationInstructions: '3-5 days abstinence required. Collect at lab.', active: true }); + console.log(' Semen Analysis'); + + // Consultations + const cardioConsult = await mk('labTest', { name: 'Cardiologist Consultation', testName: 'Cardiologist Consultation', testCode: 'CON-CAR01', category: 'CONSULTATION', department: 'CARDIOLOGY', price: { amountMicros: 800_000_000, currencyCode: 'INR' }, durationMin: 30, preparationInstructions: 'Bring previous reports and medication list', active: true }); + console.log(' Cardiologist Consultation'); + + const gyneConsult = await mk('labTest', { name: 'Gynecologist Consultation', testName: 'Gynecologist Consultation', testCode: 'CON-GYN01', category: 'CONSULTATION', department: 'GYNECOLOGY', price: { amountMicros: 700_000_000, currencyCode: 'INR' }, durationMin: 30, preparationInstructions: 'Bring previous reports and menstrual history', active: true }); + console.log(' Gynecologist Consultation'); + + const orthoConsult = await mk('labTest', { name: 'Orthopedic Consultation', testName: 'Orthopedic Consultation', testCode: 'CON-ORT01', category: 'CONSULTATION', department: 'ORTHOPEDICS', price: { amountMicros: 600_000_000, currencyCode: 'INR' }, durationMin: 30, preparationInstructions: 'Bring previous X-rays and reports', active: true }); + console.log(' Orthopedic Consultation'); + + const fertilityConsult = await mk('labTest', { name: 'Fertility Specialist Consultation', testName: 'Fertility Specialist Consultation', testCode: 'CON-FER01', category: 'CONSULTATION', department: 'GYNECOLOGY', price: { amountMicros: 1_000_000_000, currencyCode: 'INR' }, durationMin: 45, preparationInstructions: 'Bring previous reports, cycle history, and partner details', active: true }); + console.log(' Fertility Specialist Consultation'); + + console.log(`\n Total: 34 lab tests created\n`); + + await auth(); + + // ═══════════════════════════════════════════ + // LINK TESTS TO PACKAGES (packageTest junction) + // ═══════════════════════════════════════════ + console.log('🔗 Linking tests to packages\n'); + + async function link(pkgName: string, tests: { id: string; pos: number; mandatory?: boolean }[]) { + const pkgId = pkgs[pkgName]; + if (!pkgId) { console.log(` ⚠️ Package "${pkgName}" not found — skipping`); return; } + for (const t of tests) { + await mk('packageTest', { + name: `${pkgName} — Test #${t.pos}`, + healthPackageId: pkgId, + labTestId: t.id, + order: t.pos, + mandatory: t.mandatory ?? true, + }); + } + console.log(` ${pkgName}: ${tests.length} tests linked`); + } + + // Master Health Checkup (11 tests) + await link('Master Health Checkup', [ + { id: cbc, pos: 1 }, { id: lipid, pos: 2 }, { id: lft, pos: 3 }, + { id: kft, pos: 4 }, { id: thyroid, pos: 5 }, { id: bloodSugar, pos: 6 }, + { id: ecg, pos: 7 }, { id: chestXray, pos: 8 }, { id: bmi, pos: 9 }, + { id: vision, pos: 10 }, { id: dental, pos: 11 }, + ]); + + await auth(); + + // Cardiac Screening (8 tests) + await link('Cardiac Screening', [ + { id: ecg, pos: 1 }, { id: echo, pos: 2 }, { id: tmt, pos: 3 }, + { id: lipid, pos: 4 }, { id: hsCrp, pos: 5 }, { id: homocysteine, pos: 6 }, + { id: hba1c, pos: 7 }, { id: cardioConsult, pos: 8 }, + ]); + + await auth(); + + // Women's Wellness (10 tests) + await link("Women's Wellness Package", [ + { id: papSmear, pos: 1 }, { id: mammogram, pos: 2 }, { id: pelvicUS, pos: 3 }, + { id: thyroid, pos: 4 }, { id: vitD, pos: 5 }, { id: calcium, pos: 6 }, + { id: cbc, pos: 7 }, { id: fsh, pos: 8 }, { id: boneDensity, pos: 9 }, + { id: gyneConsult, pos: 10 }, + ]); + + await auth(); + + // Orthopedic Assessment (7 tests) + await link('Orthopedic Assessment', [ + { id: jointXray, pos: 1 }, { id: boneDensity, pos: 2 }, { id: vitD, pos: 3 }, + { id: calcium, pos: 4 }, { id: uricAcid, pos: 5 }, { id: raFactor, pos: 6 }, + { id: orthoConsult, pos: 7 }, + ]); + + await auth(); + + // IVF Consultation (9 tests) + await link('IVF Consultation Package', [ + { id: amh, pos: 1 }, { id: fsh, pos: 2 }, { id: lh, pos: 3 }, + { id: estradiol, pos: 4 }, { id: prolactin, pos: 5 }, { id: thyroid, pos: 6 }, + { id: tvUltrasound, pos: 7 }, { id: semenAnalysis, pos: 8 }, + { id: fertilityConsult, pos: 9 }, + ]); + + console.log('\n🎉 Done!'); + console.log(' 34 lab tests · 45 package-test linkages across 5 packages'); +} + +main().catch(e => { console.error('💥', e.message); process.exit(1); }); diff --git a/scripts/seed-new-entities.ts b/scripts/seed-new-entities.ts new file mode 100644 index 0000000..9f3e34c --- /dev/null +++ b/scripts/seed-new-entities.ts @@ -0,0 +1,348 @@ +/** + * Seed clinics, health packages, insurance partners, and link doctors to clinics. + * Run: cd helix-engage && npx tsx scripts/seed-new-entities.ts + * + * Prerequisites: doctors already seeded via seed-data.ts + * + * Platform field mapping (SDK name → platform name): + * Clinic: address→addressCustom, operatingHoursWeekday→weekdayHours, + * operatingHoursSaturday→saturdayHours, operatingHoursSunday→sundayHours, + * clinicStatus→status, onlineBookingEnabled→onlineBooking, + * arriveEarlyMinutes→arriveEarlyMin, paymentCash→acceptsCash, + * paymentCard→acceptsCard, paymentUpi→acceptsUpi + * HealthPackage: packageDepartment→department, durationMinutes→durationMin, isActive→active + * InsurancePartner: planTypes→planTypesAccepted + */ + +const GQL = 'http://localhost:4000/graphql'; +const SUB = 'fortytwo-dev'; +const ORIGIN = 'http://fortytwo-dev.localhost:4010'; + +let token = ''; + +async function gql(query: string, variables?: any) { + const h: Record = { 'Content-Type': 'application/json', 'X-Workspace-Subdomain': SUB }; + if (token) h['Authorization'] = `Bearer ${token}`; + const r = await fetch(GQL, { method: 'POST', headers: h, body: JSON.stringify({ query, variables }) }); + const d: any = await r.json(); + if (d.errors) { console.error('❌', d.errors[0].message); throw new Error(d.errors[0].message); } + return d.data; +} + +async function auth() { + const d1 = await gql(`mutation { getLoginTokenFromCredentials(email: "dev@fortytwo.dev", password: "tim@apple.dev", origin: "${ORIGIN}") { loginToken { token } } }`); + const lt = d1.getLoginTokenFromCredentials.loginToken.token; + const d2 = await gql(`mutation { getAuthTokensFromLoginToken(loginToken: "${lt}", origin: "${ORIGIN}") { tokens { accessOrWorkspaceAgnosticToken { token } } } }`); + token = d2.getAuthTokensFromLoginToken.tokens.accessOrWorkspaceAgnosticToken.token; +} + +async function mk(entity: string, data: any): Promise { + const cap = entity[0].toUpperCase() + entity.slice(1); + const d = await gql(`mutation($data: ${cap}CreateInput!) { create${cap}(data: $data) { id } }`, { data }); + return d[`create${cap}`].id; +} + +async function update(entity: string, id: string, data: any): Promise { + const cap = entity[0].toUpperCase() + entity.slice(1); + await gql(`mutation($id: UUID!, $data: ${cap}UpdateInput!) { update${cap}(id: $id, data: $data) { id } }`, { id, data }); +} + +async function main() { + console.log('🌱 Seeding clinics, health packages, insurance partners...\n'); + await auth(); + console.log('✅ Auth OK\n'); + + // ═══════════════════════════════════════════ + // CLINICS + // ═══════════════════════════════════════════ + console.log('🏥 Clinics'); + const koramangala = await mk('clinic', { + name: 'Global Hospital — Koramangala', + clinicName: 'Koramangala', + addressCustom: { + addressStreet1: '#45, 80 Feet Road', + addressCity: 'Bengaluru', + addressState: 'Karnataka', + addressPostcode: '560034', + addressCountry: 'India', + }, + phone: { primaryPhoneNumber: '08041234567', primaryPhoneCallingCode: '+91', primaryPhoneCountryCode: 'IN' }, + email: { primaryEmail: 'koramangala@globalhospital.com' }, + weekdayHours: '8:00 AM – 8:00 PM', + saturdayHours: '8:00 AM – 8:00 PM', + sundayHours: '9:00 AM – 2:00 PM', + status: 'ACTIVE', + walkInAllowed: true, + onlineBooking: true, + cancellationWindowHours: 4, + arriveEarlyMin: 15, + requiredDocuments: 'ID proof + medical records', + acceptsCash: 'YES', + acceptsCard: 'YES', + acceptsUpi: 'YES', + }); + console.log(` Koramangala: ${koramangala}`); + + const whitefield = await mk('clinic', { + name: 'Global Hospital — Whitefield', + clinicName: 'Whitefield', + addressCustom: { + addressStreet1: 'Prestige Shantiniketan', + addressCity: 'Bengaluru', + addressState: 'Karnataka', + addressPostcode: '560048', + addressCountry: 'India', + }, + phone: { primaryPhoneNumber: '08041234568', primaryPhoneCallingCode: '+91', primaryPhoneCountryCode: 'IN' }, + email: { primaryEmail: 'whitefield@globalhospital.com' }, + weekdayHours: '8:00 AM – 8:00 PM', + saturdayHours: '8:00 AM – 8:00 PM', + sundayHours: 'Closed', + status: 'ACTIVE', + walkInAllowed: true, + onlineBooking: true, + cancellationWindowHours: 4, + arriveEarlyMin: 15, + requiredDocuments: 'ID proof + medical records', + acceptsCash: 'YES', + acceptsCard: 'YES', + acceptsUpi: 'YES', + }); + console.log(` Whitefield: ${whitefield}`); + + const indiranagar = await mk('clinic', { + name: 'Global Hospital — Indiranagar', + clinicName: 'Indiranagar', + addressCustom: { + addressStreet1: '#12, 100 Feet Road', + addressCity: 'Bengaluru', + addressState: 'Karnataka', + addressPostcode: '560038', + addressCountry: 'India', + }, + phone: { primaryPhoneNumber: '08041234569', primaryPhoneCallingCode: '+91', primaryPhoneCountryCode: 'IN' }, + email: { primaryEmail: 'indiranagar@globalhospital.com' }, + weekdayHours: '9:00 AM – 7:00 PM', + saturdayHours: '9:00 AM – 7:00 PM', + sundayHours: '10:00 AM – 1:00 PM', + status: 'ACTIVE', + walkInAllowed: true, + onlineBooking: true, + cancellationWindowHours: 4, + arriveEarlyMin: 15, + requiredDocuments: 'ID proof + medical records', + acceptsCash: 'YES', + acceptsCard: 'YES', + acceptsUpi: 'YES', + }); + console.log(` Indiranagar: ${indiranagar}\n`); + + await auth(); + + // ═══════════════════════════════════════════ + // LINK DOCTORS TO CLINICS + // ═══════════════════════════════════════════ + console.log('🔗 Linking doctors to clinics'); + const doctors: Record = { + 'da5678f3-6b52-492e-87d3-c4707d105938': 'Dr. Sharma', // Koramangala + 'b080cdf0-4527-46c7-b723-47f2eee623e4': 'Dr. Patel', // Indiranagar + 'd780976a-7ddb-4a00-9a56-e7e3a77fa416': 'Dr. Kumar', // Whitefield + 'bf77c148-438f-4b6f-9e5d-b1c1ff2e10f8': 'Dr. Reddy', // Koramangala + 'e71c2c59-574f-4e81-b8cd-2d7b4b5da8e5': 'Dr. Singh', // Indiranagar + }; + const doctorClinicMap: Record = { + 'da5678f3-6b52-492e-87d3-c4707d105938': koramangala, + 'b080cdf0-4527-46c7-b723-47f2eee623e4': indiranagar, + 'd780976a-7ddb-4a00-9a56-e7e3a77fa416': whitefield, + 'bf77c148-438f-4b6f-9e5d-b1c1ff2e10f8': koramangala, + 'e71c2c59-574f-4e81-b8cd-2d7b4b5da8e5': indiranagar, + }; + for (const [docId, clinicId] of Object.entries(doctorClinicMap)) { + await update('doctor', docId, { clinicId }); + console.log(` ${doctors[docId]} → ${clinicId === koramangala ? 'Koramangala' : clinicId === whitefield ? 'Whitefield' : 'Indiranagar'}`); + } + console.log(''); + + await auth(); + + // ═══════════════════════════════════════════ + // HEALTH PACKAGES + // ═══════════════════════════════════════════ + console.log('📦 Health Packages'); + await mk('healthPackage', { + name: 'Master Health Checkup', + packageName: 'Master Health Checkup', + description: 'Comprehensive annual health screening — blood work, ECG, chest X-ray, BMI, vision, dental', + price: { amountMicros: 2_999_000_000, currencyCode: 'INR' }, + department: 'PREVENTIVE', + inclusions: 'CBC, Lipid Profile, Liver Function, Kidney Function, Thyroid, Blood Sugar, ECG, Chest X-ray, BMI, Vision Test, Dental Check', + durationMin: 180, + eligibility: 'All adults above 18 years', + active: true, + clinicId: koramangala, + }); + console.log(' Master Health Checkup — ₹2,999'); + + await mk('healthPackage', { + name: 'Cardiac Screening', + packageName: 'Cardiac Screening', + description: 'Heart health assessment — ECG, Echo, TMT, lipid panel, cardiac risk markers', + price: { amountMicros: 4_999_000_000, currencyCode: 'INR' }, + department: 'CARDIOLOGY', + inclusions: 'ECG, 2D Echocardiogram, TMT (Treadmill Test), Lipid Profile, hs-CRP, Homocysteine, HbA1c, Cardiologist Consultation', + durationMin: 240, + eligibility: 'Adults above 30 or family history of heart disease', + active: true, + clinicId: koramangala, + }); + console.log(' Cardiac Screening — ₹4,999'); + + await mk('healthPackage', { + name: "Women's Wellness Package", + packageName: "Women's Wellness Package", + description: 'Complete women\'s health screening — PAP smear, mammogram, hormone panel, bone density', + price: { amountMicros: 3_499_000_000, currencyCode: 'INR' }, + department: 'GYNECOLOGY', + inclusions: 'PAP Smear, Mammogram, Pelvic Ultrasound, Thyroid Panel, Vitamin D, Calcium, CBC, Hormone Panel (FSH/LH/Estradiol), Bone Density Scan, Gynecologist Consultation', + durationMin: 210, + eligibility: 'Women above 25 years', + active: true, + clinicId: indiranagar, + }); + console.log(" Women's Wellness — ₹3,499"); + + await mk('healthPackage', { + name: 'Orthopedic Assessment', + packageName: 'Orthopedic Assessment', + description: 'Joint and bone health evaluation — X-rays, bone density, vitamin levels, specialist consult', + price: { amountMicros: 1_999_000_000, currencyCode: 'INR' }, + department: 'ORTHOPEDICS', + inclusions: 'Joint X-rays (knee/hip/spine), Bone Density Scan, Vitamin D, Calcium, Uric Acid, RA Factor, Orthopedic Consultation', + durationMin: 120, + eligibility: 'Adults with joint pain or age above 40', + active: true, + clinicId: whitefield, + }); + console.log(' Orthopedic Assessment — ₹1,999'); + + await mk('healthPackage', { + name: 'IVF Consultation Package', + packageName: 'IVF Consultation Package', + description: 'Initial fertility assessment — hormone panel, ultrasound, semen analysis, specialist consult', + price: { amountMicros: 5_999_000_000, currencyCode: 'INR' }, + discountedPrice: { amountMicros: 0, currencyCode: 'INR' }, + department: 'GYNECOLOGY', + inclusions: 'AMH, FSH, LH, Estradiol, Prolactin, Thyroid Panel, Transvaginal Ultrasound, Semen Analysis, Fertility Specialist Consultation', + durationMin: 150, + eligibility: 'Couples planning IVF or facing infertility (1+ year)', + active: true, + clinicId: indiranagar, + }); + console.log(' IVF Consultation — ₹5,999 (free first visit via campaign)\n'); + + await auth(); + + // ═══════════════════════════════════════════ + // INSURANCE PARTNERS + // ═══════════════════════════════════════════ + console.log('🛡️ Insurance Partners'); + await mk('insurancePartner', { + name: 'Star Health Insurance', + insurerName: 'Star Health', + tpaName: 'Star Health & Allied Insurance', + planTypesAccepted: 'Family Health Optima, Comprehensive, Senior Citizen Red Carpet', + settlementType: 'BOTH', + empanelmentStatus: 'ACTIVE', + validUntil: '2027-03-31', + contactPerson: 'Rajesh M', + contactPhone: { primaryPhoneNumber: '9900200001', primaryPhoneCallingCode: '+91', primaryPhoneCountryCode: 'IN' }, + clinicId: koramangala, + }); + console.log(' Star Health — cashless + reimbursement'); + + await mk('insurancePartner', { + name: 'ICICI Lombard', + insurerName: 'ICICI Lombard', + tpaName: 'Medi Assist', + planTypesAccepted: 'iHealth, Complete Health Insurance, Group Mediclaim', + settlementType: 'CASHLESS', + empanelmentStatus: 'ACTIVE', + validUntil: '2027-06-30', + contactPerson: 'Priya K', + contactPhone: { primaryPhoneNumber: '9900200002', primaryPhoneCallingCode: '+91', primaryPhoneCountryCode: 'IN' }, + clinicId: koramangala, + }); + console.log(' ICICI Lombard — cashless'); + + await mk('insurancePartner', { + name: 'Bajaj Allianz', + insurerName: 'Bajaj Allianz', + tpaName: 'Health India TPA', + planTypesAccepted: 'Health Guard, Silver Health, Group Health', + settlementType: 'BOTH', + empanelmentStatus: 'ACTIVE', + validUntil: '2027-01-31', + contactPerson: 'Suresh V', + contactPhone: { primaryPhoneNumber: '9900200003', primaryPhoneCallingCode: '+91', primaryPhoneCountryCode: 'IN' }, + clinicId: whitefield, + }); + console.log(' Bajaj Allianz — cashless + reimbursement'); + + await mk('insurancePartner', { + name: 'HDFC Ergo', + insurerName: 'HDFC Ergo', + tpaName: 'Paramount Health Services', + planTypesAccepted: 'Optima Secure, Optima Restore, My Health Suraksha', + settlementType: 'CASHLESS', + empanelmentStatus: 'ACTIVE', + validUntil: '2027-09-30', + contactPerson: 'Anita S', + contactPhone: { primaryPhoneNumber: '9900200004', primaryPhoneCallingCode: '+91', primaryPhoneCountryCode: 'IN' }, + clinicId: indiranagar, + }); + console.log(' HDFC Ergo — cashless'); + + await mk('insurancePartner', { + name: 'Niva Bupa (Max Bupa)', + insurerName: 'Niva Bupa', + tpaName: 'Niva Bupa Health Insurance', + planTypesAccepted: 'Health Recharge, Health Premia, ReAssure 2.0', + settlementType: 'BOTH', + empanelmentStatus: 'ACTIVE', + validUntil: '2027-04-30', + contactPerson: 'Deepak R', + contactPhone: { primaryPhoneNumber: '9900200005', primaryPhoneCallingCode: '+91', primaryPhoneCountryCode: 'IN' }, + clinicId: koramangala, + }); + console.log(' Niva Bupa — cashless + reimbursement'); + + await mk('insurancePartner', { + name: 'New India Assurance', + insurerName: 'New India Assurance', + tpaName: 'Raksha TPA', + planTypesAccepted: 'Mediclaim Policy, Jan Arogya Bima, Group Mediclaim', + settlementType: 'REIMBURSEMENT', + empanelmentStatus: 'ACTIVE', + validUntil: '2027-12-31', + contactPerson: 'Mohan T', + contactPhone: { primaryPhoneNumber: '9900200006', primaryPhoneCallingCode: '+91', primaryPhoneCountryCode: 'IN' }, + clinicId: whitefield, + }); + console.log(' New India Assurance — reimbursement\n'); + + // ═══════════════════════════════════════════ + // CLEANUP: Delete the empty doctor record + // ═══════════════════════════════════════════ + try { + await gql(`mutation { deleteDoctor(id: "967e1959-9b85-497e-9f8e-cad65120c5de") { id } }`); + console.log('🧹 Cleaned up empty doctor record\n'); + } catch { + // Ignore if already deleted + } + + console.log('🎉 Done!'); + console.log(' 3 clinics · 5 health packages · 6 insurance partners'); + console.log(' 5 doctors linked to clinics'); +} + +main().catch(e => { console.error('💥', e.message); process.exit(1); }); diff --git a/scripts/test-ai-flow.ts b/scripts/test-ai-flow.ts index 14bf335..27d3ae0 100644 --- a/scripts/test-ai-flow.ts +++ b/scripts/test-ai-flow.ts @@ -144,11 +144,12 @@ async function main() { const doctorsData = await gql(`{ doctors(first: 10) { edges { node { id name fullName { firstName lastName } department specialty qualifications yearsOfExperience - branchClinic visitingHours + visitingHours consultationFeeNew { amountMicros currencyCode } consultationFeeFollowUp { amountMicros currencyCode } active registrationNumber portalUserId + clinic { id clinicName } } } } }`); const doctors = doctorsData.doctors.edges.map((e: any) => e.node); assert(doctors.length === 5, `Found ${doctors.length} doctors`); @@ -208,7 +209,7 @@ async function main() { console.log(` AI Summary: ${matchedLead?.aiSummary}`); console.log(` Suggested Action: ${matchedLead?.aiSuggestedAction}`); console.log(` Doctor: Dr. Patel — ${drPatel?.specialty}`); - console.log(` Next Appointment: ${drPatel?.visitingHours} at ${drPatel?.branchClinic}`); + console.log(` Next Appointment: ${drPatel?.visitingHours} at ${drPatel?.clinic?.clinicName ?? 'N/A'}`); console.log(` Fee: ₹${(drPatel?.consultationFeeNew?.amountMicros ?? 0) / 1_000_000} (new) / ₹${(drPatel?.consultationFeeFollowUp?.amountMicros ?? 0) / 1_000_000} (follow-up)`); // ═══════════════════════════════════════ diff --git a/src/components/application/app-navigation/base-components/nav-account-card.tsx b/src/components/application/app-navigation/base-components/nav-account-card.tsx index 67836a7..08320dc 100644 --- a/src/components/application/app-navigation/base-components/nav-account-card.tsx +++ b/src/components/application/app-navigation/base-components/nav-account-card.tsx @@ -1,13 +1,11 @@ import type { FC, HTMLAttributes } from "react"; import { useCallback, useEffect, useRef } from "react"; import type { Placement } from "@react-types/overlays"; -import { BookOpen01, ChevronSelectorVertical, LogOut01, Plus, Settings01, User01 } from "@untitledui/icons"; +import { ChevronSelectorVertical, LogOut01, Settings01, User01 } from "@untitledui/icons"; import { useFocusManager } from "react-aria"; import type { DialogProps as AriaDialogProps } from "react-aria-components"; import { Button as AriaButton, Dialog as AriaDialog, DialogTrigger as AriaDialogTrigger, Popover as AriaPopover } from "react-aria-components"; import { AvatarLabelGroup } from "@/components/base/avatar/avatar-label-group"; -import { Button } from "@/components/base/buttons/button"; -import { RadioButtonBase } from "@/components/base/radio-buttons/radio-buttons"; import { useBreakpoint } from "@/hooks/use-breakpoint"; import { cx } from "@/utils/cx"; @@ -24,26 +22,9 @@ type NavAccountType = { status: "online" | "offline"; }; -const placeholderAccounts: NavAccountType[] = [ - { - id: "olivia", - name: "Olivia Rhye", - email: "olivia@untitledui.com", - avatar: "https://www.untitledui.com/images/avatars/olivia-rhye?fm=webp&q=80", - status: "online", - }, - { - id: "sienna", - name: "Sienna Hewitt", - email: "sienna@untitledui.com", - avatar: "https://www.untitledui.com/images/avatars/transparent/sienna-hewitt?bg=%23E0E0E0", - status: "online", - }, -]; export const NavAccountMenu = ({ className, - selectedAccountId = "olivia", onSignOut, ...dialogProps }: AriaDialogProps & { className?: string; accounts?: NavAccountType[]; selectedAccountId?: string; onSignOut?: () => void }) => { @@ -87,31 +68,6 @@ export const NavAccountMenu = ({
- -
-
-
Switch account
- -
- {placeholderAccounts.map((account) => ( - - ))} -
-
-
-
@@ -155,8 +111,8 @@ const NavAccountCardMenuItem = ({ export const NavAccountCard = ({ popoverPlacement, - selectedAccountId = "olivia", - items = placeholderAccounts, + selectedAccountId, + items = [], onSignOut, }: { popoverPlacement?: Placement; diff --git a/src/hooks/use-worklist.ts b/src/hooks/use-worklist.ts index 3ea4385..186bf47 100644 --- a/src/hooks/use-worklist.ts +++ b/src/hooks/use-worklist.ts @@ -90,7 +90,32 @@ export const useWorklist = (): UseWorklistResult => { if (response.ok) { const json = await response.json(); - setData(json); + // Transform platform field shapes to frontend types + const transformed: WorklistData = { + ...json, + marketingLeads: (json.marketingLeads ?? []).map((lead: any) => ({ + ...lead, + leadSource: lead.source ?? lead.leadSource, + leadStatus: lead.status ?? lead.leadStatus, + contactPhone: lead.contactPhone?.primaryPhoneNumber + ? [{ number: lead.contactPhone.primaryPhoneNumber, callingCode: lead.contactPhone.primaryPhoneCallingCode ?? '+91' }] + : lead.contactPhone, + contactEmail: lead.contactEmail?.primaryEmail + ? [{ address: lead.contactEmail.primaryEmail }] + : lead.contactEmail, + })), + missedCalls: (json.missedCalls ?? []).map((call: any) => ({ + ...call, + callDirection: call.direction ?? call.callDirection, + durationSeconds: call.durationSec ?? call.durationSeconds ?? 0, + })), + followUps: (json.followUps ?? []).map((fu: any) => ({ + ...fu, + followUpType: fu.typeCustom ?? fu.followUpType, + followUpStatus: fu.status ?? fu.followUpStatus, + })), + }; + setData(transformed); setError(null); } else { setError(`Worklist API returned ${response.status}`); diff --git a/src/lib/queries.ts b/src/lib/queries.ts new file mode 100644 index 0000000..97694fd --- /dev/null +++ b/src/lib/queries.ts @@ -0,0 +1,74 @@ +// GraphQL queries for platform entities +// Platform remaps some SDK field names — queries use platform names + +export const LEADS_QUERY = `{ leads(first: 100, orderBy: [{ createdAt: DescNullsLast }]) { edges { node { + id name createdAt updatedAt + contactName { firstName lastName } + contactPhone { primaryPhoneNumber primaryPhoneCallingCode } + contactEmail { primaryEmail } + source status priority interestedService assignedAgent + utmSource utmMedium utmCampaign utmContent utmTerm landingPage referrerUrl + leadScore spamScore isSpam isDuplicate duplicateOfLeadId + firstContacted lastContacted contactAttempts convertedAt + patientId campaignId + aiSummary aiSuggestedAction +} } } }`; + +export const CAMPAIGNS_QUERY = `{ campaigns(first: 50, orderBy: [{ createdAt: DescNullsLast }]) { edges { node { + id name createdAt updatedAt + campaignName typeCustom status platform + startDate endDate + budget { amountMicros currencyCode } + amountSpent { amountMicros currencyCode } + impressions clicks targetCount contacted converted leadsGenerated + externalCampaignId platformUrl +} } } }`; + +export const ADS_QUERY = `{ ads(first: 100, orderBy: [{ createdAt: DescNullsLast }]) { edges { node { + id name createdAt updatedAt + adName externalAdId adStatus adFormat + headline adDescription destinationUrl previewUrl + impressions clicks conversions + spend { amountMicros currencyCode } + campaignId +} } } }`; + +export const FOLLOW_UPS_QUERY = `{ followUps(first: 50, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node { + id name createdAt + typeCustom status scheduledAt completedAt + priority assignedAgent + patientId callId +} } } }`; + +export const LEAD_ACTIVITIES_QUERY = `{ leadActivities(first: 200, orderBy: [{ occurredAt: DescNullsLast }]) { edges { node { + id name createdAt + activityType summary occurredAt performedBy + previousValue newValue + channel durationSeconds outcome + leadId +} } } }`; + +export const CALLS_QUERY = `{ calls(first: 100, orderBy: [{ startedAt: DescNullsLast }]) { edges { node { + id name createdAt + direction callStatus callerNumber agentName + startedAt endedAt durationSec + recordingUrl disposition + patientId appointmentId leadId +} } } }`; + +export const DOCTORS_QUERY = `{ doctors(first: 20) { edges { node { + id name fullName { firstName lastName } + department specialty qualifications yearsOfExperience + visitingHours + consultationFeeNew { amountMicros currencyCode } + consultationFeeFollowUp { amountMicros currencyCode } + active registrationNumber + clinic { id name clinicName } +} } } }`; + +export const PATIENTS_QUERY = `{ patients(first: 50) { edges { node { + id name fullName { firstName lastName } + phones { primaryPhoneNumber } + emails { primaryEmail } + dateOfBirth gender patientType +} } } }`; diff --git a/src/lib/transforms.ts b/src/lib/transforms.ts new file mode 100644 index 0000000..0a0049b --- /dev/null +++ b/src/lib/transforms.ts @@ -0,0 +1,152 @@ +// Transform platform GraphQL responses → frontend entity types +// Platform remaps some field names during sync + +import type { Lead, Campaign, Ad, FollowUp, LeadActivity, Call } from '@/types/entities'; + +type PlatformNode = Record; + +function extractEdges(data: any, entityName: string): PlatformNode[] { + return data?.[entityName]?.edges?.map((e: any) => e.node) ?? []; +} + +export function transformLeads(data: any): Lead[] { + return extractEdges(data, 'leads').map((n) => ({ + id: n.id, + createdAt: n.createdAt, + updatedAt: n.updatedAt, + contactName: n.contactName ?? { firstName: '', lastName: '' }, + contactPhone: n.contactPhone?.primaryPhoneNumber + ? [{ number: n.contactPhone.primaryPhoneNumber, callingCode: n.contactPhone.primaryPhoneCallingCode ?? '+91' }] + : [], + contactEmail: n.contactEmail?.primaryEmail + ? [{ address: n.contactEmail.primaryEmail }] + : [], + leadSource: n.source, + leadStatus: n.status, + priority: n.priority ?? 'NORMAL', + interestedService: n.interestedService, + assignedAgent: n.assignedAgent, + utmSource: n.utmSource, + utmMedium: n.utmMedium, + utmCampaign: n.utmCampaign, + utmContent: n.utmContent, + utmTerm: n.utmTerm, + landingPageUrl: n.landingPage, + referrerUrl: n.referrerUrl, + leadScore: n.leadScore, + spamScore: n.spamScore ?? 0, + isSpam: n.isSpam ?? false, + isDuplicate: n.isDuplicate ?? false, + duplicateOfLeadId: n.duplicateOfLeadId, + firstContactedAt: n.firstContacted, + lastContactedAt: n.lastContacted, + contactAttempts: n.contactAttempts ?? 0, + convertedAt: n.convertedAt, + patientId: n.patientId, + campaignId: n.campaignId, + adId: null, + aiSummary: n.aiSummary, + aiSuggestedAction: n.aiSuggestedAction, + })); +} + +export function transformCampaigns(data: any): Campaign[] { + return extractEdges(data, 'campaigns').map((n) => ({ + id: n.id, + createdAt: n.createdAt, + updatedAt: n.updatedAt, + campaignName: n.campaignName ?? n.name, + campaignType: n.typeCustom, + campaignStatus: n.status, + platform: n.platform, + startDate: n.startDate, + endDate: n.endDate, + budget: n.budget ? { amountMicros: n.budget.amountMicros, currencyCode: n.budget.currencyCode } : null, + amountSpent: n.amountSpent ? { amountMicros: n.amountSpent.amountMicros, currencyCode: n.amountSpent.currencyCode } : null, + impressionCount: n.impressions ?? 0, + clickCount: n.clicks ?? 0, + targetCount: n.targetCount ?? 0, + contactedCount: n.contacted ?? 0, + convertedCount: n.converted ?? 0, + leadCount: n.leadsGenerated ?? 0, + externalCampaignId: n.externalCampaignId, + platformUrl: n.platformUrl, + })); +} + +export function transformAds(data: any): Ad[] { + return extractEdges(data, 'ads').map((n) => ({ + id: n.id, + createdAt: n.createdAt, + updatedAt: n.updatedAt, + adName: n.adName ?? n.name, + externalAdId: n.externalAdId, + adStatus: n.adStatus, + adFormat: n.adFormat, + headline: n.headline, + adDescription: n.adDescription, + destinationUrl: n.destinationUrl, + previewUrl: n.previewUrl, + impressions: n.impressions ?? 0, + clicks: n.clicks ?? 0, + conversions: n.conversions ?? 0, + spend: n.spend ? { amountMicros: n.spend.amountMicros, currencyCode: n.spend.currencyCode } : null, + campaignId: n.campaignId, + })); +} + +export function transformFollowUps(data: any): FollowUp[] { + return extractEdges(data, 'followUps').map((n) => ({ + id: n.id, + createdAt: n.createdAt, + followUpType: n.typeCustom, + followUpStatus: n.status, + scheduledAt: n.scheduledAt, + completedAt: n.completedAt, + priority: n.priority ?? 'NORMAL', + assignedAgent: n.assignedAgent, + patientId: n.patientId, + callId: n.callId, + patientName: undefined, + patientPhone: undefined, + description: n.name, + })); +} + +export function transformLeadActivities(data: any): LeadActivity[] { + return extractEdges(data, 'leadActivities').map((n) => ({ + id: n.id, + createdAt: n.createdAt, + activityType: n.activityType, + summary: n.summary, + occurredAt: n.occurredAt, + performedBy: n.performedBy, + previousValue: n.previousValue, + newValue: n.newValue, + channel: n.channel, + durationSeconds: n.durationSeconds, + outcome: n.outcome, + activityNotes: null, + leadId: n.leadId, + })); +} + +export function transformCalls(data: any): Call[] { + return extractEdges(data, 'calls').map((n) => ({ + id: n.id, + createdAt: n.createdAt, + callDirection: n.direction, + callStatus: n.callStatus, + callerNumber: n.callerNumber ? [{ number: n.callerNumber, callingCode: '+91' }] : [], + agentName: n.agentName, + startedAt: n.startedAt, + endedAt: n.endedAt, + durationSeconds: n.durationSec ?? 0, + recordingUrl: n.recordingUrl, + disposition: n.disposition, + callNotes: undefined, + patientId: n.patientId, + appointmentId: n.appointmentId, + leadId: n.leadId, + })); +} diff --git a/src/pages/login.tsx b/src/pages/login.tsx index f390882..a844a8c 100644 --- a/src/pages/login.tsx +++ b/src/pages/login.tsx @@ -1,8 +1,11 @@ import { useState } from 'react'; import { useNavigate } from 'react-router'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faEye, faEyeSlash } from '@fortawesome/pro-duotone-svg-icons'; import { useAuth } from '@/providers/auth-provider'; import { Button } from '@/components/base/buttons/button'; import { SocialButton } from '@/components/base/buttons/social-button'; +import { Checkbox } from '@/components/base/checkbox/checkbox'; import { Input } from '@/components/base/input/input'; const features = [ @@ -25,8 +28,13 @@ export const LoginPage = () => { const { loginWithUser } = useAuth(); const navigate = useNavigate(); - const [email, setEmail] = useState(''); - const [password, setPassword] = useState(''); + const saved = localStorage.getItem('helix_remember'); + const savedCreds = saved ? JSON.parse(saved) : null; + + const [email, setEmail] = useState(savedCreds?.email ?? ''); + const [password, setPassword] = useState(savedCreds?.password ?? ''); + const [showPassword, setShowPassword] = useState(false); + const [rememberMe, setRememberMe] = useState(!!savedCreds); const [error, setError] = useState(null); const [isLoading, setIsLoading] = useState(false); @@ -51,6 +59,12 @@ export const LoginPage = () => { const name = `${firstName} ${lastName}`.trim() || email; const initials = `${firstName[0] ?? ''}${lastName[0] ?? ''}`.toUpperCase() || email[0].toUpperCase(); + if (rememberMe) { + localStorage.setItem('helix_remember', JSON.stringify({ email, password })); + } else { + localStorage.removeItem('helix_remember'); + } + loginWithUser({ id: u?.id, name, @@ -181,16 +195,41 @@ export const LoginPage = () => { /> - {/* Password input */} -
+ {/* Password input with eye toggle */} +
setPassword(value)} size="md" /> + +
+ + {/* Remember me + Forgot password */} +
+ +
{/* Error message */} diff --git a/src/providers/data-provider.tsx b/src/providers/data-provider.tsx index 3c73218..d2043bb 100644 --- a/src/providers/data-provider.tsx +++ b/src/providers/data-provider.tsx @@ -1,8 +1,24 @@ import type { ReactNode } from 'react'; -import { createContext, useContext, useState } from 'react'; +import { createContext, useCallback, useContext, useEffect, useState } from 'react'; +import { apiClient } from '@/lib/api-client'; +import { + LEADS_QUERY, + CAMPAIGNS_QUERY, + ADS_QUERY, + FOLLOW_UPS_QUERY, + LEAD_ACTIVITIES_QUERY, + CALLS_QUERY, +} from '@/lib/queries'; +import { + transformLeads, + transformCampaigns, + transformAds, + transformFollowUps, + transformLeadActivities, + transformCalls, +} from '@/lib/transforms'; import type { Lead, Campaign, Ad, LeadActivity, FollowUp, WhatsAppTemplate, Agent, Call, LeadIngestionSource } from '@/types/entities'; -import { mockLeads, mockCampaigns, mockAds, mockFollowUps, mockLeadActivities, mockTemplates, mockAgents, mockCalls, mockIngestionSources } from '@/mocks'; type DataContextType = { leads: Lead[]; @@ -14,8 +30,11 @@ type DataContextType = { agents: Agent[]; calls: Call[]; ingestionSources: LeadIngestionSource[]; + loading: boolean; + error: string | null; updateLead: (id: string, updates: Partial) => void; addCall: (call: Call) => void; + refresh: () => void; }; const DataContext = createContext(undefined); @@ -35,15 +54,56 @@ interface DataProviderProps { } export const DataProvider = ({ children }: DataProviderProps) => { - const [leads, setLeads] = useState(mockLeads); - const [campaigns] = useState(mockCampaigns); - const [ads] = useState(mockAds); - const [followUps] = useState(mockFollowUps); - const [leadActivities] = useState(mockLeadActivities); - const [templates] = useState(mockTemplates); - const [agents] = useState(mockAgents); - const [calls, setCalls] = useState(mockCalls); - const [ingestionSources] = useState(mockIngestionSources); + const [leads, setLeads] = useState([]); + const [campaigns, setCampaigns] = useState([]); + const [ads, setAds] = useState([]); + const [followUps, setFollowUps] = useState([]); + const [leadActivities, setLeadActivities] = useState([]); + const [calls, setCalls] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // These don't have platform entities yet — empty for now + const [templates] = useState([]); + const [agents] = useState([]); + const [ingestionSources] = useState([]); + + const fetchData = useCallback(async () => { + if (!apiClient.isAuthenticated()) { + setLoading(false); + return; + } + + setLoading(true); + setError(null); + + try { + const [leadsData, campaignsData, adsData, followUpsData, activitiesData, callsData] = await Promise.all([ + apiClient.graphql(LEADS_QUERY), + apiClient.graphql(CAMPAIGNS_QUERY), + apiClient.graphql(ADS_QUERY), + apiClient.graphql(FOLLOW_UPS_QUERY), + apiClient.graphql(LEAD_ACTIVITIES_QUERY), + apiClient.graphql(CALLS_QUERY), + ]); + + setLeads(transformLeads(leadsData)); + setCampaigns(transformCampaigns(campaignsData)); + setAds(transformAds(adsData)); + setFollowUps(transformFollowUps(followUpsData)); + setLeadActivities(transformLeadActivities(activitiesData)); + setCalls(transformCalls(callsData)); + } catch (err: any) { + console.error('Failed to fetch platform data:', err); + setError(err.message ?? 'Failed to load data'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchData(); + }, [fetchData]); const updateLead = (id: string, updates: Partial) => { setLeads((prev) => prev.map((lead) => (lead.id === id ? { ...lead, ...updates } : lead))); @@ -54,7 +114,11 @@ export const DataProvider = ({ children }: DataProviderProps) => { }; return ( - + {children} ); diff --git a/src/providers/sip-provider.tsx b/src/providers/sip-provider.tsx index 2995368..306b512 100644 --- a/src/providers/sip-provider.tsx +++ b/src/providers/sip-provider.tsx @@ -1,43 +1,135 @@ -import { createContext, useContext, useEffect, useState, type PropsWithChildren } from 'react'; -import { useSipPhone } from '@/hooks/use-sip-phone'; +import { useEffect, useCallback, type PropsWithChildren } from 'react'; +import { useAtom, useSetAtom } from 'jotai'; +import { + sipConnectionStatusAtom, + sipCallStateAtom, + sipCallerNumberAtom, + sipIsMutedAtom, + sipIsOnHoldAtom, + sipCallDurationAtom, + sipCallStartTimeAtom, +} from '@/state/sip-state'; +import { registerSipStateUpdater, connectSip, disconnectSip, getSipClient } from '@/state/sip-manager'; +import type { SIPConfig } from '@/types/sip'; -type SipContextType = ReturnType & { - ozonetelStatus: 'idle' | 'logging-in' | 'logged-in' | 'error'; - ozonetelError: string | null; +const DEFAULT_CONFIG: SIPConfig = { + displayName: import.meta.env.VITE_SIP_DISPLAY_NAME ?? 'Helix Agent', + uri: import.meta.env.VITE_SIP_URI ?? '', + password: import.meta.env.VITE_SIP_PASSWORD ?? '', + wsServer: import.meta.env.VITE_SIP_WS_SERVER ?? '', + stunServers: 'stun:stun.l.google.com:19302', }; -const SipContext = createContext(null); - -// Module-level flag — survives React StrictMode double-mount -let sipConnectedGlobal = false; - export const SipProvider = ({ children }: PropsWithChildren) => { - const sipPhone = useSipPhone(); - const [ozonetelStatus, setOzonetelStatus] = useState<'idle' | 'logging-in' | 'logged-in' | 'error'>('idle'); - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const [ozonetelError, _setOzonetelError] = useState(null); + const [, setConnectionStatus] = useAtom(sipConnectionStatusAtom); + const [callState, setCallState] = useAtom(sipCallStateAtom); + const setCallerNumber = useSetAtom(sipCallerNumberAtom); + const setCallDuration = useSetAtom(sipCallDurationAtom); + const setCallStartTime = useSetAtom(sipCallStartTimeAtom); - // Auto-connect SIP on mount — module-level guard prevents duplicate connections + // Register Jotai setters so the singleton SIP manager can update atoms useEffect(() => { - if (!sipConnectedGlobal) { - sipConnectedGlobal = true; - sipPhone.connect(); - setOzonetelStatus('logged-in'); - } - // eslint-disable-next-line react-hooks/exhaustive-deps + registerSipStateUpdater({ + setConnectionStatus, + setCallState, + setCallerNumber, + }); + }, [setConnectionStatus, setCallState, setCallerNumber]); + + // Auto-connect SIP on mount + useEffect(() => { + connectSip(DEFAULT_CONFIG); }, []); - return ( - - {children} - - ); + // Call duration timer + useEffect(() => { + if (callState === 'active') { + const start = new Date(); + setCallStartTime(start); + const interval = window.setInterval(() => { + setCallDuration(Math.floor((Date.now() - start.getTime()) / 1000)); + }, 1000); + return () => clearInterval(interval); + } + setCallDuration(0); + setCallStartTime(null); + }, [callState, setCallDuration, setCallStartTime]); + + // Auto-reset to idle after ended/failed + useEffect(() => { + if (callState === 'ended' || callState === 'failed') { + const timer = setTimeout(() => { + setCallState('idle'); + setCallerNumber(null); + }, 2000); + return () => clearTimeout(timer); + } + }, [callState, setCallState, setCallerNumber]); + + // Cleanup on page unload + useEffect(() => { + const handleUnload = () => disconnectSip(); + window.addEventListener('beforeunload', handleUnload); + return () => window.removeEventListener('beforeunload', handleUnload); + }, []); + + return <>{children}; }; -export const useSip = (): SipContextType => { - const ctx = useContext(SipContext); - if (ctx === null) { - throw new Error('useSip must be used within a SipProvider'); - } - return ctx; +// Hook for components to access SIP actions + state +export const useSip = () => { + const [connectionStatus] = useAtom(sipConnectionStatusAtom); + const [callState] = useAtom(sipCallStateAtom); + const [callerNumber, setCallerNumber] = useAtom(sipCallerNumberAtom); + const [isMuted, setIsMuted] = useAtom(sipIsMutedAtom); + const [isOnHold, setIsOnHold] = useAtom(sipIsOnHoldAtom); + const [callDuration] = useAtom(sipCallDurationAtom); + + const makeCall = useCallback((phoneNumber: string) => { + getSipClient()?.call(phoneNumber); + setCallerNumber(phoneNumber); + }, [setCallerNumber]); + + const answer = useCallback(() => getSipClient()?.answer(), []); + const reject = useCallback(() => getSipClient()?.reject(), []); + const hangup = useCallback(() => getSipClient()?.hangup(), []); + + const toggleMute = useCallback(() => { + if (isMuted) { + getSipClient()?.unmute(); + } else { + getSipClient()?.mute(); + } + setIsMuted(!isMuted); + }, [isMuted, setIsMuted]); + + const toggleHold = useCallback(() => { + if (isOnHold) { + getSipClient()?.unhold(); + } else { + getSipClient()?.hold(); + } + setIsOnHold(!isOnHold); + }, [isOnHold, setIsOnHold]); + + return { + connectionStatus, + callState, + callerNumber, + isMuted, + isOnHold, + callDuration, + isRegistered: connectionStatus === 'registered', + isInCall: ['ringing-in', 'ringing-out', 'active'].includes(callState), + ozonetelStatus: 'logged-in' as const, + ozonetelError: null as string | null, + connect: () => connectSip(DEFAULT_CONFIG), + disconnect: disconnectSip, + makeCall, + answer, + reject, + hangup, + toggleMute, + toggleHold, + }; }; diff --git a/src/state/sip-manager.ts b/src/state/sip-manager.ts new file mode 100644 index 0000000..a38b145 --- /dev/null +++ b/src/state/sip-manager.ts @@ -0,0 +1,58 @@ +import { SIPClient } from '@/lib/sip-client'; +import type { SIPConfig, ConnectionStatus, CallState } from '@/types/sip'; + +// Singleton SIP client — survives React StrictMode remounts +let sipClient: SIPClient | null = null; +let connected = false; + +type StateUpdater = { + setConnectionStatus: (status: ConnectionStatus) => void; + setCallState: (state: CallState) => void; + setCallerNumber: (number: string | null) => void; +}; + +let stateUpdater: StateUpdater | null = null; + +export function registerSipStateUpdater(updater: StateUpdater) { + stateUpdater = updater; +} + +export function connectSip(config: SIPConfig): void { + if (connected || sipClient?.isRegistered() || sipClient?.isConnected()) { + return; + } + + if (!config.wsServer || !config.uri) { + console.warn('SIP config incomplete — wsServer and uri required'); + return; + } + + if (sipClient) { + sipClient.disconnect(); + } + + connected = true; + stateUpdater?.setConnectionStatus('connecting'); + + sipClient = new SIPClient( + config, + (status) => stateUpdater?.setConnectionStatus(status), + (state, number) => { + stateUpdater?.setCallState(state); + if (number !== undefined) stateUpdater?.setCallerNumber(number ?? null); + }, + ); + + sipClient.connect(); +} + +export function disconnectSip(): void { + sipClient?.disconnect(); + sipClient = null; + connected = false; + stateUpdater?.setConnectionStatus('disconnected'); +} + +export function getSipClient(): SIPClient | null { + return sipClient; +} diff --git a/src/state/sip-state.ts b/src/state/sip-state.ts new file mode 100644 index 0000000..583d27c --- /dev/null +++ b/src/state/sip-state.ts @@ -0,0 +1,10 @@ +import { atom } from 'jotai'; +import type { ConnectionStatus, CallState } from '@/types/sip'; + +export const sipConnectionStatusAtom = atom('disconnected'); +export const sipCallStateAtom = atom('idle'); +export const sipCallerNumberAtom = atom(null); +export const sipIsMutedAtom = atom(false); +export const sipIsOnHoldAtom = atom(false); +export const sipCallDurationAtom = atom(0); +export const sipCallStartTimeAtom = atom(null);