fix(t-paliad-141): project team-add autocomplete + invite-new-user inline flow

Root cause: `.collab-suggestions` had `display: none` in CSS but no JS site
ever toggled it back on. Suggestions rendered into a permanently hidden div.
Bug originated when the akten-collab-* pattern was renamed and copied for
project team-add and partner-units member-add — the original akten-neu.ts
toggled `style.display`, but the copies relied on innerHTML alone.

Fix: switch to content-driven visibility — `.collab-suggestions:not(:empty)
{ display: block }`. No JS changes needed at consumer sites; fixes all three
broken pickers (project team-add, project parent picker, partner-units member-
add) at once. Added missing styling for `.collab-suggestion` items (padding,
hover, separators) — they were unstyled even when visible.

Plus: invite-new-user inline affordance on project /team. When the typed
query matches zero existing users, a "Benutzer nicht gefunden? Einladen"
row appears below the dropdown. Click opens the existing global invite modal
(sidebar-invite-btn → /api/invite) and pre-fills the email if the query
looks like one. No new backend, no new modal — reuses what /admin/team and
the sidebar already use.
This commit is contained in:
m
2026-05-06 16:21:53 +02:00
parent e2e1381395
commit fb1a709bb8
5 changed files with 111 additions and 2 deletions

View File

@@ -1116,6 +1116,9 @@ const translations: Record<Lang, Record<string, string>> = {
"projects.detail.team.confirm_remove": "Mitglied entfernen?",
"projects.detail.team.empty": "Noch keine Teammitglieder.",
"projects.detail.team.error.user_required": "Benutzer ausw\u00e4hlen",
"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",
"projects.view.tree": "Baumansicht",
"projects.tree.toggle": "Aufklappen / Zuklappen",
"projects.tree.loading": "Baum wird geladen\u2026",
@@ -2792,6 +2795,9 @@ const translations: Record<Lang, Record<string, string>> = {
"projects.detail.team.confirm_remove": "Remove member?",
"projects.detail.team.empty": "No team members yet.",
"projects.detail.team.error.user_required": "Select a user",
"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",
"projects.view.tree": "Tree view",
"projects.tree.toggle": "Expand / collapse",
"projects.tree.loading": "Loading tree…",

View File

@@ -1383,8 +1383,24 @@ function initTeamForm(id: string) {
const sugs = document.getElementById("team-user-suggestions") as HTMLDivElement | null;
const msg = document.getElementById("team-msg") as HTMLParagraphElement | null;
const role = document.getElementById("team-role") as HTMLSelectElement | null;
const inviteHint = document.getElementById("team-user-invite-hint") as HTMLDivElement | null;
const inviteHintText = document.getElementById("team-user-invite-hint-text") as HTMLSpanElement | null;
const inviteBtn = document.getElementById("team-user-invite-btn") as HTMLButtonElement | null;
if (!addBtn || !form || !cancel || !input || !hidden || !sugs || !msg || !role) return;
const hideInviteHint = () => {
if (inviteHint) inviteHint.style.display = "none";
};
const showInviteHint = (q: string) => {
if (!inviteHint || !inviteHintText) return;
const looksLikeEmail = /@/.test(q) && /\./.test(q.split("@")[1] || "");
inviteHintText.textContent = looksLikeEmail
? t("projects.detail.team.invite.hint_email") || "Niemand mit dieser E-Mail."
: t("projects.detail.team.invite.hint") || "Benutzer nicht gefunden?";
inviteHint.dataset.email = looksLikeEmail ? q : "";
inviteHint.style.display = "";
};
addBtn.addEventListener("click", () => {
form.style.display = "";
addBtn.style.display = "none";
@@ -1396,18 +1412,21 @@ function initTeamForm(id: string) {
input.value = "";
hidden.value = "";
sugs.innerHTML = "";
hideInviteHint();
msg.textContent = "";
});
input.addEventListener("input", () => {
const q = input.value.trim().toLowerCase();
const q = input.value.trim();
const lc = q.toLowerCase();
hidden.value = "";
if (!q) {
sugs.innerHTML = "";
hideInviteHint();
return;
}
const matches = userOptions
.filter((u) => (u.display_name + " " + u.email).toLowerCase().includes(q))
.filter((u) => (u.display_name + " " + u.email).toLowerCase().includes(lc))
.slice(0, 8);
sugs.innerHTML = matches
.map(
@@ -1422,8 +1441,29 @@ function initTeamForm(id: string) {
hidden.value = el.dataset.id!;
input.value = el.dataset.label!;
sugs.innerHTML = "";
hideInviteHint();
});
});
if (matches.length === 0) {
showInviteHint(q);
} else {
hideInviteHint();
}
});
inviteBtn?.addEventListener("click", () => {
const sidebarBtn = document.getElementById("sidebar-invite-btn") as HTMLButtonElement | null;
if (!sidebarBtn) return;
sidebarBtn.click();
const prefill = inviteHint?.dataset.email || "";
if (prefill) {
const inviteEmail = document.getElementById("invite-email") as HTMLInputElement | null;
if (inviteEmail) {
inviteEmail.value = prefill;
inviteEmail.dispatchEvent(new Event("input", { bubbles: true }));
}
}
});
form.addEventListener("submit", async (e) => {
@@ -1446,6 +1486,7 @@ function initTeamForm(id: string) {
input.value = "";
hidden.value = "";
sugs.innerHTML = "";
hideInviteHint();
form.style.display = "none";
addBtn.style.display = "";
await loadTeam(id);

View File

@@ -1447,6 +1447,9 @@ export type I18nKey =
| "projects.detail.team.form.role"
| "projects.detail.team.form.submit"
| "projects.detail.team.form.user"
| "projects.detail.team.invite.cta"
| "projects.detail.team.invite.hint"
| "projects.detail.team.invite.hint_email"
| "projects.detail.team.remove"
| "projects.detail.title"
| "projects.detail.verlauf.empty"

View File

@@ -109,6 +109,10 @@ export function renderProjectsDetail(): string {
<input type="text" id="team-user-input" placeholder="Name oder E-Mail..." autocomplete="off" />
<input type="hidden" id="team-user-id" />
<div id="team-user-suggestions" className="collab-suggestions" />
<div id="team-user-invite-hint" className="collab-invite-hint" style="display:none">
<span id="team-user-invite-hint-text" data-i18n="projects.detail.team.invite.hint">Benutzer nicht gefunden?</span>
<button type="button" className="btn-secondary btn-small" id="team-user-invite-btn" data-i18n="projects.detail.team.invite.cta">Einladen</button>
</div>
</div>
<div className="form-field">
<label htmlFor="team-role" data-i18n="projects.detail.team.form.role">Rolle</label>

View File

@@ -5872,6 +5872,61 @@ input[type="range"]::-moz-range-thumb {
z-index: 10;
}
/* Visibility is content-driven: when innerHTML is "" the div has no children
and stays hidden; the moment a consumer renders <.collab-suggestion> rows
the dropdown shows. Keeps the JS sites (project team-add, project parent
picker, partner-units member-add) from each having to toggle display. */
.collab-suggestions:not(:empty) {
display: block;
}
.collab-suggestion {
display: block;
padding: 0.5rem 0.75rem;
cursor: pointer;
border-bottom: 1px solid var(--color-border);
}
.collab-suggestion:last-child {
border-bottom: none;
}
.collab-suggestion:hover,
.collab-suggestion.is-active {
background: var(--color-bg-lime-tint);
}
.collab-suggestion strong {
display: block;
font-weight: 600;
font-size: 0.9rem;
}
.collab-suggestion .form-hint {
display: block;
font-size: 0.8rem;
margin-top: 0.1rem;
}
/* Inline "invite this user instead" affordance shown beneath an empty
.collab-suggestions when the typed query has no matches. */
.collab-invite-hint {
margin-top: 0.5rem;
padding: 0.5rem 0.75rem;
background: var(--color-surface-alt, var(--color-bg-lime-tint));
border: 1px dashed var(--color-border);
border-radius: var(--radius);
font-size: 0.85rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.collab-invite-hint button {
flex-shrink: 0;
}
.entity-suggestion {
width: 100%;
display: flex;