- Replace 'TEAM' label with Type attribute (Business/Enabling/Staf) in team blocks - Make Type labels larger (text-sm) and brighter colors - Make SUBTEAM label less bright (indigo-300) and smaller (text-[10px]) - Add 'FTE' suffix to bandbreedte values in header and application blocks - Add Platform and Connected Device labels to application blocks - Show Platform FTE and Workloads FTE separately in Platform blocks - Add spacing between Regiemodel letter and count value - Add cache invalidation for Team Dashboard when applications are updated - Enrich team references with Type attribute in getSubteamToTeamMapping
682 lines
26 KiB
TypeScript
682 lines
26 KiB
TypeScript
import { useEffect, useState, useCallback } from 'react';
|
|
import { useNavigate, useSearchParams, Link } from 'react-router-dom';
|
|
import { clsx } from 'clsx';
|
|
import { searchApplications, getReferenceData } from '../services/api';
|
|
import { useSearchStore } from '../stores/searchStore';
|
|
import { useNavigationStore } from '../stores/navigationStore';
|
|
import type { ApplicationListItem, SearchResult, ReferenceValue, ApplicationStatus } from '../types';
|
|
|
|
const ALL_STATUSES: ApplicationStatus[] = [
|
|
'In Production',
|
|
'Implementation',
|
|
'Proof of Concept',
|
|
'End of support',
|
|
'End of life',
|
|
'Deprecated',
|
|
'Shadow IT',
|
|
'Closed',
|
|
'Undefined',
|
|
];
|
|
|
|
export default function ApplicationList() {
|
|
const navigate = useNavigate();
|
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
const {
|
|
filters,
|
|
currentPage,
|
|
pageSize,
|
|
setSearchText,
|
|
setStatuses,
|
|
setApplicationFunction,
|
|
setGovernanceModel,
|
|
setApplicationSubteam,
|
|
setApplicationType,
|
|
setOrganisation,
|
|
setHostingType,
|
|
setBusinessImportance,
|
|
setCurrentPage,
|
|
resetFilters,
|
|
} = useSearchStore();
|
|
const { setNavigationContext } = useNavigationStore();
|
|
|
|
const [result, setResult] = useState<SearchResult | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [organisations, setOrganisations] = useState<ReferenceValue[]>([]);
|
|
const [hostingTypes, setHostingTypes] = useState<ReferenceValue[]>([]);
|
|
const [businessImportanceOptions, setBusinessImportanceOptions] = useState<ReferenceValue[]>([]);
|
|
const [applicationSubteams, setApplicationSubteams] = useState<ReferenceValue[]>([]);
|
|
const [showFilters, setShowFilters] = useState(true);
|
|
|
|
// Sync URL params with store on mount
|
|
useEffect(() => {
|
|
const pageParam = searchParams.get('page');
|
|
if (pageParam) {
|
|
const page = parseInt(pageParam, 10);
|
|
if (!isNaN(page) && page > 0 && page !== currentPage) {
|
|
setCurrentPage(page);
|
|
}
|
|
}
|
|
}, []); // Only run on mount
|
|
|
|
// Update URL when page changes
|
|
useEffect(() => {
|
|
const currentUrlPage = searchParams.get('page');
|
|
const currentUrlPageNum = currentUrlPage ? parseInt(currentUrlPage, 10) : 1;
|
|
|
|
if (currentPage !== currentUrlPageNum) {
|
|
if (currentPage === 1) {
|
|
// Remove page param when on page 1
|
|
searchParams.delete('page');
|
|
} else {
|
|
searchParams.set('page', currentPage.toString());
|
|
}
|
|
setSearchParams(searchParams, { replace: true });
|
|
}
|
|
}, [currentPage, searchParams, setSearchParams]);
|
|
|
|
const fetchApplications = useCallback(async () => {
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
const data = await searchApplications(filters, currentPage, pageSize);
|
|
setResult(data);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to load applications');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [filters, currentPage, pageSize]);
|
|
|
|
useEffect(() => {
|
|
fetchApplications();
|
|
}, [fetchApplications]);
|
|
|
|
useEffect(() => {
|
|
async function loadReferenceData() {
|
|
try {
|
|
const data = await getReferenceData();
|
|
setOrganisations(data.organisations);
|
|
setHostingTypes(data.hostingTypes);
|
|
setBusinessImportanceOptions(data.businessImportance || []);
|
|
setApplicationSubteams(data.applicationSubteams || []);
|
|
} catch (err) {
|
|
console.error('Failed to load reference data', err);
|
|
}
|
|
}
|
|
loadReferenceData();
|
|
}, []);
|
|
|
|
// Update navigation context whenever results change, so "Opslaan & Volgende" works
|
|
// even when user opens an application in a new tab
|
|
useEffect(() => {
|
|
if (result && result.applications.length > 0) {
|
|
const allIds = result.applications.map((a) => a.id);
|
|
// Preserve current index if it's still valid, otherwise reset to 0
|
|
setNavigationContext(allIds, filters, 0);
|
|
}
|
|
}, [result, filters, setNavigationContext]);
|
|
|
|
const handleRowClick = (app: ApplicationListItem, index: number, event: React.MouseEvent) => {
|
|
// Update current index in navigation context
|
|
if (result) {
|
|
const allIds = result.applications.map((a) => a.id);
|
|
setNavigationContext(allIds, filters, index);
|
|
}
|
|
|
|
// Let the browser handle CTRL+click / CMD+click / middle-click natively for new tab
|
|
// Only navigate programmatically for regular clicks
|
|
if (!event.ctrlKey && !event.metaKey && !event.shiftKey && event.button === 0) {
|
|
event.preventDefault();
|
|
navigate(`/application/${app.id}`);
|
|
}
|
|
};
|
|
|
|
const toggleStatus = (status: ApplicationStatus) => {
|
|
const current = filters.statuses || [];
|
|
if (current.includes(status)) {
|
|
setStatuses(current.filter((s) => s !== status));
|
|
} else {
|
|
setStatuses([...current, status]);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Page header */}
|
|
<div className="flex justify-between items-center">
|
|
<div>
|
|
<h2 className="text-2xl font-bold text-gray-900">Applicaties</h2>
|
|
<p className="text-gray-600">Zoek en classificeer applicatiecomponenten</p>
|
|
</div>
|
|
<button
|
|
onClick={() => setShowFilters(!showFilters)}
|
|
className="btn btn-secondary"
|
|
>
|
|
{showFilters ? 'Verberg filters' : 'Toon filters'}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Search and filters */}
|
|
<div className="card">
|
|
{/* Search bar */}
|
|
<div className="p-4 border-b border-gray-200">
|
|
<div className="relative">
|
|
<input
|
|
type="text"
|
|
placeholder="Zoeken op naam, beschrijving, leverancier..."
|
|
value={filters.searchText || ''}
|
|
onChange={(e) => setSearchText(e.target.value)}
|
|
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
/>
|
|
<svg
|
|
className="absolute left-3 top-2.5 h-5 w-5 text-gray-400"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Filters */}
|
|
{showFilters && (
|
|
<div className="p-4 bg-gray-50 border-b border-gray-200">
|
|
<div className="flex justify-between items-center mb-4">
|
|
<h3 className="font-medium text-gray-900">Filters</h3>
|
|
<button
|
|
onClick={resetFilters}
|
|
className="text-sm text-blue-600 hover:text-blue-800"
|
|
>
|
|
Wis alle filters
|
|
</button>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
|
{/* Status filter */}
|
|
<div>
|
|
<label className="label mb-2">Status</label>
|
|
<div className="space-y-1 max-h-48 overflow-y-auto">
|
|
{ALL_STATUSES.map((status) => (
|
|
<label key={status} className="flex items-center space-x-2">
|
|
<input
|
|
type="checkbox"
|
|
checked={(filters.statuses || []).includes(status)}
|
|
onChange={() => toggleStatus(status)}
|
|
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
|
/>
|
|
<span className="text-sm text-gray-700">{status}</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Classification filters */}
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="label mb-2">ApplicationFunction</label>
|
|
<div className="space-y-1">
|
|
{(['all', 'filled', 'empty'] as const).map((value) => (
|
|
<label key={value} className="flex items-center space-x-2">
|
|
<input
|
|
type="radio"
|
|
name="applicationFunction"
|
|
checked={filters.applicationFunction === value}
|
|
onChange={() => setApplicationFunction(value)}
|
|
className="border-gray-300 text-blue-600 focus:ring-blue-500"
|
|
/>
|
|
<span className="text-sm text-gray-700">
|
|
{value === 'all' ? 'Alle' : value === 'filled' ? 'Ingevuld' : 'Leeg'}
|
|
</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="label mb-2">Governance Model</label>
|
|
<div className="space-y-1">
|
|
{(['all', 'filled', 'empty'] as const).map((value) => (
|
|
<label key={value} className="flex items-center space-x-2">
|
|
<input
|
|
type="radio"
|
|
name="governanceModel"
|
|
checked={filters.governanceModel === value}
|
|
onChange={() => setGovernanceModel(value)}
|
|
className="border-gray-300 text-blue-600 focus:ring-blue-500"
|
|
/>
|
|
<span className="text-sm text-gray-700">
|
|
{value === 'all' ? 'Alle' : value === 'filled' ? 'Ingevuld' : 'Leeg'}
|
|
</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="label mb-2">Application Type</label>
|
|
<div className="space-y-1">
|
|
{(['all', 'filled', 'empty'] as const).map((value) => (
|
|
<label key={value} className="flex items-center space-x-2">
|
|
<input
|
|
type="radio"
|
|
name="applicationType"
|
|
checked={filters.applicationType === value}
|
|
onChange={() => setApplicationType(value)}
|
|
className="border-gray-300 text-blue-600 focus:ring-blue-500"
|
|
/>
|
|
<span className="text-sm text-gray-700">
|
|
{value === 'all' ? 'Alle' : value === 'filled' ? 'Ingevuld' : 'Leeg'}
|
|
</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Dropdown filters */}
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="label mb-2">Organisatie</label>
|
|
<select
|
|
value={filters.organisation || ''}
|
|
onChange={(e) => setOrganisation(e.target.value || undefined)}
|
|
className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
>
|
|
<option value="">Alle organisaties</option>
|
|
{organisations.map((org) => (
|
|
<option key={org.objectId} value={org.name}>
|
|
{org.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="label mb-2">Hosting Type</label>
|
|
<select
|
|
value={filters.hostingType || ''}
|
|
onChange={(e) => setHostingType(e.target.value || undefined)}
|
|
className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
>
|
|
<option value="">Alle types</option>
|
|
{hostingTypes.map((type) => (
|
|
<option key={type.objectId} value={type.name}>
|
|
{type.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="label mb-2">Business Importance</label>
|
|
<select
|
|
value={filters.businessImportance || ''}
|
|
onChange={(e) => setBusinessImportance(e.target.value || undefined)}
|
|
className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
>
|
|
<option value="">Alle</option>
|
|
{businessImportanceOptions.map((importance) => (
|
|
<option key={importance.objectId} value={importance.name}>
|
|
{importance.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="label mb-2">Subteam</label>
|
|
<select
|
|
value={filters.applicationSubteam || 'all'}
|
|
onChange={(e) => setApplicationSubteam(e.target.value as 'all' | 'empty' | string)}
|
|
className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
>
|
|
<option value="all">Alle</option>
|
|
<option value="empty">Leeg</option>
|
|
{applicationSubteams.map((subteam) => (
|
|
<option key={subteam.objectId} value={subteam.name}>
|
|
{subteam.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Results count */}
|
|
<div className="px-4 py-3 bg-white border-b border-gray-200 flex justify-between items-center">
|
|
<span className="text-sm text-gray-600">
|
|
{result ? (
|
|
<>
|
|
Resultaten: <strong>{result.totalCount}</strong> applicaties
|
|
</>
|
|
) : (
|
|
'Laden...'
|
|
)}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Results table */}
|
|
{loading ? (
|
|
<div className="flex items-center justify-center h-64">
|
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
|
|
</div>
|
|
) : error ? (
|
|
<div className="p-4 text-red-600">{error}</div>
|
|
) : (
|
|
<div className="overflow-x-auto">
|
|
<table className="min-w-full divide-y divide-gray-200">
|
|
<thead className="bg-gray-50">
|
|
<tr>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
#
|
|
</th>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Naam
|
|
</th>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Status
|
|
</th>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
AppFunctie
|
|
</th>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Governance
|
|
</th>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Benodigde inspanning
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="bg-white divide-y divide-gray-200">
|
|
{result?.applications.map((app, index) => (
|
|
<tr
|
|
key={app.id}
|
|
className="hover:bg-blue-50 transition-colors group"
|
|
>
|
|
<td className="py-0">
|
|
<Link
|
|
to={`/application/${app.id}`}
|
|
onClick={(e) => handleRowClick(app, index, e)}
|
|
className="block px-4 py-3 text-sm text-gray-500"
|
|
>
|
|
{(currentPage - 1) * pageSize + index + 1}
|
|
</Link>
|
|
</td>
|
|
<td className="py-0">
|
|
<Link
|
|
to={`/application/${app.id}`}
|
|
onClick={(e) => handleRowClick(app, index, e)}
|
|
className="block px-4 py-3"
|
|
>
|
|
<div className="text-sm font-medium text-blue-600 group-hover:text-blue-800 group-hover:underline">
|
|
{app.name}
|
|
</div>
|
|
<div className="text-xs text-gray-500">{app.key}</div>
|
|
</Link>
|
|
</td>
|
|
<td className="py-0">
|
|
<Link
|
|
to={`/application/${app.id}`}
|
|
onClick={(e) => handleRowClick(app, index, e)}
|
|
className="block px-4 py-3"
|
|
>
|
|
<StatusBadge status={app.status} />
|
|
</Link>
|
|
</td>
|
|
<td className="py-0">
|
|
<Link
|
|
to={`/application/${app.id}`}
|
|
onClick={(e) => handleRowClick(app, index, e)}
|
|
className="block px-4 py-3"
|
|
>
|
|
{app.applicationFunctions && app.applicationFunctions.length > 0 ? (
|
|
<div className="flex flex-wrap gap-1">
|
|
{app.applicationFunctions.map((func) => (
|
|
<span
|
|
key={func.objectId}
|
|
className="inline-block px-2 py-0.5 text-xs bg-blue-100 text-blue-800 rounded"
|
|
title={func.description || func.name}
|
|
>
|
|
{func.name}{func.applicationFunctionCategory ? ` (${func.applicationFunctionCategory.name})` : ''}
|
|
</span>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<span className="text-sm text-orange-600 font-medium">
|
|
Leeg
|
|
</span>
|
|
)}
|
|
</Link>
|
|
</td>
|
|
<td className="py-0">
|
|
<Link
|
|
to={`/application/${app.id}`}
|
|
onClick={(e) => handleRowClick(app, index, e)}
|
|
className="block px-4 py-3"
|
|
>
|
|
{app.governanceModel ? (
|
|
<span className="text-sm text-gray-900">
|
|
{app.governanceModel.name}
|
|
</span>
|
|
) : (
|
|
<span className="text-sm text-orange-600 font-medium">
|
|
Leeg
|
|
</span>
|
|
)}
|
|
</Link>
|
|
</td>
|
|
<td className="py-0">
|
|
<Link
|
|
to={`/application/${app.id}`}
|
|
onClick={(e) => handleRowClick(app, index, e)}
|
|
className="block px-4 py-3 text-sm text-gray-900"
|
|
>
|
|
{app.requiredEffortApplicationManagement !== null ? (
|
|
<span className="font-medium">
|
|
{app.requiredEffortApplicationManagement.toFixed(2)} FTE
|
|
</span>
|
|
) : (
|
|
<span className="text-gray-400">-</span>
|
|
)}
|
|
</Link>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
|
|
{/* Pagination */}
|
|
{result && result.totalPages > 1 && (
|
|
<div className="px-4 py-3 bg-gray-50 border-t border-gray-200 flex items-center justify-between">
|
|
{currentPage > 1 ? (
|
|
<Link
|
|
to={currentPage === 2 ? '/application/overview' : `/application/overview?page=${currentPage - 1}`}
|
|
onClick={() => setCurrentPage(currentPage - 1)}
|
|
className="btn btn-secondary"
|
|
>
|
|
Vorige
|
|
</Link>
|
|
) : (
|
|
<button disabled className="btn btn-secondary opacity-50 cursor-not-allowed">
|
|
Vorige
|
|
</button>
|
|
)}
|
|
<span className="text-sm text-gray-600">
|
|
Pagina {currentPage} van {result.totalPages}
|
|
</span>
|
|
{currentPage < result.totalPages ? (
|
|
<Link
|
|
to={`/application/overview?page=${currentPage + 1}`}
|
|
onClick={() => setCurrentPage(currentPage + 1)}
|
|
className="btn btn-secondary"
|
|
>
|
|
Volgende
|
|
</Link>
|
|
) : (
|
|
<button disabled className="btn btn-secondary opacity-50 cursor-not-allowed">
|
|
Volgende
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function StatusBadge({ status }: { status: string | null }) {
|
|
const statusColors: Record<string, string> = {
|
|
'Closed': 'badge-dark-red',
|
|
'Deprecated': 'badge-yellow',
|
|
'End of life': 'badge-light-red',
|
|
'End of support': 'badge-light-red',
|
|
'Implementation': 'badge-blue',
|
|
'In Production': 'badge-dark-green',
|
|
'Proof of Concept': 'badge-light-green',
|
|
'Shadow IT': 'badge-black',
|
|
'Undefined': 'badge-gray',
|
|
};
|
|
|
|
if (!status) return <span className="text-sm text-gray-400">-</span>;
|
|
|
|
return (
|
|
<span className={clsx('badge', statusColors[status] || 'badge-gray')}>
|
|
{status}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
export function BusinessImportanceBadge({ importance }: { importance: string | null }) {
|
|
// Helper function to get the number prefix from the importance string
|
|
const getImportanceNumber = (value: string | null): string | null => {
|
|
if (!value) return null;
|
|
// Match patterns like "0 - Critical Infrastructure" or just "0"
|
|
const match = value.match(/^(\d+)/);
|
|
return match ? match[1] : null;
|
|
};
|
|
|
|
const importanceNumber = getImportanceNumber(importance);
|
|
|
|
// Map importance number to icon type and color
|
|
const getImportanceConfig = (num: string | null) => {
|
|
switch (num) {
|
|
case '0':
|
|
return {
|
|
icon: 'warning',
|
|
color: 'badge-darker-red',
|
|
label: importance || '0 - Critical Infrastructure',
|
|
};
|
|
case '1':
|
|
return {
|
|
icon: 'exclamation',
|
|
color: 'badge-dark-red',
|
|
label: importance || '1 - Critical',
|
|
};
|
|
case '2':
|
|
return {
|
|
icon: 'exclamation',
|
|
color: 'badge-red',
|
|
label: importance || '2 - Highest',
|
|
};
|
|
case '3':
|
|
return {
|
|
icon: 'circle',
|
|
color: 'badge-yellow-orange',
|
|
label: importance || '3 - High',
|
|
};
|
|
case '4':
|
|
return {
|
|
icon: 'circle',
|
|
color: 'badge-dark-blue',
|
|
label: importance || '4 - Medium',
|
|
};
|
|
case '5':
|
|
return {
|
|
icon: 'circle',
|
|
color: 'badge-light-blue',
|
|
label: importance || '5 - Low',
|
|
};
|
|
case '6':
|
|
return {
|
|
icon: 'circle',
|
|
color: 'badge-lighter-blue',
|
|
label: importance || '6 - Lowest',
|
|
};
|
|
case '9':
|
|
return {
|
|
icon: 'question',
|
|
color: 'badge-black',
|
|
label: importance || '9 - Unknown',
|
|
};
|
|
default:
|
|
return {
|
|
icon: null,
|
|
color: 'badge-gray',
|
|
label: importance || '-',
|
|
};
|
|
}
|
|
};
|
|
|
|
if (!importance) return <span className="text-sm text-gray-400">-</span>;
|
|
|
|
const config = getImportanceConfig(importanceNumber);
|
|
|
|
// Icon components
|
|
const WarningIcon = () => (
|
|
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
|
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
|
</svg>
|
|
);
|
|
|
|
const ExclamationIcon = () => (
|
|
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
|
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
|
</svg>
|
|
);
|
|
|
|
const CircleIcon = () => (
|
|
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
|
<circle cx="10" cy="10" r="8" />
|
|
</svg>
|
|
);
|
|
|
|
const QuestionIcon = () => (
|
|
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
|
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-3a1 1 0 00-.867.5 1 1 0 11-1.731-1A3 3 0 0113 8a3.001 3.001 0 01-2 2.83V11a1 1 0 11-2 0v-1a1 1 0 011-1 1 1 0 100-2zm0 8a1 1 0 100-2 1 1 0 000 2z" clipRule="evenodd" />
|
|
</svg>
|
|
);
|
|
|
|
const renderIcon = () => {
|
|
switch (config.icon) {
|
|
case 'warning':
|
|
return <WarningIcon />;
|
|
case 'exclamation':
|
|
return <ExclamationIcon />;
|
|
case 'circle':
|
|
return <CircleIcon />;
|
|
case 'question':
|
|
return <QuestionIcon />;
|
|
default:
|
|
return null;
|
|
}
|
|
};
|
|
|
|
return (
|
|
<span className={clsx('badge inline-flex items-center gap-1.5', config.color)}>
|
|
{renderIcon()}
|
|
<span>{config.label}</span>
|
|
</span>
|
|
);
|
|
}
|