383 lines
13 KiB
TypeScript
383 lines
13 KiB
TypeScript
'use client'
|
|
|
|
import React, { useRef, useState, useTransition } from 'react'
|
|
import { useRouter } from 'next/navigation'
|
|
|
|
import { createPastor, updatePastor, uploadLetter } from './actions'
|
|
|
|
export type PastorRow = {
|
|
id: number
|
|
name: string
|
|
email: string | null
|
|
telephone: string[]
|
|
city: string | null
|
|
state: string | null
|
|
country: string | null
|
|
countryName: string | null
|
|
countryFlag: string
|
|
inTelegram: boolean
|
|
notes: string | null
|
|
letterUrl: string | null
|
|
letterName: string | null
|
|
}
|
|
|
|
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)
|
|
|
|
return (
|
|
<>
|
|
<div className="flex justify-end mb-4">
|
|
<button type="button" className="btn btn-primary btn-sm" onClick={() => setCreating(true)}>
|
|
+ Crear pastor
|
|
</button>
|
|
</div>
|
|
|
|
{rows.length === 0 ? (
|
|
<div className="card bg-base-100 shadow-sm">
|
|
<div className="card-body items-center text-center">
|
|
<h3 className="card-title">No se encontraron pastores</h3>
|
|
<p className="text-base-content/60">Intenta con otro término de búsqueda.</p>
|
|
</div>
|
|
</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">Sí</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>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
className="btn btn-xs btn-outline"
|
|
onClick={() => setUploading(p)}
|
|
>
|
|
Subir
|
|
</button>
|
|
)}
|
|
</td>
|
|
<td className="text-right">
|
|
<button
|
|
type="button"
|
|
className="btn btn-xs btn-ghost"
|
|
onClick={() => setEditing(p)}
|
|
>
|
|
Editar
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
|
|
{creating && <PastorModal key="create" pastor={null} onClose={() => setCreating(false)} />}
|
|
{editing && (
|
|
<PastorModal key={`edit-${editing.id}`} pastor={editing} onClose={() => setEditing(null)} />
|
|
)}
|
|
{uploading && (
|
|
<UploadModal
|
|
key={`upload-${uploading.id}`}
|
|
pastor={uploading}
|
|
onClose={() => setUploading(null)}
|
|
/>
|
|
)}
|
|
</>
|
|
)
|
|
}
|
|
|
|
function PastorModal({ pastor, onClose }: { pastor: PastorRow | null; onClose: () => void }) {
|
|
const router = useRouter()
|
|
const isCreate = pastor === null
|
|
const [pending, startTransition] = useTransition()
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
const [name, setName] = useState(pastor?.name ?? '')
|
|
const [email, setEmail] = useState(pastor?.email ?? '')
|
|
const [phones, setPhones] = useState<string[]>(
|
|
pastor?.telephone.length ? pastor.telephone : [''],
|
|
)
|
|
const [city, setCity] = useState(pastor?.city ?? '')
|
|
const [state, setState] = useState(pastor?.state ?? '')
|
|
const [country, setCountry] = useState(pastor?.country ?? '')
|
|
const [inTelegram, setInTelegram] = useState(pastor?.inTelegram ?? false)
|
|
const [notes, setNotes] = useState(pastor?.notes ?? '')
|
|
|
|
const setPhone = (i: number, v: string) =>
|
|
setPhones((prev) => prev.map((p, idx) => (idx === i ? v : p)))
|
|
const addPhone = () => setPhones((prev) => [...prev, ''])
|
|
const removePhone = (i: number) => setPhones((prev) => prev.filter((_, idx) => idx !== i))
|
|
|
|
const onSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault()
|
|
setError(null)
|
|
startTransition(async () => {
|
|
const input = { name, email, telephone: phones, city, state, country, inTelegram, notes }
|
|
const res = isCreate ? await createPastor(input) : await updatePastor(pastor.id, input)
|
|
if (res?.ok) {
|
|
router.refresh()
|
|
onClose()
|
|
} else {
|
|
setError(
|
|
('error' in res && res.error) ||
|
|
(isCreate ? 'No se pudo crear el pastor.' : 'No se pudieron guardar los cambios.'),
|
|
)
|
|
}
|
|
})
|
|
}
|
|
|
|
return (
|
|
<dialog className="modal modal-open">
|
|
<div className="modal-box max-w-xl">
|
|
<h3 className="text-lg font-bold pb-3 mb-5 border-b border-base-300">
|
|
{isCreate ? 'Crear pastor' : 'Editar pastor'}
|
|
</h3>
|
|
<form
|
|
onSubmit={onSubmit}
|
|
className="grid grid-cols-1 sm:grid-cols-[7.5rem_1fr] sm:items-center gap-x-4 gap-y-4"
|
|
>
|
|
<label htmlFor="f-name" className="text-sm font-medium sm:text-right sm:pr-1">
|
|
Nombre
|
|
</label>
|
|
<input
|
|
id="f-name"
|
|
className="input input-bordered w-full"
|
|
value={name}
|
|
placeholder="Nombre completo"
|
|
onChange={(e) => setName(e.target.value)}
|
|
required
|
|
/>
|
|
|
|
<label htmlFor="f-email" className="text-sm font-medium sm:text-right sm:pr-1">
|
|
Correo
|
|
</label>
|
|
<input
|
|
id="f-email"
|
|
type="email"
|
|
className="input input-bordered w-full"
|
|
value={email}
|
|
placeholder="correo@ejemplo.com"
|
|
onChange={(e) => setEmail(e.target.value)}
|
|
/>
|
|
|
|
<span className="text-sm font-medium sm:text-right sm:pr-1 sm:self-start sm:pt-3">
|
|
Teléfonos
|
|
</span>
|
|
<div className="flex flex-col gap-2">
|
|
{phones.map((p, i) => (
|
|
<div key={i} className="join w-full">
|
|
<input
|
|
className="input input-bordered join-item w-full"
|
|
value={p}
|
|
placeholder="+1 555 123 4567"
|
|
onChange={(e) => setPhone(i, e.target.value)}
|
|
/>
|
|
<button
|
|
type="button"
|
|
className="btn btn-outline join-item"
|
|
onClick={() => removePhone(i)}
|
|
aria-label="Eliminar número"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
))}
|
|
<button type="button" className="btn btn-sm btn-ghost self-start" onClick={addPhone}>
|
|
+ Agregar número
|
|
</button>
|
|
</div>
|
|
|
|
<label htmlFor="f-city" className="text-sm font-medium sm:text-right sm:pr-1">
|
|
Ciudad
|
|
</label>
|
|
<input
|
|
id="f-city"
|
|
className="input input-bordered w-full"
|
|
value={city}
|
|
onChange={(e) => setCity(e.target.value)}
|
|
/>
|
|
|
|
<label htmlFor="f-state" className="text-sm font-medium sm:text-right sm:pr-1">
|
|
Estado
|
|
</label>
|
|
<input
|
|
id="f-state"
|
|
className="input input-bordered w-full"
|
|
value={state}
|
|
onChange={(e) => setState(e.target.value)}
|
|
/>
|
|
|
|
<label htmlFor="f-country" className="text-sm font-medium sm:text-right sm:pr-1">
|
|
País
|
|
</label>
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
id="f-country"
|
|
className="input input-bordered w-24 uppercase"
|
|
value={country}
|
|
maxLength={2}
|
|
placeholder="MX"
|
|
onChange={(e) => setCountry(e.target.value)}
|
|
/>
|
|
<span className="text-xs text-base-content/50">Código ISO de 2 letras</span>
|
|
</div>
|
|
|
|
<span className="text-sm font-medium sm:text-right sm:pr-1">En Telegram</span>
|
|
<input
|
|
type="checkbox"
|
|
className="toggle toggle-success"
|
|
checked={inTelegram}
|
|
onChange={(e) => setInTelegram(e.target.checked)}
|
|
/>
|
|
|
|
<label htmlFor="f-notes" className="text-sm font-medium sm:text-right sm:pr-1 sm:self-start sm:pt-3">
|
|
Notas
|
|
</label>
|
|
<textarea
|
|
id="f-notes"
|
|
className="textarea textarea-bordered w-full"
|
|
rows={2}
|
|
value={notes}
|
|
onChange={(e) => setNotes(e.target.value)}
|
|
/>
|
|
|
|
{error && (
|
|
<div role="alert" className="alert alert-error alert-soft sm:col-span-2 py-2">
|
|
<span className="text-sm">{error}</span>
|
|
</div>
|
|
)}
|
|
|
|
<div className="modal-action sm:col-span-2 mt-2">
|
|
<button type="button" className="btn btn-ghost" onClick={onClose} disabled={pending}>
|
|
Cancelar
|
|
</button>
|
|
<button type="submit" className="btn btn-primary" disabled={pending}>
|
|
{pending && <span className="loading loading-spinner loading-sm" />}
|
|
{isCreate ? 'Crear' : 'Guardar'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
<button type="button" className="modal-backdrop" onClick={onClose} aria-label="Cerrar" />
|
|
</dialog>
|
|
)
|
|
}
|
|
|
|
function UploadModal({ pastor, onClose }: { pastor: PastorRow; onClose: () => void }) {
|
|
const router = useRouter()
|
|
const formRef = useRef<HTMLFormElement>(null)
|
|
const [pending, startTransition] = useTransition()
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
const onSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault()
|
|
setError(null)
|
|
const formData = new FormData(formRef.current!)
|
|
startTransition(async () => {
|
|
const res = await uploadLetter(pastor.id, formData)
|
|
if (res?.ok) {
|
|
router.refresh()
|
|
onClose()
|
|
} else {
|
|
setError(res?.error ?? 'Error al subir el archivo.')
|
|
}
|
|
})
|
|
}
|
|
|
|
return (
|
|
<dialog className="modal modal-open">
|
|
<div className="modal-box">
|
|
<h3 className="text-lg font-bold">Subir carta</h3>
|
|
<p className="text-sm text-base-content/60 mt-1 mb-4">
|
|
Adjunta una carta para <span className="font-medium">{pastor.name}</span>. Solo PDF o Word.
|
|
</p>
|
|
<form ref={formRef} onSubmit={onSubmit} className="flex flex-col gap-4">
|
|
<input
|
|
type="file"
|
|
name="file"
|
|
accept=".pdf,.doc,.docx,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
|
className="file-input file-input-bordered w-full"
|
|
required
|
|
/>
|
|
{error && <p className="text-error text-sm">{error}</p>}
|
|
<div className="modal-action">
|
|
<button type="button" className="btn btn-ghost" onClick={onClose} disabled={pending}>
|
|
Cancelar
|
|
</button>
|
|
<button type="submit" className="btn btn-primary" disabled={pending}>
|
|
{pending && <span className="loading loading-spinner loading-sm" />}
|
|
Subir
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
<button type="button" className="modal-backdrop" onClick={onClose} aria-label="Cerrar" />
|
|
</dialog>
|
|
)
|
|
}
|