From bcea28cd71f3e9d563ec2aa08c0c7b0c0a6d577b Mon Sep 17 00:00:00 2001 From: Matt Ciaccio Date: Fri, 1 May 2026 15:39:06 +0200 Subject: [PATCH] feat(mobile): mobile cards for reminders, audit log, users Three new files using the shared shell, wired into each list page's 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) --- src/components/admin/audit/audit-log-card.tsx | 151 +++++++++++ src/components/admin/audit/audit-log-list.tsx | 2 + src/components/admin/users/user-card.tsx | 149 ++++++++++ src/components/admin/users/user-list.tsx | 9 + src/components/reminders/reminder-card.tsx | 256 ++++++++++++++++++ src/components/reminders/reminder-list.tsx | 17 ++ 6 files changed, 584 insertions(+) create mode 100644 src/components/admin/audit/audit-log-card.tsx create mode 100644 src/components/admin/users/user-card.tsx create mode 100644 src/components/reminders/reminder-card.tsx diff --git a/src/components/admin/audit/audit-log-card.tsx b/src/components/admin/audit/audit-log-card.tsx new file mode 100644 index 0000000..28d406c --- /dev/null +++ b/src/components/admin/audit/audit-log-card.tsx @@ -0,0 +1,151 @@ +'use client'; + +import { Activity, Clock, Eye, Pencil, Plus, Trash2, User } from 'lucide-react'; +import { formatDistanceToNow } from 'date-fns'; + +import { ListCard, ListCardAvatar, ListCardMeta } from '@/components/shared/list-card'; +import { cn } from '@/lib/utils'; + +interface AuditEntry { + id: string; + userId: string | null; + action: string; + entityType: string; + entityId: string | null; + fieldChanged: string | null; + oldValue: Record | null; + newValue: Record | null; + metadata: Record | null; + ipAddress: string | null; + createdAt: string; + actor: { id: string; email: string; name: string } | null; +} + +const ACTION_ACCENT: Record = { + create: 'bg-emerald-400', + update: 'bg-blue-400', + delete: 'bg-rose-400', + viewed: 'bg-slate-300', +}; + +const ACTION_BADGE_COLORS: Record = { + create: 'bg-green-600', + update: 'bg-blue-500', + delete: 'bg-red-600', + archive: 'bg-orange-500', + restore: 'bg-teal-500', + login: 'bg-gray-500', + permission_denied: 'bg-red-800', + merge: 'bg-purple-500', + revert: 'bg-amber-500', +}; + +function ActionIcon({ action }: { action: string }) { + if (action === 'create') return ; + if (action === 'update') return ; + if (action === 'delete') return ; + if (action === 'viewed') return ; + return ; +} + +function actionVerb(action: string): string { + const map: Record = { + create: 'Created', + update: 'Updated', + delete: 'Deleted', + archive: 'Archived', + restore: 'Restored', + login: 'Logged in', + permission_denied: 'Permission denied', + merge: 'Merged', + revert: 'Reverted', + viewed: 'Viewed', + }; + return map[action] ?? action.charAt(0).toUpperCase() + action.slice(1); +} + +interface AuditLogCardProps { + entry: AuditEntry; +} + +export function AuditLogCard({ entry }: AuditLogCardProps) { + const accentClass = ACTION_ACCENT[entry.action] ?? 'bg-slate-300'; + const badgeColor = ACTION_BADGE_COLORS[entry.action] ?? 'bg-gray-500'; + + const entityTitle = `${entry.entityType.charAt(0).toUpperCase()}${entry.entityType.slice(1)}${ + entry.entityId ? ` ${entry.entityId.slice(0, 8)}…` : '' + }`; + + const actorName = entry.actor?.name ?? (entry.userId ? `${entry.userId.slice(0, 8)}…` : 'system'); + + // Changed-fields chip line: prefer fieldChanged (single field), then newValue keys + let changedFields: string[] = []; + if (entry.fieldChanged) { + changedFields = [entry.fieldChanged]; + } else if (entry.newValue) { + changedFields = Object.keys(entry.newValue); + } + const visibleFields = changedFields.slice(0, 3); + const overflowCount = changedFields.length - visibleFields.length; + + return ( + +
+ } /> +
+ {/* Title: entity type + short ID */} +

+ {entityTitle} +

+ + {/* Subtitle: action verb + actor */} +

+ + + {actionVerb(entry.action)} by {actorName} + +

