Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 114 additions & 13 deletions apps/app/src/app/(app)/[orgId]/people/[employeeId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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('/');
Expand Down Expand Up @@ -89,6 +90,7 @@ const getEmployee = async (employeeId: string) => {
const employee = await db.member.findFirst({
where: {
id: employeeId,
organizationId,
},
include: {
user: true,
Expand Down Expand Up @@ -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 };
}
};
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -16,6 +18,7 @@ interface EmployeeCompletionChartProps {
trainingVideos: (EmployeeTrainingVideoCompletion & {
metadata: TrainingVideo;
})[];
showAll?: boolean;
}

// Define colors for the chart
Expand All @@ -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) => {
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -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 (
<Card>
<CardHeader>
<CardTitle>{'Employee Task Completion'}</CardTitle>
{showAll && (
<div className="mt-4">
<Input
placeholder="Search employees..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
leftIcon={<Search className="h-4 w-4" />}
/>
</div>
)}
</CardHeader>
<CardContent>
<div className="space-y-8">
{sortedStats.map((stat) => (
<div key={stat.id} className="space-y-2">
<div className="flex items-center justify-between text-sm">
<p>{stat.name}</p>
<span className="text-muted-foreground">
{stat.policiesCompleted + stat.trainingsCompleted} / {stat.totalTasks} {'tasks'}
</span>
</div>
{filteredStats.length === 0 ? (
<div className="flex h-[200px] items-center justify-center">
<p className="text-muted-foreground text-center text-sm">
{searchTerm ? 'No employees found matching your search' : 'No employees available'}
</p>
</div>
) : (
<>
<div className="space-y-8">
{sortedStats.map((stat) => (
<div key={stat.id} className="space-y-2">
<div className="flex items-center justify-between text-sm">
<div>
<p className="font-medium">{stat.name}</p>
<p className="text-muted-foreground text-xs">{stat.email}</p>
</div>
<span className="text-muted-foreground">
{stat.policiesCompleted + stat.trainingsCompleted} / {stat.totalTasks}{' '}
{'tasks'}
</span>
</div>

<TaskBarChart stat={stat} />
<TaskBarChart stat={stat} />

<div className="text-muted-foreground flex flex-wrap gap-3 text-xs">
<div className="flex items-center gap-1">
<div className="bg-primary size-2" />
<span>{'Completed'}</span>
</div>
<div className="flex items-center gap-1">
<div className="size-2 bg-[var(--chart-open)]" />
<span>{'Not Completed'}</span>
<div className="text-muted-foreground flex flex-wrap gap-3 text-xs">
<div className="flex items-center gap-1">
<div className="bg-primary size-2 rounded-xs" />
<span>{'Completed'}</span>
</div>
<div className="flex items-center gap-1">
<div className="size-2 rounded-xs bg-[var(--chart-open)]" />
<span>{'Not Completed'}</span>
</div>
</div>
</div>
</div>
))}
</div>
))}
</div>

{showAll && sortedStats.length < filteredStats.length && (
<div className="mt-8 flex justify-center">
{isLoading ? (
<div className="text-muted-foreground text-sm">Loading more employees...</div>
) : (
<button
onClick={loadMore}
className="text-primary hover:text-primary/80 text-sm font-medium"
>
Load more employees
</button>
)}
</div>
)}

{showAll && (
<div className="mt-4 text-center text-muted-foreground text-xs">
Showing {sortedStats.length} of {filteredStats.length} employees
</div>
)}
</>
)}
</CardContent>
</Card>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,8 @@ export async function EmployeesOverview() {
<EmployeeCompletionChart
employees={employees}
policies={policies}
// Use the correctly typed array, potentially casting if EmployeeCompletionChart expects a slightly different type
trainingVideos={processedTrainingVideos as any}
showAll={true}
/>
</div>
);
Expand Down
Loading