From 761e67a673584c49ad1eb00a5d9a22bc0cf41fc6 Mon Sep 17 00:00:00 2001 From: Sajith Ekanayaka Date: Sun, 25 Oct 2026 23:12:19 +0530 Subject: [PATCH 1/6] entity-service: read endpoint for confirmed engagement allocations on the team schedule Add POST /team-schedule/engagement-allocations/search. For the given user ids and a date window it returns the confirmed customer engagement allocations that overlap the window, with the engagement's display id, account name, type, mode of delivery and the allocation's utilization, so the team schedule can draw them as read-only bars on each engineer's row. Internal callers only, same window and user id validation as the other schedule reads. Additive: no existing route or schema changes. Fields the engagement or account lacks are omitted rather than invented. --- .../domain/schedule_engagement_allocation.go | 54 ++++++ .../internal/handler/schedule_handler.go | 15 ++ .../schedule_engagement_allocation_repo.go | 69 ++++++++ ...gement_allocation_repo_integration_test.go | 160 ++++++++++++++++++ .../internal/repository/schedule_repo.go | 3 + entity-service/internal/server/routes.go | 1 + .../schedule_engagement_allocation_service.go | 61 +++++++ .../internal/service/schedule_service.go | 1 + .../internal/service/schedule_service_test.go | 91 +++++++++- entity-service/openapi.yaml | 104 ++++++++++++ 10 files changed, 554 insertions(+), 5 deletions(-) create mode 100644 entity-service/internal/domain/schedule_engagement_allocation.go create mode 100644 entity-service/internal/repository/schedule_engagement_allocation_repo.go create mode 100644 entity-service/internal/repository/schedule_engagement_allocation_repo_integration_test.go create mode 100644 entity-service/internal/service/schedule_engagement_allocation_service.go diff --git a/entity-service/internal/domain/schedule_engagement_allocation.go b/entity-service/internal/domain/schedule_engagement_allocation.go new file mode 100644 index 0000000000..226c8b38d2 --- /dev/null +++ b/entity-service/internal/domain/schedule_engagement_allocation.go @@ -0,0 +1,54 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package domain + +// SearchScheduleEngagementAllocationsRequest finds the confirmed customer +// engagement allocations of the given people that overlap a date window. +// UserIDs and both dates are required: the read is for drawing an engineer's +// row, and an unbounded one would return every allocation ever made. +type SearchScheduleEngagementAllocationsRequest struct { + UserIDs []string `json:"userIds"` + From string `json:"from"` + To string `json:"to"` +} + +// ScheduleEngagementAllocation is one person's confirmed allocation to a +// customer engagement, as the team schedule draws it: a read-only bar from +// StartDate to EndDate on that person's row. +// +// Every field the engagement or its account may lack is optional and left out +// rather than invented. +type ScheduleEngagementAllocation struct { + ID string `json:"id"` + AllocationID *string `json:"allocationId,omitempty"` + UserID string `json:"userId"` + EngagementID string `json:"engagementId"` + // EngagementDisplayID is the engagement's human-readable id, not its uuid. + EngagementDisplayID *string `json:"engagementDisplayId,omitempty"` + AccountName *string `json:"accountName,omitempty"` + EngagementType *string `json:"engagementType,omitempty"` + ModeOfDelivery *string `json:"modeOfDelivery,omitempty"` + Utilization *float64 `json:"utilization,omitempty"` + StartDate string `json:"startDate"` + EndDate string `json:"endDate"` +} + +// ScheduleEngagementAllocationsResponse is the allocations over the window asked for. +type ScheduleEngagementAllocationsResponse struct { + Allocations []ScheduleEngagementAllocation `json:"allocations"` + Count int `json:"count"` +} diff --git a/entity-service/internal/handler/schedule_handler.go b/entity-service/internal/handler/schedule_handler.go index 08b424f94e..de38cf8c5e 100644 --- a/entity-service/internal/handler/schedule_handler.go +++ b/entity-service/internal/handler/schedule_handler.go @@ -82,6 +82,21 @@ func (h *ScheduleHandler) SearchScheduleAbsences(w http.ResponseWriter, r *http. writeScheduleJSON(w, http.StatusOK, resp) } +// SearchScheduleEngagementAllocations handles +// POST /team-schedule/engagement-allocations/search. +func (h *ScheduleHandler) SearchScheduleEngagementAllocations(w http.ResponseWriter, r *http.Request) { + var req domain.SearchScheduleEngagementAllocationsRequest + if !decodeRequest(w, r, &req) { + return + } + resp, err := h.svc.SearchEngagementAllocations(r.Context(), req) + if err != nil { + writeServiceError(w, r, err) + return + } + writeScheduleJSON(w, http.StatusOK, resp) +} + // GetScheduleOnDuty handles GET /team-schedule/on-duty[?at=RFC3339][&includeOnLeave=true] // -- who is responsible right now, or at the instant asked for; with // includeOnLeave, people away that day too, marked onLeave. diff --git a/entity-service/internal/repository/schedule_engagement_allocation_repo.go b/entity-service/internal/repository/schedule_engagement_allocation_repo.go new file mode 100644 index 0000000000..167ecf5bdc --- /dev/null +++ b/entity-service/internal/repository/schedule_engagement_allocation_repo.go @@ -0,0 +1,69 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package repository + +import ( + "context" + "fmt" + "time" + + "github.com/wso2-open-operations/cs-tools/entity-service/internal/domain" +) + +// SearchEngagementAllocations returns the confirmed allocations of the given +// people whose [start_date, end_date] overlaps [from, to], both ends inclusive. +// +// Only CONFIRMED rows are returned: tentative, cancelled and rejected +// allocations are not work anyone is committed to. A row without a resource, +// an engagement or both dates cannot be drawn on a person's row and is left out. +func (r *scheduleRepository) SearchEngagementAllocations(ctx context.Context, req domain.SearchScheduleEngagementAllocationsRequest) ([]domain.ScheduleEngagementAllocation, error) { + rows, err := r.db.Query(ctx, ` + SELECT ar.id::text, ar.allocation_id, ar.resource_id::text, ar.engagement_id::text, + ce.engagement_id, a.name, ce.engagement_type::text, ce.delivery_mode::text, + ar.utilization::float8, ar.start_date, ar.end_date + FROM customer_engagement_allocation_resource ar + JOIN customer_engagement ce ON ce.id = ar.engagement_id + LEFT JOIN account a ON a.id = ce.account_id + WHERE ar.resource_id = ANY($1::uuid[]) + AND ar.state = 'CONFIRMED' + AND ar.start_date IS NOT NULL AND ar.end_date IS NOT NULL + AND ar.start_date <= $3::date AND ar.end_date >= $2::date + ORDER BY ar.resource_id, ar.start_date, ar.end_date, ar.id`, + req.UserIDs, req.From, req.To) + if err != nil { + return nil, fmt.Errorf("query schedule engagement allocations: %w", err) + } + defer rows.Close() + + out := []domain.ScheduleEngagementAllocation{} + for rows.Next() { + var al domain.ScheduleEngagementAllocation + var start, end time.Time + if err := rows.Scan(&al.ID, &al.AllocationID, &al.UserID, &al.EngagementID, + &al.EngagementDisplayID, &al.AccountName, &al.EngagementType, &al.ModeOfDelivery, + &al.Utilization, &start, &end); err != nil { + return nil, fmt.Errorf("scan schedule engagement allocation: %w", err) + } + al.StartDate = start.Format("2006-01-02") + al.EndDate = end.Format("2006-01-02") + out = append(out, al) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate schedule engagement allocations: %w", err) + } + return out, nil +} diff --git a/entity-service/internal/repository/schedule_engagement_allocation_repo_integration_test.go b/entity-service/internal/repository/schedule_engagement_allocation_repo_integration_test.go new file mode 100644 index 0000000000..0c5f904338 --- /dev/null +++ b/entity-service/internal/repository/schedule_engagement_allocation_repo_integration_test.go @@ -0,0 +1,160 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +// Runs the real SQL of SearchEngagementAllocations against a migrated +// database; skipped unless ENTITY_TEST_DATABASE_URL is set (see +// schedule_repo_integration_test.go for how to build one). + +package repository + +import ( + "context" + "os" + "testing" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/wso2-open-operations/cs-tools/entity-service/internal/domain" +) + +const ( + seaAccount = "5ea10000-0000-4000-8000-000000000001" + seaUserA = "5ea10000-0000-4000-8000-000000000011" + seaUserB = "5ea10000-0000-4000-8000-000000000012" + seaEngA = "5ea10000-0000-4000-8000-000000000021" + seaEngB = "5ea10000-0000-4000-8000-000000000022" +) + +func newEngagementAllocationIntegrationRepo(t *testing.T) (ScheduleRepository, *pgxpool.Pool) { + t.Helper() + dsn := os.Getenv("ENTITY_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("ENTITY_TEST_DATABASE_URL is not set; skipping the live-database tests") + } + ctx := context.Background() + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Fatalf("connect: %v", err) + } + t.Cleanup(pool.Close) + + clean := func() { + mustExec(t, pool, `DELETE FROM customer_engagement WHERE id IN ($1, $2)`, seaEngA, seaEngB) + mustExec(t, pool, `DELETE FROM account WHERE id = $1`, seaAccount) + mustExec(t, pool, `DELETE FROM "user" WHERE id IN ($1, $2)`, seaUserA, seaUserB) + } + clean() + t.Cleanup(clean) + + mustExec(t, pool, `INSERT INTO account (id, created_on, updated_on, created_by, updated_by, name, number) + VALUES ($1, now(), now(), 't', 't', 'Example Corp', 'ACC-SEA-IT-1')`, seaAccount) + for _, u := range []struct{ id, email string }{{seaUserA, "jane.doe@example.com"}, {seaUserB, "john.roe@example.com"}} { + mustExec(t, pool, `INSERT INTO "user" (id, created_on, updated_on, user_name, email) + VALUES ($1, now(), now(), $2, $2)`, u.id, u.email) + } + // A is on an account with a type and a delivery mode; B has neither. + mustExec(t, pool, `INSERT INTO customer_engagement (id, created_on, updated_on, name, engagement_id, state, account_id, engagement_type, delivery_mode) + VALUES ($1, now(), now(), 'Rollout', 'EITSEA1', 'IN_PROGRESS', $2, 'CONSULTANCY', 'ONSITE')`, seaEngA, seaAccount) + mustExec(t, pool, `INSERT INTO customer_engagement (id, created_on, updated_on, name, state) + VALUES ($1, now(), now(), 'No account', 'IN_PROGRESS')`, seaEngB) + + alloc := func(allocID, eng, user, state, start, end string, util any) { + mustExec(t, pool, `INSERT INTO customer_engagement_allocation_resource + (id, created_on, updated_on, allocation_id, engagement_id, resource_id, state, start_date, end_date, utilization) + VALUES (gen_random_uuid(), now(), now(), $1, $2, $3, $4::engagement_allocation_state_enum, $5::date, $6::date, $7)`, + allocID, eng, user, state, start, end, util) + } + // In window, full detail. + alloc("EALLOC-1", seaEngA, seaUserA, "CONFIRMED", "2026-09-21", "2026-09-25", 50) + // Starts before and ends on the window's first day: overlaps (inclusive). + alloc("EALLOC-2", seaEngA, seaUserA, "CONFIRMED", "2026-09-10", "2026-09-21", 25.5) + // Ends the day before the window: out. + alloc("EALLOC-3", seaEngA, seaUserA, "CONFIRMED", "2026-09-10", "2026-09-20", 100) + // Starts the day after the window: out. + alloc("EALLOC-4", seaEngA, seaUserA, "CONFIRMED", "2026-09-28", "2026-09-30", 100) + // In window but not confirmed: out. + alloc("EALLOC-5", seaEngA, seaUserA, "TENTATIVE", "2026-09-22", "2026-09-23", 100) + alloc("EALLOC-6", seaEngA, seaUserA, "ALLOCATION_CANCELLED", "2026-09-22", "2026-09-23", 100) + // Another person, engagement with no account, type, mode or utilization. + alloc("EALLOC-7", seaEngB, seaUserB, "CONFIRMED", "2026-09-23", "2026-09-24", nil) + return NewScheduleRepository(pool), pool +} + +func TestScheduleIntegration_EngagementAllocationsConfirmedAndOverlapping(t *testing.T) { + repo, _ := newEngagementAllocationIntegrationRepo(t) + + got, err := repo.SearchEngagementAllocations(context.Background(), domain.SearchScheduleEngagementAllocationsRequest{ + UserIDs: []string{seaUserA, seaUserB}, From: "2026-09-21", To: "2026-09-27"}) + if err != nil { + t.Fatalf("SearchEngagementAllocations: %v", err) + } + ids := []string{} + for _, a := range got { + ids = append(ids, seaStr(a.AllocationID)) + } + want := []string{"EALLOC-2", "EALLOC-1", "EALLOC-7"} // by person, then start date + if len(ids) != len(want) { + t.Fatalf("got allocations %v, want %v", ids, want) + } + for i := range want { + if ids[i] != want[i] { + t.Fatalf("got allocations %v, want %v", ids, want) + } + } + + full := got[1] + if full.UserID != seaUserA || full.EngagementID != seaEngA { + t.Fatalf("wrong ids: %+v", full) + } + if seaStr(full.EngagementDisplayID) != "EITSEA1" || seaStr(full.AccountName) != "Example Corp" || + seaStr(full.EngagementType) != "CONSULTANCY" || seaStr(full.ModeOfDelivery) != "ONSITE" { + t.Fatalf("engagement/account fields wrong: %+v", full) + } + if full.Utilization == nil || *full.Utilization != 50 { + t.Fatalf("utilization = %v, want 50", full.Utilization) + } + if full.StartDate != "2026-09-21" || full.EndDate != "2026-09-25" { + t.Fatalf("dates = %s..%s", full.StartDate, full.EndDate) + } + if got[0].Utilization == nil || *got[0].Utilization != 25.5 { + t.Fatalf("fractional utilization = %v, want 25.5", got[0].Utilization) + } + + bare := got[2] + if bare.AccountName != nil || bare.EngagementDisplayID != nil || bare.EngagementType != nil || + bare.ModeOfDelivery != nil || bare.Utilization != nil { + t.Fatalf("missing data must be absent, not invented: %+v", bare) + } +} + +func TestScheduleIntegration_EngagementAllocationsOnlyTheRequestedPeople(t *testing.T) { + repo, _ := newEngagementAllocationIntegrationRepo(t) + + got, err := repo.SearchEngagementAllocations(context.Background(), domain.SearchScheduleEngagementAllocationsRequest{ + UserIDs: []string{seaUserB}, From: "2026-09-21", To: "2026-09-27"}) + if err != nil { + t.Fatalf("SearchEngagementAllocations: %v", err) + } + if len(got) != 1 || got[0].UserID != seaUserB { + t.Fatalf("got %+v, want only user B's allocation", got) + } +} + +func seaStr(s *string) string { + if s == nil { + return "" + } + return *s +} diff --git a/entity-service/internal/repository/schedule_repo.go b/entity-service/internal/repository/schedule_repo.go index af4fb85fca..339e5c1781 100644 --- a/entity-service/internal/repository/schedule_repo.go +++ b/entity-service/internal/repository/schedule_repo.go @@ -36,6 +36,9 @@ type ScheduleRepository interface { Catalogue(ctx context.Context) (domain.ScheduleCatalogue, error) SearchAssignments(ctx context.Context, req domain.SearchScheduleAssignmentsRequest) ([]domain.ScheduleAssignment, error) SearchAbsences(ctx context.Context, req domain.SearchScheduleAbsencesRequest) ([]domain.ScheduleAbsence, error) + // SearchEngagementAllocations is the confirmed customer-engagement + // allocations of the given people overlapping the window. + SearchEngagementAllocations(ctx context.Context, req domain.SearchScheduleEngagementAllocationsRequest) ([]domain.ScheduleEngagementAllocation, error) // OnDutyAt is who is on duty at the instant, leaving out anyone on leave; // with includeOnLeave they are returned too, marked OnLeave. OnDutyAt(ctx context.Context, at time.Time, includeOnLeave bool) ([]domain.ScheduleAssignment, error) diff --git a/entity-service/internal/server/routes.go b/entity-service/internal/server/routes.go index b2795b80a9..a1e57f2341 100644 --- a/entity-service/internal/server/routes.go +++ b/entity-service/internal/server/routes.go @@ -1897,6 +1897,7 @@ func NewRouter(db *pgxpool.Pool, cfg *config.Config) (http.Handler, func()) { mux.HandleFunc("GET /team-schedule/catalogue", scheduleHandler.GetScheduleCatalogue) mux.HandleFunc("POST /team-schedule/assignments/search", scheduleHandler.SearchScheduleAssignments) mux.HandleFunc("POST /team-schedule/absences/search", scheduleHandler.SearchScheduleAbsences) + mux.HandleFunc("POST /team-schedule/engagement-allocations/search", scheduleHandler.SearchScheduleEngagementAllocations) mux.HandleFunc("GET /team-schedule/on-duty", scheduleHandler.GetScheduleOnDuty) } if teamMemberHandler != nil { diff --git a/entity-service/internal/service/schedule_engagement_allocation_service.go b/entity-service/internal/service/schedule_engagement_allocation_service.go new file mode 100644 index 0000000000..b3a71c31d3 --- /dev/null +++ b/entity-service/internal/service/schedule_engagement_allocation_service.go @@ -0,0 +1,61 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package service + +import ( + "context" + + "github.com/wso2-open-operations/cs-tools/entity-service/internal/apierror" + "github.com/wso2-open-operations/cs-tools/entity-service/internal/domain" +) + +// maxEngagementAllocationUsers bounds how many people one read may ask about. +const maxEngagementAllocationUsers = 500 + +// SearchEngagementAllocations returns the confirmed customer engagement +// allocations of the requested people over the window. Internal callers only, +// like the rest of the team schedule. +func (s *scheduleService) SearchEngagementAllocations(ctx context.Context, req domain.SearchScheduleEngagementAllocationsRequest) (domain.ScheduleEngagementAllocationsResponse, error) { + if err := s.requireInternalCaller(ctx); err != nil { + return domain.ScheduleEngagementAllocationsResponse{}, err + } + if _, _, err := parseWindow(req.From, req.To); err != nil { + return domain.ScheduleEngagementAllocationsResponse{}, err + } + if len(req.UserIDs) == 0 { + return domain.ScheduleEngagementAllocationsResponse{}, &apierror.ValidationError{Msg: "userIds is required"} + } + if len(req.UserIDs) > maxEngagementAllocationUsers { + return domain.ScheduleEngagementAllocationsResponse{}, &apierror.ValidationError{Msg: "too many userIds"} + } + for _, id := range req.UserIDs { + if id == "" { + return domain.ScheduleEngagementAllocationsResponse{}, &apierror.ValidationError{Msg: "userIds must not contain an empty id"} + } + if err := validateUserID(id); err != nil { + return domain.ScheduleEngagementAllocationsResponse{}, err + } + } + rows, err := s.repo.SearchEngagementAllocations(ctx, req) + if err != nil { + return domain.ScheduleEngagementAllocationsResponse{}, err + } + if rows == nil { + rows = []domain.ScheduleEngagementAllocation{} + } + return domain.ScheduleEngagementAllocationsResponse{Allocations: rows, Count: len(rows)}, nil +} diff --git a/entity-service/internal/service/schedule_service.go b/entity-service/internal/service/schedule_service.go index 91375b5cef..daa3078d6b 100644 --- a/entity-service/internal/service/schedule_service.go +++ b/entity-service/internal/service/schedule_service.go @@ -43,6 +43,7 @@ type ScheduleService interface { Catalogue(ctx context.Context) (domain.ScheduleCatalogue, error) SearchAssignments(ctx context.Context, req domain.SearchScheduleAssignmentsRequest) (domain.ScheduleAssignmentsResponse, error) SearchAbsences(ctx context.Context, req domain.SearchScheduleAbsencesRequest) (domain.ScheduleAbsencesResponse, error) + SearchEngagementAllocations(ctx context.Context, req domain.SearchScheduleEngagementAllocationsRequest) (domain.ScheduleEngagementAllocationsResponse, error) OnDuty(ctx context.Context, at *time.Time, includeOnLeave bool) (domain.ScheduleAssignmentsResponse, error) // The lead edit path. Each checks that the caller leads the team the slot diff --git a/entity-service/internal/service/schedule_service_test.go b/entity-service/internal/service/schedule_service_test.go index d36f619ceb..765fe6d0dd 100644 --- a/entity-service/internal/service/schedule_service_test.go +++ b/entity-service/internal/service/schedule_service_test.go @@ -34,11 +34,13 @@ import ( // the request reached the repository unchanged — and returns fixed rows, since // what the repository does with them is SQL's business, not the service's. type fakeScheduleRepo struct { - gotAssignmentReq domain.SearchScheduleAssignmentsRequest - gotAbsenceReq domain.SearchScheduleAbsencesRequest - gotOnDutyAt time.Time - gotIncludeOnLeave bool - called bool + gotAssignmentReq domain.SearchScheduleAssignmentsRequest + gotAbsenceReq domain.SearchScheduleAbsencesRequest + gotOnDutyAt time.Time + gotEngagementAllocationReq domain.SearchScheduleEngagementAllocationsRequest + engagementAllocations []domain.ScheduleEngagementAllocation + gotIncludeOnLeave bool + called bool assignments []domain.ScheduleAssignment absences []domain.ScheduleAbsence @@ -233,6 +235,12 @@ func (f *fakeScheduleRepo) SearchAbsences(_ context.Context, req domain.SearchSc return f.absences, f.err } +func (f *fakeScheduleRepo) SearchEngagementAllocations(_ context.Context, req domain.SearchScheduleEngagementAllocationsRequest) ([]domain.ScheduleEngagementAllocation, error) { + f.called = true + f.gotEngagementAllocationReq = req + return f.engagementAllocations, f.err +} + func (f *fakeScheduleRepo) OnDutyAt(_ context.Context, at time.Time, includeOnLeave bool) ([]domain.ScheduleAssignment, error) { f.called = true f.gotOnDutyAt = at @@ -425,6 +433,11 @@ func TestScheduleReadsRequireInternalCaller(t *testing.T) { domain.SearchScheduleAbsencesRequest{From: window.From, To: window.To}) return err }, + "engagement-allocations": func(s ScheduleService) error { + _, err := s.SearchEngagementAllocations(context.Background(), domain.SearchScheduleEngagementAllocationsRequest{ + UserIDs: []string{"00000000-0000-0000-0000-000000000001"}, From: window.From, To: window.To}) + return err + }, "on-duty": func(s ScheduleService) error { _, err := s.OnDuty(context.Background(), nil, false) return err @@ -1168,3 +1181,71 @@ func TestTheHomeLeadRemovesAMoveFiledUnderTheOtherTeam(t *testing.T) { t.Fatal("the repository was written to") } } + +func TestSearchEngagementAllocationsValidatesTheRequest(t *testing.T) { + t.Parallel() + + const id = "00000000-0000-0000-0000-000000000001" + cases := map[string]domain.SearchScheduleEngagementAllocationsRequest{ + "no users": {From: "2026-09-21", To: "2026-09-27"}, + "empty user id": {UserIDs: []string{""}, From: "2026-09-21", To: "2026-09-27"}, + "user not uuid": {UserIDs: []string{"jane.doe@example.com"}, From: "2026-09-21", To: "2026-09-27"}, + "no window": {UserIDs: []string{id}}, + "inverted": {UserIDs: []string{id}, From: "2026-09-27", To: "2026-09-21"}, + "bad date": {UserIDs: []string{id}, From: "21/09/2026", To: "2026-09-27"}, + "window too big": {UserIDs: []string{id}, From: "2026-01-01", To: "2026-12-31"}, + "too many users": {UserIDs: make([]string, maxEngagementAllocationUsers+1), From: "2026-09-21", To: "2026-09-27"}, + } + for name, req := range cases { + t.Run(name, func(t *testing.T) { + t.Parallel() + repo := &fakeScheduleRepo{} + _, err := NewScheduleService(repo, alwaysUnrestrictedAccess{}).SearchEngagementAllocations(context.Background(), req) + var validation *apierror.ValidationError + if !errors.As(err, &validation) { + t.Fatalf("want a ValidationError, got %v", err) + } + if repo.called { + t.Fatal("repository was called for a request that failed validation") + } + }) + } +} + +func TestSearchEngagementAllocationsPassesTheRequestThroughAndCounts(t *testing.T) { + t.Parallel() + + name := "Example Corp" + repo := &fakeScheduleRepo{engagementAllocations: []domain.ScheduleEngagementAllocation{ + {ID: "a", UserID: "u", EngagementID: "e", AccountName: &name, StartDate: "2026-09-21", EndDate: "2026-09-25"}, + {ID: "b", UserID: "u", EngagementID: "e", StartDate: "2026-09-26", EndDate: "2026-09-27"}, + }} + req := domain.SearchScheduleEngagementAllocationsRequest{ + UserIDs: []string{"00000000-0000-0000-0000-000000000001"}, From: "2026-09-21", To: "2026-09-27"} + + resp, err := NewScheduleService(repo, alwaysUnrestrictedAccess{}).SearchEngagementAllocations(context.Background(), req) + if err != nil { + t.Fatalf("SearchEngagementAllocations: %v", err) + } + if !reflect.DeepEqual(repo.gotEngagementAllocationReq, req) { + t.Fatalf("repository got %+v, want %+v", repo.gotEngagementAllocationReq, req) + } + if resp.Count != 2 || len(resp.Allocations) != 2 { + t.Fatalf("got count %d with %d rows, want 2", resp.Count, len(resp.Allocations)) + } +} + +func TestSearchEngagementAllocationsEmptyIsAnEmptyList(t *testing.T) { + t.Parallel() + + repo := &fakeScheduleRepo{} + resp, err := NewScheduleService(repo, alwaysUnrestrictedAccess{}).SearchEngagementAllocations(context.Background(), + domain.SearchScheduleEngagementAllocationsRequest{ + UserIDs: []string{"00000000-0000-0000-0000-000000000001"}, From: "2026-09-21", To: "2026-09-27"}) + if err != nil { + t.Fatalf("SearchEngagementAllocations: %v", err) + } + if resp.Count != 0 || resp.Allocations == nil { + t.Fatalf("got count %d, allocations nil=%v; want 0 and a non-nil empty list", resp.Count, resp.Allocations == nil) + } +} diff --git a/entity-service/openapi.yaml b/entity-service/openapi.yaml index 69e383cb63..2920b291c3 100644 --- a/entity-service/openapi.yaml +++ b/entity-service/openapi.yaml @@ -7950,6 +7950,61 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorResponse' + /team-schedule/engagement-allocations/search: + post: + summary: Customer engagement allocations of the given people over a date window. + description: > + Read-only. Returns only CONFIRMED allocations whose `startDate`-`endDate` + span overlaps `from`-`to` (both inclusive), for the people in `userIds`, + so the team schedule can draw them as bars on each engineer's row. Fields + the engagement or its account does not have are omitted. At most 500 + `userIds` and a window of at most 70 days, like the other schedule reads. + operationId: searchScheduleEngagementAllocations + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SearchScheduleEngagementAllocationsRequest' + responses: + "200": + description: Confirmed allocations overlapping the window asked for. + content: + application/json: + schema: + $ref: '#/components/schemas/ScheduleEngagementAllocationsResponse' + "400": + description: Bad request. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + "401": + description: No user token and not an authorized internal client. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + "403": + description: The caller is not internal staff. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + "404": + description: > + Not found — the schedule routes are registered only when a database + pool is configured. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + "500": + description: Unexpected error. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /team-schedule/assignments: post: summary: Put somebody on a window for a day. @@ -26155,6 +26210,55 @@ components: items: { type: string } userId: { type: string, format: uuid } userEmail: { type: string } + SearchScheduleEngagementAllocationsRequest: + type: object + required: [userIds, from, to] + properties: + userIds: + type: array + minItems: 1 + maxItems: 500 + items: { type: string, format: uuid } + from: { type: string, format: date } + to: { type: string, format: date } + ScheduleEngagementAllocation: + type: object + required: [id, userId, engagementId, startDate, endDate] + properties: + id: { type: string, format: uuid } + allocationId: + type: string + description: The allocation's human-readable reference. + userId: { type: string, format: uuid } + engagementId: + type: string + format: uuid + description: The engagement's id. + engagementDisplayId: + type: string + description: The engagement's human-readable id; absent when it has none. + accountName: + type: string + description: The customer account's name; absent when the engagement has no account. + engagementType: + type: string + description: The engagement type enum value, e.g. `CONSULTANCY`. + modeOfDelivery: + type: string + enum: [OFFSITE, ONSITE] + utilization: + type: number + description: Percentage of the person's time the allocation takes. + startDate: { type: string, format: date } + endDate: { type: string, format: date } + ScheduleEngagementAllocationsResponse: + type: object + required: [allocations, count] + properties: + allocations: + type: array + items: { $ref: '#/components/schemas/ScheduleEngagementAllocation' } + count: { type: integer } TeamMembersResponse: type: object required: [members, count] From 4b2dc014c93700f8c10c4c806f97887e8ca19883 Mon Sep 17 00:00:00 2001 From: Sajith Ekanayaka Date: Sun, 25 Oct 2026 23:14:44 +0530 Subject: [PATCH 2/6] csm-portal backend: team schedule engagement allocations read Add POST /team-schedule/engagement-allocations/search, a view-permission passthrough to the entity service that returns the confirmed customer engagement allocations of the given engineers over a date window. The body and response are forwarded unchanged (enums stay raw); upstream errors map through the same generic handler as the neighbouring schedule searches. Documented in the backend openapi.yaml and covered by handler tests. --- apps/csm-portal/backend/cmd/server/main.go | 1 + .../backend/internal/entity/customer.go | 8 ++ .../backend/internal/handler/schedule.go | 22 +++++ .../backend/internal/handler/schedule_test.go | 99 +++++++++++++++++++ apps/csm-portal/backend/openapi.yaml | 84 ++++++++++++++++ 5 files changed, 214 insertions(+) diff --git a/apps/csm-portal/backend/cmd/server/main.go b/apps/csm-portal/backend/cmd/server/main.go index b5b8a11b61..9441a60796 100644 --- a/apps/csm-portal/backend/cmd/server/main.go +++ b/apps/csm-portal/backend/cmd/server/main.go @@ -615,6 +615,7 @@ func main() { route("GET /team-schedule/catalogue", handler.PermView, scheduleHandler.GetScheduleCatalogue) route("POST /team-schedule/assignments/search", handler.PermView, scheduleHandler.SearchScheduleAssignments) route("POST /team-schedule/absences/search", handler.PermView, scheduleHandler.SearchScheduleAbsences) + route("POST /team-schedule/engagement-allocations/search", handler.PermView, scheduleHandler.SearchScheduleEngagementAllocations) route("GET /team-schedule/on-duty", handler.PermView, scheduleHandler.GetScheduleOnDuty) // Lead edit. PermWrite keeps the control away from a caller who could not diff --git a/apps/csm-portal/backend/internal/entity/customer.go b/apps/csm-portal/backend/internal/entity/customer.go index 293e5527b0..4f71a29bc1 100644 --- a/apps/csm-portal/backend/internal/entity/customer.go +++ b/apps/csm-portal/backend/internal/entity/customer.go @@ -781,6 +781,14 @@ func (c *CustomerEntityClient) SearchScheduleAbsences(ctx context.Context, body return c.do(ctx, http.MethodPost, "/team-schedule/absences/search", body) } +// SearchScheduleEngagementAllocations calls +// POST /team-schedule/engagement-allocations/search on the entity service: the +// confirmed customer engagement allocations of the given users over a date +// window. Response is returned as raw JSON. +func (c *CustomerEntityClient) SearchScheduleEngagementAllocations(ctx context.Context, body []byte) ([]byte, error) { + return c.do(ctx, http.MethodPost, "/team-schedule/engagement-allocations/search", body) +} + // GetScheduleOnDuty calls GET /team-schedule/on-duty on the entity service, passing // the optional `at` instant through unchanged. Response is raw JSON. func (c *CustomerEntityClient) GetScheduleOnDuty(ctx context.Context, at string) ([]byte, error) { diff --git a/apps/csm-portal/backend/internal/handler/schedule.go b/apps/csm-portal/backend/internal/handler/schedule.go index e53636fbe8..64a408c9c9 100644 --- a/apps/csm-portal/backend/internal/handler/schedule.go +++ b/apps/csm-portal/backend/internal/handler/schedule.go @@ -35,6 +35,7 @@ type entityScheduleClient interface { GetScheduleCatalogue(ctx context.Context) ([]byte, error) SearchScheduleAssignments(ctx context.Context, body []byte) ([]byte, error) SearchScheduleAbsences(ctx context.Context, body []byte) ([]byte, error) + SearchScheduleEngagementAllocations(ctx context.Context, body []byte) ([]byte, error) GetScheduleOnDuty(ctx context.Context, at string) ([]byte, error) // Lead edit. The portal proxies these unchanged -- entity-service is where @@ -167,6 +168,27 @@ func (h *ScheduleHandler) SearchScheduleAbsences(w http.ResponseWriter, r *http. writeJSON(w, http.StatusOK, result) } +// SearchScheduleEngagementAllocations handles +// POST /team-schedule/engagement-allocations/search: the confirmed customer +// engagement allocations of the given engineers over a date window, shown +// read-only on the schedule. The body (userIds, from, to) is validated by +// entity-service and forwarded unchanged; the response is returned as sent. +func (h *ScheduleHandler) SearchScheduleEngagementAllocations(w http.ResponseWriter, r *http.Request) { + body, userID, ok := readScheduleBody(w, r) + if !ok { + return + } + + result, err := h.entity.SearchScheduleEngagementAllocations(r.Context(), body) + if err != nil { + slog.ErrorContext(r.Context(), "entity SearchScheduleEngagementAllocations failed", "userID", userID, "err", err) + mapUpstreamErrorGeneric(w, err, "Failed to search engagement allocations.") + return + } + + writeJSON(w, http.StatusOK, result) +} + // GetScheduleOnDuty handles GET /team-schedule/on-duty[?at=RFC3339] -- who is // responsible at this instant. The `at` parameter is passed through unchanged; // the entity service validates it. diff --git a/apps/csm-portal/backend/internal/handler/schedule_test.go b/apps/csm-portal/backend/internal/handler/schedule_test.go index be9d834a1b..9a436c6556 100644 --- a/apps/csm-portal/backend/internal/handler/schedule_test.go +++ b/apps/csm-portal/backend/internal/handler/schedule_test.go @@ -32,6 +32,7 @@ import ( type mockEntityScheduleClient struct { catalogueFn func(ctx context.Context) ([]byte, error) assignmentsFn func(ctx context.Context, body []byte) ([]byte, error) + allocationsFn func(ctx context.Context, body []byte) ([]byte, error) absencesFn func(ctx context.Context, body []byte) ([]byte, error) onDutyFn func(ctx context.Context, at string) ([]byte, error) @@ -185,6 +186,13 @@ func (m *mockEntityScheduleClient) SearchScheduleAbsences(ctx context.Context, b return []byte(`{"absences":[],"count":0}`), nil } +func (m *mockEntityScheduleClient) SearchScheduleEngagementAllocations(ctx context.Context, body []byte) ([]byte, error) { + if m.allocationsFn != nil { + return m.allocationsFn(ctx, body) + } + return []byte(`{"allocations":[],"count":0}`), nil +} + func (m *mockEntityScheduleClient) GetScheduleOnDuty(ctx context.Context, at string) ([]byte, error) { if m.onDutyFn != nil { return m.onDutyFn(ctx, at) @@ -319,6 +327,97 @@ func TestGetScheduleOnDuty(t *testing.T) { }) } +func TestSearchScheduleEngagementAllocations(t *testing.T) { + const path = "/team-schedule/engagement-allocations/search" + const payload = `{"userIds":["00000000-0000-0000-0000-000000000000"],"from":"2026-10-19","to":"2026-10-25"}` + + t.Run("requires an authenticated user", func(t *testing.T) { + h := NewScheduleHandler(&mockEntityScheduleClient{}) + w := httptest.NewRecorder() + h.SearchScheduleEngagementAllocations(w, httptest.NewRequest(http.MethodPost, path, strings.NewReader(payload))) + assertStatus(t, w, http.StatusUnauthorized) + assertErrorMessage(t, w, ErrMsgUnauthorized) + }) + + t.Run("rejects a body that is not JSON", func(t *testing.T) { + h := NewScheduleHandler(&mockEntityScheduleClient{}) + w := httptest.NewRecorder() + h.SearchScheduleEngagementAllocations(w, withUser(httptest.NewRequest(http.MethodPost, path, strings.NewReader(`not-json`)))) + assertStatus(t, w, http.StatusBadRequest) + assertErrorMessage(t, w, ErrMsgBadRequest) + }) + + t.Run("rejects a body over the size limit", func(t *testing.T) { + h := NewScheduleHandler(&mockEntityScheduleClient{}) + w := httptest.NewRecorder() + h.SearchScheduleEngagementAllocations(w, withUser(httptest.NewRequest(http.MethodPost, path, + strings.NewReader(strings.Repeat("x", maxRequestBodyBytes+1))))) + assertStatus(t, w, http.StatusRequestEntityTooLarge) + }) + + t.Run("forwards the search unchanged and returns the response as sent", func(t *testing.T) { + const upstream = `{"allocations":[{"id":"00000000-0000-0000-0000-000000000001","userId":"00000000-0000-0000-0000-000000000000","engagementId":"00000000-0000-0000-0000-000000000002","startDate":"2026-10-19","endDate":"2026-10-21","accountName":"Example Corp","engagementType":"CONSULTANCY","modeOfDelivery":"ONSITE","utilization":50}],"count":1}` + var captured []byte + h := NewScheduleHandler(&mockEntityScheduleClient{ + allocationsFn: func(_ context.Context, body []byte) ([]byte, error) { + captured = body + return []byte(upstream), nil + }, + }) + w := httptest.NewRecorder() + h.SearchScheduleEngagementAllocations(w, withUser(httptest.NewRequest(http.MethodPost, path, strings.NewReader(payload)))) + + assertStatus(t, w, http.StatusOK) + assertContentType(t, w, "application/json") + if string(captured) != payload { + t.Fatalf("body was altered in transit:\n got %s\nwant %s", captured, payload) + } + if w.Body.String() != upstream { + t.Fatalf("response was altered (enums must stay raw):\n got %s\nwant %s", w.Body.String(), upstream) + } + }) + + t.Run("an empty result is an empty list, not null", func(t *testing.T) { + h := NewScheduleHandler(&mockEntityScheduleClient{}) + w := httptest.NewRecorder() + h.SearchScheduleEngagementAllocations(w, withUser(httptest.NewRequest(http.MethodPost, path, strings.NewReader(payload)))) + assertStatus(t, w, http.StatusOK) + if !strings.Contains(w.Body.String(), `"allocations":[]`) || strings.Contains(w.Body.String(), "null") { + t.Fatalf("want an empty list, got %s", w.Body.String()) + } + }) + + t.Run("maps upstream failures", func(t *testing.T) { + for _, c := range []struct { + name string + err error + wantStatus int + }{ + {"400 from the window or id limits", &apierror.Error{StatusCode: http.StatusBadRequest, Body: `{"message":"window too large"}`}, http.StatusBadRequest}, + {"403 stays forbidden", &apierror.Error{StatusCode: http.StatusForbidden, Body: `{"message":"no"}`}, http.StatusForbidden}, + {"500 stays generic", &apierror.Error{StatusCode: http.StatusInternalServerError, Body: `{"message":"pq: relation does not exist"}`}, http.StatusInternalServerError}, + {"transport error is not a 200", errors.New("upstream is down"), 0}, + } { + t.Run(c.name, func(t *testing.T) { + h := NewScheduleHandler(&mockEntityScheduleClient{ + allocationsFn: func(context.Context, []byte) ([]byte, error) { return nil, c.err }, + }) + w := httptest.NewRecorder() + h.SearchScheduleEngagementAllocations(w, withUser(httptest.NewRequest(http.MethodPost, path, strings.NewReader(payload)))) + if w.Code == http.StatusOK { + t.Fatal("an upstream error must not come back as 200") + } + if c.wantStatus != 0 { + assertStatus(t, w, c.wantStatus) + } + if strings.Contains(w.Body.String(), "pq:") { + t.Fatalf("internal detail leaked: %s", w.Body.String()) + } + }) + } + }) +} + func TestSearchScheduleAbsences(t *testing.T) { t.Run("forwards the search and returns the response", func(t *testing.T) { h := NewScheduleHandler(&mockEntityScheduleClient{ diff --git a/apps/csm-portal/backend/openapi.yaml b/apps/csm-portal/backend/openapi.yaml index 6325d67e99..a9069a3435 100644 --- a/apps/csm-portal/backend/openapi.yaml +++ b/apps/csm-portal/backend/openapi.yaml @@ -14248,6 +14248,90 @@ paths: schema: $ref: '#/components/schemas/ErrorPayload' + /team-schedule/engagement-allocations/search: + post: + summary: Search confirmed customer engagement allocations. + operationId: searchTeamScheduleEngagementAllocations + description: > + The confirmed customer engagement allocations of the given engineers + over a date window, shown read-only on the team schedule. Body is + validated by the entity service and forwarded unchanged; the response + is returned as sent, with enum values raw (the caller formats them). + `userIds` are user ids as found on a schedule assignment's + `engineer.userId`. Optional fields are omitted when empty. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [userIds, from, to] + properties: + userIds: + type: array + minItems: 1 + maxItems: 500 + items: { type: string, format: uuid } + from: { type: string, format: date, description: First day of the window, inclusive. } + to: { type: string, format: date, description: Last day of the window, inclusive; at most 70 days after `from`. } + responses: + "200": + description: Ok. + content: + application/json: + schema: + type: object + required: [allocations, count] + properties: + allocations: + type: array + items: + type: object + required: [id, userId, engagementId, startDate, endDate] + properties: + id: { type: string, format: uuid } + userId: { type: string, format: uuid } + engagementId: { type: string, format: uuid } + startDate: { type: string, format: date } + endDate: { type: string, format: date } + allocationId: { type: string } + engagementDisplayId: { type: string } + accountName: { type: string } + engagementType: { type: string, description: Raw enum value, for example CONSULTANCY. } + modeOfDelivery: { type: string, description: Raw enum value, ONSITE or OFFSITE. } + utilization: { type: number } + count: { type: integer } + "400": + description: Bad request. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorPayload' + "401": + description: Unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorPayload' + "403": + description: Forbidden. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorPayload' + "404": + description: Not found. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorPayload' + "500": + description: Internal server error. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorPayload' + /team-schedule/on-duty: get: summary: Who is on duty at an instant. From 38b2e9d33e63bdbb11a3b9b23b313d0aee6a5f39 Mon Sep 17 00:00:00 2001 From: Sajith Ekanayaka Date: Sun, 25 Oct 2026 23:29:55 +0530 Subject: [PATCH 3/6] csm-portal: show customer engagement allocations on the team schedule week The week table now draws confirmed customer engagement allocations as read-only bars, one per day for every day an allocation covers, on a row per engineer under the leave row. A bar is labelled with the account and the engagement id, falling back to the id and then to "Engagement", and its tooltip carries the type, mode of delivery, allocation id, utilization and dates, with enum values turned into words. Several allocations on one day stack in the same cell. The allocations are read with a new hook that batches user ids at 500 and splits windows longer than 70 days. The ids are the people the schedule is already drawing: the engineers on the rota read, those on leave, and the members of the teams in view. A failed read leaves the schedule untouched and adds a quiet note; bars from pieces that did load are kept. Bars use a dedicated colour pair defined for both themes and a legend entry. They link nowhere. The month roster is not covered by this change. --- .../api/useEngagementAllocations.test.tsx | 120 ++++++++++++ .../api/useEngagementAllocations.ts | 87 +++++++++ .../components/RosterLegend.test.tsx | 9 + .../components/RosterLegend.tsx | 12 +- .../components/WeekTable.test.tsx | 116 +++++++++++- .../components/WeekTable.tsx | 88 +++++++++ .../pages/CsmTeamSchedulePage.tsx | 34 ++++ .../csm-team-schedule/teamSchedule.css | 69 +++++++ .../features/csm-team-schedule/types/index.ts | 37 ++++ .../utils/engagementAllocations.test.ts | 176 ++++++++++++++++++ .../utils/engagementAllocations.ts | 164 ++++++++++++++++ 11 files changed, 908 insertions(+), 4 deletions(-) create mode 100644 apps/csm-portal/webapp/src/features/csm-team-schedule/api/useEngagementAllocations.test.tsx create mode 100644 apps/csm-portal/webapp/src/features/csm-team-schedule/api/useEngagementAllocations.ts create mode 100644 apps/csm-portal/webapp/src/features/csm-team-schedule/utils/engagementAllocations.test.ts create mode 100644 apps/csm-portal/webapp/src/features/csm-team-schedule/utils/engagementAllocations.ts diff --git a/apps/csm-portal/webapp/src/features/csm-team-schedule/api/useEngagementAllocations.test.tsx b/apps/csm-portal/webapp/src/features/csm-team-schedule/api/useEngagementAllocations.test.tsx new file mode 100644 index 0000000000..400574c4e4 --- /dev/null +++ b/apps/csm-portal/webapp/src/features/csm-team-schedule/api/useEngagementAllocations.test.tsx @@ -0,0 +1,120 @@ +/** + * Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { ReactNode } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const api = { post: vi.fn() }; +vi.mock("@api/backend/client", () => ({ useBackendApi: () => api })); +vi.mock("@config/apiConfig", () => ({ apiConfig: { backendUrl: "https://example.test" } })); + +import { useEngagementAllocations } from "./useEngagementAllocations"; + +const PATH = "/team-schedule/engagement-allocations/search"; + +function wrapper() { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return ({ children }: { children: ReactNode }) => ( + {children} + ); +} + +const ids = (n: number) => Array.from({ length: n }, (_, i) => `00000000-0000-0000-0000-${String(i).padStart(12, "0")}`); +const row = (id: string, userId: string) => ({ + id, + userId, + engagementId: "00000000-0000-0000-0000-0000000000e1", + startDate: "2026-09-21", + endDate: "2026-09-22", +}); + +beforeEach(() => { + api.post.mockReset(); +}); + +describe("useEngagementAllocations", () => { + it("posts the ids and the window to the search endpoint", async () => { + api.post.mockResolvedValue({ allocations: [row("a1", ids(1)[0])], count: 1 }); + const { result } = renderHook(() => useEngagementAllocations(ids(2), "2026-09-21", "2026-09-27"), { + wrapper: wrapper(), + }); + await waitFor(() => expect(result.current.allocations).toHaveLength(1)); + expect(api.post).toHaveBeenCalledTimes(1); + expect(api.post).toHaveBeenCalledWith(PATH, { userIds: ids(2), from: "2026-09-21", to: "2026-09-27" }); + expect(result.current.isError).toBe(false); + }); + + it("batches ids at 500 per request", async () => { + api.post.mockResolvedValue({ allocations: [], count: 0 }); + renderHook(() => useEngagementAllocations(ids(1001), "2026-09-21", "2026-09-27"), { wrapper: wrapper() }); + await waitFor(() => expect(api.post).toHaveBeenCalledTimes(3)); + const sizes = api.post.mock.calls.map((c) => c[1].userIds.length).sort((a, b) => b - a); + expect(sizes).toEqual([500, 500, 1]); + }); + + it("splits a window over 70 days into requests of at most 70", async () => { + api.post.mockResolvedValue({ allocations: [], count: 0 }); + renderHook(() => useEngagementAllocations(ids(1), "2026-01-01", "2026-03-12"), { wrapper: wrapper() }); + await waitFor(() => expect(api.post).toHaveBeenCalledTimes(2)); + const windows = api.post.mock.calls.map((c) => [c[1].from, c[1].to]).sort(); + expect(windows).toEqual([ + ["2026-01-01", "2026-03-11"], + ["2026-03-12", "2026-03-12"], + ]); + }); + + it("merges the pieces and keeps one copy of an allocation both returned", async () => { + api.post.mockImplementation(async (_path: string, body: { from: string }) => ({ + allocations: [row("shared", ids(1)[0]), row(`only-${body.from}`, ids(1)[0])], + count: 2, + })); + const { result } = renderHook(() => useEngagementAllocations(ids(1), "2026-01-01", "2026-03-12"), { + wrapper: wrapper(), + }); + await waitFor(() => expect(result.current.allocations).toHaveLength(3)); + }); + + it("keeps the pieces that loaded when another fails, and says so", async () => { + api.post.mockImplementation(async (_p: string, body: { from: string }) => { + if (body.from === "2026-03-12") throw new Error("boom"); + return { allocations: [row("ok", ids(1)[0])], count: 1 }; + }); + const { result } = renderHook(() => useEngagementAllocations(ids(1), "2026-01-01", "2026-03-12"), { + wrapper: wrapper(), + }); + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(result.current.allocations.map((a) => a.id)).toEqual(["ok"]); + }); + + it("degrades to no bars and no throw when the read fails outright", async () => { + api.post.mockRejectedValue(new Error("boom")); + const { result } = renderHook(() => useEngagementAllocations(ids(1), "2026-09-21", "2026-09-27"), { + wrapper: wrapper(), + }); + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(result.current.allocations).toEqual([]); + }); + + it("asks for nothing with no ids, or when disabled", () => { + renderHook(() => useEngagementAllocations([], "2026-09-21", "2026-09-27"), { wrapper: wrapper() }); + renderHook(() => useEngagementAllocations(ids(1), "2026-09-21", "2026-09-27", false), { wrapper: wrapper() }); + expect(api.post).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/csm-portal/webapp/src/features/csm-team-schedule/api/useEngagementAllocations.ts b/apps/csm-portal/webapp/src/features/csm-team-schedule/api/useEngagementAllocations.ts new file mode 100644 index 0000000000..8dc0ee5b34 --- /dev/null +++ b/apps/csm-portal/webapp/src/features/csm-team-schedule/api/useEngagementAllocations.ts @@ -0,0 +1,87 @@ +/** + * Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { keepPreviousData, useQueries } from "@tanstack/react-query"; +import { useBackendApi } from "@api/backend/client"; +import type { + EngagementAllocation, + EngagementAllocationsResponse, + SearchEngagementAllocationsPayload, +} from "../types"; +import { chunk, splitWindow } from "../utils/engagementAllocations"; + +/** A read of who is at a customer; it changes when somebody confirms one. */ +const ALLOCATIONS_STALE_MS = 60_000; + +/** What the schedule reads of the allocations: the bars, and whether some + * piece of the read failed. A failure never takes the bars that did load. */ +export interface EngagementAllocationsRead { + allocations: EngagementAllocation[]; + isLoading: boolean; + isError: boolean; +} + +/** + * Confirmed customer engagement allocations for a set of engineers over a + * date window, read-only. + * + * The read takes at most 500 user ids and 70 days, so the ids are cut into + * batches and a longer window into pieces, one request per batch per piece. + * Each is cached under its own key (ids and window), so paging the window only + * fetches the piece that came into view. Pieces that fail leave out their own + * bars and nothing else: this read is decoration on the schedule, and the + * schedule must not blank or break because it failed. + * + * `userIds` should be the distinct ids of the people on screen (see + * distinctUserIds). With none there is nothing to ask and no request is made. + */ +export function useEngagementAllocations( + userIds: readonly string[], + from: string, + to: string, + enabled = true, +): EngagementAllocationsRead { + const api = useBackendApi(); + const batches = chunk(userIds); + const windows = splitWindow(from, to); + const payloads: SearchEngagementAllocationsPayload[] = batches.flatMap((ids) => + windows.map((w) => ({ userIds: ids, from: w.from, to: w.to })), + ); + return useQueries({ + queries: payloads.map((p) => ({ + queryKey: ["team-schedule", "engagement-allocations", p] as const, + queryFn: () => + api.post( + "/team-schedule/engagement-allocations/search", + p, + ), + enabled, + placeholderData: keepPreviousData, + staleTime: ALLOCATIONS_STALE_MS, + })), + combine: (results): EngagementAllocationsRead => { + const byId = new Map(); + for (const r of results) for (const a of r.data?.allocations ?? []) byId.set(a.id, a); + return { + allocations: [...byId.values()], + isLoading: results.some((r) => r.isLoading), + isError: results.some((r) => r.isError), + }; + }, + }); +} diff --git a/apps/csm-portal/webapp/src/features/csm-team-schedule/components/RosterLegend.test.tsx b/apps/csm-portal/webapp/src/features/csm-team-schedule/components/RosterLegend.test.tsx index 346ab403a9..3a5e1fbe17 100644 --- a/apps/csm-portal/webapp/src/features/csm-team-schedule/components/RosterLegend.test.tsx +++ b/apps/csm-portal/webapp/src/features/csm-team-schedule/components/RosterLegend.test.tsx @@ -19,6 +19,7 @@ import { act, fireEvent, render } from "@testing-library/react"; import { beforeEach, describe, expect, it } from "vitest"; import RosterLegend, { type LegendItem } from "./RosterLegend"; +import { ENGAGEMENT_LEGEND_ITEM } from "../utils/engagementAllocations"; const ITEMS: LegendItem[] = [ { key: "s|LK", group: "Hours", code: "LK", token: "LK", label: "Regular hours" }, @@ -89,4 +90,12 @@ describe("RosterLegend", () => { const third = render(); expect(legend(third.container).open).toBe(true); }); + + it("lists a customer engagement as its own group, with the bar's swatch rather than a code chip", () => { + const { container } = render(); + const heads = [...container.querySelectorAll(".rl-h")].map((h) => h.textContent); + expect(heads).toEqual(["Hours", "Leave", "Engagements"]); + expect(container.querySelectorAll(".engsw")).toHaveLength(1); + expect(container.querySelectorAll(".rl-g li .chip")).toHaveLength(2); + }); }); diff --git a/apps/csm-portal/webapp/src/features/csm-team-schedule/components/RosterLegend.tsx b/apps/csm-portal/webapp/src/features/csm-team-schedule/components/RosterLegend.tsx index dde314fc81..1f471f6820 100644 --- a/apps/csm-portal/webapp/src/features/csm-team-schedule/components/RosterLegend.tsx +++ b/apps/csm-portal/webapp/src/features/csm-team-schedule/components/RosterLegend.tsx @@ -18,7 +18,7 @@ import { useState, type JSX } from "react"; -export type LegendGroup = "Rota" | "Hours" | "Leave" | "Allocations"; +export type LegendGroup = "Rota" | "Hours" | "Leave" | "Allocations" | "Engagements"; export interface LegendItem { /** Unique within the legend: two windows can share a short code. */ @@ -30,9 +30,11 @@ export interface LegendItem { label: string; /** Drawn dashed, as an on-call window is on the grid. */ onCall?: boolean; + /** Draw the engagement bar's swatch instead of a code chip. */ + swatch?: "engagement"; } -const ORDER: LegendGroup[] = ["Rota", "Hours", "Leave", "Allocations"]; +const ORDER: LegendGroup[] = ["Rota", "Hours", "Leave", "Allocations", "Engagements"]; /** Where the open/closed choice is kept, per browser. */ const STORE_KEY = "csm-ts.roster-legend.open"; @@ -88,7 +90,11 @@ export default function RosterLegend({ items }: { items: LegendItem[] }): JSX.El
    {g.items.map((it) => (
  • - {it.code} + {it.swatch === "engagement" ? ( +
  • ))} diff --git a/apps/csm-portal/webapp/src/features/csm-team-schedule/components/WeekTable.test.tsx b/apps/csm-portal/webapp/src/features/csm-team-schedule/components/WeekTable.test.tsx index cf69db4db3..1616c9265e 100644 --- a/apps/csm-portal/webapp/src/features/csm-team-schedule/components/WeekTable.test.tsx +++ b/apps/csm-portal/webapp/src/features/csm-team-schedule/components/WeekTable.test.tsx @@ -16,10 +16,11 @@ * under the License. */ -import { render, screen } from "@testing-library/react"; +import { render, screen, within } from "@testing-library/react"; import { describe, expect, it } from "vitest"; import "@testing-library/jest-dom/vitest"; import WeekTable from "./WeekTable"; +import type { EngagementAllocation } from "../types"; import { ANNUAL_LEAVE, EVENING, @@ -163,3 +164,116 @@ describe("WeekTable: no tier on a zone's escalation window", () => { expect(container.textContent).not.toContain("tier not set"); }); }); + +describe("WeekTable: customer engagements", () => { + const JANE = "00000000-0000-0000-0000-000000000001"; + const PEOPLE = new Map([[JANE, { name: "Jane Doe", teamKey: "alpha" }]]); + const onRota = [assignment({ name: "Jane Doe", userId: JANE, rotaDate: "2026-09-21", shiftCode: REGULAR.code })]; + const alloc = (over: Partial = {}): EngagementAllocation => ({ + id: "a1", + userId: JANE, + engagementId: "00000000-0000-0000-0000-0000000000e1", + startDate: "2026-09-22", + endDate: "2026-09-24", + accountName: "Example Corp", + engagementDisplayId: "ENG-1001", + engagementType: "CONSULTANCY", + modeOfDelivery: "ONSITE", + allocationId: "ALLOC-7", + utilization: 50, + ...over, + }); + const engRows = (c: HTMLElement) => [...c.querySelectorAll("tr.engrow")] as HTMLElement[]; + const bars = (el: ParentNode) => [...el.querySelectorAll(".engbar")] as HTMLElement[]; + + it("draws one bar a day for each day the allocation covers, on the engineer's own row", () => { + const { container } = renderWeek({ + assignments: onRota, + engagementAllocations: [alloc()], + engagementPeople: PEOPLE, + }); + const rows = engRows(container); + expect(rows).toHaveLength(1); + expect(within(rows[0]).getByText("Jane Doe")).toBeInTheDocument(); + // Tuesday to Thursday: three of the seven day cells hold a bar. + const cells = [...rows[0].querySelectorAll("td")]; + expect(cells.map((td) => bars(td).length)).toEqual([0, 1, 1, 1, 0, 0, 0]); + }); + + it("labels the bar with the account and engagement id and explains it in the tooltip", () => { + const { container } = renderWeek({ + assignments: onRota, + engagementAllocations: [alloc()], + engagementPeople: PEOPLE, + }); + const bar = bars(container)[0]; + expect(bar).toHaveTextContent("Example Corp - ENG-1001"); + expect(bar).toHaveAttribute("title", "Consultancy ● Onsite ● ALLOC-7 ● 50% ● 2026-09-22 - 2026-09-24"); + }); + + it("falls back to Engagement when the server sent no names", () => { + const { container } = renderWeek({ + assignments: onRota, + engagementAllocations: [ + alloc({ accountName: undefined, engagementDisplayId: undefined, engagementType: undefined, modeOfDelivery: undefined, allocationId: undefined, utilization: undefined }), + ], + engagementPeople: PEOPLE, + }); + const bar = bars(container)[0]; + expect(bar).toHaveTextContent("Engagement"); + expect(bar).toHaveAttribute("title", "2026-09-22 - 2026-09-24"); + }); + + it("stacks every allocation on the same day without adding rows", () => { + const { container } = renderWeek({ + assignments: onRota, + engagementAllocations: [ + alloc({ id: "a1", accountName: "Alpha Ltd" }), + alloc({ id: "a2", accountName: "Beta Ltd", startDate: "2026-09-22", endDate: "2026-09-22" }), + ], + engagementPeople: PEOPLE, + }); + expect(engRows(container)).toHaveLength(1); + const tuesday = engRows(container)[0].querySelectorAll("td")[1]; + expect(bars(tuesday).map((b) => b.textContent)).toEqual(["Alpha Ltd - ENG-1001", "Beta Ltd - ENG-1001"]); + expect(tuesday.querySelectorAll(".engstack")).toHaveLength(1); + }); + + it("is read-only: a bar is not a link, button or form control", () => { + const { container } = renderWeek({ + assignments: onRota, + engagementAllocations: [alloc()], + engagementPeople: PEOPLE, + }); + const row = engRows(container)[0]; + expect(row.querySelector("a, button, input, select, textarea, [tabindex]")).toBeNull(); + }); + + it("explains the bar in the legend, and only when there is one", () => { + const withBar = renderWeek({ assignments: onRota, engagementAllocations: [alloc()], engagementPeople: PEOPLE }); + expect(withBar.container.querySelector("details.rlegend .engsw")).not.toBeNull(); + withBar.unmount(); + const without = renderWeek({ assignments: onRota }); + expect(without.container.querySelector("details.rlegend")).toBeNull(); + expect(without.container.querySelector("tr.engrow")).toBeNull(); + }); + + it("leaves the week intact and says so quietly when the read failed", () => { + const { container } = renderWeek({ assignments: onRota, engagementsFailed: true }); + expect(screen.getByText("Regular hours")).toBeInTheDocument(); + expect(screen.getAllByText("Jane Doe").length).toBeGreaterThan(0); + expect(screen.getByRole("status")).toHaveTextContent(/engagements could not be loaded/i); + expect(container.querySelector("tr.engrow")).toBeNull(); + }); + + it("keeps the bars it has when the read failed in part", () => { + const { container } = renderWeek({ + assignments: onRota, + engagementAllocations: [alloc()], + engagementPeople: PEOPLE, + engagementsFailed: true, + }); + expect(bars(container)).toHaveLength(3); + expect(screen.getByRole("status")).toBeInTheDocument(); + }); +}); diff --git a/apps/csm-portal/webapp/src/features/csm-team-schedule/components/WeekTable.tsx b/apps/csm-portal/webapp/src/features/csm-team-schedule/components/WeekTable.tsx index 15361e2de4..0e5df3af41 100644 --- a/apps/csm-portal/webapp/src/features/csm-team-schedule/components/WeekTable.tsx +++ b/apps/csm-portal/webapp/src/features/csm-team-schedule/components/WeekTable.tsx @@ -17,8 +17,10 @@ */ import { useEffect, useMemo, useState, type JSX } from "react"; +import RosterLegend from "./RosterLegend"; import RotaPicker, { type RotaOption } from "./RotaPicker"; import type { + EngagementAllocation, ScheduleAbsence, ScheduleAbsenceKind, ScheduleAssignment, @@ -34,6 +36,12 @@ import { shortDayName, standingWindowKey, toIsoDate, rotaZoneName, zoneAbbreviation } from "../utils/rota"; +import { + ENGAGEMENT_LEGEND_ITEM, + allocationLabel, + allocationTitle, + allocationsByPersonAndDay, +} from "../utils/engagementAllocations"; import { accentOf } from "../utils/rotaHues"; import { useTeamColour, useTeamName } from "../utils/teamColourContext"; @@ -60,6 +68,14 @@ interface WeekTableProps { /** Absences over the week, for the leave row at the foot of the table. */ absences?: ScheduleAbsence[]; absenceKinds?: ScheduleAbsenceKind[]; + /** Confirmed customer engagement allocations over the week, drawn read-only + * as one bar a day on the engineer's own row under the leave row. */ + engagementAllocations?: EngagementAllocation[]; + /** Who the allocations' user ids are, for the row heading and its colour. */ + engagementPeople?: ReadonlyMap; + /** The allocations could not be read: the week is drawn without them, with a + * quiet note saying so. */ + engagementsFailed?: boolean; } /** @@ -192,6 +208,9 @@ export default function WeekTable({ onRotaChange, absences = [], absenceKinds = [], + engagementAllocations = [], + engagementPeople, + engagementsFailed = false, }: WeekTableProps): JSX.Element { const teamColourOf = useTeamColour(); const teamNameOf = useTeamName(); @@ -356,6 +375,22 @@ export default function WeekTable({ }, [absences, absenceKinds, days]); const anyLeave = [...leaveByDay.values()].some((l) => l.length > 0); + /** Customer engagements, engineer then day. Every day an allocation covers + * gets its own bar, weekends included -- an engagement does not stop for + * one -- and an engineer with none this week has no row. */ + const engagementRows = useMemo(() => { + const iso = days.map(toIsoDate); + const byPerson = allocationsByPersonAndDay(engagementAllocations, iso); + return [...byPerson.entries()] + .map(([userId, byDay]) => ({ + userId, + name: engagementPeople?.get(userId)?.name ?? "Engineer", + teamKey: engagementPeople?.get(userId)?.teamKey, + byDay, + })) + .sort((a, b) => a.name.localeCompare(b.name) || a.userId.localeCompare(b.userId)); + }, [engagementAllocations, engagementPeople, days]); + /** Distinct people on the rota this week, not rows: one engineer covering * three rotations is one engineer. */ const headcount = useMemo( @@ -423,6 +458,11 @@ export default function WeekTable({ return ( <> {head} + {engagementsFailed ? ( +
    + Customer engagements could not be loaded, so they are not shown. +
    + ) : null}
    @@ -541,9 +581,57 @@ export default function WeekTable({ })} ) : null} + + {/* Who is at a customer, one row per engineer and one bar per day, + under the leave row. Read-only: a bar says where somebody is and + opens nothing. */} + {engagementRows.map(({ userId, name, teamKey: personTeam, byDay }, i) => ( + + + {days.map((d) => { + const iso = toIsoDate(d); + const weekend = d.getDay() === 0 || d.getDay() === 6; + const list = byDay.get(iso) ?? []; + return ( + + ); + })} + + ))}
    + + + {initialsOf(name)} + + {name} + + Engagements + + {list.length === 0 ? ( + — + ) : ( +
    + {list.map((a) => { + const label = allocationLabel(a); + const title = allocationTitle(a); + return ( +
    + {label} +
    + ); + })} +
    + )} +
    + {engagementRows.length > 0 ? : null} ); } diff --git a/apps/csm-portal/webapp/src/features/csm-team-schedule/pages/CsmTeamSchedulePage.tsx b/apps/csm-portal/webapp/src/features/csm-team-schedule/pages/CsmTeamSchedulePage.tsx index 0a02290528..55aaa3581a 100644 --- a/apps/csm-portal/webapp/src/features/csm-team-schedule/pages/CsmTeamSchedulePage.tsx +++ b/apps/csm-portal/webapp/src/features/csm-team-schedule/pages/CsmTeamSchedulePage.tsx @@ -48,6 +48,8 @@ import RecentChanges from "../components/RecentChanges"; import UndoToast, { type UndoOffer } from "../components/UndoToast"; import { UndoIncomplete, applyRevertRuns, awayIsRevertible, rotaRevertRuns } from "../utils/undo"; import WeekTable from "../components/WeekTable"; +import { useEngagementAllocations } from "../api/useEngagementAllocations"; +import { distinctUserIds } from "../utils/engagementAllocations"; import CasePagingTab, { type PagingRotaTarget } from "../components/CasePagingTab"; import { caseAlertChainsFor, caseAlertChainsReady, caseAlertsOffered } from "../utils/pagingChains"; import RotaAccessTab from "../components/RotaAccessTab"; @@ -1368,6 +1370,35 @@ export default function CsmTeamSchedulePage(): JSX.Element { [catalogue.data?.teams, shownTeamKey, noRotaOfTheirs, user?.team?.teamKey, rotaScoped, scopeRota, family], ); + /** The people the week is drawing, by user id: the engineers on the rota + * read, those on leave, and every member of the teams in view -- the same + * three sources the roster seats its rows from, so an engineer with no + * rota entry this week is still asked after. Each carries the user id the + * schedule reads already return. */ + const engagementPeople = useMemo(() => { + const m = new Map(); + for (const a of rows) if (!m.has(a.engineer.userId)) m.set(a.engineer.userId, { name: a.engineer.name, teamKey: a.teamKey }); + for (const ab of absences.data?.absences ?? []) { + if (!m.has(ab.engineer.userId)) m.set(ab.engineer.userId, { name: ab.engineer.name, teamKey: ab.teamKey }); + } + for (const [key, members] of Object.entries(teamMembers)) { + for (const mem of members) if (!m.has(mem.userId)) m.set(mem.userId, { name: mem.name, teamKey: key }); + } + return m; + }, [rows, absences.data?.absences, teamMembers]); + const engagementUserIds = useMemo( + () => distinctUserIds([...engagementPeople.keys()].map((userId) => ({ userId }))), + [engagementPeople], + ); + // Week view only, over the week on screen. A failure or an empty answer + // leaves the schedule exactly as it was; the week table says so quietly. + const engagements = useEngagementAllocations( + engagementUserIds, + toIsoDate(weekStart), + toIsoDate(addDays(weekStart, 6)), + view === "week" && engagementUserIds.length > 0, + ); + if (catalogue.isError) { return ; } @@ -1688,6 +1719,9 @@ export default function CsmTeamSchedulePage(): JSX.Element { shifts={shifts} absences={absences.data?.absences ?? []} absenceKinds={catalogue.data?.absenceKinds ?? []} + engagementAllocations={engagements.allocations} + engagementPeople={engagementPeople} + engagementsFailed={engagements.isError} {...scopeControls} /> ) : view === "roster" ? ( diff --git a/apps/csm-portal/webapp/src/features/csm-team-schedule/teamSchedule.css b/apps/csm-portal/webapp/src/features/csm-team-schedule/teamSchedule.css index 6f67824009..74baf25fa9 100644 --- a/apps/csm-portal/webapp/src/features/csm-team-schedule/teamSchedule.css +++ b/apps/csm-portal/webapp/src/features/csm-team-schedule/teamSchedule.css @@ -3285,3 +3285,72 @@ .csm-ts .ladder .lane .lhand { position: absolute; left: -13px; width: 20px; min-height: 20px; z-index: 3; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border-radius: 9px; background: var(--accent); color: #fff; box-shadow: 0 0 0 2px var(--surface-solid), 0 1px 3px rgba(0,0,0,.25); cursor: default; } .csm-ts .ladder .lane .lhand i { font-style: normal; font-size: 12px; font-weight: 800; line-height: 1; } .csm-ts .ladder .lax .laxhand { position: absolute; right: 0; width: 3px; border-radius: 2px; background: var(--accent); } + +/* ── Customer engagements ───────────────────────────────────────────────── + Read-only bars on an engineer's row in the week table, one a day for every + day an allocation covers. Drawn as a squared bar with a heavy left edge, + which no code chip has, in a hue none of the rota, hours or leave chips + use, so the shape and the colour each say "not a rota turn". The pair is + tokens for both themes like the leave kinds above; text on the bar is + 8:1 or better against it in either, and the edge is over 3:1 against the + cell. */ +.csm-ts { + --eng-bg: #d3eef6; --eng-fg: #0b4a5e; --eng-edge: #0e7c99; +} +:root[data-color-scheme="dark"] .csm-ts { + --eng-bg: #16414f; --eng-fg: #c4ecf8; --eng-edge: #4cc3e3; +} +.csm-ts .weekwrap .tw tr.engrow > th, +.csm-ts .weekwrap .tw tr.engrow > td { + background: var(--surface-2); +} +.csm-ts .weekwrap .tw tr.engrow.engfirst > th, +.csm-ts .weekwrap .tw tr.engrow.engfirst > td { + border-top: 2px solid var(--line-2); +} +.csm-ts .weekwrap .tw tr.engrow > th .nm { + display: flex; + align-items: center; + gap: 6px; +} +/* Bars stack in the cell's own flow: a second allocation on a day adds one + line to the row rather than squeezing both. */ +.csm-ts .engstack { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} +.csm-ts .engbar { + min-width: 0; + padding: 2px 6px; + border-left: 4px solid var(--eng-edge); + border-radius: 2px; + background: var(--eng-bg); + color: var(--eng-fg); + font-size: 11px; + font-weight: 600; + line-height: 16px; + cursor: default; +} +.csm-ts .engbar .engbar-t { + display: block; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} +/* The legend's swatch: the same bar, empty. */ +.csm-ts .engsw { + display: inline-block; + width: 34px; + height: 14px; + border-left: 4px solid var(--eng-edge); + border-radius: 2px; + background: var(--eng-bg); +} +.csm-ts .engnote { + padding: 4px 16px; + font-size: 12px; + color: var(--muted); + border-bottom: 1px solid var(--line); +} diff --git a/apps/csm-portal/webapp/src/features/csm-team-schedule/types/index.ts b/apps/csm-portal/webapp/src/features/csm-team-schedule/types/index.ts index 7b4cbf9b64..b761f36171 100644 --- a/apps/csm-portal/webapp/src/features/csm-team-schedule/types/index.ts +++ b/apps/csm-portal/webapp/src/features/csm-team-schedule/types/index.ts @@ -238,6 +238,43 @@ export interface ScheduleAbsencesResponse { count: number; } +/** Body of the confirmed engagement allocations search. At most 500 user ids + * and a window of at most 70 days, both ends inclusive. */ +export interface SearchEngagementAllocationsPayload { + userIds: string[]; + from: string; + to: string; +} + +/** + * One confirmed customer engagement allocation: an engineer given to an + * engagement for a run of days. Read-only on the schedule. + * + * The server omits what it does not have, so every descriptive field is + * optional; `engagementType` and `modeOfDelivery` are raw enum values and are + * turned into words by the reader (see utils/engagementAllocations). + */ +export interface EngagementAllocation { + id: string; + userId: string; + engagementId: string; + /** First and last day covered, YYYY-MM-DD, both inclusive. */ + startDate: string; + endDate: string; + allocationId?: string; + engagementDisplayId?: string; + accountName?: string; + engagementType?: string; + modeOfDelivery?: string; + /** Percent of the engineer's time, e.g. 50. */ + utilization?: number; +} + +export interface EngagementAllocationsResponse { + allocations: EngagementAllocation[]; + count: number; +} + /** One recorded change to a team's rota or leave, as the history keeps it. */ export interface ScheduleActivity { id: string; diff --git a/apps/csm-portal/webapp/src/features/csm-team-schedule/utils/engagementAllocations.test.ts b/apps/csm-portal/webapp/src/features/csm-team-schedule/utils/engagementAllocations.test.ts new file mode 100644 index 0000000000..b518716902 --- /dev/null +++ b/apps/csm-portal/webapp/src/features/csm-team-schedule/utils/engagementAllocations.test.ts @@ -0,0 +1,176 @@ +/** + * Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { describe, expect, it } from "vitest"; +import type { EngagementAllocation } from "../types"; +import { + allocationLabel, + allocationTitle, + allocationsByPersonAndDay, + chunk, + distinctUserIds, + humaniseEnum, + splitWindow, +} from "./engagementAllocations"; + +const U1 = "00000000-0000-0000-0000-000000000001"; +const U2 = "00000000-0000-0000-0000-000000000002"; + +function alloc(over: Partial = {}): EngagementAllocation { + return { + id: "a1", + userId: U1, + engagementId: "00000000-0000-0000-0000-0000000000e1", + startDate: "2026-09-21", + endDate: "2026-09-23", + ...over, + }; +} + +describe("humaniseEnum", () => { + it("titles the known values", () => { + expect(humaniseEnum("CONSULTANCY")).toBe("Consultancy"); + expect(humaniseEnum("ONSITE")).toBe("Onsite"); + expect(humaniseEnum("OFFSITE")).toBe("Offsite"); + }); + + it("humanises a value it has never seen: underscores out, Title Case", () => { + expect(humaniseEnum("PROFESSIONAL_SERVICES")).toBe("Professional Services"); + expect(humaniseEnum("hybrid_remote")).toBe("Hybrid Remote"); + }); + + it("is empty for nothing", () => { + expect(humaniseEnum(undefined)).toBe(""); + expect(humaniseEnum("")).toBe(""); + }); +}); + +describe("allocationLabel", () => { + it("is the account and the engagement id", () => { + expect(allocationLabel(alloc({ accountName: "Example Corp", engagementDisplayId: "ENG-1001" }))).toBe( + "Example Corp - ENG-1001", + ); + }); + + it("is the account alone when there is no engagement id", () => { + expect(allocationLabel(alloc({ accountName: "Example Corp" }))).toBe("Example Corp"); + }); + + it("falls back to the engagement id, then to the word Engagement", () => { + expect(allocationLabel(alloc({ engagementDisplayId: "ENG-1001" }))).toBe("ENG-1001"); + expect(allocationLabel(alloc())).toBe("Engagement"); + }); +}); + +describe("allocationTitle", () => { + it("joins every part with the bullet, in order", () => { + expect( + allocationTitle( + alloc({ + engagementType: "CONSULTANCY", + modeOfDelivery: "ONSITE", + allocationId: "ALLOC-7", + utilization: 50, + }), + ), + ).toBe("Consultancy ● Onsite ● ALLOC-7 ● 50% ● 2026-09-21 - 2026-09-23"); + }); + + it("leaves out what is absent, keeping the dates", () => { + expect(allocationTitle(alloc())).toBe("2026-09-21 - 2026-09-23"); + expect(allocationTitle(alloc({ modeOfDelivery: "OFFSITE" }))).toBe("Offsite ● 2026-09-21 - 2026-09-23"); + }); + + it("keeps a utilization of zero", () => { + expect(allocationTitle(alloc({ utilization: 0 }))).toBe("0% ● 2026-09-21 - 2026-09-23"); + }); +}); + +describe("splitWindow", () => { + it("leaves a window within 70 days whole", () => { + expect(splitWindow("2026-09-21", "2026-09-27")).toEqual([{ from: "2026-09-21", to: "2026-09-27" }]); + // Exactly 70 days inclusive is still one request. + expect(splitWindow("2026-01-01", "2026-03-11")).toEqual([{ from: "2026-01-01", to: "2026-03-11" }]); + }); + + it("cuts a longer window into consecutive pieces of at most 70 days, no day twice", () => { + // 71 days: one full piece and one day. + expect(splitWindow("2026-01-01", "2026-03-12")).toEqual([ + { from: "2026-01-01", to: "2026-03-11" }, + { from: "2026-03-12", to: "2026-03-12" }, + ]); + const pieces = splitWindow("2026-01-01", "2026-06-30"); + expect(pieces[0].from).toBe("2026-01-01"); + expect(pieces[pieces.length - 1].to).toBe("2026-06-30"); + for (let i = 1; i < pieces.length; i++) { + const prev = new Date(`${pieces[i - 1].to}T00:00:00Z`).getTime(); + expect(new Date(`${pieces[i].from}T00:00:00Z`).getTime() - prev).toBe(86_400_000); + } + }); + + it("gives no pieces for an empty or unreadable window", () => { + expect(splitWindow("2026-09-27", "2026-09-21")).toEqual([]); + expect(splitWindow("", "")).toEqual([]); + }); +}); + +describe("chunk and distinctUserIds", () => { + it("batches at 500 by default", () => { + const ids = Array.from({ length: 1201 }, (_, i) => String(i)); + expect(chunk(ids).map((c) => c.length)).toEqual([500, 500, 201]); + expect(chunk([])).toEqual([]); + }); + + it("deduplicates across sources, sorts, and drops blanks", () => { + expect( + distinctUserIds([{ userId: U2 }, { userId: U1 }], [{ userId: U1 }, { userId: "" }, { userId: undefined }]), + ).toEqual([U1, U2]); + }); +}); + +describe("allocationsByPersonAndDay", () => { + const days = ["2026-09-21", "2026-09-22", "2026-09-23", "2026-09-24"]; + + it("draws one entry for every day a run covers inside the window", () => { + const out = allocationsByPersonAndDay([alloc({ startDate: "2026-09-22", endDate: "2026-09-30" })], days); + expect([...out.get(U1)!.keys()]).toEqual(["2026-09-22", "2026-09-23", "2026-09-24"]); + }); + + it("ignores days outside the window", () => { + const out = allocationsByPersonAndDay([alloc({ startDate: "2026-08-01", endDate: "2026-08-05" })], days); + expect(out.size).toBe(0); + }); + + it("keeps every allocation on a day, in label order", () => { + const out = allocationsByPersonAndDay( + [ + alloc({ id: "b", accountName: "Zeta Ltd", startDate: "2026-09-21", endDate: "2026-09-21" }), + alloc({ id: "a", accountName: "Alpha Ltd", startDate: "2026-09-21", endDate: "2026-09-22" }), + ], + days, + ); + expect(out.get(U1)!.get("2026-09-21")!.map((x) => x.id)).toEqual(["a", "b"]); + expect(out.get(U1)!.get("2026-09-22")!.map((x) => x.id)).toEqual(["a"]); + }); + + it("counts an allocation returned twice once", () => { + const a = alloc(); + const out = allocationsByPersonAndDay([a, { ...a }], days); + expect(out.get(U1)!.get("2026-09-21")).toHaveLength(1); + }); +}); diff --git a/apps/csm-portal/webapp/src/features/csm-team-schedule/utils/engagementAllocations.ts b/apps/csm-portal/webapp/src/features/csm-team-schedule/utils/engagementAllocations.ts new file mode 100644 index 0000000000..0a2ef75f57 --- /dev/null +++ b/apps/csm-portal/webapp/src/features/csm-team-schedule/utils/engagementAllocations.ts @@ -0,0 +1,164 @@ +/** + * Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { LegendItem } from "../components/RosterLegend"; +import type { EngagementAllocation } from "../types"; + +/** What the engagement allocations read accepts in one request. */ +export const ALLOCATION_MAX_USER_IDS = 500; +export const ALLOCATION_MAX_WINDOW_DAYS = 70; + +const DAY_MS = 86_400_000; + +/** A YYYY-MM-DD as a day number, on UTC so a DST change cannot shift it. */ +function dayNumber(iso: string): number { + const [y, m, d] = iso.split("-").map(Number); + return Date.UTC(y, m - 1, d) / DAY_MS; +} + +function isoOfDay(n: number): string { + return new Date(n * DAY_MS).toISOString().slice(0, 10); +} + +/** + * A date window as the pieces one request may ask for. + * + * The read refuses more than 70 days, so a longer window is cut into + * consecutive runs of at most that many days, both ends inclusive and with no + * day in two pieces. A window that is empty or unreadable gives no pieces. + */ +export function splitWindow( + from: string, + to: string, + maxDays: number = ALLOCATION_MAX_WINDOW_DAYS, +): { from: string; to: string }[] { + const first = dayNumber(from); + const last = dayNumber(to); + if (!(last >= first)) return []; + const out: { from: string; to: string }[] = []; + for (let start = first; start <= last; start += maxDays) { + out.push({ from: isoOfDay(start), to: isoOfDay(Math.min(start + maxDays - 1, last)) }); + } + return out; +} + +/** The ids in runs of at most `size`, in order. */ +export function chunk(items: readonly T[], size: number = ALLOCATION_MAX_USER_IDS): T[][] { + const out: T[][] = []; + for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size)); + return out; +} + +/** + * The distinct user ids of everyone the schedule is drawing, sorted so the + * same people always make the same request (and the same cache key). + * Anything without an id is left out rather than guessed at. + */ +export function distinctUserIds(...sources: Iterable<{ userId?: string | null }>[]): string[] { + const ids = new Set(); + for (const source of sources) { + for (const p of source) { + const id = p.userId?.trim(); + if (id) ids.add(id); + } + } + return [...ids].sort(); +} + +/** + * A raw enum value as words: underscores to spaces, Title Case. + * CONSULTANCY becomes "Consultancy", PROFESSIONAL_SERVICES "Professional + * Services". Used for values this code has never seen, so a new type on the + * server reads sensibly before anyone has taught the screen about it. + */ +export function humaniseEnum(raw: string | undefined | null): string { + if (!raw) return ""; + return raw + .split(/[_\s]+/) + .filter(Boolean) + .map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()) + .join(" "); +} + +/** The account, then " - " and the engagement's own id where it has one. With + * no account the id stands alone, and with neither it is just "Engagement". */ +export function allocationLabel(a: EngagementAllocation): string { + const account = a.accountName?.trim(); + const display = a.engagementDisplayId?.trim(); + if (account) return display ? `${account} - ${display}` : account; + return display || "Engagement"; +} + +/** The tooltip: type, mode, allocation id, utilisation and dates, with what + * the server did not send simply left out. */ +export function allocationTitle(a: EngagementAllocation): string { + return [ + humaniseEnum(a.engagementType), + humaniseEnum(a.modeOfDelivery), + a.allocationId?.trim(), + a.utilization === undefined || a.utilization === null ? "" : `${a.utilization}%`, + `${a.startDate} - ${a.endDate}`, + ] + .filter(Boolean) + .join(" ● "); +} + +/** + * Allocations laid out as engineer, then day, then the bars on that day. + * + * One entry per day an allocation covers inside `days`, so a five-day run is + * five bars and a day with two allocations holds both. Days outside the window + * are not drawn. An allocation returned twice (two request pieces both touch + * it) is counted once. Bars on a day are in label order, then id, so a redraw + * never shuffles them. + */ +export function allocationsByPersonAndDay( + allocations: readonly EngagementAllocation[], + days: readonly string[], +): Map> { + const out = new Map>(); + const seen = new Set(); + for (const a of allocations) { + if (seen.has(a.id)) continue; + seen.add(a.id); + for (const iso of days) { + if (iso < a.startDate || iso > a.endDate) continue; + let person = out.get(a.userId); + if (!person) out.set(a.userId, (person = new Map())); + const day = person.get(iso); + if (day) day.push(a); + else person.set(iso, [a]); + } + } + for (const person of out.values()) { + for (const day of person.values()) { + day.sort((x, y) => allocationLabel(x).localeCompare(allocationLabel(y)) || x.id.localeCompare(y.id)); + } + } + return out; +} + +/** What a customer engagement bar is: one line, for any view that draws them. */ +export const ENGAGEMENT_LEGEND_ITEM: LegendItem = { + key: "engagement", + group: "Engagements", + code: "", + token: "", + label: "Customer engagement: one bar a day, account and engagement id (read-only)", + swatch: "engagement", +}; From 16b8fbf50a4dd0d86155c197c5117076bcdb4672 Mon Sep 17 00:00:00 2001 From: Sajith Ekanayaka Date: Sun, 25 Oct 2026 23:49:19 +0530 Subject: [PATCH 4/6] csm-portal backend: quote engagement allocation descriptions in openapi Unquoted commas and semicolons split three flow-mapping descriptions into keys with no value, which the openapi spec check rejects. --- apps/csm-portal/backend/openapi.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/csm-portal/backend/openapi.yaml b/apps/csm-portal/backend/openapi.yaml index a9069a3435..52e5351735 100644 --- a/apps/csm-portal/backend/openapi.yaml +++ b/apps/csm-portal/backend/openapi.yaml @@ -14273,7 +14273,7 @@ paths: maxItems: 500 items: { type: string, format: uuid } from: { type: string, format: date, description: First day of the window, inclusive. } - to: { type: string, format: date, description: Last day of the window, inclusive; at most 70 days after `from`. } + to: { type: string, format: date, description: "Last day of the window, inclusive; at most 70 days after `from`." } responses: "200": description: Ok. @@ -14297,8 +14297,8 @@ paths: allocationId: { type: string } engagementDisplayId: { type: string } accountName: { type: string } - engagementType: { type: string, description: Raw enum value, for example CONSULTANCY. } - modeOfDelivery: { type: string, description: Raw enum value, ONSITE or OFFSITE. } + engagementType: { type: string, description: "Raw enum value, for example CONSULTANCY." } + modeOfDelivery: { type: string, description: "Raw enum value, ONSITE or OFFSITE." } utilization: { type: number } count: { type: integer } "400": From ab441ceba4fb77e5b7a4a03096da0d8528d067e2 Mon Sep 17 00:00:00 2001 From: Sajith Ekanayaka Date: Sun, 25 Oct 2026 23:54:24 +0530 Subject: [PATCH 5/6] csm-portal: mark customer engagement days on the month roster The roster now shows a thin strip along the foot of a day cell for every day an engineer is on a confirmed customer engagement. The strip is absolutely placed in the cell padding, so row height, the rota chip, leave, on-call styling and the zone split are untouched. On a zone-split day the strip runs across every zone cell of the day. The strip's tooltip lists each allocation covering the day, one per line: the label, then the same details the week bar carries. Its accessible name says how many there are and lists them; on a split day only the first zone cell is named and the rest are hidden from assistive technology, so the day is announced once. The strip reuses the engagement colour tokens (4.2:1 or better against the cell in light, 6.1:1 in dark). The legend gains a matching entry, only while a strip is drawn. The page reads allocations for the roster's own window (the days its grid draws) as well as for the week, through the same hook and cache keys, and shows the same quiet note on a failed read, leaving the roster as it was. --- .../api/useEngagementAllocations.test.tsx | 30 ++++ .../components/MonthRoster.test.tsx | 170 ++++++++++++++++++ .../components/MonthRoster.tsx | 50 +++++- .../components/RosterLegend.test.tsx | 11 +- .../components/RosterLegend.tsx | 6 +- .../components/WeekTable.tsx | 3 +- .../pages/CsmTeamSchedulePage.tsx | 20 ++- .../csm-team-schedule/teamSchedule.css | 35 ++++ .../utils/engagementAllocations.ts | 35 ++++ 9 files changed, 350 insertions(+), 10 deletions(-) diff --git a/apps/csm-portal/webapp/src/features/csm-team-schedule/api/useEngagementAllocations.test.tsx b/apps/csm-portal/webapp/src/features/csm-team-schedule/api/useEngagementAllocations.test.tsx index 400574c4e4..5dfc301137 100644 --- a/apps/csm-portal/webapp/src/features/csm-team-schedule/api/useEngagementAllocations.test.tsx +++ b/apps/csm-portal/webapp/src/features/csm-team-schedule/api/useEngagementAllocations.test.tsx @@ -117,4 +117,34 @@ describe("useEngagementAllocations", () => { renderHook(() => useEngagementAllocations(ids(1), "2026-09-21", "2026-09-27", false), { wrapper: wrapper() }); expect(api.post).not.toHaveBeenCalled(); }); + + it("reads a roster-sized window of 90 days in two pieces, and a one-month window in one", async () => { + api.post.mockResolvedValue({ allocations: [], count: 0 }); + renderHook(() => useEngagementAllocations(ids(1), "2026-09-01", "2026-09-30"), { wrapper: wrapper() }); + await waitFor(() => expect(api.post).toHaveBeenCalledTimes(1)); + expect(api.post.mock.calls[0][1]).toEqual({ userIds: ids(1), from: "2026-09-01", to: "2026-09-30" }); + api.post.mockClear(); + renderHook(() => useEngagementAllocations(ids(1), "2026-08-07", "2026-11-04"), { wrapper: wrapper() }); + await waitFor(() => expect(api.post).toHaveBeenCalledTimes(2)); + const windows = api.post.mock.calls.map((c) => [c[1].from, c[1].to]).sort(); + expect(windows).toEqual([ + ["2026-08-07", "2026-10-15"], + ["2026-10-16", "2026-11-04"], + ]); + }); + + it("does not read again when the view changes to a window it already holds", async () => { + api.post.mockResolvedValue({ allocations: [row("a1", ids(1)[0])], count: 1 }); + const w = wrapper(); + const { result, rerender } = renderHook( + (p: { from: string; to: string }) => useEngagementAllocations(ids(1), p.from, p.to), + { wrapper: w, initialProps: { from: "2026-09-21", to: "2026-09-27" } }, + ); + await waitFor(() => expect(result.current.allocations).toHaveLength(1)); + rerender({ from: "2026-09-01", to: "2026-09-30" }); + await waitFor(() => expect(api.post).toHaveBeenCalledTimes(2)); + rerender({ from: "2026-09-21", to: "2026-09-27" }); + expect(result.current.allocations).toHaveLength(1); + expect(api.post).toHaveBeenCalledTimes(2); + }); }); diff --git a/apps/csm-portal/webapp/src/features/csm-team-schedule/components/MonthRoster.test.tsx b/apps/csm-portal/webapp/src/features/csm-team-schedule/components/MonthRoster.test.tsx index 609a25dfc8..aa99d1d7fe 100644 --- a/apps/csm-portal/webapp/src/features/csm-team-schedule/components/MonthRoster.test.tsx +++ b/apps/csm-portal/webapp/src/features/csm-team-schedule/components/MonthRoster.test.tsx @@ -20,6 +20,7 @@ import { fireEvent, render, screen, within } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import "@testing-library/jest-dom/vitest"; import MonthRoster from "./MonthRoster"; +import type { EngagementAllocation } from "../types"; import { ANNUAL_LEAVE, EVENING, @@ -1157,3 +1158,172 @@ describe("MonthRoster: editing by keyboard", () => { ); }); }); + +describe("MonthRoster: customer engagement markers", () => { + const JANE = "00000000-0000-0000-0000-000000000001"; + const alloc = (over: Partial = {}): EngagementAllocation => ({ + id: "a1", + userId: JANE, + engagementId: "00000000-0000-0000-0000-0000000000e1", + startDate: "2026-09-22", + endDate: "2026-09-24", + accountName: "Example Corp", + engagementDisplayId: "ENG-1001", + engagementType: "CONSULTANCY", + modeOfDelivery: "ONSITE", + allocationId: "ALLOC-7", + utilization: 50, + ...over, + }); + const onRota = [ + assignment({ name: "Jane Doe", userId: JANE, rotaDate: "2026-09-21", shiftCode: EVENING.code }), + assignment({ name: "Asela", rotaDate: "2026-09-21", shiftCode: EVENING.code }), + ]; + const marks = (el: ParentNode) => [...el.querySelectorAll(".engmk")] as HTMLElement[]; + const rowOf = (c: HTMLElement, name: string) => + [...c.querySelectorAll("tbody tr")].find((r) => r.textContent?.includes(name)) as HTMLElement; + + it("draws no marker, and no legend entry, without allocations", () => { + const { container } = renderRoster({ assignments: onRota }); + expect(marks(container)).toHaveLength(0); + expect(container.querySelectorAll(".engmksw")).toHaveLength(0); + }); + + it("marks only the days an allocation covers, on that engineer's row", () => { + const { container } = renderRoster({ assignments: onRota, engagementAllocations: [alloc()] }); + const jane = rowOf(container, "Jane Doe"); + const cells = [...jane.querySelectorAll("td")]; + const marked = cells.map((td, i) => (marks(td).length ? i + 1 : 0)).filter(Boolean); + // Sept 22 to 24 on a grid that starts on the 1st. + expect(marked).toEqual([22, 23, 24]); + expect(marks(rowOf(container, "Asela"))).toHaveLength(0); + }); + + it("does not draw days outside the days on the grid", () => { + const { container } = renderRoster({ + assignments: onRota, + engagementAllocations: [alloc({ startDate: "2026-08-25", endDate: "2026-09-02" })], + }); + expect(marks(rowOf(container, "Jane Doe"))).toHaveLength(2); + }); + + it("carries the week view's details in the title, with the label leading", () => { + const { container } = renderRoster({ assignments: onRota, engagementAllocations: [alloc()] }); + expect(marks(container)[0]).toHaveAttribute( + "title", + "Example Corp - ENG-1001: Consultancy ● Onsite ● ALLOC-7 ● 50% ● 2026-09-22 - 2026-09-24", + ); + }); + + it("lists every allocation on a day in one marker, one per line", () => { + const { container } = renderRoster({ + assignments: onRota, + engagementAllocations: [ + alloc(), + alloc({ id: "a2", accountName: "Another Corp", engagementDisplayId: "ENG-2002", startDate: "2026-09-23", endDate: "2026-09-23" }), + ], + }); + const jane = rowOf(container, "Jane Doe"); + const day23 = marks([...jane.querySelectorAll("td")][22]); + expect(day23).toHaveLength(1); + const lines = day23[0].getAttribute("title")!.split("\n"); + expect(lines).toHaveLength(2); + expect(lines[0]).toContain("Another Corp - ENG-2002"); + expect(lines[1]).toContain("Example Corp - ENG-1001"); + // The 22nd has the one allocation only. + expect(marks([...jane.querySelectorAll("td")][21])[0].getAttribute("title")!.split("\n")).toHaveLength(1); + }); + + it("names the marker once for assistive technology, and says how many", () => { + const { container } = renderRoster({ + assignments: onRota, + engagementAllocations: [alloc(), alloc({ id: "a2", accountName: "Another Corp" })], + }); + const m = marks(container)[0]; + expect(m).toHaveAttribute("role", "img"); + expect(m.getAttribute("aria-label")).toMatch(/^2 customer engagements\. /); + expect(m.getAttribute("aria-label")).not.toContain("\n"); + expect(screen.getAllByRole("img", { name: /customer engagement/i })).toHaveLength(3); + }); + + it("leaves the rota chip, the leave and the cell's own title as they were", () => { + const base = renderRoster({ assignments: onRota, absences: [absence({ name: "Jane Doe", userId: JANE, startsOn: "2026-09-23", endsOn: "2026-09-23" })] }); + const before = [...rowOf(base.container, "Jane Doe").querySelectorAll("td")].map((td) => [td.className, td.getAttribute("title"), td.querySelector(".chip")?.textContent]); + base.unmount(); + const { container } = renderRoster({ + assignments: onRota, + absences: [absence({ name: "Jane Doe", userId: JANE, startsOn: "2026-09-23", endsOn: "2026-09-23" })], + engagementAllocations: [alloc()], + }); + const after = [...rowOf(container, "Jane Doe").querySelectorAll("td")].map((td) => [td.className, td.getAttribute("title"), td.querySelector(".chip")?.textContent]); + expect(after).toEqual(before); + // The leave day still shows its chip, with the marker beside it. + const leaveCell = [...rowOf(container, "Jane Doe").querySelectorAll("td")][22]; + expect(leaveCell.querySelector(".chip")).not.toBeNull(); + expect(marks(leaveCell)).toHaveLength(1); + }); + + it("keeps the marker out of the way of an edit click", () => { + const onEditCell = vi.fn(); + const { container } = renderRoster({ + assignments: onRota, + engagementAllocations: [alloc()], + leadTeams: ["alpha"], + editing: true, + onEditCell, + }); + fireEvent.click(marks(container)[0]); + expect(onEditCell).toHaveBeenCalledTimes(1); + }); + + it("explains the marker in the legend, once, only while markers are drawn", () => { + const { container } = renderRoster({ assignments: onRota, engagementAllocations: [alloc()] }); + expect(container.querySelectorAll(".engmksw")).toHaveLength(1); + expect(container.textContent).toContain("Customer engagement: a strip under the day"); + }); + + it("hides the legend entry when the only allocations are off the grid", () => { + const { container } = renderRoster({ + assignments: onRota, + engagementAllocations: [alloc({ startDate: "2026-10-05", endDate: "2026-10-06" })], + }); + expect(container.querySelectorAll(".engmksw")).toHaveLength(0); + }); + + it("says quietly that engagements could not be loaded, and draws the roster unchanged", () => { + const base = renderRoster({ assignments: onRota }); + const html = base.container.querySelector("table")!.innerHTML; + base.unmount(); + const { container } = renderRoster({ assignments: onRota, engagementsFailed: true }); + expect(screen.getByRole("status")).toHaveTextContent("Customer engagements could not be loaded, so they are not shown."); + expect(container.querySelector("table")!.innerHTML).toBe(html); + }); + + describe("on a zone-split day", () => { + const sre = { + assignments: [ + assignment({ name: "Jane Doe", userId: JANE, rotaDate: "2026-09-22", shiftCode: TZ1.code, zoneCode: "TZ1", teamKey: "delta" }), + ], + family: "SRE" as const, + engagementAllocations: [alloc({ startDate: "2026-09-22", endDate: "2026-09-22" })], + }; + + it("runs the strip across every zone of the day but names it once", () => { + const { container } = renderRoster(sre); + const day = marks(rowOf(container, "Jane Doe")); + expect(day.length).toBeGreaterThan(1); + expect(day.filter((m) => m.getAttribute("role") === "img")).toHaveLength(1); + expect(day.filter((m) => m.getAttribute("aria-hidden") === "true")).toHaveLength(day.length - 1); + expect(new Set(day.map((m) => m.getAttribute("title"))).size).toBe(1); + }); + + it("marks a whole-day cell once", () => { + const { container } = renderRoster({ + ...sre, + assignments: [assignment({ name: "Jane Doe", userId: JANE, rotaDate: "2026-09-21", shiftCode: TZ1.code, zoneCode: "TZ1", teamKey: "delta" })], + }); + expect(marks(rowOf(container, "Jane Doe"))).toHaveLength(1); + expect(rowOf(container, "Jane Doe").querySelector("td.zwhole .engmk")).not.toBeNull(); + }); + }); +}); diff --git a/apps/csm-portal/webapp/src/features/csm-team-schedule/components/MonthRoster.tsx b/apps/csm-portal/webapp/src/features/csm-team-schedule/components/MonthRoster.tsx index b4333db035..7012dad0e9 100644 --- a/apps/csm-portal/webapp/src/features/csm-team-schedule/components/MonthRoster.tsx +++ b/apps/csm-portal/webapp/src/features/csm-team-schedule/components/MonthRoster.tsx @@ -21,6 +21,7 @@ import RosterLegend, { type LegendItem } from "./RosterLegend"; import RotaPicker, { type RotaOption } from "./RotaPicker"; import type { CellAbsence, + EngagementAllocation, ScheduleAbsence, ScheduleAbsenceKind, ScheduleAssignment, @@ -30,6 +31,13 @@ import type { RotaFamily, } from "../types"; import { addDays, initialsOf, isRotationShift, kindsOfferedOn, mondayOf, movesOfferedOn, toIsoDate, zoneColumnOf, zoneLabelOn, type RosterSpan } from "../utils/rota"; +import { + ENGAGEMENTS_FAILED_NOTE, + ENGAGEMENT_MARK_LEGEND_ITEM, + allocationsByPersonAndDay, + dayMarkerAriaLabel, + dayMarkerTitle, +} from "../utils/engagementAllocations"; import { ordinaryDayCode, REGULAR_HOURS_CODE } from "../utils/standingDay"; import { useTeamColour, useTeamName } from "../utils/teamColourContext"; @@ -53,6 +61,12 @@ interface MonthRosterProps { * choice to offer. */ span?: RosterSpan; onSpanChange?: (span: RosterSpan) => void; + /** Confirmed customer engagement allocations over the days shown, drawn + * read-only as a strip under each day an engineer is on one. */ + engagementAllocations?: EngagementAllocation[]; + /** The allocations could not be read: the roster is drawn without them, + * with a quiet note saying so. */ + engagementsFailed?: boolean; assignments: ScheduleAssignment[]; absences: ScheduleAbsence[]; shifts: Map; @@ -216,6 +230,8 @@ export default function MonthRoster({ to: rangeTo, span, onSpanChange, + engagementAllocations, + engagementsFailed = false, assignments, absences, shifts, @@ -275,6 +291,11 @@ export default function MonthRoster({ const count = Math.round((last.getTime() - first.getTime()) / 86_400_000) + 1; return Array.from({ length: count }, (_, i) => new Date(first.getFullYear(), first.getMonth(), first.getDate() + i)); }, [month, monthCount, fromIso, toIso]); + /** Customer engagements, engineer then day, over the days on the grid. */ + const engByPerson = useMemo( + () => allocationsByPersonAndDay(engagementAllocations ?? [], days.map(toIsoDate)), + [engagementAllocations, days], + ); /** The 1st of each month after the first: where the grid draws a month rule * and names the month, so ninety columns still read as three months. */ const opensMonth = (d: Date): boolean => d.getDate() === 1 && d.getTime() !== days[0].getTime(); @@ -848,8 +869,11 @@ export default function MonthRoster({ const sh = shifts.get(kind.showsAsShiftCode); if (sh && sh.family === family) put({ key: `s|${sh.shortCode}|${sh.colourToken}|`, group: "Hours", code: sh.shortCode, token: sh.colourToken, label: sh.label }); } + // Only when a marker is on a row in view: the legend explains what is + // drawn, and the marker is not a code a lead can mark. + if (rows.some((r) => (engByPerson.get(r.userId)?.size ?? 0) > 0)) put(ENGAGEMENT_MARK_LEGEND_ITEM); return [...out.values()]; - }, [rows, days, kindByCode, defaultFor, absenceKinds, family, shifts]); + }, [rows, days, kindByCode, defaultFor, absenceKinds, family, shifts, engByPerson]); /** Turn a click on any roster cell into the slot the page should open the * picker on. Shared by the plain grid and by SRE's zone-split one, which @@ -903,6 +927,22 @@ export default function MonthRoster({ ) : ( {c.code} ); + /** The strip under a day on which the engineer is on a customer + * engagement, or nothing. Absolutely placed in the cell (see the css), so + * it moves nothing. A day split by zone draws it in every sub-column, so + * the strip runs the width of the day, but only the first is named: the + * rest are hidden from assistive technology, so the day is announced once. + * The title is on all of them so the tooltip shows wherever the pointer + * is along the strip. */ + const engMark = (userId: string, iso: string, named = true): JSX.Element | null => { + const list = engByPerson.get(userId)?.get(iso); + if (!list || list.length === 0) return null; + return named ? ( + + ) : ( +