Documentation Index
Fetch the complete documentation index at: https://docs.encoreos.io/llms.txt
Use this file to discover all available pages before exploring further.
Version: 1.3.0
Last Updated: 2026-04-13
Status: Active
Owning Module: PF (Platform Foundation)
Note: This guide consolidates content from settings-page-pattern.md (archived). All settings pattern documentation is now in this single guide.
Overview
This guide documents the standardized pattern for module settings pages across all cores. All settings pages should use shared components for consistency and maintainability.
For current Platform Settings module surfaces (hub/routes/exports), also see:
src/platform/settings/README.md
src/platform/settings/pages/data-migration/README.md
Architecture
Settings Page (canonical)
├── SettingsPageLayout (PageContainer + loading/error + optional unsaved guard + optional save shortcut)
│ ├── SettingsPageHeader (title, description, docsPath, optional actions)
│ ├── Tabs (prefer ScrollableTabsList or SettingsTabs)
│ └── Form content
When adding a new platform settings page (/settings/*):
- Add the lazy route in
src/routes/platform.tsx.
- Add/verify the correct
RequirePermission(...) guard.
- Add a Settings Hub card (if it should be user-discoverable from
/settings).
- Keep card visibility and route permission checks aligned.
- Confirm loading and error states use shared settings components.
This prevents route/hub drift and permission mismatch regressions.
Quick Start
import { Settings } from 'lucide-react';
import { useOrganization } from '@/platform/organizations/OrganizationContext';
import { SettingsPageHeader, SettingsPageLayout } from '@/platform/settings/components';
import { PageContainer } from '@/shared/components/PageContainer';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card';
import { ModuleSettingsForm } from '../components/ModuleSettingsForm';
import { useModuleSettings } from '../hooks/useModuleSettings';
export default function ModuleSettingsPage() {
const { currentOrganization } = useOrganization();
const { settings, isLoading, error, upsert, isUpserting, refetch } = useModuleSettings();
if (!currentOrganization) {
return (
<PageContainer spacing="md">
<Card>
<CardContent className="py-8">
<p className="text-center text-muted-foreground">No organization selected</p>
</CardContent>
</Card>
</PageContainer>
);
}
return (
<SettingsPageLayout
isLoading={isLoading}
error={error}
onRetry={() => {
void refetch();
}}
isDirty={false}
header={
<SettingsPageHeader
icon={Settings}
title="Module Settings"
description="Configure settings for your module"
docsPath="/docs/module/admin-guides/settings"
/>
}
>
<Card>
<CardHeader>
<CardTitle>Module Configuration</CardTitle>
<CardDescription>Customize module-specific settings</CardDescription>
</CardHeader>
<CardContent>
<ModuleSettingsForm initialValues={settings || {}} onSubmit={upsert} isSubmitting={isUpserting} />
</CardContent>
</Card>
</SettingsPageLayout>
);
}
Notes
- Prefer
PageContainer / SettingsPageLayout instead of container mx-auto.
- Include
docsPath on every SettingsPageHeader so the docs link is consistent.
- Wire
isDirty from your form (react-hook-form formState.isDirty) when you enable the unsaved navigation guard.
- Call
transformSettingsForDb in module upsert hooks or submit handlers before writing nullable columns.
- Run
npm run audit:settings-consistency when changing settings routes or page shells.
Component Reference
Located at @/platform/settings/components/SettingsPageHeader
Provides a standardized header with:
- Module icon
- Title and description
- Optional documentation link
import { SettingsPageHeader } from '@/platform/settings/components';
import { Settings } from 'lucide-react';
<SettingsPageHeader
icon={Settings}
title="Module Settings"
description="Configure settings for your module"
docsPath="/docs/module/admin-guides/settings"
/>
Props:
| Prop | Type | Required | Description |
|---|
icon | LucideIcon | Yes | Icon to display |
title | string | Yes | Page title |
description | string | Yes | Page description |
docsPath | string | No | Documentation path (relative to docs base URL); include on all new settings pages |
actions | ReactNode | No | Extra controls rendered next to the docs link (e.g. help, reset) |
Legacy Interface (also supported):
interface SettingsPageHeaderProps {
title: string;
description?: string;
onSave?: () => void;
isSaving?: boolean;
isDirty?: boolean;
showSaveButton?: boolean;
}
SettingsLoadingSkeleton
Located at @/platform/settings/components/SettingsLoadingSkeleton
Provides a loading skeleton that matches the settings page layout:
import { SettingsLoadingSkeleton } from '@/platform/settings/components';
if (isLoading) {
return <SettingsLoadingSkeleton rows={4} />;
}
Props:
| Prop | Type | Default | Description |
|---|
rows | number | 4 | Number of form field rows to show |
cards | number | 1 | Number of cards to show |
Renders:
- Header skeleton (title + description)
- Tab bar skeleton (if using tabs)
- Form field skeletons
SettingsErrorState
Error display with retry option.
interface SettingsErrorStateProps {
error: Error;
onRetry?: () => void;
}
<SettingsErrorState
error={error}
onRetry={() => refetch()}
/>
Settings Hook Pattern
Each module should have a standardized settings hook. The recommended pattern uses a single upsert mutation for simplicity and consistency.
Recommended: Upsert Pattern
The preferred pattern uses a single upsert mutation (as seen in LO, RH, and all other modules):
export function useModuleSettings() {
const { currentOrganization } = useOrganization();
// Query
const { data: settings, isLoading } = useQuery({
queryKey: ['module-settings', currentOrganization?.id],
queryFn: async () => {
const { data, error } = await supabase
.from('module_settings')
.select('*')
.eq('organization_id', currentOrganization!.id)
.maybeSingle();
if (error) throw error;
return data;
},
enabled: !!currentOrganization?.id,
});
// Upsert mutation
const { mutateAsync: upsert, isPending: isUpserting } = useMutation({
mutationFn: async (values: ModuleSettingsFormValues) => {
const { error } = await supabase
.from('module_settings')
.upsert({
organization_id: currentOrganization!.id,
...values,
updated_at: new Date().toISOString(),
}, {
onConflict: 'organization_id',
});
if (error) throw error;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['module-settings'] });
toast.success('Settings saved');
},
});
return { settings, isLoading, upsert, isUpserting };
}
Upsert Hook Return Interface
All module settings hooks now return a consistent interface:
{
settings: ModuleSettings | null; // Current settings or null
isLoading: boolean; // Query loading state
error: Error | null; // Query error state
upsert: (values: ModuleSettingsUpdate) => Promise<void>; // Save function
isUpserting: boolean; // Mutation pending state
}
Settings Page Usage
With the upsert pattern, settings pages are simplified:
const { settings, isLoading, upsert, isUpserting } = useModuleSettings();
const handleSubmit = (values: ModuleSettingsFormValues) => {
upsert(values); // No conditional logic needed!
};
// In JSX:
<ModuleSettingsForm
initialValues={settings || {}}
onSubmit={handleSubmit}
isSubmitting={isUpserting}
/>
Legacy Pattern (Update-based)
For reference, the older update-based pattern is still supported but not recommended for new implementations:
interface ModuleSettingsHook<T> {
settings: T | undefined;
isLoading: boolean;
error: Error | null;
updateSettings: (updates: Partial<T>) => Promise<void>;
isSaving: boolean;
refetch: () => void;
}
// Example implementation
export function useHRModuleSettings(): ModuleSettingsHook<HRModuleSettings> {
const { user } = useCurrentUser();
const { data, isLoading, error, refetch } = useQuery({
queryKey: ['hr-module-settings', user?.organization_id],
queryFn: async () => {
const { data, error } = await supabase
.from('hr_module_settings')
.select('*')
.eq('organization_id', user!.organization_id)
.single();
if (error) throw error;
return data;
},
enabled: !!user?.organization_id,
});
const { mutateAsync: updateSettings, isPending: isSaving } = useMutation({
mutationFn: async (updates: Partial<HRModuleSettings>) => {
const { error } = await supabase
.from('hr_module_settings')
.update(updates)
.eq('organization_id', user!.organization_id);
if (error) throw error;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['hr-module-settings'] });
toast.success('Settings saved');
},
});
return { settings: data, isLoading, error, updateSettings, isSaving, refetch };
}
Mobile Responsiveness
Settings pages must be mobile-responsive:
Desktop (md+)
- Full tab bar visible
- Side-by-side form layouts where appropriate
Mobile (< md)
- Tabs convert to Select dropdown
- Single-column form layouts
- Touch-friendly inputs (44px minimum)
// Responsive tabs pattern
<Tabs defaultValue="general" className="w-full">
{/* Desktop: TabsList */}
<TabsList className="hidden md:flex">
<TabsTrigger value="general">General</TabsTrigger>
<TabsTrigger value="advanced">Advanced</TabsTrigger>
</TabsList>
{/* Mobile: Select */}
<div className="md:hidden mb-4">
<Select value={activeTab} onValueChange={setActiveTab}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="general">General</SelectItem>
<SelectItem value="advanced">Advanced</SelectItem>
</SelectContent>
</Select>
</div>
<TabsContent value="general">...</TabsContent>
<TabsContent value="advanced">...</TabsContent>
</Tabs>
Tab Structure
Organize settings into logical tabs:
| Tab | Content |
|---|
| General | Common settings, defaults |
| Module-Specific | Feature-specific settings |
| Notifications | Alert preferences |
| Advanced | Power user options |
| AI | AI feature toggles (if applicable) |
<div className="space-y-6">
{/* Section */}
<div className="space-y-4">
<h3 className="text-lg font-medium">Section Title</h3>
<p className="text-sm text-muted-foreground">
Section description
</p>
{/* Form fields */}
<div className="grid gap-4 md:grid-cols-2">
<FormField label="Field 1" />
<FormField label="Field 2" />
</div>
</div>
<Separator />
{/* Next section */}
</div>
Dirty State Management
Track unsaved changes:
function useSettingsForm<T>(initialSettings: T) {
const [settings, setSettings] = useState<T>(initialSettings);
const [isDirty, setIsDirty] = useState(false);
const updateField = useCallback(<K extends keyof T>(
field: K,
value: T[K]
) => {
setSettings(prev => ({ ...prev, [field]: value }));
setIsDirty(true);
}, []);
const resetDirty = useCallback(() => setIsDirty(false), []);
return { settings, updateField, isDirty, resetDirty };
}
Module Compliance Status
All modules now use the standardized upsert pattern:
| Module | Uses Standard Components | Has DocsLink | Hook Pattern |
|---|
| FW | ✅ | ✅ | Upsert ✅ |
| HR | ✅ | ✅ | Upsert ✅ |
| FA | ✅ | ✅ | Upsert ✅ |
| LO | ✅ | ✅ | Upsert ✅ |
| RH | ✅ | ✅ | Upsert ✅ |
| GR | ✅ | ✅ | Upsert ✅ |
| FM | ✅ | ✅ | Upsert ✅ |
Existing Implementations
Reference these for patterns:
| Module | Settings Page | Hook |
|---|
| HR | /hr/settings | useHRModuleSettings |
| RH | /rh/settings | useRHModuleSettings |
| FA | /fa/settings | useFAModuleSettings |
| FW | /fw/settings | useFWModuleSettings |
| LO | /lo/settings | useLOModuleSettings |
| GR | /gr/settings | useGRModuleSettings |
| FM | /fm/settings | useFMModuleSettings |
Best Practices
- Never return null for loading states - Always use
SettingsLoadingSkeleton
- Always include docsPath - Link to relevant admin documentation
- Use semantic icons - Choose icons that represent the module
- Handle no-organization state - Show a helpful message
- Use consistent card structure - CardHeader with title/description, CardContent with form
- Use upsert pattern - All modules now use this pattern for consistency
- Always use shared components - Don’t create custom headers/skeletons
- Handle all states - Loading, error, empty, success
- Validate before save - Use form validation
- Show save confirmation - Toast on success
- Warn on unsaved changes - Block navigation if dirty
- Keep tabs focused - 3-5 settings per section max
- Use appropriate inputs - Switch for boolean, Select for enum