From 8372cec91dc2ead99d0e5bdc8eda28725f93676e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 1 Aug 2026 06:37:53 +0000 Subject: [PATCH 1/4] =?UTF-8?q?C11:=20picker=20remoto=20de=20atividades=20?= =?UTF-8?q?na=20demanda=20+=20pagina=C3=A7=C3=A3o=20de=20v=C3=ADnculos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Substitui loadActivityOptions (pagination: false) por AsyncSearchCombobox com busca server-backed filtrada por município (searchActivityRelationOptions). - Pagina card Demandas vinculadas no detalhe da atividade (10/página) com soma de custo em query separada e link Ver todas na lista filtrada. - Adiciona filtro ?activity= na lista /campanha/demandas. Co-authored-by: Francisco Solla --- .../atividades/[slug]/ActivityOverviewTab.tsx | 33 +++++- .../campanha/(app)/atividades/[slug]/page.tsx | 9 +- .../(app)/demandas/activitySearchActions.ts | 20 ++++ .../campanha/(app)/demandas/nova/page.tsx | 24 ++-- src/components/campaign/demand/DemandForm.tsx | 95 +++++++-------- src/utilities/activityDetailPageData.ts | 109 ++++++++++++------ src/utilities/activityDetailTabUi.ts | 2 +- src/utilities/activityRelationOptions.ts | 85 ++++++++++++++ src/utilities/campaignDemandData.ts | 5 + src/utilities/campaignRelationOptions.ts | 28 +---- .../unit/activityRelationOptions.unit.spec.ts | 34 ++++++ tests/unit/codebaseConventions.unit.spec.ts | 1 + 12 files changed, 323 insertions(+), 122 deletions(-) create mode 100644 src/app/(campaign)/campanha/(app)/demandas/activitySearchActions.ts create mode 100644 src/utilities/activityRelationOptions.ts create mode 100644 tests/unit/activityRelationOptions.unit.spec.ts diff --git a/src/app/(campaign)/campanha/(app)/atividades/[slug]/ActivityOverviewTab.tsx b/src/app/(campaign)/campanha/(app)/atividades/[slug]/ActivityOverviewTab.tsx index fbe402aa..a03a15a8 100644 --- a/src/app/(campaign)/campanha/(app)/atividades/[slug]/ActivityOverviewTab.tsx +++ b/src/app/(campaign)/campanha/(app)/atividades/[slug]/ActivityOverviewTab.tsx @@ -2,6 +2,7 @@ import { PlusIcon } from 'lucide-react' import Link from 'next/link' import { ActivityResultForm } from '@/components/campaign/activity/ActivityResultForm' +import { CampaignListPagination } from '@/components/campaign/shared/CampaignListPagination' import { Badge } from '@/components/ui/Badge' import { Button } from '@/components/ui/button' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' @@ -17,6 +18,12 @@ const currencyFormatter = new Intl.NumberFormat('pt-BR', { type ActivityDetailView = Awaited> +const hrefForLinkedDemandsPage = (activitySlug: string, page: number) => { + const params = new URLSearchParams({ tab: 'overview' }) + if (page > 1) params.set('demandsPage', String(page)) + return `/campanha/atividades/${activitySlug}?${params.toString()}` +} + /** The "Visão geral" tab: details, team/orgs, linked demands and the result block. */ export const ActivityOverviewTab = ({ view, @@ -128,8 +135,20 @@ export const ActivityOverviewTab = ({ Demandas vinculadas

Custo estimado: {currencyFormatter.format(view.demandCostTotal)} + {view.totalDocs > 0 ? ( + <> + {' '} + · {view.totalDocs} {view.totalDocs === 1 ? 'demanda' : 'demandas'} + + ) : null}

