Linting and Formatting

This commit is contained in:
Kartik Datrika
2026-03-23 16:41:58 +05:30
parent 727a0728ee
commit 2c87a39733
175 changed files with 16535 additions and 11532 deletions

38
.claudeignore Normal file
View File

@@ -0,0 +1,38 @@
# 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
.husky/pre-commit Normal file
View File

@@ -0,0 +1 @@
npx lint-staged

View File

@@ -5,7 +5,10 @@
"@trivago/prettier-plugin-sort-imports",
"prettier-plugin-tailwindcss"
],
"tailwindFunctions": ["sortCx", "cx"],
"tailwindFunctions": [
"sortCx",
"cx"
],
"importOrder": [
"^react$",
"^react-dom$",

View File

@@ -39,15 +39,16 @@ npm run build # TypeScript check + production build
### Environment Variables (set at build time or in `.env`)
| Variable | Purpose | Dev Default | Production |
|----------|---------|-------------|------------|
| `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_SIP_URI` | Ozonetel SIP URI | — | `sip:523590@blr-pub-rtc4.ozonetel.com` |
| `VITE_SIP_PASSWORD` | SIP password | — | `523590` |
| `VITE_SIP_WS_SERVER` | SIP WebSocket | — | `wss://blr-pub-rtc4.ozonetel.com:444` |
| Variable | Purpose | Dev Default | Production |
| -------------------- | ---------------- | ----------------------- | ------------------------------------------- |
| `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_SIP_URI` | Ozonetel SIP URI | — | `sip:523590@blr-pub-rtc4.ozonetel.com` |
| `VITE_SIP_PASSWORD` | SIP password | — | `523590` |
| `VITE_SIP_WS_SERVER` | SIP WebSocket | — | `wss://blr-pub-rtc4.ozonetel.com:444` |
**Production build command:**
```bash
VITE_API_URL=https://engage-api.srv1477139.hstgr.cloud \
VITE_SIDECAR_URL=https://engage-api.srv1477139.hstgr.cloud \
@@ -123,34 +124,42 @@ src/
## Troubleshooting Guide — Where to Look
### "The call desk isn't working"
**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.
### "Calls aren't connecting / SIP errors"
**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.
### "Worklist not loading / empty"
**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.
### "Missed calls not appearing / sub-tabs empty"
**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`).
### "Disposition / appointment not saving"
**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.
### "Login broken / Failed to fetch"
**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.**
### "UI component looks wrong"
**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)`.
### "Navigation / role-based access"
**File:** `src/components/layout/sidebar.tsx`
Navigation groups are defined per role (admin, cc-agent, executive). Routes are registered in `src/main.tsx`.
@@ -173,6 +182,7 @@ Component (e.g. ActiveCallCard)
```
**Key pattern:** The frontend talks to TWO backends:
1. **Sidecar** (REST) — for Ozonetel telephony operations and worklist
2. **Platform** (GraphQL) — for entity CRUD (leads, appointments, patients)

40
eslint.config.mjs Normal file
View File

@@ -0,0 +1,40 @@
// @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'] }],
},
}
);

13166
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,58 +1,74 @@
{
"name": "helix-engage",
"version": "0.1.0",
"type": "module",
"private": true,
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^7.2.0",
"@fortawesome/free-solid-svg-icons": "^7.2.0",
"@fortawesome/pro-duotone-svg-icons": "^7.2.0",
"@fortawesome/pro-light-svg-icons": "^7.2.0",
"@fortawesome/pro-regular-svg-icons": "^7.2.0",
"@fortawesome/pro-solid-svg-icons": "^7.2.0",
"@fortawesome/react-fontawesome": "^3.2.0",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.18",
"@untitledui/file-icons": "^0.0.8",
"@untitledui/icons": "^0.0.21",
"echarts": "^6.0.0",
"echarts-for-react": "^3.0.6",
"input-otp": "^1.4.2",
"jotai": "^2.18.1",
"jssip": "^3.13.6",
"motion": "^12.29.0",
"qr-code-styling": "^1.9.2",
"react": "^19.2.3",
"react-aria": "^3.46.0",
"react-aria-components": "^1.16.0",
"react-dom": "^19.2.3",
"react-hotkeys-hook": "^5.2.3",
"react-router": "^7.13.0",
"socket.io-client": "^4.8.3",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.1.18",
"tailwindcss-animate": "^1.0.7",
"tailwindcss-react-aria-components": "^2.0.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.18",
"@trivago/prettier-plugin-sort-imports": "^5.2.2",
"@types/jssip": "^3.5.3",
"@types/node": "^24.10.9",
"@types/react": "^19.2.9",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react-swc": "^4.2.2",
"globals": "^16.5.0",
"prettier": "^3.8.1",
"prettier-plugin-tailwindcss": "^0.6.14",
"typescript": "^5.9.3",
"typescript-eslint": "^8.53.1",
"vite": "^7.3.1"
}
"name": "helix-engage",
"version": "0.1.0",
"type": "module",
"private": true,
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"format": "prettier --write \"src/**/*.{ts,tsx}\"",
"lint": "eslint src",
"lint:fix": "eslint src --fix",
"prepare": "husky"
},
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^7.2.0",
"@fortawesome/free-solid-svg-icons": "^7.2.0",
"@fortawesome/pro-duotone-svg-icons": "^7.2.0",
"@fortawesome/pro-light-svg-icons": "^7.2.0",
"@fortawesome/pro-regular-svg-icons": "^7.2.0",
"@fortawesome/pro-solid-svg-icons": "^7.2.0",
"@fortawesome/react-fontawesome": "^3.2.0",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.18",
"@untitledui/file-icons": "^0.0.8",
"@untitledui/icons": "^0.0.21",
"echarts": "^6.0.0",
"echarts-for-react": "^3.0.6",
"input-otp": "^1.4.2",
"jotai": "^2.18.1",
"jssip": "^3.13.6",
"motion": "^12.29.0",
"qr-code-styling": "^1.9.2",
"react": "^19.2.3",
"react-aria": "^3.46.0",
"react-aria-components": "^1.16.0",
"react-dom": "^19.2.3",
"react-hotkeys-hook": "^5.2.3",
"react-router": "^7.13.0",
"socket.io-client": "^4.8.3",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.1.18",
"tailwindcss-animate": "^1.0.7",
"tailwindcss-react-aria-components": "^2.0.1"
},
"lint-staged": {
"src/**/*.{ts,tsx}": [
"eslint --fix",
"prettier --write"
]
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@tailwindcss/postcss": "^4.1.18",
"@trivago/prettier-plugin-sort-imports": "^5.2.2",
"@types/jssip": "^3.5.3",
"@types/node": "^24.10.9",
"@types/react": "^19.2.9",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react-swc": "^4.2.2",
"eslint": "^10.1.0",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^16.5.0",
"husky": "^9.1.7",
"lint-staged": "^16.4.0",
"prettier": "^3.8.1",
"prettier-plugin-tailwindcss": "^0.6.14",
"typescript": "^5.9.3",
"typescript-eslint": "^8.53.1",
"vite": "^7.3.1"
}
}

