# Custom Pages

Custom pages extend the CMS beyond standard table views. Define split-panel layouts, matching rules for record reconciliation, drag-and-drop interactions, and page-level actions — all from a single `definePage()` call.

## Defining a Page

Pages are defined in your feature directory under a `pages/` folder:

```
quickback/features/
  banking/
    bank-accounts.ts          # One table per file — any filename
    bank-transactions.ts
    actions/
      reconcile.ts            # One action per file — export default defineAction({...})
    pages/
      bank-reconciliation.ts
```

Each page file exports a `definePage()` call:

```typescript title="features/banking/pages/bank-reconciliation.ts"
import { definePage } from '@quickback/compiler';

export default definePage({
  slug: 'bank-reconciliation',
  title: 'Bank Reconciliation',
  description: 'Match bank transactions to ledger entries',
  icon: 'landmark',
  feature: 'banking',

  access: { roles: ['owner', 'admin'] },

  dataSources: {
    bankTransactions: {
      table: 'bankTransaction',
      defaultSort: { field: 'date', order: 'desc' },
      displayColumns: ['date', 'description', 'amount'],
    },
    ledgerEntries: {
      table: 'ledgerEntry',
      defaultSort: { field: 'date', order: 'desc' },
      displayColumns: ['date', 'memo', 'amount'],
    },
  },

  layout: {
    type: 'split-panel',
    panels: [
      {
        id: 'bank',
        title: 'Bank Transactions',
        dataSource: 'bankTransactions',
        position: 'left',
        features: ['drag-source'],
      },
      {
        id: 'ledger',
        title: 'Ledger Entries',
        dataSource: 'ledgerEntries',
        position: 'right',
        features: ['drop-target'],
      },
    ],
  },

  matching: {
    enabled: true,
    confidenceThreshold: 0.5,
    rules: [
      {
        name: 'Amount Match',
        weight: 0.5,
        condition: {
          left: 'bankTransactions.amount',
          right: 'ledgerEntries.amount',
          operator: 'abs-equals',
        },
      },
      {
        name: 'Date Proximity',
        weight: 0.3,
        condition: {
          left: 'bankTransactions.date',
          right: 'ledgerEntries.date',
          operator: 'within-days',
          value: 7,
        },
      },
      {
        name: 'Description Match',
        weight: 0.2,
        condition: {
          left: 'bankTransactions.description',
          right: 'ledgerEntries.memo',
          operator: 'fuzzy-match',
        },
      },
    ],
  },

  pageActions: {
    reconcile: {
      table: 'bankTransaction',
      action: 'reconcile',
      label: 'Reconcile',
      icon: 'check-circle',
      confirm: 'Match this bank transaction to the selected ledger entry?',
      inputMapping: {
        transactionId: 'bankTransactions.$id',
        ledgerEntryId: 'ledgerEntries.$id',
        amount: 'bankTransactions.$amount',
      },
    },
  },
});
```

## Data Sources

Each page declares one or more named data sources that bind to tables in your schema:

```typescript
dataSources: {
  bankTransactions: {
    table: 'bankTransaction',          // Table name from your schema
    defaultFilters: { status: 'pending' },  // Applied on load
    defaultSort: { field: 'date', order: 'desc' },
    displayColumns: ['date', 'description', 'amount'],
  },
}
```

| Field | Type | Description |
|-------|------|-------------|
| `table` | `string` | Table name to query (required) |
| `defaultFilters` | `Record<string, unknown>` | Filters applied on initial load |
| `defaultSort` | `{ field, order }` | Default sort column and direction |
| `displayColumns` | `string[]` | Columns shown in the panel (defaults to `["id"]`) |

## Layout

Pages use a layout configuration to arrange panels. Currently the only layout type is `split-panel`:

