Skip to content
Merged
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
43 changes: 43 additions & 0 deletions frontend/cntr/AmenitiesList/AmenitiesList.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { AmenitiesList } from './AmenitiesList';

describe('AmenitiesList', () => {
it('renders a list of amenities with capitalized labels', () => {
render(<AmenitiesList amenities={['wifi', 'parking', 'air conditioning']} />);

expect(screen.getByText('Wifi')).toBeInTheDocument();
expect(screen.getByText('Parking')).toBeInTheDocument();
expect(screen.getByText('Air conditioning')).toBeInTheDocument();
});

it('renders an unknown amenity and falls back to the Tag icon', () => {
const { container } = render(<AmenitiesList amenities={['unknown amenity']} />);

expect(screen.getByText('Unknown amenity')).toBeInTheDocument();
// Verify it renders the fallback Tag icon (lucide icons add class 'lucide-tag')
expect(container.querySelector('.lucide-tag')).toBeInTheDocument();
});

it('handles empty or missing arrays gracefully', () => {
const { container } = render(<AmenitiesList amenities={[]} />);
expect(container.firstChild).toBeNull();
});

it('handles varied casing and whitespace in inputs', () => {
render(<AmenitiesList amenities={[' WIFI ', 'PARKING', 'coFfee']} />);

expect(screen.getByText('Wifi')).toBeInTheDocument();
expect(screen.getByText('Parking')).toBeInTheDocument();
expect(screen.getByText('Coffee')).toBeInTheDocument();
});

it('renders multiple known icons', () => {
const { container } = render(<AmenitiesList amenities={['wifi', 'parking', 'gym']} />);

expect(container.querySelector('.lucide-wifi')).toBeInTheDocument();
expect(container.querySelector('.lucide-car')).toBeInTheDocument();
expect(container.querySelector('.lucide-dumbbell')).toBeInTheDocument();
});
});
47 changes: 47 additions & 0 deletions frontend/cntr/AmenitiesList/AmenitiesList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import React from 'react';
import { Wifi, Car, Printer, Coffee, Monitor, Lock, Dumbbell, Wind, Tag } from 'lucide-react';
import { cn } from '../../lib/utils';

export interface AmenitiesListProps extends React.HTMLAttributes<HTMLDivElement> {
amenities: string[];
}

const iconMap: Record<string, React.ElementType> = {
wifi: Wifi,
parking: Car,
printing: Printer,
coffee: Coffee,
monitor: Monitor,
security: Lock,
gym: Dumbbell,
'air conditioning': Wind,
ac: Wind,
};

function capitalizeFirstLetter(string: string) {
if (!string) return '';
return string.charAt(0).toUpperCase() + string.slice(1).toLowerCase();
}

export function AmenitiesList({ amenities, className, ...props }: AmenitiesListProps) {
if (!amenities || amenities.length === 0) return null;

return (
<div className={cn('flex flex-wrap gap-2', className)} {...props}>
{amenities.map((amenity, index) => {
const normalizedKey = amenity.trim().toLowerCase();
const IconComponent = iconMap[normalizedKey] || Tag;

return (
<div
key={`${normalizedKey}-${index}`}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md bg-zinc-100 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300 text-sm font-medium"
>
<IconComponent className="w-4 h-4 text-zinc-500 dark:text-zinc-400" />
<span>{capitalizeFirstLetter(amenity.trim())}</span>
</div>
);
})}
</div>
);
}
57 changes: 57 additions & 0 deletions frontend/cntr/BookingStatusBadge/BookingStatusBadge.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { BookingStatusBadge, BookingStatus } from './BookingStatusBadge';

