feat: teams within a project

Projects keep teams of members in their settings (settings.teams; people
outside the project are dropped, teams are archived, not removed; absent
when saving means unchanged). Tickets go to a team (`team`, also filtered
in the list and in MCP search_issues), and a comment mentioning
@[Frontend](team-FRONT) tells each of its members. Settings has the teams
with their members, the ticket a «Equipo» select, the list a team filter,
and «@» offers the teams after the people.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 16:20:35 +02:00
parent 4acf629106
commit 869acd3f1f
41 changed files with 895 additions and 13 deletions

View File

@@ -234,6 +234,7 @@ describe('ProjectFacade', () => {
issueTypes: [],
areas: [],
customFields: [],
teams: [],
};
project.saveSettings(settings);

View File

@@ -1,6 +1,7 @@
import { computed, inject, Injectable, Provider } from '@angular/core';
import { injectDispatch } from '@ngrx/signals/events';
import {
TEAM_MENTION_PREFIX,
CreateReleaseInput,
CreateSprintInput,
ProjectRole,
@@ -25,6 +26,20 @@ export class ProjectFacade {
readonly project = this.store.project;
readonly members = this.store.members;
/**
* Who `@` offers: the members, then the active teams (a team's mention,
* `team-<id>`, tells each of its members).
*/
readonly mentionable = computed(() => [
...this.store.members().map((m) => m.user),
...this.store
.workflow()
.activeTeams()
.map((t) => ({
id: `${TEAM_MENTION_PREFIX}${t.id}`,
name: `${t.name} · equipo`,
})),
]);
readonly stats = this.store.stats;
readonly releases = this.store.releases;
/** Under way, planned, then closed; with their progress. */

View File

@@ -46,6 +46,7 @@ export interface CreateIssueInput {
title: string;
description?: string | null;
area?: string | null;
team?: string | null;
priority?: IssuePriority;
labels?: string[];
figmaUrl?: string | null;
@@ -98,6 +99,7 @@ export interface IssueListFilter {
search?: string;
categories?: StatusCategory[];
types?: string[];
teams?: string[];
priorities?: IssuePriority[];
assigneeId?: string;
unassigned?: boolean;

View File

@@ -131,6 +131,8 @@ export interface IssueSummary {
/** Ids of the project's settings. */
type: string;
area: string | null;
/** A team id of the project's settings. */
team: string | null;
title: string;
status: string;
statusCategory: StatusCategory;

View File

@@ -84,6 +84,17 @@ export interface CustomFieldDefinition {
archived: boolean;
}
/** A group of the project's members; `@[Name](team-ID)` tells them all. */
export interface TeamDefinition {
id: string;
name: string;
memberIds: string[];
archived: boolean;
}
/** Prefix of a team's id in a mention. */
export const TEAM_MENTION_PREFIX = 'team-';
/** A ticket's value of a custom field. */
export interface CustomFieldValue {
fieldId: string;
@@ -99,6 +110,7 @@ export interface ProjectSettings {
customFields: CustomFieldDefinition[];
/** Definition of done: a DONE status only with the checklist ticked. */
checklistBeforeDone?: boolean;
teams: TeamDefinition[];
}
export const FINISHED_CATEGORIES: readonly StatusCategory[] = [
@@ -134,6 +146,7 @@ const EMPTY: ProjectSettings = {
issueTypes: [],
areas: [],
customFields: [],
teams: [],
};
/**
@@ -187,6 +200,15 @@ export class Workflow {
return this.settings.areas.filter((a) => !a.archived);
}
/** Teams to pick (archived ones only show where a ticket still has them). */
activeTeams(): TeamDefinition[] {
return (this.settings.teams ?? []).filter((t) => !t.archived);
}
team(id: string): TeamDefinition | undefined {
return this.settings.teams?.find((t) => t.id === id);
}
/** Custom fields to fill in (archived ones only show where they have a value). */
activeCustomFields(): CustomFieldDefinition[] {
return (this.settings.customFields ?? []).filter((f) => !f.archived);

View File

@@ -113,5 +113,11 @@ export function toSettingsInput(
archived: f.archived,
})),
checklistBeforeDone: !!settings.checklistBeforeDone,
teams: (settings.teams ?? []).map((t) => ({
id: t.id,
name: t.name,
memberIds: t.memberIds,
archived: t.archived,
})),
};
}

View File

@@ -60,6 +60,12 @@ fragment ProjectFields on Project {
archived
}
checklistBeforeDone
teams {
id
name
memberIds
archived
}
}
}
@@ -69,6 +75,7 @@ fragment IssueSummaryFields on Issue {
key
type
area
team
title
status
statusCategory

View File

@@ -104,6 +104,8 @@ export type CreateIssueInput = {
/** When work should start (roadmap), YYYY-MM-DD */
startDate?: string | null | undefined;
storyPoints?: number | null | undefined;
/** A team id */
team?: string | null | undefined;
title: string;
/** A ticket type id */
type: string;
@@ -194,6 +196,8 @@ export type IssueFilterInput = {
sort?: IssueSort | null | undefined;
sprintId?: string | null | undefined;
statuses?: Array<string> | null | undefined;
/** Team ids */
teams?: Array<string> | null | undefined;
types?: Array<string> | null | undefined;
unassigned?: boolean | null | undefined;
/** Only tickets not updated since this instant (ISO) */
@@ -279,6 +283,8 @@ export type ProjectSettingsInput = {
customFields?: Array<CustomFieldDefinitionInput> | null | undefined;
issueTypes: Array<IssueTypeOptionInput>;
statuses: Array<WorkflowStatusInput>;
/** Absent: unchanged. Teams cannot be removed: archive them. */
teams?: Array<TeamInput> | null | undefined;
transitions: Array<StatusTransitionsInput>;
};
@@ -339,6 +345,13 @@ export type StatusTransitionsInput = {
to: Array<string>;
};
export type TeamInput = {
archived?: boolean;
id: string;
memberIds?: Array<string>;
name: string;
};
export type UpdateChecklistItemInput = {
done?: boolean | null | undefined;
issueId: string;
@@ -361,6 +374,8 @@ export type UpdateIssueInput = {
/** When work should start (roadmap), YYYY-MM-DD */
startDate?: string | null | undefined;
storyPoints?: number | null | undefined;
/** A team id */
team?: string | null | undefined;
title?: string | null | undefined;
type?: string | null | undefined;
};
@@ -560,6 +575,12 @@ export type ProjectFieldsFragment = {
options: Array<string>;
archived: boolean;
}>;
teams: Array<{
id: string;
name: string;
memberIds: Array<string>;
archived: boolean;
}>;
};
};
@@ -569,6 +590,7 @@ export type IssueSummaryFieldsFragment = {
key: string;
type: string;
area: string | null;
team: string | null;
title: string;
status: string;
statusCategory: StatusCategory;
@@ -602,6 +624,7 @@ export type IssueFieldsFragment = {
key: string;
type: string;
area: string | null;
team: string | null;
title: string;
status: string;
statusCategory: StatusCategory;
@@ -780,6 +803,7 @@ export type BoardQuery = {
key: string;
type: string;
area: string | null;
team: string | null;
title: string;
status: string;
statusCategory: StatusCategory;
@@ -821,6 +845,7 @@ export type IssueQuery = {
key: string;
type: string;
area: string | null;
team: string | null;
title: string;
status: string;
statusCategory: StatusCategory;
@@ -886,6 +911,7 @@ export type SearchIssuesQuery = {
key: string;
type: string;
area: string | null;
team: string | null;
title: string;
status: string;
statusCategory: StatusCategory;
@@ -1059,6 +1085,7 @@ export type MyIssuesQuery = {
key: string;
type: string;
area: string | null;
team: string | null;
title: string;
status: string;
statusCategory: StatusCategory;
@@ -1096,6 +1123,7 @@ export type IssueChildrenQuery = {
key: string;
type: string;
area: string | null;
team: string | null;
title: string;
status: string;
statusCategory: StatusCategory;
@@ -1133,6 +1161,7 @@ export type EpicsQuery = {
key: string;
type: string;
area: string | null;
team: string | null;
title: string;
status: string;
statusCategory: StatusCategory;
@@ -1218,6 +1247,7 @@ export type BacklogQuery = {
key: string;
type: string;
area: string | null;
team: string | null;
title: string;
status: string;
statusCategory: StatusCategory;
@@ -1289,6 +1319,7 @@ export type TrashQuery = {
key: string;
type: string;
area: string | null;
team: string | null;
title: string;
status: string;
statusCategory: StatusCategory;
@@ -1325,6 +1356,7 @@ export type DueIssuesQuery = {
key: string;
type: string;
area: string | null;
team: string | null;
title: string;
status: string;
statusCategory: StatusCategory;
@@ -1364,6 +1396,7 @@ export type IssueListQuery = {
key: string;
type: string;
area: string | null;
team: string | null;
title: string;
status: string;
statusCategory: StatusCategory;
@@ -1559,6 +1592,12 @@ export type MyProjectsQuery = {
options: Array<string>;
archived: boolean;
}>;
teams: Array<{
id: string;
name: string;
memberIds: Array<string>;
archived: boolean;
}>;
};
}>;
};
@@ -1611,6 +1650,12 @@ export type ProjectQuery = {
options: Array<string>;
archived: boolean;
}>;
teams: Array<{
id: string;
name: string;
memberIds: Array<string>;
archived: boolean;
}>;
};
};
};
@@ -1773,6 +1818,12 @@ export type PublicProjectsQuery = {
options: Array<string>;
archived: boolean;
}>;
teams: Array<{
id: string;
name: string;
memberIds: Array<string>;
archived: boolean;
}>;
};
}>;
};
@@ -2331,6 +2382,7 @@ export type IssueChangedSubscription = {
key: string;
type: string;
area: string | null;
team: string | null;
title: string;
status: string;
statusCategory: StatusCategory;
@@ -2466,6 +2518,12 @@ export type ProjectChangedSubscription = {
options: Array<string>;
archived: boolean;
}>;
teams: Array<{
id: string;
name: string;
memberIds: Array<string>;
archived: boolean;
}>;
};
};
};
@@ -2521,6 +2579,12 @@ export type MyProjectsChangedSubscription = {
options: Array<string>;
archived: boolean;
}>;
teams: Array<{
id: string;
name: string;
memberIds: Array<string>;
archived: boolean;
}>;
};
};
};
@@ -2909,6 +2973,25 @@ export const ProjectFieldsFragmentDoc = {
kind: 'Field',
name: { kind: 'Name', value: 'checklistBeforeDone' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'teams' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'memberIds' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'archived' },
},
],
},
},
],
},
},
@@ -2950,6 +3033,7 @@ export const IssueSummaryFieldsFragmentDoc = {
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
{ kind: 'Field', name: { kind: 'Name', value: 'type' } },
{ kind: 'Field', name: { kind: 'Name', value: 'area' } },
{ kind: 'Field', name: { kind: 'Name', value: 'team' } },
{ kind: 'Field', name: { kind: 'Name', value: 'title' } },
{ kind: 'Field', name: { kind: 'Name', value: 'status' } },
{ kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } },
@@ -3209,6 +3293,7 @@ export const IssueFieldsFragmentDoc = {
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
{ kind: 'Field', name: { kind: 'Name', value: 'type' } },
{ kind: 'Field', name: { kind: 'Name', value: 'area' } },
{ kind: 'Field', name: { kind: 'Name', value: 'team' } },
{ kind: 'Field', name: { kind: 'Name', value: 'title' } },
{ kind: 'Field', name: { kind: 'Name', value: 'status' } },
{ kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } },
@@ -4986,6 +5071,7 @@ export const BoardDocument = {
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
{ kind: 'Field', name: { kind: 'Name', value: 'type' } },
{ kind: 'Field', name: { kind: 'Name', value: 'area' } },
{ kind: 'Field', name: { kind: 'Name', value: 'team' } },
{ kind: 'Field', name: { kind: 'Name', value: 'title' } },
{ kind: 'Field', name: { kind: 'Name', value: 'status' } },
{ kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } },
@@ -5158,6 +5244,7 @@ export const IssueDocument = {
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
{ kind: 'Field', name: { kind: 'Name', value: 'type' } },
{ kind: 'Field', name: { kind: 'Name', value: 'area' } },
{ kind: 'Field', name: { kind: 'Name', value: 'team' } },
{ kind: 'Field', name: { kind: 'Name', value: 'title' } },
{ kind: 'Field', name: { kind: 'Name', value: 'status' } },
{ kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } },
@@ -5465,6 +5552,7 @@ export const SearchIssuesDocument = {
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
{ kind: 'Field', name: { kind: 'Name', value: 'type' } },
{ kind: 'Field', name: { kind: 'Name', value: 'area' } },
{ kind: 'Field', name: { kind: 'Name', value: 'team' } },
{ kind: 'Field', name: { kind: 'Name', value: 'title' } },
{ kind: 'Field', name: { kind: 'Name', value: 'status' } },
{ kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } },
@@ -6873,6 +6961,7 @@ export const MyIssuesDocument = {
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
{ kind: 'Field', name: { kind: 'Name', value: 'type' } },
{ kind: 'Field', name: { kind: 'Name', value: 'area' } },
{ kind: 'Field', name: { kind: 'Name', value: 'team' } },
{ kind: 'Field', name: { kind: 'Name', value: 'title' } },
{ kind: 'Field', name: { kind: 'Name', value: 'status' } },
{ kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } },
@@ -7059,6 +7148,7 @@ export const IssueChildrenDocument = {
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
{ kind: 'Field', name: { kind: 'Name', value: 'type' } },
{ kind: 'Field', name: { kind: 'Name', value: 'area' } },
{ kind: 'Field', name: { kind: 'Name', value: 'team' } },
{ kind: 'Field', name: { kind: 'Name', value: 'title' } },
{ kind: 'Field', name: { kind: 'Name', value: 'status' } },
{ kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } },
@@ -7288,6 +7378,7 @@ export const EpicsDocument = {
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
{ kind: 'Field', name: { kind: 'Name', value: 'type' } },
{ kind: 'Field', name: { kind: 'Name', value: 'area' } },
{ kind: 'Field', name: { kind: 'Name', value: 'team' } },
{ kind: 'Field', name: { kind: 'Name', value: 'title' } },
{ kind: 'Field', name: { kind: 'Name', value: 'status' } },
{ kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } },
@@ -7796,6 +7887,7 @@ export const BacklogDocument = {
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
{ kind: 'Field', name: { kind: 'Name', value: 'type' } },
{ kind: 'Field', name: { kind: 'Name', value: 'area' } },
{ kind: 'Field', name: { kind: 'Name', value: 'team' } },
{ kind: 'Field', name: { kind: 'Name', value: 'title' } },
{ kind: 'Field', name: { kind: 'Name', value: 'status' } },
{ kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } },
@@ -8241,6 +8333,7 @@ export const TrashDocument = {
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
{ kind: 'Field', name: { kind: 'Name', value: 'type' } },
{ kind: 'Field', name: { kind: 'Name', value: 'area' } },
{ kind: 'Field', name: { kind: 'Name', value: 'team' } },
{ kind: 'Field', name: { kind: 'Name', value: 'title' } },
{ kind: 'Field', name: { kind: 'Name', value: 'status' } },
{ kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } },
@@ -8427,6 +8520,7 @@ export const DueIssuesDocument = {
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
{ kind: 'Field', name: { kind: 'Name', value: 'type' } },
{ kind: 'Field', name: { kind: 'Name', value: 'area' } },
{ kind: 'Field', name: { kind: 'Name', value: 'team' } },
{ kind: 'Field', name: { kind: 'Name', value: 'title' } },
{ kind: 'Field', name: { kind: 'Name', value: 'status' } },
{ kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } },
@@ -8628,6 +8722,7 @@ export const IssueListDocument = {
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
{ kind: 'Field', name: { kind: 'Name', value: 'type' } },
{ kind: 'Field', name: { kind: 'Name', value: 'area' } },
{ kind: 'Field', name: { kind: 'Name', value: 'team' } },
{ kind: 'Field', name: { kind: 'Name', value: 'title' } },
{ kind: 'Field', name: { kind: 'Name', value: 'status' } },
{ kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } },
@@ -9655,6 +9750,25 @@ export const MyProjectsDocument = {
kind: 'Field',
name: { kind: 'Name', value: 'checklistBeforeDone' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'teams' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'memberIds' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'archived' },
},
],
},
},
],
},
},
@@ -9903,6 +10017,25 @@ export const ProjectDocument = {
kind: 'Field',
name: { kind: 'Name', value: 'checklistBeforeDone' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'teams' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'memberIds' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'archived' },
},
],
},
},
],
},
},
@@ -11002,6 +11135,25 @@ export const PublicProjectsDocument = {
kind: 'Field',
name: { kind: 'Name', value: 'checklistBeforeDone' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'teams' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'memberIds' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'archived' },
},
],
},
},
],
},
},
@@ -13868,6 +14020,7 @@ export const IssueChangedDocument = {
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
{ kind: 'Field', name: { kind: 'Name', value: 'type' } },
{ kind: 'Field', name: { kind: 'Name', value: 'area' } },
{ kind: 'Field', name: { kind: 'Name', value: 'team' } },
{ kind: 'Field', name: { kind: 'Name', value: 'title' } },
{ kind: 'Field', name: { kind: 'Name', value: 'status' } },
{ kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } },
@@ -14529,6 +14682,25 @@ export const ProjectChangedDocument = {
kind: 'Field',
name: { kind: 'Name', value: 'checklistBeforeDone' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'teams' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'memberIds' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'archived' },
},
],
},
},
],
},
},
@@ -14764,6 +14936,25 @@ export const MyProjectsChangedDocument = {
kind: 'Field',
name: { kind: 'Name', value: 'checklistBeforeDone' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'teams' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'memberIds' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'archived' },
},
],
},
},
],
},
},

