Skip to content

Commit f1d8be1

Browse files
authored
Merge pull request #2584 from trycompai/tofik/eng-108-policies-tailoring-ui
fix(policies): restore AI-tailoring UI on the Policies page (ENG-108)
2 parents ac2a8a8 + 6d2434c commit f1d8be1

File tree

3 files changed

+194
-63
lines changed

3 files changed

+194
-63
lines changed
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
'use client';
2+
3+
import { useRealtimeRun } from '@trigger.dev/react-hooks';
4+
import { useMemo } from 'react';
5+
import type { PolicyTailoringStatus } from '../../all/components/policy-tailoring-context';
6+
7+
export interface PolicyOnboardingItemInfo {
8+
id: string;
9+
name: string;
10+
}
11+
12+
/**
13+
* Subscribe to the onboarding trigger.dev run and derive per-policy tailoring
14+
* status plus overall progress. Mirrors use-onboarding-status in risk/ and
15+
* vendors/ but handles the `policies` → `policy_<id>_status` singular
16+
* conversion correctly (the shared hook's `itemType.slice(0, -1)` would
17+
* produce `policie_`).
18+
*/
19+
export function usePolicyOnboardingStatus(
20+
onboardingRunId: string | null | undefined,
21+
) {
22+
const shouldSubscribe = Boolean(onboardingRunId);
23+
const { run } = useRealtimeRun(shouldSubscribe ? onboardingRunId! : '', {
24+
enabled: shouldSubscribe,
25+
});
26+
27+
const itemStatuses = useMemo<Record<string, PolicyTailoringStatus>>(() => {
28+
if (!run?.metadata) return {};
29+
30+
const meta = run.metadata as Record<string, unknown>;
31+
const itemsInfo =
32+
(meta.policiesInfo as Array<{ id: string; name: string }>) || [];
33+
34+
return itemsInfo.reduce<Record<string, PolicyTailoringStatus>>(
35+
(acc, item) => {
36+
const status = meta[`policy_${item.id}_status`];
37+
if (
38+
status === 'queued' ||
39+
status === 'pending' ||
40+
status === 'processing' ||
41+
status === 'completed'
42+
) {
43+
acc[item.id] = status;
44+
}
45+
return acc;
46+
},
47+
{},
48+
);
49+
}, [run?.metadata]);
50+
51+
const progress = useMemo(() => {
52+
if (!run?.metadata) return null;
53+
54+
const meta = run.metadata as Record<string, unknown>;
55+
const total = typeof meta.policiesTotal === 'number' ? meta.policiesTotal : 0;
56+
const completed =
57+
typeof meta.policiesCompleted === 'number' ? meta.policiesCompleted : 0;
58+
59+
if (total === 0) return null;
60+
return { total, completed };
61+
}, [run?.metadata]);
62+
63+
const itemsInfo = useMemo<PolicyOnboardingItemInfo[]>(() => {
64+
if (!run?.metadata) return [];
65+
const meta = run.metadata as Record<string, unknown>;
66+
return (meta.policiesInfo as Array<{ id: string; name: string }>) || [];
67+
}, [run?.metadata]);
68+
69+
// Active if any item is not yet completed
70+
const hasActiveItems = useMemo(
71+
() =>
72+
Object.values(itemStatuses).some(
73+
(status) => status !== 'completed' && status !== undefined,
74+
),
75+
[itemStatuses],
76+
);
77+
78+
const isRunActive = useMemo(() => {
79+
if (!run) return false;
80+
return ['EXECUTING', 'QUEUED', 'WAITING'].includes(run.status);
81+
}, [run]);
82+
83+
const hasActiveProgress =
84+
progress !== null && progress.completed < progress.total;
85+
const isActive = isRunActive || hasActiveProgress || hasActiveItems;
86+
87+
return {
88+
itemStatuses,
89+
progress,
90+
itemsInfo,
91+
isActive,
92+
isLoading: shouldSubscribe && !run,
93+
runStatus: run?.status,
94+
};
95+
}

