diff --git a/frontend/src/client/i18n.ts b/frontend/src/client/i18n.ts index 2053da3..0520764 100644 --- a/frontend/src/client/i18n.ts +++ b/frontend/src/client/i18n.ts @@ -1595,6 +1595,14 @@ const translations: Record> = { "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> = { "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…", diff --git a/frontend/src/client/projects-detail.ts b/frontend/src/client/projects-detail.ts index 1acb8b0..e6879d4 100644 --- a/frontend/src/client/projects-detail.ts +++ b/frontend/src/client/projects-detail.ts @@ -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 = 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 `; + return ` + ${checkboxCell} ${esc(m.user_display_name || m.user_email)} · ${esc(m.user_email)}${officeLabel ? " · " + esc(officeLabel) : ""} ${esc(professionLabel)} @@ -2574,6 +2601,21 @@ function renderTeam() { }) .join(""); + // t-paliad-231 — wire row checkboxes + master + mailto button. + body.querySelectorAll(".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(".team-remove-btn").forEach((btn) => { btn.addEventListener("click", async () => { if (!project) return; @@ -2860,6 +2902,113 @@ async function showTeamErrorToast(resp: Response): Promise { }, 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(); + 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( + "#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("#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; diff --git a/frontend/src/i18n-keys.ts b/frontend/src/i18n-keys.ts index 0084fbd..3d1cd7a 100644 --- a/frontend/src/i18n-keys.ts +++ b/frontend/src/i18n-keys.ts @@ -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" diff --git a/frontend/src/projects-detail.tsx b/frontend/src/projects-detail.tsx index 867ae7c..8f90f02 100644 --- a/frontend/src/projects-detail.tsx +++ b/frontend/src/projects-detail.tsx @@ -286,9 +286,33 @@ export function renderProjectsDetail(): string {

+ {/* 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. */} +

+ + diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index 7809c0f..085f3b0 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -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; }
+ + Name Profession Rolle