From 073bf8b85300c3db696e08edec812bb24db0fde0 Mon Sep 17 00:00:00 2001 From: ignacionelson Date: Sat, 12 Sep 2026 16:16:35 -0300 Subject: [PATCH] Put the invite form and the pending list on their own tabs They were stacked: the form, then the list under it. Two different jobs on one scroll, and the list -- the half somebody opens this screen to act on rather than to fill in -- sat below the fold on any installation with a few invitations out. Two tabs now, the same plain border-bottom nav the staff account screen and the theming settings already use. The count rides in the tab label, because the reason to open that half is that something is waiting in it. Three details worth stating: - The form is hidden rather than unmounted, exactly as the staff account form is, so switching to the list and back does not throw away a half-typed invitation. - Revoking passes preserveState, so the page comes back on the tab the person was working in. Acting on a row and landing on the other half reads as having lost the list. - ?tab=pending opens on the list, so something elsewhere can link at the half it means rather than at the screen plus a sentence telling the reader which tab to find. Verified in a browser, since none of this is visible to the suite: both tabs render, the form is present on one and absent on the other, the query parameter opens the right one, and revoking leaves the page on the pending tab with the count down by one. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CPk8qAs38pudYGWwmGkYPe --- resources/js/pages/clients/invite.tsx | 131 +++++++++++++++++--------- 1 file changed, 84 insertions(+), 47 deletions(-) diff --git a/resources/js/pages/clients/invite.tsx b/resources/js/pages/clients/invite.tsx index eb96ad47..c76814d2 100644 --- a/resources/js/pages/clients/invite.tsx +++ b/resources/js/pages/clients/invite.tsx @@ -1,6 +1,6 @@ import { type BreadcrumbItem } from '@/types'; import { Head, router, useForm } from '@inertiajs/react'; -import { FormEventHandler } from 'react'; +import { FormEventHandler, useState } from 'react'; import { ConfirmDialog } from '@/components/confirm-dialog'; import Heading from '@/components/heading'; @@ -16,6 +16,8 @@ import { useFormatDate } from '@/hooks/use-format-date'; import { useTranslation } from '@/hooks/use-translation'; import AppLayout from '@/layouts/app-layout'; +type Tab = 'send' | 'pending'; + interface InvitationFormData { [key: string]: string; email: string; @@ -46,6 +48,10 @@ interface ClientsInviteProps { export default function ClientsInvite({ groups, default_storage_quota_mb, invitations, pagination }: ClientsInviteProps) { const { t } = useTranslation(); const { dateTime } = useFormatDate(); + // ?tab=pending opens on the list, so a link can point at the half it + // means — the same reason the theming settings read their own tab from + // the query string. Anything unrecognised falls back to the form. + const [tab, setTab] = useState(new URLSearchParams(window.location.search).get('tab') === 'pending' ? 'pending' : 'send'); const breadcrumbs: BreadcrumbItem[] = [ { title: t('Clients'), href: '/clients' }, @@ -73,7 +79,28 @@ export default function ClientsInvite({ groups, default_storage_quota_mb, invita
-
+ + + {/* Hidden rather than unmounted, the same as the staff account + form's tabs: switching to the list and back must not throw + away a half-typed invitation. */} +
-
- + {tab === 'pending' && ( +
+

+ {t('Links that have been sent and not used yet. Revoking one stops it working for good.')} +

- {t('No invitations are waiting to be used.')}} - > - {invitations.map((invitation) => ( - - - {invitation.email} - {invitation.name && {invitation.name}} - - {invitation.group ?? '—'} - {invitation.invited_by ?? '—'} - {dateTime(invitation.created_at)} - - {invitation.expired ? {t('Expired')} : dateTime(invitation.expires_at)} - - -
- - {t('Revoke')} - - } - title={t('Revoke this invitation?')} - description={t( - 'The link sent to :email stops working, and cannot be renewed by whoever holds it. You can send a new invitation at any time.', - { email: invitation.email }, - )} - confirmLabel={t('Revoke')} - onConfirm={() => router.delete(route('invitations.destroy', invitation.id))} - /> -
- - - ))} -
+ {t('No invitations are waiting to be used.')}} + > + {invitations.map((invitation) => ( + + + {invitation.email} + {invitation.name && {invitation.name}} + + {invitation.group ?? '—'} + {invitation.invited_by ?? '—'} + {dateTime(invitation.created_at)} + + {invitation.expired ? {t('Expired')} : dateTime(invitation.expires_at)} + + +
+ + {t('Revoke')} + + } + title={t('Revoke this invitation?')} + description={t( + 'The link sent to :email stops working, and cannot be renewed by whoever holds it. You can send a new invitation at any time.', + { email: invitation.email }, + )} + confirmLabel={t('Revoke')} + // preserveState so the page comes back on this tab + // rather than on the form: revoking redirects back + // here, and landing on the other half after acting + // on this one reads as having lost the list. + onConfirm={() => + router.delete(route('invitations.destroy', invitation.id), { + preserveState: true, + preserveScroll: true, + }) + } + /> +
+ + + ))} +
- -
+ +
+ )}
);