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` |
|
| 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.
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -285,7 +285,7 @@ export function AppointmentsPage() {
|
|||||||
!!activeEditingAppointment &&
|
!!activeEditingAppointment &&
|
||||||
!activeEditingAppointment.hasTreatment
|
!activeEditingAppointment.hasTreatment
|
||||||
}
|
}
|
||||||
onDelete={() => void handleDeleteEditingAppointment()}
|
onDelete={() => handleDeleteEditingAppointment()}
|
||||||
deleting={deletingAppointment}
|
deleting={deletingAppointment}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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')}
|
||||||
|
|||||||
@@ -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')}
|
||||||
|
|||||||
@@ -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')}
|
||||||
|
|||||||
@@ -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 }) {
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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')}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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" />}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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')}
|
||||||
|
|||||||
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