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:
@@ -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…",
|
||||
|
||||
@@ -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">· ${esc(m.user_email)}${officeLabel ? " · " + 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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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ä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ä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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user