fix(admin): layout 3 columnas en invite form, banner de confirmación visible

Email/Organización/botón en una fila en desktop, apilado en mobile.
El mensaje de éxito/error ahora es un banner (no texto inline) visible
al menos 4s o hasta que el usuario vuelva a tocar el formulario.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
markosbenitez
2026-06-29 16:56:27 -03:00
parent 4611fb2363
commit 7efab390da
2 changed files with 77 additions and 28 deletions

View File

@@ -1,5 +1,5 @@
'use client' 'use client'
import { useActionState } from 'react' import { useActionState, useEffect, useState } from 'react'
import { inviteUser, type InviteUserResult } from '@/app/actions/invite-user' import { inviteUser, type InviteUserResult } from '@/app/actions/invite-user'
interface OrgOption { interface OrgOption {
@@ -8,6 +8,7 @@ interface OrgOption {
} }
const INITIAL_STATE: InviteUserResult = { success: false, error: '' } const INITIAL_STATE: InviteUserResult = { success: false, error: '' }
const BANNER_VISIBLE_MS = 4000
const ERROR_MESSAGES: Record<string, string> = { const ERROR_MESSAGES: Record<string, string> = {
no_autenticado: 'Tu sesión expiró — volvé a iniciar sesión.', no_autenticado: 'Tu sesión expiró — volvé a iniciar sesión.',
@@ -30,38 +31,59 @@ export function InviteForm({ organizations }: { organizations: OrgOption[] }) {
async (_prev: InviteUserResult, formData: FormData) => inviteUser(formData), async (_prev: InviteUserResult, formData: FormData) => inviteUser(formData),
INITIAL_STATE INITIAL_STATE
) )
const [bannerVisible, setBannerVisible] = useState(false)
// Banner visible al menos BANNER_VISIBLE_MS tras cada resultado nuevo, o
// hasta que el usuario vuelva a tocar el formulario (lo que pase primero).
useEffect(() => {
const hasMessage = state.success || (!state.success && state.error !== '')
setBannerVisible(hasMessage)
if (!hasMessage) return
const timer = setTimeout(() => setBannerVisible(false), BANNER_VISIBLE_MS)
return () => clearTimeout(timer)
}, [state])
return ( return (
<form action={formAction} className="login-form"> <form
<div className="login-field"> action={formAction}
<label htmlFor="email" className="login-label">Email</label> className="invite-form"
<input onChange={() => setBannerVisible(false)}
id="email" >
name="email" <div className="invite-form-row">
type="email" <div className="login-field">
required <label htmlFor="email" className="login-label">Email</label>
disabled={isPending} <input
className="text-short-input" id="email"
/> name="email"
type="email"
required
disabled={isPending}
className="text-short-input"
/>
</div>
<div className="login-field">
<label htmlFor="orgId" className="login-label">Organización</label>
<select id="orgId" name="orgId" required disabled={isPending} className="admin-survey-select">
<option value="">Seleccioná una organización</option>
{organizations.map((org) => (
<option key={org.id} value={org.id}>{org.name}</option>
))}
</select>
</div>
<button type="submit" disabled={isPending} className="login-submit">
{isPending ? 'Enviando…' : 'Enviar invitación'}
</button>
</div> </div>
<div className="login-field"> {bannerVisible && !state.success && state.error && (
<label htmlFor="orgId" className="login-label">Organización</label> <p role="alert" className="invite-banner invite-banner-error">
<select id="orgId" name="orgId" required disabled={isPending} className="admin-survey-select"> {describeError(state.error)}
<option value="">Seleccioná una organización</option> </p>
{organizations.map((org) => (
<option key={org.id} value={org.id}>{org.name}</option>
))}
</select>
</div>
{!state.success && state.error && (
<p role="alert" className="field-error login-error">{describeError(state.error)}</p>
)} )}
{state.success && ( {bannerVisible && state.success && (
<p className="admin-subtitle" role="status">Invitación enviada correctamente.</p> <p role="status" className="invite-banner invite-banner-success">
Invitación enviada correctamente.
</p>
)} )}
<button type="submit" disabled={isPending} className="login-submit">
{isPending ? 'Enviando…' : 'Enviar invitación'}
</button>
</form> </form>
) )
} }

View File

@@ -1164,6 +1164,33 @@ body {
.login-submit:hover:not(:disabled) { background: var(--color-primary-hover); } .login-submit:hover:not(:disabled) { background: var(--color-primary-hover); }
.login-submit:disabled { opacity: 0.6; cursor: not-allowed; } .login-submit:disabled { opacity: 0.6; cursor: not-allowed; }
/* ── Invite form (/admin/usuarios) ── */
.invite-form { display: flex; flex-direction: column; gap: 12px; }
.invite-form-row {
display: grid;
grid-template-columns: 1fr 1fr auto;
gap: 12px;
align-items: end;
}
.invite-banner {
margin: 0;
padding: 10px 14px;
border-radius: var(--radius);
font-size: 0.875rem;
font-weight: 600;
}
.invite-banner-success {
background: var(--color-primary-light);
color: var(--color-primary-hover);
}
.invite-banner-error {
background: var(--color-error-bg);
color: var(--color-error);
}
@media (max-width: 768px) {
.invite-form-row { grid-template-columns: 1fr; }
}
/* ── Admin sign-out ── */ /* ── Admin sign-out ── */
.admin-header-actions { display: flex; align-items: center; gap: 12px; } .admin-header-actions { display: flex; align-items: center; gap: 12px; }
.admin-signout { .admin-signout {