# Time-entry refs registration (extension entities)

The platform's HR module lets a user book time entries. Beyond the built-in activity types, an entry can be
**imputed onto a CLIENT extension entity** — a project, a mandate, a cost center… These "external refs" are
a **registry of `ITimeEntryRefProvider`** resolved as `IEnumerable<ITimeEntryRefProvider>`: **Core ships NO
built-in provider**, and an extension entity is invisible to the time-entry picker until it is registered.
There is **no auto-scan** of the `extensions` schema — by design. Unlike global search (where every list
entity is a natural search target), **imputation is an opt-in BUSINESS decision**: having a list screen does
NOT mean time should be booked against an entity, so nothing is ever auto-registered.

You do **not** write an `ITimeEntryRefProvider` per entity. A generic engine does the work; you declare each
imputation dimension once via `AddExtensionTimeEntryRefs<ExtensionsDbContext>(…)` in
`{Project}.Infrastructure/DependencyInjection.cs` (inside the `<<< TIME-ENTRY-REFS-DI >>>` markers). The
core time-entry UI renders the picker automatically — **ZERO client frontend code needed**.

**You do not hand-write this either** — the deterministic CLI `cli/scaffold-time-entry-refs` generates and
idempotently patches the block (it even inserts the wrapper + the required `using` when a project predates
the seam, so `ss upgrade` back-fills a **commented** seam into existing apps). The C# below is exactly what it
emits; it is documented here so reviewers can read the intent.

## When to register an entity

Register an entity **only when time should be booked against it** — a project, a mandate, a work order. This
is a deliberate choice per entity, not "every list entity" (contrast with global search). If you are unsure,
do not register it: an unregistered entity simply never appears in the time-entry picker (fail-closed).

## What you declare (and what is automatic)

| Declared (per dimension) | Automatic (engine) |
|---|---|
| `refType` (stable, persisted key), `label`, lucide `icon` | display-column search (`ToLower().Contains`) + ordering, translated to SQL |
| `WithDisplay(x => x.Prop)` — the option label (**mandatory**) | pagination / `take`, result mapping to `TimeRefOptionDto` |
| `WithSubtitle(x => x.Prop)` — optional secondary text | write-time validation + **label snapshot** onto the entry (`ExternalRefLabel`) |
| `ActiveWhen(x => …)` — pickable predicate (optional) | inactive targets excluded from search, rejected on write, still resolvable for display |
| `RequirePermission("…")` — per-dimension gate (optional) | authorization filtering of the listed dimensions (`PermissionMatcher`) |
| `TenantScoped()` for every tenant-scoped entity (defense in depth over the named context filter) | `TenantId == current` predicate; per-ref hour totals for reporting |
| `Order(n)` — picker order (optional, default 100) | the `GET /api/hr/my-time/refs` + `/refs/{refType}` endpoints; the report breakdown |

## Backend contract (verbatim — `SmartStack.Application.Common.TimeEntryRefs`)

The seam a provider implements, and the DTOs it exchanges. The declarative `AddExtensionTimeEntryRefs<T>(…)`
builder registers a generic provider that fulfils this contract for you; a bespoke provider implements it
directly and is registered with `AddSmartStackTimeEntryRefProvider<T>()`.

```csharp
/// <summary>Inputs for one ref-options search. Term is already trimmed + lower-cased; empty ⇒ first page.</summary>
public sealed record TimeRefSearchContext(string Term, int Take);

/// <summary>One selectable external imputation target (a client project, mandate, cost center…).</summary>
public sealed record TimeRefOptionDto(Guid Id, string Label, string? Subtitle, bool IsActive);

/// <summary>A registered external ref type, as listed to the SPA picker.</summary>
public sealed record TimeRefTypeDto(string RefType, string Label, string Icon);

public interface ITimeEntryRefProvider
{
    /// <summary>Stable ref-type key persisted on the time entry (e.g. "project"). Unique across providers.</summary>
    string RefType { get; }
    /// <summary>Display label of the dimension (localized server-side; the SPA shows it verbatim).</summary>
    string Label { get; }
    /// <summary>Icon hint for the dimension and its options (a lucide icon name).</summary>
    string Icon { get; }
    /// <summary>Optional permission a caller must hold (via PermissionMatcher). Null = any authorized caller.</summary>
    string? RequiredPermission { get; }
    /// <summary>Display order of the dimension in the picker (lower first).</summary>
    int Order { get; }
    /// <summary>Pickable options matching the term, honouring tenant/data-scope filters. Active targets only.</summary>
    Task<IReadOnlyList<TimeRefOptionDto>> SearchAsync(TimeRefSearchContext context, CancellationToken cancellationToken);
    /// <summary>Resolves one target by id for write-time validation + label snapshot. Null when it does not
    /// exist / is out of tenant scope; returns IsActive=false rather than null when it exists but is closed.</summary>
    Task<TimeRefOptionDto?> GetByIdAsync(Guid id, CancellationToken cancellationToken);
}
```

Registration extension methods (`SmartStack.Infrastructure.Services.TimeEntryRefs.TimeEntryRefServiceCollectionExtensions`):

```csharp
// Declarative — the common path. A generic provider per declared entity.
public static IServiceCollection AddExtensionTimeEntryRefs<TContext>(
    this IServiceCollection services,
    Action<ExtensionTimeEntryRefBuilder<TContext>> configure) where TContext : DbContext;

// Class-based — for a bespoke ITimeEntryRefProvider (Core lookups, computed dimensions…).
public static IServiceCollection AddSmartStackTimeEntryRefProvider<TProvider>(this IServiceCollection services)
    where TProvider : class, ITimeEntryRefProvider;
```

