feat(team): mailto: selection on project-detail Team tab (t-paliad-231)

Non-admins can now select team members directly on the project detail
Team tab and open a mailto: link in their local mail client with every
selected member queued in the To: line. No server call, no audit row —
the existing /admin/team server-SMTP broadcast (t-paliad-147) stays
admin-only and untouched.

Behaviour:
- Checkbox column on every team-body row (direct + ancestor-inherited).
  Rows for users without an email render a disabled checkbox so the
  column geometry stays uniform.
- Tri-state master checkbox in the header row toggles every visible,
  email-bearing row.
- Single "Mail an Auswahl" button above the table, disabled while the
  selection is empty. When one or more rows are selected the label
  picks up "(N)" and the title attribute spells out the count.
- Click composes mailto:a@x,b@y via the existing buildMailtoHref
  helper from broadcast.ts (RFC 6068 comma join + encodeURIComponent
  per address) and sets window.location.href. Pure client side.
- Selection is pruned to currently-rendered, email-bearing user_ids
  on every renderTeam call so removed members or members who lose
  their email drop out automatically.
This commit is contained in:
mAi
2026-05-21 15:17:52 +02:00
parent 7c3c84454d
commit 9f339747e5
5 changed files with 217 additions and 0 deletions

View File

@@ -1595,6 +1595,14 @@ const translations: Record<Lang, Record<string, string>> = {
"projects.detail.team.invite.hint": "Benutzer nicht gefunden?",
"projects.detail.team.invite.hint_email": "Niemand mit dieser E-Mail.",
"projects.detail.team.invite.cta": "Einladen",
// t-paliad-231 — pure-client mailto: button on the Team tab. No
// server call; opens the local mail client with every selected
// member queued in the To: line.
"projects.team.mailto.label": "Mail an Auswahl",
"projects.team.mailto.empty": "Mindestens ein Mitglied auswählen",
"projects.team.mailto.count": "{n} ausgewählt",
"projects.team.mailto.select_all": "Alle sichtbaren auswählen",
"projects.team.mailto.select_row": "Mitglied auswählen",
"projects.view.tree": "Baumansicht",
"projects.tree.toggle": "Aufklappen / Zuklappen",
"projects.tree.loading": "Baum wird geladen\u2026",
@@ -4449,6 +4457,12 @@ const translations: Record<Lang, Record<string, string>> = {
"projects.detail.team.invite.hint": "User not found?",
"projects.detail.team.invite.hint_email": "No one with that email.",
"projects.detail.team.invite.cta": "Invite",
// t-paliad-231 — pure-client mailto: button on the Team tab.
"projects.team.mailto.label": "Mail to selection",
"projects.team.mailto.empty": "Select at least one member",
"projects.team.mailto.count": "{n} selected",
"projects.team.mailto.select_all": "Select all visible",
"projects.team.mailto.select_row": "Select member",
"projects.view.tree": "Tree view",
"projects.tree.toggle": "Expand / collapse",
"projects.tree.loading": "Loading tree…",

View File

@@ -13,6 +13,7 @@ import { mountFilterBar, type BarHandle } from "./filter-bar";
import type { FilterSpec, RenderSpec } from "./views/types";
import { renderSmartTimeline, type TimelineEvent as SmartTimelineEvent, type LaneInfo as SmartTimelineLane } from "./views/shape-timeline";
import { loadAndRenderSubmissions } from "./submissions";
import { buildMailtoHref, type BroadcastRecipient } from "./broadcast";
interface Project {
id: string;
@@ -236,6 +237,12 @@ let attachedUnits: AttachedUnit[] = [];
let allUnits: { id: string; name: string; office: string }[] = [];
let userOptions: { id: string; display_name: string; email: string; profession?: string }[] = [];
// t-paliad-231 — checkbox selection backing the "Mail an Auswahl"
// mailto: button on the Team tab. Pure client state, wiped on page
// navigation. Pruned to currently-visible user_ids on every renderTeam
// so removed/filtered-out members don't ride along in the next mailto.
const selectedMailUserIDs: Set<string> = new Set();
const EVENTS_PAGE_SIZE = 50;
let eventsHasMore = false;
let eventsLoadingMore = false;
@@ -2507,6 +2514,7 @@ async function loadUserList() {
function renderTeam() {
const body = document.getElementById("team-body")!;
const empty = document.getElementById("team-empty")!;
const mailtoControls = document.getElementById("team-mailto-controls") as HTMLDivElement | null;
// Existing team-body shows the direct + ancestor-inherited members
// returned by /api/projects/{id}/team. The derived + descendant
@@ -2517,12 +2525,21 @@ function renderTeam() {
if (totalRows === 0) {
body.innerHTML = "";
empty.style.display = "";
if (mailtoControls) mailtoControls.style.display = "none";
selectedMailUserIDs.clear();
syncMailtoButton();
syncMasterCheckbox();
renderDescendantStaffed();
renderDerivedMembers();
renderAttachedUnits();
return;
}
empty.style.display = "none";
if (mailtoControls) mailtoControls.style.display = teamMembers.length > 0 ? "" : "none";
// Prune the selection to whoever is actually rendered in team-body
// right now (e.g. a member just got removed). Invariant: selection ⊆
// currently-visible team-body rows.
pruneMailSelectionToVisible();
// t-paliad-223: callers with effective_project_admin authority see an
// inline <select> on the Rolle cell. Everyone else sees the read-only
@@ -2563,7 +2580,17 @@ function renderTeam() {
? renderResponsibilitySelect(m.user_id, responsibility)
: `<span class="projekt-team-responsibility">${esc(responsibilityLabel)}</span>`;
// t-paliad-231: per-row checkbox feeding selectedMailUserIDs. Only
// rows with a real email participate in the mailto: build; rows
// without are still rendered with a disabled checkbox so the
// column geometry stays uniform.
const hasEmail = !!(m.user_email && m.user_email.trim());
const checked = hasEmail && selectedMailUserIDs.has(m.user_id) ? " checked" : "";
const disabled = hasEmail ? "" : " disabled";
const checkboxCell = `<td class="team-col-select"><input type="checkbox" class="team-mail-select" data-user-id="${esc(m.user_id)}" data-email="${escAttr(m.user_email || "")}" aria-label="${escAttr(t("projects.team.mailto.select_row") || "Mitglied auswählen")}"${checked}${disabled} /></td>`;
return `<tr>
${checkboxCell}
<td><strong>${esc(m.user_display_name || m.user_email)}</strong>
<span class="form-hint">&middot; ${esc(m.user_email)}${officeLabel ? " &middot; " + esc(officeLabel) : ""}</span></td>
<td><span class="${profCls}" title="${escAttr(professionTitle)}">${esc(professionLabel)}</span></td>
@@ -2574,6 +2601,21 @@ function renderTeam() {
})
.join("");
// t-paliad-231 — wire row checkboxes + master + mailto button.
body.querySelectorAll<HTMLInputElement>(".team-mail-select").forEach((cb) => {
cb.addEventListener("change", () => {
const userID = cb.dataset.userId!;
if (cb.checked) selectedMailUserIDs.add(userID);
else selectedMailUserIDs.delete(userID);
syncMailtoButton();
syncMasterCheckbox();
});
});
wireMailtoMaster();
wireMailtoButton();
syncMailtoButton();
syncMasterCheckbox();
body.querySelectorAll<HTMLButtonElement>(".team-remove-btn").forEach((btn) => {
btn.addEventListener("click", async () => {
if (!project) return;
@@ -2860,6 +2902,113 @@ async function showTeamErrorToast(resp: Response): Promise<void> {
}, 5000);
}
// t-paliad-231 — mailto: selection helpers for the Team tab. The
// admin-only server SMTP broadcast (POST /api/team/broadcast) lives
// elsewhere; this is the non-admin / quick-CC variant that opens the
// user's local mail client. Pure client; no server call.
function pruneMailSelectionToVisible(): void {
const visible = new Set<string>();
for (const m of teamMembers) {
if (m.user_email && m.user_email.trim()) visible.add(m.user_id);
}
for (const id of Array.from(selectedMailUserIDs)) {
if (!visible.has(id)) selectedMailUserIDs.delete(id);
}
}
function selectedMailRecipients(): BroadcastRecipient[] {
const out: BroadcastRecipient[] = [];
for (const m of teamMembers) {
if (!selectedMailUserIDs.has(m.user_id)) continue;
if (!m.user_email || !m.user_email.trim()) continue;
out.push({
user_id: m.user_id,
email: m.user_email,
display_name: m.user_display_name || m.user_email,
first_name: (m.user_display_name || m.user_email).trim().split(/\s+/)[0] ?? "",
role_on_project: m.responsibility || "member",
});
}
return out;
}
function syncMailtoButton(): void {
const btn = document.getElementById("team-mailto-btn") as HTMLButtonElement | null;
const label = document.getElementById("team-mailto-label") as HTMLSpanElement | null;
if (!btn || !label) return;
const n = selectedMailRecipients().length;
const baseLabel = t("projects.team.mailto.label") || "Mail an Auswahl";
if (n === 0) {
btn.disabled = true;
label.textContent = baseLabel;
btn.title = t("projects.team.mailto.empty") || "Mindestens ein Mitglied auswählen";
} else {
btn.disabled = false;
label.textContent = `${baseLabel} (${n})`;
const tooltip = (t("projects.team.mailto.count") || "{n} ausgewählt").replace("{n}", String(n));
btn.title = tooltip;
}
}
function syncMasterCheckbox(): void {
const master = document.getElementById("team-select-master") as HTMLInputElement | null;
if (!master) return;
// Only count rows that actually rendered with an enabled checkbox —
// members without an email don't participate.
const checkboxes = document.querySelectorAll<HTMLInputElement>(
"#team-body .team-mail-select:not(:disabled)",
);
const total = checkboxes.length;
let selected = 0;
checkboxes.forEach((cb) => {
if (selectedMailUserIDs.has(cb.dataset.userId!)) selected++;
});
master.disabled = total === 0;
if (total === 0 || selected === 0) {
master.checked = false;
master.indeterminate = false;
} else if (selected === total) {
master.checked = true;
master.indeterminate = false;
} else {
master.checked = false;
master.indeterminate = true;
}
}
// wireMailtoMaster is idempotent — registers once via a sentinel data
// attr so re-renders don't stack click handlers.
function wireMailtoMaster(): void {
const master = document.getElementById("team-select-master") as HTMLInputElement | null;
if (!master || master.dataset.wired === "1") return;
master.dataset.wired = "1";
master.addEventListener("change", () => {
const turnOn = master.checked;
document
.querySelectorAll<HTMLInputElement>("#team-body .team-mail-select:not(:disabled)")
.forEach((cb) => {
const id = cb.dataset.userId!;
if (turnOn) selectedMailUserIDs.add(id);
else selectedMailUserIDs.delete(id);
cb.checked = turnOn;
});
syncMailtoButton();
syncMasterCheckbox();
});
}
function wireMailtoButton(): void {
const btn = document.getElementById("team-mailto-btn") as HTMLButtonElement | null;
if (!btn || btn.dataset.wired === "1") return;
btn.dataset.wired = "1";
btn.addEventListener("click", (e) => {
e.preventDefault();
const recipients = selectedMailRecipients();
if (recipients.length === 0) return;
window.location.href = buildMailtoHref(recipients);
});
}
function initTeamForm(id: string) {
const addBtn = document.getElementById("team-add-btn") as HTMLButtonElement | null;
const form = document.getElementById("team-form") as HTMLFormElement | null;

View File

@@ -2399,6 +2399,11 @@ export type I18nKey =
| "projects.team.error.generic"
| "projects.team.error.last_admin"
| "projects.team.inherited.hint"
| "projects.team.mailto.count"
| "projects.team.mailto.empty"
| "projects.team.mailto.label"
| "projects.team.mailto.select_all"
| "projects.team.mailto.select_row"
| "projects.team.profession.associate"
| "projects.team.profession.hint"
| "projects.team.profession.none"

View File

@@ -286,9 +286,33 @@ export function renderProjectsDetail(): string {
<p className="form-msg" id="team-msg" />
</form>
{/* t-paliad-231 — pure-client mailto: for non-admins.
Button stays disabled until at least one row is
selected; click opens a mailto: with every selected
member in the To: line. No server call. */}
<div className="party-controls team-mailto-controls" id="team-mailto-controls" style="display:none">
<button
type="button"
id="team-mailto-btn"
className="btn-secondary btn-small"
disabled
data-i18n-title="projects.team.mailto.empty"
title="Mindestens ein Mitglied ausw&auml;hlen"
>
<span id="team-mailto-label" data-i18n="projects.team.mailto.label">Mail an Auswahl</span>
</button>
</div>
<table className="party-table">
<thead>
<tr>
<th className="team-col-select">
<input
type="checkbox"
id="team-select-master"
aria-label="Alle sichtbaren ausw&auml;hlen"
/>
</th>
<th data-i18n="projects.detail.team.col.name">Name</th>
<th data-i18n="projects.detail.team.col.profession">Profession</th>
<th data-i18n="projects.detail.team.col.responsibility">Rolle</th>

View File

@@ -7143,6 +7143,31 @@ dialog.modal::backdrop {
border-bottom: none;
}
/* t-paliad-231 — checkbox column for the project-detail Team tab's
"Mail an Auswahl" mailto: selection. Narrow, centred, accent-coloured. */
.party-table .team-col-select {
width: 2.4rem;
text-align: center;
padding-left: 0.6rem;
padding-right: 0.4rem;
}
.team-mail-select,
#team-select-master {
accent-color: var(--color-primary, #c6f41c);
cursor: pointer;
}
.team-mail-select:disabled {
cursor: not-allowed;
opacity: 0.4;
}
.team-mailto-controls {
justify-content: flex-end;
margin-bottom: 0.5rem;
}
.entity-col-actions {
text-align: right;
}