mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:43:06 +00:00
C11: picker remoto de atividades na demanda + paginação de vínculos
- 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 <fsolla@users.noreply.github.com>
This commit is contained in:
parent
e99bbb5388
commit
8372cec91d
12 changed files with 323 additions and 122 deletions
|
|
@ -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<ReturnType<typeof getActivityDetailPageData>>
|
||||
|
||||
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 = ({
|
|||
<CardTitle>Demandas vinculadas</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Custo estimado: {currencyFormatter.format(view.demandCostTotal)}
|
||||
{view.totalDocs > 0 ? (
|
||||
<>
|
||||
{' '}
|
||||
· {view.totalDocs} {view.totalDocs === 1 ? 'demanda' : 'demandas'}
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap gap-2">
|
||||
{view.totalDocs > view.demands.length ? (
|
||||
<Button asChild variant="ghost" className="min-h-11">
|
||||
<Link href={`/campanha/demandas?activity=${view.id}`}>Ver todas</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
{view.municipality ? (
|
||||
<Button asChild variant="outline" className="min-h-11 shrink-0">
|
||||
<Link
|
||||
|
|
@ -140,10 +159,12 @@ export const ActivityOverviewTab = ({
|
|||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
{view.demands.length ? (
|
||||
<ul role="list" className="divide-y">
|
||||
<>
|
||||
<ul role="list" className="divide-y">
|
||||
{view.demands.map((demand) => (
|
||||
<li
|
||||
key={demand.id}
|
||||
|
|
@ -168,7 +189,13 @@ export const ActivityOverviewTab = ({
|
|||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</ul>
|
||||
<CampaignListPagination
|
||||
page={view.page}
|
||||
totalPages={view.totalPages}
|
||||
hrefForPage={(page) => hrefForLinkedDemandsPage(view.slug, page)}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Nenhuma demanda vinculada a esta atividade.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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<AsyncSearchOption[]> => {
|
||||
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 }))
|
||||
}
|
||||
|
|
@ -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
|
|||
|
||||
<DemandForm
|
||||
municipalityOptions={municipalityOptions}
|
||||
activityOptions={activityOptions}
|
||||
initialActivity={initialActivity}
|
||||
initialMunicipalityId={initialMunicipalityId}
|
||||
initialActivityId={requestedActivity?.id}
|
||||
searchActivities={searchDemandActivityOptions}
|
||||
formAction={createDemandFormAction}
|
||||
/>
|
||||
</CampaignPageShell>
|
||||
|
|
|
|||
|
|
@ -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<Array<{ id: number; label: string }>>
|
||||
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 (
|
||||
<form action={submitAction} className="flex max-w-2xl flex-col gap-4">
|
||||
|
|
@ -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 = ({
|
|||
</div>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="demand-activity">Atividade relacionada</FieldLabel>
|
||||
<NativeSelect
|
||||
id="demand-activity"
|
||||
name="activityId"
|
||||
value={activityId}
|
||||
onChange={(event) => {
|
||||
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"
|
||||
>
|
||||
<NativeSelectOption value="">Nenhuma atividade</NativeSelectOption>
|
||||
{visibleActivities.map((option) => (
|
||||
<NativeSelectOption key={option.id} value={String(option.id)}>
|
||||
{option.name}
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
<FieldDescription>
|
||||
Opcional. Ao escolher uma atividade, o município correspondente é preenchido
|
||||
automaticamente.
|
||||
</FieldDescription>
|
||||
{parsedMunicipalityId ? (
|
||||
<AsyncSearchCombobox
|
||||
name="activityId"
|
||||
label="Atividade relacionada"
|
||||
value={activity}
|
||||
emptyOptionLabel="Nenhuma atividade"
|
||||
dialogDescription="Busque atividades do município selecionado por título."
|
||||
isQueryReady={activityQueryReady}
|
||||
queryTooShortMessage="Selecione o município para buscar atividades."
|
||||
search={searchActivitiesForMunicipality}
|
||||
onChange={setActivity}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled
|
||||
className="min-h-11 w-full justify-between font-normal opacity-70"
|
||||
>
|
||||
Selecione o município primeiro…
|
||||
</Button>
|
||||
)}
|
||||
<FieldDescription>{activityFieldDescription}</FieldDescription>
|
||||
{fieldError(state.fieldErrors, 'activity') ? (
|
||||
<FieldError>{fieldError(state.fieldErrors, 'activity')}</FieldError>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -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<Payload, 'find'>,
|
||||
user: CampaignUser,
|
||||
|
|
@ -75,17 +93,63 @@ const loadActivityMunicipalitySummaryById = async (
|
|||
: null
|
||||
}
|
||||
|
||||
const loadLinkedDemandsPage = async (
|
||||
payload: Pick<Payload, 'find'>,
|
||||
user: CampaignUser,
|
||||
activityId: number,
|
||||
page: number,
|
||||
): Promise<ActivityLinkedDemandsPage> => {
|
||||
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<Payload, 'find'>,
|
||||
user: CampaignUser,
|
||||
context: AccessibleActivityContext,
|
||||
activeTab: ActivityDetailTab,
|
||||
): Promise<
|
||||
ActivityDetailViewModel & {
|
||||
demands: ActivityDemandSummary[]
|
||||
demandCostTotal: number
|
||||
}
|
||||
> => {
|
||||
linkedDemandsPage = 1,
|
||||
): Promise<ActivityDetailViewModel & ActivityLinkedDemandsPage> => {
|
||||
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<number, string>())
|
||||
const demandSummariesPromise: Promise<ActivityDemandSummary[]> =
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ const helpers = createDetailTabHelpers<ActivityDetailTab>({
|
|||
tabs: activityDetailTabs,
|
||||
defaultTab: 'overview',
|
||||
tabQueryKeys: {
|
||||
overview: [],
|
||||
overview: ['demandsPage'],
|
||||
tasks: [],
|
||||
updates: ['newUpdate'],
|
||||
},
|
||||
|
|
|
|||
85
src/utilities/activityRelationOptions.ts
Normal file
85
src/utilities/activityRelationOptions.ts
Normal file
|
|
@ -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<Payload, 'find'>,
|
||||
user: CampaignUser,
|
||||
activityId: number,
|
||||
): Promise<ActivityRelationOption | null> => {
|
||||
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<Payload, 'find'>,
|
||||
user: CampaignUser,
|
||||
query: string,
|
||||
municipalityId: number | null,
|
||||
): Promise<ActivityRelationOption[]> => {
|
||||
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)
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<ActivityRelationOption[]> => {
|
||||
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
|
||||
|
|
|
|||
34
tests/unit/activityRelationOptions.unit.spec.ts
Normal file
34
tests/unit/activityRelationOptions.unit.spec.ts
Normal file
|
|
@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Reference in a new issue