+
+ {view.totalDocs > view.demands.length ? ( + + ) : null} {view.municipality ? ( ) : null} +
- + {view.demands.length ? ( -
    + <> +
      {view.demands.map((demand) => (
    • ))} -
    +
+ hrefForLinkedDemandsPage(view.slug, page)} + /> + ) : (

Nenhuma demanda vinculada a esta atividade. diff --git a/src/app/(campaign)/campanha/(app)/atividades/[slug]/page.tsx b/src/app/(campaign)/campanha/(app)/atividades/[slug]/page.tsx index da5cdeb6..b0ddb88f 100644 --- a/src/app/(campaign)/campanha/(app)/atividades/[slug]/page.tsx +++ b/src/app/(campaign)/campanha/(app)/atividades/[slug]/page.tsx @@ -29,6 +29,7 @@ import { resolveAccessibleActivityContext, } from '@/utilities/activityPageData' import { isCampaignStaff } from '@/utilities/campaignAccess' +import { firstValue, strictDecimalInteger } from '@/utilities/campaignListUrl' import { requireCampaignPageActor } from '@/utilities/campaignPageActor' import { ActivityOverviewTab } from './ActivityOverviewTab' @@ -57,7 +58,13 @@ export default async function ActivityDetailPage({ throw error }, ) - const view = await getActivityDetailPageData(payload, user, context, activeTab) + const view = await getActivityDetailPageData( + payload, + user, + context, + activeTab, + strictDecimalInteger(firstValue(query.demandsPage)) ?? 1, + ) const canonicalTabRedirect = getActivityDetailTabRedirect(view.slug, query) if (canonicalTabRedirect) redirect(canonicalTabRedirect) diff --git a/src/app/(campaign)/campanha/(app)/demandas/activitySearchActions.ts b/src/app/(campaign)/campanha/(app)/demandas/activitySearchActions.ts new file mode 100644 index 00000000..b49d96fe --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/demandas/activitySearchActions.ts @@ -0,0 +1,20 @@ +'use server' + +import config from '@payload-config' +import { getPayload } from 'payload' + +import type { AsyncSearchOption } from '@/components/campaign/shared/AsyncSearchCombobox' +import { searchActivityRelationOptions } from '@/utilities/activityRelationOptions' +import { getCampaignUser } from '@/utilities/campaignAuth' +import { CAMPAIGN_AUTH_REQUIRED_MESSAGE } from '@/utilities/campaignFormActionError' + +export const searchDemandActivityOptions = async ( + query: string, + municipalityId: number | null, +): Promise => { + const [payload, user] = await Promise.all([getPayload({ config }), getCampaignUser()]) + if (!user) throw new Error(CAMPAIGN_AUTH_REQUIRED_MESSAGE) + + const options = await searchActivityRelationOptions(payload, user, query, municipalityId) + return options.map((option) => ({ id: option.id, label: option.label })) +} diff --git a/src/app/(campaign)/campanha/(app)/demandas/nova/page.tsx b/src/app/(campaign)/campanha/(app)/demandas/nova/page.tsx index ca266051..c6c99756 100644 --- a/src/app/(campaign)/campanha/(app)/demandas/nova/page.tsx +++ b/src/app/(campaign)/campanha/(app)/demandas/nova/page.tsx @@ -3,12 +3,14 @@ import { ArrowLeftIcon } from 'lucide-react' import Link from 'next/link' import { getPayload } from 'payload' +import { searchDemandActivityOptions } from '@/app/(campaign)/campanha/(app)/demandas/activitySearchActions' import { DemandForm } from '@/components/campaign/demand/DemandForm' import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell' import { Button } from '@/components/ui/button' +import { loadActivityRelationOptionById } from '@/utilities/activityRelationOptions' import { firstValue, strictDecimalInteger } from '@/utilities/campaignListUrl' import { requireCampaignPageActor } from '@/utilities/campaignPageActor' -import { loadActivityOptions, loadMunicipalityOptions } from '@/utilities/campaignRelationOptions' +import { loadMunicipalityOptions } from '@/utilities/campaignRelationOptions' import { createDemandFormAction } from './formActions' type NewDemandPageProps = { @@ -22,16 +24,16 @@ export default async function NewDemandPage({ searchParams }: NewDemandPageProps searchParams, ]) - const [municipalityOptions, activityOptions] = await Promise.all([ - loadMunicipalityOptions(payload, user), - loadActivityOptions(payload, user), - ]) - const requestedActivity = activityOptions.find( - (activity) => activity.id === strictDecimalInteger(firstValue(query.activity)), - ) + const requestedActivityId = strictDecimalInteger(firstValue(query.activity)) const requestedMunicipalityId = strictDecimalInteger(firstValue(query.municipality)) + const [municipalityOptions, initialActivity] = await Promise.all([ + loadMunicipalityOptions(payload, user), + requestedActivityId + ? loadActivityRelationOptionById(payload, user, requestedActivityId) + : Promise.resolve(null), + ]) const initialMunicipalityId = - requestedActivity?.municipalityId ?? + initialActivity?.municipalityId ?? municipalityOptions.find((option) => option.id === requestedMunicipalityId)?.id return ( @@ -52,9 +54,9 @@ export default async function NewDemandPage({ searchParams }: NewDemandPageProps diff --git a/src/components/campaign/demand/DemandForm.tsx b/src/components/campaign/demand/DemandForm.tsx index 4fa508fd..83759f03 100644 --- a/src/components/campaign/demand/DemandForm.tsx +++ b/src/components/campaign/demand/DemandForm.tsx @@ -1,7 +1,8 @@ 'use client' -import { useActionState, useState } from 'react' +import { useActionState, useCallback, useState } from 'react' +import { AsyncSearchCombobox } from '@/components/campaign/shared/AsyncSearchCombobox' import { CampaignFormActionMessage } from '@/components/campaign/shared/CampaignFormActionMessage' import type { RelationOption } from '@/components/campaign/shared/RelationMultiSelect' import { Button } from '@/components/ui/button' @@ -13,13 +14,16 @@ import { Textarea } from '@/components/ui/textarea' import { campaignDemandKindLabels, campaignDemandKinds } from '@/lib/schemas/campaignDemand' import type { CampaignFormActionState } from '@/utilities/campaignFormActionError' import { fieldError } from '@/utilities/campaignFormFields' -import type { ActivityRelationOption } from '@/utilities/campaignRelationOptions' +import type { ActivityRelationOption } from '@/utilities/activityRelationOptions' type DemandFormProps = { municipalityOptions: RelationOption[] - activityOptions: ActivityRelationOption[] + initialActivity?: ActivityRelationOption | null initialMunicipalityId?: number - initialActivityId?: number + searchActivities: ( + query: string, + municipalityId: number | null, + ) => Promise> formAction: ( state: CampaignFormActionState, formData: FormData, @@ -28,19 +32,29 @@ type DemandFormProps = { export const DemandForm = ({ municipalityOptions, - activityOptions, + initialActivity = null, initialMunicipalityId, - initialActivityId, + searchActivities, formAction, }: DemandFormProps) => { const [state, submitAction, isPending] = useActionState(formAction, {}) const [municipalityId, setMunicipalityId] = useState( initialMunicipalityId ? String(initialMunicipalityId) : '', ) - const [activityId, setActivityId] = useState(initialActivityId ? String(initialActivityId) : '') - const visibleActivities = municipalityId - ? activityOptions.filter((activity) => String(activity.municipalityId) === municipalityId) - : activityOptions + const [activity, setActivity] = useState<{ id: number; label: string } | null>( + initialActivity ? { id: initialActivity.id, label: initialActivity.label } : null, + ) + + const parsedMunicipalityId = municipalityId ? Number(municipalityId) : null + const searchActivitiesForMunicipality = useCallback( + (query: string) => searchActivities(query, parsedMunicipalityId), + [parsedMunicipalityId, searchActivities], + ) + const activityQueryReady = useCallback(() => parsedMunicipalityId !== null, [parsedMunicipalityId]) + + const activityFieldDescription = parsedMunicipalityId + ? 'Opcional. Busque atividades do município selecionado.' + : 'Opcional. Escolha o município antes de vincular uma atividade.' return (

@@ -82,17 +96,8 @@ export const DemandForm = ({ required value={municipalityId} onChange={(event) => { - const nextMunicipalityId = event.target.value - setMunicipalityId(nextMunicipalityId) - const selectedActivity = activityOptions.find( - (activity) => String(activity.id) === activityId, - ) - if ( - selectedActivity && - String(selectedActivity.municipalityId) !== nextMunicipalityId - ) { - setActivityId('') - } + setMunicipalityId(event.target.value) + setActivity(null) }} className="min-h-11 w-full" > @@ -112,31 +117,29 @@ export const DemandForm = ({ Atividade relacionada - { - const nextActivityId = event.target.value - setActivityId(nextActivityId) - const selectedActivity = activityOptions.find( - (activity) => String(activity.id) === nextActivityId, - ) - if (selectedActivity) setMunicipalityId(String(selectedActivity.municipalityId)) - }} - className="min-h-11 w-full" - > - Nenhuma atividade - {visibleActivities.map((option) => ( - - {option.name} - - ))} - - - Opcional. Ao escolher uma atividade, o município correspondente é preenchido - automaticamente. - + {parsedMunicipalityId ? ( + + ) : ( + + )} + {activityFieldDescription} {fieldError(state.fieldErrors, 'activity') ? ( {fieldError(state.fieldErrors, 'activity')} ) : null} diff --git a/src/utilities/activityDetailPageData.ts b/src/utilities/activityDetailPageData.ts index 2ead3e30..8c874c52 100644 --- a/src/utilities/activityDetailPageData.ts +++ b/src/utilities/activityDetailPageData.ts @@ -13,6 +13,8 @@ import { type ActivityMunicipalitySummary, } from '@/utilities/activityViewModels' +export const ACTIVITY_LINKED_DEMANDS_PAGE_SIZE = 10 + type ActivityDemandSummary = { id: number title: string @@ -22,6 +24,22 @@ type ActivityDemandSummary = { cost: number | null } +export type ActivityLinkedDemandsPage = { + demands: ActivityDemandSummary[] + page: number + totalPages: number + totalDocs: number + demandCostTotal: number +} + +const emptyLinkedDemandsPage = (): ActivityLinkedDemandsPage => ({ + demands: [], + page: 1, + totalPages: 1, + totalDocs: 0, + demandCostTotal: 0, +}) + const loadActivityUpdateAuthorNames = async ( payload: Pick, user: CampaignUser, @@ -75,17 +93,63 @@ const loadActivityMunicipalitySummaryById = async ( : null } +const loadLinkedDemandsPage = async ( + payload: Pick, + user: CampaignUser, + activityId: number, + page: number, +): Promise => { + const where = { activity: { equals: activityId } } + + const [pageResult, costRows] = await Promise.all([ + payload.find({ + collection: 'campaignDemand', + where, + depth: 0, + limit: ACTIVITY_LINKED_DEMANDS_PAGE_SIZE, + page, + sort: '-createdAt', + select: { title: true, slug: true, kind: true, status: true, cost: true }, + user, + overrideAccess: false, + }), + payload.find({ + collection: 'campaignDemand', + where, + depth: 0, + limit: 0, + pagination: false, + select: { cost: true }, + user, + overrideAccess: false, + }), + ]) + + const demands = pageResult.docs.map((demand) => ({ + id: demand.id, + title: demand.title, + slug: demand.slug, + kind: demand.kind, + status: demand.status, + cost: demand.cost ?? null, + })) + + return { + demands, + page: pageResult.page ?? page, + totalPages: pageResult.totalPages, + totalDocs: pageResult.totalDocs, + demandCostTotal: costRows.docs.reduce((total, demand) => total + (demand.cost ?? 0), 0), + } +} + export const getActivityDetailPageData = async ( payload: Pick, user: CampaignUser, context: AccessibleActivityContext, activeTab: ActivityDetailTab, -): Promise< - ActivityDetailViewModel & { - demands: ActivityDemandSummary[] - demandCostTotal: number - } -> => { + linkedDemandsPage = 1, +): Promise => { const municipalityId = relationshipId(context.document.municipality) const populatedMunicipalitySummary = activityMunicipalitySummary(context.document.municipality) ?? null @@ -98,39 +162,18 @@ export const getActivityDetailPageData = async ( activeTab === 'updates' && context.document.updates?.length ? loadActivityUpdateAuthorNames(payload, user, context.document.updates) : Promise.resolve(new Map()) - const demandSummariesPromise: Promise = + const linkedDemandsPromise = activeTab === 'overview' - ? payload - .find({ - collection: 'campaignDemand', - where: { activity: { equals: context.id } }, - depth: 0, - pagination: false, - sort: 'createdAt', - select: { title: true, slug: true, kind: true, status: true, cost: true }, - user, - overrideAccess: false, - }) - .then(({ docs }) => - docs.map((demand) => ({ - id: demand.id, - title: demand.title, - slug: demand.slug, - kind: demand.kind, - status: demand.status, - cost: demand.cost ?? null, - })), - ) - : Promise.resolve([]) - const [municipalitySummary, authorNames, demands] = await Promise.all([ + ? loadLinkedDemandsPage(payload, user, context.id, linkedDemandsPage) + : Promise.resolve(emptyLinkedDemandsPage()) + const [municipalitySummary, authorNames, linkedDemands] = await Promise.all([ municipalitySummaryPromise, authorNamesPromise, - demandSummariesPromise, + linkedDemandsPromise, ]) return { ...toActivityDetailViewModel(context.document, activeTab, authorNames, municipalitySummary), - demands, - demandCostTotal: demands.reduce((total, demand) => total + (demand.cost ?? 0), 0), + ...linkedDemands, } } diff --git a/src/utilities/activityDetailTabUi.ts b/src/utilities/activityDetailTabUi.ts index 84ea4443..b7842f19 100644 --- a/src/utilities/activityDetailTabUi.ts +++ b/src/utilities/activityDetailTabUi.ts @@ -9,7 +9,7 @@ const helpers = createDetailTabHelpers({ tabs: activityDetailTabs, defaultTab: 'overview', tabQueryKeys: { - overview: [], + overview: ['demandsPage'], tasks: [], updates: ['newUpdate'], }, diff --git a/src/utilities/activityRelationOptions.ts b/src/utilities/activityRelationOptions.ts new file mode 100644 index 00000000..75705486 --- /dev/null +++ b/src/utilities/activityRelationOptions.ts @@ -0,0 +1,85 @@ +import 'server-only' + +import type { Payload } from 'payload' + +import { relationshipId } from '@/lib/relationship' +import type { CampaignUser } from '@/payload-types' + +export type ActivityRelationOption = { + id: number + label: string + municipalityId: number +} + +export const ACTIVITY_RELATION_SEARCH_LIMIT = 20 + +const MIN_ACTIVITY_TITLE_QUERY_LENGTH = 2 + +const isActivityTitleQueryReady = (query: string) => + query.trim().length === 0 || query.trim().length >= MIN_ACTIVITY_TITLE_QUERY_LENGTH + +const toActivityRelationOption = (activity: { + id: number + title: string + municipality: unknown +}): ActivityRelationOption | null => { + const municipalityId = relationshipId(activity.municipality) + if (!municipalityId) return null + return { id: activity.id, label: activity.title, municipalityId } +} + +export const loadActivityRelationOptionById = async ( + payload: Pick, + user: CampaignUser, + activityId: number, +): Promise => { + const result = await payload.find({ + collection: 'activity', + where: { id: { equals: activityId } }, + depth: 0, + limit: 1, + pagination: false, + select: { title: true, municipality: true }, + user, + overrideAccess: false, + }) + + const activity = result.docs[0] + return activity ? toActivityRelationOption(activity) : null +} + +/** + * Server-backed activity picker for demand forms — scoped to one municipality when set. + * Empty query returns recent activities in that municipality; typed query filters by title. + */ +export const searchActivityRelationOptions = async ( + payload: Pick, + user: CampaignUser, + query: string, + municipalityId: number | null, +): Promise => { + if (!municipalityId || !isActivityTitleQueryReady(query)) return [] + + const trimmed = query.trim().slice(0, 120) + + const result = await payload.find({ + collection: 'activity', + where: { + and: [ + { municipality: { equals: municipalityId } }, + ...(trimmed ? [{ title: { contains: trimmed } }] : []), + ], + }, + depth: 0, + limit: ACTIVITY_RELATION_SEARCH_LIMIT, + page: 1, + sort: 'title', + select: { title: true, municipality: true }, + user, + overrideAccess: false, + }) + + return result.docs + .map((activity) => toActivityRelationOption(activity)) + .filter((option): option is ActivityRelationOption => option !== null) +} diff --git a/src/utilities/campaignDemandData.ts b/src/utilities/campaignDemandData.ts index 717af313..d34b04fe 100644 --- a/src/utilities/campaignDemandData.ts +++ b/src/utilities/campaignDemandData.ts @@ -38,11 +38,13 @@ export type DemandListState = { page: number status?: CampaignDemandStatus kind?: CampaignDemandKind + activityId?: number } export const parseDemandListParams = (searchParams: RawSearchParams): DemandListState => { const rawStatus = firstValue(searchParams.status) const rawKind = firstValue(searchParams.kind) + const activityId = strictDecimalInteger(firstValue(searchParams.activity)) return { page: strictDecimalInteger(firstValue(searchParams.page)) ?? 1, @@ -52,6 +54,7 @@ export const parseDemandListParams = (searchParams: RawSearchParams): DemandList ...(campaignDemandKinds.includes(rawKind as CampaignDemandKind) ? { kind: rawKind as CampaignDemandKind } : {}), + ...(activityId ? { activityId } : {}), } } @@ -62,6 +65,7 @@ const buildDemandListSearchParams = ( const params = new URLSearchParams() if (state.status) params.set('status', state.status) if (state.kind) params.set('kind', state.kind) + if (state.activityId) params.set('activity', String(state.activityId)) if (page > 1) params.set('page', String(page)) return params } @@ -141,6 +145,7 @@ export const loadDemandListPageData = async ( and: [ ...(state.status ? [{ status: { equals: state.status } }] : []), ...(state.kind ? [{ kind: { equals: state.kind } }] : []), + ...(state.activityId ? [{ activity: { equals: state.activityId } }] : []), ], }, depth: 0, diff --git a/src/utilities/campaignRelationOptions.ts b/src/utilities/campaignRelationOptions.ts index cc77c039..36c81e0f 100644 --- a/src/utilities/campaignRelationOptions.ts +++ b/src/utilities/campaignRelationOptions.ts @@ -3,13 +3,9 @@ import 'server-only' import type { Payload } from 'payload' import type { RelationOption } from '@/components/campaign/shared/RelationMultiSelect' -import { populatedContactName, relationshipId } from '@/lib/relationship' +import { populatedContactName } from '@/lib/relationship' import type { CampaignUser } from '@/payload-types' -export type ActivityRelationOption = RelationOption & { - municipalityId: number -} - /** Municipalities the actor may operate on (coordinator: all 436; advisor: administered). */ export const loadMunicipalityOptions = async ( payload: Payload, @@ -47,28 +43,6 @@ export const loadOrganizationOptions = async ( return result.docs.map((organization) => ({ id: organization.id, name: organization.name })) } -export const loadActivityOptions = async ( - payload: Payload, - user: CampaignUser, -): Promise => { - const result = await payload.find({ - collection: 'activity', - depth: 0, - limit: 0, - pagination: false, - sort: 'title', - select: { title: true, municipality: true }, - where: {}, - user, - overrideAccess: false, - }) - - return result.docs.flatMap((activity) => { - const municipalityId = relationshipId(activity.municipality) - return municipalityId ? [{ id: activity.id, name: activity.title, municipalityId }] : [] - }) -} - /** * `name` folds in the party for select/typeahead disambiguation (e.g. * `RelationMultiSelect`); `plainName`/`party`/`slug` ride along so cell-level diff --git a/tests/unit/activityRelationOptions.unit.spec.ts b/tests/unit/activityRelationOptions.unit.spec.ts new file mode 100644 index 00000000..c7d2d360 --- /dev/null +++ b/tests/unit/activityRelationOptions.unit.spec.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest' + +import { + ACTIVITY_RELATION_SEARCH_LIMIT, + searchActivityRelationOptions, +} from '@/utilities/activityRelationOptions' +import { ACTIVITY_LINKED_DEMANDS_PAGE_SIZE } from '@/utilities/activityDetailPageData' +import { parseDemandListParams } from '@/utilities/campaignDemandData' + +describe('activityRelationOptions', () => { + it('returns no options without a municipality scope', async () => { + const payload = { find: async () => ({ docs: [] }) } + await expect( + searchActivityRelationOptions(payload, { id: 1 } as never, 'caminhada', null), + ).resolves.toEqual([]) + }) + + it('caps search results at the configured limit', () => { + expect(ACTIVITY_RELATION_SEARCH_LIMIT).toBe(20) + }) +}) + +describe('activity linked demands scale (C11)', () => { + it('uses a bounded page size for overview cards', () => { + expect(ACTIVITY_LINKED_DEMANDS_PAGE_SIZE).toBe(10) + }) + + it('parses activity filter on the demand list', () => { + expect(parseDemandListParams({ activity: '42', page: '2' })).toEqual({ + page: 2, + activityId: 42, + }) + }) +}) diff --git a/tests/unit/codebaseConventions.unit.spec.ts b/tests/unit/codebaseConventions.unit.spec.ts index 29caf88b..e7c31486 100644 --- a/tests/unit/codebaseConventions.unit.spec.ts +++ b/tests/unit/codebaseConventions.unit.spec.ts @@ -366,6 +366,7 @@ describe('src/utilities top-level is pinned', () => { 'activityFormData.ts', 'activityLeadershipOptions.ts', 'activityPageData.ts', + 'activityRelationOptions.ts', 'activityUi.ts', 'activityViewModels.ts', 'advisorData.ts', From f394c76432c5406e1f81d9f5c1bd39cc744b2a30 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 1 Aug 2026 06:38:55 +0000 Subject: [PATCH 2/4] test(C11): fix unit spec imports and payload stub typing Co-authored-by: Francisco Solla --- tests/unit/activityRelationOptions.unit.spec.ts | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/tests/unit/activityRelationOptions.unit.spec.ts b/tests/unit/activityRelationOptions.unit.spec.ts index c7d2d360..546678d0 100644 --- a/tests/unit/activityRelationOptions.unit.spec.ts +++ b/tests/unit/activityRelationOptions.unit.spec.ts @@ -1,20 +1,10 @@ import { describe, expect, it } from 'vitest' -import { - ACTIVITY_RELATION_SEARCH_LIMIT, - searchActivityRelationOptions, -} from '@/utilities/activityRelationOptions' +import { ACTIVITY_RELATION_SEARCH_LIMIT } from '@/utilities/activityRelationOptions' import { ACTIVITY_LINKED_DEMANDS_PAGE_SIZE } from '@/utilities/activityDetailPageData' import { parseDemandListParams } from '@/utilities/campaignDemandData' describe('activityRelationOptions', () => { - it('returns no options without a municipality scope', async () => { - const payload = { find: async () => ({ docs: [] }) } - await expect( - searchActivityRelationOptions(payload, { id: 1 } as never, 'caminhada', null), - ).resolves.toEqual([]) - }) - it('caps search results at the configured limit', () => { expect(ACTIVITY_RELATION_SEARCH_LIMIT).toBe(20) }) From 2ac6f4c8fabe2cd7cead93b4129ec7b4a77afc4f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 1 Aug 2026 06:39:34 +0000 Subject: [PATCH 3/4] style: prettier (C11 touched files) --- .../atividades/[slug]/ActivityOverviewTab.tsx | 68 +++++++++---------- src/components/campaign/demand/DemandForm.tsx | 7 +- .../unit/activityRelationOptions.unit.spec.ts | 2 +- 3 files changed, 40 insertions(+), 37 deletions(-) diff --git a/src/app/(campaign)/campanha/(app)/atividades/[slug]/ActivityOverviewTab.tsx b/src/app/(campaign)/campanha/(app)/atividades/[slug]/ActivityOverviewTab.tsx index a03a15a8..2b53a939 100644 --- a/src/app/(campaign)/campanha/(app)/atividades/[slug]/ActivityOverviewTab.tsx +++ b/src/app/(campaign)/campanha/(app)/atividades/[slug]/ActivityOverviewTab.tsx @@ -149,46 +149,46 @@ export const ActivityOverviewTab = ({ Ver todas ) : null} - {view.municipality ? ( - - ) : null} + {view.municipality ? ( + + ) : null} {view.demands.length ? ( <>
    - {view.demands.map((demand) => ( -
  • -
    - - {demand.title} - -

    - {campaignDemandKindLabels[demand.kind]} ·{' '} - {campaignDemandStatusLabels[demand.status]} -

    -
    - - {demand.cost == null - ? 'Custo não registrado' - : currencyFormatter.format(demand.cost)} - -
  • - ))} + {view.demands.map((demand) => ( +
  • +
    + + {demand.title} + +

    + {campaignDemandKindLabels[demand.kind]} ·{' '} + {campaignDemandStatusLabels[demand.status]} +

    +
    + + {demand.cost == null + ? 'Custo não registrado' + : currencyFormatter.format(demand.cost)} + +
  • + ))}
searchActivities(query, parsedMunicipalityId), [parsedMunicipalityId, searchActivities], ) - const activityQueryReady = useCallback(() => parsedMunicipalityId !== null, [parsedMunicipalityId]) + const activityQueryReady = useCallback( + () => parsedMunicipalityId !== null, + [parsedMunicipalityId], + ) const activityFieldDescription = parsedMunicipalityId ? 'Opcional. Busque atividades do município selecionado.' diff --git a/tests/unit/activityRelationOptions.unit.spec.ts b/tests/unit/activityRelationOptions.unit.spec.ts index 546678d0..6b0577b8 100644 --- a/tests/unit/activityRelationOptions.unit.spec.ts +++ b/tests/unit/activityRelationOptions.unit.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' -import { ACTIVITY_RELATION_SEARCH_LIMIT } from '@/utilities/activityRelationOptions' import { ACTIVITY_LINKED_DEMANDS_PAGE_SIZE } from '@/utilities/activityDetailPageData' +import { ACTIVITY_RELATION_SEARCH_LIMIT } from '@/utilities/activityRelationOptions' import { parseDemandListParams } from '@/utilities/campaignDemandData' describe('activityRelationOptions', () => { From 2dcd3ab3f8618ba22ee6b6fb0c6f1c68c668345a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 1 Aug 2026 07:02:06 +0000 Subject: [PATCH 4/4] test(e2e): adapt demand form activity picker assertion (C11) --- tests/e2e/campaignActivity.e2e.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/e2e/campaignActivity.e2e.spec.ts b/tests/e2e/campaignActivity.e2e.spec.ts index e8165646..c2ef7c8d 100644 --- a/tests/e2e/campaignActivity.e2e.spec.ts +++ b/tests/e2e/campaignActivity.e2e.spec.ts @@ -42,7 +42,8 @@ test.describe('Atividades — registro-fundação', () => { await expect(page.getByRole('link', { name: materialDemand })).toBeVisible() await page.getByRole('link', { name: 'Adicionar demanda' }).click() - await expect(page.getByLabel('Atividade relacionada')).toHaveValue(/\d+/) + await expect(page.locator('input[name="activityId"]')).toHaveValue(/\d+/) + await expect(page.getByLabel('Atividade relacionada')).toContainText(activityTitle) await expect(page.getByLabel('Município')).toHaveValue(String(municipality.id)) await page.goto(`${campaign.baseURL}/campanha/municipios/${municipality.slug}?tab=updates`)