Files
pn-new-crm/src/components/admin/users/user-card.tsx
Matt Ciaccio bcea28cd71 feat(mobile): mobile cards for reminders, audit log, users
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>
2026-05-01 15:39:06 +02:00

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>
);
}