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
12 changes: 10 additions & 2 deletions apps/frontend/src/app/components/ProjectCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,14 @@ type ArchiveProps = {
type ProjectCardProps = ActiveProps | ArchiveProps;

export default function ProjectCard(props: ProjectCardProps) {
// A project with no budget set divides by zero. Now that these numbers come
// from the API rather than a hardcoded 0, that is a real case: it yields
// NaN% (or Infinity% once anything is spent) straight into the bar width.
const percentUsed =
props.variant === 'active' && props.total_budget > 0
? Math.round((props.budget_used / props.total_budget) * 100)
: 0;

return (
<div className="!border-[1px] border-solid border-black-300 w-full sm:w-[50%] md:w-[35%] lg:w-[25%] rounded-[4px] overflow-hidden">
<div className="flex flex-col !gap-4 !p-4">
Expand Down Expand Up @@ -51,11 +59,11 @@ export default function ProjectCard(props: ProjectCardProps) {
<div className="flex flex-row items-center !gap-2">
<div className="w-full !h-[24px] rounded-full bg-black-100">
<div
style={{ width: `${Math.round((props.budget_used / props.total_budget) * 100)}%` }}
style={{ width: `${Math.min(percentUsed, 100)}%` }}
className="!h-full rounded-full bg-core-green"
/>
</div>
<p>{Math.round((props.budget_used / props.total_budget) * 100)}%</p>
<p>{percentUsed}%</p>
</div>
) : (
<div className="flex flex-row w-full items-center !gap-4 !px-2">
Expand Down
21 changes: 10 additions & 11 deletions apps/frontend/src/app/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,30 +7,29 @@ import Header from '../components/Header';
import ProjectCard from '../components/ProjectCard';
import { useApi } from '@/hooks/useApi';
import { useAuth } from '@/context/AuthContext';
import { Dashboard, ProjectSummary } from '@/types';

/**
* Landing page for a signed-in user, and the target the login flow redirects to.
*
* The Navbar has always linked here; the route simply never existed.
*/

interface ProjectRow {
project_id: number;
name: string;
total_budget: number | string | null;
}

export default function DashboardPage() {
const api = useApi();
const { user } = useAuth();
const [projects, setProjects] = useState<ProjectRow[]>([]);
const [projects, setProjects] = useState<ProjectSummary[]>([]);
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);

// /projects/dashboard rather than /projects: it returns the same set of
// projects (scoped to the caller) already carrying the spend and staff counts
// the cards need, so there is nothing left to aggregate client-side.
const load = useCallback(async () => {
try {
setError(null);
setProjects(await api.get<ProjectRow[]>('/projects'));
const dashboard = await api.get<Dashboard>('/projects/dashboard');
setProjects(dashboard.projects ?? []);
} catch (err) {
setError(err instanceof Error ? err.message : 'Could not load projects');
} finally {
Expand Down Expand Up @@ -83,9 +82,9 @@ export default function DashboardPage() {
<ProjectCard
variant="active"
name={project.name}
total_budget={Number(project.total_budget ?? 0)}
budget_used={0}
members={0}
total_budget={project.total_budget ?? 0}
budget_used={project.spent}
members={project.staff_count}
/>
</Link>
))}
Expand Down
21 changes: 10 additions & 11 deletions apps/frontend/src/app/projects/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import NavBar from '../components/Navbar';
import Header from '../components/Header';
import ProjectCard from '../components/ProjectCard';
import { useApi } from '@/hooks/useApi';
import { Dashboard, ProjectSummary } from '@/types';

/**
* Projects index. The Navbar has always linked to /projects, but only the
Expand All @@ -15,22 +16,20 @@ import { useApi } from '@/hooks/useApi';
* `output: 'export'`.
*/

interface ProjectRow {
project_id: number;
name: string;
total_budget: number | string | null;
}

export default function ProjectsListPage() {
const api = useApi();
const [projects, setProjects] = useState<ProjectRow[]>([]);
const [projects, setProjects] = useState<ProjectSummary[]>([]);
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);

// /projects/dashboard rather than /projects: it returns the same set of
// projects (scoped to the caller) already carrying the spend and staff counts
// the cards need, so there is nothing left to aggregate client-side.
const load = useCallback(async () => {
try {
setError(null);
setProjects(await api.get<ProjectRow[]>('/projects'));
const dashboard = await api.get<Dashboard>('/projects/dashboard');
setProjects(dashboard.projects ?? []);
} catch (err) {
setError(err instanceof Error ? err.message : 'Could not load projects');
} finally {
Expand Down Expand Up @@ -81,9 +80,9 @@ export default function ProjectsListPage() {
<ProjectCard
variant="active"
name={project.name}
total_budget={Number(project.total_budget ?? 0)}
budget_used={0}
members={0}
total_budget={project.total_budget ?? 0}
budget_used={project.spent}
members={project.staff_count}
/>
</Link>
))}
Expand Down
22 changes: 22 additions & 0 deletions apps/frontend/src/types/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,28 @@ export interface Project {
created_at: string | null;
};

/** A project as `GET /projects/dashboard` returns it: the row plus its aggregates. */
export interface ProjectSummary {
project_id: number;
name: string;
total_budget: number | null;
currency: string | null;
spent: number;
staff_count: number;
spent_percentage: number;
}

export interface Dashboard {
summary: {
topExpenseCategory: { category: string; amount: number } | null;
totalSpent: number;
totalProjects: number;
averageSpendPerProject: number;
};
projects: ProjectSummary[];
expensesByMonth: { month: string; category: string; amount: number }[];
}

export type ProjectRole = 'PI' | 'Accountant' | 'Staff' | 'Admin';

export interface Member {
Expand Down
19 changes: 19 additions & 0 deletions apps/frontend/test/components/ProjectCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,25 @@ describe('ProjectCard (active)', () => {
const percentage = Math.round((activeMockProps.budget_used / activeMockProps.total_budget) * 100);
expect(screen.getByText(`${percentage}%`)).toBeInTheDocument();
});

it('renders 0% rather than NaN when the project has no budget', () => {
render(<ProjectCard {...activeMockProps} total_budget={0} budget_used={0} />);
expect(screen.getByText('0%')).toBeInTheDocument();
});

it('renders 0% rather than Infinity when spend exists but no budget is set', () => {
render(<ProjectCard {...activeMockProps} total_budget={0} budget_used={4500} />);
expect(screen.getByText('0%')).toBeInTheDocument();
});

it('reports overspend honestly but keeps the bar within the track', () => {
const { container } = render(
<ProjectCard {...activeMockProps} total_budget={1000} budget_used={2000} />,
);
expect(screen.getByText('200%')).toBeInTheDocument();
const bar = container.querySelector('.bg-core-green') as HTMLElement;
expect(bar.style.width).toBe('100%');
});
});

describe('ProjectCard (archive)', () => {
Expand Down
89 changes: 89 additions & 0 deletions apps/frontend/test/components/ProjectsPage.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { render, screen, waitFor } from '../utils';
import ProjectsListPage from '@/app/projects/page';
import DashboardPage from '@/app/dashboard/page';

const mockAuthedFetch = jest.fn();
jest.mock('../../src/lib/authClient', () => ({
...jest.requireActual('../../src/lib/authClient'),
authedFetch: (...args: Parameters<typeof mockAuthedFetch>) => mockAuthedFetch(...args),
}));

const dashboard = {
summary: {
topExpenseCategory: { category: 'Travel', amount: 6800 },
totalSpent: 13700,
totalProjects: 2,
averageSpendPerProject: 6850,
},
projects: [
{
project_id: 1,
name: 'Clinician Communication Study',
total_budget: 500000,
currency: 'USD',
spent: 9200,
staff_count: 2,
spent_percentage: 1.84,
},
{
project_id: 4,
name: 'Proj B',
total_budget: null,
currency: 'USD',
spent: 0,
staff_count: 0,
spent_percentage: 0,
},
],
expensesByMonth: [],
};

beforeEach(() => {
jest.clearAllMocks();
mockAuthedFetch.mockImplementation((url: string) => {
if (url === '/auth/me') {
return Promise.resolve({ userId: 1, email: 'ashley@branch.org', name: 'Ashley Duggan', isAdmin: true });
}
if (url === '/projects/dashboard') return Promise.resolve(dashboard);
return Promise.reject(new Error(`unexpected request: ${url}`));
});
});

describe.each([
['Projects index', ProjectsListPage],
['Dashboard', DashboardPage],
])('%s', (_name, Page) => {
it('reads its project cards from GET /projects/dashboard', async () => {
render(<Page />);
await waitFor(() => {
expect(mockAuthedFetch).toHaveBeenCalledWith('/projects/dashboard', { method: 'GET' });
});
// The list endpoint carries no spend or staffing, so it must not be the source.
expect(mockAuthedFetch).not.toHaveBeenCalledWith('/projects', expect.anything());
});

it('renders the real spend and staff count, not zeros', async () => {
render(<Page />);
expect(
await screen.findByText((text) => text.includes('9,200') && text.includes('500,000')),
).toBeInTheDocument();
expect(screen.getByText('2 members')).toBeInTheDocument();
expect(screen.getByText('2%')).toBeInTheDocument();
});

it('renders a project with no budget as 0% instead of NaN', async () => {
render(<Page />);
expect(await screen.findByText('Proj B')).toBeInTheDocument();
expect(screen.getByText('0 members')).toBeInTheDocument();
expect(screen.getByText('0%')).toBeInTheDocument();
});

it('surfaces a failure instead of rendering empty cards', async () => {
mockAuthedFetch.mockImplementation((url: string) => {
if (url === '/auth/me') return Promise.resolve({ userId: 1, email: 'a@b.org', name: 'A', isAdmin: false });
return Promise.reject(new Error('Authentication required'));
});
render(<Page />);
expect(await screen.findByText('Authentication required')).toBeInTheDocument();
});
});