Add impacted-people total and letter deletion

- Home header shows a running sum of impacted people across all matching
  records (respects the current search filter), next to the record count
- Letters can be deleted from the table (trash button next to Download),
  which unlinks the pastor and removes the upload doc so a new file can be
  uploaded in its place

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Julio Ruiz 2026-08-13 22:53:03 -05:00
parent 348bb3480a
commit efeee14752
3 changed files with 104 additions and 21 deletions

View File

@ -3,7 +3,7 @@
import React, { useRef, useState, useTransition } from 'react'
import { useRouter } from 'next/navigation'
import { createPastor, updatePastor, uploadLetter } from './actions'
import { createPastor, deleteLetter, updatePastor, uploadLetter } from './actions'
export type PastorRow = {
id: number
@ -136,26 +136,7 @@ export function PastorsTable({ rows }: { rows: PastorRow[] }) {
{
key: 'letter',
label: 'Carta',
cell: (p) =>
p.letterUrl ? (
<a
href={p.letterUrl}
target="_blank"
rel="noopener noreferrer"
download={p.letterName ?? undefined}
className="btn btn-xs btn-outline btn-success"
>
Descargar
</a>
) : (
<button
type="button"
className="btn btn-xs btn-outline"
onClick={() => setUploading(p)}
>
Subir
</button>
),
cell: (p) => <LetterActions pastor={p} onUpload={() => setUploading(p)} />,
},
]
@ -282,6 +263,67 @@ export function PastorsTable({ rows }: { rows: PastorRow[] }) {
)
}
function LetterActions({ pastor, onUpload }: { pastor: PastorRow; onUpload: () => void }) {
const router = useRouter()
const [pending, startTransition] = useTransition()
if (!pastor.letterUrl) {
return (
<button type="button" className="btn btn-xs btn-outline" onClick={onUpload}>
Subir
</button>
)
}
const onDelete = () => {
if (!window.confirm('¿Eliminar la carta? Podrás subir una nueva después.')) return
startTransition(async () => {
await deleteLetter(pastor.id)
router.refresh()
})
}
return (
<div className="flex items-center gap-1">
<a
href={pastor.letterUrl}
target="_blank"
rel="noopener noreferrer"
download={pastor.letterName ?? undefined}
className="btn btn-xs btn-outline btn-success"
>
Descargar
</a>
<button
type="button"
className="btn btn-xs btn-square btn-ghost text-error"
onClick={onDelete}
disabled={pending}
aria-label="Eliminar carta"
title="Eliminar carta"
>
{pending ? (
<span className="loading loading-spinner loading-xs" />
) : (
<svg
xmlns="http://www.w3.org/2000/svg"
className="w-4 h-4"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z"
clipRule="evenodd"
/>
</svg>
)}
</button>
</div>
)
}
function PastorModal({ pastor, onClose }: { pastor: PastorRow | null; onClose: () => void }) {
const router = useRouter()
const isCreate = pastor === null

View File

@ -80,6 +80,31 @@ export async function updatePastor(id: number, data: PastorUpdateInput) {
return { ok: true }
}
/**
* Elimina la carta asociada a un pastor: desvincula el campo `letter` y borra
* el documento de subida, de modo que se pueda subir uno nuevo en su lugar.
*/
export async function deleteLetter(pastorId: number) {
const payload = await getPayload({ config: await config })
const pastor = await payload.findByID({ collection: 'pastors', id: pastorId, depth: 0 })
const letterId =
pastor.letter && typeof pastor.letter === 'object' ? pastor.letter.id : pastor.letter
await payload.update({ collection: 'pastors', id: pastorId, data: { letter: null } })
if (letterId) {
try {
await payload.delete({ collection: 'pastor-letters', id: letterId })
} catch {
// The upload doc may already be gone; unlinking the pastor is enough.
}
}
revalidatePath('/')
return { ok: true }
}
/**
* Sube una carta (PDF o Word) y la asocia a un pastor. El archivo se guarda en
* la colección de subidas `pastor-letters`, que valida los tipos de archivo

View File

@ -61,6 +61,17 @@ export default async function HomePage({
limit: PAGE_SIZE,
})
// Running total of impacted people across ALL matching records (not just the
// current page). Only the number field is selected to keep this light.
const impactedAgg = await payload.find({
collection: 'pastors',
where,
pagination: false,
depth: 0,
select: { impactedPeople: true },
})
const totalImpacted = impactedAgg.docs.reduce((sum, d) => sum + (d.impactedPeople ?? 0), 0)
const rows: PastorRow[] = result.docs.map((p) => {
const letter = p.letter && typeof p.letter === 'object' ? p.letter : null
return {
@ -90,6 +101,11 @@ export default async function HomePage({
<h1 className="text-3xl font-bold">Pastores</h1>
<p className="text-base-content/60 text-sm mt-1">
{result.totalDocs.toLocaleString('es')} registros
<span className="mx-2 opacity-40">·</span>
<span className="font-medium text-base-content/80">
{totalImpacted.toLocaleString('es')}
</span>{' '}
personas impactadas
</p>
</div>
<form method="get" className="join">