Skip to main content

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

Platform Settings Hub Integration Checklist

When adding a new platform settings page (/settings/*):
  1. Add the lazy route in src/routes/platform.tsx.
  2. Add/verify the correct RequirePermission(...) guard.
  3. Add a Settings Hub card (if it should be user-discoverable from /settings).
  4. Keep card visibility and route permission checks aligned.
  5. 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

SettingsPageHeader

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:
PropTypeRequiredDescription
iconLucideIconYesIcon to display
titlestringYesPage title
descriptionstringYesPage description
docsPathstringNoDocumentation path (relative to docs base URL); include on all new settings pages
actionsReactNodeNoExtra 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:
PropTypeDefaultDescription
rowsnumber4Number of form field rows to show
cardsnumber1Number 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. 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>

Form Organization

Tab Structure

Organize settings into logical tabs:
TabContent
GeneralCommon settings, defaults
Module-SpecificFeature-specific settings
NotificationsAlert preferences
AdvancedPower user options
AIAI feature toggles (if applicable)

Form Layout

<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:
ModuleUses Standard ComponentsHas DocsLinkHook Pattern
FWUpsert ✅
HRUpsert ✅
FAUpsert ✅
LOUpsert ✅
RHUpsert ✅
GRUpsert ✅
FMUpsert ✅

Existing Implementations

Reference these for patterns:
ModuleSettings PageHook
HR/hr/settingsuseHRModuleSettings
RH/rh/settingsuseRHModuleSettings
FA/fa/settingsuseFAModuleSettings
FW/fw/settingsuseFWModuleSettings
LO/lo/settingsuseLOModuleSettings
GR/gr/settingsuseGRModuleSettings
FM/fm/settingsuseFMModuleSettings

Best Practices

  1. Never return null for loading states - Always use SettingsLoadingSkeleton
  2. Always include docsPath - Link to relevant admin documentation
  3. Use semantic icons - Choose icons that represent the module
  4. Handle no-organization state - Show a helpful message
  5. Use consistent card structure - CardHeader with title/description, CardContent with form
  6. Use upsert pattern - All modules now use this pattern for consistency
  7. Always use shared components - Don’t create custom headers/skeletons
  8. Handle all states - Loading, error, empty, success
  9. Validate before save - Use form validation
  10. Show save confirmation - Toast on success
  11. Warn on unsaved changes - Block navigation if dirty
  12. Keep tabs focused - 3-5 settings per section max
  13. Use appropriate inputs - Switch for boolean, Select for enum