# `PhoenixKitProjects.Assignees`
[🔗](https://github.com/BeamLabEU/phoenix_kit_projects/blob/v0.21.2/lib/phoenix_kit_projects/assignees.ex#L1)

The single resolver for "whose work is this?" questions against the
polymorphic assignment assignee (person OR team OR department).

A person's **effective scope** is themselves plus one level of inherited
membership: the teams they belong to (staff `TeamMembership`) and the
departments those teams sit in plus their own primary department. One level
only, by design — transitive roll-ups (team of team, parent departments)
don't exist in the staff model and would be guesswork here.

`match/2` answers whether an assignment falls in a scope and HOW — `:direct`
or `{:team, name}` / `{:department, name}` — so UIs can show provenance
("via Engineering") instead of implying personal ownership. Consumers pick
their semantics: inherited = any match; direct-only = `:direct` matches.

Built for the Overview calendar's assignee filter, but deliberately
UI-agnostic: any future per-person surface (widgets, queues, a staff-side
consumer via an events contract) should resolve through this module rather
than re-deriving membership joins — the panel-review risk was exactly this
logic drifting between call sites.

Staff reads are rescued to safe defaults (same convention as
`Projects.list_assignments_for_user/1`) — an assignee filter must never
crash the dashboard.

# `scope`

```elixir
@type scope() :: %{
  person_uuid: String.t(),
  person_name: String.t(),
  team_names: %{optional(String.t()) =&gt; String.t()},
  department_names: %{optional(String.t()) =&gt; String.t()}
}
```

A resolved person scope: the person plus their one-level memberships, with
display names captured for provenance labels.

# `match`

```elixir
@spec match(PhoenixKitProjects.Schemas.Assignment.t(), scope()) ::
  :direct | {:team, String.t()} | {:department, String.t()} | nil
```

How `assignment` falls inside `scope`:

  * `:direct` — assigned to the person themselves
  * `{:team, name}` — assigned to a team the person belongs to
  * `{:department, name}` — assigned to a department in the person's scope
  * `nil` — outside the scope

Direct-only consumers accept only `:direct`; inherited consumers accept any
non-nil result (and can show the tuple's name as provenance).

# `scope_for_person`

```elixir
@spec scope_for_person(String.t(), String.t() | nil) :: scope() | nil
```

Builds the effective scope for a staff person: their uuid, their teams
(via memberships), and their departments (the teams' departments + their
primary department). Names are localized to `lang` for provenance labels.

Returns `nil` when the person doesn't exist (or the staff read fails).

# `scope_for_user`

```elixir
@spec scope_for_user(String.t() | nil, String.t() | nil) :: scope() | nil
```

The scope for the CURRENT USER (auth uuid → staff person), or `nil` when
they have no staff person. Backs the filter's "Me" shortcut.

# `search_people`

```elixir
@spec search_people(String.t() | nil, pos_integer(), keyword()) ::
  {[map()], boolean()}
```

Searches people for the assignee picker (core `<.search_picker>` contract):
name/email typeahead, LIMITed at the database (limit+1 probes `has_more` for
the picker's Load more), trashed people excluded. An empty query is browse
mode — the first page, name-sorted — per the workspace picker rule that a
picker must offer options before any typing.

**Only relevant people are offered**: someone at least one non-template
assignment points at — directly, via a team they belong to, or via a
department in their one-level scope (their teams' departments + their
primary department). A person no task has ever touched would only ever
filter the calendar to an empty month, so the picker doesn't suggest them
(and on a fresh install with no projects it correctly offers nobody).
`opts[:project_uuids]` narrows relevance to assignments of the given
projects — the per-project Calendar tab passes its rendered tree so the
picker offers only that project's people.

Returns `{rows, has_more}` where each row is the picker's
`%{kind:, uuid:, label:, sublabel:, icon:}` shape. `{[], false}` on a
failed staff read. `opts[:exclude]` drops the given person uuids at the
database (already-picked chips shouldn't reappear as suggestions) without
disturbing the page/`has_more` math.

Queries the staff `Person` schema directly (already a hard schema dep via
the assignment FKs) because the staff context's `list_people/1` has no
LIMIT — the whole point here is not loading 1000 people per keystroke.

# `unassigned?`

```elixir
@spec unassigned?(PhoenixKitProjects.Schemas.Assignment.t()) :: boolean()
```

Whether the assignment has no assignee at all (person/team/department).

---

*Consult [api-reference.md](api-reference.md) for complete listing*
