Skip to content

Commit 830635a

Browse files
committed
Add UI components: DataTable, Layout, StatCard, and StatusBadge
1 parent 7c025a6 commit 830635a

File tree

4 files changed

+341
-0
lines changed

4 files changed

+341
-0
lines changed
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { useState } from 'react';
2+
3+
export default function DataTable({ columns, data, loading, emptyMessage }) {
4+
const [sortConfig, setSortConfig] = useState({ key: null, direction: 'asc' });
5+
6+
const requestSort = (key) => {
7+
let direction = 'asc';
8+
if (sortConfig.key === key && sortConfig.direction === 'asc') {
9+
direction = 'desc';
10+
}
11+
setSortConfig({ key, direction });
12+
};
13+
14+
const sortedData = [...data].sort((a, b) => {
15+
if (!sortConfig.key) return 0;
16+
if (a[sortConfig.key] < b[sortConfig.key]) {
17+
return sortConfig.direction === 'asc' ? -1 : 1;
18+
}
19+
if (a[sortConfig.key] > b[sortConfig.key]) {
20+
return sortConfig.direction === 'asc' ? 1 : -1;
21+
}
22+
return 0;
23+
});
24+
25+
return (
26+
<div className="overflow-x-auto">
27+
<table className="min-w-full divide-y divide-gray-200">
28+
<thead className="bg-gray-50">
29+
<tr>
30+
{columns.map((column) => (
31+
<th
32+
key={column.accessor}
33+
scope="col"
34+
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
35+
onClick={() => requestSort(column.accessor)}
36+
>
37+
<div className="flex items-center">
38+
{column.header}
39+
{sortConfig.key === column.accessor && (
40+
<span className="ml-1">
41+
{sortConfig.direction === 'asc' ? '↑' : '↓'}
42+
</span>
43+
)}
44+
</div>
45+
</th>
46+
))}
47+
</tr>
48+
</thead>
49+
<tbody className="bg-white divide-y divide-gray-200">
50+
{loading ? (
51+
<tr>
52+
<td colSpan={columns.length} className="px-6 py-4 text-center">
53+
<div className="flex justify-center py-8">
54+
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-green-600"></div>
55+
</div>
56+
</td>
57+
</tr>
58+
) : sortedData.length === 0 ? (
59+
<tr>
60+
<td colSpan={columns.length} className="px-6 py-4 text-center text-gray-500">
61+
{emptyMessage}
62+
</td>
63+
</tr>
64+
) : (
65+
sortedData.map((row, rowIndex) => (
66+
<tr key={rowIndex} className="hover:bg-green-50">
67+
{columns.map((column) => (
68+
<td key={`${rowIndex}-${column.accessor}`} className="px-6 py-4 whitespace-nowrap">
69+
{column.cell
70+
? column.cell(row[column.accessor], row)
71+
: <span className="text-sm text-gray-900">{row[column.accessor]}</span>
72+
}
73+
</td>
74+
))}
75+
</tr>
76+
))
77+
)}
78+
</tbody>
79+
</table>
80+
</div>
81+
);
82+
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { useState } from 'react';
2+
import { Link, useNavigate } from 'react-router-dom';
3+
import useAuthStore from '../../store/useAuthStore';
4+
import { LayoutDashboard, Stethoscope, User, Calendar, Activity, Pill, Ambulance, LogOut } from 'lucide-react';
5+
6+
export default function Layout({ children }) {
7+
const user = useAuthStore((state) => state.user);
8+
const logout = useAuthStore((state) => state.logout);
9+
const navigate = useNavigate();
10+
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
11+
12+
const handleLogout = () => {
13+
logout();
14+
navigate('/login');
15+
};
16+
17+
const navItems = [
18+
{ path: '/doctor', icon: LayoutDashboard, label: 'Dashboard' },
19+
{ path: '/doctor/appointments', icon: Calendar, label: 'Appointments' },
20+
{ path: '/doctor/patients', icon: User, label: 'Patients' },
21+
{ path: '/doctor/labs', icon: Activity, label: 'Lab Tests' },
22+
{ path: '/doctor/pharmacy', icon: Pill, label: 'Pharmacy' },
23+
{ path: '/doctor/ambulance', icon: Ambulance, label: 'Ambulance' }
24+
];
25+
26+
return (
27+
<div className="min-h-screen bg-gray-50 flex">
28+
{/* Mobile menu button */}
29+
<button
30+
className="md:hidden fixed top-4 right-4 z-50 p-2 rounded-md bg-green-600 text-white"
31+
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
32+
>
33+
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
34+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
35+
</svg>
36+
</button>
37+
38+
{/* Sidebar */}
39+
<div className={`${mobileMenuOpen ? 'translate-x-0' : '-translate-x-full'} md:translate-x-0 fixed md:static inset-y-0 left-0 w-64 bg-green-800 text-white transition-transform duration-300 ease-in-out z-40`}>
40+
<div className="flex items-center justify-center h-16 px-4 border-b border-green-700">
41+
<div className="flex items-center">
42+
<Stethoscope className="h-8 w-8 mr-2" />
43+
<span className="text-xl font-semibold">HealVista</span>
44+
</div>
45+
</div>
46+
<div className="flex flex-col h-full p-4">
47+
<div className="mt-6 flex-1">
48+
<nav className="space-y-2">
49+
{navItems.map((item) => (
50+
<Link
51+
key={item.path}
52+
to={item.path}
53+
className="flex items-center px-4 py-3 rounded-lg hover:bg-green-700 transition"
54+
onClick={() => setMobileMenuOpen(false)}
55+
>
56+
<item.icon className="w-5 h-5 mr-3" />
57+
{item.label}
58+
</Link>
59+
))}
60+
</nav>
61+
</div>
62+
<div className="pb-4">
63+
<div className="flex items-center px-4 py-3 text-sm">
64+
<div className="flex-shrink-0">
65+
<div className="h-10 w-10 rounded-full bg-green-600 flex items-center justify-center">
66+
{user?.full_name?.charAt(0) || 'D'}
67+
</div>
68+
</div>
69+
<div className="ml-3">
70+
<p className="font-medium">Dr. {user?.full_name}</p>
71+
<p className="text-green-200">{user?.specialization}</p>
72+
</div>
73+
</div>
74+
<button
75+
onClick={handleLogout}
76+
className="w-full flex items-center px-4 py-3 rounded-lg hover:bg-green-700 transition mt-2"
77+
>
78+
<LogOut className="w-5 h-5 mr-3" />
79+
Sign out
80+
</button>
81+
</div>
82+
</div>
83+
</div>
84+
85+
{/* Main content */}
86+
<div className="flex-1 flex flex-col overflow-hidden">
87+
{/* Overlay for mobile menu */}
88+
{mobileMenuOpen && (
89+
<div
90+
className="fixed inset-0 bg-black bg-opacity-50 z-30 md:hidden"
91+
onClick={() => setMobileMenuOpen(false)}
92+
/>
93+
)}
94+
95+
{/* Content area */}
96+
<main className="flex-1 overflow-y-auto p-4 md:p-8">
97+
{children}
98+
</main>
99+
</div>
100+
</div>
101+
);
102+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/**
2+
* @typedef {Object} StatCardProps
3+
* @property {React.ElementType} icon
4+
* @property {string} title
5+
* @property {string|number} value
6+
* @property {'green'|'blue'|'red'|'yellow'|'purple'|'indigo'|'pink'|'teal'|'orange'} [color]
7+
* @property {boolean} [loading]
8+
*/
9+
10+
const colorClasses = {
11+
bg: {
12+
green: 'bg-green-100',
13+
blue: 'bg-blue-100',
14+
red: 'bg-red-100',
15+
yellow: 'bg-yellow-100',
16+
purple: 'bg-purple-100',
17+
indigo: 'bg-indigo-100',
18+
pink: 'bg-pink-100',
19+
teal: 'bg-teal-100',
20+
orange: 'bg-orange-100',
21+
},
22+
text: {
23+
green: 'text-green-600',
24+
blue: 'text-blue-600',
25+
red: 'text-red-600',
26+
yellow: 'text-yellow-600',
27+
purple: 'text-purple-600',
28+
indigo: 'text-indigo-600',
29+
pink: 'text-pink-600',
30+
teal: 'text-teal-600',
31+
orange: 'text-orange-600',
32+
},
33+
border: {
34+
green: 'border-green-200',
35+
blue: 'border-blue-200',
36+
red: 'border-red-200',
37+
yellow: 'border-yellow-200',
38+
purple: 'border-purple-200',
39+
indigo: 'border-indigo-200',
40+
pink: 'border-pink-200',
41+
teal: 'border-teal-200',
42+
orange: 'border-orange-200',
43+
}
44+
};
45+
46+
export default function StatCard({
47+
icon: Icon,
48+
title,
49+
value,
50+
color = 'green',
51+
loading = false
52+
}) {
53+
return (
54+
<div className={`bg-white rounded-lg shadow-sm p-4 border ${colorClasses.border[color]}`}>
55+
<div className="flex items-center justify-between">
56+
<div className={`w-12 h-12 rounded-full ${colorClasses.bg[color]} flex items-center justify-center`}>
57+
<Icon className={`w-6 h-6 ${colorClasses.text[color]}`} />
58+
</div>
59+
<div className="text-right">
60+
<p className="text-sm text-gray-500">{title}</p>
61+
<p className="text-2xl font-bold text-gray-800">
62+
{loading ? (
63+
<span className="inline-block h-6 w-8 bg-gray-200 rounded animate-pulse"></span>
64+
) : (
65+
value
66+
)}
67+
</p>
68+
</div>
69+
</div>
70+
</div>
71+
);
72+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
export default function StatusBadge({ status }) {
2+
// Define status styles
3+
const statusStyles = {
4+
pending: {
5+
bg: 'bg-yellow-100',
6+
text: 'text-yellow-800',
7+
border: 'border-yellow-200'
8+
},
9+
completed: {
10+
bg: 'bg-green-100',
11+
text: 'text-green-800',
12+
border: 'border-green-200'
13+
},
14+
confirmed: {
15+
bg: 'bg-blue-100',
16+
text: 'text-blue-800',
17+
border: 'border-blue-200'
18+
},
19+
cancelled: {
20+
bg: 'bg-red-100',
21+
text: 'text-red-800',
22+
border: 'border-red-200'
23+
},
24+
critical: {
25+
bg: 'bg-red-100',
26+
text: 'text-red-800',
27+
border: 'border-red-200'
28+
},
29+
active: {
30+
bg: 'bg-green-100',
31+
text: 'text-green-800',
32+
border: 'border-green-200'
33+
},
34+
inactive: {
35+
bg: 'bg-gray-100',
36+
text: 'text-gray-800',
37+
border: 'border-gray-200'
38+
},
39+
default: {
40+
bg: 'bg-gray-100',
41+
text: 'text-gray-800',
42+
border: 'border-gray-200'
43+
}
44+
};
45+
46+
// Get styles for the current status or use default
47+
const currentStatus = statusStyles[status.toLowerCase()] || statusStyles.default;
48+
49+
return (
50+
<span
51+
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
52+
${currentStatus.bg} ${currentStatus.text} ${currentStatus.border} border`}
53+
>
54+
{/* Status icon based on status */}
55+
{status.toLowerCase() === 'pending' && (
56+
<svg className="-ml-0.5 mr-1.5 h-2 w-2 text-yellow-500" fill="currentColor" viewBox="0 0 8 8">
57+
<circle cx="4" cy="4" r="3" />
58+
</svg>
59+
)}
60+
{status.toLowerCase() === 'completed' && (
61+
<svg className="-ml-0.5 mr-1.5 h-2 w-2 text-green-500" fill="currentColor" viewBox="0 0 8 8">
62+
<circle cx="4" cy="4" r="3" />
63+
</svg>
64+
)}
65+
{status.toLowerCase() === 'confirmed' && (
66+
<svg className="-ml-0.5 mr-1.5 h-2 w-2 text-blue-500" fill="currentColor" viewBox="0 0 8 8">
67+
<circle cx="4" cy="4" r="3" />
68+
</svg>
69+
)}
70+
{status.toLowerCase() === 'cancelled' && (
71+
<svg className="-ml-0.5 mr-1.5 h-2 w-2 text-red-500" fill="currentColor" viewBox="0 0 8 8">
72+
<circle cx="4" cy="4" r="3" />
73+
</svg>
74+
)}
75+
{status.toLowerCase() === 'critical' && (
76+
<svg className="-ml-0.5 mr-1.5 h-2 w-2 text-red-500" fill="currentColor" viewBox="0 0 8 8">
77+
<circle cx="4" cy="4" r="3" />
78+
</svg>
79+
)}
80+
81+
{/* Status text with capitalization */}
82+
{status.toLowerCase().charAt(0).toUpperCase() + status.toLowerCase().slice(1)}
83+
</span>
84+
);
85+
}

0 commit comments

Comments
 (0)