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` |
| Tiny select (sort dir, etc.) | `FORM_SELECT_COMPACT_CLASS` |
| 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.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -368,7 +368,7 @@ export function TasksPage() {
setExpandedCommentsTaskId((prev) => (prev === id ? null : id))
}
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
type="button"
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')}
aria-label={labels.acceptRequest}
title={labels.acceptRequest}
@@ -145,7 +145,7 @@ export function OrganizationConnectionsMobileList({
<button
type="button"
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')}
aria-label={labels.declineRequest}
title={labels.declineRequest}
@@ -158,7 +158,7 @@ export function OrganizationConnectionsMobileList({
<button
type="button"
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)}
aria-label={labels.removeConnection}
title={labels.removeConnection}
@@ -195,7 +195,7 @@ export function OrganizationConnectionsMobileList({
<button
type="button"
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)}
aria-label={labels.sendRequest}
title={labels.sendRequest}

View File

@@ -4,6 +4,7 @@ import { useCallback, useEffect, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl';
import { useToast } from '@/lib/hooks/useToast';
import { useAsyncActionById } from '@/lib/hooks/useAsyncAction';
import { Check, Trash2, UserPlus, X } from 'lucide-react';
import { useAuth } from '@/lib/hooks/useAuth';
import { notifyPendingConnectionsChanged } from '@/lib/hooks/usePendingConnectionsCount';
@@ -81,8 +82,8 @@ export function OrganizationsPage() {
const [mode, setMode] = useState<TableMode>('existing');
const [searching, setSearching] = useState(false);
const [searchResults, setSearchResults] = useState<CounterpartSearchResultDto[]>([]);
const [pendingConnectionRowId, setPendingConnectionRowId] = useState<string | null>(null);
const [deleteConnectionRowId, setDeleteConnectionRowId] = useState<string | null>(null);
const connectionBusy = useAsyncActionById();
const deleteBusy = useAsyncActionById();
const [items, setItems] = useState<CounterpartItemDto[]>([]);
const [manualOrganizationName, setManualOrganizationName] = useState('');
@@ -171,7 +172,7 @@ export function OrganizationsPage() {
}, [query, formatApiMessage, showError, setToastError]);
async function submitConnectionRequest(targetOrganizationId: string) {
setPendingConnectionRowId(targetOrganizationId);
await connectionBusy.run(targetOrganizationId, async () => {
toast.setError('');
try {
await organizationApi.createConnectionRequest(targetOrganizationId);
@@ -182,9 +183,8 @@ export function OrganizationsPage() {
await loadList();
} catch (e) {
toast.showError(formatApiMessage(e));
} finally {
setPendingConnectionRowId(null);
}
});
}
async function sendInvite() {
@@ -272,7 +272,7 @@ export function OrganizationsPage() {
}
async function respondToPendingConnection(connectionId: string, action: 'ACCEPT' | 'REJECT') {
setPendingConnectionRowId(connectionId);
await connectionBusy.run(connectionId, async () => {
toast.setError('');
try {
await organizationApi.respondToConnectionRequest(connectionId, action);
@@ -283,13 +283,12 @@ export function OrganizationsPage() {
await loadList();
} catch (e) {
toast.showError(formatApiMessage(e));
} finally {
setPendingConnectionRowId(null);
}
});
}
async function deleteConnection(connectionId: string) {
setDeleteConnectionRowId(connectionId);
await deleteBusy.run(connectionId, async () => {
toast.setError('');
try {
await organizationApi.deleteConnection(connectionId);
@@ -297,9 +296,8 @@ export function OrganizationsPage() {
await loadList();
} catch (e) {
toast.showError(formatApiMessage(e));
} finally {
setDeleteConnectionRowId(null);
}
});
}
function clearSearchView() {
@@ -320,7 +318,7 @@ export function OrganizationsPage() {
<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>
</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')}
</Button>
</div>
@@ -349,8 +347,8 @@ export function OrganizationsPage() {
searchResults={searchResults}
currentOrganizationId={currentOrganization.id}
counterpart={counterpart}
pendingConnectionRowId={pendingConnectionRowId}
deleteConnectionRowId={deleteConnectionRowId}
pendingConnectionRowId={connectionBusy.pendingId}
deleteConnectionRowId={deleteBusy.pendingId}
copiedId={copiedId}
copyingInvitationId={copyingInvitationId}
showInviteForm={showInviteForm}
@@ -360,14 +358,14 @@ export function OrganizationsPage() {
formatConnectionStatusLabel={formatConnectionStatusLabel}
formatTableDate={formatTableDate}
getInvitationTarget={(row) => invitationTargetFromConnectionRow(row, currentOrganization.id)}
onCopyInvitation={(row) => void handleCopyInvitationFromRow(row)}
onRespond={(rowId, action) => void respondToPendingConnection(rowId, action)}
onDeleteConnection={(rowId) => void deleteConnection(rowId)}
onSendConnectionRequest={(orgId) => void submitConnectionRequest(orgId)}
onCopyInvitation={(row) => handleCopyInvitationFromRow(row)}
onRespond={(rowId, action) => respondToPendingConnection(rowId, action)}
onDeleteConnection={(rowId) => deleteConnection(rowId)}
onSendConnectionRequest={(orgId) => submitConnectionRequest(orgId)}
onToggleInviteForm={() => setShowInviteForm((v) => !v)}
onManualOrganizationNameChange={setManualOrganizationName}
onManualOwnerEmailChange={setManualOwnerEmail}
onSendInvite={() => void sendInvite()}
onSendInvite={() => sendInvite()}
labels={{
loading: tCommon('loadingEllipsis'),
emptyConnections: t('emptyConnections'),
@@ -462,8 +460,8 @@ export function OrganizationsPage() {
<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"
disabled={pendingConnectionRowId !== null && pendingConnectionRowId !== row.id}
onClick={() => void respondToPendingConnection(row.id, 'ACCEPT')}
disabled={connectionBusy.pendingId !== null}
onClick={() => respondToPendingConnection(row.id, 'ACCEPT')}
aria-label={t('acceptRequest')}
title={t('acceptRequest')}
>
@@ -472,8 +470,8 @@ export function OrganizationsPage() {
<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"
disabled={pendingConnectionRowId !== null && pendingConnectionRowId !== row.id}
onClick={() => void respondToPendingConnection(row.id, 'REJECT')}
disabled={connectionBusy.pendingId !== null}
onClick={() => respondToPendingConnection(row.id, 'REJECT')}
aria-label={t('declineRequest')}
title={t('declineRequest')}
>
@@ -486,8 +484,8 @@ export function OrganizationsPage() {
<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"
disabled={deleteConnectionRowId !== null && deleteConnectionRowId !== row.id}
onClick={() => void deleteConnection(row.id)}
disabled={deleteBusy.pendingId !== null}
onClick={() => deleteConnection(row.id)}
aria-label={t('removeConnection')}
title={t('removeConnection')}
>
@@ -514,10 +512,8 @@ export function OrganizationsPage() {
<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"
disabled={
pendingConnectionRowId !== null && pendingConnectionRowId !== r.id
}
onClick={() => void submitConnectionRequest(r.id)}
disabled={connectionBusy.pendingId !== null}
onClick={() => submitConnectionRequest(r.id)}
aria-label={t('sendRequest')}
title={t('sendRequest')}
>
@@ -556,7 +552,7 @@ export function OrganizationsPage() {
type="button"
isLoading={inviteLoading}
disabled={!manualOrganizationName.trim() || !manualOwnerEmail.trim()}
onClick={() => void sendInvite()}
onClick={() => sendInvite()}
className="w-full"
>
{t('sendInvitation')}

View File

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

View File

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

View File

@@ -140,7 +140,7 @@ export function OwnerWorkingHoursDialog({
type="button"
variant="outline"
isLoading={loading}
onClick={() => void handleSkip()}
onClick={() => handleSkip()}
>
{tStaff('skipForNow')}
</Button>
@@ -148,7 +148,7 @@ export function OwnerWorkingHoursDialog({
type="button"
isLoading={loading}
disabled={Boolean(validationError)}
onClick={() => void handleSave()}
onClick={() => handleSave()}
>
{t('participateSaveHours')}
</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 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;
size?: ButtonSize;
/** External loading (e.g. form-level busy). ORed with auto-pending from async onClick. */
isLoading?: boolean;
fullWidth?: boolean;
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> = ({
@@ -19,15 +30,20 @@ export const Button: React.FC<ButtonProps> = ({
children,
className = '',
disabled,
onClick,
...props
}) => {
const [autoPending, setAutoPending] = useState(false);
const autoPendingRef = useRef(false);
const pending = isLoading || autoPending;
const baseClasses =
'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';
const variantClasses: Record<ButtonVariant, string> = {
primary:
'bg-primary text-white hover:opacity-90 disabled:opacity-60',
primary: 'bg-primary text-white hover:opacity-90 disabled:opacity-60',
secondary:
'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 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 (
<button
className={`${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${widthClass} ${loadingClass} ${className}`}
disabled={disabled || isLoading}
aria-busy={isLoading || undefined}
type="button"
{...props}
className={`${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${widthClass} ${loadingClass} ${className}`}
disabled={disabled || pending}
aria-busy={pending || undefined}
onClick={handleClick}
>
{children}
</button>

View File

@@ -79,7 +79,7 @@ export function LanguageToggle() {
<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"
onClick={() => void switchLocale(option)}
onClick={() => switchLocale(option)}
>
<span>{t(option)}</span>
{selected && <Check className="h-4 w-4 text-primary shrink-0" />}

View File

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

View File

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

View File

@@ -2054,8 +2054,8 @@ export function TreatmentWorkspace({
});
}}
sendBusyId={sendBusyId}
onAddLabCase={() => void handleAddLabCase()}
onSendLabCase={(lc, comment) => void handleSendLabCase(lc, comment)}
onAddLabCase={() => handleAddLabCase()}
onSendLabCase={(lc, comment) => handleSendLabCase(lc, comment)}
onCommentError={showError}
canInviteLab={canAccessOrganizations}
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 };
}