improvement: loading state added to buttons who interact with backend.
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -182,7 +182,7 @@ export default function ForgotPasswordPage() {
|
||||
variant="primary"
|
||||
isLoading={isSending}
|
||||
fullWidth
|
||||
onClick={() => void onSendCode()}
|
||||
onClick={() => onSendCode()}
|
||||
>
|
||||
{t('sendCode')}
|
||||
</Button>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -285,7 +285,7 @@ export function AppointmentsPage() {
|
||||
!!activeEditingAppointment &&
|
||||
!activeEditingAppointment.hasTreatment
|
||||
}
|
||||
onDelete={() => void handleDeleteEditingAppointment()}
|
||||
onDelete={() => handleDeleteEditingAppointment()}
|
||||
deleting={deletingAppointment}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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(() => {
|
||||
setOpen(false);
|
||||
void logout();
|
||||
}, [logout]);
|
||||
return runLogout(async () => {
|
||||
await logout();
|
||||
setOpen(false);
|
||||
});
|
||||
}, [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>
|
||||
)}
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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;
|
||||
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')));
|
||||
}
|
||||
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')}
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,20 +172,19 @@ export function OrganizationsPage() {
|
||||
}, [query, formatApiMessage, showError, setToastError]);
|
||||
|
||||
async function submitConnectionRequest(targetOrganizationId: string) {
|
||||
setPendingConnectionRowId(targetOrganizationId);
|
||||
toast.setError('');
|
||||
try {
|
||||
await organizationApi.createConnectionRequest(targetOrganizationId);
|
||||
toast.showSuccess(t('successConnectionSent', { counterpart }));
|
||||
setSearchResults([]);
|
||||
setQuery('');
|
||||
setMode('existing');
|
||||
await loadList();
|
||||
} catch (e) {
|
||||
toast.showError(formatApiMessage(e));
|
||||
} finally {
|
||||
setPendingConnectionRowId(null);
|
||||
}
|
||||
await connectionBusy.run(targetOrganizationId, async () => {
|
||||
toast.setError('');
|
||||
try {
|
||||
await organizationApi.createConnectionRequest(targetOrganizationId);
|
||||
toast.showSuccess(t('successConnectionSent', { counterpart }));
|
||||
setSearchResults([]);
|
||||
setQuery('');
|
||||
setMode('existing');
|
||||
await loadList();
|
||||
} catch (e) {
|
||||
toast.showError(formatApiMessage(e));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function sendInvite() {
|
||||
@@ -272,34 +272,32 @@ export function OrganizationsPage() {
|
||||
}
|
||||
|
||||
async function respondToPendingConnection(connectionId: string, action: 'ACCEPT' | 'REJECT') {
|
||||
setPendingConnectionRowId(connectionId);
|
||||
toast.setError('');
|
||||
try {
|
||||
await organizationApi.respondToConnectionRequest(connectionId, action);
|
||||
toast.showSuccess(
|
||||
action === 'ACCEPT' ? t('successAccepted') : t('successDeclined'),
|
||||
);
|
||||
notifyPendingConnectionsChanged();
|
||||
await loadList();
|
||||
} catch (e) {
|
||||
toast.showError(formatApiMessage(e));
|
||||
} finally {
|
||||
setPendingConnectionRowId(null);
|
||||
}
|
||||
await connectionBusy.run(connectionId, async () => {
|
||||
toast.setError('');
|
||||
try {
|
||||
await organizationApi.respondToConnectionRequest(connectionId, action);
|
||||
toast.showSuccess(
|
||||
action === 'ACCEPT' ? t('successAccepted') : t('successDeclined'),
|
||||
);
|
||||
notifyPendingConnectionsChanged();
|
||||
await loadList();
|
||||
} catch (e) {
|
||||
toast.showError(formatApiMessage(e));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function deleteConnection(connectionId: string) {
|
||||
setDeleteConnectionRowId(connectionId);
|
||||
toast.setError('');
|
||||
try {
|
||||
await organizationApi.deleteConnection(connectionId);
|
||||
toast.showSuccess(t('successRemoved'));
|
||||
await loadList();
|
||||
} catch (e) {
|
||||
toast.showError(formatApiMessage(e));
|
||||
} finally {
|
||||
setDeleteConnectionRowId(null);
|
||||
}
|
||||
await deleteBusy.run(connectionId, async () => {
|
||||
toast.setError('');
|
||||
try {
|
||||
await organizationApi.deleteConnection(connectionId);
|
||||
toast.showSuccess(t('successRemoved'));
|
||||
await loadList();
|
||||
} catch (e) {
|
||||
toast.showError(formatApiMessage(e));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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')}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -435,7 +435,7 @@ export function AccountSettingsPage() {
|
||||
type="button"
|
||||
variant="primary"
|
||||
isLoading={participationLoading}
|
||||
onClick={() => void confirmRevokeParticipation()}
|
||||
onClick={() => confirmRevokeParticipation()}
|
||||
>
|
||||
{t('participateConfirmRevokeConfirm')}
|
||||
</Button>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />}
|
||||
|
||||
@@ -597,39 +597,37 @@ 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 {
|
||||
const res = await staffApi.getInvitationLink(lastInviteInfo.membershipId);
|
||||
if (currentOrganization?.id) {
|
||||
const nextLinks = {
|
||||
...pendingInviteLinks,
|
||||
[lastInviteInfo.membershipId]: {
|
||||
membershipId: lastInviteInfo.membershipId,
|
||||
email: lastInviteInfo.email,
|
||||
invitationUrl: res.data.invitationUrl,
|
||||
},
|
||||
};
|
||||
setPendingInviteLinks(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);
|
||||
setCopyingInviteMembershipId(lastInviteInfo.membershipId);
|
||||
toast.setError('');
|
||||
try {
|
||||
const res = await staffApi.getInvitationLink(lastInviteInfo.membershipId);
|
||||
if (currentOrganization?.id) {
|
||||
const nextLinks = {
|
||||
...pendingInviteLinks,
|
||||
[lastInviteInfo.membershipId]: {
|
||||
membershipId: lastInviteInfo.membershipId,
|
||||
email: lastInviteInfo.email,
|
||||
invitationUrl: res.data.invitationUrl,
|
||||
},
|
||||
};
|
||||
setPendingInviteLinks(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);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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')}
|
||||
|
||||
50
frontend/src/lib/hooks/useAsyncAction.ts
Normal file
50
frontend/src/lib/hooks/useAsyncAction.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user