feat: Add HQ section components for Headquarters page
Implements the five section components consumed by the HQ page: - HQWaitingForInputSection, HQNeedsReviewSection, HQNeedsApprovalSection, HQBlockedSection, HQEmptyState with typed props from RouterOutputs. - Shared types.ts defines the 5 item types from getHeadquartersDashboard. - Adds RouterOutputs export to trpc.ts via inferRouterOutputs<AppRouter>. - 22 unit tests verify rendering, navigation CTAs, truncation, and edge cases. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
51
apps/web/src/components/hq/HQBlockedSection.tsx
Normal file
51
apps/web/src/components/hq/HQBlockedSection.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { useNavigate } from '@tanstack/react-router'
|
||||||
|
import { Card } from '@/components/ui/card'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { formatRelativeTime } from '@/lib/utils'
|
||||||
|
import type { BlockedPhaseItem } from './types'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
items: BlockedPhaseItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HQBlockedSection({ items }: Props) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h2 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
|
||||||
|
Blocked
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{items.map((item) => (
|
||||||
|
<Card key={item.phaseId} className="p-4 flex items-center justify-between gap-4">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-1 font-semibold text-sm">
|
||||||
|
<span>{item.initiativeName} › {item.phaseName}</span>
|
||||||
|
<Badge variant="destructive" className="ml-2">Blocked</Badge>
|
||||||
|
</div>
|
||||||
|
{item.lastMessage && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">{item.lastMessage}</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-muted-foreground">{formatRelativeTime(item.since)}</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
navigate({
|
||||||
|
to: '/initiatives/$id',
|
||||||
|
params: { id: item.initiativeId },
|
||||||
|
search: { tab: 'execution' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
View
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
15
apps/web/src/components/hq/HQEmptyState.tsx
Normal file
15
apps/web/src/components/hq/HQEmptyState.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { Link } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
export function HQEmptyState() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-24 text-center gap-3">
|
||||||
|
<p className="text-lg font-semibold">All clear.</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No initiatives need your attention right now.
|
||||||
|
</p>
|
||||||
|
<Link to="/initiatives" className="text-sm text-primary hover:underline">
|
||||||
|
Browse active work
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
50
apps/web/src/components/hq/HQNeedsApprovalSection.tsx
Normal file
50
apps/web/src/components/hq/HQNeedsApprovalSection.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { useNavigate } from '@tanstack/react-router'
|
||||||
|
import { Card } from '@/components/ui/card'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { formatRelativeTime } from '@/lib/utils'
|
||||||
|
import type { PlanningInitiativeItem } from './types'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
items: PlanningInitiativeItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HQNeedsApprovalSection({ items }: Props) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h2 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
|
||||||
|
Needs Approval to Continue
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{items.map((item) => {
|
||||||
|
const s = item.pendingPhaseCount === 1 ? '' : 's'
|
||||||
|
return (
|
||||||
|
<Card key={item.initiativeId} className="p-4 flex items-center justify-between gap-4">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="font-semibold text-sm">{item.initiativeName}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Plan ready — {item.pendingPhaseCount} phase{s} awaiting approval
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{formatRelativeTime(item.since)}</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
navigate({
|
||||||
|
to: '/initiatives/$id',
|
||||||
|
params: { id: item.initiativeId },
|
||||||
|
search: { tab: 'plan' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Review Plan
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
68
apps/web/src/components/hq/HQNeedsReviewSection.tsx
Normal file
68
apps/web/src/components/hq/HQNeedsReviewSection.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { useNavigate } from '@tanstack/react-router'
|
||||||
|
import { Card } from '@/components/ui/card'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { formatRelativeTime } from '@/lib/utils'
|
||||||
|
import type { PendingReviewInitiativeItem, PendingReviewPhaseItem } from './types'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
initiatives: PendingReviewInitiativeItem[]
|
||||||
|
phases: PendingReviewPhaseItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HQNeedsReviewSection({ initiatives, phases }: Props) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h2 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
|
||||||
|
Needs Review
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{initiatives.map((item) => (
|
||||||
|
<Card key={item.initiativeId} className="p-4 flex items-center justify-between gap-4">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="font-semibold text-sm">{item.initiativeName}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">Content ready for review</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{formatRelativeTime(item.since)}</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
navigate({
|
||||||
|
to: '/initiatives/$id',
|
||||||
|
params: { id: item.initiativeId },
|
||||||
|
search: { tab: 'review' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Review
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
{phases.map((item) => (
|
||||||
|
<Card key={item.phaseId} className="p-4 flex items-center justify-between gap-4">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="font-semibold text-sm">{item.initiativeName} › {item.phaseName}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">Phase execution complete — review diff</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{formatRelativeTime(item.since)}</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
navigate({
|
||||||
|
to: '/initiatives/$id',
|
||||||
|
params: { id: item.initiativeId },
|
||||||
|
search: { tab: 'review' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Review Diff
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
376
apps/web/src/components/hq/HQSections.test.tsx
Normal file
376
apps/web/src/components/hq/HQSections.test.tsx
Normal file
@@ -0,0 +1,376 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import '@testing-library/jest-dom/vitest'
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react'
|
||||||
|
import { vi, describe, it, expect, beforeEach } from 'vitest'
|
||||||
|
|
||||||
|
const mockNavigate = vi.fn()
|
||||||
|
vi.mock('@tanstack/react-router', () => ({
|
||||||
|
useNavigate: () => mockNavigate,
|
||||||
|
Link: ({ to, children, className }: { to: string; children: React.ReactNode; className?: string }) => (
|
||||||
|
<a href={to} className={className}>{children}</a>
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Mock formatRelativeTime to return a predictable string
|
||||||
|
vi.mock('@/lib/utils', () => ({
|
||||||
|
cn: (...classes: string[]) => classes.filter(Boolean).join(' '),
|
||||||
|
formatRelativeTime: () => '5 minutes ago',
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { HQWaitingForInputSection } from './HQWaitingForInputSection'
|
||||||
|
import { HQNeedsReviewSection } from './HQNeedsReviewSection'
|
||||||
|
import { HQNeedsApprovalSection } from './HQNeedsApprovalSection'
|
||||||
|
import { HQBlockedSection } from './HQBlockedSection'
|
||||||
|
import { HQEmptyState } from './HQEmptyState'
|
||||||
|
|
||||||
|
const since = new Date(Date.now() - 5 * 60 * 1000).toISOString()
|
||||||
|
|
||||||
|
// ─── HQWaitingForInputSection ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('HQWaitingForInputSection', () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks())
|
||||||
|
|
||||||
|
it('renders section heading "Waiting for Input"', () => {
|
||||||
|
render(<HQWaitingForInputSection items={[]} />)
|
||||||
|
expect(screen.getByText('Waiting for Input')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders agent name and truncated question text', () => {
|
||||||
|
const longQuestion = 'A'.repeat(150)
|
||||||
|
render(
|
||||||
|
<HQWaitingForInputSection
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
agentId: 'a1',
|
||||||
|
agentName: 'Agent Alpha',
|
||||||
|
initiativeId: null,
|
||||||
|
initiativeName: null,
|
||||||
|
questionText: longQuestion,
|
||||||
|
waitingSince: since,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
expect(screen.getByText('Agent Alpha')).toBeInTheDocument()
|
||||||
|
// Truncated to 120 chars + ellipsis
|
||||||
|
const truncated = 'A'.repeat(120) + '…'
|
||||||
|
expect(screen.getByText(truncated)).toBeInTheDocument()
|
||||||
|
// Full text in tooltip content (forceMount renders it into DOM)
|
||||||
|
expect(screen.getAllByText(longQuestion).length).toBeGreaterThanOrEqual(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders "waiting X" relative time', () => {
|
||||||
|
render(
|
||||||
|
<HQWaitingForInputSection
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
agentId: 'a1',
|
||||||
|
agentName: 'Agent Alpha',
|
||||||
|
initiativeId: null,
|
||||||
|
initiativeName: null,
|
||||||
|
questionText: 'What should I do?',
|
||||||
|
waitingSince: since,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
expect(screen.getByText('waiting 5 minutes ago')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clicking "Answer" calls navigate to /inbox', () => {
|
||||||
|
render(
|
||||||
|
<HQWaitingForInputSection
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
agentId: 'a1',
|
||||||
|
agentName: 'Agent Alpha',
|
||||||
|
initiativeId: null,
|
||||||
|
initiativeName: null,
|
||||||
|
questionText: 'Question?',
|
||||||
|
waitingSince: since,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /answer/i }))
|
||||||
|
expect(mockNavigate).toHaveBeenCalledWith({ to: '/inbox' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows initiative name when non-null', () => {
|
||||||
|
render(
|
||||||
|
<HQWaitingForInputSection
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
agentId: 'a1',
|
||||||
|
agentName: 'Agent Alpha',
|
||||||
|
initiativeId: 'init-1',
|
||||||
|
initiativeName: 'My Initiative',
|
||||||
|
questionText: 'Question?',
|
||||||
|
waitingSince: since,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
expect(screen.getByText(/My Initiative/)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides initiative name when null', () => {
|
||||||
|
render(
|
||||||
|
<HQWaitingForInputSection
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
agentId: 'a1',
|
||||||
|
agentName: 'Agent Alpha',
|
||||||
|
initiativeId: null,
|
||||||
|
initiativeName: null,
|
||||||
|
questionText: 'Question?',
|
||||||
|
waitingSince: since,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
// No separator dot should appear since initiative is null
|
||||||
|
expect(screen.queryByText(/·/)).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ─── HQNeedsReviewSection ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('HQNeedsReviewSection', () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks())
|
||||||
|
|
||||||
|
it('renders section heading "Needs Review"', () => {
|
||||||
|
render(<HQNeedsReviewSection initiatives={[]} phases={[]} />)
|
||||||
|
expect(screen.getByText('Needs Review')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('2a: shows initiative name, "Content ready for review", "Review" CTA navigates correctly', () => {
|
||||||
|
render(
|
||||||
|
<HQNeedsReviewSection
|
||||||
|
initiatives={[
|
||||||
|
{ initiativeId: 'init-1', initiativeName: 'Init One', since },
|
||||||
|
]}
|
||||||
|
phases={[]}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
expect(screen.getByText('Init One')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Content ready for review')).toBeInTheDocument()
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /^review$/i }))
|
||||||
|
expect(mockNavigate).toHaveBeenCalledWith({
|
||||||
|
to: '/initiatives/$id',
|
||||||
|
params: { id: 'init-1' },
|
||||||
|
search: { tab: 'review' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('2b: shows initiative › phase, "Phase execution complete — review diff", "Review Diff" navigates correctly', () => {
|
||||||
|
render(
|
||||||
|
<HQNeedsReviewSection
|
||||||
|
initiatives={[]}
|
||||||
|
phases={[
|
||||||
|
{
|
||||||
|
phaseId: 'ph-1',
|
||||||
|
phaseName: 'Phase One',
|
||||||
|
initiativeId: 'init-1',
|
||||||
|
initiativeName: 'Init One',
|
||||||
|
since,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
expect(screen.getByText('Init One › Phase One')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Phase execution complete — review diff')).toBeInTheDocument()
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /review diff/i }))
|
||||||
|
expect(mockNavigate).toHaveBeenCalledWith({
|
||||||
|
to: '/initiatives/$id',
|
||||||
|
params: { id: 'init-1' },
|
||||||
|
search: { tab: 'review' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('when only initiatives provided, only 2a cards render', () => {
|
||||||
|
render(
|
||||||
|
<HQNeedsReviewSection
|
||||||
|
initiatives={[{ initiativeId: 'init-1', initiativeName: 'Init One', since }]}
|
||||||
|
phases={[]}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
expect(screen.getByText('Content ready for review')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('Phase execution complete — review diff')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('when only phases provided, only 2b cards render', () => {
|
||||||
|
render(
|
||||||
|
<HQNeedsReviewSection
|
||||||
|
initiatives={[]}
|
||||||
|
phases={[
|
||||||
|
{
|
||||||
|
phaseId: 'ph-1',
|
||||||
|
phaseName: 'Phase One',
|
||||||
|
initiativeId: 'init-1',
|
||||||
|
initiativeName: 'Init One',
|
||||||
|
since,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
expect(screen.getByText('Phase execution complete — review diff')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('Content ready for review')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ─── HQNeedsApprovalSection ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('HQNeedsApprovalSection', () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks())
|
||||||
|
|
||||||
|
it('renders "Needs Approval to Continue" heading', () => {
|
||||||
|
render(<HQNeedsApprovalSection items={[]} />)
|
||||||
|
expect(screen.getByText('Needs Approval to Continue')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows singular phase count: "1 phase awaiting approval"', () => {
|
||||||
|
render(
|
||||||
|
<HQNeedsApprovalSection
|
||||||
|
items={[
|
||||||
|
{ initiativeId: 'init-1', initiativeName: 'Init One', pendingPhaseCount: 1, since },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
expect(screen.getByText('Plan ready — 1 phase awaiting approval')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows plural phase count: "3 phases awaiting approval"', () => {
|
||||||
|
render(
|
||||||
|
<HQNeedsApprovalSection
|
||||||
|
items={[
|
||||||
|
{ initiativeId: 'init-1', initiativeName: 'Init One', pendingPhaseCount: 3, since },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
expect(screen.getByText('Plan ready — 3 phases awaiting approval')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('"Review Plan" CTA navigates to /initiatives/$id?tab=plan', () => {
|
||||||
|
render(
|
||||||
|
<HQNeedsApprovalSection
|
||||||
|
items={[
|
||||||
|
{ initiativeId: 'init-1', initiativeName: 'Init One', pendingPhaseCount: 2, since },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /review plan/i }))
|
||||||
|
expect(mockNavigate).toHaveBeenCalledWith({
|
||||||
|
to: '/initiatives/$id',
|
||||||
|
params: { id: 'init-1' },
|
||||||
|
search: { tab: 'plan' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ─── HQBlockedSection ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('HQBlockedSection', () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks())
|
||||||
|
|
||||||
|
it('renders "Blocked" heading', () => {
|
||||||
|
render(<HQBlockedSection items={[]} />)
|
||||||
|
expect(screen.getByText('Blocked')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows initiative › phase with "Blocked" badge', () => {
|
||||||
|
render(
|
||||||
|
<HQBlockedSection
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
phaseId: 'ph-1',
|
||||||
|
phaseName: 'Phase One',
|
||||||
|
initiativeId: 'init-1',
|
||||||
|
initiativeName: 'Init One',
|
||||||
|
lastMessage: null,
|
||||||
|
since,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
expect(screen.getByText('Init One › Phase One')).toBeInTheDocument()
|
||||||
|
// The "Blocked" badge - there will be one in the heading and one in the card
|
||||||
|
const badges = screen.getAllByText('Blocked')
|
||||||
|
expect(badges.length).toBeGreaterThanOrEqual(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows lastMessage when non-null', () => {
|
||||||
|
render(
|
||||||
|
<HQBlockedSection
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
phaseId: 'ph-1',
|
||||||
|
phaseName: 'Phase One',
|
||||||
|
initiativeId: 'init-1',
|
||||||
|
initiativeName: 'Init One',
|
||||||
|
lastMessage: 'Something went wrong.',
|
||||||
|
since,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
expect(screen.getByText('Something went wrong.')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('omits lastMessage when null', () => {
|
||||||
|
render(
|
||||||
|
<HQBlockedSection
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
phaseId: 'ph-1',
|
||||||
|
phaseName: 'Phase One',
|
||||||
|
initiativeId: 'init-1',
|
||||||
|
initiativeName: 'Init One',
|
||||||
|
lastMessage: null,
|
||||||
|
since,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
expect(screen.queryByText('Something went wrong.')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('"View" CTA navigates to /initiatives/$id?tab=execution', () => {
|
||||||
|
render(
|
||||||
|
<HQBlockedSection
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
phaseId: 'ph-1',
|
||||||
|
phaseName: 'Phase One',
|
||||||
|
initiativeId: 'init-1',
|
||||||
|
initiativeName: 'Init One',
|
||||||
|
lastMessage: null,
|
||||||
|
since,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /view/i }))
|
||||||
|
expect(mockNavigate).toHaveBeenCalledWith({
|
||||||
|
to: '/initiatives/$id',
|
||||||
|
params: { id: 'init-1' },
|
||||||
|
search: { tab: 'execution' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ─── HQEmptyState ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('HQEmptyState', () => {
|
||||||
|
it('renders "All clear." text', () => {
|
||||||
|
render(<HQEmptyState />)
|
||||||
|
expect(screen.getByText('All clear.')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders "Browse active work" link pointing to /initiatives', () => {
|
||||||
|
render(<HQEmptyState />)
|
||||||
|
const link = screen.getByRole('link', { name: /browse active work/i })
|
||||||
|
expect(link).toBeInTheDocument()
|
||||||
|
expect(link).toHaveAttribute('href', '/initiatives')
|
||||||
|
})
|
||||||
|
})
|
||||||
65
apps/web/src/components/hq/HQWaitingForInputSection.tsx
Normal file
65
apps/web/src/components/hq/HQWaitingForInputSection.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { useNavigate } from '@tanstack/react-router'
|
||||||
|
import { Card } from '@/components/ui/card'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipTrigger,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
} from '@/components/ui/tooltip'
|
||||||
|
import { formatRelativeTime } from '@/lib/utils'
|
||||||
|
import type { WaitingForInputItem } from './types'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
items: WaitingForInputItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HQWaitingForInputSection({ items }: Props) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h2 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
|
||||||
|
Waiting for Input
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{items.map((item) => {
|
||||||
|
const truncated =
|
||||||
|
item.questionText.slice(0, 120) +
|
||||||
|
(item.questionText.length > 120 ? '…' : '')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card key={item.agentId} className="p-4 flex items-center justify-between gap-4">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="font-bold text-sm">
|
||||||
|
{item.agentName}
|
||||||
|
{item.initiativeName && (
|
||||||
|
<span className="font-normal text-muted-foreground"> · {item.initiativeName}</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<p className="text-sm text-muted-foreground truncate">{truncated}</p>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent forceMount>{item.questionText}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
waiting {formatRelativeTime(item.waitingSince)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => navigate({ to: '/inbox' })}
|
||||||
|
>
|
||||||
|
Answer
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
8
apps/web/src/components/hq/types.ts
Normal file
8
apps/web/src/components/hq/types.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import type { RouterOutputs } from '@/lib/trpc'
|
||||||
|
|
||||||
|
type HQDashboard = RouterOutputs['getHeadquartersDashboard']
|
||||||
|
export type WaitingForInputItem = HQDashboard['waitingForInput'][number]
|
||||||
|
export type PendingReviewInitiativeItem = HQDashboard['pendingReviewInitiatives'][number]
|
||||||
|
export type PendingReviewPhaseItem = HQDashboard['pendingReviewPhases'][number]
|
||||||
|
export type PlanningInitiativeItem = HQDashboard['planningInitiatives'][number]
|
||||||
|
export type BlockedPhaseItem = HQDashboard['blockedPhases'][number]
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import { createTRPCReact } from '@trpc/react-query';
|
import { createTRPCReact } from '@trpc/react-query';
|
||||||
import { httpBatchLink, splitLink, httpSubscriptionLink } from '@trpc/client';
|
import { httpBatchLink, splitLink, httpSubscriptionLink } from '@trpc/client';
|
||||||
import type { AppRouter } from '@codewalk-district/shared';
|
import type { AppRouter } from '@codewalk-district/shared';
|
||||||
|
import type { inferRouterOutputs } from '@trpc/server';
|
||||||
|
|
||||||
export const trpc = createTRPCReact<AppRouter>();
|
export const trpc = createTRPCReact<AppRouter>();
|
||||||
|
export type RouterOutputs = inferRouterOutputs<AppRouter>;
|
||||||
|
|
||||||
export function createTRPCClient() {
|
export function createTRPCClient() {
|
||||||
return trpc.createClient({
|
return trpc.createClient({
|
||||||
|
|||||||
Reference in New Issue
Block a user