feat(portal): add explicit team management panel

This commit is contained in:
rcourtman
2026-03-27 14:09:37 +00:00
parent 298bf791b9
commit 23df63ed97
10 changed files with 214 additions and 15 deletions
@@ -143,6 +143,9 @@ Core rules:
10. Workspace lifecycle actions must open in an explicit management surface
with the selected workspace context visible, not inside a hidden overflow
menu or a blind confirm-first interaction.
11. Team and membership management must be treated as an account-management
surface with visible roster and invite context, not as a hidden table that
appears without orientation.
## Screen Model
+1 -1
View File
@@ -1,5 +1,5 @@
{
"source_hash": "9e66ef00fa2623eb7e00f30d4cd4a823bfb4552eeb89136392577ad25b7caad2",
"source_hash": "ffa91a3e86257e3723273ec4a2950841ad9a5d3ed8d67b74c591d825ed5e1103",
"build_inputs": [
"package.json",
"tsconfig.json",
+89 -5
View File
@@ -451,19 +451,92 @@ header .logout-btn:hover {
.team-section {
margin-top: 12px;
display: none;
}
.team-section.visible {
display: block;
}
.team-management-panel {
background: #fff;
border: 1px solid #dbe4f0;
border-radius: 12px;
padding: 18px;
box-shadow: 0 1px 3px rgba(15, 23, 42, .04);
}
.team-management-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 14px;
}
.team-management-header h3 {
margin: 0 0 4px;
font-size: 16px;
font-weight: 700;
}
.team-management-header p {
margin: 0;
font-size: 13px;
color: #64748b;
line-height: 1.5;
max-width: 560px;
}
.team-management-stats {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.team-stat-card {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 10px;
padding: 12px 14px;
display: flex;
flex-direction: column;
gap: 6px;
}
.team-stat-label {
font-size: 12px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #64748b;
}
.team-stat-value {
font-size: 20px;
font-weight: 700;
color: #0f172a;
}
.team-stat-error {
color: #b91c1c;
}
.team-management-grid {
display: grid;
grid-template-columns: minmax(0, 1.5fr) minmax(280px, 1fr);
gap: 16px;
}
.team-roster,
.team-invite-panel {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 10px;
padding: 16px;
}
.team-section.visible {
display: block;
.team-panel-heading {
margin-bottom: 12px;
}
.team-section h3 {
margin: 0 0 12px;
.team-panel-heading h4 {
margin: 0 0 4px;
font-size: 15px;
font-weight: 700;
}
.team-panel-heading p {
margin: 0;
font-size: 13px;
color: #64748b;
line-height: 1.5;
}
.team-table {
width: 100%;
border-collapse: collapse;
@@ -511,7 +584,6 @@ header .logout-btn:hover {
text-decoration: underline;
}
.team-invite {
margin-top: 12px;
display: flex;
gap: 8px;
align-items: flex-end;
@@ -917,6 +989,15 @@ header .logout-btn:hover {
.workspace-management-header {
flex-direction: column;
}
.team-management-header {
flex-direction: column;
}
.team-management-stats {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.team-management-grid {
grid-template-columns: 1fr;
}
.overview-stats {
grid-template-columns: 1fr;
}
@@ -925,6 +1006,9 @@ header .logout-btn:hover {
.account-status-grid {
grid-template-columns: 1fr;
}
.team-management-stats {
grid-template-columns: 1fr;
}
.workspace-card {
flex-direction: column;
align-items: flex-start;
+27 -1
View File
@@ -78,6 +78,31 @@
tr.appendChild(td);
tbody.appendChild(tr);
}
function countMembersByRole(members, role) {
var count = 0;
for (var i = 0; i < members.length; i += 1) {
if (members[i].role === role) count += 1;
}
return count;
}
function renderTeamStats(accountID, entry) {
var stats = getElement("team-stats-" + accountID);
if (!stats) return;
if (!entry.teamVisible) {
stats.innerHTML = "";
return;
}
if (entry.teamQuery.status === "loading") {
stats.innerHTML = '<div class="team-stat-card"><span class="team-stat-label">Roster</span><span class="team-stat-value">Loading\u2026</span></div>';
return;
}
if (entry.teamQuery.status === "error") {
stats.innerHTML = '<div class="team-stat-card"><span class="team-stat-label">Roster</span><span class="team-stat-value team-stat-error">Needs attention</span></div>';
return;
}
var members = entry.teamQuery.data;
stats.innerHTML = '<div class="team-stat-card"><span class="team-stat-label">Members</span><span class="team-stat-value">' + String(members.length) + '</span></div><div class="team-stat-card"><span class="team-stat-label">Owners</span><span class="team-stat-value">' + String(countMembersByRole(members, "owner")) + '</span></div><div class="team-stat-card"><span class="team-stat-label">Admins</span><span class="team-stat-value">' + String(countMembersByRole(members, "admin")) + '</span></div><div class="team-stat-card"><span class="team-stat-label">Operators</span><span class="team-stat-value">' + String(countMembersByRole(members, "tech") + countMembersByRole(members, "read_only")) + "</span></div>";
}
function renderTeamMemberRoleCell(accountID, member, isOwner) {
var tdRole = document.createElement("td");
if (member.role === "owner" && !isOwner) {
@@ -131,6 +156,7 @@
var actorRole = section.getAttribute("data-actor-role") || "";
var isOwner = actorRole === "owner";
section.classList.toggle("visible", entry.teamVisible);
renderTeamStats(accountID, entry);
if (!entry.teamVisible) {
return;
}
@@ -1586,7 +1612,7 @@
var workspaceManagement = "";
if (account.can_manage) {
actions = '<div class="account-operations-panel"><div class="account-operations-copy"><h3>Account operations</h3><p>' + escapeHTML(operationsCopy) + '</p></div><div class="account-actions">' + (account.kind === "msp" ? '<button type="button" class="btn-secondary" id="add-ws-btn-' + escapeAttr(account.id) + '" data-action="toggle-add-workspace" data-account-id="' + escapeAttr(account.id) + '">Add workspace</button>' : "") + (account.has_billing ? '<button type="button" class="btn-secondary" data-action="open-billing" data-account-id="' + escapeAttr(account.id) + '">Manage billing</button>' : "") + '<button type="button" class="btn-secondary" id="team-btn-' + escapeAttr(account.id) + '" data-action="toggle-team" data-account-id="' + escapeAttr(account.id) + '">Manage team</button></div></div>';
teamSection = '<div class="team-section" id="team-section-' + escapeAttr(account.id) + '" data-actor-role="' + escapeAttr(account.role) + '"><h3>Team members</h3><table class="team-table"><thead><tr><th>Email</th><th>Role</th><th></th></tr></thead><tbody id="team-list-' + escapeAttr(account.id) + '"><tr><td colspan="3" class="team-message-cell">Loading\u2026</td></tr></tbody></table><div class="team-invite"><div><label for="invite-email-' + escapeAttr(account.id) + '">Email</label><input type="email" id="invite-email-' + escapeAttr(account.id) + '" placeholder="user@example.com" autocomplete="off"></div><div><label for="invite-role-' + escapeAttr(account.id) + '">Role</label><select id="invite-role-' + escapeAttr(account.id) + '"><option value="admin">Admin</option><option value="tech">Tech</option><option value="read_only">Read-only</option></select></div><button type="button" class="btn-primary btn-compact" data-action="invite-member" data-account-id="' + escapeAttr(account.id) + '">Invite</button></div></div>';
teamSection = '<div class="team-management-panel team-section" id="team-section-' + escapeAttr(account.id) + '" data-actor-role="' + escapeAttr(account.role) + '"><div class="team-management-header"><div><h3>Team management</h3><p>Control who can operate this account, what role they hold, and who should be invited next.</p></div><button type="button" class="btn-secondary btn-compact" data-action="toggle-team" data-account-id="' + escapeAttr(account.id) + '">Done</button></div><div class="team-management-stats" id="team-stats-' + escapeAttr(account.id) + '"></div><div class="team-management-grid"><div class="team-roster"><div class="team-panel-heading"><h4>People on this account</h4><p>Owners can manage billing and account access. Admins and techs keep the hosted fleet running day to day.</p></div><table class="team-table"><thead><tr><th>Email</th><th>Role</th><th></th></tr></thead><tbody id="team-list-' + escapeAttr(account.id) + '"><tr><td colspan="3" class="team-message-cell">Loading\u2026</td></tr></tbody></table></div><div class="team-invite-panel"><div class="team-panel-heading"><h4>Invite someone new</h4><p>Add another operator with the minimum role they need for this account.</p></div><div class="team-invite"><div><label for="invite-email-' + escapeAttr(account.id) + '">Email</label><input type="email" id="invite-email-' + escapeAttr(account.id) + '" placeholder="user@example.com" autocomplete="off"></div><div><label for="invite-role-' + escapeAttr(account.id) + '">Role</label><select id="invite-role-' + escapeAttr(account.id) + '"><option value="admin">Admin</option><option value="tech">Tech</option><option value="read_only">Read-only</option></select></div><button type="button" class="btn-primary btn-compact" data-action="invite-member" data-account-id="' + escapeAttr(account.id) + '">Invite</button></div></div></div></div>';
workspaceManagement = '<div class="workspace-management-panel" id="workspace-management-' + escapeAttr(account.id) + '"><div class="workspace-management-header"><div><h3>Workspace management</h3><p>Select a workspace from the fleet to review its lifecycle state and run explicit management actions.</p></div><button type="button" class="btn-secondary btn-compact" id="workspace-management-close-' + escapeAttr(account.id) + '" data-action="clear-workspace-selection" data-account-id="' + escapeAttr(account.id) + '">Done</button></div><div class="workspace-management-empty" id="workspace-management-empty-' + escapeAttr(account.id) + '">Choose a workspace to manage from the fleet above.</div><div class="workspace-management-content" id="workspace-management-content-' + escapeAttr(account.id) + '" hidden><div class="workspace-management-meta" id="workspace-management-meta-' + escapeAttr(account.id) + '"></div><h4 id="workspace-management-title-' + escapeAttr(account.id) + '"></h4><p class="workspace-management-summary" id="workspace-management-summary-' + escapeAttr(account.id) + '"></p><div class="workspace-management-actions"><button type="button" class="btn-danger" id="workspace-management-action-' + escapeAttr(account.id) + '" data-action="workspace-action" data-account-id="' + escapeAttr(account.id) + '">Manage workspace</button></div></div></div>';
if (account.kind === "msp") {
addWorkspaceForm = '<div class="add-workspace-form" id="add-ws-form-' + escapeAttr(account.id) + '"><label for="ws-name-' + escapeAttr(account.id) + '">Workspace name (e.g. client name)</label><input type="text" id="ws-name-' + escapeAttr(account.id) + '" placeholder="Acme Corp" maxlength="80" autocomplete="off"><div class="form-actions"><button type="button" class="btn-primary" data-action="create-workspace" data-account-id="' + escapeAttr(account.id) + '">Create workspace</button><button type="button" class="btn-secondary" data-action="toggle-add-workspace" data-account-id="' + escapeAttr(account.id) + '">Cancel</button><div class="spinner" id="ws-spinner-' + escapeAttr(account.id) + '" hidden></div></div></div>';
@@ -176,6 +176,7 @@ describe('account runtime', function() {
document.body.innerHTML =
'<div id="workspace-management-acct_1" class="workspace-management-panel"><button id="workspace-management-close-acct_1"></button><div id="workspace-management-empty-acct_1"></div><div id="workspace-management-content-acct_1" hidden><div id="workspace-management-meta-acct_1"></div><h4 id="workspace-management-title-acct_1"></h4><p id="workspace-management-summary-acct_1"></p><button id="workspace-management-action-acct_1"></button></div></div>' +
'<div id="team-section-acct_1" class="team-section" data-actor-role="owner">' +
'<div id="team-stats-acct_1"></div>' +
'<table><tbody id="team-list-acct_1"></tbody></table>' +
'<input id="invite-email-acct_1">' +
'<select id="invite-role-acct_1"><option value="admin">Admin</option></select>' +
@@ -187,6 +188,7 @@ describe('account runtime', function() {
expect(deps.store.getAccountState().byAccountID.acct_1.teamVisible).toBe(true);
expect(deps.store.getAccountState().byAccountID.acct_1.teamQuery.status).toBe('ready');
expect(deps.store.getAccountState().byAccountID.acct_1.teamQuery.data).toHaveLength(1);
expect(document.getElementById('team-stats-acct_1')?.textContent).toContain('Members');
var roleSelect = document.querySelector('[data-action="change-role"]') as HTMLSelectElement | null;
expect(roleSelect).not.toBeNull();
expect(roleSelect?.value).toBe('owner');
@@ -75,6 +75,7 @@ describe('account view', function() {
it('renders team loading, error, and populated member states', function() {
document.body.innerHTML =
'<div id="team-section-acct_1" class="team-section" data-actor-role="owner">' +
'<div id="team-stats-acct_1"></div>' +
'<table><tbody id="team-list-acct_1"></tbody></table>' +
'</div>';
@@ -97,12 +98,17 @@ describe('account view', function() {
teamQuery: {
status: 'ready',
error: '',
data: [{ email: 'owner@example.com', role: 'owner', user_id: 'u1' }],
data: [
{ email: 'owner@example.com', role: 'owner', user_id: 'u1' },
{ email: 'tech@example.com', role: 'tech', user_id: 'u2' },
],
},
})
);
expect(document.querySelector('[data-action="change-role"]')).not.toBeNull();
expect(document.querySelector('[data-action="remove-member"]')).not.toBeNull();
expect(document.getElementById('team-stats-acct_1')?.textContent).toContain('Members');
expect(document.getElementById('team-stats-acct_1')?.textContent).toContain('2');
});
it('renders workspace management selection from account UI state', function() {
@@ -130,10 +136,10 @@ describe('account view', function() {
document.body.innerHTML =
'<div id="add-ws-form-acct_1" class="add-workspace-form"></div><div id="ws-spinner-acct_1"></div>' +
'<div id="workspace-management-acct_1" class="workspace-management-panel"><button id="workspace-management-close-acct_1"></button><div id="workspace-management-empty-acct_1"></div><div id="workspace-management-content-acct_1" hidden><div id="workspace-management-meta-acct_1"></div><h4 id="workspace-management-title-acct_1"></h4><p id="workspace-management-summary-acct_1"></p><button id="workspace-management-action-acct_1"></button></div></div>' +
'<div id="team-section-acct_1" class="team-section" data-actor-role="admin"><table><tbody id="team-list-acct_1"></tbody></table></div>' +
'<div id="team-section-acct_1" class="team-section" data-actor-role="admin"><div id="team-stats-acct_1"></div><table><tbody id="team-list-acct_1"></tbody></table></div>' +
'<div id="add-ws-form-acct_2" class="add-workspace-form"></div><div id="ws-spinner-acct_2"></div>' +
'<div id="workspace-management-acct_2" class="workspace-management-panel"><button id="workspace-management-close-acct_2"></button><div id="workspace-management-empty-acct_2"></div><div id="workspace-management-content-acct_2" hidden><div id="workspace-management-meta-acct_2"></div><h4 id="workspace-management-title-acct_2"></h4><p id="workspace-management-summary-acct_2"></p><button id="workspace-management-action-acct_2"></button></div></div>' +
'<div id="team-section-acct_2" class="team-section" data-actor-role="owner"><table><tbody id="team-list-acct_2"></tbody></table></div>';
'<div id="team-section-acct_2" class="team-section" data-actor-role="owner"><div id="team-stats-acct_2"></div><table><tbody id="team-list-acct_2"></tbody></table></div>';
var state: PortalAccountState = {
byAccountID: {
@@ -98,6 +98,38 @@ function setTbodyMessage(tbody: HTMLElement, msg: string, isError: boolean): voi
tbody.appendChild(tr);
}
function countMembersByRole(members: PortalTeamMember[], role: string): number {
var count = 0;
for (var i = 0; i < members.length; i += 1) {
if (members[i].role === role) count += 1;
}
return count;
}
function renderTeamStats(accountID: string, entry: PortalAccountUIEntry): void {
var stats = getElement<HTMLElement>('team-stats-' + accountID);
if (!stats) return;
if (!entry.teamVisible) {
stats.innerHTML = '';
return;
}
if (entry.teamQuery.status === 'loading') {
stats.innerHTML = '<div class="team-stat-card"><span class="team-stat-label">Roster</span><span class="team-stat-value">Loading…</span></div>';
return;
}
if (entry.teamQuery.status === 'error') {
stats.innerHTML = '<div class="team-stat-card"><span class="team-stat-label">Roster</span><span class="team-stat-value team-stat-error">Needs attention</span></div>';
return;
}
var members = entry.teamQuery.data;
stats.innerHTML =
'<div class="team-stat-card"><span class="team-stat-label">Members</span><span class="team-stat-value">' + String(members.length) + '</span></div>' +
'<div class="team-stat-card"><span class="team-stat-label">Owners</span><span class="team-stat-value">' + String(countMembersByRole(members, 'owner')) + '</span></div>' +
'<div class="team-stat-card"><span class="team-stat-label">Admins</span><span class="team-stat-value">' + String(countMembersByRole(members, 'admin')) + '</span></div>' +
'<div class="team-stat-card"><span class="team-stat-label">Operators</span><span class="team-stat-value">' + String(countMembersByRole(members, 'tech') + countMembersByRole(members, 'read_only')) + '</span></div>';
}
function renderTeamMemberRoleCell(accountID: string, member: PortalTeamMember, isOwner: boolean): HTMLTableCellElement {
var tdRole = document.createElement('td');
if (member.role === 'owner' && !isOwner) {
@@ -157,6 +189,7 @@ export function renderTeamSection(accountID: string, entry: PortalAccountUIEntry
var actorRole = section.getAttribute('data-actor-role') || '';
var isOwner = actorRole === 'owner';
section.classList.toggle('visible', entry.teamVisible);
renderTeamStats(accountID, entry);
if (!entry.teamVisible) {
return;
@@ -151,6 +151,9 @@ describe('shell view', function() {
expect(html).toContain('Workspace management');
expect(html).toContain('Choose a workspace to manage from the fleet above.');
expect(html).toContain('data-action="clear-workspace-selection"');
expect(html).toContain('Team management');
expect(html).toContain('Invite someone new');
expect(html).toContain('People on this account');
expect(html).toContain('data-action="workspace-action"');
expect(html).toContain('service-card-button');
expect(html).toContain('id="open-retrieve-service"');
@@ -239,12 +239,29 @@ function renderAccountSection(account: PortalAccountSummary, accountAPIBasePath:
'</div>';
teamSection =
'<div class="team-section" id="team-section-' +
'<div class="team-management-panel team-section" id="team-section-' +
escapeAttr(account.id) +
'" data-actor-role="' +
escapeAttr(account.role) +
'">' +
'<h3>Team members</h3>' +
'<div class="team-management-header">' +
'<div>' +
'<h3>Team management</h3>' +
'<p>Control who can operate this account, what role they hold, and who should be invited next.</p>' +
'</div>' +
'<button type="button" class="btn-secondary btn-compact" data-action="toggle-team" data-account-id="' +
escapeAttr(account.id) +
'">Done</button>' +
'</div>' +
'<div class="team-management-stats" id="team-stats-' +
escapeAttr(account.id) +
'"></div>' +
'<div class="team-management-grid">' +
'<div class="team-roster">' +
'<div class="team-panel-heading">' +
'<h4>People on this account</h4>' +
'<p>Owners can manage billing and account access. Admins and techs keep the hosted fleet running day to day.</p>' +
'</div>' +
'<table class="team-table">' +
'<thead><tr><th>Email</th><th>Role</th><th></th></tr></thead>' +
'<tbody id="team-list-' +
@@ -253,6 +270,12 @@ function renderAccountSection(account: PortalAccountSummary, accountAPIBasePath:
'<tr><td colspan="3" class="team-message-cell">Loading…</td></tr>' +
'</tbody>' +
'</table>' +
'</div>' +
'<div class="team-invite-panel">' +
'<div class="team-panel-heading">' +
'<h4>Invite someone new</h4>' +
'<p>Add another operator with the minimum role they need for this account.</p>' +
'</div>' +
'<div class="team-invite">' +
'<div><label for="invite-email-' +
escapeAttr(account.id) +
@@ -268,6 +291,8 @@ function renderAccountSection(account: PortalAccountSummary, accountAPIBasePath:
escapeAttr(account.id) +
'">Invite</button>' +
'</div>' +
'</div>' +
'</div>' +
'</div>';
workspaceManagement =
@@ -76,9 +76,22 @@
.add-workspace-form label { display: block; font-size: 13px; font-weight: 600; margin-bottom: 6px; }
.add-workspace-form input { width: 100%; border: 1px solid #cbd5e1; border-radius: 8px; padding: 8px 12px; font-size: 14px; margin-bottom: 10px; }
.add-workspace-form .form-actions { display: flex; gap: 8px; }
.team-section { margin-top: 12px; display: none; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 10px; padding: 16px; }
.team-section { margin-top: 12px; display: none; }
.team-section.visible { display: block; }
.team-section h3 { margin: 0 0 12px; font-size: 15px; font-weight: 700; }
.team-management-panel { background: #fff; border: 1px solid #dbe4f0; border-radius: 12px; padding: 18px; box-shadow: 0 1px 3px rgba(15,23,42,.04); }
.team-management-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 14px; }
.team-management-header h3 { margin: 0 0 4px; font-size: 16px; font-weight: 700; }
.team-management-header p { margin: 0; font-size: 13px; color: #64748b; line-height: 1.5; max-width: 560px; }
.team-management-stats { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin-bottom: 16px; }
.team-stat-card { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 10px; padding: 12px 14px; display: flex; flex-direction: column; gap: 6px; }
.team-stat-label { font-size: 12px; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: #64748b; }
.team-stat-value { font-size: 20px; font-weight: 700; color: #0f172a; }
.team-stat-error { color: #b91c1c; }
.team-management-grid { display: grid; grid-template-columns: minmax(0, 1.5fr) minmax(280px, 1fr); gap: 16px; }
.team-roster, .team-invite-panel { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 10px; padding: 16px; }
.team-panel-heading { margin-bottom: 12px; }
.team-panel-heading h4 { margin: 0 0 4px; font-size: 15px; font-weight: 700; }
.team-panel-heading p { margin: 0; font-size: 13px; color: #64748b; line-height: 1.5; }
.team-table { width: 100%; border-collapse: collapse; font-size: 14px; }
.team-table th { text-align: left; font-size: 12px; font-weight: 600; color: #64748b; text-transform: uppercase; letter-spacing: 0.5px; padding: 6px 8px; border-bottom: 1px solid #e2e8f0; }
.team-table td { padding: 8px; border-bottom: 1px solid #f1f5f9; vertical-align: middle; }
@@ -87,7 +100,7 @@
.team-table select { border: 1px solid #cbd5e1; border-radius: 6px; padding: 4px 8px; font-size: 13px; background: #fff; }
.team-table .btn-remove { background: none; border: none; color: #dc2626; cursor: pointer; font-size: 13px; padding: 4px 8px; }
.team-table .btn-remove:hover { text-decoration: underline; }
.team-invite { margin-top: 12px; display: flex; gap: 8px; align-items: flex-end; flex-wrap: wrap; }
.team-invite { display: flex; gap: 8px; align-items: flex-end; flex-wrap: wrap; }
.team-invite label { font-size: 12px; font-weight: 600; display: block; margin-bottom: 4px; }
.team-invite input { border: 1px solid #cbd5e1; border-radius: 8px; padding: 8px 12px; font-size: 14px; min-width: 200px; }
.team-invite select { border: 1px solid #cbd5e1; border-radius: 6px; padding: 8px 10px; font-size: 14px; background: #fff; }
@@ -163,10 +176,14 @@
.account-actions { justify-content: flex-start; }
.account-subsection-header { flex-direction: column; align-items: flex-start; }
.workspace-management-header { flex-direction: column; }
.team-management-header { flex-direction: column; }
.team-management-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.team-management-grid { grid-template-columns: 1fr; }
.overview-stats { grid-template-columns: 1fr; }
}
@media (max-width: 560px) {
.account-status-grid { grid-template-columns: 1fr; }
.team-management-stats { grid-template-columns: 1fr; }
.workspace-card { flex-direction: column; align-items: flex-start; }
.ws-actions { align-self: stretch; }
.ws-actions form, .ws-actions .btn-primary { width: 100%; text-align: center; }