describe('BookingStatusBadge', () => {
it('renders PENDING status correctly', () => {
render(<BookingStatusBadge status="PENDING" />);
const badge = screen.getByText('Pending');
expect(badge).toBeInTheDocument();
expect(badge.className).toContain('bg-yellow-100');
expect(badge.className).toContain('text-yellow-800');
});

it('renders CONFIRMED status correctly', () => {
render(<BookingStatusBadge status="CONFIRMED" />);
const badge = screen.getByText('Confirmed');
expect(badge).toBeInTheDocument();
expect(badge.className).toContain('bg-green-100');
expect(badge.className).toContain('text-green-800');
});

it('renders CANCELLED status correctly', () => {
render(<BookingStatusBadge status="CANCELLED" />);
const badge = screen.getByText('Cancelled');
expect(badge).toBeInTheDocument();
expect(badge.className).toContain('bg-red-100');
expect(badge.className).toContain('text-red-800');
});

it('renders COMPLETED status correctly', () => {
render(<BookingStatusBadge status="COMPLETED" />);
const badge = screen.getByText('Completed');
expect(badge).toBeInTheDocument();
expect(badge.className).toContain('bg-blue-100');
expect(badge.className).toContain('text-blue-800');
});

it('renders NO_SHOW status correctly', () => {
render(<BookingStatusBadge status="NO_SHOW" />);
const badge = screen.getByText('No Show');
expect(badge).toBeInTheDocument();
expect(badge.className).toContain('bg-zinc-100');
expect(badge.className).toContain('text-zinc-800');
});

it('allows passing custom classNames', () => {
render(<BookingStatusBadge status="CONFIRMED" className="custom-test-class" />);
const badge = screen.getByText('Confirmed');
expect(badge.className).toContain('custom-test-class');
});

it('returns null for an invalid status', () => {
const { container } = render(<BookingStatusBadge status={"UNKNOWN" as BookingStatus} />);
expect(container.firstChild).toBeNull();
});
});
52 changes: 52 additions & 0 deletions frontend/cntr/BookingStatusBadge/BookingStatusBadge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import React from 'react';
import { cn } from '../../lib/utils'; // Assuming standard shadcn/tailwind setup

export type BookingStatus = 'PENDING' | 'CONFIRMED' | 'CANCELLED' | 'COMPLETED' | 'NO_SHOW';

export interface BookingStatusBadgeProps extends React.HTMLAttributes<HTMLSpanElement> {
status: BookingStatus;
}

const statusConfig: Record<BookingStatus, { label: string; className: string }> = {
PENDING: {
label: 'Pending',
className: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-500',
},
CONFIRMED: {
label: 'Confirmed',
className: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-500',
},
CANCELLED: {
label: 'Cancelled',
className: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-500',
},
COMPLETED: {
label: 'Completed',
className: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-500',
},
NO_SHOW: {
label: 'No Show',
className: 'bg-zinc-100 text-zinc-800 dark:bg-zinc-800 dark:text-zinc-400',
},
};

export function BookingStatusBadge({ status, className, ...props }: BookingStatusBadgeProps) {
const config = statusConfig[status];

if (!config) {
return null; // or fallback
}

return (
<span
className={cn(
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium',
config.className,
className
)}
{...props}
>
{config.label}
</span>
);
}
80 changes: 80 additions & 0 deletions frontend/cntr/CheckInHistory/CheckInHistory.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { CheckInHistory, CheckInLog } from './CheckInHistory';

describe('CheckInHistory', () => {
const mockLogs: CheckInLog[] = [
{
workspaceName: 'Hub A',
checkInTime: '2026-05-30T10:00:00Z',
checkOutTime: '2026-05-30T12:15:00Z',
durationMinutes: 135,
status: 'COMPLETED',
},
{
workspaceName: 'Hub B',
checkInTime: '2026-05-31T09:00:00Z',
checkOutTime: null,
durationMinutes: 45,
status: 'CONFIRMED',
}
];

it('renders a table with all check-in logs', () => {
render(<CheckInHistory logs={mockLogs} />);

// Check columns
expect(screen.getByText('Workspace')).toBeInTheDocument();
expect(screen.getByText('Check-In')).toBeInTheDocument();

// Check data
expect(screen.getByText('Hub A')).toBeInTheDocument();
expect(screen.getByText('Hub B')).toBeInTheDocument();
});

it('formats duration correctly as Xh Ym', () => {
render(<CheckInHistory logs={mockLogs} />);

// 135 minutes = 2h 15m
expect(screen.getByText('2h 15m')).toBeInTheDocument();

// 45 minutes = 45m
expect(screen.getByText('45m')).toBeInTheDocument();
});

it('renders "Active" in green when checkOutTime is null', () => {
render(<CheckInHistory logs={mockLogs} />);

const activeCell = screen.getByText('Active');
expect(activeCell).toBeInTheDocument();
expect(activeCell.className).toContain('text-green-600');
});

it('renders the BookingStatusBadge for the status column', () => {
render(<CheckInHistory logs={mockLogs} />);

// Statuses passed to BookingStatusBadge
expect(screen.getByText('Completed')).toBeInTheDocument();
expect(screen.getByText('Confirmed')).toBeInTheDocument();
});

it('renders an empty state message when logs are empty', () => {
render(<CheckInHistory logs={[]} />);

expect(screen.getByText('No check-in history available.')).toBeInTheDocument();
expect(screen.queryByText('Workspace')).not.toBeInTheDocument();
});

it('formats zero minutes properly', () => {
const zeroLog: CheckInLog[] = [{
workspaceName: 'Hub Zero',
checkInTime: '2026-05-30T10:00:00Z',
checkOutTime: '2026-05-30T10:00:00Z',
durationMinutes: 0,
status: 'COMPLETED',
}];
render(<CheckInHistory logs={zeroLog} />);
expect(screen.getByText('0m')).toBeInTheDocument();
});
});
89 changes: 89 additions & 0 deletions frontend/cntr/CheckInHistory/CheckInHistory.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import React from 'react';
import { cn } from '../../lib/utils';
import { BookingStatusBadge, BookingStatus } from '../BookingStatusBadge/BookingStatusBadge';

