diff --git a/src/app/(campaign)/campanha/(app)/dobradinhas/formActions.ts b/src/app/(campaign)/campanha/(app)/dobradinhas/formActions.ts
index f0b32122..aeafd5ac 100644
--- a/src/app/(campaign)/campanha/(app)/dobradinhas/formActions.ts
+++ b/src/app/(campaign)/campanha/(app)/dobradinhas/formActions.ts
@@ -43,7 +43,8 @@ const stateDeputyContactSafeMessages = [
const stateDeputyPartySafeMessages = [STATE_DEPUTY_STAFF_MESSAGE] as const
-const stateDeputyBallotNameSafeMessages = [STATE_DEPUTY_STAFF_MESSAGE] as const
+/** The party and ballotName inline cells share the same refusal vocabulary. */
+const stateDeputyInlineFieldSafeMessages = stateDeputyPartySafeMessages
/** Per-field Contact edit for B163 (lista + ficha). */
export const updateStateDeputyContactFormAction = async (
@@ -119,7 +120,7 @@ export const updateStateDeputyBallotNameFormAction = async (
})
return { message: 'Salvo.' }
},
- safeMessages: stateDeputyBallotNameSafeMessages,
+ safeMessages: stateDeputyInlineFieldSafeMessages,
genericMessage:
'Não foi possível salvar o nome de legenda. Verifique os dados e tente novamente.',
})
diff --git a/src/app/(campaign)/campanha/(app)/dobradinhas/page.tsx b/src/app/(campaign)/campanha/(app)/dobradinhas/page.tsx
index 4d13b525..4e48d4bb 100644
--- a/src/app/(campaign)/campanha/(app)/dobradinhas/page.tsx
+++ b/src/app/(campaign)/campanha/(app)/dobradinhas/page.tsx
@@ -13,6 +13,7 @@ import {
CampaignTransitionAnchor,
} from '@/components/campaign/shared/CampaignListPending'
import { CampaignListSheetProvider } from '@/components/campaign/shared/CampaignListSheetHost'
+import { CampaignNameSubline } from '@/components/campaign/shared/CampaignNameSubline'
import {
CampaignTable,
CampaignTableHead,
@@ -136,10 +137,12 @@ const stateDeputyColumns = (
/>
{/* C129 — the ballot name sits OUTSIDE the name cell's click-to-edit
region (sibling, not child), so clicking it never opens the name
- editor. */}
- {row.ballotName ? (
- {row.ballotName}
- ) : null}
+ editor. A legenda identical to the real name is skipped (same
+ policy as `peopleNameSubline`). */}
+
),
},
diff --git a/src/app/(campaign)/campanha/(app)/pessoas/page.tsx b/src/app/(campaign)/campanha/(app)/pessoas/page.tsx
index 712ae9fa..acf718fa 100644
--- a/src/app/(campaign)/campanha/(app)/pessoas/page.tsx
+++ b/src/app/(campaign)/campanha/(app)/pessoas/page.tsx
@@ -21,6 +21,7 @@ import {
CampaignTransitionAnchor,
} from '@/components/campaign/shared/CampaignListPending'
import { CampaignListSheetProvider } from '@/components/campaign/shared/CampaignListSheetHost'
+import { CampaignNameSubline } from '@/components/campaign/shared/CampaignNameSubline'
import {
CampaignTable,
CampaignTableHead,
@@ -193,9 +194,7 @@ const peopleColumns = ({
({row.party})
) : null}
- {subline ? (
- {subline}
- ) : null}
+
)
},
diff --git a/src/components/campaign/shared/CampaignNameSubline.tsx b/src/components/campaign/shared/CampaignNameSubline.tsx
new file mode 100644
index 00000000..85d692e0
--- /dev/null
+++ b/src/components/campaign/shared/CampaignNameSubline.tsx
@@ -0,0 +1,19 @@
+/**
+ * The "discreto sob o nome" secondary line (C129): the same muted small text
+ * under a name in every surface — people table, dobradinhas list and ficha.
+ * The position is the visual label; an optional `srLabel` names the line for
+ * screen readers (the muted/small styling does not exist in a linear read).
+ */
+export const CampaignNameSubline = ({
+ value,
+ srLabel,
+}: {
+ value: string | null | undefined
+ srLabel?: string
+}) =>
+ value ? (
+
+ {srLabel ? {srLabel} : null}
+ {value}
+
+ ) : null
diff --git a/src/components/campaign/stateDeputy/StateDeputyContactSection.tsx b/src/components/campaign/stateDeputy/StateDeputyContactSection.tsx
index a625d9af..8cc17c87 100644
--- a/src/components/campaign/stateDeputy/StateDeputyContactSection.tsx
+++ b/src/components/campaign/stateDeputy/StateDeputyContactSection.tsx
@@ -2,6 +2,7 @@
import { updateStateDeputyContactFormAction } from '@/app/(campaign)/campanha/(app)/dobradinhas/formActions'
import { CampaignInlineEditableCell } from '@/components/campaign/shared/CampaignInlineEditableCell'
+import { CampaignNameSubline } from '@/components/campaign/shared/CampaignNameSubline'
import { PhonesFieldEditor } from '@/components/campaign/shared/PhonesFieldEditor'
type StateDeputyContactSectionProps = {
@@ -39,8 +40,8 @@ export const StateDeputyContactSection = ({
formAction={updateStateDeputyContactFormAction}
href={`/campanha/dobradinhas/${stateDeputyId}`}
/>
- {ballotName ? (
- {ballotName}
+ {ballotName !== name ? (
+
) : null}
diff --git a/src/utilities/people/peopleData.ts b/src/utilities/people/peopleData.ts
index e26eb87f..a401a77b 100644
--- a/src/utilities/people/peopleData.ts
+++ b/src/utilities/people/peopleData.ts
@@ -78,6 +78,9 @@ export type PeopleRowViewModel = {
/**
* C129 — the Name-cell subline of `/campanha/pessoas`: the dobradinha's
* "nome de legenda" (ballot name), discreet under the real name, when present.
+ * A ballot name identical to the real name is redundant noise — it reads as a
+ * duplicated line, so it is skipped (a mesa registering the full name as urn
+ * name is plausible data entry).
*
* C130 extends this with the base fallback — the FINAL shared rule for the
* second line is `ballotName ?? city` (legenda overrides base), the two plans
@@ -86,8 +89,10 @@ export type PeopleRowViewModel = {
* here (and its unit spec), never markup. Until C130 lands, this branch keeps
* the accept "sem nome de legenda, nada muda na linha" — null when absent.
*/
-export const peopleNameSubline = (person: MergedPerson | PeopleRowViewModel): string | null =>
- person.ballotName
+export const peopleNameSubline = (
+ person: Pick,
+): string | null =>
+ person.ballotName && person.ballotName !== person.name ? person.ballotName : null
/** The merge intermediate carries every field the list/detail row needs. */
export type MergedPerson = Omit & {
diff --git a/src/utilities/people/personDetail.ts b/src/utilities/people/personDetail.ts
index 74ca19a0..aac9f225 100644
--- a/src/utilities/people/personDetail.ts
+++ b/src/utilities/people/personDetail.ts
@@ -91,7 +91,7 @@ export const loadPersonDetail = async (
depth: 1,
limit: 0,
pagination: false,
- select: { contact: true, slug: true, party: true, advisors: true },
+ select: { contact: true, slug: true, party: true, ballotName: true, advisors: true },
user,
overrideAccess: false,
}),
diff --git a/src/utilities/stateDeputyData.ts b/src/utilities/stateDeputyData.ts
index 13548b8c..697d5810 100644
--- a/src/utilities/stateDeputyData.ts
+++ b/src/utilities/stateDeputyData.ts
@@ -239,13 +239,13 @@ export type StateDeputySummary = {
name: string
slug: string
party: string | null
- /** C129 — the "nome de legenda" (ballot name), shown discreet under the name. */
- ballotName: string | null
}
export type StateDeputyDetailViewModel = StateDeputySummary & {
email: string | null
phone: string | null
+ /** C129 — the "nome de legenda" (ballot name), shown discreet under the name. */
+ ballotName: string | null
/** Every number of the ficha, order = priority (C112) — primary first. */
phones: string[]
notes: string | null
@@ -345,7 +345,7 @@ export const loadStateDeputySummaries = async (
limit: 0,
pagination: false,
sort: 'contact.name',
- select: { contact: true, slug: true, party: true, ballotName: true },
+ select: { contact: true, slug: true, party: true },
// Intentional admin bypass: id lookups for the home-search card, no
// actor-scoping possible or needed (B52 precedent).
overrideAccess: true,
@@ -359,7 +359,6 @@ export const loadStateDeputySummaries = async (
name: stateDeputyContactSummary(doc.contact).name,
slug: doc.slug,
party: doc.party ?? null,
- ballotName: doc.ballotName ?? null,
},
]),
)
diff --git a/tests/int/campaignStateDeputyBallotName.int.spec.ts b/tests/int/campaignStateDeputyBallotName.int.spec.ts
index 016925e8..5544b0dd 100644
--- a/tests/int/campaignStateDeputyBallotName.int.spec.ts
+++ b/tests/int/campaignStateDeputyBallotName.int.spec.ts
@@ -55,6 +55,15 @@ describe('updateStateDeputyBallotNameRecord (C129)', () => {
})
expect(updated.ballotName).toBeNull()
+
+ const reloaded = await payload.findByID({
+ collection: 'stateDeputy',
+ id: stateDeputy.id,
+ depth: 0,
+ select: { ballotName: true },
+ overrideAccess: true,
+ })
+ expect(reloaded.ballotName).toBeNull()
})
it('lets an advisor edit the ballot name of a dobradinha (staff scope, no carteira gate)', async () => {
@@ -68,6 +77,15 @@ describe('updateStateDeputyBallotNameRecord (C129)', () => {
})
expect(updated.ballotName).toBe('Apelido da Mesa')
+
+ const reloaded = await payload.findByID({
+ collection: 'stateDeputy',
+ id: stateDeputy.id,
+ depth: 0,
+ select: { ballotName: true },
+ overrideAccess: true,
+ })
+ expect(reloaded.ballotName).toBe('Apelido da Mesa')
})
it('refuses a leader (staff gate)', async () => {
diff --git a/tests/unit/peopleMerge.unit.spec.ts b/tests/unit/peopleMerge.unit.spec.ts
index ca7fd7dc..f3c6b684 100644
--- a/tests/unit/peopleMerge.unit.spec.ts
+++ b/tests/unit/peopleMerge.unit.spec.ts
@@ -341,6 +341,15 @@ describe('peopleNameSubline (C129)', () => {
const maria = byContact(mergePeopleSources(source), 100)
expect(peopleNameSubline(maria)).toBeNull()
})
+
+ it('skips a ballot name identical to the real name (redundant duplicate line)', () => {
+ const withIdenticalBallotName = mergePeopleSources({
+ ...source,
+ deputies: [{ ...source.deputies[0]!, ballotName: 'Ana Lima' }],
+ })
+ const ana = byContact(withIdenticalBallotName, 101)
+ expect(peopleNameSubline(ana)).toBeNull()
+ })
})
describe('peopleFilterFacetsFromRows', () => {