# Teams — persistent crews and direct staff selection on the timesheet

Date: 2026-09-08
Status: design locked 2026-09-08 (open: overlap rule, item 29; reassign affordance "Забрати"). Prototype: `docs/prototypes/timesheet-staff-teams-selection.html`, published at https://hortusys-teams-prototype.pages.dev
Replaces: the per-timesheet `Group` / `GroupUser` model and the Build Team screen

## What changes for the user

Today a supervisor must first add workers to "my team" on the Build Team screen. Only those
workers appear on the timesheet screen, and a worker in another supervisor's daily group shows
as "taken".

After this change:

- The timesheet screen lists **every worker and supervisor** in the current orchard, plus the
  **teams** visible in it. The supervisor selects people or a whole team and starts a task right
  there.
- A **team** is a saved crew: a name, members, and optionally one orchard. It lives
  across days. A team can also be created for one day only.
- The Build Team tab becomes the **Teams** tab, where teams are created, edited and deleted.

## Decisions (locked)

### Data model

1. **Teams are templates, not the unit of work.** Selecting a team on the timesheet selects its
   members. Nothing about the manual task or timesheet references the team afterwards.
2. **New tables `teams` and `team_members`** on both server and app. The old `groups` and
   `groups_users` tables stay on the server as history. The app stops syncing them and drops its
   local copies.
3. **`teams.timesheet_id` is nullable.** `NULL` = constant team, visible every day. Set = one-day
   team for that timesheet, shown only on that date.
4. **`teams.orchard_id` is nullable.** `NULL` = visible in every orchard. Set = visible only in
   that orchard. One orchard at most, no join table.
5. **`teams.leader_id` is nullable and set only in the team wizard (2026-09-08).** The leader is
   chosen from the team's members on its own step and may be left empty. It is not shown on the
   timesheet screen, team rows, cards or the team sheet. No new user role exists on either side.
6. **`teams.name` is required.** The name step pre-fills "Бригада {leader first name}" when a
   leader was chosen, otherwise "Бригада {next number}", and offers suggestion chips. The user
   can overwrite it.
7. **A worker may be in several teams.** No uniqueness across teams; `(team_id, user_id)` is unique
   within a team.
8. **Teams are shared by orchard and editable by anyone who sees them.** `created_by_id` is stored
   for history only. No per-owner edit rule.

```
teams(id, name NOT NULL, leader_id NULL → users, orchard_id NULL → orchards,
      timesheet_id NULL → timesheets, created_by_id → users, deleted, created_at, updated_at)
team_members(id, team_id → teams, user_id → users, deleted, created_at, updated_at,
             UNIQUE(team_id, user_id))
```

### The daily group is gone

9. **No "add to my team first" step.** The timesheet screen shows everyone in the orchard.
10. **"Busy" is derived from running manual tasks, not from group membership.** A worker with a
    task in progress under another supervisor shows as busy with that supervisor's name. The
    existing `REASSIGN_WORKERS` permission still allows taking them.
11. **"Current timesheet" is found or created by date alone.** Today the supervisor's timesheet is
    found through an inner join on their `groups` row (`TimesheetDao.observeByDateAndSupervisorId`)
    and `createTimesheetWithGroup` inserts the group in the same transaction. Both change to
    date-only lookups.
12. **The QR clock-in scanner no longer adds the scanned worker to a group.** Scanning selects
    them on the timesheet screen.

### Where teams are managed

13. **Teams are created and edited only on the Teams screen.** The timesheet screen uses teams; it
    does not create them. The Teams screen replaces Build Team in the bottom bar.

### Timesheet screen (locked 2026-09-08, layout C1)

14. **No tabs.** "Вільні" is the screen itself. "В процесі" (clocked in) moves to the bottom
    navigation as its own destination. Bottom bar: Зміна, В процесі, Бригади, Усі задачі.
15. **Scan is a top-bar action icon**, not a FAB. The only FAB is "Вибрати роботу" with the
    selection count, shown once at least one person is selected.
16. **The current selection is a tray under the top bar**: member faces, count, first names,
    "Очистити". Empty state is a dashed placeholder with a one-line hint.
17. **Search** filters by person name and team name together.
18. **Filter chips** are an M3 expressive connected button group under the search field:
    "Усі / Персонал / Бригади". "Усі" is one list, teams first, then people. **The chip row is
    hidden when there are no teams at all**; the screen is then just the people list.
19. **Workers not assigned to the current orchard are hidden** from the list. They still appear
    inside a team sheet, dimmed, with the reason, so the "3 з 5" count stays honest.
20. **Workers busy under another supervisor stay in the list**, dimmed, with "Працює з
    {supervisor}". "Забрати" on the row is the reassign override (shape still open).
20a. **Your own clocked-in workers are selectable** for a new task straight from the list. Their
    row shows the current work and its start time as recorded, e.g. "Обрізка, з 08:20", instead
    of the role. No elapsed-time arithmetic anywhere: times are shown exactly as stored.
