Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions admin/app/components/Avatar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
'use client';

import React, { useState } from 'react';

export interface AvatarProps {
/** Image URL — falls back to initials if omitted or fails to load */
src?: string;
/** Full name used for initials and tooltip */
name: string;
/** Size variant */
size?: 'sm' | 'md' | 'lg';
/** Override the background color for the initials fallback */
color?: string;
/** Additional CSS classes */
className?: string;
}

const SIZE_CLASSES: Record<NonNullable<AvatarProps['size']>, string> = {
sm: 'w-8 h-8 text-xs',
md: 'w-10 h-10 text-sm',
lg: 'w-14 h-14 text-base',
};

/** Deterministic hue from a string so each user gets a consistent colour. */
function colorFromName(name: string): string {
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash);
}
const hue = Math.abs(hash) % 360;
return `hsl(${hue}, 55%, 50%)`;
}

function getInitials(name: string): string {
const parts = name.trim().split(/\s+/);
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}

const Avatar: React.FC<AvatarProps> = ({
src,
name,
size = 'md',
color,
className = '',
}) => {
const [imgError, setImgError] = useState(false);
const showImage = !!src && !imgError;
const initials = getInitials(name);
const bgColor = color ?? colorFromName(name);

return (
<div
className={`relative inline-flex items-center justify-center rounded-full overflow-hidden shrink-0 group ${SIZE_CLASSES[size]} ${className}`}
title={name}
>
{showImage ? (
<img
src={src}
alt={name}
className="w-full h-full object-cover"
onError={() => setImgError(true)}
/>
) : (
<span
className="flex items-center justify-center w-full h-full font-semibold text-white select-none"
style={{ backgroundColor: bgColor }}
aria-label={initials}
>
{initials}
</span>
)}

{/* Hover tooltip */}
<span
className="
absolute bottom-full left-1/2 -translate-x-1/2 mb-1.5
bg-gray-900 text-white text-xs rounded px-2 py-1 whitespace-nowrap
opacity-0 group-hover:opacity-100 pointer-events-none
transition-opacity duration-150 z-10
"
role="tooltip"
aria-hidden="true"
>
{name}
</span>
</div>
);
};

export default Avatar;
62 changes: 62 additions & 0 deletions admin/app/components/Breadcrumb.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
'use client';

import React from 'react';
import Link from 'next/link';

export interface BreadcrumbItem {
/** Display label */
label: string;
/** Navigation href — omit for the current (last) page */
href?: string;
}

export interface BreadcrumbProps {
/** Ordered list of breadcrumb items */
items: BreadcrumbItem[];
/** Character(s) used as separator between items */
separator?: React.ReactNode;
/** Additional CSS classes */
className?: string;
}

const Breadcrumb: React.FC<BreadcrumbProps> = ({
items,
separator = '/',
className = '',
}) => {
return (
<nav aria-label="Breadcrumb" className={className}>
<ol className="flex flex-wrap items-center gap-1 text-sm text-gray-500">
{items.map((item, index) => {
const isLast = index === items.length - 1;
return (
<li key={index} className="flex items-center gap-1">
{index > 0 && (
<span aria-hidden="true" className="text-gray-400 select-none">
{separator}
</span>
)}
{isLast || !item.href ? (
<span
aria-current={isLast ? 'page' : undefined}
className={isLast ? 'text-gray-900 font-medium' : 'text-gray-500'}
>
{item.label}
</span>
) : (
<Link
href={item.href}
className="hover:text-gray-900 hover:underline transition-colors"
>
{item.label}
</Link>
)}
</li>
);
})}
</ol>
</nav>
);
};

export default Breadcrumb;
56 changes: 56 additions & 0 deletions admin/app/components/Divider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
'use client';

import React from 'react';

export interface DividerProps {
/** Orientation of the divider */
orientation?: 'horizontal' | 'vertical';
/** Optional label centered on a horizontal divider */
label?: string;
/** Visual weight */
variant?: 'subtle' | 'prominent';
/** Custom color (overrides variant) */
color?: string;
/** Additional CSS classes */
className?: string;
}

const Divider: React.FC<DividerProps> = ({
orientation = 'horizontal',
label,
variant = 'subtle',
color,
className = '',
}) => {
const lineStyle = color ? { backgroundColor: color } : undefined;

const lineClass = [
orientation === 'vertical' ? 'w-px h-full' : 'h-px w-full',
variant === 'subtle' ? 'bg-gray-200' : 'bg-gray-400',
].join(' ');

if (orientation === 'horizontal' && label) {
return (
<div
role="separator"
aria-orientation="horizontal"
className={`flex items-center gap-3 w-full ${className}`}
>
<div className={`flex-1 ${lineClass}`} style={lineStyle} />
<span className="text-sm text-gray-500 whitespace-nowrap">{label}</span>
<div className={`flex-1 ${lineClass}`} style={lineStyle} />
</div>
);
}

return (
<div
role="separator"
aria-orientation={orientation}
className={`${lineClass} ${className}`}
style={lineStyle}
/>
);
};

export default Divider;
79 changes: 79 additions & 0 deletions admin/app/components/__tests__/Avatar.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import Avatar from '../Avatar';

