mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-11 18:28:15 +00:00
feat: wire frontend to platform data, migrate to Jotai + Vercel AI SDK
- Replace mock DataProvider with real GraphQL queries through sidecar - Add queries.ts and transforms.ts for platform field name mapping - Migrate SIP state from React Context to Jotai atoms (React 19 compat) - Add singleton SIP manager to survive StrictMode remounts - Remove hardcoded Olivia/Sienna accounts from nav menu - Add password eye toggle, remember me checkbox, forgot password link - Fix worklist hook to transform platform field names - Add seed scripts for clinics, health packages, lab tests - Update test harness for new doctor→clinic relation Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
48
package-lock.json
generated
48
package-lock.json
generated
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
250
scripts/seed-lab-tests.ts
Normal file
250
scripts/seed-lab-tests.ts
Normal file
@@ -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<string, string> = { '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<string> {
|
||||
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<string, string> = {};
|
||||
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); });
|
||||
348
scripts/seed-new-entities.ts
Normal file
348
scripts/seed-new-entities.ts
Normal file
@@ -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<string, string> = { '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<string> {
|
||||
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<void> {
|
||||
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<string, string> = {
|
||||
'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<string, string> = {
|
||||
'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); });
|
||||
@@ -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)`);
|
||||
|
||||
// ═══════════════════════════════════════
|
||||
|
||||
@@ -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 = ({
|
||||
<div className="flex flex-col gap-0.5 py-1.5">
|
||||
<NavAccountCardMenuItem label="View profile" icon={User01} shortcut="⌘K->P" />
|
||||
<NavAccountCardMenuItem label="Account settings" icon={Settings01} shortcut="⌘S" />
|
||||
<NavAccountCardMenuItem label="Documentation" icon={BookOpen01} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 border-t border-secondary py-1.5">
|
||||
<div className="px-3 pt-1.5 pb-1 text-xs font-semibold text-tertiary">Switch account</div>
|
||||
|
||||
<div className="flex flex-col gap-0.5 px-1.5">
|
||||
{placeholderAccounts.map((account) => (
|
||||
<button
|
||||
key={account.id}
|
||||
className={cx(
|
||||
"relative w-full cursor-pointer rounded-md px-2 py-1.5 text-left outline-focus-ring hover:bg-primary_hover focus:z-10 focus-visible:outline-2 focus-visible:outline-offset-2",
|
||||
account.id === selectedAccountId && "bg-primary_hover",
|
||||
)}
|
||||
>
|
||||
<AvatarLabelGroup status="online" size="md" src={account.avatar} title={account.name} subtitle={account.email} />
|
||||
|
||||
<RadioButtonBase isSelected={account.id === selectedAccountId} className="absolute top-2 right-2" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 px-2 pt-0.5 pb-2">
|
||||
<Button iconLeading={Plus} color="secondary" size="sm">
|
||||
Add account
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -155,8 +111,8 @@ const NavAccountCardMenuItem = ({
|
||||
|
||||
export const NavAccountCard = ({
|
||||
popoverPlacement,
|
||||
selectedAccountId = "olivia",
|
||||
items = placeholderAccounts,
|
||||
selectedAccountId,
|
||||
items = [],
|
||||
onSignOut,
|
||||
}: {
|
||||
popoverPlacement?: Placement;
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
74
src/lib/queries.ts
Normal file
74
src/lib/queries.ts
Normal file
@@ -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
|
||||
} } } }`;
|
||||
152
src/lib/transforms.ts
Normal file
152
src/lib/transforms.ts
Normal file
@@ -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<string, any>;
|
||||
|
||||
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,
|
||||
}));
|
||||
}
|
||||
@@ -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<string | null>(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 = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Password input */}
|
||||
<div className="mt-4">
|
||||
{/* Password input with eye toggle */}
|
||||
<div className="mt-4 relative">
|
||||
<Input
|
||||
label="Password"
|
||||
type="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
placeholder="Enter your password"
|
||||
value={password}
|
||||
onChange={(value) => setPassword(value)}
|
||||
size="md"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-[38px] text-fg-quaternary hover:text-fg-secondary transition duration-100 ease-linear"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<FontAwesomeIcon icon={showPassword ? faEyeSlash : faEye} className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Remember me + Forgot password */}
|
||||
<div className="mt-3 flex items-center justify-between">
|
||||
<Checkbox
|
||||
label="Remember me"
|
||||
size="sm"
|
||||
isSelected={rememberMe}
|
||||
onChange={setRememberMe}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm font-semibold text-brand-secondary hover:text-brand-secondary_hover transition duration-100 ease-linear"
|
||||
onClick={() => setError('Password reset is not yet configured. Contact your administrator.')}
|
||||
>
|
||||
Forgot password?
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
|
||||
@@ -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<Lead>) => void;
|
||||
addCall: (call: Call) => void;
|
||||
refresh: () => void;
|
||||
};
|
||||
|
||||
const DataContext = createContext<DataContextType | undefined>(undefined);
|
||||
@@ -35,15 +54,56 @@ interface DataProviderProps {
|
||||
}
|
||||
|
||||
export const DataProvider = ({ children }: DataProviderProps) => {
|
||||
const [leads, setLeads] = useState<Lead[]>(mockLeads);
|
||||
const [campaigns] = useState<Campaign[]>(mockCampaigns);
|
||||
const [ads] = useState<Ad[]>(mockAds);
|
||||
const [followUps] = useState<FollowUp[]>(mockFollowUps);
|
||||
const [leadActivities] = useState<LeadActivity[]>(mockLeadActivities);
|
||||
const [templates] = useState<WhatsAppTemplate[]>(mockTemplates);
|
||||
const [agents] = useState<Agent[]>(mockAgents);
|
||||
const [calls, setCalls] = useState<Call[]>(mockCalls);
|
||||
const [ingestionSources] = useState<LeadIngestionSource[]>(mockIngestionSources);
|
||||
const [leads, setLeads] = useState<Lead[]>([]);
|
||||
const [campaigns, setCampaigns] = useState<Campaign[]>([]);
|
||||
const [ads, setAds] = useState<Ad[]>([]);
|
||||
const [followUps, setFollowUps] = useState<FollowUp[]>([]);
|
||||
const [leadActivities, setLeadActivities] = useState<LeadActivity[]>([]);
|
||||
const [calls, setCalls] = useState<Call[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// These don't have platform entities yet — empty for now
|
||||
const [templates] = useState<WhatsAppTemplate[]>([]);
|
||||
const [agents] = useState<Agent[]>([]);
|
||||
const [ingestionSources] = useState<LeadIngestionSource[]>([]);
|
||||
|
||||
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<any>(LEADS_QUERY),
|
||||
apiClient.graphql<any>(CAMPAIGNS_QUERY),
|
||||
apiClient.graphql<any>(ADS_QUERY),
|
||||
apiClient.graphql<any>(FOLLOW_UPS_QUERY),
|
||||
apiClient.graphql<any>(LEAD_ACTIVITIES_QUERY),
|
||||
apiClient.graphql<any>(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<Lead>) => {
|
||||
setLeads((prev) => prev.map((lead) => (lead.id === id ? { ...lead, ...updates } : lead)));
|
||||
@@ -54,7 +114,11 @@ export const DataProvider = ({ children }: DataProviderProps) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<DataContext.Provider value={{ leads, campaigns, ads, followUps, leadActivities, templates, agents, calls, ingestionSources, updateLead, addCall }}>
|
||||
<DataContext.Provider value={{
|
||||
leads, campaigns, ads, followUps, leadActivities, templates, agents, calls, ingestionSources,
|
||||
loading, error,
|
||||
updateLead, addCall, refresh: fetchData,
|
||||
}}>
|
||||
{children}
|
||||
</DataContext.Provider>
|
||||
);
|
||||
|
||||
@@ -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<typeof useSipPhone> & {
|
||||
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<SipContextType | null>(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<string | null>(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 (
|
||||
<SipContext.Provider value={{ ...sipPhone, ozonetelStatus, ozonetelError }}>
|
||||
{children}
|
||||
</SipContext.Provider>
|
||||
);
|
||||
// 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,
|
||||
};
|
||||
};
|
||||
|
||||
58
src/state/sip-manager.ts
Normal file
58
src/state/sip-manager.ts
Normal file
@@ -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;
|
||||
}
|
||||
10
src/state/sip-state.ts
Normal file
10
src/state/sip-state.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { atom } from 'jotai';
|
||||
import type { ConnectionStatus, CallState } from '@/types/sip';
|
||||
|
||||
export const sipConnectionStatusAtom = atom<ConnectionStatus>('disconnected');
|
||||
export const sipCallStateAtom = atom<CallState>('idle');
|
||||
export const sipCallerNumberAtom = atom<string | null>(null);
|
||||
export const sipIsMutedAtom = atom<boolean>(false);
|
||||
export const sipIsOnHoldAtom = atom<boolean>(false);
|
||||
export const sipCallDurationAtom = atom<number>(0);
|
||||
export const sipCallStartTimeAtom = atom<Date | null>(null);
|
||||
Reference in New Issue
Block a user