20b. **Empty state** (no workers in the orchard): tray and search hidden, one message, two actions:
    scan and add worker. **Error state** (refresh failed): a compact banner above the list with
    "Повторити"; stale data stays visible with "Дані від {time}".
21. **Tapping a team opens a bottom sheet** titled with the team name only, listing its members, free ones pre-checked, busy or
    away ones dimmed with the reason. Nobody is added until the user confirms.
22. **Rejected (2026-09-08):** A crew grid with immediate select, B horizontal shelf with
    immediate select, C2 docked bottom selection bar, C3 selected people as chips inside the
    search field.

### Shared row and control geometry (locked 2026-09-08)

Every selectable row on the timesheet screen, in the team sheet and in the wizard is the bin
wizard's `SelectableRow` (`feature/harvesting/.../bin_wizard/presentation/chrome/SelectableRow.kt`,
currently `internal`; promote it to `:core:ui` rather than cloning it):

| Element | Value | Source |
|---|---|---|
| Row container | `Surface`, `RoundedCornerShape(16.dp)`, `heightIn(min = 72.dp)` | `SelectableRowDefaults.Corner` / `MinHeight` |
| Row colour | `surfaceContainer` unselected, `secondaryContainer` selected, content via `contentColorFor`, 140 ms tween | `SelectableRowDefaults.colors` |
| Row content | `ListItem` (headline `bodyLarge`, supporting `bodyMedium`), transparent container | `SelectableRow` |
| List | `LazyColumn`, `contentPadding` top `spacing_small`, sides `spacing_medium`, `spacedBy(spacing_small)`, `animateItem()` | `CrewCandidateList` |
| Avatar | `InitialsAvatar`, 48 dp circle, `titleMedium` bold, white initials, categorical colour | `UserUtils.InitialsAvatar` |
| Check | `SelectionCheckIcon`: `icon_size_medium` (32 dp) circle, 2 dp `outline` border, `primary` fill with an `icon_size_small` (18 dp) `onPrimary` check, spring scale-in | `selection/SelectionCheckIcon.kt` |
| Team row leading | 48 dp circle in `secondaryContainer` with the group icon; `primary` when the row is selected | new, same footprint as the avatar |
| Search | `DefaultSearchBar`, full width, `spacing_medium` side padding | `CrewSearchBar` |
| Primary action | `WizardPrimaryButton`: `heightIn(min = 56.dp)`, `RoundedCornerShape(20.dp)`, `titleMediumEmphasized` | `WizardButtons.kt` |
| Secondary action | `WizardSecondaryButton` (outlined) / `WizardTonalButton`, same height and shape | `WizardButtons.kt` |
| Step progress | `WizardStepSegments`: 5 dp segments, `spacing_smaller` gaps, `secondaryContainer` done / `primary` active / `surfaceContainerHigh` upcoming, 250 ms colour tween | `WizardStepSegments.kt` |
| Wizard top bar | `DefaultCenterAlignedTopBar`, title `wizard_step_progress` ("Крок N з M"), subtitle = member count, close action | `BinWizardScreen.WizardTopBar` |
| Wizard footer | `Surface` on `surface`, `spacing_medium` sides, `spacing_small` vertical, Назад `weight(1)` + Далі `weight(2)`, footer only rides above the keyboard | `BinWizardScreen.WizardFooter` |
| Step transition | `AnimatedContent`, slide horizontally in the direction of travel plus fade | `BinWizardScreen.StepContent` |

### Haptic feedback (locked 2026-09-08)

Same constants the bin wizard uses, through `LocalView.current.performHapticFeedback`:

| Action | Constant |
|---|---|
| Select a person, team member or "Тільки сьогодні" on | `HapticFeedbackConstantsCompat.TOGGLE_ON` |
| Deselect, remove a scanned member, switch off | `TOGGLE_OFF` |
| Pick the leader or the orchard (single choice) | `CONTEXT_CLICK` |
| Confirm the team sheet, "Забрати", "Далі", "Створити", a successful badge scan | `CONFIRM` |
| Tap a dimmed (busy or away) row, "Далі" while nothing is selected, an unknown badge | `REJECT` |

Scan feedback itself (success / error banner and vibration) comes from the app-scoped
`ScanFeedbackHost`, exactly as in the bin wizard; the team wizard emits the same `UiEvent.Vibrate`.

### Planned tasks on the same day (prototyped 2026-09-08, rule not yet chosen)

A worker can hold several manual tasks on one timesheet: finished ones, at most one running, and
any number planned for later (start time in the future). Today's working-users query treats any
task without an end time as "working", so a planned evening task hides the worker from the
morning shift. That changes:

26. **Task state is derived from the timesheet clock.** `planned` = start in the future;
    `running` = started, no end; `done` = end in the past. A worker is busy only with a `running`
    task. Planned tasks never block selection.
27. **Rows show the plan as a pill**: clock icon, start time and work type ("17:00 Обрізка"); two
    or more plans collapse to "ще N заплановано". On Зміна the row keeps the role line; on
    В процесі the running task stays the main line and the pill sits under it.
