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>
| Rule | Value |
|---|---|
| Back button | variant="ghost" size="sm", ChevronLeft, router.back() |
| Record title | header-title semantic class |
| Status badge | StatusBadge component from @/components/shared/status-badge |
| Edit button | variant="outline" size="sm" — never default/primary |
| More actions | DropdownMenu with MoreHorizontal trigger |
| Delete | In 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>
| Column | Width | Content |
|---|---|---|
| Main | lg:col-span-2 | Field sections, related data tables, activity feed |
| Sidebar | lg:col-span-1 | MetadataCard, related links, quick actions |
| Mobile | col-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, sidebarlg:col-span-1 - MetadataCard always present in sidebar
- Edit button is
variant="outline", nevervariant="default"(primary) - Delete in DropdownMenu with
text-destructive+ AlertDialog confirmation - Loading skeleton matches exact page layout
-
useAnnaPageContextwired 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