diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/page.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/page.tsx index f178acb3d..52f7a62fb 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/page.tsx @@ -6,7 +6,7 @@ import { trainingVideos as trainingVideosData, } from '@/lib/data/training-videos'; import { getFleetInstance } from '@/lib/fleet'; -import type { EmployeeTrainingVideoCompletion, Member } from '@db'; +import type { EmployeeTrainingVideoCompletion, Member, User } from '@db'; import { db } from '@db'; import type { Metadata } from 'next'; import { headers } from 'next/headers'; @@ -33,7 +33,8 @@ export default async function EmployeeDetailsPage({ }, }); - const canEditMembers = ['owner', 'admin'].includes(currentUserMember?.role ?? ''); + const canEditMembers = + currentUserMember?.role.includes('owner') || currentUserMember?.role.includes('admin') || false; if (!organizationId) { redirect('/'); @@ -89,6 +90,7 @@ const getEmployee = async (employeeId: string) => { const employee = await db.member.findFirst({ where: { id: employeeId, + organizationId, }, include: { user: true, @@ -168,28 +170,127 @@ const getTrainingVideos = async (employeeId: string) => { ); }; -const getFleetPolicies = async (member: Member) => { - const deviceLabelId = member.fleetDmLabelId; +const getFleetPolicies = async (member: Member & { user: User }) => { const fleet = await getFleetInstance(); + const session = await auth.api.getSession({ + headers: await headers(), + }); + const organizationId = session?.session.activeOrganizationId; + + // Try individual member's fleet label first + if (member.fleetDmLabelId) { + console.log( + `Found individual fleetDmLabelId: ${member.fleetDmLabelId} for member: ${member.id}, member email: ${member.user?.email}`, + ); + + try { + const deviceResponse = await fleet.get(`/labels/${member.fleetDmLabelId}/hosts`); + const device = deviceResponse.data.hosts?.[0]; + + if (device) { + const deviceWithPolicies = await fleet.get(`/hosts/${device.id}`); + const fleetPolicies = deviceWithPolicies.data.host.policies; + return { fleetPolicies, device }; + } + } catch (error) { + console.log( + `Failed to get device using individual fleet label for member: ${member.id}`, + error, + ); + } + } - if (!deviceLabelId) { + // Fallback: Use organization fleet label and find device by matching criteria + if (!organizationId) { + console.log('No organizationId available for fallback device lookup'); return { fleetPolicies: [], device: null }; } try { - const deviceResponse = await fleet.get(`/labels/${deviceLabelId}/hosts`); - const device = deviceResponse.data.hosts?.[0]; // There should only be one device per label. + const organization = await db.organization.findUnique({ + where: { id: organizationId }, + }); - if (!device) { - console.log(`No host found for device label id: ${deviceLabelId} - member: ${member.id}`); + if (!organization?.fleetDmLabelId) { + console.log( + `No organization fleetDmLabelId found for fallback device lookup - member: ${member.id}`, + ); return { fleetPolicies: [], device: null }; } - const deviceWithPolicies = await fleet.get(`/hosts/${device.id}`); - const fleetPolicies = deviceWithPolicies.data.host.policies; - return { fleetPolicies, device }; + console.log( + `Using organization fleetDmLabelId: ${organization.fleetDmLabelId} as fallback for member: ${member.id}`, + ); + + // Get all devices from organization + const deviceResponse = await fleet.get(`/labels/${organization.fleetDmLabelId}/hosts`); + const allDevices = deviceResponse.data.hosts || []; + + if (allDevices.length === 0) { + console.log('No devices found in organization fleet'); + return { fleetPolicies: [], device: null }; + } + + // Get detailed info for all devices to help match them to the employee + const devicesWithDetails = await Promise.all( + allDevices.map(async (device: any) => { + try { + const deviceDetails = await fleet.get(`/hosts/${device.id}`); + return deviceDetails.data.host; + } catch (error) { + console.log(`Failed to get details for device ${device.id}:`, error); + return null; + } + }), + ); + + const validDevices = devicesWithDetails.filter(Boolean); + + // Try to match device to employee by computer name containing user's name + const userName = member.user.name?.toLowerCase(); + const userEmail = member.user.email?.toLowerCase(); + + let matchedDevice = null; + + if (userName) { + // Try to find device with computer name containing user's name + matchedDevice = validDevices.find( + (device: any) => + device.computer_name?.toLowerCase().includes(userName.split(' ')[0]) || + device.computer_name?.toLowerCase().includes(userName.split(' ').pop()), + ); + } + + if (!matchedDevice && userEmail) { + // Try to find device with computer name containing part of email + const emailPrefix = userEmail.split('@')[0]; + matchedDevice = validDevices.find((device: any) => + device.computer_name?.toLowerCase().includes(emailPrefix), + ); + } + + // If no specific match found and there's only one device, assume it's theirs + if (!matchedDevice && validDevices.length === 1) { + matchedDevice = validDevices[0]; + console.log(`Only one device found, assigning to member: ${member.id}`); + } + + if (matchedDevice) { + console.log( + `Matched device ${matchedDevice.computer_name} (ID: ${matchedDevice.id}) to member: ${member.id}`, + ); + return { + fleetPolicies: matchedDevice.policies || [], + device: matchedDevice, + }; + } + + console.log( + `No device could be matched to member: ${member.id}. Available devices: ${validDevices.map((d: any) => d.computer_name).join(', ')}`, + ); + return { fleetPolicies: [], device: null }; } catch (error) { - console.error(`Failed to get fleet policies for member: ${member.id}`, error); + console.error(`Failed to get fleet policies using fallback for member: ${member.id}`, error); return { fleetPolicies: [], device: null }; } }; diff --git a/apps/app/src/app/(app)/[orgId]/people/dashboard/components/EmployeeCompletionChart.tsx b/apps/app/src/app/(app)/[orgId]/people/dashboard/components/EmployeeCompletionChart.tsx index 18270cb3d..55ff38f56 100644 --- a/apps/app/src/app/(app)/[orgId]/people/dashboard/components/EmployeeCompletionChart.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/dashboard/components/EmployeeCompletionChart.tsx @@ -1,6 +1,8 @@ 'use client'; import { Card, CardContent, CardHeader, CardTitle } from '@comp/ui/card'; +import { Input } from '@comp/ui/input'; +import { Search } from 'lucide-react'; import type { CSSProperties } from 'react'; import * as React from 'react'; @@ -16,6 +18,7 @@ interface EmployeeCompletionChartProps { trainingVideos: (EmployeeTrainingVideoCompletion & { metadata: TrainingVideo; })[]; + showAll?: boolean; } // Define colors for the chart @@ -42,7 +45,11 @@ export function EmployeeCompletionChart({ employees, policies, trainingVideos, + showAll = false, }: EmployeeCompletionChartProps) { + const [searchTerm, setSearchTerm] = React.useState(''); + const [displayedItems, setDisplayedItems] = React.useState(showAll ? 20 : 5); + const [isLoading, setIsLoading] = React.useState(false); // Calculate completion data for each employee const employeeStats: EmployeeTaskStats[] = React.useMemo(() => { return employees.map((employee) => { @@ -97,6 +104,51 @@ export function EmployeeCompletionChart({ }); }, [employees, policies, trainingVideos]); + // Filter employees based on search term + const filteredStats = React.useMemo(() => { + if (!searchTerm) return employeeStats; + + return employeeStats.filter( + (stat) => + stat.name.toLowerCase().includes(searchTerm.toLowerCase()) || + stat.email.toLowerCase().includes(searchTerm.toLowerCase()), + ); + }, [employeeStats, searchTerm]); + + // Sort and limit employees + const sortedStats = React.useMemo(() => { + const sorted = [...filteredStats].sort((a, b) => b.overallPercentage - a.overallPercentage); + return showAll ? sorted.slice(0, displayedItems) : sorted.slice(0, 5); + }, [filteredStats, displayedItems, showAll]); + + // Load more function for infinite scroll + const loadMore = React.useCallback(async () => { + if (isLoading || !showAll) return; + + setIsLoading(true); + // Simulate loading delay + await new Promise((resolve) => setTimeout(resolve, 300)); + setDisplayedItems((prev) => prev + 20); + setIsLoading(false); + }, [isLoading, showAll]); + + // Infinite scroll effect + React.useEffect(() => { + if (!showAll) return; + + const handleScroll = () => { + if ( + window.innerHeight + document.documentElement.scrollTop >= + document.documentElement.offsetHeight - 1000 + ) { + loadMore(); + } + }; + + window.addEventListener('scroll', handleScroll); + return () => window.removeEventListener('scroll', handleScroll); + }, [loadMore, showAll]); + // Check for empty data scenarios if (!employees.length) { return ( @@ -129,42 +181,82 @@ export function EmployeeCompletionChart({ ); } - // Sort by completion percentage and limit to top 5 - const sortedStats = [...employeeStats] - .sort((a, b) => b.overallPercentage - a.overallPercentage) - .slice(0, 5); - return ( {'Employee Task Completion'} + {showAll && ( +
+ setSearchTerm(e.target.value)} + leftIcon={} + /> +
+ )}
-
- {sortedStats.map((stat) => ( -
-
-

{stat.name}

- - {stat.policiesCompleted + stat.trainingsCompleted} / {stat.totalTasks} {'tasks'} - -
+ {filteredStats.length === 0 ? ( +
+

+ {searchTerm ? 'No employees found matching your search' : 'No employees available'} +

+
+ ) : ( + <> +
+ {sortedStats.map((stat) => ( +
+
+
+

{stat.name}

+

{stat.email}

+
+ + {stat.policiesCompleted + stat.trainingsCompleted} / {stat.totalTasks}{' '} + {'tasks'} + +
- + -
-
-
- {'Completed'} -
-
-
- {'Not Completed'} +
+
+
+ {'Completed'} +
+
+
+ {'Not Completed'} +
+
-
+ ))}
- ))} -
+ + {showAll && sortedStats.length < filteredStats.length && ( +
+ {isLoading ? ( +
Loading more employees...
+ ) : ( + + )} +
+ )} + + {showAll && ( +
+ Showing {sortedStats.length} of {filteredStats.length} employees +
+ )} + + )} ); diff --git a/apps/app/src/app/(app)/[orgId]/people/dashboard/components/EmployeesOverview.tsx b/apps/app/src/app/(app)/[orgId]/people/dashboard/components/EmployeesOverview.tsx index 0cfa16347..95f93acda 100644 --- a/apps/app/src/app/(app)/[orgId]/people/dashboard/components/EmployeesOverview.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/dashboard/components/EmployeesOverview.tsx @@ -93,8 +93,8 @@ export async function EmployeesOverview() {
);