improvement: loading state added to buttons who interact with backend.

This commit is contained in:
2026-07-17 10:58:04 +03:30
parent 68fc9d5d6d
commit d2ad1fd7b6
23 changed files with 240 additions and 144 deletions

View File

@@ -60,5 +60,6 @@ Reference: `app/.../treatment/page.tsx` + `components/ui/treatment/TreatmentWork
| Filter or inline `<select>` | `FORM_SELECT_CLASS` from `components/shared/formSelectStyles.ts` | | Filter or inline `<select>` | `FORM_SELECT_CLASS` from `components/shared/formSelectStyles.ts` |
| Tiny select (sort dir, etc.) | `FORM_SELECT_COMPACT_CLASS` | | Tiny select (sort dir, etc.) | `FORM_SELECT_COMPACT_CLASS` |
| Desktop data table | `Table` (`ui/shared/Table.tsx`) — logical alignment only | | Desktop data table | `Table` (`ui/shared/Table.tsx`) — logical alignment only |
| API action button | `Button` — return the Promise from `onClick` (`() => doThing()` not `() => void doThing()`); auto-disables until settle. List/icon mutations: `useAsyncAction` / `useAsyncActionById` |
Styles for `.form-select` chevrons live in `styles/globals.css`. Do not duplicate chevron icons on raw selects. Styles for `.form-select` chevrons live in `styles/globals.css`. Do not duplicate chevron icons on raw selects.

View File

@@ -148,7 +148,7 @@ function AcceptInviteContent() {
onChange={(e) => setConfirmPassword(e.target.value)} onChange={(e) => setConfirmPassword(e.target.value)}
passwordToggleLabels={passwordToggleLabels} passwordToggleLabels={passwordToggleLabels}
/> />
<Button type="button" fullWidth isLoading={submitting} onClick={() => void onAccept()}> <Button type="button" fullWidth isLoading={submitting} onClick={() => onAccept()}>
{t('activateAccount')} {t('activateAccount')}
</Button> </Button>
</div> </div>

View File

@@ -244,7 +244,7 @@ function AcceptOrganizationInviteContent() {
icon={<Lock className="h-5 w-5 icon-flat" />} icon={<Lock className="h-5 w-5 icon-flat" />}
passwordToggleLabels={passwordToggleLabels} passwordToggleLabels={passwordToggleLabels}
/> />
<Button type="button" variant="primary" onClick={() => void handleNext()} fullWidth> <Button type="button" variant="primary" onClick={() => handleNext()} fullWidth>
{tCommon('continue')} {tCommon('continue')}
</Button> </Button>
</> </>

View File

@@ -182,7 +182,7 @@ export default function ForgotPasswordPage() {
variant="primary" variant="primary"
isLoading={isSending} isLoading={isSending}
fullWidth fullWidth
onClick={() => void onSendCode()} onClick={() => onSendCode()}
> >
{t('sendCode')} {t('sendCode')}
</Button> </Button>

View File

@@ -319,7 +319,7 @@ export function AppointmentBookingModal({
<Button <Button
type="button" type="button"
variant="danger" variant="danger"
onClick={() => void onDelete()} onClick={() => onDelete()}
disabled={loading || deleting} disabled={loading || deleting}
isLoading={deleting} isLoading={deleting}
fullWidth fullWidth
@@ -342,7 +342,7 @@ export function AppointmentBookingModal({
<Button <Button
type="button" type="button"
variant="primary" variant="primary"
onClick={() => void handleSubmit()} onClick={() => handleSubmit()}
isLoading={loading} isLoading={loading}
disabled={deleting} disabled={deleting}
fullWidth fullWidth

View File

@@ -285,7 +285,7 @@ export function AppointmentsPage() {
!!activeEditingAppointment && !!activeEditingAppointment &&
!activeEditingAppointment.hasTreatment !activeEditingAppointment.hasTreatment
} }
onDelete={() => void handleDeleteEditingAppointment()} onDelete={() => handleDeleteEditingAppointment()}
deleting={deletingAppointment} deleting={deletingAppointment}
/> />
</div> </div>

View File

@@ -13,8 +13,10 @@ import {
ChevronDown, ChevronDown,
} from 'lucide-react'; } from 'lucide-react';
import { useAuth } from '@/lib/hooks/useAuth'; import { useAuth } from '@/lib/hooks/useAuth';
import { useAsyncAction } from '@/lib/hooks/useAsyncAction';
import { authApi } from '@/lib/api/auth'; import { authApi } from '@/lib/api/auth';
import type { SubscriptionAlertData } from '@/types/subscription'; import type { SubscriptionAlertData } from '@/types/subscription';
import { Button } from '@/components/ui/shared/Button';
function warningTooltip( function warningTooltip(
data: SubscriptionAlertData | null, data: SubscriptionAlertData | null,
@@ -32,6 +34,7 @@ export function DashboardAccountMenu() {
const t = useTranslations('auth'); const t = useTranslations('auth');
const tAccount = useTranslations('accountMenu'); const tAccount = useTranslations('accountMenu');
const { user, currentOrganization, logout } = useAuth(); const { user, currentOrganization, logout } = useAuth();
const { pending: logoutPending, run: runLogout } = useAsyncAction();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null); const menuRef = useRef<HTMLDivElement>(null);
const [alert, setAlert] = useState<SubscriptionAlertData | null>(null); const [alert, setAlert] = useState<SubscriptionAlertData | null>(null);
@@ -70,9 +73,11 @@ export function DashboardAccountMenu() {
const tooltip = useMemo(() => warningTooltip(alert, tAccount), [alert, tAccount]); const tooltip = useMemo(() => warningTooltip(alert, tAccount), [alert, tAccount]);
const handleLogout = useCallback(() => { const handleLogout = useCallback(() => {
setOpen(false); return runLogout(async () => {
void logout(); await logout();
}, [logout]); setOpen(false);
});
}, [logout, runLogout]);
return ( return (
<div className="relative z-[120]" ref={menuRef}> <div className="relative z-[120]" ref={menuRef}>
@@ -146,15 +151,17 @@ export function DashboardAccountMenu() {
</div> </div>
<div className="border-t border-border/60 pt-1"> <div className="border-t border-border/60 pt-1">
<button <Button
type="button" type="button"
variant="ghost"
role="menuitem" role="menuitem"
className="flex w-full items-center gap-3 px-3 py-2.5 text-sm text-text-secondary hover:bg-background-card/70 hover:text-text-primary" className="flex w-full items-center justify-start gap-3 rounded-none px-3 py-2.5 text-sm text-text-secondary hover:bg-background-card/70 hover:text-text-primary"
onClick={handleLogout} isLoading={logoutPending}
onClick={() => handleLogout()}
> >
<LogOut className="h-4 w-4 icon-flat shrink-0" /> <LogOut className="h-4 w-4 icon-flat shrink-0" />
{t('signOut')} {t('signOut')}
</button> </Button>
</div> </div>
</div> </div>
)} )}

View File

@@ -185,7 +185,7 @@ export function LabCaseAttachmentsDialog({
variant="outline" variant="outline"
size="sm" size="sm"
disabled={downloadingAll} disabled={downloadingAll}
onClick={() => void handleDownloadAll()} onClick={() => handleDownloadAll()}
> >
<Download className="h-4 w-4 me-1.5" /> <Download className="h-4 w-4 me-1.5" />
{t('downloadAllAttachments')} {t('downloadAllAttachments')}

View File

@@ -4,6 +4,7 @@ import { useCallback, useEffect, useState, type KeyboardEvent } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { Eye, EyeOff, Send } from 'lucide-react'; import { Eye, EyeOff, Send } from 'lucide-react';
import { getUserFacingError } from '@/components/shared/formatApiError'; import { getUserFacingError } from '@/components/shared/formatApiError';
import { useAsyncActionById } from '@/lib/hooks/useAsyncAction';
import type { LabCaseComment } from '@/types/cases'; import type { LabCaseComment } from '@/types/cases';
interface LabCaseCommentsPanelProps { interface LabCaseCommentsPanelProps {
@@ -42,6 +43,7 @@ export function LabCaseCommentsPanel({
const [posting, setPosting] = useState(false); const [posting, setPosting] = useState(false);
const [body, setBody] = useState(''); const [body, setBody] = useState('');
const [visibleToClinic, setVisibleToClinic] = useState(false); const [visibleToClinic, setVisibleToClinic] = useState(false);
const toggleBusy = useAsyncActionById();
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
setLoading(true); setLoading(true);
@@ -84,12 +86,14 @@ export function LabCaseCommentsPanel({
async function handleToggle(comment: LabCaseComment) { async function handleToggle(comment: LabCaseComment) {
if (!onToggleVisibility || !canToggleVisibility) return; if (!onToggleVisibility || !canToggleVisibility) return;
try { await toggleBusy.run(comment.id, async () => {
const updated = await onToggleVisibility(comment.id, !comment.visibleToClinic); try {
setComments((prev) => prev.map((c) => (c.id === updated.id ? updated : c))); const updated = await onToggleVisibility(comment.id, !comment.visibleToClinic);
} catch (error: unknown) { setComments((prev) => prev.map((c) => (c.id === updated.id ? updated : c)));
onError?.(getUserFacingError(error, tErrors, t('errorToggle'))); } catch (error: unknown) {
} onError?.(getUserFacingError(error, tErrors, t('errorToggle')));
}
});
} }
return ( return (
@@ -127,10 +131,14 @@ export function LabCaseCommentsPanel({
{canToggleVisibility && comment.canToggleVisibility && onToggleVisibility ? ( {canToggleVisibility && comment.canToggleVisibility && onToggleVisibility ? (
<button <button
type="button" type="button"
onClick={() => void handleToggle(comment)} onClick={() => handleToggle(comment)}
className="shrink-0 p-1 rounded hover:bg-border text-text-muted" disabled={toggleBusy.pendingId !== null}
className={`shrink-0 p-1 rounded hover:bg-border text-text-muted disabled:opacity-40 disabled:cursor-not-allowed ${
toggleBusy.pendingId === comment.id ? 'animate-pulse' : ''
}`}
title={comment.visibleToClinic ? t('makeHidden') : t('makeVisible')} title={comment.visibleToClinic ? t('makeHidden') : t('makeVisible')}
aria-label={comment.visibleToClinic ? t('makeHidden') : t('makeVisible')} aria-label={comment.visibleToClinic ? t('makeHidden') : t('makeVisible')}
aria-busy={toggleBusy.pendingId === comment.id || undefined}
> >
{comment.visibleToClinic ? ( {comment.visibleToClinic ? (
<Eye className="h-4 w-4" /> <Eye className="h-4 w-4" />
@@ -184,7 +192,7 @@ export function LabCaseCommentsPanel({
) : null} ) : null}
<button <button
type="button" type="button"
onClick={() => void handlePost()} onClick={() => handlePost()}
disabled={posting || !body.trim()} disabled={posting || !body.trim()}
className="shrink-0 p-2 rounded-md bg-primary text-white transition-colors hover:opacity-90 disabled:opacity-40 disabled:cursor-not-allowed" className="shrink-0 p-2 rounded-md bg-primary text-white transition-colors hover:opacity-90 disabled:opacity-40 disabled:cursor-not-allowed"
title={t('send')} title={t('send')}

View File

@@ -68,7 +68,7 @@ export function LabCaseShareQrDialog({ open, onClose, shareUrl }: LabCaseShareQr
variant="outline" variant="outline"
disabled={copying} disabled={copying}
className="w-full sm:w-auto" className="w-full sm:w-auto"
onClick={() => void handleCopy()} onClick={() => handleCopy()}
> >
<Copy className="h-4 w-4 me-1.5" /> <Copy className="h-4 w-4 me-1.5" />
{t('copyShareLink')} {t('copyShareLink')}

View File

@@ -38,7 +38,7 @@ interface TaskRowProps {
onStatusUpdate: (taskId: string, status: LabTaskStatus) => void; onStatusUpdate: (taskId: string, status: LabTaskStatus) => void;
onToggleComments: (taskId: string) => void; onToggleComments: (taskId: string) => void;
onCommentError: (message: string) => void; onCommentError: (message: string) => void;
onShowInCase?: (task: LabTaskListItem) => void; onShowInCase?: (task: LabTaskListItem) => void | Promise<void>;
} }
function formatPatientName(patient: { firstName: string; lastName: string }) { function formatPatientName(patient: { firstName: string; lastName: string }) {

View File

@@ -368,7 +368,7 @@ export function TasksPage() {
setExpandedCommentsTaskId((prev) => (prev === id ? null : id)) setExpandedCommentsTaskId((prev) => (prev === id ? null : id))
} }
onCommentError={showError} onCommentError={showError}
onShowInCase={flatMode ? (row) => void handleShowInCase(row) : undefined} onShowInCase={flatMode ? (row) => handleShowInCase(row) : undefined}
/> />
); );

View File

@@ -135,7 +135,7 @@ export function OrganizationConnectionsMobileList({
<button <button
type="button" type="button"
className="p-2 rounded-md text-text-secondary hover:bg-background-card/80 hover:text-text-primary disabled:opacity-50" className="p-2 rounded-md text-text-secondary hover:bg-background-card/80 hover:text-text-primary disabled:opacity-50"
disabled={pendingConnectionRowId !== null && pendingConnectionRowId !== row.id} disabled={pendingConnectionRowId !== null}
onClick={() => onRespond(row.id, 'ACCEPT')} onClick={() => onRespond(row.id, 'ACCEPT')}
aria-label={labels.acceptRequest} aria-label={labels.acceptRequest}
title={labels.acceptRequest} title={labels.acceptRequest}
@@ -145,7 +145,7 @@ export function OrganizationConnectionsMobileList({
<button <button
type="button" type="button"
className="p-2 rounded-md text-text-secondary hover:bg-red-500/15 hover:text-red-600 disabled:opacity-50" className="p-2 rounded-md text-text-secondary hover:bg-red-500/15 hover:text-red-600 disabled:opacity-50"
disabled={pendingConnectionRowId !== null && pendingConnectionRowId !== row.id} disabled={pendingConnectionRowId !== null}
onClick={() => onRespond(row.id, 'REJECT')} onClick={() => onRespond(row.id, 'REJECT')}
aria-label={labels.declineRequest} aria-label={labels.declineRequest}
title={labels.declineRequest} title={labels.declineRequest}
@@ -158,7 +158,7 @@ export function OrganizationConnectionsMobileList({
<button <button
type="button" type="button"
className="p-2 rounded-md text-text-secondary hover:bg-red-500/15 hover:text-red-600 disabled:opacity-50" className="p-2 rounded-md text-text-secondary hover:bg-red-500/15 hover:text-red-600 disabled:opacity-50"
disabled={deleteConnectionRowId !== null && deleteConnectionRowId !== row.id} disabled={deleteConnectionRowId !== null}
onClick={() => onDeleteConnection(row.id)} onClick={() => onDeleteConnection(row.id)}
aria-label={labels.removeConnection} aria-label={labels.removeConnection}
title={labels.removeConnection} title={labels.removeConnection}
@@ -195,7 +195,7 @@ export function OrganizationConnectionsMobileList({
<button <button
type="button" type="button"
className="p-2 rounded-md text-text-secondary hover:bg-background-card/80 hover:text-text-primary disabled:opacity-50" className="p-2 rounded-md text-text-secondary hover:bg-background-card/80 hover:text-text-primary disabled:opacity-50"
disabled={pendingConnectionRowId !== null && pendingConnectionRowId !== result.id} disabled={pendingConnectionRowId !== null}
onClick={() => onSendConnectionRequest(result.id)} onClick={() => onSendConnectionRequest(result.id)}
aria-label={labels.sendRequest} aria-label={labels.sendRequest}
title={labels.sendRequest} title={labels.sendRequest}

View File

@@ -4,6 +4,7 @@ import { useCallback, useEffect, useState } from 'react';
import { useSearchParams } from 'next/navigation'; import { useSearchParams } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl'; import { useLocale, useTranslations } from 'next-intl';
import { useToast } from '@/lib/hooks/useToast'; import { useToast } from '@/lib/hooks/useToast';
import { useAsyncActionById } from '@/lib/hooks/useAsyncAction';
import { Check, Trash2, UserPlus, X } from 'lucide-react'; import { Check, Trash2, UserPlus, X } from 'lucide-react';
import { useAuth } from '@/lib/hooks/useAuth'; import { useAuth } from '@/lib/hooks/useAuth';
import { notifyPendingConnectionsChanged } from '@/lib/hooks/usePendingConnectionsCount'; import { notifyPendingConnectionsChanged } from '@/lib/hooks/usePendingConnectionsCount';
@@ -81,8 +82,8 @@ export function OrganizationsPage() {
const [mode, setMode] = useState<TableMode>('existing'); const [mode, setMode] = useState<TableMode>('existing');
const [searching, setSearching] = useState(false); const [searching, setSearching] = useState(false);
const [searchResults, setSearchResults] = useState<CounterpartSearchResultDto[]>([]); const [searchResults, setSearchResults] = useState<CounterpartSearchResultDto[]>([]);
const [pendingConnectionRowId, setPendingConnectionRowId] = useState<string | null>(null); const connectionBusy = useAsyncActionById();
const [deleteConnectionRowId, setDeleteConnectionRowId] = useState<string | null>(null); const deleteBusy = useAsyncActionById();
const [items, setItems] = useState<CounterpartItemDto[]>([]); const [items, setItems] = useState<CounterpartItemDto[]>([]);
const [manualOrganizationName, setManualOrganizationName] = useState(''); const [manualOrganizationName, setManualOrganizationName] = useState('');
@@ -171,20 +172,19 @@ export function OrganizationsPage() {
}, [query, formatApiMessage, showError, setToastError]); }, [query, formatApiMessage, showError, setToastError]);
async function submitConnectionRequest(targetOrganizationId: string) { async function submitConnectionRequest(targetOrganizationId: string) {
setPendingConnectionRowId(targetOrganizationId); await connectionBusy.run(targetOrganizationId, async () => {
toast.setError(''); toast.setError('');
try { try {
await organizationApi.createConnectionRequest(targetOrganizationId); await organizationApi.createConnectionRequest(targetOrganizationId);
toast.showSuccess(t('successConnectionSent', { counterpart })); toast.showSuccess(t('successConnectionSent', { counterpart }));
setSearchResults([]); setSearchResults([]);
setQuery(''); setQuery('');
setMode('existing'); setMode('existing');
await loadList(); await loadList();
} catch (e) { } catch (e) {
toast.showError(formatApiMessage(e)); toast.showError(formatApiMessage(e));
} finally { }
setPendingConnectionRowId(null); });
}
} }
async function sendInvite() { async function sendInvite() {
@@ -272,34 +272,32 @@ export function OrganizationsPage() {
} }
async function respondToPendingConnection(connectionId: string, action: 'ACCEPT' | 'REJECT') { async function respondToPendingConnection(connectionId: string, action: 'ACCEPT' | 'REJECT') {
setPendingConnectionRowId(connectionId); await connectionBusy.run(connectionId, async () => {
toast.setError(''); toast.setError('');
try { try {
await organizationApi.respondToConnectionRequest(connectionId, action); await organizationApi.respondToConnectionRequest(connectionId, action);
toast.showSuccess( toast.showSuccess(
action === 'ACCEPT' ? t('successAccepted') : t('successDeclined'), action === 'ACCEPT' ? t('successAccepted') : t('successDeclined'),
); );
notifyPendingConnectionsChanged(); notifyPendingConnectionsChanged();
await loadList(); await loadList();
} catch (e) { } catch (e) {
toast.showError(formatApiMessage(e)); toast.showError(formatApiMessage(e));
} finally { }
setPendingConnectionRowId(null); });
}
} }
async function deleteConnection(connectionId: string) { async function deleteConnection(connectionId: string) {
setDeleteConnectionRowId(connectionId); await deleteBusy.run(connectionId, async () => {
toast.setError(''); toast.setError('');
try { try {
await organizationApi.deleteConnection(connectionId); await organizationApi.deleteConnection(connectionId);
toast.showSuccess(t('successRemoved')); toast.showSuccess(t('successRemoved'));
await loadList(); await loadList();
} catch (e) { } catch (e) {
toast.showError(formatApiMessage(e)); toast.showError(formatApiMessage(e));
} finally { }
setDeleteConnectionRowId(null); });
}
} }
function clearSearchView() { function clearSearchView() {
@@ -320,7 +318,7 @@ export function OrganizationsPage() {
<h1 className="text-xl sm:text-2xl font-semibold text-text-primary">{tabLabel}</h1> <h1 className="text-xl sm:text-2xl font-semibold text-text-primary">{tabLabel}</h1>
<p className="text-sm text-text-secondary mt-1">{t('subtitle')}</p> <p className="text-sm text-text-secondary mt-1">{t('subtitle')}</p>
</div> </div>
<Button type="button" size="sm" className="w-full sm:w-auto shrink-0" onClick={() => void openInvitationHistory()}> <Button type="button" size="sm" className="w-full sm:w-auto shrink-0" onClick={() => openInvitationHistory()}>
{t('invitationHistory')} {t('invitationHistory')}
</Button> </Button>
</div> </div>
@@ -349,8 +347,8 @@ export function OrganizationsPage() {
searchResults={searchResults} searchResults={searchResults}
currentOrganizationId={currentOrganization.id} currentOrganizationId={currentOrganization.id}
counterpart={counterpart} counterpart={counterpart}
pendingConnectionRowId={pendingConnectionRowId} pendingConnectionRowId={connectionBusy.pendingId}
deleteConnectionRowId={deleteConnectionRowId} deleteConnectionRowId={deleteBusy.pendingId}
copiedId={copiedId} copiedId={copiedId}
copyingInvitationId={copyingInvitationId} copyingInvitationId={copyingInvitationId}
showInviteForm={showInviteForm} showInviteForm={showInviteForm}
@@ -360,14 +358,14 @@ export function OrganizationsPage() {
formatConnectionStatusLabel={formatConnectionStatusLabel} formatConnectionStatusLabel={formatConnectionStatusLabel}
formatTableDate={formatTableDate} formatTableDate={formatTableDate}
getInvitationTarget={(row) => invitationTargetFromConnectionRow(row, currentOrganization.id)} getInvitationTarget={(row) => invitationTargetFromConnectionRow(row, currentOrganization.id)}
onCopyInvitation={(row) => void handleCopyInvitationFromRow(row)} onCopyInvitation={(row) => handleCopyInvitationFromRow(row)}
onRespond={(rowId, action) => void respondToPendingConnection(rowId, action)} onRespond={(rowId, action) => respondToPendingConnection(rowId, action)}
onDeleteConnection={(rowId) => void deleteConnection(rowId)} onDeleteConnection={(rowId) => deleteConnection(rowId)}
onSendConnectionRequest={(orgId) => void submitConnectionRequest(orgId)} onSendConnectionRequest={(orgId) => submitConnectionRequest(orgId)}
onToggleInviteForm={() => setShowInviteForm((v) => !v)} onToggleInviteForm={() => setShowInviteForm((v) => !v)}
onManualOrganizationNameChange={setManualOrganizationName} onManualOrganizationNameChange={setManualOrganizationName}
onManualOwnerEmailChange={setManualOwnerEmail} onManualOwnerEmailChange={setManualOwnerEmail}
onSendInvite={() => void sendInvite()} onSendInvite={() => sendInvite()}
labels={{ labels={{
loading: tCommon('loadingEllipsis'), loading: tCommon('loadingEllipsis'),
emptyConnections: t('emptyConnections'), emptyConnections: t('emptyConnections'),
@@ -462,8 +460,8 @@ export function OrganizationsPage() {
<button <button
type="button" type="button"
className="p-2 rounded-md text-text-secondary hover:bg-background-card/80 hover:text-text-primary disabled:text-text-muted disabled:opacity-50" className="p-2 rounded-md text-text-secondary hover:bg-background-card/80 hover:text-text-primary disabled:text-text-muted disabled:opacity-50"
disabled={pendingConnectionRowId !== null && pendingConnectionRowId !== row.id} disabled={connectionBusy.pendingId !== null}
onClick={() => void respondToPendingConnection(row.id, 'ACCEPT')} onClick={() => respondToPendingConnection(row.id, 'ACCEPT')}
aria-label={t('acceptRequest')} aria-label={t('acceptRequest')}
title={t('acceptRequest')} title={t('acceptRequest')}
> >
@@ -472,8 +470,8 @@ export function OrganizationsPage() {
<button <button
type="button" type="button"
className="p-2 rounded-md text-text-secondary hover:bg-red-500/15 hover:text-red-600 disabled:text-text-muted disabled:opacity-50" className="p-2 rounded-md text-text-secondary hover:bg-red-500/15 hover:text-red-600 disabled:text-text-muted disabled:opacity-50"
disabled={pendingConnectionRowId !== null && pendingConnectionRowId !== row.id} disabled={connectionBusy.pendingId !== null}
onClick={() => void respondToPendingConnection(row.id, 'REJECT')} onClick={() => respondToPendingConnection(row.id, 'REJECT')}
aria-label={t('declineRequest')} aria-label={t('declineRequest')}
title={t('declineRequest')} title={t('declineRequest')}
> >
@@ -486,8 +484,8 @@ export function OrganizationsPage() {
<button <button
type="button" type="button"
className="p-2 rounded-md text-text-secondary hover:bg-red-500/15 hover:text-red-600 disabled:text-text-muted disabled:opacity-50" className="p-2 rounded-md text-text-secondary hover:bg-red-500/15 hover:text-red-600 disabled:text-text-muted disabled:opacity-50"
disabled={deleteConnectionRowId !== null && deleteConnectionRowId !== row.id} disabled={deleteBusy.pendingId !== null}
onClick={() => void deleteConnection(row.id)} onClick={() => deleteConnection(row.id)}
aria-label={t('removeConnection')} aria-label={t('removeConnection')}
title={t('removeConnection')} title={t('removeConnection')}
> >
@@ -514,10 +512,8 @@ export function OrganizationsPage() {
<button <button
type="button" type="button"
className="p-2 rounded-md text-text-secondary hover:bg-background-card/80 hover:text-text-primary disabled:text-text-muted disabled:opacity-50" className="p-2 rounded-md text-text-secondary hover:bg-background-card/80 hover:text-text-primary disabled:text-text-muted disabled:opacity-50"
disabled={ disabled={connectionBusy.pendingId !== null}
pendingConnectionRowId !== null && pendingConnectionRowId !== r.id onClick={() => submitConnectionRequest(r.id)}
}
onClick={() => void submitConnectionRequest(r.id)}
aria-label={t('sendRequest')} aria-label={t('sendRequest')}
title={t('sendRequest')} title={t('sendRequest')}
> >
@@ -556,7 +552,7 @@ export function OrganizationsPage() {
type="button" type="button"
isLoading={inviteLoading} isLoading={inviteLoading}
disabled={!manualOrganizationName.trim() || !manualOwnerEmail.trim()} disabled={!manualOrganizationName.trim() || !manualOwnerEmail.trim()}
onClick={() => void sendInvite()} onClick={() => sendInvite()}
className="w-full" className="w-full"
> >
{t('sendInvitation')} {t('sendInvitation')}

View File

@@ -142,7 +142,7 @@ export function PatientsPage() {
isOpen={isCreateOpen} isOpen={isCreateOpen}
formData={patientForm} formData={patientForm}
onChange={(patch) => setPatientForm((prev) => ({ ...prev, ...patch }))} onChange={(patch) => setPatientForm((prev) => ({ ...prev, ...patch }))}
onSubmit={() => void handleCreatePatient()} onSubmit={() => handleCreatePatient()}
onClose={() => { onClose={() => {
setIsCreateOpen(false); setIsCreateOpen(false);
setPatientForm(EMPTY_PATIENT_FORM); setPatientForm(EMPTY_PATIENT_FORM);

View File

@@ -435,7 +435,7 @@ export function AccountSettingsPage() {
type="button" type="button"
variant="primary" variant="primary"
isLoading={participationLoading} isLoading={participationLoading}
onClick={() => void confirmRevokeParticipation()} onClick={() => confirmRevokeParticipation()}
> >
{t('participateConfirmRevokeConfirm')} {t('participateConfirmRevokeConfirm')}
</Button> </Button>

View File

@@ -140,7 +140,7 @@ export function OwnerWorkingHoursDialog({
type="button" type="button"
variant="outline" variant="outline"
isLoading={loading} isLoading={loading}
onClick={() => void handleSkip()} onClick={() => handleSkip()}
> >
{tStaff('skipForNow')} {tStaff('skipForNow')}
</Button> </Button>
@@ -148,7 +148,7 @@ export function OwnerWorkingHoursDialog({
type="button" type="button"
isLoading={loading} isLoading={loading}
disabled={Boolean(validationError)} disabled={Boolean(validationError)}
onClick={() => void handleSave()} onClick={() => handleSave()}
> >
{t('participateSaveHours')} {t('participateSaveHours')}
</Button> </Button>

View File

@@ -1,14 +1,25 @@
import React from 'react'; import React, { useRef, useState } from 'react';
type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'danger' | 'ghost'; type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'danger' | 'ghost';
type ButtonSize = 'sm' | 'md' | 'lg'; type ButtonSize = 'sm' | 'md' | 'lg';
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> { type ButtonClickHandler = (
event: React.MouseEvent<HTMLButtonElement>,
) => void | Promise<void>;
interface ButtonProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'onClick'> {
variant?: ButtonVariant; variant?: ButtonVariant;
size?: ButtonSize; size?: ButtonSize;
/** External loading (e.g. form-level busy). ORed with auto-pending from async onClick. */
isLoading?: boolean; isLoading?: boolean;
fullWidth?: boolean; fullWidth?: boolean;
children: React.ReactNode; children: React.ReactNode;
/**
* If the handler returns a Promise, the button stays loading/disabled until it
* settles — prevents double-clicks without a separate busy flag per call site.
* Prefer `onClick={() => doThing()}` over `onClick={() => void doThing()}`.
*/
onClick?: ButtonClickHandler;
} }
export const Button: React.FC<ButtonProps> = ({ export const Button: React.FC<ButtonProps> = ({
@@ -19,15 +30,20 @@ export const Button: React.FC<ButtonProps> = ({
children, children,
className = '', className = '',
disabled, disabled,
onClick,
...props ...props
}) => { }) => {
const [autoPending, setAutoPending] = useState(false);
const autoPendingRef = useRef(false);
const pending = isLoading || autoPending;
const baseClasses = const baseClasses =
'inline-flex items-center justify-center rounded-[var(--radius-md)] font-medium transition-all duration-200 ' + 'inline-flex items-center justify-center rounded-[var(--radius-md)] font-medium transition-all duration-200 ' +
'focus:outline-none focus:ring-2 focus:ring-primary/40 disabled:cursor-not-allowed'; 'focus:outline-none focus:ring-2 focus:ring-primary/40 disabled:cursor-not-allowed';
const variantClasses: Record<ButtonVariant, string> = { const variantClasses: Record<ButtonVariant, string> = {
primary: primary: 'bg-primary text-white hover:opacity-90 disabled:opacity-60',
'bg-primary text-white hover:opacity-90 disabled:opacity-60',
secondary: secondary:
'bg-surface-elevated text-text-primary border border-border hover:border-border-strong disabled:opacity-50', 'bg-surface-elevated text-text-primary border border-border hover:border-border-strong disabled:opacity-50',
@@ -48,14 +64,34 @@ export const Button: React.FC<ButtonProps> = ({
}; };
const widthClass = fullWidth ? 'w-full' : ''; const widthClass = fullWidth ? 'w-full' : '';
const loadingClass = isLoading ? 'opacity-70 animate-pulse pointer-events-none' : ''; const loadingClass = pending ? 'opacity-70 animate-pulse pointer-events-none' : '';
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
if (disabled || pending || autoPendingRef.current) {
event.preventDefault();
return;
}
if (!onClick) return;
const result = onClick(event);
if (result != null && typeof (result as Promise<void>).then === 'function') {
autoPendingRef.current = true;
setAutoPending(true);
void Promise.resolve(result).finally(() => {
autoPendingRef.current = false;
setAutoPending(false);
});
}
};
return ( return (
<button <button
className={`${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${widthClass} ${loadingClass} ${className}`} type="button"
disabled={disabled || isLoading}
aria-busy={isLoading || undefined}
{...props} {...props}
className={`${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${widthClass} ${loadingClass} ${className}`}
disabled={disabled || pending}
aria-busy={pending || undefined}
onClick={handleClick}
> >
{children} {children}
</button> </button>

View File

@@ -79,7 +79,7 @@ export function LanguageToggle() {
<button <button
type="button" type="button"
className="flex w-full items-center justify-between gap-3 px-3 py-2 text-sm text-text-primary hover:bg-background-card/70" className="flex w-full items-center justify-between gap-3 px-3 py-2 text-sm text-text-primary hover:bg-background-card/70"
onClick={() => void switchLocale(option)} onClick={() => switchLocale(option)}
> >
<span>{t(option)}</span> <span>{t(option)}</span>
{selected && <Check className="h-4 w-4 text-primary shrink-0" />} {selected && <Check className="h-4 w-4 text-primary shrink-0" />}

View File

@@ -597,39 +597,37 @@ export function StaffPage() {
variant="outline" variant="outline"
size="sm" size="sm"
isLoading={copyingInviteMembershipId === lastInviteInfo.membershipId} isLoading={copyingInviteMembershipId === lastInviteInfo.membershipId}
onClick={() => { onClick={async () => {
const member = members.find((item) => item.id === lastInviteInfo.membershipId); const member = members.find((item) => item.id === lastInviteInfo.membershipId);
if (member) { if (member) {
void copyStaffInviteLink(member); await copyStaffInviteLink(member);
return; return;
} }
void (async () => { setCopyingInviteMembershipId(lastInviteInfo.membershipId);
setCopyingInviteMembershipId(lastInviteInfo.membershipId); toast.setError('');
toast.setError(''); try {
try { const res = await staffApi.getInvitationLink(lastInviteInfo.membershipId);
const res = await staffApi.getInvitationLink(lastInviteInfo.membershipId); if (currentOrganization?.id) {
if (currentOrganization?.id) { const nextLinks = {
const nextLinks = { ...pendingInviteLinks,
...pendingInviteLinks, [lastInviteInfo.membershipId]: {
[lastInviteInfo.membershipId]: { membershipId: lastInviteInfo.membershipId,
membershipId: lastInviteInfo.membershipId, email: lastInviteInfo.email,
email: lastInviteInfo.email, invitationUrl: res.data.invitationUrl,
invitationUrl: res.data.invitationUrl, },
}, };
}; setPendingInviteLinks(nextLinks);
setPendingInviteLinks(nextLinks); writeStoredInviteLinks(currentOrganization.id, nextLinks);
writeStoredInviteLinks(currentOrganization.id, nextLinks);
}
setLastInviteInfo({ ...lastInviteInfo, invitationUrl: res.data.invitationUrl });
await navigator.clipboard.writeText(res.data.invitationUrl);
setCopiedInviteMembershipId(lastInviteInfo.membershipId);
setTimeout(() => setCopiedInviteMembershipId(null), 1500);
} catch (e) {
toast.showError(getUserFacingError(e, tErrors, t('errorCopyInvite')));
} finally {
setCopyingInviteMembershipId(null);
} }
})(); setLastInviteInfo({ ...lastInviteInfo, invitationUrl: res.data.invitationUrl });
await navigator.clipboard.writeText(res.data.invitationUrl);
setCopiedInviteMembershipId(lastInviteInfo.membershipId);
setTimeout(() => setCopiedInviteMembershipId(null), 1500);
} catch (e) {
toast.showError(getUserFacingError(e, tErrors, t('errorCopyInvite')));
} finally {
setCopyingInviteMembershipId(null);
}
}} }}
> >
{copiedInviteMembershipId === lastInviteInfo.membershipId {copiedInviteMembershipId === lastInviteInfo.membershipId
@@ -661,7 +659,7 @@ export function StaffPage() {
canShareInviteLink={canShareStaffInviteLink} canShareInviteLink={canShareStaffInviteLink}
canEnable={canEnableStaff} canEnable={canEnableStaff}
canDisable={canDisableStaff} canDisable={canDisableStaff}
onCopyInviteLink={(member) => void copyStaffInviteLink(member)} onCopyInviteLink={(member) => copyStaffInviteLink(member)}
onEnable={setEnableTarget} onEnable={setEnableTarget}
onDisable={setDisableTarget} onDisable={setDisableTarget}
onEdit={openEdit} onEdit={openEdit}
@@ -747,7 +745,7 @@ export function StaffPage() {
type="button" type="button"
className="p-2 rounded-md text-text-secondary hover:bg-background-card/80 hover:text-text-primary disabled:opacity-50" className="p-2 rounded-md text-text-secondary hover:bg-background-card/80 hover:text-text-primary disabled:opacity-50"
disabled={copyingInviteMembershipId === m.id} disabled={copyingInviteMembershipId === m.id}
onClick={() => void copyStaffInviteLink(m)} onClick={() => copyStaffInviteLink(m)}
aria-label={t('copyInviteLink')} aria-label={t('copyInviteLink')}
title={t('copyInviteLinkTitle')} title={t('copyInviteLinkTitle')}
> >
@@ -930,7 +928,7 @@ export function StaffPage() {
type="button" type="button"
isLoading={inviteLoading} isLoading={inviteLoading}
disabled={!inviteEmail.trim() || !inviteName.trim()} disabled={!inviteEmail.trim() || !inviteName.trim()}
onClick={() => void submitInvite(false)} onClick={() => submitInvite(false)}
> >
{t('sendInvite')} {t('sendInvite')}
</Button> </Button>
@@ -941,7 +939,7 @@ export function StaffPage() {
type="button" type="button"
variant="outline" variant="outline"
isLoading={inviteLoading} isLoading={inviteLoading}
onClick={() => void submitInvite(false)} onClick={() => submitInvite(false)}
> >
{t('skipForNow')} {t('skipForNow')}
</Button> </Button>
@@ -949,7 +947,7 @@ export function StaffPage() {
type="button" type="button"
isLoading={inviteLoading} isLoading={inviteLoading}
disabled={Boolean(inviteHoursValidationError)} disabled={Boolean(inviteHoursValidationError)}
onClick={() => void submitInvite(true)} onClick={() => submitInvite(true)}
> >
{t('sendInvite')} {t('sendInvite')}
</Button> </Button>
@@ -1004,7 +1002,7 @@ export function StaffPage() {
variant="primary" variant="primary"
isLoading={enablingMembershipId === enableTarget.id} isLoading={enablingMembershipId === enableTarget.id}
disabled={Boolean(enablingMembershipId) || !hasAvailableSeat} disabled={Boolean(enablingMembershipId) || !hasAvailableSeat}
onClick={() => void confirmEnableMember()} onClick={() => confirmEnableMember()}
> >
{t('enableMemberButton')} {t('enableMemberButton')}
</Button> </Button>
@@ -1054,7 +1052,7 @@ export function StaffPage() {
variant="danger" variant="danger"
isLoading={disablingMembershipId === disableTarget.id} isLoading={disablingMembershipId === disableTarget.id}
disabled={Boolean(disablingMembershipId)} disabled={Boolean(disablingMembershipId)}
onClick={() => void confirmDisableMember()} onClick={() => confirmDisableMember()}
> >
{t('disableMemberButton')} {t('disableMemberButton')}
</Button> </Button>
@@ -1136,7 +1134,7 @@ export function StaffPage() {
{tCommon('next')} {tCommon('next')}
</Button> </Button>
) : ( ) : (
<Button type="button" isLoading={editLoading} onClick={() => void submitEdit()}> <Button type="button" isLoading={editLoading} onClick={() => submitEdit()}>
{tCommon('save')} {tCommon('save')}
</Button> </Button>
) )
@@ -1145,7 +1143,7 @@ export function StaffPage() {
type="button" type="button"
isLoading={editLoading} isLoading={editLoading}
disabled={Boolean(editHoursValidationError)} disabled={Boolean(editHoursValidationError)}
onClick={() => void submitEdit()} onClick={() => submitEdit()}
> >
{tCommon('save')} {tCommon('save')}
</Button> </Button>

View File

@@ -45,8 +45,8 @@ interface LabCasesDispatchPanelProps {
canInviteLab?: boolean; canInviteLab?: boolean;
onInviteLab?: () => void; onInviteLab?: () => void;
sendBusyId: string | null; sendBusyId: string | null;
onAddLabCase: () => void; onAddLabCase: () => void | Promise<void>;
onSendLabCase: (labCase: LabCaseDraft, comment?: string) => void; onSendLabCase: (labCase: LabCaseDraft, comment?: string) => void | Promise<void>;
onCommentError?: (message: string) => void; onCommentError?: (message: string) => void;
} }

View File

@@ -2054,8 +2054,8 @@ export function TreatmentWorkspace({
}); });
}} }}
sendBusyId={sendBusyId} sendBusyId={sendBusyId}
onAddLabCase={() => void handleAddLabCase()} onAddLabCase={() => handleAddLabCase()}
onSendLabCase={(lc, comment) => void handleSendLabCase(lc, comment)} onSendLabCase={(lc, comment) => handleSendLabCase(lc, comment)}
onCommentError={showError} onCommentError={showError}
canInviteLab={canAccessOrganizations} canInviteLab={canAccessOrganizations}
onInviteLab={() => router.push('/organizations?action=invite-lab')} onInviteLab={() => router.push('/organizations?action=invite-lab')}

View File

@@ -0,0 +1,50 @@
'use client';
import { useCallback, useRef, useState } from 'react';
/**
* Runs an async action at most once at a time (sync re-entry guard via ref).
* Use for API mutations from buttons/controls that are not the shared Button,
* or when several controls share one pending flag.
*/
export function useAsyncAction() {
const [pending, setPending] = useState(false);
const pendingRef = useRef(false);
const run = useCallback(async <T,>(fn: () => Promise<T>): Promise<T | undefined> => {
if (pendingRef.current) return undefined;
pendingRef.current = true;
setPending(true);
try {
return await fn();
} finally {
pendingRef.current = false;
setPending(false);
}
}, []);
return { pending, run };
}
/**
* Same as useAsyncAction, but tracks which row/id is in flight so list UIs
* can disable sibling actions while one mutation runs.
*/
export function useAsyncActionById() {
const [pendingId, setPendingId] = useState<string | null>(null);
const pendingIdRef = useRef<string | null>(null);
const run = useCallback(async <T,>(id: string, fn: () => Promise<T>): Promise<T | undefined> => {
if (pendingIdRef.current) return undefined;
pendingIdRef.current = id;
setPendingId(id);
try {
return await fn();
} finally {
pendingIdRef.current = null;
setPendingId(null);
}
}, []);
return { pendingId, run };
}