4641
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,8 @@
import { useMemo } from 'react';
import { Link } from 'react-router';
import { formatCurrency } from '@/lib/format';
import { cx } from '@/utils/cx';
import type { Campaign } from '@/types/entities';
import { useMemo } from "react";
import { Link } from "react-router";
import { formatCurrency } from "@/lib/format";
import type { Campaign } from "@/types/entities";
import { cx } from "@/utils/cx";
interface CampaignRoiCardsProps {
campaigns: Campaign[];
@@ -34,9 +33,9 @@ export const CampaignRoiCards = ({ campaigns }: CampaignRoiCardsProps) => {
}, [campaigns]);
const getHealthColor = (rate: number): string => {
if (rate >= 0.1) return 'bg-success-500';
if (rate >= 0.05) return 'bg-warning-500';
return 'bg-error-500';
if (rate >= 0.1) return "bg-success-500";
if (rate >= 0.05) return "bg-warning-500";
return "bg-error-500";
};
return (
@@ -44,17 +43,10 @@ export const CampaignRoiCards = ({ campaigns }: CampaignRoiCardsProps) => {
<h3 className="text-sm font-bold text-primary">Campaign ROI</h3>
<div className="flex gap-4 overflow-x-auto pb-1">
{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">
<span
className={cx('size-2 shrink-0 rounded-full', getHealthColor(campaign.conversionRate))}
/>
<span className="truncate text-sm font-semibold text-primary">
{campaign.campaignName}
</span>
<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 className="mt-3 flex items-center gap-3 text-xs text-tertiary">
@@ -64,23 +56,14 @@ export const CampaignRoiCards = ({ campaigns }: CampaignRoiCardsProps) => {
</div>
<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>
</div>
<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>
<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>

View File

@@ -1,37 +1,37 @@
import { BadgeWithDot } from '@/components/base/badges/badges';
import { cx } from '@/utils/cx';
import type { IntegrationStatus, AuthStatus, LeadIngestionSource } from '@/types/entities';
import { BadgeWithDot } from "@/components/base/badges/badges";
import type { AuthStatus, IntegrationStatus, LeadIngestionSource } from "@/types/entities";
import { cx } from "@/utils/cx";
interface IntegrationHealthProps {
sources: LeadIngestionSource[];
}
const statusBorderMap: Record<IntegrationStatus, string> = {
ACTIVE: 'border-secondary',
WARNING: 'border-warning',
ERROR: 'border-error',
DISABLED: 'border-secondary',
ACTIVE: "border-secondary",
WARNING: "border-warning",
ERROR: "border-error",
DISABLED: "border-secondary",
};
const statusBadgeColorMap: Record<IntegrationStatus, 'success' | 'warning' | 'error' | 'gray'> = {
ACTIVE: 'success',
WARNING: 'warning',
ERROR: 'error',
DISABLED: 'gray',
const statusBadgeColorMap: Record<IntegrationStatus, "success" | "warning" | "error" | "gray"> = {
ACTIVE: "success",
WARNING: "warning",
ERROR: "error",
DISABLED: "gray",
};
const authBadgeColorMap: Record<AuthStatus, 'success' | 'warning' | 'error' | 'gray'> = {
VALID: 'success',
EXPIRING_SOON: 'warning',
EXPIRED: 'error',
NOT_CONFIGURED: 'gray',
const authBadgeColorMap: Record<AuthStatus, "success" | "warning" | "error" | "gray"> = {
VALID: "success",
EXPIRING_SOON: "warning",
EXPIRED: "error",
NOT_CONFIGURED: "gray",
};
function formatRelativeTime(isoString: string): string {
const diffMs = Date.now() - new Date(isoString).getTime();
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`;
const diffHours = Math.floor(diffMinutes / 60);
@@ -48,60 +48,35 @@ export const IntegrationHealth = ({ sources }: IntegrationHealthProps) => {
<div className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
{sources.map((source) => {
const status = source.integrationStatus ?? 'DISABLED';
const authStatus = source.authStatus ?? 'NOT_CONFIGURED';
const showAuthBadge = authStatus !== 'VALID';
const status = source.integrationStatus ?? "DISABLED";
const authStatus = source.authStatus ?? "NOT_CONFIGURED";
const showAuthBadge = authStatus !== "VALID";
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">
<span className="text-sm font-semibold text-primary">
{source.sourceName}
</span>
<BadgeWithDot
size="sm"
type="pill-color"
color={statusBadgeColorMap[status]}
>
<span className="text-sm font-semibold text-primary">{source.sourceName}</span>
<BadgeWithDot size="sm" type="pill-color" color={statusBadgeColorMap[status]}>
{status}
</BadgeWithDot>
</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 && (
<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 && (
<div className="mt-2">
<BadgeWithDot
size="sm"
type="pill-color"
color={authBadgeColorMap[authStatus]}
>
Auth: {authStatus.replace(/_/g, ' ')}
<BadgeWithDot size="sm" type="pill-color" color={authBadgeColorMap[authStatus]}>
Auth: {authStatus.replace(/_/g, " ")}
</BadgeWithDot>
</div>
)}
{(status === 'WARNING' || status === 'ERROR') && source.lastErrorMessage && (
<p
className={cx(
'mt-2 text-xs',
status === 'ERROR' ? 'text-error-primary' : 'text-warning-primary',
)}
>
{(status === "WARNING" || status === "ERROR") && source.lastErrorMessage && (
<p className={cx("mt-2 text-xs", status === "ERROR" ? "text-error-primary" : "text-warning-primary")}>
{source.lastErrorMessage}
</p>
)}

View File

@@ -1,7 +1,6 @@
import { useMemo } from 'react';
import { cx } from '@/utils/cx';
import type { Lead } from '@/types/entities';
import { useMemo } from "react";
import type { Lead } from "@/types/entities";
import { cx } from "@/utils/cx";
interface LeadFunnelProps {
leads: Lead[];
@@ -17,28 +16,24 @@ export const LeadFunnel = ({ leads }: LeadFunnelProps) => {
const stages = useMemo((): FunnelStage[] => {
const total = leads.length;
const contacted = leads.filter((lead) =>
lead.leadStatus === 'CONTACTED' ||
lead.leadStatus === 'QUALIFIED' ||
lead.leadStatus === 'NURTURING' ||
lead.leadStatus === 'APPOINTMENT_SET' ||
lead.leadStatus === 'CONVERTED',
const contacted = leads.filter(
(lead) =>
lead.leadStatus === "CONTACTED" ||
lead.leadStatus === "QUALIFIED" ||
lead.leadStatus === "NURTURING" ||
lead.leadStatus === "APPOINTMENT_SET" ||
lead.leadStatus === "CONVERTED",
).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 [
{ label: 'Generated', count: total, color: 'bg-brand-600' },
{ label: 'Contacted', count: contacted, color: 'bg-brand-500' },
{ label: 'Appointment Set', count: appointmentSet, color: 'bg-brand-400' },
{ label: 'Converted', count: converted, color: 'bg-success-500' },
{ label: "Generated", count: total, color: "bg-brand-600" },
{ label: "Contacted", count: contacted, color: "bg-brand-500" },
{ label: "Appointment Set", count: appointmentSet, color: "bg-brand-400" },
{ label: "Converted", count: converted, color: "bg-success-500" },
];
}, [leads]);
@@ -52,10 +47,7 @@ export const LeadFunnel = ({ leads }: LeadFunnelProps) => {
{stages.map((stage, index) => {
const widthPercent = maxCount > 0 ? (stage.count / maxCount) * 100 : 0;
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 (
<div key={stage.label} className="space-y-1">
@@ -63,16 +55,11 @@ export const LeadFunnel = ({ leads }: LeadFunnelProps) => {
<span className="text-xs font-medium text-secondary">{stage.label}</span>
<div className="flex items-center gap-2">
<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 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>
);

View File

@@ -1,10 +1,9 @@
import type { FC } from 'react';
import { useMemo } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faCircleCheck, faTriangleExclamation, faCircleExclamation } from '@fortawesome/pro-duotone-svg-icons';
import { cx } from '@/utils/cx';
import type { Lead } from '@/types/entities';
import type { FC } from "react";
import { useMemo } from "react";
import { faCircleCheck, faCircleExclamation, faTriangleExclamation } from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import type { Lead } from "@/types/entities";
import { cx } from "@/utils/cx";
const CheckCircle: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faCircleCheck} className={className} />;
const AlertTriangle: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faTriangleExclamation} className={className} />;
@@ -20,7 +19,7 @@ export const SlaMetrics = ({ leads }: SlaMetricsProps) => {
const metrics = useMemo(() => {
const responseTimes: number[] = [];
let withinSla = 0;
let total = leads.length;
const total = leads.length;
for (const lead of leads) {
if (lead.createdAt && lead.firstContactedAt) {
@@ -36,10 +35,7 @@ export const SlaMetrics = ({ leads }: SlaMetricsProps) => {
// 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;
@@ -52,23 +48,23 @@ export const SlaMetrics = ({ leads }: SlaMetricsProps) => {
if (diff <= 0) {
return {
icon: CheckCircle,
label: 'Below target',
colorClass: 'text-success-primary',
label: "Below target",
colorClass: "text-success-primary",
};
}
if (diff <= 0.5) {
return {
icon: AlertTriangle,
label: 'Near target',
colorClass: 'text-warning-primary',
label: "Near target",
colorClass: "text-warning-primary",
};
}
return {
icon: AlertCircle,
label: 'Above target',
colorClass: 'text-error-primary',
label: "Above target",
colorClass: "text-error-primary",
};
};
@@ -80,18 +76,14 @@ export const SlaMetrics = ({ leads }: SlaMetricsProps) => {
<h3 className="text-sm font-bold text-primary">Response SLA</h3>
<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">
<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>
<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="flex items-center justify-between text-xs text-tertiary">
@@ -101,12 +93,8 @@ export const SlaMetrics = ({ leads }: SlaMetricsProps) => {
<div className="mt-1.5 h-2 w-full overflow-hidden rounded-full bg-tertiary">
<div
className={cx(
'h-full rounded-full transition-all',
metrics.slaPercent >= 80
? 'bg-success-500'
: metrics.slaPercent >= 60
? 'bg-warning-500'
: 'bg-error-500',
"h-full rounded-full transition-all",
metrics.slaPercent >= 80 ? "bg-success-500" : metrics.slaPercent >= 60 ? "bg-warning-500" : "bg-error-500",
)}
style={{ width: `${metrics.slaPercent}%` }}
/>

View File

@@ -1,9 +1,8 @@
import { useMemo } from 'react';
import { Avatar } from '@/components/base/avatar/avatar';
import { BadgeWithDot } from '@/components/base/badges/badges';
import { cx } from '@/utils/cx';
import type { Lead, Call, Agent } from '@/types/entities';
import { useMemo } from "react";
import { Avatar } from "@/components/base/avatar/avatar";
import { BadgeWithDot } from "@/components/base/badges/badges";
import type { Agent, Call, Lead } from "@/types/entities";
import { cx } from "@/utils/cx";
interface TeamScoreboardProps {
leads: Lead[];
@@ -25,16 +24,14 @@ export const TeamScoreboard = ({ leads, calls, agents }: TeamScoreboardProps) =>
const leadsProcessed = leads.filter((lead) => lead.assignedAgent === agentName).length;
const agentCalls = calls.filter((call) => call.agentName === agentName);
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 };
});
}, [leads, calls, agents]);
const bestPerformerId = useMemo(() => {
let bestId = '';
let bestId = "";
let maxAppointments = -1;
for (const stat of agentStats) {
@@ -56,29 +53,19 @@ export const TeamScoreboard = ({ leads, calls, agents }: TeamScoreboardProps) =>
<div
key={agent.id}
className={cx(
'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',
"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",
)}
>
<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">
<span className="text-sm font-semibold text-primary">{agent.name}</span>
<BadgeWithDot
size="sm"
type="pill-color"
color={agent.isOnShift ? 'success' : 'gray'}
>
{agent.isOnShift ? 'On Shift' : 'Off Shift'}
<BadgeWithDot size="sm" type="pill-color" color={agent.isOnShift ? "success" : "gray"}>
{agent.isOnShift ? "On Shift" : "Off Shift"}
</BadgeWithDot>
</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 className="grid grid-cols-2 gap-3">
@@ -96,9 +83,7 @@ export const TeamScoreboard = ({ leads, calls, agents }: TeamScoreboardProps) =>
</div>
<div className="flex flex-col">
<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>

View File

@@ -1,6 +1,6 @@
import type { PropsWithChildren } from "react";
import { faBars, faXmark } from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faXmark, faBars } from "@fortawesome/pro-duotone-svg-icons";
import {
Button as AriaButton,
Dialog as AriaDialog,

View File

@@ -1,13 +1,8 @@
import type { FC, HTMLAttributes } from "react";
import { useCallback, useEffect, useRef } from "react";
import type { Placement } from "@react-types/overlays";
import { faArrowRightFromBracket, faGear, faPhoneVolume, faSort, faUser } from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faUser, faGear, faArrowRightFromBracket, faPhoneVolume, faSort } from "@fortawesome/pro-duotone-svg-icons";
const IconUser: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faUser} className={className} />;
const IconSettings: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faGear} className={className} />;
const IconLogout: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faArrowRightFromBracket} className={className} />;
const IconForceReady: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faPhoneVolume} className={className} />;
import type { Placement } from "@react-types/overlays";
import { useFocusManager } from "react-aria";
import type { DialogProps as AriaDialogProps } from "react-aria-components";
import { Button as AriaButton, Dialog as AriaDialog, DialogTrigger as AriaDialogTrigger, Popover as AriaPopover } from "react-aria-components";
@@ -15,6 +10,11 @@ import { AvatarLabelGroup } from "@/components/base/avatar/avatar-label-group";
import { useBreakpoint } from "@/hooks/use-breakpoint";
import { cx } from "@/utils/cx";
const IconUser: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faUser} className={className} />;
const IconSettings: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faGear} className={className} />;
const IconLogout: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faArrowRightFromBracket} className={className} />;
const IconForceReady: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faPhoneVolume} className={className} />;
type NavAccountType = {
/** Unique identifier for the nav item. */
id: string;
@@ -28,7 +28,6 @@ type NavAccountType = {
status: "online" | "offline";
};
export const NavAccountMenu = ({
className,
onSignOut,
@@ -77,12 +76,27 @@ export const NavAccountMenu = ({
<div className="flex flex-col gap-0.5 py-1.5">
<NavAccountCardMenuItem label="View profile" icon={IconUser} shortcut="⌘K->P" />
<NavAccountCardMenuItem label="Account settings" icon={IconSettings} shortcut="⌘S" />
<NavAccountCardMenuItem label="Force Ready" icon={IconForceReady} onClick={() => { close(); onForceReady?.(); }} />
<NavAccountCardMenuItem
label="Force Ready"
icon={IconForceReady}
onClick={() => {
close();
onForceReady?.();
}}
/>
</div>
</div>
<div className="pt-1 pb-1.5">
<NavAccountCardMenuItem label="Sign out" icon={IconLogout} shortcut="⌥⇧Q" onClick={() => { close(); onSignOut?.(); }} />
<NavAccountCardMenuItem
label="Sign out"
icon={IconLogout}
shortcut="⌥⇧Q"
onClick={() => {
close();
onSignOut?.();
}}
/>
</div>
</>
)}

View File

@@ -1,13 +1,13 @@
import type { FC, MouseEventHandler, ReactNode } from "react";
import { faArrowUpRightFromSquare, faChevronDown } from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faChevronDown, faArrowUpRightFromSquare } from "@fortawesome/pro-duotone-svg-icons";
import { Link as AriaLink } from "react-aria-components";
import { Badge } from "@/components/base/badges/badges";
import { cx, sortCx } from "@/utils/cx";
const styles = sortCx({
root: "group relative flex w-full cursor-pointer items-center rounded-md bg-primary outline-focus-ring transition duration-100 ease-linear select-none hover:bg-primary_hover focus-visible:z-10 focus-visible:outline-2 focus-visible:outline-offset-2",
rootSelected: "bg-active hover:bg-secondary_hover border-l-2 border-l-brand-600 text-brand-secondary",
rootSelected: "border-l-2 border-l-brand-600 bg-active text-brand-secondary hover:bg-secondary_hover",
});
interface NavItemBaseProps {

View File

@@ -1,11 +1,6 @@
import type { FC, ReactNode } from "react";
import { faBell, faGear, faLifeRing, faMagnifyingGlass } from "@fortawesome/pro-duotone-svg-icons";
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 { Avatar } from "@/components/base/avatar/avatar";
import { BadgeWithDot } from "@/components/base/badges/badges";
@@ -18,6 +13,11 @@ import { NavItemBase } from "./base-components/nav-item";
import { NavItemButton } from "./base-components/nav-item-button";
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 = {
/** Label text for the nav item. */
label: string;

View File

@@ -1,9 +1,7 @@
import type { FC, ReactNode } from "react";
import { useState } from "react";
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 { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { AnimatePresence, motion } from "motion/react";
import { Input } from "@/components/base/input/input";
import { UntitledLogo } from "@/components/foundations/logo/untitledui-logo";
@@ -14,6 +12,8 @@ import { NavItemBase } from "../base-components/nav-item";
import { NavList } from "../base-components/nav-list";
import type { NavItemType } from "../config";
const SearchLg: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />;
interface SidebarNavigationDualTierProps {
/** URL of the currently active item. */
activeUrl?: string;

View File

@@ -1,8 +1,6 @@
import type { FC } from "react";
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 { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Input } from "@/components/base/input/input";
import { UntitledLogo } from "@/components/foundations/logo/untitledui-logo";
import { MobileNavigationHeader } from "../base-components/mobile-header";
@@ -10,6 +8,8 @@ import { NavAccountCard } from "../base-components/nav-account-card";
import { NavList } from "../base-components/nav-list";
import type { NavItemDividerType, NavItemType } from "../config";
const SearchLg: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />;
interface SidebarNavigationSectionDividersProps {
/** URL of the currently active item. */
activeUrl?: string;

View File

@@ -1,8 +1,6 @@
import type { FC, ReactNode } from "react";
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 { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Input } from "@/components/base/input/input";
import { UntitledLogo } from "@/components/foundations/logo/untitledui-logo";
import { cx } from "@/utils/cx";
@@ -12,6 +10,8 @@ import { NavItemBase } from "../base-components/nav-item";
import { NavList } from "../base-components/nav-list";
import type { NavItemType } from "../config";
const SearchLg: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />;
interface SidebarNavigationProps {
/** URL of the currently active item. */
activeUrl?: string;

View File

@@ -1,11 +1,7 @@
import type { FC } from "react";
import { useState } from "react";
import { faArrowRightFromBracket, faGear, faLifeRing } from "@fortawesome/pro-duotone-svg-icons";
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 { Button as AriaButton, DialogTrigger as AriaDialogTrigger, Popover as AriaPopover } from "react-aria-components";
import { Avatar } from "@/components/base/avatar/avatar";
@@ -22,6 +18,10 @@ import { NavItemButton } from "../base-components/nav-item-button";
import { NavList } from "../base-components/nav-list";
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 {
/** URL of the currently active item. */
activeUrl?: string;

View File

@@ -1,11 +1,8 @@
import type { FC, HTMLAttributes, PropsWithChildren } from "react";
import { Fragment, useContext, useState } from "react";
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 { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { type CalendarDate, getLocalTimeZone, today } from "@internationalized/date";
import type { CalendarProps as AriaCalendarProps, DateValue } from "react-aria-components";
import {
Calendar as AriaCalendar,
@@ -23,6 +20,9 @@ import { cx } from "@/utils/cx";
import { CalendarCell } from "./cell";
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) => {
const [value, onChange] = useState<DateValue | null>(null);
const [focusedValue, onFocusChange] = useState<DateValue | undefined>();

View File

@@ -2,7 +2,7 @@ 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";
interface DateInputProps extends Omit<AriaDateInputProps, "children"> {}
type DateInputProps = Omit<AriaDateInputProps, "children">;
export const DateInput = (props: DateInputProps) => {
return (

View File

@@ -1,10 +1,8 @@
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 { 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 type { DatePickerProps as AriaDatePickerProps, DateValue } from "react-aria-components";
import { DatePicker as AriaDatePicker, Dialog as AriaDialog, Group as AriaGroup, Popover as AriaPopover } from "react-aria-components";
@@ -12,6 +10,8 @@ import { Button } from "@/components/base/buttons/button";
import { cx } from "@/utils/cx";
import { Calendar } from "./calendar";
const CalendarIcon: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faCalendar} className={className} />;
const highlightedDates = [today(getLocalTimeZone())];
interface DatePickerProps extends AriaDatePickerProps<DateValue> {

View File

@@ -1,11 +1,9 @@
import type { FC } from "react";
import { useMemo, useState } from "react";
import { faCalendar } from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { endOfMonth, endOfWeek, getLocalTimeZone, startOfMonth, startOfWeek, today } from "@internationalized/date";
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 type { DateRangePickerProps as AriaDateRangePickerProps, DateValue } from "react-aria-components";
import { DateRangePicker as AriaDateRangePicker, Dialog as AriaDialog, Group as AriaGroup, Popover as AriaPopover, useLocale } from "react-aria-components";
@@ -15,6 +13,8 @@ import { DateInput } from "./date-input";
import { RangeCalendar } from "./range-calendar";
import { RangePresetButton } from "./range-preset";
const CalendarIcon: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faCalendar} className={className} />;
const now = today(getLocalTimeZone());
const highlightedDates = [today(getLocalTimeZone())];

View File

@@ -1,11 +1,8 @@
import type { FC, HTMLAttributes, PropsWithChildren } from "react";
import { Fragment, useContext, useState } from "react";
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 { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import type { CalendarDate } from "@internationalized/date";
import { useDateFormatter } from "react-aria";
import type { RangeCalendarProps as AriaRangeCalendarProps, DateValue } from "react-aria-components";
import {
@@ -23,6 +20,9 @@ import { useBreakpoint } from "@/hooks/use-breakpoint";
import { CalendarCell } from "./cell";
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) => {
const [value, onChange] = useState<{ start: DateValue; end: DateValue } | null>(null);
const [focusedValue, onFocusChange] = useState<DateValue | undefined>();

View File

@@ -1,16 +1,16 @@
import type { ComponentProps, ComponentPropsWithRef, FC } from "react";
import { Children, createContext, isValidElement, useContext } from "react";
import { FileIcon } from "@untitledui/file-icons";
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 { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { FileIcon } from "@untitledui/file-icons";
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";
const SearchLg: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />;
interface RootContextProps {
size?: "sm" | "md" | "lg";
}

View File

@@ -1,11 +1,9 @@
import type { ComponentProps, ComponentPropsWithRef, FC } from "react";
import { useId, useRef, useState } from "react";
import { faCircleCheck, faCircleXmark, faCloudArrowUp, faTrash } from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import type { FileIcon } from "@untitledui/file-icons";
import { FileIcon as FileTypeIcon } from "@untitledui/file-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
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 { Button } from "@/components/base/buttons/button";
import { ButtonUtility } from "@/components/base/buttons/button-utility";
@@ -13,6 +11,8 @@ import { ProgressBar } from "@/components/base/progress-indicators/progress-indi
import { FeaturedIcon } from "@/components/foundations/featured-icon/featured-icon";
import { cx } from "@/utils/cx";
const Trash01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faTrash} className={className} />;
/**
* Returns a human-readable file size.
* @param bytes - The size of the file in bytes.

View File

@@ -1,10 +1,6 @@
import type { FC } from "react";
import { faCircleCheck, faCircleExclamation, faCircleInfo } from "@fortawesome/pro-duotone-svg-icons";
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 { Button } from "@/components/base/buttons/button";
import { CloseButton } from "@/components/base/buttons/close-button";
@@ -12,6 +8,10 @@ import { ProgressBar } from "@/components/base/progress-indicators/progress-indi
import { FeaturedIcon } from "@/components/foundations/featured-icon/featured-icon";
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 = {
default: InfoCircle,
brand: InfoCircle,

View File

@@ -1,5 +1,5 @@
import type { CSSProperties, FC, HTMLAttributes, ReactNode } from "react";
import React, { cloneElement, createContext, isValidElement, useCallback, useContext, useEffect, useState } from "react";
import React, { cloneElement, createContext, isValidElement, useCallback, useContext, useMemo } from "react";
type PaginationPage = {
/** The type of the pagination item. */
@@ -47,8 +47,6 @@ export interface PaginationRootProps {
}
const PaginationRoot = ({ total, siblingCount = 1, page, onPageChange, children, style, className }: PaginationRootProps) => {
const [pages, setPages] = useState<PaginationItemType[]>([]);
const createPaginationItems = useCallback((): PaginationItemType[] => {
const items: PaginationItemType[] = [];
// Calculate the maximum number of pagination elements (pages, potential ellipsis, first and last) to show
@@ -150,10 +148,7 @@ const PaginationRoot = ({ total, siblingCount = 1, page, onPageChange, children,
return items;
}, [total, siblingCount, page]);
useEffect(() => {
const paginationItems = createPaginationItems();
setPages(paginationItems);
}, [createPaginationItems]);
const pages = useMemo(() => createPaginationItems(), [createPaginationItems]);
const onPageChangeHandler = (newPage: number) => {
onPageChange?.(newPage);

View File

@@ -1,9 +1,6 @@
import type { FC } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faArrowLeft, faArrowRight } from "@fortawesome/pro-duotone-svg-icons";
const ArrowLeft: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faArrowLeft} className={className} />;
const ArrowRight: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faArrowRight} className={className} />;
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { ButtonGroup, ButtonGroupItem } from "@/components/base/button-group/button-group";
import { Button } from "@/components/base/buttons/button";
import { useBreakpoint } from "@/hooks/use-breakpoint";
@@ -11,6 +8,9 @@ import { cx } from "@/utils/cx";
import type { PaginationRootProps } from "./pagination-base";
import { Pagination } from "./pagination-base";
const ArrowLeft: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faArrowLeft} className={className} />;
const ArrowRight: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faArrowRight} className={className} />;
interface PaginationProps extends Partial<Omit<PaginationRootProps, "children">> {
/** Whether the pagination buttons are rounded. */
rounded?: boolean;

View File

@@ -1,11 +1,7 @@
import type { ComponentPropsWithRef, FC, HTMLAttributes, ReactNode, Ref, TdHTMLAttributes, ThHTMLAttributes } from "react";
import { createContext, isValidElement, useContext } from "react";
import { faArrowDown, faCircleQuestion, faCopy, faPenToSquare, faSort, faTrash } from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faArrowDown, faSort, faCopy, faPenToSquare, faCircleQuestion, faTrash } from "@fortawesome/pro-duotone-svg-icons";
const Edit01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faPenToSquare} className={className} />;
const Copy01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faCopy} className={className} />;
const Trash01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faTrash} className={className} />;
import type {
CellProps as AriaCellProps,
ColumnProps as AriaColumnProps,
@@ -30,6 +26,10 @@ import { Dropdown } from "@/components/base/dropdown/dropdown";
import { Tooltip, TooltipTrigger } from "@/components/base/tooltip/tooltip";
import { cx } from "@/utils/cx";
const Edit01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faPenToSquare} className={className} />;
const Copy01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faCopy} className={className} />;
const Trash01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faTrash} className={className} />;
export const TableRowActionsDropdown = () => (
<Dropdown.Root>
<Dropdown.DotsButton />
@@ -124,8 +124,7 @@ const TableRoot = ({ className, size = "md", ...props }: TableRootProps) => {
TableRoot.displayName = "Table";
interface TableHeaderProps<T extends object>
extends AriaTableHeaderProps<T>,
Omit<ComponentPropsWithRef<"thead">, "children" | "className" | "slot" | "style"> {
extends AriaTableHeaderProps<T>, Omit<ComponentPropsWithRef<"thead">, "children" | "className" | "slot" | "style"> {
bordered?: boolean;
}
@@ -202,7 +201,10 @@ const TableHead = ({ className, tooltip, label, children, ...props }: TableHeadP
{state.allowsSorting &&
(state.sortDirection ? (
<FontAwesomeIcon icon={faArrowDown} className={cx("size-3 text-fg-quaternary", state.sortDirection === "ascending" && "rotate-180")} />
<FontAwesomeIcon
icon={faArrowDown}
className={cx("size-3 text-fg-quaternary", state.sortDirection === "ascending" && "rotate-180")}
/>
) : (
<FontAwesomeIcon icon={faSort} className="text-fg-quaternary" />
))}
@@ -214,8 +216,7 @@ const TableHead = ({ className, tooltip, label, children, ...props }: TableHeadP
TableHead.displayName = "TableHead";
interface TableRowProps<T extends object>
extends AriaRowProps<T>,
Omit<ComponentPropsWithRef<"tr">, "children" | "className" | "onClick" | "slot" | "style" | "id"> {
extends AriaRowProps<T>, Omit<ComponentPropsWithRef<"tr">, "children" | "className" | "onClick" | "slot" | "style" | "id"> {
highlightSelectedRow?: boolean;
}

View File

@@ -1,6 +1,6 @@
import { useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faUser } from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { cx } from "@/utils/cx";
import { type AvatarProps } from "./avatar";
import { AvatarOnlineIndicator, VerifiedTick } from "./base-components";

View File

@@ -1,6 +1,6 @@
import { type FC, type ReactNode, useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faUser } from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { cx } from "@/utils/cx";
import { AvatarOnlineIndicator, VerifiedTick } from "./base-components";

View File

@@ -1,5 +1,5 @@
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faPlus } from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import type { ButtonProps as AriaButtonProps } from "react-aria-components";
import { Tooltip as AriaTooltip, TooltipTrigger as AriaTooltipTrigger } from "@/components/base/tooltip/tooltip";
import { cx } from "@/utils/cx";

View File

@@ -1,12 +1,12 @@
import type { FC, ReactNode } from "react";
import { isValidElement } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faArrowRight } from "@fortawesome/pro-duotone-svg-icons";
const ArrowRight: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faArrowRight} className={className} />;
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { cx, sortCx } from "@/utils/cx";
import { isReactComponent } from "@/utils/is-react-component";
const ArrowRight: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faArrowRight} className={className} />;
type Size = "md" | "lg";
type Color = "brand" | "warning" | "error" | "gray" | "success";
type Theme = "light" | "modern";

View File

@@ -1,13 +1,13 @@
import type { FC, MouseEventHandler, ReactNode } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faXmark } from "@fortawesome/pro-duotone-svg-icons";
const CloseX: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faXmark} className={className} />;
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
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";
const CloseX: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faXmark} className={className} />;
export const filledColors: Record<BadgeColors, { root: string; addon: string; addonButton: string }> = {
gray: {
root: "bg-utility-gray-50 text-utility-gray-700 ring-utility-gray-200",

View File

@@ -63,27 +63,20 @@ export const ButtonUtility = ({
const href = "href" in otherProps ? otherProps.href : undefined;
const Component = href ? AriaLink : AriaButton;
let props = {};
if (href) {
props = {
...otherProps,
href: isDisabled ? undefined : href,
// 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
// to use the `disabled:` selector in classes.
...(isDisabled ? { "data-rac": true, "data-disabled": true } : {}),
};
} else {
props = {
...otherProps,
type: otherProps.type || "button",
isDisabled,
};
}
const props = href
? {
...otherProps,
href: isDisabled ? undefined : href,
// 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
// to use the `disabled:` selector in classes.
...(isDisabled ? { "data-rac": true, "data-disabled": true } : {}),
}
: {
...otherProps,
type: otherProps.type || "button",
isDisabled,
};
const content = (
<Component

View File

@@ -192,22 +192,16 @@ export const Button = ({
noTextPadding = isLinkType || noTextPadding;
let props = {};
if (href) {
props = {
...otherProps,
href: disabled ? undefined : href,
};
} else {
props = {
...otherProps,
type: otherProps.type || "button",
isPending: loading,
};
}
const props = href
? {
...otherProps,
href: disabled ? undefined : href,
}
: {
...otherProps,
type: otherProps.type || "button",
isPending: loading,
};
return (
<Component

View File

@@ -1,5 +1,5 @@
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faXmark } from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Button as AriaButton, type ButtonProps as AriaButtonProps } from "react-aria-components";
import { cx } from "@/utils/cx";

View File

@@ -96,27 +96,20 @@ export const SocialButton = ({ size = "lg", theme = "brand", social, className,
const Logo = logos[social];
let props = {};
if (href) {
props = {
...otherProps,
href: disabled ? undefined : href,
// 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
// to use the `disabled:` selector in classes.
...(disabled ? { "data-rac": true, "data-disabled": true } : {}),
};
} else {
props = {
...otherProps,
type: otherProps.type || "button",
isDisabled: disabled,
};
}
const props = href
? {
...otherProps,
href: disabled ? undefined : href,
// 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
// to use the `disabled:` selector in classes.
...(disabled ? { "data-rac": true, "data-disabled": true } : {}),
}
: {
...otherProps,
type: otherProps.type || "button",
isDisabled: disabled,
};
return (
<Component

View File

@@ -1,6 +1,6 @@
import type { FC, RefAttributes } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faEllipsisVertical } from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import type {
ButtonProps as AriaButtonProps,
MenuItemProps as AriaMenuItemProps,
@@ -89,7 +89,7 @@ const DropdownItem = ({ label, children, addon, icon: Icon, unstyled, ...props }
);
};
interface DropdownMenuProps<T extends object> extends AriaMenuProps<T> {}
type DropdownMenuProps<T extends object> = AriaMenuProps<T>;
const DropdownMenu = <T extends object>(props: DropdownMenuProps<T>) => {
return (
@@ -104,7 +104,7 @@ const DropdownMenu = <T extends object>(props: DropdownMenuProps<T>) => {
);
};
interface DropdownPopoverProps extends AriaPopoverProps {}
type DropdownPopoverProps = AriaPopoverProps;
const DropdownPopover = (props: DropdownPopoverProps) => {
return (

View File

@@ -42,6 +42,7 @@ export const FileTrigger = (props: FileTriggerProps) => {
const clonableElement = React.Children.only(children);
// 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>, {
onClick: () => {
if (inputRef.current?.value) {
@@ -63,7 +64,7 @@ export const FileTrigger = (props: FileTriggerProps) => {
onChange={(e) => onSelect?.(e.target.files)}
capture={defaultCamera}
multiple={allowsMultiple}
// @ts-expect-error
// @ts-expect-error -- webkitdirectory is not in React's HTML types but is valid in modern browsers
webkitdirectory={acceptDirectory ? "" : undefined}
/>
</>

View File

@@ -76,7 +76,7 @@ export const formatCardNumber = (number: string) => {
return cleaned;
};
interface PaymentInputProps extends Omit<InputBaseProps, "icon"> {}
type PaymentInputProps = Omit<InputBaseProps, "icon">;
export const PaymentInput = ({ onChange, value, defaultValue, className, maxLength = 19, label, hint, ...props }: PaymentInputProps) => {
const [cardNumber, setCardNumber] = useControlledState(value, defaultValue || "", (value) => {

View File

@@ -1,6 +1,6 @@
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 { faCircleQuestion, faCircleExclamation } from "@fortawesome/pro-duotone-svg-icons";
import type { InputProps as AriaInputProps, TextFieldProps as AriaTextFieldProps } from "react-aria-components";
import { Group as AriaGroup, Input as AriaInput, TextField as AriaTextField } from "react-aria-components";
import { HintText } from "@/components/base/input/hint-text";
@@ -192,9 +192,7 @@ interface BaseProps {
}
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>;
}

View File

@@ -1,6 +1,6 @@
import type { ReactNode, Ref } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCircleQuestion } from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
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";

View File

@@ -1,7 +1,7 @@
import type { FocusEventHandler, PointerEventHandler, RefAttributes, RefObject } from "react";
import { useCallback, useContext, useRef, useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faMagnifyingGlass } from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import type { ComboBoxProps as AriaComboBoxProps, GroupProps as AriaGroupProps, ListBoxProps as AriaListBoxProps } from "react-aria-components";
import { ComboBox as AriaComboBox, Group as AriaGroup, Input as AriaInput, ListBox as AriaListBox, ComboBoxStateContext } from "react-aria-components";
import { HintText } from "@/components/base/input/hint-text";

View File

@@ -1,9 +1,7 @@
import type { FC, FocusEventHandler, KeyboardEvent, PointerEventHandler, RefAttributes, RefObject } from "react";
import { createContext, useCallback, useContext, useRef, useState } from "react";
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 { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { FocusScope, useFilter, useFocusManager } from "react-aria";
import type { ComboBoxProps as AriaComboBoxProps, GroupProps as AriaGroupProps, ListBoxProps as AriaListBoxProps, Key } from "react-aria-components";
import { ComboBox as AriaComboBox, Group as AriaGroup, Input as AriaInput, ListBox as AriaListBox, ComboBoxStateContext } from "react-aria-components";
@@ -20,6 +18,8 @@ import { useResizeObserver } from "@/hooks/use-resize-observer";
import { cx } from "@/utils/cx";
import { SelectItem } from "./select-item";
const SearchIcon: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />;
interface ComboBoxValueProps extends AriaGroupProps {
size: "sm" | "md";
shortcut?: boolean;
@@ -133,7 +133,7 @@ export const MultiSelectBase = ({
// Resize observer for popover width
const onResize = useCallback(() => {
if (!placeholderRef.current) return;
let divRect = placeholderRef.current?.getBoundingClientRect();
const divRect = placeholderRef.current?.getBoundingClientRect();
setPopoverWidth(divRect.width + "px");
}, [placeholderRef, setPopoverWidth]);

View File

@@ -1,6 +1,6 @@
import { isValidElement, useContext } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCheck } from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
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";
@@ -83,11 +83,7 @@ export const SelectItem = ({ label, id, value, avatarUrl, supportingText, isDisa
<FontAwesomeIcon
icon={faCheck}
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>

View File

@@ -1,6 +1,6 @@
import { type SelectHTMLAttributes, useId } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faChevronDown } from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { HintText } from "@/components/base/input/hint-text";
import { Label } from "@/components/base/input/label";
import { cx } from "@/utils/cx";

View File

@@ -1,7 +1,7 @@
import type { FC, ReactNode, Ref, RefAttributes } from "react";
import { createContext, isValidElement } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faChevronDown } from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import type { SelectProps as AriaSelectProps } from "react-aria-components";
import { Button as AriaButton, ListBox as AriaListBox, Select as AriaSelect, SelectValue as AriaSelectValue } from "react-aria-components";
import { Avatar } from "@/components/base/avatar/avatar";

View File

@@ -1,6 +1,6 @@
import type { RefAttributes } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faXmark } from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Button as AriaButton, type ButtonProps as AriaButtonProps } from "react-aria-components";
import { cx } from "@/utils/cx";

View File

@@ -96,7 +96,7 @@ export const Tooltip = ({
);
};
interface TooltipTriggerProps extends AriaButtonProps {}
type TooltipTriggerProps = AriaButtonProps;
export const TooltipTrigger = ({ children, className, ...buttonProps }: TooltipTriggerProps) => {
return (

View File

@@ -1,27 +1,35 @@
import { useState, useRef } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { useEffect, useRef, useState } from "react";
import {
faPhone, faPhoneHangup, faMicrophone, faMicrophoneSlash,
faPause, faPlay, faCalendarPlus, faCheckCircle,
faPhoneArrowRight, faRecordVinyl, faClipboardQuestion,
} from '@fortawesome/pro-duotone-svg-icons';
import { Button } from '@/components/base/buttons/button';
import { Badge } from '@/components/base/badges/badges';
import { useSetAtom } from 'jotai';
import { sipCallStateAtom, sipCallerNumberAtom, sipCallUcidAtom } from '@/state/sip-state';
import { setOutboundPending } from '@/state/sip-manager';
import { useSip } from '@/providers/sip-provider';
import { DispositionForm } from './disposition-form';
import { AppointmentForm } from './appointment-form';
import { TransferDialog } from './transfer-dialog';
import { EnquiryForm } from './enquiry-form';
import { formatPhone } from '@/lib/format';
import { apiClient } from '@/lib/api-client';
import { cx } from '@/utils/cx';
import { notify } from '@/lib/toast';
import type { Lead, CallDisposition } from '@/types/entities';
faCalendarPlus,
faCheckCircle,
faClipboardQuestion,
faMicrophone,
faMicrophoneSlash,
faPause,
faPhone,
faPhoneArrowRight,
faPhoneHangup,
faPlay,
faRecordVinyl,
} from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useSetAtom } from "jotai";
import { Badge } from "@/components/base/badges/badges";
import { Button } from "@/components/base/buttons/button";
import { apiClient } from "@/lib/api-client";
import { formatPhone } from "@/lib/format";
import { notify } from "@/lib/toast";
import { useSip } from "@/providers/sip-provider";
import { setOutboundPending } from "@/state/sip-manager";
import { sipCallStateAtom, sipCallUcidAtom, sipCallerNumberAtom } from "@/state/sip-state";
import type { CallDisposition, Lead } from "@/types/entities";
import { cx } from "@/utils/cx";
import { AppointmentForm } from "./appointment-form";
import { DispositionForm } from "./disposition-form";
import { EnquiryForm } from "./enquiry-form";
import { TransferDialog } from "./transfer-dialog";
type PostCallStage = 'disposition' | 'appointment' | 'follow-up' | 'done';
type PostCallStage = "disposition" | "appointment" | "follow-up" | "done";
interface ActiveCallCardProps {
lead: Lead | null;
@@ -33,7 +41,7 @@ interface ActiveCallCardProps {
const formatDuration = (seconds: number): string => {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
return `${m}:${s.toString().padStart(2, "0")}`;
};
export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete }: ActiveCallCardProps) => {
@@ -49,76 +57,89 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
const [recordingPaused, setRecordingPaused] = useState(false);
const [enquiryOpen, setEnquiryOpen] = useState(false);
// Capture direction at mount — survives through disposition stage
const callDirectionRef = useRef(callState === 'ringing-out' ? 'OUTBOUND' : 'INBOUND');
const callDirectionRef = useRef(callState === "ringing-out" ? "OUTBOUND" : "INBOUND");
// Track if the call was ever answered (reached 'active' state)
const wasAnsweredRef = useRef(callState === 'active');
const [wasAnswered, setWasAnswered] = useState(callState === "active");
const firstName = lead?.contactName?.firstName ?? '';
const lastName = lead?.contactName?.lastName ?? '';
useEffect(() => {
if (callState === "active") {
// eslint-disable-next-line react-hooks/set-state-in-effect
setWasAnswered(true);
}
}, [callState]);
const firstName = lead?.contactName?.firstName ?? "";
const lastName = lead?.contactName?.lastName ?? "";
const fullName = `${firstName} ${lastName}`.trim();
const phone = lead?.contactPhone?.[0];
const phoneDisplay = phone ? formatPhone(phone) : callerPhone || 'Unknown';
const phoneDisplay = phone ? formatPhone(phone) : callerPhone || "Unknown";
const handleDisposition = async (disposition: CallDisposition, notes: string) => {
setSavedDisposition(disposition);
// Submit disposition to sidecar — handles Ozonetel ACW release
if (callUcid) {
apiClient.post('/api/ozonetel/dispose', {
ucid: callUcid,
disposition,
callerPhone,
direction: callDirectionRef.current,
durationSec: callDuration,
leadId: lead?.id ?? null,
notes,
missedCallId: missedCallId ?? undefined,
}).catch((err) => console.warn('Disposition failed:', err));
apiClient
.post("/api/ozonetel/dispose", {
ucid: callUcid,
disposition,
callerPhone,
direction: callDirectionRef.current,
durationSec: callDuration,
leadId: lead?.id ?? null,
notes,
missedCallId: missedCallId ?? undefined,
})
.catch((err) => console.warn("Disposition failed:", err));
}
if (disposition === 'APPOINTMENT_BOOKED') {
setPostCallStage('appointment');
if (disposition === "APPOINTMENT_BOOKED") {
setPostCallStage("appointment");
setAppointmentOpen(true);
} else if (disposition === 'FOLLOW_UP_SCHEDULED') {
setPostCallStage('follow-up');
} else if (disposition === "FOLLOW_UP_SCHEDULED") {
setPostCallStage("follow-up");
// Create follow-up
try {
await apiClient.graphql(`mutation($data: FollowUpCreateInput!) { createFollowUp(data: $data) { id } }`, {
data: {
name: `Follow-up — ${fullName || phoneDisplay}`,
typeCustom: 'CALLBACK',
status: 'PENDING',
assignedAgent: null,
priority: 'NORMAL',
scheduledAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
await apiClient.graphql(
`mutation($data: FollowUpCreateInput!) { createFollowUp(data: $data) { id } }`,
{
data: {
name: `Follow-up — ${fullName || phoneDisplay}`,
typeCustom: "CALLBACK",
status: "PENDING",
assignedAgent: null,
priority: "NORMAL",
scheduledAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
},
},
}, { silent: true });
notify.success('Follow-up Created', 'Callback scheduled for tomorrow');
{ silent: true },
);
notify.success("Follow-up Created", "Callback scheduled for tomorrow");
} catch {
notify.info('Follow-up', 'Could not auto-create follow-up');
notify.info("Follow-up", "Could not auto-create follow-up");
}
setPostCallStage('done');
setPostCallStage("done");
} else {
notify.success('Call Logged', `Disposition: ${disposition.replace(/_/g, ' ').toLowerCase()}`);
setPostCallStage('done');
notify.success("Call Logged", `Disposition: ${disposition.replace(/_/g, " ").toLowerCase()}`);
setPostCallStage("done");
}
};
const handleAppointmentSaved = () => {
setAppointmentOpen(false);
notify.success('Appointment Booked', 'Payment link will be sent to the patient');
notify.success("Appointment Booked", "Payment link will be sent to the patient");
// If booked during active call, don't skip to 'done' — wait for disposition after call ends
if (callState === 'active') {
if (callState === "active") {
setAppointmentBookedDuringCall(true);
} else {
setPostCallStage('done');
setPostCallStage("done");
}
};
const handleReset = () => {
setPostCallStage(null);
setSavedDisposition(null);
setCallState('idle');
setCallState("idle");
setCallerNumber(null);
setCallUcid(null);
setOutboundPending(false);
@@ -126,7 +147,7 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
};
// Outbound ringing — agent initiated the call
if (callState === 'ringing-out') {
if (callState === "ringing-out") {
return (
<div className="rounded-xl bg-brand-primary p-4">
<div className="flex items-center gap-3">
@@ -137,13 +158,20 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
</div>
</div>
<div className="min-w-0 flex-1">
<p className="text-xs font-bold uppercase tracking-wider text-brand-secondary">Calling...</p>
<p className="text-xs font-bold tracking-wider text-brand-secondary uppercase">Calling...</p>
<p className="text-lg font-bold text-primary">{fullName || phoneDisplay}</p>
{fullName && <p className="text-sm text-secondary">{phoneDisplay}</p>}
</div>
</div>
<div className="mt-3 flex gap-2">
<Button size="sm" color="primary-destructive" onClick={() => { hangup(); handleReset(); }}>
<Button
size="sm"
color="primary-destructive"
onClick={() => {
hangup();
handleReset();
}}
>
End Call
</Button>
</div>
@@ -152,37 +180,41 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
}
// Inbound ringing
if (callState === 'ringing-in') {
if (callState === "ringing-in") {
return (
<div className="rounded-xl bg-brand-primary p-4">
<div className="flex items-center gap-3">
<div className="relative">
<div className="absolute inset-0 animate-ping rounded-full bg-brand-solid opacity-20" />
<div className="relative flex size-10 items-center justify-center rounded-full bg-brand-solid">
<FontAwesomeIcon icon={faPhone} className="size-4 text-white animate-bounce" />
<FontAwesomeIcon icon={faPhone} className="size-4 animate-bounce text-white" />
</div>
</div>
<div className="min-w-0 flex-1">
<p className="text-xs font-bold uppercase tracking-wider text-brand-secondary">Incoming Call</p>
<p className="text-xs font-bold tracking-wider text-brand-secondary uppercase">Incoming Call</p>
<p className="text-lg font-bold text-primary">{fullName || phoneDisplay}</p>
{fullName && <p className="text-sm text-secondary">{phoneDisplay}</p>}
</div>
</div>
<div className="mt-3 flex gap-2">
<Button size="sm" color="primary" onClick={answer}>Answer</Button>
<Button size="sm" color="tertiary-destructive" onClick={reject}>Decline</Button>
<Button size="sm" color="primary" onClick={answer}>
Answer
</Button>
<Button size="sm" color="tertiary-destructive" onClick={reject}>
Decline
</Button>
</div>
</div>
);
}
// Skip disposition for unanswered calls (ringing-in → ended without ever reaching active)
if (!wasAnsweredRef.current && postCallStage === null && (callState === 'ended' || callState === 'failed')) {
if (!wasAnswered && postCallStage === null && (callState === "ended" || callState === "failed")) {
return (
<div className="rounded-xl border border-secondary bg-primary p-4 text-center">
<FontAwesomeIcon icon={faPhoneHangup} className="size-6 text-fg-quaternary mb-2" />
<FontAwesomeIcon icon={faPhoneHangup} className="mb-2 size-6 text-fg-quaternary" />
<p className="text-sm font-semibold text-primary">Missed Call</p>
<p className="text-xs text-tertiary mt-1">{phoneDisplay} not answered</p>
<p className="mt-1 text-xs text-tertiary">{phoneDisplay} not answered</p>
<Button size="sm" color="secondary" className="mt-3" onClick={handleReset}>
Back to Worklist
</Button>
@@ -191,16 +223,14 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
}
// Post-call flow takes priority over active state (handles race between hangup + SIP ended event)
if (postCallStage !== null || callState === 'ended' || callState === 'failed') {
if (postCallStage !== null || callState === "ended" || callState === "failed") {
// Done state
if (postCallStage === 'done') {
if (postCallStage === "done") {
return (
<div className="rounded-xl border border-success bg-success-primary p-4 text-center">
<FontAwesomeIcon icon={faCheckCircle} className="size-8 text-fg-success-primary mb-2" />
<div className="border-success rounded-xl border bg-success-primary p-4 text-center">
<FontAwesomeIcon icon={faCheckCircle} className="mb-2 size-8 text-fg-success-primary" />
<p className="text-sm font-semibold text-success-primary">Call Completed</p>
<p className="text-xs text-tertiary mt-1">
{savedDisposition ? savedDisposition.replace(/_/g, ' ').toLowerCase() : 'logged'}
</p>
<p className="mt-1 text-xs text-tertiary">{savedDisposition ? savedDisposition.replace(/_/g, " ").toLowerCase() : "logged"}</p>
<Button size="sm" color="secondary" className="mt-3" onClick={handleReset}>
Back to Worklist
</Button>
@@ -209,19 +239,19 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
}
// Appointment booking after disposition
if (postCallStage === 'appointment') {
if (postCallStage === "appointment") {
return (
<>
<div className="rounded-xl border border-brand bg-brand-primary p-4 text-center">
<FontAwesomeIcon icon={faCalendarPlus} className="size-6 text-fg-brand-primary mb-2" />
<FontAwesomeIcon icon={faCalendarPlus} className="mb-2 size-6 text-fg-brand-primary" />
<p className="text-sm font-semibold text-brand-secondary">Booking Appointment</p>
<p className="text-xs text-tertiary mt-1">for {fullName || phoneDisplay}</p>
<p className="mt-1 text-xs text-tertiary">for {fullName || phoneDisplay}</p>
</div>
<AppointmentForm
isOpen={appointmentOpen}
onOpenChange={(open) => {
setAppointmentOpen(open);
if (!open) setPostCallStage('done');
if (!open) setPostCallStage("done");
}}
callerNumber={callerPhone}
leadName={fullName || null}
@@ -235,7 +265,7 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
// Disposition form
return (
<div className="rounded-xl border border-secondary bg-primary p-4">
<div className="flex items-center gap-2 mb-3">
<div className="mb-3 flex items-center gap-2">
<div className="flex size-8 items-center justify-center rounded-full bg-secondary">
<FontAwesomeIcon icon={faPhoneHangup} className="size-3.5 text-fg-quaternary" />
</div>
@@ -244,14 +274,13 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
<p className="text-xs text-tertiary">{formatDuration(callDuration)} · Log this call</p>
</div>
</div>
<DispositionForm onSubmit={handleDisposition} defaultDisposition={appointmentBookedDuringCall ? 'APPOINTMENT_BOOKED' : null} />
<DispositionForm onSubmit={handleDisposition} defaultDisposition={appointmentBookedDuringCall ? "APPOINTMENT_BOOKED" : null} />
</div>
);
}
// Active call
if (callState === 'active') {
wasAnsweredRef.current = true;
if (callState === "active") {
return (
<div className="rounded-xl border border-brand bg-primary p-4">
<div className="flex items-center justify-between">
@@ -264,60 +293,86 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
{fullName && <p className="text-xs text-tertiary">{phoneDisplay}</p>}
</div>
</div>
<Badge size="md" color="success" type="pill-color">{formatDuration(callDuration)}</Badge>
<Badge size="md" color="success" type="pill-color">
{formatDuration(callDuration)}
</Badge>
</div>
<div className="mt-3 flex items-center gap-1.5">
{/* Icon-only toggles */}
<button
onClick={toggleMute}
title={isMuted ? 'Unmute' : 'Mute'}
title={isMuted ? "Unmute" : "Mute"}
className={cx(
'flex size-8 items-center justify-center rounded-lg transition duration-100 ease-linear',
isMuted ? 'bg-error-solid text-white' : 'bg-secondary text-fg-quaternary hover:bg-secondary_hover hover:text-fg-secondary',
"flex size-8 items-center justify-center rounded-lg transition duration-100 ease-linear",
isMuted ? "bg-error-solid text-white" : "bg-secondary text-fg-quaternary hover:bg-secondary_hover hover:text-fg-secondary",
)}
>
<FontAwesomeIcon icon={isMuted ? faMicrophoneSlash : faMicrophone} className="size-3.5" />
</button>
<button
onClick={toggleHold}
title={isOnHold ? 'Resume' : 'Hold'}
title={isOnHold ? "Resume" : "Hold"}
className={cx(
'flex size-8 items-center justify-center rounded-lg transition duration-100 ease-linear',
isOnHold ? 'bg-warning-solid text-white' : 'bg-secondary text-fg-quaternary hover:bg-secondary_hover hover:text-fg-secondary',
"flex size-8 items-center justify-center rounded-lg transition duration-100 ease-linear",
isOnHold ? "bg-warning-solid text-white" : "bg-secondary text-fg-quaternary hover:bg-secondary_hover hover:text-fg-secondary",
)}
>
<FontAwesomeIcon icon={isOnHold ? faPlay : faPause} className="size-3.5" />
</button>
<button
onClick={() => {
const action = recordingPaused ? 'unPause' : 'pause';
if (callUcid) apiClient.post('/api/ozonetel/recording', { ucid: callUcid, action }).catch(() => {});
setRecordingPaused(!recordingPaused);
const action = recordingPaused ? "unPause" : "pause";
if (callUcid) apiClient.post("/api/ozonetel/recording", { ucid: callUcid, action }).catch(() => {});
setRecordingPaused((prev) => !prev);
}}
title={recordingPaused ? 'Resume Recording' : 'Pause Recording'}
title={recordingPaused ? "Resume Recording" : "Pause Recording"}
className={cx(
'flex size-8 items-center justify-center rounded-lg transition duration-100 ease-linear',
recordingPaused ? 'bg-error-solid text-white' : 'bg-secondary text-fg-quaternary hover:bg-secondary_hover hover:text-fg-secondary',
"flex size-8 items-center justify-center rounded-lg transition duration-100 ease-linear",
recordingPaused ? "bg-error-solid text-white" : "bg-secondary text-fg-quaternary hover:bg-secondary_hover hover:text-fg-secondary",
)}
>
<FontAwesomeIcon icon={faRecordVinyl} className="size-3.5" />
</button>
<div className="w-px h-6 bg-secondary mx-0.5" />
<div className="mx-0.5 h-6 w-px bg-secondary" />
{/* Text+Icon primary actions */}
<Button size="sm" color="secondary"
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faCalendarPlus} className={className} {...rest} />}
onClick={() => setAppointmentOpen(true)}>Book Appt</Button>
<Button size="sm" color="secondary"
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faClipboardQuestion} className={className} {...rest} />}
onClick={() => setEnquiryOpen(!enquiryOpen)}>Enquiry</Button>
<Button size="sm" color="secondary"
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faPhoneArrowRight} className={className} {...rest} />}
onClick={() => setTransferOpen(!transferOpen)}>Transfer</Button>
<Button size="sm" color="primary-destructive" className="ml-auto"
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faPhoneHangup} className={className} {...rest} />}
onClick={() => { hangup(); setPostCallStage('disposition'); }}>End</Button>
<Button
size="sm"
color="secondary"
iconLeading={<FontAwesomeIcon icon={faCalendarPlus} data-icon className="size-3.5" />}
onClick={() => setAppointmentOpen(true)}
>
Book Appt
</Button>
<Button
size="sm"
color="secondary"
iconLeading={<FontAwesomeIcon icon={faClipboardQuestion} data-icon className="size-3.5" />}
onClick={() => setEnquiryOpen((prev) => !prev)}
>
Enquiry
</Button>
<Button
size="sm"
color="secondary"
iconLeading={<FontAwesomeIcon icon={faPhoneArrowRight} data-icon className="size-3.5" />}
onClick={() => setTransferOpen((prev) => !prev)}
>
Transfer
</Button>
<Button
size="sm"
color="primary-destructive"
className="ml-auto"
iconLeading={<FontAwesomeIcon icon={faPhoneHangup} data-icon className="size-3.5" />}
onClick={() => {
hangup();
setPostCallStage("disposition");
}}
>
End
</Button>
</div>
{/* Transfer dialog */}
@@ -328,7 +383,7 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
onTransferred={() => {
setTransferOpen(false);
hangup();
setPostCallStage('disposition');
setPostCallStage("disposition");
}}
/>
)}
@@ -350,7 +405,7 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
callerPhone={callerPhone}
onSaved={() => {
setEnquiryOpen(false);
notify.success('Enquiry Logged');
notify.success("Enquiry Logged");
}}
/>
</div>

View File

@@ -1,17 +1,17 @@
import { useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faCircle, faChevronDown } from '@fortawesome/pro-duotone-svg-icons';
import { apiClient } from '@/lib/api-client';
import { notify } from '@/lib/toast';
import { cx } from '@/utils/cx';
import { useState } from "react";
import { faChevronDown, faCircle } from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { apiClient } from "@/lib/api-client";
import { notify } from "@/lib/toast";
import { cx } from "@/utils/cx";
type AgentStatus = 'ready' | 'break' | 'training' | 'offline';
type AgentStatus = "ready" | "break" | "training" | "offline";
const statusConfig: Record<AgentStatus, { label: string; color: string; dotColor: string }> = {
ready: { label: 'Ready', color: 'text-success-primary', dotColor: 'text-fg-success-primary' },
break: { label: 'Break', color: 'text-warning-primary', dotColor: 'text-fg-warning-primary' },
training: { label: 'Training', color: 'text-brand-secondary', dotColor: 'text-fg-brand-primary' },
offline: { label: 'Offline', color: 'text-tertiary', dotColor: 'text-fg-quaternary' },
ready: { label: "Ready", color: "text-success-primary", dotColor: "text-fg-success-primary" },
break: { label: "Break", color: "text-warning-primary", dotColor: "text-fg-warning-primary" },
training: { label: "Training", color: "text-brand-secondary", dotColor: "text-fg-brand-primary" },
offline: { label: "Offline", color: "text-tertiary", dotColor: "text-fg-quaternary" },
};
type AgentStatusToggleProps = {
@@ -20,7 +20,7 @@ type AgentStatusToggleProps = {
};
export const AgentStatusToggle = ({ isRegistered, connectionStatus }: AgentStatusToggleProps) => {
const [status, setStatus] = useState<AgentStatus>(isRegistered ? 'ready' : 'offline');
const [status, setStatus] = useState<AgentStatus>(isRegistered ? "ready" : "offline");
const [menuOpen, setMenuOpen] = useState(false);
const [changing, setChanging] = useState(false);
@@ -30,20 +30,20 @@ export const AgentStatusToggle = ({ isRegistered, connectionStatus }: AgentStatu
setChanging(true);
try {
if (newStatus === 'ready') {
await apiClient.post('/api/ozonetel/agent-state', { state: 'Ready' });
} else if (newStatus === 'offline') {
await apiClient.post('/api/ozonetel/agent-logout', {
agentId: 'global',
password: 'Test123$',
if (newStatus === "ready") {
await apiClient.post("/api/ozonetel/agent-state", { state: "Ready" });
} else if (newStatus === "offline") {
await apiClient.post("/api/ozonetel/agent-logout", {
agentId: "global",
password: "Test123$",
});
} else {
const pauseReason = newStatus === 'break' ? 'Break' : 'Training';
await apiClient.post('/api/ozonetel/agent-state', { state: 'Pause', pauseReason });
const pauseReason = newStatus === "break" ? "Break" : "Training";
await apiClient.post("/api/ozonetel/agent-state", { state: "Pause", pauseReason });
}
setStatus(newStatus);
} catch {
notify.error('Status Change Failed', 'Could not update agent status');
notify.error("Status Change Failed", "Could not update agent status");
} finally {
setChanging(false);
}
@@ -53,7 +53,7 @@ export const AgentStatusToggle = ({ isRegistered, connectionStatus }: AgentStatu
if (!isRegistered) {
return (
<div className="flex items-center gap-1.5 rounded-full bg-secondary px-3 py-1">
<FontAwesomeIcon icon={faCircle} className="size-2 text-fg-warning-primary animate-pulse" />
<FontAwesomeIcon icon={faCircle} className="size-2 animate-pulse text-fg-warning-primary" />
<span className="text-xs font-medium text-tertiary">{connectionStatus}</span>
</div>
);
@@ -67,30 +67,30 @@ export const AgentStatusToggle = ({ isRegistered, connectionStatus }: AgentStatu
onClick={() => setMenuOpen(!menuOpen)}
disabled={changing}
className={cx(
'flex items-center gap-1.5 rounded-full bg-secondary px-3 py-1 transition duration-100 ease-linear',
'hover:bg-secondary_hover cursor-pointer',
changing && 'opacity-50',
"flex items-center gap-1.5 rounded-full bg-secondary px-3 py-1 transition duration-100 ease-linear",
"cursor-pointer hover:bg-secondary_hover",
changing && "opacity-50",
)}
>
<FontAwesomeIcon icon={faCircle} className={cx('size-2', current.dotColor)} />
<span className={cx('text-xs font-medium', current.color)}>{current.label}</span>
<FontAwesomeIcon icon={faCircle} className={cx("size-2", current.dotColor)} />
<span className={cx("text-xs font-medium", current.color)}>{current.label}</span>
<FontAwesomeIcon icon={faChevronDown} className="size-2.5 text-fg-quaternary" />
</button>
{menuOpen && (
<>
<div className="fixed inset-0 z-40" onClick={() => setMenuOpen(false)} />
<div className="absolute right-0 top-full z-50 mt-1 w-36 rounded-lg bg-primary shadow-lg ring-1 ring-secondary py-1">
<div className="absolute top-full right-0 z-50 mt-1 w-36 rounded-lg bg-primary py-1 shadow-lg ring-1 ring-secondary">
{(Object.entries(statusConfig) as [AgentStatus, typeof current][]).map(([key, cfg]) => (
<button
key={key}
onClick={() => handleChange(key)}
className={cx(
'flex w-full items-center gap-2 px-3 py-2 text-xs font-medium transition duration-100 ease-linear',
key === status ? 'bg-active' : 'hover:bg-primary_hover',
"flex w-full items-center gap-2 px-3 py-2 text-xs font-medium transition duration-100 ease-linear",
key === status ? "bg-active" : "hover:bg-primary_hover",
)}
>
<FontAwesomeIcon icon={faCircle} className={cx('size-2', cfg.dotColor)} />
<FontAwesomeIcon icon={faCircle} className={cx("size-2", cfg.dotColor)} />
<span className={cfg.color}>{cfg.label}</span>
</button>
))}

View File

@@ -1,12 +1,12 @@
import type { ReactNode } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPaperPlaneTop, faRobot, faSparkles, faUserHeadset } from '@fortawesome/pro-duotone-svg-icons';
import { apiClient } from '@/lib/api-client';
import type { ReactNode } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { faPaperPlaneTop, faRobot, faSparkles, faUserHeadset } from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { apiClient } from "@/lib/api-client";
type ChatMessage = {
id: string;
role: 'user' | 'assistant';
role: "user" | "assistant";
content: string;
timestamp: Date;
};
@@ -19,92 +19,101 @@ type CallerContext = {
interface AiChatPanelProps {
callerContext?: CallerContext;
role?: 'cc-agent' | 'admin' | 'executive';
role?: "cc-agent" | "admin" | "executive";
}
const QUICK_ASK_AGENT = [
{ label: 'Doctor availability', template: 'What are the visiting hours for all doctors?' },
{ label: 'Clinic timings', template: 'What are the clinic locations and timings?' },
{ label: 'Patient history', template: 'Can you summarize this patient\'s history?' },
{ label: 'Treatment packages', template: 'What treatment packages are available?' },
{ label: "Doctor availability", template: "What are the visiting hours for all doctors?" },
{ label: "Clinic timings", template: "What are the clinic locations and timings?" },
{ label: "Patient history", template: "Can you summarize this patient's history?" },
{ label: "Treatment packages", template: "What treatment packages are available?" },
];
const QUICK_ASK_MANAGER = [
{ label: 'Agent performance', template: 'Which agents have the highest appointment conversion rates this week?' },
{ label: 'Missed call risks', template: 'Which missed calls have been waiting the longest without a callback?' },
{ label: 'Pending leads', template: 'How many leads are still pending first contact?' },
{ label: 'Weekly summary', template: 'Give me a summary of this week\'s team performance — total calls, conversions, missed calls.' },
{ label: "Agent performance", template: "Which agents have the highest appointment conversion rates this week?" },
{ label: "Missed call risks", template: "Which missed calls have been waiting the longest without a callback?" },
{ label: "Pending leads", template: "How many leads are still pending first contact?" },
{ label: "Weekly summary", template: "Give me a summary of this week's team performance — total calls, conversions, missed calls." },
];
export const AiChatPanel = ({ callerContext, role = 'cc-agent' }: AiChatPanelProps) => {
const quickButtons = role === 'admin' ? QUICK_ASK_MANAGER : QUICK_ASK_AGENT;
export const AiChatPanel = ({ callerContext, role = "cc-agent" }: AiChatPanelProps) => {
const quickButtons = role === "admin" ? QUICK_ASK_MANAGER : QUICK_ASK_AGENT;
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState('');
const [input, setInput] = useState("");
const [isLoading, setIsLoading] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const scrollToBottom = useCallback(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, []);
useEffect(() => {
scrollToBottom();
}, [messages, scrollToBottom]);
const sendMessage = useCallback(async (text?: string) => {
const messageText = (text ?? input).trim();
if (messageText.length === 0 || isLoading) return;
const sendMessage = useCallback(
async (text?: string) => {
const messageText = (text ?? input).trim();
if (messageText.length === 0 || isLoading) return;
const userMessage: ChatMessage = {
id: `user-${Date.now()}`,
role: 'user',
content: messageText,
timestamp: new Date(),
};
setMessages((prev) => [...prev, userMessage]);
setInput('');
setIsLoading(true);
try {
const data = await apiClient.post<{ reply: string; sources?: string[] }>('/api/ai/chat', {
message: messageText,
context: callerContext,
});
const assistantMessage: ChatMessage = {
id: `assistant-${Date.now()}`,
role: 'assistant',
content: data.reply ?? 'Sorry, I could not process that request.',
const userMessage: ChatMessage = {
id: `user-${Date.now()}`,
role: "user",
content: messageText,
timestamp: new Date(),
};
setMessages((prev) => [...prev, assistantMessage]);
} catch {
const errorMessage: ChatMessage = {
id: `error-${Date.now()}`,
role: 'assistant',
content: 'Sorry, I\'m having trouble connecting to the AI service. Please try again.',
timestamp: new Date(),
};
setMessages((prev) => [...prev, errorMessage]);
} finally {
setIsLoading(false);
inputRef.current?.focus();
}
}, [input, isLoading, callerContext]);
setMessages((prev) => [...prev, userMessage]);
setInput("");
setIsLoading(true);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
}, [sendMessage]);
try {
const data = await apiClient.post<{ reply: string; sources?: string[] }>("/api/ai/chat", {
message: messageText,
context: callerContext,
});
const handleQuickAsk = useCallback((template: string) => {
sendMessage(template);
}, [sendMessage]);
const assistantMessage: ChatMessage = {
id: `assistant-${Date.now()}`,
role: "assistant",
content: data.reply ?? "Sorry, I could not process that request.",
timestamp: new Date(),
};
setMessages((prev) => [...prev, assistantMessage]);
} catch {
const errorMessage: ChatMessage = {
id: `error-${Date.now()}`,
role: "assistant",
content: "Sorry, I'm having trouble connecting to the AI service. Please try again.",
timestamp: new Date(),
};
setMessages((prev) => [...prev, errorMessage]);
} finally {
setIsLoading(false);
inputRef.current?.focus();
}
},
[input, isLoading, callerContext],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
},
[sendMessage],
);
const handleQuickAsk = useCallback(
(template: string) => {
sendMessage(template);
},
[sendMessage],
);
return (
<div className="flex h-full flex-col">
@@ -113,7 +122,7 @@ export const AiChatPanel = ({ callerContext, role = 'cc-agent' }: AiChatPanelPro
<div className="mb-3 rounded-lg bg-brand-primary px-3 py-2">
<span className="text-xs text-brand-secondary">
Talking to: <span className="font-semibold">{callerContext.leadName}</span>
{callerContext.callerPhone ? ` (${callerContext.callerPhone})` : ''}
{callerContext.callerPhone ? ` (${callerContext.callerPhone})` : ""}
</span>
</div>
)}
@@ -139,28 +148,21 @@ export const AiChatPanel = ({ callerContext, role = 'cc-agent' }: AiChatPanelPro
{messages.length === 0 && (
<div className="flex flex-col items-center justify-center py-8 text-center">
<FontAwesomeIcon icon={faRobot} className="mb-3 size-8 text-fg-quaternary" />
<p className="text-sm text-tertiary">
Ask me about doctors, clinics, packages, or patient info.
</p>
<p className="text-sm text-tertiary">Ask me about doctors, clinics, packages, or patient info.</p>
</div>
)}
{messages.map((msg) => (
<div
key={msg.id}
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
>
<div key={msg.id} className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}>
<div
className={`max-w-[90%] rounded-xl px-3 py-2 text-xs leading-relaxed ${
msg.role === 'user'
? 'bg-brand-solid text-white'
: 'bg-secondary text-primary'
msg.role === "user" ? "bg-brand-solid text-white" : "bg-secondary text-primary"
}`}
>
{msg.role === 'assistant' && (
{msg.role === "assistant" && (
<div className="mb-1 flex items-center gap-1">
<FontAwesomeIcon icon={faSparkles} className="size-2.5 text-fg-brand-primary" />
<span className="text-[10px] font-semibold uppercase tracking-wider text-brand-secondary">AI</span>
<span className="text-[10px] font-semibold tracking-wider text-brand-secondary uppercase">AI</span>
</div>
)}
<MessageContent content={msg.content} />
@@ -186,10 +188,7 @@ export const AiChatPanel = ({ callerContext, role = 'cc-agent' }: AiChatPanelPro
{/* Input area */}
<div className="mt-3 flex items-center gap-2">
<div className="flex flex-1 items-center rounded-lg border border-secondary bg-primary shadow-xs transition duration-100 ease-linear focus-within:border-brand focus-within:ring-4 focus-within:ring-brand-100">
<FontAwesomeIcon
icon={faUserHeadset}
className="ml-2.5 size-3.5 text-fg-quaternary"
/>
<FontAwesomeIcon icon={faUserHeadset} className="ml-2.5 size-3.5 text-fg-quaternary" />
<input
ref={inputRef}
type="text"
@@ -198,7 +197,7 @@ export const AiChatPanel = ({ callerContext, role = 'cc-agent' }: AiChatPanelPro
onKeyDown={handleKeyDown}
placeholder="Ask the AI assistant..."
disabled={isLoading}
className="flex-1 bg-transparent px-2 py-2 text-xs text-primary placeholder:text-placeholder outline-none disabled:cursor-not-allowed"
className="flex-1 bg-transparent px-2 py-2 text-xs text-primary outline-none placeholder:text-placeholder disabled:cursor-not-allowed"
/>
</div>
<button
@@ -240,7 +239,7 @@ const parseLine = (text: string): ReactNode[] => {
};
const MessageContent = ({ content }: { content: string }) => {
const lines = content.split('\n');
const lines = content.split("\n");
return (
<div className="space-y-1">
@@ -248,11 +247,11 @@ const MessageContent = ({ content }: { content: string }) => {
if (line.trim().length === 0) return <div key={i} className="h-1" />;
// Bullet points
if (line.trimStart().startsWith('- ')) {
if (line.trimStart().startsWith("- ")) {
return (
<div key={i} className="flex gap-1.5 pl-1">
<span className="mt-1.5 size-1 shrink-0 rounded-full bg-fg-quaternary" />
<span>{parseLine(line.replace(/^\s*-\s*/, ''))}</span>
<span>{parseLine(line.replace(/^\s*-\s*/, ""))}</span>
</div>
);
}

View File

@@ -1,17 +1,17 @@
import { useState, useEffect } from 'react';
import { faCalendarPlus, faXmark } from '@fortawesome/pro-duotone-svg-icons';
import { faIcon } from '@/lib/icon-wrapper';
import { useEffect, useState } from "react";
import { faCalendarPlus, faXmark } from "@fortawesome/pro-duotone-svg-icons";
import { Button } from "@/components/base/buttons/button";
import { Checkbox } from "@/components/base/checkbox/checkbox";
import { Input } from "@/components/base/input/input";
import { Select } from "@/components/base/select/select";
import { TextArea } from "@/components/base/textarea/textarea";
import { apiClient } from "@/lib/api-client";
import { faIcon } from "@/lib/icon-wrapper";
import { notify } from "@/lib/toast";
import { cx } from "@/utils/cx";
const CalendarPlus02 = faIcon(faCalendarPlus);
const XClose = faIcon(faXmark);
import { Input } from '@/components/base/input/input';
import { Select } from '@/components/base/select/select';
import { TextArea } from '@/components/base/textarea/textarea';
import { Checkbox } from '@/components/base/checkbox/checkbox';
import { Button } from '@/components/base/buttons/button';
import { apiClient } from '@/lib/api-client';
import { cx } from '@/utils/cx';
import { notify } from '@/lib/toast';
type ExistingAppointment = {
id: string;
@@ -36,71 +36,62 @@ type AppointmentFormProps = {
type DoctorRecord = { id: string; name: string; department: string; clinic: string };
const clinicItems = [
{ id: 'koramangala', label: 'Global Hospital - Koramangala' },
{ id: 'whitefield', label: 'Global Hospital - Whitefield' },
{ id: 'indiranagar', label: 'Global Hospital - Indiranagar' },
{ id: "koramangala", label: "Global Hospital - Koramangala" },
{ id: "whitefield", label: "Global Hospital - Whitefield" },
{ id: "indiranagar", label: "Global Hospital - Indiranagar" },
];
const genderItems = [
{ id: 'male', label: 'Male' },
{ id: 'female', label: 'Female' },
{ id: 'other', label: 'Other' },
{ id: "male", label: "Male" },
{ id: "female", label: "Female" },
{ id: "other", label: "Other" },
];
const timeSlotItems = [
{ id: '09:00', label: '9:00 AM' },
{ 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' },
{ id: "09:00", label: "9:00 AM" },
{ 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());
const formatDeptLabel = (dept: string) => dept.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
export const AppointmentForm = ({
isOpen,
onOpenChange,
callerNumber,
leadName,
leadId,
onSaved,
existingAppointment,
}: AppointmentFormProps) => {
export const AppointmentForm = ({ isOpen, onOpenChange, callerNumber, leadName, leadId, onSaved, existingAppointment }: AppointmentFormProps) => {
const isEditMode = !!existingAppointment;
// Doctor data from platform
const [doctors, setDoctors] = useState<DoctorRecord[]>([]);
// Form state — initialized from existing appointment in edit mode
const [patientName, setPatientName] = useState(leadName ?? '');
const [patientPhone, setPatientPhone] = useState(callerNumber ?? '');
const [age, setAge] = useState('');
const [patientName, setPatientName] = useState(leadName ?? "");
const [patientPhone, setPatientPhone] = useState(callerNumber ?? "");
const [age, setAge] = useState("");
const [gender, setGender] = useState<string | null>(null);
const [clinic, setClinic] = useState<string | null>(null);
const [department, setDepartment] = useState<string | null>(existingAppointment?.department ?? null);
const [doctor, setDoctor] = useState<string | null>(existingAppointment?.doctorId ?? null);
const [date, setDate] = useState(() => {
if (existingAppointment?.scheduledAt) return existingAppointment.scheduledAt.split('T')[0];
return '';
if (existingAppointment?.scheduledAt) return existingAppointment.scheduledAt.split("T")[0];
return "";
});
const [timeSlot, setTimeSlot] = useState<string | null>(() => {
if (existingAppointment?.scheduledAt) {
const dt = new Date(existingAppointment.scheduledAt);
return `${dt.getHours().toString().padStart(2, '0')}:${dt.getMinutes().toString().padStart(2, '0')}`;
return `${dt.getHours().toString().padStart(2, "0")}:${dt.getMinutes().toString().padStart(2, "0")}`;
}
return null;
});
const [chiefComplaint, setChiefComplaint] = useState(existingAppointment?.reasonForVisit ?? '');
const [chiefComplaint, setChiefComplaint] = useState(existingAppointment?.reasonForVisit ?? "");
const [isReturning, setIsReturning] = useState(false);
const [source, setSource] = useState('Inbound Call');
const [agentNotes, setAgentNotes] = useState('');
const [source, setSource] = useState("Inbound Call");
const [agentNotes, setAgentNotes] = useState("");
// Availability state
const [bookedSlots, setBookedSlots] = useState<string[]>([]);
@@ -112,21 +103,22 @@ export const AppointmentForm = ({
// Fetch doctors on mount
useEffect(() => {
if (!isOpen) return;
apiClient.graphql<{ doctors: { edges: Array<{ node: any }> } }>(
`{ doctors(first: 50) { edges { node {
apiClient
.graphql<{ doctors: { edges: Array<{ node: any }> } }>(
`{ doctors(first: 50) { edges { node {
id name fullName { firstName lastName } department clinic { id name clinicName }
} } } }`,
).then(data => {
const docs = data.doctors.edges.map(e => ({
id: e.node.id,
name: e.node.fullName
? `Dr. ${e.node.fullName.firstName} ${e.node.fullName.lastName}`.trim()
: e.node.name,
department: e.node.department ?? '',
clinic: e.node.clinic?.clinicName ?? e.node.clinic?.name ?? '',
}));
setDoctors(docs);
}).catch(() => {});
)
.then((data) => {
const docs = data.doctors.edges.map((e) => ({
id: e.node.id,
name: e.node.fullName ? `Dr. ${e.node.fullName.firstName} ${e.node.fullName.lastName}`.trim() : e.node.name,
department: e.node.department ?? "",
clinic: e.node.clinic?.clinicName ?? e.node.clinic?.name ?? "",
}));
setDoctors(docs);
})
.catch(() => {});
}, [isOpen]);
// Fetch booked slots when doctor + date selected
@@ -137,31 +129,34 @@ export const AppointmentForm = ({
}
setLoadingSlots(true);
apiClient.graphql<{ appointments: { edges: Array<{ node: any }> } }>(
`{ appointments(filter: {
apiClient
.graphql<{ appointments: { edges: Array<{ node: any }> } }>(
`{ appointments(filter: {
doctorId: { eq: "${doctor}" },
scheduledAt: { gte: "${date}T00:00:00", lte: "${date}T23:59:59" }
}) { edges { node { id scheduledAt durationMin appointmentStatus } } } }`,
).then(data => {
// Filter out cancelled/completed appointments client-side
const activeAppointments = data.appointments.edges.filter(e => {
const status = e.node.appointmentStatus;
return status !== 'CANCELLED' && status !== 'COMPLETED' && status !== 'NO_SHOW';
});
const slots = activeAppointments.map(e => {
const dt = new Date(e.node.scheduledAt);
return `${dt.getHours().toString().padStart(2, '0')}:${dt.getMinutes().toString().padStart(2, '0')}`;
});
// In edit mode, don't block the current appointment's slot
if (isEditMode && existingAppointment) {
const currentDt = new Date(existingAppointment.scheduledAt);
const currentSlot = `${currentDt.getHours().toString().padStart(2, '0')}:${currentDt.getMinutes().toString().padStart(2, '0')}`;
setBookedSlots(slots.filter(s => s !== currentSlot));
} else {
setBookedSlots(slots);
}
}).catch(() => setBookedSlots([]))
.finally(() => setLoadingSlots(false));
)
.then((data) => {
// Filter out cancelled/completed appointments client-side
const activeAppointments = data.appointments.edges.filter((e) => {
const status = e.node.appointmentStatus;
return status !== "CANCELLED" && status !== "COMPLETED" && status !== "NO_SHOW";
});
const slots = activeAppointments.map((e) => {
const dt = new Date(e.node.scheduledAt);
return `${dt.getHours().toString().padStart(2, "0")}:${dt.getMinutes().toString().padStart(2, "0")}`;
});
// In edit mode, don't block the current appointment's slot
if (isEditMode && existingAppointment) {
const currentDt = new Date(existingAppointment.scheduledAt);
const currentSlot = `${currentDt.getHours().toString().padStart(2, "0")}:${currentDt.getMinutes().toString().padStart(2, "0")}`;
setBookedSlots(slots.filter((s) => s !== currentSlot));
} else {
setBookedSlots(slots);
}
})
.catch(() => setBookedSlots([]))
.finally(() => setLoadingSlots(false));
}, [doctor, date, isEditMode, existingAppointment]);
// Reset doctor when department changes
@@ -176,15 +171,12 @@ export const AppointmentForm = ({
}, [doctor, date]);
// Derive department and doctor lists from fetched data
const departmentItems = [...new Set(doctors.map(d => d.department).filter(Boolean))]
.map(dept => ({ id: dept, label: formatDeptLabel(dept) }));
const departmentItems = [...new Set(doctors.map((d) => d.department).filter(Boolean))].map((dept) => ({ id: dept, label: formatDeptLabel(dept) }));
const filteredDoctors = department
? doctors.filter(d => d.department === department)
: doctors;
const doctorSelectItems = filteredDoctors.map(d => ({ id: d.id, label: d.name }));
const filteredDoctors = department ? doctors.filter((d) => d.department === department) : doctors;
const doctorSelectItems = filteredDoctors.map((d) => ({ id: d.id, label: d.name }));
const timeSlotSelectItems = timeSlotItems.map(slot => ({
const timeSlotSelectItems = timeSlotItems.map((slot) => ({
...slot,
isDisabled: bookedSlots.includes(slot.id),
label: bookedSlots.includes(slot.id) ? `${slot.label} (Booked)` : slot.label,
@@ -192,7 +184,7 @@ export const AppointmentForm = ({
const handleSave = async () => {
if (!date || !timeSlot || !doctor || !department) {
setError('Please fill in the required fields: date, time, doctor, and department.');
setError("Please fill in the required fields: date, time, doctor, and department.");
return;
}
@@ -201,7 +193,7 @@ export const AppointmentForm = ({
try {
const scheduledAt = new Date(`${date}T${timeSlot}:00`).toISOString();
const selectedDoctor = doctors.find(d => d.id === doctor);
const selectedDoctor = doctors.find((d) => d.id === doctor);
if (isEditMode && existingAppointment) {
// Update existing appointment
@@ -213,14 +205,14 @@ export const AppointmentForm = ({
id: existingAppointment.id,
data: {
scheduledAt,
doctorName: selectedDoctor?.name ?? '',
department: selectedDoctor?.department ?? '',
doctorName: selectedDoctor?.name ?? "",
department: selectedDoctor?.department ?? "",
doctorId: doctor,
reasonForVisit: chiefComplaint || null,
},
},
);
notify.success('Appointment Updated');
notify.success("Appointment Updated");
} else {
// Double-check slot availability before booking
const checkResult = await apiClient.graphql<{ appointments: { edges: Array<{ node: { appointmentStatus: string } }> } }>(
@@ -229,11 +221,11 @@ export const AppointmentForm = ({
scheduledAt: { gte: "${date}T${timeSlot}:00", lte: "${date}T${timeSlot}:00" }
}) { edges { node { appointmentStatus } } } }`,
);
const activeBookings = checkResult.appointments.edges.filter(e =>
e.node.appointmentStatus !== 'CANCELLED' && e.node.appointmentStatus !== 'NO_SHOW',
const activeBookings = checkResult.appointments.edges.filter(
(e) => e.node.appointmentStatus !== "CANCELLED" && e.node.appointmentStatus !== "NO_SHOW",
);
if (activeBookings.length > 0) {
setError('This slot was just booked by someone else. Please select a different time.');
setError("This slot was just booked by someone else. Please select a different time.");
setIsSaving(false);
return;
}
@@ -247,10 +239,10 @@ export const AppointmentForm = ({
data: {
scheduledAt,
durationMin: 30,
appointmentType: 'CONSULTATION',
appointmentStatus: 'SCHEDULED',
doctorName: selectedDoctor?.name ?? '',
department: selectedDoctor?.department ?? '',
appointmentType: "CONSULTATION",
appointmentStatus: "SCHEDULED",
doctorName: selectedDoctor?.name ?? "",
department: selectedDoctor?.department ?? "",
doctorId: doctor,
reasonForVisit: chiefComplaint || null,
...(leadId ? { patientId: leadId } : {}),
@@ -260,25 +252,27 @@ export const AppointmentForm = ({
// Update lead status if we have a matched lead
if (leadId) {
await apiClient.graphql(
`mutation UpdateLead($id: ID!, $data: LeadUpdateInput!) {
await apiClient
.graphql(
`mutation UpdateLead($id: ID!, $data: LeadUpdateInput!) {
updateLead(id: $id, data: $data) { id }
}`,
{
id: leadId,
data: {
leadStatus: 'APPOINTMENT_SET',
lastContactedAt: new Date().toISOString(),
{
id: leadId,
data: {
leadStatus: "APPOINTMENT_SET",
lastContactedAt: new Date().toISOString(),
},
},
},
).catch((err: unknown) => console.warn('Failed to update lead:', err));
)
.catch((err: unknown) => console.warn("Failed to update lead:", err));
}
}
onSaved?.();
} catch (err) {
console.error('Failed to save appointment:', err);
setError(err instanceof Error ? err.message : 'Failed to save appointment. Please try again.');
console.error("Failed to save appointment:", err);
setError(err instanceof Error ? err.message : "Failed to save appointment. Please try again.");
} finally {
setIsSaving(false);
}
@@ -294,13 +288,13 @@ export const AppointmentForm = ({
}`,
{
id: existingAppointment.id,
data: { appointmentStatus: 'CANCELLED' },
data: { appointmentStatus: "CANCELLED" },
},
);
notify.success('Appointment Cancelled');
notify.success("Appointment Cancelled");
onSaved?.();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to cancel appointment');
setError(err instanceof Error ? err.message : "Failed to cancel appointment");
} finally {
setIsSaving(false);
}
@@ -311,23 +305,19 @@ export const AppointmentForm = ({
return (
<div className="rounded-xl border border-secondary bg-primary p-4">
{/* Header with close button */}
<div className="flex items-center justify-between mb-4">
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex size-8 items-center justify-center rounded-lg bg-brand-secondary">
<CalendarPlus02 className="size-4 text-fg-brand-primary" />
</div>
<div>
<h3 className="text-sm font-semibold text-primary">
{isEditMode ? 'Edit Appointment' : 'Book Appointment'}
</h3>
<p className="text-xs text-tertiary">
{isEditMode ? 'Modify or cancel this appointment' : 'Schedule a new patient visit'}
</p>
<h3 className="text-sm font-semibold text-primary">{isEditMode ? "Edit Appointment" : "Book Appointment"}</h3>
<p className="text-xs text-tertiary">{isEditMode ? "Modify or cancel this appointment" : "Schedule a new patient visit"}</p>
</div>
</div>
<button
onClick={() => onOpenChange(false)}
className="flex size-7 items-center justify-center rounded-md text-fg-quaternary hover:text-fg-secondary hover:bg-primary_hover transition duration-100 ease-linear"
className="flex size-7 items-center justify-center rounded-md text-fg-quaternary transition duration-100 ease-linear hover:bg-primary_hover hover:text-fg-secondary"
>
<XClose className="size-4" />
</button>
@@ -339,32 +329,14 @@ export const AppointmentForm = ({
{!isEditMode && (
<>
<div className="flex flex-col gap-1">
<span className="text-xs font-bold uppercase tracking-wider text-tertiary">
Patient Information
</span>
<span className="text-xs font-bold tracking-wider text-tertiary uppercase">Patient Information</span>
</div>
<Input
label="Patient Name"
placeholder="Full name"
value={patientName}
onChange={setPatientName}
/>
<Input label="Patient Name" placeholder="Full name" value={patientName} onChange={setPatientName} />
<div className="grid grid-cols-2 gap-3">
<Input
label="Phone"
placeholder="Phone number"
value={patientPhone}
onChange={setPatientPhone}
/>
<Input
label="Age"
placeholder="Age"
type="number"
value={age}
onChange={setAge}
/>
<Input label="Phone" placeholder="Phone number" value={patientPhone} onChange={setPatientPhone} />
<Input label="Age" placeholder="Age" type="number" value={age} onChange={setAge} />
</div>
<Select
@@ -383,9 +355,7 @@ export const AppointmentForm = ({
{/* Appointment Details */}
<div className="flex flex-col gap-1">
<span className="text-xs font-bold uppercase tracking-wider text-tertiary">
Appointment Details
</span>
<span className="text-xs font-bold tracking-wider text-tertiary uppercase">Appointment Details</span>
</div>
{!isEditMode && (
@@ -402,7 +372,7 @@ export const AppointmentForm = ({
<Select
label="Department / Specialty"
placeholder={doctors.length === 0 ? 'Loading...' : 'Select department'}
placeholder={doctors.length === 0 ? "Loading..." : "Select department"}
items={departmentItems}
selectedKey={department}
onSelectionChange={(key) => setDepartment(key as string)}
@@ -414,7 +384,7 @@ export const AppointmentForm = ({
<Select
label="Doctor"
placeholder={!department ? 'Select department first' : 'Select doctor'}
placeholder={!department ? "Select department first" : "Select doctor"}
items={doctorSelectItems}
selectedKey={doctor}
onSelectionChange={(key) => setDoctor(key as string)}
@@ -424,22 +394,14 @@ export const AppointmentForm = ({
{(item) => <Select.Item id={item.id} label={item.label} />}
</Select>
<Input
label="Date"
type="date"
value={date}
onChange={setDate}
isRequired
/>
<Input label="Date" type="date" value={date} onChange={setDate} isRequired />
{/* Time slot grid */}
{doctor && date && (
<div className="flex flex-col gap-2">
<span className="text-xs font-semibold text-secondary">
{loadingSlots ? 'Checking availability...' : 'Available Slots'}
</span>
<span className="text-xs font-semibold text-secondary">{loadingSlots ? "Checking availability..." : "Available Slots"}</span>
<div className="grid grid-cols-4 gap-1.5">
{timeSlotSelectItems.map(slot => {
{timeSlotSelectItems.map((slot) => {
const isBooked = slot.isDisabled;
const isSelected = timeSlot === slot.id;
return (
@@ -449,15 +411,15 @@ export const AppointmentForm = ({
disabled={isBooked}
onClick={() => setTimeSlot(slot.id)}
className={cx(
'rounded-lg py-2 px-1 text-xs font-medium transition duration-100 ease-linear',
"rounded-lg px-1 py-2 text-xs font-medium transition duration-100 ease-linear",
isBooked
? 'cursor-not-allowed bg-disabled text-disabled line-through'
? "cursor-not-allowed bg-disabled text-disabled line-through"
: isSelected
? 'bg-brand-solid text-white ring-2 ring-brand'
: 'cursor-pointer bg-secondary text-secondary hover:bg-secondary_hover hover:text-secondary_hover',
? "bg-brand-solid text-white ring-2 ring-brand"
: "cursor-pointer bg-secondary text-secondary hover:bg-secondary_hover hover:text-secondary_hover",
)}
>
{timeSlotItems.find(t => t.id === slot.id)?.label ?? slot.id}
{timeSlotItems.find((t) => t.id === slot.id)?.label ?? slot.id}
</button>
);
})}
@@ -465,56 +427,28 @@ export const AppointmentForm = ({
</div>
)}
{!doctor || !date ? (
<p className="text-xs text-tertiary">Select a doctor and date to see available time slots</p>
) : null}
{!doctor || !date ? <p className="text-xs text-tertiary">Select a doctor and date to see available time slots</p> : null}
<TextArea
label="Chief Complaint"
placeholder="Describe the reason for visit..."
value={chiefComplaint}
onChange={setChiefComplaint}
rows={2}
/>
<TextArea label="Chief Complaint" placeholder="Describe the reason for visit..." value={chiefComplaint} onChange={setChiefComplaint} rows={2} />
{/* Additional Info — only for new appointments */}
{!isEditMode && (
<>
<div className="border-t border-secondary" />
<Checkbox
isSelected={isReturning}
onChange={setIsReturning}
label="Returning Patient"
hint="Check if the patient has visited before"
/>
<Checkbox isSelected={isReturning} onChange={setIsReturning} label="Returning Patient" hint="Check if the patient has visited before" />
<Input
label="Source / Referral"
placeholder="How did the patient reach us?"
value={source}
onChange={setSource}
/>
<Input label="Source / Referral" placeholder="How did the patient reach us?" value={source} onChange={setSource} />
<TextArea
label="Agent Notes"
placeholder="Any additional notes..."
value={agentNotes}
onChange={setAgentNotes}
rows={2}
/>
<TextArea label="Agent Notes" placeholder="Any additional notes..." value={agentNotes} onChange={setAgentNotes} rows={2} />
</>
)}
{error && (
<div className="rounded-lg bg-error-primary p-3 text-sm text-error-primary">
{error}
</div>
)}
{error && <div className="rounded-lg bg-error-primary p-3 text-sm text-error-primary">{error}</div>}
</div>
{/* Footer buttons */}
<div className="flex items-center justify-between mt-4 pt-4 border-t border-secondary">
<div className="mt-4 flex items-center justify-between border-t border-secondary pt-4">
<div>
{isEditMode && (
<Button size="sm" color="primary-destructive" isLoading={isSaving} onClick={handleCancel}>
@@ -527,7 +461,7 @@ export const AppointmentForm = ({
Close
</Button>
<Button size="sm" color="primary" isLoading={isSaving} showTextWhileLoading onClick={handleSave}>
{isSaving ? 'Saving...' : isEditMode ? 'Update Appointment' : 'Book Appointment'}
{isSaving ? "Saving..." : isEditMode ? "Update Appointment" : "Book Appointment"}
</Button>
</div>
</div>

View File

@@ -1,28 +1,28 @@
import type { FC } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faArrowRight } from '@fortawesome/pro-duotone-svg-icons';
import { Badge } from '@/components/base/badges/badges';
import { Button } from '@/components/base/buttons/button';
import type { FC } from "react";
import { faArrowRight } from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Badge } from "@/components/base/badges/badges";
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} />;
import { formatShortDate } from '@/lib/format';
import type { Call, CallDisposition } from '@/types/entities';
interface CallLogProps {
calls: Call[];
}
const dispositionConfig: Record<CallDisposition, { label: string; color: 'success' | 'brand' | 'blue-light' | 'warning' | 'gray' | 'error' }> = {
APPOINTMENT_BOOKED: { label: 'Booked', color: 'success' },
FOLLOW_UP_SCHEDULED: { label: 'Follow-up', color: 'brand' },
INFO_PROVIDED: { label: 'Info', color: 'blue-light' },
NO_ANSWER: { label: 'No Answer', color: 'warning' },
WRONG_NUMBER: { label: 'Wrong #', color: 'gray' },
CALLBACK_REQUESTED: { label: 'Not Interested', color: 'error' },
const dispositionConfig: Record<CallDisposition, { label: string; color: "success" | "brand" | "blue-light" | "warning" | "gray" | "error" }> = {
APPOINTMENT_BOOKED: { label: "Booked", color: "success" },
FOLLOW_UP_SCHEDULED: { label: "Follow-up", color: "brand" },
INFO_PROVIDED: { label: "Info", color: "blue-light" },
NO_ANSWER: { label: "No Answer", color: "warning" },
WRONG_NUMBER: { label: "Wrong #", color: "gray" },
CALLBACK_REQUESTED: { label: "Not Interested", color: "error" },
};
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);
return `${minutes} min`;
};
@@ -33,34 +33,29 @@ 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 gap-2">
<span className="text-sm font-bold text-primary">Today's Calls</span>
<Badge size="sm" color="gray">{calls.length}</Badge>
<Badge size="sm" color="gray">
{calls.length}
</Badge>
</div>
</div>
{calls.length > 0 ? (
<div className="divide-y divide-secondary">
{calls.map((call) => {
const config = call.disposition !== null
? dispositionConfig[call.disposition]
: null;
const config = call.disposition !== null ? dispositionConfig[call.disposition] : null;
return (
<div
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>
<div 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">
{call.leadName ?? call.callerNumber?.[0]?.number ?? 'Unknown'}
{call.leadName ?? call.callerNumber?.[0]?.number ?? "Unknown"}
</span>
{config !== null && (
<Badge size="sm" color={config.color}>{config.label}</Badge>
<Badge size="sm" color={config.color}>
{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>
);
})}

View File

@@ -1,9 +1,9 @@
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faSparkles, faUserPlus } from '@fortawesome/pro-duotone-svg-icons';
import { Badge } from '@/components/base/badges/badges';
import { Button } from '@/components/base/buttons/button';
import { formatShortDate } from '@/lib/format';
import type { Lead, LeadActivity } from '@/types/entities';
import { faSparkles, faUserPlus } from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Badge } from "@/components/base/badges/badges";
import { Button } from "@/components/base/buttons/button";
import { formatShortDate } from "@/lib/format";
import type { Lead, LeadActivity } from "@/types/entities";
interface CallPrepCardProps {
lead: Lead | null;
@@ -19,8 +19,8 @@ export const CallPrepCard = ({ lead, callerPhone, activities }: CallPrepCardProp
const leadActivities = activities
.filter((a) => a.leadId === lead.id)
.sort((a, b) => {
const dateA = a.occurredAt ?? a.createdAt ?? '';
const dateB = b.occurredAt ?? b.createdAt ?? '';
const dateA = a.occurredAt ?? a.createdAt ?? "";
const dateB = b.occurredAt ?? b.createdAt ?? "";
return new Date(dateB).getTime() - new Date(dateA).getTime();
})
.slice(0, 3);
@@ -29,22 +29,16 @@ export const CallPrepCard = ({ lead, callerPhone, activities }: CallPrepCardProp
<div className="rounded-xl bg-brand-primary p-4">
<div className="mb-2 flex items-center gap-1.5">
<FontAwesomeIcon icon={faSparkles} className="size-3.5 text-fg-brand-primary" />
<span className="text-xs font-bold uppercase tracking-wider text-brand-secondary">AI Call Prep</span>
<span className="text-xs font-bold tracking-wider text-brand-secondary uppercase">AI Call Prep</span>
</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>
<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.aiSummary && !lead.aiSuggestedAction && <p className="text-sm text-quaternary">No AI insights available for this lead.</p>}
{leadActivities.length > 0 && (
<div className="mt-3 border-t border-brand pt-3">
@@ -52,11 +46,11 @@ export const CallPrepCard = ({ lead, callerPhone, activities }: CallPrepCardProp
<div className="mt-1.5 space-y-1">
{leadActivities.map((a) => (
<div key={a.id} className="flex items-start gap-2">
<Badge size="sm" color="gray" className="shrink-0 mt-0.5">{a.activityType}</Badge>
<Badge size="sm" color="gray" className="mt-0.5 shrink-0">
{a.activityType}
</Badge>
<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>
@@ -70,10 +64,10 @@ const UnknownCallerPrep = ({ callerPhone }: { callerPhone: string }) => (
<div className="rounded-xl bg-secondary p-4">
<div className="mb-2 flex items-center gap-1.5">
<FontAwesomeIcon icon={faSparkles} className="size-3.5 text-fg-quaternary" />
<span className="text-xs font-bold uppercase tracking-wider text-tertiary">Unknown Caller</span>
<span className="text-xs font-bold tracking-wider text-tertiary uppercase">Unknown Caller</span>
</div>
<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>
<div className="mt-3 space-y-1.5">
<p className="text-xs font-semibold text-secondary">Suggested script:</p>
@@ -85,9 +79,11 @@ const UnknownCallerPrep = ({ callerPhone }: { callerPhone: string }) => (
</ul>
</div>
<div className="mt-3">
<Button size="sm" color="secondary" iconLeading={({ className }: { className?: string }) => (
<FontAwesomeIcon icon={faUserPlus} className={className} />
)}>
<Button
size="sm"
color="secondary"
iconLeading={({ className }: { className?: string }) => <FontAwesomeIcon icon={faUserPlus} className={className} />}
>
Create Lead
</Button>
</div>

View File

@@ -1,6 +1,6 @@
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPhoneArrowUpRight } from '@fortawesome/pro-duotone-svg-icons';
import { cx } from '@/utils/cx';
import { faPhoneArrowUpRight } from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { cx } from "@/utils/cx";
interface CallSimulatorProps {
onSimulate: () => void;
@@ -14,14 +14,14 @@ export const CallSimulator = ({ onSimulate, isCallActive }: CallSimulatorProps)
onClick={onSimulate}
disabled={isCallActive}
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
? '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-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]",
)}
>
<FontAwesomeIcon icon={faPhoneArrowUpRight} className="size-5 shrink-0" />
{isCallActive ? 'Call in progress...' : 'Simulate Incoming Call'}
{isCallActive ? "Call in progress..." : "Simulate Incoming Call"}
</button>
);
};

View File

@@ -1,18 +1,25 @@
import { useState, useEffect, useRef } from 'react';
import { useEffect, useRef, useState } from "react";
import {
faCalendarPlus,
faCircleCheck,
faFloppyDisk,
faMicrophone,
faMicrophoneSlash,
faPause,
faPhone,
faPhoneArrowDown,
faPhoneArrowUp,
faPhoneHangup,
faPhoneXmark,
faMicrophoneSlash,
faMicrophone,
faPause,
faCircleCheck,
faFloppyDisk,
faCalendarPlus,
} from '@fortawesome/pro-duotone-svg-icons';
import { faIcon } from '@/lib/icon-wrapper';
} from "@fortawesome/pro-duotone-svg-icons";
import { Button } from "@/components/base/buttons/button";
import { TextArea } from "@/components/base/textarea/textarea";
import { AppointmentForm } from "@/components/call-desk/appointment-form";
import { faIcon } from "@/lib/icon-wrapper";
import { useAuth } from "@/providers/auth-provider";
import { useSip } from "@/providers/sip-provider";
import type { CallDisposition } from "@/types/entities";
import { cx } from "@/utils/cx";
const Phone01 = faIcon(faPhone);
const PhoneIncoming01 = faIcon(faPhoneArrowDown);
@@ -25,34 +32,27 @@ const PauseCircle = faIcon(faPause);
const CheckCircle = faIcon(faCircleCheck);
const Save01 = faIcon(faFloppyDisk);
const CalendarPlus02 = faIcon(faCalendarPlus);
import { Button } from '@/components/base/buttons/button';
import { TextArea } from '@/components/base/textarea/textarea';
import { AppointmentForm } from '@/components/call-desk/appointment-form';
import { useSip } from '@/providers/sip-provider';
import { useAuth } from '@/providers/auth-provider';
import { cx } from '@/utils/cx';
import type { CallDisposition } from '@/types/entities';
const formatDuration = (seconds: number): string => {
const m = Math.floor(seconds / 60)
.toString()
.padStart(2, '0');
const s = (seconds % 60).toString().padStart(2, '0');
.padStart(2, "0");
const s = (seconds % 60).toString().padStart(2, "0");
return `${m}:${s}`;
};
const statusDotColor: Record<string, string> = {
registered: 'bg-success-500',
connecting: 'bg-warning-500',
disconnected: 'bg-quaternary',
error: 'bg-error-500',
registered: "bg-success-500",
connecting: "bg-warning-500",
disconnected: "bg-quaternary",
error: "bg-error-500",
};
const statusLabel: Record<string, string> = {
registered: 'Ready',
connecting: 'Connecting...',
disconnected: 'Offline',
error: 'Error',
registered: "Ready",
connecting: "Connecting...",
disconnected: "Offline",
error: "Error",
};
const dispositionOptions: Array<{
@@ -62,61 +62,49 @@ const dispositionOptions: Array<{
defaultClass: string;
}> = [
{
value: 'APPOINTMENT_BOOKED',
label: 'Appt Booked',
activeClass: 'bg-success-solid text-white ring-transparent',
defaultClass: 'bg-success-primary text-success-primary border-success',
value: "APPOINTMENT_BOOKED",
label: "Appt Booked",
activeClass: "bg-success-solid text-white ring-transparent",
defaultClass: "bg-success-primary text-success-primary border-success",
},
{
value: 'FOLLOW_UP_SCHEDULED',
label: 'Follow-up',
activeClass: 'bg-brand-solid text-white ring-transparent',
defaultClass: 'bg-brand-primary text-brand-secondary border-brand',
value: "FOLLOW_UP_SCHEDULED",
label: "Follow-up",
activeClass: "bg-brand-solid text-white ring-transparent",
defaultClass: "bg-brand-primary text-brand-secondary border-brand",
},
{
value: 'INFO_PROVIDED',
label: 'Info Given',
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',
value: "INFO_PROVIDED",
label: "Info Given",
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",
},
{
value: 'NO_ANSWER',
label: 'No Answer',
activeClass: 'bg-warning-solid text-white ring-transparent',
defaultClass: 'bg-warning-primary text-warning-primary border-warning',
value: "NO_ANSWER",
label: "No Answer",
activeClass: "bg-warning-solid text-white ring-transparent",
defaultClass: "bg-warning-primary text-warning-primary border-warning",
},
{
value: 'WRONG_NUMBER',
label: 'Wrong #',
activeClass: 'bg-secondary-solid text-white ring-transparent',
defaultClass: 'bg-secondary text-secondary border-secondary',
value: "WRONG_NUMBER",
label: "Wrong #",
activeClass: "bg-secondary-solid text-white ring-transparent",
defaultClass: "bg-secondary text-secondary border-secondary",
},
{
value: 'CALLBACK_REQUESTED',
label: 'Not Interested',
activeClass: 'bg-error-solid text-white ring-transparent',
defaultClass: 'bg-error-primary text-error-primary border-error',
value: "CALLBACK_REQUESTED",
label: "Not Interested",
activeClass: "bg-error-solid text-white ring-transparent",
defaultClass: "bg-error-primary text-error-primary border-error",
},
];
export const CallWidget = () => {
const {
connectionStatus,
callState,
callerNumber,
isMuted,
isOnHold,
callDuration,
answer,
reject,
hangup,
toggleMute,
toggleHold,
} = useSip();
const { connectionStatus, callState, callerNumber, isMuted, isOnHold, callDuration, answer, reject, hangup, toggleMute, toggleHold } = useSip();
const { user } = useAuth();
const [disposition, setDisposition] = useState<CallDisposition | null>(null);
const [notes, setNotes] = useState('');
const [notes, setNotes] = useState("");
const [lastDuration, setLastDuration] = useState(0);
const [matchedLead, setMatchedLead] = useState<any>(null);
const [leadActivities, setLeadActivities] = useState<any[]>([]);
@@ -126,36 +114,36 @@ export const CallWidget = () => {
// Capture duration right before call ends
useEffect(() => {
if (callState === 'active' && callDuration > 0) {
if (callState === "active" && callDuration > 0) {
setLastDuration(callDuration);
}
}, [callState, callDuration]);
// Track call start time
useEffect(() => {
if (callState === 'active' && !callStartTimeRef.current) {
if (callState === "active" && !callStartTimeRef.current) {
callStartTimeRef.current = new Date().toISOString();
}
if (callState === 'idle') {
if (callState === "idle") {
callStartTimeRef.current = null;
}
}, [callState]);
// Look up caller when call becomes active
useEffect(() => {
if (callState === 'ringing-in' && callerNumber && callerNumber !== 'Unknown') {
if (callState === "ringing-in" && callerNumber && callerNumber !== "Unknown") {
const lookup = async () => {
try {
const { apiClient } = await import('@/lib/api-client');
const { apiClient } = await import("@/lib/api-client");
const token = apiClient.getStoredToken();
if (!token) return;
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4100';
const API_URL = import.meta.env.VITE_API_URL ?? "http://localhost:4100";
const res = await fetch(`${API_URL}/api/call/lookup`, {
method: 'POST',
method: "POST",
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ phoneNumber: callerNumber }),
});
@@ -165,7 +153,7 @@ export const CallWidget = () => {
setLeadActivities(data.activities ?? []);
}
} catch (err) {
console.warn('Lead lookup failed:', err);
console.warn("Lead lookup failed:", err);
}
};
lookup();
@@ -174,9 +162,9 @@ export const CallWidget = () => {
// Reset state when returning to idle
useEffect(() => {
if (callState === 'idle') {
if (callState === "idle") {
setDisposition(null);
setNotes('');
setNotes("");
setMatchedLead(null);
setLeadActivities([]);
}
@@ -187,97 +175,103 @@ export const CallWidget = () => {
setIsSaving(true);
try {
const { apiClient } = await import('@/lib/api-client');
const { apiClient } = await import("@/lib/api-client");
// 1. Create Call record on platform
await apiClient.graphql(
`mutation CreateCall($data: CallCreateInput!) {
await apiClient
.graphql(
`mutation CreateCall($data: CallCreateInput!) {
createCall(data: $data) { id }
}`,
{
data: {
callDirection: 'INBOUND',
callStatus: 'COMPLETED',
agentName: user.name,
startedAt: callStartTimeRef.current,
endedAt: new Date().toISOString(),
durationSeconds: callDuration,
disposition,
callNotes: notes || null,
leadId: matchedLead?.id ?? null,
{
data: {
callDirection: "INBOUND",
callStatus: "COMPLETED",
agentName: user.name,
startedAt: callStartTimeRef.current,
endedAt: new Date().toISOString(),
durationSeconds: callDuration,
disposition,
callNotes: notes || null,
leadId: matchedLead?.id ?? null,
},
},
},
).catch(err => console.warn('Failed to create call record:', err));
)
.catch((err) => console.warn("Failed to create call record:", err));
// 2. Update lead status if matched
if (matchedLead?.id) {
const statusMap: Partial<Record<string, string>> = {
APPOINTMENT_BOOKED: 'APPOINTMENT_SET',
FOLLOW_UP_SCHEDULED: 'CONTACTED',
INFO_PROVIDED: 'CONTACTED',
NO_ANSWER: 'CONTACTED',
WRONG_NUMBER: 'LOST',
CALLBACK_REQUESTED: 'CONTACTED',
NOT_INTERESTED: 'LOST',
APPOINTMENT_BOOKED: "APPOINTMENT_SET",
FOLLOW_UP_SCHEDULED: "CONTACTED",
INFO_PROVIDED: "CONTACTED",
NO_ANSWER: "CONTACTED",
WRONG_NUMBER: "LOST",
CALLBACK_REQUESTED: "CONTACTED",
NOT_INTERESTED: "LOST",
};
const newStatus = statusMap[disposition];
if (newStatus) {
await apiClient.graphql(
`mutation UpdateLead($id: ID!, $data: LeadUpdateInput!) {
await apiClient
.graphql(
`mutation UpdateLead($id: ID!, $data: LeadUpdateInput!) {
updateLead(id: $id, data: $data) { id }
}`,
{
id: matchedLead.id,
data: {
leadStatus: newStatus,
lastContactedAt: new Date().toISOString(),
{
id: matchedLead.id,
data: {
leadStatus: newStatus,
lastContactedAt: new Date().toISOString(),
},
},
},
).catch(err => console.warn('Failed to update lead:', err));
)
.catch((err) => console.warn("Failed to update lead:", err));
}
// 3. Create lead activity
await apiClient.graphql(
`mutation CreateLeadActivity($data: LeadActivityCreateInput!) {
await apiClient
.graphql(
`mutation CreateLeadActivity($data: LeadActivityCreateInput!) {
createLeadActivity(data: $data) { id }
}`,
{
data: {
activityType: 'CALL_RECEIVED',
summary: `Inbound call — ${disposition.replace(/_/g, ' ')}`,
occurredAt: new Date().toISOString(),
performedBy: user.name,
channel: 'PHONE',
durationSeconds: callDuration,
leadId: matchedLead.id,
{
data: {
activityType: "CALL_RECEIVED",
summary: `Inbound call — ${disposition.replace(/_/g, " ")}`,
occurredAt: new Date().toISOString(),
performedBy: user.name,
channel: "PHONE",
durationSeconds: callDuration,
leadId: matchedLead.id,
},
},
},
).catch(err => console.warn('Failed to create activity:', err));
)
.catch((err) => console.warn("Failed to create activity:", err));
}
} catch (err) {
console.error('Save failed:', err);
console.error("Save failed:", err);
}
setIsSaving(false);
hangup();
setDisposition(null);
setNotes('');
setNotes("");
};
const dotColor = statusDotColor[connectionStatus] ?? 'bg-quaternary';
const dotColor = statusDotColor[connectionStatus] ?? "bg-quaternary";
const label = statusLabel[connectionStatus] ?? connectionStatus;
// Idle: collapsed pill
if (callState === 'idle') {
if (callState === "idle") {
return (
<div
className={cx(
'fixed bottom-6 right-6 z-50',
'inline-flex items-center gap-2 rounded-full border border-secondary bg-primary px-4 py-2.5 shadow-lg',
'transition-all duration-300',
"fixed right-6 bottom-6 z-50",
"inline-flex items-center gap-2 rounded-full border border-secondary bg-primary px-4 py-2.5 shadow-lg",
"transition-all duration-300",
)}
>
<span className={cx('size-2.5 shrink-0 rounded-full', dotColor)} />
<span className={cx("size-2.5 shrink-0 rounded-full", dotColor)} />
<span className="text-sm font-semibold text-secondary">{label}</span>
<span className="text-sm text-tertiary">Helix Phone</span>
</div>
@@ -285,13 +279,13 @@ export const CallWidget = () => {
}
// Ringing inbound
if (callState === 'ringing-in') {
if (callState === "ringing-in") {
return (
<div
className={cx(
'fixed bottom-6 right-6 z-50 w-80',
'flex flex-col items-center gap-5 rounded-2xl border border-secondary bg-primary p-6 shadow-2xl',
'transition-all duration-300',
"fixed right-6 bottom-6 z-50 w-80",
"flex flex-col items-center gap-5 rounded-2xl border border-secondary bg-primary p-6 shadow-2xl",
"transition-all duration-300",
)}
>
<div className="relative">
@@ -302,10 +296,8 @@ export const CallWidget = () => {
</div>
<div className="flex flex-col items-center gap-1">
<span className="text-xs font-bold uppercase tracking-wider text-brand-secondary">
Incoming Call
</span>
<span className="text-lg font-bold text-primary">{callerNumber ?? 'Unknown'}</span>
<span className="text-xs font-bold tracking-wider text-brand-secondary uppercase">Incoming Call</span>
<span className="text-lg font-bold text-primary">{callerNumber ?? "Unknown"}</span>
</div>
<div className="flex items-center gap-3">
@@ -321,22 +313,20 @@ export const CallWidget = () => {
}
// Ringing outbound
if (callState === 'ringing-out') {
if (callState === "ringing-out") {
return (
<div
className={cx(
'fixed bottom-6 right-6 z-50 w-80',
'flex flex-col items-center gap-5 rounded-2xl border border-secondary bg-primary p-6 shadow-2xl',
'transition-all duration-300',
"fixed right-6 bottom-6 z-50 w-80",
"flex flex-col items-center gap-5 rounded-2xl border border-secondary bg-primary p-6 shadow-2xl",
"transition-all duration-300",
)}
>
<PhoneOutgoing01 className="size-10 animate-pulse text-fg-brand-primary" />
<div className="flex flex-col items-center gap-1">
<span className="text-xs font-bold uppercase tracking-wider text-brand-secondary">
Calling...
</span>
<span className="text-lg font-bold text-primary">{callerNumber ?? 'Unknown'}</span>
<span className="text-xs font-bold tracking-wider text-brand-secondary uppercase">Calling...</span>
<span className="text-lg font-bold text-primary">{callerNumber ?? "Unknown"}</span>
</div>
<Button size="md" color="primary-destructive" iconLeading={PhoneHangUp} onClick={hangup}>
@@ -347,13 +337,13 @@ export const CallWidget = () => {
}
// Active call (full widget)
if (callState === 'active') {
if (callState === "active") {
return (
<div
className={cx(
'fixed bottom-6 right-6 z-50 w-80',
'flex flex-col gap-4 rounded-2xl border border-secondary bg-primary p-5 shadow-2xl',
'transition-all duration-300',
"fixed right-6 bottom-6 z-50 w-80",
"flex flex-col gap-4 rounded-2xl border border-secondary bg-primary p-5 shadow-2xl",
"transition-all duration-300",
)}
>
{/* Header */}
@@ -362,27 +352,23 @@ export const CallWidget = () => {
<Phone01 className="size-4 text-fg-success-primary" />
<span className="text-sm font-semibold text-primary">Active Call</span>
</div>
<span className="font-mono text-sm font-bold tabular-nums text-brand-secondary">
{formatDuration(callDuration)}
</span>
<span className="font-mono text-sm font-bold text-brand-secondary tabular-nums">{formatDuration(callDuration)}</span>
</div>
{/* Caller info */}
<div>
<span className="text-lg font-bold text-primary">
{matchedLead?.contactName
? `${matchedLead.contactName.firstName ?? ''} ${matchedLead.contactName.lastName ?? ''}`.trim()
: callerNumber ?? 'Unknown'}
? `${matchedLead.contactName.firstName ?? ""} ${matchedLead.contactName.lastName ?? ""}`.trim()
: (callerNumber ?? "Unknown")}
</span>
{matchedLead && (
<span className="ml-2 text-sm text-tertiary">{callerNumber}</span>
)}
{matchedLead && <span className="ml-2 text-sm text-tertiary">{callerNumber}</span>}
</div>
{/* AI Summary */}
{matchedLead?.aiSummary && (
<div className="rounded-xl bg-brand-primary p-3">
<div className="mb-1 text-xs font-bold uppercase tracking-wider text-brand-secondary">AI Insight</div>
<div className="mb-1 text-xs font-bold tracking-wider text-brand-secondary uppercase">AI Insight</div>
<p className="text-sm text-primary">{matchedLead.aiSummary}</p>
{matchedLead.aiSuggestedAction && (
<span className="mt-2 inline-block rounded-lg bg-brand-solid px-3 py-1 text-xs font-semibold text-white">
@@ -398,7 +384,7 @@ export const CallWidget = () => {
<div className="text-xs font-semibold text-tertiary">Recent Activity</div>
{leadActivities.slice(0, 3).map((a: any, i: number) => (
<div key={i} className="text-xs text-quaternary">
{a.activityType?.replace(/_/g, ' ')}: {a.summary}
{a.activityType?.replace(/_/g, " ")}: {a.summary}
</div>
))}
</div>
@@ -406,21 +392,11 @@ export const CallWidget = () => {
{/* Call controls */}
<div className="flex items-center gap-2">
<Button
size="sm"
color={isMuted ? 'primary' : 'secondary'}
iconLeading={isMuted ? MicrophoneOff01 : Microphone01}
onClick={toggleMute}
>
{isMuted ? 'Unmute' : 'Mute'}
<Button size="sm" color={isMuted ? "primary" : "secondary"} iconLeading={isMuted ? MicrophoneOff01 : Microphone01} onClick={toggleMute}>
{isMuted ? "Unmute" : "Mute"}
</Button>
<Button
size="sm"
color={isOnHold ? 'primary' : 'secondary'}
iconLeading={PauseCircle}
onClick={toggleHold}
>
{isOnHold ? 'Resume' : 'Hold'}
<Button size="sm" color={isOnHold ? "primary" : "secondary"} iconLeading={PauseCircle} onClick={toggleHold}>
{isOnHold ? "Resume" : "Hold"}
</Button>
<Button size="sm" color="primary-destructive" iconLeading={PhoneHangUp} onClick={hangup}>
End
@@ -428,13 +404,7 @@ export const CallWidget = () => {
</div>
{/* Book Appointment */}
<Button
size="sm"
color="primary"
iconLeading={CalendarPlus02}
onClick={() => setIsAppointmentOpen(true)}
className="w-full"
>
<Button size="sm" color="primary" iconLeading={CalendarPlus02} onClick={() => setIsAppointmentOpen(true)} className="w-full">
Book Appointment
</Button>
@@ -442,11 +412,11 @@ export const CallWidget = () => {
isOpen={isAppointmentOpen}
onOpenChange={setIsAppointmentOpen}
callerNumber={callerNumber}
leadName={matchedLead ? `${matchedLead.contactName?.firstName ?? ''} ${matchedLead.contactName?.lastName ?? ''}`.trim() : null}
leadName={matchedLead ? `${matchedLead.contactName?.firstName ?? ""} ${matchedLead.contactName?.lastName ?? ""}`.trim() : null}
leadId={matchedLead?.id}
onSaved={() => {
setIsAppointmentOpen(false);
setDisposition('APPOINTMENT_BOOKED');
setDisposition("APPOINTMENT_BOOKED");
}}
/>
@@ -455,7 +425,7 @@ export const CallWidget = () => {
{/* Disposition */}
<div className="flex flex-col gap-2.5">
<span className="text-xs font-bold uppercase tracking-wider text-secondary">Disposition</span>
<span className="text-xs font-bold tracking-wider text-secondary uppercase">Disposition</span>
<div className="grid grid-cols-2 gap-1.5">
{dispositionOptions.map((opt) => {
const isSelected = disposition === opt.value;
@@ -465,8 +435,8 @@ export const CallWidget = () => {
type="button"
onClick={() => setDisposition(opt.value)}
className={cx(
'cursor-pointer rounded-lg border px-2.5 py-1.5 text-xs font-semibold transition duration-100 ease-linear',
isSelected ? cx(opt.activeClass, 'ring-2 ring-brand') : opt.defaultClass,
"cursor-pointer rounded-lg border px-2.5 py-1.5 text-xs font-semibold transition duration-100 ease-linear",
isSelected ? cx(opt.activeClass, "ring-2 ring-brand") : opt.defaultClass,
)}
>
{opt.label}
@@ -475,13 +445,7 @@ export const CallWidget = () => {
})}
</div>
<TextArea
placeholder="Add notes..."
value={notes}
onChange={(value) => setNotes(value)}
rows={2}
textAreaClassName="text-xs"
/>
<TextArea placeholder="Add notes..." value={notes} onChange={(value) => setNotes(value)} rows={2} textAreaClassName="text-xs" />
<Button
size="sm"
@@ -492,7 +456,7 @@ export const CallWidget = () => {
onClick={handleSaveAndClose}
className="w-full"
>
{isSaving ? 'Saving...' : 'Save & Close'}
{isSaving ? "Saving..." : "Save & Close"}
</Button>
</div>
</div>
@@ -500,21 +464,19 @@ export const CallWidget = () => {
}
// Ended / Failed
if (callState === 'ended' || callState === 'failed') {
const isEnded = callState === 'ended';
if (callState === "ended" || callState === "failed") {
const isEnded = callState === "ended";
return (
<div
className={cx(
'fixed bottom-6 right-6 z-50 w-80',
'flex flex-col items-center gap-2 rounded-2xl border border-secondary bg-primary p-5 shadow-2xl',
'transition-all duration-300',
"fixed right-6 bottom-6 z-50 w-80",
"flex flex-col items-center gap-2 rounded-2xl border border-secondary bg-primary p-5 shadow-2xl",
"transition-all duration-300",
)}
>
<CheckCircle
className={cx('size-8', isEnded ? 'text-fg-success-primary' : 'text-fg-error-primary')}
/>
<CheckCircle className={cx("size-8", isEnded ? "text-fg-success-primary" : "text-fg-error-primary")} />
<span className="text-sm font-semibold text-primary">
{isEnded ? 'Call Ended' : 'Call Failed'}
{isEnded ? "Call Ended" : "Call Failed"}
{lastDuration > 0 && ` \u00B7 ${formatDuration(lastDuration)}`}
</span>
<span className="text-xs text-tertiary">auto-closing...</span>

View File

@@ -1,25 +1,27 @@
import type { FC } from 'react';
import { useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPhone } from '@fortawesome/pro-duotone-svg-icons';
import { useSetAtom } from 'jotai';
import type { FC } from "react";
import { useState } from "react";
import { faPhone } from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useSetAtom } from "jotai";
import { Button } from "@/components/base/buttons/button";
import { apiClient } from "@/lib/api-client";
import { notify } from "@/lib/toast";
import { useSip } from "@/providers/sip-provider";
import { setOutboundPending } from "@/state/sip-manager";
import { sipCallStateAtom, sipCallUcidAtom, sipCallerNumberAtom } from "@/state/sip-state";
const Phone01: FC<{ className?: string } & Record<string, any>> = ({ className, ...rest }) => <FontAwesomeIcon icon={faPhone} className={className} {...rest} />;
import { Button } from '@/components/base/buttons/button';
import { useSip } from '@/providers/sip-provider';
import { sipCallStateAtom, sipCallerNumberAtom, sipCallUcidAtom } from '@/state/sip-state';
import { setOutboundPending } from '@/state/sip-manager';
import { apiClient } from '@/lib/api-client';
import { notify } from '@/lib/toast';
const Phone01: FC<{ className?: string } & Record<string, any>> = ({ className, ...rest }) => (
<FontAwesomeIcon icon={faPhone} className={className} {...rest} />
);
interface ClickToCallButtonProps {
phoneNumber: string;
leadId?: 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 } = useSip();
const [dialing, setDialing] = useState(false);
const setCallState = useSetAtom(sipCallStateAtom);
@@ -30,24 +32,24 @@ export const ClickToCallButton = ({ phoneNumber, label, size = 'sm' }: ClickToCa
setDialing(true);
// Show call UI immediately
setCallState('ringing-out');
setCallState("ringing-out");
setCallerNumber(phoneNumber);
setOutboundPending(true);
// Safety: reset flag if SIP INVITE doesn't arrive within 30s
const safetyTimer = setTimeout(() => setOutboundPending(false), 30000);
try {
const result = await apiClient.post<{ ucid?: string; status?: string }>('/api/ozonetel/dial', { phoneNumber });
const result = await apiClient.post<{ ucid?: string; status?: string }>("/api/ozonetel/dial", { phoneNumber });
if (result?.ucid) {
setCallUcid(result.ucid);
}
} catch {
clearTimeout(safetyTimer);
setCallState('idle');
setCallState("idle");
setCallerNumber(null);
setOutboundPending(false);
setCallUcid(null);
notify.error('Dial Failed', 'Could not place the call');
notify.error("Dial Failed", "Could not place the call");
} finally {
setDialing(false);
}
@@ -62,7 +64,7 @@ export const ClickToCallButton = ({ phoneNumber, label, size = 'sm' }: ClickToCa
isDisabled={!isRegistered || isInCall || !phoneNumber || dialing}
isLoading={dialing}
>
{label ?? 'Call'}
{label ?? "Call"}
</Button>
);
};

View File

@@ -1,15 +1,15 @@
import { useEffect, useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faSparkles, faUser } from '@fortawesome/pro-duotone-svg-icons';
import { AiChatPanel } from './ai-chat-panel';
import { LiveTranscript } from './live-transcript';
import { useCallAssist } from '@/hooks/use-call-assist';
import { Badge } from '@/components/base/badges/badges';
import { formatPhone, formatShortDate } from '@/lib/format';
import { cx } from '@/utils/cx';
import type { Lead, LeadActivity } from '@/types/entities';
import { useEffect, useState } from "react";
import { faSparkles, faUser } from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Badge } from "@/components/base/badges/badges";
import { useCallAssist } from "@/hooks/use-call-assist";
import { formatPhone, formatShortDate } from "@/lib/format";
import type { Lead, LeadActivity } from "@/types/entities";
import { cx } from "@/utils/cx";
import { AiChatPanel } from "./ai-chat-panel";
import { LiveTranscript } from "./live-transcript";
type ContextTab = 'ai' | 'lead360';
type ContextTab = "ai" | "lead360";
interface ContextPanelProps {
selectedLead: Lead | null;
@@ -20,51 +20,51 @@ interface ContextPanelProps {
}
export const ContextPanel = ({ selectedLead, activities, callerPhone, isInCall, callUcid }: ContextPanelProps) => {
const [activeTab, setActiveTab] = useState<ContextTab>('ai');
const [activeTab, setActiveTab] = useState<ContextTab>("ai");
// Auto-switch to lead 360 when a lead is selected
useEffect(() => {
if (selectedLead) {
setActiveTab('lead360');
// eslint-disable-next-line react-hooks/set-state-in-effect
setActiveTab("lead360");
}
}, [selectedLead?.id]);
const { transcript, suggestions, connected: assistConnected } = useCallAssist(
isInCall ?? false,
callUcid ?? null,
selectedLead?.id ?? null,
callerPhone ?? null,
);
const {
transcript,
suggestions,
connected: assistConnected,
} = useCallAssist(isInCall ?? false, callUcid ?? null, selectedLead?.id ?? null, callerPhone ?? null);
const callerContext = selectedLead ? {
callerPhone: selectedLead.contactPhone?.[0]?.number ?? callerPhone,
leadId: selectedLead.id,
leadName: `${selectedLead.contactName?.firstName ?? ''} ${selectedLead.contactName?.lastName ?? ''}`.trim(),
} : callerPhone ? { callerPhone } : undefined;
const callerContext = selectedLead
? {
callerPhone: selectedLead.contactPhone?.[0]?.number ?? callerPhone,
leadId: selectedLead.id,
leadName: `${selectedLead.contactName?.firstName ?? ""} ${selectedLead.contactName?.lastName ?? ""}`.trim(),
}
: callerPhone
? { callerPhone }
: undefined;
return (
<div className="flex h-full flex-col">
{/* Tab bar */}
<div className="flex shrink-0 border-b border-secondary">
<button
onClick={() => setActiveTab('ai')}
onClick={() => setActiveTab("ai")}
className={cx(
"flex flex-1 items-center justify-center gap-1.5 px-3 py-2.5 text-xs font-semibold transition duration-100 ease-linear",
activeTab === 'ai'
? "border-b-2 border-brand text-brand-secondary"
: "text-tertiary hover:text-secondary",
activeTab === "ai" ? "border-b-2 border-brand text-brand-secondary" : "text-tertiary hover:text-secondary",
)}
>
<FontAwesomeIcon icon={faSparkles} className="size-3.5" />
AI Assistant
</button>
<button
onClick={() => setActiveTab('lead360')}
onClick={() => setActiveTab("lead360")}
className={cx(
"flex flex-1 items-center justify-center gap-1.5 px-3 py-2.5 text-xs font-semibold transition duration-100 ease-linear",
activeTab === 'lead360'
? "border-b-2 border-brand text-brand-secondary"
: "text-tertiary hover:text-secondary",
activeTab === "lead360" ? "border-b-2 border-brand text-brand-secondary" : "text-tertiary hover:text-secondary",
)}
>
<FontAwesomeIcon icon={faUser} className="size-3.5" />
@@ -74,18 +74,15 @@ export const ContextPanel = ({ selectedLead, activities, callerPhone, isInCall,
{/* Tab content */}
<div className="flex-1 overflow-y-auto">
{activeTab === 'ai' && (
isInCall ? (
{activeTab === "ai" &&
(isInCall ? (
<LiveTranscript transcript={transcript} suggestions={suggestions} connected={assistConnected} />
) : (
<div className="flex h-full flex-col p-4">
<AiChatPanel callerContext={callerContext} />
</div>
)
)}
{activeTab === 'lead360' && (
<Lead360Tab lead={selectedLead} activities={activities} />
)}
))}
{activeTab === "lead360" && <Lead360Tab lead={selectedLead} activities={activities} />}
</div>
</div>
);
@@ -94,44 +91,50 @@ export const ContextPanel = ({ selectedLead, activities, callerPhone, isInCall,
const Lead360Tab = ({ lead, activities }: { lead: Lead | null; activities: LeadActivity[] }) => {
if (!lead) {
return (
<div className="flex flex-col items-center justify-center py-16 text-center px-4">
<div className="flex flex-col items-center justify-center px-4 py-16 text-center">
<FontAwesomeIcon icon={faUser} className="mb-3 size-8 text-fg-quaternary" />
<p className="text-sm text-tertiary">Select a lead from the worklist to see their full profile.</p>
</div>
);
}
const firstName = lead.contactName?.firstName ?? '';
const lastName = lead.contactName?.lastName ?? '';
const fullName = `${firstName} ${lastName}`.trim() || 'Unknown';
const firstName = lead.contactName?.firstName ?? "";
const lastName = lead.contactName?.lastName ?? "";
const fullName = `${firstName} ${lastName}`.trim() || "Unknown";
const phone = lead.contactPhone?.[0];
const email = lead.contactEmail?.[0]?.address;
const leadActivities = activities
.filter((a) => a.leadId === lead.id)
.sort((a, b) => new Date(b.occurredAt ?? b.createdAt ?? '').getTime() - new Date(a.occurredAt ?? a.createdAt ?? '').getTime())
.sort((a, b) => new Date(b.occurredAt ?? b.createdAt ?? "").getTime() - new Date(a.occurredAt ?? a.createdAt ?? "").getTime())
.slice(0, 10);
return (
<div className="p-4 space-y-4">
<div className="space-y-4 p-4">
{/* Profile */}
<div>
<h3 className="text-lg font-bold text-primary">{fullName}</h3>
{phone && <p className="text-sm text-secondary">{formatPhone(phone)}</p>}
{email && <p className="text-xs text-tertiary">{email}</p>}
<div className="mt-2 flex flex-wrap gap-1.5">
{lead.leadStatus && <Badge size="sm" color="brand">{lead.leadStatus}</Badge>}
{lead.leadSource && <Badge size="sm" color="gray">{lead.leadSource}</Badge>}
{lead.priority && lead.priority !== 'NORMAL' && (
<Badge size="sm" color={lead.priority === 'URGENT' ? 'error' : 'warning'}>{lead.priority}</Badge>
{lead.leadStatus && (
<Badge size="sm" color="brand">
{lead.leadStatus}
</Badge>
)}
{lead.leadSource && (
<Badge size="sm" color="gray">
{lead.leadSource}
</Badge>
)}
{lead.priority && lead.priority !== "NORMAL" && (
<Badge size="sm" color={lead.priority === "URGENT" ? "error" : "warning"}>
{lead.priority}
</Badge>
)}
</div>
{lead.interestedService && (
<p className="mt-2 text-sm text-secondary">Interested in: {lead.interestedService}</p>
)}
{lead.leadScore !== null && lead.leadScore !== undefined && (
<p className="text-xs text-tertiary">Lead score: {lead.leadScore}</p>
)}
{lead.interestedService && <p className="mt-2 text-sm text-secondary">Interested in: {lead.interestedService}</p>}
{lead.leadScore !== null && lead.leadScore !== undefined && <p className="text-xs text-tertiary">Lead score: {lead.leadScore}</p>}
</div>
{/* AI Insight */}
@@ -139,12 +142,10 @@ const Lead360Tab = ({ lead, activities }: { lead: Lead | null; activities: LeadA
<div className="rounded-lg bg-brand-primary p-3">
<div className="mb-1 flex items-center gap-1.5">
<FontAwesomeIcon icon={faSparkles} className="size-3 text-fg-brand-primary" />
<span className="text-[10px] font-bold uppercase tracking-wider text-brand-secondary">AI Insight</span>
<span className="text-[10px] font-bold tracking-wider text-brand-secondary uppercase">AI Insight</span>
</div>
{lead.aiSummary && <p className="text-xs text-primary">{lead.aiSummary}</p>}
{lead.aiSuggestedAction && (
<p className="mt-1 text-xs font-semibold text-brand-secondary">{lead.aiSuggestedAction}</p>
)}
{lead.aiSuggestedAction && <p className="mt-1 text-xs font-semibold text-brand-secondary">{lead.aiSuggestedAction}</p>}
</div>
)}
@@ -159,7 +160,8 @@ const Lead360Tab = ({ lead, activities }: { lead: Lead | null; activities: LeadA
<div className="min-w-0 flex-1">
<p className="text-xs text-primary">{a.summary}</p>
<p className="text-[10px] text-quaternary">
{a.activityType}{a.occurredAt ? ` · ${formatShortDate(a.occurredAt)}` : ''}
{a.activityType}
{a.occurredAt ? ` · ${formatShortDate(a.occurredAt)}` : ""}
</p>
</div>
</div>

View File

@@ -1,11 +1,11 @@
import type { Call } from '@/types/entities';
import type { Call } from "@/types/entities";
interface DailyStatsProps {
calls: Call[];
}
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 avgMinutes = totalSeconds / calls.length / 60;
return `${avgMinutes.toFixed(1)} min`;
@@ -13,29 +13,24 @@ const formatAvgDuration = (calls: Call[]): string => {
export const DailyStats = ({ calls }: DailyStatsProps) => {
const callsHandled = calls.length;
const appointmentsBooked = calls.filter((c) => c.disposition === 'APPOINTMENT_BOOKED').length;
const followUps = calls.filter((c) => c.disposition === 'FOLLOW_UP_SCHEDULED').length;
const appointmentsBooked = calls.filter((c) => c.disposition === "APPOINTMENT_BOOKED").length;
const followUps = calls.filter((c) => c.disposition === "FOLLOW_UP_SCHEDULED").length;
const avgDuration = formatAvgDuration(calls);
const stats = [
{ label: 'Calls Handled', value: String(callsHandled) },
{ label: 'Appointments Booked', value: String(appointmentsBooked) },
{ label: 'Follow-ups', value: String(followUps) },
{ label: 'Avg Duration', value: avgDuration },
{ label: "Calls Handled", value: String(callsHandled) },
{ label: "Appointments Booked", value: String(appointmentsBooked) },
{ label: "Follow-ups", value: String(followUps) },
{ label: "Avg Duration", value: avgDuration },
];
return (
<div className="flex flex-col gap-3">
<h3 className="text-sm font-bold text-primary">Daily Stats</h3>
{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="mt-1 text-xs uppercase tracking-wider text-tertiary">
{stat.label}
</div>
<div className="mt-1 text-xs tracking-wider text-tertiary uppercase">{stat.label}</div>
</div>
))}
</div>

View File

@@ -1,7 +1,7 @@
import { useState } from 'react';
import { TextArea } from '@/components/base/textarea/textarea';
import type { CallDisposition } from '@/types/entities';
import { cx } from '@/utils/cx';
import { useState } from "react";
import { TextArea } from "@/components/base/textarea/textarea";
import type { CallDisposition } from "@/types/entities";
import { cx } from "@/utils/cx";
interface DispositionFormProps {
onSubmit: (disposition: CallDisposition, notes: string) => void;
@@ -15,46 +15,46 @@ const dispositionOptions: Array<{
defaultClass: string;
}> = [
{
value: 'APPOINTMENT_BOOKED',
label: 'Appointment Booked',
activeClass: 'bg-success-solid text-white ring-transparent',
defaultClass: 'bg-success-primary text-success-primary border-success',
value: "APPOINTMENT_BOOKED",
label: "Appointment Booked",
activeClass: "bg-success-solid text-white ring-transparent",
defaultClass: "bg-success-primary text-success-primary border-success",
},
{
value: 'FOLLOW_UP_SCHEDULED',
label: 'Follow-up Needed',
activeClass: 'bg-brand-solid text-white ring-transparent',
defaultClass: 'bg-brand-primary text-brand-secondary border-brand',
value: "FOLLOW_UP_SCHEDULED",
label: "Follow-up Needed",
activeClass: "bg-brand-solid text-white ring-transparent",
defaultClass: "bg-brand-primary text-brand-secondary border-brand",
},
{
value: 'INFO_PROVIDED',
label: 'Info Provided',
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',
value: "INFO_PROVIDED",
label: "Info Provided",
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",
},
{
value: 'NO_ANSWER',
label: 'No Answer',
activeClass: 'bg-warning-solid text-white ring-transparent',
defaultClass: 'bg-warning-primary text-warning-primary border-warning',
value: "NO_ANSWER",
label: "No Answer",
activeClass: "bg-warning-solid text-white ring-transparent",
defaultClass: "bg-warning-primary text-warning-primary border-warning",
},
{
value: 'WRONG_NUMBER',
label: 'Wrong Number',
activeClass: 'bg-secondary-solid text-white ring-transparent',
defaultClass: 'bg-secondary text-secondary border-secondary',
value: "WRONG_NUMBER",
label: "Wrong Number",
activeClass: "bg-secondary-solid text-white ring-transparent",
defaultClass: "bg-secondary text-secondary border-secondary",
},
{
value: 'CALLBACK_REQUESTED',
label: 'Not Interested',
activeClass: 'bg-error-solid text-white ring-transparent',
defaultClass: 'bg-error-primary text-error-primary border-error',
value: "CALLBACK_REQUESTED",
label: "Not Interested",
activeClass: "bg-error-solid text-white ring-transparent",
defaultClass: "bg-error-primary text-error-primary border-error",
},
];
export const DispositionForm = ({ onSubmit, defaultDisposition }: DispositionFormProps) => {
const [selected, setSelected] = useState<CallDisposition | null>(defaultDisposition ?? null);
const [notes, setNotes] = useState('');
const [notes, setNotes] = useState("");
const handleSubmit = () => {
if (selected === null) return;
@@ -74,10 +74,8 @@ export const DispositionForm = ({ onSubmit, defaultDisposition }: DispositionFor
type="button"
onClick={() => setSelected(option.value)}
className={cx(
'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,
"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,
)}
>
{option.label}
@@ -86,13 +84,7 @@ export const DispositionForm = ({ onSubmit, defaultDisposition }: DispositionFor
})}
</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">
<button
@@ -100,10 +92,10 @@ export const DispositionForm = ({ onSubmit, defaultDisposition }: DispositionFor
onClick={handleSubmit}
disabled={selected === null}
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
? 'cursor-pointer bg-brand-solid text-white hover:bg-brand-solid_hover'
: 'cursor-not-allowed bg-disabled text-disabled',
? "cursor-pointer bg-brand-solid text-white hover:bg-brand-solid_hover"
: "cursor-not-allowed bg-disabled text-disabled",
)}
>
Save & Close Call

View File

@@ -1,13 +1,13 @@
import { useState, useEffect } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faClipboardQuestion, faXmark } from '@fortawesome/pro-duotone-svg-icons';
import { Input } from '@/components/base/input/input';
import { Select } from '@/components/base/select/select';
import { TextArea } from '@/components/base/textarea/textarea';
import { Checkbox } from '@/components/base/checkbox/checkbox';
import { Button } from '@/components/base/buttons/button';
import { apiClient } from '@/lib/api-client';
import { notify } from '@/lib/toast';
import { useEffect, useState } from "react";
import { faClipboardQuestion, faXmark } from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Button } from "@/components/base/buttons/button";
import { Checkbox } from "@/components/base/checkbox/checkbox";
import { Input } from "@/components/base/input/input";
import { Select } from "@/components/base/select/select";
import { TextArea } from "@/components/base/textarea/textarea";
import { apiClient } from "@/lib/api-client";
import { notify } from "@/lib/toast";
type EnquiryFormProps = {
isOpen: boolean;
@@ -17,24 +17,24 @@ type EnquiryFormProps = {
};
const dispositionItems = [
{ id: 'CONVERTED', label: 'Converted' },
{ id: 'FOLLOW_UP', label: 'Follow-up Needed' },
{ id: 'GENERAL_QUERY', label: 'General Query' },
{ id: 'NO_ANSWER', label: 'No Answer' },
{ id: 'INVALID_NUMBER', label: 'Invalid Number' },
{ id: 'CALL_DROPPED', label: 'Call Dropped' },
{ id: "CONVERTED", label: "Converted" },
{ id: "FOLLOW_UP", label: "Follow-up Needed" },
{ id: "GENERAL_QUERY", label: "General Query" },
{ id: "NO_ANSWER", label: "No Answer" },
{ id: "INVALID_NUMBER", label: "Invalid Number" },
{ id: "CALL_DROPPED", label: "Call Dropped" },
];
export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, onSaved }: EnquiryFormProps) => {
const [patientName, setPatientName] = useState('');
const [source, setSource] = useState('Phone Inquiry');
const [queryAsked, setQueryAsked] = useState('');
const [patientName, setPatientName] = useState("");
const [source, setSource] = useState("Phone Inquiry");
const [queryAsked, setQueryAsked] = useState("");
const [isExisting, setIsExisting] = useState(false);
const [registeredPhone, setRegisteredPhone] = useState(callerPhone ?? '');
const [registeredPhone, setRegisteredPhone] = useState(callerPhone ?? "");
const [department, setDepartment] = useState<string | null>(null);
const [doctor, setDoctor] = useState<string | null>(null);
const [followUpNeeded, setFollowUpNeeded] = useState(false);
const [followUpDate, setFollowUpDate] = useState('');
const [followUpDate, setFollowUpDate] = useState("");
const [disposition, setDisposition] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -44,28 +44,35 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, onSaved }: Enqu
useEffect(() => {
if (!isOpen) return;
apiClient.graphql<{ doctors: { edges: Array<{ node: any }> } }>(
`{ doctors(first: 50) { edges { node {
apiClient
.graphql<{ doctors: { edges: Array<{ node: any }> } }>(
`{ doctors(first: 50) { edges { node {
id name fullName { firstName lastName } department
} } } }`,
).then(data => {
setDoctors(data.doctors.edges.map(e => ({
id: e.node.id,
name: e.node.fullName ? `Dr. ${e.node.fullName.firstName} ${e.node.fullName.lastName}`.trim() : e.node.name,
department: e.node.department ?? '',
})));
}).catch(() => {});
)
.then((data) => {
setDoctors(
data.doctors.edges.map((e) => ({
id: e.node.id,
name: e.node.fullName ? `Dr. ${e.node.fullName.firstName} ${e.node.fullName.lastName}`.trim() : e.node.name,
department: e.node.department ?? "",
})),
);
})
.catch(() => {});
}, [isOpen]);
const departmentItems = [...new Set(doctors.map(d => d.department).filter(Boolean))]
.map(dept => ({ id: dept, label: dept.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()) }));
const departmentItems = [...new Set(doctors.map((d) => d.department).filter(Boolean))].map((dept) => ({
id: dept,
label: dept.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()),
}));
const filteredDoctors = department ? doctors.filter(d => d.department === department) : doctors;
const doctorItems = filteredDoctors.map(d => ({ id: d.id, label: d.name }));
const filteredDoctors = department ? doctors.filter((d) => d.department === department) : doctors;
const doctorItems = filteredDoctors.map((d) => ({ id: d.id, label: d.name }));
const handleSave = async () => {
if (!patientName.trim() || !queryAsked.trim() || !disposition) {
setError('Please fill in required fields: patient name, query, and disposition.');
setError("Please fill in required fields: patient name, query, and disposition.");
return;
}
@@ -74,19 +81,16 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, onSaved }: Enqu
try {
// Create a lead with source PHONE_INQUIRY
await apiClient.graphql(
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
{
data: {
name: `Enquiry — ${patientName}`,
contactName: { firstName: patientName.split(' ')[0], lastName: patientName.split(' ').slice(1).join(' ') || '' },
contactPhone: registeredPhone ? { primaryPhoneNumber: registeredPhone } : undefined,
source: 'PHONE_INQUIRY',
status: disposition === 'CONVERTED' ? 'CONVERTED' : 'NEW',
interestedService: queryAsked.substring(0, 100),
},
await apiClient.graphql(`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`, {
data: {
name: `Enquiry — ${patientName}`,
contactName: { firstName: patientName.split(" ")[0], lastName: patientName.split(" ").slice(1).join(" ") || "" },
contactPhone: registeredPhone ? { primaryPhoneNumber: registeredPhone } : undefined,
source: "PHONE_INQUIRY",
status: disposition === "CONVERTED" ? "CONVERTED" : "NEW",
interestedService: queryAsked.substring(0, 100),
},
);
});
// Create follow-up if needed
if (followUpNeeded && followUpDate) {
@@ -95,9 +99,9 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, onSaved }: Enqu
{
data: {
name: `Follow-up — ${patientName}`,
typeCustom: 'CALLBACK',
status: 'PENDING',
priority: 'NORMAL',
typeCustom: "CALLBACK",
status: "PENDING",
priority: "NORMAL",
scheduledAt: new Date(`${followUpDate}T09:00:00`).toISOString(),
},
},
@@ -105,10 +109,10 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, onSaved }: Enqu
);
}
notify.success('Enquiry Logged', 'Contact details and query captured');
notify.success("Enquiry Logged", "Contact details and query captured");
onSaved?.();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save enquiry');
setError(err instanceof Error ? err.message : "Failed to save enquiry");
} finally {
setIsSaving(false);
}
@@ -118,7 +122,7 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, onSaved }: Enqu
return (
<div className="rounded-xl border border-secondary bg-primary p-4">
<div className="flex items-center justify-between mb-4">
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex size-8 items-center justify-center rounded-lg bg-warning-secondary">
<FontAwesomeIcon icon={faClipboardQuestion} className="size-4 text-fg-warning-primary" />
@@ -130,7 +134,7 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, onSaved }: Enqu
</div>
<button
onClick={() => onOpenChange(false)}
className="flex size-7 items-center justify-center rounded-md text-fg-quaternary hover:text-fg-secondary hover:bg-primary_hover transition duration-100 ease-linear"
className="flex size-7 items-center justify-center rounded-md text-fg-quaternary transition duration-100 ease-linear hover:bg-primary_hover hover:text-fg-secondary"
>
<FontAwesomeIcon icon={faXmark} className="size-4" />
</button>
@@ -145,43 +149,59 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, onSaved }: Enqu
<Checkbox isSelected={isExisting} onChange={setIsExisting} label="Existing Patient" hint="Has visited the hospital before" />
{isExisting && (
<Input label="Registered Phone" placeholder="Phone number on file" value={registeredPhone} onChange={setRegisteredPhone} />
)}
{isExisting && <Input label="Registered Phone" placeholder="Phone number on file" value={registeredPhone} onChange={setRegisteredPhone} />}
<div className="border-t border-secondary" />
<div className="grid grid-cols-2 gap-3">
<Select label="Department" placeholder="Optional" items={departmentItems} selectedKey={department}
onSelectionChange={(key) => { setDepartment(key as string); setDoctor(null); }}>
<Select
label="Department"
placeholder="Optional"
items={departmentItems}
selectedKey={department}
onSelectionChange={(key) => {
setDepartment(key as string);
setDoctor(null);
}}
>
{(item) => <Select.Item id={item.id} label={item.label} />}
</Select>
<Select label="Doctor" placeholder="Optional" items={doctorItems} selectedKey={doctor}
onSelectionChange={(key) => setDoctor(key as string)} isDisabled={!department}>
<Select
label="Doctor"
placeholder="Optional"
items={doctorItems}
selectedKey={doctor}
onSelectionChange={(key) => setDoctor(key as string)}
isDisabled={!department}
>
{(item) => <Select.Item id={item.id} label={item.label} />}
</Select>
</div>
<Checkbox isSelected={followUpNeeded} onChange={setFollowUpNeeded} label="Follow-up Needed" />
{followUpNeeded && (
<Input label="Follow-up Date" type="date" value={followUpDate} onChange={setFollowUpDate} isRequired />
)}
{followUpNeeded && <Input label="Follow-up Date" type="date" value={followUpDate} onChange={setFollowUpDate} isRequired />}
<Select label="Disposition" placeholder="Select outcome" items={dispositionItems} selectedKey={disposition}
onSelectionChange={(key) => setDisposition(key as string)} isRequired>
<Select
label="Disposition"
placeholder="Select outcome"
items={dispositionItems}
selectedKey={disposition}
onSelectionChange={(key) => setDisposition(key as string)}
isRequired
>
{(item) => <Select.Item id={item.id} label={item.label} />}
</Select>
{error && (
<div className="rounded-lg bg-error-primary p-3 text-sm text-error-primary">{error}</div>
)}
{error && <div className="rounded-lg bg-error-primary p-3 text-sm text-error-primary">{error}</div>}
</div>
<div className="flex items-center justify-end gap-3 mt-4 pt-4 border-t border-secondary">
<Button size="sm" color="secondary" onClick={() => onOpenChange(false)}>Cancel</Button>
<div className="mt-4 flex items-center justify-end gap-3 border-t border-secondary pt-4">
<Button size="sm" color="secondary" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button size="sm" color="primary" isLoading={isSaving} showTextWhileLoading onClick={handleSave}>
{isSaving ? 'Saving...' : 'Log Enquiry'}
{isSaving ? "Saving..." : "Log Enquiry"}
</Button>
</div>
</div>

View File

@@ -1,15 +1,15 @@
import { useMemo } from 'react';
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 { Badge } from '@/components/base/badges/badges';
import { SourceTag } from '@/components/shared/source-tag';
import { AgeIndicator } from '@/components/shared/age-indicator';
import { DispositionForm } from './disposition-form';
import { formatPhone, formatShortDate, getInitials } from '@/lib/format';
import type { Lead, LeadActivity, CallDisposition, Campaign } from '@/types/entities';
import { useMemo } from "react";
import { faCircleCheck, faClock, faEnvelope, faPhone, faPhoneArrowDown, faStars } from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Avatar } from "@/components/base/avatar/avatar";
import { Badge } from "@/components/base/badges/badges";
import { AgeIndicator } from "@/components/shared/age-indicator";
import { SourceTag } from "@/components/shared/source-tag";
import { formatPhone, formatShortDate, getInitials } from "@/lib/format";
import type { CallDisposition, Campaign, Lead, LeadActivity } from "@/types/entities";
import { DispositionForm } from "./disposition-form";
type CallState = 'idle' | 'ringing' | 'active' | 'completed';
type CallState = "idle" | "ringing" | "active" | "completed";
interface IncomingCallCardProps {
callState: CallState;
@@ -21,57 +21,57 @@ interface IncomingCallCardProps {
}
const activityTypeIcons: Record<string, string> = {
CALL_MADE: 'phone',
CALL_RECEIVED: 'phone',
WHATSAPP_SENT: 'message',
WHATSAPP_RECEIVED: 'message',
SMS_SENT: 'message',
EMAIL_SENT: 'email',
EMAIL_RECEIVED: 'email',
NOTE_ADDED: 'note',
ASSIGNED: 'assign',
STATUS_CHANGE: 'status',
APPOINTMENT_BOOKED: 'calendar',
FOLLOW_UP_CREATED: 'clock',
CONVERTED: 'check',
MARKED_SPAM: 'alert',
DUPLICATE_DETECTED: 'alert',
CALL_MADE: "phone",
CALL_RECEIVED: "phone",
WHATSAPP_SENT: "message",
WHATSAPP_RECEIVED: "message",
SMS_SENT: "message",
EMAIL_SENT: "email",
EMAIL_RECEIVED: "email",
NOTE_ADDED: "note",
ASSIGNED: "assign",
STATUS_CHANGE: "status",
APPOINTMENT_BOOKED: "calendar",
FOLLOW_UP_CREATED: "clock",
CONVERTED: "check",
MARKED_SPAM: "alert",
DUPLICATE_DETECTED: "alert",
};
const ActivityIcon = ({ type }: { type: string }) => {
const iconType = activityTypeIcons[type] ?? 'note';
const baseClass = 'size-3.5 shrink-0 text-fg-quaternary';
const iconType = activityTypeIcons[type] ?? "note";
const baseClass = "size-3.5 shrink-0 text-fg-quaternary";
if (iconType === 'phone') return <FontAwesomeIcon icon={faPhone} className={baseClass} />;
if (iconType === 'email') return <FontAwesomeIcon icon={faEnvelope} className={baseClass} />;
if (iconType === 'clock') return <FontAwesomeIcon icon={faClock} className={baseClass} />;
if (iconType === 'check') return <FontAwesomeIcon icon={faCircleCheck} className={baseClass} />;
if (iconType === "phone") return <FontAwesomeIcon icon={faPhone} className={baseClass} />;
if (iconType === "email") return <FontAwesomeIcon icon={faEnvelope} className={baseClass} />;
if (iconType === "clock") return <FontAwesomeIcon icon={faClock} className={baseClass} />;
if (iconType === "check") return <FontAwesomeIcon icon={faCircleCheck} className={baseClass} />;
return <FontAwesomeIcon icon={faClock} className={baseClass} />;
};
const dispositionLabels: Record<CallDisposition, string> = {
APPOINTMENT_BOOKED: 'Appointment Booked',
FOLLOW_UP_SCHEDULED: 'Follow-up Needed',
INFO_PROVIDED: 'Info Provided',
NO_ANSWER: 'No Answer',
WRONG_NUMBER: 'Wrong Number',
CALLBACK_REQUESTED: 'Not Interested',
APPOINTMENT_BOOKED: "Appointment Booked",
FOLLOW_UP_SCHEDULED: "Follow-up Needed",
INFO_PROVIDED: "Info Provided",
NO_ANSWER: "No Answer",
WRONG_NUMBER: "Wrong Number",
CALLBACK_REQUESTED: "Not Interested",
};
export const IncomingCallCard = ({ callState, lead, activities, campaigns, onDisposition, completedDisposition }: IncomingCallCardProps) => {
if (callState === 'idle') {
if (callState === "idle") {
return <IdleState />;
}
if (callState === 'ringing') {
if (callState === "ringing") {
return <RingingState lead={lead} />;
}
if (callState === 'active' && lead !== null) {
if (callState === "active" && lead !== null) {
return <ActiveState lead={lead} activities={activities} campaigns={campaigns} onDisposition={onDisposition} />;
}
if (callState === 'completed') {
if (callState === "completed") {
return <CompletedState disposition={completedDisposition ?? null} />;
}
@@ -88,9 +88,7 @@ const IdleState = () => (
);
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 (
<div className="flex flex-col items-center justify-center rounded-2xl bg-brand-primary p-12 text-center">
@@ -100,12 +98,8 @@ const RingingState = ({ lead }: { lead: Lead | null }) => {
<FontAwesomeIcon icon={faPhoneArrowDown} className="size-12 text-fg-brand-primary" />
</div>
</div>
<span className="mb-1 text-xs font-bold uppercase tracking-wider text-brand-secondary">
Incoming Call
</span>
<span className="text-display-xs font-bold text-primary">
{phoneDisplay}
</span>
<span className="mb-1 text-xs font-bold tracking-wider text-brand-secondary uppercase">Incoming Call</span>
<span className="text-display-xs font-bold text-primary">{phoneDisplay}</span>
</div>
);
};
@@ -126,8 +120,8 @@ const ActiveState = ({
activities
.filter((a) => a.leadId === lead.id)
.sort((a, b) => {
const dateA = a.occurredAt ?? a.createdAt ?? '';
const dateB = b.occurredAt ?? b.createdAt ?? '';
const dateA = a.occurredAt ?? a.createdAt ?? "";
const dateB = b.occurredAt ?? b.createdAt ?? "";
return new Date(dateB).getTime() - new Date(dateA).getTime();
})
.slice(0, 3),
@@ -140,13 +134,11 @@ const ActiveState = ({
return campaign?.campaignName ?? null;
}, [campaigns, lead.campaignId]);
const firstName = lead.contactName?.firstName ?? '';
const lastName = lead.contactName?.lastName ?? '';
const fullName = `${firstName} ${lastName}`.trim() || 'Unknown Lead';
const initials = firstName && lastName ? getInitials(firstName, lastName) : 'UL';
const phoneDisplay = lead.contactPhone?.[0]
? formatPhone(lead.contactPhone[0])
: 'No phone';
const firstName = lead.contactName?.firstName ?? "";
const lastName = lead.contactName?.lastName ?? "";
const fullName = `${firstName} ${lastName}`.trim() || "Unknown Lead";
const initials = firstName && lastName ? getInitials(firstName, lastName) : "UL";
const phoneDisplay = lead.contactPhone?.[0] ? formatPhone(lead.contactPhone[0]) : "No phone";
const emailDisplay = lead.contactEmail?.[0]?.address ?? null;
return (
@@ -169,18 +161,14 @@ const ActiveState = ({
</div>
)}
<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 && (
<Badge size="sm" color="brand">{campaignName}</Badge>
<Badge size="sm" color="brand">
{campaignName}
</Badge>
)}
</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 && (
<div className="mt-1 flex items-center gap-1.5 text-sm text-tertiary">
<span>Lead age:</span>
@@ -194,9 +182,7 @@ const ActiveState = ({
<div className="mt-4 rounded-xl bg-brand-primary p-4">
<div className="mb-2 flex items-center gap-1.5">
<FontAwesomeIcon icon={faStars} className="size-4 text-fg-brand-primary" />
<span className="text-xs font-bold uppercase tracking-wider text-brand-secondary">
AI Insight
</span>
<span className="text-xs font-bold tracking-wider text-brand-secondary uppercase">AI Insight</span>
</div>
{lead.aiSummary !== null ? (
<>
@@ -208,9 +194,7 @@ 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>
@@ -221,14 +205,10 @@ const ActiveState = ({
<div className="flex flex-col gap-2">
{leadActivities.map((activity) => (
<div key={activity.id} className="flex items-start gap-2">
<ActivityIcon type={activity.activityType ?? 'NOTE_ADDED'} />
<span className="flex-1 text-xs text-secondary">
{activity.summary}
</span>
<ActivityIcon type={activity.activityType ?? "NOTE_ADDED"} />
<span className="flex-1 text-xs text-secondary">{activity.summary}</span>
<span className="shrink-0 text-xs text-quaternary">
{activity.occurredAt !== null
? formatShortDate(activity.occurredAt)
: ''}
{activity.occurredAt !== null ? formatShortDate(activity.occurredAt) : ""}
</span>
</div>
))}
@@ -240,7 +220,7 @@ const ActiveState = ({
</div>
{/* Right section: disposition form */}
<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">
<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">
<DispositionForm onSubmit={onDisposition} />
</div>
</div>
@@ -249,14 +229,16 @@ const ActiveState = ({
};
const CompletedState = ({ disposition }: { disposition: CallDisposition | null }) => {
const label = disposition !== null ? dispositionLabels[disposition] : 'Unknown';
const label = disposition !== null ? dispositionLabels[disposition] : "Unknown";
return (
<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" />
<h3 className="text-lg font-bold text-success-primary">Call Logged</h3>
{disposition !== null && (
<Badge size="md" color="success" className="mt-2">{label}</Badge>
<Badge size="md" color="success" className="mt-2">
{label}
</Badge>
)}
<p className="mt-2 text-sm text-tertiary">Returning to call desk...</p>
</div>

View File

@@ -1,7 +1,7 @@
import { useEffect, useRef } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faSparkles, faMicrophone } from '@fortawesome/pro-duotone-svg-icons';
import { cx } from '@/utils/cx';
import { useEffect, useRef } from "react";
import { faMicrophone, faSparkles } from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { cx } from "@/utils/cx";
type TranscriptLine = {
id: string;
@@ -33,37 +33,34 @@ export const LiveTranscript = ({ transcript, suggestions, connected }: LiveTrans
// Merge transcript and suggestions by timestamp
const items = [
...transcript.map(t => ({ ...t, kind: 'transcript' as const })),
...suggestions.map(s => ({ ...s, kind: 'suggestion' as const, isFinal: true })),
...transcript.map((t) => ({ ...t, kind: "transcript" as const })),
...suggestions.map((s) => ({ ...s, kind: "suggestion" as const, isFinal: true })),
].sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
return (
<div className="flex flex-1 flex-col overflow-hidden">
{/* Header */}
<div className="flex items-center gap-2 px-4 py-3 border-b border-secondary">
<div className="flex items-center gap-2 border-b border-secondary px-4 py-3">
<FontAwesomeIcon icon={faSparkles} className="size-3.5 text-fg-brand-primary" />
<span className="text-xs font-bold uppercase tracking-wider text-brand-secondary">Live Assist</span>
<div className={cx(
"ml-auto size-2 rounded-full",
connected ? "bg-success-solid" : "bg-disabled",
)} />
<span className="text-xs font-bold tracking-wider text-brand-secondary uppercase">Live Assist</span>
<div className={cx("ml-auto size-2 rounded-full", connected ? "bg-success-solid" : "bg-disabled")} />
</div>
{/* Transcript body */}
<div ref={scrollRef} className="flex-1 overflow-y-auto px-4 py-3 space-y-2">
<div ref={scrollRef} className="flex-1 space-y-2 overflow-y-auto px-4 py-3">
{items.length === 0 && (
<div className="flex flex-col items-center justify-center py-8 text-center">
<FontAwesomeIcon icon={faMicrophone} className="size-6 text-fg-quaternary mb-2" />
<FontAwesomeIcon icon={faMicrophone} className="mb-2 size-6 text-fg-quaternary" />
<p className="text-xs text-quaternary">Listening to customer...</p>
<p className="text-xs text-quaternary">Transcript will appear here</p>
</div>
)}
{items.map(item => {
if (item.kind === 'suggestion') {
{items.map((item) => {
if (item.kind === "suggestion") {
return (
<div key={item.id} className="rounded-lg bg-brand-primary p-3 border border-brand">
<div className="flex items-center gap-1.5 mb-1">
<div key={item.id} className="rounded-lg border border-brand bg-brand-primary p-3">
<div className="mb-1 flex items-center gap-1.5">
<FontAwesomeIcon icon={faSparkles} className="size-3 text-fg-brand-primary" />
<span className="text-xs font-semibold text-brand-secondary">AI Suggestion</span>
</div>
@@ -73,12 +70,9 @@ export const LiveTranscript = ({ transcript, suggestions, connected }: LiveTrans
}
return (
<div key={item.id} className={cx(
"text-sm",
item.isFinal ? "text-primary" : "text-tertiary italic",
)}>
<span className="text-xs text-quaternary mr-2">
{item.timestamp.toLocaleTimeString('en-IN', { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
<div key={item.id} className={cx("text-sm", item.isFinal ? "text-primary" : "text-tertiary italic")}>
<span className="mr-2 text-xs text-quaternary">
{item.timestamp.toLocaleTimeString("en-IN", { hour: "2-digit", minute: "2-digit", second: "2-digit" })}
</span>
{item.text}
</div>

View File

@@ -1,13 +1,13 @@
import { useState, useRef, useEffect } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPhone, faCommentDots, faEllipsisVertical, faMessageDots } from '@fortawesome/pro-duotone-svg-icons';
import { useSetAtom } from 'jotai';
import { useSip } from '@/providers/sip-provider';
import { sipCallStateAtom, sipCallerNumberAtom, sipCallUcidAtom } from '@/state/sip-state';
import { setOutboundPending } from '@/state/sip-manager';
import { apiClient } from '@/lib/api-client';
import { notify } from '@/lib/toast';
import { cx } from '@/utils/cx';
import { useEffect, useRef, useState } from "react";
import { faCommentDots, faEllipsisVertical, faMessageDots, faPhone } from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useSetAtom } from "jotai";
import { apiClient } from "@/lib/api-client";
import { notify } from "@/lib/toast";
import { useSip } from "@/providers/sip-provider";
import { setOutboundPending } from "@/state/sip-manager";
import { sipCallStateAtom, sipCallUcidAtom, sipCallerNumberAtom } from "@/state/sip-state";
import { cx } from "@/utils/cx";
type PhoneActionCellProps = {
phoneNumber: string;
@@ -33,29 +33,29 @@ export const PhoneActionCell = ({ phoneNumber, displayNumber, leadId: _leadId }:
setMenuOpen(false);
}
};
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [menuOpen]);
const handleCall = async () => {
if (!isRegistered || isInCall || dialing) return;
setMenuOpen(false);
setDialing(true);
setCallState('ringing-out');
setCallState("ringing-out");
setCallerNumber(phoneNumber);
setOutboundPending(true);
const safetyTimer = setTimeout(() => setOutboundPending(false), 30000);
try {
const result = await apiClient.post<{ ucid?: string }>('/api/ozonetel/dial', { phoneNumber });
const result = await apiClient.post<{ ucid?: string }>("/api/ozonetel/dial", { phoneNumber });
if (result?.ucid) setCallUcid(result.ucid);
} catch {
clearTimeout(safetyTimer);
setCallState('idle');
setCallState("idle");
setCallerNumber(null);
setOutboundPending(false);
setCallUcid(null);
notify.error('Dial Failed', 'Could not place the call');
notify.error("Dial Failed", "Could not place the call");
} finally {
setDialing(false);
}
@@ -63,12 +63,12 @@ export const PhoneActionCell = ({ phoneNumber, displayNumber, leadId: _leadId }:
const handleSms = () => {
setMenuOpen(false);
window.open(`sms:+91${phoneNumber}`, '_self');
window.open(`sms:+91${phoneNumber}`, "_self");
};
const handleWhatsApp = () => {
setMenuOpen(false);
window.open(`https://wa.me/91${phoneNumber}`, '_blank');
window.open(`https://wa.me/91${phoneNumber}`, "_blank");
};
// Long-press for mobile
@@ -93,13 +93,14 @@ export const PhoneActionCell = ({ phoneNumber, displayNumber, leadId: _leadId }:
onClick={handleCall}
onTouchStart={onTouchStart}
onTouchEnd={onTouchEnd}
onContextMenu={(e) => { e.preventDefault(); setMenuOpen(true); }}
onContextMenu={(e) => {
e.preventDefault();
setMenuOpen(true);
}}
disabled={!canCall}
className={cx(
'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',
"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",
)}
>
<FontAwesomeIcon icon={faPhone} className="size-3" />
@@ -109,15 +110,18 @@ export const PhoneActionCell = ({ phoneNumber, displayNumber, leadId: _leadId }:
{/* Kebab menu trigger — desktop */}
<button
type="button"
onClick={(e) => { e.stopPropagation(); setMenuOpen(!menuOpen); }}
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"
onClick={(e) => {
e.stopPropagation();
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" />
</button>
{/* Context menu */}
{menuOpen && (
<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">
<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">
<button
type="button"
onClick={handleCall}

View File

@@ -1,10 +1,10 @@
import { useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faXmark } from '@fortawesome/pro-duotone-svg-icons';
import { Input } from '@/components/base/input/input';
import { Button } from '@/components/base/buttons/button';
import { apiClient } from '@/lib/api-client';
import { notify } from '@/lib/toast';
import { useState } from "react";
import { faXmark } from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Button } from "@/components/base/buttons/button";
import { Input } from "@/components/base/input/input";
import { apiClient } from "@/lib/api-client";
import { notify } from "@/lib/toast";
type TransferDialogProps = {
ucid: string;
@@ -13,23 +13,23 @@ type TransferDialogProps = {
};
export const TransferDialog = ({ ucid, onClose, onTransferred }: TransferDialogProps) => {
const [number, setNumber] = useState('');
const [number, setNumber] = useState("");
const [transferring, setTransferring] = useState(false);
const [stage, setStage] = useState<'input' | 'connected'>('input');
const [stage, setStage] = useState<"input" | "connected">("input");
const handleConference = async () => {
if (!number.trim()) return;
setTransferring(true);
try {
await apiClient.post('/api/ozonetel/call-control', {
action: 'CONFERENCE',
await apiClient.post("/api/ozonetel/call-control", {
action: "CONFERENCE",
ucid,
conferenceNumber: `0${number.replace(/\D/g, '')}`,
conferenceNumber: `0${number.replace(/\D/g, "")}`,
});
notify.success('Connected', 'Third party connected. Click Complete to transfer.');
setStage('connected');
notify.success("Connected", "Third party connected. Click Complete to transfer.");
setStage("connected");
} catch {
notify.error('Transfer Failed', 'Could not connect to the target number');
notify.error("Transfer Failed", "Could not connect to the target number");
} finally {
setTransferring(false);
}
@@ -38,15 +38,15 @@ export const TransferDialog = ({ ucid, onClose, onTransferred }: TransferDialogP
const handleComplete = async () => {
setTransferring(true);
try {
await apiClient.post('/api/ozonetel/call-control', {
action: 'KICK_CALL',
await apiClient.post("/api/ozonetel/call-control", {
action: "KICK_CALL",
ucid,
conferenceNumber: `0${number.replace(/\D/g, '')}`,
conferenceNumber: `0${number.replace(/\D/g, "")}`,
});
notify.success('Transferred', 'Call transferred successfully');
notify.success("Transferred", "Call transferred successfully");
onTransferred();
} catch {
notify.error('Transfer Failed', 'Could not complete transfer');
notify.error("Transfer Failed", "Could not complete transfer");
} finally {
setTransferring(false);
}
@@ -54,27 +54,16 @@ export const TransferDialog = ({ ucid, onClose, onTransferred }: TransferDialogP
return (
<div className="mt-3 rounded-lg border border-secondary bg-secondary p-3">
<div className="flex items-center justify-between mb-2">
<div className="mb-2 flex items-center justify-between">
<span className="text-xs font-semibold text-secondary">Transfer Call</span>
<button onClick={onClose} className="text-fg-quaternary hover:text-fg-secondary transition duration-100 ease-linear">
<button onClick={onClose} className="text-fg-quaternary transition duration-100 ease-linear hover:text-fg-secondary">
<FontAwesomeIcon icon={faXmark} className="size-3" />
</button>
</div>
{stage === 'input' ? (
{stage === "input" ? (
<div className="flex gap-2">
<Input
size="sm"
placeholder="Enter phone number"
value={number}
onChange={setNumber}
/>
<Button
size="sm"
color="primary"
isLoading={transferring}
onClick={handleConference}
isDisabled={!number.trim()}
>
<Input size="sm" placeholder="Enter phone number" value={number} onChange={setNumber} />
<Button size="sm" color="primary" isLoading={transferring} onClick={handleConference} isDisabled={!number.trim()}>
Connect
</Button>
</div>

View File

@@ -1,16 +1,16 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { faPhoneArrowDown, faPhoneArrowUp, faMagnifyingGlass } from '@fortawesome/pro-duotone-svg-icons';
import { faIcon } from '@/lib/icon-wrapper';
import { Table } from '@/components/application/table/table';
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { faMagnifyingGlass, faPhoneArrowDown, faPhoneArrowUp } from "@fortawesome/pro-duotone-svg-icons";
import { Table } from "@/components/application/table/table";
import { Tab, TabList, Tabs } from "@/components/application/tabs/tabs";
import { Badge } from "@/components/base/badges/badges";
import { Input } from "@/components/base/input/input";
import { formatPhone } from "@/lib/format";
import { faIcon } from "@/lib/icon-wrapper";
import { notify } from "@/lib/toast";
import { cx } from "@/utils/cx";
import { PhoneActionCell } from "./phone-action-cell";
const SearchLg = faIcon(faMagnifyingGlass);
import { Badge } from '@/components/base/badges/badges';
import { Input } from '@/components/base/input/input';
import { Tabs, TabList, Tab } from '@/components/application/tabs/tabs';
import { PhoneActionCell } from './phone-action-cell';
import { formatPhone } from '@/lib/format';
import { notify } from '@/lib/toast';
import { cx } from '@/utils/cx';
type WorklistLead = {
id: string;
@@ -51,7 +51,7 @@ type MissedCall = {
callbackattemptedat: string | null;
};
type MissedSubTab = 'pending' | 'attempted' | 'completed' | 'invalid';
type MissedSubTab = "pending" | "attempted" | "completed" | "invalid";
interface WorklistPanelProps {
missedCalls: MissedCall[];
@@ -62,20 +62,20 @@ interface WorklistPanelProps {
selectedLeadId: string | null;
}
type TabKey = 'all' | 'missed' | 'leads' | 'follow-ups';
type TabKey = "all" | "missed" | "leads" | "follow-ups";
type WorklistRow = {
id: string;
type: 'missed' | 'callback' | 'follow-up' | 'lead';
priority: 'URGENT' | 'HIGH' | 'NORMAL' | 'LOW';
type: "missed" | "callback" | "follow-up" | "lead";
priority: "URGENT" | "HIGH" | "NORMAL" | "LOW";
name: string;
phone: string;
phoneRaw: string;
direction: 'inbound' | 'outbound' | null;
direction: "inbound" | "outbound" | null;
typeLabel: string;
reason: string;
createdAt: string;
taskState: 'PENDING' | 'ATTEMPTED' | 'SCHEDULED';
taskState: "PENDING" | "ATTEMPTED" | "SCHEDULED";
leadId: string | null;
originalLead: WorklistLead | null;
lastContactedAt: string | null;
@@ -84,54 +84,53 @@ type WorklistRow = {
lastDisposition: string | null;
};
const priorityConfig: Record<string, { color: 'error' | 'warning' | 'brand' | 'gray'; label: string; sort: number }> = {
URGENT: { color: 'error', label: 'Urgent', sort: 0 },
HIGH: { color: 'warning', label: 'High', sort: 1 },
NORMAL: { color: 'brand', label: 'Normal', sort: 2 },
LOW: { color: 'gray', label: 'Low', sort: 3 },
const priorityConfig: Record<string, { color: "error" | "warning" | "brand" | "gray"; label: string; sort: number }> = {
URGENT: { color: "error", label: "Urgent", sort: 0 },
HIGH: { color: "warning", label: "High", sort: 1 },
NORMAL: { color: "brand", label: "Normal", sort: 2 },
LOW: { color: "gray", label: "Low", sort: 3 },
};
const followUpLabel: Record<string, string> = {
CALLBACK: 'Callback',
APPOINTMENT_REMINDER: 'Appt Reminder',
POST_VISIT: 'Post-visit',
MARKETING: 'Marketing',
REVIEW_REQUEST: 'Review',
CALLBACK: "Callback",
APPOINTMENT_REMINDER: "Appt Reminder",
POST_VISIT: "Post-visit",
MARKETING: "Marketing",
REVIEW_REQUEST: "Review",
};
const computeSla = (dateStr: string): { label: string; color: 'success' | 'warning' | 'error' } => {
const computeSla = (dateStr: string): { label: string; color: "success" | "warning" | "error" } => {
const minutes = Math.max(0, Math.round((Date.now() - new Date(dateStr).getTime()) / 60000));
if (minutes < 1) return { label: '<1m', color: 'success' };
if (minutes < 15) return { label: `${minutes}m`, color: 'success' };
if (minutes < 30) return { label: `${minutes}m`, color: 'warning' };
if (minutes < 60) return { label: `${minutes}m`, color: 'error' };
if (minutes < 1) return { label: "<1m", color: "success" };
if (minutes < 15) return { label: `${minutes}m`, color: "success" };
if (minutes < 30) return { label: `${minutes}m`, color: "warning" };
if (minutes < 60) return { label: `${minutes}m`, color: "error" };
const hours = Math.floor(minutes / 60);
if (hours < 24) return { label: `${hours}h ${minutes % 60}m`, color: 'error' };
return { label: `${Math.floor(hours / 24)}d`, color: 'error' };
if (hours < 24) return { label: `${hours}h ${minutes % 60}m`, color: "error" };
return { label: `${Math.floor(hours / 24)}d`, color: "error" };
};
const formatTimeAgo = (dateStr: string): string => {
const minutes = Math.round((Date.now() - new Date(dateStr).getTime()) / 60000);
if (minutes < 1) return 'Just now';
if (minutes < 1) return "Just now";
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
return `${Math.floor(hours / 24)}d ago`;
};
const formatDisposition = (disposition: string): string =>
disposition.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
const formatDisposition = (disposition: string): string => disposition.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
const formatSource = (source: string): string => {
const map: Record<string, string> = {
FACEBOOK_AD: 'Facebook',
GOOGLE_AD: 'Google',
WALK_IN: 'Walk-in',
REFERRAL: 'Referral',
WEBSITE: 'Website',
PHONE_INQUIRY: 'Phone',
FACEBOOK_AD: "Facebook",
GOOGLE_AD: "Google",
WALK_IN: "Walk-in",
REFERRAL: "Referral",
WEBSITE: "Website",
PHONE_INQUIRY: "Phone",
};
return map[source] ?? source.replace(/_/g, ' ');
return map[source] ?? source.replace(/_/g, " ");
};
const IconInbound = faIcon(faPhoneArrowDown);
@@ -142,22 +141,22 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea
for (const call of missedCalls) {
const phone = call.callerNumber?.[0];
const countBadge = call.missedcallcount && call.missedcallcount > 1 ? ` (${call.missedcallcount}x)` : '';
const sourceSuffix = call.callsourcenumber ? `${call.callsourcenumber}` : '';
const countBadge = call.missedcallcount && call.missedcallcount > 1 ? ` (${call.missedcallcount}x)` : "";
const sourceSuffix = call.callsourcenumber ? `${call.callsourcenumber}` : "";
rows.push({
id: `mc-${call.id}`,
type: 'missed',
priority: 'HIGH',
name: (phone ? formatPhone(phone) : 'Unknown') + countBadge,
phone: phone ? formatPhone(phone) : '',
phoneRaw: phone?.number ?? '',
direction: call.callDirection === 'OUTBOUND' ? 'outbound' : 'inbound',
typeLabel: 'Missed Call',
type: "missed",
priority: "HIGH",
name: (phone ? formatPhone(phone) : "Unknown") + countBadge,
phone: phone ? formatPhone(phone) : "",
phoneRaw: phone?.number ?? "",
direction: call.callDirection === "OUTBOUND" ? "outbound" : "inbound",
typeLabel: "Missed Call",
reason: call.startedAt
? `Missed at ${new Date(call.startedAt).toLocaleTimeString('en-IN', { hour: 'numeric', minute: '2-digit', hour12: true })}${sourceSuffix}`
: 'Missed call',
? `Missed at ${new Date(call.startedAt).toLocaleTimeString("en-IN", { hour: "numeric", minute: "2-digit", hour12: true })}${sourceSuffix}`
: "Missed call",
createdAt: call.createdAt,
taskState: call.callbackstatus === 'CALLBACK_ATTEMPTED' ? 'ATTEMPTED' : 'PENDING',
taskState: call.callbackstatus === "CALLBACK_ATTEMPTED" ? "ATTEMPTED" : "PENDING",
leadId: call.leadId,
originalLead: null,
lastContactedAt: call.callbackattemptedat ?? call.startedAt ?? call.createdAt,
@@ -168,22 +167,22 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea
}
for (const fu of followUps) {
const isOverdue = fu.followUpStatus === 'OVERDUE' || (fu.scheduledAt !== null && new Date(fu.scheduledAt) < new Date());
const label = followUpLabel[fu.followUpType ?? ''] ?? fu.followUpType ?? 'Follow-up';
const isOverdue = fu.followUpStatus === "OVERDUE" || (fu.scheduledAt !== null && new Date(fu.scheduledAt) < new Date());
const label = followUpLabel[fu.followUpType ?? ""] ?? fu.followUpType ?? "Follow-up";
rows.push({
id: `fu-${fu.id}`,
type: fu.followUpType === 'CALLBACK' ? 'callback' : 'follow-up',
priority: (fu.priority as WorklistRow['priority']) ?? (isOverdue ? 'HIGH' : 'NORMAL'),
type: fu.followUpType === "CALLBACK" ? "callback" : "follow-up",
priority: (fu.priority as WorklistRow["priority"]) ?? (isOverdue ? "HIGH" : "NORMAL"),
name: label,
phone: '',
phoneRaw: '',
phone: "",
phoneRaw: "",
direction: null,
typeLabel: fu.followUpType === 'CALLBACK' ? 'Callback' : 'Follow-up',
typeLabel: fu.followUpType === "CALLBACK" ? "Callback" : "Follow-up",
reason: fu.scheduledAt
? `Scheduled ${new Date(fu.scheduledAt).toLocaleString('en-IN', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', hour12: true })}`
: '',
? `Scheduled ${new Date(fu.scheduledAt).toLocaleString("en-IN", { month: "short", day: "numeric", hour: "numeric", minute: "2-digit", hour12: true })}`
: "",
createdAt: fu.createdAt ?? fu.scheduledAt ?? new Date().toISOString(),
taskState: isOverdue ? 'PENDING' : (fu.followUpStatus === 'COMPLETED' ? 'ATTEMPTED' : 'SCHEDULED'),
taskState: isOverdue ? "PENDING" : fu.followUpStatus === "COMPLETED" ? "ATTEMPTED" : "SCHEDULED",
leadId: null,
originalLead: null,
lastContactedAt: fu.scheduledAt ?? fu.createdAt ?? null,
@@ -194,22 +193,22 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea
}
for (const lead of leads) {
const firstName = lead.contactName?.firstName ?? '';
const lastName = lead.contactName?.lastName ?? '';
const fullName = `${firstName} ${lastName}`.trim() || 'Unknown';
const firstName = lead.contactName?.firstName ?? "";
const lastName = lead.contactName?.lastName ?? "";
const fullName = `${firstName} ${lastName}`.trim() || "Unknown";
const phone = lead.contactPhone?.[0];
rows.push({
id: `lead-${lead.id}`,
type: 'lead',
priority: 'NORMAL',
type: "lead",
priority: "NORMAL",
name: fullName,
phone: phone ? formatPhone(phone) : '',
phoneRaw: phone?.number ?? '',
phone: phone ? formatPhone(phone) : "",
phoneRaw: phone?.number ?? "",
direction: null,
typeLabel: 'Lead',
reason: lead.interestedService ?? lead.aiSuggestedAction ?? '',
typeLabel: "Lead",
reason: lead.interestedService ?? lead.aiSuggestedAction ?? "",
createdAt: lead.createdAt,
taskState: 'PENDING',
taskState: "PENDING",
leadId: lead.id,
originalLead: lead,
lastContactedAt: lead.lastContacted ?? null,
@@ -220,7 +219,7 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea
}
// Remove rows without a phone number — agent can't act on them
const actionableRows = rows.filter(r => r.phoneRaw);
const actionableRows = rows.filter((r) => r.phoneRaw);
actionableRows.sort((a, b) => {
const pa = priorityConfig[a.priority]?.sort ?? 2;
@@ -233,53 +232,48 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea
};
export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelectLead, selectedLeadId }: WorklistPanelProps) => {
const [tab, setTab] = useState<TabKey>('all');
const [search, setSearch] = useState('');
const [missedSubTab, setMissedSubTab] = useState<MissedSubTab>('pending');
const [tab, setTab] = useState<TabKey>("all");
const [search, setSearch] = useState("");
const [missedSubTab, setMissedSubTab] = useState<MissedSubTab>("pending");
const missedByStatus = useMemo(() => ({
pending: missedCalls.filter(c => c.callbackstatus === 'PENDING_CALLBACK' || !c.callbackstatus),
attempted: missedCalls.filter(c => c.callbackstatus === 'CALLBACK_ATTEMPTED'),
completed: missedCalls.filter(c => c.callbackstatus === 'CALLBACK_COMPLETED' || c.callbackstatus === 'WRONG_NUMBER'),
invalid: missedCalls.filter(c => c.callbackstatus === 'INVALID'),
}), [missedCalls]);
const allRows = useMemo(
() => buildRows(missedCalls, followUps, leads),
[missedCalls, followUps, leads],
const missedByStatus = useMemo(
() => ({
pending: missedCalls.filter((c) => c.callbackstatus === "PENDING_CALLBACK" || !c.callbackstatus),
attempted: missedCalls.filter((c) => c.callbackstatus === "CALLBACK_ATTEMPTED"),
completed: missedCalls.filter((c) => c.callbackstatus === "CALLBACK_COMPLETED" || c.callbackstatus === "WRONG_NUMBER"),
invalid: missedCalls.filter((c) => c.callbackstatus === "INVALID"),
}),
[missedCalls],
);
const allRows = useMemo(() => buildRows(missedCalls, followUps, leads), [missedCalls, followUps, leads]);
// Build rows from sub-tab filtered missed calls when on missed tab
const missedSubTabRows = useMemo(
() => buildRows(missedByStatus[missedSubTab], [], []),
[missedByStatus, missedSubTab],
);
const missedSubTabRows = useMemo(() => buildRows(missedByStatus[missedSubTab], [], []), [missedByStatus, missedSubTab]);
const filteredRows = useMemo(() => {
let rows = allRows;
if (tab === 'missed') rows = missedSubTabRows;
else if (tab === 'leads') rows = rows.filter((r) => r.type === 'lead');
else if (tab === 'follow-ups') rows = rows.filter((r) => r.type === 'follow-up');
if (tab === "missed") rows = missedSubTabRows;
else if (tab === "leads") rows = rows.filter((r) => r.type === "lead");
else if (tab === "follow-ups") rows = rows.filter((r) => r.type === "follow-up");
if (search.trim()) {
const q = search.toLowerCase();
rows = rows.filter(
(r) => r.name.toLowerCase().includes(q) || r.phone.toLowerCase().includes(q) || r.phoneRaw.includes(q),
);
rows = rows.filter((r) => r.name.toLowerCase().includes(q) || r.phone.toLowerCase().includes(q) || r.phoneRaw.includes(q));
}
return rows;
}, [allRows, tab, search]);
}, [allRows, missedSubTabRows, tab, search]);
const missedCount = allRows.filter((r) => r.type === 'missed').length;
const leadCount = allRows.filter((r) => r.type === 'lead').length;
const followUpCount = allRows.filter((r) => r.type === 'follow-up').length;
const missedCount = allRows.filter((r) => r.type === "missed").length;
const leadCount = allRows.filter((r) => r.type === "lead").length;
const followUpCount = allRows.filter((r) => r.type === "follow-up").length;
// Notification for new missed calls
const prevMissedCount = useRef(missedCount);
useEffect(() => {
if (missedCount > prevMissedCount.current && prevMissedCount.current > 0) {
notify.info('New Missed Call', `${missedCount - prevMissedCount.current} new missed call(s)`);
notify.info("New Missed Call", `${missedCount - prevMissedCount.current} new missed call(s)`);
}
prevMissedCount.current = missedCount;
}, [missedCount]);
@@ -287,17 +281,23 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
const PAGE_SIZE = 15;
const [page, setPage] = useState(1);
const handleTabChange = useCallback((key: TabKey) => { setTab(key); setPage(1); }, []);
const handleSearch = useCallback((value: string) => { setSearch(value); setPage(1); }, []);
const handleTabChange = useCallback((key: TabKey) => {
setTab(key);
setPage(1);
}, []);
const handleSearch = useCallback((value: string) => {
setSearch(value);
setPage(1);
}, []);
const totalPages = Math.max(1, Math.ceil(filteredRows.length / PAGE_SIZE));
const pagedRows = filteredRows.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
const tabItems = [
{ id: 'all' as const, label: 'All Tasks', badge: allRows.length > 0 ? String(allRows.length) : undefined },
{ id: 'missed' as const, label: 'Missed Calls', badge: missedCount > 0 ? String(missedCount) : undefined },
{ id: 'leads' as const, label: 'Leads', badge: leadCount > 0 ? String(leadCount) : undefined },
{ id: 'follow-ups' as const, label: 'Follow-ups', badge: followUpCount > 0 ? String(followUpCount) : undefined },
{ id: "all" as const, label: "All Tasks", badge: allRows.length > 0 ? String(allRows.length) : undefined },
{ id: "missed" as const, label: "Missed Calls", badge: missedCount > 0 ? String(missedCount) : undefined },
{ id: "leads" as const, label: "Leads", badge: leadCount > 0 ? String(leadCount) : undefined },
{ id: "follow-ups" as const, label: "Follow-ups", badge: followUpCount > 0 ? String(followUpCount) : undefined },
];
if (loading) {
@@ -314,7 +314,7 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
return (
<div className="flex flex-col items-center justify-center py-12 text-center">
<p className="text-sm font-semibold text-primary">All clear</p>
<p className="text-xs text-tertiary mt-1">No pending items in your worklist</p>
<p className="mt-1 text-xs text-tertiary">No pending items in your worklist</p>
</div>
);
}
@@ -329,36 +329,30 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
</TabList>
</Tabs>
<div className="w-44 shrink-0">
<Input
placeholder="Search..."
icon={SearchLg}
size="sm"
value={search}
onChange={handleSearch}
aria-label="Search worklist"
/>
<Input placeholder="Search..." icon={SearchLg} size="sm" value={search} onChange={handleSearch} aria-label="Search worklist" />
</div>
</div>
{/* Missed call status sub-tabs */}
{tab === 'missed' && (
<div className="flex gap-1 px-5 py-2 border-b border-secondary">
{(['pending', 'attempted', 'completed', 'invalid'] as MissedSubTab[]).map(sub => (
{tab === "missed" && (
<div className="flex gap-1 border-b border-secondary px-5 py-2">
{(["pending", "attempted", "completed", "invalid"] as MissedSubTab[]).map((sub) => (
<button
key={sub}
onClick={() => { setMissedSubTab(sub); setPage(1); }}
onClick={() => {
setMissedSubTab(sub);
setPage(1);
}}
className={cx(
'px-3 py-1 text-xs font-medium rounded-md capitalize transition duration-100 ease-linear',
"rounded-md px-3 py-1 text-xs font-medium capitalize transition duration-100 ease-linear",
missedSubTab === sub
? 'bg-brand-50 text-brand-700 border border-brand-200'
: 'text-tertiary hover:text-secondary hover:bg-secondary',
? "border border-brand-200 bg-brand-50 text-brand-700"
: "text-tertiary hover:bg-secondary hover:text-secondary",
)}
>
{sub}
{sub === 'pending' && missedByStatus.pending.length > 0 && (
<span className="ml-1.5 bg-error-50 text-error-700 text-xs px-1.5 py-0.5 rounded-full">
{missedByStatus.pending.length}
</span>
{sub === "pending" && missedByStatus.pending.length > 0 && (
<span className="ml-1.5 rounded-full bg-error-50 px-1.5 py-0.5 text-xs text-error-700">{missedByStatus.pending.length}</span>
)}
</button>
))}
@@ -367,130 +361,111 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
{filteredRows.length === 0 ? (
<div className="flex items-center justify-center py-12">
<p className="text-sm text-quaternary">
{search ? 'No matching items' : 'No items in this category'}
</p>
<p className="text-sm text-quaternary">{search ? "No matching items" : "No items in this category"}</p>
</div>
) : (
<div className="px-2 pt-3">
<Table size="sm">
<Table.Header>
<Table.Head label="PRIORITY" className="w-20" isRowHeader />
<Table.Head label="PATIENT" />
<Table.Head label="PHONE" />
<Table.Head label={tab === 'missed' ? 'BRANCH' : 'SOURCE'} className="w-28" />
<Table.Head label="SLA" className="w-24" />
</Table.Header>
<Table.Body items={pagedRows}>
{(row) => {
const priority = priorityConfig[row.priority] ?? priorityConfig.NORMAL;
const sla = computeSla(row.lastContactedAt ?? row.createdAt);
const isSelected = row.originalLead !== null && row.originalLead.id === selectedLeadId;
<Table size="sm">
<Table.Header>
<Table.Head label="PRIORITY" className="w-20" isRowHeader />
<Table.Head label="PATIENT" />
<Table.Head label="PHONE" />
<Table.Head label={tab === "missed" ? "BRANCH" : "SOURCE"} className="w-28" />
<Table.Head label="SLA" className="w-24" />
</Table.Header>
<Table.Body items={pagedRows}>
{(row) => {
const priority = priorityConfig[row.priority] ?? priorityConfig.NORMAL;
const sla = computeSla(row.lastContactedAt ?? row.createdAt);
const isSelected = row.originalLead !== null && row.originalLead.id === selectedLeadId;
// Sub-line: last interaction context
const subLine = row.lastContactedAt
? `${formatTimeAgo(row.lastContactedAt)}${row.lastDisposition ? `${formatDisposition(row.lastDisposition)}` : ''}`
: row.reason || row.typeLabel;
// Sub-line: last interaction context
const subLine = row.lastContactedAt
? `${formatTimeAgo(row.lastContactedAt)}${row.lastDisposition ? `${formatDisposition(row.lastDisposition)}` : ""}`
: row.reason || row.typeLabel;
return (
<Table.Row
id={row.id}
className={cx(
'cursor-pointer group/row',
isSelected && 'bg-brand-primary',
)}
onAction={() => {
if (row.originalLead) onSelectLead(row.originalLead);
}}
>
<Table.Cell>
<Badge size="sm" color={priority.color} type="pill-color">
{priority.label}
</Badge>
</Table.Cell>
<Table.Cell>
<div className="flex items-center gap-2">
{row.direction === 'inbound' && (
<IconInbound className="size-3.5 text-fg-success-secondary shrink-0" />
)}
{row.direction === 'outbound' && (
<IconOutbound className="size-3.5 text-fg-brand-secondary shrink-0" />
)}
<div className="min-w-0">
<span className="text-sm font-medium text-primary truncate block max-w-[180px]">
{row.name}
</span>
<span className="text-xs text-tertiary truncate block max-w-[200px]">
{subLine}
</span>
return (
<Table.Row
id={row.id}
className={cx("group/row cursor-pointer", isSelected && "bg-brand-primary")}
onAction={() => {
if (row.originalLead) onSelectLead(row.originalLead);
}}
>
<Table.Cell>
<Badge size="sm" color={priority.color} type="pill-color">
{priority.label}
</Badge>
</Table.Cell>
<Table.Cell>
<div className="flex items-center gap-2">
{row.direction === "inbound" && <IconInbound className="size-3.5 shrink-0 text-fg-success-secondary" />}
{row.direction === "outbound" && <IconOutbound className="size-3.5 shrink-0 text-fg-brand-secondary" />}
<div className="min-w-0">
<span className="block max-w-[180px] truncate text-sm font-medium text-primary">{row.name}</span>
<span className="block max-w-[200px] truncate text-xs text-tertiary">{subLine}</span>
</div>
</div>
</div>
</Table.Cell>
<Table.Cell>
{row.phoneRaw ? (
<PhoneActionCell
phoneNumber={row.phoneRaw}
displayNumber={row.phone}
leadId={row.leadId ?? undefined}
/>
) : (
<span className="text-xs text-quaternary italic">No phone</span>
)}
</Table.Cell>
<Table.Cell>
{row.source ? (
<span className="text-xs text-tertiary truncate block max-w-[100px]">
{formatSource(row.source)}
</span>
) : (
<span className="text-xs text-quaternary"></span>
)}
</Table.Cell>
<Table.Cell>
<Badge size="sm" color={sla.color} type="pill-color">
{sla.label}
</Badge>
</Table.Cell>
</Table.Row>
);
}}
</Table.Body>
</Table>
{totalPages > 1 && (
<div className="flex items-center justify-between border-t border-secondary px-5 py-3">
<span className="text-xs text-tertiary">
Showing {(page - 1) * PAGE_SIZE + 1}{Math.min(page * PAGE_SIZE, filteredRows.length)} of {filteredRows.length}
</span>
<div className="flex items-center gap-1">
<button
onClick={() => setPage(Math.max(1, page - 1))}
disabled={page === 1}
className="px-2 py-1 text-xs font-medium text-secondary rounded-md hover:bg-primary_hover disabled:text-disabled disabled:cursor-not-allowed transition duration-100 ease-linear"
>
Previous
</button>
{Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => (
</Table.Cell>
<Table.Cell>
{row.phoneRaw ? (
<PhoneActionCell phoneNumber={row.phoneRaw} displayNumber={row.phone} leadId={row.leadId ?? undefined} />
) : (
<span className="text-xs text-quaternary italic">No phone</span>
)}
</Table.Cell>
<Table.Cell>
{row.source ? (
<span className="block max-w-[100px] truncate text-xs text-tertiary">{formatSource(row.source)}</span>
) : (
<span className="text-xs text-quaternary"></span>
)}
</Table.Cell>
<Table.Cell>
<Badge size="sm" color={sla.color} type="pill-color">
{sla.label}
</Badge>
</Table.Cell>
</Table.Row>
);
}}
</Table.Body>
</Table>
{totalPages > 1 && (
<div className="flex items-center justify-between border-t border-secondary px-5 py-3">
<span className="text-xs text-tertiary">
Showing {(page - 1) * PAGE_SIZE + 1}{Math.min(page * PAGE_SIZE, filteredRows.length)} of {filteredRows.length}
</span>
<div className="flex items-center gap-1">
<button
key={p}
onClick={() => setPage(p)}
className={cx(
"size-8 text-xs font-medium rounded-lg transition duration-100 ease-linear",
p === page ? "bg-active text-brand-secondary" : "text-tertiary hover:bg-primary_hover",
)}
onClick={() => setPage(Math.max(1, page - 1))}
disabled={page === 1}
className="rounded-md px-2 py-1 text-xs font-medium text-secondary transition duration-100 ease-linear hover:bg-primary_hover disabled:cursor-not-allowed disabled:text-disabled"
>
{p}
Previous
</button>
))}
<button
onClick={() => setPage(Math.min(totalPages, page + 1))}
disabled={page === totalPages}
className="px-2 py-1 text-xs font-medium text-secondary rounded-md hover:bg-primary_hover disabled:text-disabled disabled:cursor-not-allowed transition duration-100 ease-linear"
>
Next
</button>
{Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => (
<button
key={p}
onClick={() => setPage(p)}
className={cx(
"size-8 rounded-lg text-xs font-medium transition duration-100 ease-linear",
p === page ? "bg-active text-brand-secondary" : "text-tertiary hover:bg-primary_hover",
)}
>
{p}
</button>
))}
<button
onClick={() => setPage(Math.min(totalPages, page + 1))}
disabled={page === totalPages}
className="rounded-md px-2 py-1 text-xs font-medium text-secondary transition duration-100 ease-linear hover:bg-primary_hover disabled:cursor-not-allowed disabled:text-disabled"
>
Next
</button>
</div>
</div>
</div>
)}
)}
</div>
)}
</div>

View File

@@ -1,61 +1,49 @@
import { cx } from '@/utils/cx';
import { formatCurrency, formatCompact } from '@/lib/format';
import { AdStatusBadge } from '@/components/shared/status-badge';
import type { Ad, AdFormat } from '@/types/entities';
import { AdStatusBadge } from "@/components/shared/status-badge";
import { formatCompact, formatCurrency } from "@/lib/format";
import type { Ad, AdFormat } from "@/types/entities";
import { cx } from "@/utils/cx";
interface AdCardProps {
ad: Ad;
}
const formatPreviewStyles: Record<AdFormat, { bg: string; icon: string }> = {
IMAGE: { bg: 'bg-brand-solid', icon: 'IMG' },
VIDEO: { bg: 'bg-fg-brand-secondary', icon: 'VID' },
CAROUSEL: { bg: 'bg-error-solid', icon: 'CAR' },
TEXT: { bg: 'bg-fg-tertiary', icon: 'TXT' },
LEAD_FORM: { bg: 'bg-success-solid', icon: 'FORM' },
IMAGE: { bg: "bg-brand-solid", icon: "IMG" },
VIDEO: { bg: "bg-fg-brand-secondary", icon: "VID" },
CAROUSEL: { bg: "bg-error-solid", icon: "CAR" },
TEXT: { bg: "bg-fg-tertiary", icon: "TXT" },
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) => {
const format = ad.adFormat ?? 'IMAGE';
const format = ad.adFormat ?? "IMAGE";
const preview = formatPreviewStyles[format] ?? formatPreviewStyles.IMAGE;
const currencyCode = ad.spend?.currencyCode ?? 'INR';
const currencyCode = ad.spend?.currencyCode ?? "INR";
const metrics = [
{ label: 'Impr.', value: formatCompact(ad.impressions ?? 0) },
{ label: 'Clicks', value: formatCompact(ad.clicks ?? 0) },
{ label: 'Leads', value: String(ad.conversions ?? 0) },
{ label: 'Spend', value: ad.spend ? formatCurrency(ad.spend.amountMicros, currencyCode) : '--' },
{ label: "Impr.", value: formatCompact(ad.impressions ?? 0) },
{ label: "Clicks", value: formatCompact(ad.clicks ?? 0) },
{ label: "Leads", value: String(ad.conversions ?? 0) },
{ label: "Spend", value: ad.spend ? formatCurrency(ad.spend.amountMicros, currencyCode) : "--" },
];
return (
<div className="flex items-center gap-4 rounded-xl border border-secondary bg-primary p-4 transition hover:shadow-sm">
{/* 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}
</div>
{/* Ad info */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h4 className="truncate text-sm font-bold text-primary">
{ad.adName ?? 'Untitled Ad'}
</h4>
<span className="rounded-md bg-secondary px-1.5 py-0.5 text-xs text-tertiary">
{formatBadgeLabel(format)}
</span>
<h4 className="truncate text-sm font-bold text-primary">{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>
<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>
{/* Inline metrics */}

View File

@@ -1,5 +1,5 @@
import { cx } from '@/utils/cx';
import { formatCurrency } from '@/lib/format';
import { formatCurrency } from "@/lib/format";
import { cx } from "@/utils/cx";
type CurrencyAmount = {
amountMicros: number;
@@ -18,15 +18,10 @@ export const BudgetBar = ({ spent, budget }: BudgetBarProps) => {
const ratio = budgetMicros > 0 ? spentMicros / budgetMicros : 0;
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 budgetDisplay = budget ? formatCurrency(budget.amountMicros, budget.currencyCode) : '--';
const spentDisplay = spent ? formatCurrency(spent.amountMicros, spent.currencyCode) : "--";
const budgetDisplay = budget ? formatCurrency(budget.amountMicros, budget.currencyCode) : "--";
return (
<div>
@@ -36,11 +31,8 @@ export const BudgetBar = ({ spent, budget }: BudgetBarProps) => {
{spentDisplay} / {budgetDisplay}
</span>
</div>
<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="h-1.5 overflow-hidden rounded-full bg-tertiary">
<div className={cx("h-full rounded-full transition-all duration-300", fillColor)} style={{ width: `${percentage}%` }} />
</div>
</div>
);

View File

@@ -1,9 +1,9 @@
import { cx } from '@/utils/cx';
import { formatCurrency } from '@/lib/format';
import type { Campaign, Ad, Lead, LeadSource } from '@/types/entities';
import { CampaignStatusBadge } from '@/components/shared/status-badge';
import { BudgetBar } from './budget-bar';
import { HealthIndicator } from './health-indicator';
import { CampaignStatusBadge } from "@/components/shared/status-badge";
import { formatCurrency } from "@/lib/format";
import type { Ad, Campaign, Lead, LeadSource } from "@/types/entities";
import { cx } from "@/utils/cx";
import { BudgetBar } from "./budget-bar";
import { HealthIndicator } from "./health-indicator";
interface CampaignCardProps {
campaign: Campaign;
@@ -12,23 +12,22 @@ interface CampaignCardProps {
}
const sourceColors: Record<string, string> = {
FACEBOOK_AD: 'bg-brand-solid',
GOOGLE_AD: 'bg-success-solid',
INSTAGRAM: 'bg-error-solid',
GOOGLE_MY_BUSINESS: 'bg-warning-solid',
WEBSITE: 'bg-fg-brand-primary',
REFERRAL: 'bg-fg-tertiary',
WHATSAPP: 'bg-success-solid',
WALK_IN: 'bg-fg-quaternary',
PHONE: 'bg-fg-secondary',
OTHER: 'bg-fg-disabled',
FACEBOOK_AD: "bg-brand-solid",
GOOGLE_AD: "bg-success-solid",
INSTAGRAM: "bg-error-solid",
GOOGLE_MY_BUSINESS: "bg-warning-solid",
WEBSITE: "bg-fg-brand-primary",
REFERRAL: "bg-fg-tertiary",
WHATSAPP: "bg-success-solid",
WALK_IN: "bg-fg-quaternary",
PHONE: "bg-fg-secondary",
OTHER: "bg-fg-disabled",
};
const sourceLabel = (source: LeadSource): string =>
source.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
const sourceLabel = (source: LeadSource): string => source.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
const formatDuration = (startDate: string | null, endDate: string | null): string => {
if (!startDate) return '--';
if (!startDate) return "--";
const start = new Date(startDate);
const end = endDate ? new Date(endDate) : new Date();
@@ -38,38 +37,37 @@ const formatDuration = (startDate: string | null, endDate: string | null): strin
};
export const CampaignCard = ({ campaign, ads, leads }: CampaignCardProps) => {
const isPaused = campaign.campaignStatus === 'PAUSED';
const isPaused = campaign.campaignStatus === "PAUSED";
const leadCount = campaign.leadCount ?? 0;
const contactedCount = campaign.contactedCount ?? 0;
const convertedCount = campaign.convertedCount ?? 0;
const cac =
convertedCount > 0 && campaign.amountSpent
? formatCurrency(campaign.amountSpent.amountMicros / convertedCount, campaign.amountSpent.currencyCode)
: '--';
: "--";
// Count leads per source
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;
return acc;
}, {});
return (
<div
className={cx(
'rounded-2xl border border-secondary bg-primary overflow-hidden transition hover:shadow-lg cursor-pointer',
isPaused && 'opacity-60',
)}
className={cx("cursor-pointer overflow-hidden rounded-2xl border border-secondary bg-primary transition hover:shadow-lg", isPaused && "opacity-60")}
>
{/* Header */}
<div className="p-5 pb-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<h3 className="text-md font-bold text-primary truncate">{campaign.campaignName ?? 'Untitled Campaign'}</h3>
<h3 className="truncate text-md font-bold text-primary">{campaign.campaignName ?? "Untitled Campaign"}</h3>
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-tertiary">
<span>{campaign.externalCampaignId ?? campaign.id.slice(0, 12)}</span>
<span className="text-quaternary">&middot;</span>
<span>{ads.length} ad{ads.length !== 1 ? 's' : ''}</span>
<span>
{ads.length} ad{ads.length !== 1 ? "s" : ""}
</span>
{campaign.platform && (
<>
<span className="text-quaternary">&middot;</span>
@@ -114,13 +112,13 @@ export const CampaignCard = ({ campaign, ads, leads }: CampaignCardProps) => {
{/* Source breakdown */}
{Object.keys(sourceCounts).length > 0 && (
<div className="mx-5 border-t border-tertiary px-0 pb-4 pt-3">
<div className="mx-5 border-t border-tertiary px-0 pt-3 pb-4">
<div className="flex flex-wrap items-center gap-3">
{Object.entries(sourceCounts)
.sort(([, a], [, b]) => b - a)
.map(([source, count]) => (
<div key={source} className="flex items-center gap-1.5">
<span className={cx('h-2 w-2 rounded-full', sourceColors[source] ?? 'bg-fg-disabled')} />
<span className={cx("h-2 w-2 rounded-full", sourceColors[source] ?? "bg-fg-disabled")} />
<span className="text-xs text-tertiary">
{sourceLabel(source as LeadSource)} ({count})
</span>
@@ -131,7 +129,7 @@ export const CampaignCard = ({ campaign, ads, leads }: CampaignCardProps) => {
)}
{/* Health indicator */}
<div className="mx-5 border-t border-tertiary px-0 pb-4 pt-3">
<div className="mx-5 border-t border-tertiary px-0 pt-3 pb-4">
<HealthIndicator campaign={campaign} leads={leads} />
</div>
</div>

View File

@@ -1,13 +1,13 @@
import { useState } from 'react';
import { faPenToSquare } from '@fortawesome/pro-duotone-svg-icons';
import { faIcon } from '@/lib/icon-wrapper';
import { SlideoutMenu } from '@/components/application/slideout-menus/slideout-menu';
import { Input } from '@/components/base/input/input';
import { Select } from '@/components/base/select/select';
import { Button } from '@/components/base/buttons/button';
import { apiClient } from '@/lib/api-client';
import { notify } from '@/lib/toast';
import type { Campaign, CampaignStatus } from '@/types/entities';
import { useState } from "react";
import { faPenToSquare } from "@fortawesome/pro-duotone-svg-icons";
import { SlideoutMenu } from "@/components/application/slideout-menus/slideout-menu";
import { Button } from "@/components/base/buttons/button";
import { Input } from "@/components/base/input/input";
import { Select } from "@/components/base/select/select";
import { apiClient } from "@/lib/api-client";
import { faIcon } from "@/lib/icon-wrapper";
import { notify } from "@/lib/toast";
import type { Campaign, CampaignStatus } from "@/types/entities";
const PenIcon = faIcon(faPenToSquare);
@@ -19,28 +19,28 @@ type CampaignEditSlideoutProps = {
};
const statusItems = [
{ id: 'DRAFT' as const, label: 'Draft' },
{ id: 'ACTIVE' as const, label: 'Active' },
{ id: 'PAUSED' as const, label: 'Paused' },
{ id: 'COMPLETED' as const, label: 'Completed' },
{ id: "DRAFT" as const, label: "Draft" },
{ id: "ACTIVE" as const, label: "Active" },
{ id: "PAUSED" as const, label: "Paused" },
{ id: "COMPLETED" as const, label: "Completed" },
];
const formatDateForInput = (dateStr: string | null): string => {
if (!dateStr) return '';
if (!dateStr) return "";
try {
return new Date(dateStr).toISOString().slice(0, 10);
} catch {
return '';
return "";
}
};
const budgetToDisplay = (campaign: Campaign): string => {
if (!campaign.budget) return '';
if (!campaign.budget) return "";
return String(Math.round(campaign.budget.amountMicros / 1_000_000));
};
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 [budget, setBudget] = useState(budgetToDisplay(campaign));
const [startDate, setStartDate] = useState(formatDateForInput(campaign.startDate));
@@ -65,7 +65,7 @@ export const CampaignEditSlideout = ({ isOpen, onOpenChange, campaign, onSaved }
? {
budget: {
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?.();
close();
} catch (err) {
// apiClient.graphql already toasts on error
console.error('Failed to update campaign:', err);
console.error("Failed to update campaign:", err);
} finally {
setIsSaving(false);
}
@@ -104,12 +104,7 @@ export const CampaignEditSlideout = ({ isOpen, onOpenChange, campaign, onSaved }
<SlideoutMenu.Content>
<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
label="Status"
@@ -121,27 +116,11 @@ export const CampaignEditSlideout = ({ isOpen, onOpenChange, campaign, onSaved }
{(item) => <Select.Item id={item.id} label={item.label} />}
</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">
<Input
label="Start Date"
type="date"
value={startDate}
onChange={setStartDate}
/>
<Input
label="End Date"
type="date"
value={endDate}
onChange={setEndDate}
/>
<Input label="Start Date" type="date" value={startDate} onChange={setStartDate} />
<Input label="End Date" type="date" value={endDate} onChange={setEndDate} />
</div>
</div>
</SlideoutMenu.Content>
@@ -151,14 +130,8 @@ export const CampaignEditSlideout = ({ isOpen, onOpenChange, campaign, onSaved }
<Button size="md" color="secondary" onClick={close}>
Cancel
</Button>
<Button
size="md"
color="primary"
isLoading={isSaving}
showTextWhileLoading
onClick={() => handleSave(close)}
>
{isSaving ? 'Saving...' : 'Save Changes'}
<Button size="md" color="primary" isLoading={isSaving} showTextWhileLoading onClick={() => handleSave(close)}>
{isSaving ? "Saving..." : "Save Changes"}
</Button>
</div>
</SlideoutMenu.Footer>

View File

@@ -1,30 +1,28 @@
import type { FC } from 'react';
import { useNavigate } from 'react-router';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faArrowLeft, faArrowUpRightFromSquare } from '@fortawesome/pro-duotone-svg-icons';
import type { FC } from "react";
import { faArrowLeft, faArrowUpRightFromSquare } from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate } from "react-router";
import { Button } from "@/components/base/buttons/button";
import { CampaignStatusBadge } from "@/components/shared/status-badge";
import type { Campaign } from "@/types/entities";
const ArrowLeft: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faArrowLeft} className={className} />;
const LinkExternal01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faArrowUpRightFromSquare} className={className} />;
import { Button } from '@/components/base/buttons/button';
import { CampaignStatusBadge } from '@/components/shared/status-badge';
import type { Campaign } from '@/types/entities';
interface CampaignHeroProps {
campaign: Campaign;
}
const formatDateRange = (startDate: string | null, endDate: string | null): string => {
const fmt = (d: string) =>
new Intl.DateTimeFormat('en-IN', { month: 'short', day: 'numeric', year: 'numeric' }).format(new Date(d));
const fmt = (d: string) => new Intl.DateTimeFormat("en-IN", { month: "short", day: "numeric", year: "numeric" }).format(new Date(d));
if (!startDate) return '--';
if (!startDate) return "--";
if (!endDate) return `${fmt(startDate)} \u2014 Ongoing`;
return `${fmt(startDate)} \u2014 ${fmt(endDate)}`;
};
const formatDuration = (startDate: string | null, endDate: string | null): string => {
if (!startDate) return '--';
if (!startDate) return "--";
const start = new Date(startDate);
const end = endDate ? new Date(endDate) : new Date();
@@ -40,8 +38,8 @@ export const CampaignHero = ({ campaign }: CampaignHeroProps) => {
<div className="border-b border-secondary bg-primary px-7 py-6">
{/* Back button */}
<button
onClick={() => navigate('/campaigns')}
className="mb-4 flex items-center gap-1.5 text-sm text-tertiary transition hover:text-secondary cursor-pointer"
onClick={() => navigate("/campaigns")}
className="mb-4 flex cursor-pointer items-center gap-1.5 text-sm text-tertiary transition hover:text-secondary"
>
<ArrowLeft className="size-4" />
<span>Back to Campaigns</span>
@@ -50,9 +48,7 @@ export const CampaignHero = ({ campaign }: CampaignHeroProps) => {
{/* Title row */}
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<h1 className="text-display-xs font-bold text-primary">
{campaign.campaignName ?? 'Untitled Campaign'}
</h1>
<h1 className="text-display-xs font-bold text-primary">{campaign.campaignName ?? "Untitled Campaign"}</h1>
<div className="mt-2 flex flex-wrap items-center gap-2 text-sm text-tertiary">
<span>{campaign.externalCampaignId ?? campaign.id.slice(0, 12)}</span>
@@ -63,13 +59,11 @@ export const CampaignHero = ({ campaign }: CampaignHeroProps) => {
{/* Badges */}
<div className="mt-3 flex flex-wrap items-center gap-2">
{campaign.platform && (
<span className="rounded-lg bg-secondary px-2 py-0.5 text-xs font-medium text-secondary">
{campaign.platform}
</span>
<span className="rounded-lg bg-secondary px-2 py-0.5 text-xs font-medium text-secondary">{campaign.platform}</span>
)}
{campaign.campaignType && (
<span className="rounded-lg bg-secondary px-2 py-0.5 text-xs font-medium text-secondary">
{campaign.campaignType.replace(/_/g, ' ')}
{campaign.campaignType.replace(/_/g, " ")}
</span>
)}
{campaign.campaignStatus && <CampaignStatusBadge status={campaign.campaignStatus} />}
@@ -82,22 +76,11 @@ export const CampaignHero = ({ campaign }: CampaignHeroProps) => {
{/* Actions */}
<div className="flex shrink-0 items-center gap-2">
{campaign.platformUrl && (
<Button
color="secondary"
size="sm"
iconTrailing={LinkExternal01}
href={campaign.platformUrl}
target="_blank"
rel="noopener noreferrer"
>
<Button color="secondary" size="sm" iconTrailing={LinkExternal01} href={campaign.platformUrl} target="_blank" rel="noopener noreferrer">
View on Platform
</Button>
)}
<Button
color="primary"
size="sm"
href={`/leads`}
>
<Button color="primary" size="sm" href={`/leads`}>
View Leads
</Button>
</div>

View File

@@ -1,5 +1,5 @@
import { cx } from '@/utils/cx';
import type { Campaign, Lead } from '@/types/entities';
import type { Campaign, Lead } from "@/types/entities";
import { cx } from "@/utils/cx";
interface ConversionFunnelProps {
campaign: Campaign;
@@ -15,14 +15,14 @@ type FunnelStep = {
export const ConversionFunnel = ({ campaign, leads }: ConversionFunnelProps) => {
const leadCount = campaign.leadCount ?? 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 steps: FunnelStep[] = [
{ label: 'Leads', count: leadCount, color: 'bg-brand-solid' },
{ label: 'Contacted', count: contactedCount, color: 'bg-brand-primary' },
{ label: 'Appointment Set', count: appointmentCount, color: 'bg-brand-primary_alt' },
{ label: 'Converted', count: convertedCount, color: 'bg-success-solid' },
{ label: "Leads", count: leadCount, color: "bg-brand-solid" },
{ label: "Contacted", count: contactedCount, color: "bg-brand-primary" },
{ label: "Appointment Set", count: appointmentCount, color: "bg-brand-primary_alt" },
{ label: "Converted", count: convertedCount, color: "bg-success-solid" },
];
const maxCount = Math.max(...steps.map((s) => s.count), 1);
@@ -37,16 +37,14 @@ export const ConversionFunnel = ({ campaign, leads }: ConversionFunnelProps) =>
<div key={step.label} className="flex items-center gap-3">
<span className="w-24 shrink-0 text-xs text-tertiary">{step.label}</span>
<div className="flex-1">
<div className="h-5 rounded bg-secondary overflow-hidden">
<div className="h-5 overflow-hidden rounded bg-secondary">
<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)}%` }}
/>
</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>
);
})}

View File

@@ -1,5 +1,5 @@
import { cx } from '@/utils/cx';
import type { Campaign, Lead, HealthStatus } from '@/types/entities';
import type { Campaign, HealthStatus, Lead } from "@/types/entities";
import { cx } from "@/utils/cx";
interface HealthIndicatorProps {
campaign: Campaign;
@@ -7,8 +7,8 @@ interface HealthIndicatorProps {
}
const computeHealth = (campaign: Campaign, _leads: Lead[]): { status: HealthStatus; reason: string } => {
if (campaign.campaignStatus === 'PAUSED') {
return { status: 'UNHEALTHY', reason: 'Campaign is paused' };
if (campaign.campaignStatus === "PAUSED") {
return { status: "UNHEALTHY", reason: "Campaign is paused" };
}
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;
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) {
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 }> = {
HEALTHY: { dot: 'bg-success-solid', text: 'text-success-primary', label: 'Healthy' },
WARNING: { dot: 'bg-warning-solid', text: 'text-warning-primary', label: 'Warning' },
UNHEALTHY: { dot: 'bg-error-solid', text: 'text-error-primary', label: 'Unhealthy' },
HEALTHY: { dot: "bg-success-solid", text: "text-success-primary", label: "Healthy" },
WARNING: { dot: "bg-warning-solid", text: "text-warning-primary", label: "Warning" },
UNHEALTHY: { dot: "bg-error-solid", text: "text-error-primary", label: "Unhealthy" },
};
export const HealthIndicator = ({ campaign, leads }: HealthIndicatorProps) => {
@@ -38,10 +38,10 @@ export const HealthIndicator = ({ campaign, leads }: HealthIndicatorProps) => {
return (
<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">
<span className={cx('font-bold', style.text)}>{style.label}</span>
{' \u2014 '}
<span className={cx("font-bold", style.text)}>{style.label}</span>
{" \u2014 "}
{reason}
</p>
</div>

View File

@@ -1,6 +1,6 @@
import { cx } from '@/utils/cx';
import { formatCurrency } from '@/lib/format';
import type { Campaign } from '@/types/entities';
import { formatCurrency } from "@/lib/format";
import type { Campaign } from "@/types/entities";
import { cx } from "@/utils/cx";
interface KpiStripProps {
campaign: Campaign;
@@ -19,50 +19,50 @@ export const KpiStrip = ({ campaign }: KpiStripProps) => {
const convertedCount = campaign.convertedCount ?? 0;
const spentMicros = campaign.amountSpent?.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 conversionRate = leadCount > 0 ? ((convertedCount / leadCount) * 100).toFixed(1) : '0.0';
const budgetPercent = budgetMicros > 0 ? ((spentMicros / budgetMicros) * 100).toFixed(0) : '--';
const costPerLead = leadCount > 0 ? formatCurrency(spentMicros / leadCount, currencyCode) : '--';
const cac = convertedCount > 0 ? formatCurrency(spentMicros / convertedCount, currencyCode) : '--';
const contactRate = leadCount > 0 ? ((contactedCount / 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 costPerLead = leadCount > 0 ? formatCurrency(spentMicros / leadCount, currencyCode) : "--";
const cac = convertedCount > 0 ? formatCurrency(spentMicros / convertedCount, currencyCode) : "--";
const items: KpiItem[] = [
{
label: 'Total Leads',
label: "Total Leads",
value: String(leadCount),
subText: `${campaign.impressionCount ?? 0} impressions`,
subColor: 'text-tertiary',
subColor: "text-tertiary",
},
{
label: 'Contacted',
label: "Contacted",
value: String(contactedCount),
subText: `${contactRate}% contact rate`,
subColor: 'text-success-primary',
subColor: "text-success-primary",
},
{
label: 'Converted',
label: "Converted",
value: String(convertedCount),
subText: `${conversionRate}% conversion`,
subColor: 'text-success-primary',
subColor: "text-success-primary",
},
{
label: 'Spent',
label: "Spent",
value: formatCurrency(spentMicros, currencyCode),
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,
subText: 'avg per lead',
subColor: 'text-tertiary',
subText: "avg per lead",
subColor: "text-tertiary",
},
{
label: 'CAC',
label: "CAC",
value: cac,
subText: 'per conversion',
subColor: 'text-tertiary',
subText: "per conversion",
subColor: "text-tertiary",
},
];
@@ -72,15 +72,15 @@ export const KpiStrip = ({ campaign }: KpiStripProps) => {
<div
key={item.label}
className={cx(
'flex flex-1 flex-col justify-center px-4',
index === 0 && 'pl-0',
index === items.length - 1 && 'pr-0',
index < items.length - 1 && 'border-r border-tertiary',
"flex flex-1 flex-col justify-center px-4",
index === 0 && "pl-0",
index === items.length - 1 && "pr-0",
index < items.length - 1 && "border-r border-tertiary",
)}
>
<p className="text-xl font-bold text-primary">{item.value}</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="text-xs font-medium text-quaternary uppercase">{item.label}</p>
<p className={cx("mt-0.5 text-xs", item.subColor)}>{item.subText}</p>
</div>
))}
</div>

View File

@@ -1,29 +1,28 @@
import { cx } from '@/utils/cx';
import type { Lead } from '@/types/entities';
import type { Lead } from "@/types/entities";
import { cx } from "@/utils/cx";
interface SourceBreakdownProps {
leads: Lead[];
}
const sourceColors: Record<string, string> = {
FACEBOOK_AD: 'bg-brand-solid',
GOOGLE_AD: 'bg-success-solid',
INSTAGRAM: 'bg-error-solid',
GOOGLE_MY_BUSINESS: 'bg-warning-solid',
WEBSITE: 'bg-fg-brand-primary',
REFERRAL: 'bg-fg-tertiary',
WHATSAPP: 'bg-success-solid',
WALK_IN: 'bg-fg-quaternary',
PHONE: 'bg-fg-secondary',
OTHER: 'bg-fg-disabled',
FACEBOOK_AD: "bg-brand-solid",
GOOGLE_AD: "bg-success-solid",
INSTAGRAM: "bg-error-solid",
GOOGLE_MY_BUSINESS: "bg-warning-solid",
WEBSITE: "bg-fg-brand-primary",
REFERRAL: "bg-fg-tertiary",
WHATSAPP: "bg-success-solid",
WALK_IN: "bg-fg-quaternary",
PHONE: "bg-fg-secondary",
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) => {
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;
return acc;
}, {});
@@ -43,23 +42,16 @@ export const SourceBreakdown = ({ leads }: SourceBreakdownProps) => {
const widthPercent = (count / maxCount) * 100;
return (
<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="h-4 rounded bg-secondary overflow-hidden">
<div className="h-4 overflow-hidden rounded bg-secondary">
<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)}%` }}
/>
</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>
);
})}

View File

@@ -1,12 +1,12 @@
import { useMemo } from 'react';
import { Link } from 'react-router';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faUserHeadset } from '@fortawesome/pro-duotone-svg-icons';
import { Avatar } from '@/components/base/avatar/avatar';
import { Badge } from '@/components/base/badges/badges';
import { Table, TableCard } from '@/components/application/table/table';
import { getInitials } from '@/lib/format';
import type { Call } from '@/types/entities';
import { useMemo } from "react";
import { faUserHeadset } from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Link } from "react-router";
import { Table, TableCard } from "@/components/application/table/table";
import { Avatar } from "@/components/base/avatar/avatar";
import { Badge } from "@/components/base/badges/badges";
import { getInitials } from "@/lib/format";
import type { Call } from "@/types/entities";
const formatDuration = (seconds: number): string => {
if (seconds < 60) return `${seconds}s`;
@@ -16,7 +16,7 @@ const formatDuration = (seconds: 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)}%`;
};
@@ -28,37 +28,44 @@ export const AgentTable = ({ calls }: AgentTableProps) => {
const agents = useMemo(() => {
const agentMap = new Map<string, Call[]>();
for (const call of calls) {
const agent = call.agentName ?? 'Unknown';
const agent = call.agentName ?? "Unknown";
if (!agentMap.has(agent)) agentMap.set(agent, []);
agentMap.get(agent)!.push(call);
}
return Array.from(agentMap.entries()).map(([name, agentCalls]) => {
const inbound = agentCalls.filter((c) => c.callDirection === 'INBOUND').length;
const outbound = agentCalls.filter((c) => c.callDirection === 'OUTBOUND').length;
const missed = agentCalls.filter((c) => c.callStatus === 'MISSED').length;
const total = agentCalls.length;
const completedCalls = agentCalls.filter((c) => (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 booked = agentCalls.filter((c) => c.disposition === 'APPOINTMENT_BOOKED').length;
const conversion = total > 0 ? (booked / total) * 100 : 0;
const nameParts = name.split(' ');
return Array.from(agentMap.entries())
.map(([name, agentCalls]) => {
const inbound = agentCalls.filter((c) => c.callDirection === "INBOUND").length;
const outbound = agentCalls.filter((c) => c.callDirection === "OUTBOUND").length;
const missed = agentCalls.filter((c) => c.callStatus === "MISSED").length;
const total = agentCalls.length;
const completedCalls = agentCalls.filter((c) => (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 booked = agentCalls.filter((c) => c.disposition === "APPOINTMENT_BOOKED").length;
const conversion = total > 0 ? (booked / total) * 100 : 0;
const nameParts = name.split(" ");
return {
id: name,
name,
initials: getInitials(nameParts[0] ?? '', nameParts[1] ?? ''),
inbound, outbound, missed, total, avgHandle, conversion,
};
}).sort((a, b) => b.total - a.total);
return {
id: name,
name,
initials: getInitials(nameParts[0] ?? "", nameParts[1] ?? ""),
inbound,
outbound,
missed,
total,
avgHandle,
conversion,
};
})
.sort((a, b) => b.total - a.total);
}, [calls]);
if (agents.length === 0) {
return (
<TableCard.Root size="sm">
<TableCard.Header title="Agent Performance" description="Call metrics by agent" />
<div className="flex flex-col items-center justify-center py-12 gap-2">
<div className="flex flex-col items-center justify-center gap-2 py-12">
<FontAwesomeIcon icon={faUserHeadset} className="size-8 text-fg-quaternary" />
<p className="text-sm text-tertiary">No agent data available</p>
</div>
@@ -85,18 +92,32 @@ export const AgentTable = ({ calls }: AgentTableProps) => {
<Link to={`/agent/${encodeURIComponent(agent.name)}`} className="no-underline">
<div className="flex items-center gap-2">
<Avatar size="xs" initials={agent.initials} />
<span className="text-sm font-medium text-brand-secondary hover:text-brand-secondary_hover transition duration-100 ease-linear">{agent.name}</span>
<span className="text-sm font-medium text-brand-secondary transition duration-100 ease-linear hover:text-brand-secondary_hover">
{agent.name}
</span>
</div>
</Link>
</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>
{agent.missed > 0 ? <Badge size="sm" color="error">{agent.missed}</Badge> : <span className="text-sm text-tertiary">0</span>}
<span className="text-sm text-success-primary">{agent.inbound}</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'}>
<span className="text-sm text-brand-secondary">{agent.outbound}</span>
</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)}
</Badge>
</Table.Cell>

View File

@@ -1,13 +1,8 @@
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faPhone,
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';
import type { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import { faPhone, faPhoneArrowDownLeft, faPhoneArrowUpRight, faPhoneMissed } from "@fortawesome/pro-duotone-svg-icons";
import { faCircleInfo } from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import type { Call, Lead } from "@/types/entities";
type KpiCardProps = {
label: string;
@@ -24,7 +19,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}`}>
<FontAwesomeIcon icon={icon} className={`size-4 ${iconColor}`} />
</div>
<div className="flex flex-col flex-1">
<div className="flex flex-1 flex-col">
<div className="flex items-center gap-1">
<span className="text-xs font-medium text-tertiary">{label}</span>
{tooltip && <FontAwesomeIcon icon={faCircleInfo} className="size-3 text-fg-quaternary" title={tooltip} />}
@@ -54,12 +49,12 @@ const MetricCard = ({ label, value, description, tooltip }: MetricCardProps) =>
);
const formatPercent = (value: number): string => {
if (isNaN(value) || !isFinite(value)) return '0%';
if (isNaN(value) || !isFinite(value)) return "0%";
return `${Math.round(value)}%`;
};
const formatMinutes = (minutes: number | null): string => {
if (minutes === null) return '—';
if (minutes === null) return "—";
if (minutes < 60) return `${minutes}m`;
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
@@ -73,46 +68,96 @@ interface DashboardKpiProps {
export const DashboardKpi = ({ calls, leads }: DashboardKpiProps) => {
const totalCalls = calls.length;
const inboundCalls = calls.filter((c) => c.callDirection === 'INBOUND').length;
const outboundCalls = calls.filter((c) => c.callDirection === 'OUTBOUND').length;
const missedCalls = calls.filter((c) => c.callStatus === 'MISSED').length;
const inboundCalls = calls.filter((c) => c.callDirection === "INBOUND").length;
const outboundCalls = calls.filter((c) => c.callDirection === "OUTBOUND").length;
const missedCalls = calls.filter((c) => c.callStatus === "MISSED").length;
const leadsWithResponse = leads.filter((l) => l.createdAt && l.firstContactedAt);
const avgResponseTime = leadsWithResponse.length > 0
? Math.round(leadsWithResponse.reduce((sum, l) => {
const diff = Math.abs(new Date(l.firstContactedAt!).getTime() - new Date(l.createdAt!).getTime()) / 60000;
return sum + diff;
}, 0) / leadsWithResponse.length)
: null;
const avgResponseTime =
leadsWithResponse.length > 0
? Math.round(
leadsWithResponse.reduce((sum, l) => {
const diff = Math.abs(new Date(l.firstContactedAt!).getTime() - new Date(l.createdAt!).getTime()) / 60000;
return sum + diff;
}, 0) / leadsWithResponse.length,
)
: null;
const missedCallsList = calls.filter((c) => c.callStatus === 'MISSED' && c.startedAt);
const missedCallbackTime = missedCallsList.length > 0
? Math.round(missedCallsList.reduce((sum, c) => sum + (Date.now() - new Date(c.startedAt!).getTime()) / 60000, 0) / missedCallsList.length)
: null;
const missedCallsList = calls.filter((c) => c.callStatus === "MISSED" && c.startedAt);
// eslint-disable-next-line react-hooks/purity
const renderTime = Date.now();
const missedCallbackTime =
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 = leads.length > 0
? (leads.filter((l) => l.leadStatus === 'APPOINTMENT_SET' || l.leadStatus === 'CONVERTED').length / leads.length) * 100
: 0;
const leadToAppt =
leads.length > 0 ? (leads.filter((l) => l.leadStatus === "APPOINTMENT_SET" || l.leadStatus === "CONVERTED").length / leads.length) * 100 : 0;
return (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3 xl:grid-cols-4">
<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" />
<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"
<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"
/>
<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}
tooltip="Inbound calls that were not answered by any agent" />
tooltip="Inbound calls that were not answered by any agent"
/>
</div>
<div className="grid grid-cols-2 gap-3 xl:grid-cols-4">
<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" />
<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" />
<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"
/>
<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>
);

View File

@@ -1,14 +1,14 @@
import { useMemo } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPhoneMissed } from '@fortawesome/pro-duotone-svg-icons';
import { Badge } from '@/components/base/badges/badges';
import { ClickToCallButton } from '@/components/call-desk/click-to-call-button';
import type { Call } from '@/types/entities';
import { useMemo } from "react";
import { faPhoneMissed } from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Badge } from "@/components/base/badges/badges";
import { ClickToCallButton } from "@/components/call-desk/click-to-call-button";
import type { Call } from "@/types/entities";
const getTimeSince = (dateStr: string | null): string => {
if (!dateStr) return '—';
if (!dateStr) return "—";
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`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h ago`;
@@ -22,7 +22,7 @@ interface MissedQueueProps {
export const MissedQueue = ({ calls }: MissedQueueProps) => {
const missedCalls = useMemo(() => {
return calls
.filter((c) => c.callStatus === 'MISSED')
.filter((c) => c.callStatus === "MISSED")
.sort((a, b) => {
const dateA = a.startedAt ? new Date(a.startedAt).getTime() : 0;
const dateB = b.startedAt ? new Date(b.startedAt).getTime() : 0;
@@ -39,22 +39,27 @@ export const MissedQueue = ({ calls }: MissedQueueProps) => {
<h3 className="text-sm font-semibold text-primary">Missed Call Queue</h3>
</div>
{missedCalls.length > 0 && (
<Badge size="sm" color="error">{missedCalls.length}</Badge>
<Badge size="sm" color="error">
{missedCalls.length}
</Badge>
)}
</div>
<div className="max-h-[500px] overflow-y-auto">
{missedCalls.length === 0 ? (
<div className="flex flex-col items-center justify-center py-10 gap-2">
<div className="flex flex-col items-center justify-center gap-2 py-10">
<FontAwesomeIcon icon={faPhoneMissed} className="size-6 text-fg-quaternary" />
<p className="text-sm text-tertiary">No missed calls</p>
</div>
) : (
<ul className="divide-y divide-secondary">
{missedCalls.map((call) => {
const phone = call.callerNumber?.[0]?.number ?? '';
const display = phone ? `+91 ${phone}` : 'Unknown';
const phone = call.callerNumber?.[0]?.number ?? "";
const display = phone ? `+91 ${phone}` : "Unknown";
return (
<li key={call.id} className="flex items-center justify-between px-4 py-2.5 hover:bg-primary_hover transition duration-100 ease-linear">
<li
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">
<span className="text-sm font-medium text-primary">{display}</span>
<span className="text-xs text-tertiary">{getTimeSince(call.startedAt)}</span>

View File

@@ -1,15 +1,15 @@
import { useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faGear, faCopy, faLink } from '@fortawesome/pro-duotone-svg-icons';
import { faIcon } from '@/lib/icon-wrapper';
import { SlideoutMenu } from '@/components/application/slideout-menus/slideout-menu';
import { Input } from '@/components/base/input/input';
import { Button } from '@/components/base/buttons/button';
import { notify } from '@/lib/toast';
import { useState } from "react";
import { faCopy, faGear, faLink } from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { SlideoutMenu } from "@/components/application/slideout-menus/slideout-menu";
import { Button } from "@/components/base/buttons/button";
import { Input } from "@/components/base/input/input";
import { faIcon } from "@/lib/icon-wrapper";
import { notify } from "@/lib/toast";
const GearIcon = faIcon(faGear);
type IntegrationType = 'ozonetel' | 'whatsapp' | 'facebook' | 'google' | 'instagram' | 'website' | 'email';
type IntegrationType = "ozonetel" | "whatsapp" | "facebook" | "google" | "instagram" | "website" | "email";
type IntegrationConfig = {
type: IntegrationType;
@@ -36,39 +36,39 @@ type FieldDef = {
const getFieldsForType = (integration: IntegrationConfig): FieldDef[] => {
switch (integration.type) {
case 'ozonetel':
case "ozonetel":
return [
{ key: 'account', label: 'Account ID', placeholder: 'e.g. global_healthx' },
{ key: 'apiKey', label: 'API Key', placeholder: 'Enter API key', type: 'password' },
{ key: 'agentId', label: 'Agent ID', placeholder: 'e.g. global' },
{ key: 'sipId', label: 'SIP ID / Extension', placeholder: 'e.g. 523590' },
{ key: 'campaign', label: 'Campaign Name', placeholder: 'e.g. Inbound_918041763265' },
{ key: "account", label: "Account ID", placeholder: "e.g. global_healthx" },
{ key: "apiKey", label: "API Key", placeholder: "Enter API key", type: "password" },
{ key: "agentId", label: "Agent ID", placeholder: "e.g. global" },
{ key: "sipId", label: "SIP ID / Extension", placeholder: "e.g. 523590" },
{ key: "campaign", label: "Campaign Name", placeholder: "e.g. Inbound_918041763265" },
];
case 'whatsapp':
case "whatsapp":
return [
{ key: 'apiKey', label: 'API Key', placeholder: 'Enter WhatsApp API key', type: 'password' },
{ key: 'phoneNumberId', label: 'Phone Number ID', placeholder: 'e.g. 123456789012345' },
{ key: "apiKey", label: "API Key", placeholder: "Enter WhatsApp API key", type: "password" },
{ key: "phoneNumberId", label: "Phone Number ID", placeholder: "e.g. 123456789012345" },
];
case 'facebook':
case 'google':
case 'instagram':
case "facebook":
case "google":
case "instagram":
return [];
case 'website':
case "website":
return [
{
key: 'webhookUrl',
label: 'Webhook URL',
placeholder: '',
key: "webhookUrl",
label: "Webhook URL",
placeholder: "",
readOnly: true,
copyable: true,
},
];
case 'email':
case "email":
return [
{ key: 'smtpHost', label: 'SMTP Host', placeholder: 'e.g. smtp.gmail.com' },
{ key: 'smtpPort', label: 'Port', placeholder: 'e.g. 587' },
{ key: 'smtpUser', label: 'Username', placeholder: 'e.g. noreply@clinic.com' },
{ key: 'smtpPassword', label: 'Password', placeholder: 'Enter SMTP password', type: 'password' },
{ key: "smtpHost", label: "SMTP Host", placeholder: "e.g. smtp.gmail.com" },
{ key: "smtpPort", label: "Port", placeholder: "e.g. 587" },
{ key: "smtpUser", label: "Username", placeholder: "e.g. noreply@clinic.com" },
{ key: "smtpPassword", label: "Password", placeholder: "Enter SMTP password", type: "password" },
];
default:
return [];
@@ -80,25 +80,25 @@ const getInitialValues = (integration: IntegrationConfig): Record<string, string
const detailMap = new Map(integration.details.map((d) => [d.label, d.value]));
switch (integration.type) {
case 'ozonetel':
values.account = detailMap.get('Account') ?? '';
values.apiKey = '';
values.agentId = detailMap.get('Agent ID') ?? '';
values.sipId = detailMap.get('SIP Extension') ?? '';
values.campaign = detailMap.get('Inbound Campaign') ?? '';
case "ozonetel":
values.account = detailMap.get("Account") ?? "";
values.apiKey = "";
values.agentId = detailMap.get("Agent ID") ?? "";
values.sipId = detailMap.get("SIP Extension") ?? "";
values.campaign = detailMap.get("Inbound Campaign") ?? "";
break;
case 'whatsapp':
values.apiKey = '';
values.phoneNumberId = '';
case "whatsapp":
values.apiKey = "";
values.phoneNumberId = "";
break;
case 'website':
values.webhookUrl = integration.webhookUrl ?? '';
case "website":
values.webhookUrl = integration.webhookUrl ?? "";
break;
case 'email':
values.smtpHost = '';
values.smtpPort = '587';
values.smtpUser = '';
values.smtpPassword = '';
case "email":
values.smtpHost = "";
values.smtpPort = "587";
values.smtpUser = "";
values.smtpPassword = "";
break;
default:
break;
@@ -107,8 +107,7 @@ const getInitialValues = (integration: IntegrationConfig): Record<string, string
return values;
};
const isOAuthType = (type: IntegrationType): boolean =>
type === 'facebook' || type === 'google' || type === 'instagram';
const isOAuthType = (type: IntegrationType): boolean => type === "facebook" || type === "google" || type === "instagram";
export const IntegrationEditSlideout = ({ isOpen, onOpenChange, integration }: IntegrationEditSlideoutProps) => {
const fields = getFieldsForType(integration);
@@ -120,16 +119,16 @@ export const IntegrationEditSlideout = ({ isOpen, onOpenChange, integration }: I
const handleCopy = (value: string) => {
navigator.clipboard.writeText(value);
notify.success('Copied', 'Value copied to clipboard');
notify.success("Copied", "Value copied to clipboard");
};
const handleSave = (close: () => void) => {
notify.success('Configuration saved', `${integration.name} configuration has been saved.`);
notify.success("Configuration saved", `${integration.name} configuration has been saved.`);
close();
};
const handleOAuthConnect = () => {
notify.info('OAuth Connect', `${integration.name} OAuth flow is not yet implemented. This is a placeholder.`);
notify.info("OAuth Connect", `${integration.name} OAuth flow is not yet implemented. This is a placeholder.`);
};
return (
@@ -161,12 +160,7 @@ export const IntegrationEditSlideout = ({ isOpen, onOpenChange, integration }: I
Authorize Helix Engage to access your {integration.name} account to import leads automatically.
</p>
</div>
<Button
size="md"
color="primary"
iconLeading={faIcon(faLink)}
onClick={handleOAuthConnect}
>
<Button size="md" color="primary" iconLeading={faIcon(faLink)} onClick={handleOAuthConnect}>
Connect {integration.name}
</Button>
</div>
@@ -177,14 +171,12 @@ export const IntegrationEditSlideout = ({ isOpen, onOpenChange, integration }: I
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-secondary">{field.label}</label>
<div className="flex items-center gap-2 rounded-lg bg-secondary p-3">
<code className="flex-1 truncate text-xs text-secondary">
{values[field.key] || '\u2014'}
</code>
<code className="flex-1 truncate text-xs text-secondary">{values[field.key] || "\u2014"}</code>
<Button
size="sm"
color="secondary"
iconLeading={faIcon(faCopy)}
onClick={() => handleCopy(values[field.key] ?? '')}
onClick={() => handleCopy(values[field.key] ?? "")}
>
Copy
</Button>
@@ -195,7 +187,7 @@ export const IntegrationEditSlideout = ({ isOpen, onOpenChange, integration }: I
label={field.label}
placeholder={field.placeholder}
type={field.type}
value={values[field.key] ?? ''}
value={values[field.key] ?? ""}
onChange={(value) => updateValue(field.key, value)}
isDisabled={field.readOnly}
/>
@@ -212,11 +204,7 @@ export const IntegrationEditSlideout = ({ isOpen, onOpenChange, integration }: I
Cancel
</Button>
{!isOAuthType(integration.type) && (
<Button
size="md"
color="primary"
onClick={() => handleSave(close)}
>
<Button size="md" color="primary" onClick={() => handleSave(close)}>
Save Configuration
</Button>
)}

View File

@@ -1,9 +1,9 @@
import type { ReactNode } from 'react';
import { useLocation } from 'react-router';
import { Sidebar } from './sidebar';
import { SipProvider } from '@/providers/sip-provider';
import { CallWidget } from '@/components/call-desk/call-widget';
import { useAuth } from '@/providers/auth-provider';
import type { ReactNode } from "react";
import { useLocation } from "react-router";
import { CallWidget } from "@/components/call-desk/call-widget";
import { useAuth } from "@/providers/auth-provider";
import { SipProvider } from "@/providers/sip-provider";
import { Sidebar } from "./sidebar";
interface AppShellProps {
children: ReactNode;
@@ -18,7 +18,7 @@ export const AppShell = ({ children }: AppShellProps) => {
<div className="flex min-h-screen bg-primary">
<Sidebar activeUrl={pathname} />
<main className="flex flex-1 flex-col overflow-auto">{children}</main>
{isCCAgent && pathname !== '/' && pathname !== '/call-desk' && <CallWidget />}
{isCCAgent && pathname !== "/" && pathname !== "/call-desk" && <CallWidget />}
</div>
</SipProvider>
);

View File

@@ -1,5 +1,5 @@
import { Navigate, Outlet } from 'react-router';
import { useAuth } from '@/providers/auth-provider';
import { Navigate, Outlet } from "react-router";
import { useAuth } from "@/providers/auth-provider";
export const AuthGuard = () => {
const { isAuthenticated } = useAuth();

View File

@@ -1,15 +1,15 @@
import { useAuth } from '@/providers/auth-provider';
import { LeadWorkspacePage } from '@/pages/lead-workspace';
import { TeamDashboardPage } from '@/pages/team-dashboard';
import { CallDeskPage } from '@/pages/call-desk';
import { CallDeskPage } from "@/pages/call-desk";
import { LeadWorkspacePage } from "@/pages/lead-workspace";
import { TeamDashboardPage } from "@/pages/team-dashboard";
import { useAuth } from "@/providers/auth-provider";
export const RoleRouter = () => {
const { user } = useAuth();
switch (user.role) {
case 'admin':
case "admin":
return <TeamDashboardPage />;
case 'cc-agent':
case "cc-agent":
return <CallDeskPage />;
default:
return <LeadWorkspacePage />;

View File

@@ -1,6 +1,6 @@
import { useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faArrowRightFromBracket,
faBullhorn,
faChartMixed,
faChevronLeft,
@@ -13,19 +13,19 @@ import {
faPhone,
faPlug,
faUsers,
faArrowRightFromBracket,
} from "@fortawesome/pro-duotone-svg-icons";
import { faIcon } from "@/lib/icon-wrapper";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useAtom } from "jotai";
import { Link, useNavigate } from "react-router";
import { ModalOverlay, Modal, Dialog } from "@/components/application/modals/modal";
import { Button } from "@/components/base/buttons/button";
import { MobileNavigationHeader } from "@/components/application/app-navigation/base-components/mobile-header";
import { NavAccountCard } from "@/components/application/app-navigation/base-components/nav-account-card";
import { NavItemBase } from "@/components/application/app-navigation/base-components/nav-item";
import type { NavItemType } from "@/components/application/app-navigation/config";
import { Dialog, Modal, ModalOverlay } from "@/components/application/modals/modal";
import { Avatar } from "@/components/base/avatar/avatar";
import { Button } from "@/components/base/buttons/button";
import { apiClient } from "@/lib/api-client";
import { faIcon } from "@/lib/icon-wrapper";
import { notify } from "@/lib/toast";
import { useAuth } from "@/providers/auth-provider";
import { sidebarCollapsedAtom } from "@/state/sidebar-state";
@@ -51,50 +51,63 @@ type NavSection = {
};
const getNavSections = (role: string): NavSection[] => {
if (role === 'admin') {
if (role === "admin") {
return [
{ label: 'Overview', items: [{ label: 'Team Dashboard', href: '/', icon: IconGrid2 }] },
{ label: 'Management', items: [
{ label: 'Campaigns', href: '/campaigns', icon: IconBullhorn },
{ label: 'Analytics', href: '/reports', icon: IconChartMixed },
]},
{ label: 'Admin', items: [
{ label: 'Integrations', href: '/integrations', icon: IconPlug },
{ label: 'Settings', href: '/settings', icon: IconGear },
]},
{ label: "Overview", items: [{ label: "Team Dashboard", href: "/", icon: IconGrid2 }] },
{
label: "Management",
items: [
{ label: "Campaigns", href: "/campaigns", icon: IconBullhorn },
{ label: "Analytics", href: "/reports", icon: IconChartMixed },
],
},
{
label: "Admin",
items: [
{ label: "Integrations", href: "/integrations", icon: IconPlug },
{ label: "Settings", href: "/settings", icon: IconGear },
],
},
];
}
if (role === 'cc-agent') {
if (role === "cc-agent") {
return [
{ label: 'Call Center', items: [
{ label: 'Call Desk', href: '/', icon: IconPhone },
{ label: 'Call History', href: '/call-history', icon: IconClockRewind },
{ label: 'Patients', href: '/patients', icon: IconHospitalUser },
{ label: 'My Performance', href: '/my-performance', icon: IconChartMixed },
]},
{
label: "Call Center",
items: [
{ label: "Call Desk", href: "/", icon: IconPhone },
{ label: "Call History", href: "/call-history", icon: IconClockRewind },
{ label: "Patients", href: "/patients", icon: IconHospitalUser },
{ label: "My Performance", href: "/my-performance", icon: IconChartMixed },
],
},
];
}
return [
{ label: 'Main', items: [
{ label: 'Lead Workspace', href: '/', icon: IconGrid2 },
{ label: 'All Leads', href: '/leads', icon: IconUsers },
{ label: 'Patients', href: '/patients', icon: IconHospitalUser },
{ label: 'Campaigns', href: '/campaigns', icon: IconBullhorn },
{ label: 'Outreach', href: '/outreach', icon: IconCommentDots },
]},
{ label: 'Insights', items: [
{ label: 'Analytics', href: '/reports', icon: IconChartMixed },
]},
{
label: "Main",
items: [
{ label: "Lead Workspace", href: "/", icon: IconGrid2 },
{ label: "All Leads", href: "/leads", icon: IconUsers },
{ label: "Patients", href: "/patients", icon: IconHospitalUser },
{ label: "Campaigns", href: "/campaigns", icon: IconBullhorn },
{ label: "Outreach", href: "/outreach", icon: IconCommentDots },
],
},
{ label: "Insights", items: [{ label: "Analytics", href: "/reports", icon: IconChartMixed }] },
];
};
const getRoleSubtitle = (role: string): string => {
switch (role) {
case 'admin': return 'Marketing Admin';
case 'cc-agent': return 'Call Center Agent';
default: return 'Marketing Executive';
case "admin":
return "Marketing Admin";
case "cc-agent":
return "Call Center Agent";
default:
return "Marketing Executive";
}
};
@@ -118,15 +131,15 @@ export const Sidebar = ({ activeUrl = "/" }: SidebarProps) => {
const confirmSignOut = () => {
setLogoutOpen(false);
logout();
navigate('/login');
navigate("/login");
};
const handleForceReady = async () => {
try {
await apiClient.post('/api/ozonetel/agent-ready', {});
notify.success('Agent Ready', 'Agent state has been reset to Ready');
await apiClient.post("/api/ozonetel/agent-ready", {});
notify.success("Agent Ready", "Agent state has been reset to Ready");
} catch {
notify.error('Force Ready Failed', 'Could not reset agent state');
notify.error("Force Ready Failed", "Could not reset agent state");
}
};
@@ -142,7 +155,7 @@ export const Sidebar = ({ activeUrl = "/" }: SidebarProps) => {
{/* Logo + collapse toggle */}
<div className={cx("flex items-center gap-2", collapsed ? "justify-center px-2" : "justify-between px-4 lg:px-5")}>
{collapsed ? (
<img src="/favicon-32.png" alt="Helix Engage" className="size-8 rounded-lg shrink-0" />
<img src="/favicon-32.png" alt="Helix Engage" className="size-8 shrink-0 rounded-lg" />
) : (
<div className="flex flex-col gap-1">
<span className="text-md font-bold text-primary">Helix Engage</span>
@@ -151,8 +164,8 @@ export const Sidebar = ({ activeUrl = "/" }: SidebarProps) => {
)}
<button
onClick={() => setCollapsed(!collapsed)}
className="hidden lg:flex size-6 items-center justify-center rounded-md text-fg-quaternary hover:text-fg-secondary hover:bg-secondary transition duration-100 ease-linear"
title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
className="hidden size-6 items-center justify-center rounded-md text-fg-quaternary transition duration-100 ease-linear hover:bg-secondary hover:text-fg-secondary lg:flex"
title={collapsed ? "Expand sidebar" : "Collapse sidebar"}
>
<FontAwesomeIcon icon={collapsed ? faChevronRight : faChevronLeft} className="size-3" />
</button>
@@ -172,7 +185,7 @@ export const Sidebar = ({ activeUrl = "/" }: SidebarProps) => {
<li key={item.label} className="py-0.5">
{collapsed ? (
<Link
to={item.href ?? '/'}
to={item.href ?? "/"}
title={item.label}
className={cx(
"flex size-10 items-center justify-center rounded-lg transition duration-100 ease-linear",
@@ -184,13 +197,7 @@ export const Sidebar = ({ activeUrl = "/" }: SidebarProps) => {
{item.icon && <item.icon className="size-5" />}
</Link>
) : (
<NavItemBase
icon={item.icon}
href={item.href}
badge={item.badge}
type="link"
current={item.href === activeUrl}
>
<NavItemBase icon={item.icon} href={item.href} badge={item.badge} type="link" current={item.href === activeUrl}>
{item.label}
</NavItemBase>
)}
@@ -207,19 +214,21 @@ export const Sidebar = ({ activeUrl = "/" }: SidebarProps) => {
<button
onClick={handleSignOut}
title={`${user.name}\nSign out`}
className="rounded-lg p-1 hover:bg-primary_hover transition duration-100 ease-linear"
className="rounded-lg p-1 transition duration-100 ease-linear hover:bg-primary_hover"
>
<Avatar size="sm" initials={user.initials} status="online" />
</button>
) : (
<NavAccountCard
items={[{
id: 'current',
name: user.name,
email: user.email,
avatar: '',
status: 'online' as const,
}]}
items={[
{
id: "current",
name: user.name,
email: user.email,
avatar: "",
status: "online" as const,
},
]}
selectedAccountId="current"
onSignOut={handleSignOut}
onForceReady={handleForceReady}
@@ -235,7 +244,7 @@ export const Sidebar = ({ activeUrl = "/" }: SidebarProps) => {
<div className="hidden lg:fixed lg:inset-y-0 lg:left-0 lg:flex lg:py-1 lg:pl-1">{content}</div>
<div
style={{ paddingLeft: width + 4 }}
className="invisible hidden lg:sticky lg:top-0 lg:bottom-0 lg:left-0 lg:block transition-all duration-200 ease-linear"
className="invisible hidden transition-all duration-200 ease-linear lg:sticky lg:top-0 lg:bottom-0 lg:left-0 lg:block"
/>
{/* Logout confirmation modal */}
@@ -243,7 +252,7 @@ export const Sidebar = ({ activeUrl = "/" }: SidebarProps) => {
<Modal className="max-w-md">
<Dialog>
<div className="rounded-xl bg-primary p-6 shadow-xl">
<div className="flex flex-col items-center text-center gap-4">
<div className="flex flex-col items-center gap-4 text-center">
<div className="flex size-12 items-center justify-center rounded-full bg-warning-secondary">
<FontAwesomeIcon icon={faArrowRightFromBracket} className="size-5 text-fg-warning-primary" />
</div>

View File

@@ -1,6 +1,6 @@
import { cx } from '@/utils/cx';
import { daysAgoFromNow } from '@/lib/format';
import type { Lead } from '@/types/entities';
import { daysAgoFromNow } from "@/lib/format";
import type { Lead } from "@/types/entities";
import { cx } from "@/utils/cx";
interface AgingWidgetProps {
leads: Lead[];
@@ -26,21 +26,21 @@ export const AgingWidget = ({ leads }: AgingWidgetProps) => {
const brackets: AgingBracket[] = [
{
label: 'Fresh (<2 days)',
color: 'text-success-primary',
barColor: 'bg-success-solid',
label: "Fresh (<2 days)",
color: "text-success-primary",
barColor: "bg-success-solid",
count: freshCount,
},
{
label: 'Warm (2-5 days)',
color: 'text-warning-primary',
barColor: 'bg-warning-solid',
label: "Warm (2-5 days)",
color: "text-warning-primary",
barColor: "bg-warning-solid",
count: warmCount,
},
{
label: 'Cold (>5 days)',
color: 'text-error-primary',
barColor: 'bg-error-solid',
label: "Cold (>5 days)",
color: "text-error-primary",
barColor: "bg-error-solid",
count: coldCount,
},
];
@@ -52,14 +52,12 @@ export const AgingWidget = ({ leads }: AgingWidgetProps) => {
{brackets.map((bracket) => (
<div key={bracket.label}>
<div className="mb-1 flex items-center justify-between">
<span className={cx('text-xs', bracket.color)}>{bracket.label}</span>
<span className={cx('text-sm font-bold', bracket.color)}>
{bracket.count}
</span>
<span className={cx("text-xs", bracket.color)}>{bracket.label}</span>
<span className={cx("text-sm font-bold", bracket.color)}>{bracket.count}</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-tertiary">
<div
className={cx('h-full rounded-full transition-all', bracket.barColor)}
className={cx("h-full rounded-full transition-all", bracket.barColor)}
style={{ width: `${(bracket.count / total) * 100}%` }}
/>
</div>

View File

@@ -1,30 +1,21 @@
import { daysAgoFromNow } from '@/lib/format';
import type { Lead } from '@/types/entities';
import { daysAgoFromNow } from "@/lib/format";
import type { Lead } from "@/types/entities";
interface AlertsWidgetProps {
leads: Lead[];
}
export const AlertsWidget = ({ leads }: AlertsWidgetProps) => {
const agingCount = leads.filter(
(l) =>
l.leadStatus === 'NEW' &&
l.createdAt !== null &&
daysAgoFromNow(l.createdAt) > 5,
).length;
const agingCount = leads.filter((l) => l.leadStatus === "NEW" && l.createdAt !== null && daysAgoFromNow(l.createdAt) > 5).length;
if (agingCount === 0) {
return null;
}
return (
<div className="rounded-xl border border-error-subtle bg-error-primary p-4">
<p className="text-xs font-bold text-error-primary">
{agingCount} leads aging &gt; 5 days
</p>
<p className="mt-1 text-xs text-error-primary opacity-80">
These leads haven&apos;t been contacted and are at risk of going cold.
</p>
<div className="border-error-subtle rounded-xl border bg-error-primary p-4">
<p className="text-xs font-bold text-error-primary">{agingCount} leads aging &gt; 5 days</p>
<p className="mt-1 text-xs text-error-primary opacity-80">These leads haven&apos;t been contacted and are at risk of going cold.</p>
</div>
);
};

View File

@@ -9,39 +9,23 @@ interface BulkActionBarProps {
export const BulkActionBar = ({ selectedCount, onAssign, onWhatsApp, onMarkSpam, onDeselect }: BulkActionBarProps) => {
if (selectedCount === 0) return null;
const buttonBase = 'rounded-lg px-3 py-1.5 text-xs font-semibold border-none cursor-pointer transition duration-100 ease-linear';
const buttonBase = "rounded-lg px-3 py-1.5 text-xs font-semibold border-none cursor-pointer transition duration-100 ease-linear";
return (
<div className="flex items-center gap-3 rounded-xl bg-brand-section p-3 text-white">
<span className="text-sm font-semibold">{selectedCount} leads selected</span>
<div className="ml-auto flex gap-2">
<button
type="button"
onClick={onAssign}
className={`${buttonBase} bg-white/20 text-white hover:bg-white/30`}
>
<button type="button" onClick={onAssign} className={`${buttonBase} bg-white/20 text-white hover:bg-white/30`}>
Assign to Call Center
</button>
<button
type="button"
onClick={onWhatsApp}
className={`${buttonBase} bg-success-solid text-white hover:bg-success-solid/90`}
>
<button type="button" onClick={onWhatsApp} className={`${buttonBase} bg-success-solid text-white hover:bg-success-solid/90`}>
Send WhatsApp
</button>
<button
type="button"
onClick={onMarkSpam}
className={`${buttonBase} bg-white/15 text-error-primary hover:bg-white/25`}
>
<button type="button" onClick={onMarkSpam} className={`${buttonBase} bg-white/15 text-error-primary hover:bg-white/25`}>
Mark Spam
</button>
<button
type="button"
onClick={onDeselect}
className={`${buttonBase} bg-white/10 text-white/70 hover:bg-white/20 hover:text-white`}
>
<button type="button" onClick={onDeselect} className={`${buttonBase} bg-white/10 text-white/70 hover:bg-white/20 hover:text-white`}>
Deselect
</button>
</div>

View File

@@ -1,6 +1,6 @@
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faXmark } from '@fortawesome/pro-duotone-svg-icons';
import { cx } from '@/utils/cx';
import { faXmark } from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { cx } from "@/utils/cx";
type FilterPill = {
key: string;
@@ -23,7 +23,7 @@ export const FilterPills = ({ filters, onRemove, onClearAll }: FilterPillsProps)
<span
key={filter.key}
className={cx(
'flex items-center gap-1 rounded-full border border-brand bg-brand-primary px-3 py-1 text-xs font-medium text-brand-secondary',
"flex items-center gap-1 rounded-full border border-brand bg-brand-primary px-3 py-1 text-xs font-medium text-brand-secondary",
)}
>
{filter.label}: {filter.value}

View File

@@ -1,7 +1,7 @@
import { Button } from '@/components/base/buttons/button';
import { cx } from '@/utils/cx';
import { formatShortDate } from '@/lib/format';
import type { FollowUp } from '@/types/entities';
import { Button } from "@/components/base/buttons/button";
import { formatShortDate } from "@/lib/format";
import type { FollowUp } from "@/types/entities";
import { cx } from "@/utils/cx";
interface FollowupWidgetProps {
overdue: FollowUp[];
@@ -26,26 +26,15 @@ export const FollowupWidget = ({ overdue, upcoming }: FollowupWidgetProps) => {
<div
key={item.id}
className={cx(
'rounded-lg border-l-2 p-3',
isOverdue
? 'border-l-error-solid bg-error-primary'
: 'border-l-brand-solid bg-secondary',
"rounded-lg border-l-2 p-3",
isOverdue ? "border-l-error-solid bg-error-primary" : "border-l-brand-solid bg-secondary",
)}
>
<p
className={cx(
'text-xs font-semibold',
isOverdue ? 'text-error-primary' : 'text-brand-secondary',
)}
>
{item.scheduledAt
? formatShortDate(item.scheduledAt)
: 'No date'}
{isOverdue && ' — Overdue'}
</p>
<p className="mt-0.5 text-xs font-medium text-primary">
{item.description ?? item.followUpType ?? 'Follow-up'}
<p className={cx("text-xs font-semibold", isOverdue ? "text-error-primary" : "text-brand-secondary")}>
{item.scheduledAt ? formatShortDate(item.scheduledAt) : "No date"}
{isOverdue && " — Overdue"}
</p>
<p className="mt-0.5 text-xs font-medium text-primary">{item.description ?? item.followUpType ?? "Follow-up"}</p>
{(item.patientName || item.patientPhone) && (
<p className="mt-0.5 text-xs text-tertiary">
{item.patientName}

View File

@@ -1,5 +1,5 @@
import { cx } from '@/utils/cx';
import type { Lead } from '@/types/entities';
import type { Lead } from "@/types/entities";
import { cx } from "@/utils/cx";
interface KpiCardsProps {
leads: Lead[];
@@ -14,38 +14,38 @@ type KpiCard = {
};
export const KpiCards = ({ leads }: KpiCardsProps) => {
const newCount = leads.filter((l) => l.leadStatus === 'NEW').length;
const newCount = leads.filter((l) => l.leadStatus === "NEW").length;
const assignedCount = leads.filter((l) => l.assignedAgent !== null).length;
const contactedCount = leads.filter((l) => l.leadStatus === 'CONTACTED').length;
const convertedCount = leads.filter((l) => l.leadStatus === 'CONVERTED').length;
const contactedCount = leads.filter((l) => l.leadStatus === "CONTACTED").length;
const convertedCount = leads.filter((l) => l.leadStatus === "CONVERTED").length;
const cards: KpiCard[] = [
{
label: 'New Leads Today',
label: "New Leads Today",
value: newCount,
delta: '+12% vs yesterday',
deltaColor: 'text-success-primary',
delta: "+12% vs yesterday",
deltaColor: "text-success-primary",
isHero: true,
},
{
label: 'Assigned to CC',
label: "Assigned to CC",
value: assignedCount,
delta: '85% assigned',
deltaColor: 'text-brand-secondary',
delta: "85% assigned",
deltaColor: "text-brand-secondary",
isHero: false,
},
{
label: 'Contacted',
label: "Contacted",
value: contactedCount,
delta: '+8% vs yesterday',
deltaColor: 'text-success-primary',
delta: "+8% vs yesterday",
deltaColor: "text-success-primary",
isHero: false,
},
{
label: 'Converted',
label: "Converted",
value: convertedCount,
delta: '+3 this week',
deltaColor: 'text-warning-primary',
delta: "+3 this week",
deltaColor: "text-warning-primary",
isHero: false,
},
];
@@ -56,36 +56,13 @@ export const KpiCards = ({ leads }: KpiCardsProps) => {
<div
key={card.label}
className={cx(
'rounded-2xl p-5 transition hover:shadow-md',
card.isHero
? 'flex-[1.3] bg-brand-solid text-white'
: 'flex-1 border border-secondary bg-primary',
"rounded-2xl p-5 transition hover:shadow-md",
card.isHero ? "flex-[1.3] bg-brand-solid text-white" : "flex-1 border border-secondary bg-primary",
)}
>
<p
className={cx(
'text-xs font-medium uppercase tracking-wider',
card.isHero ? 'text-white/70' : 'text-quaternary',
)}
>
{card.label}
</p>
<p
className={cx(
'mt-1 text-display-sm font-bold',
card.isHero ? 'text-white' : 'text-primary',
)}
>
{card.value}
</p>
<p
className={cx(
'mt-1 text-xs',
card.isHero ? 'text-white/80' : card.deltaColor,
)}
>
{card.delta}
</p>
<p className={cx("text-xs font-medium tracking-wider uppercase", card.isHero ? "text-white/70" : "text-quaternary")}>{card.label}</p>
<p className={cx("mt-1 text-display-sm font-bold", card.isHero ? "text-white" : "text-primary")}>{card.value}</p>
<p className={cx("mt-1 text-xs", card.isHero ? "text-white/80" : card.deltaColor)}>{card.delta}</p>
</div>
))}
</div>

View File

@@ -1,7 +1,7 @@
import { SlideoutMenu } from '@/components/application/slideout-menus/slideout-menu';
import { formatPhone, formatShortDate } from '@/lib/format';
import type { Lead, LeadActivity, LeadActivityType } from '@/types/entities';
import { cx } from '@/utils/cx';
import { SlideoutMenu } from "@/components/application/slideout-menus/slideout-menu";
import { formatPhone, formatShortDate } from "@/lib/format";
import type { Lead, LeadActivity, LeadActivityType } from "@/types/entities";
import { cx } from "@/utils/cx";
type LeadActivitySlideoutProps = {
isOpen: boolean;
@@ -17,31 +17,29 @@ type ActivityConfig = {
};
const ACTIVITY_CONFIG: Record<LeadActivityType, ActivityConfig> = {
STATUS_CHANGE: { icon: '🔄', dotClass: 'bg-brand-secondary', label: 'Status Changed' },
CALL_MADE: { icon: '📞', dotClass: 'bg-brand-secondary', label: 'Call Made' },
CALL_RECEIVED: { icon: '📲', dotClass: 'bg-brand-secondary', label: 'Call Received' },
WHATSAPP_SENT: { icon: '💬', dotClass: 'bg-success-solid', label: 'WhatsApp Sent' },
WHATSAPP_RECEIVED: { icon: '💬', dotClass: 'bg-success-solid', label: 'WhatsApp Received' },
SMS_SENT: { icon: '✉️', dotClass: 'bg-brand-secondary', label: 'SMS Sent' },
EMAIL_SENT: { icon: '📧', dotClass: 'bg-brand-secondary', label: 'Email Sent' },
EMAIL_RECEIVED: { icon: '📧', dotClass: 'bg-brand-secondary', label: 'Email Received' },
NOTE_ADDED: { icon: '📝', dotClass: 'bg-warning-solid', label: 'Note Added' },
ASSIGNED: { icon: '📤', dotClass: 'bg-brand-secondary', label: 'Assigned' },
APPOINTMENT_BOOKED: { icon: '📅', dotClass: 'bg-brand-secondary', label: 'Appointment Booked' },
FOLLOW_UP_CREATED: { icon: '🔁', dotClass: 'bg-brand-secondary', label: 'Follow-up Created' },
CONVERTED: { icon: '✅', dotClass: 'bg-success-solid', label: 'Converted' },
MARKED_SPAM: { icon: '🚫', dotClass: 'bg-error-solid', label: 'Marked as Spam' },
DUPLICATE_DETECTED: { icon: '🔍', dotClass: 'bg-warning-solid', label: 'Duplicate Detected' },
STATUS_CHANGE: { icon: "🔄", dotClass: "bg-brand-secondary", label: "Status Changed" },
CALL_MADE: { icon: "📞", dotClass: "bg-brand-secondary", label: "Call Made" },
CALL_RECEIVED: { icon: "📲", dotClass: "bg-brand-secondary", label: "Call Received" },
WHATSAPP_SENT: { icon: "💬", dotClass: "bg-success-solid", label: "WhatsApp Sent" },
WHATSAPP_RECEIVED: { icon: "💬", dotClass: "bg-success-solid", label: "WhatsApp Received" },
SMS_SENT: { icon: "✉️", dotClass: "bg-brand-secondary", label: "SMS Sent" },
EMAIL_SENT: { icon: "📧", dotClass: "bg-brand-secondary", label: "Email Sent" },
EMAIL_RECEIVED: { icon: "📧", dotClass: "bg-brand-secondary", label: "Email Received" },
NOTE_ADDED: { icon: "📝", dotClass: "bg-warning-solid", label: "Note Added" },
ASSIGNED: { icon: "📤", dotClass: "bg-brand-secondary", label: "Assigned" },
APPOINTMENT_BOOKED: { icon: "📅", dotClass: "bg-brand-secondary", label: "Appointment Booked" },
FOLLOW_UP_CREATED: { icon: "🔁", dotClass: "bg-brand-secondary", label: "Follow-up Created" },
CONVERTED: { icon: "✅", dotClass: "bg-success-solid", label: "Converted" },
MARKED_SPAM: { icon: "🚫", dotClass: "bg-error-solid", label: "Marked as Spam" },
DUPLICATE_DETECTED: { icon: "🔍", dotClass: "bg-warning-solid", label: "Duplicate Detected" },
};
const DEFAULT_CONFIG: ActivityConfig = { icon: '📌', dotClass: 'bg-tertiary', label: 'Activity' };
const DEFAULT_CONFIG: ActivityConfig = { icon: "📌", dotClass: "bg-tertiary", label: "Activity" };
const StatusChangeContent = ({ previousValue, newValue }: { previousValue: string | null; newValue: string | null }) => (
<span className="text-sm text-secondary">
{previousValue && (
<span className="mr-1 text-sm line-through text-quaternary">{previousValue}</span>
)}
{previousValue && newValue && '→ '}
{previousValue && <span className="mr-1 text-sm text-quaternary line-through">{previousValue}</span>}
{previousValue && newValue && "→ "}
{newValue && <span className="font-medium text-brand-secondary">{newValue}</span>}
</span>
);
@@ -49,45 +47,33 @@ const StatusChangeContent = ({ previousValue, newValue }: { previousValue: strin
const ActivityItem = ({ activity, isLast }: { activity: LeadActivity; isLast: boolean }) => {
const type = activity.activityType;
const config = type ? (ACTIVITY_CONFIG[type] ?? DEFAULT_CONFIG) : DEFAULT_CONFIG;
const occurredAt = activity.occurredAt ? formatShortDate(activity.occurredAt) : '';
const occurredAt = activity.occurredAt ? formatShortDate(activity.occurredAt) : "";
return (
<div className="relative flex gap-3 pb-4">
{/* Vertical connecting line */}
{!isLast && (
<div className="absolute left-[15px] top-[36px] bottom-0 w-0.5 bg-tertiary" />
)}
{!isLast && <div className="absolute top-[36px] bottom-0 left-[15px] w-0.5 bg-tertiary" />}
{/* Dot */}
<div
className={cx(
'relative z-10 flex size-8 shrink-0 items-center justify-center rounded-full text-sm',
config.dotClass,
)}
aria-hidden="true"
>
<div className={cx("relative z-10 flex size-8 shrink-0 items-center justify-center rounded-full text-sm", config.dotClass)} aria-hidden="true">
{config.icon}
</div>
{/* Content */}
<div className="flex flex-1 flex-col gap-0.5 pt-1 min-w-0">
<div className="flex min-w-0 flex-1 flex-col gap-0.5 pt-1">
<div className="flex items-start gap-2">
<span className="flex-1 text-sm font-semibold text-primary">
{activity.summary ?? config.label}
</span>
<span className="flex-1 text-sm font-semibold text-primary">{activity.summary ?? config.label}</span>
</div>
{type === 'STATUS_CHANGE' && (activity.previousValue || activity.newValue) && (
{type === "STATUS_CHANGE" && (activity.previousValue || activity.newValue) && (
<StatusChangeContent previousValue={activity.previousValue} newValue={activity.newValue} />
)}
{type !== 'STATUS_CHANGE' && activity.newValue && (
<p className="text-xs text-tertiary">{activity.newValue}</p>
)}
{type !== "STATUS_CHANGE" && activity.newValue && <p className="text-xs text-tertiary">{activity.newValue}</p>}
<p className="text-xs text-quaternary">
{occurredAt}
{activity.performedBy ? ` · by ${activity.performedBy}` : ''}
{activity.performedBy ? ` · by ${activity.performedBy}` : ""}
</p>
</div>
</div>
@@ -95,9 +81,9 @@ const ActivityItem = ({ activity, isLast }: { activity: LeadActivity; isLast: bo
};
export const LeadActivitySlideout = ({ isOpen, onOpenChange, lead, activities }: LeadActivitySlideoutProps) => {
const firstName = lead.contactName?.firstName ?? '';
const lastName = lead.contactName?.lastName ?? '';
const fullName = `${firstName} ${lastName}`.trim() || 'Unknown Lead';
const firstName = lead.contactName?.firstName ?? "";
const lastName = lead.contactName?.lastName ?? "";
const fullName = `${firstName} ${lastName}`.trim() || "Unknown Lead";
const phone = lead.contactPhone?.[0] ? formatPhone(lead.contactPhone[0]) : null;
const email = lead.contactEmail?.[0]?.address ?? null;
@@ -116,11 +102,7 @@ export const LeadActivitySlideout = ({ isOpen, onOpenChange, lead, activities }:
<SlideoutMenu.Header onClose={close}>
<div className="flex flex-col gap-0.5 pr-8">
<h2 className="text-lg font-semibold text-primary">Lead Activity {fullName}</h2>
<p className="text-sm text-tertiary">
{[phone, email, lead.leadSource, lead.utmCampaign]
.filter(Boolean)
.join(' · ')}
</p>
<p className="text-sm text-tertiary">{[phone, email, lead.leadSource, lead.utmCampaign].filter(Boolean).join(" · ")}</p>
</div>
</SlideoutMenu.Header>
@@ -134,11 +116,7 @@ export const LeadActivitySlideout = ({ isOpen, onOpenChange, lead, activities }:
) : (
<div className="flex flex-col">
{filteredActivities.map((activity, idx) => (
<ActivityItem
key={activity.id}
activity={activity}
isLast={idx === filteredActivities.length - 1}
/>
<ActivityItem key={activity.id} activity={activity} isLast={idx === filteredActivities.length - 1} />
))}
</div>
)}

View File

@@ -1,12 +1,12 @@
import { Avatar } from '@/components/base/avatar/avatar';
import { Badge } from '@/components/base/badges/badges';
import { Button } from '@/components/base/buttons/button';
import { LeadStatusBadge } from '@/components/shared/status-badge';
import { SourceTag } from '@/components/shared/source-tag';
import { AgeIndicator } from '@/components/shared/age-indicator';
import { formatPhone, getInitials } from '@/lib/format';
import { cx } from '@/utils/cx';
import type { Lead } from '@/types/entities';
import { Avatar } from "@/components/base/avatar/avatar";
import { Badge } from "@/components/base/badges/badges";
import { Button } from "@/components/base/buttons/button";
import { AgeIndicator } from "@/components/shared/age-indicator";
import { SourceTag } from "@/components/shared/source-tag";
import { LeadStatusBadge } from "@/components/shared/status-badge";
import { formatPhone, getInitials } from "@/lib/format";
import type { Lead } from "@/types/entities";
import { cx } from "@/utils/cx";
interface LeadCardProps {
lead: Lead;
@@ -19,34 +19,34 @@ interface LeadCardProps {
}
const sourceLabelMap: Record<string, string> = {
FACEBOOK_AD: 'Facebook',
INSTAGRAM: 'Instagram',
GOOGLE_AD: 'Google',
GOOGLE_MY_BUSINESS: 'GMB',
WHATSAPP: 'WhatsApp',
WEBSITE: 'Website',
REFERRAL: 'Referral',
WALK_IN: 'Walk-in',
PHONE: 'Phone',
OTHER: 'Other',
FACEBOOK_AD: "Facebook",
INSTAGRAM: "Instagram",
GOOGLE_AD: "Google",
GOOGLE_MY_BUSINESS: "GMB",
WHATSAPP: "WhatsApp",
WEBSITE: "Website",
REFERRAL: "Referral",
WALK_IN: "Walk-in",
PHONE: "Phone",
OTHER: "Other",
};
export const LeadCard = ({ lead, onAssign, onMessage, onMarkSpam, onMerge, onLogCall, onUpdateStatus }: LeadCardProps) => {
const firstName = lead.contactName?.firstName ?? '';
const lastName = lead.contactName?.lastName ?? '';
const name = `${firstName} ${lastName}`.trim() || 'Unknown';
const initials = firstName && lastName ? getInitials(firstName, lastName) : '??';
const phone = lead.contactPhone?.[0] ? formatPhone(lead.contactPhone[0]) : '';
const sourceLabel = lead.leadSource ? sourceLabelMap[lead.leadSource] ?? lead.leadSource : '';
const firstName = lead.contactName?.firstName ?? "";
const lastName = lead.contactName?.lastName ?? "";
const name = `${firstName} ${lastName}`.trim() || "Unknown";
const initials = firstName && lastName ? getInitials(firstName, lastName) : "??";
const phone = lead.contactPhone?.[0] ? formatPhone(lead.contactPhone[0]) : "";
const sourceLabel = lead.leadSource ? (sourceLabelMap[lead.leadSource] ?? lead.leadSource) : "";
const isSpam = (lead.spamScore ?? 0) >= 60;
const isDuplicate = lead.isDuplicate === true;
const isAssigned = lead.assignedAgent !== null && lead.leadStatus !== 'NEW';
const isAssigned = lead.assignedAgent !== null && lead.leadStatus !== "NEW";
return (
<div
className={cx(
'flex items-center gap-4 rounded-2xl border border-secondary p-5 transition hover:shadow-md',
isSpam ? 'bg-warning-primary' : 'bg-primary',
"flex items-center gap-4 rounded-2xl border border-secondary p-5 transition hover:shadow-md",
isSpam ? "bg-warning-primary" : "bg-primary",
)}
>
{/* Avatar */}

View File

@@ -1,77 +1,64 @@
import type { FC } from 'react';
import { useMemo, useState } from 'react';
import { TableBody as AriaTableBody } from 'react-aria-components';
import type { SortDescriptor, Selection } from 'react-aria-components';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faEllipsisVertical } from '@fortawesome/pro-duotone-svg-icons';
import type { FC } from "react";
import { useMemo, useState } from "react";
import { faEllipsisVertical } from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { TableBody as AriaTableBody } from "react-aria-components";
import type { Selection, SortDescriptor } from "react-aria-components";
import { Table } from "@/components/application/table/table";
import { Badge } from "@/components/base/badges/badges";
import { Button } from "@/components/base/buttons/button";
import { AgeIndicator } from "@/components/shared/age-indicator";
import { SourceTag } from "@/components/shared/source-tag";
import { LeadStatusBadge } from "@/components/shared/status-badge";
import { formatPhone, formatShortDate } from "@/lib/format";
import type { Lead } from "@/types/entities";
import { cx } from "@/utils/cx";
const DotsVertical: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faEllipsisVertical} className={className} />;
import { Badge } from '@/components/base/badges/badges';
import { Button } from '@/components/base/buttons/button';
import { Table } from '@/components/application/table/table';
import { LeadStatusBadge } from '@/components/shared/status-badge';
import { SourceTag } from '@/components/shared/source-tag';
import { AgeIndicator } from '@/components/shared/age-indicator';
import { formatPhone, formatShortDate } from '@/lib/format';
import { cx } from '@/utils/cx';
import type { Lead } from '@/types/entities';
type LeadTableProps = {
leads: Lead[];
onSelectionChange: (selectedIds: string[]) => void;
selectedIds: string[];
sortField: string;
sortDirection: 'asc' | 'desc';
sortDirection: "asc" | "desc";
onSort: (field: string) => void;
onViewActivity?: (lead: Lead) => void;
};
type TableRow = {
id: string;
type: 'lead' | 'dup-sub';
type: "lead" | "dup-sub";
lead: Lead;
};
const SpamDisplay = ({ score }: { score: number }) => {
const colorClass =
score < 30
? 'text-success-primary'
: score < 60
? 'text-warning-primary'
: 'text-error-primary';
const colorClass = score < 30 ? "text-success-primary" : score < 60 ? "text-warning-primary" : "text-error-primary";
const bgClass = score >= 60 ? 'rounded px-1.5 py-0.5 bg-error-primary' : '';
const bgClass = score >= 60 ? "rounded px-1.5 py-0.5 bg-error-primary" : "";
return <span className={cx('text-xs font-semibold', colorClass, bgClass)}>{score}%</span>;
return <span className={cx("text-xs font-semibold", colorClass, bgClass)}>{score}%</span>;
};
export const LeadTable = ({
leads,
onSelectionChange,
selectedIds,
sortField,
sortDirection,
onSort,
onViewActivity,
}: LeadTableProps) => {
export const LeadTable = ({ leads, onSelectionChange, selectedIds, sortField, sortDirection, onSort, onViewActivity }: LeadTableProps) => {
const [expandedDupId, setExpandedDupId] = useState<string | null>(null);
const selectedKeys: Selection = new Set(selectedIds);
const handleSelectionChange = (keys: Selection) => {
if (keys === 'all') {
if (keys === "all") {
// Only select actual lead rows, not dup sub-rows
onSelectionChange(leads.map((l) => l.id));
} else {
// Filter out dup sub-row IDs
const leadOnlyIds = [...keys].filter((k) => !String(k).endsWith('-dup')) as string[];
const leadOnlyIds = [...keys].filter((k) => !String(k).endsWith("-dup")) as string[];
onSelectionChange(leadOnlyIds);
}
};
const sortDescriptor: SortDescriptor = {
column: sortField,
direction: sortDirection === 'asc' ? 'ascending' : 'descending',
direction: sortDirection === "asc" ? "ascending" : "descending",
};
const handleSortChange = (descriptor: SortDescriptor) => {
@@ -84,28 +71,28 @@ export const LeadTable = ({
const tableRows = useMemo<TableRow[]>(() => {
const rows: TableRow[] = [];
for (const lead of leads) {
rows.push({ id: lead.id, type: 'lead', lead });
rows.push({ id: lead.id, type: "lead", lead });
if (lead.isDuplicate === true && expandedDupId === lead.id) {
rows.push({ id: `${lead.id}-dup`, type: 'dup-sub', lead });
rows.push({ id: `${lead.id}-dup`, type: "dup-sub", lead });
}
}
return rows;
}, [leads, expandedDupId]);
const columns = [
{ id: 'phone', label: 'Phone', allowsSorting: true },
{ id: 'name', label: 'Name', allowsSorting: true },
{ id: 'email', label: 'Email', allowsSorting: false },
{ id: 'campaign', label: 'Campaign', allowsSorting: false },
{ id: 'ad', label: 'Ad', allowsSorting: false },
{ id: 'source', label: 'Source', allowsSorting: true },
{ id: 'firstContactedAt', label: 'First Contact', allowsSorting: true },
{ id: 'lastContactedAt', label: 'Last Contact', allowsSorting: true },
{ id: 'status', label: 'Status', allowsSorting: true },
{ id: 'createdAt', label: 'Age', allowsSorting: true },
{ id: 'spamScore', label: 'Spam', allowsSorting: true },
{ id: 'dups', label: 'Dups', allowsSorting: false },
{ id: 'actions', label: '', allowsSorting: false },
{ id: "phone", label: "Phone", allowsSorting: true },
{ id: "name", label: "Name", allowsSorting: true },
{ id: "email", label: "Email", allowsSorting: false },
{ id: "campaign", label: "Campaign", allowsSorting: false },
{ id: "ad", label: "Ad", allowsSorting: false },
{ id: "source", label: "Source", allowsSorting: true },
{ id: "firstContactedAt", label: "First Contact", allowsSorting: true },
{ id: "lastContactedAt", label: "Last Contact", allowsSorting: true },
{ id: "status", label: "Status", allowsSorting: true },
{ id: "createdAt", label: "Age", allowsSorting: true },
{ id: "spamScore", label: "Spam", allowsSorting: true },
{ id: "dups", label: "Dups", allowsSorting: false },
{ id: "actions", label: "", allowsSorting: false },
];
return (
@@ -121,35 +108,22 @@ export const LeadTable = ({
size="sm"
>
<Table.Header columns={columns}>
{(column) => (
<Table.Head
key={column.id}
id={column.id}
label={column.label}
allowsSorting={column.allowsSorting}
/>
)}
{(column) => <Table.Head key={column.id} id={column.id} label={column.label} allowsSorting={column.allowsSorting} />}
</Table.Header>
<AriaTableBody items={tableRows}>
{(row) => {
const { lead } = row;
const firstName = lead.contactName?.firstName ?? '';
const lastName = lead.contactName?.lastName ?? '';
const name = `${firstName} ${lastName}`.trim() || '\u2014';
const phone = lead.contactPhone?.[0]
? formatPhone(lead.contactPhone[0])
: '\u2014';
const email = lead.contactEmail?.[0]?.address ?? '\u2014';
const firstName = lead.contactName?.firstName ?? "";
const lastName = lead.contactName?.lastName ?? "";
const name = `${firstName} ${lastName}`.trim() || "\u2014";
const phone = lead.contactPhone?.[0] ? formatPhone(lead.contactPhone[0]) : "\u2014";
const email = lead.contactEmail?.[0]?.address ?? "\u2014";
// Render duplicate sub-row
if (row.type === 'dup-sub') {
if (row.type === "dup-sub") {
return (
<Table.Row
key={row.id}
id={row.id}
className="bg-warning-primary"
>
<Table.Row key={row.id} id={row.id} className="bg-warning-primary">
<Table.Cell className="pl-10">
<span className="text-xs text-tertiary">{phone}</span>
</Table.Cell>
@@ -160,11 +134,7 @@ export const LeadTable = ({
<span className="text-xs text-tertiary">{email}</span>
</Table.Cell>
<Table.Cell>
{lead.leadSource ? (
<SourceTag source={lead.leadSource} size="sm" />
) : (
<span className="text-tertiary">{'\u2014'}</span>
)}
{lead.leadSource ? <SourceTag source={lead.leadSource} size="sm" /> : <span className="text-tertiary">{"\u2014"}</span>}
</Table.Cell>
<Table.Cell>
<Badge size="sm" type="pill-color" color="warning">
@@ -172,11 +142,7 @@ export const LeadTable = ({
</Badge>
</Table.Cell>
<Table.Cell>
<span className="text-xs text-tertiary">
{lead.createdAt
? formatShortDate(lead.createdAt)
: '\u2014'}
</span>
<span className="text-xs text-tertiary">{lead.createdAt ? formatShortDate(lead.createdAt) : "\u2014"}</span>
</Table.Cell>
<Table.Cell />
<Table.Cell />
@@ -208,10 +174,7 @@ export const LeadTable = ({
<Table.Row
key={row.id}
id={row.id}
className={cx(
isSpamRow && !isSelected && 'bg-warning-primary',
isSelected && 'bg-brand-primary',
)}
className={cx(isSpamRow && !isSelected && "bg-warning-primary", isSelected && "bg-brand-primary")}
>
<Table.Cell>
<span className="font-semibold text-primary">{phone}</span>
@@ -228,7 +191,7 @@ export const LeadTable = ({
{lead.utmCampaign}
</Badge>
) : (
<span className="text-tertiary">{'\u2014'}</span>
<span className="text-tertiary">{"\u2014"}</span>
)}
</Table.Cell>
<Table.Cell>
@@ -237,50 +200,26 @@ export const LeadTable = ({
Ad
</Badge>
) : (
<span className="text-tertiary">{'\u2014'}</span>
<span className="text-tertiary">{"\u2014"}</span>
)}
</Table.Cell>
<Table.Cell>
{lead.leadSource ? (
<SourceTag source={lead.leadSource} />
) : (
<span className="text-tertiary">{'\u2014'}</span>
)}
{lead.leadSource ? <SourceTag source={lead.leadSource} /> : <span className="text-tertiary">{"\u2014"}</span>}
</Table.Cell>
<Table.Cell>
<span className="text-tertiary">
{lead.firstContactedAt
? formatShortDate(lead.firstContactedAt)
: '\u2014'}
</span>
<span className="text-tertiary">{lead.firstContactedAt ? formatShortDate(lead.firstContactedAt) : "\u2014"}</span>
</Table.Cell>
<Table.Cell>
<span className="text-tertiary">
{lead.lastContactedAt
? formatShortDate(lead.lastContactedAt)
: '\u2014'}
</span>
<span className="text-tertiary">{lead.lastContactedAt ? formatShortDate(lead.lastContactedAt) : "\u2014"}</span>
</Table.Cell>
<Table.Cell>
{lead.leadStatus ? (
<LeadStatusBadge status={lead.leadStatus} />
) : (
<span className="text-tertiary">{'\u2014'}</span>
)}
{lead.leadStatus ? <LeadStatusBadge status={lead.leadStatus} /> : <span className="text-tertiary">{"\u2014"}</span>}
</Table.Cell>
<Table.Cell>
{lead.createdAt ? (
<AgeIndicator dateStr={lead.createdAt} />
) : (
<span className="text-tertiary">{'\u2014'}</span>
)}
{lead.createdAt ? <AgeIndicator dateStr={lead.createdAt} /> : <span className="text-tertiary">{"\u2014"}</span>}
</Table.Cell>
<Table.Cell>
{lead.spamScore != null ? (
<SpamDisplay score={lead.spamScore} />
) : (
<span className="text-tertiary">0%</span>
)}
{lead.spamScore != null ? <SpamDisplay score={lead.spamScore} /> : <span className="text-tertiary">0%</span>}
</Table.Cell>
<Table.Cell>
{isDup ? (
@@ -292,7 +231,7 @@ export const LeadTable = ({
}}
className="cursor-pointer border-none bg-transparent text-xs font-semibold text-warning-primary hover:text-warning-primary"
>
1 {isExpanded ? '\u25B4' : '\u25BE'}
1 {isExpanded ? "\u25B4" : "\u25BE"}
</button>
) : (
<span className="text-tertiary">0</span>

View File

@@ -1,5 +1,5 @@
import { cx } from '@/utils/cx';
import type { Lead, LeadSource } from '@/types/entities';
import type { Lead, LeadSource } from "@/types/entities";
import { cx } from "@/utils/cx";
interface SourceGridProps {
leads: Lead[];
@@ -19,64 +19,63 @@ type SourceConfig = {
const sourceConfigs: SourceConfig[] = [
{
source: 'FACEBOOK_AD',
label: 'Facebook Ads',
icon: 'f',
iconBg: 'bg-utility-blue-50',
iconText: 'text-utility-blue-700',
countColor: 'text-utility-blue-700',
delta: '+4',
source: "FACEBOOK_AD",
label: "Facebook Ads",
icon: "f",
iconBg: "bg-utility-blue-50",
iconText: "text-utility-blue-700",
countColor: "text-utility-blue-700",
delta: "+4",
},
{
source: 'GOOGLE_AD',
label: 'Google Ads',
icon: 'G',
iconBg: 'bg-utility-success-50',
iconText: 'text-utility-success-700',
countColor: 'text-utility-success-700',
delta: '+2',
source: "GOOGLE_AD",
label: "Google Ads",
icon: "G",
iconBg: "bg-utility-success-50",
iconText: "text-utility-success-700",
countColor: "text-utility-success-700",
delta: "+2",
},
{
source: 'INSTAGRAM',
label: 'Instagram',
icon: '@',
iconBg: 'bg-utility-pink-50',
iconText: 'text-utility-pink-700',
countColor: 'text-utility-pink-700',
delta: '+1',
source: "INSTAGRAM",
label: "Instagram",
icon: "@",
iconBg: "bg-utility-pink-50",
iconText: "text-utility-pink-700",
countColor: "text-utility-pink-700",
delta: "+1",
},
{
source: 'GOOGLE_MY_BUSINESS',
label: 'Google My Business',
icon: 'G',
iconBg: 'bg-utility-blue-light-50',
iconText: 'text-utility-blue-light-700',
countColor: 'text-utility-blue-light-700',
delta: '+3',
source: "GOOGLE_MY_BUSINESS",
label: "Google My Business",
icon: "G",
iconBg: "bg-utility-blue-light-50",
iconText: "text-utility-blue-light-700",
countColor: "text-utility-blue-light-700",
delta: "+3",
},
{
source: 'REFERRAL',
label: 'Referrals',
icon: 'R',
iconBg: 'bg-utility-purple-50',
iconText: 'text-utility-purple-700',
countColor: 'text-utility-purple-700',
delta: '+2',
source: "REFERRAL",
label: "Referrals",
icon: "R",
iconBg: "bg-utility-purple-50",
iconText: "text-utility-purple-700",
countColor: "text-utility-purple-700",
delta: "+2",
},
{
source: 'WALK_IN',
label: 'Walk-ins',
icon: 'W',
iconBg: 'bg-utility-orange-50',
iconText: 'text-utility-orange-700',
countColor: 'text-utility-orange-700',
delta: '0',
source: "WALK_IN",
label: "Walk-ins",
icon: "W",
iconBg: "bg-utility-orange-50",
iconText: "text-utility-orange-700",
countColor: "text-utility-orange-700",
delta: "0",
},
];
export const SourceGrid = ({ leads, onSourceFilter, activeSource }: SourceGridProps) => {
const countBySource = (source: LeadSource): number =>
leads.filter((l) => l.leadSource === source).length;
const countBySource = (source: LeadSource): number => leads.filter((l) => l.leadSource === source).length;
const handleClick = (source: LeadSource) => {
if (activeSource === source) {
@@ -98,37 +97,25 @@ export const SourceGrid = ({ leads, onSourceFilter, activeSource }: SourceGridPr
type="button"
onClick={() => handleClick(config.source)}
className={cx(
'cursor-pointer rounded-xl border border-secondary bg-primary p-4 text-left transition hover:shadow-md',
isActive && 'ring-2 ring-brand',
"cursor-pointer rounded-xl border border-secondary bg-primary p-4 text-left transition hover:shadow-md",
isActive && "ring-2 ring-brand",
)}
>
<div className="mb-2 flex items-center gap-2">
<span
className={cx(
'flex size-6 items-center justify-center rounded text-xs font-bold',
config.iconBg,
config.iconText,
)}
>
<span className={cx("flex size-6 items-center justify-center rounded text-xs font-bold", config.iconBg, config.iconText)}>
{config.icon}
</span>
<span className="text-xs text-quaternary">{config.label}</span>
</div>
<div className="flex items-baseline gap-2">
<span className={cx('text-xl font-bold', config.countColor)}>
{count}
</span>
<span className={cx("text-xl font-bold", config.countColor)}>{count}</span>
<span
className={cx(
'text-xs',
config.delta.startsWith('+')
? 'text-success-primary'
: config.delta === '0'
? 'text-quaternary'
: 'text-error-primary',
"text-xs",
config.delta.startsWith("+") ? "text-success-primary" : config.delta === "0" ? "text-quaternary" : "text-error-primary",
)}
>
{config.delta === '0' ? 'same' : config.delta}
{config.delta === "0" ? "same" : config.delta}
</span>
</div>
</button>

View File

@@ -1,6 +1,6 @@
import type { FC } from "react";
import { faBookBookmark, faCirclePlay, faFileCode, faLifeRing, faStars } from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faBookBookmark, faFileCode, faLifeRing, faCirclePlay, faStars } from "@fortawesome/pro-duotone-svg-icons";
import { NavMenuItemLink } from "./base-components/nav-menu-item";
const BookClosed: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faBookBookmark} className={className} />;

View File

@@ -1,7 +1,7 @@
import type { ReactNode } from "react";
import { useRef, useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faChevronDown } from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Button as AriaButton, Dialog as AriaDialog, DialogTrigger as AriaDialogTrigger, Popover as AriaPopover } from "react-aria-components";
import { Button } from "@/components/base/buttons/button";
import { UntitledLogo } from "@/components/foundations/logo/untitledui-logo";
@@ -130,7 +130,10 @@ export const Header = ({ items = headerNavItems, isFullWidth, isFloating, classN
<AriaButton className="flex cursor-pointer items-center gap-0.5 rounded-lg px-1.5 py-1 text-md font-semibold text-secondary outline-focus-ring transition duration-100 ease-linear hover:text-secondary_hover focus-visible:outline-2 focus-visible:outline-offset-2">
<span className="px-0.5">{navItem.label}</span>
<FontAwesomeIcon icon={faChevronDown} className="size-4 rotate-0 text-fg-quaternary transition duration-100 ease-linear in-aria-expanded:-rotate-180" />
<FontAwesomeIcon
icon={faChevronDown}
className="size-4 rotate-0 text-fg-quaternary transition duration-100 ease-linear in-aria-expanded:-rotate-180"
/>
</AriaButton>
<AriaPopover

Some files were not shown because too many files have changed in this diff Show More