describe('Avatar', () => {
// --- Image rendering ---
it('renders image when src is provided', () => {
render(<Avatar src="https://example.com/photo.jpg" name="Alice Smith" />);
const img = screen.getByRole('img', { name: 'Alice Smith' });
expect(img).toBeInTheDocument();
expect(img).toHaveAttribute('src', 'https://example.com/photo.jpg');
});

it('shows initials fallback when no src provided', () => {
render(<Avatar name="Alice Smith" />);
expect(screen.queryByRole('img')).not.toBeInTheDocument();
expect(screen.getByLabelText('AS')).toBeInTheDocument();
});

it('shows initials fallback when image fails to load', () => {
render(<Avatar src="https://example.com/broken.jpg" name="Bob Jones" />);
const img = screen.getByRole('img');
fireEvent.error(img);
expect(screen.queryByRole('img')).not.toBeInTheDocument();
expect(screen.getByLabelText('BJ')).toBeInTheDocument();
});

// --- Initials logic ---
it('uses first two chars for single-word name', () => {
render(<Avatar name="Alice" />);
expect(screen.getByLabelText('AL')).toBeInTheDocument();
});

it('uses first + last initials for multi-word name', () => {
render(<Avatar name="John Michael Doe" />);
expect(screen.getByLabelText('JD')).toBeInTheDocument();
});

// --- Size variants ---
it('applies md size by default', () => {
const { container } = render(<Avatar name="Test User" />);
expect(container.firstChild).toHaveClass('w-10', 'h-10');
});

it('applies sm size', () => {
const { container } = render(<Avatar name="Test User" size="sm" />);
expect(container.firstChild).toHaveClass('w-8', 'h-8');
});

it('applies lg size', () => {
const { container } = render(<Avatar name="Test User" size="lg" />);
expect(container.firstChild).toHaveClass('w-14', 'h-14');
});

// --- Tooltip ---
it('renders name as title attribute for native tooltip', () => {
const { container } = render(<Avatar name="Alice Smith" />);
expect(container.firstChild).toHaveAttribute('title', 'Alice Smith');
});

it('renders tooltip span with name text', () => {
render(<Avatar name="Alice Smith" />);
expect(screen.getByRole('tooltip', { hidden: true })).toHaveTextContent('Alice Smith');
});

// --- Custom color ---
it('applies custom background color to initials', () => {
render(<Avatar name="Alice" color="#abc123" />);
const span = screen.getByLabelText('AL');
expect(span).toHaveStyle({ backgroundColor: '#abc123' });
});

// --- Custom className ---
it('applies custom className', () => {
const { container } = render(<Avatar name="Alice" className="my-avatar" />);
expect(container.firstChild).toHaveClass('my-avatar');
});
});
82 changes: 82 additions & 0 deletions admin/app/components/__tests__/Breadcrumb.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import Breadcrumb from '../Breadcrumb';

// Mock Next.js Link — in the admin project (no full Next.js test setup)
jest.mock('next/link', () => {
const MockLink = ({ href, children, className }: { href: string; children: React.ReactNode; className?: string }) => (
<a href={href} className={className}>{children}</a>
);
MockLink.displayName = 'Link';
return MockLink;
});

const items = [
{ label: 'Home', href: '/' },
{ label: 'Contracts', href: '/contracts' },
{ label: 'Details' },
];

describe('Breadcrumb', () => {
it('renders all items', () => {
render(<Breadcrumb items={items} />);
expect(screen.getByText('Home')).toBeInTheDocument();
expect(screen.getByText('Contracts')).toBeInTheDocument();
expect(screen.getByText('Details')).toBeInTheDocument();
});

it('renders as a nav with aria-label', () => {
render(<Breadcrumb items={items} />);
expect(screen.getByRole('navigation', { name: /breadcrumb/i })).toBeInTheDocument();
});

it('renders links for all but the last item', () => {
render(<Breadcrumb items={items} />);
const links = screen.getAllByRole('link');
expect(links).toHaveLength(2);
expect(links[0]).toHaveAttribute('href', '/');
expect(links[1]).toHaveAttribute('href', '/contracts');
});

it('last item is not a link', () => {
render(<Breadcrumb items={items} />);
const links = screen.queryAllByRole('link');
const texts = links.map(l => l.textContent);
expect(texts).not.toContain('Details');
});

it('marks last item with aria-current="page"', () => {
render(<Breadcrumb items={items} />);
expect(screen.getByText('Details')).toHaveAttribute('aria-current', 'page');
});

it('renders separator between items', () => {
render(<Breadcrumb items={items} />);
// Two separators for three items
const separators = screen.getAllByText('/');
expect(separators).toHaveLength(2);
});

it('supports custom separator', () => {
render(<Breadcrumb items={items} separator=">" />);
const separators = screen.getAllByText('>');
expect(separators).toHaveLength(2);
});

it('renders single item without separator', () => {
render(<Breadcrumb items={[{ label: 'Home' }]} />);
expect(screen.queryByText('/')).not.toBeInTheDocument();
});

it('item without href is non-link text', () => {
render(<Breadcrumb items={[{ label: 'Static', href: undefined }]} />);
expect(screen.queryByRole('link')).not.toBeInTheDocument();
expect(screen.getByText('Static')).toBeInTheDocument();
});

it('applies custom className to nav', () => {
const { container } = render(<Breadcrumb items={items} className="my-class" />);
expect(container.querySelector('nav')).toHaveClass('my-class');
});
});
Loading
Loading