Files
helix-engage/src/components/layout/app-shell.tsx
saridsa2 f57fbc1f24 feat(onboarding/phase-6): setup wizard polish, seed script alignment, doctor visit slots
- Setup wizard: 3-pane layout with right-side live previews, resume
  banner, edit/copy icons on team step, AI prompt configuration
- Forms: employee-create replaces invite-member (no email invites),
  clinic form with address/hours/payment, doctor form with visit slots
- Seed script: aligned to current SDK schema — doctors created as
  workspace members (HelixEngage Manager role), visitingHours replaced
  by doctorVisitSlot entity, clinics seeded, portalUserId linked
  dynamically, SUB/ORIGIN/GQL configurable via env vars
- Pages: clinics + doctors CRUD updated for new schema, team settings
  with temp password + role assignment
- New components: time-picker, day-selector, wizard-right-panes,
  wizard-layout-context, resume-setup-banner
- Removed: invite-member-form (replaced by employee-create-form per
  no-email-invites rule)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 08:37:34 +05:30

161 lines
7.5 KiB
TypeScript

import { useEffect, useState, useCallback, type ReactNode } from 'react';
import { useLocation } from 'react-router';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faWifi, faWifiSlash } from '@fortawesome/pro-duotone-svg-icons';
import { Sidebar } from './sidebar';
import { SipProvider } from '@/providers/sip-provider';
import { useSip } from '@/providers/sip-provider';
import { CallWidget } from '@/components/call-desk/call-widget';
import { MaintOtpModal } from '@/components/modals/maint-otp-modal';
import { AgentStatusToggle } from '@/components/call-desk/agent-status-toggle';
import { NotificationBell } from './notification-bell';
import { ResumeSetupBanner } from '@/components/setup/resume-setup-banner';
import { Badge } from '@/components/base/badges/badges';
import { useAuth } from '@/providers/auth-provider';
import { useData } from '@/providers/data-provider';
import { useMaintShortcuts } from '@/hooks/use-maint-shortcuts';
import { useNetworkStatus } from '@/hooks/use-network-status';
import { apiClient } from '@/lib/api-client';
import { cx } from '@/utils/cx';
interface AppShellProps {
children: ReactNode;
}
export const AppShell = ({ children }: AppShellProps) => {
const { pathname } = useLocation();
const { isCCAgent, isAdmin } = useAuth();
const { isOpen, activeAction, close } = useMaintShortcuts();
const { connectionStatus, isRegistered } = useSip();
const networkQuality = useNetworkStatus();
const hasAgentConfig = !!localStorage.getItem('helix_agent_config');
// Pre-step state for actions that need user input before OTP
const [preStepPayload, setPreStepPayload] = useState<Record<string, any> | undefined>(undefined);
const { campaigns, leads, refresh } = useData();
const leadsPerCampaign = useCallback((campaignId: string) =>
leads.filter(l => l.campaignId === campaignId).length,
[leads],
);
// Client-side handler for clearing campaign leads
const clearCampaignLeadsHandler = useCallback(async (payload: any) => {
const campaignId = payload?.campaignId;
if (!campaignId) return { status: 'error', message: 'No campaign selected' };
const campaignLeads = leads.filter(l => l.campaignId === campaignId);
if (campaignLeads.length === 0) return { status: 'ok', message: 'No leads to clear' };
let deleted = 0;
for (const lead of campaignLeads) {
try {
await apiClient.graphql(`mutation($id: UUID!) { deleteLead(id: $id) { id } }`, { id: lead.id }, { silent: true });
deleted++;
} catch { /* continue */ }
}
refresh();
return { status: 'ok', message: `${deleted} leads deleted` };
}, [leads, refresh]);
// Attach client-side handler to the action when it's clear-campaign-leads
const enrichedAction = activeAction ? {
...activeAction,
...(activeAction.endpoint === 'clear-campaign-leads' ? { clientSideHandler: clearCampaignLeadsHandler } : {}),
} : null;
// Reset pre-step when modal closes
useEffect(() => {
if (!isOpen) setPreStepPayload(undefined);
}, [isOpen]);
// Pre-step content for campaign selection
const campaignPreStep = activeAction?.needsPreStep && activeAction.endpoint === 'clear-campaign-leads' ? (
<div className="space-y-2 max-h-48 overflow-y-auto">
{campaigns.map(c => {
const count = leadsPerCampaign(c.id);
const isSelected = preStepPayload?.campaignId === c.id;
return (
<button
key={c.id}
onClick={() => setPreStepPayload({ campaignId: c.id })}
className={cx(
'flex w-full items-center justify-between rounded-lg border-2 px-3 py-2.5 text-left transition duration-100 ease-linear',
isSelected ? 'border-error bg-error-primary' : 'border-secondary hover:border-error',
)}
>
<span className="text-sm font-medium text-primary">{c.campaignName ?? 'Untitled'}</span>
<Badge size="sm" color={count > 0 ? 'error' : 'gray'} type="pill-color">{count} leads</Badge>
</button>
);
})}
</div>
) : undefined;
// Heartbeat: keep agent session alive in Redis (CC agents only)
useEffect(() => {
if (!isCCAgent) return;
const beat = () => {
const token = localStorage.getItem('helix_access_token');
if (token) {
const apiUrl = import.meta.env.VITE_API_URL ?? 'http://localhost:4100';
fetch(`${apiUrl}/auth/heartbeat`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
}).catch(() => {});
}
};
const interval = setInterval(beat, 5 * 60 * 1000);
return () => clearInterval(interval);
}, [isCCAgent]);
return (
<SipProvider>
<div className="flex h-screen bg-primary">
<Sidebar activeUrl={pathname} />
<div className="flex flex-1 flex-col overflow-hidden">
{/* Persistent top bar — visible on all pages */}
{(hasAgentConfig || isAdmin) && (
<div className="flex shrink-0 items-center justify-end gap-2 border-b border-secondary px-4 py-2">
{isAdmin && <NotificationBell />}
{hasAgentConfig && (
<>
<div className={cx(
'flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium',
networkQuality === 'good'
? 'bg-success-primary text-success-primary'
: networkQuality === 'offline'
? 'bg-error-secondary text-error-primary'
: 'bg-warning-secondary text-warning-primary',
)}>
<FontAwesomeIcon
icon={networkQuality === 'offline' ? faWifiSlash : faWifi}
className="size-3"
/>
{networkQuality === 'good' ? 'Connected' : networkQuality === 'offline' ? 'No connection' : 'Unstable'}
</div>
<AgentStatusToggle isRegistered={isRegistered} connectionStatus={connectionStatus} />
</>
)}
</div>
)}
<ResumeSetupBanner />
<main className="flex flex-1 flex-col overflow-hidden">{children}</main>
</div>
{isCCAgent && pathname !== '/' && pathname !== '/call-desk' && <CallWidget />}
</div>
<MaintOtpModal
isOpen={isOpen}
onOpenChange={(open) => !open && close()}
action={enrichedAction}
preStepContent={campaignPreStep}
preStepPayload={preStepPayload}
preStepReady={!activeAction?.needsPreStep || !!preStepPayload}
/>
</SipProvider>
);
};