diff --git a/src/gui/src/UI/Dashboard/TabTeams.js b/src/gui/src/UI/Dashboard/TabTeams.js index 9141c354c..e651b4a2d 100644 --- a/src/gui/src/UI/Dashboard/TabTeams.js +++ b/src/gui/src/UI/Dashboard/TabTeams.js @@ -17,17 +17,20 @@ * along with this program. If not, see . */ -import UIAlert from '../UIAlert.js'; -import UIPrompt from '../UIPrompt.js'; import teamActionButton from './teamActionIcons.js'; +import UIDashboardDialog from './UIDashboardDialog.js'; import { annotateMembers, auditActionKey, - auditSlice, - canDeleteAccount, auditReasonKey, + auditSlice, + avatarHue, + billingSummaryKey, + canDeleteAccount, + initialOf, membersBillingSummary, memberPlanLabel, + parseTimestamp, sortMembers, } from './teamsConsole.js'; @@ -43,17 +46,15 @@ const AUDIT_ROWS_PER_PAGE = 10; /** In flight, so `init` and the initial-route `onActivate` don't both load. */ let loadPromise = null; -const modalOptions = ($el_window) => ({ - parent_uuid: $el_window.attr('data-element_uuid'), - backdrop: true, - close_on_backdrop_click: true, - parent_center: true, - stay_on_top: true, -}); +/** Every dialog here is the dashboard's overlay card, mounted in the window. */ +const dialog = ($el_window, opts) => UIDashboardDialog({ $container: $el_window, ...opts }); -const dateText = (value) => { - const parsed = new Date(value); - return Number.isNaN(parsed.getTime()) ? '' : parsed.toLocaleDateString(); +const dateText = (value) => parseTimestamp(value)?.toLocaleDateString() ?? ''; + +/** The record needs the hour: several entries share a day. */ +const dateTimeText = (value) => { + const date = parseTimestamp(value); + return date ? date.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' }) : ''; }; const teamName = (team) => team?.name || team?.handle || i18n('teams_untitled'); @@ -63,18 +64,69 @@ const setTabVisible = ($el_window, visible) => { $el_window.find('.dashboard-sidebar-item[data-section="teams"]').toggle(visible); }; +// Inline like the other tabs' card icons: one-place glyphs, not shared assets. +const SVG = (body) => + ``; +const ICONS = { + team: SVG(''), + directory: SVG(''), + copy: SVG(''), + check: SVG(''), + close: SVG(''), + warning: SVG(''), +}; + // -- Rendering ------------------------------------------------------------ +/** A lettered tile in place of a picture: team accounts have none. */ +const renderAvatar = (name, extraClass = '') => { + const hue = avatarHue(name); + return ``; +}; + const renderTeamPicker = () => { if ( state.teams.length < 2 ) return ''; - let h = '
'; - h += ``; - h += '`; for ( const team of state.teams ) { const selected = team.uid === state.selected?.uid ? ' selected' : ''; h += ``; } - h += '
'; + h += ''; + return h; +}; + +/** Name, handle and headcount for the selected team, plus who you are in it. */ +const renderHero = () => { + const team = state.selected; + const count = state.members.length; + let h = '
'; + h += '
'; + h += renderAvatar(teamName(team), ' teams-avatar-lg'); + h += '
'; + h += '
'; + h += `${html_encode(teamName(team))}`; + h += `${i18n(team.isOwner ? 'teams_role_owner' : 'teams_role_member')}`; + h += '
'; + const meta = []; + // Nothing here sets a handle, so its absence is not worth a label. + if ( team.handle ) meta.push(html_encode(`@${team.handle}`)); + meta.push(i18n(count === 1 ? 'teams_member_count_one' : 'teams_member_count', { count })); + h += `${meta.join('')}`; + h += '
'; + const picker = renderTeamPicker(); + const rename = team.isOwner ? `` : ''; + if ( picker || rename ) h += `
${picker}${rename}
`; + h += '
'; + return h; +}; + +const renderCardHead = (title, hint, count = null) => { + let h = '
'; + h += `

${title}`; + if ( count !== null ) h += `${count}`; + h += '

'; + if ( hint ) h += `

${hint}

`; + h += '
'; return h; }; @@ -84,56 +136,82 @@ const planCell = (member) => { return i18n(`teams_member_plan_${label.kind}`); }; +const renderStatus = (member) => { + const kind = member.disabled ? 'disabled' : 'active'; + return `${i18n(`teams_member_state_${kind}`)}`; +}; + +const renderIdentity = (username, subline) => { + const you = username === window.user?.username; + let h = '
'; + h += renderAvatar(username); + h += '
'; + h += `${html_encode(username)}`; + if ( you ) h += `${i18n('share_you')}`; + h += ''; + if ( subline ) h += `${subline}`; + h += '
'; + return h; +}; + +const renderMemberActions = (member) => { + if ( ! member.orgOwned ) return ''; + const forMember = { 'data-username': member.username }; + let h = '
'; + if ( window.team_billing_ui && state.plan?.status === 'ready' ) { + h += teamActionButton({ + className: 'teams-plan-change', + icon: 'plan', + label: i18n('teams_plan_change'), + attrs: { ...forMember, 'data-uuid': member.uuid ?? '' }, + }); + } + h += teamActionButton({ + className: 'teams-reset', + icon: 'credential', + label: i18n('teams_reissue_credential'), + attrs: forMember, + }); + h += member.disabled + ? teamActionButton({ + className: 'teams-enable', + icon: 'enable', + label: i18n('teams_enable_account'), + attrs: forMember, + }) + : teamActionButton({ + className: 'teams-disable', + icon: 'suspend', + label: i18n('teams_disable_account'), + danger: true, + attrs: forMember, + }); + if ( canDeleteAccount(member) ) { + h += teamActionButton({ + className: 'teams-delete-account', + icon: 'remove', + label: i18n('teams_delete_account'), + danger: true, + attrs: forMember, + }); + } + h += '
'; + return h; +}; + +// `data-label` repeats the column heading so the phone layout, which drops +// the header row, can print it beside each value. +const labelled = (key) => `data-label="${html_encode(i18n(key, [], false))}"`; + const renderMemberRow = (member) => { const username = html_encode(member.username); let h = ``; - h += `${username}`; - h += `${i18n(member.orgOwned ? 'teams_member_provisioned' : 'teams_member_joined')}`; - h += `${i18n(member.disabled ? 'teams_member_state_disabled' : 'teams_member_state_active')}`; - h += `${html_encode(dateText(member.createdAt))}`; - h += `${planCell(member)}`; - h += ''; - if ( member.orgOwned ) { - const forMember = { 'data-username': member.username }; - if ( window.team_billing_ui && state.plan?.status === 'ready' ) { - h += teamActionButton({ - className: 'teams-plan-change', - icon: 'plan', - label: i18n('teams_plan_change'), - attrs: { ...forMember, 'data-uuid': member.uuid ?? '' }, - }); - } - h += teamActionButton({ - className: 'teams-reset', - icon: 'credential', - label: i18n('teams_reissue_credential'), - attrs: forMember, - }); - h += member.disabled - ? teamActionButton({ - className: 'teams-enable', - icon: 'enable', - label: i18n('teams_enable_account'), - attrs: forMember, - }) - : teamActionButton({ - className: 'teams-disable', - icon: 'suspend', - label: i18n('teams_disable_account'), - danger: true, - attrs: forMember, - }); - if ( canDeleteAccount(member) ) { - h += teamActionButton({ - className: 'teams-delete-account', - icon: 'remove', - label: i18n('teams_delete_account'), - danger: true, - attrs: forMember, - }); - } - } - h += ''; + h += `${renderIdentity(member.username, i18n(member.orgOwned ? 'teams_member_provisioned' : 'teams_member_joined'))}`; + h += `${renderStatus(member)}`; + h += `${html_encode(dateText(member.createdAt))}`; + h += `${planCell(member)}`; + h += `${renderMemberActions(member)}`; + h += ''; return h; }; @@ -142,20 +220,21 @@ const renderMembers = () => { const summary = membersBillingSummary(annotated); let h = '
'; - h += `

${i18n('teams_accounts')}

`; - const billingKey = summary.billed === 1 ? 'teams_billing_summary_one' : 'teams_billing_summary'; - h += `

${i18n(billingKey, { billed: summary.billed, disabled: summary.disabled })}

`; + h += renderCardHead( + i18n('teams_accounts'), + i18n(billingSummaryKey(summary), { billed: summary.billed, disabled: summary.disabled }), + annotated.length, + ); if ( annotated.length === 0 ) { h += `

${i18n('teams_no_accounts')}

`; } else { - h += '
'; - h += ``; - h += ``; + h += '
${i18n('username')}${i18n('teams_member_kind')}
'; + h += ``; h += ``; h += ``; h += ``; - h += ``; + h += ``; h += ''; for ( const member of sortMembers(annotated) ) h += renderMemberRow(member); h += '
${i18n('teams_member_account')}${i18n('teams_member_state')}${i18n('teams_member_since')}${i18n('teams_member_plan')}${i18n('teams_member_actions')}${i18n('teams_member_actions')}
'; @@ -166,48 +245,52 @@ const renderMembers = () => { const renderAddAccount = () => { let h = '
'; - h += `

${i18n('teams_add_account')}

`; - h += `

${i18n('teams_add_account_hint')}

`; - h += `

${i18n('teams_add_account_email_hint')}

`; - h += '
'; - h += ``; - h += ``; - h += ``; + h += renderCardHead(i18n('teams_add_account'), i18n('teams_add_account_hint')); + h += '
'; + h += '
'; + h += ``; + h += ''; h += '
'; - h += ''; + h += '
'; + h += ``; + h += ''; + h += '
'; + h += ``; + h += '
'; + h += `

${i18n('teams_add_account_email_hint')}

`; + h += ''; h += '
'; return h; }; -const renderAudit = () => { +const renderAudit = ({ title, hint }) => { let h = '
'; - h += `

${i18n('teams_audit')}

`; - h += `

${i18n('teams_audit_hint')}

`; + h += renderCardHead(title, hint, state.audit.length || null); if ( state.audit.length === 0 ) { h += `

${i18n('teams_no_audit')}

`; } else { - h += '
'; - h += ``; - h += ``; - h += ``; - h += ``; - h += ''; const { items, page, pages, from, to, total } = auditSlice( state.audit, state.auditPage, AUDIT_ROWS_PER_PAGE, ); + h += '
${i18n('teams_audit_when')}${i18n('teams_audit_action')}${i18n('teams_audit_account')}${i18n('teams_audit_actor')}
'; + h += ``; + h += ``; + h += ``; + h += ``; + h += ''; for ( const entry of items ) { const actionKey = auditActionKey(entry.action); const reasonKey = auditReasonKey(entry.reason); h += ''; - h += ``; - h += `'; - h += ``; - h += ``; + h += ``; + h += ``; + h += ``; h += ''; } h += '
${i18n('teams_audit_action')}${i18n('teams_audit_account')}${i18n('teams_audit_actor')}${i18n('teams_audit_when')}
${html_encode(dateText(entry.createdAt))}${actionKey ? i18n(actionKey) : html_encode(entry.action)}`; + h += `${actionKey ? i18n(actionKey) : html_encode(entry.action)}`; if ( reasonKey ) h += ` ${i18n(reasonKey)}`; h += '${html_encode(entry.username ?? '')}${html_encode(entry.actorUsername ?? '')}${html_encode(entry.username ?? '')}${html_encode(entry.actorUsername ?? '')}${html_encode(dateTimeText(entry.createdAt))}
'; @@ -227,17 +310,13 @@ const renderAudit = () => { /** Colleagues, by name. No state, no dates, no actions — none are theirs. */ const renderRoster = () => { let h = '
'; - h += `

${i18n('teams_roster')}

`; - h += `

${i18n('teams_roster_hint')}

`; + h += renderCardHead(i18n('teams_roster'), i18n('teams_roster_hint'), state.members.length || null); if ( state.members.length === 0 ) { h += `

${i18n('teams_roster_empty')}

`; } else { h += '
    '; for ( const member of sortMembers(state.members) ) { - const you = member.username === window.user?.username; - h += `
  • ${html_encode(member.username)}`; - if ( you ) h += ` (${i18n('share_you')})`; - h += '
  • '; + h += `
  • ${renderIdentity(member.username)}
  • `; } h += '
'; } @@ -247,44 +326,37 @@ const renderRoster = () => { /** What a member sees: who else is here, their own entries, nothing admin. */ const renderMemberView = () => { - let h = '
'; - h += `

${i18n('teams_your_record')}

`; - h += `

${i18n('teams_your_record_hint', { team: teamName(state.selected) })}

`; - h += '
'; - return h + renderRoster() + renderAudit(); + return renderHero() + + renderRoster() + + renderAudit({ + title: i18n('teams_your_record'), + hint: i18n('teams_your_record_hint', { team: teamName(state.selected) }), + }); }; const renderDirectory = () => { const on = state.selected?.directoryEnabled === true; - let h = '
'; - h += `

${i18n('teams_directory')}

`; - h += `

${i18n('teams_directory_hint')}

`; - h += '