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
6 changes: 6 additions & 0 deletions src/components/admin/AdminPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { AdminsManagement } from './AdminsManagement';
import { AdminSystemLogs } from './AdminSystemLogs';
import { CentersManagement } from './CentersManagement';
import { CenterDetail } from './CenterDetail';
import { PlatformAccessPage } from './PlatformAccessPage';
import { UserDetail } from './UserDetail';
import { DateRangeSelector, type DateRange } from '../dashboard/Superadmin/analytics/DateRangeSelector';

Expand Down Expand Up @@ -69,6 +70,8 @@ const AdminPanelContent: React.FC = () => {
return t('admin.centers');
case '/admin/admins':
return t('admin.regionalAdmins');
case '/admin/platforms':
return t('admin.platformAccess');
case '/admin/logs':
return t('admin.systemLogs');
default:
Expand Down Expand Up @@ -107,6 +110,9 @@ const AdminPanelContent: React.FC = () => {
{/* Regional Admins Management */}
<Route path="/admins" element={<AdminsManagement />} />

{/* Platform Access (TASK-2.2.2) */}
<Route path="/platforms" element={<PlatformAccessPage />} />

{/* System Logs */}
<Route path="/logs" element={<AdminSystemLogs />} />

Expand Down
286 changes: 286 additions & 0 deletions src/components/admin/PlatformAccessPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,286 @@
/**
* PlatformAccessPage
*
* Dedicated `/admin/platforms` route for the super-admin to review and
* toggle platform access across every group. Mirrors the layout conventions
* of `GroupsList` (stats row → filter bar → card grid), but each card carries
* INLINE toggle buttons instead of a per-card quick-edit grid — opening the
* shared `ConfirmPlatformChangeDialog` for every change keeps the audit
* trail clean.
*
* TASK-2.2.2 scope:
* - 2.2.2.1 — Page component + dedicated route.
* - 2.2.2.2 — ConfirmPlatformChangeDialog wired UP to the per-group toggle.
* - 2.2.2.4 — i18n, WCAG, dark-mode, Vitest coverage.
*
* Re-entrance / loading-state handling:
* - Page-level `pendingChange` state holds the staged diff while the dialog
* is open. The dialog owns its own `isSaving` flag so the page is not
* refetched twice during the PUT.
* - On error: assumption is `useGroups().updatePlatforms` already toasts.
* The dialog re-enables itself via promise rejection (see dialog).
*/
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { MaterialIcon } from '../ui/Icon';
import {
useGroups,
PLATFORMS,
type Group,
type Platform,
} from '../../hooks/useGroups';
import { ConfirmPlatformChangeDialog } from './groups/ConfirmPlatformChangeDialog';

interface PendingChange {
group: Group;
current: Platform[];
proposed: Platform[];
}

export const PlatformAccessPage: React.FC = () => {
const { t } = useTranslation();
const { groups, isLoading, error, updatePlatforms } = useGroups();

const [searchQuery, setSearchQuery] = useState('');
const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'inactive'>(
'all'
);
const [pendingChange, setPendingChange] = useState<PendingChange | null>(null);

/** Per-platform "how many active groups have this enabled" tally for the
* stats row. Memoized so we don't re-tally on every render. */
const platformCounts = useMemo<Record<Platform, number>>(() => {
const counts: Record<Platform, number> = {
bolsa: 0,
tms: 0,
ecmr: 0,
transcend: 0,
};
for (const g of groups) {
if (!g.isActive) continue;
for (const p of g.enabledPlatforms) counts[p]++;
}
return counts;
}, [groups]);

const filteredGroups = useMemo(() => {
const q = searchQuery.trim().toLowerCase();
return groups.filter((g) => {
const matchesSearch =
q.length === 0 ||
g.name.toLowerCase().includes(q) ||
g.code.toLowerCase().includes(q) ||
g.domain.toLowerCase().includes(q);
const matchesStatus =
statusFilter === 'all' ||
(statusFilter === 'active' ? g.isActive : !g.isActive);
return matchesSearch && matchesStatus;
});
}, [groups, searchQuery, statusFilter]);

const handleToggleRequested = (group: Group, platform: Platform): void => {
const current = group.enabledPlatforms;
const proposed = current.includes(platform)
? current.filter((p) => p !== platform)
: [...current, platform];
setPendingChange({ group, current, proposed });
};

const handleConfirm = async (): Promise<void> => {
if (!pendingChange) return;
await updatePlatforms(pendingChange.group.id, pendingChange.proposed);
};

return (
<div className="space-y-6" data-testid="platform-access-page">
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">
{t('groups.platformAccess.pageTitle', 'Platform Access')}
</h1>
<p className="text-slate-500 dark:text-slate-400 mt-1">
{t(
'groups.platformAccess.pageSubtitle',
'Manage platform access across all groups. Changes require confirmation.'
)}
</p>
</div>

{/* Stats — per-platform tallies */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{Object.values(PLATFORMS).map((info) => (
<div
key={info.id}
className="bg-white dark:bg-slate-900 shadow rounded-lg p-4"
>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-slate-500 dark:text-slate-400">
{info.name}
</p>
<p className="text-2xl font-bold text-slate-900 dark:text-white">
{platformCounts[info.id]}
</p>
</div>
<div className="h-10 w-10 rounded-lg bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
<MaterialIcon
name={info.icon}
size="text-xl"
decorative
className="text-blue-600 dark:text-blue-400"
/>
</div>
</div>
</div>
))}
</div>

{/* Filter bar */}
<div className="bg-white dark:bg-slate-900 shadow rounded-lg p-4">
<div className="flex flex-col md:flex-row gap-4">
<div className="flex-1">
<label htmlFor="platform-access-search" className="sr-only">
{t('groups.searchPlaceholder', 'Search groups...')}
</label>
<input
id="platform-access-search"
type="text"
placeholder={t('groups.searchPlaceholder', 'Search groups...')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-slate-700 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-slate-800 dark:text-white"
/>
</div>
<select
value={statusFilter}
onChange={(e) =>
setStatusFilter(e.target.value as 'all' | 'active' | 'inactive')
}
className="px-3 py-2 border border-gray-300 dark:border-slate-700 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-slate-800 dark:text-white"
aria-label={t('groups.status', 'Status')}
>
<option value="all">{t('groups.allStatuses', 'All Statuses')}</option>
<option value="active">{t('common.active', 'Active')}</option>
<option value="inactive">{t('common.inactive', 'Inactive')}</option>
</select>
</div>
</div>

{/* Loading */}
{isLoading && (
<div className="flex justify-center py-12" role="status">
<div className="animate-spin rounded-full h-12 w-12 border-4 border-blue-600 border-t-transparent" />
</div>
)}

{/* Error */}
{error && (
<div
className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-400 px-4 py-3 rounded-lg"
role="alert"
>
<p className="font-medium">
{t('groups.errors.loadFailed', 'Failed to load groups')}
</p>
<p className="text-sm">{error}</p>
</div>
)}

{/* Grid */}
{!isLoading && !error && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{filteredGroups.length === 0 ? (
<div className="col-span-full text-center py-12 bg-white dark:bg-slate-900 rounded-lg border-2 border-dashed border-gray-300 dark:border-slate-700">
<MaterialIcon
name="folder_off"
size="text-4xl"
className="mx-auto text-slate-400 dark:text-slate-600 mb-3"
decorative
/>
<p className="text-slate-500 dark:text-slate-400">
{t('groups.noGroups', 'No groups found')}
</p>
</div>
) : (
filteredGroups.map((group) => {
const enabled = group.enabledPlatforms;
return (
<div
key={group.id}
className="bg-white dark:bg-slate-900 shadow rounded-lg p-4 transition-all hover:shadow-md"
data-testid={`platform-card-${group.id}`}
>
<div className="flex items-start justify-between mb-3">
<div>
<h3 className="text-base font-semibold text-slate-900 dark:text-white">
{group.name}
</h3>
<p className="text-xs text-slate-500 dark:text-slate-400">
{group.code} · {group.domain}
</p>
</div>
<span
className="text-xs font-mono text-slate-400 dark:text-slate-500"
aria-label={t(
'groups.platformAccess.platformsEnabledAriaLabel',
'{{count}} of 4 platforms enabled',
{ count: enabled.length }
)}
>
{enabled.length}/4
</span>
</div>

<div
className="flex flex-wrap gap-1.5"
role="group"
aria-label={`Platforms for ${group.name}`}
>
{Object.values(PLATFORMS).map((info) => {
const isOn = enabled.includes(info.id);
return (
<button
key={info.id}
type="button"
onClick={() => handleToggleRequested(group, info.id)}
disabled={!group.isActive}
aria-pressed={isOn}
aria-label={t(
'groups.platformAccess.toggleAriaLabel',
isOn
? 'Disable {{platform}} for {{group}}'
: 'Enable {{platform}} for {{group}}',
{ platform: info.name, group: group.name }
)}
data-testid={`platform-toggle-${group.id}-${info.id}`}
className={`text-xs px-2 py-1 rounded-md border transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1 dark:focus:ring-offset-slate-900 ${
isOn
? 'bg-blue-100 dark:bg-blue-900/30 border-blue-300 dark:border-blue-700 text-blue-800 dark:text-blue-200'
: 'bg-white dark:bg-slate-800 border-gray-300 dark:border-slate-600 text-slate-500 dark:text-slate-400 hover:border-blue-400 dark:hover:border-blue-500'
} ${!group.isActive ? 'opacity-50 cursor-not-allowed' : ''}`}
>
{info.name}
</button>
);
})}
</div>
</div>
);
})
)}
</div>
)}

{/* Shared confirm dialog */}
{pendingChange && (
<ConfirmPlatformChangeDialog
isOpen={!!pendingChange}
onClose={() => setPendingChange(null)}
onConfirm={handleConfirm}
groupName={pendingChange.group.name}
currentPlatforms={pendingChange.current}
proposedPlatforms={pendingChange.proposed}
/>
)}
</div>
);
};
Loading