```typescript
layout: {
  type: 'split-panel',
  panels: [
    { id: 'bank', title: 'Bank Txs', dataSource: 'bankTransactions', position: 'left', features: ['drag-source'] },
    { id: 'ledger', title: 'Ledger', dataSource: 'ledgerEntries', position: 'right', features: ['drop-target'] },
  ],
}
```

### Panel Features

Panels can enable interactive features:

| Feature | Description |
|---------|-------------|
| `drag-source` | Rows in this panel can be dragged |
| `drop-target` | Rows in this panel accept drops |

When a row is dragged from one panel and dropped onto a row in the other panel, the first page action is triggered with both records as context.

## Matching Engine

The matching engine compares records across two panels and computes confidence scores for potential matches. Matched rows display a colored confidence badge.

### Rules

Each rule defines a comparison between fields from two data sources:

```typescript
matching: {
  enabled: true,
  confidenceThreshold: 0.5,  // Minimum score to show (0-1)
  rules: [
    {
      name: 'Amount Match',
      weight: 0.5,           // Weight in final score (0-1)
      condition: {
        left: 'bankTransactions.amount',   // dataSourceName.fieldName
        right: 'ledgerEntries.amount',
        operator: 'abs-equals',
      },
    },
  ],
}
```

### Operators

| Operator | Description | Returns |
|----------|-------------|---------|
| `abs-equals` | Absolute values are equal | `1.0` if match, `0` otherwise |
| `within-days` | Dates are within N days | `1 - (daysDiff / maxDays)`, clamped to 0 |
| `fuzzy-match` | Word overlap between strings | Overlap ratio (0-1) |

The `within-days` operator uses the `value` parameter to set the maximum number of days (defaults to 7).

### Confidence Scoring

The final confidence score is the weighted average of all rule scores:

```
score = sum(ruleScore * ruleWeight) / sum(ruleWeights)
```

Only matches above `confidenceThreshold` are shown. Confidence badges are color-coded:

| Score | Color |
|-------|-------|
| 80%+ | Green |
| 50-79% | Yellow |
| Below 50% | Orange |

## Page Actions

Page actions trigger table actions using data from both the source (dragged) and target (dropped-on) records:

```typescript
pageActions: {
  reconcile: {
    table: 'bankTransaction',      // Target table for the action
    action: 'reconcile',           // Action name on that table
    label: 'Reconcile',
    icon: 'check-circle',
    confirm: 'Match this transaction?',
    inputMapping: {
      transactionId: 'bankTransactions.$id',
      ledgerEntryId: 'ledgerEntries.$id',
      amount: 'bankTransactions.$amount',
    },
  },
}
```

### Input Mapping

Input mapping resolves field references from the source and target records into the action's input fields. The format is `"dataSourceName.$fieldName"`:

- `bankTransactions.$id` — resolves to the `id` field of the dragged record from the `bankTransactions` data source
- `ledgerEntries.$amount` — resolves to the `amount` field of the drop-target record from the `ledgerEntries` data source

The `$` prefix denotes a field reference on the record. The data source name determines which record (source or target) to read from.

## Access Control

Restrict page access by role:

```typescript
access: { roles: ['owner', 'admin'] }
```

When set, only users with the specified roles can view the page. The page link is hidden from the sidebar for unauthorized users.

## Sidebar Navigation

Custom pages appear in the CMS sidebar under their feature group. Each page is listed alongside the feature's tables, making them easily discoverable.

## Validation

The compiler validates page definitions at compile time:

- `slug` must start with a lowercase letter and contain only lowercase letters, digits, and hyphens
- At least one data source is required
- Every panel's `dataSource` must reference a declared data source
- Matching rule weights must be between 0 and 1
- `confidenceThreshold` must be between 0 and 1

## Next Steps

- **[Dashboard](/ui/admin/dashboard)** — The CMS home page with schema stats
- **[Components Reference](/ui/admin/components)** — Page-related component details
- **[Schema Format Reference](/ui/admin/schema-format)** — PageMeta type definition
