Skip to main content

UX Standards — 26: Detail and Record View Pages

Governs: Single-record view pages — the "detail page" counterpart to every list page. Parent rules: See 00-OVERVIEW-AND-CSS-RULES.md and 01-LAYOUT-SHELL.md first.


Overview

Every DocType list page has a corresponding detail page. Detail pages show a single record with all its fields, related records, and history. They are reached by clicking a row in the data table.


Page Header Zone

<div className="flex items-start justify-between gap-4 mb-6">
{/* Left: back + title + status */}
<div className="flex flex-col gap-1">
<Button variant="ghost" size="sm" className="w-fit -ml-2 text-muted-foreground"
onClick={() => router.back()}>
<ChevronLeft className="h-4 w-4 mr-1" />
{t('common.back')}
</Button>
<div className="flex items-center gap-3">
<h1 className="header-title">{record.name}</h1>
<StatusBadge status={record.status} />
</div>
<p className="caption-text">{record.doctype} · {formatDate(record.creation)}</p>
</div>

{/* Right: primary actions */}
<div className="flex items-center gap-2 flex-shrink-0">
<Button variant="outline" size="sm" onClick={handleEdit}>
<Pencil className="h-4 w-4 mr-2" />
{t('common.edit')}
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-9 w-9">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem>{t('common.duplicate')}</DropdownMenuItem>
<DropdownMenuItem>{t('common.printView')}</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-destructive" onClick={handleDelete}>
<Trash2 className="h-4 w-4 mr-2" />
{t('common.delete')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
RuleValue
Back buttonvariant="ghost" size="sm", ChevronLeft, router.back()
Record titleheader-title semantic class
Status badgeStatusBadge component from @/components/shared/status-badge
Edit buttonvariant="outline" size="sm" — never default/primary
More actionsDropdownMenu with MoreHorizontal trigger
DeleteIn DropdownMenu, text-destructive, requires AlertDialog confirmation

Two-Column Layout

<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Main content — 2/3 width */}
<div className="lg:col-span-2 space-y-6">
<SectionCard title={t('booking.details')} />
<SectionCard title={t('booking.items')} />
<ActivityFeedCard />
</div>

{/* Sidebar — 1/3 width */}
<div className="space-y-4">
<MetadataCard record={record} />
<RelatedRecordsCard />
</div>
</div>
ColumnWidthContent
Mainlg:col-span-2Field sections, related data tables, activity feed
Sidebarlg:col-span-1MetadataCard, related links, quick actions
Mobilecol-span-1 (stacked)Sidebar moves below main content

DetailRow Component

The primary building block for displaying field values.

// packages/shared/components/shared/detail-row.tsx
interface DetailRowProps {
label: string;
value: React.ReactNode;
fullWidth?: boolean; // spans both columns
}

export function DetailRow({ label, value, fullWidth }: DetailRowProps) {
return (
<div className={cn("grid gap-1", fullWidth ? "col-span-2" : "")}>
<dt className="text-sm text-muted-foreground">{label}</dt>
<dd className="text-sm text-foreground">
{value ?? <span className="text-muted-foreground"></span>}
</dd>
</div>
);
}

Rule: Always render (em dash) for null/empty fields. Never leave a blank value. Never show null, undefined, or an empty string.

Detail Section Card

<Card>
<CardHeader className="pb-3">
<CardTitle className="card-title">{t('booking.coreDetails')}</CardTitle>
</CardHeader>
<CardContent>
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<DetailRow label={t('booking.bookingId')} value={record.name} />
<DetailRow label={t('booking.status')} value={<StatusBadge status={record.status} />} />
<DetailRow label={t('booking.customer')} value={record.customer_name} />
<DetailRow label={t('booking.startDate')} value={formatDate(record.start_date)} />
<DetailRow label={t('booking.notes')} value={record.notes} fullWidth />
</dl>
</CardContent>
</Card>

MetadataCard Component

Always present in the sidebar. Shows audit fields.