+ + {/* Timestamp meta line */} +
+ }> + {formatDistanceToNow(new Date(entry.createdAt), { addSuffix: true })} + +
+ + {/* Action badge + changed-fields chips */} +
+ + {entry.action} + + + {visibleFields.length > 0 ? ( + <> + {visibleFields.map((field) => ( + + {field} + + ))} + {overflowCount > 0 ? ( + +{overflowCount} + ) : null} + + ) : null} +
+
+
+
+ ); +} diff --git a/src/components/admin/audit/audit-log-list.tsx b/src/components/admin/audit/audit-log-list.tsx index f74c37d..e076926 100644 --- a/src/components/admin/audit/audit-log-list.tsx +++ b/src/components/admin/audit/audit-log-list.tsx @@ -19,6 +19,7 @@ import { SelectValue, } from '@/components/ui/select'; import { apiFetch } from '@/lib/api/client'; +import { AuditLogCard } from './audit-log-card'; interface AuditEntry { id: string; @@ -357,6 +358,7 @@ export function AuditLogList() { data={entries} isLoading={loading} getRowId={(row) => row.id} + cardRender={(row) => } emptyState={

No audit log entries found.

diff --git a/src/components/admin/users/user-card.tsx b/src/components/admin/users/user-card.tsx new file mode 100644 index 0000000..2ec1281 --- /dev/null +++ b/src/components/admin/users/user-card.tsx @@ -0,0 +1,149 @@ +'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 ( + + + + + + { + e.preventDefault(); + onEdit(user); + }} + > + + Edit + + e.preventDefault()}> + + Remove + + } + 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} + /> + + + } + > +
+ +
+ {/* Title row + spacer for actions button */} +
+

+ {user.displayName || user.email} +

+ +
+ + {/* Email subtitle — only when display name is shown as title */} + {user.displayName && user.displayName !== user.email ? ( +

+ + {user.email} +

+ ) : null} + + {/* Role + last login meta */} +
+ }>{user.role.name} + + {user.lastLoginAt ? ( + }> + {formatDistanceToNow(new Date(user.lastLoginAt), { addSuffix: true })} + + ) : ( + }>Never logged in + )} +
+ + {/* Status + super-admin pills */} +
+ {!user.isActive ? ( + + Inactive + + ) : null} + {user.isSuperAdmin ? ( + + Super Admin + + ) : null} +
+
+
+
+ ); +} diff --git a/src/components/admin/users/user-list.tsx b/src/components/admin/users/user-list.tsx index f4a53ee..2853b0d 100644 --- a/src/components/admin/users/user-list.tsx +++ b/src/components/admin/users/user-list.tsx @@ -10,6 +10,7 @@ import { ConfirmationDialog } from '@/components/shared/confirmation-dialog'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { apiFetch } from '@/lib/api/client'; +import { UserCard } from './user-card'; import { UserForm } from './user-form'; interface UserRow { @@ -152,6 +153,14 @@ export function UserList() { data={users} isLoading={loading} getRowId={(row) => row.userId} + cardRender={(row) => ( + + )} emptyState={

No users assigned to this port.

diff --git a/src/components/reminders/reminder-card.tsx b/src/components/reminders/reminder-card.tsx new file mode 100644 index 0000000..496f45e --- /dev/null +++ b/src/components/reminders/reminder-card.tsx @@ -0,0 +1,256 @@ +'use client'; + +import { + Anchor, + Bell, + Calendar, + CheckCircle2, + Clock, + FileText, + MoreHorizontal, + User, + XCircle, +} from 'lucide-react'; +import { format } from 'date-fns'; + +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { ListCard, ListCardAvatar, ListCardMeta } from '@/components/shared/list-card'; +import { cn } from '@/lib/utils'; + +interface Reminder { + id: string; + title: string; + note: string | null; + dueAt: string; + priority: 'low' | 'medium' | 'high' | 'urgent'; + status: 'pending' | 'snoozed' | 'completed' | 'dismissed'; + assignedTo: string | null; + createdBy: string; + clientId: string | null; + interestId: string | null; + berthId: string | null; + autoGenerated: boolean; + snoozedUntil: string | null; + completedAt: string | null; + createdAt: string; + client?: { id: string; fullName: string } | null; + interest?: { id: string; pipelineStage: string } | null; + berth?: { id: string; mooringNumber: string } | null; +} + +const STATUS_CONFIG = { + pending: { label: 'Pending', icon: Bell }, + snoozed: { label: 'Snoozed', icon: Clock }, + completed: { label: 'Completed', icon: CheckCircle2 }, + dismissed: { label: 'Dismissed', icon: XCircle }, +} as const; + +const STATUS_PILL: Record = { + pending: 'bg-amber-100 text-amber-700', + snoozed: 'bg-slate-100 text-slate-700', + completed: 'bg-emerald-100 text-emerald-700', + dismissed: 'bg-emerald-100 text-emerald-700', +}; + +const PRIORITY_CONFIG = { + urgent: { label: 'Urgent', className: 'bg-red-600 text-white' }, + high: { label: 'High', className: 'bg-orange-500 text-white' }, + medium: { label: 'Medium', className: 'bg-blue-500 text-white' }, + low: { label: 'Low', className: 'bg-gray-400 text-white' }, +} as const; + +function accentForReminder(status: string, isPastDue: boolean): string { + if (isPastDue) return 'bg-rose-400'; + if (status === 'pending') return 'bg-amber-400'; + if (status === 'snoozed') return 'bg-slate-400'; + if (status === 'completed' || status === 'dismissed') return 'bg-emerald-400'; + return 'bg-slate-300'; +} + +interface ReminderCardProps { + reminder: Reminder; + portSlug: string; + onComplete: (id: string) => void; + onSnooze: (id: string) => void; + onDismiss: (id: string) => void; + onEdit: (reminder: Reminder) => void; +} + +export function ReminderCard({ + reminder, + portSlug: _portSlug, + onComplete, + onSnooze, + onDismiss, + onEdit, +}: ReminderCardProps) { + const isPastDue = + (reminder.status === 'pending' || reminder.status === 'snoozed') && + new Date(reminder.dueAt) < new Date(); + + const accentClass = accentForReminder(reminder.status, isPastDue); + const statusConfig = STATUS_CONFIG[reminder.status]; + const StatusIcon = statusConfig.icon; + const statusPill = STATUS_PILL[reminder.status] ?? 'bg-slate-100 text-slate-700'; + const priorityConfig = PRIORITY_CONFIG[reminder.priority]; + + const isResolved = reminder.status === 'completed' || reminder.status === 'dismissed'; + + // Subtitle: related-entity context + let subtitleIcon: React.ReactNode = null; + let subtitleText: string | null = null; + if (reminder.client) { + subtitleIcon = ; + subtitleText = reminder.client.fullName; + } else if (reminder.berth) { + subtitleIcon = ; + subtitleText = `Berth ${reminder.berth.mooringNumber}`; + } else if (reminder.interest) { + subtitleIcon = ( + + ); + subtitleText = `Interest (${reminder.interest.pipelineStage})`; + } + + const hasActions = !isResolved; + + return ( + + + + + + { + e.preventDefault(); + onComplete(reminder.id); + }} + > + + Complete + + { + e.preventDefault(); + onSnooze(reminder.id); + }} + > + + Snooze + + { + e.preventDefault(); + onEdit(reminder); + }} + > + + Edit + + { + e.preventDefault(); + onDismiss(reminder.id); + }} + > + + Dismiss + + + + ) : undefined + } + > +
+ } /> +
+ {/* Title row + spacer for actions button */} +
+

+ {reminder.title} +

+ {hasActions ? : null} +
+ + {/* Related entity subtitle */} + {subtitleText ? ( +

+ {subtitleIcon} + {subtitleText} +

+ ) : null} + + {/* Due date meta line */} +
+ + } + > + + Due {format(new Date(reminder.dueAt), 'MMM d, yyyy')} + + +
+ + {/* Pills row: status + priority + past-due flag */} +
+ {/* Status pill */} + + + {statusConfig.label} + + + {/* Priority pill */} + + {priorityConfig.label} + + + {/* Past-due flag */} + {isPastDue ? ( + + Past due + + ) : null} +
+
+
+
+ ); +} diff --git a/src/components/reminders/reminder-list.tsx b/src/components/reminders/reminder-list.tsx index ab15613..b9800e9 100644 --- a/src/components/reminders/reminder-list.tsx +++ b/src/components/reminders/reminder-list.tsx @@ -4,6 +4,7 @@ import { useState, useEffect, useCallback } from 'react'; import { type ColumnDef } from '@tanstack/react-table'; import { Plus, CheckCircle2, Clock, XCircle, AlertTriangle, Bell } from 'lucide-react'; import { formatDistanceToNow } from 'date-fns'; +import { useParams } from 'next/navigation'; import { DataTable } from '@/components/shared/data-table'; import { PageHeader } from '@/components/shared/page-header'; @@ -19,6 +20,7 @@ import { } from '@/components/ui/select'; import { apiFetch } from '@/lib/api/client'; import { usePermissions } from '@/hooks/use-permissions'; +import { ReminderCard } from './reminder-card'; import { ReminderForm } from './reminder-form'; import { SnoozeDialog } from './snooze-dialog'; @@ -69,6 +71,8 @@ export function ReminderList() { const [total, setTotal] = useState(0); const { can } = usePermissions(); const canViewAll = can('reminders', 'view_all'); + const params = useParams<{ portSlug: string }>(); + const portSlug = params?.portSlug ?? ''; const fetchReminders = useCallback(async () => { setLoading(true); @@ -290,6 +294,19 @@ export function ReminderList() { data={reminders} isLoading={loading} getRowId={(row) => row.id} + cardRender={(row) => ( + { + setEditingReminder(r); + setFormOpen(true); + }} + /> + )} emptyState={