Frontend Developer Guide¶
Quick Start¶
Prerequisites¶
- Node.js: 20.19.0 or higher (specified in package.json engines)
- npm: 10.x or higher (comes with Node.js)
- Code editor: VS Code recommended with extensions:
- ESLint
- Prettier
- Tailwind CSS IntelliSense
- TypeScript and JavaScript Language Features
- Basic knowledge: React 18+, TypeScript, Tailwind CSS, React Router
Initial Setup¶
-
Navigate to frontend directory
-
Install dependencies
-
Set up environment variables
Add the following to .env:
# Development (local backend)
VITE_API_URL=http://localhost:8000/api/v1
VITE_ENVIRONMENT=development
# Production (Azure - uncomment when deploying)
# VITE_API_URL=https://lco-function-app-hggkctgbhhhghgef.canadacentral-01.azurewebsites.net/api/v1
# VITE_ENVIRONMENT=production
- Start development server
The app will be available at http://localhost:5173
Vite will display the URL in the terminal
- Verify backend connection
- Ensure the backend API is running on
http://localhost:8000 - Open browser DevTools (F12)
- Check Console tab for API configuration logs
- Check Network tab for API requests
- Visit
http://localhost:8000/api/docsto verify backend is up
Development Workflow¶
Running the Application¶
# Development server with hot reload
npm run dev
# Build for production
npm run build
# Preview production build locally
npm run preview
# Lint code
npm run lint
Project Scripts¶
| Command | Description |
|---|---|
npm run dev |
Start Vite dev server with HMR on port 5173 |
npm run build |
Run TypeScript type check (tsc -b) + Vite production build |
npm run lint |
Run ESLint on codebase (check for errors) |
npm run preview |
Preview production build locally (after npm run build) |
Note: All scripts are defined in package.json. The build script runs type checking before building to catch TypeScript errors early.
Directory Structure Guide¶
/src/components/¶
UI Components (/ui/)
- Reusable, atomic components
- Based on Radix UI primitives
- Styled with Tailwind CSS
- No business logic
Feature Components
- Business-specific components
- May contain state and logic
- Examples: Sidebar.tsx, CrewsTable.tsx, DisciplineCard.tsx
Dialog/Modal Components
- Focused workflows (create, edit)
- Examples: ServiceDialog.tsx, CreateCrewDialog.tsx
/src/pages/¶
Route-specific page components: - Data fetching and loading states - Layout composition - Route parameter handling - Navigation logic
/src/services/api/¶
API integration modules:
- One file per domain (projects, services, crews, etc.)
- Typed request/response interfaces
- Error handling
- Uses centralized apiClient from config.ts
/src/types/¶
TypeScript type definitions: - Match backend data models - Include request/response DTOs - Helper types for UI state
/src/utils/¶
Pure utility functions: - Business logic calculations - Data transformations - No side effects
Code Style Guidelines¶
TypeScript Best Practices¶
-
Always define types/interfaces
-
Use type inference where obvious
-
Prefer interfaces over types for objects
Component Structure¶
Functional Component Pattern:
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { projectsApi } from '@/services/api/projects';
import type { Project } from '@/types/project';
interface MyComponentProps {
title: string;
onSave?: (data: Project) => void;
}
export function MyComponent({ title, onSave }: MyComponentProps) {
// Hooks first
const { projectId } = useParams();
const navigate = useNavigate();
const [data, setData] = useState<Project | null>(null);
// Effects
useEffect(() => {
// Fetch data
}, [projectId]);
// Event handlers
const handleSave = () => {
// Logic
};
// Render
return (
<div>
{/* JSX */}
</div>
);
}
Naming Conventions¶
Files:
- Components: PascalCase.tsx (e.g., ProjectDetailPage.tsx)
- Utilities: camelCase.ts (e.g., crewRateCalculator.ts)
- Types: camelCase.ts (e.g., project.ts)
Variables & Functions:
- camelCase for variables and functions
- PascalCase for components and types
- UPPER_SNAKE_CASE for constants
Event Handlers:
- Prefix with handle: handleClick, handleSave, handleCrewSelect
Boolean Props:
- Prefix with is, has, should: isLoading, hasError, shouldRender
Import Organization¶
// 1. External libraries
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
// 2. UI components
import { Button } from '@/components/ui/button';
import { Table } from '@/components/ui/table';
// 3. Feature components
import { PageHeader } from '@/components/PageHeader';
import { Sidebar } from '@/components/Sidebar';
// 4. Services/API
import { projectsApi } from '@/services/api/projects';
// 5. Types
import type { Project } from '@/types/project';
// 6. Utilities/Helpers
import { cn } from '@/lib/utils';
// 7. Styles/Assets
import './MyComponent.css';
Working with APIs¶
API Call Pattern¶
import { useState, useEffect } from 'react';
import { projectsApi } from '@/services/api/projects';
import { useToast } from '@/hooks/use-toast';
export function MyPage() {
const [data, setData] = useState<Project[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const { toast } = useToast();
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
setError(null);
const response = await projectsApi.getProjects();
setData(response.data);
} catch (err: any) {
console.error('Error fetching projects:', err);
setError('Failed to load projects');
toast({
title: 'Error',
description: 'Failed to load projects',
variant: 'destructive',
});
} finally {
setLoading(false);
}
};
fetchData();
}, [toast]);
// Render loading, error, or data
}
Adding a New API Endpoint¶
-
Define types in
/src/types/ -
Create API module in
/src/services/api/// services/api/myDomain.ts import { apiClient } from './config'; import type { MyEntity, MyEntityCreate } from '@/types/myDomain'; const ENDPOINT = '/my-entities'; export const myDomainApi = { async getAll(): Promise<MyEntity[]> { const response = await apiClient.get<MyEntity[]>(ENDPOINT); return response.data; }, async create(data: MyEntityCreate): Promise<MyEntity> { const response = await apiClient.post<MyEntity>(ENDPOINT, data); return response.data; }, }; -
Use in component
Creating New Components¶
UI Component (shadcn/ui style)¶
// components/ui/my-widget.tsx
import * as React from 'react';
import { cn } from '@/lib/utils';
export interface MyWidgetProps extends React.HTMLAttributes<HTMLDivElement> {
variant?: 'default' | 'outline';
size?: 'sm' | 'md' | 'lg';
}
const MyWidget = React.forwardRef<HTMLDivElement, MyWidgetProps>(
({ className, variant = 'default', size = 'md', ...props }, ref) => {
return (
<div
ref={ref}
className={cn(
'base-styles',
variant === 'outline' && 'outline-styles',
size === 'sm' && 'text-sm',
className
)}
{...props}
/>
);
}
);
MyWidget.displayName = 'MyWidget';
export { MyWidget };
Feature Component¶
// components/MyFeature.tsx
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { myDomainApi } from '@/services/api/myDomain';
interface MyFeatureProps {
projectId: string;
onComplete?: () => void;
}
export function MyFeature({ projectId, onComplete }: MyFeatureProps) {
const [isLoading, setIsLoading] = useState(false);
const handleAction = async () => {
setIsLoading(true);
try {
await myDomainApi.create({ /* data */ });
onComplete?.();
} catch (err) {
console.error(err);
} finally {
setIsLoading(false);
}
};
return (
<div>
<Button onClick={handleAction} disabled={isLoading}>
{isLoading ? 'Loading...' : 'Submit'}
</Button>
</div>
);
}
Adding a New Page/Route¶
-
Create page component in
/src/pages/// pages/MyNewPage.tsx import { PageHeader } from '@/components/PageHeader'; export function MyNewPage() { const breadcrumbs = [ { label: 'Home', href: '/' }, { label: 'My New Page' }, ]; return ( <div className="flex flex-col h-full"> <PageHeader title="My New Page" breadcrumbs={breadcrumbs} /> <div className="flex-1 bg-neutral-100 p-6"> {/* Content */} </div> </div> ); } -
Add route in
App.tsx -
Add navigation link (if needed in Sidebar)
State Management Patterns¶
The application uses a hybrid approach with Zustand for global state and React hooks for local state.
Global State with Zustand¶
Project Context Store:
import { useProjectStore } from '@/stores/projectStore';
function MyComponent() {
// Access context
const { projectId, serviceId, clientId } = useProjectStore();
// Set context (usually in page components)
const { setContext } = useProjectStore();
useEffect(() => {
setContext({ projectId, serviceId, clientId });
}, [projectId, serviceId, clientId]);
// Check if context exists
const { hasContext } = useProjectStore();
if (!hasContext()) {
return <div>No project context</div>;
}
// Get query params (non-null values)
const { getQueryParams } = useProjectStore();
const params = getQueryParams(); // { projectId: string, serviceId: string, clientId: string } | null
}
Benefits: - Eliminates prop drilling - Redux DevTools integration - Type-safe with TypeScript - Minimal boilerplate
Custom Hooks for Business Logic¶
The app extensively uses custom hooks to encapsulate reusable logic:
// Data fetching with pagination
import { useCrewMembers } from '@/hooks';
const { crewMembersMap, crewMembers, loading, error } = useCrewMembers({
country: 'CA',
province: 'AB',
enabled: true
});
// Crew rate calculations
import { useCrewRates } from '@/hooks';
const {
rates,
indirectCosts,
updateLabourIndirectCost,
updateEquipmentIndirectCost
} = useCrewRates(baseLabourRate, baseEquipmentRate, initialIndirectCosts);
// MTO management
import { useMTOManagement } from '@/hooks';
const {
mtoItems,
mtoLoading,
handleCellEdit,
handleSaveMTOChanges,
handleBulkEditMTO
} = useMTOManagement({ projectId, disciplineApiEnum, selectedDiscipline, toast });
See src/hooks/README.md for complete documentation of all available hooks.
Component State¶
For simple, local state:
Derived State¶
Compute from existing state/props instead of storing:
// Good - derived
const totalPrice = items.reduce((sum, item) => sum + item.price, 0);
// Avoid - redundant state
const [totalPrice, setTotalPrice] = useState(0);
Form State¶
const [formData, setFormData] = useState({
name: '',
email: '',
description: '',
});
const handleChange = (field: keyof typeof formData) => (
e: React.ChangeEvent<HTMLInputElement>
) => {
setFormData(prev => ({ ...prev, [field]: e.target.value }));
};
Session Storage (for temporary persistence)¶
// Save
sessionStorage.setItem('myKey', JSON.stringify(data));
// Load
const stored = sessionStorage.getItem('myKey');
const data = stored ? JSON.parse(stored) : defaultValue;
// Clear
sessionStorage.removeItem('myKey');
Navigation Patterns¶
Navigate with Query Params¶
import { useNavigate } from 'react-router-dom';
const navigate = useNavigate();
// With query params
navigate(`/projects/${projectId}?clientId=${clientId}`);
// With state
navigate('/some-page', {
state: { returnPath: '/previous-page', data: someData }
});
Access Route Params¶
import { useParams } from 'react-router-dom';
const { projectId, serviceId } = useParams<{
projectId: string;
serviceId: string;
}>();
Access Query Params¶
const urlParams = new URLSearchParams(window.location.search);
const clientId = urlParams.get('clientId');
Access Navigation State¶
import { useLocation } from 'react-router-dom';
const location = useLocation();
const { activeTab, updatedData } = location.state || {};
Styling with Tailwind CSS¶
Utility Classes¶
<div className="flex items-center justify-between p-4 bg-white border border-neutral-200">
<h1 className="text-lg font-medium text-neutral-950">Title</h1>
<button className="px-4 py-2 text-sm bg-blue-900 text-white rounded hover:bg-blue-800">
Action
</button>
</div>
Conditional Classes with cn()¶
import { cn } from '@/lib/utils';
<div className={cn(
'base-class',
isActive && 'active-class',
variant === 'primary' && 'primary-variant-class',
className // Allow prop override
)} />
Responsive Design¶
<div className="w-full md:w-1/2 lg:w-1/3">
{/* Full width on mobile, half on tablet, third on desktop */}
</div>
Custom Theme Colors¶
Defined in tailwind.config.js:
colors: {
'blue-900': '#1e3a8a', // Primary brand color
'neutral-50': '#fafafa',
'neutral-950': '#0a0a0a',
}
Working with Advanced Tables¶
The application uses @tanstack/react-table with custom components for advanced data tables.
DataTable Component¶
import { DataTable } from '@/components/table/DataTable';
import { ColumnDef } from '@tanstack/react-table';
// Define columns
const columns: ColumnDef<MyDataType>[] = [
{
accessorKey: 'name',
header: 'Name',
cell: ({ row }) => <span>{row.original.name}</span>,
},
// ... more columns
];
function MyTablePage() {
const [data, setData] = useState<MyDataType[]>([]);
const [filters, setFilters] = useState<FilterGroup>({ id: 'root', logicalOperator: 'AND', conditions: [] });
const [sorting, setSorting] = useState<SortingState>([]);
const [searchTerm, setSearchTerm] = useState('');
return (
<DataTable
data={data}
columns={columns}
filters={filters}
onFiltersChange={setFilters}
sorting={sorting}
onSortingChange={setSorting}
searchTerm={searchTerm}
onSearchChange={setSearchTerm}
// Optional features
enableRowSelection={true}
enableColumnVisibility={true}
enableBulkEdit={true}
onBulkEdit={handleBulkEdit}
onBulkDelete={handleBulkDelete}
/>
);
}
Editable Cells¶
import { EditableCell } from '@/components/table/EditableCell';
const columns: ColumnDef<MyDataType>[] = [
{
accessorKey: 'quantity',
header: 'Quantity',
cell: ({ row }) => (
<EditableCell
value={row.original.quantity}
type="number"
onSave={(newValue) => handleCellEdit(row.id, { quantity: newValue })}
/>
),
},
];
Advanced Filtering¶
Users can build complex filters with AND/OR logic via the filter builder UI. The filter state structure:
interface FilterGroup {
id: string;
logicalOperator: 'AND' | 'OR';
conditions: Array<{
field: string;
operator: 'equals' | 'contains' | 'greaterThan' | 'lessThan';
value: string | number;
} | FilterGroup>;
}
Column Visibility¶
// Enable column visibility with drag-and-drop reordering
<DataTable
columns={columns}
data={data}
enableColumnVisibility={true}
/>
Users can show/hide columns and reorder them via the column visibility dialog.
Working with Forms¶
Controlled Form Example¶
import { useState } from 'react';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
export function MyForm() {
const [formData, setFormData] = useState({
name: '',
description: '',
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
// Submit logic
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<Label htmlFor="name">Name</Label>
<Input
id="name"
value={formData.name}
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
placeholder="Enter name"
/>
</div>
<div>
<Label htmlFor="description">Description</Label>
<Input
id="description"
value={formData.description}
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
/>
</div>
<Button type="submit">Save</Button>
</form>
);
}
Error Handling¶
Component-Level Errors¶
try {
await api.call();
} catch (err: any) {
console.error('Detailed error:', err);
toast({
title: 'Error',
description: err.response?.data?.message || 'Something went wrong',
variant: 'destructive',
});
}
Loading & Error States¶
if (loading) {
return <Loader2 className="h-8 w-8 animate-spin" />;
}
if (error) {
return (
<div className="text-red-600">
<p>{error}</p>
<button onClick={retry}>Retry</button>
</div>
);
}
return <div>{/* Normal content */}</div>;
Toast Notifications¶
import { useToast } from '@/hooks/use-toast';
const { toast } = useToast();
// Success
toast({
title: 'Success',
description: 'Project created successfully',
});
// Error
toast({
title: 'Error',
description: 'Failed to save project',
variant: 'destructive',
});
// Info
toast({
title: 'Coming Soon',
description: 'This feature is not yet available',
});
Debugging Tips¶
React DevTools¶
Install browser extension for component inspection and state debugging.
Console Logging¶
// Development logs
if (import.meta.env.DEV) {
console.log('Loaded crews from sessionStorage:', parsedCrews);
}
// Always log errors
console.error('Error fetching data:', err);
Network Tab¶
- Monitor API calls in browser DevTools Network tab
- Check request/response payloads
- Verify status codes
Vite Dev Server Logs¶
- Check terminal for server errors
- Hot reload failures
- Compilation errors
Common Patterns & Solutions¶
Prevent Memory Leaks with AbortController¶
useEffect(() => {
const abortController = new AbortController();
const fetchData = async () => {
try {
const data = await api.get('/endpoint', {
signal: abortController.signal
});
setData(data);
} catch (err: any) {
if (err.name === 'AbortError') return; // Ignore abort errors
console.error(err);
}
};
fetchData();
return () => {
abortController.abort(); // Cleanup on unmount
};
}, []);
Optimistic UI Updates¶
const handleDelete = async (id: string) => {
// Optimistically remove from UI
setItems(items.filter(item => item.id !== id));
try {
await api.delete(id);
toast({ title: 'Deleted successfully' });
} catch (err) {
// Revert on error
setItems(originalItems);
toast({ title: 'Delete failed', variant: 'destructive' });
}
};
Batch API Calls¶
// Fetch all pages
const allItems: Item[] = [];
let skip = 0;
const limit = 100;
let hasMore = true;
while (hasMore) {
const response = await api.getItems({ skip, limit });
allItems.push(...response.data);
hasMore = response.hasMore;
skip += limit;
// Safety limit
if (skip > 10000) break;
}
Performance Best Practices¶
-
Memoization:
-
Callback Memoization:
-
Avoid Inline Object/Array Creation:
-
Conditional Rendering:
Testing (Future Setup)¶
Unit Tests (Vitest - planned)¶
import { describe, it, expect } from 'vitest';
import { calculateCrewLabourRate } from '@/utils/crewRateCalculator';
describe('crewRateCalculator', () => {
it('should calculate weighted labour rate', () => {
const result = calculateCrewLabourRate(mockCrew, mockProject, mockMap);
expect(result).toBe(50.5);
});
});
Component Tests (React Testing Library - planned)¶
import { render, screen } from '@testing-library/react';
import { MyComponent } from './MyComponent';
it('renders component with props', () => {
render(<MyComponent title="Test" />);
expect(screen.getByText('Test')).toBeInTheDocument();
});
Troubleshooting¶
Build Errors¶
Issue: TypeScript errors during build
- Check all type errors - Ensure imports are correct - Verify types match backend APIIssue: Module not found
- Check import paths
- Verify @/ alias in vite.config.ts
Runtime Errors¶
Issue: API calls failing (CORS, 404)
- Check VITE_API_URL in .env
- Verify backend is running
- Check Network tab in DevTools
Issue: State not updating
- Check if state setter is called
- Verify dependencies in useEffect
- Check for immutability issues
Styling Issues¶
Issue: Tailwind classes not applying
- Check tailwind.config.js content paths
- Restart dev server after config changes
- Verify class names are correct
Useful Resources¶
Official Documentation¶
Internal Resources¶
Contributing Guidelines¶
- Branch naming:
- Features:
feature/my-feature-name - Bug fixes:
fix/bug-description - Hotfixes:
hotfix/critical-issue - Commit messages:
- Clear, descriptive commit messages
- Follow conventional commits format when possible
- Example: "feat: add crew rate calculator", "fix: resolve navigation issue"
- Pull requests:
- Include clear description of changes
- Add screenshots for UI changes
- Reference related issues
- Ensure CI checks pass
- Code review:
- Address all review comments
- Request re-review after changes
- Be responsive to feedback
- Testing:
- Test all changes locally
- Ensure no console errors or warnings
- Check cross-browser compatibility (Chrome, Firefox, Safari, Edge)
- Verify mobile responsiveness
- Code quality:
- Run
npm run lintbefore committing - Run
npm run buildto catch type errors - Follow existing code patterns and conventions
Getting Help¶
- Check existing documentation in
docs/frontend/ - Review similar existing components for patterns
- Consult the codebase's existing implementations
- Ask team members for clarification on business logic