improvement: dashboard UX improved

This commit is contained in:
2026-04-30 18:49:23 +03:30
parent c2be551a04
commit b6bd4b47be
10 changed files with 232 additions and 45 deletions

View File

@@ -12,6 +12,7 @@ import {
permissionNamesFromFeatureState,
emptyFeaturePermissionState,
featureStateFromPermissionNames,
resolveStaffFeatureLabel,
formatAccessSummary,
type FeaturePermState,
} from './staff-permission-form';
@@ -23,6 +24,33 @@ import { Input } from '@/components/ui/common/Input';
import { Checkbox } from '@/components/ui/common/Checkbox';
import type { ApiError } from '@/types/api';
type StoredInviteLink = {
membershipId: string;
email: string;
invitationUrl: string;
};
function inviteLinksStorageKey(orgId: string): string {
return `staffInviteLinks:${orgId}`;
}
function readStoredInviteLinks(orgId: string): Record<string, StoredInviteLink> {
if (typeof window === 'undefined') return {};
try {
const raw = window.localStorage.getItem(inviteLinksStorageKey(orgId));
if (!raw) return {};
const parsed = JSON.parse(raw) as Record<string, StoredInviteLink>;
return parsed && typeof parsed === 'object' ? parsed : {};
} catch {
return {};
}
}
function writeStoredInviteLinks(orgId: string, links: Record<string, StoredInviteLink>) {
if (typeof window === 'undefined') return;
window.localStorage.setItem(inviteLinksStorageKey(orgId), JSON.stringify(links));
}
function formatApiMessage(err: unknown): string {
if (!err || typeof err !== 'object') return 'Something went wrong';
const m = (err as ApiError).message;
@@ -35,10 +63,12 @@ function PermissionGrid({
state,
onChange,
disabled,
organizationType,
}: {
state: FeaturePermState;
onChange: (next: FeaturePermState) => void;
disabled?: boolean;
organizationType?: 'CLINIC' | 'LAB';
}) {
const setRead = (editKey: string, read: boolean) => {
const cur = state[editKey] ?? { read: false, edit: false };
@@ -65,7 +95,9 @@ function PermissionGrid({
key={g.edit}
className="flex flex-col gap-3 rounded-[var(--radius-md)] border border-border/60 bg-background-card/50 px-3 py-3"
>
<span className="text-sm font-medium text-text-primary">{g.label}</span>
<span className="text-sm font-medium text-text-primary">
{resolveStaffFeatureLabel(g, organizationType)}
</span>
<div className="flex flex-col gap-2.5 pl-0.5">
<Checkbox
checked={cell.read}
@@ -105,13 +137,15 @@ export default function StaffPage() {
const [inviteName, setInviteName] = useState('');
const [invitePerms, setInvitePerms] = useState(() => emptyFeaturePermissionState());
const [inviteLoading, setInviteLoading] = useState(false);
const [copiedInviteLink, setCopiedInviteLink] = useState(false);
const [copiedInviteMembershipId, setCopiedInviteMembershipId] = useState<string | null>(null);
const [lastInviteInfo, setLastInviteInfo] = useState<{
membershipId: string;
name: string;
email: string;
invitationUrl: string | null;
invitationStatus: 'PENDING' | 'ACCEPTED';
} | null>(null);
const [pendingInviteLinks, setPendingInviteLinks] = useState<Record<string, StoredInviteLink>>({});
const [editing, setEditing] = useState<StaffMemberDto | null>(null);
const [editName, setEditName] = useState('');
@@ -140,6 +174,34 @@ export default function StaffPage() {
}
}, []);
useEffect(() => {
if (!currentOrganization?.id) return;
setPendingInviteLinks(readStoredInviteLinks(currentOrganization.id));
}, [currentOrganization?.id]);
useEffect(() => {
if (!currentOrganization?.id || loading) return;
const activeMemberIds = new Set(
members
.filter((m) => m.isOwner || m.invitationStatus === 'ACTIVE')
.map((m) => m.id),
);
let changed = false;
const nextLinks: Record<string, StoredInviteLink> = { ...pendingInviteLinks };
for (const memberId of Object.keys(nextLinks)) {
if (activeMemberIds.has(memberId)) {
delete nextLinks[memberId];
changed = true;
}
}
if (!changed) return;
setPendingInviteLinks(nextLinks);
writeStoredInviteLinks(currentOrganization.id, nextLinks);
}, [currentOrganization?.id, loading, members, pendingInviteLinks]);
useEffect(() => {
void load();
}, [load]);
@@ -171,11 +233,24 @@ export default function StaffPage() {
permissionNames,
});
setLastInviteInfo({
membershipId: res.data.membershipId,
name: displayName,
email: res.data.email,
invitationUrl: res.data.invitationUrl,
invitationStatus: res.data.invitationStatus,
});
if (currentOrganization?.id && res.data.invitationUrl) {
const nextLinks = {
...pendingInviteLinks,
[res.data.membershipId]: {
membershipId: res.data.membershipId,
email: res.data.email,
invitationUrl: res.data.invitationUrl,
},
};
setPendingInviteLinks(nextLinks);
writeStoredInviteLinks(currentOrganization.id, nextLinks);
}
setSuccess('');
setInviteOpen(false);
setInviteEmail('');
@@ -328,15 +403,21 @@ export default function StaffPage() {
onClick={async () => {
try {
await navigator.clipboard.writeText(lastInviteInfo.invitationUrl as string);
setCopiedInviteLink(true);
setTimeout(() => setCopiedInviteLink(false), 1500);
setCopiedInviteMembershipId(lastInviteInfo.membershipId);
setTimeout(() => setCopiedInviteMembershipId(null), 1500);
} catch {
setError('Could not copy invitation link');
}
}}
>
{copiedInviteLink ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
<span className="ml-1">{copiedInviteLink ? 'Copied' : 'Copy link'}</span>
{copiedInviteMembershipId === lastInviteInfo.membershipId ? (
<Check className="w-4 h-4" />
) : (
<Copy className="w-4 h-4" />
)}
<span className="ml-1">
{copiedInviteMembershipId === lastInviteInfo.membershipId ? 'Copied' : 'Copy link'}
</span>
</Button>
</div>
<p className="text-xs text-text-muted">
@@ -364,7 +445,7 @@ export default function StaffPage() {
</thead>
<tbody>
{members.map((m) => (
<tr key={m.id} className="border-b border-border/40 last:border-0">
<tr key={m.id} className="h-14 border-b border-border/40 last:border-0">
<td className="p-3 text-text-primary">{m.name}</td>
<td className="p-3 text-text-secondary">{m.email}</td>
<td className="p-3">
@@ -374,7 +455,7 @@ export default function StaffPage() {
<span className="text-text-secondary">Staff</span>
)}
</td>
<td className="p-3">
<td className="p-3 align-middle">
{m.isOwner || m.invitationStatus === 'ACTIVE' ? (
<span className="inline-flex items-center rounded-full border border-emerald-600/40 bg-emerald-600/15 px-2 py-0.5 text-xs text-emerald-400">
Active
@@ -395,13 +476,36 @@ export default function StaffPage() {
<span className="text-text-muted">All features</span>
) : (
<span className="line-clamp-3 text-sm leading-relaxed">
{formatAccessSummary(m.permissions)}
{formatAccessSummary(m.permissions, currentOrganization?.type)}
</span>
)}
</td>
<td className="p-3">
<td className="p-3 align-middle">
{!m.isOwner && (
<div className="flex items-center gap-1">
<div className="flex min-h-[36px] items-center justify-end gap-1">
{m.invitationStatus === 'PENDING' && pendingInviteLinks[m.id]?.invitationUrl && (
<button
type="button"
className="p-2 rounded-md text-text-secondary hover:bg-background-card/80 hover:text-text-primary"
onClick={async () => {
try {
await navigator.clipboard.writeText(pendingInviteLinks[m.id].invitationUrl);
setCopiedInviteMembershipId(m.id);
setTimeout(() => setCopiedInviteMembershipId(null), 1500);
} catch {
setError('Could not copy invitation link');
}
}}
aria-label="Copy invite link"
title="Copy invite link"
>
{copiedInviteMembershipId === m.id ? (
<Check className="w-4 h-4" />
) : (
<Copy className="w-4 h-4" />
)}
</button>
)}
<button
type="button"
className={`p-2 rounded-md ${
@@ -469,7 +573,11 @@ export default function StaffPage() {
/>
<div>
<p className="text-sm font-medium text-text-secondary mb-2">Tab access</p>
<PermissionGrid state={invitePerms} onChange={setInvitePerms} />
<PermissionGrid
state={invitePerms}
onChange={setInvitePerms}
organizationType={currentOrganization?.type}
/>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" type="button" onClick={() => setInviteOpen(false)}>
@@ -500,7 +608,11 @@ export default function StaffPage() {
<Input label="Display name" value={editName} onChange={(e) => setEditName(e.target.value)} />
<div>
<p className="text-sm font-medium text-text-secondary mb-2">Tab access</p>
<PermissionGrid state={editPerms} onChange={setEditPerms} />
<PermissionGrid
state={editPerms}
onChange={setEditPerms}
organizationType={currentOrganization?.type}
/>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" type="button" onClick={() => setEditing(null)}>