diff --git a/frontends/nextjs/src/data/form/FieldGroup.tsx b/frontends/nextjs/src/data/form/FieldGroup.tsx
new file mode 100644
index 000000000..67ad752ca
--- /dev/null
+++ b/frontends/nextjs/src/data/form/FieldGroup.tsx
@@ -0,0 +1,50 @@
+import type { ReactNode } from 'react'
+import { Box, Stack, Typography } from '@mui/material'
+
+interface FieldGroupProps {
+ title: string
+ description?: ReactNode
+ actions?: ReactNode
+ children: ReactNode
+ spacing?: number
+}
+
+export function FieldGroup({
+ title,
+ description,
+ actions,
+ children,
+ spacing = 2,
+}: FieldGroupProps) {
+ return (
+
+
+
+
+ {title}
+
+ {description ? (
+
+ {description}
+
+ ) : null}
+
+
+ {actions ? (
+ {actions}
+ ) : null}
+
+
+ {children}
+
+ )
+}
diff --git a/frontends/nextjs/src/data/form/ValidationSummary.tsx b/frontends/nextjs/src/data/form/ValidationSummary.tsx
new file mode 100644
index 000000000..57eb90278
--- /dev/null
+++ b/frontends/nextjs/src/data/form/ValidationSummary.tsx
@@ -0,0 +1,53 @@
+import type { ReactNode } from 'react'
+import { Alert, AlertTitle, List, ListItem, ListItemText } from '@mui/material'
+
+/**
+ * Props for {@link ValidationSummary}.
+ *
+ * @property errors List of validation errors to display. Each entry can be a plain
+ * string message or a ReactNode for fully custom rendering (e.g. including links
+ * or emphasized text). The list is rendered as items in a bulleted list.
+ * @property title Optional title rendered inside the error alert header. This
+ * is only shown when {@link ValidationSummaryProps.showTitle | showTitle} is
+ * `true`. Defaults to `"Please fix the following"`.
+ * @property showTitle When `true` (default), renders an {@link AlertTitle} using
+ * the provided `title`. Set to `false` when you want to suppress the header and
+ * only show the list of errors.
+ */
+interface ValidationSummaryProps {
+ errors: Array
+ title?: string
+ showTitle?: boolean
+}
+
+/**
+ * Displays a standardized validation error summary as an error {@link Alert}
+ * containing an optional title and a bulleted list of errors.
+ *
+ * Renders nothing when the `errors` array is empty.
+ */
+export function ValidationSummary({
+ errors,
+ title = 'Please fix the following',
+ showTitle = true,
+}: ValidationSummaryProps) {
+ if (!errors.length) return null
+
+ return (
+
+ {showTitle ? {title} : null}
+
+ {errors.map((error, index) => (
+
+
+
+ ))}
+
+
+ )
+}
diff --git a/frontends/nextjs/src/data/table/Body.tsx b/frontends/nextjs/src/data/table/Body.tsx
new file mode 100644
index 000000000..80225d390
--- /dev/null
+++ b/frontends/nextjs/src/data/table/Body.tsx
@@ -0,0 +1,59 @@
+import type { ReactNode } from 'react'
+import { TableBody, TableCell, TableRow } from '@mui/material'
+
+import { EmptyState } from './EmptyState'
+import type { DataTableColumn } from './types'
+
+interface BodyProps {
+ columns: Array>
+ rows: T[]
+ getRowId?: (row: T, rowIndex: number) => string | number
+ renderActions?: (row: T) => ReactNode
+ onRowClick?: (row: T) => void
+ emptyMessage?: string
+}
+
+export function Body({
+ columns,
+ rows,
+ getRowId,
+ renderActions,
+ onRowClick,
+ emptyMessage = 'No records found',
+}: BodyProps) {
+ const colSpan = columns.length + (renderActions ? 1 : 0)
+
+ return (
+
+ {rows.length === 0 ? (
+
+ ) : (
+ rows.map((row, rowIndex) => {
+ const rowId = getRowId ? getRowId(row, rowIndex) : rowIndex
+ const handleClick = onRowClick ? () => onRowClick(row) : undefined
+
+ return (
+
+ {columns.map((column) => {
+ const content = column.render ? column.render(row, rowIndex) : (row as Record)[column.key]
+
+ return (
+
+ {content ?? '—'}
+
+ )
+ })}
+
+ {renderActions ? {renderActions(row)} : null}
+
+ )
+ })
+ )}
+
+ )
+}
diff --git a/frontends/nextjs/src/data/table/EmptyState.tsx b/frontends/nextjs/src/data/table/EmptyState.tsx
new file mode 100644
index 000000000..b79247aaa
--- /dev/null
+++ b/frontends/nextjs/src/data/table/EmptyState.tsx
@@ -0,0 +1,40 @@
+import type { ReactNode } from 'react'
+import { Stack, TableCell, TableRow, Typography } from '@mui/material'
+
+/**
+ * Props for the {@link EmptyState} table row.
+ *
+ * This is typically used when a data-driven table has no rows to display and
+ * you want to show a friendly message, optionally with a follow-up action.
+ *
+ * @property colSpan - Number of table columns the empty-state cell should span.
+ * @property message - Optional message shown to explain that there is no data.
+ * @property action - Optional call-to-action content (for example, a "Create"
+ * button or "Reload" button) rendered below the message.
+ */
+interface EmptyStateProps {
+ colSpan: number
+ message?: string
+ action?: ReactNode
+}
+
+/**
+ * Renders a full-width table row indicating that there is currently no data
+ * to display for the surrounding table.
+ *
+ * Use this component as the only row in a table body when the data source is
+ * empty. You can pass an optional `action` to surface primary actions such as
+ * creating a new item or retrying a failed load.
+ */
+export function EmptyState({ colSpan, message = 'No data to display', action }: EmptyStateProps) {
+ return (
+
+
+
+ {message}
+ {action ? {action} : null}
+
+
+
+ )
+}
diff --git a/frontends/nextjs/src/data/table/Header.tsx b/frontends/nextjs/src/data/table/Header.tsx
new file mode 100644
index 000000000..a7c9f6602
--- /dev/null
+++ b/frontends/nextjs/src/data/table/Header.tsx
@@ -0,0 +1,52 @@
+import { TableCell, TableHead, TableRow, Typography } from '@mui/material'
+
+import type { DataTableColumn } from './types'
+
+interface HeaderProps {
+ columns: Array>
+ actionsHeader?: string
+}
+
+export function Header({ columns, actionsHeader }: HeaderProps) {
+ return (
+
+
+ {columns.map((column) => (
+
+
+ {column.label}
+
+
+ ))}
+
+ {actionsHeader ? (
+
+
+ {actionsHeader}
+
+
+ ) : null}
+
+
+ )
+}
diff --git a/frontends/nextjs/src/data/table/types.ts b/frontends/nextjs/src/data/table/types.ts
new file mode 100644
index 000000000..d0b94318f
--- /dev/null
+++ b/frontends/nextjs/src/data/table/types.ts
@@ -0,0 +1,11 @@
+import type { ReactNode } from 'react'
+import type { TableCellProps } from '@mui/material'
+
+export interface DataTableColumn {
+ key: string
+ label: string
+ align?: TableCellProps['align']
+ width?: number | string
+ render?: (row: T, rowIndex: number) => ReactNode
+ sx?: TableCellProps['sx']
+}