mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-11 18:28:15 +00:00
Compare commits
17 Commits
dev-kartik
...
a1598716ee
| Author | SHA1 | Date | |
|---|---|---|---|
| a1598716ee | |||
| c4b6f9a438 | |||
| 951acf59c5 | |||
| 8da431a6cd | |||
| 05de50f796 | |||
| 0fc9375729 | |||
| 6a2fc47226 | |||
| fb92da113e | |||
| 72012f099c | |||
| f57fbc1f24 | |||
| efe67dc28b | |||
| a287a97fe4 | |||
| a7b2fd7fbe | |||
| 4420b648d4 | |||
| c1b636cb6d | |||
| 0f23e84737 | |||
| 82ec843c6c |
@@ -1,38 +0,0 @@
|
|||||||
# Build outputs
|
|
||||||
dist/
|
|
||||||
build/
|
|
||||||
.vite/
|
|
||||||
|
|
||||||
# Dependencies
|
|
||||||
node_modules/
|
|
||||||
|
|
||||||
# Lock files (large, rarely useful)
|
|
||||||
package-lock.json
|
|
||||||
bun.lock
|
|
||||||
yarn.lock
|
|
||||||
|
|
||||||
# Generated / cache
|
|
||||||
nanobanana-output/
|
|
||||||
*.tsbuildinfo
|
|
||||||
.cache/
|
|
||||||
|
|
||||||
# Design / static assets
|
|
||||||
public/
|
|
||||||
src/components/shared-assets/
|
|
||||||
|
|
||||||
# Type declaration outputs
|
|
||||||
**/*.d.ts
|
|
||||||
|
|
||||||
# Logs
|
|
||||||
*.log
|
|
||||||
npm-debug.log*
|
|
||||||
|
|
||||||
# OS files
|
|
||||||
.DS_Store
|
|
||||||
Thumbs.db
|
|
||||||
|
|
||||||
# GitHub workflows (not relevant to code tasks)
|
|
||||||
.github/
|
|
||||||
|
|
||||||
# Scripts (deployment/utility scripts rarely needed)
|
|
||||||
scripts/
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
npx lint-staged
|
|
||||||
@@ -5,10 +5,7 @@
|
|||||||
"@trivago/prettier-plugin-sort-imports",
|
"@trivago/prettier-plugin-sort-imports",
|
||||||
"prettier-plugin-tailwindcss"
|
"prettier-plugin-tailwindcss"
|
||||||
],
|
],
|
||||||
"tailwindFunctions": [
|
"tailwindFunctions": ["sortCx", "cx"],
|
||||||
"sortCx",
|
|
||||||
"cx"
|
|
||||||
],
|
|
||||||
"importOrder": [
|
"importOrder": [
|
||||||
"^react$",
|
"^react$",
|
||||||
"^react-dom$",
|
"^react-dom$",
|
||||||
@@ -19,4 +16,4 @@
|
|||||||
"importOrderSeparation": false,
|
"importOrderSeparation": false,
|
||||||
"importOrderSortSpecifiers": true,
|
"importOrderSortSpecifiers": true,
|
||||||
"tailwindStylesheet": "./src/styles/globals.css"
|
"tailwindStylesheet": "./src/styles/globals.css"
|
||||||
}
|
}
|
||||||
|
|||||||
24
README.md
24
README.md
@@ -39,16 +39,15 @@ npm run build # TypeScript check + production build
|
|||||||
|
|
||||||
### Environment Variables (set at build time or in `.env`)
|
### Environment Variables (set at build time or in `.env`)
|
||||||
|
|
||||||
| Variable | Purpose | Dev Default | Production |
|
| Variable | Purpose | Dev Default | Production |
|
||||||
| -------------------- | ---------------- | ----------------------- | ------------------------------------------- |
|
|----------|---------|-------------|------------|
|
||||||
| `VITE_API_URL` | Platform GraphQL | `http://localhost:4000` | `https://engage-api.srv1477139.hstgr.cloud` |
|
| `VITE_API_URL` | Platform GraphQL | `http://localhost:4000` | `https://engage-api.srv1477139.hstgr.cloud` |
|
||||||
| `VITE_SIDECAR_URL` | Sidecar REST API | `http://localhost:4100` | `https://engage-api.srv1477139.hstgr.cloud` |
|
| `VITE_SIDECAR_URL` | Sidecar REST API | `http://localhost:4100` | `https://engage-api.srv1477139.hstgr.cloud` |
|
||||||
| `VITE_SIP_URI` | Ozonetel SIP URI | — | `sip:523590@blr-pub-rtc4.ozonetel.com` |
|
| `VITE_SIP_URI` | Ozonetel SIP URI | — | `sip:523590@blr-pub-rtc4.ozonetel.com` |
|
||||||
| `VITE_SIP_PASSWORD` | SIP password | — | `523590` |
|
| `VITE_SIP_PASSWORD` | SIP password | — | `523590` |
|
||||||
| `VITE_SIP_WS_SERVER` | SIP WebSocket | — | `wss://blr-pub-rtc4.ozonetel.com:444` |
|
| `VITE_SIP_WS_SERVER` | SIP WebSocket | — | `wss://blr-pub-rtc4.ozonetel.com:444` |
|
||||||
|
|
||||||
**Production build command:**
|
**Production build command:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
VITE_API_URL=https://engage-api.srv1477139.hstgr.cloud \
|
VITE_API_URL=https://engage-api.srv1477139.hstgr.cloud \
|
||||||
VITE_SIDECAR_URL=https://engage-api.srv1477139.hstgr.cloud \
|
VITE_SIDECAR_URL=https://engage-api.srv1477139.hstgr.cloud \
|
||||||
@@ -124,42 +123,34 @@ src/
|
|||||||
## Troubleshooting Guide — Where to Look
|
## Troubleshooting Guide — Where to Look
|
||||||
|
|
||||||
### "The call desk isn't working"
|
### "The call desk isn't working"
|
||||||
|
|
||||||
**File:** `src/pages/call-desk.tsx`
|
**File:** `src/pages/call-desk.tsx`
|
||||||
This is the orchestrator. It uses `useSip()` for call state, `useWorklist()` for the task queue, and renders either `ActiveCallCard` (in-call) or `WorklistPanel` (idle). Start here, then drill into whichever child component is misbehaving.
|
This is the orchestrator. It uses `useSip()` for call state, `useWorklist()` for the task queue, and renders either `ActiveCallCard` (in-call) or `WorklistPanel` (idle). Start here, then drill into whichever child component is misbehaving.
|
||||||
|
|
||||||
### "Calls aren't connecting / SIP errors"
|
### "Calls aren't connecting / SIP errors"
|
||||||
|
|
||||||
**File:** `src/providers/sip-provider.tsx` + `src/state/sip-state.ts`
|
**File:** `src/providers/sip-provider.tsx` + `src/state/sip-state.ts`
|
||||||
Check `VITE_SIP_*` env vars. Ozonetel SIP WebSocket runs on **port 444** — VPNs block it. If WebSocket hangs at "connecting", turn off VPN. Also check browser console for SIP.js registration errors.
|
Check `VITE_SIP_*` env vars. Ozonetel SIP WebSocket runs on **port 444** — VPNs block it. If WebSocket hangs at "connecting", turn off VPN. Also check browser console for SIP.js registration errors.
|
||||||
|
|
||||||
### "Worklist not loading / empty"
|
### "Worklist not loading / empty"
|
||||||
|
|
||||||
**File:** `src/hooks/use-worklist.ts`
|
**File:** `src/hooks/use-worklist.ts`
|
||||||
This polls `GET /api/worklist` on the sidecar every 30s. Open browser Network tab → filter for `/api/worklist`. Common causes: sidecar is down, auth token expired, or agent name doesn't match any assigned leads.
|
This polls `GET /api/worklist` on the sidecar every 30s. Open browser Network tab → filter for `/api/worklist`. Common causes: sidecar is down, auth token expired, or agent name doesn't match any assigned leads.
|
||||||
|
|
||||||
### "Missed calls not appearing / sub-tabs empty"
|
### "Missed calls not appearing / sub-tabs empty"
|
||||||
|
|
||||||
**File:** `src/components/call-desk/worklist-panel.tsx`
|
**File:** `src/components/call-desk/worklist-panel.tsx`
|
||||||
Missed calls come from the sidecar worklist response. The sub-tabs filter by `callbackstatus` field. If all sub-tabs are empty, the sidecar ingestion may not be running (check sidecar logs for `MissedQueueService`).
|
Missed calls come from the sidecar worklist response. The sub-tabs filter by `callbackstatus` field. If all sub-tabs are empty, the sidecar ingestion may not be running (check sidecar logs for `MissedQueueService`).
|
||||||
|
|
||||||
### "Disposition / appointment not saving"
|
### "Disposition / appointment not saving"
|
||||||
|
|
||||||
**File:** `src/components/call-desk/active-call-card.tsx` → `handleDisposition()`
|
**File:** `src/components/call-desk/active-call-card.tsx` → `handleDisposition()`
|
||||||
Posts to sidecar `POST /api/ozonetel/dispose`. Errors are caught silently (non-blocking). Check browser Network tab for the dispose request/response, then check sidecar logs.
|
Posts to sidecar `POST /api/ozonetel/dispose`. Errors are caught silently (non-blocking). Check browser Network tab for the dispose request/response, then check sidecar logs.
|
||||||
|
|
||||||
### "Login broken / Failed to fetch"
|
### "Login broken / Failed to fetch"
|
||||||
|
|
||||||
**File:** `src/pages/login.tsx` + `src/lib/api-client.ts`
|
**File:** `src/pages/login.tsx` + `src/lib/api-client.ts`
|
||||||
Login calls `apiClient.login()` → sidecar `/auth/login` → platform GraphQL. Most common cause: wrong `VITE_API_URL` (built with localhost instead of production URL). **Always set env vars at build time.**
|
Login calls `apiClient.login()` → sidecar `/auth/login` → platform GraphQL. Most common cause: wrong `VITE_API_URL` (built with localhost instead of production URL). **Always set env vars at build time.**
|
||||||
|
|
||||||
### "UI component looks wrong"
|
### "UI component looks wrong"
|
||||||
|
|
||||||
**Files:** `src/components/base/` (primitives), `src/components/application/` (complex)
|
**Files:** `src/components/base/` (primitives), `src/components/application/` (complex)
|
||||||
These come from the Untitled UI library. Design tokens are in `src/styles/theme.css`. Brand colors were rebuilt from logo blue `rgb(32, 96, 160)`.
|
These come from the Untitled UI library. Design tokens are in `src/styles/theme.css`. Brand colors were rebuilt from logo blue `rgb(32, 96, 160)`.
|
||||||
|
|
||||||
### "Navigation / role-based access"
|
### "Navigation / role-based access"
|
||||||
|
|
||||||
**File:** `src/components/layout/sidebar.tsx`
|
**File:** `src/components/layout/sidebar.tsx`
|
||||||
Navigation groups are defined per role (admin, cc-agent, executive). Routes are registered in `src/main.tsx`.
|
Navigation groups are defined per role (admin, cc-agent, executive). Routes are registered in `src/main.tsx`.
|
||||||
|
|
||||||
@@ -182,7 +173,6 @@ Component (e.g. ActiveCallCard)
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Key pattern:** The frontend talks to TWO backends:
|
**Key pattern:** The frontend talks to TWO backends:
|
||||||
|
|
||||||
1. **Sidecar** (REST) — for Ozonetel telephony operations and worklist
|
1. **Sidecar** (REST) — for Ozonetel telephony operations and worklist
|
||||||
2. **Platform** (GraphQL) — for entity CRUD (leads, appointments, patients)
|
2. **Platform** (GraphQL) — for entity CRUD (leads, appointments, patients)
|
||||||
|
|
||||||
|
|||||||
979
docs/superpowers/plans/2026-04-05-website-widget.md
Normal file
979
docs/superpowers/plans/2026-04-05-website-widget.md
Normal file
@@ -0,0 +1,979 @@
|
|||||||
|
# Website Widget — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Build an embeddable website widget (AI chat + appointment booking + lead capture) served from the sidecar, with HMAC-signed site keys, captcha protection, and theme integration.
|
||||||
|
|
||||||
|
**Architecture:** Sidecar gets a new `widget` module with endpoints for init, chat, booking, leads, and key management. A separate Preact-based widget bundle is built with Vite in library mode, served as a static file from the sidecar. The widget renders in a shadow DOM for CSS isolation and fetches theme/config via the site key.
|
||||||
|
|
||||||
|
**Tech Stack:** NestJS (sidecar endpoints), Preact + Vite (widget bundle), Shadow DOM, HMAC-SHA256 (key signing), reCAPTCHA v3 (captcha)
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-04-05-website-widget-design.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Map
|
||||||
|
|
||||||
|
### Sidecar (helix-engage-server)
|
||||||
|
|
||||||
|
| File | Action | Responsibility |
|
||||||
|
|---|---|---|
|
||||||
|
| `src/widget/widget.module.ts` | Create | NestJS module |
|
||||||
|
| `src/widget/widget.controller.ts` | Create | REST endpoints: init, chat, doctors, slots, book, lead |
|
||||||
|
| `src/widget/widget.service.ts` | Create | Lead creation, appointment booking, doctor queries |
|
||||||
|
| `src/widget/widget-keys.service.ts` | Create | HMAC key generation, validation, CRUD via Redis |
|
||||||
|
| `src/widget/widget-key.guard.ts` | Create | NestJS guard for key + origin validation |
|
||||||
|
| `src/widget/captcha.guard.ts` | Create | reCAPTCHA v3 token verification |
|
||||||
|
| `src/widget/widget.types.ts` | Create | Types for widget requests/responses |
|
||||||
|
| `src/auth/session.service.ts` | Modify | Add `setCachePersistent()` method |
|
||||||
|
| `src/app.module.ts` | Modify | Import WidgetModule |
|
||||||
|
| `src/main.ts` | Modify | Serve static widget.js file |
|
||||||
|
|
||||||
|
### Widget Bundle (new package)
|
||||||
|
|
||||||
|
| File | Action | Responsibility |
|
||||||
|
|---|---|---|
|
||||||
|
| `packages/helix-engage-widget/package.json` | Create | Package config |
|
||||||
|
| `packages/helix-engage-widget/vite.config.ts` | Create | Library mode, IIFE output |
|
||||||
|
| `packages/helix-engage-widget/tsconfig.json` | Create | TypeScript config |
|
||||||
|
| `packages/helix-engage-widget/src/main.ts` | Create | Entry: read data-key, init widget |
|
||||||
|
| `packages/helix-engage-widget/src/api.ts` | Create | HTTP client for widget endpoints |
|
||||||
|
| `packages/helix-engage-widget/src/widget.tsx` | Create | Shadow DOM mount, theming, tab routing |
|
||||||
|
| `packages/helix-engage-widget/src/chat.tsx` | Create | AI chatbot with streaming |
|
||||||
|
| `packages/helix-engage-widget/src/booking.tsx` | Create | Appointment booking flow |
|
||||||
|
| `packages/helix-engage-widget/src/contact.tsx` | Create | Lead capture form |
|
||||||
|
| `packages/helix-engage-widget/src/captcha.ts` | Create | reCAPTCHA v3 integration |
|
||||||
|
| `packages/helix-engage-widget/src/styles.ts` | Create | CSS-in-JS for shadow DOM |
|
||||||
|
| `packages/helix-engage-widget/src/types.ts` | Create | Shared types |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Widget Types + Key Service (Sidecar)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `helix-engage-server/src/widget/widget.types.ts`
|
||||||
|
- Create: `helix-engage-server/src/widget/widget-keys.service.ts`
|
||||||
|
- Modify: `helix-engage-server/src/auth/session.service.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add setCachePersistent to SessionService**
|
||||||
|
|
||||||
|
Add a method that sets a Redis key without TTL:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async setCachePersistent(key: string, value: string): Promise<void> {
|
||||||
|
await this.redis.set(key, value);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create widget.types.ts**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/widget/widget.types.ts
|
||||||
|
|
||||||
|
export type WidgetSiteKey = {
|
||||||
|
siteId: string;
|
||||||
|
hospitalName: string;
|
||||||
|
allowedOrigins: string[];
|
||||||
|
active: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WidgetInitResponse = {
|
||||||
|
brand: { name: string; logo: string };
|
||||||
|
colors: { primary: string; primaryLight: string; text: string; textLight: string };
|
||||||
|
captchaSiteKey: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WidgetBookRequest = {
|
||||||
|
departmentId: string;
|
||||||
|
doctorId: string;
|
||||||
|
scheduledAt: string;
|
||||||
|
patientName: string;
|
||||||
|
patientPhone: string;
|
||||||
|
age?: string;
|
||||||
|
gender?: string;
|
||||||
|
chiefComplaint?: string;
|
||||||
|
captchaToken: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WidgetLeadRequest = {
|
||||||
|
name: string;
|
||||||
|
phone: string;
|
||||||
|
interest?: string;
|
||||||
|
message?: string;
|
||||||
|
captchaToken: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WidgetChatRequest = {
|
||||||
|
messages: Array<{ role: string; content: string }>;
|
||||||
|
captchaToken?: string;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Create widget-keys.service.ts**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/widget/widget-keys.service.ts
|
||||||
|
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { createHmac, timingSafeEqual, randomUUID } from 'crypto';
|
||||||
|
import { SessionService } from '../auth/session.service';
|
||||||
|
import type { WidgetSiteKey } from './widget.types';
|
||||||
|
|
||||||
|
const KEY_PREFIX = 'widget:keys:';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WidgetKeysService {
|
||||||
|
private readonly logger = new Logger(WidgetKeysService.name);
|
||||||
|
private readonly secret: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private config: ConfigService,
|
||||||
|
private session: SessionService,
|
||||||
|
) {
|
||||||
|
this.secret = process.env.WIDGET_SECRET ?? config.get<string>('WIDGET_SECRET') ?? 'helix-widget-default-secret';
|
||||||
|
}
|
||||||
|
|
||||||
|
generateKey(hospitalName: string, allowedOrigins: string[]): { key: string; siteKey: WidgetSiteKey } {
|
||||||
|
const siteId = randomUUID().replace(/-/g, '').substring(0, 16);
|
||||||
|
const signature = this.sign(siteId);
|
||||||
|
const key = `${siteId}.${signature}`;
|
||||||
|
|
||||||
|
const siteKey: WidgetSiteKey = {
|
||||||
|
siteId,
|
||||||
|
hospitalName,
|
||||||
|
allowedOrigins,
|
||||||
|
active: true,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
return { key, siteKey };
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveKey(siteKey: WidgetSiteKey): Promise<void> {
|
||||||
|
await this.session.setCachePersistent(`${KEY_PREFIX}${siteKey.siteId}`, JSON.stringify(siteKey));
|
||||||
|
this.logger.log(`Widget key saved: ${siteKey.siteId} (${siteKey.hospitalName})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async validateKey(rawKey: string): Promise<WidgetSiteKey | null> {
|
||||||
|
const dotIndex = rawKey.indexOf('.');
|
||||||
|
if (dotIndex === -1) return null;
|
||||||
|
|
||||||
|
const siteId = rawKey.substring(0, dotIndex);
|
||||||
|
const signature = rawKey.substring(dotIndex + 1);
|
||||||
|
|
||||||
|
// Verify HMAC
|
||||||
|
const expected = this.sign(siteId);
|
||||||
|
try {
|
||||||
|
if (!timingSafeEqual(Buffer.from(signature, 'hex'), Buffer.from(expected, 'hex'))) return null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch from Redis
|
||||||
|
const data = await this.session.getCache(`${KEY_PREFIX}${siteId}`);
|
||||||
|
if (!data) return null;
|
||||||
|
|
||||||
|
const siteKey: WidgetSiteKey = JSON.parse(data);
|
||||||
|
if (!siteKey.active) return null;
|
||||||
|
|
||||||
|
return siteKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
validateOrigin(siteKey: WidgetSiteKey, origin: string | undefined): boolean {
|
||||||
|
if (!origin) return false;
|
||||||
|
if (siteKey.allowedOrigins.length === 0) return true;
|
||||||
|
return siteKey.allowedOrigins.some(allowed => origin.startsWith(allowed));
|
||||||
|
}
|
||||||
|
|
||||||
|
async listKeys(): Promise<WidgetSiteKey[]> {
|
||||||
|
const keys = await this.session.scanKeys(`${KEY_PREFIX}*`);
|
||||||
|
const results: WidgetSiteKey[] = [];
|
||||||
|
for (const key of keys) {
|
||||||
|
const data = await this.session.getCache(key);
|
||||||
|
if (data) results.push(JSON.parse(data));
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
async revokeKey(siteId: string): Promise<boolean> {
|
||||||
|
const data = await this.session.getCache(`${KEY_PREFIX}${siteId}`);
|
||||||
|
if (!data) return false;
|
||||||
|
const siteKey: WidgetSiteKey = JSON.parse(data);
|
||||||
|
siteKey.active = false;
|
||||||
|
await this.session.setCachePersistent(`${KEY_PREFIX}${siteId}`, JSON.stringify(siteKey));
|
||||||
|
this.logger.log(`Widget key revoked: ${siteId}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sign(data: string): string {
|
||||||
|
return createHmac('sha256', this.secret).update(data).digest('hex');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd helix-engage-server
|
||||||
|
git add src/widget/widget.types.ts src/widget/widget-keys.service.ts src/auth/session.service.ts
|
||||||
|
git commit -m "feat: widget types + HMAC key service with Redis storage"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Widget Guards (Key + Captcha)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `helix-engage-server/src/widget/widget-key.guard.ts`
|
||||||
|
- Create: `helix-engage-server/src/widget/captcha.guard.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create widget-key.guard.ts**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/widget/widget-key.guard.ts
|
||||||
|
|
||||||
|
import { CanActivate, ExecutionContext, Injectable, HttpException } from '@nestjs/common';
|
||||||
|
import { WidgetKeysService } from './widget-keys.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WidgetKeyGuard implements CanActivate {
|
||||||
|
constructor(private readonly keys: WidgetKeysService) {}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
const request = context.switchToHttp().getRequest();
|
||||||
|
const key = request.query?.key ?? request.headers['x-widget-key'];
|
||||||
|
|
||||||
|
if (!key) throw new HttpException('Widget key required', 401);
|
||||||
|
|
||||||
|
const siteKey = await this.keys.validateKey(key);
|
||||||
|
if (!siteKey) throw new HttpException('Invalid widget key', 403);
|
||||||
|
|
||||||
|
const origin = request.headers.origin ?? request.headers.referer;
|
||||||
|
if (!this.keys.validateOrigin(siteKey, origin)) {
|
||||||
|
throw new HttpException('Origin not allowed', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach to request for downstream use
|
||||||
|
request.widgetSiteKey = siteKey;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create captcha.guard.ts**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/widget/captcha.guard.ts
|
||||||
|
|
||||||
|
import { CanActivate, ExecutionContext, Injectable, HttpException, Logger } from '@nestjs/common';
|
||||||
|
|
||||||
|
const RECAPTCHA_VERIFY_URL = 'https://www.google.com/recaptcha/api/siteverify';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CaptchaGuard implements CanActivate {
|
||||||
|
private readonly logger = new Logger(CaptchaGuard.name);
|
||||||
|
private readonly secretKey: string;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.secretKey = process.env.RECAPTCHA_SECRET_KEY ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
if (!this.secretKey) {
|
||||||
|
this.logger.warn('RECAPTCHA_SECRET_KEY not set — captcha disabled');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = context.switchToHttp().getRequest();
|
||||||
|
const token = request.body?.captchaToken;
|
||||||
|
|
||||||
|
if (!token) throw new HttpException('Captcha token required', 400);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(RECAPTCHA_VERIFY_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: `secret=${this.secretKey}&response=${token}`,
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!data.success || (data.score != null && data.score < 0.3)) {
|
||||||
|
this.logger.warn(`Captcha failed: score=${data.score} success=${data.success}`);
|
||||||
|
throw new HttpException('Captcha verification failed', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err instanceof HttpException) throw err;
|
||||||
|
this.logger.error(`Captcha verification error: ${err.message}`);
|
||||||
|
return true; // Fail open if captcha service is down
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/widget/widget-key.guard.ts src/widget/captcha.guard.ts
|
||||||
|
git commit -m "feat: widget guards — HMAC key validation + reCAPTCHA v3"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Widget Controller + Service + Module (Sidecar)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `helix-engage-server/src/widget/widget.service.ts`
|
||||||
|
- Create: `helix-engage-server/src/widget/widget.controller.ts`
|
||||||
|
- Create: `helix-engage-server/src/widget/widget.module.ts`
|
||||||
|
- Modify: `helix-engage-server/src/app.module.ts`
|
||||||
|
- Modify: `helix-engage-server/src/main.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create widget.service.ts**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/widget/widget.service.ts
|
||||||
|
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import type { WidgetInitResponse, WidgetBookRequest, WidgetLeadRequest } from './widget.types';
|
||||||
|
import { ThemeService } from '../config/theme.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WidgetService {
|
||||||
|
private readonly logger = new Logger(WidgetService.name);
|
||||||
|
private readonly apiKey: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private platform: PlatformGraphqlService,
|
||||||
|
private theme: ThemeService,
|
||||||
|
private config: ConfigService,
|
||||||
|
) {
|
||||||
|
this.apiKey = config.get<string>('platform.apiKey') ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
getInitData(): WidgetInitResponse {
|
||||||
|
const t = this.theme.getTheme();
|
||||||
|
return {
|
||||||
|
brand: { name: t.brand.hospitalName, logo: t.brand.logo },
|
||||||
|
colors: {
|
||||||
|
primary: t.colors.brand['600'] ?? 'rgb(29 78 216)',
|
||||||
|
primaryLight: t.colors.brand['50'] ?? 'rgb(219 234 254)',
|
||||||
|
text: t.colors.brand['950'] ?? 'rgb(15 23 42)',
|
||||||
|
textLight: t.colors.brand['400'] ?? 'rgb(100 116 139)',
|
||||||
|
},
|
||||||
|
captchaSiteKey: process.env.RECAPTCHA_SITE_KEY ?? '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDoctors(): Promise<any[]> {
|
||||||
|
const auth = `Bearer ${this.apiKey}`;
|
||||||
|
const data = await this.platform.queryWithAuth<any>(
|
||||||
|
`{ doctors(first: 50) { edges { node {
|
||||||
|
id name fullName { firstName lastName } department specialty visitingHours
|
||||||
|
consultationFeeNew { amountMicros currencyCode }
|
||||||
|
clinic { clinicName }
|
||||||
|
} } } }`,
|
||||||
|
undefined, auth,
|
||||||
|
);
|
||||||
|
return data.doctors.edges.map((e: any) => e.node);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSlots(doctorId: string, date: string): Promise<any> {
|
||||||
|
const auth = `Bearer ${this.apiKey}`;
|
||||||
|
const data = await this.platform.queryWithAuth<any>(
|
||||||
|
`{ appointments(first: 50, filter: { doctorId: { eq: "${doctorId}" }, scheduledAt: { gte: "${date}T00:00:00Z", lte: "${date}T23:59:59Z" } }) { edges { node { scheduledAt } } } }`,
|
||||||
|
undefined, auth,
|
||||||
|
);
|
||||||
|
const booked = data.appointments.edges.map((e: any) => {
|
||||||
|
const dt = new Date(e.node.scheduledAt);
|
||||||
|
return `${dt.getHours().toString().padStart(2, '0')}:${dt.getMinutes().toString().padStart(2, '0')}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const allSlots = ['09:00', '09:30', '10:00', '10:30', '11:00', '11:30', '14:00', '14:30', '15:00', '15:30', '16:00'];
|
||||||
|
return allSlots.map(s => ({ time: s, available: !booked.includes(s) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async bookAppointment(req: WidgetBookRequest): Promise<{ appointmentId: string; reference: string }> {
|
||||||
|
const auth = `Bearer ${this.apiKey}`;
|
||||||
|
const phone = req.patientPhone.replace(/[^0-9]/g, '').slice(-10);
|
||||||
|
|
||||||
|
// Create or find patient
|
||||||
|
let patientId: string | null = null;
|
||||||
|
try {
|
||||||
|
const existing = await this.platform.queryWithAuth<any>(
|
||||||
|
`{ patients(first: 1, filter: { phones: { primaryPhoneNumber: { like: "%${phone}" } } }) { edges { node { id } } } }`,
|
||||||
|
undefined, auth,
|
||||||
|
);
|
||||||
|
patientId = existing.patients.edges[0]?.node?.id ?? null;
|
||||||
|
} catch { /* continue */ }
|
||||||
|
|
||||||
|
if (!patientId) {
|
||||||
|
const created = await this.platform.queryWithAuth<any>(
|
||||||
|
`mutation($data: PatientCreateInput!) { createPatient(data: $data) { id } }`,
|
||||||
|
{ data: {
|
||||||
|
fullName: { firstName: req.patientName.split(' ')[0], lastName: req.patientName.split(' ').slice(1).join(' ') || '' },
|
||||||
|
phones: { primaryPhoneNumber: `+91${phone}` },
|
||||||
|
patientType: 'NEW',
|
||||||
|
} },
|
||||||
|
auth,
|
||||||
|
);
|
||||||
|
patientId = created.createPatient.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create appointment
|
||||||
|
const appt = await this.platform.queryWithAuth<any>(
|
||||||
|
`mutation($data: AppointmentCreateInput!) { createAppointment(data: $data) { id } }`,
|
||||||
|
{ data: {
|
||||||
|
scheduledAt: req.scheduledAt,
|
||||||
|
durationMin: 30,
|
||||||
|
appointmentType: 'CONSULTATION',
|
||||||
|
status: 'SCHEDULED',
|
||||||
|
doctorId: req.doctorId,
|
||||||
|
department: req.departmentId,
|
||||||
|
reasonForVisit: req.chiefComplaint ?? '',
|
||||||
|
patientId,
|
||||||
|
} },
|
||||||
|
auth,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create lead
|
||||||
|
await this.platform.queryWithAuth<any>(
|
||||||
|
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
|
||||||
|
{ data: {
|
||||||
|
name: req.patientName,
|
||||||
|
contactName: { firstName: req.patientName.split(' ')[0], lastName: req.patientName.split(' ').slice(1).join(' ') || '' },
|
||||||
|
contactPhone: { primaryPhoneNumber: `+91${phone}` },
|
||||||
|
source: 'WEBSITE',
|
||||||
|
status: 'APPOINTMENT_SET',
|
||||||
|
interestedService: req.chiefComplaint ?? 'Appointment Booking',
|
||||||
|
patientId,
|
||||||
|
} },
|
||||||
|
auth,
|
||||||
|
).catch(err => this.logger.warn(`Widget lead creation failed: ${err}`));
|
||||||
|
|
||||||
|
const reference = appt.createAppointment.id.substring(0, 8).toUpperCase();
|
||||||
|
this.logger.log(`Widget booking: ${req.patientName} → ${req.doctorId} at ${req.scheduledAt} (ref: ${reference})`);
|
||||||
|
|
||||||
|
return { appointmentId: appt.createAppointment.id, reference };
|
||||||
|
}
|
||||||
|
|
||||||
|
async createLead(req: WidgetLeadRequest): Promise<{ leadId: string }> {
|
||||||
|
const auth = `Bearer ${this.apiKey}`;
|
||||||
|
const phone = req.phone.replace(/[^0-9]/g, '').slice(-10);
|
||||||
|
|
||||||
|
const data = await this.platform.queryWithAuth<any>(
|
||||||
|
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
|
||||||
|
{ data: {
|
||||||
|
name: req.name,
|
||||||
|
contactName: { firstName: req.name.split(' ')[0], lastName: req.name.split(' ').slice(1).join(' ') || '' },
|
||||||
|
contactPhone: { primaryPhoneNumber: `+91${phone}` },
|
||||||
|
source: 'WEBSITE',
|
||||||
|
status: 'NEW',
|
||||||
|
interestedService: req.interest ?? 'Website Enquiry',
|
||||||
|
} },
|
||||||
|
auth,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.logger.log(`Widget lead: ${req.name} (${phone}) — ${req.interest ?? 'general'}`);
|
||||||
|
return { leadId: data.createLead.id };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create widget.controller.ts**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/widget/widget.controller.ts
|
||||||
|
|
||||||
|
import { Controller, Get, Post, Delete, Body, Query, Param, Req, Res, UseGuards, Logger, HttpException } from '@nestjs/common';
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
import { WidgetService } from './widget.service';
|
||||||
|
import { WidgetKeysService } from './widget-keys.service';
|
||||||
|
import { WidgetKeyGuard } from './widget-key.guard';
|
||||||
|
import { CaptchaGuard } from './captcha.guard';
|
||||||
|
import { AiChatController } from '../ai/ai-chat.controller';
|
||||||
|
import type { WidgetBookRequest, WidgetLeadRequest } from './widget.types';
|
||||||
|
|
||||||
|
@Controller('api/widget')
|
||||||
|
export class WidgetController {
|
||||||
|
private readonly logger = new Logger(WidgetController.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly widget: WidgetService,
|
||||||
|
private readonly keys: WidgetKeysService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get('init')
|
||||||
|
@UseGuards(WidgetKeyGuard)
|
||||||
|
init() {
|
||||||
|
return this.widget.getInitData();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('doctors')
|
||||||
|
@UseGuards(WidgetKeyGuard)
|
||||||
|
async doctors() {
|
||||||
|
return this.widget.getDoctors();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('slots')
|
||||||
|
@UseGuards(WidgetKeyGuard)
|
||||||
|
async slots(@Query('doctorId') doctorId: string, @Query('date') date: string) {
|
||||||
|
if (!doctorId || !date) throw new HttpException('doctorId and date required', 400);
|
||||||
|
return this.widget.getSlots(doctorId, date);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('book')
|
||||||
|
@UseGuards(WidgetKeyGuard, CaptchaGuard)
|
||||||
|
async book(@Body() body: WidgetBookRequest) {
|
||||||
|
if (!body.patientName || !body.patientPhone || !body.doctorId || !body.scheduledAt) {
|
||||||
|
throw new HttpException('patientName, patientPhone, doctorId, and scheduledAt required', 400);
|
||||||
|
}
|
||||||
|
return this.widget.bookAppointment(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('lead')
|
||||||
|
@UseGuards(WidgetKeyGuard, CaptchaGuard)
|
||||||
|
async lead(@Body() body: WidgetLeadRequest) {
|
||||||
|
if (!body.name || !body.phone) {
|
||||||
|
throw new HttpException('name and phone required', 400);
|
||||||
|
}
|
||||||
|
return this.widget.createLead(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Key management (admin only — no widget key guard, requires JWT)
|
||||||
|
@Post('keys/generate')
|
||||||
|
async generateKey(@Body() body: { hospitalName: string; allowedOrigins: string[] }) {
|
||||||
|
if (!body.hospitalName) throw new HttpException('hospitalName required', 400);
|
||||||
|
const { key, siteKey } = this.keys.generateKey(body.hospitalName, body.allowedOrigins ?? []);
|
||||||
|
await this.keys.saveKey(siteKey);
|
||||||
|
return { key, siteKey };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('keys')
|
||||||
|
async listKeys() {
|
||||||
|
return this.keys.listKeys();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('keys/:siteId')
|
||||||
|
async revokeKey(@Param('siteId') siteId: string) {
|
||||||
|
const revoked = await this.keys.revokeKey(siteId);
|
||||||
|
if (!revoked) throw new HttpException('Key not found', 404);
|
||||||
|
return { status: 'revoked' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Create widget.module.ts**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/widget/widget.module.ts
|
||||||
|
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { WidgetController } from './widget.controller';
|
||||||
|
import { WidgetService } from './widget.service';
|
||||||
|
import { WidgetKeysService } from './widget-keys.service';
|
||||||
|
import { PlatformModule } from '../platform/platform.module';
|
||||||
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
import { ConfigThemeModule } from '../config/config-theme.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PlatformModule, AuthModule, ConfigThemeModule],
|
||||||
|
controllers: [WidgetController],
|
||||||
|
providers: [WidgetService, WidgetKeysService],
|
||||||
|
exports: [WidgetKeysService],
|
||||||
|
})
|
||||||
|
export class WidgetModule {}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Register in app.module.ts**
|
||||||
|
|
||||||
|
Add import:
|
||||||
|
```typescript
|
||||||
|
import { WidgetModule } from './widget/widget.module';
|
||||||
|
```
|
||||||
|
|
||||||
|
Add to imports array:
|
||||||
|
```typescript
|
||||||
|
WidgetModule,
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Serve static widget.js from main.ts**
|
||||||
|
|
||||||
|
In `src/main.ts`, after the NestJS app bootstrap, add static file serving for the widget bundle:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { join } from 'path';
|
||||||
|
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||||
|
|
||||||
|
// After app.listen():
|
||||||
|
app.useStaticAssets(join(__dirname, '..', 'public'), { prefix: '/' });
|
||||||
|
```
|
||||||
|
|
||||||
|
Create `helix-engage-server/public/` directory for the widget bundle output.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Build and verify**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd helix-engage-server && npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/widget/ src/app.module.ts src/main.ts public/
|
||||||
|
git commit -m "feat: widget module — endpoints, service, key management, captcha"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Widget Bundle — Project Setup + Entry Point
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `packages/helix-engage-widget/package.json`
|
||||||
|
- Create: `packages/helix-engage-widget/vite.config.ts`
|
||||||
|
- Create: `packages/helix-engage-widget/tsconfig.json`
|
||||||
|
- Create: `packages/helix-engage-widget/src/types.ts`
|
||||||
|
- Create: `packages/helix-engage-widget/src/api.ts`
|
||||||
|
- Create: `packages/helix-engage-widget/src/main.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create package.json**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "helix-engage-widget",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"preact": "^10.25.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@preact/preset-vite": "^2.9.0",
|
||||||
|
"typescript": "^5.7.0",
|
||||||
|
"vite": "^7.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create vite.config.ts**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import preact from '@preact/preset-vite';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [preact()],
|
||||||
|
build: {
|
||||||
|
lib: {
|
||||||
|
entry: 'src/main.ts',
|
||||||
|
name: 'HelixWidget',
|
||||||
|
fileName: () => 'widget.js',
|
||||||
|
formats: ['iife'],
|
||||||
|
},
|
||||||
|
outDir: '../../helix-engage-server/public',
|
||||||
|
emptyOutDir: false,
|
||||||
|
minify: 'terser',
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
inlineDynamicImports: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Create tsconfig.json**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"jsxImportSource": "preact",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"outDir": "dist",
|
||||||
|
"skipLibCheck": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Create types.ts**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/types.ts
|
||||||
|
|
||||||
|
export type WidgetConfig = {
|
||||||
|
brand: { name: string; logo: string };
|
||||||
|
colors: { primary: string; primaryLight: string; text: string; textLight: string };
|
||||||
|
captchaSiteKey: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Doctor = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
fullName: { firstName: string; lastName: string };
|
||||||
|
department: string;
|
||||||
|
specialty: string;
|
||||||
|
visitingHours: string;
|
||||||
|
consultationFeeNew: { amountMicros: number; currencyCode: string } | null;
|
||||||
|
clinic: { clinicName: string } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TimeSlot = {
|
||||||
|
time: string;
|
||||||
|
available: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ChatMessage = {
|
||||||
|
role: 'user' | 'assistant';
|
||||||
|
content: string;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Create api.ts**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/api.ts
|
||||||
|
|
||||||
|
import type { WidgetConfig, Doctor, TimeSlot } from './types';
|
||||||
|
|
||||||
|
let baseUrl = '';
|
||||||
|
let widgetKey = '';
|
||||||
|
|
||||||
|
export const initApi = (url: string, key: string) => {
|
||||||
|
baseUrl = url;
|
||||||
|
widgetKey = key;
|
||||||
|
};
|
||||||
|
|
||||||
|
const headers = () => ({
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Widget-Key': widgetKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const fetchInit = async (): Promise<WidgetConfig> => {
|
||||||
|
const res = await fetch(`${baseUrl}/api/widget/init?key=${widgetKey}`);
|
||||||
|
if (!res.ok) throw new Error('Widget init failed');
|
||||||
|
return res.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchDoctors = async (): Promise<Doctor[]> => {
|
||||||
|
const res = await fetch(`${baseUrl}/api/widget/doctors?key=${widgetKey}`);
|
||||||
|
if (!res.ok) throw new Error('Failed to load doctors');
|
||||||
|
return res.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchSlots = async (doctorId: string, date: string): Promise<TimeSlot[]> => {
|
||||||
|
const res = await fetch(`${baseUrl}/api/widget/slots?key=${widgetKey}&doctorId=${doctorId}&date=${date}`);
|
||||||
|
if (!res.ok) throw new Error('Failed to load slots');
|
||||||
|
return res.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const submitBooking = async (data: any): Promise<{ appointmentId: string; reference: string }> => {
|
||||||
|
const res = await fetch(`${baseUrl}/api/widget/book?key=${widgetKey}`, {
|
||||||
|
method: 'POST', headers: headers(), body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Booking failed');
|
||||||
|
return res.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const submitLead = async (data: any): Promise<{ leadId: string }> => {
|
||||||
|
const res = await fetch(`${baseUrl}/api/widget/lead?key=${widgetKey}`, {
|
||||||
|
method: 'POST', headers: headers(), body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Submission failed');
|
||||||
|
return res.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const streamChat = async (messages: any[], captchaToken?: string): Promise<ReadableStream> => {
|
||||||
|
const res = await fetch(`${baseUrl}/api/widget/chat?key=${widgetKey}`, {
|
||||||
|
method: 'POST', headers: headers(),
|
||||||
|
body: JSON.stringify({ messages, captchaToken }),
|
||||||
|
});
|
||||||
|
if (!res.ok || !res.body) throw new Error('Chat failed');
|
||||||
|
return res.body;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Create main.ts**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/main.ts
|
||||||
|
|
||||||
|
import { render } from 'preact';
|
||||||
|
import { initApi, fetchInit } from './api';
|
||||||
|
import { Widget } from './widget';
|
||||||
|
import type { WidgetConfig } from './types';
|
||||||
|
|
||||||
|
const init = async () => {
|
||||||
|
const script = document.querySelector('script[data-key]') as HTMLScriptElement | null;
|
||||||
|
if (!script) { console.error('[HelixWidget] Missing data-key attribute'); return; }
|
||||||
|
|
||||||
|
const key = script.getAttribute('data-key') ?? '';
|
||||||
|
const baseUrl = script.src.replace(/\/widget\.js.*$/, '');
|
||||||
|
|
||||||
|
initApi(baseUrl, key);
|
||||||
|
|
||||||
|
let config: WidgetConfig;
|
||||||
|
try {
|
||||||
|
config = await fetchInit();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[HelixWidget] Init failed:', err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create shadow DOM host
|
||||||
|
const host = document.createElement('div');
|
||||||
|
host.id = 'helix-widget-host';
|
||||||
|
host.style.cssText = 'position:fixed;bottom:20px;right:20px;z-index:999999;';
|
||||||
|
document.body.appendChild(host);
|
||||||
|
|
||||||
|
const shadow = host.attachShadow({ mode: 'open' });
|
||||||
|
const mountPoint = document.createElement('div');
|
||||||
|
shadow.appendChild(mountPoint);
|
||||||
|
|
||||||
|
render(<Widget config={config} shadow={shadow} />, mountPoint);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
|
} else {
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7: Install dependencies and verify**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd packages/helix-engage-widget && npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 8: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add packages/helix-engage-widget/
|
||||||
|
git commit -m "feat: widget bundle — project setup, API client, entry point"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Widget UI Components (Preact)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `packages/helix-engage-widget/src/styles.ts`
|
||||||
|
- Create: `packages/helix-engage-widget/src/widget.tsx`
|
||||||
|
- Create: `packages/helix-engage-widget/src/chat.tsx`
|
||||||
|
- Create: `packages/helix-engage-widget/src/booking.tsx`
|
||||||
|
- Create: `packages/helix-engage-widget/src/contact.tsx`
|
||||||
|
- Create: `packages/helix-engage-widget/src/captcha.ts`
|
||||||
|
|
||||||
|
These are the Preact components rendered inside the shadow DOM. Each component is self-contained.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create styles.ts** — CSS string injected into shadow DOM
|
||||||
|
- [ ] **Step 2: Create widget.tsx** — Main shell with bubble, panel, tab routing
|
||||||
|
- [ ] **Step 3: Create chat.tsx** — AI chat with streaming, quick actions, lead capture fallback
|
||||||
|
- [ ] **Step 4: Create booking.tsx** — Step-by-step appointment booking
|
||||||
|
- [ ] **Step 5: Create contact.tsx** — Simple lead capture form
|
||||||
|
- [ ] **Step 6: Create captcha.ts** — Load reCAPTCHA script, get token
|
||||||
|
|
||||||
|
Each component follows the pattern: fetch data from API, render form/chat, submit with captcha token.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Build the widget**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd packages/helix-engage-widget && npm run build
|
||||||
|
# Output: ../../helix-engage-server/public/widget.js
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 8: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add packages/helix-engage-widget/src/
|
||||||
|
git commit -m "feat: widget UI — chat, booking, contact, theming, shadow DOM"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Integration Test + Key Generation
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- None new — testing the full flow
|
||||||
|
|
||||||
|
- [ ] **Step 1: Generate a site key**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST http://localhost:4100/api/widget/keys/generate \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"hospitalName":"Global Hospital","allowedOrigins":["http://localhost:3000","http://localhost:5173"]}' | python3 -m json.tool
|
||||||
|
```
|
||||||
|
|
||||||
|
Save the returned `key` value.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Test init endpoint**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s "http://localhost:4100/api/widget/init?key=SITE_KEY_HERE" | python3 -m json.tool
|
||||||
|
```
|
||||||
|
|
||||||
|
Should return theme config with brand name, colors, captcha site key.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Test widget.js serving**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -o /dev/null -w "%{http_code}" http://localhost:4100/widget.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Should return 200.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Create a test HTML page**
|
||||||
|
|
||||||
|
Create `packages/helix-engage-widget/test.html`:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head><title>Widget Test</title></head>
|
||||||
|
<body>
|
||||||
|
<h1>Hospital Website</h1>
|
||||||
|
<p>This is a test page for the Helix Engage widget.</p>
|
||||||
|
<script src="http://localhost:4100/widget.js" data-key="SITE_KEY_HERE"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
```
|
||||||
|
|
||||||
|
Open in browser, verify the floating bubble appears, themed correctly.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Test booking flow end-to-end**
|
||||||
|
|
||||||
|
Click Book tab → select department → doctor → date → slot → fill name + phone → submit. Verify appointment + lead created in platform.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Build sidecar and commit all**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd helix-engage-server && npm run build
|
||||||
|
git add -A && git commit -m "feat: website widget — full integration (chat + booking + lead capture)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Execution Notes
|
||||||
|
|
||||||
|
- The widget bundle builds into `helix-engage-server/public/widget.js` — Vite outputs directly to the sidecar's public dir
|
||||||
|
- The sidecar serves it via Express static middleware
|
||||||
|
- Site keys use HMAC-SHA256 with `WIDGET_SECRET` env var
|
||||||
|
- Captcha is gated by `RECAPTCHA_SECRET_KEY` env var — if not set, captcha is disabled (dev mode)
|
||||||
|
- All widget endpoints use the server-side API key for platform queries (not the visitor's JWT)
|
||||||
|
- The widget has no dependency on the main helix-engage frontend — completely standalone
|
||||||
|
- Task 5 steps are intentionally less detailed — the UI components follow standard Preact patterns and depend on the API client from Task 4
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
# Hospital Onboarding & Self-Service Setup
|
||||||
|
|
||||||
|
**Date:** 2026-04-06
|
||||||
|
**Status:** Plan — pending implementation
|
||||||
|
**Owner:** helix-engage
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Make onboarding a new hospital a one-command devops action plus a guided self-service flow inside the staff portal. After running the script, the hospital admin should be able to log into a fresh workspace and reach a fully operational call center by filling in 6 setup pages — without anyone touching env vars, JSON files, or running shell commands a second time.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Per-tenant secrets management (env vars stay infra-owned for now).
|
||||||
|
- Self-service Cloudflare Turnstile / Ozonetel account provisioning. Operator pastes pre-existing credentials.
|
||||||
|
- Multi-hospital routing inside one sidecar. One sidecar per workspace; multi-tenancy is handled by the platform.
|
||||||
|
- Bulk CSV import of doctors / staff. Single-row form CRUD only.
|
||||||
|
- Email infrastructure for invitations beyond what core already does.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## User journey
|
||||||
|
|
||||||
|
### T0 — devops, one-command bootstrap (~30 seconds)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./onboard-hospital.sh \
|
||||||
|
--create \
|
||||||
|
--display-name "Care Hospital" \
|
||||||
|
--subdomain care \
|
||||||
|
--admin-email admin@carehospital.com \
|
||||||
|
--admin-password 'TempCare#2026'
|
||||||
|
```
|
||||||
|
|
||||||
|
Script signs up the admin user, creates and activates the workspace, syncs the helix-engage SDK, mints an API key, writes a sidecar `.env`, and prints a credentials handoff block. Done.
|
||||||
|
|
||||||
|
### T1 — hospital admin first login (~10 minutes)
|
||||||
|
|
||||||
|
Admin opens the workspace URL, signs in with the temp password. App detects an unconfigured workspace and routes them to `/setup`. A 6-step wizard walks them through:
|
||||||
|
|
||||||
|
1. **Hospital identity** — confirm display name, upload logo, pick brand colors → writes to `theme.json`
|
||||||
|
2. **Clinics** — add at least one branch (name, address, phone, timings) → creates Clinic records on platform
|
||||||
|
3. **Doctors** — add at least one doctor (name, specialty, clinic, visiting hours) → creates Doctor records on platform
|
||||||
|
4. **Team** — create supervisors and CC agents **in place** (name, email, temp password, role). If the role is `HelixEngage User` the form also shows a SIP seat dropdown so the admin links the new employee to an Agent profile in the same step. Posts to sidecar `POST /api/team/members` which chains `signUpInWorkspace` (using the workspace's own `inviteHash` server-side — no email is sent) → `updateWorkspaceMember` → `updateWorkspaceMemberRole` → optional `updateAgent`. **Never uses `sendInvitations`** — see `feedback-no-invites` memory for the absolute rule.
|
||||||
|
5. **Telephony** — read-only summary of which workspace members own which SIP seats. Seats themselves are seeded during onboarding (`onboard-hospital.sh` step 5b) and linked to members in step 4. Admin just confirms and advances.
|
||||||
|
6. **AI assistant** — pick provider (OpenAI / Anthropic), model, optional system prompt override → writes to `ai.json`
|
||||||
|
|
||||||
|
After step 6, admin clicks "Finish setup" and lands on the home dashboard. Setup state is recorded in `setup-state.json` so the wizard never auto-shows again.
|
||||||
|
|
||||||
|
### T2 — hospital admin returns later (any time)
|
||||||
|
|
||||||
|
Each setup page is also accessible standalone via the **Settings** menu. Admin can edit any of them at any time. Settings hub shows green checkmarks for completed sections and yellow badges for sections still using defaults.
|
||||||
|
|
||||||
|
### T3 — agents and supervisors join
|
||||||
|
|
||||||
|
The admin hands each employee their email + temp password directly (WhatsApp, in-person, etc.). Employees sign in, land on the home dashboard, and change their password from their profile. They're already role-assigned and (if CC agents) SIP-linked from T1 step 4, so they see the right pages — and can take calls — immediately.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture decisions
|
||||||
|
|
||||||
|
### 1. Script does identity. Portal does configuration.
|
||||||
|
|
||||||
|
- **In script:** anything requiring platform-admin credentials (signup, workspace activation, SDK sync, API key creation). One-time, devops-only.
|
||||||
|
- **In staff portal:** anything that operates inside the workspace (clinics, doctors, team, sidecar config files). Self-serve, repeatable.
|
||||||
|
|
||||||
|
This keeps the script's blast radius small and means the hospital admin never needs platform-admin access.
|
||||||
|
|
||||||
|
### 2. Two distinct frontend → backend patterns
|
||||||
|
|
||||||
|
**Pattern A — Direct GraphQL to platform** (for entities the platform owns)
|
||||||
|
- Clinics, Doctors, Workspace Members
|
||||||
|
- Frontend uses `apiClient.graphql<any>(...)` with the user's JWT
|
||||||
|
- Already established by `settings.tsx` for member listing
|
||||||
|
- No sidecar code needed
|
||||||
|
|
||||||
|
**Pattern B — Sidecar admin endpoints** (for sidecar-owned config files)
|
||||||
|
- Theme (`theme.json`), Widget (`widget.json`), Telephony (`telephony.json`), AI (`ai.json`), Setup state (`setup-state.json`)
|
||||||
|
- Frontend uses `apiClient.fetch('/api/config/...')`
|
||||||
|
- Sidecar persists to disk via `*ConfigService` mirroring `ThemeService`
|
||||||
|
- Already established by `branding-settings.tsx` and `WidgetConfigService`
|
||||||
|
|
||||||
|
**Rule:** if it lives in a workspace schema on the platform, use Pattern A. If it's a sidecar config file, use Pattern B. Don't mix.
|
||||||
|
|
||||||
|
### 3. Telephony config moves out of env vars
|
||||||
|
|
||||||
|
`OZONETEL_*`, `SIP_*`, `EXOTEL_*` env vars become bootstrap defaults that seed `data/telephony.json` on first boot, then never read again. All runtime reads go through `TelephonyConfigService.getConfig()`. Six read sites refactor (auth.controller, ozonetel-agent.service, ozonetel-agent.controller, kookoo-ivr.controller, agent-config.service, maint.controller).
|
||||||
|
|
||||||
|
### 4. AI config moves out of env vars
|
||||||
|
|
||||||
|
Same pattern. `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` stay in env (true secrets), but `AI_PROVIDER` / `AI_MODEL` move to `data/ai.json`. `WidgetChatService` and any other AI-using services read from `AiConfigService`.
|
||||||
|
|
||||||
|
### 5. Setup state lives in its own file
|
||||||
|
|
||||||
|
`data/setup-state.json` tracks completion status for each of the 6 setup steps + a global `wizardDismissed` flag. Frontend reads it on app load to decide whether to show the setup wizard. Each setup page marks its step complete on save.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"wizardDismissed": false,
|
||||||
|
"steps": {
|
||||||
|
"identity": { "completed": false, "completedAt": null },
|
||||||
|
"clinics": { "completed": false, "completedAt": null },
|
||||||
|
"doctors": { "completed": false, "completedAt": null },
|
||||||
|
"team": { "completed": false, "completedAt": null },
|
||||||
|
"telephony": { "completed": false, "completedAt": null },
|
||||||
|
"ai": { "completed": false, "completedAt": null }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Members are created in place — **never** via email invitation
|
||||||
|
|
||||||
|
Absolute rule (see `feedback-no-invites` in memory): Helix Engage does not use the platform's `sendInvitations` flow for any reason, ever. Hospital admins are expected to onboard employees in person or over WhatsApp, hand out login credentials directly, and have the employee change the password on first login.
|
||||||
|
|
||||||
|
The sidecar exposes `POST /api/team/members` taking `{ firstName, lastName, email, password, roleId, agentId? }`. Server-side it chains:
|
||||||
|
|
||||||
|
1. `signUpInWorkspace(email, password, workspaceId, workspaceInviteHash)` — the platform's `isPublicInviteLinkEnabled` + `inviteHash` values are read once per boot and used to authorize the create. The hash is a server-side secret, never surfaced to the admin UI, and no email is sent.
|
||||||
|
2. `updateWorkspaceMember` — set first name / last name (the signUp mutation doesn't take them).
|
||||||
|
3. `updateWorkspaceMemberRole` — assign the role the admin picked.
|
||||||
|
4. `updateAgent` (optional) — link the new workspace member to the chosen Agent profile if the admin selected a SIP seat.
|
||||||
|
|
||||||
|
The Team wizard step and the `/settings/team` slideout both call this endpoint via the new `EmployeeCreateForm` component. The old `InviteMemberForm` and all `sendInvitations` call sites have been deleted.
|
||||||
|
|
||||||
|
### 7. Roles are auto-synced by SDK
|
||||||
|
|
||||||
|
`HelixEngage Manager`, `HelixEngage Supervisor`, and `HelixEngage User` roles are defined in `FortyTwoApps/helix-engage/src/roles/` and created automatically by `yarn app:sync`. The frontend's role dropdown in the team form queries the platform via `getRoles` and uses real role IDs (no email-pattern hacks). The "is this person a CC agent, so show the SIP seat dropdown?" check matches by the exact label `HelixEngage User` — see `CC_AGENT_ROLE_LABEL` in `wizard-step-team.tsx` / `team-settings.tsx`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Backend changes (helix-engage-server)
|
||||||
|
|
||||||
|
### New services / files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `src/config/setup-state.defaults.ts` | Type + defaults for `data/setup-state.json` |
|
||||||
|
| `src/config/setup-state.service.ts` | Load / get / mark step complete / dismiss wizard |
|
||||||
|
| `src/config/telephony.defaults.ts` | Type + defaults for `data/telephony.json` (Ozonetel + Exotel + SIP) |
|
||||||
|
| `src/config/telephony-config.service.ts` | File-backed CRUD; `onModuleInit` seeds from env vars on first boot |
|
||||||
|
| `src/config/ai.defaults.ts` | Type + defaults for `data/ai.json` |
|
||||||
|
| `src/config/ai-config.service.ts` | File-backed CRUD; seeds from env on first boot |
|
||||||
|
| `src/config/setup-state.controller.ts` | `GET /api/config/setup-state`, `PUT /api/config/setup-state/steps/:step`, `POST /api/config/setup-state/dismiss` |
|
||||||
|
| `src/config/telephony-config.controller.ts` | `GET/PUT /api/config/telephony` with secret masking on GET |
|
||||||
|
| `src/config/ai-config.controller.ts` | `GET/PUT /api/config/ai` with secret masking |
|
||||||
|
|
||||||
|
### Modified files
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|---|---|
|
||||||
|
| `src/config/config-theme.module.ts` | Register the 3 new services + 3 new controllers |
|
||||||
|
| `src/config/widget.defaults.ts` | Drop `hospitalName` field (the long-standing duplicate) |
|
||||||
|
| `src/config/widget-config.service.ts` | Inject `ThemeService`, read `brand.hospitalName` from theme at the 2 generateKey call sites |
|
||||||
|
| `src/widget/widget.service.ts` | `getInitData()` reads captcha site key from `WidgetConfigService` instead of `process.env.RECAPTCHA_SITE_KEY` |
|
||||||
|
| `src/auth/agent-config.service.ts:49` | Read `OZONETEL_CAMPAIGN_NAME` from `TelephonyConfigService` |
|
||||||
|
| `src/auth/auth.controller.ts:141, 255` | Read `OZONETEL_AGENT_PASSWORD` from `TelephonyConfigService` |
|
||||||
|
| `src/ozonetel/ozonetel-agent.service.ts:199, 235, 236` | Read `OZONETEL_DID`, `OZONETEL_SIP_ID` from `TelephonyConfigService` |
|
||||||
|
| `src/ozonetel/ozonetel-agent.controller.ts:39, 42, 192` | Same |
|
||||||
|
| `src/ozonetel/kookoo-ivr.controller.ts:11, 12` | Same |
|
||||||
|
| `src/maint/maint.controller.ts:27` | Same |
|
||||||
|
| `src/widget/widget-chat.service.ts` | Read `provider` and `model` from `AiConfigService` instead of `ConfigService` |
|
||||||
|
| `src/ai/ai-provider.ts` | Same — provider/model from config file, API keys still from env |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Frontend changes (helix-engage)
|
||||||
|
|
||||||
|
### New pages
|
||||||
|
|
||||||
|
| Page | Path | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `setup/setup-wizard.tsx` | `/setup` | 6-step wizard, auto-shown on first login when setup incomplete |
|
||||||
|
| `pages/clinics.tsx` | `/settings/clinics` | List + add/edit clinic records (slideout pattern) |
|
||||||
|
| `pages/doctors.tsx` | `/settings/doctors` | List + add/edit doctors, assign to clinics |
|
||||||
|
| `pages/team-settings.tsx` | `/settings/team` | Member list + invite form + role editor (replaces current `settings.tsx` member view) |
|
||||||
|
| `pages/telephony-settings.tsx` | `/settings/telephony` | Ozonetel + Exotel + SIP form (consumes `/api/config/telephony`) |
|
||||||
|
| `pages/ai-settings.tsx` | `/settings/ai` | AI provider/model/prompt form (consumes `/api/config/ai`) |
|
||||||
|
| `pages/widget-settings.tsx` | `/settings/widget` | Widget enabled/embed/captcha form (consumes `/api/config/widget`) |
|
||||||
|
| `pages/settings-hub.tsx` | `/settings` | Index page listing all setup sections with completion badges. Replaces current `settings.tsx`. |
|
||||||
|
|
||||||
|
### Modified pages
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|---|---|
|
||||||
|
| `src/pages/login.tsx` | After successful login, fetch `/api/config/setup-state`. If incomplete and user is workspace admin, redirect to `/setup`. Otherwise existing flow. |
|
||||||
|
| `src/pages/branding-settings.tsx` | On save, mark `identity` step complete via `PUT /api/config/setup-state/steps/identity` |
|
||||||
|
| `src/components/layout/sidebar.tsx` | Add Settings hub entry; remove direct links to individual settings pages from main nav (move them under Settings) |
|
||||||
|
| `src/providers/router-provider.tsx` | Register the 7 new routes |
|
||||||
|
| `src/pages/integrations.tsx` | Remove the Ozonetel + Exotel cards (functionality moves to `telephony-settings.tsx`); keep WhatsApp/FB/Google/website cards for now |
|
||||||
|
|
||||||
|
### New shared components
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `src/components/setup/wizard-shell.tsx` | Layout: progress bar, step navigation, footer with prev/next |
|
||||||
|
| `src/components/setup/wizard-step.tsx` | Single-step container — title, description, content slot, validation hook |
|
||||||
|
| `src/components/setup/section-card.tsx` | Settings hub section card with status badge |
|
||||||
|
| `src/components/forms/clinic-form.tsx` | Reused by clinics page + setup wizard step 2 |
|
||||||
|
| `src/components/forms/doctor-form.tsx` | Reused by doctors page + setup wizard step 3 |
|
||||||
|
| `src/components/forms/invite-member-form.tsx` | Reused by team page + setup wizard step 4 |
|
||||||
|
| `src/components/forms/telephony-form.tsx` | Reused by telephony settings + setup wizard step 5 |
|
||||||
|
| `src/components/forms/ai-form.tsx` | Reused by ai settings + setup wizard step 6 |
|
||||||
|
|
||||||
|
The pattern: each settings page renders the same form component the wizard step renders. Wizard steps just wrap the form in `<WizardStep>` and add prev/next navigation. Standalone settings pages wrap the form in a normal page layout. Form is the source of truth; wizard and settings page are two presentations of the same thing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Onboarding script changes
|
||||||
|
|
||||||
|
`onboard-hospital.sh` is already 90% there. Three minor changes:
|
||||||
|
|
||||||
|
1. **Drop the `--sidecar-env-out` default behavior** — print a structured "credentials handoff" block at the end with admin email, temp password, workspace URL, sidecar `.env` content. Operator copies what they need.
|
||||||
|
2. **Change the credentials block format** — make it copy-pasteable as a single email body so the operator can email it to the hospital owner directly.
|
||||||
|
3. **Add `setup-state.json` initialization** — the script writes a fresh `setup-state.json` to the sidecar's `data/` directory as part of step 6, so the first frontend load knows nothing is configured yet.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phasing
|
||||||
|
|
||||||
|
Each phase is a coherent commit. Don't ship phases out of order.
|
||||||
|
|
||||||
|
### Phase 1 — Backend foundations (config services + endpoints)
|
||||||
|
|
||||||
|
**Files:** 9 new + 4 modified backend files. No frontend.
|
||||||
|
|
||||||
|
- New services: `setup-state`, `telephony-config`, `ai-config`
|
||||||
|
- New defaults files for each
|
||||||
|
- New controllers for each
|
||||||
|
- Module wiring
|
||||||
|
- Drop `widget.json.hospitalName` (the original duplicate that started this whole thread)
|
||||||
|
- Migrate the 6 Ozonetel read sites to `TelephonyConfigService`
|
||||||
|
- Migrate the AI provider/model reads to `AiConfigService`
|
||||||
|
- First-boot env-var seeding: each new service reads its respective env vars on `onModuleInit` and writes them to its config file if the file doesn't exist
|
||||||
|
|
||||||
|
**Verifies:** sidecar still serves all existing endpoints, env-var-driven Ozonetel still works (because the seeding picks up the same values), `data/telephony.json` and `data/ai.json` exist on first boot.
|
||||||
|
|
||||||
|
**Estimate:** 4-5 hours.
|
||||||
|
|
||||||
|
### Phase 2 — Settings hub + first-run detection
|
||||||
|
|
||||||
|
**Files:** 2 new pages + 4 modified frontend files + new shared `section-card` component.
|
||||||
|
|
||||||
|
- `settings-hub.tsx` replaces `settings.tsx` as the `/settings` route
|
||||||
|
- Move the existing member-list view from `settings.tsx` into a new `team-settings.tsx` (read-only for now; invite + role editing comes in Phase 3)
|
||||||
|
- `login.tsx` fetches setup-state after successful login and redirects to `/setup` if incomplete
|
||||||
|
- `setup/setup-wizard.tsx` shell renders the 6 step containers (with placeholder content for now)
|
||||||
|
- Sidebar redesign: collapse all settings into one Settings entry that opens the hub
|
||||||
|
- Router updates to register the new routes
|
||||||
|
|
||||||
|
**Verifies:** clean login → setup wizard appearance for fresh workspace; Settings hub navigates to existing pages; nothing breaks for already-set-up workspaces.
|
||||||
|
|
||||||
|
**Estimate:** 3-4 hours.
|
||||||
|
|
||||||
|
### Phase 3 — Entity CRUD pages (Pattern A — direct platform GraphQL)
|
||||||
|
|
||||||
|
**Files:** 3 new pages + 3 new form components + 1 modified team page.
|
||||||
|
|
||||||
|
- `clinics.tsx` + `clinic-form.tsx` — list with add/edit slideout
|
||||||
|
- `doctors.tsx` + `doctor-form.tsx` — list with add/edit, clinic dropdown sourced from `clinics`
|
||||||
|
- `team-settings.tsx` becomes interactive — employees are created in place via the sidecar's `POST /api/team/members` endpoint (see architecture decision 6), real role dropdown via `getRoles`, role assignment via `updateWorkspaceMemberRole`. **Never uses `sendInvitations`.**
|
||||||
|
|
||||||
|
**Verifies:** admin can create clinics, doctors, and invite team members from the staff portal without touching the database.
|
||||||
|
|
||||||
|
**Estimate:** 5-6 hours.
|
||||||
|
|
||||||
|
### Phase 4 — Sidecar-config CRUD pages (Pattern B — sidecar admin endpoints)
|
||||||
|
|
||||||
|
**Files:** 3 new pages + 3 new form components.
|
||||||
|
|
||||||
|
- `telephony-settings.tsx` + `telephony-form.tsx` — Ozonetel + Exotel + SIP fields
|
||||||
|
- `ai-settings.tsx` + `ai-form.tsx` — provider, model, temperature, system prompt
|
||||||
|
- `widget-settings.tsx` + `widget-form.tsx` — wraps the existing widget config endpoint with a real form
|
||||||
|
|
||||||
|
**Verifies:** admin can edit telephony, AI, and widget config from the staff portal. Changes take effect without sidecar restart (since services use in-memory cache + file write).
|
||||||
|
|
||||||
|
**Estimate:** 4-5 hours.
|
||||||
|
|
||||||
|
### Phase 5 — Wizard step composition
|
||||||
|
|
||||||
|
**Files:** 6 wizard step components, each thin wrappers around the Phase 3/4 forms.
|
||||||
|
|
||||||
|
- `wizard-step-identity.tsx`
|
||||||
|
- `wizard-step-clinics.tsx`
|
||||||
|
- `wizard-step-doctors.tsx`
|
||||||
|
- `wizard-step-team.tsx`
|
||||||
|
- `wizard-step-telephony.tsx`
|
||||||
|
- `wizard-step-ai.tsx`
|
||||||
|
|
||||||
|
Each wraps the corresponding form, adds wizard validation (required fields enforced for setup completion), and on save calls `PUT /api/config/setup-state/steps/<step>` to mark the step complete.
|
||||||
|
|
||||||
|
**Verifies:** admin can complete the entire setup wizard end-to-end on a fresh workspace. After step 6, redirected to home dashboard. Setup state file shows all 6 steps complete.
|
||||||
|
|
||||||
|
**Estimate:** 2-3 hours.
|
||||||
|
|
||||||
|
### Phase 6 — Polish
|
||||||
|
|
||||||
|
- Onboarding script credentials handoff block format
|
||||||
|
- "Resume setup" CTA on home dashboard if any step is incomplete
|
||||||
|
- Loading states, error toasts, optimistic updates
|
||||||
|
- Setup-state badges on the Settings hub
|
||||||
|
- Validation: clinic count > 0 required for booking flow, doctor count > 0 required for booking flow, etc.
|
||||||
|
- E2E smoke test against the Care Hospital workspace I already created
|
||||||
|
|
||||||
|
**Estimate:** 2-3 hours.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Total estimate
|
||||||
|
|
||||||
|
**20-26 hours of focused implementation work** spanning ~30 new files and ~15 modified files. Realistic over 3-4 working days with checkpoints at each phase boundary.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Out of scope (explicit)
|
||||||
|
|
||||||
|
- Self-service Cloudflare Turnstile signup (operator pastes existing site key)
|
||||||
|
- Self-service Ozonetel account creation (operator pastes credentials)
|
||||||
|
- Bulk import of doctors / staff (single-row form only)
|
||||||
|
- Per-tenant secrets management (env vars stay infra-owned for AI keys, captcha secret, HMAC secret)
|
||||||
|
- Workspace deletion / archival
|
||||||
|
- Multi-hospital admin (one admin per workspace; switching workspaces is platform-level)
|
||||||
|
- Hospital templates ("clone from Ramaiah") — useful follow-up but not required for the first real onboarding
|
||||||
|
- Self-service password reset for employees (handled by the existing platform reset-password flow)
|
||||||
|
- Onboarding analytics / metrics
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open questions before phase 1
|
||||||
|
|
||||||
|
1. **Sidecar config file hot-reload** — when an admin updates `telephony.json` via the new endpoint, does the change need to take effect immediately (in-memory cache invalidation, no restart) or is a sidecar restart acceptable? Decision affects whether services need a "refresh" hook. **Recommendation: in-memory cache only, no restart needed** — already how `ThemeService` works.
|
||||||
|
|
||||||
|
2. **Setup state visibility** — should the setup-state file be a simple flag set or should it track *who* completed each step and *when*? Recommendation: track `completedAt` timestamp + `completedBy` user id for audit trail.
|
||||||
|
|
||||||
|
3. **Auto-mark "identity" step complete from existing branding** — if the workspace already has a `theme.json` with a non-default `brand.hospitalName`, should the wizard auto-skip step 1? **Recommendation: yes** — don't make admins re-confirm something they already configured.
|
||||||
|
|
||||||
|
4. **What if the admin tries to create an employee whose email already exists on the platform?** `signUpInWorkspace` will surface the platform's "email already exists" error, which the sidecar's `TeamService.extractGraphqlMessage` passes through to the toast. No "find or link existing user" path yet — if this comes up in practice, add a `findUserByEmail` preflight lookup before the `signUpInWorkspace` call.
|
||||||
|
|
||||||
|
5. **Logo upload** — do we accept a URL only (admin pastes a CDN link) or do we need real file upload to MinIO? **Recommendation: URL only for Phase 1**, file upload as Phase 6 polish.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- **`yarn app:sync` may sometimes fail to register HelixEngage roles cleanly** if a workspace was activated but never had its first sync — this would block the team page's role dropdown. Mitigation: script runs sync immediately after activation, before exiting.
|
||||||
|
- **Frontend role queries require user JWT, not API key** — `settings.tsx` already noted this with the "Roles are only accessible via user JWT" comment. The team-settings page has to use direct GraphQL with user auth, not the sidecar proxy.
|
||||||
|
- **Migrating Ozonetel env vars to a config file mid-session can break a running sidecar** if someone's actively using the call desk during deploy. Mitigation: deploy during low-usage window; the new service falls back to env vars if the config file is missing.
|
||||||
|
- **Setup wizard auto-redirect could trap users in a loop** if `setup-state.json` write fails. Mitigation: wizard always has a "Skip for now" link in the top right that sets `wizardDismissed: true`.
|
||||||
337
docs/superpowers/specs/2026-04-05-website-widget-design.md
Normal file
337
docs/superpowers/specs/2026-04-05-website-widget-design.md
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
# Website Widget — Embeddable AI Chat + Appointment Booking
|
||||||
|
|
||||||
|
**Date**: 2026-04-05
|
||||||
|
**Status**: Draft
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
A single JavaScript file that hospitals embed on their website via a `<script>` tag. Renders a floating chat bubble that opens to an AI chatbot (hospital knowledge base), appointment booking flow, and lead capture form. Themed to match the hospital's branding. All write endpoints are captcha-gated.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Embed Code
|
||||||
|
|
||||||
|
```html
|
||||||
|
<script src="https://engage-api.srv1477139.hstgr.cloud/widget.js"
|
||||||
|
data-key="a8f3e2b1.7c4d9e6f2a1b8c3d5e0f4a2b6c8d1e3f7a9b0c2d4e6f8a1b3c5d7e9f0a2b"></script>
|
||||||
|
```
|
||||||
|
|
||||||
|
The `data-key` is an HMAC-signed token: `{siteId}.{hmacSignature}`. Cannot be guessed or forged without the server-side secret.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Hospital Website (any tech stack)
|
||||||
|
└─ <script data-key="xxx"> loads widget.js from sidecar
|
||||||
|
└─ Widget initializes:
|
||||||
|
1. GET /api/widget/init?key=xxx → validates key, returns theme + config
|
||||||
|
2. Renders shadow DOM (CSS-isolated from host page)
|
||||||
|
3. All interactions go to /api/widget/* endpoints
|
||||||
|
|
||||||
|
Sidecar (helix-engage-server):
|
||||||
|
└─ src/widget/
|
||||||
|
├── widget.controller.ts — REST endpoints for the widget
|
||||||
|
├── widget.service.ts — lead creation, appointment booking, key validation
|
||||||
|
├── widget.guard.ts — HMAC key validation + origin check
|
||||||
|
├── captcha.guard.ts — reCAPTCHA/Turnstile verification
|
||||||
|
└── widget-keys.service.ts — generate/validate site keys
|
||||||
|
|
||||||
|
Widget Bundle:
|
||||||
|
└─ packages/helix-engage-widget/
|
||||||
|
├── src/
|
||||||
|
│ ├── main.ts — entry point, reads data-key, initializes
|
||||||
|
│ ├── widget.ts — shadow DOM mount, theming, tab routing
|
||||||
|
│ ├── chat.ts — AI chatbot (streaming)
|
||||||
|
│ ├── booking.ts — appointment booking flow
|
||||||
|
│ ├── contact.ts — lead capture form
|
||||||
|
│ ├── captcha.ts — captcha integration
|
||||||
|
│ ├── api.ts — HTTP client for widget endpoints
|
||||||
|
│ └── styles.ts — CSS-in-JS (injected into shadow DOM)
|
||||||
|
├── vite.config.ts — library mode, single IIFE bundle
|
||||||
|
└── package.json
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sidecar Endpoints
|
||||||
|
|
||||||
|
All prefixed with `/api/widget/`. Public endpoints validate the site key. Write endpoints require captcha.
|
||||||
|
|
||||||
|
| Method | Path | Auth | Captcha | Description |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| GET | `/init` | Key | No | Returns theme, config, captcha site key |
|
||||||
|
| POST | `/chat` | Key | Yes (first message only) | AI chat stream (same knowledge base as agent AI) |
|
||||||
|
| GET | `/doctors` | Key | No | Department + doctor list with visiting hours |
|
||||||
|
| GET | `/slots` | Key | No | Available time slots for a doctor + date |
|
||||||
|
| POST | `/book` | Key | Yes | Create appointment + lead + patient |
|
||||||
|
| POST | `/lead` | Key | Yes | Create lead (contact form submission) |
|
||||||
|
| POST | `/keys/generate` | Admin JWT | No | Generate a new site key for a hospital |
|
||||||
|
| GET | `/keys` | Admin JWT | No | List all site keys |
|
||||||
|
| DELETE | `/keys/:siteId` | Admin JWT | No | Revoke a site key |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Site Key System
|
||||||
|
|
||||||
|
### Generation
|
||||||
|
|
||||||
|
```
|
||||||
|
siteId = uuid v4 (random)
|
||||||
|
payload = siteId
|
||||||
|
signature = HMAC-SHA256(payload, SERVER_SECRET)
|
||||||
|
key = `${siteId}.${signature}`
|
||||||
|
```
|
||||||
|
|
||||||
|
The `SERVER_SECRET` is an environment variable on the sidecar. Never leaves the server.
|
||||||
|
|
||||||
|
### Validation
|
||||||
|
|
||||||
|
```
|
||||||
|
input = "a8f3e2b1.7c4d9e6f2a1b8c3d5e0f4a2b6c8d1e3f7a9b0c2d4e6f8a1b3c5d7e9f0a2b"
|
||||||
|
[siteId, signature] = input.split('.')
|
||||||
|
expectedSignature = HMAC-SHA256(siteId, SERVER_SECRET)
|
||||||
|
valid = timingSafeEqual(signature, expectedSignature)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Storage
|
||||||
|
|
||||||
|
Site keys are stored in Redis (already running in the stack):
|
||||||
|
|
||||||
|
```
|
||||||
|
Key: widget:keys:{siteId}
|
||||||
|
Value: JSON { hospitalName, allowedOrigins, active, createdAt }
|
||||||
|
TTL: none (persistent until revoked)
|
||||||
|
```
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```
|
||||||
|
widget:keys:a8f3e2b1 → {
|
||||||
|
"hospitalName": "Global Hospital",
|
||||||
|
"allowedOrigins": ["https://globalhospital.com", "https://www.globalhospital.com"],
|
||||||
|
"createdAt": "2026-04-05T10:00:00Z",
|
||||||
|
"active": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
CRUD via `SessionService` (getCache/setCache/deleteCache/scanKeys) — same pattern as caller cache and agent names.
|
||||||
|
|
||||||
|
### Origin Validation
|
||||||
|
|
||||||
|
On every widget request, the sidecar checks:
|
||||||
|
1. Key signature is valid (HMAC)
|
||||||
|
2. `siteId` exists and is active
|
||||||
|
3. `Referer` or `Origin` header matches `allowedOrigins` for this site key
|
||||||
|
4. If origin doesn't match → 403
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Widget UI
|
||||||
|
|
||||||
|
### Collapsed State (Floating Bubble)
|
||||||
|
|
||||||
|
- Position: fixed bottom-right, 20px margin
|
||||||
|
- Size: 56px circle
|
||||||
|
- Shows hospital logo (from theme)
|
||||||
|
- Pulse animation on first load
|
||||||
|
- Click → expands panel
|
||||||
|
- Z-index: 999999 (above host page content)
|
||||||
|
|
||||||
|
### Expanded State (Panel)
|
||||||
|
|
||||||
|
- Size: 380px wide × 520px tall
|
||||||
|
- Anchored bottom-right
|
||||||
|
- Shadow DOM container (CSS isolation from host page)
|
||||||
|
- Header: hospital logo + name + close button
|
||||||
|
- Three tabs: Chat (default) | Book | Contact
|
||||||
|
- All styled with brand colors from theme
|
||||||
|
|
||||||
|
### Chat Tab (Default)
|
||||||
|
|
||||||
|
- AI chatbot interface
|
||||||
|
- Streaming responses (same endpoint as agent AI, but with widget system prompt)
|
||||||
|
- Quick action chips: "Doctor availability", "Clinic timings", "Book appointment", "Treatment packages"
|
||||||
|
- If AI detects it can't help → shows: "An agent will call you shortly" + lead capture fields (name, phone)
|
||||||
|
- First message triggers captcha verification (invisible reCAPTCHA v3)
|
||||||
|
|
||||||
|
### Book Tab
|
||||||
|
|
||||||
|
Step-by-step appointment booking:
|
||||||
|
|
||||||
|
1. **Department** — dropdown populated from `/api/widget/doctors`
|
||||||
|
2. **Doctor** — dropdown filtered by department, shows visiting hours
|
||||||
|
3. **Date** — date picker (min: today, max: 30 days)
|
||||||
|
4. **Time Slot** — grid of available slots from `/api/widget/slots`
|
||||||
|
5. **Patient Details** — name, phone, age, gender, chief complaint
|
||||||
|
6. **Captcha** — invisible reCAPTCHA v3 on submit
|
||||||
|
7. **Confirmation** — "Appointment booked! Reference: ABC123. We'll send a confirmation SMS."
|
||||||
|
|
||||||
|
On successful booking:
|
||||||
|
- Creates patient (if new phone number)
|
||||||
|
- Creates lead with `source: 'WEBSITE'`
|
||||||
|
- Creates appointment linked to patient + doctor
|
||||||
|
- Rules engine scores the lead
|
||||||
|
- Pushes to agent worklist
|
||||||
|
- Real-time notification to agents
|
||||||
|
|
||||||
|
### Contact Tab
|
||||||
|
|
||||||
|
Simple lead capture form:
|
||||||
|
- Name (required)
|
||||||
|
- Phone (required)
|
||||||
|
- Interest / Department (dropdown, optional)
|
||||||
|
- Message (textarea, optional)
|
||||||
|
- Captcha on submit
|
||||||
|
- Success: "Thank you! An agent will call you shortly."
|
||||||
|
|
||||||
|
On submit:
|
||||||
|
- Creates lead with `source: 'WEBSITE'`, `interestedService: interest`
|
||||||
|
- Rules engine scores it
|
||||||
|
- Pushes to agent worklist + notification
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Theming
|
||||||
|
|
||||||
|
Widget fetches theme from `/api/widget/init`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"brand": { "name": "Global Hospital", "logo": "https://..." },
|
||||||
|
"colors": {
|
||||||
|
"primary": "rgb(29 78 216)",
|
||||||
|
"primaryLight": "rgb(219 234 254)",
|
||||||
|
"text": "rgb(15 23 42)",
|
||||||
|
"textLight": "rgb(100 116 139)"
|
||||||
|
},
|
||||||
|
"captchaSiteKey": "6Lc..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Colors are injected as CSS variables inside the shadow DOM:
|
||||||
|
```css
|
||||||
|
:host {
|
||||||
|
--widget-primary: rgb(29 78 216);
|
||||||
|
--widget-primary-light: rgb(219 234 254);
|
||||||
|
--widget-text: rgb(15 23 42);
|
||||||
|
--widget-text-light: rgb(100 116 139);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
All widget elements reference these variables. Changing the theme API → widget auto-updates on next load.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Widget System Prompt (AI Chat)
|
||||||
|
|
||||||
|
Different from the agent AI prompt — tailored for website visitors:
|
||||||
|
|
||||||
|
```
|
||||||
|
You are a virtual assistant for {hospitalName}.
|
||||||
|
You help website visitors with:
|
||||||
|
- Doctor availability and visiting hours
|
||||||
|
- Clinic locations and timings
|
||||||
|
- Health packages and pricing
|
||||||
|
- Booking appointments
|
||||||
|
- General hospital information
|
||||||
|
|
||||||
|
RULES:
|
||||||
|
1. Be friendly and welcoming — this is the hospital's first impression
|
||||||
|
2. If someone wants to book an appointment, guide them to the Book tab
|
||||||
|
3. If you can't answer a question, say "I'd be happy to have our team call you" and ask for their name and phone number
|
||||||
|
4. Never give medical advice
|
||||||
|
5. Keep responses under 80 words — visitors are scanning, not reading
|
||||||
|
6. Always mention the hospital name naturally in first response
|
||||||
|
|
||||||
|
KNOWLEDGE BASE:
|
||||||
|
{same KB as agent AI — clinics, doctors, packages, insurance}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Captcha
|
||||||
|
|
||||||
|
- **Provider**: Google reCAPTCHA v3 (invisible) or Cloudflare Turnstile
|
||||||
|
- **When**: On first chat message, appointment booking submit, lead form submit
|
||||||
|
- **How**: Widget loads captcha script, gets token, sends with request. Sidecar validates via provider API before processing.
|
||||||
|
- **Fallback**: If captcha fails to load (ad blocker), show a simple challenge or allow with rate limiting
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Widget Bundle
|
||||||
|
|
||||||
|
### Tech Stack
|
||||||
|
|
||||||
|
- **Preact** — 3KB, React-compatible API, sufficient for the widget UI
|
||||||
|
- **Vite** — library mode build, outputs single IIFE bundle
|
||||||
|
- **CSS-in-JS** — styles injected into shadow DOM (no external CSS files)
|
||||||
|
- **Target**: ~60KB gzipped (Preact + UI + styles)
|
||||||
|
|
||||||
|
### Build Output
|
||||||
|
|
||||||
|
```
|
||||||
|
dist/
|
||||||
|
└── widget.js — single IIFE bundle, self-contained
|
||||||
|
```
|
||||||
|
|
||||||
|
### Serving
|
||||||
|
|
||||||
|
Sidecar serves `widget.js` as a static file:
|
||||||
|
```
|
||||||
|
GET /widget.js → serves dist/widget.js with Cache-Control: public, max-age=3600
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Lead Flow (all channels)
|
||||||
|
|
||||||
|
```
|
||||||
|
Widget submit (chat/book/contact)
|
||||||
|
→ POST /api/widget/lead or /api/widget/book
|
||||||
|
→ captcha validation
|
||||||
|
→ key + origin validation
|
||||||
|
→ create patient (if new phone)
|
||||||
|
→ create lead (source: WEBSITE, channel metadata)
|
||||||
|
→ rules engine scores lead (source weight, campaign weight)
|
||||||
|
→ push to agent worklist
|
||||||
|
→ WebSocket notification to agents (bell + toast)
|
||||||
|
→ response to widget: success + reference number
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rate Limiting
|
||||||
|
|
||||||
|
| Endpoint | Limit |
|
||||||
|
|---|---|
|
||||||
|
| `/init` | 60/min per IP |
|
||||||
|
| `/chat` | 10/min per IP |
|
||||||
|
| `/doctors`, `/slots` | 30/min per IP |
|
||||||
|
| `/book` | 5/min per IP |
|
||||||
|
| `/lead` | 5/min per IP |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
**In scope:**
|
||||||
|
- Widget JS bundle (Preact + shadow DOM + theming)
|
||||||
|
- Sidecar widget endpoints (init, chat, doctors, slots, book, lead)
|
||||||
|
- Site key generation + validation (HMAC)
|
||||||
|
- Captcha integration (reCAPTCHA v3)
|
||||||
|
- Lead creation with worklist integration
|
||||||
|
- Appointment booking end-to-end
|
||||||
|
- Origin validation
|
||||||
|
- Rate limiting
|
||||||
|
- Widget served from sidecar
|
||||||
|
|
||||||
|
**Out of scope:**
|
||||||
|
- Live agent chat in widget (shows "agent will call you" instead)
|
||||||
|
- Widget analytics/tracking dashboard
|
||||||
|
- A/B testing widget variations
|
||||||
|
- Multi-language widget UI
|
||||||
|
- File upload in widget
|
||||||
|
- Payment integration in widget
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
// @ts-check
|
|
||||||
import js from '@eslint/js';
|
|
||||||
import globals from 'globals';
|
|
||||||
import reactHooks from 'eslint-plugin-react-hooks';
|
|
||||||
import reactRefresh from 'eslint-plugin-react-refresh';
|
|
||||||
import tseslint from 'typescript-eslint';
|
|
||||||
|
|
||||||
export default tseslint.config(
|
|
||||||
{ ignores: ['dist', 'node_modules'] },
|
|
||||||
{
|
|
||||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
|
||||||
files: ['**/*.{ts,tsx}'],
|
|
||||||
languageOptions: {
|
|
||||||
ecmaVersion: 2020,
|
|
||||||
globals: globals.browser,
|
|
||||||
},
|
|
||||||
plugins: {
|
|
||||||
'react-hooks': reactHooks,
|
|
||||||
'react-refresh': reactRefresh,
|
|
||||||
},
|
|
||||||
rules: {
|
|
||||||
// React Hooks — enforce rules of hooks and exhaustive deps
|
|
||||||
...reactHooks.configs.recommended.rules,
|
|
||||||
|
|
||||||
// React Refresh — warn on non-component exports (Vite HMR)
|
|
||||||
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
|
|
||||||
|
|
||||||
// TypeScript
|
|
||||||
'@typescript-eslint/no-unused-vars': [
|
|
||||||
'error',
|
|
||||||
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
|
|
||||||
],
|
|
||||||
'@typescript-eslint/no-explicit-any': 'warn',
|
|
||||||
'@typescript-eslint/consistent-type-imports': ['error', { prefer: 'type-imports' }],
|
|
||||||
|
|
||||||
// General
|
|
||||||
'no-console': ['error', { allow: ['warn', 'error'] }],
|
|
||||||
'no-duplicate-imports': 'error',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
4641
pnpm-lock.yaml
generated
4641
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,227 +0,0 @@
|
|||||||
/**
|
|
||||||
* fix-duplicate-imports.mjs
|
|
||||||
*
|
|
||||||
* Merges duplicate import statements from the same module across all TypeScript
|
|
||||||
* source files in the project. Run this whenever `npm run lint` reports
|
|
||||||
* `no-duplicate-imports` errors.
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* node scripts/fix-duplicate-imports.mjs
|
|
||||||
*
|
|
||||||
* Handles:
|
|
||||||
* import type { A, B } from 'module' — type-only imports
|
|
||||||
* import Default from 'module' — default imports
|
|
||||||
* import Default, { A, B } from 'mod' — mixed default + named
|
|
||||||
* import { A, B } from 'module' — named imports
|
|
||||||
* import {\n A,\n B\n} from 'mod' — multi-line named imports
|
|
||||||
*
|
|
||||||
* When merging a `import type` with a value import from the same module,
|
|
||||||
* type-only specifiers are inlined as `type Name` in the merged statement.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { readFileSync, readdirSync, statSync, writeFileSync } from "fs";
|
|
||||||
import { extname, join, resolve } from "path";
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Config
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const projectRoot = resolve(new URL(".", import.meta.url).pathname, "..");
|
|
||||||
const srcDir = join(projectRoot, "src");
|
|
||||||
const extensions = new Set([".ts", ".tsx"]);
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// File discovery — recursively find all TS/TSX files under src/
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function findFiles(dir) {
|
|
||||||
const results = [];
|
|
||||||
for (const entry of readdirSync(dir)) {
|
|
||||||
const full = join(dir, entry);
|
|
||||||
if (statSync(full).isDirectory()) {
|
|
||||||
results.push(...findFiles(full));
|
|
||||||
} else if (extensions.has(extname(entry))) {
|
|
||||||
results.push(full);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Import extraction
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extracts all top-level import statements from file content.
|
|
||||||
* Returns array of { start, end, raw, module, isType, defaultImport, namedImports }
|
|
||||||
* where start/end are character positions in the content.
|
|
||||||
*/
|
|
||||||
function extractImports(content) {
|
|
||||||
const results = [];
|
|
||||||
const importRe = /^import\s+([\s\S]*?)from\s+['"]([^'"]+)['"]\s*;?/gm;
|
|
||||||
|
|
||||||
let match;
|
|
||||||
while ((match = importRe.exec(content)) !== null) {
|
|
||||||
const raw = match[0];
|
|
||||||
const specifierPart = match[1];
|
|
||||||
const moduleName = match[2];
|
|
||||||
|
|
||||||
const isType = /^type\s+/.test(specifierPart.trimStart());
|
|
||||||
const cleanSpec = specifierPart.replace(/^type\s+/, "").trim();
|
|
||||||
|
|
||||||
let defaultImport = null;
|
|
||||||
let namedStr = null;
|
|
||||||
|
|
||||||
const namespaceMatch = cleanSpec.match(/^\*\s+as\s+(\w+)/);
|
|
||||||
if (namespaceMatch) {
|
|
||||||
defaultImport = `* as ${namespaceMatch[1]}`;
|
|
||||||
} else {
|
|
||||||
const braceIdx = cleanSpec.indexOf("{");
|
|
||||||
if (braceIdx === -1) {
|
|
||||||
const def = cleanSpec.replace(/,$/, "").trim();
|
|
||||||
if (def) defaultImport = def;
|
|
||||||
} else {
|
|
||||||
const beforeBrace = cleanSpec.slice(0, braceIdx).replace(/,$/, "").trim();
|
|
||||||
if (beforeBrace) defaultImport = beforeBrace;
|
|
||||||
const closeBrace = cleanSpec.lastIndexOf("}");
|
|
||||||
namedStr = cleanSpec.slice(braceIdx + 1, closeBrace);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const namedImports = namedStr
|
|
||||||
? namedStr.split(",").map((s) => s.trim()).filter(Boolean)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
results.push({
|
|
||||||
start: match.index,
|
|
||||||
end: match.index + raw.length,
|
|
||||||
raw,
|
|
||||||
module: moduleName,
|
|
||||||
isType,
|
|
||||||
defaultImport,
|
|
||||||
namedImports,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Import merging
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build a single merged import statement from multiple imports of the same module.
|
|
||||||
*
|
|
||||||
* - All type imports → merged `import type { ... }`
|
|
||||||
* - Mixed type + value → merged value import with inline `type Name` specifiers
|
|
||||||
*/
|
|
||||||
function buildMergedImport(moduleName, importList) {
|
|
||||||
const allType = importList.every((i) => i.isType);
|
|
||||||
|
|
||||||
if (allType) {
|
|
||||||
const allNamed = new Set(importList.flatMap((i) => i.namedImports));
|
|
||||||
const defaultImport = importList.map((i) => i.defaultImport).find(Boolean) ?? null;
|
|
||||||
const parts = [];
|
|
||||||
if (defaultImport) parts.push(defaultImport);
|
|
||||||
if (allNamed.size > 0) parts.push(`{ ${[...allNamed].join(", ")} }`);
|
|
||||||
if (parts.length === 0) return `import type "${moduleName}";`;
|
|
||||||
return `import type ${parts.join(", ")} from "${moduleName}";`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mixed: collect value default, type-only named, value named separately
|
|
||||||
let valueDefault = null;
|
|
||||||
const typeNamed = new Set();
|
|
||||||
const valueNamed = new Set();
|
|
||||||
|
|
||||||
for (const imp of importList) {
|
|
||||||
if (imp.defaultImport && !imp.isType) valueDefault = imp.defaultImport;
|
|
||||||
for (const n of imp.namedImports) {
|
|
||||||
(imp.isType ? typeNamed : valueNamed).add(n);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build named specifiers: `type X` for type-only, plain for value
|
|
||||||
const typeSpecifiers = [...typeNamed].filter((n) => !valueNamed.has(n)).map((n) => `type ${n}`);
|
|
||||||
const valueSpecifiers = [...valueNamed];
|
|
||||||
const namedParts = [...typeSpecifiers, ...valueSpecifiers];
|
|
||||||
|
|
||||||
const parts = [];
|
|
||||||
if (valueDefault) parts.push(valueDefault);
|
|
||||||
if (namedParts.length > 0) parts.push(`{ ${namedParts.join(", ")} }`);
|
|
||||||
if (parts.length === 0) return `import "${moduleName}";`;
|
|
||||||
return `import ${parts.join(", ")} from "${moduleName}";`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// File fixer
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function fixFile(filePath) {
|
|
||||||
let content = readFileSync(filePath, "utf-8");
|
|
||||||
const imports = extractImports(content);
|
|
||||||
if (imports.length === 0) return null;
|
|
||||||
|
|
||||||
// Group by module
|
|
||||||
const byModule = new Map();
|
|
||||||
for (const imp of imports) {
|
|
||||||
if (!byModule.has(imp.module)) byModule.set(imp.module, []);
|
|
||||||
byModule.get(imp.module).push(imp);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (![...byModule.values()].some((v) => v.length > 1)) return null;
|
|
||||||
|
|
||||||
// Build merged text for each module
|
|
||||||
const mergedMap = new Map();
|
|
||||||
for (const [mod, imps] of byModule) {
|
|
||||||
mergedMap.set(mod, imps.length === 1 ? imps[0].raw : buildMergedImport(mod, imps));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build replacement list (process in reverse order to preserve character positions)
|
|
||||||
imports.sort((a, b) => a.start - b.start);
|
|
||||||
const placedModules = new Set();
|
|
||||||
const replacements = [];
|
|
||||||
|
|
||||||
for (const imp of imports) {
|
|
||||||
if (!placedModules.has(imp.module)) {
|
|
||||||
replacements.push({ start: imp.start, end: imp.end, replacement: mergedMap.get(imp.module) });
|
|
||||||
placedModules.add(imp.module);
|
|
||||||
} else {
|
|
||||||
// Remove duplicate, including its trailing newline
|
|
||||||
const end = content[imp.end] === "\n" ? imp.end + 1 : imp.end;
|
|
||||||
replacements.push({ start: imp.start, end, replacement: "" });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
replacements.sort((a, b) => b.start - a.start);
|
|
||||||
for (const { start, end, replacement } of replacements) {
|
|
||||||
content = content.slice(0, start) + replacement + content.slice(end);
|
|
||||||
}
|
|
||||||
|
|
||||||
return content;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Main
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const files = findFiles(srcDir);
|
|
||||||
let fixedCount = 0;
|
|
||||||
|
|
||||||
for (const filePath of files) {
|
|
||||||
try {
|
|
||||||
const original = readFileSync(filePath, "utf-8");
|
|
||||||
const fixed = fixFile(filePath);
|
|
||||||
if (fixed && fixed !== original) {
|
|
||||||
writeFileSync(filePath, fixed, "utf-8");
|
|
||||||
const rel = filePath.replace(projectRoot + "/", "");
|
|
||||||
console.log(`Fixed: ${rel}`);
|
|
||||||
fixedCount++;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
const rel = filePath.replace(projectRoot + "/", "");
|
|
||||||
console.error(`Error: ${rel} — ${err.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`\nDone. Fixed ${fixedCount} file${fixedCount !== 1 ? "s" : ""}.`);
|
|
||||||
@@ -1,23 +1,21 @@
|
|||||||
/**
|
/**
|
||||||
* Helix Engage — Platform Data Seeder
|
* Helix Engage — Platform Data Seeder
|
||||||
* Creates 5 patient stories + 5 doctors with fully linked records.
|
* Creates 2 clinics, 5 doctors with multi-clinic visit slots,
|
||||||
* Run: cd helix-engage && npx tsx scripts/seed-data.ts
|
* 3 patient stories with fully linked records (campaigns, leads,
|
||||||
|
* calls, appointments, follow-ups, lead activities).
|
||||||
*
|
*
|
||||||
* Platform field mapping (SDK name → platform name):
|
* Run: cd helix-engage && npx tsx scripts/seed-data.ts
|
||||||
* Campaign: campaignType→typeCustom, campaignStatus→status, impressionCount→impressions,
|
* Env: SEED_GQL (graphql url), SEED_ORIGIN (workspace origin), SEED_SUB (workspace subdomain)
|
||||||
* clickCount→clicks, contactedCount→contacted, convertedCount→converted, leadCount→leadsGenerated
|
*
|
||||||
* Lead: leadSource→source, leadStatus→status, firstContactedAt→firstContacted,
|
* Schema alignment (2026-04-10):
|
||||||
* lastContactedAt→lastContacted, landingPageUrl→landingPage
|
* - Doctor.visitingHours removed → replaced by DoctorVisitSlot entity
|
||||||
* Call: callDirection→direction, durationSeconds→durationSec
|
* - Doctor.portalUserId omitted (workspace member IDs are per-deployment)
|
||||||
* Appointment: durationMinutes→durationMin, appointmentStatus→status, roomNumber→room
|
* - Clinic entity added (needed for visit slot FK)
|
||||||
* FollowUp: followUpType→typeCustom, followUpStatus→status
|
|
||||||
* Patient: address→addressCustom
|
|
||||||
* Doctor: isActive→active, branch→branchClinic
|
|
||||||
* NOTE: callNotes/visitNotes/clinicalNotes are RICH_TEXT — read-only, cannot seed
|
* NOTE: callNotes/visitNotes/clinicalNotes are RICH_TEXT — read-only, cannot seed
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const GQL = process.env.SEED_GQL ?? 'http://localhost:4000/graphql';
|
const GQL = process.env.SEED_GQL ?? 'http://localhost:4000/graphql';
|
||||||
const SUB = 'fortytwo-dev';
|
const SUB = process.env.SEED_SUB ?? 'fortytwo-dev';
|
||||||
const ORIGIN = process.env.SEED_ORIGIN ?? 'http://fortytwo-dev.localhost:4010';
|
const ORIGIN = process.env.SEED_ORIGIN ?? 'http://fortytwo-dev.localhost:4010';
|
||||||
|
|
||||||
let token = '';
|
let token = '';
|
||||||
@@ -51,28 +49,119 @@ async function mk(entity: string, data: any): Promise<string> {
|
|||||||
return d[`create${cap}`].id;
|
return d[`create${cap}`].id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create a workspace member (user account) and return its workspace member id.
|
||||||
|
// Uses signUpInWorkspace + updateWorkspaceMember for name + updateWorkspaceMemberRole.
|
||||||
|
// The invite hash and role IDs are fetched once and cached.
|
||||||
|
let _inviteHash = '';
|
||||||
|
let _wsId = '';
|
||||||
|
const _roleIds: Record<string, string> = {};
|
||||||
|
|
||||||
|
async function ensureWorkspaceContext() {
|
||||||
|
if (_wsId) return;
|
||||||
|
const ws = await gql('{ currentWorkspace { id inviteHash } }');
|
||||||
|
_wsId = ws.currentWorkspace.id;
|
||||||
|
_inviteHash = ws.currentWorkspace.inviteHash;
|
||||||
|
const roles = await gql('{ getRoles { id label } }');
|
||||||
|
for (const r of roles.getRoles) _roleIds[r.label] = r.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mkMember(email: string, password: string, firstName: string, lastName: string, roleName?: string): Promise<string> {
|
||||||
|
await ensureWorkspaceContext();
|
||||||
|
|
||||||
|
// Create the user + link to workspace
|
||||||
|
await gql(
|
||||||
|
`mutation($email: String!, $password: String!, $workspaceId: UUID!, $workspaceInviteHash: String!) {
|
||||||
|
signUpInWorkspace(email: $email, password: $password, workspaceId: $workspaceId, workspaceInviteHash: $workspaceInviteHash) { workspace { id } }
|
||||||
|
}`,
|
||||||
|
{ email, password, workspaceId: _wsId, workspaceInviteHash: _inviteHash },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Find the new member id
|
||||||
|
const members = await gql('{ workspaceMembers { edges { node { id userEmail } } } }');
|
||||||
|
const member = members.workspaceMembers.edges.find((e: any) => e.node.userEmail.toLowerCase() === email.toLowerCase());
|
||||||
|
if (!member) throw new Error(`Could not find workspace member for ${email}`);
|
||||||
|
const memberId = member.node.id;
|
||||||
|
|
||||||
|
// Set their display name
|
||||||
|
await gql(
|
||||||
|
`mutation($id: UUID!, $data: WorkspaceMemberUpdateInput!) { updateWorkspaceMember(id: $id, data: $data) { id } }`,
|
||||||
|
{ id: memberId, data: { name: { firstName, lastName } } },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Assign role if specified
|
||||||
|
if (roleName && _roleIds[roleName]) {
|
||||||
|
await gql(
|
||||||
|
`mutation($wm: UUID!, $role: UUID!) { updateWorkspaceMemberRole(workspaceMemberId: $wm, roleId: $role) { id } }`,
|
||||||
|
{ wm: memberId, role: _roleIds[roleName] },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return memberId;
|
||||||
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
console.log('🌱 Seeding Helix Engage demo data...\n');
|
console.log('🌱 Seeding Helix Engage demo data...\n');
|
||||||
await auth();
|
await auth();
|
||||||
console.log('✅ Auth OK\n');
|
console.log('✅ Auth OK\n');
|
||||||
|
|
||||||
// Workspace member IDs — switch based on target platform
|
// ═══════════════════════════════════════════
|
||||||
const WM = GQL.includes('srv1477139') ? {
|
// CLINICS (needed for doctor visit slots)
|
||||||
drSharma: '107efa70-fd32-4819-8936-994197c6ada1',
|
// ═══════════════════════════════════════════
|
||||||
drPatel: '7e1fe368-1f23-4a10-8c2f-3e9c3846b209',
|
console.log('🏥 Clinics');
|
||||||
drKumar: 'b86ff7d3-57de-44e5-aa13-e5da848a960c',
|
const clinicKor = await mk('clinic', {
|
||||||
drReddy: 'b82693b6-701c-4783-8d02-cc137c9c306b',
|
name: 'Global Hospital — Koramangala',
|
||||||
drSingh: 'b2a00dd2-5bb5-4c29-8fb1-70a681193a4c',
|
clinicName: 'Global Hospital — Koramangala',
|
||||||
} : {
|
status: 'ACTIVE',
|
||||||
drSharma: '251e9b32-3a83-4f3c-a904-fad7e8b840c3',
|
opensAt: '08:00', closesAt: '20:00',
|
||||||
drPatel: '2b1bbf20-3838-434f-9fe9-b98436362230',
|
openMonday: true, openTuesday: true, openWednesday: true,
|
||||||
drKumar: '16109622-9b13-4682-b327-eb611ffa8338',
|
openThursday: true, openFriday: true, openSaturday: true, openSunday: false,
|
||||||
drReddy: '478a9ccb-d231-48fb-a740-0228d3c9325b',
|
phone: { primaryPhoneNumber: '8041763265', primaryPhoneCallingCode: '+91', primaryPhoneCountryCode: 'IN' },
|
||||||
drSingh: 'b854b55b-7302-4981-8dfc-bea516abdc86',
|
addressCustom: { addressCity: 'Bangalore', addressState: 'Karnataka', addressCountry: 'India', addressStreet1: 'Koramangala 4th Block' },
|
||||||
};
|
onlineBooking: true, walkInAllowed: true, acceptsCash: 'YES', acceptsCard: 'YES', acceptsUpi: 'YES',
|
||||||
|
});
|
||||||
|
console.log(` Koramangala: ${clinicKor}`);
|
||||||
|
|
||||||
|
const clinicWf = await mk('clinic', {
|
||||||
|
name: 'Global Hospital — Whitefield',
|
||||||
|
clinicName: 'Global Hospital — Whitefield',
|
||||||
|
status: 'ACTIVE',
|
||||||
|
opensAt: '09:00', closesAt: '18:00',
|
||||||
|
openMonday: true, openTuesday: true, openWednesday: true,
|
||||||
|
openThursday: true, openFriday: true, openSaturday: true, openSunday: false,
|
||||||
|
phone: { primaryPhoneNumber: '8041763400', primaryPhoneCallingCode: '+91', primaryPhoneCountryCode: 'IN' },
|
||||||
|
addressCustom: { addressCity: 'Bangalore', addressState: 'Karnataka', addressCountry: 'India', addressStreet1: 'ITPL Main Road, Whitefield' },
|
||||||
|
onlineBooking: true, walkInAllowed: true, acceptsCash: 'YES', acceptsCard: 'YES', acceptsUpi: 'YES',
|
||||||
|
});
|
||||||
|
console.log(` Whitefield: ${clinicWf}\n`);
|
||||||
|
|
||||||
|
await auth();
|
||||||
|
|
||||||
// ═══════════════════════════════════════════
|
// ═══════════════════════════════════════════
|
||||||
// DOCTORS (linked to workspace members)
|
// DOCTOR WORKSPACE MEMBERS
|
||||||
|
//
|
||||||
|
// Each doctor gets a real platform login so they can access the
|
||||||
|
// portal. Created via signUpInWorkspace, then linked to the Doctor
|
||||||
|
// entity via portalUserId. Email domain matches the deployment.
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
console.log('👤 Doctor workspace members (role: HelixEngage Manager)');
|
||||||
|
const wmSharma = await mkMember('dr.sharma@globalcare.com', 'DrSharma@2026', 'Arun', 'Sharma', 'HelixEngage Manager');
|
||||||
|
console.log(` Dr. Sharma member: ${wmSharma}`);
|
||||||
|
const wmPatel = await mkMember('dr.patel@globalcare.com', 'DrPatel@2026', 'Meena', 'Patel', 'HelixEngage Manager');
|
||||||
|
console.log(` Dr. Patel member: ${wmPatel}`);
|
||||||
|
const wmKumar = await mkMember('dr.kumar@globalcare.com', 'DrKumar@2026', 'Rajesh', 'Kumar', 'HelixEngage Manager');
|
||||||
|
console.log(` Dr. Kumar member: ${wmKumar}`);
|
||||||
|
const wmReddy = await mkMember('dr.reddy@globalcare.com', 'DrReddy@2026', 'Lakshmi', 'Reddy', 'HelixEngage Manager');
|
||||||
|
console.log(` Dr. Reddy member: ${wmReddy}`);
|
||||||
|
const wmSingh = await mkMember('dr.singh@globalcare.com', 'DrSingh@2026', 'Harpreet', 'Singh', 'HelixEngage Manager');
|
||||||
|
console.log(` Dr. Singh member: ${wmSingh}\n`);
|
||||||
|
|
||||||
|
await auth();
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
// DOCTORS (linked to workspace members via portalUserId)
|
||||||
|
//
|
||||||
|
// visitingHours was removed — multi-clinic schedules now live
|
||||||
|
// on DoctorVisitSlot (seeded below).
|
||||||
// ═══════════════════════════════════════════
|
// ═══════════════════════════════════════════
|
||||||
console.log('👨⚕️ Doctors');
|
console.log('👨⚕️ Doctors');
|
||||||
const drSharma = await mk('doctor', {
|
const drSharma = await mk('doctor', {
|
||||||
@@ -82,16 +171,15 @@ async function main() {
|
|||||||
specialty: 'Interventional Cardiology',
|
specialty: 'Interventional Cardiology',
|
||||||
qualifications: 'MBBS, MD (Medicine), DM (Cardiology), FACC',
|
qualifications: 'MBBS, MD (Medicine), DM (Cardiology), FACC',
|
||||||
yearsOfExperience: 18,
|
yearsOfExperience: 18,
|
||||||
visitingHours: 'Mon/Wed/Fri 10:00 AM – 1:00 PM',
|
|
||||||
consultationFeeNew: { amountMicros: 800_000_000, currencyCode: 'INR' },
|
consultationFeeNew: { amountMicros: 800_000_000, currencyCode: 'INR' },
|
||||||
consultationFeeFollowUp: { amountMicros: 500_000_000, currencyCode: 'INR' },
|
consultationFeeFollowUp: { amountMicros: 500_000_000, currencyCode: 'INR' },
|
||||||
phone: { primaryPhoneNumber: '9900100001', primaryPhoneCallingCode: '+91', primaryPhoneCountryCode: 'IN' },
|
phone: { primaryPhoneNumber: '9900100001', primaryPhoneCallingCode: '+91', primaryPhoneCountryCode: 'IN' },
|
||||||
email: { primaryEmail: 'dr.sharma@globalhospital.com' },
|
email: { primaryEmail: 'dr.sharma@globalcare.com' },
|
||||||
registrationNumber: 'KMC-45672',
|
registrationNumber: 'KMC-45672',
|
||||||
active: true,
|
active: true,
|
||||||
portalUserId: WM.drSharma,
|
portalUserId: wmSharma,
|
||||||
});
|
});
|
||||||
console.log(` Dr. Sharma (Cardiology, WM: ${WM.drSharma}): ${drSharma}`);
|
console.log(` Dr. Sharma (Cardiology → ${wmSharma}): ${drSharma}`);
|
||||||
|
|
||||||
const drPatel = await mk('doctor', {
|
const drPatel = await mk('doctor', {
|
||||||
name: 'Dr. Meena Patel',
|
name: 'Dr. Meena Patel',
|
||||||
@@ -100,16 +188,15 @@ async function main() {
|
|||||||
specialty: 'Reproductive Medicine & IVF',
|
specialty: 'Reproductive Medicine & IVF',
|
||||||
qualifications: 'MBBS, MS (OBG), Fellowship in Reproductive Medicine',
|
qualifications: 'MBBS, MS (OBG), Fellowship in Reproductive Medicine',
|
||||||
yearsOfExperience: 15,
|
yearsOfExperience: 15,
|
||||||
visitingHours: 'Tue/Thu/Sat 9:00 AM – 12:00 PM',
|
|
||||||
consultationFeeNew: { amountMicros: 700_000_000, currencyCode: 'INR' },
|
consultationFeeNew: { amountMicros: 700_000_000, currencyCode: 'INR' },
|
||||||
consultationFeeFollowUp: { amountMicros: 400_000_000, currencyCode: 'INR' },
|
consultationFeeFollowUp: { amountMicros: 400_000_000, currencyCode: 'INR' },
|
||||||
phone: { primaryPhoneNumber: '9900100002', primaryPhoneCallingCode: '+91', primaryPhoneCountryCode: 'IN' },
|
phone: { primaryPhoneNumber: '9900100002', primaryPhoneCallingCode: '+91', primaryPhoneCountryCode: 'IN' },
|
||||||
email: { primaryEmail: 'dr.patel@globalhospital.com' },
|
email: { primaryEmail: 'dr.patel@globalcare.com' },
|
||||||
registrationNumber: 'KMC-38291',
|
registrationNumber: 'KMC-38291',
|
||||||
active: true,
|
active: true,
|
||||||
portalUserId: WM.drPatel,
|
portalUserId: wmPatel,
|
||||||
});
|
});
|
||||||
console.log(` Dr. Patel (Gynecology/IVF, WM: ${WM.drPatel}): ${drPatel}`);
|
console.log(` Dr. Patel (Gynecology/IVF → ${wmPatel}): ${drPatel}`);
|
||||||
|
|
||||||
const drKumar = await mk('doctor', {
|
const drKumar = await mk('doctor', {
|
||||||
name: 'Dr. Rajesh Kumar',
|
name: 'Dr. Rajesh Kumar',
|
||||||
@@ -118,16 +205,15 @@ async function main() {
|
|||||||
specialty: 'Joint Replacement & Sports Medicine',
|
specialty: 'Joint Replacement & Sports Medicine',
|
||||||
qualifications: 'MBBS, MS (Ortho), Fellowship in Arthroplasty',
|
qualifications: 'MBBS, MS (Ortho), Fellowship in Arthroplasty',
|
||||||
yearsOfExperience: 12,
|
yearsOfExperience: 12,
|
||||||
visitingHours: 'Mon–Fri 2:00 PM – 5:00 PM',
|
|
||||||
consultationFeeNew: { amountMicros: 600_000_000, currencyCode: 'INR' },
|
consultationFeeNew: { amountMicros: 600_000_000, currencyCode: 'INR' },
|
||||||
consultationFeeFollowUp: { amountMicros: 400_000_000, currencyCode: 'INR' },
|
consultationFeeFollowUp: { amountMicros: 400_000_000, currencyCode: 'INR' },
|
||||||
phone: { primaryPhoneNumber: '9900100003', primaryPhoneCallingCode: '+91', primaryPhoneCountryCode: 'IN' },
|
phone: { primaryPhoneNumber: '9900100003', primaryPhoneCallingCode: '+91', primaryPhoneCountryCode: 'IN' },
|
||||||
email: { primaryEmail: 'dr.kumar@globalhospital.com' },
|
email: { primaryEmail: 'dr.kumar@globalcare.com' },
|
||||||
registrationNumber: 'KMC-51003',
|
registrationNumber: 'KMC-51003',
|
||||||
active: true,
|
active: true,
|
||||||
portalUserId: WM.drKumar,
|
portalUserId: wmKumar,
|
||||||
});
|
});
|
||||||
console.log(` Dr. Kumar (Orthopedics, WM: ${WM.drKumar}): ${drKumar}`);
|
console.log(` Dr. Kumar (Orthopedics → ${wmKumar}): ${drKumar}`);
|
||||||
|
|
||||||
const drReddy = await mk('doctor', {
|
const drReddy = await mk('doctor', {
|
||||||
name: 'Dr. Lakshmi Reddy',
|
name: 'Dr. Lakshmi Reddy',
|
||||||
@@ -136,16 +222,15 @@ async function main() {
|
|||||||
specialty: 'Internal Medicine & Preventive Health',
|
specialty: 'Internal Medicine & Preventive Health',
|
||||||
qualifications: 'MBBS, MD (General Medicine)',
|
qualifications: 'MBBS, MD (General Medicine)',
|
||||||
yearsOfExperience: 20,
|
yearsOfExperience: 20,
|
||||||
visitingHours: 'Mon–Sat 9:00 AM – 6:00 PM',
|
|
||||||
consultationFeeNew: { amountMicros: 500_000_000, currencyCode: 'INR' },
|
consultationFeeNew: { amountMicros: 500_000_000, currencyCode: 'INR' },
|
||||||
consultationFeeFollowUp: { amountMicros: 300_000_000, currencyCode: 'INR' },
|
consultationFeeFollowUp: { amountMicros: 300_000_000, currencyCode: 'INR' },
|
||||||
phone: { primaryPhoneNumber: '9900100004', primaryPhoneCallingCode: '+91', primaryPhoneCountryCode: 'IN' },
|
phone: { primaryPhoneNumber: '9900100004', primaryPhoneCallingCode: '+91', primaryPhoneCountryCode: 'IN' },
|
||||||
email: { primaryEmail: 'dr.reddy@globalhospital.com' },
|
email: { primaryEmail: 'dr.reddy@globalcare.com' },
|
||||||
registrationNumber: 'KMC-22145',
|
registrationNumber: 'KMC-22145',
|
||||||
active: true,
|
active: true,
|
||||||
portalUserId: WM.drReddy,
|
portalUserId: wmReddy,
|
||||||
});
|
});
|
||||||
console.log(` Dr. Reddy (General Medicine, WM: ${WM.drReddy}): ${drReddy}`);
|
console.log(` Dr. Reddy (General Medicine → ${wmReddy}): ${drReddy}`);
|
||||||
|
|
||||||
const drSingh = await mk('doctor', {
|
const drSingh = await mk('doctor', {
|
||||||
name: 'Dr. Harpreet Singh',
|
name: 'Dr. Harpreet Singh',
|
||||||
@@ -154,16 +239,57 @@ async function main() {
|
|||||||
specialty: 'Otorhinolaryngology & Head/Neck Surgery',
|
specialty: 'Otorhinolaryngology & Head/Neck Surgery',
|
||||||
qualifications: 'MBBS, MS (ENT), DNB',
|
qualifications: 'MBBS, MS (ENT), DNB',
|
||||||
yearsOfExperience: 10,
|
yearsOfExperience: 10,
|
||||||
visitingHours: 'Mon/Wed/Fri 11:00 AM – 3:00 PM',
|
|
||||||
consultationFeeNew: { amountMicros: 600_000_000, currencyCode: 'INR' },
|
consultationFeeNew: { amountMicros: 600_000_000, currencyCode: 'INR' },
|
||||||
consultationFeeFollowUp: { amountMicros: 400_000_000, currencyCode: 'INR' },
|
consultationFeeFollowUp: { amountMicros: 400_000_000, currencyCode: 'INR' },
|
||||||
phone: { primaryPhoneNumber: '9900100005', primaryPhoneCallingCode: '+91', primaryPhoneCountryCode: 'IN' },
|
phone: { primaryPhoneNumber: '9900100005', primaryPhoneCallingCode: '+91', primaryPhoneCountryCode: 'IN' },
|
||||||
email: { primaryEmail: 'dr.singh@globalhospital.com' },
|
email: { primaryEmail: 'dr.singh@globalcare.com' },
|
||||||
registrationNumber: 'KMC-60782',
|
registrationNumber: 'KMC-60782',
|
||||||
active: true,
|
active: true,
|
||||||
portalUserId: WM.drSingh,
|
portalUserId: wmSingh,
|
||||||
});
|
});
|
||||||
console.log(` Dr. Singh (ENT, WM: ${WM.drSingh}): ${drSingh}\n`);
|
console.log(` Dr. Singh (ENT → ${wmSingh}): ${drSingh}\n`);
|
||||||
|
|
||||||
|
await auth();
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
// DOCTOR VISIT SLOTS (weekly schedule per doctor × clinic)
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
console.log('📅 Visit Slots');
|
||||||
|
const slots: Array<{ doc: string; docName: string; clinic: string; clinicName: string; day: string; start: string; end: string }> = [
|
||||||
|
// Dr. Sharma — Koramangala Mon/Wed/Fri 10:00–13:00
|
||||||
|
{ doc: drSharma, docName: 'Sharma', clinic: clinicKor, clinicName: 'Kor', day: 'MONDAY', start: '10:00', end: '13:00' },
|
||||||
|
{ doc: drSharma, docName: 'Sharma', clinic: clinicKor, clinicName: 'Kor', day: 'WEDNESDAY', start: '10:00', end: '13:00' },
|
||||||
|
{ doc: drSharma, docName: 'Sharma', clinic: clinicKor, clinicName: 'Kor', day: 'FRIDAY', start: '10:00', end: '13:00' },
|
||||||
|
// Dr. Patel — Whitefield Tue/Thu/Sat 9:00–12:00
|
||||||
|
{ doc: drPatel, docName: 'Patel', clinic: clinicWf, clinicName: 'WF', day: 'TUESDAY', start: '09:00', end: '12:00' },
|
||||||
|
{ doc: drPatel, docName: 'Patel', clinic: clinicWf, clinicName: 'WF', day: 'THURSDAY', start: '09:00', end: '12:00' },
|
||||||
|
{ doc: drPatel, docName: 'Patel', clinic: clinicWf, clinicName: 'WF', day: 'SATURDAY', start: '09:00', end: '12:00' },
|
||||||
|
// Dr. Kumar — Koramangala Mon–Fri 14:00–17:00
|
||||||
|
{ doc: drKumar, docName: 'Kumar', clinic: clinicKor, clinicName: 'Kor', day: 'MONDAY', start: '14:00', end: '17:00' },
|
||||||
|
{ doc: drKumar, docName: 'Kumar', clinic: clinicKor, clinicName: 'Kor', day: 'TUESDAY', start: '14:00', end: '17:00' },
|
||||||
|
{ doc: drKumar, docName: 'Kumar', clinic: clinicKor, clinicName: 'Kor', day: 'WEDNESDAY', start: '14:00', end: '17:00' },
|
||||||
|
{ doc: drKumar, docName: 'Kumar', clinic: clinicKor, clinicName: 'Kor', day: 'THURSDAY', start: '14:00', end: '17:00' },
|
||||||
|
{ doc: drKumar, docName: 'Kumar', clinic: clinicKor, clinicName: 'Kor', day: 'FRIDAY', start: '14:00', end: '17:00' },
|
||||||
|
// Dr. Reddy — both clinics Mon–Sat
|
||||||
|
{ doc: drReddy, docName: 'Reddy', clinic: clinicKor, clinicName: 'Kor', day: 'MONDAY', start: '09:00', end: '13:00' },
|
||||||
|
{ doc: drReddy, docName: 'Reddy', clinic: clinicKor, clinicName: 'Kor', day: 'WEDNESDAY', start: '09:00', end: '13:00' },
|
||||||
|
{ doc: drReddy, docName: 'Reddy', clinic: clinicKor, clinicName: 'Kor', day: 'FRIDAY', start: '09:00', end: '13:00' },
|
||||||
|
{ doc: drReddy, docName: 'Reddy', clinic: clinicWf, clinicName: 'WF', day: 'TUESDAY', start: '14:00', end: '18:00' },
|
||||||
|
{ doc: drReddy, docName: 'Reddy', clinic: clinicWf, clinicName: 'WF', day: 'THURSDAY', start: '14:00', end: '18:00' },
|
||||||
|
{ doc: drReddy, docName: 'Reddy', clinic: clinicWf, clinicName: 'WF', day: 'SATURDAY', start: '14:00', end: '18:00' },
|
||||||
|
// Dr. Singh — Whitefield Mon/Wed/Fri 11:00–15:00
|
||||||
|
{ doc: drSingh, docName: 'Singh', clinic: clinicWf, clinicName: 'WF', day: 'MONDAY', start: '11:00', end: '15:00' },
|
||||||
|
{ doc: drSingh, docName: 'Singh', clinic: clinicWf, clinicName: 'WF', day: 'WEDNESDAY', start: '11:00', end: '15:00' },
|
||||||
|
{ doc: drSingh, docName: 'Singh', clinic: clinicWf, clinicName: 'WF', day: 'FRIDAY', start: '11:00', end: '15:00' },
|
||||||
|
];
|
||||||
|
for (const s of slots) {
|
||||||
|
await mk('doctorVisitSlot', {
|
||||||
|
name: `Dr. ${s.docName} — ${s.day} ${s.start}–${s.end} (${s.clinicName})`,
|
||||||
|
doctorId: s.doc, clinicId: s.clinic,
|
||||||
|
dayOfWeek: s.day, startTime: s.start, endTime: s.end,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.log(` ${slots.length} visit slots created\n`);
|
||||||
|
|
||||||
await auth();
|
await auth();
|
||||||
|
|
||||||
@@ -406,9 +532,10 @@ async function main() {
|
|||||||
console.log(' Vijay — appointment reminder (tomorrow 9am)\n');
|
console.log(' Vijay — appointment reminder (tomorrow 9am)\n');
|
||||||
|
|
||||||
console.log('🎉 Seed complete!');
|
console.log('🎉 Seed complete!');
|
||||||
console.log(' 5 doctors · 3 campaigns · 3 patients · 5 leads · 6 appointments · 10 calls · 22 activities · 2 follow-ups');
|
console.log(' 2 clinics · 5 doctors · 20 visit slots · 3 campaigns');
|
||||||
|
console.log(' 3 patients · 5 leads · 6 appointments · 10 calls · 22 activities · 2 follow-ups');
|
||||||
console.log(' Demo phones: Priya=9949879837, Ravi=6309248884');
|
console.log(' Demo phones: Priya=9949879837, Ravi=6309248884');
|
||||||
console.log(' All appointments linked to doctor entities');
|
console.log(' Doctors linked to clinics via visit slots (multi-clinic schedule)');
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch(e => { console.error('💥', e.message); process.exit(1); });
|
main().catch(e => { console.error('💥', e.message); process.exit(1); });
|
||||||
|
|||||||
117
scripts/seed-ramaiah.ts
Normal file
117
scripts/seed-ramaiah.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
/**
|
||||||
|
* Helix Engage — Ramaiah Hospital Data Seeder
|
||||||
|
*
|
||||||
|
* Seeds clinic + 195 doctors from scraped website data.
|
||||||
|
* Run: cd helix-engage && SEED_GQL=https://ramaiah.app.healix360.net/graphql SEED_SUB=ramaiah SEED_ORIGIN=https://ramaiah.app.healix360.net npx tsx scripts/seed-ramaiah.ts
|
||||||
|
*/
|
||||||
|
import { readFileSync } from 'fs';
|
||||||
|
|
||||||
|
const GQL = process.env.SEED_GQL ?? 'http://localhost:4000/graphql';
|
||||||
|
const SUB = process.env.SEED_SUB ?? 'ramaiah';
|
||||||
|
const ORIGIN = process.env.SEED_ORIGIN ?? 'http://ramaiah.localhost:5080';
|
||||||
|
const DATA_FILE = process.env.SEED_DATA ?? '/tmp/ramaiah-seed-data.json';
|
||||||
|
|
||||||
|
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 Ramaiah Hospital data...\n');
|
||||||
|
|
||||||
|
const raw = JSON.parse(readFileSync(DATA_FILE, 'utf-8'));
|
||||||
|
console.log(`📁 Loaded ${raw.doctors.length} doctors, ${raw.departments.length} departments\n`);
|
||||||
|
|
||||||
|
await auth();
|
||||||
|
console.log('✅ Auth OK\n');
|
||||||
|
|
||||||
|
// Clinic
|
||||||
|
console.log('🏥 Clinic');
|
||||||
|
const clinicId = await mk('clinic', {
|
||||||
|
name: raw.clinic.name,
|
||||||
|
clinicName: raw.clinic.name,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
opensAt: '08:00',
|
||||||
|
closesAt: '20:00',
|
||||||
|
openMonday: true, openTuesday: true, openWednesday: true,
|
||||||
|
openThursday: true, openFriday: true, openSaturday: true, openSunday: false,
|
||||||
|
phone: {
|
||||||
|
primaryPhoneNumber: raw.clinic.phone?.replace(/[^0-9]/g, '').slice(-10) ?? '',
|
||||||
|
primaryPhoneCallingCode: '+91',
|
||||||
|
primaryPhoneCountryCode: 'IN',
|
||||||
|
},
|
||||||
|
addressCustom: {
|
||||||
|
addressStreet1: raw.clinic.address?.split(',')[0] ?? 'New BEL Road',
|
||||||
|
addressCity: raw.clinic.city ?? 'Bangalore',
|
||||||
|
addressState: raw.clinic.state ?? 'Karnataka',
|
||||||
|
addressCountry: 'India',
|
||||||
|
addressPostcode: raw.clinic.pincode ?? '560054',
|
||||||
|
},
|
||||||
|
onlineBooking: true,
|
||||||
|
walkInAllowed: true,
|
||||||
|
});
|
||||||
|
console.log(` ${raw.clinic.name}: ${clinicId}\n`);
|
||||||
|
|
||||||
|
// Re-auth (long operation ahead)
|
||||||
|
await auth();
|
||||||
|
|
||||||
|
// Doctors — batch in groups of 20 with re-auth
|
||||||
|
console.log(`👨⚕️ Doctors (${raw.doctors.length})`);
|
||||||
|
let created = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < raw.doctors.length; i++) {
|
||||||
|
// Re-auth every 40 doctors (token may expire on long runs)
|
||||||
|
if (i > 0 && i % 40 === 0) {
|
||||||
|
await auth();
|
||||||
|
console.log(` (re-authed at ${i})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const doc = raw.doctors[i];
|
||||||
|
const firstName = doc.name.replace(/^Dr\.?\s*/i, '').split(' ')[0] ?? '';
|
||||||
|
const lastNameParts = doc.name.replace(/^Dr\.?\s*/i, '').split(' ').slice(1);
|
||||||
|
const lastName = lastNameParts.join(' ');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await mk('doctor', {
|
||||||
|
name: doc.name,
|
||||||
|
fullName: { firstName, lastName },
|
||||||
|
department: doc.department ?? 'Other',
|
||||||
|
specialty: doc.designation ?? 'Consultant',
|
||||||
|
qualifications: doc.qualifications ?? '',
|
||||||
|
registrationNumber: '',
|
||||||
|
active: true,
|
||||||
|
});
|
||||||
|
created++;
|
||||||
|
if (created % 20 === 0) console.log(` ${created}/${raw.doctors.length} created...`);
|
||||||
|
} catch (err: any) {
|
||||||
|
failed++;
|
||||||
|
console.error(` ✗ ${doc.name}: ${err.message?.slice(0, 80)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n ✅ ${created} doctors created, ${failed} failed\n`);
|
||||||
|
console.log('🎉 Ramaiah seed complete!');
|
||||||
|
console.log(` 1 clinic · ${created} doctors · ${raw.departments.length} departments`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(e => { console.error('💥', e.message); process.exit(1); });
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from 'react';
|
||||||
import { Link } from "react-router";
|
import { Link } from 'react-router';
|
||||||
import { formatCurrency } from "@/lib/format";
|
|
||||||
import type { Campaign } from "@/types/entities";
|
import { formatCurrency } from '@/lib/format';
|
||||||
import { cx } from "@/utils/cx";
|
import { cx } from '@/utils/cx';
|
||||||
|
import type { Campaign } from '@/types/entities';
|
||||||
|
|
||||||
interface CampaignRoiCardsProps {
|
interface CampaignRoiCardsProps {
|
||||||
campaigns: Campaign[];
|
campaigns: Campaign[];
|
||||||
@@ -33,9 +34,9 @@ export const CampaignRoiCards = ({ campaigns }: CampaignRoiCardsProps) => {
|
|||||||
}, [campaigns]);
|
}, [campaigns]);
|
||||||
|
|
||||||
const getHealthColor = (rate: number): string => {
|
const getHealthColor = (rate: number): string => {
|
||||||
if (rate >= 0.1) return "bg-success-500";
|
if (rate >= 0.1) return 'bg-success-500';
|
||||||
if (rate >= 0.05) return "bg-warning-500";
|
if (rate >= 0.05) return 'bg-warning-500';
|
||||||
return "bg-error-500";
|
return 'bg-error-500';
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -43,10 +44,17 @@ export const CampaignRoiCards = ({ campaigns }: CampaignRoiCardsProps) => {
|
|||||||
<h3 className="text-sm font-bold text-primary">Campaign ROI</h3>
|
<h3 className="text-sm font-bold text-primary">Campaign ROI</h3>
|
||||||
<div className="flex gap-4 overflow-x-auto pb-1">
|
<div className="flex gap-4 overflow-x-auto pb-1">
|
||||||
{sorted.map((campaign) => (
|
{sorted.map((campaign) => (
|
||||||
<div key={campaign.id} className="min-w-[220px] flex-shrink-0 rounded-xl border border-secondary bg-primary p-4">
|
<div
|
||||||
|
key={campaign.id}
|
||||||
|
className="min-w-[220px] flex-shrink-0 rounded-xl border border-secondary bg-primary p-4"
|
||||||
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className={cx("size-2 shrink-0 rounded-full", getHealthColor(campaign.conversionRate))} />
|
<span
|
||||||
<span className="truncate text-sm font-semibold text-primary">{campaign.campaignName}</span>
|
className={cx('size-2 shrink-0 rounded-full', getHealthColor(campaign.conversionRate))}
|
||||||
|
/>
|
||||||
|
<span className="truncate text-sm font-semibold text-primary">
|
||||||
|
{campaign.campaignName}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-3 flex items-center gap-3 text-xs text-tertiary">
|
<div className="mt-3 flex items-center gap-3 text-xs text-tertiary">
|
||||||
@@ -56,14 +64,23 @@ export const CampaignRoiCards = ({ campaigns }: CampaignRoiCardsProps) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<span className="text-sm font-bold text-primary">{campaign.cac === Infinity ? "—" : formatCurrency(campaign.cac)}</span>
|
<span className="text-sm font-bold text-primary">
|
||||||
|
{campaign.cac === Infinity
|
||||||
|
? '—'
|
||||||
|
: formatCurrency(campaign.cac)}
|
||||||
|
</span>
|
||||||
<span className="ml-1 text-xs text-tertiary">CAC</span>
|
<span className="ml-1 text-xs text-tertiary">CAC</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-3 h-1 w-full overflow-hidden rounded-full bg-tertiary">
|
<div className="mt-3 h-1 w-full overflow-hidden rounded-full bg-tertiary">
|
||||||
<div className="h-full rounded-full bg-brand-solid" style={{ width: `${Math.min(campaign.budgetProgress * 100, 100)}%` }} />
|
<div
|
||||||
|
className="h-full rounded-full bg-brand-solid"
|
||||||
|
style={{ width: `${Math.min(campaign.budgetProgress * 100, 100)}%` }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="mt-1 block text-xs text-quaternary">{Math.round(campaign.budgetProgress * 100)}% budget used</span>
|
<span className="mt-1 block text-xs text-quaternary">
|
||||||
|
{Math.round(campaign.budgetProgress * 100)}% budget used
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,37 +1,37 @@
|
|||||||
import { BadgeWithDot } from "@/components/base/badges/badges";
|
import { BadgeWithDot } from '@/components/base/badges/badges';
|
||||||
import type { AuthStatus, IntegrationStatus, LeadIngestionSource } from "@/types/entities";
|
import { cx } from '@/utils/cx';
|
||||||
import { cx } from "@/utils/cx";
|
import type { IntegrationStatus, AuthStatus, LeadIngestionSource } from '@/types/entities';
|
||||||
|
|
||||||
interface IntegrationHealthProps {
|
interface IntegrationHealthProps {
|
||||||
sources: LeadIngestionSource[];
|
sources: LeadIngestionSource[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusBorderMap: Record<IntegrationStatus, string> = {
|
const statusBorderMap: Record<IntegrationStatus, string> = {
|
||||||
ACTIVE: "border-secondary",
|
ACTIVE: 'border-secondary',
|
||||||
WARNING: "border-warning",
|
WARNING: 'border-warning',
|
||||||
ERROR: "border-error",
|
ERROR: 'border-error',
|
||||||
DISABLED: "border-secondary",
|
DISABLED: 'border-secondary',
|
||||||
};
|
};
|
||||||
|
|
||||||
const statusBadgeColorMap: Record<IntegrationStatus, "success" | "warning" | "error" | "gray"> = {
|
const statusBadgeColorMap: Record<IntegrationStatus, 'success' | 'warning' | 'error' | 'gray'> = {
|
||||||
ACTIVE: "success",
|
ACTIVE: 'success',
|
||||||
WARNING: "warning",
|
WARNING: 'warning',
|
||||||
ERROR: "error",
|
ERROR: 'error',
|
||||||
DISABLED: "gray",
|
DISABLED: 'gray',
|
||||||
};
|
};
|
||||||
|
|
||||||
const authBadgeColorMap: Record<AuthStatus, "success" | "warning" | "error" | "gray"> = {
|
const authBadgeColorMap: Record<AuthStatus, 'success' | 'warning' | 'error' | 'gray'> = {
|
||||||
VALID: "success",
|
VALID: 'success',
|
||||||
EXPIRING_SOON: "warning",
|
EXPIRING_SOON: 'warning',
|
||||||
EXPIRED: "error",
|
EXPIRED: 'error',
|
||||||
NOT_CONFIGURED: "gray",
|
NOT_CONFIGURED: 'gray',
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatRelativeTime(isoString: string): string {
|
function formatRelativeTime(isoString: string): string {
|
||||||
const diffMs = Date.now() - new Date(isoString).getTime();
|
const diffMs = Date.now() - new Date(isoString).getTime();
|
||||||
const diffMinutes = Math.floor(diffMs / (1000 * 60));
|
const diffMinutes = Math.floor(diffMs / (1000 * 60));
|
||||||
|
|
||||||
if (diffMinutes < 1) return "just now";
|
if (diffMinutes < 1) return 'just now';
|
||||||
if (diffMinutes < 60) return `${diffMinutes} min ago`;
|
if (diffMinutes < 60) return `${diffMinutes} min ago`;
|
||||||
|
|
||||||
const diffHours = Math.floor(diffMinutes / 60);
|
const diffHours = Math.floor(diffMinutes / 60);
|
||||||
@@ -48,35 +48,60 @@ export const IntegrationHealth = ({ sources }: IntegrationHealthProps) => {
|
|||||||
|
|
||||||
<div className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
<div className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
{sources.map((source) => {
|
{sources.map((source) => {
|
||||||
const status = source.integrationStatus ?? "DISABLED";
|
const status = source.integrationStatus ?? 'DISABLED';
|
||||||
const authStatus = source.authStatus ?? "NOT_CONFIGURED";
|
const authStatus = source.authStatus ?? 'NOT_CONFIGURED';
|
||||||
const showAuthBadge = authStatus !== "VALID";
|
const showAuthBadge = authStatus !== 'VALID';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={source.id} className={cx("rounded-xl border bg-primary p-4", statusBorderMap[status])}>
|
<div
|
||||||
|
key={source.id}
|
||||||
|
className={cx(
|
||||||
|
'rounded-xl border bg-primary p-4',
|
||||||
|
statusBorderMap[status],
|
||||||
|
)}
|
||||||
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm font-semibold text-primary">{source.sourceName}</span>
|
<span className="text-sm font-semibold text-primary">
|
||||||
<BadgeWithDot size="sm" type="pill-color" color={statusBadgeColorMap[status]}>
|
{source.sourceName}
|
||||||
|
</span>
|
||||||
|
<BadgeWithDot
|
||||||
|
size="sm"
|
||||||
|
type="pill-color"
|
||||||
|
color={statusBadgeColorMap[status]}
|
||||||
|
>
|
||||||
{status}
|
{status}
|
||||||
</BadgeWithDot>
|
</BadgeWithDot>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="mt-2 text-xs text-tertiary">{source.leadsReceivedLast24h ?? 0} leads in 24h</p>
|
<p className="mt-2 text-xs text-tertiary">
|
||||||
|
{source.leadsReceivedLast24h ?? 0} leads in 24h
|
||||||
|
</p>
|
||||||
|
|
||||||
{source.lastSuccessfulSyncAt && (
|
{source.lastSuccessfulSyncAt && (
|
||||||
<p className="mt-0.5 text-xs text-quaternary">Last sync: {formatRelativeTime(source.lastSuccessfulSyncAt)}</p>
|
<p className="mt-0.5 text-xs text-quaternary">
|
||||||
|
Last sync: {formatRelativeTime(source.lastSuccessfulSyncAt)}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showAuthBadge && (
|
{showAuthBadge && (
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<BadgeWithDot size="sm" type="pill-color" color={authBadgeColorMap[authStatus]}>
|
<BadgeWithDot
|
||||||
Auth: {authStatus.replace(/_/g, " ")}
|
size="sm"
|
||||||
|
type="pill-color"
|
||||||
|
color={authBadgeColorMap[authStatus]}
|
||||||
|
>
|
||||||
|
Auth: {authStatus.replace(/_/g, ' ')}
|
||||||
</BadgeWithDot>
|
</BadgeWithDot>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(status === "WARNING" || status === "ERROR") && source.lastErrorMessage && (
|
{(status === 'WARNING' || status === 'ERROR') && source.lastErrorMessage && (
|
||||||
<p className={cx("mt-2 text-xs", status === "ERROR" ? "text-error-primary" : "text-warning-primary")}>
|
<p
|
||||||
|
className={cx(
|
||||||
|
'mt-2 text-xs',
|
||||||
|
status === 'ERROR' ? 'text-error-primary' : 'text-warning-primary',
|
||||||
|
)}
|
||||||
|
>
|
||||||
{source.lastErrorMessage}
|
{source.lastErrorMessage}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from 'react';
|
||||||
import type { Lead } from "@/types/entities";
|
|
||||||
import { cx } from "@/utils/cx";
|
import { cx } from '@/utils/cx';
|
||||||
|
import type { Lead } from '@/types/entities';
|
||||||
|
|
||||||
interface LeadFunnelProps {
|
interface LeadFunnelProps {
|
||||||
leads: Lead[];
|
leads: Lead[];
|
||||||
@@ -16,24 +17,28 @@ export const LeadFunnel = ({ leads }: LeadFunnelProps) => {
|
|||||||
const stages = useMemo((): FunnelStage[] => {
|
const stages = useMemo((): FunnelStage[] => {
|
||||||
const total = leads.length;
|
const total = leads.length;
|
||||||
|
|
||||||
const contacted = leads.filter(
|
const contacted = leads.filter((lead) =>
|
||||||
(lead) =>
|
lead.leadStatus === 'CONTACTED' ||
|
||||||
lead.leadStatus === "CONTACTED" ||
|
lead.leadStatus === 'QUALIFIED' ||
|
||||||
lead.leadStatus === "QUALIFIED" ||
|
lead.leadStatus === 'NURTURING' ||
|
||||||
lead.leadStatus === "NURTURING" ||
|
lead.leadStatus === 'APPOINTMENT_SET' ||
|
||||||
lead.leadStatus === "APPOINTMENT_SET" ||
|
lead.leadStatus === 'CONVERTED',
|
||||||
lead.leadStatus === "CONVERTED",
|
|
||||||
).length;
|
).length;
|
||||||
|
|
||||||
const appointmentSet = leads.filter((lead) => lead.leadStatus === "APPOINTMENT_SET" || lead.leadStatus === "CONVERTED").length;
|
const appointmentSet = leads.filter((lead) =>
|
||||||
|
lead.leadStatus === 'APPOINTMENT_SET' ||
|
||||||
|
lead.leadStatus === 'CONVERTED',
|
||||||
|
).length;
|
||||||
|
|
||||||
const converted = leads.filter((lead) => lead.leadStatus === "CONVERTED").length;
|
const converted = leads.filter((lead) =>
|
||||||
|
lead.leadStatus === 'CONVERTED',
|
||||||
|
).length;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{ label: "Generated", count: total, color: "bg-brand-600" },
|
{ label: 'Generated', count: total, color: 'bg-brand-600' },
|
||||||
{ label: "Contacted", count: contacted, color: "bg-brand-500" },
|
{ label: 'Contacted', count: contacted, color: 'bg-brand-500' },
|
||||||
{ label: "Appointment Set", count: appointmentSet, color: "bg-brand-400" },
|
{ label: 'Appointment Set', count: appointmentSet, color: 'bg-brand-400' },
|
||||||
{ label: "Converted", count: converted, color: "bg-success-500" },
|
{ label: 'Converted', count: converted, color: 'bg-success-500' },
|
||||||
];
|
];
|
||||||
}, [leads]);
|
}, [leads]);
|
||||||
|
|
||||||
@@ -47,7 +52,10 @@ export const LeadFunnel = ({ leads }: LeadFunnelProps) => {
|
|||||||
{stages.map((stage, index) => {
|
{stages.map((stage, index) => {
|
||||||
const widthPercent = maxCount > 0 ? (stage.count / maxCount) * 100 : 0;
|
const widthPercent = maxCount > 0 ? (stage.count / maxCount) * 100 : 0;
|
||||||
const previousCount = index > 0 ? stages[index - 1].count : null;
|
const previousCount = index > 0 ? stages[index - 1].count : null;
|
||||||
const conversionRate = previousCount !== null && previousCount > 0 ? ((stage.count / previousCount) * 100).toFixed(0) : null;
|
const conversionRate =
|
||||||
|
previousCount !== null && previousCount > 0
|
||||||
|
? ((stage.count / previousCount) * 100).toFixed(0)
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={stage.label} className="space-y-1">
|
<div key={stage.label} className="space-y-1">
|
||||||
@@ -55,11 +63,16 @@ export const LeadFunnel = ({ leads }: LeadFunnelProps) => {
|
|||||||
<span className="text-xs font-medium text-secondary">{stage.label}</span>
|
<span className="text-xs font-medium text-secondary">{stage.label}</span>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm font-bold text-primary">{stage.count}</span>
|
<span className="text-sm font-bold text-primary">{stage.count}</span>
|
||||||
{conversionRate !== null && <span className="text-xs text-tertiary">({conversionRate}%)</span>}
|
{conversionRate !== null && (
|
||||||
|
<span className="text-xs text-tertiary">({conversionRate}%)</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-6 w-full overflow-hidden rounded-md bg-secondary">
|
<div className="h-6 w-full overflow-hidden rounded-md bg-secondary">
|
||||||
<div className={cx("h-full rounded-md transition-all", stage.color)} style={{ width: `${Math.max(widthPercent, 2)}%` }} />
|
<div
|
||||||
|
className={cx('h-full rounded-md transition-all', stage.color)}
|
||||||
|
style={{ width: `${Math.max(widthPercent, 2)}%` }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { type FC, useMemo } from "react";
|
import type { FC } from 'react';
|
||||||
import { faCircleCheck, faCircleExclamation, faTriangleExclamation } from "@fortawesome/pro-duotone-svg-icons";
|
import { useMemo } from 'react';
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|
||||||
import type { Lead } from "@/types/entities";
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { cx } from "@/utils/cx";
|
import { faCircleCheck, faTriangleExclamation, faCircleExclamation } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
|
import { cx } from '@/utils/cx';
|
||||||
|
import type { Lead } from '@/types/entities';
|
||||||
|
|
||||||
const CheckCircle: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faCircleCheck} className={className} />;
|
const CheckCircle: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faCircleCheck} className={className} />;
|
||||||
const AlertTriangle: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faTriangleExclamation} className={className} />;
|
const AlertTriangle: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faTriangleExclamation} className={className} />;
|
||||||
@@ -18,7 +20,7 @@ export const SlaMetrics = ({ leads }: SlaMetricsProps) => {
|
|||||||
const metrics = useMemo(() => {
|
const metrics = useMemo(() => {
|
||||||
const responseTimes: number[] = [];
|
const responseTimes: number[] = [];
|
||||||
let withinSla = 0;
|
let withinSla = 0;
|
||||||
const total = leads.length;
|
let total = leads.length;
|
||||||
|
|
||||||
for (const lead of leads) {
|
for (const lead of leads) {
|
||||||
if (lead.createdAt && lead.firstContactedAt) {
|
if (lead.createdAt && lead.firstContactedAt) {
|
||||||
@@ -34,40 +36,39 @@ export const SlaMetrics = ({ leads }: SlaMetricsProps) => {
|
|||||||
// Leads without firstContactedAt are counted as outside SLA
|
// Leads without firstContactedAt are counted as outside SLA
|
||||||
}
|
}
|
||||||
|
|
||||||
const avgHours = responseTimes.length > 0 ? responseTimes.reduce((sum, h) => sum + h, 0) / responseTimes.length : 0;
|
const avgHours =
|
||||||
|
responseTimes.length > 0
|
||||||
|
? responseTimes.reduce((sum, h) => sum + h, 0) / responseTimes.length
|
||||||
|
: 0;
|
||||||
|
|
||||||
const slaPercent = total > 0 ? (withinSla / total) * 100 : 0;
|
const slaPercent = total > 0 ? (withinSla / total) * 100 : 0;
|
||||||
|
|
||||||
return { avgHours, withinSla, total, slaPercent };
|
return { avgHours, withinSla, total, slaPercent };
|
||||||
}, [leads]);
|
}, [leads]);
|
||||||
|
|
||||||
const getTargetStatus = (): {
|
const getTargetStatus = (): { icon: FC<{ className?: string }>; label: string; colorClass: string } => {
|
||||||
icon: FC<{ className?: string }>;
|
|
||||||
label: string;
|
|
||||||
colorClass: string;
|
|
||||||
} => {
|
|
||||||
const diff = metrics.avgHours - SLA_TARGET_HOURS;
|
const diff = metrics.avgHours - SLA_TARGET_HOURS;
|
||||||
|
|
||||||
if (diff <= 0) {
|
if (diff <= 0) {
|
||||||
return {
|
return {
|
||||||
icon: CheckCircle,
|
icon: CheckCircle,
|
||||||
label: "Below target",
|
label: 'Below target',
|
||||||
colorClass: "text-success-primary",
|
colorClass: 'text-success-primary',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (diff <= 0.5) {
|
if (diff <= 0.5) {
|
||||||
return {
|
return {
|
||||||
icon: AlertTriangle,
|
icon: AlertTriangle,
|
||||||
label: "Near target",
|
label: 'Near target',
|
||||||
colorClass: "text-warning-primary",
|
colorClass: 'text-warning-primary',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
icon: AlertCircle,
|
icon: AlertCircle,
|
||||||
label: "Above target",
|
label: 'Above target',
|
||||||
colorClass: "text-error-primary",
|
colorClass: 'text-error-primary',
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -79,14 +80,18 @@ export const SlaMetrics = ({ leads }: SlaMetricsProps) => {
|
|||||||
<h3 className="text-sm font-bold text-primary">Response SLA</h3>
|
<h3 className="text-sm font-bold text-primary">Response SLA</h3>
|
||||||
|
|
||||||
<div className="mt-4 flex items-end gap-3">
|
<div className="mt-4 flex items-end gap-3">
|
||||||
<span className="text-display-sm font-bold text-primary">{metrics.avgHours.toFixed(1)}h</span>
|
<span className="text-display-sm font-bold text-primary">
|
||||||
|
{metrics.avgHours.toFixed(1)}h
|
||||||
|
</span>
|
||||||
<div className="mb-1 flex items-center gap-1">
|
<div className="mb-1 flex items-center gap-1">
|
||||||
<span className="text-xs text-tertiary">Target: {SLA_TARGET_HOURS}h</span>
|
<span className="text-xs text-tertiary">Target: {SLA_TARGET_HOURS}h</span>
|
||||||
<StatusIcon className={cx("size-4", status.colorClass)} />
|
<StatusIcon className={cx('size-4', status.colorClass)} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span className={cx("mt-1 block text-xs font-medium", status.colorClass)}>{status.label}</span>
|
<span className={cx('mt-1 block text-xs font-medium', status.colorClass)}>
|
||||||
|
{status.label}
|
||||||
|
</span>
|
||||||
|
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<div className="flex items-center justify-between text-xs text-tertiary">
|
<div className="flex items-center justify-between text-xs text-tertiary">
|
||||||
@@ -96,15 +101,18 @@ export const SlaMetrics = ({ leads }: SlaMetricsProps) => {
|
|||||||
<div className="mt-1.5 h-2 w-full overflow-hidden rounded-full bg-tertiary">
|
<div className="mt-1.5 h-2 w-full overflow-hidden rounded-full bg-tertiary">
|
||||||
<div
|
<div
|
||||||
className={cx(
|
className={cx(
|
||||||
"h-full rounded-full transition-all",
|
'h-full rounded-full transition-all',
|
||||||
metrics.slaPercent >= 80 ? "bg-success-500" : metrics.slaPercent >= 60 ? "bg-warning-500" : "bg-error-500",
|
metrics.slaPercent >= 80
|
||||||
|
? 'bg-success-500'
|
||||||
|
: metrics.slaPercent >= 60
|
||||||
|
? 'bg-warning-500'
|
||||||
|
: 'bg-error-500',
|
||||||
)}
|
)}
|
||||||
style={{ width: `${metrics.slaPercent}%` }}
|
style={{ width: `${metrics.slaPercent}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="mt-1.5 block text-xs text-tertiary">
|
<span className="mt-1.5 block text-xs text-tertiary">
|
||||||
{metrics.withinSla} of {metrics.total} leads within SLA ({Math.round(metrics.slaPercent)}
|
{metrics.withinSla} of {metrics.total} leads within SLA ({Math.round(metrics.slaPercent)}%)
|
||||||
%)
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from 'react';
|
||||||
import { Avatar } from "@/components/base/avatar/avatar";
|
|
||||||
import { BadgeWithDot } from "@/components/base/badges/badges";
|
import { Avatar } from '@/components/base/avatar/avatar';
|
||||||
import type { Agent, Call, Lead } from "@/types/entities";
|
import { BadgeWithDot } from '@/components/base/badges/badges';
|
||||||
import { cx } from "@/utils/cx";
|
import { cx } from '@/utils/cx';
|
||||||
|
import type { Lead, Call, Agent } from '@/types/entities';
|
||||||
|
|
||||||
interface TeamScoreboardProps {
|
interface TeamScoreboardProps {
|
||||||
leads: Lead[];
|
leads: Lead[];
|
||||||
@@ -24,14 +25,16 @@ export const TeamScoreboard = ({ leads, calls, agents }: TeamScoreboardProps) =>
|
|||||||
const leadsProcessed = leads.filter((lead) => lead.assignedAgent === agentName).length;
|
const leadsProcessed = leads.filter((lead) => lead.assignedAgent === agentName).length;
|
||||||
const agentCalls = calls.filter((call) => call.agentName === agentName);
|
const agentCalls = calls.filter((call) => call.agentName === agentName);
|
||||||
const callsMade = agentCalls.length;
|
const callsMade = agentCalls.length;
|
||||||
const appointmentsBooked = agentCalls.filter((call) => call.disposition === "APPOINTMENT_BOOKED").length;
|
const appointmentsBooked = agentCalls.filter(
|
||||||
|
(call) => call.disposition === 'APPOINTMENT_BOOKED',
|
||||||
|
).length;
|
||||||
|
|
||||||
return { agent, leadsProcessed, callsMade, appointmentsBooked };
|
return { agent, leadsProcessed, callsMade, appointmentsBooked };
|
||||||
});
|
});
|
||||||
}, [leads, calls, agents]);
|
}, [leads, calls, agents]);
|
||||||
|
|
||||||
const bestPerformerId = useMemo(() => {
|
const bestPerformerId = useMemo(() => {
|
||||||
let bestId = "";
|
let bestId = '';
|
||||||
let maxAppointments = -1;
|
let maxAppointments = -1;
|
||||||
|
|
||||||
for (const stat of agentStats) {
|
for (const stat of agentStats) {
|
||||||
@@ -53,19 +56,29 @@ export const TeamScoreboard = ({ leads, calls, agents }: TeamScoreboardProps) =>
|
|||||||
<div
|
<div
|
||||||
key={agent.id}
|
key={agent.id}
|
||||||
className={cx(
|
className={cx(
|
||||||
"flex min-w-[260px] flex-1 flex-col gap-4 rounded-2xl border border-secondary bg-primary p-5",
|
'flex min-w-[260px] flex-1 flex-col gap-4 rounded-2xl border border-secondary bg-primary p-5',
|
||||||
isBest && "ring-2 ring-success-600",
|
isBest && 'ring-2 ring-success-600',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Avatar initials={agent.initials ?? undefined} size="md" status={agent.isOnShift ? "online" : "offline"} />
|
<Avatar
|
||||||
|
initials={agent.initials ?? undefined}
|
||||||
|
size="md"
|
||||||
|
status={agent.isOnShift ? 'online' : 'offline'}
|
||||||
|
/>
|
||||||
<div className="flex flex-1 flex-col">
|
<div className="flex flex-1 flex-col">
|
||||||
<span className="text-sm font-semibold text-primary">{agent.name}</span>
|
<span className="text-sm font-semibold text-primary">{agent.name}</span>
|
||||||
<BadgeWithDot size="sm" type="pill-color" color={agent.isOnShift ? "success" : "gray"}>
|
<BadgeWithDot
|
||||||
{agent.isOnShift ? "On Shift" : "Off Shift"}
|
size="sm"
|
||||||
|
type="pill-color"
|
||||||
|
color={agent.isOnShift ? 'success' : 'gray'}
|
||||||
|
>
|
||||||
|
{agent.isOnShift ? 'On Shift' : 'Off Shift'}
|
||||||
</BadgeWithDot>
|
</BadgeWithDot>
|
||||||
</div>
|
</div>
|
||||||
{isBest && <span className="text-xs font-medium text-success-primary">Top</span>}
|
{isBest && (
|
||||||
|
<span className="text-xs font-medium text-success-primary">Top</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
@@ -83,7 +96,9 @@ export const TeamScoreboard = ({ leads, calls, agents }: TeamScoreboardProps) =>
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="text-xs text-tertiary">Avg Response</span>
|
<span className="text-xs text-tertiary">Avg Response</span>
|
||||||
<span className="text-lg font-bold text-primary">{agent.avgResponseHours ?? "—"}h</span>
|
<span className="text-lg font-bold text-primary">
|
||||||
|
{agent.avgResponseHours ?? '—'}h
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { PropsWithChildren } from "react";
|
import type { PropsWithChildren } from "react";
|
||||||
import { faBars, faXmark } from "@fortawesome/pro-duotone-svg-icons";
|
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { faXmark, faBars } from "@fortawesome/pro-duotone-svg-icons";
|
||||||
import {
|
import {
|
||||||
Button as AriaButton,
|
Button as AriaButton,
|
||||||
Dialog as AriaDialog,
|
Dialog as AriaDialog,
|
||||||
|
|||||||
@@ -6,12 +6,10 @@ export type NavItemType = {
|
|||||||
/** URL to navigate to when the nav item is clicked. */
|
/** URL to navigate to when the nav item is clicked. */
|
||||||
href?: string;
|
href?: string;
|
||||||
/** Icon component to display. */
|
/** Icon component to display. */
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
icon?: FC<Record<string, any>>;
|
icon?: FC<Record<string, any>>;
|
||||||
/** Badge to display. */
|
/** Badge to display. */
|
||||||
badge?: ReactNode;
|
badge?: ReactNode;
|
||||||
/** List of sub-items to display. */
|
/** List of sub-items to display. */
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
items?: { label: string; href: string; icon?: FC<Record<string, any>>; badge?: ReactNode }[];
|
items?: { label: string; href: string; icon?: FC<Record<string, any>>; badge?: ReactNode }[];
|
||||||
/** Whether this nav item is a divider. */
|
/** Whether this nav item is a divider. */
|
||||||
divider?: boolean;
|
divider?: boolean;
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import type { FC, ReactNode } from "react";
|
import type { FC, ReactNode } from "react";
|
||||||
import { faBell, faGear, faLifeRing, faMagnifyingGlass } from "@fortawesome/pro-duotone-svg-icons";
|
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { faBell, faLifeRing, faMagnifyingGlass, faGear } from "@fortawesome/pro-duotone-svg-icons";
|
||||||
|
|
||||||
|
const Bell01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faBell} className={className} />;
|
||||||
|
const LifeBuoy01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faLifeRing} className={className} />;
|
||||||
|
const SearchLg: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />;
|
||||||
|
const Settings01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faGear} className={className} />;
|
||||||
import { Button as AriaButton, DialogTrigger, Popover } from "react-aria-components";
|
import { Button as AriaButton, DialogTrigger, Popover } from "react-aria-components";
|
||||||
import { Avatar } from "@/components/base/avatar/avatar";
|
import { Avatar } from "@/components/base/avatar/avatar";
|
||||||
import { BadgeWithDot } from "@/components/base/badges/badges";
|
import { BadgeWithDot } from "@/components/base/badges/badges";
|
||||||
@@ -13,11 +18,6 @@ import { NavItemBase } from "./base-components/nav-item";
|
|||||||
import { NavItemButton } from "./base-components/nav-item-button";
|
import { NavItemButton } from "./base-components/nav-item-button";
|
||||||
import { NavList } from "./base-components/nav-list";
|
import { NavList } from "./base-components/nav-list";
|
||||||
|
|
||||||
const Bell01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faBell} className={className} />;
|
|
||||||
const LifeBuoy01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faLifeRing} className={className} />;
|
|
||||||
const SearchLg: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />;
|
|
||||||
const Settings01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faGear} className={className} />;
|
|
||||||
|
|
||||||
type NavItem = {
|
type NavItem = {
|
||||||
/** Label text for the nav item. */
|
/** Label text for the nav item. */
|
||||||
label: string;
|
label: string;
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { type FC, type ReactNode, useState } from "react";
|
import type { FC, ReactNode } from "react";
|
||||||
import { faMagnifyingGlass } from "@fortawesome/pro-duotone-svg-icons";
|
import { useState } from "react";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { faMagnifyingGlass } from "@fortawesome/pro-duotone-svg-icons";
|
||||||
|
|
||||||
|
const SearchLg: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />;
|
||||||
import { AnimatePresence, motion } from "motion/react";
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
import { Input } from "@/components/base/input/input";
|
import { Input } from "@/components/base/input/input";
|
||||||
import { UntitledLogo } from "@/components/foundations/logo/untitledui-logo";
|
import { UntitledLogo } from "@/components/foundations/logo/untitledui-logo";
|
||||||
@@ -11,8 +14,6 @@ import { NavItemBase } from "../base-components/nav-item";
|
|||||||
import { NavList } from "../base-components/nav-list";
|
import { NavList } from "../base-components/nav-list";
|
||||||
import type { NavItemType } from "../config";
|
import type { NavItemType } from "../config";
|
||||||
|
|
||||||
const SearchLg: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />;
|
|
||||||
|
|
||||||
interface SidebarNavigationDualTierProps {
|
interface SidebarNavigationDualTierProps {
|
||||||
/** URL of the currently active item. */
|
/** URL of the currently active item. */
|
||||||
activeUrl?: string;
|
activeUrl?: string;
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { faMagnifyingGlass } from "@fortawesome/pro-duotone-svg-icons";
|
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { faMagnifyingGlass } from "@fortawesome/pro-duotone-svg-icons";
|
||||||
|
|
||||||
|
const SearchLg: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />;
|
||||||
import { Input } from "@/components/base/input/input";
|
import { Input } from "@/components/base/input/input";
|
||||||
import { UntitledLogo } from "@/components/foundations/logo/untitledui-logo";
|
import { UntitledLogo } from "@/components/foundations/logo/untitledui-logo";
|
||||||
import { MobileNavigationHeader } from "../base-components/mobile-header";
|
import { MobileNavigationHeader } from "../base-components/mobile-header";
|
||||||
@@ -8,8 +10,6 @@ import { NavAccountCard } from "../base-components/nav-account-card";
|
|||||||
import { NavList } from "../base-components/nav-list";
|
import { NavList } from "../base-components/nav-list";
|
||||||
import type { NavItemDividerType, NavItemType } from "../config";
|
import type { NavItemDividerType, NavItemType } from "../config";
|
||||||
|
|
||||||
const SearchLg: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />;
|
|
||||||
|
|
||||||
interface SidebarNavigationSectionDividersProps {
|
interface SidebarNavigationSectionDividersProps {
|
||||||
/** URL of the currently active item. */
|
/** URL of the currently active item. */
|
||||||
activeUrl?: string;
|
activeUrl?: string;
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import type { FC, ReactNode } from "react";
|
import type { FC, ReactNode } from "react";
|
||||||
import { faMagnifyingGlass } from "@fortawesome/pro-duotone-svg-icons";
|
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { faMagnifyingGlass } from "@fortawesome/pro-duotone-svg-icons";
|
||||||
|
|
||||||
|
const SearchLg: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />;
|
||||||
import { Input } from "@/components/base/input/input";
|
import { Input } from "@/components/base/input/input";
|
||||||
import { UntitledLogo } from "@/components/foundations/logo/untitledui-logo";
|
import { UntitledLogo } from "@/components/foundations/logo/untitledui-logo";
|
||||||
import { cx } from "@/utils/cx";
|
import { cx } from "@/utils/cx";
|
||||||
@@ -10,8 +12,6 @@ import { NavItemBase } from "../base-components/nav-item";
|
|||||||
import { NavList } from "../base-components/nav-list";
|
import { NavList } from "../base-components/nav-list";
|
||||||
import type { NavItemType } from "../config";
|
import type { NavItemType } from "../config";
|
||||||
|
|
||||||
const SearchLg: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />;
|
|
||||||
|
|
||||||
interface SidebarNavigationProps {
|
interface SidebarNavigationProps {
|
||||||
/** URL of the currently active item. */
|
/** URL of the currently active item. */
|
||||||
activeUrl?: string;
|
activeUrl?: string;
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { type FC, useState } from "react";
|
import type { FC } from "react";
|
||||||
import { faArrowRightFromBracket, faGear, faLifeRing } from "@fortawesome/pro-duotone-svg-icons";
|
import { useState } from "react";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { faLifeRing, faArrowRightFromBracket, faGear } from "@fortawesome/pro-duotone-svg-icons";
|
||||||
|
|
||||||
|
const LifeBuoy01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faLifeRing} className={className} />;
|
||||||
|
const LogOut01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faArrowRightFromBracket} className={className} />;
|
||||||
|
const Settings01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faGear} className={className} />;
|
||||||
import { AnimatePresence, motion } from "motion/react";
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
import { Button as AriaButton, DialogTrigger as AriaDialogTrigger, Popover as AriaPopover } from "react-aria-components";
|
import { Button as AriaButton, DialogTrigger as AriaDialogTrigger, Popover as AriaPopover } from "react-aria-components";
|
||||||
import { Avatar } from "@/components/base/avatar/avatar";
|
import { Avatar } from "@/components/base/avatar/avatar";
|
||||||
@@ -17,10 +22,6 @@ import { NavItemButton } from "../base-components/nav-item-button";
|
|||||||
import { NavList } from "../base-components/nav-list";
|
import { NavList } from "../base-components/nav-list";
|
||||||
import type { NavItemType } from "../config";
|
import type { NavItemType } from "../config";
|
||||||
|
|
||||||
const LifeBuoy01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faLifeRing} className={className} />;
|
|
||||||
const LogOut01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faArrowRightFromBracket} className={className} />;
|
|
||||||
const Settings01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faGear} className={className} />;
|
|
||||||
|
|
||||||
interface SidebarNavigationSlimProps {
|
interface SidebarNavigationSlimProps {
|
||||||
/** URL of the currently active item. */
|
/** URL of the currently active item. */
|
||||||
activeUrl?: string;
|
activeUrl?: string;
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import { type FC, Fragment, type HTMLAttributes, type PropsWithChildren, useContext, useState } from "react";
|
import type { FC, HTMLAttributes, PropsWithChildren } from "react";
|
||||||
import { faChevronLeft, faChevronRight } from "@fortawesome/pro-duotone-svg-icons";
|
import { Fragment, useContext, useState } from "react";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|
||||||
import { type CalendarDate, getLocalTimeZone, today } from "@internationalized/date";
|
import { type CalendarDate, getLocalTimeZone, today } from "@internationalized/date";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { faChevronLeft, faChevronRight } from "@fortawesome/pro-duotone-svg-icons";
|
||||||
|
|
||||||
|
const ChevronLeft: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faChevronLeft} className={className} />;
|
||||||
|
const ChevronRight: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faChevronRight} className={className} />;
|
||||||
|
import type { CalendarProps as AriaCalendarProps, DateValue } from "react-aria-components";
|
||||||
import {
|
import {
|
||||||
Calendar as AriaCalendar,
|
Calendar as AriaCalendar,
|
||||||
CalendarContext as AriaCalendarContext,
|
CalendarContext as AriaCalendarContext,
|
||||||
@@ -9,10 +14,8 @@ import {
|
|||||||
CalendarGridBody as AriaCalendarGridBody,
|
CalendarGridBody as AriaCalendarGridBody,
|
||||||
CalendarGridHeader as AriaCalendarGridHeader,
|
CalendarGridHeader as AriaCalendarGridHeader,
|
||||||
CalendarHeaderCell as AriaCalendarHeaderCell,
|
CalendarHeaderCell as AriaCalendarHeaderCell,
|
||||||
type CalendarProps as AriaCalendarProps,
|
|
||||||
CalendarStateContext as AriaCalendarStateContext,
|
CalendarStateContext as AriaCalendarStateContext,
|
||||||
Heading as AriaHeading,
|
Heading as AriaHeading,
|
||||||
type DateValue,
|
|
||||||
useSlottedContext,
|
useSlottedContext,
|
||||||
} from "react-aria-components";
|
} from "react-aria-components";
|
||||||
import { Button } from "@/components/base/buttons/button";
|
import { Button } from "@/components/base/buttons/button";
|
||||||
@@ -20,9 +23,6 @@ import { cx } from "@/utils/cx";
|
|||||||
import { CalendarCell } from "./cell";
|
import { CalendarCell } from "./cell";
|
||||||
import { DateInput } from "./date-input";
|
import { DateInput } from "./date-input";
|
||||||
|
|
||||||
const ChevronLeft: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faChevronLeft} className={className} />;
|
|
||||||
const ChevronRight: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faChevronRight} className={className} />;
|
|
||||||
|
|
||||||
export const CalendarContextProvider = ({ children }: PropsWithChildren) => {
|
export const CalendarContextProvider = ({ children }: PropsWithChildren) => {
|
||||||
const [value, onChange] = useState<DateValue | null>(null);
|
const [value, onChange] = useState<DateValue | null>(null);
|
||||||
const [focusedValue, onFocusChange] = useState<DateValue | undefined>();
|
const [focusedValue, onFocusChange] = useState<DateValue | undefined>();
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
import { getDayOfWeek, getLocalTimeZone, isToday } from "@internationalized/date";
|
import { getDayOfWeek, getLocalTimeZone, isToday } from "@internationalized/date";
|
||||||
import {
|
import type { CalendarCellProps as AriaCalendarCellProps } from "react-aria-components";
|
||||||
CalendarCell as AriaCalendarCell,
|
import { CalendarCell as AriaCalendarCell, RangeCalendarContext, useLocale, useSlottedContext } from "react-aria-components";
|
||||||
type CalendarCellProps as AriaCalendarCellProps,
|
|
||||||
RangeCalendarContext,
|
|
||||||
useLocale,
|
|
||||||
useSlottedContext,
|
|
||||||
} from "react-aria-components";
|
|
||||||
import { cx } from "@/utils/cx";
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
interface CalendarCellProps extends AriaCalendarCellProps {
|
interface CalendarCellProps extends AriaCalendarCellProps {
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { DateInput as AriaDateInput, type DateInputProps as AriaDateInputProps, DateSegment as AriaDateSegment } from "react-aria-components";
|
import type { DateInputProps as AriaDateInputProps } from "react-aria-components";
|
||||||
|
import { DateInput as AriaDateInput, DateSegment as AriaDateSegment } from "react-aria-components";
|
||||||
import { cx } from "@/utils/cx";
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
type DateInputProps = Omit<AriaDateInputProps, "children">;
|
interface DateInputProps extends Omit<AriaDateInputProps, "children"> {}
|
||||||
|
|
||||||
export const DateInput = (props: DateInputProps) => {
|
export const DateInput = (props: DateInputProps) => {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,23 +1,17 @@
|
|||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { faCalendar } from "@fortawesome/pro-duotone-svg-icons";
|
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|
||||||
import { getLocalTimeZone, today } from "@internationalized/date";
|
import { getLocalTimeZone, today } from "@internationalized/date";
|
||||||
import { useControlledState } from "@react-stately/utils";
|
import { useControlledState } from "@react-stately/utils";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { faCalendar } from "@fortawesome/pro-duotone-svg-icons";
|
||||||
|
|
||||||
|
const CalendarIcon: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faCalendar} className={className} />;
|
||||||
import { useDateFormatter } from "react-aria";
|
import { useDateFormatter } from "react-aria";
|
||||||
import {
|
import type { DatePickerProps as AriaDatePickerProps, DateValue } from "react-aria-components";
|
||||||
DatePicker as AriaDatePicker,
|
import { DatePicker as AriaDatePicker, Dialog as AriaDialog, Group as AriaGroup, Popover as AriaPopover } from "react-aria-components";
|
||||||
type DatePickerProps as AriaDatePickerProps,
|
|
||||||
Dialog as AriaDialog,
|
|
||||||
Group as AriaGroup,
|
|
||||||
Popover as AriaPopover,
|
|
||||||
type DateValue,
|
|
||||||
} from "react-aria-components";
|
|
||||||
import { Button } from "@/components/base/buttons/button";
|
import { Button } from "@/components/base/buttons/button";
|
||||||
import { cx } from "@/utils/cx";
|
import { cx } from "@/utils/cx";
|
||||||
import { Calendar } from "./calendar";
|
import { Calendar } from "./calendar";
|
||||||
|
|
||||||
const CalendarIcon: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faCalendar} className={className} />;
|
|
||||||
|
|
||||||
const highlightedDates = [today(getLocalTimeZone())];
|
const highlightedDates = [today(getLocalTimeZone())];
|
||||||
|
|
||||||
interface DatePickerProps extends AriaDatePickerProps<DateValue> {
|
interface DatePickerProps extends AriaDatePickerProps<DateValue> {
|
||||||
|
|||||||
@@ -1,26 +1,20 @@
|
|||||||
import { type FC, useMemo, useState } from "react";
|
import type { FC } from "react";
|
||||||
import { faCalendar } from "@fortawesome/pro-duotone-svg-icons";
|
import { useMemo, useState } from "react";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|
||||||
import { endOfMonth, endOfWeek, getLocalTimeZone, startOfMonth, startOfWeek, today } from "@internationalized/date";
|
import { endOfMonth, endOfWeek, getLocalTimeZone, startOfMonth, startOfWeek, today } from "@internationalized/date";
|
||||||
import { useControlledState } from "@react-stately/utils";
|
import { useControlledState } from "@react-stately/utils";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { faCalendar } from "@fortawesome/pro-duotone-svg-icons";
|
||||||
|
|
||||||
|
const CalendarIcon: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faCalendar} className={className} />;
|
||||||
import { useDateFormatter } from "react-aria";
|
import { useDateFormatter } from "react-aria";
|
||||||
import {
|
import type { DateRangePickerProps as AriaDateRangePickerProps, DateValue } from "react-aria-components";
|
||||||
DateRangePicker as AriaDateRangePicker,
|
import { DateRangePicker as AriaDateRangePicker, Dialog as AriaDialog, Group as AriaGroup, Popover as AriaPopover, useLocale } from "react-aria-components";
|
||||||
type DateRangePickerProps as AriaDateRangePickerProps,
|
|
||||||
Dialog as AriaDialog,
|
|
||||||
Group as AriaGroup,
|
|
||||||
Popover as AriaPopover,
|
|
||||||
type DateValue,
|
|
||||||
useLocale,
|
|
||||||
} from "react-aria-components";
|
|
||||||
import { Button } from "@/components/base/buttons/button";
|
import { Button } from "@/components/base/buttons/button";
|
||||||
import { cx } from "@/utils/cx";
|
import { cx } from "@/utils/cx";
|
||||||
import { DateInput } from "./date-input";
|
import { DateInput } from "./date-input";
|
||||||
import { RangeCalendar } from "./range-calendar";
|
import { RangeCalendar } from "./range-calendar";
|
||||||
import { RangePresetButton } from "./range-preset";
|
import { RangePresetButton } from "./range-preset";
|
||||||
|
|
||||||
const CalendarIcon: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faCalendar} className={className} />;
|
|
||||||
|
|
||||||
const now = today(getLocalTimeZone());
|
const now = today(getLocalTimeZone());
|
||||||
|
|
||||||
const highlightedDates = [today(getLocalTimeZone())];
|
const highlightedDates = [today(getLocalTimeZone())];
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
import { type FC, Fragment, type HTMLAttributes, type PropsWithChildren, useContext, useState } from "react";
|
import type { FC, HTMLAttributes, PropsWithChildren } from "react";
|
||||||
import { faChevronLeft, faChevronRight } from "@fortawesome/pro-duotone-svg-icons";
|
import { Fragment, useContext, useState } from "react";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|
||||||
import type { CalendarDate } from "@internationalized/date";
|
import type { CalendarDate } from "@internationalized/date";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { faChevronLeft, faChevronRight } from "@fortawesome/pro-duotone-svg-icons";
|
||||||
|
|
||||||
|
const ChevronLeft: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faChevronLeft} className={className} />;
|
||||||
|
const ChevronRight: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faChevronRight} className={className} />;
|
||||||
import { useDateFormatter } from "react-aria";
|
import { useDateFormatter } from "react-aria";
|
||||||
|
import type { RangeCalendarProps as AriaRangeCalendarProps, DateValue } from "react-aria-components";
|
||||||
import {
|
import {
|
||||||
CalendarGrid as AriaCalendarGrid,
|
CalendarGrid as AriaCalendarGrid,
|
||||||
CalendarGridBody as AriaCalendarGridBody,
|
CalendarGridBody as AriaCalendarGridBody,
|
||||||
CalendarGridHeader as AriaCalendarGridHeader,
|
CalendarGridHeader as AriaCalendarGridHeader,
|
||||||
CalendarHeaderCell as AriaCalendarHeaderCell,
|
CalendarHeaderCell as AriaCalendarHeaderCell,
|
||||||
RangeCalendar as AriaRangeCalendar,
|
RangeCalendar as AriaRangeCalendar,
|
||||||
type RangeCalendarProps as AriaRangeCalendarProps,
|
|
||||||
type DateValue,
|
|
||||||
RangeCalendarContext,
|
RangeCalendarContext,
|
||||||
RangeCalendarStateContext,
|
RangeCalendarStateContext,
|
||||||
useSlottedContext,
|
useSlottedContext,
|
||||||
@@ -20,9 +23,6 @@ import { useBreakpoint } from "@/hooks/use-breakpoint";
|
|||||||
import { CalendarCell } from "./cell";
|
import { CalendarCell } from "./cell";
|
||||||
import { DateInput } from "./date-input";
|
import { DateInput } from "./date-input";
|
||||||
|
|
||||||
const ChevronLeft: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faChevronLeft} className={className} />;
|
|
||||||
const ChevronRight: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faChevronRight} className={className} />;
|
|
||||||
|
|
||||||
export const RangeCalendarContextProvider = ({ children }: PropsWithChildren) => {
|
export const RangeCalendarContextProvider = ({ children }: PropsWithChildren) => {
|
||||||
const [value, onChange] = useState<{ start: DateValue; end: DateValue } | null>(null);
|
const [value, onChange] = useState<{ start: DateValue; end: DateValue } | null>(null);
|
||||||
const [focusedValue, onFocusChange] = useState<DateValue | undefined>();
|
const [focusedValue, onFocusChange] = useState<DateValue | undefined>();
|
||||||
|
|||||||
73
src/components/application/date-picker/time-picker.tsx
Normal file
73
src/components/application/date-picker/time-picker.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { Select } from "@/components/base/select/select";
|
||||||
|
|
||||||
|
// 30-minute increments from 05:00 to 23:00 → 37 slots.
|
||||||
|
// Covers every realistic clinic opening / closing time.
|
||||||
|
// Values are 24-hour HH:MM strings — the same format stored on the
|
||||||
|
// Clinic + DoctorVisitSlot entities in the platform. Labels are
|
||||||
|
// 12-hour format with AM/PM for readability.
|
||||||
|
const TIME_SLOTS = Array.from({ length: 37 }, (_, i) => {
|
||||||
|
const totalMinutes = 5 * 60 + i * 30;
|
||||||
|
const hour = Math.floor(totalMinutes / 60);
|
||||||
|
const minute = totalMinutes % 60;
|
||||||
|
const h12 = hour % 12 || 12;
|
||||||
|
const period = hour >= 12 ? "PM" : "AM";
|
||||||
|
const id = `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`;
|
||||||
|
const label = `${h12}:${String(minute).padStart(2, "0")} ${period}`;
|
||||||
|
return { id, label };
|
||||||
|
});
|
||||||
|
|
||||||
|
type TimePickerProps = {
|
||||||
|
/** Field label rendered above the select. */
|
||||||
|
label?: string;
|
||||||
|
/** Current value in 24-hour HH:MM format, or null when unset. */
|
||||||
|
value: string | null;
|
||||||
|
/** Called with the new HH:MM string when the user picks a slot. */
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
isRequired?: boolean;
|
||||||
|
isDisabled?: boolean;
|
||||||
|
placeholder?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// A minimal time-of-day picker built on top of the existing base
|
||||||
|
// Select component. Intentionally dropdown-based rather than the
|
||||||
|
// full DateTimePicker popover pattern from the reference demo —
|
||||||
|
// the clinic + doctor flows only need time, not date, and a
|
||||||
|
// dropdown is faster to use when the agent already knows the time.
|
||||||
|
//
|
||||||
|
// Use this for: clinic.opensAt / closesAt, doctorVisitSlot.startTime /
|
||||||
|
// endTime. For time-AND-date (appointment scheduling), stick with the
|
||||||
|
// existing DatePicker in the same directory.
|
||||||
|
export const TimePicker = ({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
isRequired,
|
||||||
|
isDisabled,
|
||||||
|
placeholder = "Select time",
|
||||||
|
}: TimePickerProps) => (
|
||||||
|
<Select
|
||||||
|
label={label}
|
||||||
|
placeholder={placeholder}
|
||||||
|
items={TIME_SLOTS}
|
||||||
|
selectedKey={value}
|
||||||
|
onSelectionChange={(key) => {
|
||||||
|
if (key !== null) onChange(String(key));
|
||||||
|
}}
|
||||||
|
isRequired={isRequired}
|
||||||
|
isDisabled={isDisabled}
|
||||||
|
>
|
||||||
|
{(slot) => <Select.Item id={slot.id} label={slot.label} />}
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Format a 24-hour HH:MM string as a 12-hour display label (e.g.
|
||||||
|
// "09:30" → "9:30 AM"). Useful on list/detail pages that render
|
||||||
|
// stored clinic hours without re-mounting the picker.
|
||||||
|
export const formatTimeLabel = (hhmm: string | null | undefined): string => {
|
||||||
|
if (!hhmm) return "—";
|
||||||
|
const [h, m] = hhmm.split(":").map(Number);
|
||||||
|
if (Number.isNaN(h) || Number.isNaN(m)) return hhmm;
|
||||||
|
const h12 = h % 12 || 12;
|
||||||
|
const period = h >= 12 ? "PM" : "AM";
|
||||||
|
return `${h12}:${String(m).padStart(2, "0")} ${period}`;
|
||||||
|
};
|
||||||
108
src/components/application/day-selector/day-selector.tsx
Normal file
108
src/components/application/day-selector/day-selector.tsx
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
|
// Keys match the Clinic entity's openMonday..openSunday fields
|
||||||
|
// directly — no translation layer needed when reading/writing the
|
||||||
|
// form value into GraphQL mutations.
|
||||||
|
export type DayKey =
|
||||||
|
| "monday"
|
||||||
|
| "tuesday"
|
||||||
|
| "wednesday"
|
||||||
|
| "thursday"
|
||||||
|
| "friday"
|
||||||
|
| "saturday"
|
||||||
|
| "sunday";
|
||||||
|
|
||||||
|
export type DaySelection = Record<DayKey, boolean>;
|
||||||
|
|
||||||
|
const DAYS: { key: DayKey; label: string }[] = [
|
||||||
|
{ key: "monday", label: "Mon" },
|
||||||
|
{ key: "tuesday", label: "Tue" },
|
||||||
|
{ key: "wednesday", label: "Wed" },
|
||||||
|
{ key: "thursday", label: "Thu" },
|
||||||
|
{ key: "friday", label: "Fri" },
|
||||||
|
{ key: "saturday", label: "Sat" },
|
||||||
|
{ key: "sunday", label: "Sun" },
|
||||||
|
];
|
||||||
|
|
||||||
|
type DaySelectorProps = {
|
||||||
|
/** Selected-state for each weekday. */
|
||||||
|
value: DaySelection;
|
||||||
|
/** Fires with the full updated selection whenever a pill is tapped. */
|
||||||
|
onChange: (value: DaySelection) => void;
|
||||||
|
/** Optional heading above the pills. */
|
||||||
|
label?: string;
|
||||||
|
/** Optional helper text below the pills. */
|
||||||
|
hint?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Seven tappable Mon–Sun pills. Used on the Clinic form to pick which
|
||||||
|
// days the clinic is open, since the Clinic entity has seven separate
|
||||||
|
// BOOLEAN fields (openMonday..openSunday) — SDK has no MULTI_SELECT.
|
||||||
|
// Also reusable anywhere else we need a weekly-recurrence picker
|
||||||
|
// (future: follow-up schedules, on-call rotations).
|
||||||
|
export const DaySelector = ({ value, onChange, label, hint }: DaySelectorProps) => (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{label && (
|
||||||
|
<span className="text-sm font-medium text-secondary">{label}</span>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{DAYS.map(({ key, label: dayLabel }) => {
|
||||||
|
const isSelected = !!value[key];
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange({ ...value, [key]: !isSelected })}
|
||||||
|
className={cx(
|
||||||
|
"flex h-10 min-w-12 items-center justify-center rounded-full border px-4 text-sm font-semibold transition duration-100 ease-linear",
|
||||||
|
isSelected
|
||||||
|
? "border-brand bg-brand-solid text-white hover:bg-brand-solid_hover"
|
||||||
|
: "border-secondary bg-primary text-secondary hover:border-primary hover:bg-secondary_hover",
|
||||||
|
)}
|
||||||
|
aria-pressed={isSelected}
|
||||||
|
>
|
||||||
|
{dayLabel}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{hint && <span className="text-xs text-tertiary">{hint}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Helper factories — use these instead of spelling out the empty
|
||||||
|
// object literal everywhere.
|
||||||
|
export const emptyDaySelection = (): DaySelection => ({
|
||||||
|
monday: false,
|
||||||
|
tuesday: false,
|
||||||
|
wednesday: false,
|
||||||
|
thursday: false,
|
||||||
|
friday: false,
|
||||||
|
saturday: false,
|
||||||
|
sunday: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// The default new-clinic state: Mon–Sat open, Sun closed. Matches the
|
||||||
|
// typical Indian outpatient hospital schedule.
|
||||||
|
export const defaultDaySelection = (): DaySelection => ({
|
||||||
|
monday: true,
|
||||||
|
tuesday: true,
|
||||||
|
wednesday: true,
|
||||||
|
thursday: true,
|
||||||
|
friday: true,
|
||||||
|
saturday: true,
|
||||||
|
sunday: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Format a DaySelection as a compact human-readable string for list
|
||||||
|
// pages (e.g. "Mon–Fri", "Mon–Sat", "Mon Wed Fri"). Collapses
|
||||||
|
// consecutive selected days into ranges.
|
||||||
|
export const formatDaySelection = (sel: DaySelection): string => {
|
||||||
|
const openKeys = DAYS.filter((d) => sel[d.key]).map((d) => d.label);
|
||||||
|
if (openKeys.length === 0) return "Closed";
|
||||||
|
if (openKeys.length === 7) return "Every day";
|
||||||
|
// Monday-Friday, Monday-Saturday shorthand
|
||||||
|
if (openKeys.length === 5 && openKeys.join(",") === "Mon,Tue,Wed,Thu,Fri") return "Mon–Fri";
|
||||||
|
if (openKeys.length === 6 && openKeys.join(",") === "Mon,Tue,Wed,Thu,Fri,Sat") return "Mon–Sat";
|
||||||
|
return openKeys.join(" ");
|
||||||
|
};
|
||||||
@@ -1,13 +1,15 @@
|
|||||||
import { Children, type ComponentProps, type ComponentPropsWithRef, type FC, createContext, isValidElement, useContext } from "react";
|
import type { ComponentProps, ComponentPropsWithRef, FC } from "react";
|
||||||
import { faMagnifyingGlass } from "@fortawesome/pro-duotone-svg-icons";
|
import { Children, createContext, isValidElement, useContext } from "react";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|
||||||
import { FileIcon } from "@untitledui/file-icons";
|
import { FileIcon } from "@untitledui/file-icons";
|
||||||
import { FeaturedIcon as FeaturedIconbase } from "@/components/foundations/featured-icon/featured-icon";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { BackgroundPattern, type BackgroundPatternProps } from "@/components/shared-assets/background-patterns";
|
import { faMagnifyingGlass } from "@fortawesome/pro-duotone-svg-icons";
|
||||||
import { Illustration as Illustrations } from "@/components/shared-assets/illustrations";
|
|
||||||
import { cx } from "@/utils/cx";
|
|
||||||
|
|
||||||
const SearchLg: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />;
|
const SearchLg: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />;
|
||||||
|
import { FeaturedIcon as FeaturedIconbase } from "@/components/foundations/featured-icon/featured-icon";
|
||||||
|
import type { BackgroundPatternProps } from "@/components/shared-assets/background-patterns";
|
||||||
|
import { BackgroundPattern } from "@/components/shared-assets/background-patterns";
|
||||||
|
import { Illustration as Illustrations } from "@/components/shared-assets/illustrations";
|
||||||
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
interface RootContextProps {
|
interface RootContextProps {
|
||||||
size?: "sm" | "md" | "lg";
|
size?: "sm" | "md" | "lg";
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import { type ComponentProps, type ComponentPropsWithRef, type FC, useId, useRef, useState } from "react";
|
import type { ComponentProps, ComponentPropsWithRef, FC } from "react";
|
||||||
import { faCircleCheck, faCircleXmark, faCloudArrowUp, faTrash } from "@fortawesome/pro-duotone-svg-icons";
|
import { useId, useRef, useState } from "react";
|
||||||
|
import type { FileIcon } from "@untitledui/file-icons";
|
||||||
|
import { FileIcon as FileTypeIcon } from "@untitledui/file-icons";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { type FileIcon, FileIcon as FileTypeIcon } from "@untitledui/file-icons";
|
import { faCircleCheck, faTrash, faCloudArrowUp, faCircleXmark } from "@fortawesome/pro-duotone-svg-icons";
|
||||||
|
|
||||||
|
const Trash01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faTrash} className={className} />;
|
||||||
import { AnimatePresence, motion } from "motion/react";
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
import { Button } from "@/components/base/buttons/button";
|
import { Button } from "@/components/base/buttons/button";
|
||||||
import { ButtonUtility } from "@/components/base/buttons/button-utility";
|
import { ButtonUtility } from "@/components/base/buttons/button-utility";
|
||||||
@@ -9,14 +13,11 @@ import { ProgressBar } from "@/components/base/progress-indicators/progress-indi
|
|||||||
import { FeaturedIcon } from "@/components/foundations/featured-icon/featured-icon";
|
import { FeaturedIcon } from "@/components/foundations/featured-icon/featured-icon";
|
||||||
import { cx } from "@/utils/cx";
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
const Trash01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faTrash} className={className} />;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns a human-readable file size.
|
* Returns a human-readable file size.
|
||||||
* @param bytes - The size of the file in bytes.
|
* @param bytes - The size of the file in bytes.
|
||||||
* @returns A string representing the file size in a human-readable format.
|
* @returns A string representing the file size in a human-readable format.
|
||||||
*/
|
*/
|
||||||
// eslint-disable-next-line react-refresh/only-export-components
|
|
||||||
export const getReadableFileSize = (bytes: number) => {
|
export const getReadableFileSize = (bytes: number) => {
|
||||||
if (bytes === 0) return "0 KB";
|
if (bytes === 0) return "0 KB";
|
||||||
|
|
||||||
@@ -387,7 +388,6 @@ const FileUploadList = (props: ComponentPropsWithRef<"ul">) => (
|
|||||||
</ul>
|
</ul>
|
||||||
);
|
);
|
||||||
|
|
||||||
// eslint-disable-next-line react-refresh/only-export-components
|
|
||||||
export const FileUpload = {
|
export const FileUpload = {
|
||||||
Root: FileUploadRoot,
|
Root: FileUploadRoot,
|
||||||
List: FileUploadList,
|
List: FileUploadList,
|
||||||
|
|||||||
@@ -1,11 +1,5 @@
|
|||||||
import {
|
import type { DialogProps as AriaDialogProps, ModalOverlayProps as AriaModalOverlayProps } from "react-aria-components";
|
||||||
Dialog as AriaDialog,
|
import { Dialog as AriaDialog, DialogTrigger as AriaDialogTrigger, Modal as AriaModal, ModalOverlay as AriaModalOverlay } from "react-aria-components";
|
||||||
type DialogProps as AriaDialogProps,
|
|
||||||
DialogTrigger as AriaDialogTrigger,
|
|
||||||
Modal as AriaModal,
|
|
||||||
ModalOverlay as AriaModalOverlay,
|
|
||||||
type ModalOverlayProps as AriaModalOverlayProps,
|
|
||||||
} from "react-aria-components";
|
|
||||||
import { cx } from "@/utils/cx";
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
export const DialogTrigger = AriaDialogTrigger;
|
export const DialogTrigger = AriaDialogTrigger;
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { faCircleCheck, faCircleExclamation, faCircleInfo } from "@fortawesome/pro-duotone-svg-icons";
|
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { faCircleExclamation, faCircleCheck, faCircleInfo } from "@fortawesome/pro-duotone-svg-icons";
|
||||||
|
|
||||||
|
const AlertCircle: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faCircleExclamation} className={className} />;
|
||||||
|
const CheckCircle: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faCircleCheck} className={className} />;
|
||||||
|
const InfoCircle: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faCircleInfo} className={className} />;
|
||||||
import { Avatar } from "@/components/base/avatar/avatar";
|
import { Avatar } from "@/components/base/avatar/avatar";
|
||||||
import { Button } from "@/components/base/buttons/button";
|
import { Button } from "@/components/base/buttons/button";
|
||||||
import { CloseButton } from "@/components/base/buttons/close-button";
|
import { CloseButton } from "@/components/base/buttons/close-button";
|
||||||
@@ -8,10 +12,6 @@ import { ProgressBar } from "@/components/base/progress-indicators/progress-indi
|
|||||||
import { FeaturedIcon } from "@/components/foundations/featured-icon/featured-icon";
|
import { FeaturedIcon } from "@/components/foundations/featured-icon/featured-icon";
|
||||||
import { cx } from "@/utils/cx";
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
const AlertCircle: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faCircleExclamation} className={className} />;
|
|
||||||
const CheckCircle: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faCircleCheck} className={className} />;
|
|
||||||
const InfoCircle: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faCircleInfo} className={className} />;
|
|
||||||
|
|
||||||
const iconMap = {
|
const iconMap = {
|
||||||
default: InfoCircle,
|
default: InfoCircle,
|
||||||
brand: InfoCircle,
|
brand: InfoCircle,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Toaster as SonnerToaster, type ToasterProps, useSonner } from "sonner";
|
import type { ToasterProps } from "sonner";
|
||||||
|
import { Toaster as SonnerToaster, useSonner } from "sonner";
|
||||||
import { cx } from "@/utils/cx";
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
export const DEFAULT_TOAST_POSITION = "bottom-right";
|
export const DEFAULT_TOAST_POSITION = "bottom-right";
|
||||||
|
|||||||
@@ -1,15 +1,5 @@
|
|||||||
import React, {
|
import type { CSSProperties, FC, HTMLAttributes, ReactNode } from "react";
|
||||||
type CSSProperties,
|
import React, { cloneElement, createContext, isValidElement, useCallback, useContext, useEffect, useState } from "react";
|
||||||
type FC,
|
|
||||||
type HTMLAttributes,
|
|
||||||
type ReactNode,
|
|
||||||
cloneElement,
|
|
||||||
createContext,
|
|
||||||
isValidElement,
|
|
||||||
useCallback,
|
|
||||||
useContext,
|
|
||||||
useMemo,
|
|
||||||
} from "react";
|
|
||||||
|
|
||||||
type PaginationPage = {
|
type PaginationPage = {
|
||||||
/** The type of the pagination item. */
|
/** The type of the pagination item. */
|
||||||
@@ -56,8 +46,9 @@ export interface PaginationRootProps {
|
|||||||
onPageChange?: (page: number) => void;
|
onPageChange?: (page: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line react-refresh/only-export-components
|
|
||||||
const PaginationRoot = ({ total, siblingCount = 1, page, onPageChange, children, style, className }: PaginationRootProps) => {
|
const PaginationRoot = ({ total, siblingCount = 1, page, onPageChange, children, style, className }: PaginationRootProps) => {
|
||||||
|
const [pages, setPages] = useState<PaginationItemType[]>([]);
|
||||||
|
|
||||||
const createPaginationItems = useCallback((): PaginationItemType[] => {
|
const createPaginationItems = useCallback((): PaginationItemType[] => {
|
||||||
const items: PaginationItemType[] = [];
|
const items: PaginationItemType[] = [];
|
||||||
// Calculate the maximum number of pagination elements (pages, potential ellipsis, first and last) to show
|
// Calculate the maximum number of pagination elements (pages, potential ellipsis, first and last) to show
|
||||||
@@ -159,7 +150,10 @@ const PaginationRoot = ({ total, siblingCount = 1, page, onPageChange, children,
|
|||||||
return items;
|
return items;
|
||||||
}, [total, siblingCount, page]);
|
}, [total, siblingCount, page]);
|
||||||
|
|
||||||
const pages = useMemo(() => createPaginationItems(), [createPaginationItems]);
|
useEffect(() => {
|
||||||
|
const paginationItems = createPaginationItems();
|
||||||
|
setPages(paginationItems);
|
||||||
|
}, [createPaginationItems]);
|
||||||
|
|
||||||
const onPageChangeHandler = (newPage: number) => {
|
const onPageChangeHandler = (newPage: number) => {
|
||||||
onPageChange?.(newPage);
|
onPageChange?.(newPage);
|
||||||
@@ -213,7 +207,6 @@ interface TriggerProps {
|
|||||||
ariaLabel?: string;
|
ariaLabel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line react-refresh/only-export-components
|
|
||||||
const Trigger: FC<TriggerProps> = ({ children, style, className, asChild = false, direction, ariaLabel }) => {
|
const Trigger: FC<TriggerProps> = ({ children, style, className, asChild = false, direction, ariaLabel }) => {
|
||||||
const context = useContext(PaginationContext);
|
const context = useContext(PaginationContext);
|
||||||
if (!context) {
|
if (!context) {
|
||||||
@@ -259,10 +252,8 @@ const Trigger: FC<TriggerProps> = ({ children, style, className, asChild = false
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// eslint-disable-next-line react-refresh/only-export-components
|
|
||||||
const PaginationPrevTrigger: FC<Omit<TriggerProps, "direction">> = (props) => <Trigger {...props} direction="prev" />;
|
const PaginationPrevTrigger: FC<Omit<TriggerProps, "direction">> = (props) => <Trigger {...props} direction="prev" />;
|
||||||
|
|
||||||
// eslint-disable-next-line react-refresh/only-export-components
|
|
||||||
const PaginationNextTrigger: FC<Omit<TriggerProps, "direction">> = (props) => <Trigger {...props} direction="next" />;
|
const PaginationNextTrigger: FC<Omit<TriggerProps, "direction">> = (props) => <Trigger {...props} direction="next" />;
|
||||||
|
|
||||||
interface PaginationItemRenderProps {
|
interface PaginationItemRenderProps {
|
||||||
@@ -290,7 +281,6 @@ export interface PaginationItemProps {
|
|||||||
asChild?: boolean;
|
asChild?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line react-refresh/only-export-components
|
|
||||||
const PaginationItem = ({ value, isCurrent, children, style, className, ariaLabel, asChild = false }: PaginationItemProps) => {
|
const PaginationItem = ({ value, isCurrent, children, style, className, ariaLabel, asChild = false }: PaginationItemProps) => {
|
||||||
const context = useContext(PaginationContext);
|
const context = useContext(PaginationContext);
|
||||||
if (!context) {
|
if (!context) {
|
||||||
@@ -353,7 +343,6 @@ interface PaginationEllipsisProps {
|
|||||||
className?: string | (() => string);
|
className?: string | (() => string);
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line react-refresh/only-export-components
|
|
||||||
const PaginationEllipsis: FC<PaginationEllipsisProps> = ({ children, style, className }) => {
|
const PaginationEllipsis: FC<PaginationEllipsisProps> = ({ children, style, className }) => {
|
||||||
const computedClassName = typeof className === "function" ? className() : className;
|
const computedClassName = typeof className === "function" ? className() : className;
|
||||||
|
|
||||||
@@ -368,7 +357,6 @@ interface PaginationContextComponentProps {
|
|||||||
children: (pagination: PaginationContextType) => ReactNode;
|
children: (pagination: PaginationContextType) => ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line react-refresh/only-export-components
|
|
||||||
const PaginationContextComponent: FC<PaginationContextComponentProps> = ({ children }) => {
|
const PaginationContextComponent: FC<PaginationContextComponentProps> = ({ children }) => {
|
||||||
const context = useContext(PaginationContext);
|
const context = useContext(PaginationContext);
|
||||||
if (!context) {
|
if (!context) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { cx } from "@/utils/cx";
|
import { cx } from "@/utils/cx";
|
||||||
import { Pagination, type PaginationRootProps } from "./pagination-base";
|
import type { PaginationRootProps } from "./pagination-base";
|
||||||
|
import { Pagination } from "./pagination-base";
|
||||||
|
|
||||||
interface PaginationDotProps extends Omit<PaginationRootProps, "children"> {
|
interface PaginationDotProps extends Omit<PaginationRootProps, "children"> {
|
||||||
/** The size of the pagination dot. */
|
/** The size of the pagination dot. */
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { cx } from "@/utils/cx";
|
import { cx } from "@/utils/cx";
|
||||||
import { Pagination, type PaginationRootProps } from "./pagination-base";
|
import type { PaginationRootProps } from "./pagination-base";
|
||||||
|
import { Pagination } from "./pagination-base";
|
||||||
|
|
||||||
interface PaginationLineProps extends Omit<PaginationRootProps, "children"> {
|
interface PaginationLineProps extends Omit<PaginationRootProps, "children"> {
|
||||||
/** The size of the pagination line. */
|
/** The size of the pagination line. */
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
import { type ComponentPropsWithRef, type ReactNode, type RefAttributes } from "react";
|
import { type ComponentPropsWithRef, type ReactNode, type RefAttributes } from "react";
|
||||||
import {
|
import type {
|
||||||
Dialog as AriaDialog,
|
DialogProps as AriaDialogProps,
|
||||||
type DialogProps as AriaDialogProps,
|
ModalOverlayProps as AriaModalOverlayProps,
|
||||||
DialogTrigger as AriaDialogTrigger,
|
ModalRenderProps as AriaModalRenderProps,
|
||||||
Modal as AriaModal,
|
|
||||||
ModalOverlay as AriaModalOverlay,
|
|
||||||
type ModalOverlayProps as AriaModalOverlayProps,
|
|
||||||
type ModalRenderProps as AriaModalRenderProps,
|
|
||||||
} from "react-aria-components";
|
} from "react-aria-components";
|
||||||
|
import { Dialog as AriaDialog, DialogTrigger as AriaDialogTrigger, Modal as AriaModal, ModalOverlay as AriaModalOverlay } from "react-aria-components";
|
||||||
import { CloseButton } from "@/components/base/buttons/close-button";
|
import { CloseButton } from "@/components/base/buttons/close-button";
|
||||||
import { cx } from "@/utils/cx";
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,7 @@
|
|||||||
import { type ComponentPropsWithRef, Fragment, type ReactNode, createContext, useContext } from "react";
|
import type { ComponentPropsWithRef, ReactNode } from "react";
|
||||||
import {
|
import { Fragment, createContext, useContext } from "react";
|
||||||
Tab as AriaTab,
|
import type { TabListProps as AriaTabListProps, TabProps as AriaTabProps, TabRenderProps as AriaTabRenderProps } from "react-aria-components";
|
||||||
TabList as AriaTabList,
|
import { Tab as AriaTab, TabList as AriaTabList, TabPanel as AriaTabPanel, Tabs as AriaTabs, TabsContext, useSlottedContext } from "react-aria-components";
|
||||||
type TabListProps as AriaTabListProps,
|
|
||||||
TabPanel as AriaTabPanel,
|
|
||||||
type TabProps as AriaTabProps,
|
|
||||||
type TabRenderProps as AriaTabRenderProps,
|
|
||||||
Tabs as AriaTabs,
|
|
||||||
TabsContext,
|
|
||||||
useSlottedContext,
|
|
||||||
} from "react-aria-components";
|
|
||||||
import type { BadgeColors } from "@/components/base/badges/badge-types";
|
import type { BadgeColors } from "@/components/base/badges/badge-types";
|
||||||
import { Badge } from "@/components/base/badges/badges";
|
import { Badge } from "@/components/base/badges/badges";
|
||||||
import { cx } from "@/utils/cx";
|
import { cx } from "@/utils/cx";
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { faUser } from "@fortawesome/pro-duotone-svg-icons";
|
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { faUser } from "@fortawesome/pro-duotone-svg-icons";
|
||||||
import { cx } from "@/utils/cx";
|
import { cx } from "@/utils/cx";
|
||||||
import { type AvatarProps } from "./avatar";
|
import { type AvatarProps } from "./avatar";
|
||||||
import { AvatarOnlineIndicator, VerifiedTick } from "./base-components";
|
import { AvatarOnlineIndicator, VerifiedTick } from "./base-components";
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { type FC, type ReactNode, useState } from "react";
|
import { type FC, type ReactNode, useState } from "react";
|
||||||
import { faUser } from "@fortawesome/pro-duotone-svg-icons";
|
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { faUser } from "@fortawesome/pro-duotone-svg-icons";
|
||||||
import { cx } from "@/utils/cx";
|
import { cx } from "@/utils/cx";
|
||||||
import { AvatarOnlineIndicator, VerifiedTick } from "./base-components";
|
import { AvatarOnlineIndicator, VerifiedTick } from "./base-components";
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { faPlus } from "@fortawesome/pro-duotone-svg-icons";
|
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { faPlus } from "@fortawesome/pro-duotone-svg-icons";
|
||||||
import type { ButtonProps as AriaButtonProps } from "react-aria-components";
|
import type { ButtonProps as AriaButtonProps } from "react-aria-components";
|
||||||
import { Tooltip as AriaTooltip, TooltipTrigger as AriaTooltipTrigger } from "@/components/base/tooltip/tooltip";
|
import { Tooltip as AriaTooltip, TooltipTrigger as AriaTooltipTrigger } from "@/components/base/tooltip/tooltip";
|
||||||
import { cx } from "@/utils/cx";
|
import { cx } from "@/utils/cx";
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable react-refresh/only-export-components */
|
|
||||||
export * from "./avatar-add-button";
|
export * from "./avatar-add-button";
|
||||||
export * from "./avatar-company-icon";
|
export * from "./avatar-company-icon";
|
||||||
export * from "./avatar-online-indicator";
|
export * from "./avatar-online-indicator";
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { type FC, type ReactNode, isValidElement } from "react";
|
import type { FC, ReactNode } from "react";
|
||||||
import { faArrowRight } from "@fortawesome/pro-duotone-svg-icons";
|
import { isValidElement } from "react";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { cx, sortCx } from "@/utils/cx";
|
import { faArrowRight } from "@fortawesome/pro-duotone-svg-icons";
|
||||||
import { isReactComponent } from "@/utils/is-react-component";
|
|
||||||
|
|
||||||
const ArrowRight: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faArrowRight} className={className} />;
|
const ArrowRight: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faArrowRight} className={className} />;
|
||||||
|
import { cx, sortCx } from "@/utils/cx";
|
||||||
|
import { isReactComponent } from "@/utils/is-react-component";
|
||||||
|
|
||||||
type Size = "md" | "lg";
|
type Size = "md" | "lg";
|
||||||
type Color = "brand" | "warning" | "error" | "gray" | "success";
|
type Color = "brand" | "warning" | "error" | "gray" | "success";
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import type { FC, MouseEventHandler, ReactNode } from "react";
|
import type { FC, MouseEventHandler, ReactNode } from "react";
|
||||||
import { faXmark } from "@fortawesome/pro-duotone-svg-icons";
|
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { Dot } from "@/components/foundations/dot-icon";
|
import { faXmark } from "@fortawesome/pro-duotone-svg-icons";
|
||||||
import { cx } from "@/utils/cx";
|
|
||||||
import { type BadgeColors, type BadgeTypeToColorMap, type BadgeTypes, type FlagTypes, type IconComponentType, type Sizes, badgeTypes } from "./badge-types";
|
|
||||||
|
|
||||||
const CloseX: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faXmark} className={className} />;
|
const CloseX: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faXmark} className={className} />;
|
||||||
|
import { Dot } from "@/components/foundations/dot-icon";
|
||||||
|
import { cx } from "@/utils/cx";
|
||||||
|
import type { BadgeColors, BadgeTypeToColorMap, BadgeTypes, FlagTypes, IconComponentType, Sizes } from "./badge-types";
|
||||||
|
import { badgeTypes } from "./badge-types";
|
||||||
|
|
||||||
// eslint-disable-next-line react-refresh/only-export-components
|
|
||||||
export const filledColors: Record<BadgeColors, { root: string; addon: string; addonButton: string }> = {
|
export const filledColors: Record<BadgeColors, { root: string; addon: string; addonButton: string }> = {
|
||||||
gray: {
|
gray: {
|
||||||
root: "bg-utility-gray-50 text-utility-gray-700 ring-utility-gray-200",
|
root: "bg-utility-gray-50 text-utility-gray-700 ring-utility-gray-200",
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
import { cx, sortCx } from "@/utils/cx";
|
import { cx, sortCx } from "@/utils/cx";
|
||||||
import { isReactComponent } from "@/utils/is-react-component";
|
import { isReactComponent } from "@/utils/is-react-component";
|
||||||
|
|
||||||
// eslint-disable-next-line react-refresh/only-export-components
|
|
||||||
export const styles = sortCx({
|
export const styles = sortCx({
|
||||||
common: {
|
common: {
|
||||||
root: [
|
root: [
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { type AnchorHTMLAttributes, type ButtonHTMLAttributes, type DetailedHTMLProps, type FC, type ReactNode, isValidElement } from "react";
|
import type { AnchorHTMLAttributes, ButtonHTMLAttributes, DetailedHTMLProps, FC, ReactNode } from "react";
|
||||||
|
import { isValidElement } from "react";
|
||||||
import type { Placement } from "react-aria";
|
import type { Placement } from "react-aria";
|
||||||
import { Button as AriaButton, type ButtonProps as AriaButtonProps, Link as AriaLink, type LinkProps as AriaLinkProps } from "react-aria-components";
|
import type { ButtonProps as AriaButtonProps, LinkProps as AriaLinkProps } from "react-aria-components";
|
||||||
|
import { Button as AriaButton, Link as AriaLink } from "react-aria-components";
|
||||||
import { Tooltip } from "@/components/base/tooltip/tooltip";
|
import { Tooltip } from "@/components/base/tooltip/tooltip";
|
||||||
import { cx } from "@/utils/cx";
|
import { cx } from "@/utils/cx";
|
||||||
import { isReactComponent } from "@/utils/is-react-component";
|
import { isReactComponent } from "@/utils/is-react-component";
|
||||||
|
|
||||||
// eslint-disable-next-line react-refresh/only-export-components
|
|
||||||
export const styles = {
|
export const styles = {
|
||||||
secondary:
|
secondary:
|
||||||
"bg-primary text-fg-quaternary shadow-xs-skeumorphic ring-1 ring-primary ring-inset hover:bg-primary_hover hover:text-fg-quaternary_hover disabled:shadow-xs disabled:ring-disabled_subtle",
|
"bg-primary text-fg-quaternary shadow-xs-skeumorphic ring-1 ring-primary ring-inset hover:bg-primary_hover hover:text-fg-quaternary_hover disabled:shadow-xs disabled:ring-disabled_subtle",
|
||||||
@@ -62,20 +63,27 @@ export const ButtonUtility = ({
|
|||||||
const href = "href" in otherProps ? otherProps.href : undefined;
|
const href = "href" in otherProps ? otherProps.href : undefined;
|
||||||
const Component = href ? AriaLink : AriaButton;
|
const Component = href ? AriaLink : AriaButton;
|
||||||
|
|
||||||
const props = href
|
let props = {};
|
||||||
? {
|
|
||||||
...otherProps,
|
if (href) {
|
||||||
href: isDisabled ? undefined : href,
|
props = {
|
||||||
// Since anchor elements do not support the `disabled` attribute and state,
|
...otherProps,
|
||||||
// we need to specify `data-rac` and `data-disabled` in order to be able
|
|
||||||
// to use the `disabled:` selector in classes.
|
href: isDisabled ? undefined : href,
|
||||||
...(isDisabled ? { "data-rac": true, "data-disabled": true } : {}),
|
|
||||||
}
|
// Since anchor elements do not support the `disabled` attribute and state,
|
||||||
: {
|
// we need to specify `data-rac` and `data-disabled` in order to be able
|
||||||
...otherProps,
|
// to use the `disabled:` selector in classes.
|
||||||
type: otherProps.type || "button",
|
...(isDisabled ? { "data-rac": true, "data-disabled": true } : {}),
|
||||||
isDisabled,
|
};
|
||||||
};
|
} else {
|
||||||
|
props = {
|
||||||
|
...otherProps,
|
||||||
|
|
||||||
|
type: otherProps.type || "button",
|
||||||
|
isDisabled,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
<Component
|
<Component
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import React, { type AnchorHTMLAttributes, type ButtonHTMLAttributes, type DetailedHTMLProps, type FC, type ReactNode, isValidElement } from "react";
|
import type { AnchorHTMLAttributes, ButtonHTMLAttributes, DetailedHTMLProps, FC, ReactNode } from "react";
|
||||||
import { Button as AriaButton, type ButtonProps as AriaButtonProps, Link as AriaLink, type LinkProps as AriaLinkProps } from "react-aria-components";
|
import React, { isValidElement } from "react";
|
||||||
|
import type { ButtonProps as AriaButtonProps, LinkProps as AriaLinkProps } from "react-aria-components";
|
||||||
|
import { Button as AriaButton, Link as AriaLink } from "react-aria-components";
|
||||||
import { cx, sortCx } from "@/utils/cx";
|
import { cx, sortCx } from "@/utils/cx";
|
||||||
import { isReactComponent } from "@/utils/is-react-component";
|
import { isReactComponent } from "@/utils/is-react-component";
|
||||||
|
|
||||||
// eslint-disable-next-line react-refresh/only-export-components
|
|
||||||
export const styles = sortCx({
|
export const styles = sortCx({
|
||||||
common: {
|
common: {
|
||||||
root: [
|
root: [
|
||||||
@@ -191,16 +192,22 @@ export const Button = ({
|
|||||||
|
|
||||||
noTextPadding = isLinkType || noTextPadding;
|
noTextPadding = isLinkType || noTextPadding;
|
||||||
|
|
||||||
const props = href
|
let props = {};
|
||||||
? {
|
|
||||||
...otherProps,
|
if (href) {
|
||||||
href: disabled ? undefined : href,
|
props = {
|
||||||
}
|
...otherProps,
|
||||||
: {
|
|
||||||
...otherProps,
|
href: disabled ? undefined : href,
|
||||||
type: otherProps.type || "button",
|
};
|
||||||
isPending: loading,
|
} else {
|
||||||
};
|
props = {
|
||||||
|
...otherProps,
|
||||||
|
|
||||||
|
type: otherProps.type || "button",
|
||||||
|
isPending: loading,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Component
|
<Component
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { faXmark } from "@fortawesome/pro-duotone-svg-icons";
|
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { faXmark } from "@fortawesome/pro-duotone-svg-icons";
|
||||||
import { Button as AriaButton, type ButtonProps as AriaButtonProps } from "react-aria-components";
|
import { Button as AriaButton, type ButtonProps as AriaButtonProps } from "react-aria-components";
|
||||||
import { cx } from "@/utils/cx";
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import type { AnchorHTMLAttributes, ButtonHTMLAttributes, DetailedHTMLProps } from "react";
|
import type { AnchorHTMLAttributes, ButtonHTMLAttributes, DetailedHTMLProps } from "react";
|
||||||
import { Button as AriaButton, type ButtonProps as AriaButtonProps, Link as AriaLink, type LinkProps as AriaLinkProps } from "react-aria-components";
|
import type { ButtonProps as AriaButtonProps, LinkProps as AriaLinkProps } from "react-aria-components";
|
||||||
|
import { Button as AriaButton, Link as AriaLink } from "react-aria-components";
|
||||||
import { cx, sortCx } from "@/utils/cx";
|
import { cx, sortCx } from "@/utils/cx";
|
||||||
import { AppleLogo, DribbleLogo, FacebookLogo, FigmaLogo, FigmaLogoOutlined, GoogleLogo, TwitterLogo } from "./social-logos";
|
import { AppleLogo, DribbleLogo, FacebookLogo, FigmaLogo, FigmaLogoOutlined, GoogleLogo, TwitterLogo } from "./social-logos";
|
||||||
|
|
||||||
// eslint-disable-next-line react-refresh/only-export-components
|
|
||||||
export const styles = sortCx({
|
export const styles = sortCx({
|
||||||
common: {
|
common: {
|
||||||
root: "group relative inline-flex h-max cursor-pointer items-center justify-center font-semibold whitespace-nowrap outline-focus-ring transition duration-100 ease-linear before:absolute focus-visible:outline-2 focus-visible:outline-offset-2 disabled:cursor-not-allowed disabled:stroke-fg-disabled disabled:text-fg-disabled disabled:*:text-fg-disabled",
|
root: "group relative inline-flex h-max cursor-pointer items-center justify-center font-semibold whitespace-nowrap outline-focus-ring transition duration-100 ease-linear before:absolute focus-visible:outline-2 focus-visible:outline-offset-2 disabled:cursor-not-allowed disabled:stroke-fg-disabled disabled:text-fg-disabled disabled:*:text-fg-disabled",
|
||||||
@@ -96,20 +96,27 @@ export const SocialButton = ({ size = "lg", theme = "brand", social, className,
|
|||||||
|
|
||||||
const Logo = logos[social];
|
const Logo = logos[social];
|
||||||
|
|
||||||
const props = href
|
let props = {};
|
||||||
? {
|
|
||||||
...otherProps,
|
if (href) {
|
||||||
href: disabled ? undefined : href,
|
props = {
|
||||||
// Since anchor elements do not support the `disabled` attribute and state,
|
...otherProps,
|
||||||
// we need to specify `data-rac` and `data-disabled` in order to be able
|
|
||||||
// to use the `disabled:` selector in classes.
|
href: disabled ? undefined : href,
|
||||||
...(disabled ? { "data-rac": true, "data-disabled": true } : {}),
|
|
||||||
}
|
// Since anchor elements do not support the `disabled` attribute and state,
|
||||||
: {
|
// we need to specify `data-rac` and `data-disabled` in order to be able
|
||||||
...otherProps,
|
// to use the `disabled:` selector in classes.
|
||||||
type: otherProps.type || "button",
|
...(disabled ? { "data-rac": true, "data-disabled": true } : {}),
|
||||||
isDisabled: disabled,
|
};
|
||||||
};
|
} else {
|
||||||
|
props = {
|
||||||
|
...otherProps,
|
||||||
|
|
||||||
|
type: otherProps.type || "button",
|
||||||
|
isDisabled: disabled,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Component
|
<Component
|
||||||
|
|||||||
@@ -1,20 +1,22 @@
|
|||||||
import type { FC, RefAttributes } from "react";
|
import type { FC, RefAttributes } from "react";
|
||||||
import { faEllipsisVertical } from "@fortawesome/pro-duotone-svg-icons";
|
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { faEllipsisVertical } from "@fortawesome/pro-duotone-svg-icons";
|
||||||
|
import type {
|
||||||
|
ButtonProps as AriaButtonProps,
|
||||||
|
MenuItemProps as AriaMenuItemProps,
|
||||||
|
MenuProps as AriaMenuProps,
|
||||||
|
PopoverProps as AriaPopoverProps,
|
||||||
|
SeparatorProps as AriaSeparatorProps,
|
||||||
|
} from "react-aria-components";
|
||||||
import {
|
import {
|
||||||
Button as AriaButton,
|
Button as AriaButton,
|
||||||
type ButtonProps as AriaButtonProps,
|
|
||||||
Header as AriaHeader,
|
Header as AriaHeader,
|
||||||
Menu as AriaMenu,
|
Menu as AriaMenu,
|
||||||
MenuItem as AriaMenuItem,
|
MenuItem as AriaMenuItem,
|
||||||
type MenuItemProps as AriaMenuItemProps,
|
|
||||||
type MenuProps as AriaMenuProps,
|
|
||||||
MenuSection as AriaMenuSection,
|
MenuSection as AriaMenuSection,
|
||||||
MenuTrigger as AriaMenuTrigger,
|
MenuTrigger as AriaMenuTrigger,
|
||||||
Popover as AriaPopover,
|
Popover as AriaPopover,
|
||||||
type PopoverProps as AriaPopoverProps,
|
|
||||||
Separator as AriaSeparator,
|
Separator as AriaSeparator,
|
||||||
type SeparatorProps as AriaSeparatorProps,
|
|
||||||
} from "react-aria-components";
|
} from "react-aria-components";
|
||||||
import { cx } from "@/utils/cx";
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
@@ -29,7 +31,6 @@ interface DropdownItemProps extends AriaMenuItemProps {
|
|||||||
icon?: FC<{ className?: string }>;
|
icon?: FC<{ className?: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line react-refresh/only-export-components
|
|
||||||
const DropdownItem = ({ label, children, addon, icon: Icon, unstyled, ...props }: DropdownItemProps) => {
|
const DropdownItem = ({ label, children, addon, icon: Icon, unstyled, ...props }: DropdownItemProps) => {
|
||||||
if (unstyled) {
|
if (unstyled) {
|
||||||
return <AriaMenuItem id={label} textValue={label} {...props} />;
|
return <AriaMenuItem id={label} textValue={label} {...props} />;
|
||||||
@@ -88,9 +89,8 @@ const DropdownItem = ({ label, children, addon, icon: Icon, unstyled, ...props }
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
type DropdownMenuProps<T extends object> = AriaMenuProps<T>;
|
interface DropdownMenuProps<T extends object> extends AriaMenuProps<T> {}
|
||||||
|
|
||||||
// eslint-disable-next-line react-refresh/only-export-components
|
|
||||||
const DropdownMenu = <T extends object>(props: DropdownMenuProps<T>) => {
|
const DropdownMenu = <T extends object>(props: DropdownMenuProps<T>) => {
|
||||||
return (
|
return (
|
||||||
<AriaMenu
|
<AriaMenu
|
||||||
@@ -104,9 +104,8 @@ const DropdownMenu = <T extends object>(props: DropdownMenuProps<T>) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
type DropdownPopoverProps = AriaPopoverProps;
|
interface DropdownPopoverProps extends AriaPopoverProps {}
|
||||||
|
|
||||||
// eslint-disable-next-line react-refresh/only-export-components
|
|
||||||
const DropdownPopover = (props: DropdownPopoverProps) => {
|
const DropdownPopover = (props: DropdownPopoverProps) => {
|
||||||
return (
|
return (
|
||||||
<AriaPopover
|
<AriaPopover
|
||||||
@@ -128,12 +127,10 @@ const DropdownPopover = (props: DropdownPopoverProps) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// eslint-disable-next-line react-refresh/only-export-components
|
|
||||||
const DropdownSeparator = (props: AriaSeparatorProps) => {
|
const DropdownSeparator = (props: AriaSeparatorProps) => {
|
||||||
return <AriaSeparator {...props} className={cx("my-1 h-px w-full bg-border-secondary", props.className)} />;
|
return <AriaSeparator {...props} className={cx("my-1 h-px w-full bg-border-secondary", props.className)} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
// eslint-disable-next-line react-refresh/only-export-components
|
|
||||||
const DropdownDotsButton = (props: AriaButtonProps & RefAttributes<HTMLButtonElement>) => {
|
const DropdownDotsButton = (props: AriaButtonProps & RefAttributes<HTMLButtonElement>) => {
|
||||||
return (
|
return (
|
||||||
<AriaButton
|
<AriaButton
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { type DetailedReactHTMLElement, type HTMLAttributes, type ReactNode, cloneElement, useRef } from "react";
|
import type { DetailedReactHTMLElement, HTMLAttributes, ReactNode } from "react";
|
||||||
|
import React, { cloneElement, useRef } from "react";
|
||||||
import { filterDOMProps } from "@react-aria/utils";
|
import { filterDOMProps } from "@react-aria/utils";
|
||||||
|
|
||||||
interface FileTriggerProps {
|
interface FileTriggerProps {
|
||||||
@@ -41,7 +42,6 @@ export const FileTrigger = (props: FileTriggerProps) => {
|
|||||||
const clonableElement = React.Children.only(children);
|
const clonableElement = React.Children.only(children);
|
||||||
|
|
||||||
// Clone the child element and add an `onClick` handler to open the file dialog.
|
// Clone the child element and add an `onClick` handler to open the file dialog.
|
||||||
// eslint-disable-next-line react-hooks/refs
|
|
||||||
const mainElement = cloneElement(clonableElement as DetailedReactHTMLElement<HTMLAttributes<HTMLElement>, HTMLElement>, {
|
const mainElement = cloneElement(clonableElement as DetailedReactHTMLElement<HTMLAttributes<HTMLElement>, HTMLElement>, {
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
if (inputRef.current?.value) {
|
if (inputRef.current?.value) {
|
||||||
@@ -63,7 +63,7 @@ export const FileTrigger = (props: FileTriggerProps) => {
|
|||||||
onChange={(e) => onSelect?.(e.target.files)}
|
onChange={(e) => onSelect?.(e.target.files)}
|
||||||
capture={defaultCamera}
|
capture={defaultCamera}
|
||||||
multiple={allowsMultiple}
|
multiple={allowsMultiple}
|
||||||
// @ts-expect-error -- webkitdirectory is not in React's HTML types but is valid in modern browsers
|
// @ts-expect-error
|
||||||
webkitdirectory={acceptDirectory ? "" : undefined}
|
webkitdirectory={acceptDirectory ? "" : undefined}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { ReactNode, Ref } from "react";
|
import type { ReactNode, Ref } from "react";
|
||||||
import { Text as AriaText, type TextProps as AriaTextProps } from "react-aria-components";
|
import type { TextProps as AriaTextProps } from "react-aria-components";
|
||||||
|
import { Text as AriaText } from "react-aria-components";
|
||||||
import { cx } from "@/utils/cx";
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
interface HintTextProps extends AriaTextProps {
|
interface HintTextProps extends AriaTextProps {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { type HTMLAttributes, type ReactNode } from "react";
|
import { type HTMLAttributes, type ReactNode } from "react";
|
||||||
import { HintText } from "@/components/base/input/hint-text";
|
import { HintText } from "@/components/base/input/hint-text";
|
||||||
import { type InputBaseProps, TextField } from "@/components/base/input/input";
|
import type { InputBaseProps } from "@/components/base/input/input";
|
||||||
|
import { TextField } from "@/components/base/input/input";
|
||||||
import { Label } from "@/components/base/input/label";
|
import { Label } from "@/components/base/input/label";
|
||||||
import { cx, sortCx } from "@/utils/cx";
|
import { cx, sortCx } from "@/utils/cx";
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useControlledState } from "@react-stately/utils";
|
import { useControlledState } from "@react-stately/utils";
|
||||||
import { HintText } from "@/components/base/input/hint-text";
|
import { HintText } from "@/components/base/input/hint-text";
|
||||||
import { InputBase, type InputBaseProps, TextField } from "@/components/base/input/input";
|
import type { InputBaseProps } from "@/components/base/input/input";
|
||||||
|
import { InputBase, TextField } from "@/components/base/input/input";
|
||||||
import { Label } from "@/components/base/input/label";
|
import { Label } from "@/components/base/input/label";
|
||||||
import { AmexIcon, DiscoverIcon, MastercardIcon, UnionPayIcon, VisaIcon } from "@/components/foundations/payment-icons";
|
import { AmexIcon, DiscoverIcon, MastercardIcon, UnionPayIcon, VisaIcon } from "@/components/foundations/payment-icons";
|
||||||
|
|
||||||
@@ -61,7 +62,6 @@ const detectCardType = (number: string) => {
|
|||||||
/**
|
/**
|
||||||
* Format the card number in groups of 4 digits (i.e. 1234 5678 9012 3456).
|
* Format the card number in groups of 4 digits (i.e. 1234 5678 9012 3456).
|
||||||
*/
|
*/
|
||||||
// eslint-disable-next-line react-refresh/only-export-components
|
|
||||||
export const formatCardNumber = (number: string) => {
|
export const formatCardNumber = (number: string) => {
|
||||||
// Remove non-numeric characters
|
// Remove non-numeric characters
|
||||||
const cleaned = number.replace(/\D/g, "");
|
const cleaned = number.replace(/\D/g, "");
|
||||||
@@ -76,7 +76,7 @@ export const formatCardNumber = (number: string) => {
|
|||||||
return cleaned;
|
return cleaned;
|
||||||
};
|
};
|
||||||
|
|
||||||
type PaymentInputProps = Omit<InputBaseProps, "icon">;
|
interface PaymentInputProps extends Omit<InputBaseProps, "icon"> {}
|
||||||
|
|
||||||
export const PaymentInput = ({ onChange, value, defaultValue, className, maxLength = 19, label, hint, ...props }: PaymentInputProps) => {
|
export const PaymentInput = ({ onChange, value, defaultValue, className, maxLength = 19, label, hint, ...props }: PaymentInputProps) => {
|
||||||
const [cardNumber, setCardNumber] = useControlledState(value, defaultValue || "", (value) => {
|
const [cardNumber, setCardNumber] = useControlledState(value, defaultValue || "", (value) => {
|
||||||
|
|||||||
@@ -1,13 +1,8 @@
|
|||||||
import { type ComponentType, type HTMLAttributes, type ReactNode, type Ref, createContext, useContext } from "react";
|
import { type ComponentType, type HTMLAttributes, type ReactNode, type Ref, createContext, useContext } from "react";
|
||||||
import { faCircleExclamation, faCircleQuestion } from "@fortawesome/pro-duotone-svg-icons";
|
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import {
|
import { faCircleQuestion, faCircleExclamation } from "@fortawesome/pro-duotone-svg-icons";
|
||||||
Group as AriaGroup,
|
import type { InputProps as AriaInputProps, TextFieldProps as AriaTextFieldProps } from "react-aria-components";
|
||||||
Input as AriaInput,
|
import { Group as AriaGroup, Input as AriaInput, TextField as AriaTextField } from "react-aria-components";
|
||||||
type InputProps as AriaInputProps,
|
|
||||||
TextField as AriaTextField,
|
|
||||||
type TextFieldProps as AriaTextFieldProps,
|
|
||||||
} from "react-aria-components";
|
|
||||||
import { HintText } from "@/components/base/input/hint-text";
|
import { HintText } from "@/components/base/input/hint-text";
|
||||||
import { Label } from "@/components/base/input/label";
|
import { Label } from "@/components/base/input/label";
|
||||||
import { Tooltip, TooltipTrigger } from "@/components/base/tooltip/tooltip";
|
import { Tooltip, TooltipTrigger } from "@/components/base/tooltip/tooltip";
|
||||||
@@ -197,7 +192,9 @@ interface BaseProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface TextFieldProps
|
interface TextFieldProps
|
||||||
extends BaseProps, AriaTextFieldProps, Pick<InputBaseProps, "size" | "wrapperClassName" | "inputClassName" | "iconClassName" | "tooltipClassName"> {
|
extends BaseProps,
|
||||||
|
AriaTextFieldProps,
|
||||||
|
Pick<InputBaseProps, "size" | "wrapperClassName" | "inputClassName" | "iconClassName" | "tooltipClassName"> {
|
||||||
ref?: Ref<HTMLDivElement>;
|
ref?: Ref<HTMLDivElement>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import type { ReactNode, Ref } from "react";
|
import type { ReactNode, Ref } from "react";
|
||||||
import { faCircleQuestion } from "@fortawesome/pro-duotone-svg-icons";
|
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { Label as AriaLabel, type LabelProps as AriaLabelProps } from "react-aria-components";
|
import { faCircleQuestion } from "@fortawesome/pro-duotone-svg-icons";
|
||||||
|
import type { LabelProps as AriaLabelProps } from "react-aria-components";
|
||||||
|
import { Label as AriaLabel } from "react-aria-components";
|
||||||
import { Tooltip, TooltipTrigger } from "@/components/base/tooltip/tooltip";
|
import { Tooltip, TooltipTrigger } from "@/components/base/tooltip/tooltip";
|
||||||
import { cx } from "@/utils/cx";
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { type ComponentPropsWithRef, createContext, useContext, useId } from "react";
|
import type { ComponentPropsWithRef } from "react";
|
||||||
|
import { createContext, useContext, useId } from "react";
|
||||||
import { OTPInput, OTPInputContext } from "input-otp";
|
import { OTPInput, OTPInputContext } from "input-otp";
|
||||||
import { cx } from "@/utils/cx";
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
@@ -14,7 +15,6 @@ const PinInputContext = createContext<PinInputContextType>({
|
|||||||
disabled: false,
|
disabled: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
// eslint-disable-next-line react-refresh/only-export-components
|
|
||||||
export const usePinInputContext = () => {
|
export const usePinInputContext = () => {
|
||||||
const context = useContext(PinInputContext);
|
const context = useContext(PinInputContext);
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,9 @@
|
|||||||
import { type FocusEventHandler, type PointerEventHandler, type RefAttributes, type RefObject, useCallback, useContext, useRef, useState } from "react";
|
import type { FocusEventHandler, PointerEventHandler, RefAttributes, RefObject } from "react";
|
||||||
import { faMagnifyingGlass } from "@fortawesome/pro-duotone-svg-icons";
|
import { useCallback, useContext, useRef, useState } from "react";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import {
|
import { faMagnifyingGlass } from "@fortawesome/pro-duotone-svg-icons";
|
||||||
ComboBox as AriaComboBox,
|
import type { ComboBoxProps as AriaComboBoxProps, GroupProps as AriaGroupProps, ListBoxProps as AriaListBoxProps } from "react-aria-components";
|
||||||
type ComboBoxProps as AriaComboBoxProps,
|
import { ComboBox as AriaComboBox, Group as AriaGroup, Input as AriaInput, ListBox as AriaListBox, ComboBoxStateContext } from "react-aria-components";
|
||||||
Group as AriaGroup,
|
|
||||||
type GroupProps as AriaGroupProps,
|
|
||||||
Input as AriaInput,
|
|
||||||
ListBox as AriaListBox,
|
|
||||||
type ListBoxProps as AriaListBoxProps,
|
|
||||||
ComboBoxStateContext,
|
|
||||||
} from "react-aria-components";
|
|
||||||
import { HintText } from "@/components/base/input/hint-text";
|
import { HintText } from "@/components/base/input/hint-text";
|
||||||
import { Label } from "@/components/base/input/label";
|
import { Label } from "@/components/base/input/label";
|
||||||
import { Popover } from "@/components/base/select/popover";
|
import { Popover } from "@/components/base/select/popover";
|
||||||
|
|||||||
@@ -1,31 +1,14 @@
|
|||||||
import {
|
import type { FC, FocusEventHandler, KeyboardEvent, PointerEventHandler, RefAttributes, RefObject } from "react";
|
||||||
type FC,
|
import { createContext, useCallback, useContext, useRef, useState } from "react";
|
||||||
type FocusEventHandler,
|
|
||||||
type KeyboardEvent,
|
|
||||||
type PointerEventHandler,
|
|
||||||
type RefAttributes,
|
|
||||||
type RefObject,
|
|
||||||
createContext,
|
|
||||||
useCallback,
|
|
||||||
useContext,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { faMagnifyingGlass } from "@fortawesome/pro-duotone-svg-icons";
|
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { faMagnifyingGlass } from "@fortawesome/pro-duotone-svg-icons";
|
||||||
|
|
||||||
|
const SearchIcon: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />;
|
||||||
import { FocusScope, useFilter, useFocusManager } from "react-aria";
|
import { FocusScope, useFilter, useFocusManager } from "react-aria";
|
||||||
import {
|
import type { ComboBoxProps as AriaComboBoxProps, GroupProps as AriaGroupProps, ListBoxProps as AriaListBoxProps, Key } from "react-aria-components";
|
||||||
ComboBox as AriaComboBox,
|
import { ComboBox as AriaComboBox, Group as AriaGroup, Input as AriaInput, ListBox as AriaListBox, ComboBoxStateContext } from "react-aria-components";
|
||||||
type ComboBoxProps as AriaComboBoxProps,
|
import type { ListData } from "react-stately";
|
||||||
Group as AriaGroup,
|
import { useListData } from "react-stately";
|
||||||
type GroupProps as AriaGroupProps,
|
|
||||||
Input as AriaInput,
|
|
||||||
ListBox as AriaListBox,
|
|
||||||
type ListBoxProps as AriaListBoxProps,
|
|
||||||
ComboBoxStateContext,
|
|
||||||
type Key,
|
|
||||||
} from "react-aria-components";
|
|
||||||
import { type ListData, useListData } from "react-stately";
|
|
||||||
import { Avatar } from "@/components/base/avatar/avatar";
|
import { Avatar } from "@/components/base/avatar/avatar";
|
||||||
import type { IconComponentType } from "@/components/base/badges/badge-types";
|
import type { IconComponentType } from "@/components/base/badges/badge-types";
|
||||||
import { HintText } from "@/components/base/input/hint-text";
|
import { HintText } from "@/components/base/input/hint-text";
|
||||||
@@ -37,8 +20,6 @@ import { useResizeObserver } from "@/hooks/use-resize-observer";
|
|||||||
import { cx } from "@/utils/cx";
|
import { cx } from "@/utils/cx";
|
||||||
import { SelectItem } from "./select-item";
|
import { SelectItem } from "./select-item";
|
||||||
|
|
||||||
const SearchIcon: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />;
|
|
||||||
|
|
||||||
interface ComboBoxValueProps extends AriaGroupProps {
|
interface ComboBoxValueProps extends AriaGroupProps {
|
||||||
size: "sm" | "md";
|
size: "sm" | "md";
|
||||||
shortcut?: boolean;
|
shortcut?: boolean;
|
||||||
@@ -152,7 +133,7 @@ export const MultiSelectBase = ({
|
|||||||
// Resize observer for popover width
|
// Resize observer for popover width
|
||||||
const onResize = useCallback(() => {
|
const onResize = useCallback(() => {
|
||||||
if (!placeholderRef.current) return;
|
if (!placeholderRef.current) return;
|
||||||
const divRect = placeholderRef.current?.getBoundingClientRect();
|
let divRect = placeholderRef.current?.getBoundingClientRect();
|
||||||
setPopoverWidth(divRect.width + "px");
|
setPopoverWidth(divRect.width + "px");
|
||||||
}, [placeholderRef, setPopoverWidth]);
|
}, [placeholderRef, setPopoverWidth]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { RefAttributes } from "react";
|
import type { RefAttributes } from "react";
|
||||||
import { Popover as AriaPopover, type PopoverProps as AriaPopoverProps } from "react-aria-components";
|
import type { PopoverProps as AriaPopoverProps } from "react-aria-components";
|
||||||
|
import { Popover as AriaPopover } from "react-aria-components";
|
||||||
import { cx } from "@/utils/cx";
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
interface PopoverProps extends AriaPopoverProps, RefAttributes<HTMLElement> {
|
interface PopoverProps extends AriaPopoverProps, RefAttributes<HTMLElement> {
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { isValidElement, useContext } from "react";
|
import { isValidElement, useContext } from "react";
|
||||||
import { faCheck } from "@fortawesome/pro-duotone-svg-icons";
|
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { ListBoxItem as AriaListBoxItem, type ListBoxItemProps as AriaListBoxItemProps, Text as AriaText } from "react-aria-components";
|
import { faCheck } from "@fortawesome/pro-duotone-svg-icons";
|
||||||
|
import type { ListBoxItemProps as AriaListBoxItemProps } from "react-aria-components";
|
||||||
|
import { ListBoxItem as AriaListBoxItem, Text as AriaText } from "react-aria-components";
|
||||||
import { Avatar } from "@/components/base/avatar/avatar";
|
import { Avatar } from "@/components/base/avatar/avatar";
|
||||||
import { cx } from "@/utils/cx";
|
import { cx } from "@/utils/cx";
|
||||||
import { isReactComponent } from "@/utils/is-react-component";
|
import { isReactComponent } from "@/utils/is-react-component";
|
||||||
import { SelectContext, type SelectItemType } from "./select";
|
import type { SelectItemType } from "./select";
|
||||||
|
import { SelectContext } from "./select";
|
||||||
|
|
||||||
const sizes = {
|
const sizes = {
|
||||||
sm: "p-2 pr-2.5",
|
sm: "p-2 pr-2.5",
|
||||||
@@ -81,7 +83,11 @@ export const SelectItem = ({ label, id, value, avatarUrl, supportingText, isDisa
|
|||||||
<FontAwesomeIcon
|
<FontAwesomeIcon
|
||||||
icon={faCheck}
|
icon={faCheck}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className={cx("ml-auto text-fg-brand-primary", size === "sm" ? "size-4" : "size-5", state.isDisabled && "text-fg-disabled")}
|
className={cx(
|
||||||
|
"ml-auto text-fg-brand-primary",
|
||||||
|
size === "sm" ? "size-4" : "size-5",
|
||||||
|
state.isDisabled && "text-fg-disabled",
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { type SelectHTMLAttributes, useId } from "react";
|
import { type SelectHTMLAttributes, useId } from "react";
|
||||||
import { faChevronDown } from "@fortawesome/pro-duotone-svg-icons";
|
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { faChevronDown } from "@fortawesome/pro-duotone-svg-icons";
|
||||||
import { HintText } from "@/components/base/input/hint-text";
|
import { HintText } from "@/components/base/input/hint-text";
|
||||||
import { Label } from "@/components/base/input/label";
|
import { Label } from "@/components/base/input/label";
|
||||||
import { cx } from "@/utils/cx";
|
import { cx } from "@/utils/cx";
|
||||||
|
|||||||
@@ -1,13 +1,9 @@
|
|||||||
import { type FC, type ReactNode, type Ref, type RefAttributes, createContext, isValidElement } from "react";
|
import type { FC, ReactNode, Ref, RefAttributes } from "react";
|
||||||
import { faChevronDown } from "@fortawesome/pro-duotone-svg-icons";
|
import { createContext, isValidElement } from "react";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import {
|
import { faChevronDown } from "@fortawesome/pro-duotone-svg-icons";
|
||||||
Button as AriaButton,
|
import type { SelectProps as AriaSelectProps } from "react-aria-components";
|
||||||
ListBox as AriaListBox,
|
import { Button as AriaButton, ListBox as AriaListBox, Select as AriaSelect, SelectValue as AriaSelectValue } from "react-aria-components";
|
||||||
Select as AriaSelect,
|
|
||||||
type SelectProps as AriaSelectProps,
|
|
||||||
SelectValue as AriaSelectValue,
|
|
||||||
} from "react-aria-components";
|
|
||||||
import { Avatar } from "@/components/base/avatar/avatar";
|
import { Avatar } from "@/components/base/avatar/avatar";
|
||||||
import { HintText } from "@/components/base/input/hint-text";
|
import { HintText } from "@/components/base/input/hint-text";
|
||||||
import { Label } from "@/components/base/input/label";
|
import { Label } from "@/components/base/input/label";
|
||||||
@@ -51,7 +47,6 @@ interface SelectValueProps {
|
|||||||
placeholderIcon?: FC | ReactNode;
|
placeholderIcon?: FC | ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line react-refresh/only-export-components
|
|
||||||
export const sizes = {
|
export const sizes = {
|
||||||
sm: { root: "py-2 px-3", shortcut: "pr-2.5" },
|
sm: { root: "py-2 px-3", shortcut: "pr-2.5" },
|
||||||
md: { root: "py-2.5 px-3.5", shortcut: "pr-3" },
|
md: { root: "py-2.5 px-3.5", shortcut: "pr-3" },
|
||||||
@@ -111,7 +106,6 @@ const SelectValue = ({ isOpen, isFocused, isDisabled, size, placeholder, placeho
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// eslint-disable-next-line react-refresh/only-export-components
|
|
||||||
export const SelectContext = createContext<{ size: "sm" | "md" }>({ size: "sm" });
|
export const SelectContext = createContext<{ size: "sm" | "md" }>({ size: "sm" });
|
||||||
|
|
||||||
const Select = ({ placeholder = "Select", placeholderIcon, size = "sm", children, items, label, hint, tooltip, className, ...rest }: SelectProps) => {
|
const Select = ({ placeholder = "Select", placeholderIcon, size = "sm", children, items, label, hint, tooltip, className, ...rest }: SelectProps) => {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
|
import type { SliderProps as AriaSliderProps } from "react-aria-components";
|
||||||
import {
|
import {
|
||||||
Label as AriaLabel,
|
Label as AriaLabel,
|
||||||
Slider as AriaSlider,
|
Slider as AriaSlider,
|
||||||
SliderOutput as AriaSliderOutput,
|
SliderOutput as AriaSliderOutput,
|
||||||
type SliderProps as AriaSliderProps,
|
|
||||||
SliderThumb as AriaSliderThumb,
|
SliderThumb as AriaSliderThumb,
|
||||||
SliderTrack as AriaSliderTrack,
|
SliderTrack as AriaSliderTrack,
|
||||||
} from "react-aria-components";
|
} from "react-aria-components";
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { RefAttributes } from "react";
|
import type { RefAttributes } from "react";
|
||||||
import { faXmark } from "@fortawesome/pro-duotone-svg-icons";
|
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { faXmark } from "@fortawesome/pro-duotone-svg-icons";
|
||||||
import { Button as AriaButton, type ButtonProps as AriaButtonProps } from "react-aria-components";
|
import { Button as AriaButton, type ButtonProps as AriaButtonProps } from "react-aria-components";
|
||||||
import { cx } from "@/utils/cx";
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import React, { type ReactNode, type Ref } from "react";
|
import type { ReactNode, Ref } from "react";
|
||||||
import {
|
import React from "react";
|
||||||
TextArea as AriaTextArea,
|
import type { TextAreaProps as AriaTextAreaProps, TextFieldProps as AriaTextFieldProps } from "react-aria-components";
|
||||||
type TextAreaProps as AriaTextAreaProps,
|
import { TextArea as AriaTextArea, TextField as AriaTextField } from "react-aria-components";
|
||||||
TextField as AriaTextField,
|
|
||||||
type TextFieldProps as AriaTextFieldProps,
|
|
||||||
} from "react-aria-components";
|
|
||||||
import { HintText } from "@/components/base/input/hint-text";
|
import { HintText } from "@/components/base/input/hint-text";
|
||||||
import { Label } from "@/components/base/input/label";
|
import { Label } from "@/components/base/input/label";
|
||||||
import { cx } from "@/utils/cx";
|
import { cx } from "@/utils/cx";
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { Switch as AriaSwitch, type SwitchProps as AriaSwitchProps } from "react-aria-components";
|
import type { SwitchProps as AriaSwitchProps } from "react-aria-components";
|
||||||
|
import { Switch as AriaSwitch } from "react-aria-components";
|
||||||
import { cx } from "@/utils/cx";
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
interface ToggleBaseProps {
|
interface ToggleBaseProps {
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import {
|
import type {
|
||||||
Button as AriaButton,
|
ButtonProps as AriaButtonProps,
|
||||||
type ButtonProps as AriaButtonProps,
|
TooltipProps as AriaTooltipProps,
|
||||||
OverlayArrow as AriaOverlayArrow,
|
TooltipTriggerComponentProps as AriaTooltipTriggerComponentProps,
|
||||||
Tooltip as AriaTooltip,
|
|
||||||
type TooltipProps as AriaTooltipProps,
|
|
||||||
TooltipTrigger as AriaTooltipTrigger,
|
|
||||||
type TooltipTriggerComponentProps as AriaTooltipTriggerComponentProps,
|
|
||||||
} from "react-aria-components";
|
} from "react-aria-components";
|
||||||
|
import { Button as AriaButton, OverlayArrow as AriaOverlayArrow, Tooltip as AriaTooltip, TooltipTrigger as AriaTooltipTrigger } from "react-aria-components";
|
||||||
import { cx } from "@/utils/cx";
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
interface TooltipProps extends AriaTooltipTriggerComponentProps, Omit<AriaTooltipProps, "children"> {
|
interface TooltipProps extends AriaTooltipTriggerComponentProps, Omit<AriaTooltipProps, "children"> {
|
||||||
@@ -99,7 +96,7 @@ export const Tooltip = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
type TooltipTriggerProps = AriaButtonProps;
|
interface TooltipTriggerProps extends AriaButtonProps {}
|
||||||
|
|
||||||
export const TooltipTrigger = ({ children, className, ...buttonProps }: TooltipTriggerProps) => {
|
export const TooltipTrigger = ({ children, className, ...buttonProps }: TooltipTriggerProps) => {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -56,6 +56,18 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
|||||||
console.log(`[ACTIVE-CALL-CARD] Mounted: state=${callState} direction=${callDirectionRef.current} ucid=${callUcid ?? 'none'} lead=${lead?.id ?? 'none'} phone=${callerPhone}`);
|
console.log(`[ACTIVE-CALL-CARD] Mounted: state=${callState} direction=${callDirectionRef.current} ucid=${callUcid ?? 'none'} lead=${lead?.id ?? 'none'} phone=${callerPhone}`);
|
||||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
// Sync UCID to localStorage so sendBeacon can fire auto-dispose on page refresh.
|
||||||
|
// Cleared on disposition submit (handleDisposition below) or when call resets to idle.
|
||||||
|
useEffect(() => {
|
||||||
|
if (callUcid) {
|
||||||
|
localStorage.setItem('helix_active_ucid', callUcid);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
// Don't clear on unmount if disposition hasn't fired — the
|
||||||
|
// beforeunload handler in SipProvider needs it
|
||||||
|
};
|
||||||
|
}, [callUcid]);
|
||||||
|
|
||||||
// Detect caller disconnect: call was active and ended without agent pressing End
|
// Detect caller disconnect: call was active and ended without agent pressing End
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (wasAnsweredRef.current && !dispositionOpen && (callState === 'ended' || callState === 'failed')) {
|
if (wasAnsweredRef.current && !dispositionOpen && (callState === 'ended' || callState === 'failed')) {
|
||||||
@@ -78,9 +90,11 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
|||||||
|
|
||||||
// Submit disposition to sidecar
|
// Submit disposition to sidecar
|
||||||
if (callUcid) {
|
if (callUcid) {
|
||||||
|
const agentCfg = JSON.parse(localStorage.getItem('helix_agent_config') ?? '{}');
|
||||||
const disposePayload = {
|
const disposePayload = {
|
||||||
ucid: callUcid,
|
ucid: callUcid,
|
||||||
disposition,
|
disposition,
|
||||||
|
agentId: agentCfg.ozonetelAgentId,
|
||||||
callerPhone,
|
callerPhone,
|
||||||
direction: callDirectionRef.current,
|
direction: callDirectionRef.current,
|
||||||
durationSec: callDuration,
|
durationSec: callDuration,
|
||||||
@@ -115,6 +129,9 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear persisted UCID — disposition was submitted, no need for sendBeacon fallback
|
||||||
|
localStorage.removeItem('helix_active_ucid');
|
||||||
|
|
||||||
notify.success('Call Logged', `Disposition: ${disposition.replace(/_/g, ' ').toLowerCase()}`);
|
notify.success('Call Logged', `Disposition: ${disposition.replace(/_/g, ' ').toLowerCase()}`);
|
||||||
handleReset();
|
handleReset();
|
||||||
};
|
};
|
||||||
@@ -306,6 +323,7 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
|||||||
isOpen={enquiryOpen}
|
isOpen={enquiryOpen}
|
||||||
onOpenChange={setEnquiryOpen}
|
onOpenChange={setEnquiryOpen}
|
||||||
callerPhone={callerPhone}
|
callerPhone={callerPhone}
|
||||||
|
leadName={fullName || null}
|
||||||
leadId={lead?.id ?? null}
|
leadId={lead?.id ?? null}
|
||||||
patientId={(lead as any)?.patientId ?? null}
|
patientId={(lead as any)?.patientId ?? null}
|
||||||
agentName={user.name}
|
agentName={user.name}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faUserPen } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
import { Input } from '@/components/base/input/input';
|
import { Input } from '@/components/base/input/input';
|
||||||
import { Select } from '@/components/base/select/select';
|
import { Select } from '@/components/base/select/select';
|
||||||
import { TextArea } from '@/components/base/textarea/textarea';
|
import { TextArea } from '@/components/base/textarea/textarea';
|
||||||
@@ -8,6 +10,7 @@ import { parseDate } from '@internationalized/date';
|
|||||||
import { apiClient } from '@/lib/api-client';
|
import { apiClient } from '@/lib/api-client';
|
||||||
import { cx } from '@/utils/cx';
|
import { cx } from '@/utils/cx';
|
||||||
import { notify } from '@/lib/toast';
|
import { notify } from '@/lib/toast';
|
||||||
|
import { EditPatientConfirmModal } from '@/components/modals/edit-patient-confirm-modal';
|
||||||
|
|
||||||
type ExistingAppointment = {
|
type ExistingAppointment = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -32,11 +35,8 @@ type AppointmentFormProps = {
|
|||||||
|
|
||||||
type DoctorRecord = { id: string; name: string; department: string; clinic: string };
|
type DoctorRecord = { id: string; name: string; department: string; clinic: string };
|
||||||
|
|
||||||
const clinicItems = [
|
// Clinics are fetched dynamically from the platform — no hardcoded list.
|
||||||
{ id: 'koramangala', label: 'Global Hospital - Koramangala' },
|
// If the workspace has no clinics configured, the dropdown shows empty.
|
||||||
{ id: 'whitefield', label: 'Global Hospital - Whitefield' },
|
|
||||||
{ id: 'indiranagar', label: 'Global Hospital - Indiranagar' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const genderItems = [
|
const genderItems = [
|
||||||
{ id: 'male', label: 'Male' },
|
{ id: 'male', label: 'Male' },
|
||||||
@@ -44,22 +44,8 @@ const genderItems = [
|
|||||||
{ id: 'other', label: 'Other' },
|
{ id: 'other', label: 'Other' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const timeSlotItems = [
|
// Time slots are fetched from /api/masterdata/slots based on
|
||||||
{ id: '09:00', label: '9:00 AM' },
|
// doctor + date. No hardcoded times.
|
||||||
{ id: '09:30', label: '9:30 AM' },
|
|
||||||
{ id: '10:00', label: '10:00 AM' },
|
|
||||||
{ id: '10:30', label: '10:30 AM' },
|
|
||||||
{ id: '11:00', label: '11:00 AM' },
|
|
||||||
{ id: '11:30', label: '11:30 AM' },
|
|
||||||
{ id: '14:00', label: '2:00 PM' },
|
|
||||||
{ id: '14:30', label: '2:30 PM' },
|
|
||||||
{ id: '15:00', label: '3:00 PM' },
|
|
||||||
{ id: '15:30', label: '3:30 PM' },
|
|
||||||
{ id: '16:00', label: '4:00 PM' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const formatDeptLabel = (dept: string) =>
|
|
||||||
dept.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
|
||||||
|
|
||||||
export const AppointmentForm = ({
|
export const AppointmentForm = ({
|
||||||
isOpen,
|
isOpen,
|
||||||
@@ -76,12 +62,25 @@ export const AppointmentForm = ({
|
|||||||
// Doctor data from platform
|
// Doctor data from platform
|
||||||
const [doctors, setDoctors] = useState<DoctorRecord[]>([]);
|
const [doctors, setDoctors] = useState<DoctorRecord[]>([]);
|
||||||
|
|
||||||
|
// Initial name captured at form open — used to detect whether the
|
||||||
|
// agent actually changed the name before we commit any destructive
|
||||||
|
// updatePatient / updateLead.contactName mutations.
|
||||||
|
const initialLeadName = (leadName ?? '').trim();
|
||||||
|
|
||||||
// Form state — initialized from existing appointment in edit mode
|
// Form state — initialized from existing appointment in edit mode
|
||||||
const [patientName, setPatientName] = useState(leadName ?? '');
|
const [patientName, setPatientName] = useState(leadName ?? '');
|
||||||
|
// The patient-name input is locked by default when there's an
|
||||||
|
// existing caller name (to prevent accidental rename-on-save), and
|
||||||
|
// unlocked only after the agent clicks the Edit button and confirms
|
||||||
|
// in the warning modal. First-time callers with no existing name
|
||||||
|
// start unlocked because there's nothing to protect.
|
||||||
|
const [isNameEditable, setIsNameEditable] = useState(initialLeadName.length === 0);
|
||||||
|
const [editConfirmOpen, setEditConfirmOpen] = useState(false);
|
||||||
const [patientPhone, setPatientPhone] = useState(callerNumber ?? '');
|
const [patientPhone, setPatientPhone] = useState(callerNumber ?? '');
|
||||||
const [age, setAge] = useState('');
|
const [age, setAge] = useState('');
|
||||||
const [gender, setGender] = useState<string | null>(null);
|
const [gender, setGender] = useState<string | null>(null);
|
||||||
const [clinic, setClinic] = useState<string | null>(null);
|
const [clinic, setClinic] = useState<string | null>(null);
|
||||||
|
const [clinicItems, setClinicItems] = useState<Array<{ id: string; label: string }>>([]);
|
||||||
const [department, setDepartment] = useState<string | null>(existingAppointment?.department ?? null);
|
const [department, setDepartment] = useState<string | null>(existingAppointment?.department ?? null);
|
||||||
const [doctor, setDoctor] = useState<string | null>(existingAppointment?.doctorId ?? null);
|
const [doctor, setDoctor] = useState<string | null>(existingAppointment?.doctorId ?? null);
|
||||||
const [date, setDate] = useState(() => {
|
const [date, setDate] = useState(() => {
|
||||||
@@ -98,6 +97,24 @@ export const AppointmentForm = ({
|
|||||||
const [chiefComplaint, setChiefComplaint] = useState(existingAppointment?.reasonForVisit ?? '');
|
const [chiefComplaint, setChiefComplaint] = useState(existingAppointment?.reasonForVisit ?? '');
|
||||||
const [source, setSource] = useState('Inbound Call');
|
const [source, setSource] = useState('Inbound Call');
|
||||||
const [agentNotes, setAgentNotes] = useState('');
|
const [agentNotes, setAgentNotes] = useState('');
|
||||||
|
const [timeSlotItems, setTimeSlotItems] = useState<Array<{ id: string; label: string }>>([]);
|
||||||
|
|
||||||
|
// Fetch available time slots when doctor + date change
|
||||||
|
useEffect(() => {
|
||||||
|
if (!doctor || !date) {
|
||||||
|
setTimeSlotItems([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
apiClient.get<Array<{ time: string; label: string; clinicId: string; clinicName: string }>>(
|
||||||
|
`/api/masterdata/slots?doctorId=${doctor}&date=${date}`,
|
||||||
|
).then(slots => {
|
||||||
|
setTimeSlotItems(slots.map(s => ({ id: s.time, label: s.label })));
|
||||||
|
// Auto-select clinic from the slot's clinic
|
||||||
|
if (slots.length > 0 && !clinic) {
|
||||||
|
setClinic(slots[0].clinicId);
|
||||||
|
}
|
||||||
|
}).catch(() => setTimeSlotItems([]));
|
||||||
|
}, [doctor, date]);
|
||||||
|
|
||||||
// Availability state
|
// Availability state
|
||||||
const [bookedSlots, setBookedSlots] = useState<string[]>([]);
|
const [bookedSlots, setBookedSlots] = useState<string[]>([]);
|
||||||
@@ -106,24 +123,29 @@ export const AppointmentForm = ({
|
|||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Fetch doctors on mount
|
// Fetch doctors on mount. Doctors are hospital-wide — no single
|
||||||
|
// `clinic` field anymore. We pull the full visit-slot list via the
|
||||||
|
// Fetch clinics + doctors from the master data endpoint (Redis-cached).
|
||||||
|
// This is faster than direct GraphQL and returns pre-formatted data.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return;
|
if (!isOpen) return;
|
||||||
apiClient.graphql<{ doctors: { edges: Array<{ node: any }> } }>(
|
apiClient.get<Array<{ id: string; name: string; phone: string; address: string }>>('/api/masterdata/clinics')
|
||||||
`{ doctors(first: 50) { edges { node {
|
.then(clinics => {
|
||||||
id name fullName { firstName lastName } department clinic { id name clinicName }
|
setClinicItems(clinics.map(c => ({ id: c.id, label: c.name || 'Unnamed Clinic' })));
|
||||||
} } } }`,
|
}).catch(() => {});
|
||||||
).then(data => {
|
}, [isOpen]);
|
||||||
const docs = data.doctors.edges.map(e => ({
|
|
||||||
id: e.node.id,
|
useEffect(() => {
|
||||||
name: e.node.fullName
|
if (!isOpen) return;
|
||||||
? `Dr. ${e.node.fullName.firstName} ${e.node.fullName.lastName}`.trim()
|
apiClient.get<Array<{ id: string; name: string; department: string; qualifications: string }>>('/api/masterdata/doctors')
|
||||||
: e.node.name,
|
.then(docs => {
|
||||||
department: e.node.department ?? '',
|
setDoctors(docs.map(d => ({
|
||||||
clinic: e.node.clinic?.clinicName ?? e.node.clinic?.name ?? '',
|
id: d.id,
|
||||||
}));
|
name: d.name,
|
||||||
setDoctors(docs);
|
department: d.department,
|
||||||
}).catch(() => {});
|
clinic: '', // clinic assignment via visit slots, not on doctor directly
|
||||||
|
})));
|
||||||
|
}).catch(() => {});
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
// Fetch booked slots when doctor + date selected
|
// Fetch booked slots when doctor + date selected
|
||||||
@@ -172,9 +194,18 @@ export const AppointmentForm = ({
|
|||||||
setTimeSlot(null);
|
setTimeSlot(null);
|
||||||
}, [doctor, date]);
|
}, [doctor, date]);
|
||||||
|
|
||||||
// Derive department and doctor lists from fetched data
|
// Departments from master data (or fallback to deriving from doctors)
|
||||||
const departmentItems = [...new Set(doctors.map(d => d.department).filter(Boolean))]
|
const [departmentItems, setDepartmentItems] = useState<Array<{ id: string; label: string }>>([]);
|
||||||
.map(dept => ({ id: dept, label: formatDeptLabel(dept) }));
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
apiClient.get<string[]>('/api/masterdata/departments')
|
||||||
|
.then(depts => setDepartmentItems(depts.map(d => ({ id: d, label: d }))))
|
||||||
|
.catch(() => {
|
||||||
|
// Fallback: derive from doctor list
|
||||||
|
const derived = [...new Set(doctors.map(d => d.department).filter(Boolean))];
|
||||||
|
setDepartmentItems(derived.map(d => ({ id: d, label: d })));
|
||||||
|
});
|
||||||
|
}, [isOpen, doctors]);
|
||||||
|
|
||||||
const filteredDoctors = department
|
const filteredDoctors = department
|
||||||
? doctors.filter(d => d.department === department)
|
? doctors.filter(d => d.department === department)
|
||||||
@@ -245,22 +276,28 @@ export const AppointmentForm = ({
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update patient name if we have a name and a linked patient
|
// Determine whether the agent actually renamed the patient.
|
||||||
if (patientId && patientName.trim()) {
|
// Only a non-empty, changed-from-initial name counts — empty
|
||||||
await apiClient.graphql(
|
// strings or an unchanged name never trigger the rename
|
||||||
`mutation UpdatePatient($id: UUID!, $data: PatientUpdateInput!) {
|
// chain, even if the field was unlocked.
|
||||||
updatePatient(id: $id, data: $data) { id }
|
const trimmedName = patientName.trim();
|
||||||
}`,
|
const nameChanged = isNameEditable && trimmedName.length > 0 && trimmedName !== initialLeadName;
|
||||||
{
|
|
||||||
id: patientId,
|
|
||||||
data: {
|
|
||||||
fullName: { firstName: patientName.split(' ')[0], lastName: patientName.split(' ').slice(1).join(' ') || '' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
).catch((err: unknown) => console.warn('Failed to update patient name:', err));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update lead status + name if we have a matched lead
|
// DO NOT update the shared Patient entity when name changes
|
||||||
|
// during appointment creation. The Patient record is shared
|
||||||
|
// across all appointments — modifying it here would
|
||||||
|
// retroactively change the name on all past appointments.
|
||||||
|
// The patient name for THIS appointment is stored on the
|
||||||
|
// Appointment entity itself (via doctorName/department).
|
||||||
|
// Bug #527: removed updatePatient() call.
|
||||||
|
|
||||||
|
// Update lead status/lastContacted on every appointment book
|
||||||
|
// (those are genuinely about this appointment), but only
|
||||||
|
// touch lead.contactName if the agent explicitly renamed.
|
||||||
|
//
|
||||||
|
// NOTE: field name is `status`, NOT `leadStatus` — the
|
||||||
|
// staging platform schema renamed this. The old name is
|
||||||
|
// rejected by LeadUpdateInput.
|
||||||
if (leadId) {
|
if (leadId) {
|
||||||
await apiClient.graphql(
|
await apiClient.graphql(
|
||||||
`mutation UpdateLead($id: UUID!, $data: LeadUpdateInput!) {
|
`mutation UpdateLead($id: UUID!, $data: LeadUpdateInput!) {
|
||||||
@@ -269,16 +306,26 @@ export const AppointmentForm = ({
|
|||||||
{
|
{
|
||||||
id: leadId,
|
id: leadId,
|
||||||
data: {
|
data: {
|
||||||
leadStatus: 'APPOINTMENT_SET',
|
status: 'APPOINTMENT_SET',
|
||||||
lastContactedAt: new Date().toISOString(),
|
lastContacted: new Date().toISOString(),
|
||||||
...(patientName.trim() ? { contactName: { firstName: patientName.split(' ')[0], lastName: patientName.split(' ').slice(1).join(' ') || '' } } : {}),
|
...(nameChanged ? { contactName: { firstName: trimmedName.split(' ')[0], lastName: trimmedName.split(' ').slice(1).join(' ') || '' } } : {}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
).catch((err: unknown) => console.warn('Failed to update lead:', err));
|
).catch((err: unknown) => console.warn('Failed to update lead:', err));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Invalidate caller cache so next lookup gets the real name
|
// If the agent actually renamed the patient, kick off the
|
||||||
if (callerNumber) {
|
// side-effect chain: regenerate the AI summary against the
|
||||||
|
// corrected identity AND invalidate the Redis caller
|
||||||
|
// resolution cache so the next incoming call from this
|
||||||
|
// phone picks up fresh data. Both are fire-and-forget —
|
||||||
|
// the save toast fires immediately either way.
|
||||||
|
if (nameChanged && leadId) {
|
||||||
|
apiClient.post(`/api/lead/${leadId}/enrich`, { phone: callerNumber ?? undefined }, { silent: true }).catch(() => {});
|
||||||
|
} else if (callerNumber) {
|
||||||
|
// No rename but still invalidate the cache so status +
|
||||||
|
// lastContacted updates propagate cleanly to the next
|
||||||
|
// lookup.
|
||||||
apiClient.post('/api/caller/invalidate', { phone: callerNumber }, { silent: true }).catch(() => {});
|
apiClient.post('/api/caller/invalidate', { phone: callerNumber }, { silent: true }).catch(() => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -330,12 +377,34 @@ export const AppointmentForm = ({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Input
|
{/* Patient name — locked by default for existing
|
||||||
label="Patient Name"
|
callers, unlocked for new callers with no
|
||||||
placeholder="Full name"
|
prior name on record. The Edit button opens
|
||||||
value={patientName}
|
a confirm modal before unlocking; see
|
||||||
onChange={setPatientName}
|
EditPatientNameModal for the rationale. */}
|
||||||
/>
|
<div className="flex items-end gap-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<Input
|
||||||
|
label="Patient Name"
|
||||||
|
placeholder="Full name"
|
||||||
|
value={patientName}
|
||||||
|
onChange={setPatientName}
|
||||||
|
isDisabled={!isNameEditable}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{!isNameEditable && initialLeadName.length > 0 && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
color="secondary"
|
||||||
|
iconLeading={({ className }: { className?: string }) => (
|
||||||
|
<FontAwesomeIcon icon={faUserPen} className={className} />
|
||||||
|
)}
|
||||||
|
onClick={() => setEditConfirmOpen(true)}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<Input
|
<Input
|
||||||
@@ -513,6 +582,24 @@ export const AppointmentForm = ({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<EditPatientConfirmModal
|
||||||
|
isOpen={editConfirmOpen}
|
||||||
|
onOpenChange={setEditConfirmOpen}
|
||||||
|
onConfirm={() => {
|
||||||
|
setIsNameEditable(true);
|
||||||
|
setEditConfirmOpen(false);
|
||||||
|
}}
|
||||||
|
description={
|
||||||
|
<>
|
||||||
|
You're about to change the name on this patient's record. This will
|
||||||
|
update their profile across Helix Engage, including past appointments,
|
||||||
|
lead history, and AI summary. Only proceed if the current name is
|
||||||
|
actually wrong — for all other cases, cancel and continue with the
|
||||||
|
appointment as-is.
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,28 +1,28 @@
|
|||||||
import type { FC } from "react";
|
import type { FC } from 'react';
|
||||||
import { faArrowRight } from "@fortawesome/pro-duotone-svg-icons";
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { faArrowRight } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
import { Badge } from "@/components/base/badges/badges";
|
import { Badge } from '@/components/base/badges/badges';
|
||||||
import { Button } from "@/components/base/buttons/button";
|
import { Button } from '@/components/base/buttons/button';
|
||||||
import { formatShortDate } from "@/lib/format";
|
|
||||||
import type { Call, CallDisposition } from "@/types/entities";
|
|
||||||
|
|
||||||
const ArrowRight: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faArrowRight} className={className} />;
|
const ArrowRight: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faArrowRight} className={className} />;
|
||||||
|
import { formatShortDate } from '@/lib/format';
|
||||||
|
import type { Call, CallDisposition } from '@/types/entities';
|
||||||
|
|
||||||
interface CallLogProps {
|
interface CallLogProps {
|
||||||
calls: Call[];
|
calls: Call[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const dispositionConfig: Record<CallDisposition, { label: string; color: "success" | "brand" | "blue-light" | "warning" | "gray" | "error" }> = {
|
const dispositionConfig: Record<CallDisposition, { label: string; color: 'success' | 'brand' | 'blue-light' | 'warning' | 'gray' | 'error' }> = {
|
||||||
APPOINTMENT_BOOKED: { label: "Booked", color: "success" },
|
APPOINTMENT_BOOKED: { label: 'Booked', color: 'success' },
|
||||||
FOLLOW_UP_SCHEDULED: { label: "Follow-up", color: "brand" },
|
FOLLOW_UP_SCHEDULED: { label: 'Follow-up', color: 'brand' },
|
||||||
INFO_PROVIDED: { label: "Info", color: "blue-light" },
|
INFO_PROVIDED: { label: 'Info', color: 'blue-light' },
|
||||||
NO_ANSWER: { label: "No Answer", color: "warning" },
|
NO_ANSWER: { label: 'No Answer', color: 'warning' },
|
||||||
WRONG_NUMBER: { label: "Wrong #", color: "gray" },
|
WRONG_NUMBER: { label: 'Wrong #', color: 'gray' },
|
||||||
CALLBACK_REQUESTED: { label: "Not Interested", color: "error" },
|
CALLBACK_REQUESTED: { label: 'Not Interested', color: 'error' },
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDuration = (seconds: number | null): string => {
|
const formatDuration = (seconds: number | null): string => {
|
||||||
if (seconds === null || seconds === 0) return "0 min";
|
if (seconds === null || seconds === 0) return '0 min';
|
||||||
const minutes = Math.round(seconds / 60);
|
const minutes = Math.round(seconds / 60);
|
||||||
return `${minutes} min`;
|
return `${minutes} min`;
|
||||||
};
|
};
|
||||||
@@ -33,29 +33,34 @@ export const CallLog = ({ calls }: CallLogProps) => {
|
|||||||
<div className="flex items-center justify-between border-b border-secondary px-5 py-4">
|
<div className="flex items-center justify-between border-b border-secondary px-5 py-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm font-bold text-primary">Today's Calls</span>
|
<span className="text-sm font-bold text-primary">Today's Calls</span>
|
||||||
<Badge size="sm" color="gray">
|
<Badge size="sm" color="gray">{calls.length}</Badge>
|
||||||
{calls.length}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{calls.length > 0 ? (
|
{calls.length > 0 ? (
|
||||||
<div className="divide-y divide-secondary">
|
<div className="divide-y divide-secondary">
|
||||||
{calls.map((call) => {
|
{calls.map((call) => {
|
||||||
const config = call.disposition !== null ? dispositionConfig[call.disposition] : null;
|
const config = call.disposition !== null
|
||||||
|
? dispositionConfig[call.disposition]
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={call.id} className="flex items-center gap-3 px-5 py-3">
|
<div
|
||||||
<span className="w-20 shrink-0 text-xs text-quaternary">{call.startedAt !== null ? formatShortDate(call.startedAt) : "—"}</span>
|
key={call.id}
|
||||||
|
className="flex items-center gap-3 px-5 py-3"
|
||||||
|
>
|
||||||
|
<span className="w-20 shrink-0 text-xs text-quaternary">
|
||||||
|
{call.startedAt !== null ? formatShortDate(call.startedAt) : '—'}
|
||||||
|
</span>
|
||||||
<span className="min-w-0 flex-1 truncate text-sm font-medium text-primary">
|
<span className="min-w-0 flex-1 truncate text-sm font-medium text-primary">
|
||||||
{call.leadName ?? call.callerNumber?.[0]?.number ?? "Unknown"}
|
{call.leadName ?? call.callerNumber?.[0]?.number ?? 'Unknown'}
|
||||||
</span>
|
</span>
|
||||||
{config !== null && (
|
{config !== null && (
|
||||||
<Badge size="sm" color={config.color}>
|
<Badge size="sm" color={config.color}>{config.label}</Badge>
|
||||||
{config.label}
|
|
||||||
</Badge>
|
|
||||||
)}
|
)}
|
||||||
<span className="w-12 shrink-0 text-right text-xs text-quaternary">{formatDuration(call.durationSeconds)}</span>
|
<span className="w-12 shrink-0 text-right text-xs text-quaternary">
|
||||||
|
{formatDuration(call.durationSeconds)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { faSparkles, faUserPlus } from "@fortawesome/pro-duotone-svg-icons";
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { faSparkles, faUserPlus } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
import { Badge } from "@/components/base/badges/badges";
|
import { Badge } from '@/components/base/badges/badges';
|
||||||
import { Button } from "@/components/base/buttons/button";
|
import { Button } from '@/components/base/buttons/button';
|
||||||
import { formatShortDate } from "@/lib/format";
|
import { formatShortDate } from '@/lib/format';
|
||||||
import type { Lead, LeadActivity } from "@/types/entities";
|
import type { Lead, LeadActivity } from '@/types/entities';
|
||||||
|
|
||||||
interface CallPrepCardProps {
|
interface CallPrepCardProps {
|
||||||
lead: Lead | null;
|
lead: Lead | null;
|
||||||
@@ -19,8 +19,8 @@ export const CallPrepCard = ({ lead, callerPhone, activities }: CallPrepCardProp
|
|||||||
const leadActivities = activities
|
const leadActivities = activities
|
||||||
.filter((a) => a.leadId === lead.id)
|
.filter((a) => a.leadId === lead.id)
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
const dateA = a.occurredAt ?? a.createdAt ?? "";
|
const dateA = a.occurredAt ?? a.createdAt ?? '';
|
||||||
const dateB = b.occurredAt ?? b.createdAt ?? "";
|
const dateB = b.occurredAt ?? b.createdAt ?? '';
|
||||||
return new Date(dateB).getTime() - new Date(dateA).getTime();
|
return new Date(dateB).getTime() - new Date(dateA).getTime();
|
||||||
})
|
})
|
||||||
.slice(0, 3);
|
.slice(0, 3);
|
||||||
@@ -29,16 +29,22 @@ export const CallPrepCard = ({ lead, callerPhone, activities }: CallPrepCardProp
|
|||||||
<div className="rounded-xl bg-brand-primary p-4">
|
<div className="rounded-xl bg-brand-primary p-4">
|
||||||
<div className="mb-2 flex items-center gap-1.5">
|
<div className="mb-2 flex items-center gap-1.5">
|
||||||
<FontAwesomeIcon icon={faSparkles} className="size-3.5 text-fg-brand-primary" />
|
<FontAwesomeIcon icon={faSparkles} className="size-3.5 text-fg-brand-primary" />
|
||||||
<span className="text-xs font-bold tracking-wider text-brand-secondary uppercase">AI Call Prep</span>
|
<span className="text-xs font-bold uppercase tracking-wider text-brand-secondary">AI Call Prep</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{lead.aiSummary && <p className="text-sm text-primary">{lead.aiSummary}</p>}
|
{lead.aiSummary && (
|
||||||
|
<p className="text-sm text-primary">{lead.aiSummary}</p>
|
||||||
{lead.aiSuggestedAction && (
|
|
||||||
<span className="mt-2 inline-block rounded-lg bg-brand-solid px-3 py-1.5 text-xs font-semibold text-white">{lead.aiSuggestedAction}</span>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!lead.aiSummary && !lead.aiSuggestedAction && <p className="text-sm text-quaternary">No AI insights available for this lead.</p>}
|
{lead.aiSuggestedAction && (
|
||||||
|
<span className="mt-2 inline-block rounded-lg bg-brand-solid px-3 py-1.5 text-xs font-semibold text-white">
|
||||||
|
{lead.aiSuggestedAction}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!lead.aiSummary && !lead.aiSuggestedAction && (
|
||||||
|
<p className="text-sm text-quaternary">No AI insights available for this lead.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{leadActivities.length > 0 && (
|
{leadActivities.length > 0 && (
|
||||||
<div className="mt-3 border-t border-brand pt-3">
|
<div className="mt-3 border-t border-brand pt-3">
|
||||||
@@ -46,11 +52,11 @@ export const CallPrepCard = ({ lead, callerPhone, activities }: CallPrepCardProp
|
|||||||
<div className="mt-1.5 space-y-1">
|
<div className="mt-1.5 space-y-1">
|
||||||
{leadActivities.map((a) => (
|
{leadActivities.map((a) => (
|
||||||
<div key={a.id} className="flex items-start gap-2">
|
<div key={a.id} className="flex items-start gap-2">
|
||||||
<Badge size="sm" color="gray" className="mt-0.5 shrink-0">
|
<Badge size="sm" color="gray" className="shrink-0 mt-0.5">{a.activityType}</Badge>
|
||||||
{a.activityType}
|
|
||||||
</Badge>
|
|
||||||
<span className="flex-1 text-xs text-secondary">{a.summary}</span>
|
<span className="flex-1 text-xs text-secondary">{a.summary}</span>
|
||||||
{a.occurredAt && <span className="shrink-0 text-xs text-quaternary">{formatShortDate(a.occurredAt)}</span>}
|
{a.occurredAt && (
|
||||||
|
<span className="shrink-0 text-xs text-quaternary">{formatShortDate(a.occurredAt)}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -64,10 +70,10 @@ const UnknownCallerPrep = ({ callerPhone }: { callerPhone: string }) => (
|
|||||||
<div className="rounded-xl bg-secondary p-4">
|
<div className="rounded-xl bg-secondary p-4">
|
||||||
<div className="mb-2 flex items-center gap-1.5">
|
<div className="mb-2 flex items-center gap-1.5">
|
||||||
<FontAwesomeIcon icon={faSparkles} className="size-3.5 text-fg-quaternary" />
|
<FontAwesomeIcon icon={faSparkles} className="size-3.5 text-fg-quaternary" />
|
||||||
<span className="text-xs font-bold tracking-wider text-tertiary uppercase">Unknown Caller</span>
|
<span className="text-xs font-bold uppercase tracking-wider text-tertiary">Unknown Caller</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-secondary">
|
<p className="text-sm text-secondary">
|
||||||
No record found for <span className="font-semibold">{callerPhone || "this number"}</span>
|
No record found for <span className="font-semibold">{callerPhone || 'this number'}</span>
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-3 space-y-1.5">
|
<div className="mt-3 space-y-1.5">
|
||||||
<p className="text-xs font-semibold text-secondary">Suggested script:</p>
|
<p className="text-xs font-semibold text-secondary">Suggested script:</p>
|
||||||
@@ -79,11 +85,9 @@ const UnknownCallerPrep = ({ callerPhone }: { callerPhone: string }) => (
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
<Button
|
<Button size="sm" color="secondary" iconLeading={({ className }: { className?: string }) => (
|
||||||
size="sm"
|
<FontAwesomeIcon icon={faUserPlus} className={className} />
|
||||||
color="secondary"
|
)}>
|
||||||
iconLeading={({ className }: { className?: string }) => <FontAwesomeIcon icon={faUserPlus} className={className} />}
|
|
||||||
>
|
|
||||||
Create Lead
|
Create Lead
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { faPhoneArrowUpRight } from "@fortawesome/pro-duotone-svg-icons";
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { faPhoneArrowUpRight } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
import { cx } from "@/utils/cx";
|
import { cx } from '@/utils/cx';
|
||||||
|
|
||||||
interface CallSimulatorProps {
|
interface CallSimulatorProps {
|
||||||
onSimulate: () => void;
|
onSimulate: () => void;
|
||||||
@@ -14,14 +14,14 @@ export const CallSimulator = ({ onSimulate, isCallActive }: CallSimulatorProps)
|
|||||||
onClick={onSimulate}
|
onClick={onSimulate}
|
||||||
disabled={isCallActive}
|
disabled={isCallActive}
|
||||||
className={cx(
|
className={cx(
|
||||||
"inline-flex w-full items-center justify-center gap-2 rounded-xl px-6 py-3 text-sm font-semibold text-white transition duration-100 ease-linear sm:w-auto",
|
'inline-flex w-full items-center justify-center gap-2 rounded-xl px-6 py-3 text-sm font-semibold text-white transition duration-100 ease-linear sm:w-auto',
|
||||||
isCallActive
|
isCallActive
|
||||||
? "cursor-not-allowed bg-disabled text-disabled"
|
? 'cursor-not-allowed bg-disabled text-disabled'
|
||||||
: "cursor-pointer bg-brand-solid hover:bg-brand-solid_hover [&:hover_svg]:animate-[ring-shake_0.5s_ease-in-out]",
|
: 'cursor-pointer bg-brand-solid hover:bg-brand-solid_hover [&:hover_svg]:animate-[ring-shake_0.5s_ease-in-out]',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faPhoneArrowUpRight} className="size-5 shrink-0" />
|
<FontAwesomeIcon icon={faPhoneArrowUpRight} className="size-5 shrink-0" />
|
||||||
{isCallActive ? "Call in progress..." : "Simulate Incoming Call"}
|
{isCallActive ? 'Call in progress...' : 'Simulate Incoming Call'}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,23 +1,21 @@
|
|||||||
import { type FC, useState } from "react";
|
import type { FC } from 'react';
|
||||||
import { faPhone } from "@fortawesome/pro-duotone-svg-icons";
|
import { useState } from 'react';
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { Button } from "@/components/base/buttons/button";
|
import { faPhone } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
import { notify } from "@/lib/toast";
|
|
||||||
import { useSip } from "@/providers/sip-provider";
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
const Phone01: FC<{ className?: string } & Record<string, any>> = ({ className, ...rest }) => <FontAwesomeIcon icon={faPhone} className={className} {...rest} />;
|
||||||
const Phone01: FC<{ className?: string } & Record<string, any>> = ({ className, ...rest }) => (
|
import { Button } from '@/components/base/buttons/button';
|
||||||
<FontAwesomeIcon icon={faPhone} className={className} {...rest} />
|
import { useSip } from '@/providers/sip-provider';
|
||||||
);
|
import { notify } from '@/lib/toast';
|
||||||
|
|
||||||
interface ClickToCallButtonProps {
|
interface ClickToCallButtonProps {
|
||||||
phoneNumber: string;
|
phoneNumber: string;
|
||||||
leadId?: string;
|
leadId?: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
size?: "sm" | "md";
|
size?: 'sm' | 'md';
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ClickToCallButton = ({ phoneNumber, label, size = "sm" }: ClickToCallButtonProps) => {
|
export const ClickToCallButton = ({ phoneNumber, label, size = 'sm' }: ClickToCallButtonProps) => {
|
||||||
const { isRegistered, isInCall, dialOutbound } = useSip();
|
const { isRegistered, isInCall, dialOutbound } = useSip();
|
||||||
const [dialing, setDialing] = useState(false);
|
const [dialing, setDialing] = useState(false);
|
||||||
|
|
||||||
@@ -26,7 +24,7 @@ export const ClickToCallButton = ({ phoneNumber, label, size = "sm" }: ClickToCa
|
|||||||
try {
|
try {
|
||||||
await dialOutbound(phoneNumber);
|
await dialOutbound(phoneNumber);
|
||||||
} catch {
|
} catch {
|
||||||
notify.error("Dial Failed", "Could not place the call");
|
notify.error('Dial Failed', 'Could not place the call');
|
||||||
} finally {
|
} finally {
|
||||||
setDialing(false);
|
setDialing(false);
|
||||||
}
|
}
|
||||||
@@ -41,7 +39,7 @@ export const ClickToCallButton = ({ phoneNumber, label, size = "sm" }: ClickToCa
|
|||||||
isDisabled={!isRegistered || isInCall || !phoneNumber || dialing}
|
isDisabled={!isRegistered || isInCall || !phoneNumber || dialing}
|
||||||
isLoading={dialing}
|
isLoading={dialing}
|
||||||
>
|
>
|
||||||
{label ?? "Call"}
|
{label ?? 'Call'}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import type { Call } from "@/types/entities";
|
import type { Call } from '@/types/entities';
|
||||||
|
|
||||||
interface DailyStatsProps {
|
interface DailyStatsProps {
|
||||||
calls: Call[];
|
calls: Call[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatAvgDuration = (calls: Call[]): string => {
|
const formatAvgDuration = (calls: Call[]): string => {
|
||||||
if (calls.length === 0) return "0.0 min";
|
if (calls.length === 0) return '0.0 min';
|
||||||
const totalSeconds = calls.reduce((sum, c) => sum + (c.durationSeconds ?? 0), 0);
|
const totalSeconds = calls.reduce((sum, c) => sum + (c.durationSeconds ?? 0), 0);
|
||||||
const avgMinutes = totalSeconds / calls.length / 60;
|
const avgMinutes = totalSeconds / calls.length / 60;
|
||||||
return `${avgMinutes.toFixed(1)} min`;
|
return `${avgMinutes.toFixed(1)} min`;
|
||||||
@@ -13,24 +13,29 @@ const formatAvgDuration = (calls: Call[]): string => {
|
|||||||
|
|
||||||
export const DailyStats = ({ calls }: DailyStatsProps) => {
|
export const DailyStats = ({ calls }: DailyStatsProps) => {
|
||||||
const callsHandled = calls.length;
|
const callsHandled = calls.length;
|
||||||
const appointmentsBooked = calls.filter((c) => c.disposition === "APPOINTMENT_BOOKED").length;
|
const appointmentsBooked = calls.filter((c) => c.disposition === 'APPOINTMENT_BOOKED').length;
|
||||||
const followUps = calls.filter((c) => c.disposition === "FOLLOW_UP_SCHEDULED").length;
|
const followUps = calls.filter((c) => c.disposition === 'FOLLOW_UP_SCHEDULED').length;
|
||||||
const avgDuration = formatAvgDuration(calls);
|
const avgDuration = formatAvgDuration(calls);
|
||||||
|
|
||||||
const stats = [
|
const stats = [
|
||||||
{ label: "Calls Handled", value: String(callsHandled) },
|
{ label: 'Calls Handled', value: String(callsHandled) },
|
||||||
{ label: "Appointments Booked", value: String(appointmentsBooked) },
|
{ label: 'Appointments Booked', value: String(appointmentsBooked) },
|
||||||
{ label: "Follow-ups", value: String(followUps) },
|
{ label: 'Follow-ups', value: String(followUps) },
|
||||||
{ label: "Avg Duration", value: avgDuration },
|
{ label: 'Avg Duration', value: avgDuration },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<h3 className="text-sm font-bold text-primary">Daily Stats</h3>
|
<h3 className="text-sm font-bold text-primary">Daily Stats</h3>
|
||||||
{stats.map((stat) => (
|
{stats.map((stat) => (
|
||||||
<div key={stat.label} className="rounded-xl bg-secondary p-4 text-center">
|
<div
|
||||||
|
key={stat.label}
|
||||||
|
className="rounded-xl bg-secondary p-4 text-center"
|
||||||
|
>
|
||||||
<div className="text-display-xs font-bold text-primary">{stat.value}</div>
|
<div className="text-display-xs font-bold text-primary">{stat.value}</div>
|
||||||
<div className="mt-1 text-xs tracking-wider text-tertiary uppercase">{stat.label}</div>
|
<div className="mt-1 text-xs uppercase tracking-wider text-tertiary">
|
||||||
|
{stat.label}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState } from "react";
|
import { useState } from 'react';
|
||||||
import { TextArea } from "@/components/base/textarea/textarea";
|
import { TextArea } from '@/components/base/textarea/textarea';
|
||||||
import type { CallDisposition } from "@/types/entities";
|
import type { CallDisposition } from '@/types/entities';
|
||||||
import { cx } from "@/utils/cx";
|
import { cx } from '@/utils/cx';
|
||||||
|
|
||||||
interface DispositionFormProps {
|
interface DispositionFormProps {
|
||||||
onSubmit: (disposition: CallDisposition, notes: string) => void;
|
onSubmit: (disposition: CallDisposition, notes: string) => void;
|
||||||
@@ -15,46 +15,46 @@ const dispositionOptions: Array<{
|
|||||||
defaultClass: string;
|
defaultClass: string;
|
||||||
}> = [
|
}> = [
|
||||||
{
|
{
|
||||||
value: "APPOINTMENT_BOOKED",
|
value: 'APPOINTMENT_BOOKED',
|
||||||
label: "Appointment Booked",
|
label: 'Appointment Booked',
|
||||||
activeClass: "bg-success-solid text-white ring-transparent",
|
activeClass: 'bg-success-solid text-white ring-transparent',
|
||||||
defaultClass: "bg-success-primary text-success-primary border-success",
|
defaultClass: 'bg-success-primary text-success-primary border-success',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "FOLLOW_UP_SCHEDULED",
|
value: 'FOLLOW_UP_SCHEDULED',
|
||||||
label: "Follow-up Needed",
|
label: 'Follow-up Needed',
|
||||||
activeClass: "bg-brand-solid text-white ring-transparent",
|
activeClass: 'bg-brand-solid text-white ring-transparent',
|
||||||
defaultClass: "bg-brand-primary text-brand-secondary border-brand",
|
defaultClass: 'bg-brand-primary text-brand-secondary border-brand',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "INFO_PROVIDED",
|
value: 'INFO_PROVIDED',
|
||||||
label: "Info Provided",
|
label: 'Info Provided',
|
||||||
activeClass: "bg-utility-blue-light-600 text-white ring-transparent",
|
activeClass: 'bg-utility-blue-light-600 text-white ring-transparent',
|
||||||
defaultClass: "bg-utility-blue-light-50 text-utility-blue-light-700 border-utility-blue-light-200",
|
defaultClass: 'bg-utility-blue-light-50 text-utility-blue-light-700 border-utility-blue-light-200',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "NO_ANSWER",
|
value: 'NO_ANSWER',
|
||||||
label: "No Answer",
|
label: 'No Answer',
|
||||||
activeClass: "bg-warning-solid text-white ring-transparent",
|
activeClass: 'bg-warning-solid text-white ring-transparent',
|
||||||
defaultClass: "bg-warning-primary text-warning-primary border-warning",
|
defaultClass: 'bg-warning-primary text-warning-primary border-warning',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "WRONG_NUMBER",
|
value: 'WRONG_NUMBER',
|
||||||
label: "Wrong Number",
|
label: 'Wrong Number',
|
||||||
activeClass: "bg-secondary-solid text-white ring-transparent",
|
activeClass: 'bg-secondary-solid text-white ring-transparent',
|
||||||
defaultClass: "bg-secondary text-secondary border-secondary",
|
defaultClass: 'bg-secondary text-secondary border-secondary',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "CALLBACK_REQUESTED",
|
value: 'CALLBACK_REQUESTED',
|
||||||
label: "Not Interested",
|
label: 'Not Interested',
|
||||||
activeClass: "bg-error-solid text-white ring-transparent",
|
activeClass: 'bg-error-solid text-white ring-transparent',
|
||||||
defaultClass: "bg-error-primary text-error-primary border-error",
|
defaultClass: 'bg-error-primary text-error-primary border-error',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const DispositionForm = ({ onSubmit, defaultDisposition }: DispositionFormProps) => {
|
export const DispositionForm = ({ onSubmit, defaultDisposition }: DispositionFormProps) => {
|
||||||
const [selected, setSelected] = useState<CallDisposition | null>(defaultDisposition ?? null);
|
const [selected, setSelected] = useState<CallDisposition | null>(defaultDisposition ?? null);
|
||||||
const [notes, setNotes] = useState("");
|
const [notes, setNotes] = useState('');
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
if (selected === null) return;
|
if (selected === null) return;
|
||||||
@@ -74,8 +74,10 @@ export const DispositionForm = ({ onSubmit, defaultDisposition }: DispositionFor
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => setSelected(option.value)}
|
onClick={() => setSelected(option.value)}
|
||||||
className={cx(
|
className={cx(
|
||||||
"cursor-pointer rounded-xl border-2 p-3 text-xs font-semibold transition duration-100 ease-linear",
|
'cursor-pointer rounded-xl border-2 p-3 text-xs font-semibold transition duration-100 ease-linear',
|
||||||
isSelected ? cx(option.activeClass, "ring-2 ring-brand") : option.defaultClass,
|
isSelected
|
||||||
|
? cx(option.activeClass, 'ring-2 ring-brand')
|
||||||
|
: option.defaultClass,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{option.label}
|
{option.label}
|
||||||
@@ -84,7 +86,13 @@ export const DispositionForm = ({ onSubmit, defaultDisposition }: DispositionFor
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TextArea label="Notes (optional)" placeholder="Add any notes about this call..." value={notes} onChange={(value) => setNotes(value)} rows={3} />
|
<TextArea
|
||||||
|
label="Notes (optional)"
|
||||||
|
placeholder="Add any notes about this call..."
|
||||||
|
value={notes}
|
||||||
|
onChange={(value) => setNotes(value)}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<button
|
<button
|
||||||
@@ -92,10 +100,10 @@ export const DispositionForm = ({ onSubmit, defaultDisposition }: DispositionFor
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={selected === null}
|
disabled={selected === null}
|
||||||
className={cx(
|
className={cx(
|
||||||
"rounded-xl px-6 py-2.5 text-sm font-semibold transition duration-100 ease-linear",
|
'rounded-xl px-6 py-2.5 text-sm font-semibold transition duration-100 ease-linear',
|
||||||
selected !== null
|
selected !== null
|
||||||
? "cursor-pointer bg-brand-solid text-white hover:bg-brand-solid_hover"
|
? 'cursor-pointer bg-brand-solid text-white hover:bg-brand-solid_hover'
|
||||||
: "cursor-not-allowed bg-disabled text-disabled",
|
: 'cursor-not-allowed bg-disabled text-disabled',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
Save & Close Call
|
Save & Close Call
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faUserPen } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
import { Input } from '@/components/base/input/input';
|
import { Input } from '@/components/base/input/input';
|
||||||
import { Select } from '@/components/base/select/select';
|
import { Select } from '@/components/base/select/select';
|
||||||
import { TextArea } from '@/components/base/textarea/textarea';
|
import { TextArea } from '@/components/base/textarea/textarea';
|
||||||
import { Checkbox } from '@/components/base/checkbox/checkbox';
|
import { Checkbox } from '@/components/base/checkbox/checkbox';
|
||||||
import { Button } from '@/components/base/buttons/button';
|
import { Button } from '@/components/base/buttons/button';
|
||||||
|
import { EditPatientConfirmModal } from '@/components/modals/edit-patient-confirm-modal';
|
||||||
import { apiClient } from '@/lib/api-client';
|
import { apiClient } from '@/lib/api-client';
|
||||||
import { notify } from '@/lib/toast';
|
import { notify } from '@/lib/toast';
|
||||||
|
|
||||||
@@ -11,6 +14,11 @@ type EnquiryFormProps = {
|
|||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
callerPhone?: string | null;
|
callerPhone?: string | null;
|
||||||
|
// Pre-populated caller name (from caller-resolution). When set, the
|
||||||
|
// patient-name field is locked behind the Edit-confirm modal to
|
||||||
|
// prevent accidental rename-on-save. When empty or null, the field
|
||||||
|
// starts unlocked because there's no existing name to protect.
|
||||||
|
leadName?: string | null;
|
||||||
leadId?: string | null;
|
leadId?: string | null;
|
||||||
patientId?: string | null;
|
patientId?: string | null;
|
||||||
agentName?: string | null;
|
agentName?: string | null;
|
||||||
@@ -18,8 +26,14 @@ type EnquiryFormProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, leadId: propLeadId, patientId, agentName, onSaved }: EnquiryFormProps) => {
|
export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, leadName, leadId: propLeadId, patientId, agentName, onSaved }: EnquiryFormProps) => {
|
||||||
const [patientName, setPatientName] = useState('');
|
// Initial name captured at form open — used to detect whether the
|
||||||
|
// agent actually changed the name before committing any updatePatient /
|
||||||
|
// updateLead.contactName mutations. See also appointment-form.tsx.
|
||||||
|
const initialLeadName = (leadName ?? '').trim();
|
||||||
|
const [patientName, setPatientName] = useState(leadName ?? '');
|
||||||
|
const [isNameEditable, setIsNameEditable] = useState(initialLeadName.length === 0);
|
||||||
|
const [editConfirmOpen, setEditConfirmOpen] = useState(false);
|
||||||
const [source, setSource] = useState('Phone Inquiry');
|
const [source, setSource] = useState('Phone Inquiry');
|
||||||
const [queryAsked, setQueryAsked] = useState('');
|
const [queryAsked, setQueryAsked] = useState('');
|
||||||
const [isExisting, setIsExisting] = useState(false);
|
const [isExisting, setIsExisting] = useState(false);
|
||||||
@@ -72,29 +86,44 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, leadId: propLea
|
|||||||
leadId = resolved.leadId;
|
leadId = resolved.leadId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Determine whether the agent actually renamed the patient.
|
||||||
|
// Only a non-empty, changed-from-initial name counts — empty
|
||||||
|
// strings or an unchanged name never trigger the rename
|
||||||
|
// chain, even if the field was unlocked.
|
||||||
|
const trimmedName = patientName.trim();
|
||||||
|
const nameChanged = isNameEditable && trimmedName.length > 0 && trimmedName !== initialLeadName;
|
||||||
|
const nameParts = {
|
||||||
|
firstName: trimmedName.split(' ')[0],
|
||||||
|
lastName: trimmedName.split(' ').slice(1).join(' ') || '',
|
||||||
|
};
|
||||||
|
|
||||||
if (leadId) {
|
if (leadId) {
|
||||||
// Update existing lead with enquiry details
|
// Update existing lead with enquiry details. Only touches
|
||||||
|
// contactName if the agent explicitly renamed — otherwise
|
||||||
|
// we leave the existing caller identity alone.
|
||||||
await apiClient.graphql(
|
await apiClient.graphql(
|
||||||
`mutation($id: UUID!, $data: LeadUpdateInput!) { updateLead(id: $id, data: $data) { id } }`,
|
`mutation($id: UUID!, $data: LeadUpdateInput!) { updateLead(id: $id, data: $data) { id } }`,
|
||||||
{
|
{
|
||||||
id: leadId,
|
id: leadId,
|
||||||
data: {
|
data: {
|
||||||
name: `Enquiry — ${patientName}`,
|
name: `Enquiry — ${trimmedName || 'Unknown caller'}`,
|
||||||
contactName: { firstName: patientName.split(' ')[0], lastName: patientName.split(' ').slice(1).join(' ') || '' },
|
|
||||||
source: 'PHONE',
|
source: 'PHONE',
|
||||||
status: 'CONTACTED',
|
status: 'CONTACTED',
|
||||||
interestedService: queryAsked.substring(0, 100),
|
interestedService: queryAsked.substring(0, 100),
|
||||||
|
...(nameChanged ? { contactName: nameParts } : {}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// No phone provided — create a new lead (rare edge case)
|
// No matched lead — create a fresh one. For net-new leads
|
||||||
|
// we always populate contactName from the typed value
|
||||||
|
// (there's no existing record to protect).
|
||||||
await apiClient.graphql(
|
await apiClient.graphql(
|
||||||
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
|
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
|
||||||
{
|
{
|
||||||
data: {
|
data: {
|
||||||
name: `Enquiry — ${patientName}`,
|
name: `Enquiry — ${trimmedName || 'Unknown caller'}`,
|
||||||
contactName: { firstName: patientName.split(' ')[0], lastName: patientName.split(' ').slice(1).join(' ') || '' },
|
contactName: nameParts,
|
||||||
contactPhone: registeredPhone ? { primaryPhoneNumber: registeredPhone } : undefined,
|
contactPhone: registeredPhone ? { primaryPhoneNumber: registeredPhone } : undefined,
|
||||||
source: 'PHONE',
|
source: 'PHONE',
|
||||||
status: 'CONTACTED',
|
status: 'CONTACTED',
|
||||||
@@ -104,21 +133,29 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, leadId: propLea
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update patient name if we have a name and a linked patient
|
// Update linked patient's name ONLY if the agent explicitly
|
||||||
if (patientId && patientName.trim()) {
|
// renamed. Fixes the long-standing bug where typing a name
|
||||||
|
// into this form silently overwrote the existing patient
|
||||||
|
// record.
|
||||||
|
if (nameChanged && patientId) {
|
||||||
await apiClient.graphql(
|
await apiClient.graphql(
|
||||||
`mutation($id: UUID!, $data: PatientUpdateInput!) { updatePatient(id: $id, data: $data) { id } }`,
|
`mutation($id: UUID!, $data: PatientUpdateInput!) { updatePatient(id: $id, data: $data) { id } }`,
|
||||||
{
|
{
|
||||||
id: patientId,
|
id: patientId,
|
||||||
data: {
|
data: {
|
||||||
fullName: { firstName: patientName.split(' ')[0], lastName: patientName.split(' ').slice(1).join(' ') || '' },
|
fullName: nameParts,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
).catch((err: unknown) => console.warn('Failed to update patient name:', err));
|
).catch((err: unknown) => console.warn('Failed to update patient name:', err));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Invalidate caller cache so next lookup gets the real name
|
// Post-save side-effects. If the agent actually renamed the
|
||||||
if (callerPhone) {
|
// patient, kick off AI summary regen + cache invalidation.
|
||||||
|
// Otherwise just invalidate the cache so the status update
|
||||||
|
// propagates.
|
||||||
|
if (nameChanged && leadId) {
|
||||||
|
apiClient.post(`/api/lead/${leadId}/enrich`, { phone: callerPhone ?? undefined }, { silent: true }).catch(() => {});
|
||||||
|
} else if (callerPhone) {
|
||||||
apiClient.post('/api/caller/invalidate', { phone: callerPhone }, { silent: true }).catch(() => {});
|
apiClient.post('/api/caller/invalidate', { phone: callerPhone }, { silent: true }).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,7 +199,34 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, leadId: propLea
|
|||||||
{/* Form fields — scrollable */}
|
{/* Form fields — scrollable */}
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<Input label="Patient Name" placeholder="Full name" value={patientName} onChange={setPatientName} isRequired />
|
{/* Patient name — locked by default for existing callers,
|
||||||
|
unlocked for new callers with no prior name on record.
|
||||||
|
The Edit button opens a confirm modal before unlocking;
|
||||||
|
see EditPatientConfirmModal for the rationale. */}
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<Input
|
||||||
|
label="Patient Name"
|
||||||
|
placeholder="Full name"
|
||||||
|
value={patientName}
|
||||||
|
onChange={setPatientName}
|
||||||
|
isRequired
|
||||||
|
isDisabled={!isNameEditable}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{!isNameEditable && initialLeadName.length > 0 && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
color="secondary"
|
||||||
|
iconLeading={({ className }: { className?: string }) => (
|
||||||
|
<FontAwesomeIcon icon={faUserPen} className={className} />
|
||||||
|
)}
|
||||||
|
onClick={() => setEditConfirmOpen(true)}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<Input label="Source / Referral" placeholder="How did they reach us?" value={source} onChange={setSource} isRequired />
|
<Input label="Source / Referral" placeholder="How did they reach us?" value={source} onChange={setSource} isRequired />
|
||||||
|
|
||||||
@@ -206,6 +270,24 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, leadId: propLea
|
|||||||
{isSaving ? 'Saving...' : 'Log Enquiry'}
|
{isSaving ? 'Saving...' : 'Log Enquiry'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<EditPatientConfirmModal
|
||||||
|
isOpen={editConfirmOpen}
|
||||||
|
onOpenChange={setEditConfirmOpen}
|
||||||
|
onConfirm={() => {
|
||||||
|
setIsNameEditable(true);
|
||||||
|
setEditConfirmOpen(false);
|
||||||
|
}}
|
||||||
|
description={
|
||||||
|
<>
|
||||||
|
You're about to change the name on this patient's record. This will
|
||||||
|
update their profile across Helix Engage, including past appointments,
|
||||||
|
lead history, and AI summary. Only proceed if the current name is
|
||||||
|
actually wrong — for all other cases, cancel and continue logging the
|
||||||
|
enquiry as-is.
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from 'react';
|
||||||
import { faCircleCheck, faClock, faEnvelope, faPhone, faPhoneArrowDown, faStars } from "@fortawesome/pro-duotone-svg-icons";
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { faPhone, faPhoneArrowDown, faCircleCheck, faEnvelope, faClock, faStars } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
import { Avatar } from "@/components/base/avatar/avatar";
|
import { Avatar } from '@/components/base/avatar/avatar';
|
||||||
import { Badge } from "@/components/base/badges/badges";
|
import { Badge } from '@/components/base/badges/badges';
|
||||||
import { AgeIndicator } from "@/components/shared/age-indicator";
|
import { SourceTag } from '@/components/shared/source-tag';
|
||||||
import { SourceTag } from "@/components/shared/source-tag";
|
import { AgeIndicator } from '@/components/shared/age-indicator';
|
||||||
import { formatPhone, formatShortDate, getInitials } from "@/lib/format";
|
import { DispositionForm } from './disposition-form';
|
||||||
import type { CallDisposition, Campaign, Lead, LeadActivity } from "@/types/entities";
|
import { formatPhone, formatShortDate, getInitials } from '@/lib/format';
|
||||||
import { DispositionForm } from "./disposition-form";
|
import type { Lead, LeadActivity, CallDisposition, Campaign } from '@/types/entities';
|
||||||
|
|
||||||
type CallState = "idle" | "ringing" | "active" | "completed";
|
type CallState = 'idle' | 'ringing' | 'active' | 'completed';
|
||||||
|
|
||||||
interface IncomingCallCardProps {
|
interface IncomingCallCardProps {
|
||||||
callState: CallState;
|
callState: CallState;
|
||||||
@@ -21,57 +21,57 @@ interface IncomingCallCardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const activityTypeIcons: Record<string, string> = {
|
const activityTypeIcons: Record<string, string> = {
|
||||||
CALL_MADE: "phone",
|
CALL_MADE: 'phone',
|
||||||
CALL_RECEIVED: "phone",
|
CALL_RECEIVED: 'phone',
|
||||||
WHATSAPP_SENT: "message",
|
WHATSAPP_SENT: 'message',
|
||||||
WHATSAPP_RECEIVED: "message",
|
WHATSAPP_RECEIVED: 'message',
|
||||||
SMS_SENT: "message",
|
SMS_SENT: 'message',
|
||||||
EMAIL_SENT: "email",
|
EMAIL_SENT: 'email',
|
||||||
EMAIL_RECEIVED: "email",
|
EMAIL_RECEIVED: 'email',
|
||||||
NOTE_ADDED: "note",
|
NOTE_ADDED: 'note',
|
||||||
ASSIGNED: "assign",
|
ASSIGNED: 'assign',
|
||||||
STATUS_CHANGE: "status",
|
STATUS_CHANGE: 'status',
|
||||||
APPOINTMENT_BOOKED: "calendar",
|
APPOINTMENT_BOOKED: 'calendar',
|
||||||
FOLLOW_UP_CREATED: "clock",
|
FOLLOW_UP_CREATED: 'clock',
|
||||||
CONVERTED: "check",
|
CONVERTED: 'check',
|
||||||
MARKED_SPAM: "alert",
|
MARKED_SPAM: 'alert',
|
||||||
DUPLICATE_DETECTED: "alert",
|
DUPLICATE_DETECTED: 'alert',
|
||||||
};
|
};
|
||||||
|
|
||||||
const ActivityIcon = ({ type }: { type: string }) => {
|
const ActivityIcon = ({ type }: { type: string }) => {
|
||||||
const iconType = activityTypeIcons[type] ?? "note";
|
const iconType = activityTypeIcons[type] ?? 'note';
|
||||||
const baseClass = "size-3.5 shrink-0 text-fg-quaternary";
|
const baseClass = 'size-3.5 shrink-0 text-fg-quaternary';
|
||||||
|
|
||||||
if (iconType === "phone") return <FontAwesomeIcon icon={faPhone} className={baseClass} />;
|
if (iconType === 'phone') return <FontAwesomeIcon icon={faPhone} className={baseClass} />;
|
||||||
if (iconType === "email") return <FontAwesomeIcon icon={faEnvelope} className={baseClass} />;
|
if (iconType === 'email') return <FontAwesomeIcon icon={faEnvelope} className={baseClass} />;
|
||||||
if (iconType === "clock") return <FontAwesomeIcon icon={faClock} className={baseClass} />;
|
if (iconType === 'clock') return <FontAwesomeIcon icon={faClock} className={baseClass} />;
|
||||||
if (iconType === "check") return <FontAwesomeIcon icon={faCircleCheck} className={baseClass} />;
|
if (iconType === 'check') return <FontAwesomeIcon icon={faCircleCheck} className={baseClass} />;
|
||||||
return <FontAwesomeIcon icon={faClock} className={baseClass} />;
|
return <FontAwesomeIcon icon={faClock} className={baseClass} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
const dispositionLabels: Record<CallDisposition, string> = {
|
const dispositionLabels: Record<CallDisposition, string> = {
|
||||||
APPOINTMENT_BOOKED: "Appointment Booked",
|
APPOINTMENT_BOOKED: 'Appointment Booked',
|
||||||
FOLLOW_UP_SCHEDULED: "Follow-up Needed",
|
FOLLOW_UP_SCHEDULED: 'Follow-up Needed',
|
||||||
INFO_PROVIDED: "Info Provided",
|
INFO_PROVIDED: 'Info Provided',
|
||||||
NO_ANSWER: "No Answer",
|
NO_ANSWER: 'No Answer',
|
||||||
WRONG_NUMBER: "Wrong Number",
|
WRONG_NUMBER: 'Wrong Number',
|
||||||
CALLBACK_REQUESTED: "Not Interested",
|
CALLBACK_REQUESTED: 'Not Interested',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const IncomingCallCard = ({ callState, lead, activities, campaigns, onDisposition, completedDisposition }: IncomingCallCardProps) => {
|
export const IncomingCallCard = ({ callState, lead, activities, campaigns, onDisposition, completedDisposition }: IncomingCallCardProps) => {
|
||||||
if (callState === "idle") {
|
if (callState === 'idle') {
|
||||||
return <IdleState />;
|
return <IdleState />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (callState === "ringing") {
|
if (callState === 'ringing') {
|
||||||
return <RingingState lead={lead} />;
|
return <RingingState lead={lead} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (callState === "active" && lead !== null) {
|
if (callState === 'active' && lead !== null) {
|
||||||
return <ActiveState lead={lead} activities={activities} campaigns={campaigns} onDisposition={onDisposition} />;
|
return <ActiveState lead={lead} activities={activities} campaigns={campaigns} onDisposition={onDisposition} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (callState === "completed") {
|
if (callState === 'completed') {
|
||||||
return <CompletedState disposition={completedDisposition ?? null} />;
|
return <CompletedState disposition={completedDisposition ?? null} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,7 +88,9 @@ const IdleState = () => (
|
|||||||
);
|
);
|
||||||
|
|
||||||
const RingingState = ({ lead }: { lead: Lead | null }) => {
|
const RingingState = ({ lead }: { lead: Lead | null }) => {
|
||||||
const phoneDisplay = lead?.contactPhone?.[0] ? formatPhone(lead.contactPhone[0]) : "+91 98765 43210";
|
const phoneDisplay = lead?.contactPhone?.[0]
|
||||||
|
? formatPhone(lead.contactPhone[0])
|
||||||
|
: '+91 98765 43210';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center rounded-2xl bg-brand-primary p-12 text-center">
|
<div className="flex flex-col items-center justify-center rounded-2xl bg-brand-primary p-12 text-center">
|
||||||
@@ -98,8 +100,12 @@ const RingingState = ({ lead }: { lead: Lead | null }) => {
|
|||||||
<FontAwesomeIcon icon={faPhoneArrowDown} className="size-12 text-fg-brand-primary" />
|
<FontAwesomeIcon icon={faPhoneArrowDown} className="size-12 text-fg-brand-primary" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="mb-1 text-xs font-bold tracking-wider text-brand-secondary uppercase">Incoming Call</span>
|
<span className="mb-1 text-xs font-bold uppercase tracking-wider text-brand-secondary">
|
||||||
<span className="text-display-xs font-bold text-primary">{phoneDisplay}</span>
|
Incoming Call
|
||||||
|
</span>
|
||||||
|
<span className="text-display-xs font-bold text-primary">
|
||||||
|
{phoneDisplay}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -120,8 +126,8 @@ const ActiveState = ({
|
|||||||
activities
|
activities
|
||||||
.filter((a) => a.leadId === lead.id)
|
.filter((a) => a.leadId === lead.id)
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
const dateA = a.occurredAt ?? a.createdAt ?? "";
|
const dateA = a.occurredAt ?? a.createdAt ?? '';
|
||||||
const dateB = b.occurredAt ?? b.createdAt ?? "";
|
const dateB = b.occurredAt ?? b.createdAt ?? '';
|
||||||
return new Date(dateB).getTime() - new Date(dateA).getTime();
|
return new Date(dateB).getTime() - new Date(dateA).getTime();
|
||||||
})
|
})
|
||||||
.slice(0, 3),
|
.slice(0, 3),
|
||||||
@@ -134,11 +140,13 @@ const ActiveState = ({
|
|||||||
return campaign?.campaignName ?? null;
|
return campaign?.campaignName ?? null;
|
||||||
}, [campaigns, lead.campaignId]);
|
}, [campaigns, lead.campaignId]);
|
||||||
|
|
||||||
const firstName = lead.contactName?.firstName ?? "";
|
const firstName = lead.contactName?.firstName ?? '';
|
||||||
const lastName = lead.contactName?.lastName ?? "";
|
const lastName = lead.contactName?.lastName ?? '';
|
||||||
const fullName = `${firstName} ${lastName}`.trim() || "Unknown Lead";
|
const fullName = `${firstName} ${lastName}`.trim() || 'Unknown Lead';
|
||||||
const initials = firstName && lastName ? getInitials(firstName, lastName) : "UL";
|
const initials = firstName && lastName ? getInitials(firstName, lastName) : 'UL';
|
||||||
const phoneDisplay = lead.contactPhone?.[0] ? formatPhone(lead.contactPhone[0]) : "No phone";
|
const phoneDisplay = lead.contactPhone?.[0]
|
||||||
|
? formatPhone(lead.contactPhone[0])
|
||||||
|
: 'No phone';
|
||||||
const emailDisplay = lead.contactEmail?.[0]?.address ?? null;
|
const emailDisplay = lead.contactEmail?.[0]?.address ?? null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -161,14 +169,18 @@ const ActiveState = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||||
{lead.leadSource !== null && <SourceTag source={lead.leadSource} size="sm" />}
|
{lead.leadSource !== null && (
|
||||||
|
<SourceTag source={lead.leadSource} size="sm" />
|
||||||
|
)}
|
||||||
{campaignName !== null && (
|
{campaignName !== null && (
|
||||||
<Badge size="sm" color="brand">
|
<Badge size="sm" color="brand">{campaignName}</Badge>
|
||||||
{campaignName}
|
|
||||||
</Badge>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{lead.interestedService !== null && <p className="mt-1.5 text-sm text-secondary">Interested in: {lead.interestedService}</p>}
|
{lead.interestedService !== null && (
|
||||||
|
<p className="mt-1.5 text-sm text-secondary">
|
||||||
|
Interested in: {lead.interestedService}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{lead.createdAt !== null && (
|
{lead.createdAt !== null && (
|
||||||
<div className="mt-1 flex items-center gap-1.5 text-sm text-tertiary">
|
<div className="mt-1 flex items-center gap-1.5 text-sm text-tertiary">
|
||||||
<span>Lead age:</span>
|
<span>Lead age:</span>
|
||||||
@@ -182,7 +194,9 @@ const ActiveState = ({
|
|||||||
<div className="mt-4 rounded-xl bg-brand-primary p-4">
|
<div className="mt-4 rounded-xl bg-brand-primary p-4">
|
||||||
<div className="mb-2 flex items-center gap-1.5">
|
<div className="mb-2 flex items-center gap-1.5">
|
||||||
<FontAwesomeIcon icon={faStars} className="size-4 text-fg-brand-primary" />
|
<FontAwesomeIcon icon={faStars} className="size-4 text-fg-brand-primary" />
|
||||||
<span className="text-xs font-bold tracking-wider text-brand-secondary uppercase">AI Insight</span>
|
<span className="text-xs font-bold uppercase tracking-wider text-brand-secondary">
|
||||||
|
AI Insight
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{lead.aiSummary !== null ? (
|
{lead.aiSummary !== null ? (
|
||||||
<>
|
<>
|
||||||
@@ -194,7 +208,9 @@ const ActiveState = ({
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-quaternary">No AI insights available for this lead</p>
|
<p className="text-sm text-quaternary">
|
||||||
|
No AI insights available for this lead
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -205,10 +221,14 @@ const ActiveState = ({
|
|||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{leadActivities.map((activity) => (
|
{leadActivities.map((activity) => (
|
||||||
<div key={activity.id} className="flex items-start gap-2">
|
<div key={activity.id} className="flex items-start gap-2">
|
||||||
<ActivityIcon type={activity.activityType ?? "NOTE_ADDED"} />
|
<ActivityIcon type={activity.activityType ?? 'NOTE_ADDED'} />
|
||||||
<span className="flex-1 text-xs text-secondary">{activity.summary}</span>
|
<span className="flex-1 text-xs text-secondary">
|
||||||
|
{activity.summary}
|
||||||
|
</span>
|
||||||
<span className="shrink-0 text-xs text-quaternary">
|
<span className="shrink-0 text-xs text-quaternary">
|
||||||
{activity.occurredAt !== null ? formatShortDate(activity.occurredAt) : ""}
|
{activity.occurredAt !== null
|
||||||
|
? formatShortDate(activity.occurredAt)
|
||||||
|
: ''}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -220,7 +240,7 @@ const ActiveState = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right section: disposition form */}
|
{/* Right section: disposition form */}
|
||||||
<div className="w-full shrink-0 border-t border-secondary pt-4 lg:w-72 lg:border-t-0 lg:border-l lg:pt-0 lg:pl-6">
|
<div className="w-full shrink-0 border-t border-secondary pt-4 lg:w-72 lg:border-l lg:border-t-0 lg:pl-6 lg:pt-0">
|
||||||
<DispositionForm onSubmit={onDisposition} />
|
<DispositionForm onSubmit={onDisposition} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -229,16 +249,14 @@ const ActiveState = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const CompletedState = ({ disposition }: { disposition: CallDisposition | null }) => {
|
const CompletedState = ({ disposition }: { disposition: CallDisposition | null }) => {
|
||||||
const label = disposition !== null ? dispositionLabels[disposition] : "Unknown";
|
const label = disposition !== null ? dispositionLabels[disposition] : 'Unknown';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center rounded-2xl bg-success-primary p-8 text-center">
|
<div className="flex flex-col items-center justify-center rounded-2xl bg-success-primary p-8 text-center">
|
||||||
<FontAwesomeIcon icon={faCircleCheck} className="mb-3 size-12 text-fg-success-primary" />
|
<FontAwesomeIcon icon={faCircleCheck} className="mb-3 size-12 text-fg-success-primary" />
|
||||||
<h3 className="text-lg font-bold text-success-primary">Call Logged</h3>
|
<h3 className="text-lg font-bold text-success-primary">Call Logged</h3>
|
||||||
{disposition !== null && (
|
{disposition !== null && (
|
||||||
<Badge size="md" color="success" className="mt-2">
|
<Badge size="md" color="success" className="mt-2">{label}</Badge>
|
||||||
{label}
|
|
||||||
</Badge>
|
|
||||||
)}
|
)}
|
||||||
<p className="mt-2 text-sm text-tertiary">Returning to call desk...</p>
|
<p className="mt-2 text-sm text-tertiary">Returning to call desk...</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { faCommentDots, faEllipsisVertical, faMessageDots, faPhone } from "@fortawesome/pro-duotone-svg-icons";
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { faPhone, faCommentDots, faEllipsisVertical, faMessageDots } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
import { notify } from "@/lib/toast";
|
import { useSip } from '@/providers/sip-provider';
|
||||||
import { useSip } from "@/providers/sip-provider";
|
import { notify } from '@/lib/toast';
|
||||||
import { cx } from "@/utils/cx";
|
import { cx } from '@/utils/cx';
|
||||||
|
|
||||||
type PhoneActionCellProps = {
|
type PhoneActionCellProps = {
|
||||||
phoneNumber: string;
|
phoneNumber: string;
|
||||||
@@ -27,8 +27,8 @@ export const PhoneActionCell = ({ phoneNumber, displayNumber, leadId: _leadId, o
|
|||||||
setMenuOpen(false);
|
setMenuOpen(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
document.addEventListener("mousedown", handleClick);
|
document.addEventListener('mousedown', handleClick);
|
||||||
return () => document.removeEventListener("mousedown", handleClick);
|
return () => document.removeEventListener('mousedown', handleClick);
|
||||||
}, [menuOpen]);
|
}, [menuOpen]);
|
||||||
|
|
||||||
const handleCall = async () => {
|
const handleCall = async () => {
|
||||||
@@ -39,7 +39,7 @@ export const PhoneActionCell = ({ phoneNumber, displayNumber, leadId: _leadId, o
|
|||||||
onDial?.();
|
onDial?.();
|
||||||
await dialOutbound(phoneNumber);
|
await dialOutbound(phoneNumber);
|
||||||
} catch {
|
} catch {
|
||||||
notify.error("Dial Failed", "Could not place the call");
|
notify.error('Dial Failed', 'Could not place the call');
|
||||||
} finally {
|
} finally {
|
||||||
setDialing(false);
|
setDialing(false);
|
||||||
}
|
}
|
||||||
@@ -47,12 +47,12 @@ export const PhoneActionCell = ({ phoneNumber, displayNumber, leadId: _leadId, o
|
|||||||
|
|
||||||
const handleSms = () => {
|
const handleSms = () => {
|
||||||
setMenuOpen(false);
|
setMenuOpen(false);
|
||||||
window.open(`sms:+91${phoneNumber}`, "_self");
|
window.open(`sms:+91${phoneNumber}`, '_self');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleWhatsApp = () => {
|
const handleWhatsApp = () => {
|
||||||
setMenuOpen(false);
|
setMenuOpen(false);
|
||||||
window.open(`https://wa.me/91${phoneNumber}`, "_blank");
|
window.open(`https://wa.me/91${phoneNumber}`, '_blank');
|
||||||
};
|
};
|
||||||
|
|
||||||
// Long-press for mobile
|
// Long-press for mobile
|
||||||
@@ -77,14 +77,13 @@ export const PhoneActionCell = ({ phoneNumber, displayNumber, leadId: _leadId, o
|
|||||||
onClick={handleCall}
|
onClick={handleCall}
|
||||||
onTouchStart={onTouchStart}
|
onTouchStart={onTouchStart}
|
||||||
onTouchEnd={onTouchEnd}
|
onTouchEnd={onTouchEnd}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => { e.preventDefault(); setMenuOpen(true); }}
|
||||||
e.preventDefault();
|
|
||||||
setMenuOpen(true);
|
|
||||||
}}
|
|
||||||
disabled={!canCall}
|
disabled={!canCall}
|
||||||
className={cx(
|
className={cx(
|
||||||
"flex items-center gap-1.5 rounded-md px-1.5 py-1 text-sm transition duration-100 ease-linear",
|
'flex items-center gap-1.5 rounded-md px-1.5 py-1 text-sm transition duration-100 ease-linear',
|
||||||
canCall ? "cursor-pointer text-brand-secondary hover:bg-brand-primary hover:text-brand-secondary" : "cursor-default text-tertiary",
|
canCall
|
||||||
|
? 'cursor-pointer text-brand-secondary hover:bg-brand-primary hover:text-brand-secondary'
|
||||||
|
: 'cursor-default text-tertiary',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faPhone} className="size-3" />
|
<FontAwesomeIcon icon={faPhone} className="size-3" />
|
||||||
@@ -94,18 +93,15 @@ export const PhoneActionCell = ({ phoneNumber, displayNumber, leadId: _leadId, o
|
|||||||
{/* Kebab menu trigger — desktop */}
|
{/* Kebab menu trigger — desktop */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => {
|
onClick={(e) => { e.stopPropagation(); setMenuOpen(!menuOpen); }}
|
||||||
e.stopPropagation();
|
className="flex size-6 items-center justify-center rounded-md text-fg-quaternary opacity-0 group-hover/row:opacity-100 hover:text-fg-secondary hover:bg-primary_hover transition duration-100 ease-linear"
|
||||||
setMenuOpen(!menuOpen);
|
|
||||||
}}
|
|
||||||
className="flex size-6 items-center justify-center rounded-md text-fg-quaternary opacity-0 transition duration-100 ease-linear group-hover/row:opacity-100 hover:bg-primary_hover hover:text-fg-secondary"
|
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faEllipsisVertical} className="size-3" />
|
<FontAwesomeIcon icon={faEllipsisVertical} className="size-3" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Context menu */}
|
{/* Context menu */}
|
||||||
{menuOpen && (
|
{menuOpen && (
|
||||||
<div className="absolute top-full left-0 z-50 mt-1 w-40 rounded-lg bg-primary py-1 shadow-lg ring-1 ring-secondary">
|
<div className="absolute left-0 top-full z-50 mt-1 w-40 rounded-lg bg-primary shadow-lg ring-1 ring-secondary py-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleCall}
|
onClick={handleCall}
|
||||||
|
|||||||
@@ -1,49 +1,61 @@
|
|||||||
import { AdStatusBadge } from "@/components/shared/status-badge";
|
import { cx } from '@/utils/cx';
|
||||||
import { formatCompact, formatCurrency } from "@/lib/format";
|
import { formatCurrency, formatCompact } from '@/lib/format';
|
||||||
import type { Ad, AdFormat } from "@/types/entities";
|
import { AdStatusBadge } from '@/components/shared/status-badge';
|
||||||
import { cx } from "@/utils/cx";
|
import type { Ad, AdFormat } from '@/types/entities';
|
||||||
|
|
||||||
interface AdCardProps {
|
interface AdCardProps {
|
||||||
ad: Ad;
|
ad: Ad;
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatPreviewStyles: Record<AdFormat, { bg: string; icon: string }> = {
|
const formatPreviewStyles: Record<AdFormat, { bg: string; icon: string }> = {
|
||||||
IMAGE: { bg: "bg-brand-solid", icon: "IMG" },
|
IMAGE: { bg: 'bg-brand-solid', icon: 'IMG' },
|
||||||
VIDEO: { bg: "bg-fg-brand-secondary", icon: "VID" },
|
VIDEO: { bg: 'bg-fg-brand-secondary', icon: 'VID' },
|
||||||
CAROUSEL: { bg: "bg-error-solid", icon: "CAR" },
|
CAROUSEL: { bg: 'bg-error-solid', icon: 'CAR' },
|
||||||
TEXT: { bg: "bg-fg-tertiary", icon: "TXT" },
|
TEXT: { bg: 'bg-fg-tertiary', icon: 'TXT' },
|
||||||
LEAD_FORM: { bg: "bg-success-solid", icon: "FORM" },
|
LEAD_FORM: { bg: 'bg-success-solid', icon: 'FORM' },
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatBadgeLabel = (format: AdFormat): string => format.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
const formatBadgeLabel = (format: AdFormat): string =>
|
||||||
|
format.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
||||||
|
|
||||||
export const AdCard = ({ ad }: AdCardProps) => {
|
export const AdCard = ({ ad }: AdCardProps) => {
|
||||||
const format = ad.adFormat ?? "IMAGE";
|
const format = ad.adFormat ?? 'IMAGE';
|
||||||
const preview = formatPreviewStyles[format] ?? formatPreviewStyles.IMAGE;
|
const preview = formatPreviewStyles[format] ?? formatPreviewStyles.IMAGE;
|
||||||
const currencyCode = ad.spend?.currencyCode ?? "INR";
|
const currencyCode = ad.spend?.currencyCode ?? 'INR';
|
||||||
|
|
||||||
const metrics = [
|
const metrics = [
|
||||||
{ label: "Impr.", value: formatCompact(ad.impressions ?? 0) },
|
{ label: 'Impr.', value: formatCompact(ad.impressions ?? 0) },
|
||||||
{ label: "Clicks", value: formatCompact(ad.clicks ?? 0) },
|
{ label: 'Clicks', value: formatCompact(ad.clicks ?? 0) },
|
||||||
{ label: "Leads", value: String(ad.conversions ?? 0) },
|
{ label: 'Leads', value: String(ad.conversions ?? 0) },
|
||||||
{ label: "Spend", value: ad.spend ? formatCurrency(ad.spend.amountMicros, currencyCode) : "--" },
|
{ label: 'Spend', value: ad.spend ? formatCurrency(ad.spend.amountMicros, currencyCode) : '--' },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-4 rounded-xl border border-secondary bg-primary p-4 transition hover:shadow-sm">
|
<div className="flex items-center gap-4 rounded-xl border border-secondary bg-primary p-4 transition hover:shadow-sm">
|
||||||
{/* Preview thumbnail */}
|
{/* Preview thumbnail */}
|
||||||
<div className={cx("flex h-[72px] w-[72px] shrink-0 items-center justify-center rounded-lg text-xs font-bold text-white", preview.bg)}>
|
<div
|
||||||
|
className={cx(
|
||||||
|
'flex h-[72px] w-[72px] shrink-0 items-center justify-center rounded-lg text-xs font-bold text-white',
|
||||||
|
preview.bg,
|
||||||
|
)}
|
||||||
|
>
|
||||||
{preview.icon}
|
{preview.icon}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Ad info */}
|
{/* Ad info */}
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<h4 className="truncate text-sm font-bold text-primary">{ad.adName ?? "Untitled Ad"}</h4>
|
<h4 className="truncate text-sm font-bold text-primary">
|
||||||
<span className="rounded-md bg-secondary px-1.5 py-0.5 text-xs text-tertiary">{formatBadgeLabel(format)}</span>
|
{ad.adName ?? 'Untitled Ad'}
|
||||||
|
</h4>
|
||||||
|
<span className="rounded-md bg-secondary px-1.5 py-0.5 text-xs text-tertiary">
|
||||||
|
{formatBadgeLabel(format)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-0.5 text-xs text-quaternary">{ad.externalAdId ?? ad.id.slice(0, 12)}</p>
|
<p className="mt-0.5 text-xs text-quaternary">{ad.externalAdId ?? ad.id.slice(0, 12)}</p>
|
||||||
{ad.headline && <p className="mt-1 truncate text-xs text-tertiary">{ad.headline}</p>}
|
{ad.headline && (
|
||||||
|
<p className="mt-1 truncate text-xs text-tertiary">{ad.headline}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Inline metrics */}
|
{/* Inline metrics */}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { formatCurrency } from "@/lib/format";
|
import { cx } from '@/utils/cx';
|
||||||
import { cx } from "@/utils/cx";
|
import { formatCurrency } from '@/lib/format';
|
||||||
|
|
||||||
type CurrencyAmount = {
|
type CurrencyAmount = {
|
||||||
amountMicros: number;
|
amountMicros: number;
|
||||||
@@ -18,10 +18,15 @@ export const BudgetBar = ({ spent, budget }: BudgetBarProps) => {
|
|||||||
const ratio = budgetMicros > 0 ? spentMicros / budgetMicros : 0;
|
const ratio = budgetMicros > 0 ? spentMicros / budgetMicros : 0;
|
||||||
const percentage = Math.min(ratio * 100, 100);
|
const percentage = Math.min(ratio * 100, 100);
|
||||||
|
|
||||||
const fillColor = ratio > 0.9 ? "bg-error-solid" : ratio > 0.7 ? "bg-warning-solid" : "bg-brand-solid";
|
const fillColor =
|
||||||
|
ratio > 0.9
|
||||||
|
? 'bg-error-solid'
|
||||||
|
: ratio > 0.7
|
||||||
|
? 'bg-warning-solid'
|
||||||
|
: 'bg-brand-solid';
|
||||||
|
|
||||||
const spentDisplay = spent ? formatCurrency(spent.amountMicros, spent.currencyCode) : "--";
|
const spentDisplay = spent ? formatCurrency(spent.amountMicros, spent.currencyCode) : '--';
|
||||||
const budgetDisplay = budget ? formatCurrency(budget.amountMicros, budget.currencyCode) : "--";
|
const budgetDisplay = budget ? formatCurrency(budget.amountMicros, budget.currencyCode) : '--';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -31,8 +36,11 @@ export const BudgetBar = ({ spent, budget }: BudgetBarProps) => {
|
|||||||
{spentDisplay} / {budgetDisplay}
|
{spentDisplay} / {budgetDisplay}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-1.5 overflow-hidden rounded-full bg-tertiary">
|
<div className="h-1.5 rounded-full bg-tertiary overflow-hidden">
|
||||||
<div className={cx("h-full rounded-full transition-all duration-300", fillColor)} style={{ width: `${percentage}%` }} />
|
<div
|
||||||
|
className={cx('h-full rounded-full transition-all duration-300', fillColor)}
|
||||||
|
style={{ width: `${percentage}%` }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { useState } from "react";
|
import { useState } from 'react';
|
||||||
import { faPenToSquare } from "@fortawesome/pro-duotone-svg-icons";
|
import { faPenToSquare } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
import { SlideoutMenu } from "@/components/application/slideout-menus/slideout-menu";
|
import { faIcon } from '@/lib/icon-wrapper';
|
||||||
import { Button } from "@/components/base/buttons/button";
|
import { SlideoutMenu } from '@/components/application/slideout-menus/slideout-menu';
|
||||||
import { Input } from "@/components/base/input/input";
|
import { Input } from '@/components/base/input/input';
|
||||||
import { Select } from "@/components/base/select/select";
|
import { Select } from '@/components/base/select/select';
|
||||||
import { apiClient } from "@/lib/api-client";
|
import { Button } from '@/components/base/buttons/button';
|
||||||
import { faIcon } from "@/lib/icon-wrapper";
|
import { apiClient } from '@/lib/api-client';
|
||||||
import { notify } from "@/lib/toast";
|
import { notify } from '@/lib/toast';
|
||||||
import type { Campaign, CampaignStatus } from "@/types/entities";
|
import type { Campaign, CampaignStatus } from '@/types/entities';
|
||||||
|
|
||||||
const PenIcon = faIcon(faPenToSquare);
|
const PenIcon = faIcon(faPenToSquare);
|
||||||
|
|
||||||
@@ -19,28 +19,28 @@ type CampaignEditSlideoutProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const statusItems = [
|
const statusItems = [
|
||||||
{ id: "DRAFT" as const, label: "Draft" },
|
{ id: 'DRAFT' as const, label: 'Draft' },
|
||||||
{ id: "ACTIVE" as const, label: "Active" },
|
{ id: 'ACTIVE' as const, label: 'Active' },
|
||||||
{ id: "PAUSED" as const, label: "Paused" },
|
{ id: 'PAUSED' as const, label: 'Paused' },
|
||||||
{ id: "COMPLETED" as const, label: "Completed" },
|
{ id: 'COMPLETED' as const, label: 'Completed' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const formatDateForInput = (dateStr: string | null): string => {
|
const formatDateForInput = (dateStr: string | null): string => {
|
||||||
if (!dateStr) return "";
|
if (!dateStr) return '';
|
||||||
try {
|
try {
|
||||||
return new Date(dateStr).toISOString().slice(0, 10);
|
return new Date(dateStr).toISOString().slice(0, 10);
|
||||||
} catch {
|
} catch {
|
||||||
return "";
|
return '';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const budgetToDisplay = (campaign: Campaign): string => {
|
const budgetToDisplay = (campaign: Campaign): string => {
|
||||||
if (!campaign.budget) return "";
|
if (!campaign.budget) return '';
|
||||||
return String(Math.round(campaign.budget.amountMicros / 1_000_000));
|
return String(Math.round(campaign.budget.amountMicros / 1_000_000));
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CampaignEditSlideout = ({ isOpen, onOpenChange, campaign, onSaved }: CampaignEditSlideoutProps) => {
|
export const CampaignEditSlideout = ({ isOpen, onOpenChange, campaign, onSaved }: CampaignEditSlideoutProps) => {
|
||||||
const [campaignName, setCampaignName] = useState(campaign.campaignName ?? "");
|
const [campaignName, setCampaignName] = useState(campaign.campaignName ?? '');
|
||||||
const [status, setStatus] = useState<CampaignStatus | null>(campaign.campaignStatus);
|
const [status, setStatus] = useState<CampaignStatus | null>(campaign.campaignStatus);
|
||||||
const [budget, setBudget] = useState(budgetToDisplay(campaign));
|
const [budget, setBudget] = useState(budgetToDisplay(campaign));
|
||||||
const [startDate, setStartDate] = useState(formatDateForInput(campaign.startDate));
|
const [startDate, setStartDate] = useState(formatDateForInput(campaign.startDate));
|
||||||
@@ -65,7 +65,7 @@ export const CampaignEditSlideout = ({ isOpen, onOpenChange, campaign, onSaved }
|
|||||||
? {
|
? {
|
||||||
budget: {
|
budget: {
|
||||||
amountMicros: budgetMicros,
|
amountMicros: budgetMicros,
|
||||||
currencyCode: campaign.budget?.currencyCode ?? "INR",
|
currencyCode: campaign.budget?.currencyCode ?? 'INR',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
@@ -75,12 +75,12 @@ export const CampaignEditSlideout = ({ isOpen, onOpenChange, campaign, onSaved }
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
notify.success("Campaign updated", `${campaignName || "Campaign"} has been updated successfully.`);
|
notify.success('Campaign updated', `${campaignName || 'Campaign'} has been updated successfully.`);
|
||||||
onSaved?.();
|
onSaved?.();
|
||||||
close();
|
close();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// apiClient.graphql already toasts on error
|
// apiClient.graphql already toasts on error
|
||||||
console.error("Failed to update campaign:", err);
|
console.error('Failed to update campaign:', err);
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
@@ -104,7 +104,12 @@ export const CampaignEditSlideout = ({ isOpen, onOpenChange, campaign, onSaved }
|
|||||||
|
|
||||||
<SlideoutMenu.Content>
|
<SlideoutMenu.Content>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<Input label="Campaign Name" placeholder="Enter campaign name" value={campaignName} onChange={setCampaignName} />
|
<Input
|
||||||
|
label="Campaign Name"
|
||||||
|
placeholder="Enter campaign name"
|
||||||
|
value={campaignName}
|
||||||
|
onChange={setCampaignName}
|
||||||
|
/>
|
||||||
|
|
||||||
<Select
|
<Select
|
||||||
label="Status"
|
label="Status"
|
||||||
@@ -116,11 +121,27 @@ export const CampaignEditSlideout = ({ isOpen, onOpenChange, campaign, onSaved }
|
|||||||
{(item) => <Select.Item id={item.id} label={item.label} />}
|
{(item) => <Select.Item id={item.id} label={item.label} />}
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<Input label="Budget (INR)" placeholder="e.g. 50000" type="number" value={budget} onChange={setBudget} />
|
<Input
|
||||||
|
label="Budget (INR)"
|
||||||
|
placeholder="e.g. 50000"
|
||||||
|
type="number"
|
||||||
|
value={budget}
|
||||||
|
onChange={setBudget}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<Input label="Start Date" type="date" value={startDate} onChange={setStartDate} />
|
<Input
|
||||||
<Input label="End Date" type="date" value={endDate} onChange={setEndDate} />
|
label="Start Date"
|
||||||
|
type="date"
|
||||||
|
value={startDate}
|
||||||
|
onChange={setStartDate}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="End Date"
|
||||||
|
type="date"
|
||||||
|
value={endDate}
|
||||||
|
onChange={setEndDate}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SlideoutMenu.Content>
|
</SlideoutMenu.Content>
|
||||||
@@ -130,8 +151,14 @@ export const CampaignEditSlideout = ({ isOpen, onOpenChange, campaign, onSaved }
|
|||||||
<Button size="md" color="secondary" onClick={close}>
|
<Button size="md" color="secondary" onClick={close}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="md" color="primary" isLoading={isSaving} showTextWhileLoading onClick={() => handleSave(close)}>
|
<Button
|
||||||
{isSaving ? "Saving..." : "Save Changes"}
|
size="md"
|
||||||
|
color="primary"
|
||||||
|
isLoading={isSaving}
|
||||||
|
showTextWhileLoading
|
||||||
|
onClick={() => handleSave(close)}
|
||||||
|
>
|
||||||
|
{isSaving ? 'Saving...' : 'Save Changes'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</SlideoutMenu.Footer>
|
</SlideoutMenu.Footer>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Campaign, Lead } from "@/types/entities";
|
import { cx } from '@/utils/cx';
|
||||||
import { cx } from "@/utils/cx";
|
import type { Campaign, Lead } from '@/types/entities';
|
||||||
|
|
||||||
interface ConversionFunnelProps {
|
interface ConversionFunnelProps {
|
||||||
campaign: Campaign;
|
campaign: Campaign;
|
||||||
@@ -15,14 +15,14 @@ type FunnelStep = {
|
|||||||
export const ConversionFunnel = ({ campaign, leads }: ConversionFunnelProps) => {
|
export const ConversionFunnel = ({ campaign, leads }: ConversionFunnelProps) => {
|
||||||
const leadCount = campaign.leadCount ?? 0;
|
const leadCount = campaign.leadCount ?? 0;
|
||||||
const contactedCount = campaign.contactedCount ?? 0;
|
const contactedCount = campaign.contactedCount ?? 0;
|
||||||
const appointmentCount = leads.filter((l) => l.leadStatus === "APPOINTMENT_SET").length;
|
const appointmentCount = leads.filter((l) => l.leadStatus === 'APPOINTMENT_SET').length;
|
||||||
const convertedCount = campaign.convertedCount ?? 0;
|
const convertedCount = campaign.convertedCount ?? 0;
|
||||||
|
|
||||||
const steps: FunnelStep[] = [
|
const steps: FunnelStep[] = [
|
||||||
{ label: "Leads", count: leadCount, color: "bg-brand-solid" },
|
{ label: 'Leads', count: leadCount, color: 'bg-brand-solid' },
|
||||||
{ label: "Contacted", count: contactedCount, color: "bg-brand-primary" },
|
{ label: 'Contacted', count: contactedCount, color: 'bg-brand-primary' },
|
||||||
{ label: "Appointment Set", count: appointmentCount, color: "bg-brand-primary_alt" },
|
{ label: 'Appointment Set', count: appointmentCount, color: 'bg-brand-primary_alt' },
|
||||||
{ label: "Converted", count: convertedCount, color: "bg-success-solid" },
|
{ label: 'Converted', count: convertedCount, color: 'bg-success-solid' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const maxCount = Math.max(...steps.map((s) => s.count), 1);
|
const maxCount = Math.max(...steps.map((s) => s.count), 1);
|
||||||
@@ -37,14 +37,16 @@ export const ConversionFunnel = ({ campaign, leads }: ConversionFunnelProps) =>
|
|||||||
<div key={step.label} className="flex items-center gap-3">
|
<div key={step.label} className="flex items-center gap-3">
|
||||||
<span className="w-24 shrink-0 text-xs text-tertiary">{step.label}</span>
|
<span className="w-24 shrink-0 text-xs text-tertiary">{step.label}</span>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="h-5 overflow-hidden rounded bg-secondary">
|
<div className="h-5 rounded bg-secondary overflow-hidden">
|
||||||
<div
|
<div
|
||||||
className={cx("h-full rounded transition-all duration-300", step.color)}
|
className={cx('h-full rounded transition-all duration-300', step.color)}
|
||||||
style={{ width: `${Math.max(widthPercent, 2)}%` }}
|
style={{ width: `${Math.max(widthPercent, 2)}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="w-10 shrink-0 text-right text-xs font-bold text-primary">{step.count}</span>
|
<span className="w-10 shrink-0 text-right text-xs font-bold text-primary">
|
||||||
|
{step.count}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Campaign, HealthStatus, Lead } from "@/types/entities";
|
import { cx } from '@/utils/cx';
|
||||||
import { cx } from "@/utils/cx";
|
import type { Campaign, Lead, HealthStatus } from '@/types/entities';
|
||||||
|
|
||||||
interface HealthIndicatorProps {
|
interface HealthIndicatorProps {
|
||||||
campaign: Campaign;
|
campaign: Campaign;
|
||||||
@@ -7,8 +7,8 @@ interface HealthIndicatorProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const computeHealth = (campaign: Campaign, _leads: Lead[]): { status: HealthStatus; reason: string } => {
|
const computeHealth = (campaign: Campaign, _leads: Lead[]): { status: HealthStatus; reason: string } => {
|
||||||
if (campaign.campaignStatus === "PAUSED") {
|
if (campaign.campaignStatus === 'PAUSED') {
|
||||||
return { status: "UNHEALTHY", reason: "Campaign is paused" };
|
return { status: 'UNHEALTHY', reason: 'Campaign is paused' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const leadCount = campaign.leadCount ?? 0;
|
const leadCount = campaign.leadCount ?? 0;
|
||||||
@@ -16,20 +16,20 @@ const computeHealth = (campaign: Campaign, _leads: Lead[]): { status: HealthStat
|
|||||||
const conversionRate = leadCount > 0 ? (convertedCount / leadCount) * 100 : 0;
|
const conversionRate = leadCount > 0 ? (convertedCount / leadCount) * 100 : 0;
|
||||||
|
|
||||||
if (conversionRate < 5) {
|
if (conversionRate < 5) {
|
||||||
return { status: "UNHEALTHY", reason: `Low conversion rate (${conversionRate.toFixed(1)}%)` };
|
return { status: 'UNHEALTHY', reason: `Low conversion rate (${conversionRate.toFixed(1)}%)` };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (conversionRate < 10) {
|
if (conversionRate < 10) {
|
||||||
return { status: "WARNING", reason: `Moderate conversion rate (${conversionRate.toFixed(1)}%)` };
|
return { status: 'WARNING', reason: `Moderate conversion rate (${conversionRate.toFixed(1)}%)` };
|
||||||
}
|
}
|
||||||
|
|
||||||
return { status: "HEALTHY", reason: `Strong conversion rate (${conversionRate.toFixed(1)}%)` };
|
return { status: 'HEALTHY', reason: `Strong conversion rate (${conversionRate.toFixed(1)}%)` };
|
||||||
};
|
};
|
||||||
|
|
||||||
const statusStyles: Record<HealthStatus, { dot: string; text: string; label: string }> = {
|
const statusStyles: Record<HealthStatus, { dot: string; text: string; label: string }> = {
|
||||||
HEALTHY: { dot: "bg-success-solid", text: "text-success-primary", label: "Healthy" },
|
HEALTHY: { dot: 'bg-success-solid', text: 'text-success-primary', label: 'Healthy' },
|
||||||
WARNING: { dot: "bg-warning-solid", text: "text-warning-primary", label: "Warning" },
|
WARNING: { dot: 'bg-warning-solid', text: 'text-warning-primary', label: 'Warning' },
|
||||||
UNHEALTHY: { dot: "bg-error-solid", text: "text-error-primary", label: "Unhealthy" },
|
UNHEALTHY: { dot: 'bg-error-solid', text: 'text-error-primary', label: 'Unhealthy' },
|
||||||
};
|
};
|
||||||
|
|
||||||
export const HealthIndicator = ({ campaign, leads }: HealthIndicatorProps) => {
|
export const HealthIndicator = ({ campaign, leads }: HealthIndicatorProps) => {
|
||||||
@@ -38,10 +38,10 @@ export const HealthIndicator = ({ campaign, leads }: HealthIndicatorProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className={cx("h-2.5 w-2.5 shrink-0 rounded-full", style.dot)} />
|
<span className={cx('h-2.5 w-2.5 shrink-0 rounded-full', style.dot)} />
|
||||||
<p className="text-xs text-tertiary">
|
<p className="text-xs text-tertiary">
|
||||||
<span className={cx("font-bold", style.text)}>{style.label}</span>
|
<span className={cx('font-bold', style.text)}>{style.label}</span>
|
||||||
{" \u2014 "}
|
{' \u2014 '}
|
||||||
{reason}
|
{reason}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { formatCurrency } from "@/lib/format";
|
import { cx } from '@/utils/cx';
|
||||||
import type { Campaign } from "@/types/entities";
|
import { formatCurrency } from '@/lib/format';
|
||||||
import { cx } from "@/utils/cx";
|
import type { Campaign } from '@/types/entities';
|
||||||
|
|
||||||
interface KpiStripProps {
|
interface KpiStripProps {
|
||||||
campaign: Campaign;
|
campaign: Campaign;
|
||||||
@@ -19,50 +19,50 @@ export const KpiStrip = ({ campaign }: KpiStripProps) => {
|
|||||||
const convertedCount = campaign.convertedCount ?? 0;
|
const convertedCount = campaign.convertedCount ?? 0;
|
||||||
const spentMicros = campaign.amountSpent?.amountMicros ?? 0;
|
const spentMicros = campaign.amountSpent?.amountMicros ?? 0;
|
||||||
const budgetMicros = campaign.budget?.amountMicros ?? 0;
|
const budgetMicros = campaign.budget?.amountMicros ?? 0;
|
||||||
const currencyCode = campaign.amountSpent?.currencyCode ?? "INR";
|
const currencyCode = campaign.amountSpent?.currencyCode ?? 'INR';
|
||||||
|
|
||||||
const contactRate = leadCount > 0 ? ((contactedCount / leadCount) * 100).toFixed(1) : "0.0";
|
const contactRate = leadCount > 0 ? ((contactedCount / leadCount) * 100).toFixed(1) : '0.0';
|
||||||
const conversionRate = leadCount > 0 ? ((convertedCount / leadCount) * 100).toFixed(1) : "0.0";
|
const conversionRate = leadCount > 0 ? ((convertedCount / leadCount) * 100).toFixed(1) : '0.0';
|
||||||
const budgetPercent = budgetMicros > 0 ? ((spentMicros / budgetMicros) * 100).toFixed(0) : "--";
|
const budgetPercent = budgetMicros > 0 ? ((spentMicros / budgetMicros) * 100).toFixed(0) : '--';
|
||||||
const costPerLead = leadCount > 0 ? formatCurrency(spentMicros / leadCount, currencyCode) : "--";
|
const costPerLead = leadCount > 0 ? formatCurrency(spentMicros / leadCount, currencyCode) : '--';
|
||||||
const cac = convertedCount > 0 ? formatCurrency(spentMicros / convertedCount, currencyCode) : "--";
|
const cac = convertedCount > 0 ? formatCurrency(spentMicros / convertedCount, currencyCode) : '--';
|
||||||
|
|
||||||
const items: KpiItem[] = [
|
const items: KpiItem[] = [
|
||||||
{
|
{
|
||||||
label: "Total Leads",
|
label: 'Total Leads',
|
||||||
value: String(leadCount),
|
value: String(leadCount),
|
||||||
subText: `${campaign.impressionCount ?? 0} impressions`,
|
subText: `${campaign.impressionCount ?? 0} impressions`,
|
||||||
subColor: "text-tertiary",
|
subColor: 'text-tertiary',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Contacted",
|
label: 'Contacted',
|
||||||
value: String(contactedCount),
|
value: String(contactedCount),
|
||||||
subText: `${contactRate}% contact rate`,
|
subText: `${contactRate}% contact rate`,
|
||||||
subColor: "text-success-primary",
|
subColor: 'text-success-primary',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Converted",
|
label: 'Converted',
|
||||||
value: String(convertedCount),
|
value: String(convertedCount),
|
||||||
subText: `${conversionRate}% conversion`,
|
subText: `${conversionRate}% conversion`,
|
||||||
subColor: "text-success-primary",
|
subColor: 'text-success-primary',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Spent",
|
label: 'Spent',
|
||||||
value: formatCurrency(spentMicros, currencyCode),
|
value: formatCurrency(spentMicros, currencyCode),
|
||||||
subText: `${budgetPercent}% of budget`,
|
subText: `${budgetPercent}% of budget`,
|
||||||
subColor: Number(budgetPercent) > 90 ? "text-error-primary" : "text-warning-primary",
|
subColor: Number(budgetPercent) > 90 ? 'text-error-primary' : 'text-warning-primary',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Cost / Lead",
|
label: 'Cost / Lead',
|
||||||
value: costPerLead,
|
value: costPerLead,
|
||||||
subText: "avg per lead",
|
subText: 'avg per lead',
|
||||||
subColor: "text-tertiary",
|
subColor: 'text-tertiary',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "CAC",
|
label: 'CAC',
|
||||||
value: cac,
|
value: cac,
|
||||||
subText: "per conversion",
|
subText: 'per conversion',
|
||||||
subColor: "text-tertiary",
|
subColor: 'text-tertiary',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -72,15 +72,15 @@ export const KpiStrip = ({ campaign }: KpiStripProps) => {
|
|||||||
<div
|
<div
|
||||||
key={item.label}
|
key={item.label}
|
||||||
className={cx(
|
className={cx(
|
||||||
"flex flex-1 flex-col justify-center px-4",
|
'flex flex-1 flex-col justify-center px-4',
|
||||||
index === 0 && "pl-0",
|
index === 0 && 'pl-0',
|
||||||
index === items.length - 1 && "pr-0",
|
index === items.length - 1 && 'pr-0',
|
||||||
index < items.length - 1 && "border-r border-tertiary",
|
index < items.length - 1 && 'border-r border-tertiary',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<p className="text-xl font-bold text-primary">{item.value}</p>
|
<p className="text-xl font-bold text-primary">{item.value}</p>
|
||||||
<p className="text-xs font-medium text-quaternary uppercase">{item.label}</p>
|
<p className="text-xs font-medium uppercase text-quaternary">{item.label}</p>
|
||||||
<p className={cx("mt-0.5 text-xs", item.subColor)}>{item.subText}</p>
|
<p className={cx('mt-0.5 text-xs', item.subColor)}>{item.subText}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,28 +1,29 @@
|
|||||||
import type { Lead } from "@/types/entities";
|
import { cx } from '@/utils/cx';
|
||||||
import { cx } from "@/utils/cx";
|
import type { Lead } from '@/types/entities';
|
||||||
|
|
||||||
interface SourceBreakdownProps {
|
interface SourceBreakdownProps {
|
||||||
leads: Lead[];
|
leads: Lead[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const sourceColors: Record<string, string> = {
|
const sourceColors: Record<string, string> = {
|
||||||
FACEBOOK_AD: "bg-brand-solid",
|
FACEBOOK_AD: 'bg-brand-solid',
|
||||||
GOOGLE_AD: "bg-success-solid",
|
GOOGLE_AD: 'bg-success-solid',
|
||||||
INSTAGRAM: "bg-error-solid",
|
INSTAGRAM: 'bg-error-solid',
|
||||||
GOOGLE_MY_BUSINESS: "bg-warning-solid",
|
GOOGLE_MY_BUSINESS: 'bg-warning-solid',
|
||||||
WEBSITE: "bg-fg-brand-primary",
|
WEBSITE: 'bg-fg-brand-primary',
|
||||||
REFERRAL: "bg-fg-tertiary",
|
REFERRAL: 'bg-fg-tertiary',
|
||||||
WHATSAPP: "bg-success-solid",
|
WHATSAPP: 'bg-success-solid',
|
||||||
WALK_IN: "bg-fg-quaternary",
|
WALK_IN: 'bg-fg-quaternary',
|
||||||
PHONE: "bg-fg-secondary",
|
PHONE: 'bg-fg-secondary',
|
||||||
OTHER: "bg-fg-disabled",
|
OTHER: 'bg-fg-disabled',
|
||||||
};
|
};
|
||||||
|
|
||||||
const sourceLabel = (source: string): string => source.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
const sourceLabel = (source: string): string =>
|
||||||
|
source.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
||||||
|
|
||||||
export const SourceBreakdown = ({ leads }: SourceBreakdownProps) => {
|
export const SourceBreakdown = ({ leads }: SourceBreakdownProps) => {
|
||||||
const sourceCounts = leads.reduce<Record<string, number>>((acc, lead) => {
|
const sourceCounts = leads.reduce<Record<string, number>>((acc, lead) => {
|
||||||
const source = lead.leadSource ?? "OTHER";
|
const source = lead.leadSource ?? 'OTHER';
|
||||||
acc[source] = (acc[source] ?? 0) + 1;
|
acc[source] = (acc[source] ?? 0) + 1;
|
||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
@@ -42,16 +43,23 @@ export const SourceBreakdown = ({ leads }: SourceBreakdownProps) => {
|
|||||||
const widthPercent = (count / maxCount) * 100;
|
const widthPercent = (count / maxCount) * 100;
|
||||||
return (
|
return (
|
||||||
<div key={source} className="flex items-center gap-3">
|
<div key={source} className="flex items-center gap-3">
|
||||||
<span className="w-28 shrink-0 truncate text-xs text-tertiary">{sourceLabel(source)}</span>
|
<span className="w-28 shrink-0 truncate text-xs text-tertiary">
|
||||||
|
{sourceLabel(source)}
|
||||||
|
</span>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="h-4 overflow-hidden rounded bg-secondary">
|
<div className="h-4 rounded bg-secondary overflow-hidden">
|
||||||
<div
|
<div
|
||||||
className={cx("h-full rounded transition-all duration-300", sourceColors[source] ?? "bg-fg-disabled")}
|
className={cx(
|
||||||
|
'h-full rounded transition-all duration-300',
|
||||||
|
sourceColors[source] ?? 'bg-fg-disabled',
|
||||||
|
)}
|
||||||
style={{ width: `${Math.max(widthPercent, 4)}%` }}
|
style={{ width: `${Math.max(widthPercent, 4)}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="w-8 shrink-0 text-right text-xs font-bold text-primary">{count}</span>
|
<span className="w-8 shrink-0 text-right text-xs font-bold text-primary">
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from 'react';
|
||||||
import { faUserHeadset } from "@fortawesome/pro-duotone-svg-icons";
|
import { Link } from 'react-router';
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { Link } from "react-router";
|
import { faUserHeadset } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
import { Table, TableCard } from "@/components/application/table/table";
|
import { Avatar } from '@/components/base/avatar/avatar';
|
||||||
import { Avatar } from "@/components/base/avatar/avatar";
|
import { Badge } from '@/components/base/badges/badges';
|
||||||
import { Badge } from "@/components/base/badges/badges";
|
import { Table, TableCard } from '@/components/application/table/table';
|
||||||
import { getInitials } from "@/lib/format";
|
import { getInitials } from '@/lib/format';
|
||||||
import type { Call } from "@/types/entities";
|
import type { Call } from '@/types/entities';
|
||||||
|
|
||||||
const formatDuration = (seconds: number): string => {
|
const formatDuration = (seconds: number): string => {
|
||||||
if (seconds < 60) return `${seconds}s`;
|
if (seconds < 60) return `${seconds}s`;
|
||||||
@@ -16,7 +16,7 @@ const formatDuration = (seconds: number): string => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const formatPercent = (value: number): string => {
|
const formatPercent = (value: number): string => {
|
||||||
if (isNaN(value) || !isFinite(value)) return "0%";
|
if (isNaN(value) || !isFinite(value)) return '0%';
|
||||||
return `${Math.round(value)}%`;
|
return `${Math.round(value)}%`;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -28,44 +28,37 @@ export const AgentTable = ({ calls }: AgentTableProps) => {
|
|||||||
const agents = useMemo(() => {
|
const agents = useMemo(() => {
|
||||||
const agentMap = new Map<string, Call[]>();
|
const agentMap = new Map<string, Call[]>();
|
||||||
for (const call of calls) {
|
for (const call of calls) {
|
||||||
const agent = call.agentName ?? "Unknown";
|
const agent = call.agentName ?? 'Unknown';
|
||||||
if (!agentMap.has(agent)) agentMap.set(agent, []);
|
if (!agentMap.has(agent)) agentMap.set(agent, []);
|
||||||
agentMap.get(agent)!.push(call);
|
agentMap.get(agent)!.push(call);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Array.from(agentMap.entries())
|
return Array.from(agentMap.entries()).map(([name, agentCalls]) => {
|
||||||
.map(([name, agentCalls]) => {
|
const inbound = agentCalls.filter((c) => c.callDirection === 'INBOUND').length;
|
||||||
const inbound = agentCalls.filter((c) => c.callDirection === "INBOUND").length;
|
const outbound = agentCalls.filter((c) => c.callDirection === 'OUTBOUND').length;
|
||||||
const outbound = agentCalls.filter((c) => c.callDirection === "OUTBOUND").length;
|
const missed = agentCalls.filter((c) => c.callStatus === 'MISSED').length;
|
||||||
const missed = agentCalls.filter((c) => c.callStatus === "MISSED").length;
|
const total = agentCalls.length;
|
||||||
const total = agentCalls.length;
|
const completedCalls = agentCalls.filter((c) => (c.durationSeconds ?? 0) > 0);
|
||||||
const completedCalls = agentCalls.filter((c) => (c.durationSeconds ?? 0) > 0);
|
const totalDuration = completedCalls.reduce((sum, c) => sum + (c.durationSeconds ?? 0), 0);
|
||||||
const totalDuration = completedCalls.reduce((sum, c) => sum + (c.durationSeconds ?? 0), 0);
|
const avgHandle = completedCalls.length > 0 ? Math.round(totalDuration / completedCalls.length) : 0;
|
||||||
const avgHandle = completedCalls.length > 0 ? Math.round(totalDuration / completedCalls.length) : 0;
|
const booked = agentCalls.filter((c) => c.disposition === 'APPOINTMENT_BOOKED').length;
|
||||||
const booked = agentCalls.filter((c) => c.disposition === "APPOINTMENT_BOOKED").length;
|
const conversion = total > 0 ? (booked / total) * 100 : 0;
|
||||||
const conversion = total > 0 ? (booked / total) * 100 : 0;
|
const nameParts = name.split(' ');
|
||||||
const nameParts = name.split(" ");
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: name,
|
id: name,
|
||||||
name,
|
name,
|
||||||
initials: getInitials(nameParts[0] ?? "", nameParts[1] ?? ""),
|
initials: getInitials(nameParts[0] ?? '', nameParts[1] ?? ''),
|
||||||
inbound,
|
inbound, outbound, missed, total, avgHandle, conversion,
|
||||||
outbound,
|
};
|
||||||
missed,
|
}).sort((a, b) => b.total - a.total);
|
||||||
total,
|
|
||||||
avgHandle,
|
|
||||||
conversion,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.sort((a, b) => b.total - a.total);
|
|
||||||
}, [calls]);
|
}, [calls]);
|
||||||
|
|
||||||
if (agents.length === 0) {
|
if (agents.length === 0) {
|
||||||
return (
|
return (
|
||||||
<TableCard.Root size="sm">
|
<TableCard.Root size="sm">
|
||||||
<TableCard.Header title="Agent Performance" description="Call metrics by agent" />
|
<TableCard.Header title="Agent Performance" description="Call metrics by agent" />
|
||||||
<div className="flex flex-col items-center justify-center gap-2 py-12">
|
<div className="flex flex-col items-center justify-center py-12 gap-2">
|
||||||
<FontAwesomeIcon icon={faUserHeadset} className="size-8 text-fg-quaternary" />
|
<FontAwesomeIcon icon={faUserHeadset} className="size-8 text-fg-quaternary" />
|
||||||
<p className="text-sm text-tertiary">No agent data available</p>
|
<p className="text-sm text-tertiary">No agent data available</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -92,32 +85,18 @@ export const AgentTable = ({ calls }: AgentTableProps) => {
|
|||||||
<Link to={`/agent/${encodeURIComponent(agent.name)}`} className="no-underline">
|
<Link to={`/agent/${encodeURIComponent(agent.name)}`} className="no-underline">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Avatar size="xs" initials={agent.initials} />
|
<Avatar size="xs" initials={agent.initials} />
|
||||||
<span className="text-sm font-medium text-brand-secondary transition duration-100 ease-linear hover:text-brand-secondary_hover">
|
<span className="text-sm font-medium text-brand-secondary hover:text-brand-secondary_hover transition duration-100 ease-linear">{agent.name}</span>
|
||||||
{agent.name}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
|
<Table.Cell><span className="text-sm text-success-primary">{agent.inbound}</span></Table.Cell>
|
||||||
|
<Table.Cell><span className="text-sm text-brand-secondary">{agent.outbound}</span></Table.Cell>
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
<span className="text-sm text-success-primary">{agent.inbound}</span>
|
{agent.missed > 0 ? <Badge size="sm" color="error">{agent.missed}</Badge> : <span className="text-sm text-tertiary">0</span>}
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
|
<Table.Cell><span className="text-sm text-secondary">{formatDuration(agent.avgHandle)}</span></Table.Cell>
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
<span className="text-sm text-brand-secondary">{agent.outbound}</span>
|
<Badge size="sm" color={agent.conversion >= 30 ? 'success' : agent.conversion >= 15 ? 'warning' : 'gray'}>
|
||||||
</Table.Cell>
|
|
||||||
<Table.Cell>
|
|
||||||
{agent.missed > 0 ? (
|
|
||||||
<Badge size="sm" color="error">
|
|
||||||
{agent.missed}
|
|
||||||
</Badge>
|
|
||||||
) : (
|
|
||||||
<span className="text-sm text-tertiary">0</span>
|
|
||||||
)}
|
|
||||||
</Table.Cell>
|
|
||||||
<Table.Cell>
|
|
||||||
<span className="text-sm text-secondary">{formatDuration(agent.avgHandle)}</span>
|
|
||||||
</Table.Cell>
|
|
||||||
<Table.Cell>
|
|
||||||
<Badge size="sm" color={agent.conversion >= 30 ? "success" : agent.conversion >= 15 ? "warning" : "gray"}>
|
|
||||||
{formatPercent(agent.conversion)}
|
{formatPercent(agent.conversion)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import type { IconDefinition } from "@fortawesome/fontawesome-svg-core";
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faCircleInfo, faPhone, faPhoneArrowDownLeft, faPhoneArrowUpRight, faPhoneMissed } from "@fortawesome/pro-duotone-svg-icons";
|
import {
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
faPhone,
|
||||||
import type { Call, Lead } from "@/types/entities";
|
faPhoneArrowDownLeft,
|
||||||
|
faPhoneArrowUpRight,
|
||||||
|
faPhoneMissed,
|
||||||
|
} from '@fortawesome/pro-duotone-svg-icons';
|
||||||
|
import type { IconDefinition } from '@fortawesome/fontawesome-svg-core';
|
||||||
|
import { faCircleInfo } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
|
import type { Call, Lead } from '@/types/entities';
|
||||||
|
|
||||||
type KpiCardProps = {
|
type KpiCardProps = {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -18,7 +24,7 @@ const KpiCard = ({ label, value, icon, iconColor, iconBg, subtitle, tooltip }: K
|
|||||||
<div className={`flex size-10 shrink-0 items-center justify-center rounded-full ${iconBg}`}>
|
<div className={`flex size-10 shrink-0 items-center justify-center rounded-full ${iconBg}`}>
|
||||||
<FontAwesomeIcon icon={icon} className={`size-4 ${iconColor}`} />
|
<FontAwesomeIcon icon={icon} className={`size-4 ${iconColor}`} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-1 flex-col">
|
<div className="flex flex-col flex-1">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<span className="text-xs font-medium text-tertiary">{label}</span>
|
<span className="text-xs font-medium text-tertiary">{label}</span>
|
||||||
{tooltip && <FontAwesomeIcon icon={faCircleInfo} className="size-3 text-fg-quaternary" title={tooltip} />}
|
{tooltip && <FontAwesomeIcon icon={faCircleInfo} className="size-3 text-fg-quaternary" title={tooltip} />}
|
||||||
@@ -48,12 +54,12 @@ const MetricCard = ({ label, value, description, tooltip }: MetricCardProps) =>
|
|||||||
);
|
);
|
||||||
|
|
||||||
const formatPercent = (value: number): string => {
|
const formatPercent = (value: number): string => {
|
||||||
if (isNaN(value) || !isFinite(value)) return "0%";
|
if (isNaN(value) || !isFinite(value)) return '0%';
|
||||||
return `${Math.round(value)}%`;
|
return `${Math.round(value)}%`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatMinutes = (minutes: number | null): string => {
|
const formatMinutes = (minutes: number | null): string => {
|
||||||
if (minutes === null) return "—";
|
if (minutes === null) return '—';
|
||||||
if (minutes < 60) return `${minutes}m`;
|
if (minutes < 60) return `${minutes}m`;
|
||||||
const hours = Math.floor(minutes / 60);
|
const hours = Math.floor(minutes / 60);
|
||||||
const mins = minutes % 60;
|
const mins = minutes % 60;
|
||||||
@@ -67,96 +73,46 @@ interface DashboardKpiProps {
|
|||||||
|
|
||||||
export const DashboardKpi = ({ calls, leads }: DashboardKpiProps) => {
|
export const DashboardKpi = ({ calls, leads }: DashboardKpiProps) => {
|
||||||
const totalCalls = calls.length;
|
const totalCalls = calls.length;
|
||||||
const inboundCalls = calls.filter((c) => c.callDirection === "INBOUND").length;
|
const inboundCalls = calls.filter((c) => c.callDirection === 'INBOUND').length;
|
||||||
const outboundCalls = calls.filter((c) => c.callDirection === "OUTBOUND").length;
|
const outboundCalls = calls.filter((c) => c.callDirection === 'OUTBOUND').length;
|
||||||
const missedCalls = calls.filter((c) => c.callStatus === "MISSED").length;
|
const missedCalls = calls.filter((c) => c.callStatus === 'MISSED').length;
|
||||||
|
|
||||||
const leadsWithResponse = leads.filter((l) => l.createdAt && l.firstContactedAt);
|
const leadsWithResponse = leads.filter((l) => l.createdAt && l.firstContactedAt);
|
||||||
const avgResponseTime =
|
const avgResponseTime = leadsWithResponse.length > 0
|
||||||
leadsWithResponse.length > 0
|
? Math.round(leadsWithResponse.reduce((sum, l) => {
|
||||||
? Math.round(
|
const diff = Math.abs(new Date(l.firstContactedAt!).getTime() - new Date(l.createdAt!).getTime()) / 60000;
|
||||||
leadsWithResponse.reduce((sum, l) => {
|
return sum + diff;
|
||||||
const diff = Math.abs(new Date(l.firstContactedAt!).getTime() - new Date(l.createdAt!).getTime()) / 60000;
|
}, 0) / leadsWithResponse.length)
|
||||||
return sum + diff;
|
: null;
|
||||||
}, 0) / leadsWithResponse.length,
|
|
||||||
)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const missedCallsList = calls.filter((c) => c.callStatus === "MISSED" && c.startedAt);
|
const missedCallsList = calls.filter((c) => c.callStatus === 'MISSED' && c.startedAt);
|
||||||
// eslint-disable-next-line react-hooks/purity
|
const missedCallbackTime = missedCallsList.length > 0
|
||||||
const renderTime = Date.now();
|
? Math.round(missedCallsList.reduce((sum, c) => sum + (Date.now() - new Date(c.startedAt!).getTime()) / 60000, 0) / missedCallsList.length)
|
||||||
const missedCallbackTime =
|
: null;
|
||||||
missedCallsList.length > 0
|
|
||||||
? Math.round(missedCallsList.reduce((sum, c) => sum + (renderTime - new Date(c.startedAt!).getTime()) / 60000, 0) / missedCallsList.length)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const callToAppt = totalCalls > 0 ? (calls.filter((c) => c.disposition === "APPOINTMENT_BOOKED").length / totalCalls) * 100 : 0;
|
const callToAppt = totalCalls > 0
|
||||||
|
? (calls.filter((c) => c.disposition === 'APPOINTMENT_BOOKED').length / totalCalls) * 100
|
||||||
|
: 0;
|
||||||
|
|
||||||
const leadToAppt =
|
const leadToAppt = leads.length > 0
|
||||||
leads.length > 0 ? (leads.filter((l) => l.leadStatus === "APPOINTMENT_SET" || l.leadStatus === "CONVERTED").length / leads.length) * 100 : 0;
|
? (leads.filter((l) => l.leadStatus === 'APPOINTMENT_SET' || l.leadStatus === 'CONVERTED').length / leads.length) * 100
|
||||||
|
: 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="grid grid-cols-2 gap-3 xl:grid-cols-4">
|
<div className="grid grid-cols-2 gap-3 xl:grid-cols-4">
|
||||||
<KpiCard
|
<KpiCard label="Total Calls" value={totalCalls} icon={faPhone} iconColor="text-fg-brand-primary" iconBg="bg-brand-secondary" tooltip="Total inbound + outbound calls in the selected period" />
|
||||||
label="Total Calls"
|
<KpiCard label="Inbound" value={inboundCalls} icon={faPhoneArrowDownLeft} iconColor="text-fg-success-primary" iconBg="bg-success-secondary" tooltip="Calls received from patients/leads" />
|
||||||
value={totalCalls}
|
<KpiCard label="Outbound" value={outboundCalls} icon={faPhoneArrowUpRight} iconColor="text-fg-brand-primary" iconBg="bg-brand-secondary" tooltip="Calls made by agents to patients/leads" />
|
||||||
icon={faPhone}
|
<KpiCard label="Missed" value={missedCalls} icon={faPhoneMissed} iconColor="text-fg-error-primary" iconBg="bg-error-secondary"
|
||||||
iconColor="text-fg-brand-primary"
|
|
||||||
iconBg="bg-brand-secondary"
|
|
||||||
tooltip="Total inbound + outbound calls in the selected period"
|
|
||||||
/>
|
|
||||||
<KpiCard
|
|
||||||
label="Inbound"
|
|
||||||
value={inboundCalls}
|
|
||||||
icon={faPhoneArrowDownLeft}
|
|
||||||
iconColor="text-fg-success-primary"
|
|
||||||
iconBg="bg-success-secondary"
|
|
||||||
tooltip="Calls received from patients/leads"
|
|
||||||
/>
|
|
||||||
<KpiCard
|
|
||||||
label="Outbound"
|
|
||||||
value={outboundCalls}
|
|
||||||
icon={faPhoneArrowUpRight}
|
|
||||||
iconColor="text-fg-brand-primary"
|
|
||||||
iconBg="bg-brand-secondary"
|
|
||||||
tooltip="Calls made by agents to patients/leads"
|
|
||||||
/>
|
|
||||||
<KpiCard
|
|
||||||
label="Missed"
|
|
||||||
value={missedCalls}
|
|
||||||
icon={faPhoneMissed}
|
|
||||||
iconColor="text-fg-error-primary"
|
|
||||||
iconBg="bg-error-secondary"
|
|
||||||
subtitle={totalCalls > 0 ? `${formatPercent((missedCalls / totalCalls) * 100)} of total` : undefined}
|
subtitle={totalCalls > 0 ? `${formatPercent((missedCalls / totalCalls) * 100)} of total` : undefined}
|
||||||
tooltip="Inbound calls that were not answered by any agent"
|
tooltip="Inbound calls that were not answered by any agent" />
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-3 xl:grid-cols-4">
|
<div className="grid grid-cols-2 gap-3 xl:grid-cols-4">
|
||||||
<MetricCard
|
<MetricCard label="Avg Response" value={formatMinutes(avgResponseTime)} description="Lead creation to first contact" tooltip="Average time between a lead being created and an agent making first contact" />
|
||||||
label="Avg Response"
|
<MetricCard label="Missed Callback" value={formatMinutes(missedCallbackTime)} description="Avg wait for missed callbacks" tooltip="Average time missed calls have been waiting for a callback" />
|
||||||
value={formatMinutes(avgResponseTime)}
|
<MetricCard label="Call → Appt" value={formatPercent(callToAppt)} description="Calls resulting in bookings" tooltip="Percentage of calls where the outcome was an appointment booking" />
|
||||||
description="Lead creation to first contact"
|
<MetricCard label="Lead → Appt" value={formatPercent(leadToAppt)} description="Leads converted to appointments" tooltip="Percentage of leads that reached APPOINTMENT_SET or CONVERTED status" />
|
||||||
tooltip="Average time between a lead being created and an agent making first contact"
|
|
||||||
/>
|
|
||||||
<MetricCard
|
|
||||||
label="Missed Callback"
|
|
||||||
value={formatMinutes(missedCallbackTime)}
|
|
||||||
description="Avg wait for missed callbacks"
|
|
||||||
tooltip="Average time missed calls have been waiting for a callback"
|
|
||||||
/>
|
|
||||||
<MetricCard
|
|
||||||
label="Call → Appt"
|
|
||||||
value={formatPercent(callToAppt)}
|
|
||||||
description="Calls resulting in bookings"
|
|
||||||
tooltip="Percentage of calls where the outcome was an appointment booking"
|
|
||||||
/>
|
|
||||||
<MetricCard
|
|
||||||
label="Lead → Appt"
|
|
||||||
value={formatPercent(leadToAppt)}
|
|
||||||
description="Leads converted to appointments"
|
|
||||||
tooltip="Percentage of leads that reached APPOINTMENT_SET or CONVERTED status"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from 'react';
|
||||||
import { faPhoneMissed } from "@fortawesome/pro-duotone-svg-icons";
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { faPhoneMissed } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
import { Badge } from "@/components/base/badges/badges";
|
import { Badge } from '@/components/base/badges/badges';
|
||||||
import { ClickToCallButton } from "@/components/call-desk/click-to-call-button";
|
import { ClickToCallButton } from '@/components/call-desk/click-to-call-button';
|
||||||
import type { Call } from "@/types/entities";
|
import type { Call } from '@/types/entities';
|
||||||
|
|
||||||
const getTimeSince = (dateStr: string | null): string => {
|
const getTimeSince = (dateStr: string | null): string => {
|
||||||
if (!dateStr) return "—";
|
if (!dateStr) return '—';
|
||||||
const mins = Math.floor((Date.now() - new Date(dateStr).getTime()) / 60000);
|
const mins = Math.floor((Date.now() - new Date(dateStr).getTime()) / 60000);
|
||||||
if (mins < 1) return "Just now";
|
if (mins < 1) return 'Just now';
|
||||||
if (mins < 60) return `${mins}m ago`;
|
if (mins < 60) return `${mins}m ago`;
|
||||||
const hours = Math.floor(mins / 60);
|
const hours = Math.floor(mins / 60);
|
||||||
if (hours < 24) return `${hours}h ago`;
|
if (hours < 24) return `${hours}h ago`;
|
||||||
@@ -22,7 +22,7 @@ interface MissedQueueProps {
|
|||||||
export const MissedQueue = ({ calls }: MissedQueueProps) => {
|
export const MissedQueue = ({ calls }: MissedQueueProps) => {
|
||||||
const missedCalls = useMemo(() => {
|
const missedCalls = useMemo(() => {
|
||||||
return calls
|
return calls
|
||||||
.filter((c) => c.callStatus === "MISSED")
|
.filter((c) => c.callStatus === 'MISSED')
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
const dateA = a.startedAt ? new Date(a.startedAt).getTime() : 0;
|
const dateA = a.startedAt ? new Date(a.startedAt).getTime() : 0;
|
||||||
const dateB = b.startedAt ? new Date(b.startedAt).getTime() : 0;
|
const dateB = b.startedAt ? new Date(b.startedAt).getTime() : 0;
|
||||||
@@ -39,27 +39,22 @@ export const MissedQueue = ({ calls }: MissedQueueProps) => {
|
|||||||
<h3 className="text-sm font-semibold text-primary">Missed Call Queue</h3>
|
<h3 className="text-sm font-semibold text-primary">Missed Call Queue</h3>
|
||||||
</div>
|
</div>
|
||||||
{missedCalls.length > 0 && (
|
{missedCalls.length > 0 && (
|
||||||
<Badge size="sm" color="error">
|
<Badge size="sm" color="error">{missedCalls.length}</Badge>
|
||||||
{missedCalls.length}
|
|
||||||
</Badge>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="max-h-[500px] overflow-y-auto">
|
<div className="max-h-[500px] overflow-y-auto">
|
||||||
{missedCalls.length === 0 ? (
|
{missedCalls.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center gap-2 py-10">
|
<div className="flex flex-col items-center justify-center py-10 gap-2">
|
||||||
<FontAwesomeIcon icon={faPhoneMissed} className="size-6 text-fg-quaternary" />
|
<FontAwesomeIcon icon={faPhoneMissed} className="size-6 text-fg-quaternary" />
|
||||||
<p className="text-sm text-tertiary">No missed calls</p>
|
<p className="text-sm text-tertiary">No missed calls</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ul className="divide-y divide-secondary">
|
<ul className="divide-y divide-secondary">
|
||||||
{missedCalls.map((call) => {
|
{missedCalls.map((call) => {
|
||||||
const phone = call.callerNumber?.[0]?.number ?? "";
|
const phone = call.callerNumber?.[0]?.number ?? '';
|
||||||
const display = phone ? `+91 ${phone}` : "Unknown";
|
const display = phone ? `+91 ${phone}` : 'Unknown';
|
||||||
return (
|
return (
|
||||||
<li
|
<li key={call.id} className="flex items-center justify-between px-4 py-2.5 hover:bg-primary_hover transition duration-100 ease-linear">
|
||||||
key={call.id}
|
|
||||||
className="flex items-center justify-between px-4 py-2.5 transition duration-100 ease-linear hover:bg-primary_hover"
|
|
||||||
>
|
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="text-sm font-medium text-primary">{display}</span>
|
<span className="text-sm font-medium text-primary">{display}</span>
|
||||||
<span className="text-xs text-tertiary">{getTimeSince(call.startedAt)}</span>
|
<span className="text-xs text-tertiary">{getTimeSince(call.startedAt)}</span>
|
||||||
|
|||||||
136
src/components/forms/ai-form.tsx
Normal file
136
src/components/forms/ai-form.tsx
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import { Input } from '@/components/base/input/input';
|
||||||
|
import { Select } from '@/components/base/select/select';
|
||||||
|
import { TextArea } from '@/components/base/textarea/textarea';
|
||||||
|
|
||||||
|
// AI assistant form — mirrors AiConfig in
|
||||||
|
// helix-engage-server/src/config/ai.defaults.ts. API keys stay in env vars
|
||||||
|
// (true secrets, rotated at the infra level); everything the admin can safely
|
||||||
|
// adjust lives here: provider choice, model, temperature, and an optional
|
||||||
|
// system-prompt addendum appended to the hospital-specific prompts that the
|
||||||
|
// WidgetChatService generates.
|
||||||
|
|
||||||
|
export type AiProvider = 'openai' | 'anthropic';
|
||||||
|
|
||||||
|
export type AiFormValues = {
|
||||||
|
provider: AiProvider;
|
||||||
|
model: string;
|
||||||
|
temperature: string;
|
||||||
|
systemPromptAddendum: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const emptyAiFormValues = (): AiFormValues => ({
|
||||||
|
provider: 'openai',
|
||||||
|
model: 'gpt-4o-mini',
|
||||||
|
temperature: '0.7',
|
||||||
|
systemPromptAddendum: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const PROVIDER_ITEMS = [
|
||||||
|
{ id: 'openai', label: 'OpenAI' },
|
||||||
|
{ id: 'anthropic', label: 'Anthropic' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Recommended model presets per provider. Admin can still type any model
|
||||||
|
// string they want — these are suggestions, not the only options.
|
||||||
|
export const MODEL_SUGGESTIONS: Record<AiProvider, string[]> = {
|
||||||
|
openai: ['gpt-4o-mini', 'gpt-4o', 'gpt-4-turbo', 'gpt-3.5-turbo'],
|
||||||
|
anthropic: ['claude-3-5-sonnet-latest', 'claude-3-5-haiku-latest', 'claude-3-opus-latest'],
|
||||||
|
};
|
||||||
|
|
||||||
|
type AiFormProps = {
|
||||||
|
value: AiFormValues;
|
||||||
|
onChange: (value: AiFormValues) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AiForm = ({ value, onChange }: AiFormProps) => {
|
||||||
|
const patch = (updates: Partial<AiFormValues>) => onChange({ ...value, ...updates });
|
||||||
|
|
||||||
|
const suggestions = MODEL_SUGGESTIONS[value.provider];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<section className="flex flex-col gap-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-primary">Provider & model</h3>
|
||||||
|
<p className="mt-1 text-xs text-tertiary">
|
||||||
|
Choose the AI vendor powering the website widget chat and call-summary features.
|
||||||
|
Changing providers takes effect immediately.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
label="Provider"
|
||||||
|
placeholder="Select provider"
|
||||||
|
items={PROVIDER_ITEMS}
|
||||||
|
selectedKey={value.provider}
|
||||||
|
onSelectionChange={(key) => {
|
||||||
|
const next = key as AiProvider;
|
||||||
|
// When switching providers, also reset the model to the first
|
||||||
|
// suggested model for that provider — saves the admin a second
|
||||||
|
// edit step and avoids leaving an OpenAI model selected while
|
||||||
|
// provider=anthropic.
|
||||||
|
patch({
|
||||||
|
provider: next,
|
||||||
|
model: MODEL_SUGGESTIONS[next][0],
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(item) => <Select.Item id={item.id} label={item.label} />}
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Input
|
||||||
|
label="Model"
|
||||||
|
placeholder="Model identifier"
|
||||||
|
value={value.model}
|
||||||
|
onChange={(v) => patch({ model: v })}
|
||||||
|
/>
|
||||||
|
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||||
|
{suggestions.map((model) => (
|
||||||
|
<button
|
||||||
|
key={model}
|
||||||
|
type="button"
|
||||||
|
onClick={() => patch({ model })}
|
||||||
|
className={`rounded-md border px-2 py-1 text-xs transition duration-100 ease-linear ${
|
||||||
|
value.model === model
|
||||||
|
? 'border-brand bg-brand-secondary text-brand-secondary'
|
||||||
|
: 'border-secondary bg-primary text-tertiary hover:bg-secondary'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{model}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Temperature"
|
||||||
|
type="number"
|
||||||
|
placeholder="0.7"
|
||||||
|
hint="0 = deterministic, 1 = balanced, 2 = very creative"
|
||||||
|
value={value.temperature}
|
||||||
|
onChange={(v) => patch({ temperature: v })}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-primary">System prompt addendum</h3>
|
||||||
|
<p className="mt-1 text-xs text-tertiary">
|
||||||
|
Optional — gets appended to the hospital-specific prompts the widget generates
|
||||||
|
automatically from your doctors and clinics. Use this to add tone guidelines,
|
||||||
|
escalation rules, or topics the assistant should avoid. Leave blank for the default
|
||||||
|
behaviour.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<TextArea
|
||||||
|
label="Additional instructions"
|
||||||
|
placeholder="e.g. Always respond in the patient's language. Never quote specific medication dosages; refer them to a doctor for prescriptions."
|
||||||
|
value={value.systemPromptAddendum}
|
||||||
|
onChange={(v) => patch({ systemPromptAddendum: v })}
|
||||||
|
rows={6}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
514
src/components/forms/clinic-form.tsx
Normal file
514
src/components/forms/clinic-form.tsx
Normal file
@@ -0,0 +1,514 @@
|
|||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faPlus, faTrash } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
|
import { parseDate, getLocalTimeZone, today } from '@internationalized/date';
|
||||||
|
import type { DateValue } from 'react-aria-components';
|
||||||
|
import { Input } from '@/components/base/input/input';
|
||||||
|
import { Select } from '@/components/base/select/select';
|
||||||
|
import { Toggle } from '@/components/base/toggle/toggle';
|
||||||
|
import { Button } from '@/components/base/buttons/button';
|
||||||
|
import { DatePicker } from '@/components/application/date-picker/date-picker';
|
||||||
|
import { TimePicker } from '@/components/application/date-picker/time-picker';
|
||||||
|
import {
|
||||||
|
DaySelector,
|
||||||
|
defaultDaySelection,
|
||||||
|
type DaySelection,
|
||||||
|
} from '@/components/application/day-selector/day-selector';
|
||||||
|
|
||||||
|
// Reusable clinic form used by /settings/clinics slideout and the /setup
|
||||||
|
// wizard step. The parent owns form state + the save flow so it can decide
|
||||||
|
// how to orchestrate the multi-step create chain (one createClinic, then one
|
||||||
|
// createHoliday per holiday, then one createClinicRequiredDocument per doc).
|
||||||
|
//
|
||||||
|
// Schema (matches the Clinic entity in
|
||||||
|
// FortyTwoApps/helix-engage/src/objects/clinic.object.ts, column names
|
||||||
|
// derived from SDK labels — that's why opensAt/closesAt and not openTime/
|
||||||
|
// closeTime):
|
||||||
|
// - clinicName (TEXT)
|
||||||
|
// - address (ADDRESS → addressCustomAddress*)
|
||||||
|
// - phone (PHONES)
|
||||||
|
// - email (EMAILS)
|
||||||
|
// - openMonday..openSunday (7 BOOLEANs)
|
||||||
|
// - opensAt / closesAt (TEXT, HH:MM)
|
||||||
|
// - status (SELECT enum)
|
||||||
|
// - walkInAllowed / onlineBooking (BOOLEAN)
|
||||||
|
// - cancellationWindowHours / arriveEarlyMin (NUMBER)
|
||||||
|
//
|
||||||
|
// Plus two child entities populated separately:
|
||||||
|
// - Holiday (one record per closure date)
|
||||||
|
// - ClinicRequiredDocument (one record per required doc type)
|
||||||
|
|
||||||
|
export type ClinicStatus = 'ACTIVE' | 'TEMPORARILY_CLOSED' | 'PERMANENTLY_CLOSED';
|
||||||
|
|
||||||
|
// Matches the SELECT enum on ClinicRequiredDocument. Keep in sync with
|
||||||
|
// FortyTwoApps/helix-engage/src/objects/clinic-required-document.object.ts.
|
||||||
|
export type DocumentType =
|
||||||
|
| 'ID_PROOF'
|
||||||
|
| 'AADHAAR'
|
||||||
|
| 'PAN'
|
||||||
|
| 'REFERRAL_LETTER'
|
||||||
|
| 'PRESCRIPTION'
|
||||||
|
| 'INSURANCE_CARD'
|
||||||
|
| 'PREVIOUS_REPORTS'
|
||||||
|
| 'PHOTO'
|
||||||
|
| 'OTHER';
|
||||||
|
|
||||||
|
const DOCUMENT_TYPE_LABELS: Record<DocumentType, string> = {
|
||||||
|
ID_PROOF: 'Government ID',
|
||||||
|
AADHAAR: 'Aadhaar Card',
|
||||||
|
PAN: 'PAN Card',
|
||||||
|
REFERRAL_LETTER: 'Referral Letter',
|
||||||
|
PRESCRIPTION: 'Prescription',
|
||||||
|
INSURANCE_CARD: 'Insurance Card',
|
||||||
|
PREVIOUS_REPORTS: 'Previous Reports',
|
||||||
|
PHOTO: 'Passport Photo',
|
||||||
|
OTHER: 'Other',
|
||||||
|
};
|
||||||
|
|
||||||
|
const DOCUMENT_TYPE_ORDER: DocumentType[] = [
|
||||||
|
'ID_PROOF',
|
||||||
|
'AADHAAR',
|
||||||
|
'PAN',
|
||||||
|
'REFERRAL_LETTER',
|
||||||
|
'PRESCRIPTION',
|
||||||
|
'INSURANCE_CARD',
|
||||||
|
'PREVIOUS_REPORTS',
|
||||||
|
'PHOTO',
|
||||||
|
'OTHER',
|
||||||
|
];
|
||||||
|
|
||||||
|
export type ClinicHolidayEntry = {
|
||||||
|
// Populated on the existing record when editing; undefined for freshly
|
||||||
|
// added holidays the user hasn't saved yet. Used by the parent to
|
||||||
|
// decide create vs update vs delete on save.
|
||||||
|
id?: string;
|
||||||
|
date: string; // ISO yyyy-MM-dd
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ClinicFormValues = {
|
||||||
|
// Core clinic fields
|
||||||
|
clinicName: string;
|
||||||
|
addressStreet1: string;
|
||||||
|
addressStreet2: string;
|
||||||
|
addressCity: string;
|
||||||
|
addressState: string;
|
||||||
|
addressPostcode: string;
|
||||||
|
phone: string;
|
||||||
|
email: string;
|
||||||
|
// Schedule — simple pattern
|
||||||
|
openDays: DaySelection;
|
||||||
|
opensAt: string | null;
|
||||||
|
closesAt: string | null;
|
||||||
|
// Status + booking policy
|
||||||
|
status: ClinicStatus;
|
||||||
|
walkInAllowed: boolean;
|
||||||
|
onlineBooking: boolean;
|
||||||
|
cancellationWindowHours: string;
|
||||||
|
arriveEarlyMin: string;
|
||||||
|
// Children (persisted via separate mutations)
|
||||||
|
requiredDocumentTypes: DocumentType[];
|
||||||
|
holidays: ClinicHolidayEntry[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const emptyClinicFormValues = (): ClinicFormValues => ({
|
||||||
|
clinicName: '',
|
||||||
|
addressStreet1: '',
|
||||||
|
addressStreet2: '',
|
||||||
|
addressCity: '',
|
||||||
|
addressState: '',
|
||||||
|
addressPostcode: '',
|
||||||
|
phone: '',
|
||||||
|
email: '',
|
||||||
|
openDays: defaultDaySelection(),
|
||||||
|
opensAt: '09:00',
|
||||||
|
closesAt: '18:00',
|
||||||
|
status: 'ACTIVE',
|
||||||
|
walkInAllowed: true,
|
||||||
|
onlineBooking: true,
|
||||||
|
cancellationWindowHours: '24',
|
||||||
|
arriveEarlyMin: '15',
|
||||||
|
requiredDocumentTypes: [],
|
||||||
|
holidays: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const STATUS_ITEMS = [
|
||||||
|
{ id: 'ACTIVE', label: 'Active' },
|
||||||
|
{ id: 'TEMPORARILY_CLOSED', label: 'Temporarily closed' },
|
||||||
|
{ id: 'PERMANENTLY_CLOSED', label: 'Permanently closed' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Build the payload for `createClinic` / `updateClinic`. Holidays and
|
||||||
|
// required-documents are NOT included here — they're child records with
|
||||||
|
// their own mutations, orchestrated by the parent component after the
|
||||||
|
// clinic itself has been created and its id is known.
|
||||||
|
export const clinicCoreToGraphQLInput = (v: ClinicFormValues): Record<string, unknown> => {
|
||||||
|
const input: Record<string, unknown> = {
|
||||||
|
clinicName: v.clinicName.trim(),
|
||||||
|
status: v.status,
|
||||||
|
walkInAllowed: v.walkInAllowed,
|
||||||
|
onlineBooking: v.onlineBooking,
|
||||||
|
openMonday: v.openDays.monday,
|
||||||
|
openTuesday: v.openDays.tuesday,
|
||||||
|
openWednesday: v.openDays.wednesday,
|
||||||
|
openThursday: v.openDays.thursday,
|
||||||
|
openFriday: v.openDays.friday,
|
||||||
|
openSaturday: v.openDays.saturday,
|
||||||
|
openSunday: v.openDays.sunday,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Column names on the platform come from the SDK `label`, not
|
||||||
|
// `name`. "Opens At" → opensAt, "Closes At" → closesAt.
|
||||||
|
if (v.opensAt) input.opensAt = v.opensAt;
|
||||||
|
if (v.closesAt) input.closesAt = v.closesAt;
|
||||||
|
|
||||||
|
const hasAddress =
|
||||||
|
v.addressStreet1 || v.addressCity || v.addressState || v.addressPostcode;
|
||||||
|
if (hasAddress) {
|
||||||
|
input.addressCustom = {
|
||||||
|
addressStreet1: v.addressStreet1 || null,
|
||||||
|
addressStreet2: v.addressStreet2 || null,
|
||||||
|
addressCity: v.addressCity || null,
|
||||||
|
addressState: v.addressState || null,
|
||||||
|
addressPostcode: v.addressPostcode || null,
|
||||||
|
addressCountry: 'India',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (v.phone.trim()) {
|
||||||
|
input.phone = {
|
||||||
|
primaryPhoneNumber: v.phone.trim(),
|
||||||
|
primaryPhoneCountryCode: 'IN',
|
||||||
|
primaryPhoneCallingCode: '+91',
|
||||||
|
additionalPhones: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (v.email.trim()) {
|
||||||
|
input.email = {
|
||||||
|
primaryEmail: v.email.trim(),
|
||||||
|
additionalEmails: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (v.cancellationWindowHours.trim()) {
|
||||||
|
const n = Number(v.cancellationWindowHours);
|
||||||
|
if (!Number.isNaN(n)) input.cancellationWindowHours = n;
|
||||||
|
}
|
||||||
|
if (v.arriveEarlyMin.trim()) {
|
||||||
|
const n = Number(v.arriveEarlyMin);
|
||||||
|
if (!Number.isNaN(n)) input.arriveEarlyMin = n;
|
||||||
|
}
|
||||||
|
|
||||||
|
return input;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper: build HolidayCreateInput payloads. Use after the clinic has
|
||||||
|
// been created and its id is known.
|
||||||
|
export const holidayInputsFromForm = (
|
||||||
|
v: ClinicFormValues,
|
||||||
|
clinicId: string,
|
||||||
|
): Array<Record<string, unknown>> =>
|
||||||
|
v.holidays.map((h) => ({
|
||||||
|
date: h.date,
|
||||||
|
reasonLabel: h.label.trim() || null, // column name matches the SDK label "Reason / Label"
|
||||||
|
clinicId,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Helper: build ClinicRequiredDocumentCreateInput payloads. One per
|
||||||
|
// selected document type.
|
||||||
|
export const requiredDocInputsFromForm = (
|
||||||
|
v: ClinicFormValues,
|
||||||
|
clinicId: string,
|
||||||
|
): Array<Record<string, unknown>> =>
|
||||||
|
v.requiredDocumentTypes.map((t) => ({
|
||||||
|
documentType: t,
|
||||||
|
clinicId,
|
||||||
|
}));
|
||||||
|
|
||||||
|
type ClinicFormProps = {
|
||||||
|
value: ClinicFormValues;
|
||||||
|
onChange: (value: ClinicFormValues) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ClinicForm = ({ value, onChange }: ClinicFormProps) => {
|
||||||
|
const patch = (updates: Partial<ClinicFormValues>) => onChange({ ...value, ...updates });
|
||||||
|
|
||||||
|
// Required-docs add/remove handlers. The user picks a type from the
|
||||||
|
// dropdown; it gets added to the list; the pill row below shows
|
||||||
|
// selected types with an X to remove. Dropdown filters out
|
||||||
|
// already-selected types so the user can't pick duplicates.
|
||||||
|
const availableDocTypes = DOCUMENT_TYPE_ORDER.filter(
|
||||||
|
(t) => !value.requiredDocumentTypes.includes(t),
|
||||||
|
).map((t) => ({ id: t, label: DOCUMENT_TYPE_LABELS[t] }));
|
||||||
|
|
||||||
|
const addDocType = (type: DocumentType) => {
|
||||||
|
if (value.requiredDocumentTypes.includes(type)) return;
|
||||||
|
patch({ requiredDocumentTypes: [...value.requiredDocumentTypes, type] });
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeDocType = (type: DocumentType) => {
|
||||||
|
patch({
|
||||||
|
requiredDocumentTypes: value.requiredDocumentTypes.filter((t) => t !== type),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Holiday add/remove handlers. Freshly-added entries have no `id`
|
||||||
|
// field; the parent's save flow treats those as "create".
|
||||||
|
const addHoliday = () => {
|
||||||
|
const todayIso = today(getLocalTimeZone()).toString();
|
||||||
|
patch({ holidays: [...value.holidays, { date: todayIso, label: '' }] });
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateHoliday = (index: number, updates: Partial<ClinicHolidayEntry>) => {
|
||||||
|
const next = [...value.holidays];
|
||||||
|
next[index] = { ...next[index], ...updates };
|
||||||
|
patch({ holidays: next });
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeHoliday = (index: number) => {
|
||||||
|
patch({ holidays: value.holidays.filter((_, i) => i !== index) });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<Input
|
||||||
|
label="Clinic name"
|
||||||
|
isRequired
|
||||||
|
placeholder="e.g. Main Hospital Campus"
|
||||||
|
value={value.clinicName}
|
||||||
|
onChange={(v) => patch({ clinicName: v })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
label="Status"
|
||||||
|
placeholder="Select status"
|
||||||
|
items={STATUS_ITEMS}
|
||||||
|
selectedKey={value.status}
|
||||||
|
onSelectionChange={(key) => patch({ status: key as ClinicStatus })}
|
||||||
|
>
|
||||||
|
{(item) => <Select.Item id={item.id} label={item.label} />}
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{/* Address */}
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wider text-quaternary">
|
||||||
|
Address
|
||||||
|
</p>
|
||||||
|
<Input
|
||||||
|
label="Street address"
|
||||||
|
placeholder="Street / building / landmark"
|
||||||
|
value={value.addressStreet1}
|
||||||
|
onChange={(v) => patch({ addressStreet1: v })}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Area / locality (optional)"
|
||||||
|
placeholder="Area, neighbourhood"
|
||||||
|
value={value.addressStreet2}
|
||||||
|
onChange={(v) => patch({ addressStreet2: v })}
|
||||||
|
/>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Input
|
||||||
|
label="City"
|
||||||
|
placeholder="Bengaluru"
|
||||||
|
value={value.addressCity}
|
||||||
|
onChange={(v) => patch({ addressCity: v })}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="State"
|
||||||
|
placeholder="Karnataka"
|
||||||
|
value={value.addressState}
|
||||||
|
onChange={(v) => patch({ addressState: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
label="Postcode"
|
||||||
|
placeholder="560034"
|
||||||
|
value={value.addressPostcode}
|
||||||
|
onChange={(v) => patch({ addressPostcode: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Contact */}
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wider text-quaternary">
|
||||||
|
Contact
|
||||||
|
</p>
|
||||||
|
<Input
|
||||||
|
label="Phone"
|
||||||
|
type="tel"
|
||||||
|
placeholder="9876543210"
|
||||||
|
value={value.phone}
|
||||||
|
onChange={(v) => patch({ phone: v })}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Email"
|
||||||
|
type="email"
|
||||||
|
placeholder="branch@hospital.com"
|
||||||
|
value={value.email}
|
||||||
|
onChange={(v) => patch({ email: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Visiting hours — day pills + single time range */}
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wider text-quaternary">
|
||||||
|
Visiting hours
|
||||||
|
</p>
|
||||||
|
<DaySelector
|
||||||
|
label="Open days"
|
||||||
|
hint="Pick the days this clinic is open. The time range below applies to every selected day."
|
||||||
|
value={value.openDays}
|
||||||
|
onChange={(openDays) => patch({ openDays })}
|
||||||
|
/>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<TimePicker
|
||||||
|
label="Opens at"
|
||||||
|
value={value.opensAt}
|
||||||
|
onChange={(opensAt) => patch({ opensAt })}
|
||||||
|
/>
|
||||||
|
<TimePicker
|
||||||
|
label="Closes at"
|
||||||
|
value={value.closesAt}
|
||||||
|
onChange={(closesAt) => patch({ closesAt })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Holiday closures */}
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wider text-quaternary">
|
||||||
|
Holiday closures (optional)
|
||||||
|
</p>
|
||||||
|
{value.holidays.length === 0 && (
|
||||||
|
<p className="text-xs text-tertiary">
|
||||||
|
No holidays configured. Add dates when this clinic is closed (Diwali,
|
||||||
|
Republic Day, maintenance days, etc.).
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{value.holidays.map((h, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="flex items-end gap-2 rounded-lg border border-secondary bg-secondary p-3"
|
||||||
|
>
|
||||||
|
<div className="shrink-0">
|
||||||
|
<span className="mb-1 block text-xs font-medium text-secondary">
|
||||||
|
Date
|
||||||
|
</span>
|
||||||
|
<DatePicker
|
||||||
|
value={h.date ? parseDate(h.date) : null}
|
||||||
|
onChange={(dv: DateValue | null) =>
|
||||||
|
updateHoliday(idx, { date: dv ? dv.toString() : '' })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<Input
|
||||||
|
label="Reason"
|
||||||
|
placeholder="e.g. Diwali"
|
||||||
|
value={h.label}
|
||||||
|
onChange={(label) => updateHoliday(idx, { label })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
color="tertiary-destructive"
|
||||||
|
iconLeading={({ className }: { className?: string }) => (
|
||||||
|
<FontAwesomeIcon icon={faTrash} className={className} />
|
||||||
|
)}
|
||||||
|
onClick={() => removeHoliday(idx)}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
color="secondary"
|
||||||
|
iconLeading={({ className }: { className?: string }) => (
|
||||||
|
<FontAwesomeIcon icon={faPlus} className={className} />
|
||||||
|
)}
|
||||||
|
onClick={addHoliday}
|
||||||
|
className="self-start"
|
||||||
|
>
|
||||||
|
Add holiday
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Booking policy */}
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wider text-quaternary">
|
||||||
|
Booking policy
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col gap-3 rounded-lg border border-secondary bg-secondary p-4">
|
||||||
|
<Toggle
|
||||||
|
label="Walk-ins allowed"
|
||||||
|
isSelected={value.walkInAllowed}
|
||||||
|
onChange={(checked) => patch({ walkInAllowed: checked })}
|
||||||
|
/>
|
||||||
|
<Toggle
|
||||||
|
label="Accept online bookings"
|
||||||
|
isSelected={value.onlineBooking}
|
||||||
|
onChange={(checked) => patch({ onlineBooking: checked })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Input
|
||||||
|
label="Cancel window (hours)"
|
||||||
|
type="number"
|
||||||
|
value={value.cancellationWindowHours}
|
||||||
|
onChange={(v) => patch({ cancellationWindowHours: v })}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Arrive early (min)"
|
||||||
|
type="number"
|
||||||
|
value={value.arriveEarlyMin}
|
||||||
|
onChange={(v) => patch({ arriveEarlyMin: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Required documents — multi-select → pills */}
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wider text-quaternary">
|
||||||
|
Required documents (optional)
|
||||||
|
</p>
|
||||||
|
{availableDocTypes.length > 0 && (
|
||||||
|
<Select
|
||||||
|
label="Add a required document"
|
||||||
|
placeholder="Pick a document type..."
|
||||||
|
items={availableDocTypes}
|
||||||
|
selectedKey={null}
|
||||||
|
onSelectionChange={(key) => {
|
||||||
|
if (key) addDocType(key as DocumentType);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(item) => <Select.Item id={item.id} label={item.label} />}
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
{value.requiredDocumentTypes.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{value.requiredDocumentTypes.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeDocType(t)}
|
||||||
|
className="group flex items-center gap-2 rounded-full border border-brand bg-brand-secondary px-3 py-1.5 text-sm font-medium text-brand-secondary transition hover:bg-brand-primary_hover"
|
||||||
|
>
|
||||||
|
{DOCUMENT_TYPE_LABELS[t]}
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={faTrash}
|
||||||
|
className="size-3 text-fg-quaternary group-hover:text-fg-error-primary"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{value.requiredDocumentTypes.length === 0 && (
|
||||||
|
<p className="text-xs text-tertiary">
|
||||||
|
No required documents. Patients won't be asked to bring anything.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
401
src/components/forms/doctor-form.tsx
Normal file
401
src/components/forms/doctor-form.tsx
Normal file
@@ -0,0 +1,401 @@
|
|||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faPlus, faTrash } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
|
import { Input } from '@/components/base/input/input';
|
||||||
|
import { Select } from '@/components/base/select/select';
|
||||||
|
import { Toggle } from '@/components/base/toggle/toggle';
|
||||||
|
import { Button } from '@/components/base/buttons/button';
|
||||||
|
import { TimePicker } from '@/components/application/date-picker/time-picker';
|
||||||
|
|
||||||
|
// Doctor form — hospital-wide profile with multi-clinic, multi-day
|
||||||
|
// visiting schedule. Each row in the "visiting schedule" section maps
|
||||||
|
// to one DoctorVisitSlot child record. The parent component owns the
|
||||||
|
// mutation orchestration (create doctor, then create each slot).
|
||||||
|
//
|
||||||
|
// Previously the form had a single `clinicId` dropdown + a free-text
|
||||||
|
// `visitingHours` textarea. Both dropped — doctors are now hospital-
|
||||||
|
// wide, and their presence at each clinic is expressed via the
|
||||||
|
// DoctorVisitSlot records.
|
||||||
|
|
||||||
|
export type DoctorDepartment =
|
||||||
|
| 'CARDIOLOGY'
|
||||||
|
| 'GYNECOLOGY'
|
||||||
|
| 'ORTHOPEDICS'
|
||||||
|
| 'GENERAL_MEDICINE'
|
||||||
|
| 'ENT'
|
||||||
|
| 'DERMATOLOGY'
|
||||||
|
| 'PEDIATRICS'
|
||||||
|
| 'ONCOLOGY';
|
||||||
|
|
||||||
|
// Matches the DoctorVisitSlot.dayOfWeek SELECT enum on the SDK entity.
|
||||||
|
export type DayOfWeek =
|
||||||
|
| 'MONDAY'
|
||||||
|
| 'TUESDAY'
|
||||||
|
| 'WEDNESDAY'
|
||||||
|
| 'THURSDAY'
|
||||||
|
| 'FRIDAY'
|
||||||
|
| 'SATURDAY'
|
||||||
|
| 'SUNDAY';
|
||||||
|
|
||||||
|
export type DoctorVisitSlotEntry = {
|
||||||
|
// Populated on existing records when editing; undefined for
|
||||||
|
// freshly-added rows. Used by the parent to decide create vs
|
||||||
|
// update vs delete on save.
|
||||||
|
id?: string;
|
||||||
|
clinicId: string;
|
||||||
|
dayOfWeek: DayOfWeek | '';
|
||||||
|
startTime: string | null;
|
||||||
|
endTime: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DoctorFormValues = {
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
department: DoctorDepartment | '';
|
||||||
|
specialty: string;
|
||||||
|
qualifications: string;
|
||||||
|
yearsOfExperience: string;
|
||||||
|
consultationFeeNew: string;
|
||||||
|
consultationFeeFollowUp: string;
|
||||||
|
phone: string;
|
||||||
|
email: string;
|
||||||
|
registrationNumber: string;
|
||||||
|
active: boolean;
|
||||||
|
// Multi-clinic, multi-day visiting schedule. One entry per slot.
|
||||||
|
visitSlots: DoctorVisitSlotEntry[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const emptyDoctorFormValues = (): DoctorFormValues => ({
|
||||||
|
firstName: '',
|
||||||
|
lastName: '',
|
||||||
|
department: '',
|
||||||
|
specialty: '',
|
||||||
|
qualifications: '',
|
||||||
|
yearsOfExperience: '',
|
||||||
|
consultationFeeNew: '',
|
||||||
|
consultationFeeFollowUp: '',
|
||||||
|
phone: '',
|
||||||
|
email: '',
|
||||||
|
registrationNumber: '',
|
||||||
|
active: true,
|
||||||
|
visitSlots: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const DEPARTMENT_ITEMS: { id: DoctorDepartment; label: string }[] = [
|
||||||
|
{ id: 'CARDIOLOGY', label: 'Cardiology' },
|
||||||
|
{ id: 'GYNECOLOGY', label: 'Gynecology' },
|
||||||
|
{ id: 'ORTHOPEDICS', label: 'Orthopedics' },
|
||||||
|
{ id: 'GENERAL_MEDICINE', label: 'General medicine' },
|
||||||
|
{ id: 'ENT', label: 'ENT' },
|
||||||
|
{ id: 'DERMATOLOGY', label: 'Dermatology' },
|
||||||
|
{ id: 'PEDIATRICS', label: 'Pediatrics' },
|
||||||
|
{ id: 'ONCOLOGY', label: 'Oncology' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const DAY_ITEMS: { id: DayOfWeek; label: string }[] = [
|
||||||
|
{ id: 'MONDAY', label: 'Monday' },
|
||||||
|
{ id: 'TUESDAY', label: 'Tuesday' },
|
||||||
|
{ id: 'WEDNESDAY', label: 'Wednesday' },
|
||||||
|
{ id: 'THURSDAY', label: 'Thursday' },
|
||||||
|
{ id: 'FRIDAY', label: 'Friday' },
|
||||||
|
{ id: 'SATURDAY', label: 'Saturday' },
|
||||||
|
{ id: 'SUNDAY', label: 'Sunday' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Build the createDoctor / updateDoctor mutation payload. Visit slots
|
||||||
|
// are persisted via a separate mutation chain — see the parent
|
||||||
|
// component's handleSave.
|
||||||
|
export const doctorCoreToGraphQLInput = (v: DoctorFormValues): Record<string, unknown> => {
|
||||||
|
const input: Record<string, unknown> = {
|
||||||
|
fullName: {
|
||||||
|
firstName: v.firstName.trim(),
|
||||||
|
lastName: v.lastName.trim(),
|
||||||
|
},
|
||||||
|
active: v.active,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (v.department) input.department = v.department;
|
||||||
|
if (v.specialty.trim()) input.specialty = v.specialty.trim();
|
||||||
|
if (v.qualifications.trim()) input.qualifications = v.qualifications.trim();
|
||||||
|
if (v.yearsOfExperience.trim()) {
|
||||||
|
const n = Number(v.yearsOfExperience);
|
||||||
|
if (!Number.isNaN(n)) input.yearsOfExperience = n;
|
||||||
|
}
|
||||||
|
if (v.consultationFeeNew.trim()) {
|
||||||
|
const n = Number(v.consultationFeeNew);
|
||||||
|
if (!Number.isNaN(n)) {
|
||||||
|
input.consultationFeeNew = {
|
||||||
|
amountMicros: Math.round(n * 1_000_000),
|
||||||
|
currencyCode: 'INR',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (v.consultationFeeFollowUp.trim()) {
|
||||||
|
const n = Number(v.consultationFeeFollowUp);
|
||||||
|
if (!Number.isNaN(n)) {
|
||||||
|
input.consultationFeeFollowUp = {
|
||||||
|
amountMicros: Math.round(n * 1_000_000),
|
||||||
|
currencyCode: 'INR',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (v.phone.trim()) {
|
||||||
|
input.phone = {
|
||||||
|
primaryPhoneNumber: v.phone.trim(),
|
||||||
|
primaryPhoneCountryCode: 'IN',
|
||||||
|
primaryPhoneCallingCode: '+91',
|
||||||
|
additionalPhones: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (v.email.trim()) {
|
||||||
|
input.email = {
|
||||||
|
primaryEmail: v.email.trim(),
|
||||||
|
additionalEmails: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (v.registrationNumber.trim()) input.registrationNumber = v.registrationNumber.trim();
|
||||||
|
|
||||||
|
return input;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build one DoctorVisitSlotCreateInput per complete slot. Drops any
|
||||||
|
// half-filled rows silently — the form can't validate mid-entry
|
||||||
|
// without blocking the user.
|
||||||
|
export const visitSlotInputsFromForm = (
|
||||||
|
v: DoctorFormValues,
|
||||||
|
doctorId: string,
|
||||||
|
): Array<Record<string, unknown>> =>
|
||||||
|
v.visitSlots
|
||||||
|
.filter((s) => s.clinicId && s.dayOfWeek && s.startTime && s.endTime)
|
||||||
|
.map((s) => ({
|
||||||
|
doctorId,
|
||||||
|
clinicId: s.clinicId,
|
||||||
|
dayOfWeek: s.dayOfWeek,
|
||||||
|
startTime: s.startTime,
|
||||||
|
endTime: s.endTime,
|
||||||
|
}));
|
||||||
|
|
||||||
|
type ClinicOption = { id: string; label: string };
|
||||||
|
|
||||||
|
type DoctorFormProps = {
|
||||||
|
value: DoctorFormValues;
|
||||||
|
onChange: (value: DoctorFormValues) => void;
|
||||||
|
clinics: ClinicOption[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DoctorForm = ({ value, onChange, clinics }: DoctorFormProps) => {
|
||||||
|
const patch = (updates: Partial<DoctorFormValues>) => onChange({ ...value, ...updates });
|
||||||
|
|
||||||
|
// Visit-slot handlers — add/edit/remove inline inside the form.
|
||||||
|
const addSlot = () => {
|
||||||
|
patch({
|
||||||
|
visitSlots: [
|
||||||
|
...value.visitSlots,
|
||||||
|
{ clinicId: clinics[0]?.id ?? '', dayOfWeek: '', startTime: '09:00', endTime: '13:00' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateSlot = (index: number, updates: Partial<DoctorVisitSlotEntry>) => {
|
||||||
|
const next = [...value.visitSlots];
|
||||||
|
next[index] = { ...next[index], ...updates };
|
||||||
|
patch({ visitSlots: next });
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeSlot = (index: number) => {
|
||||||
|
patch({ visitSlots: value.visitSlots.filter((_, i) => i !== index) });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Input
|
||||||
|
label="First name"
|
||||||
|
isRequired
|
||||||
|
placeholder="Ananya"
|
||||||
|
value={value.firstName}
|
||||||
|
onChange={(v) => patch({ firstName: v })}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Last name"
|
||||||
|
isRequired
|
||||||
|
placeholder="Rao"
|
||||||
|
value={value.lastName}
|
||||||
|
onChange={(v) => patch({ lastName: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
label="Department"
|
||||||
|
placeholder="Select department"
|
||||||
|
items={DEPARTMENT_ITEMS}
|
||||||
|
selectedKey={value.department || null}
|
||||||
|
onSelectionChange={(key) => patch({ department: (key as DoctorDepartment) || '' })}
|
||||||
|
>
|
||||||
|
{(item) => <Select.Item id={item.id} label={item.label} />}
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Specialty"
|
||||||
|
placeholder="e.g. Interventional cardiology"
|
||||||
|
value={value.specialty}
|
||||||
|
onChange={(v) => patch({ specialty: v })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Input
|
||||||
|
label="Qualifications"
|
||||||
|
placeholder="MBBS, MD"
|
||||||
|
value={value.qualifications}
|
||||||
|
onChange={(v) => patch({ qualifications: v })}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Experience (years)"
|
||||||
|
type="number"
|
||||||
|
value={value.yearsOfExperience}
|
||||||
|
onChange={(v) => patch({ yearsOfExperience: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Visiting schedule — one row per clinic/day slot */}
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wider text-quaternary">
|
||||||
|
Visiting schedule
|
||||||
|
</p>
|
||||||
|
{clinics.length === 0 ? (
|
||||||
|
<div className="rounded-lg border border-dashed border-warning bg-warning-primary p-4">
|
||||||
|
<p className="text-sm font-semibold text-warning-primary">Add a clinic first</p>
|
||||||
|
<p className="mt-1 text-xs text-tertiary">
|
||||||
|
You need at least one clinic before you can schedule doctor visits.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{value.visitSlots.length === 0 && (
|
||||||
|
<p className="text-xs text-tertiary">
|
||||||
|
No visit slots. Add rows for each clinic + day this doctor visits.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{value.visitSlots.map((slot, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="flex flex-col gap-3 rounded-lg border border-secondary bg-secondary p-3"
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Select
|
||||||
|
label="Clinic"
|
||||||
|
placeholder="Select clinic"
|
||||||
|
items={clinics}
|
||||||
|
selectedKey={slot.clinicId || null}
|
||||||
|
onSelectionChange={(key) =>
|
||||||
|
updateSlot(idx, { clinicId: (key as string) || '' })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(item) => <Select.Item id={item.id} label={item.label} />}
|
||||||
|
</Select>
|
||||||
|
<Select
|
||||||
|
label="Day"
|
||||||
|
placeholder="Select day"
|
||||||
|
items={DAY_ITEMS}
|
||||||
|
selectedKey={slot.dayOfWeek || null}
|
||||||
|
onSelectionChange={(key) =>
|
||||||
|
updateSlot(idx, { dayOfWeek: (key as DayOfWeek) || '' })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(item) => <Select.Item id={item.id} label={item.label} />}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<TimePicker
|
||||||
|
label="Start time"
|
||||||
|
value={slot.startTime}
|
||||||
|
onChange={(startTime) => updateSlot(idx, { startTime })}
|
||||||
|
/>
|
||||||
|
<TimePicker
|
||||||
|
label="End time"
|
||||||
|
value={slot.endTime}
|
||||||
|
onChange={(endTime) => updateSlot(idx, { endTime })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
color="tertiary-destructive"
|
||||||
|
iconLeading={({ className }: { className?: string }) => (
|
||||||
|
<FontAwesomeIcon icon={faTrash} className={className} />
|
||||||
|
)}
|
||||||
|
onClick={() => removeSlot(idx)}
|
||||||
|
>
|
||||||
|
Remove slot
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
color="secondary"
|
||||||
|
iconLeading={({ className }: { className?: string }) => (
|
||||||
|
<FontAwesomeIcon icon={faPlus} className={className} />
|
||||||
|
)}
|
||||||
|
onClick={addSlot}
|
||||||
|
className="self-start"
|
||||||
|
>
|
||||||
|
Add visit slot
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Input
|
||||||
|
label="New consult fee (₹)"
|
||||||
|
type="number"
|
||||||
|
placeholder="800"
|
||||||
|
value={value.consultationFeeNew}
|
||||||
|
onChange={(v) => patch({ consultationFeeNew: v })}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Follow-up fee (₹)"
|
||||||
|
type="number"
|
||||||
|
placeholder="500"
|
||||||
|
value={value.consultationFeeFollowUp}
|
||||||
|
onChange={(v) => patch({ consultationFeeFollowUp: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wider text-quaternary">Contact</p>
|
||||||
|
<Input
|
||||||
|
label="Phone"
|
||||||
|
type="tel"
|
||||||
|
placeholder="9876543210"
|
||||||
|
value={value.phone}
|
||||||
|
onChange={(v) => patch({ phone: v })}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Email"
|
||||||
|
type="email"
|
||||||
|
placeholder="doctor@hospital.com"
|
||||||
|
value={value.email}
|
||||||
|
onChange={(v) => patch({ email: v })}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Registration number"
|
||||||
|
placeholder="Medical council reg no."
|
||||||
|
value={value.registrationNumber}
|
||||||
|
onChange={(v) => patch({ registrationNumber: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2 rounded-lg border border-secondary bg-secondary p-4">
|
||||||
|
<Toggle
|
||||||
|
label="Accepting appointments"
|
||||||
|
isSelected={value.active}
|
||||||
|
onChange={(checked) => patch({ active: checked })}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-tertiary">
|
||||||
|
Inactive doctors are hidden from appointment booking and call-desk transfer lists.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
205
src/components/forms/employee-create-form.tsx
Normal file
205
src/components/forms/employee-create-form.tsx
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faEye, faEyeSlash, faRotate } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
|
import { Input } from '@/components/base/input/input';
|
||||||
|
import { Select } from '@/components/base/select/select';
|
||||||
|
|
||||||
|
// In-place employee creation form used by the Team wizard step and
|
||||||
|
// the /settings/team slideout. Replaces the multi-email InviteMemberForm
|
||||||
|
// — this project never uses email invitations, all employees are
|
||||||
|
// created directly with a temp password that the admin hands out.
|
||||||
|
//
|
||||||
|
// Two modes:
|
||||||
|
//
|
||||||
|
// - 'create': all fields editable. The temp password is auto-generated
|
||||||
|
// on form mount (parent does this) and revealed via an eye icon. A
|
||||||
|
// refresh icon next to the eye lets the admin re-roll the password
|
||||||
|
// before saving.
|
||||||
|
//
|
||||||
|
// - 'edit': email is read-only (it's the login id, can't change),
|
||||||
|
// password field is hidden entirely (no reset-password from the
|
||||||
|
// wizard). Only firstName/lastName/role can change.
|
||||||
|
//
|
||||||
|
// SIP seat assignment is intentionally NOT in this form — it lives
|
||||||
|
// exclusively in the Telephony wizard step, so there's a single source
|
||||||
|
// of truth for "who is on which seat" and admins don't have to remember
|
||||||
|
// two places to manage the same thing.
|
||||||
|
|
||||||
|
export type RoleOption = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
supportingText?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EmployeeCreateFormValues = {
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
roleId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const emptyEmployeeCreateFormValues: EmployeeCreateFormValues = {
|
||||||
|
firstName: '',
|
||||||
|
lastName: '',
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
roleId: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Random temp password generator. Skips visually-ambiguous chars
|
||||||
|
// (0/O/1/l/I) so admins can read the password back over a phone call
|
||||||
|
// without typo risk. 11 alphanumerics + 1 symbol = 12 chars total.
|
||||||
|
export const generateTempPassword = (): string => {
|
||||||
|
const chars = 'abcdefghjkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||||
|
const symbols = '!@#$';
|
||||||
|
let pwd = '';
|
||||||
|
for (let i = 0; i < 11; i++) {
|
||||||
|
pwd += chars[Math.floor(Math.random() * chars.length)];
|
||||||
|
}
|
||||||
|
pwd += symbols[Math.floor(Math.random() * symbols.length)];
|
||||||
|
return pwd;
|
||||||
|
};
|
||||||
|
|
||||||
|
type EmployeeCreateFormProps = {
|
||||||
|
value: EmployeeCreateFormValues;
|
||||||
|
onChange: (value: EmployeeCreateFormValues) => void;
|
||||||
|
roles: RoleOption[];
|
||||||
|
// 'create' = full form, 'edit' = name + role only.
|
||||||
|
mode?: 'create' | 'edit';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Eye / eye-slash button rendered inside the password field's
|
||||||
|
// trailing slot. Stays internal to this form since password reveal
|
||||||
|
// is the only place we need it right now.
|
||||||
|
const EyeButton = ({ visible, onClick, title }: { visible: boolean; onClick: () => void; title: string }) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
title={title}
|
||||||
|
aria-label={title}
|
||||||
|
className="flex size-7 items-center justify-center rounded-md text-fg-quaternary hover:bg-secondary_hover hover:text-fg-tertiary_hover"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={visible ? faEyeSlash : faEye} className="size-4" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
const RegenerateButton = ({ onClick }: { onClick: () => void }) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
title="Generate a new password"
|
||||||
|
aria-label="Generate a new password"
|
||||||
|
className="flex size-7 items-center justify-center rounded-md text-fg-quaternary hover:bg-secondary_hover hover:text-fg-tertiary_hover"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faRotate} className="size-4" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Kept simple — name + contact + creds + role. No avatar, no phone,
|
||||||
|
// no title. The goal is to get employees onto the system fast; they
|
||||||
|
// can fill in the rest from their own profile page later.
|
||||||
|
export const EmployeeCreateForm = ({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
roles,
|
||||||
|
mode = 'create',
|
||||||
|
}: EmployeeCreateFormProps) => {
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
|
||||||
|
const patch = (partial: Partial<EmployeeCreateFormValues>) =>
|
||||||
|
onChange({ ...value, ...partial });
|
||||||
|
|
||||||
|
const isEdit = mode === 'edit';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Input
|
||||||
|
label="First name"
|
||||||
|
placeholder="Priya"
|
||||||
|
value={value.firstName}
|
||||||
|
onChange={(v) => patch({ firstName: v })}
|
||||||
|
isRequired
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Last name"
|
||||||
|
placeholder="Sharma"
|
||||||
|
value={value.lastName}
|
||||||
|
onChange={(v) => patch({ lastName: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Email"
|
||||||
|
type="email"
|
||||||
|
placeholder="priya@hospital.com"
|
||||||
|
value={value.email}
|
||||||
|
onChange={(v) => patch({ email: v })}
|
||||||
|
isRequired={!isEdit}
|
||||||
|
isReadOnly={isEdit}
|
||||||
|
isDisabled={isEdit}
|
||||||
|
hint={
|
||||||
|
isEdit
|
||||||
|
? 'Email is the login id and cannot be changed.'
|
||||||
|
: 'This is the login id for the employee. Cannot be changed later.'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!isEdit && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-secondary">
|
||||||
|
Temporary password <span className="text-error-primary">*</span>
|
||||||
|
</label>
|
||||||
|
<div className="mt-1.5 flex items-center gap-2 rounded-lg border border-secondary bg-primary px-3 shadow-xs focus-within:border-brand focus-within:ring-2 focus-within:ring-brand-100">
|
||||||
|
<input
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
value={value.password}
|
||||||
|
onChange={(e) => patch({ password: e.target.value })}
|
||||||
|
placeholder="Auto-generated"
|
||||||
|
className="flex-1 bg-transparent py-2 font-mono text-sm text-primary placeholder:text-placeholder outline-none"
|
||||||
|
/>
|
||||||
|
<EyeButton
|
||||||
|
visible={showPassword}
|
||||||
|
onClick={() => setShowPassword((v) => !v)}
|
||||||
|
title={showPassword ? 'Hide password' : 'Show password'}
|
||||||
|
/>
|
||||||
|
<RegenerateButton
|
||||||
|
onClick={() => {
|
||||||
|
patch({ password: generateTempPassword() });
|
||||||
|
setShowPassword(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-tertiary">
|
||||||
|
Auto-generated. Click the refresh icon to roll a new one. Share with the
|
||||||
|
employee directly — they should change it after first login.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Select
|
||||||
|
label="Role"
|
||||||
|
placeholder={roles.length === 0 ? 'No roles available' : 'Select a role'}
|
||||||
|
isDisabled={roles.length === 0}
|
||||||
|
items={roles}
|
||||||
|
selectedKey={value.roleId || null}
|
||||||
|
onSelectionChange={(key) => patch({ roleId: (key as string) || '' })}
|
||||||
|
isRequired
|
||||||
|
>
|
||||||
|
{(item) => (
|
||||||
|
<Select.Item
|
||||||
|
id={item.id}
|
||||||
|
label={item.label}
|
||||||
|
supportingText={item.supportingText}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-dashed border-secondary bg-secondary p-3 text-xs text-tertiary">
|
||||||
|
SIP seats are managed in the <b>Telephony</b> step — create the employee here
|
||||||
|
first, then assign them a seat there.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
177
src/components/forms/telephony-form.tsx
Normal file
177
src/components/forms/telephony-form.tsx
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
import { Input } from '@/components/base/input/input';
|
||||||
|
|
||||||
|
// Telephony form — covers Ozonetel cloud-call-center, the Ozonetel WebRTC
|
||||||
|
// gateway, and Exotel REST API credentials. Mirrors the TelephonyConfig shape
|
||||||
|
// in helix-engage-server/src/config/telephony.defaults.ts.
|
||||||
|
//
|
||||||
|
// Secrets (ozonetel.agentPassword, exotel.apiToken) come back from the GET
|
||||||
|
// endpoint as the sentinel '***masked***' — the form preserves that sentinel
|
||||||
|
// untouched unless the admin actually edits the field, in which case the
|
||||||
|
// backend overwrites the stored value. This is the same convention used by
|
||||||
|
// TelephonyConfigService.getMaskedConfig / updateConfig.
|
||||||
|
|
||||||
|
export type TelephonyFormValues = {
|
||||||
|
ozonetel: {
|
||||||
|
agentId: string;
|
||||||
|
agentPassword: string;
|
||||||
|
did: string;
|
||||||
|
sipId: string;
|
||||||
|
campaignName: string;
|
||||||
|
};
|
||||||
|
sip: {
|
||||||
|
domain: string;
|
||||||
|
wsPort: string;
|
||||||
|
};
|
||||||
|
exotel: {
|
||||||
|
apiKey: string;
|
||||||
|
apiToken: string;
|
||||||
|
accountSid: string;
|
||||||
|
subdomain: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const emptyTelephonyFormValues = (): TelephonyFormValues => ({
|
||||||
|
ozonetel: {
|
||||||
|
agentId: '',
|
||||||
|
agentPassword: '',
|
||||||
|
did: '',
|
||||||
|
sipId: '',
|
||||||
|
campaignName: '',
|
||||||
|
},
|
||||||
|
sip: {
|
||||||
|
domain: 'blr-pub-rtc4.ozonetel.com',
|
||||||
|
wsPort: '444',
|
||||||
|
},
|
||||||
|
exotel: {
|
||||||
|
apiKey: '',
|
||||||
|
apiToken: '',
|
||||||
|
accountSid: '',
|
||||||
|
subdomain: 'api.exotel.com',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
type TelephonyFormProps = {
|
||||||
|
value: TelephonyFormValues;
|
||||||
|
onChange: (value: TelephonyFormValues) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TelephonyForm = ({ value, onChange }: TelephonyFormProps) => {
|
||||||
|
const patchOzonetel = (updates: Partial<TelephonyFormValues['ozonetel']>) =>
|
||||||
|
onChange({ ...value, ozonetel: { ...value.ozonetel, ...updates } });
|
||||||
|
const patchSip = (updates: Partial<TelephonyFormValues['sip']>) =>
|
||||||
|
onChange({ ...value, sip: { ...value.sip, ...updates } });
|
||||||
|
const patchExotel = (updates: Partial<TelephonyFormValues['exotel']>) =>
|
||||||
|
onChange({ ...value, exotel: { ...value.exotel, ...updates } });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-8">
|
||||||
|
<section className="flex flex-col gap-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-primary">Ozonetel Cloud Agent</h3>
|
||||||
|
<p className="mt-1 text-xs text-tertiary">
|
||||||
|
Outbound dialing, SIP registration, and agent provisioning. Get these values from your
|
||||||
|
Ozonetel dashboard under Admin → Users and Numbers.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Input
|
||||||
|
label="Agent ID"
|
||||||
|
placeholder="e.g. agent001"
|
||||||
|
value={value.ozonetel.agentId}
|
||||||
|
onChange={(v) => patchOzonetel({ agentId: v })}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Agent password"
|
||||||
|
type="password"
|
||||||
|
placeholder="Leave '***masked***' to keep current"
|
||||||
|
value={value.ozonetel.agentPassword}
|
||||||
|
onChange={(v) => patchOzonetel({ agentPassword: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Input
|
||||||
|
label="Default DID"
|
||||||
|
placeholder="Primary hospital number"
|
||||||
|
value={value.ozonetel.did}
|
||||||
|
onChange={(v) => patchOzonetel({ did: v })}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="SIP ID"
|
||||||
|
placeholder="Softphone extension"
|
||||||
|
value={value.ozonetel.sipId}
|
||||||
|
onChange={(v) => patchOzonetel({ sipId: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
label="Campaign name"
|
||||||
|
placeholder="CloudAgent campaign for outbound dial"
|
||||||
|
value={value.ozonetel.campaignName}
|
||||||
|
onChange={(v) => patchOzonetel({ campaignName: v })}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-primary">SIP Gateway (WebRTC)</h3>
|
||||||
|
<p className="mt-1 text-xs text-tertiary">
|
||||||
|
Used by the staff portal softphone. Defaults work for most Indian Ozonetel tenants — only
|
||||||
|
change if Ozonetel support instructs you to.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Input
|
||||||
|
label="SIP domain"
|
||||||
|
placeholder="blr-pub-rtc4.ozonetel.com"
|
||||||
|
value={value.sip.domain}
|
||||||
|
onChange={(v) => patchSip({ domain: v })}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="WebSocket port"
|
||||||
|
placeholder="444"
|
||||||
|
value={value.sip.wsPort}
|
||||||
|
onChange={(v) => patchSip({ wsPort: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-primary">Exotel (SMS + inbound numbers)</h3>
|
||||||
|
<p className="mt-1 text-xs text-tertiary">
|
||||||
|
Optional — only required if you use Exotel for SMS or want inbound number management from
|
||||||
|
this portal.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Input
|
||||||
|
label="API key"
|
||||||
|
placeholder="Exotel API key"
|
||||||
|
value={value.exotel.apiKey}
|
||||||
|
onChange={(v) => patchExotel({ apiKey: v })}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="API token"
|
||||||
|
type="password"
|
||||||
|
placeholder="Leave '***masked***' to keep current"
|
||||||
|
value={value.exotel.apiToken}
|
||||||
|
onChange={(v) => patchExotel({ apiToken: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Input
|
||||||
|
label="Account SID"
|
||||||
|
placeholder="Exotel account SID"
|
||||||
|
value={value.exotel.accountSid}
|
||||||
|
onChange={(v) => patchExotel({ accountSid: v })}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Subdomain"
|
||||||
|
placeholder="api.exotel.com"
|
||||||
|
value={value.exotel.subdomain}
|
||||||
|
onChange={(v) => patchExotel({ subdomain: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
167
src/components/forms/widget-form.tsx
Normal file
167
src/components/forms/widget-form.tsx
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faPlus, faTrash } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
|
import { Input } from '@/components/base/input/input';
|
||||||
|
import { Toggle } from '@/components/base/toggle/toggle';
|
||||||
|
import { Button } from '@/components/base/buttons/button';
|
||||||
|
|
||||||
|
// Widget form — mirrors WidgetConfig from
|
||||||
|
// helix-engage-server/src/config/widget.defaults.ts. The site key and site ID
|
||||||
|
// are read-only (generated / rotated by the backend), the rest are editable.
|
||||||
|
//
|
||||||
|
// allowedOrigins is an origin allowlist — an empty list means "any origin"
|
||||||
|
// which is useful for testing but should be tightened in production.
|
||||||
|
|
||||||
|
export type WidgetFormValues = {
|
||||||
|
enabled: boolean;
|
||||||
|
url: string;
|
||||||
|
allowedOrigins: string[];
|
||||||
|
embed: {
|
||||||
|
loginPage: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const emptyWidgetFormValues = (): WidgetFormValues => ({
|
||||||
|
enabled: true,
|
||||||
|
url: '',
|
||||||
|
allowedOrigins: [],
|
||||||
|
embed: {
|
||||||
|
loginPage: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
type WidgetFormProps = {
|
||||||
|
value: WidgetFormValues;
|
||||||
|
onChange: (value: WidgetFormValues) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WidgetForm = ({ value, onChange }: WidgetFormProps) => {
|
||||||
|
const [originDraft, setOriginDraft] = useState('');
|
||||||
|
|
||||||
|
const addOrigin = () => {
|
||||||
|
const trimmed = originDraft.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
if (value.allowedOrigins.includes(trimmed)) {
|
||||||
|
setOriginDraft('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onChange({ ...value, allowedOrigins: [...value.allowedOrigins, trimmed] });
|
||||||
|
setOriginDraft('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeOrigin = (origin: string) => {
|
||||||
|
onChange({ ...value, allowedOrigins: value.allowedOrigins.filter((o) => o !== origin) });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-8">
|
||||||
|
<section className="flex flex-col gap-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-primary">Activation</h3>
|
||||||
|
<p className="mt-1 text-xs text-tertiary">
|
||||||
|
When disabled, widget.js returns an empty response and the script no-ops on the
|
||||||
|
embedding page. Use this as a kill switch if something goes wrong in production.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-secondary bg-secondary p-4">
|
||||||
|
<Toggle
|
||||||
|
label="Website widget enabled"
|
||||||
|
isSelected={value.enabled}
|
||||||
|
onChange={(checked) => onChange({ ...value, enabled: checked })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-primary">Hosting</h3>
|
||||||
|
<p className="mt-1 text-xs text-tertiary">
|
||||||
|
Public base URL where widget.js is served from. Leave blank to use the same origin as
|
||||||
|
this sidecar (the common case).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
label="Public URL"
|
||||||
|
placeholder="https://widget.hospital.com"
|
||||||
|
value={value.url}
|
||||||
|
onChange={(v) => onChange({ ...value, url: v })}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-primary">Allowed origins</h3>
|
||||||
|
<p className="mt-1 text-xs text-tertiary">
|
||||||
|
Origins where the widget may be embedded. An empty list means any origin is accepted
|
||||||
|
(test mode). In production, list every hospital website + staging environment
|
||||||
|
explicitly.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<Input
|
||||||
|
placeholder="https://hospital.com"
|
||||||
|
value={originDraft}
|
||||||
|
onChange={setOriginDraft}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
color="secondary"
|
||||||
|
iconLeading={({ className }: { className?: string }) => (
|
||||||
|
<FontAwesomeIcon icon={faPlus} className={className} />
|
||||||
|
)}
|
||||||
|
onClick={addOrigin}
|
||||||
|
isDisabled={!originDraft.trim()}
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{value.allowedOrigins.length === 0 ? (
|
||||||
|
<p className="rounded-lg border border-dashed border-secondary bg-secondary p-4 text-center text-xs text-tertiary">
|
||||||
|
Any origin allowed — widget runs in test mode.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col gap-2 rounded-lg border border-secondary bg-secondary p-3">
|
||||||
|
{value.allowedOrigins.map((origin) => (
|
||||||
|
<li
|
||||||
|
key={origin}
|
||||||
|
className="flex items-center justify-between rounded-md bg-primary px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<span className="font-mono text-primary">{origin}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeOrigin(origin)}
|
||||||
|
className="flex size-6 items-center justify-center rounded-md text-fg-quaternary hover:bg-secondary_hover hover:text-fg-error-primary"
|
||||||
|
title="Remove origin"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faTrash} className="size-3" />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-primary">Embed surfaces</h3>
|
||||||
|
<p className="mt-1 text-xs text-tertiary">
|
||||||
|
Where inside this application the widget should auto-render. Keep these off if you
|
||||||
|
only plan to embed it on your public hospital website.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-secondary bg-secondary p-4">
|
||||||
|
<Toggle
|
||||||
|
label="Show on staff login page"
|
||||||
|
hint="Useful for smoke-testing without a public landing page."
|
||||||
|
isSelected={value.embed.loginPage}
|
||||||
|
onChange={(checked) =>
|
||||||
|
onChange({ ...value, embed: { ...value.embed, loginPage: checked } })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { type FC, type ReactNode, type Ref, isValidElement } from "react";
|
import type { FC, ReactNode, Ref } from "react";
|
||||||
|
import { isValidElement } from "react";
|
||||||
import { cx, sortCx } from "@/utils/cx";
|
import { cx, sortCx } from "@/utils/cx";
|
||||||
import { isReactComponent } from "@/utils/is-react-component";
|
import { isReactComponent } from "@/utils/is-react-component";
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user