Skip to content

Commit 7b8a575

Browse files
authored
Merge pull request #1410 from trycompai/main
[comp] Production Deploy
2 parents b59942f + d266c9d commit 7b8a575

3 files changed

Lines changed: 233 additions & 40 deletions

File tree

apps/app/src/app/(app)/[orgId]/people/[employeeId]/page.tsx

Lines changed: 114 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
trainingVideos as trainingVideosData,
77
} from '@/lib/data/training-videos';
88
import { getFleetInstance } from '@/lib/fleet';
9-
import type { EmployeeTrainingVideoCompletion, Member } from '@db';
9+
import type { EmployeeTrainingVideoCompletion, Member, User } from '@db';
1010
import { db } from '@db';
1111
import type { Metadata } from 'next';
1212
import { headers } from 'next/headers';
@@ -33,7 +33,8 @@ export default async function EmployeeDetailsPage({
3333
},
3434
});
3535

36-
const canEditMembers = ['owner', 'admin'].includes(currentUserMember?.role ?? '');
36+
const canEditMembers =
37+
currentUserMember?.role.includes('owner') || currentUserMember?.role.includes('admin') || false;
3738

3839
if (!organizationId) {
3940
redirect('/');
@@ -89,6 +90,7 @@ const getEmployee = async (employeeId: string) => {
8990
const employee = await db.member.findFirst({
9091
where: {
9192
id: employeeId,
93+
organizationId,
9294
},
9395
include: {
9496
user: true,
@@ -168,28 +170,127 @@ const getTrainingVideos = async (employeeId: string) => {
168170
);
169171
};
170172

171-
const getFleetPolicies = async (member: Member) => {
172-
const deviceLabelId = member.fleetDmLabelId;
173+
const getFleetPolicies = async (member: Member & { user: User }) => {
173174
const fleet = await getFleetInstance();
175+
const session = await auth.api.getSession({
176+
headers: await headers(),
177+
});
178+
const organizationId = session?.session.activeOrganizationId;
179+
180+
// Try individual member's fleet label first
181+
if (member.fleetDmLabelId) {
182+
console.log(
183+
`Found individual fleetDmLabelId: ${member.fleetDmLabelId} for member: ${member.id}, member email: ${member.user?.email}`,
184+
);
185+
186+
try {
187+
const deviceResponse = await fleet.get(`/labels/${member.fleetDmLabelId}/hosts`);
188+
const device = deviceResponse.data.hosts?.[0];
189+
190+
if (device) {
191+
const deviceWithPolicies = await fleet.get(`/hosts/${device.id}`);
192+
const fleetPolicies = deviceWithPolicies.data.host.policies;
193+
return { fleetPolicies, device };
194+
}
195+
} catch (error) {
196+
console.log(
197+
`Failed to get device using individual fleet label for member: ${member.id}`,
198+
error,
199+
);
200+
}
201+
}
174202

175-
if (!deviceLabelId) {
203+
// Fallback: Use organization fleet label and find device by matching criteria
204+
if (!organizationId) {
205+
console.log('No organizationId available for fallback device lookup');
176206
return { fleetPolicies: [], device: null };
177207
}
178208

179209
try {
180-
const deviceResponse = await fleet.get(`/labels/${deviceLabelId}/hosts`);
181-
const device = deviceResponse.data.hosts?.[0]; // There should only be one device per label.
210+
const organization = await db.organization.findUnique({
211+
where: { id: organizationId },
212+
});
182213

183-
if (!device) {
184-
console.log(`No host found for device label id: ${deviceLabelId} - member: ${member.id}`);
214+
if (!organization?.fleetDmLabelId) {
215+
console.log(
216+
`No organization fleetDmLabelId found for fallback device lookup - member: ${member.id}`,
217+
);
185218
return { fleetPolicies: [], device: null };
186219
}
187220

188-
const deviceWithPolicies = await fleet.get(`/hosts/${device.id}`);
189-
const fleetPolicies = deviceWithPolicies.data.host.policies;
190-
return { fleetPolicies, device };
221+
console.log(
222+
`Using organization fleetDmLabelId: ${organization.fleetDmLabelId} as fallback for member: ${member.id}`,
223+
);
224+
225+
// Get all devices from organization
226+
const deviceResponse = await fleet.get(`/labels/${organization.fleetDmLabelId}/hosts`);
227+
const allDevices = deviceResponse.data.hosts || [];
228+
229+
if (allDevices.length === 0) {
230+
console.log('No devices found in organization fleet');
231+
return { fleetPolicies: [], device: null };
232+
}
233+
234+
// Get detailed info for all devices to help match them to the employee
235+
const devicesWithDetails = await Promise.all(
236+
allDevices.map(async (device: any) => {
237+
try {
238+
const deviceDetails = await fleet.get(`/hosts/${device.id}`);
239+
return deviceDetails.data.host;
240+
} catch (error) {
241+
console.log(`Failed to get details for device ${device.id}:`, error);
242+
return null;
243+
}
244+
}),
245+
);
246+
247+
const validDevices = devicesWithDetails.filter(Boolean);
248+
249+
// Try to match device to employee by computer name containing user's name
250+
const userName = member.user.name?.toLowerCase();
251+
const userEmail = member.user.email?.toLowerCase();
252+
253+
let matchedDevice = null;
254+
255+
if (userName) {
256+
// Try to find device with computer name containing user's name
257+
matchedDevice = validDevices.find(
258+
(device: any) =>
259+
device.computer_name?.toLowerCase().includes(userName.split(' ')[0]) ||
260+
device.computer_name?.toLowerCase().includes(userName.split(' ').pop()),
261+
);
262+
}
263+
264+
if (!matchedDevice && userEmail) {
265+
// Try to find device with computer name containing part of email
266+
const emailPrefix = userEmail.split('@')[0];
267+
matchedDevice = validDevices.find((device: any) =>
268+
device.computer_name?.toLowerCase().includes(emailPrefix),
269+
);
270+
}
271+
272+
// If no specific match found and there's only one device, assume it's theirs
273+
if (!matchedDevice && validDevices.length === 1) {
274+
matchedDevice = validDevices[0];
275+
console.log(`Only one device found, assigning to member: ${member.id}`);
276+
}
277+
278+
if (matchedDevice) {
279+
console.log(
280+
`Matched device ${matchedDevice.computer_name} (ID: ${matchedDevice.id}) to member: ${member.id}`,
281+
);
282+
return {
283+
fleetPolicies: matchedDevice.policies || [],
284+
device: matchedDevice,
285+
};
286+
}
287+
288+
console.log(
289+
`No device could be matched to member: ${member.id}. Available devices: ${validDevices.map((d: any) => d.computer_name).join(', ')}`,
290+
);
291+
return { fleetPolicies: [], device: null };
191292
} catch (error) {
192-
console.error(`Failed to get fleet policies for member: ${member.id}`, error);
293+
console.error(`Failed to get fleet policies using fallback for member: ${member.id}`, error);
193294
return { fleetPolicies: [], device: null };
194295
}
195296
};

apps/app/src/app/(app)/[orgId]/people/dashboard/components/EmployeeCompletionChart.tsx

Lines changed: 118 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
'use client';
22

33
import { Card, CardContent, CardHeader, CardTitle } from '@comp/ui/card';
4+
import { Input } from '@comp/ui/input';
5+
import { Search } from 'lucide-react';
46
import type { CSSProperties } from 'react';
57
import * as React from 'react';
68

@@ -16,6 +18,7 @@ interface EmployeeCompletionChartProps {
1618
trainingVideos: (EmployeeTrainingVideoCompletion & {
1719
metadata: TrainingVideo;
1820
})[];
21+
showAll?: boolean;
1922
}
2023

2124
// Define colors for the chart
@@ -42,7 +45,11 @@ export function EmployeeCompletionChart({
4245
employees,
4346
policies,
4447
trainingVideos,
48+
showAll = false,
4549
}: EmployeeCompletionChartProps) {
50+
const [searchTerm, setSearchTerm] = React.useState('');
51+
const [displayedItems, setDisplayedItems] = React.useState(showAll ? 20 : 5);
52+
const [isLoading, setIsLoading] = React.useState(false);
4653
// Calculate completion data for each employee
4754
const employeeStats: EmployeeTaskStats[] = React.useMemo(() => {
4855
return employees.map((employee) => {
@@ -97,6 +104,51 @@ export function EmployeeCompletionChart({
97104
});
98105
}, [employees, policies, trainingVideos]);
99106

107+
// Filter employees based on search term
108+
const filteredStats = React.useMemo(() => {
109+
if (!searchTerm) return employeeStats;
110+
111+
return employeeStats.filter(
112+
(stat) =>
113+
stat.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
114+
stat.email.toLowerCase().includes(searchTerm.toLowerCase()),
115+
);
116+
}, [employeeStats, searchTerm]);
117+
118+
// Sort and limit employees
119+
const sortedStats = React.useMemo(() => {
120+
const sorted = [...filteredStats].sort((a, b) => b.overallPercentage - a.overallPercentage);
121+
return showAll ? sorted.slice(0, displayedItems) : sorted.slice(0, 5);
122+
}, [filteredStats, displayedItems, showAll]);
123+
124+
// Load more function for infinite scroll
125+
const loadMore = React.useCallback(async () => {
126+
if (isLoading || !showAll) return;
127+
128+
setIsLoading(true);
129+
// Simulate loading delay
130+
await new Promise((resolve) => setTimeout(resolve, 300));
131+
setDisplayedItems((prev) => prev + 20);
132+
setIsLoading(false);
133+
}, [isLoading, showAll]);
134+
135+
// Infinite scroll effect
136+
React.useEffect(() => {
137+
if (!showAll) return;
138+
139+
const handleScroll = () => {
140+
if (
141+
window.innerHeight + document.documentElement.scrollTop >=
142+
document.documentElement.offsetHeight - 1000
143+
) {
144+
loadMore();
145+
}
146+
};
147+
148+
window.addEventListener('scroll', handleScroll);
149+
return () => window.removeEventListener('scroll', handleScroll);
150+
}, [loadMore, showAll]);
151+
100152
// Check for empty data scenarios
101153
if (!employees.length) {
102154
return (
@@ -129,42 +181,82 @@ export function EmployeeCompletionChart({
129181
);
130182
}
131183

132-
// Sort by completion percentage and limit to top 5
133-
const sortedStats = [...employeeStats]
134-
.sort((a, b) => b.overallPercentage - a.overallPercentage)
135-
.slice(0, 5);
136-
137184
return (
138185
<Card>
139186
<CardHeader>
140187
<CardTitle>{'Employee Task Completion'}</CardTitle>
188+
{showAll && (
189+
<div className="mt-4">
190+
<Input
191+
placeholder="Search employees..."
192+
value={searchTerm}
193+
onChange={(e) => setSearchTerm(e.target.value)}
194+
leftIcon={<Search className="h-4 w-4" />}
195+
/>
196+
</div>
197+
)}
141198
</CardHeader>
142199
<CardContent>
143-
<div className="space-y-8">
144-
{sortedStats.map((stat) => (
145-
<div key={stat.id} className="space-y-2">
146-
<div className="flex items-center justify-between text-sm">
147-
<p>{stat.name}</p>
148-
<span className="text-muted-foreground">
149-
{stat.policiesCompleted + stat.trainingsCompleted} / {stat.totalTasks} {'tasks'}
150-
</span>
151-
</div>
200+
{filteredStats.length === 0 ? (
201+
<div className="flex h-[200px] items-center justify-center">
202+
<p className="text-muted-foreground text-center text-sm">
203+
{searchTerm ? 'No employees found matching your search' : 'No employees available'}
204+
</p>
205+
</div>
206+
) : (
207+
<>
208+
<div className="space-y-8">
209+
{sortedStats.map((stat) => (
210+
<div key={stat.id} className="space-y-2">
211+
<div className="flex items-center justify-between text-sm">
212+
<div>
213+
<p className="font-medium">{stat.name}</p>
214+
<p className="text-muted-foreground text-xs">{stat.email}</p>
215+
</div>
216+
<span className="text-muted-foreground">
217+
{stat.policiesCompleted + stat.trainingsCompleted} / {stat.totalTasks}{' '}
218+
{'tasks'}
219+
</span>
220+
</div>
152221

153-
<TaskBarChart stat={stat} />
222+
<TaskBarChart stat={stat} />
154223

155-
<div className="text-muted-foreground flex flex-wrap gap-3 text-xs">
156-
<div className="flex items-center gap-1">
157-
<div className="bg-primary size-2" />
158-
<span>{'Completed'}</span>
159-
</div>
160-
<div className="flex items-center gap-1">
161-
<div className="size-2 bg-[var(--chart-open)]" />
162-
<span>{'Not Completed'}</span>
224+
<div className="text-muted-foreground flex flex-wrap gap-3 text-xs">
225+
<div className="flex items-center gap-1">
226+
<div className="bg-primary size-2 rounded-xs" />
227+
<span>{'Completed'}</span>
228+
</div>
229+
<div className="flex items-center gap-1">
230+
<div className="size-2 rounded-xs bg-[var(--chart-open)]" />
231+
<span>{'Not Completed'}</span>
232+
</div>
233+
</div>
163234
</div>
164-
</div>
235+
))}
165236
</div>
166-
))}
167-
</div>
237+
238+
{showAll && sortedStats.length < filteredStats.length && (
239+
<div className="mt-8 flex justify-center">
240+
{isLoading ? (
241+
<div className="text-muted-foreground text-sm">Loading more employees...</div>
242+
) : (
243+
<button
244+
onClick={loadMore}
245+
className="text-primary hover:text-primary/80 text-sm font-medium"
246+
>
247+
Load more employees
248+
</button>
249+
)}
250+
</div>
251+
)}
252+
253+
{showAll && (
254+
<div className="mt-4 text-center text-muted-foreground text-xs">
255+
Showing {sortedStats.length} of {filteredStats.length} employees
256+
</div>
257+
)}
258+
</>
259+
)}
168260
</CardContent>
169261
</Card>
170262
);

apps/app/src/app/(app)/[orgId]/people/dashboard/components/EmployeesOverview.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,8 +93,8 @@ export async function EmployeesOverview() {
9393
<EmployeeCompletionChart
9494
employees={employees}
9595
policies={policies}
96-
// Use the correctly typed array, potentially casting if EmployeeCompletionChart expects a slightly different type
9796
trainingVideos={processedTrainingVideos as any}
97+
showAll={true}
9898
/>
9999
</div>
100100
);

0 commit comments

Comments
 (0)