apps/app/src/app/(app)/[orgId]/policies/(overview)/page.tsx

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import type { Policy } from '@db';
33
import { PageHeader, PageLayout, Stack } from '@trycompai/design-system';
44
import type { Metadata } from 'next';
55
import { Suspense } from 'react';
6-
import { PolicyTailoringProvider } from '../all/components/policy-tailoring-context';
76
import { PolicyFilters } from '../all/components/PolicyFilters';
87
import { PolicyPageActions } from '../all/components/PolicyPageActions';
98
import { PolicyChartsClient } from './components/PolicyChartsClient';
@@ -21,9 +20,18 @@ interface PoliciesPageProps {
2120
export default async function PoliciesPage({ params }: PoliciesPageProps) {
2221
const { orgId } = await params;
2322

24-
const policiesRes = await serverApi.get<{ data: PolicyWithAssignee[] }>(
25-
'/v1/policies',
26-
);
23+
// ENG-108: fetch the active onboarding run id alongside policies so the
24+
// client-side filters component can subscribe and surface "tailoring your
25+
// policies…" status during first-run AI generation. Mirrors the pattern
26+
// used by the risks and vendors pages.
27+
const [policiesRes, onboardingRes] = await Promise.all([
28+
serverApi.get<{ data: PolicyWithAssignee[] }>('/v1/policies'),
29+
serverApi.get<{
30+
triggerJobId: string | null;
31+
triggerJobCompleted: boolean;
32+
} | null>('/v1/organization/onboarding'),
33+
]);
34+
2735
const policies = Array.isArray(policiesRes.data?.data)
2836
? policiesRes.data.data
2937
: [];
@@ -49,9 +57,10 @@ export default async function PoliciesPage({ params }: PoliciesPageProps) {
4957
<Suspense fallback={<Loading />}>
5058
<PolicyChartsClient organizationId={orgId} initialData={initialOverview} />
5159
</Suspense>
52-
<PolicyTailoringProvider statuses={{}}>
53-
<PolicyFilters policies={policies} />
54-
</PolicyTailoringProvider>
60+
<PolicyFilters
61+
policies={policies}
62+
onboardingRunId={onboardingRes.data?.triggerJobId ?? null}
63+
/>
5564
</Stack>
5665
</PageLayout>
5766
);

apps/app/src/app/(app)/[orgId]/policies/all/components/PolicyFilters.tsx

Lines changed: 83 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,16 @@ import {
1414
Stack,
1515
} from '@trycompai/design-system';
1616
import { Search } from '@trycompai/design-system/icons';
17+
import { Loader2 } from 'lucide-react';
1718
import { useMemo, useState } from 'react';
19+
import { usePolicyOnboardingStatus } from '../../(overview)/hooks/use-policy-onboarding-status';
1820
import { PoliciesTableDS } from './PoliciesTableDS';
21+
import { PolicyTailoringProvider } from './policy-tailoring-context';
1922
import { comparePoliciesByName } from './policy-name-sort';
2023

2124
interface PolicyFiltersProps {
2225
policies: Policy[];
26+
onboardingRunId?: string | null;
2327
}
2428

2529
const STATUS_OPTIONS: { value: PolicyStatus | 'all' | 'archived'; label: string }[] = [
@@ -30,13 +34,21 @@ const STATUS_OPTIONS: { value: PolicyStatus | 'all' | 'archived'; label: string
3034
{ value: 'archived', label: 'Archived' },
3135
];
3236

33-
export function PolicyFilters({ policies }: PolicyFiltersProps) {
37+
export function PolicyFilters({ policies, onboardingRunId }: PolicyFiltersProps) {
3438
const [searchQuery, setSearchQuery] = useState('');
3539
const [statusFilter, setStatusFilter] = useState<PolicyStatus | 'all' | 'archived'>('all');
3640
const [departmentFilter, setDepartmentFilter] = useState<string>('all');
3741
const [sortColumn, setSortColumn] = useState<'name' | 'status' | 'updatedAt'>('name');
3842
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
3943

44+
// ENG-108: subscribe to the onboarding run so PoliciesTableDS can surface
45+
// per-row "Tailoring/Queued/Preparing" state and we can render the banner
46+
// while AI is still personalizing the policy pack. Mirrors the existing
47+
// pattern in RisksTable/VendorsTable.
48+
const { itemStatuses, progress, isActive } = usePolicyOnboardingStatus(onboardingRunId);
49+
const hasActivePolicies = policies.length > 0;
50+
const showTailoringBanner = isActive && hasActivePolicies && progress !== null;
51+
4052
// Get unique departments from policies
4153
const departments = useMemo(() => {
4254
const depts = new Set<string>();
@@ -103,63 +115,78 @@ export function PolicyFilters({ policies }: PolicyFiltersProps) {
103115
departmentFilter === 'all' ? 'All Departments' : formatDepartment(departmentFilter);
104116

105117
return (
106-
<Stack gap="md">
107-
<div className="flex flex-col gap-3 md:flex-row md:items-end">
108-
{/* Search - full width on mobile, constrained on desktop */}
109-
<div className="w-full md:max-w-[300px]">
110-
<InputGroup>
111-
<InputGroupAddon>
112-
<Search size={16} />
113-
</InputGroupAddon>
114-
<InputGroupInput
115-
placeholder="Search policies..."
116-
value={searchQuery}
117-
onChange={(e) => setSearchQuery(e.target.value)}
118-
/>
119-
</InputGroup>
120-
</div>
121-
{/* Filters - side by side on mobile, inline with search on desktop */}
122-
<div className="flex gap-2">
123-
<div className="flex-1 md:w-[160px] md:flex-none">
124-
<Select
125-
value={statusFilter}
126-
onValueChange={(v) => setStatusFilter((v ?? 'all') as PolicyStatus | 'all' | 'archived')}
127-
>
128-
<SelectTrigger>
129-
<SelectValue placeholder="Status">{statusLabel}</SelectValue>
130-
</SelectTrigger>
131-
<SelectContent>
132-
{STATUS_OPTIONS.map((opt) => (
133-
<SelectItem key={opt.value} value={opt.value}>
134-
{opt.label}
135-
</SelectItem>
136-
))}
137-
</SelectContent>
138-
</Select>
118+
<PolicyTailoringProvider statuses={itemStatuses}>
119+
<Stack gap="md">
120+
<div className="flex flex-col gap-3 md:flex-row md:items-end">
121+
{/* Search - full width on mobile, constrained on desktop */}
122+
<div className="w-full md:max-w-[300px]">
123+
<InputGroup>
124+
<InputGroupAddon>
125+
<Search size={16} />
126+
</InputGroupAddon>
127+
<InputGroupInput
128+
placeholder="Search policies..."
129+
value={searchQuery}
130+
onChange={(e) => setSearchQuery(e.target.value)}
131+
/>
132+
</InputGroup>
139133
</div>
140-
<div className="flex-1 md:w-[160px] md:flex-none">
141-
<Select value={departmentFilter} onValueChange={(v) => setDepartmentFilter(v ?? 'all')}>
142-
<SelectTrigger>
143-
<SelectValue placeholder="Department">{departmentLabel}</SelectValue>
144-
</SelectTrigger>
145-
<SelectContent>
146-
<SelectItem value="all">All Departments</SelectItem>
147-
{departments.map((dept) => (
148-
<SelectItem key={dept} value={dept}>
149-
{formatDepartment(dept)}
150-
</SelectItem>
151-
))}
152-
</SelectContent>
153-
</Select>
134+
{/* Filters - side by side on mobile, inline with search on desktop */}
135+
<div className="flex gap-2">
136+
<div className="flex-1 md:w-[160px] md:flex-none">
137+
<Select
138+
value={statusFilter}
139+
onValueChange={(v) => setStatusFilter((v ?? 'all') as PolicyStatus | 'all' | 'archived')}
140+
>
141+
<SelectTrigger>
142+
<SelectValue placeholder="Status">{statusLabel}</SelectValue>
143+
</SelectTrigger>
144+
<SelectContent>
145+
{STATUS_OPTIONS.map((opt) => (
146+
<SelectItem key={opt.value} value={opt.value}>
147+
{opt.label}
148+
</SelectItem>
149+
))}
150+
</SelectContent>
151+
</Select>
152+
</div>
153+
<div className="flex-1 md:w-[160px] md:flex-none">
154+
<Select value={departmentFilter} onValueChange={(v) => setDepartmentFilter(v ?? 'all')}>
155+
<SelectTrigger>
156+
<SelectValue placeholder="Department">{departmentLabel}</SelectValue>
157+
</SelectTrigger>
158+
<SelectContent>
159+
<SelectItem value="all">All Departments</SelectItem>
160+
{departments.map((dept) => (
161+
<SelectItem key={dept} value={dept}>
162+
{formatDepartment(dept)}
163+
</SelectItem>
164+
))}
165+
</SelectContent>
166+
</Select>
167+
</div>
154168
</div>
155169
</div>
156-
</div>
157-
<PoliciesTableDS
158-
policies={filteredPolicies}
159-
sortColumn={sortColumn}
160-
sortDirection={sortDirection}
161-
onSort={handleSort}
162-
/>
163-
</Stack>
170+
{showTailoringBanner && progress !== null && (
171+
<div className="flex items-center gap-3 rounded-xl border border-primary/20 bg-linear-to-r from-primary/10 via-primary/5 to-transparent px-4 py-3">
172+
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/15 text-primary">
173+
<Loader2 className="h-5 w-5 animate-spin" />
174+
</div>
175+
<div className="flex flex-col">
176+
<span className="text-sm font-medium text-primary">Tailoring your policies</span>
177+
<span className="text-xs text-muted-foreground">
178+
Personalized {progress.completed}/{progress.total} policies
179+
</span>
180+
</div>
181+
</div>
182+
)}
183+
<PoliciesTableDS
184+
policies={filteredPolicies}
185+
sortColumn={sortColumn}
186+
sortDirection={sortDirection}
187+
onSort={handleSort}
188+
/>
189+
</Stack>
190+
</PolicyTailoringProvider>
164191
);
165192
}

0 commit comments

Comments
 (0)