Merge row edit/delete into a dropdown menu

- New deletePastor server action (also removes the pastor's letter to avoid
  orphaned uploads)
- Replace the per-row edit icon with a "⋮" actions dropdown containing
  Editar and Eliminar (Eliminar confirms before deleting)
- Uses the popover API + CSS anchor positioning so the menu isn't clipped by
  the table's horizontal-scroll container

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Julio Ruiz 2026-08-14 01:25:49 -05:00
parent ba08fc087b
commit b840172946
2 changed files with 117 additions and 18 deletions

View File

@ -3,7 +3,7 @@
import React, { useRef, useState, useTransition } from 'react' import React, { useRef, useState, useTransition } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { createPastor, deleteLetter, updatePastor, uploadLetter } from './actions' import { createPastor, deleteLetter, deletePastor, updatePastor, uploadLetter } from './actions'
export type PastorRow = { export type PastorRow = {
id: number id: number
@ -243,23 +243,7 @@ export function PastorsTable({ rows }: { rows: PastorRow[] }) {
</td> </td>
))} ))}
<td className="text-right"> <td className="text-right">
<button <RowActions pastor={p} onEdit={() => setEditing(p)} />
type="button"
className="btn btn-xs btn-square btn-ghost"
onClick={() => setEditing(p)}
aria-label={`Editar ${p.name}`}
title="Editar"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="w-4 h-4"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
</svg>
</button>
</td> </td>
</tr> </tr>
))} ))}
@ -283,6 +267,96 @@ export function PastorsTable({ rows }: { rows: PastorRow[] }) {
) )
} }
function RowActions({ pastor, onEdit }: { pastor: PastorRow; onEdit: () => void }) {
const router = useRouter()
const [pending, startTransition] = useTransition()
const menuId = `row-menu-${pastor.id}`
const anchor = `--row-anchor-${pastor.id}`
const close = () => document.getElementById(menuId)?.hidePopover?.()
const onDelete = () => {
close()
if (!window.confirm(`¿Eliminar a ${pastor.name}? Esta acción no se puede deshacer.`)) return
startTransition(async () => {
await deletePastor(pastor.id)
router.refresh()
})
}
return (
<>
<button
type="button"
className="btn btn-xs btn-square btn-ghost"
popoverTarget={menuId}
style={{ anchorName: anchor } as React.CSSProperties}
aria-label={`Acciones para ${pastor.name}`}
disabled={pending}
>
{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 d="M10 6a2 2 0 110-4 2 2 0 010 4zm0 6a2 2 0 110-4 2 2 0 010 4zm0 6a2 2 0 110-4 2 2 0 010 4z" />
</svg>
)}
</button>
<ul
id={menuId}
popover="auto"
style={{ positionAnchor: anchor } as React.CSSProperties}
className="dropdown dropdown-end menu w-40 rounded-box bg-base-100 p-2 shadow-lg border border-base-300"
>
<li>
<button
type="button"
onClick={() => {
close()
onEdit()
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="w-4 h-4"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
</svg>
Editar
</button>
</li>
<li>
<button type="button" className="text-error" onClick={onDelete}>
<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>
Eliminar
</button>
</li>
</ul>
</>
)
}
function LetterActions({ pastor, onUpload }: { pastor: PastorRow; onUpload: () => void }) { function LetterActions({ pastor, onUpload }: { pastor: PastorRow; onUpload: () => void }) {
const router = useRouter() const router = useRouter()
const [pending, startTransition] = useTransition() const [pending, startTransition] = useTransition()

View File

@ -88,6 +88,31 @@ export async function updatePastor(id: number, data: PastorUpdateInput) {
return { ok: true } return { ok: true }
} }
/**
* Elimina un pastor por completo. También borra su carta asociada (si existe)
* para no dejar archivos huérfanos.
*/
export async function deletePastor(id: number) {
const payload = await getPayload({ config: await config })
const pastor = await payload.findByID({ collection: 'pastors', id, depth: 0 })
const letterId =
pastor.letter && typeof pastor.letter === 'object' ? pastor.letter.id : pastor.letter
await payload.delete({ collection: 'pastors', id })
if (letterId) {
try {
await payload.delete({ collection: 'pastor-letters', id: letterId })
} catch {
// The upload doc may already be gone; deleting the pastor is enough.
}
}
revalidatePath('/')
return { ok: true }
}
/** /**
* Elimina la carta asociada a un pastor: desvincula el campo `letter` y borra * 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. * el documento de subida, de modo que se pueda subir uno nuevo en su lugar.