export interface CheckInLog {
workspaceName: string;
checkInTime: string;
checkOutTime: string | null;
durationMinutes: number;
status: string;
}

export interface CheckInHistoryProps extends React.HTMLAttributes<HTMLDivElement> {
logs: CheckInLog[];
}

function formatDuration(minutes: number): string {
if (minutes < 0) return '0m';
const h = Math.floor(minutes / 60);
const m = minutes % 60;
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}

function formatDateString(dateStr: string) {
try {
const d = new Date(dateStr);
return d.toLocaleString(undefined, {
year: 'numeric', month: 'short', day: 'numeric',
hour: '2-digit', minute: '2-digit'
});
} catch {
return dateStr;
}
}

export function CheckInHistory({ logs, className, ...props }: CheckInHistoryProps) {
if (!logs || logs.length === 0) {
return (
<div
className={cn("p-8 text-center border border-dashed border-zinc-200 dark:border-zinc-800 rounded-lg", className)}
{...props}
>
<p className="text-zinc-500 dark:text-zinc-400">No check-in history available.</p>
</div>
);
}

return (
<div className={cn("overflow-x-auto rounded-lg border border-zinc-200 dark:border-zinc-800", className)} {...props}>
<table className="min-w-full divide-y divide-zinc-200 dark:divide-zinc-800 text-sm text-left">
<thead className="bg-zinc-50 dark:bg-zinc-900/50">
<tr>
<th className="px-4 py-3 font-medium text-zinc-900 dark:text-zinc-200">Workspace</th>
<th className="px-4 py-3 font-medium text-zinc-900 dark:text-zinc-200">Check-In</th>
<th className="px-4 py-3 font-medium text-zinc-900 dark:text-zinc-200">Check-Out</th>
<th className="px-4 py-3 font-medium text-zinc-900 dark:text-zinc-200">Duration</th>
<th className="px-4 py-3 font-medium text-zinc-900 dark:text-zinc-200">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-200 dark:divide-zinc-800 bg-white dark:bg-zinc-950">
{logs.map((log, i) => (
<tr key={i} className="hover:bg-zinc-50 dark:hover:bg-zinc-900/50 transition-colors">
<td className="px-4 py-3 font-medium text-zinc-900 dark:text-zinc-100">
{log.workspaceName}
</td>
<td className="px-4 py-3 text-zinc-600 dark:text-zinc-400 whitespace-nowrap">
{formatDateString(log.checkInTime)}
</td>
<td className="px-4 py-3 text-zinc-600 dark:text-zinc-400 whitespace-nowrap">
{log.checkOutTime === null ? (
<span className="text-green-600 dark:text-green-500 font-medium">Active</span>
) : (
formatDateString(log.checkOutTime)
)}
</td>
<td className="px-4 py-3 text-zinc-600 dark:text-zinc-400 whitespace-nowrap">
{formatDuration(log.durationMinutes)}
</td>
<td className="px-4 py-3">
<BookingStatusBadge status={log.status as BookingStatus} />
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
Loading
Loading