Three new <EntityCard> files using the shared <ListCard> shell, wired
into each list page's <DataTable> via cardRender.
- ReminderCard: Bell icon, related-entity subtitle (User/Anchor/
FileText icon by entity type), due-date meta with
past-due flag, accent bar (rose=past-due,
amber=pending, slate=snoozed, emerald=done).
Snooze/Complete/Edit/Delete in actions menu.
- AuditLogCard: Action icon (Plus/Pencil/Trash2/Eye), entity
title, "{verb} by {actor}" subtitle, timestamp
meta, optional changed-field chip line. Accent
bar by action (created=emerald, updated=blue,
deleted=rose). Immutable, no actions menu.
- UserCard: Initials avatar, displayName/email, role meta
(Shield icon), last-login distance, "Inactive"
pill when deactivated. Accent bar (violet=
super_admin, slate=inactive, none=active).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
150 lines
5.0 KiB
TypeScript
150 lines
5.0 KiB
TypeScript
'use client';
|
|
|
|
import { Clock, Mail, MoreHorizontal, Pencil, Shield, Trash2 } from 'lucide-react';
|
|
import { formatDistanceToNow } from 'date-fns';
|
|
|
|
import { Button } from '@/components/ui/button';
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from '@/components/ui/dropdown-menu';
|
|
import { ConfirmationDialog } from '@/components/shared/confirmation-dialog';
|
|
import {
|
|
ListCard,
|
|
ListCardAvatar,
|
|
ListCardMeta,
|
|
deriveInitials,
|
|
} from '@/components/shared/list-card';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
interface UserRow {
|
|
userId: string;
|
|
displayName: string;
|
|
email: string;
|
|
phone: string | null;
|
|
isActive: boolean;
|
|
isSuperAdmin: boolean;
|
|
lastLoginAt: string | null;
|
|
role: { id: string; name: string };
|
|
assignedAt: string;
|
|
}
|
|
|
|
interface UserCardProps {
|
|
user: UserRow;
|
|
onEdit: (user: UserRow) => void;
|
|
onRemove: (userId: string) => void;
|
|
isRemoving: boolean;
|
|
}
|
|
|
|
export function UserCard({ user, onEdit, onRemove, isRemoving }: UserCardProps) {
|
|
const initials = deriveInitials(user.displayName || user.email);
|
|
|
|
const accentClass = user.isSuperAdmin
|
|
? 'bg-violet-400'
|
|
: !user.isActive
|
|
? 'bg-slate-400'
|
|
: undefined;
|
|
|
|
return (
|
|
<ListCard
|
|
href="#"
|
|
ariaLabel={`User: ${user.displayName}`}
|
|
accentClassName={accentClass}
|
|
actions={
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-9 w-9"
|
|
onClick={(e) => e.stopPropagation()}
|
|
aria-label={`Actions for ${user.displayName}`}
|
|
>
|
|
<MoreHorizontal className="h-4 w-4" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end">
|
|
<DropdownMenuItem
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
onEdit(user);
|
|
}}
|
|
>
|
|
<Pencil className="mr-2 h-3.5 w-3.5" />
|
|
Edit
|
|
</DropdownMenuItem>
|
|
<ConfirmationDialog
|
|
trigger={
|
|
<DropdownMenuItem className="text-destructive" onSelect={(e) => e.preventDefault()}>
|
|
<Trash2 className="mr-2 h-3.5 w-3.5" />
|
|
Remove
|
|
</DropdownMenuItem>
|
|
}
|
|
title="Remove User"
|
|
description={`Remove "${user.displayName}" from this port? They will lose access but their account remains.`}
|
|
confirmLabel="Remove"
|
|
onConfirm={() => onRemove(user.userId)}
|
|
loading={isRemoving}
|
|
/>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
}
|
|
>
|
|
<div className="flex items-start gap-3">
|
|
<ListCardAvatar initials={initials} className={cn(!user.isActive && 'opacity-50')} />
|
|
<div className="min-w-0 flex-1">
|
|
{/* Title row + spacer for actions button */}
|
|
<div className="flex items-start justify-between gap-2">
|
|
<h3
|
|
className={cn(
|
|
'truncate text-base font-semibold tracking-tight',
|
|
user.isActive ? 'text-foreground' : 'text-muted-foreground',
|
|
)}
|
|
>
|
|
{user.displayName || user.email}
|
|
</h3>
|
|
<span aria-hidden className="block h-9 w-9 shrink-0" />
|
|
</div>
|
|
|
|
{/* Email subtitle — only when display name is shown as title */}
|
|
{user.displayName && user.displayName !== user.email ? (
|
|
<p className="mt-0.5 inline-flex items-center gap-1 truncate text-sm text-muted-foreground">
|
|
<Mail className="h-3.5 w-3.5 shrink-0 text-muted-foreground/70" aria-hidden />
|
|
<span className="truncate">{user.email}</span>
|
|
</p>
|
|
) : null}
|
|
|
|
{/* Role + last login meta */}
|
|
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-muted-foreground">
|
|
<ListCardMeta icon={<Shield className="h-3 w-3" />}>{user.role.name}</ListCardMeta>
|
|
|
|
{user.lastLoginAt ? (
|
|
<ListCardMeta icon={<Clock className="h-3 w-3" />}>
|
|
{formatDistanceToNow(new Date(user.lastLoginAt), { addSuffix: true })}
|
|
</ListCardMeta>
|
|
) : (
|
|
<ListCardMeta icon={<Clock className="h-3 w-3" />}>Never logged in</ListCardMeta>
|
|
)}
|
|
</div>
|
|
|
|
{/* Status + super-admin pills */}
|
|
<div className="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1">
|
|
{!user.isActive ? (
|
|
<span className="inline-flex items-center rounded-full bg-slate-200 px-2 py-0.5 text-xs font-medium text-slate-700">
|
|
Inactive
|
|
</span>
|
|
) : null}
|
|
{user.isSuperAdmin ? (
|
|
<span className="inline-flex items-center rounded-full bg-violet-100 px-2 py-0.5 text-xs font-medium text-violet-700">
|
|
Super Admin
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</ListCard>
|
|
);
|
|
}
|