Files
dyolink/frontend/src/app/[locale]/(dashboard)/organizations/page.tsx

523 lines
20 KiB
TypeScript
Raw Normal View History

'use client';
import { useEffect, useState } from 'react';
import { useToast } from '@/lib/hooks/useToast';
import { Check, Trash2, UserPlus, X } from 'lucide-react';
import { useAuth } from '@/lib/hooks/useAuth';
import { useOrganizationInviteLinkCopy } from '@/lib/hooks/useOrganizationInviteLinkCopy';
import {
organizationApi,
type CounterpartItemDto,
type CounterpartSearchResultDto,
type OrganizationInvitationHistoryItemDto,
} from '@/lib/api/organization';
import { invitationTargetFromConnectionRow } from '@/components/invitations/organizationInviteLinks';
import { CopyInvitationLinkButton } from '@/components/ui/organizations/CopyInvitationLinkButton';
import { InvitationHistoryDialog } from '@/components/ui/organizations/InvitationHistoryDialog';
import { Button } from '@/components/ui/shared/Button';
import { Badge, organizationConnectionStatusVariant } from '@/components/ui/shared/Badge';
import { Input } from '@/components/ui/shared/Input';
import { SearchBar } from '@/components/ui/shared/SearchBar';
import { Table } from '@/components/ui/shared/Table';
import { ToastStack } from '@/components/ui/shared/Toast';
import type { ApiError } from '@/types/api';
function formatOrganizationStatusLabel(status: string): string {
if (!status) return status;
const lower = status.toLowerCase();
return lower.charAt(0).toUpperCase() + lower.slice(1);
}
function formatConnectionStatusLabel(
row: CounterpartItemDto,
currentOrganizationId: string,
): string {
if (row.status === 'PENDING') {
if (
row.pendingInvitationId &&
row.requestedByOrganizationId === currentOrganizationId
) {
return 'Invitation pending';
}
return 'Connection request pending';
}
if (row.status === 'ACTIVE') return 'Connected';
if (row.status === 'REJECTED') return 'Connection request declined';
return formatOrganizationStatusLabel(row.status);
}
function formatApiMessage(err: unknown): string {
if (!err || typeof err !== 'object') return 'Something went wrong';
const m = (err as ApiError).message;
if (Array.isArray(m)) return m.join(', ');
if (typeof m === 'string') return m;
return 'Something went wrong';
}
function formatTableDate(value: string): string {
const d = new Date(value);
if (Number.isNaN(d.getTime())) return '\u2014';
return d.toLocaleDateString();
}
type TableMode = 'existing' | 'search';
export default function OrganizationsPage() {
const { currentOrganization } = useAuth();
const [loading, setLoading] = useState(true);
const toast = useToast();
const [query, setQuery] = useState('');
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 [items, setItems] = useState<CounterpartItemDto[]>([]);
const [manualOrganizationName, setManualOrganizationName] = useState('');
const [manualOwnerEmail, setManualOwnerEmail] = useState('');
const [inviteLoading, setInviteLoading] = useState(false);
const [showInviteForm, setShowInviteForm] = useState(false);
const [historyOpen, setHistoryOpen] = useState(false);
const [historyLoading, setHistoryLoading] = useState(false);
const [historyItems, setHistoryItems] = useState<OrganizationInvitationHistoryItemDto[]>([]);
const {
copiedId,
copyingInvitationId,
storeInviteLink,
copyInvitationLink,
pruneAcceptedLinks,
} = useOrganizationInviteLinkCopy(currentOrganization?.id);
const counterpartLabel = currentOrganization?.type === 'LAB' ? 'Clinic' : 'Lab';
const tabLabel = currentOrganization?.type === 'LAB' ? 'Clinics' : 'Labs';
const existingRows = items;
async function loadList() {
setLoading(true);
toast.setError('');
try {
const res = await organizationApi.list();
setItems(res.data.items);
} catch (e) {
toast.showError(formatApiMessage(e));
} finally {
setLoading(false);
}
}
useEffect(() => {
void loadList();
}, []);
async function runSearch() {
const q = query.trim();
if (!q) {
setMode('existing');
setSearchResults([]);
setShowInviteForm(false);
return;
}
setSearching(true);
toast.setError('');
setMode('search');
setShowInviteForm(false);
try {
const res = await organizationApi.search(q);
setSearchResults(res.data);
} catch (e) {
toast.showError(formatApiMessage(e));
setSearchResults([]);
} finally {
setSearching(false);
}
}
async function submitConnectionRequest(targetOrganizationId: string) {
setPendingConnectionRowId(targetOrganizationId);
toast.setError('');
try {
await organizationApi.createConnectionRequest(targetOrganizationId);
toast.showSuccess(`${counterpartLabel} connection request sent.`);
setSearchResults([]);
setQuery('');
setMode('existing');
await loadList();
} catch (e) {
toast.showError(formatApiMessage(e));
} finally {
setPendingConnectionRowId(null);
}
}
async function sendInvite() {
setInviteLoading(true);
toast.setError('');
try {
const res = await organizationApi.invite({
organizationName: manualOrganizationName.trim(),
ownerEmail: manualOwnerEmail.trim(),
});
storeInviteLink(res.data.invitationId, manualOwnerEmail, res.data.invitationUrl);
toast.showSuccess(`Invitation link created for ${manualOwnerEmail.trim()}`);
setManualOrganizationName('');
setManualOwnerEmail('');
setShowInviteForm(false);
setMode('existing');
setQuery('');
setSearchResults([]);
await loadList();
} catch (e) {
toast.showError(formatApiMessage(e));
} finally {
setInviteLoading(false);
}
}
async function loadInvitationHistory() {
const res = await organizationApi.listInvitations();
setHistoryItems(res.data.items);
pruneAcceptedLinks(res.data.items);
return res.data.items;
}
async function openInvitationHistory() {
setHistoryOpen(true);
setHistoryLoading(true);
toast.clear();
try {
await loadInvitationHistory();
} catch (e) {
toast.showError(formatApiMessage(e));
} finally {
setHistoryLoading(false);
}
}
async function handleHistoryCopy(invitation: OrganizationInvitationHistoryItemDto) {
toast.setError('');
try {
await copyInvitationLink(invitation, {
onRegenerated: async () => {
await loadInvitationHistory();
},
});
toast.showSuccess('Invitation link copied to clipboard.');
} catch (e) {
toast.showError(formatApiMessage(e));
}
}
async function handleCopyInvitationFromRow(row: CounterpartItemDto) {
const target = invitationTargetFromConnectionRow(row, currentOrganization!.id);
if (!target) return;
toast.setError('');
try {
await copyInvitationLink(
{
id: target.id,
organizationName: row.organizationName,
ownerEmail: target.ownerEmail,
status: target.status,
createdAt: row.createdAt,
acceptedAt: target.acceptedAt,
},
{
onRegenerated: async () => {
await loadList();
},
},
);
toast.showSuccess('Invitation link copied to clipboard.');
} catch (e) {
toast.showError(formatApiMessage(e));
}
}
async function respondToPendingConnection(connectionId: string, action: 'ACCEPT' | 'REJECT') {
setPendingConnectionRowId(connectionId);
toast.setError('');
try {
await organizationApi.respondToConnectionRequest(connectionId, action);
toast.showSuccess(
action === 'ACCEPT' ? 'Connection request accepted.' : 'Connection request declined.',
);
await loadList();
} catch (e) {
toast.showError(formatApiMessage(e));
} finally {
setPendingConnectionRowId(null);
}
}
async function deleteConnection(connectionId: string) {
setDeleteConnectionRowId(connectionId);
toast.setError('');
try {
await organizationApi.deleteConnection(connectionId);
toast.showSuccess('Connection removed.');
await loadList();
} catch (e) {
toast.showError(formatApiMessage(e));
} finally {
setDeleteConnectionRowId(null);
}
}
function clearSearchView() {
setMode('existing');
setQuery('');
setSearchResults([]);
setShowInviteForm(false);
}
if (!currentOrganization) {
return <p className="text-sm text-text-secondary">Loading organization...</p>;
}
return (
<div className="space-y-6">
<div className="flex flex-col gap-1 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 className="text-2xl font-semibold text-text-primary">{tabLabel}</h1>
<p className="text-sm text-text-secondary mt-1">
Search organizations, send connection requests to existing accounts, or invitation
links when they are not on DyoLink yet.
</p>
</div>
<Button type="button" size="sm" onClick={() => void openInvitationHistory()}>
Invitation History
</Button>
</div>
{!historyOpen && <ToastStack {...toast.messages} />}
<SearchBar
value={query}
onChange={setQuery}
onSubmit={() => void runSearch()}
placeholder={`Search ${counterpartLabel.toLowerCase()} by name, email, or phone...`}
actions={
<>
<button
type="button"
onClick={() => void runSearch()}
disabled={searching}
className="px-4 py-2 rounded-[var(--radius-sm)] text-sm font-medium border bg-primary-soft text-primary border-primary/50 disabled:opacity-60"
>
Search
</button>
{mode === 'search' && (
<button
type="button"
onClick={clearSearchView}
className="px-4 py-2 rounded-[var(--radius-sm)] text-sm font-medium border text-text-secondary border-border/40 hover:bg-background-card/70 hover:border-border"
>
Back to list
</button>
)}
</>
}
/>
<Table
headers={
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">
Organization
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">
Owner email
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">
Date
</th>
<th className="px-6 py-3 text-center text-xs font-medium text-text-muted uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-text-muted uppercase tracking-wider">
Action
</th>
</tr>
}
body={
<>
{loading ? (
<tr>
<td colSpan={5} className="px-6 py-8 text-sm text-text-secondary">
Loading...
</td>
</tr>
) : mode === 'existing' ? (
existingRows.length === 0 ? (
<tr>
<td colSpan={5} className="px-6 py-8 text-sm text-text-secondary">
No connections yet. Search to send a connection request or an invitation link.
</td>
</tr>
) : (
existingRows.map((row) => {
const canRespond =
row.status === 'PENDING' &&
row.requestedByOrganizationId !== null &&
row.requestedByOrganizationId !== currentOrganization.id;
const invitationTarget = invitationTargetFromConnectionRow(
row,
currentOrganization.id,
);
return (
<tr key={row.id} className="hover:bg-background-secondary/45">
<td className="px-6 py-1.5 text-sm font-medium text-text-primary">
{row.organizationName}
</td>
<td className="px-6 py-1.5 text-sm text-text-secondary">{row.ownerEmail}</td>
<td className="px-6 py-1.5 text-sm text-text-secondary">
{formatTableDate(row.createdAt)}
</td>
<td className="px-6 py-1.5 text-center align-middle">
<Badge variant={organizationConnectionStatusVariant(row.status)} fixedWidth={false}>
{formatConnectionStatusLabel(row, currentOrganization.id)}
</Badge>
</td>
<td className="px-6 py-1.5 text-right">
<div className="inline-flex items-center gap-2">
{invitationTarget && (
<CopyInvitationLinkButton
invitation={invitationTarget}
copied={copiedId === invitationTarget.id}
copying={copyingInvitationId === invitationTarget.id}
onCopy={() => void handleCopyInvitationFromRow(row)}
/>
)}
{canRespond && (
<>
<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')}
aria-label="Accept connection request"
title="Accept connection request"
>
<Check className="w-4 h-4" />
</button>
<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')}
aria-label="Decline connection request"
title="Decline connection request"
>
<X className="w-4 h-4" />
</button>
</>
)}
{row.status === 'ACTIVE' && (
<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)}
aria-label="Remove connection"
title="Remove connection"
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
</td>
</tr>
);
})
)
) : searchResults.length > 0 ? (
searchResults.map((r) => (
<tr key={r.id} className="hover:bg-background-secondary/45">
<td className="px-6 py-1.5 text-sm font-medium text-text-primary">{r.name}</td>
<td className="px-6 py-1.5 text-sm text-text-secondary">{r.owner.email}</td>
<td className="px-6 py-1.5 text-sm text-text-secondary">Today</td>
<td className="px-6 py-1.5 text-center align-middle">
<Badge variant="default" fixedWidth={false}>Found</Badge>
</td>
<td className="px-6 py-1.5 text-right">
<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)}
aria-label="Send connection request"
title="Send connection request"
>
<UserPlus className="w-4 h-4" />
</button>
</td>
</tr>
))
) : (
<tr>
<td colSpan={5} className="px-6 py-6">
<div className="flex flex-col gap-3">
<p className="text-sm text-text-secondary">
No organization found in directory search.
</p>
<div className="flex flex-wrap items-center gap-2">
<Button type="button" onClick={() => setShowInviteForm((v) => !v)}>
{showInviteForm ? 'Hide invitation fields' : 'Send invitation link'}
</Button>
</div>
{showInviteForm && (
<div className="grid gap-3 sm:grid-cols-3 mt-1">
<Input
label={`${counterpartLabel} name`}
value={manualOrganizationName}
onChange={(e) => setManualOrganizationName(e.target.value)}
/>
<Input
label="Owner email"
type="email"
value={manualOwnerEmail}
onChange={(e) => setManualOwnerEmail(e.target.value)}
/>
<div className="flex items-end">
<Button
type="button"
isLoading={inviteLoading}
disabled={!manualOrganizationName.trim() || !manualOwnerEmail.trim()}
onClick={() => void sendInvite()}
className="w-full"
>
Send invitation
</Button>
</div>
</div>
)}
</div>
</td>
</tr>
)}
</>
}
/>
<InvitationHistoryDialog
open={historyOpen}
onClose={() => setHistoryOpen(false)}
loading={historyLoading}
items={historyItems}
copiedId={copiedId}
copyingInvitationId={copyingInvitationId}
onCopy={(invitation) => void handleHistoryCopy(invitation)}
toastMessages={toast.messages}
/>
</div>
);
}