Add place field and expand pastors table

Collection:
- New `place` text field on Pastors

Frontend table:
- Show church name, place, state, and impacted people columns
- Column-visibility toggle ("Columnas" dropdown) to show/hide any column
- Replace "Editar" text button with an edit icon
- Create/edit modal now covers church, place, and impacted people too

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Julio Ruiz 2026-08-13 21:42:05 -05:00
parent db9eb8a304
commit 348bb3480a
5 changed files with 293 additions and 88 deletions

View File

@ -8,27 +8,206 @@ import { createPastor, updatePastor, uploadLetter } from './actions'
export type PastorRow = {
id: number
name: string
churchName: string | null
email: string | null
telephone: string[]
place: string | null
city: string | null
state: string | null
country: string | null
countryName: string | null
countryFlag: string
impactedPeople: number | null
inTelegram: boolean
notes: string | null
letterUrl: string | null
letterName: string | null
}
const dash = <span className="opacity-40"></span>
type ColumnKey =
| 'name'
| 'churchName'
| 'email'
| 'telephone'
| 'place'
| 'city'
| 'state'
| 'country'
| 'impactedPeople'
| 'inTelegram'
| 'letter'
export function PastorsTable({ rows }: { rows: PastorRow[] }) {
const [editing, setEditing] = useState<PastorRow | null>(null)
const [uploading, setUploading] = useState<PastorRow | null>(null)
const [creating, setCreating] = useState(false)
const columns: {
key: ColumnKey
label: string
align?: 'center' | 'right'
cell: (p: PastorRow) => React.ReactNode
}[] = [
{
key: 'name',
label: 'Nombre',
cell: (p) => <span className="font-medium whitespace-nowrap">{p.name}</span>,
},
{
key: 'churchName',
label: 'Iglesia',
cell: (p) => <span className="whitespace-nowrap">{p.churchName || dash}</span>,
},
{
key: 'email',
label: 'Correo',
cell: (p) =>
p.email ? (
<a href={`mailto:${p.email}`} className="link link-hover">
{p.email}
</a>
) : (
dash
),
},
{
key: 'telephone',
label: 'Teléfonos',
cell: (p) =>
p.telephone.length ? (
<div className="flex flex-col gap-0.5">
{p.telephone.map((t, i) => (
<a key={i} href={`tel:${t}`} className="link link-hover whitespace-nowrap">
{t}
</a>
))}
</div>
) : (
dash
),
},
{
key: 'place',
label: 'Lugar',
cell: (p) => <span className="whitespace-nowrap">{p.place || dash}</span>,
},
{
key: 'city',
label: 'Ciudad',
cell: (p) => <span className="whitespace-nowrap">{p.city || dash}</span>,
},
{
key: 'state',
label: 'Estado',
cell: (p) => <span className="whitespace-nowrap">{p.state || dash}</span>,
},
{
key: 'country',
label: 'País',
cell: (p) =>
p.countryName ? (
<span className="whitespace-nowrap">
{p.countryFlag} {p.countryName}
</span>
) : (
dash
),
},
{
key: 'impactedPeople',
label: 'Personas impactadas',
align: 'right',
cell: (p) =>
p.impactedPeople === null ? dash : p.impactedPeople.toLocaleString('es'),
},
{
key: 'inTelegram',
label: 'Telegram',
align: 'center',
cell: (p) =>
p.inTelegram ? (
<span className="badge badge-success badge-sm"></span>
) : (
<span className="badge badge-ghost badge-sm">No</span>
),
},
{
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>
),
},
]
// Column visibility — all shown by default; toggled via the "Columnas" menu.
const [visible, setVisible] = useState<Record<ColumnKey, boolean>>(
() => Object.fromEntries(columns.map((c) => [c.key, true])) as Record<ColumnKey, boolean>,
)
const shown = columns.filter((c) => visible[c.key])
const alignClass = (a?: 'center' | 'right') =>
a === 'center' ? 'text-center' : a === 'right' ? 'text-right' : ''
return (
<>
<div className="flex justify-end mb-4">
<div className="flex justify-end gap-2 mb-4">
<div className="dropdown dropdown-end">
<button type="button" tabIndex={0} className="btn btn-sm btn-outline">
Columnas
<svg
xmlns="http://www.w3.org/2000/svg"
className="w-3.5 h-3.5"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M5.23 7.21a.75.75 0 011.06.02L10 11.06l3.71-3.83a.75.75 0 111.08 1.04l-4.25 4.39a.75.75 0 01-1.08 0L5.21 8.27a.75.75 0 01.02-1.06z"
clipRule="evenodd"
/>
</svg>
</button>
<ul
tabIndex={0}
className="dropdown-content menu bg-base-100 rounded-box z-10 w-56 p-2 shadow-lg border border-base-300 max-h-96 overflow-y-auto flex-nowrap"
>
{columns.map((c) => (
<li key={c.key}>
<label className="label cursor-pointer justify-start gap-3 py-1.5">
<input
type="checkbox"
className="checkbox checkbox-sm"
checked={visible[c.key]}
onChange={(e) =>
setVisible((prev) => ({ ...prev, [c.key]: e.target.checked }))
}
/>
<span className="label-text">{c.label}</span>
</label>
</li>
))}
</ul>
</div>
<button type="button" className="btn btn-primary btn-sm" onClick={() => setCreating(true)}>
+ Crear pastor
</button>
@ -43,96 +222,48 @@ export function PastorsTable({ rows }: { rows: PastorRow[] }) {
</div>
) : (
<div className="overflow-x-auto rounded-box border border-base-300 bg-base-100">
<table className="table table-zebra table-pin-rows">
<thead>
<tr>
<th>Nombre</th>
<th>Correo</th>
<th>Teléfonos</th>
<th>Ciudad</th>
<th>País</th>
<th className="text-center">Telegram</th>
<th>Carta</th>
<th className="text-right">Acciones</th>
</tr>
</thead>
<tbody>
{rows.map((p) => (
<tr key={p.id}>
<td className="font-medium whitespace-nowrap">{p.name}</td>
<td>
{p.email ? (
<a href={`mailto:${p.email}`} className="link link-hover">
{p.email}
</a>
) : (
<span className="opacity-40"></span>
)}
</td>
<td>
{p.telephone.length ? (
<div className="flex flex-col gap-0.5">
{p.telephone.map((t, i) => (
<a key={i} href={`tel:${t}`} className="link link-hover whitespace-nowrap">
{t}
</a>
))}
</div>
) : (
<span className="opacity-40"></span>
)}
</td>
<td className="whitespace-nowrap">{p.city || <span className="opacity-40"></span>}</td>
<td className="whitespace-nowrap">
{p.countryName ? (
<span>
{p.countryFlag} {p.countryName}
</span>
) : (
<span className="opacity-40"></span>
)}
</td>
<td className="text-center">
{p.inTelegram ? (
<span className="badge badge-success badge-sm"></span>
) : (
<span className="badge badge-ghost badge-sm">No</span>
)}
</td>
<td>
{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>
) : (
<table className="table table-zebra table-pin-rows">
<thead>
<tr>
{shown.map((c) => (
<th key={c.key} className={alignClass(c.align)}>
{c.label}
</th>
))}
<th className="text-right">Acciones</th>
</tr>
</thead>
<tbody>
{rows.map((p) => (
<tr key={p.id}>
{shown.map((c) => (
<td key={c.key} className={alignClass(c.align)}>
{c.cell(p)}
</td>
))}
<td className="text-right">
<button
type="button"
className="btn btn-xs btn-outline"
onClick={() => setUploading(p)}
className="btn btn-xs btn-square btn-ghost"
onClick={() => setEditing(p)}
aria-label={`Editar ${p.name}`}
title="Editar"
>
Subir
<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 className="text-right">
<button
type="button"
className="btn btn-xs btn-ghost"
onClick={() => setEditing(p)}
>
Editar
</button>
</td>
</tr>
))}
</tbody>
</table>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
@ -158,13 +289,18 @@ function PastorModal({ pastor, onClose }: { pastor: PastorRow | null; onClose: (
const [error, setError] = useState<string | null>(null)
const [name, setName] = useState(pastor?.name ?? '')
const [churchName, setChurchName] = useState(pastor?.churchName ?? '')
const [email, setEmail] = useState(pastor?.email ?? '')
const [phones, setPhones] = useState<string[]>(
pastor?.telephone.length ? pastor.telephone : [''],
)
const [place, setPlace] = useState(pastor?.place ?? '')
const [city, setCity] = useState(pastor?.city ?? '')
const [state, setState] = useState(pastor?.state ?? '')
const [country, setCountry] = useState(pastor?.country ?? '')
const [impactedPeople, setImpactedPeople] = useState(
pastor?.impactedPeople != null ? String(pastor.impactedPeople) : '',
)
const [inTelegram, setInTelegram] = useState(pastor?.inTelegram ?? false)
const [notes, setNotes] = useState(pastor?.notes ?? '')
@ -177,7 +313,19 @@ function PastorModal({ pastor, onClose }: { pastor: PastorRow | null; onClose: (
e.preventDefault()
setError(null)
startTransition(async () => {
const input = { name, email, telephone: phones, city, state, country, inTelegram, notes }
const input = {
name,
churchName,
email,
telephone: phones,
place,
city,
state,
country,
impactedPeople: impactedPeople.trim() === '' ? null : Number(impactedPeople),
inTelegram,
notes,
}
const res = isCreate ? await createPastor(input) : await updatePastor(pastor.id, input)
if (res?.ok) {
router.refresh()
@ -213,6 +361,17 @@ function PastorModal({ pastor, onClose }: { pastor: PastorRow | null; onClose: (
required
/>
<label htmlFor="f-church" className="text-sm font-medium sm:text-right sm:pr-1">
Iglesia
</label>
<input
id="f-church"
className="input input-bordered w-full"
value={churchName}
placeholder="Nombre de la iglesia"
onChange={(e) => setChurchName(e.target.value)}
/>
<label htmlFor="f-email" className="text-sm font-medium sm:text-right sm:pr-1">
Correo
</label>
@ -252,6 +411,16 @@ function PastorModal({ pastor, onClose }: { pastor: PastorRow | null; onClose: (
</button>
</div>
<label htmlFor="f-place" className="text-sm font-medium sm:text-right sm:pr-1">
Lugar
</label>
<input
id="f-place"
className="input input-bordered w-full"
value={place}
onChange={(e) => setPlace(e.target.value)}
/>
<label htmlFor="f-city" className="text-sm font-medium sm:text-right sm:pr-1">
Ciudad
</label>
@ -287,6 +456,18 @@ function PastorModal({ pastor, onClose }: { pastor: PastorRow | null; onClose: (
<span className="text-xs text-base-content/50">Código ISO de 2 letras</span>
</div>
<label htmlFor="f-impacted" className="text-sm font-medium sm:text-right sm:pr-1">
Personas impactadas
</label>
<input
id="f-impacted"
type="number"
min={0}
className="input input-bordered w-40"
value={impactedPeople}
onChange={(e) => setImpactedPeople(e.target.value)}
/>
<span className="text-sm font-medium sm:text-right sm:pr-1">En Telegram</span>
<input
type="checkbox"

View File

@ -7,11 +7,14 @@ import config from '@/payload.config'
export type PastorUpdateInput = {
name: string
churchName?: string | null
email?: string | null
telephone?: string[]
place?: string | null
city?: string | null
state?: string | null
country?: string | null
impactedPeople?: number | null
inTelegram?: boolean
notes?: string | null
}
@ -30,9 +33,15 @@ export async function createPastor(data: PastorUpdateInput) {
name: data.name.trim(),
email: data.email || undefined,
telephone: (data.telephone ?? []).map((t) => t.trim()).filter(Boolean),
churchName: data.churchName || undefined,
place: data.place || undefined,
city: data.city || undefined,
state: data.state || undefined,
country: data.country ? data.country.trim().toUpperCase() : undefined,
impactedPeople:
data.impactedPeople === null || data.impactedPeople === undefined
? undefined
: Number(data.impactedPeople),
inTelegram: Boolean(data.inTelegram),
notes: data.notes || undefined,
},
@ -53,9 +62,15 @@ export async function updatePastor(id: number, data: PastorUpdateInput) {
name: data.name,
email: data.email || undefined,
telephone: (data.telephone ?? []).map((t) => t.trim()).filter(Boolean),
churchName: data.churchName || undefined,
place: data.place || undefined,
city: data.city || undefined,
state: data.state || undefined,
country: data.country ? data.country.trim().toUpperCase() : undefined,
impactedPeople:
data.impactedPeople === null || data.impactedPeople === undefined
? undefined
: Number(data.impactedPeople),
inTelegram: Boolean(data.inTelegram),
notes: data.notes || undefined,
},

View File

@ -66,13 +66,16 @@ export default async function HomePage({
return {
id: p.id,
name: p.name,
churchName: p.churchName ?? null,
email: p.email ?? null,
telephone: Array.isArray(p.telephone) ? p.telephone.filter(Boolean) : [],
place: p.place ?? null,
city: p.city ?? null,
state: p.state ?? null,
country: p.country ?? null,
countryName: localizedCountry(p.country),
countryFlag: flag(p.country),
impactedPeople: p.impactedPeople ?? null,
inTelegram: Boolean(p.inTelegram),
notes: p.notes ?? null,
letterUrl: letter?.url ?? null,

View File

@ -20,6 +20,10 @@ export const Pastors: CollectionConfig = {
label: 'Church Name',
type: 'text',
},
{
name: 'place',
type: 'text',
},
{
name: 'city',
type: 'text',

View File

@ -224,6 +224,7 @@ export interface Pastor {
id: number;
name: string;
churchName?: string | null;
place?: string | null;
city?: string | null;
state?: string | null;
/**
@ -428,6 +429,7 @@ export interface TagsSelect<T extends boolean = true> {
export interface PastorsSelect<T extends boolean = true> {
name?: T;
churchName?: T;
place?: T;
city?: T;
state?: T;
country?: T;