View File

@@ -477,9 +477,7 @@ export class BoardPage {
protected readonly board = inject(BoardFacade);
protected readonly project = inject(ProjectFacade);
/** Who `@` offers in the new ticket's description. */
protected readonly mentionable = computed(() =>
this.project.members().map((m) => m.user),
);
protected readonly mentionable = this.project.mentionable;
private readonly drawer = inject(DrawerService);
private readonly toast = inject(ToastService);
private readonly auth = inject(AuthFacade);

View File

@@ -854,6 +854,16 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
>
<pi-label>Área</pi-label>
</pi-select-field>
@if (teamOptions().length > 1) {
<pi-select-field
[options]="teamOptions()"
[disabled]="!project.canContribute()"
[value]="issue.team ?? ''"
(valueChange)="update({ team: $any($event || null) })"
>
<pi-label>Equipo</pi-label>
</pi-select-field>
}
@for (field of customFields(); track field.id) {
<ts-custom-field
[field]="field"
@@ -1076,6 +1086,18 @@ export class IssuePage {
.map((t) => ({ value: t.id, label: t.name }));
},
);
protected readonly teamOptions = computed<PiSelectFieldOption<string>[]>(
() => {
const current = this.facade.issue()?.team;
return [
{ value: NONE, label: 'Sin equipo' },
...this.project
.workflow()
.settings.teams.filter((t) => !t.archived || t.id === current)
.map((t) => ({ value: t.id, label: t.name })),
];
},
);
protected readonly areaOptions = computed<PiSelectFieldOption<string>[]>(
() => {
const workflow = this.project.workflow();
@@ -1164,9 +1186,7 @@ export class IssuePage {
protected readonly pasteImage: ImageUploader = (file) =>
this.facade.uploadImage(file);
/** Who `@` offers in what is written here. */
protected readonly mentionable = computed(() =>
this.project.members().map((m) => m.user),
);
protected readonly mentionable = this.project.mentionable;
/** Pull requests first, then the newest. */
protected readonly devLinks = computed(() =>
[...(this.facade.issue()?.devLinks ?? [])].sort(
@@ -1332,6 +1352,7 @@ export class IssuePage {
type?: string;
priority?: IssuePriority;
area?: string | null;
team?: string | null;
storyPoints?: number | null;
startDate?: string | null;
dueDate?: string | null;

View File

@@ -112,6 +112,9 @@ const numberOf = (key: string) => Number(key.split('-').pop());
[options]="releaseFilterOptions()"
[(value)]="releaseFilter"
/>
@if (teamFilterOptions().length > 1) {
<pi-select-field [options]="teamFilterOptions()" [(value)]="team" />
}
@for (field of listFields(); track field.id) {
<pi-select-field
[options]="field.options"
@@ -527,6 +530,18 @@ export class ListPage {
protected readonly priority = signal<string | null>(ALL);
protected readonly assignee = signal<string | null>(ALL);
protected readonly releaseFilter = signal<string | null>(ALL);
protected readonly team = signal<string | null>(ALL);
protected readonly teamFilterOptions = computed<
PiSelectFieldOption<string>[]
>(() => {
const teams = this.project.workflow().activeTeams();
return teams.length
? [
{ value: ALL, label: 'Todos los equipos' },
...teams.map((t) => ({ value: t.id, label: t.name })),
]
: [];
});
private readonly releaseFromUrl = effect(() => {
const id = this.release();
if (id) untracked(() => this.releaseFilter.set(id));
@@ -626,6 +641,7 @@ export class ListPage {
unassigned: assignee === NOBODY || undefined,
assigneeId: assignee && assignee !== NOBODY ? assignee : undefined,
releaseId: this.releaseFilter() || undefined,
teams: this.team() ? [this.team() as string] : undefined,
noEstimate: this.gap() === 'estimate' || undefined,
noArea: this.gap() === 'area' || undefined,
updatedBefore: this.stale()

View File

@@ -32,7 +32,7 @@ import {
} from '@task-stream/web/tracker/domain';
import { CATEGORY, COLOR_LABEL, ICON_LABEL, tone } from '../ui/copy';
type Section = 'statuses' | 'issueTypes' | 'areas' | 'customFields';
type Section = 'statuses' | 'issueTypes' | 'areas' | 'customFields' | 'teams';
const FIELD_TYPES: { id: CustomFieldType; label: string }[] = [
{ id: CustomFieldType.TEXT, label: 'Texto' },
@@ -530,6 +530,63 @@ function idFrom(name: string): string {
</ol>
</section>
<!-- Teams -->
<section class="ts-panel ts-block" aria-labelledby="teams-title">
<header class="ts-block__head">
<div>
<h3 id="teams-title">Equipos</h3>
<p class="ts-muted">
Grupos de miembros: los tickets se pueden asignar a uno y
«@equipo» avisa a todos sus miembros.
</p>
</div>
<button type="button" class="ts-add" (click)="addTeam()">
+ Añadir equipo
</button>
</header>
@if (!d.teams.length) {
<p class="ts-muted ts-empty-fields">Ningún equipo todavía.</p>
}
<ol class="ts-rows">
@for (team of d.teams; track $index; let i = $index) {
<li
class="ts-row ts-field-grid"
[class.is-archived]="team.archived"
>
<span class="ts-name">
<input
class="ts-input"
maxlength="40"
[attr.aria-label]="'Nombre del equipo ' + (i + 1)"
[value]="team.name"
(input)="setName('teams', i, $any($event.target).value)"
/>
<code class="ts-id">{{ team.id }}</code>
</span>
<ng-container
[ngTemplateOutlet]="archive"
[ngTemplateOutletContext]="{
section: 'teams',
index: i,
item: team,
}"
/>
<div class="ts-team-members ts-field-type">
@for (member of facade.members(); track member.user.id) {
<pi-checkbox
[label]="member.user.name"
[checked]="team.memberIds.includes(member.user.id)"
(checkedChange)="
setTeamMember(i, member.user.id, $event)
"
/>
}
</div>
</li>
}
</ol>
</section>
<!-- Definition of done -->
<section class="ts-panel ts-block" aria-labelledby="dod-title">
<header class="ts-block__head">
@@ -1035,6 +1092,11 @@ function idFrom(name: string): string {
resize: vertical;
}
}
.ts-team-members {
display: flex;
flex-wrap: wrap;
gap: 0.25rem 1rem;
}
.ts-empty-fields {
margin: 0;
}
@@ -1277,7 +1339,7 @@ export class SettingsPage {
const d = this.draft();
if (!d) return [];
const problems: string[] = [];
const lists = [d.statuses, d.issueTypes, d.areas, d.customFields];
const lists = [d.statuses, d.issueTypes, d.areas, d.customFields, d.teams];
if (lists.some((list) => list.some((item) => !item.name.trim())))
problems.push('Hay nombres vacíos');
if (
@@ -1449,6 +1511,26 @@ export class SettingsPage {
);
}
protected addTeam(): void {
this.patch((d) =>
d.teams.push({
id: this.uniqueId(d.teams, 'NUEVO_EQUIPO'),
name: 'Nuevo equipo',
memberIds: [],
archived: false,
}),
);
}
protected setTeamMember(index: number, userId: string, on: boolean): void {
this.patch((d) => {
const team = d.teams[index];
team.memberIds = on
? [...new Set([...team.memberIds, userId])]
: team.memberIds.filter((id) => id !== userId);
});
}
protected setChecklistBeforeDone(on: boolean): void {
this.patch((d) => {
d.checklistBeforeDone = on;