> ## 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.

# Settings Page Pattern Guide

> 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…

**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

```tsx theme={null}
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

```tsx theme={null}
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):**

```tsx theme={null}
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:

```tsx theme={null}
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.

```tsx theme={null}
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):

```tsx theme={null}
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:

```typescript theme={null}
{
  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:

```tsx theme={null}
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:

```tsx theme={null}
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)

```tsx theme={null}
// 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:

| Tab             | Content                            |
| --------------- | ---------------------------------- |
| General         | Common settings, defaults          |
| Module-Specific | Feature-specific settings          |
| Notifications   | Alert preferences                  |
| Advanced        | Power user options                 |
| AI              | AI feature toggles (if applicable) |

### Form Layout

```tsx theme={null}
<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:

```tsx theme={null}
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

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

## Related Documentation

* [Breadcrumb Implementation Guide](./breadcrumb-implementation-guide.md)
* [Dashboard Pattern Guide](./dashboard-pattern-guide.md)
* [UI/UX Standards](./UI_UX_STANDARDS.md)
* [Platform Integration Layers](../architecture/integrations/PLATFORM_INTEGRATION_LAYERS.md)
