mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-23 20:25:56 +00:00
feat(portal): add explicit team management panel
This commit is contained in:
@@ -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
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"source_hash": "9e66ef00fa2623eb7e00f30d4cd4a823bfb4552eeb89136392577ad25b7caad2",
|
||||
"source_hash": "ffa91a3e86257e3723273ec4a2950841ad9a5d3ed8d67b74c591d825ed5e1103",
|
||||
"build_inputs": [
|
||||
"package.json",
|
||||
"tsconfig.json",
|
||||
|
||||
+89
-5
@@ -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
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user