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
8 changes: 5 additions & 3 deletions src/components/projects/ProjectList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -153,8 +153,10 @@ export function ProjectList({ onSelectProject }: ProjectListProps) {
result = result.filter((p) => p.isFavorite);
}

// Apply status filter
if (statusFilter !== 'all') {
// Apply status filter — hide archived by default
if (statusFilter === 'all') {
result = result.filter((p) => p.status !== 'archived');
} else {
Comment thread
BenGWeeks marked this conversation as resolved.
result = result.filter((p) => p.status === statusFilter);
}

Expand Down Expand Up @@ -299,7 +301,7 @@ export function ProjectList({ onSelectProject }: ProjectListProps) {
onChange={(e) => setStatusFilter(e.target.value)}
className="w-40"
options={[
{ value: 'all', label: 'All Status' },
{ value: 'all', label: 'Active & Completed' },
...uniqueStatuses.map((status) => ({
value: status,
label: status.charAt(0).toUpperCase() + status.slice(1),
Expand Down
5 changes: 4 additions & 1 deletion src/components/timer/StartTimerModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,10 @@ export function StartTimerModal({ isOpen, onClose }: StartTimerModalProps) {
}
}, [isOpen, selectedProject]);

const projectOptions: SelectOption[] = projects.map((p) => ({
// Only show active projects for timer (exclude archived and completed)
const activeProjects = projects.filter((p) => p.status === 'active');

const projectOptions: SelectOption[] = activeProjects.map((p) => ({
value: p.id,
label: `${p.code} - ${p.name}`,
}));
Expand Down
29 changes: 22 additions & 7 deletions src/components/timesheet/TimeEntryModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,10 +63,25 @@ export function TimeEntryModal({ isOpen, onClose, date, entry, weekStart }: Time
}
}, [isOpen]);

// Get unique customers from projects
// Available projects for selection in this modal: active projects only,
// plus the entry's current project if editing — so reopening an entry
// logged against a project that has since been archived still shows the
// original selection. Customer/project dropdowns both derive from this.
const availableProjects = useMemo(() => {
const result = projects.filter((p) => p.status === 'active');
if (entry) {
const entryProject = projects.find((p) => p.code === entry.projectId);
if (entryProject && entryProject.status !== 'active') {
result.push(entryProject);
}
}
return result;
}, [projects, entry]);

// Get unique customers from available projects
const customerOptions: SelectOption[] = useMemo(() => {
const customers = new Map<string, string>();
projects.forEach((p) => {
availableProjects.forEach((p) => {
const customerName = (p.customerName || 'Unknown').trim();
const normalizedKey = customerName.toLowerCase();
// Use the first occurrence's display name, but dedupe by normalized key
Expand All @@ -77,28 +92,28 @@ export function TimeEntryModal({ isOpen, onClose, date, entry, weekStart }: Time
return Array.from(customers.values())
.sort()
.map((name) => ({ value: name, label: name }));
}, [projects]);
}, [availableProjects]);

// Check if we should show customer dropdown (hide if only one customer or all "Unknown")
const showCustomerDropdown =
customerOptions.length > 1 ||
(customerOptions.length === 1 && customerOptions[0].value !== 'Unknown');

// Filter projects by selected customer (or show all if customer dropdown is hidden)
// Filter available projects by selected customer (or show all if customer dropdown is hidden)
const filteredProjects = useMemo(() => {
if (!showCustomerDropdown) {
return projects;
return availableProjects;
}
if (!customerId) {
return [];
Comment thread
BenGWeeks marked this conversation as resolved.
}
// Use case-insensitive, trimmed comparison for robustness
const normalizedCustomerId = customerId.trim().toLowerCase();
return projects.filter((p) => {
return availableProjects.filter((p) => {
const projectCustomer = (p.customerName || 'Unknown').trim().toLowerCase();
return projectCustomer === normalizedCustomerId;
});
}, [projects, customerId, showCustomerDropdown]);
}, [availableProjects, customerId, showCustomerDropdown]);

// Helper to find the matching customerOption value for a customer name
// This ensures the Select dropdown value matches exactly
Expand Down
20 changes: 18 additions & 2 deletions src/services/bc/bcClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,21 @@ const VALID_TIMESHEET_STATUSES = ['Open', 'Submitted', 'Rejected', 'Approved', '
// Available environments to query
const BC_ENVIRONMENTS: BCEnvironmentType[] = ['sandbox', 'production'];

/**
* Normalize a BCProject coming off the OData wire.
*
* BC's OData JSON serializer encodes the leading-space enum value (`' '`,
* the "not blocked" sentinel) as the literal string `"_x0020_"`
* (XML schema escape for U+0020). Translate it back to a real space so
* downstream code can use the documented enum values.
*/
function normalizeBCProject(project: BCProject): BCProject {
if (project.blocked === ('_x0020_' as unknown as BCProject['blocked'])) {
return { ...project, blocked: ' ' };
}
return project;
}

class BusinessCentralClient {
private _companyId: string;
private _environment: BCEnvironmentType;
Expand Down Expand Up @@ -365,11 +380,12 @@ class BusinessCentralClient {
endpoint += `?$filter=${encodeURIComponent(filter)}`;
}
const response = await this.fetchCustomApi<PaginatedResponse<BCProject>>(endpoint);
return response.value;
return response.value.map(normalizeBCProject);
}

async getProject(projectId: string): Promise<BCProject> {
return this.fetchCustomApi<BCProject>(`/projects(${projectId})`);
const project = await this.fetchCustomApi<BCProject>(`/projects(${projectId})`);
return normalizeBCProject(project);
}

// Customers
Expand Down
11 changes: 7 additions & 4 deletions src/services/bc/projectDetailsService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,6 @@ export const projectDetailsService = {
* Fetch project details and tasks by project number
*/
async getProjectDetails(projectNumber: string): Promise<{ project: Project; tasks: Task[] }> {
// Fetch all projects and filter by number (the /jobs endpoint isn't available in all environments)
const bcProjects = await bcClient.getProjects();
const bcProject = bcProjects.find((p) => p.number === projectNumber);

Expand All @@ -158,9 +157,13 @@ export const projectDetailsService = {
const startDate = bcProject.startingDate;
const endDate = bcProject.endingDate;

// Map BC status to Thyme status
const status: 'active' | 'completed' =
bcProject.status === 'Completed' ? 'completed' : 'active';
// Map BC status to Thyme status — blocked projects are archived
const isBlocked = bcProject.blocked && bcProject.blocked !== ' ';
const status: Project['status'] = isBlocked
? 'archived'
: bcProject.status === 'Completed'
? 'completed'
: 'active';

const project: Project = {
id: bcProject.id,
Expand Down
31 changes: 29 additions & 2 deletions src/services/bc/projectService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,37 @@ function getProjectColor(index: number): string {
return PROJECT_COLORS[index % PROJECT_COLORS.length];
}

// Latch so the older-extension warning logs once per session.
let warnedNoBlockedField = false;

/**
* Warn (once per session) if no project in the response carries a `blocked`
* field. That signals the Thyme BC Extension is older than the version that
* exposes `blocked` on /projects, in which case archived projects will fall
* through to "active" and the hide-archived feature won't apply.
*/
function warnIfBlockedFieldMissing(bcProjects: BCProject[]): void {
if (warnedNoBlockedField || bcProjects.length === 0) return;
const anyHasBlocked = bcProjects.some((p) => p.blocked !== undefined);
if (!anyHasBlocked) {
warnedNoBlockedField = true;
console.warn(
'[Thyme] No project returned a `blocked` field. Archived projects will not be hidden — ' +
'install the latest Thyme BC Extension to enable the hide-archived feature.'
);
}
}

// The Thyme BC Extension API (v1.7+) returns displayName, billToCustomerName and status fields.

function mapBCProjectToProject(bcProject: BCProject, index: number, favorites: string[]): Project {
// Map BC status to Thyme status
const status: 'active' | 'completed' = bcProject.status === 'Completed' ? 'completed' : 'active';
// Map BC status to Thyme status — blocked projects are archived
const isBlocked = bcProject.blocked && bcProject.blocked !== ' ';
const status: Project['status'] = isBlocked
? 'archived'
: bcProject.status === 'Completed'
? 'completed'
: 'active';
Comment thread
BenGWeeks marked this conversation as resolved.

return {
id: bcProject.id,
Expand Down Expand Up @@ -72,6 +98,7 @@ function saveFavorites(favorites: string[]): void {
export const projectService = {
async getProjects(_includeCompleted = false): Promise<Project[]> {
const bcProjects = await bcClient.getProjects();
warnIfBlockedFieldMissing(bcProjects);
const favorites = getFavorites();

return bcProjects.map((bcProject, index) => mapBCProjectToProject(bcProject, index, favorites));
Expand Down
1 change: 1 addition & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export interface BCProject {
billToCustomerNo?: string;
billToCustomerName?: string;
status?: 'Open' | 'Completed' | 'Planning';
blocked?: ' ' | 'Posting' | 'All';
startingDate?: string;
endingDate?: string;
lastModifiedDateTime?: string;
Expand Down
Loading