mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-11 10:23:27 +00:00
Linting and Formatting
This commit is contained in:
38
.claudeignore
Normal file
38
.claudeignore
Normal 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
1
.husky/pre-commit
Normal file
@@ -0,0 +1 @@
|
||||
npx lint-staged
|
||||
@@ -5,7 +5,10 @@
|
||||
"@trivago/prettier-plugin-sort-imports",
|
||||
"prettier-plugin-tailwindcss"
|
||||
],
|
||||
"tailwindFunctions": ["sortCx", "cx"],
|
||||
"tailwindFunctions": [
|
||||
"sortCx",
|
||||
"cx"
|
||||
],
|
||||
"importOrder": [
|
||||
"^react$",
|
||||
"^react-dom$",
|
||||
@@ -16,4 +19,4 @@
|
||||
"importOrderSeparation": false,
|
||||
"importOrderSortSpecifiers": true,
|
||||
"tailwindStylesheet": "./src/styles/globals.css"
|
||||
}
|
||||
}
|
||||
24
README.md
24
README.md
@@ -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
40
eslint.config.mjs
Normal 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
13166
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
130
package.json
130
package.json
@@ -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
4641
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}%` }}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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())];
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -96,7 +96,7 @@ export const Tooltip = ({
|
||||
);
|
||||
};
|
||||
|
||||
interface TooltipTriggerProps extends AriaButtonProps {}
|
||||
type TooltipTriggerProps = AriaButtonProps;
|
||||
|
||||
export const TooltipTrigger = ({ children, className, ...buttonProps }: TooltipTriggerProps) => {
|
||||
return (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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">·</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">·</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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 />;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 > 5 days
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-error-primary opacity-80">
|
||||
These leads haven'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 > 5 days</p>
|
||||
<p className="mt-1 text-xs text-error-primary opacity-80">These leads haven't been contacted and are at risk of going cold.</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />;
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user