Skip to content

TAB Extensions

TAB extensions add custom tabs to detail views for work units, customers, and other entities. They’re perfect for displaying related data, custom workflows, and rich visualizations within the context of an entity.

  • Display related data (time entries, attachments, notes)
  • Custom workflows within entity context
  • Rich data visualizations and dashboards
  • Integration with external services
  • Custom forms and data entry
{
point: 'work-unit.detail.tabs',
plugin: 'my-plugin',
priority: 50,
extension: {
type: ExtensionType.TAB,
id: 'my-tab',
label: 'My Tab',
icon: IconExample,
component: MyTabComponent,
}
}
  • Value: ExtensionType.TAB
  • Description: Identifies this as a tab extension
  • Type: string
  • Description: Unique identifier for the tab
  • Example: 'time-entries', 'custom-fields'
  • Type: string
  • Description: Display text shown in tab header
  • Example: 'Time Tracking', 'Documents'
  • Type: React.ComponentType<TabComponentProps>
  • Description: The React component to render
  • Type: React.ComponentType<{ size?: number }>
  • Description: Icon component from @aether/ui/icons
  • Example: IconClock, IconFiles
  • Type: (context: any) => string | number | undefined
  • Description: Function returning badge value (count, status, etc)
  • Example: (context) => context?.timeEntryCount
  • Type: boolean
  • Description: Whether this tab should be selected by default
  • Default: false
  • Type: (context: any) => boolean
  • Description: Function determining if tab should be disabled
  • Example: (context) => !context.organization?.premiumTier
interface TabComponentProps {
context: {
// Entity data
workUnit?: any;
workUnitId?: string;
customer?: any;
customerId?: string;
// Organization context
organizationId: string;
organization?: {
id: string;
name: string;
businessType: string;
premiumTier: string;
premiumFeatures: string[];
};
// User context
userId: string;
user?: {
id: string;
name: string;
email: string;
};
// Additional context
businessType?: string;
status?: string;
entityType?: string;
entityId?: string;
};
config: any; // Plugin configuration
isCondensed?: boolean; // Condensed view mode
}
import { Stack, Card, Text, Button, Group, Loader } from '@aether/ui';
import { IconClock, IconPlayerPlay, IconPlayerStop } from '@aether/ui/icons';
import { trpc } from '@/lib/trpc/client';
interface TimerTabProps {
context: {
workUnit: any;
workUnitId: string;
organizationId: string;
};
config: any;
}
export function TimerTab({ context }: TimerTabProps) {
const { workUnit, workUnitId, organizationId } = context;
// Fetch time entries using tRPC
const { data: timeEntries = [], isLoading, refetch } =
trpc.timer.getTimeEntries.useQuery({
workUnitId,
organizationId,
});
// Start timer mutation
const startTimer = trpc.timer.start.useMutation({
onSuccess: () => refetch(),
});
// Stop timer mutation
const stopTimer = trpc.timer.stop.useMutation({
onSuccess: () => refetch(),
});
const activeEntry = timeEntries.find(e => !e.endTime);
return (
<Stack gap="md">
{/* Timer Controls */}
<Card shadow="sm">
<Group justify="space-between">
<div>
<Text size="lg" fw={600}>{workUnit.name}</Text>
<Text size="sm" c="dimmed">
{activeEntry ? 'Timer running' : 'No active timer'}
</Text>
</div>
{activeEntry ? (
<Button
leftSection={<IconPlayerStop size={16} />}
color="error"
onClick={() => stopTimer.mutate({ entryId: activeEntry.id })}
>
Stop Timer
</Button>
) : (
<Button
leftSection={<IconPlayerPlay size={16} />}
onClick={() => startTimer.mutate({ workUnitId })}
>
Start Timer
</Button>
)}
</Group>
</Card>
{/* Time Entries List */}
{isLoading ? (
<Loader />
) : (
<Stack gap="sm">
{timeEntries.map(entry => (
<TimeEntryCard key={entry.id} entry={entry} />
))}
</Stack>
)}
</Stack>
);
}

Badges display counts or status indicators in the tab header:

{
point: 'work-unit.detail.tabs',
plugin: 'tasks',
priority: 60,
extension: {
type: ExtensionType.TAB,
id: 'tasks-tab',
label: 'Tasks',
icon: IconChecklist,
component: TasksTab,
// Show count of incomplete tasks
badge: (context) => {
const incompleteTasks = context?.tasks?.filter(t => !t.completed).length;
return incompleteTasks > 0 ? incompleteTasks : undefined;
}
}
}

Only show tabs for specific business types or premium tiers:

{
point: 'work-unit.detail.tabs',
plugin: 'advanced-analytics',
priority: 40,
// Only show for SERVICE business type and PREMIUM tier
match: {
businessType: ['SERVICE'],
premiumTier: ['PREMIUM', 'ENTERPRISE']
},
extension: {
type: ExtensionType.TAB,
id: 'analytics-tab',
label: 'Analytics',
icon: IconChartBar,
component: AnalyticsTab,
}
}

Support condensed view for smaller screens:

export function MyTab({ context, isCondensed }: TabComponentProps) {
return (
<Stack gap={isCondensed ? 'xs' : 'md'}>
<Title order={isCondensed ? 4 : 3}>
{context.workUnit.name}
</Title>
{!isCondensed && (
<Text c="dimmed">
Additional details only shown in full mode
</Text>
)}
<Card padding={isCondensed ? 'sm' : 'md'}>
{/* Content */}
</Card>
</Stack>
);
}
export function MyTab({ context }: TabComponentProps) {
const { data, isLoading, error } = trpc.myPlugin.getData.useQuery({
entityId: context.workUnitId,
});
if (isLoading) {
return <Loader />;
}
if (error) {
return <ErrorDisplay error={error} />;
}
return <DataDisplay data={data} />;
}
// ✅ GOOD: Use IDs for queries
const { data } = trpc.getData.useQuery({
workUnitId: context.workUnitId,
organizationId: context.organizationId,
});
// ❌ BAD: Pass entire objects
const { data } = trpc.getData.useQuery({
workUnit: context.workUnit, // Objects may not serialize well
});
export function MyTab({ context, config }: TabComponentProps) {
// Use plugin configuration
const refreshInterval = config.refreshInterval ?? 30;
const showNotifications = config.notifications ?? true;
useEffect(() => {
const interval = setInterval(refetch, refreshInterval * 1000);
return () => clearInterval(interval);
}, [refreshInterval]);
// ...
}
export function MyTab({ context }: TabComponentProps) {
useEffect(() => {
const handler = () => console.log('Update');
window.addEventListener('storage', handler);
return () => {
window.removeEventListener('storage', handler);
};
}, []);
// ...
}
export function RelatedDataTab({ context }: TabComponentProps) {
const { data: items } = trpc.items.list.useQuery({
workUnitId: context.workUnitId,
organizationId: context.organizationId,
});
const createItem = trpc.items.create.useMutation({
onSuccess: () => refetch(),
});
return (
<Stack>
<Button onClick={() => createItem.mutate({ ...newItem })}>
Add Item
</Button>
{items?.map(item => <ItemCard key={item.id} item={item} />)}
</Stack>
);
}
if (!data || data.length === 0) {
return (
<Card>
<Stack align="center" gap="md">
<IconInbox size={48} color="gray" />
<Text c="dimmed">No entries yet</Text>
<Button onClick={handleCreate}>Create First Entry</Button>
</Stack>
</Card>
);
}