1. Overview
The Site Management module provides comprehensive functionality for creating, editing, and managing sites within the Nimbly Audit Admin system. It handles location-based configurations, user assignments, department associations, and scheduling capabilities.
1.1 Key Features
- Site Creation: Multi-step form for creating new sites with validation
- Site Editing: Comprehensive edit interface with change tracking
- Location Management: Support for both address-based and coordinate-based locations
- User Management: Auditor and supervisor assignment with department-based filtering
- Scheduling: Quick [schedule](../Schedule/Schedule Listing/ScheduleListingOverview.md) and bulk [schedule](../Schedule/Schedule Listing/ScheduleListingOverview.md) capabilities
- Activity History: Complete audit trail of all site modifications
2. Architecture
graph TB subgraph "Frontend Layer" R[Routes] --> P[Pages] P --> C[Components] C --> S[State/Redux] end subgraph "Components" SC[SitesContainer] --> SD[SiteDetails] SC --> SM[SitesManager] SD --> SDF[SiteDetailsForm] SD --> SDA[SiteDetailsAuditors] SD --> SDS[SiteDetailsSupervisors] end subgraph "State Layer" S --> AS[Admin State] S --> SS[Sites State] S --> DS[Departments State] end subgraph "API Layer" S --> API[API Calls] API --> BE[Backend Services] end
3. Routes and Screens
| Route Path | Component | Purpose | Access Control |
|---|---|---|---|
/admin/sites | SitesManager | Site listing and management | Admin role required |
/admin/sites/new | SiteDetailsContainer | Create new site | Create permission |
/admin/sites/:siteId | SiteDetailsContainer | Edit existing site | Edit permission |
/admin/sites/bulk | SitesBulkModal | Bulk operations | Admin role |
/admin/sites/[schedule](../Schedule/Schedule Listing/ScheduleListingOverview.md) | SitesScheduleModal | Quick scheduling | Schedule permission |
API Endpoints
Core Site APIs
| Endpoint | Method | Purpose | File Reference |
|---|---|---|---|
/sites | GET | Fetch all sites | src/services/site.api.ts |
/sites/paginate | GET | Fetch paginated sites | src/services/site.api.ts |
/sites/{siteID}/populated | GET | Fetch single site with relations | src/services/site.api.ts |
/sites/compact | GET | Fetch compact site list | src/services/sites/siteList.service.ts |
/sites | POST | Create new site | src/services/site.api.ts |
/sites/{siteID} | PUT | Update existing site | src/services/site.api.ts |
/sites/internal | POST | Internal site queries | src/services/site.api.ts |
Site Groups APIs
| Endpoint | Method | Purpose | File Reference |
|---|---|---|---|
/sites/group | GET | Fetch site groups | src/services/sites/siteGroups.services.ts |
/sites/group | POST | Create site group | src/services/sites/siteGroups.services.ts |
/sites/group | PUT | Update site group | src/services/sites/siteGroups.services.ts |
/sites/group/{groupID} | DELETE | Delete site group | src/services/sites/siteGroups.services.ts |
Supporting APIs
| Endpoint | Method | Purpose | File Reference |
|---|---|---|---|
/[departments](../Departments/DepartmentOverview.md) | GET | Fetch departments | src/services/department.api.ts |
/users/by-[department](../Departments/DepartmentOverview.md) | GET | Fetch users by department | src/services/user.api.ts |
/admin/activity-history | GET | Fetch activity history | src/services/admin.api.ts |
Core Components
SiteDetailsContainer
Location: src/components/sites/SiteDetails/SiteDetailsContainer.tsx
Primary component for site creation and editing with the following features:
State Management
- Local state for form validation, location type, timezone data
- Redux integration for site, department, and user data
- Change tracking to prevent data loss
Location Handling
- Address Mode: Google Places autocomplete with Geosuggest
- Coordinates Mode: Manual latitude/longitude input
- Automatic timezone detection based on coordinates
- Google Maps reverse geocoding for address components
Department Management
- Dynamic department selection and filtering
- User assignment based on department membership
- Supervisor and auditor role management
Form Validation
const validation = [
{ field: 'site Name', isValid: true },
{ field: 'auditors', isValid: true },
{ field: 'owner', isValid: true },
{ field: 'address', isValid: true },
{ field: 'timezone', isValid: true },
{ field: 'country', isValid: true },
{ field: 'city', isValid: true },
{ field: 'province', isValid: true }
];Business Logic
- Radius Calculation: Supports default, small, medium, large, and custom radius values
- Coordinate Validation: Ensures valid latitude (-90 to 90) and longitude (-180 to 180)
- Timezone Mapping: Converts UTC offsets to timezone strings
- Department Filtering: Filters available users based on selected departments
Site Creation Flow
flowchart TD A[User clicks 'New Site'] --> B[SiteDetailsContainer loads] B --> C{Form Initialization} C --> D[Load Departments] C --> E[Load Timezone List] C --> F[Initialize Empty Site Object] D --> G[User fills Site Name] G --> H[User selects Location Type] H --> I{Address or Coordinates?} I -->|Address| J[Geosuggest Component] I -->|Coordinates| K[Manual Lat/Lng Input] J --> L[Google Places API] L --> M[Auto-populate Address Fields] M --> N[Auto-detect Timezone] K --> O[Reverse Geocoding] O --> M N --> P[Select Departments] P --> Q[Assign Auditors/Supervisors] Q --> R[Set Radius] R --> S[Validation Check] S -->|Valid| T[Submit to API] S -->|Invalid| U[Show Validation Errors] U --> G T --> V[Redux Action] V --> W[Saga Process] W --> X[API Call] X --> Y[Success/Error Handling] Y --> Z[Navigate to Site List]
Site Edit Flow
flowchart TD A[User clicks Edit Site] --> B[Load Site Data] B --> C[Populate Form Fields] C --> D[Load Related Data] D --> E[Departments, Users, History] E --> F[User Modifies Fields] F --> G[Change Detection] G --> H[Enable Save Button] G --> I[Show Unsaved Changes Warning] H --> J[User clicks Save] J --> K[Validate Changes] K -->|Valid| L[Submit Update] K -->|Invalid| M[Show Errors] M --> F L --> N[Update API Call] N --> O[Success/Error Handling] O --> P[Update Redux State] P --> Q[Show Success Message]
Package Dependencies
Maps & Geolocation
| Package | Version | Purpose | Usage in Site Module |
|---|---|---|---|
@react-google-maps/api | v1.9.2 | Google Maps integration | Map display for site location |
react-geosuggest | v2.12.0 | Address autocomplete | Location search and selection |
google-map-react | v2.1.10 | Alternative Maps component | Backup map implementation |
Form Handling & Validation
| Package | Version | Purpose | Usage in Site Module |
|---|---|---|---|
formik | v2.2.9 | Form state management | Complex form handling |
yup | v0.32.11 | Schema validation | Field validation rules |
react-hook-form | v6 | Alternative form library | Lightweight form handling |
@hookform/resolvers | v1.3.7 | Validation resolvers | Integration with validation libraries |
UI Components
| Package | Version | Purpose | Usage in Site Module |
|---|---|---|---|
react-select | v3.1.0 | Customizable dropdowns | Department, user, timezone selection |
@radix-ui/react-* | Various | Modern UI primitives | Dropdowns, popovers, scrollable areas |
react-beautiful-dnd | v13.1.1 | Drag and drop | Site ordering in lists |
styled-components | v5.1.0 | CSS-in-JS styling | Component styling |
Data & State Management
| Package | Version | Purpose | Usage in Site Module |
|---|---|---|---|
redux | v4.0.5 | State management | Global site state |
react-redux | v7.2.0 | React bindings | Component state connection |
redux-saga | v1.1.3 | Side effects | API call management |
@tanstack/react-query | v4 | Server state | Caching and synchronization |
Date & Time
| Package | Version | Purpose | Usage in Site Module |
|---|---|---|---|
moment | v2.24.0 | Date manipulation | Schedule handling |
moment-timezone | v0.5.26 | Timezone support | Site timezone management |
react-dates | v21.8.0 | Date pickers | Schedule date selection |
File Processing
| Package | Version | Purpose | Usage in Site Module |
|---|---|---|---|
xlsx | v0.16.2 | Excel processing | Bulk site import/export |
papaparse | v5.2.0 | CSV processing | Data import/export |
Utilities
| Package | Version | Purpose | Usage in Site Module |
|---|---|---|---|
lodash | v4.17.21 | Utility functions | Data manipulation |
libphonenumber-js | v1.7.21 | Phone validation | Contact information |
query-string | v7.0.1 | URL parameters | Route state management |
Nimbly Ecosystem
| Package | Version | Purpose | Usage in Site Module |
|---|---|---|---|
@nimbly-technologies/nimbly-common | v1.95.3 | Shared types | Site, user, department types |
@nimbly-technologies/audit-component | v1.1.8 | Internal components | Reusable UI components |
Business Logic and Flows
Location Processing Logic
Google Places Integration
The system integrates with Google Places API for location autocomplete and geocoding:
// Address to coordinates conversion
const handlePlacesChanged = async (place: any) => {
const lat = parseFloat(place.location.lat);
const lng = parseFloat(place.location.lng);
// Coordinate validation
if (isNaN(lat) || isNaN(lng) || lat > 90 || lat < -90 || lng > 180 || lng < -180) {
return; // Invalid coordinates
}
// Auto-populate location fields
setCoordinate({ latitude: lat, longitude: lng });
setTimezone(lat, lng); // Auto-detect timezone
setSiteAddress(place.gmaps.formatted_address);
// Extract location components
place.gmaps.address_components.forEach((component) => {
if (component.types.indexOf('country') !== -1) {
setSiteCountry(component.long_name);
}
if (component.types.indexOf('administrative_area_level_1') !== -1) {
setSiteProvince(component.long_name);
}
if (component.types.indexOf('administrative_area_level_2') !== -1) {
setSiteCity(component.long_name);
}
});
};Timezone Detection
Automatic timezone detection based on coordinates:
const timezoneDetection = async (lat: number, lng: number) => {
const res = await getTimezone(lat, lng);
if (res.status === 'OK') {
setSiteTimezone(res.rawOffset / 60, res.timeZoneId);
}
};Radius Calculation Logic
Sites support different radius configurations for geofencing:
const radiusOptions = [
{ label: 'Default', value: 'default' },
{ label: 'Small (50m)', value: 'small' },
{ label: 'Medium (100m)', value: 'medium' },
{ label: 'Large (200m)', value: 'large' },
{ label: 'Custom', value: 'custom' }
];
// Custom radius validation
const validateCustomRadius = (value: number) => {
return value > 0 && value <= 1000; // Max 1km radius
};Department Assignment Logic
Users are filtered and assigned based on department membership:
// Department-based user filtering
const getDepartmentUsers = (departmentId: string) => {
const deptUsers = departmentsIndex[departmentId]?.users || [];
return deptUsers.filter(user =>
user.permissions.includes('AUDIT_SITE') &&
user.status === 'active'
);
};
// Supervisor assignment validation
const validateSupervisorAssignment = (supervisors: SiteSupervisor[]) => {
// Each department can have max 3 supervisors
const deptSupervisorCount = {};
supervisors.forEach(s => {
deptSupervisorCount[s.departmentID] =
(deptSupervisorCount[s.departmentID] || 0) + 1;
});
return Object.values(deptSupervisorCount).every(count => count <= 3);
};Validation Rules
Form Validation Logic
const validateSiteForm = (site: Site) => {
const rules = [
{ field: 'name', required: true, minLength: 3 },
{ field: 'address', required: !organization?.allowCalibration },
{ field: 'timezone', required: true },
{ field: 'coordinates', validator: validateCoordinates },
{ field: 'auditors', validator: (team) => team.length > 0 || siteKey === 'new' }
];
return rules.every(rule => {
if (rule.required && !site[rule.field]) return false;
if (rule.minLength && site[rule.field]?.length < rule.minLength) return false;
if (rule.validator && !rule.validator(site[rule.field])) return false;
return true;
});
};Coordinate Validation
const isValidCoordinate = (coord: string) => {
const num = parseFloat(coord);
return !isNaN(num) && isFinite(num);
};
const validateLatitude = (lat: number) => lat >= -90 && lat <= 90;
const validateLongitude = (lng: number) => lng >= -180 && lng <= 180;Site Save Process
sequenceDiagram participant U as User participant C as Component participant V as Validator participant A as API participant R as Redux U->>C: Click Save C->>V: Validate Form V-->>C: Validation Result alt Validation Fails C->>U: Show Errors else Validation Passes C->>A: Submit Site Data A-->>C: API Response alt API Success C->>R: Update State R-->>C: State Updated C->>U: Show Success else API Error C->>U: Show Error end end
Data Models
Site Interface
interface Site {
siteID: string;
name: string;
subtitle?: string;
address: string;
locationName: string;
coordinates: {
latitude: number;
longitude: number;
};
country: string;
province: string;
city: string;
timezone: string;
utcOffset: number;
radius: 'default' | 'small' | 'medium' | 'large' | 'custom';
radiusCustom?: number;
team: string[]; // Auditor IDs
owner: string; // Owner ID
supervisors: SiteSupervisor[];
departments: Record<string, boolean>;
departmentList: string[];
issueDefaultAssignedUser?: string;
emailTargets: string[];
createdAt: string;
createdBy: string;
updatedAt: string;
updatedBy: string;
}Site Supervisor Interface
interface SiteSupervisor {
departmentID: string;
userID: string;
}
interface PopulatedSiteSupervisor extends SiteSupervisor {
user: {
uid: string;
displayName: string;
email: string;
};
department: {
departmentID: string;
name: string;
};
}State Management
Redux Store Structure
interface SitesState {
list: Site[];
selectedSite: Site | null;
loading: {
list: boolean;
create: boolean;
update: boolean;
delete: boolean;
};
modalVisible: {
bulk: boolean;
schedule: boolean;
bulkSchedule: boolean;
bulkScheduleEdit: boolean;
confirmBlockSite: boolean;
};
filters: {
department: string[];
status: 'active' | 'blocked' | 'all';
search: string;
};
pagination: {
page: number;
limit: number;
total: number;
};
}Redux Actions
// Site CRUD actions
const siteActions = {
FETCH_SITES_REQUEST: 'FETCH_SITES_REQUEST',
FETCH_SITES_SUCCESS: 'FETCH_SITES_SUCCESS',
FETCH_SITES_FAILURE: 'FETCH_SITES_FAILURE',
CREATE_SITE_REQUEST: 'CREATE_SITE_REQUEST',
CREATE_SITE_SUCCESS: 'CREATE_SITE_SUCCESS',
CREATE_SITE_FAILURE: 'CREATE_SITE_FAILURE',
UPDATE_SITE_REQUEST: 'UPDATE_SITE_REQUEST',
UPDATE_SITE_SUCCESS: 'UPDATE_SITE_SUCCESS',
UPDATE_SITE_FAILURE: 'UPDATE_SITE_FAILURE',
SET_SELECTED_SITE: 'SET_SELECTED_SITE',
CLEAR_SITE_STATE: 'CLEAR_SITE_STATE'
};Security and Permissions
Access Control
const sitePermissions = {
VIEW: 'ADMIN_SITES_VIEW',
CREATE: 'ADMIN_SITES_CREATE',
EDIT: 'ADMIN_SITES_EDIT',
DELETE: 'ADMIN_SITES_DELETE',
BULK_OPERATIONS: 'ADMIN_SITES_BULK'
};
// Permission checks in components
const hasPermission = (permission: string) => {
return userPermissions.includes(permission);
};Role-Based Feature Access
const featureGates = {
allowCalibration: organization?.allowCalibration,
bulkOperations: featureAccess.BULK_OPERATIONS,
issueTracker: featureAccess.ISSUE_TRACKER,
advancedScheduling: featureAccess.ADVANCED_SCHEDULING
};Additional Site Components
SitesManager Component
Location: src/components/sites/SitesManager/SitesManager.tsx
The SitesManager serves as the main container for site management operations:
Features
- Site Listing: Paginated table view with sorting and filtering
- Search Functionality: Real-time search across site names and locations
- Department Filtering: Filter sites by associated departments
- Status Management: Toggle between active and blocked sites
- Bulk Operations: Multi-select for bulk actions
Implementation Details
const SitesManager = ({ itemListLimit }: { itemListLimit: number }) => {
const [searchTerm, setSearchTerm] = useState('');
const [selectedDepartments, setSelectedDepartments] = useState<string[]>([]);
const [statusFilter, setStatusFilter] = useState<'active' | 'blocked' | 'all'>('active');
// Pagination logic
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(0);
// Site filtering logic
const filteredSites = useMemo(() => {
return sites.filter(site => {
const matchesSearch = site.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
site.address.toLowerCase().includes(searchTerm.toLowerCase());
const matchesDepartment = selectedDepartments.length === 0 ||
selectedDepartments.some(dept => site.departments[dept]);
const matchesStatus = statusFilter === 'all' ||
(statusFilter === 'active' && !site.blocked) ||
(statusFilter === 'blocked' && site.blocked);
return matchesSearch && matchesDepartment && matchesStatus;
});
}, [sites, searchTerm, selectedDepartments, statusFilter]);
};Site Bulk Operations
SitesBulkModal Component
Location: src/components/sites/SitesBulkModal/SitesBulkModal.tsx
Handles bulk site creation via Excel upload:
Excel Template Structure
const excelTemplate = {
requiredColumns: [
'Site Name',
'Address',
'Country',
'Province',
'City',
'Latitude',
'Longitude',
'Timezone',
'Department'
],
optionalColumns: [
'Subtitle',
'Radius',
'Email Targets',
'Owner',
'Auditors'
]
};Validation Logic
const validateBulkSites = (data: any[]) => {
const errors: string[] = [];
data.forEach((row, index) => {
// Required field validation
if (!row['Site Name']) {
errors.push(`Row ${index + 1}: Site Name is required`);
}
// Coordinate validation
const lat = parseFloat(row['Latitude']);
const lng = parseFloat(row['Longitude']);
if (isNaN(lat) || lat < -90 || lat > 90) {
errors.push(`Row ${index + 1}: Invalid latitude`);
}
if (isNaN(lng) || lng < -180 || lng > 180) {
errors.push(`Row ${index + 1}: Invalid longitude`);
}
// Department validation
if (!departments[row['Department']]) {
errors.push(`Row ${index + 1}: Invalid department`);
}
});
return errors;
};SiteBulkScheduleModal Component
Location: src/components/sites/SiteBulkScheduleModal/SiteBulkScheduleModal.tsx
Manages bulk schedule assignment to multiple sites:
Schedule Validation Rules
const scheduleValidationRules = {
timeSlots: {
minimum: 1,
maximum: 8,
duration: 30, // minutes
interval: 15 // minutes between slots
},
recurrence: {
types: ['daily', 'weekly', 'monthly', 'custom'],
weekdays: ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'],
maxAdvanceDays: 365
},
auditorAssignment: {
maxSitesPerAuditor: 10,
workingHours: {
start: '06:00',
end: '22:00'
}
}
};Site Scheduling System
Schedule Data Model
interface SiteSchedule {
scheduleID: string;
siteID: string;
questionnaireID: string;
auditorID: string;
supervisorID?: string;
// Timing information
scheduledDate: string;
scheduledTime: string;
duration: number; // minutes
// Recurrence pattern
recurrence: {
type: 'none' | 'daily' | 'weekly' | 'monthly' | 'custom';
interval: number;
endDate?: string;
weekdays?: number[]; // 0-6, Sunday to Saturday
monthlyPattern?: 'date' | 'weekday';
};
// Status tracking
status: 'scheduled' | 'in_progress' | 'completed' | 'missed' | 'cancelled';
actualStartTime?: string;
actualEndTime?: string;
completionPercentage: number;
// Metadata
createdAt: string;
createdBy: string;
updatedAt: string;
updatedBy: string;
}Schedule Conflict Detection
const detectScheduleConflicts = (
newSchedule: SiteSchedule,
existingSchedules: SiteSchedule[]
) => {
const conflicts: ScheduleConflict[] = [];
existingSchedules.forEach(existing => {
// Check auditor availability
if (existing.auditorID === newSchedule.auditorID) {
const timeOverlap = checkTimeOverlap(
existing.scheduledTime,
existing.duration,
newSchedule.scheduledTime,
newSchedule.duration
);
if (timeOverlap) {
conflicts.push({
type: 'auditor_conflict',
conflictingScheduleID: existing.scheduleID,
message: `Auditor ${existing.auditorID} already scheduled at this time`
});
}
}
// Check site availability
if (existing.siteID === newSchedule.siteID) {
const dateMatch = existing.scheduledDate === newSchedule.scheduledDate;
const timeOverlap = checkTimeOverlap(
existing.scheduledTime,
existing.duration,
newSchedule.scheduledTime,
newSchedule.duration
);
if (dateMatch && timeOverlap) {
conflicts.push({
type: 'site_conflict',
conflictingScheduleID: existing.scheduleID,
message: `Site already has scheduled audit at this time`
});
}
}
});
return conflicts;
};Site Groups Management
SiteGroupManager Component
Provides hierarchical organization of sites:
interface SiteGroup {
groupID: string;
name: string;
description?: string;
parentGroupID?: string;
departmentID: string;
siteIDs: string[];
// Hierarchy helpers
level: number;
path: string[]; // Array of parent group IDs
// Permissions
managerIDs: string[];
permissions: {
viewSites: boolean;
editSites: boolean;
manageSites: boolean;
viewReports: boolean;
};
createdAt: string;
createdBy: string;
updatedAt: string;
updatedBy: string;
}Group Hierarchy Logic
const buildGroupHierarchy = (groups: SiteGroup[]) => {
const groupMap = new Map<string, SiteGroup>();
const rootGroups: SiteGroup[] = [];
// First pass: create map
groups.forEach(group => {
groupMap.set(group.groupID, { ...group, children: [] });
});
// Second pass: build hierarchy
groups.forEach(group => {
if (group.parentGroupID) {
const parent = groupMap.get(group.parentGroupID);
if (parent) {
parent.children.push(groupMap.get(group.groupID));
}
} else {
rootGroups.push(groupMap.get(group.groupID));
}
});
return rootGroups;
};Activity History System
Activity Tracking
All site operations are tracked for audit trails:
interface SiteActivity {
activityID: string;
siteID: string;
userID: string;
// Activity details
action: 'created' | 'updated' | 'deleted' | 'blocked' | 'unblocked' | 'schedule_added' | 'schedule_removed';
description: string;
// Change tracking
changes: {
field: string;
oldValue: any;
newValue: any;
}[];
// Context
ipAddress: string;
userAgent: string;
sessionID: string;
timestamp: string;
}Activity History Hook
const useSiteActivityHistory = ({ siteID, limit = 50 }) => {
const [activities, setActivities] = useState<SiteActivity[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchActivities = useCallback(async () => {
setLoading(true);
setError(null);
try {
const response = await fetchSiteActivityHistory({
siteID,
limit,
orderBy: 'timestamp',
order: 'desc'
});
setActivities(response.data);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}, [siteID, limit]);
useEffect(() => {
if (siteID && siteID !== 'new') {
fetchActivities();
}
}, [fetchActivities, siteID]);
return { activities, loading, error, refetch: fetchActivities };
};Performance Optimization
Virtual Scrolling for Large Lists
const VirtualizedSiteList = ({ sites, itemHeight = 60 }) => {
const [visibleRange, setVisibleRange] = useState({ start: 0, end: 20 });
const containerRef = useRef<HTMLDivElement>(null);
const handleScroll = useCallback((e: Event) => {
const scrollTop = (e.target as HTMLElement).scrollTop;
const containerHeight = containerRef.current?.clientHeight || 0;
const start = Math.floor(scrollTop / itemHeight);
const end = Math.min(
start + Math.ceil(containerHeight / itemHeight) + 5,
sites.length
);
setVisibleRange({ start, end });
}, [itemHeight, sites.length]);
const visibleSites = sites.slice(visibleRange.start, visibleRange.end);
return (
<div
ref={containerRef}
style={{ height: '400px', overflow: 'auto' }}
onScroll={handleScroll}
>
<div style={{ height: sites.length * itemHeight, position: 'relative' }}>
{visibleSites.map((site, index) => (
<SiteListItem
key={site.siteID}
site={site}
style={{
position: 'absolute',
top: (visibleRange.start + index) * itemHeight,
height: itemHeight
}}
/>
))}
</div>
</div>
);
};Debounced Search Implementation
const useDebounceSearch = (initialValue: string, delay: number = 300) => {
const [searchTerm, setSearchTerm] = useState(initialValue);
const [debouncedTerm, setDebouncedTerm] = useState(initialValue);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedTerm(searchTerm);
}, delay);
return () => {
clearTimeout(handler);
};
}, [searchTerm, delay]);
return [debouncedTerm, setSearchTerm] as const;
};Error Handling and User Feedback
Error Boundary for Site Components
class SiteErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error) {
return { hasError: true, error };
}
componentDidCatch(error, errorInfo) {
// Log error to monitoring service
console.error('Site component error:', error, errorInfo);
// Track error in analytics
ReactGA.exception({
description: error.toString(),
fatal: false
});
}
render() {
if (this.state.hasError) {
return (
<div className="error-fallback">
<h2>Something went wrong with the site management.</h2>
<p>Please refresh the page or contact support if the issue persists.</p>
<button onClick={() => window.location.reload()}>
Refresh Page
</button>
</div>
);
}
return this.props.children;
}
}Toast Notification System
const showSiteNotification = {
success: (message: string) => {
toast.success(message, {
position: 'top-right',
autoClose: 3000,
hideProgressBar: false,
closeOnClick: true,
pauseOnHover: true
});
},
error: (message: string) => {
toast.error(message, {
position: 'top-right',
autoClose: 5000,
hideProgressBar: false,
closeOnClick: true,
pauseOnHover: true
});
},
warning: (message: string) => {
toast.warning(message, {
position: 'top-right',
autoClose: 4000,
hideProgressBar: false,
closeOnClick: true,
pauseOnHover: true
});
}
};Testing Strategy
Unit Test Examples
// Site validation tests
describe('Site Validation', () => {
test('should validate required fields', () => {
const invalidSite = {
name: '',
address: '',
coordinates: { latitude: 0, longitude: 0 }
};
const validation = validateSiteForm(invalidSite);
expect(validation.isValid).toBe(false);
expect(validation.errors).toContain('Site name is required');
});
test('should validate coordinates', () => {
const invalidCoords = { latitude: 91, longitude: 181 };
expect(validateLatitude(invalidCoords.latitude)).toBe(false);
expect(validateLongitude(invalidCoords.longitude)).toBe(false);
});
test('should validate custom radius', () => {
expect(validateCustomRadius(0)).toBe(false);
expect(validateCustomRadius(50)).toBe(true);
expect(validateCustomRadius(1001)).toBe(false);
});
});Integration Test Examples
// Site creation flow test
describe('Site Creation Integration', () => {
test('should create site with valid data', async () => {
const siteData = {
name: 'Test Site',
address: '123 Test Street',
coordinates: { latitude: 40.7128, longitude: -74.0060 },
timezone: 'America/New_York',
utcOffset: -300,
departments: { 'dept1': true }
};
const mockApiResponse = { siteID: 'site123', ...siteData };
jest.spyOn(siteApi, 'createSite').mockResolvedValue(mockApiResponse);
const { getByTestId } = render(<SiteDetailsContainer siteKey="new" />);
// Fill form
fireEvent.change(getByTestId('site-name'), { target: { value: siteData.name } });
fireEvent.click(getByTestId('submit-button'));
await waitFor(() => {
expect(siteApi.createSite).toHaveBeenCalledWith(
expect.objectContaining(siteData)
);
});
});
});Accessibility Features
ARIA Labels and Roles
const AccessibleSiteForm = () => {
return (
<form role="form" aria-labelledby="site-form-title">
<h2 id="site-form-title">Site Information</h2>
<div className="form-group">
<label htmlFor="site-name" className="required">
Site Name
</label>
<input
id="site-name"
type="text"
aria-required="true"
aria-describedby="site-name-error"
aria-invalid={!isValid}
/>
<div id="site-name-error" aria-live="polite">
{errors.name && <span role="alert">{errors.name}</span>}
</div>
</div>
<div className="form-group">
<fieldset>
<legend>Location Type</legend>
<label>
<input
type="radio"
name="location-type"
value="address"
aria-describedby="location-type-description"
/>
Address
</label>
<label>
<input
type="radio"
name="location-type"
value="coordinates"
aria-describedby="location-type-description"
/>
Coordinates
</label>
<div id="location-type-description">
Choose how to specify the site location
</div>
</fieldset>
</div>
</form>
);
};Keyboard Navigation
const useKeyboardNavigation = () => {
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
switch (e.key) {
case 'Enter':
if (e.ctrlKey || e.metaKey) {
// Ctrl+Enter to save
handleSave();
e.preventDefault();
}
break;
case 'Escape':
// ESC to cancel
handleCancel();
e.preventDefault();
break;
case 's':
if (e.ctrlKey || e.metaKey) {
// Ctrl+S to save
handleSave();
e.preventDefault();
}
break;
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, []);
};Internationalization (i18n)
Translation Keys
const siteTranslations = {
en: {
'site.name.label': 'Site Name',
'site.name.placeholder': 'Enter site name',
'site.address.label': 'Address',
'site.coordinates.label': 'Coordinates',
'site.timezone.label': 'Timezone',
'site.departments.label': 'Departments',
'site.auditors.label': 'Auditors',
'site.supervisors.label': 'Supervisors',
'site.save.button': 'Save Site',
'site.cancel.button': 'Cancel',
'site.validation.name.required': 'Site name is required',
'site.validation.address.required': 'Address is required',
'site.validation.coordinates.invalid': 'Invalid coordinates'
},
id: {
'site.name.label': 'Nama Situs',
'site.name.placeholder': 'Masukkan nama situs',
'site.address.label': 'Alamat',
'site.coordinates.label': 'Koordinat',
'site.timezone.label': 'Zona Waktu',
'site.departments.label': 'Departemen',
'site.auditors.label': 'Auditor',
'site.supervisors.label': 'Supervisor',
'site.save.button': 'Simpan Situs',
'site.cancel.button': 'Batal',
'site.validation.name.required': 'Nama situs diperlukan',
'site.validation.address.required': 'Alamat diperlukan',
'site.validation.coordinates.invalid': 'Koordinat tidak valid'
}
};Usage in Components
const SiteForm = () => {
const { t } = useTranslation();
return (
<div>
<label>{t('site.name.label')}</label>
<input placeholder={t('site.name.placeholder')} />
{errors.name && (
<span className="error">
{t('site.validation.name.required')}
</span>
)}
</div>
);
};Component Structure
// Standard component file structure
src/components/sites/
├── SiteDetails/
│ ├── SiteDetailsContainer.tsx // Main container component
│ ├── SiteDetails.tsx // Presentation component
│ ├── components/
│ │ ├── FormControl.tsx // Form control wrapper
│ │ ├── Label.tsx // Label component
│ │ └── AssignedDepartment.tsx // Department assignment
│ ├── hooks/
│ │ ├── useRadiusList.ts // Radius options hook
│ │ └── useSiteActivityHistory.ts // Activity history hook
│ └── SiteDetails.test.tsx // Component testsSeparation of Concerns
// Container Component (SiteDetailsContainer.tsx)
// Handles: State management, API calls, business logic
const SiteDetailsContainer = (props) => {
// State and effects
const [site, setSite] = useState(null);
const [validation, setValidation] = useState([]);
// Business logic
const handleSave = useCallback(() => {
// Validation and API calls
}, []);
// Render presentation component
return (
<SiteDetails
site={site}
onSave={handleSave}
validation={validation}
{...otherProps}
/>
);
};
// Presentation Component (SiteDetails.tsx)
// Handles: UI rendering, user interactions
const SiteDetails = ({ site, onSave, validation }) => {
return (
<form onSubmit={onSave}>
{/* UI elements */}
</form>
);
};useGoogleMaps Hook
const useGoogleMaps = (apiKey: string) => {
const [isLoaded, setIsLoaded] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (window.google) {
setIsLoaded(true);
return;
}
const script = document.createElement('script');
script.src = `https://maps.googleapis.com/maps/api/js?key=${apiKey}&libraries=places`;
script.async = true;
script.onload = () => setIsLoaded(true);
script.onerror = () => setError('Failed to load Google Maps');
document.head.appendChild(script);
return () => {
document.head.removeChild(script);
};
}, [apiKey]);
return { isLoaded, error };
};useSiteForm Hook
const useSiteForm = (initialSite: Site | null) => {
const [site, setSite] = useState<Site | null>(initialSite);
const [hasChanges, setHasChanges] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
const updateField = useCallback((field: string, value: any) => {
setSite(prevSite => ({
...prevSite,
[field]: value
}));
setHasChanges(true);
// Clear field error if exists
if (errors[field]) {
setErrors(prev => {
const newErrors = { ...prev };
delete newErrors[field];
return newErrors;
});
}
}, [errors]);
const validate = useCallback(() => {
const newErrors: Record<string, string> = {};
if (!site?.name) {
newErrors.name = 'Site name is required';
}
if (!site?.address && !site?.coordinates) {
newErrors.address = 'Address or coordinates are required';
}
// Additional validations...
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
}, [site]);
return {
site,
setSite,
hasChanges,
setHasChanges,
errors,
updateField,
validate
};
};Redux Slice Implementation
// sites.slice.ts
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
export const fetchSites = createAsyncThunk(
'sites/fetchSites',
async (params: FetchSitesParams) => {
const response = await siteApi.fetchSites(params);
return response.data;
}
);
export const createSite = createAsyncThunk(
'sites/createSite',
async (siteData: CreateSiteRequest) => {
const response = await siteApi.createSite(siteData);
return response.data;
}
);
const sitesSlice = createSlice({
name: 'sites',
initialState: {
list: [],
selectedSite: null,
loading: {
fetch: false,
create: false,
update: false
},
error: null
},
reducers: {
setSelectedSite: (state, action) => {
state.selectedSite = action.payload;
},
clearError: (state) => {
state.error = null;
}
},
extraReducers: (builder) => {
builder
.addCase(fetchSites.pending, (state) => {
state.loading.fetch = true;
})
.addCase(fetchSites.fulfilled, (state, action) => {
state.loading.fetch = false;
state.list = action.payload;
})
.addCase(fetchSites.rejected, (state, action) => {
state.loading.fetch = false;
state.error = action.error.message;
});
}
});
export const { setSelectedSite, clearError } = sitesSlice.actions;
export default sitesSlice.reducer;Service Layer Architecture
// siteService.ts
class SiteService {
private baseURL = '/api/sites';
async fetchSites(params: FetchSitesParams): Promise<Site[]> {
const response = await this.request('GET', this.baseURL, { params });
return response.data;
}
async createSite(siteData: CreateSiteRequest): Promise<Site> {
const response = await this.request('POST', this.baseURL, { data: siteData });
return response.data;
}
async updateSite(siteId: string, siteData: UpdateSiteRequest): Promise<Site> {
const response = await this.request('PUT', `${this.baseURL}/${siteId}`, { data: siteData });
return response.data;
}
private async request(method: string, url: string, options: RequestOptions = {}) {
const config = {
method,
url,
...options,
headers: {
'Content-Type': 'application/json',
...await this.getAuthHeaders(),
...options.headers
}
};
try {
const response = await axios(config);
return response.data;
} catch (error) {
throw this.handleError(error);
}
}
private async getAuthHeaders() {
const user = firebase.auth().currentUser;
if (!user) throw new Error('User not authenticated');
const token = await user.getIdToken();
return { Authorization: `Bearer ${token}` };
}
private handleError(error: any) {
if (error.response) {
// Server responded with error status
return new Error(error.response.data.message || 'Server error');
} else if (error.request) {
// Request made but no response
return new Error('Network error');
} else {
// Something else happened
return new Error(error.message);
}
}
}
export const siteService = new SiteService();Memoization Patterns
// Memoized selectors
const selectFilteredSites = createSelector(
[
(state: RootState) => state.sites.list,
(state: RootState) => state.sites.filters.search,
(state: RootState) => state.sites.filters.departments,
(state: RootState) => state.sites.filters.status
],
(sites, search, departments, status) => {
return sites.filter(site => {
const matchesSearch = !search ||
site.name.toLowerCase().includes(search.toLowerCase()) ||
site.address.toLowerCase().includes(search.toLowerCase());
const matchesDepartments = departments.length === 0 ||
departments.some(dept => site.departments[dept]);
const matchesStatus = status === 'all' ||
(status === 'active' && !site.blocked) ||
(status === 'blocked' && site.blocked);
return matchesSearch && matchesDepartments && matchesStatus;
});
}
);
// Memoized components
const SiteListItem = React.memo(({ site, onEdit, onDelete }) => {
return (
<div className="site-item">
<h3>{site.name}</h3>
<p>{site.address}</p>
<div className="actions">
<button onClick={() => onEdit(site.siteID)}>Edit</button>
<button onClick={() => onDelete(site.siteID)}>Delete</button>
</div>
</div>
);
});Conclusion
The Site Creation and Edit module represents a comprehensive solution for managing audit sites within the Nimbly platform. Key achievements include:
Technical Excellence
- Modern Architecture: React hooks, TypeScript, and clean code patterns
- Performance Optimization: Virtual scrolling, lazy loading, and memoization
- Accessibility: WCAG compliance with ARIA labels and keyboard navigation
- Internationalization: Multi-language support for global deployment
Business Value
- Streamlined Operations: Intuitive UI reduces training time and errors
- Scalability: Handles thousands of sites with efficient pagination and filtering
- Integration: Seamless connection with scheduling, reporting, and analytics
- Compliance: Comprehensive audit trails and permission controls
Future-Ready Design
- Extensible Architecture: Plugin-based components for easy feature additions
- API-First Design: RESTful APIs enable mobile and third-party integrations
- Cloud-Native: Optimized for cloud deployment and auto-scaling
- Data-Driven: Analytics integration for continuous improvement
This documentation serves as both a technical reference and implementation guide for developers working on the Nimbly audit administration platform. The site management system demonstrates enterprise-grade software development practices while maintaining usability and performance at scale.