Fluent builder (`ExtensionTimeEntryRefBuilder<TContext>.Entity<TEntity>(refType, label, icon)` →
`ExtensionTimeEntryRefEntityBuilder<TContext, TEntity>`): `WithDisplay(Expression<Func<TEntity,string>>)`
(mandatory), `WithSubtitle(Expression<Func<TEntity,string?>>)`, `ActiveWhen(Expression<Func<TEntity,bool>>)`,
`RequirePermission(string)`, `TenantScoped()`, `Order(int)`.

## Conventions

- **`refType` is a persisted discriminator** — stored on `core.hr_TimeEntries.ExternalRefType`. Keep it
  **stable** across versions (changing it orphans every stored imputation) and **unique** across ALL
  registered providers (a duplicate is rejected at startup). Lowercase kebab, e.g. `project`, `cost-center`.
- **The label is resolved SERVER-SIDE** at write time and **snapshotted** onto the entry (`ExternalRefLabel`).
  Clients never send labels; the picker only sends `refType` + the target `id`.
- **`WithDisplay` is mandatory** and is an **Expression** (not a `Func`) — the engine translates search and
  ordering to SQL through it. Fails fast at startup if omitted, or if the entity has no `Guid Id`.
- **Active vs resolvable** — `ActiveWhen(...)` marks the targets that still accept NEW imputations. A row
  failing it is excluded from the picker and rejected at write time, but `GetByIdAsync` still resolves it so
  the UI can display an entry booked before the target closed. Omit it ⇒ every row is active.
- **`TenantScoped()`** = add it for every tenant-scoped entity; the engine injects the explicit
  `TenantId == current` predicate — defense in depth over the named "Tenant" context filter
  scaffold-entity mounts (which can be lifted, or missing on a pre-seam app). It also
  requires the entity to expose a strict `TenantId` — enforced at startup.
- **`RequirePermission(...)`** = optional per-dimension gate. Null ⇒ any caller authorized on the time-entry
  endpoints may use the dimension.

## Worked example

```csharp
using SmartStack.Infrastructure.Services.TimeEntryRefs;

services.AddExtensionTimeEntryRefs<ExtensionsDbContext>(refs =>
{
    // <<< TIME-ENTRY-REFS-DI BEGIN >>>
    refs.Entity<Project>("project", "Projets", "FolderKanban")
        .WithDisplay(p => p.Name)
        .WithSubtitle(p => p.Code)
        .ActiveWhen(p => p.Status == ProjectStatus.Open)
        .RequirePermission("projets.liste.read")
        .TenantScoped()
        .Order(10);

    refs.Entity<Mandate>("mandate", "Mandats", "Briefcase")
        .WithDisplay(m => m.Title)
        .TenantScoped();
    // <<< TIME-ENTRY-REFS-DI END >>>
});
```

## Feature gate + endpoints

The seam is inert until the **tenant** admin turns it on: HR → Time settings → external refs, backed by the
`Hr / TimeEntryExternalRefsEnabled` setting (**bool, default false**). Two endpoints drive the picker (both
gated by `hr.my-time.week.view` OR `hr.time.entries.update`):

| Endpoint | Returns |
|---|---|
| `GET /api/hr/my-time/refs` | the dimensions available to the caller (`TimeRefTypeDto[]`); `[]` when the toggle is off or no provider matches |
| `GET /api/hr/my-time/refs/{refType}?search=&take=` | the pickable options of one dimension (`TimeRefOptionDto[]`) |

## Extension-facing read — `ICoreDataService.GetTimeRefTotalsAsync`

To surface booked hours on the client's OWN pages (an "hours" column on the project list, a total on a
project detail), read them back through Core:

```csharp
Task<IReadOnlyDictionary<Guid, decimal>> GetTimeRefTotalsAsync(
    string refType,
    IReadOnlyCollection<Guid> refIds,
    DateTime? from = null,
    DateTime? to = null,
    CancellationToken cancellationToken = default);
```

```csharp
// e.g. an "hours booked" column on the client's project list (one page of ids)
var pageIds = projects.Select(p => p.Id).ToList();
var hours = await coreData.GetTimeRefTotalsAsync("project", pageIds, ct: ct);
foreach (var p in projects)
    p.BookedHours = hours.TryGetValue(p.Id, out var h) ? h : 0m;
```

Only ids WITH hours appear in the dictionary (a target with none is absent). Tenant isolation rides the Core
query filters of the ambient request.

## Reporting

The HR time report gains a **per-ref breakdown automatically** — no extra work. Hours grouped by the
`refType`/target are surfaced alongside the built-in dimensions once at least one provider is registered.

## Frontend

**No client frontend code is needed** — the core time-entry form renders the picker from the two endpoints.
Optionally, a client can add its own tab to the "My time" overview via the extension slots
`hr.my-time.overview.tabs` (the tab header) + `hr.my-time.overview.tabs.content` (the panel) — e.g. a
"Hours per project" chart fed by `GetTimeRefTotalsAsync`. That is additive and unrelated to registering the
dimension itself.

## Rules

1. **Never auto-scan / auto-register** — imputation is opt-in. No declaration ⇒ not pickable (fail-closed).
2. **`refType` is stable + unique** — persisted on `core.hr_TimeEntries`; never rename it, never reuse it.
3. **`WithDisplay(...)` is mandatory** and must be an Expression (SQL-translated); the entity needs a `Guid Id`.
4. **`TenantScoped()` for every entity without a tenant query filter** — the engine adds the explicit filter.
5. **The label is server-resolved + snapshotted** — clients send only `refType` + `id`, never a label.
6. **`ActiveWhen(...)` gates NEW imputations only** — closed targets stay resolvable for display.
7. **No frontend work to register** — the picker is rendered by the core form; the client writes zero UI.