// packages/shared/components/shared/metadata-card.tsx
export function MetadataCard({ record }: { record: BaseDoc }) {
const { formatDateTime, formatRelativeTime } = useFormatters();
const { t } = useTranslation();

return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
{t('common.metadata')}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<DetailRow label={t('common.createdBy')} value={record.owner} />
<DetailRow label={t('common.createdOn')}
value={
<Tooltip>
<TooltipTrigger>{formatRelativeTime(record.creation)}</TooltipTrigger>
<TooltipContent>{formatDateTime(record.creation)}</TooltipContent>
</Tooltip>
}
/>
<DetailRow label={t('common.modifiedBy')} value={record.modified_by} />
<DetailRow label={t('common.modifiedOn')}
value={
<Tooltip>
<TooltipTrigger>{formatRelativeTime(record.modified)}</TooltipTrigger>
<TooltipContent>{formatDateTime(record.modified)}</TooltipContent>
</Tooltip>
}
/>
{record.docstatus !== undefined && (
<DetailRow label={t('common.docStatus')} value={
<Badge variant={record.docstatus === 1 ? 'default' : 'secondary'}>
{record.docstatus === 0 ? t('common.draft') : record.docstatus === 1 ? t('common.submitted') : t('common.cancelled')}
</Badge>
} />
)}
</CardContent>
</Card>
);
}

Dynamic Page Title

Detail pages override the static page title with the record name:

// In the detail page component
const { setPageTitle } = usePageTitle();

useEffect(() => {
if (record?.name) {
setPageTitle(record.name);
}
return () => setPageTitle(null); // cleanup on unmount
}, [record?.name]);

Loading Skeleton

Show while the record is fetching. Match the layout exactly.

if (isLoading) {
return (
<div className="space-y-6">
{/* Header skeleton */}
<div className="flex justify-between">
<div className="space-y-2">
<Skeleton className="h-4 w-16" /> {/* back button */}
<Skeleton className="h-7 w-48" /> {/* title */}
<Skeleton className="h-3 w-32" /> {/* subtitle */}
</div>
<div className="flex gap-2">
<Skeleton className="h-9 w-16" />
<Skeleton className="h-9 w-9" />
</div>
</div>

{/* Body skeleton */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-6">
<Card><CardContent className="pt-6 space-y-4">
{Array.from({length: 6}).map((_, i) => (
<div key={i} className="space-y-1">
<Skeleton className="h-3 w-24" />
<Skeleton className="h-4 w-full" />
</div>
))}
</CardContent></Card>
</div>
<div className="space-y-4">
<Card><CardContent className="pt-6 space-y-3">
{Array.from({length: 4}).map((_, i) => (
<div key={i} className="space-y-1">
<Skeleton className="h-3 w-20" />
<Skeleton className="h-4 w-32" />
</div>
))}
</CardContent></Card>
</div>
</div>
</div>
);
}

Anna Context Wiring (Mandatory)

import { useAnnaPageContext } from '@/components/anna/anna-provider';

// In the page component body:
useAnnaPageContext({
entityType: 'Booking', // DocType name
entityId: params.id,
page: record ?? null,
});

Inline Editing Pattern

For quick edits without navigating to a full edit form:

function EditableField({ label, value, onSave }) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value);

if (!editing) {
return (
<div className="group flex items-center gap-1">
<span className="text-sm">{value || '—'}</span>
<Button variant="ghost" size="icon" className="h-6 w-6 opacity-0 group-hover:opacity-100"
onClick={() => setEditing(true)}>
<Pencil className="h-3 w-3" />
</Button>
</div>
);
}

return (
<div className="flex items-center gap-2">
<Input value={draft} onChange={e => setDraft(e.target.value)}
className="h-7 text-sm" autoFocus />
<Button size="sm" className="h-7" onClick={() => { onSave(draft); setEditing(false); }}>
<Check className="h-3 w-3" />
</Button>
<Button variant="ghost" size="sm" className="h-7" onClick={() => setEditing(false)}>
<X className="h-3 w-3" />
</Button>
</div>
);
}

Violation Checklist

  • Back button present, uses router.back(), variant="ghost"
  • Record name shown as page title via setPageTitle(record.name)
  • Status badge in page header
  • Null/empty fields display (em dash), never blank
  • Two-column layout: main lg:col-span-2, sidebar lg:col-span-1
  • MetadataCard always present in sidebar
  • Edit button is variant="outline", never variant="default" (primary)
  • Delete in DropdownMenu with text-destructive + AlertDialog confirmation
  • Loading skeleton matches exact page layout
  • useAnnaPageContext wired with correct entityType, entityId, page data
  • All field labels go through t() — no hardcoded English
  • formatDate()/formatDateTime() used for all date values — never raw ISO strings
  • Relative times (e.g., "2 hours ago") wrapped in Tooltip showing exact datetime