Skip to content
Draft
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
2 changes: 1 addition & 1 deletion src/components/dashboard/Dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export function Dashboard() {
<h1 className="text-2xl font-bold text-white">Timesheet</h1>
<p className="text-dark-400 mt-1">
{isViewingTeammate
? `Viewing ${selectedTeammate.displayName}'s timesheet`
? `Viewing ${selectedTeammate.name || selectedTeammate.displayName || selectedTeammate.number}'s timesheet`
: 'Track your time and sync to Business Central'}
</p>
</div>
Expand Down
52 changes: 28 additions & 24 deletions src/components/timesheet/TeammateSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,18 @@ import {
import { useTeammateStore, useCompanyStore } from '@/hooks';
import { useAuth } from '@/services/auth';
import { cn } from '@/utils';
import type { BCEmployee } from '@/types';
import { bcClient } from '@/services/bc';
import type { BCResource } from '@/types';

// Resources expose `name` (and sometimes `displayName`); pick the best label.
function teammateLabel(t: BCResource): string {
return t.name || t.displayName || t.number;
}

function teammateInitial(t: BCResource): string {
const label = teammateLabel(t);
return label?.[0] || '?';
}

export function TeammateSelector() {
const [isOpen, setIsOpen] = useState(false);
Expand Down Expand Up @@ -52,39 +63,40 @@ export function TeammateSelector() {
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen]);

const handleSelect = (teammate: BCEmployee | null) => {
const handleSelect = (teammate: BCResource | null) => {
selectTeammate(teammate);
setIsOpen(false);
setSearchQuery('');
};

// Filter teammates by search query, excluding current user
// Filter teammates by search query
const filteredTeammates = teammates.filter((teammate) => {
// Filter by search
if (searchQuery) {
const query = searchQuery.toLowerCase();
const matchesName = teammate.displayName.toLowerCase().includes(query);
const matchesEmail = teammate.email?.toLowerCase().includes(query);
if (!matchesName && !matchesEmail) return false;
const matchesName = teammateLabel(teammate).toLowerCase().includes(query);
const matchesUserId = teammate.timeSheetOwnerUserId?.toLowerCase().includes(query);
const matchesNumber = teammate.number.toLowerCase().includes(query);
if (!matchesName && !matchesUserId && !matchesNumber) return false;
}
return true;
});

// Separate current user from other teammates
const currentUserEmail = account?.username?.toLowerCase();
// Identify the current user's resource by deriving the BC User ID from their UPN
// (matches the convention used in bcClient.deriveBCUserId).
const currentUserBCId = account?.username ? bcClient.deriveBCUserId(account.username) : undefined;
const currentUserTeammate = filteredTeammates.find(
(t) => t.email?.toLowerCase() === currentUserEmail
(t) => t.timeSheetOwnerUserId?.toUpperCase() === currentUserBCId
);
const otherTeammates = filteredTeammates.filter(
(t) => t.email?.toLowerCase() !== currentUserEmail
(t) => t.timeSheetOwnerUserId?.toUpperCase() !== currentUserBCId
);

// Don't show if no teammates available (only self)
if (teammates.length <= 1 && !isLoading) {
return null;
}

const displayName = selectedTeammate ? selectedTeammate.displayName : 'My Timesheet';
const displayName = selectedTeammate ? teammateLabel(selectedTeammate) : 'My Timesheet';

return (
<div className="relative" ref={dropdownRef}>
Expand Down Expand Up @@ -160,7 +172,7 @@ export function TeammateSelector() {
<span className="text-dark-200">My Timesheet</span>
{currentUserTeammate && (
<span className="text-dark-400 ml-2 text-xs">
({currentUserTeammate.displayName})
({teammateLabel(currentUserTeammate)})
</span>
)}
</div>
Expand Down Expand Up @@ -198,19 +210,11 @@ export function TeammateSelector() {
aria-selected={isSelected}
>
<div className="bg-dark-600 text-dark-200 flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full text-xs font-medium">
{teammate.givenName?.[0] ||
teammate.surname?.[0] ||
teammate.displayName?.[0] ||
'?'}
{teammate.givenName?.[0] && teammate.surname?.[0]
? teammate.surname[0]
: ''}
{teammateInitial(teammate)}
</div>
<div className="flex-1 truncate">
<div className="text-dark-200">{teammate.displayName}</div>
{teammate.jobTitle && (
<div className="text-dark-400 text-xs">{teammate.jobTitle}</div>
)}
<div className="text-dark-200">{teammateLabel(teammate)}</div>
<div className="text-dark-400 text-xs">{teammate.number}</div>
</div>
{isSelected && (
<CheckIcon className="text-knowall-green h-5 w-5 flex-shrink-0" />
Expand Down
14 changes: 9 additions & 5 deletions src/components/timesheet/WeeklyTimesheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -445,12 +445,12 @@ Thank you!`)}`}
<div className="flex-1">
<span className="text-dark-200 text-sm">
Viewing{' '}
<span className="font-medium text-white">{selectedTeammate.displayName}</span>
<span className="font-medium text-white">
{selectedTeammate.name || selectedTeammate.displayName || selectedTeammate.number}
</span>
&apos;s timesheet
</span>
{selectedTeammate.jobTitle && (
<span className="text-dark-400 ml-2 text-xs">({selectedTeammate.jobTitle})</span>
)}
<span className="text-dark-400 ml-2 text-xs">({selectedTeammate.number})</span>
</div>
<span className="bg-thyme-600/20 text-thyme-400 rounded px-2 py-1 text-xs">
Read-only
Expand Down Expand Up @@ -623,7 +623,11 @@ Thank you!`)}`}
{isViewingTeammate ? (
<>
<p className="text-dark-300 mb-2">
No time entries found for {selectedTeammate.displayName} this week.
No time entries found for{' '}
{selectedTeammate.name ||
selectedTeammate.displayName ||
selectedTeammate.number}{' '}
this week.
</p>
<p className="text-dark-500 text-sm">
Time entries will appear here once added to their timesheet.
Expand Down
18 changes: 11 additions & 7 deletions src/hooks/useTeammateStore.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import { create } from 'zustand';
import type { BCEmployee } from '@/types';
import type { BCResource } from '@/types';
import { bcClient } from '@/services/bc/bcClient';

interface TeammateStore {
teammates: BCEmployee[];
selectedTeammate: BCEmployee | null;
teammates: BCResource[];
selectedTeammate: BCResource | null;
isLoading: boolean;
error: string | null;

fetchTeammates: () => Promise<void>;
selectTeammate: (teammate: BCEmployee | null) => void;
selectTeammate: (teammate: BCResource | null) => void;
clearSelection: () => void;
isViewingTeammate: () => boolean;
}
Expand All @@ -23,15 +23,19 @@ export const useTeammateStore = create<TeammateStore>((set, get) => ({
fetchTeammates: async () => {
set({ isLoading: true, error: null });
try {
const employees = await bcClient.getEmployees("status eq 'Active'");
set({ teammates: employees, isLoading: false });
// Resources are the entities that own timesheets, so look them up directly
// (employees -> resources mapping is unreliable in BC).
const resources = await bcClient.getResources();
// Only show resources that actually use timesheets — others have nothing to display.
const withTimesheet = resources.filter((r) => r.useTimeSheet);
set({ teammates: withTimesheet, isLoading: false });
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to fetch teammates';
set({ error: message, isLoading: false, teammates: [] });
}
},

selectTeammate: (teammate: BCEmployee | null) => {
selectTeammate: (teammate: BCResource | null) => {
set({ selectedTeammate: teammate });
},

Expand Down
6 changes: 3 additions & 3 deletions src/hooks/useTimeEntriesStore.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { create } from 'zustand';
import type { TimeEntry, WeekData, BCEmployee, BCTimeSheet, TimesheetDisplayStatus } from '@/types';
import type { TimeEntry, WeekData, BCResource, BCTimeSheet, TimesheetDisplayStatus } from '@/types';
import {
timeEntryService,
NoResourceError,
Expand All @@ -26,7 +26,7 @@ interface TimeEntriesStore {

// Entry operations
fetchWeekEntries: (userId: string, weekStart?: Date) => Promise<void>;
fetchTeammateEntries: (teammate: BCEmployee, weekStart?: Date) => Promise<void>;
fetchTeammateEntries: (teammate: BCResource, weekStart?: Date) => Promise<void>;
addEntry: (
entry: Omit<
TimeEntry,
Expand Down Expand Up @@ -136,7 +136,7 @@ export const useTimeEntriesStore = create<TimeEntriesStore>((set, get) => ({
}
},

fetchTeammateEntries: async (teammate: BCEmployee, weekStart?: Date) => {
fetchTeammateEntries: async (teammate: BCResource, weekStart?: Date) => {
const week = weekStart || get().currentWeekStart;
set({ isLoading: true, error: null, currentWeekStart: week });

Expand Down
23 changes: 10 additions & 13 deletions src/services/bc/timeEntryService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type {
BCTimeSheet,
BCTimeSheetLine,
BCTimeSheetDetail,
BCEmployee,
BCResource,
} from '@/types';
import { format, startOfWeek } from 'date-fns';

Expand Down Expand Up @@ -578,31 +578,28 @@ export const timeEntryService = {

/**
* Get entries for a teammate from Business Central.
* The teammate is a BC Resource — we already have its number, so we can fetch
* the timesheet directly without an email→resource lookup.
*/
async getTeammateEntries(weekStart: Date, teammate: BCEmployee): Promise<TimeEntry[]> {
async getTeammateEntries(weekStart: Date, teammate: BCResource): Promise<TimeEntry[]> {
try {
// Get resource by employee email
const resource = teammate.email ? await bcClient.getResourceByEmail(teammate.email) : null;

if (!resource) {
return [];
}

const timesheet = await this.getTimesheet(resource.number, weekStart);
const timesheet = await this.getTimesheet(teammate.number, weekStart);
const [lines, details] = await Promise.all([
bcClient.getTimeSheetLines(timesheet.number),
bcClient.getAllTimeSheetDetails(timesheet.number),
]);

return bcDataToTimeEntries(lines, details, timesheet, teammate.id);
} catch (error) {
// Log error for debugging but don't expose to user
// This can fail for various reasons: no timesheet, no resource, network issues
// Common case: teammate has no timesheet for this week — return empty.
if (error instanceof NoTimesheetError) {
return [];
}
if (process.env.NODE_ENV === 'development') {
console.error('Failed to get teammate entries:', {
weekStart,
teammateId: teammate.id,
teammateEmail: teammate.email,
teammateNumber: teammate.number,
error,
});
}
Expand Down
Loading