28. **Tapping a row on В процесі opens the day's timeline sheet**: done, running and planned
    tasks in start order with a dot rail, each with its stored start and end ("08:20 – 12:10",
    "08:20 – …"), "Завершити" on the running one, "Скасувати" on a plan,
    and "Змінити роботу" as the sheet's primary action.
29. **Overlap rule, two options in the prototype, one to lock:**
    - **Автозавершення:** when a plan's start time arrives while an earlier task is still open,
      the earlier task is closed at that exact time and the plan starts. The timeline marks it
      "завершено о 17:00 автоматично".
    - **Попередження:** the earlier task stays open, the plan enters a `blocked` state
      ("не розпочато"), the row gets a "перекриває 17:00" pill, a banner lists the affected
      workers, and the timeline offers "Почати" once the running task is finished.

### Teams screen (bottom-nav "Бригади")

23. Redesigned; list of team cards (name, orchard or "Сьогодні" pill, member count, no avatars),
    search, chips "Усі / {orchard} / Сьогодні", FAB "Нова бригада".
24. **Creating a team is a wizard built from the existing wizard components** (`WizardStepSegments`,
    `WizardPrimaryButton` / `WizardSecondaryButton`, `DefaultCenterAlignedTopBar` with the
    "Крок N з M" title and a subtitle), same as the bin wizard:
    1. **Учасники** draws its own chrome, mirroring `CrewStepUiComponent`: `CenterAlignedTopAppBar`
       with back, title "Сканування" / "Учасники", and the list ↔ scanner toggle icon; scanner
       first (`UniversalScanner` with `ScanTarget.BADGE`: camera / hardware segmented toggle,
       "Скануйте · Бейдж працівника" banner, viewfinder), or the `SelectableRow` list with
       `DefaultSearchBar`. Footer in scanner mode: `WizardSecondaryButton` "{N} осіб" with a
       chevron that opens the scanned-members sheet, plus `WizardPrimaryButton` "Далі"; in list
       mode a full-width "Далі". At least one member is required.
    2. **Бригадир** single-select list over the chosen members plus "Без бригадира", using the
       project `CircularCheckbox` trailing each row (the `UserItem` pattern), not radio cards.
    3. **Сад** single-select list "Усі сади" or one orchard, same row pattern. **Skipped when the company has one orchard.**
    4. **Назва** a large single-line field with a primary rule and a character counter,
       no suggestion chips, the "Тільки сьогодні"
       switch and a summary card. The last button is "Створити".
    The Teams list has its own empty state ("Бригад ще немає" with a create action) and error
    state. The orchard chip on the list is hidden when there is one orchard.
25. The leader is set only inside the wizard and never displayed elsewhere (see 5).

## Server

Server ships first. Rows to add in the Rails repo:

| Row | Notes |
|---|---|
| Migration `teams`, `team_members` | Columns above. Keep `groups`, `groups_users` untouched. |
| `app/models/team.rb`, `team_member.rb` | `SeasonScoped`? No: constant teams have no timesheet. `OrchardScoped` through `orchard_id` when set, otherwise through members' `user_orchards`. |
| Routes symbol, `Api::V2::TeamsController`, `TeamMembersController` | `serve_actions :all`. `permit_params :name, :leader_id, :orchard_id, :timesheet_id` and `:team_id, :user_id`. |
| Serializers | Exactly the permitted columns plus timestamps. |
| Pundit policies | Any supervisor who can see the orchard can read and write. |
| Contract spec | Round-trip incl. an **empty** team. |

Two traps found in the current `groups` implementation that the new endpoints must not repeat:

- `GroupsController#records_processing` drops groups with zero in-scope members from `index` and
  `changes`. A freshly created team has no members, so `teams` must sync while empty.
- `groups_users` has no `deleted` column server-side while the app soft-deletes. `team_members`
  gets a `deleted` flag so removal round-trips like every other syncable entity.

## App

| Layer | Work |
|---|---|
| `:core:database` | `TeamEntity`, `TeamMemberEntity`, DAOs, tuples, schema bump; drop `GroupEntity`, `GroupUserCrossRef` and their tuples. |
| `:core:model` | `Team`, `TeamMember` models. Remove `Group`, `GroupUser`. |
| `:core:network` | `TeamApiClient`, `TeamMemberApiClient`. |
| `:core:data` | Repositories, mapper services, upload nodes, `SyncDataWorker` SEND/FETCH order (teams before members), `ScopeInvalidationModule` registration, timesheet date-only lookup, `GetAvailableUsersByTimesheetUseCase` rewritten to "everyone in orchard minus busy". |
| `:feature:timesheet` | Timesheet screen: search, chips, teams, selection. Teams screen replaces Build Team. Scanner stops writing groups. |
| Tests | Repository tests, use case precondition-failure tests, `ScopeInvalidationOrderTest` still green. |

## Open (to settle in the prototype)

- How the reassign override looks on a busy row ("Забрати" button is the current proposal, shown
  both in the list and inside the team sheet).
- Whether a one-day team should be promotable to a constant one (nice to have, not planned).
