Merge: t-paliad-370 S1 — catalog rows = one primary CTA + ⋯ menu (consistent across both entry points)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled

This commit is contained in:
mAi
2026-06-01 18:23:48 +02:00
6 changed files with 284 additions and 37 deletions

View File

@@ -1741,6 +1741,7 @@ const translations: Record<Lang, Record<string, string>> = {
"projects.detail.submissions.action.generate": "Generieren",
"projects.detail.submissions.action.no_template": "Keine Vorlage",
"projects.detail.submissions.action.edit": "Bearbeiten",
"projects.detail.submissions.action.open": "Entwurf öffnen",
"projects.detail.submissions.hint": "Schriftsätze werden direkt aus dem Projekt heraus als .docx generiert. Anpassen, drucken, einreichen.",
// t-paliad-238 — dedicated draft editor page.
"submissions.draft.title": "Schriftsatz bearbeiten — Paliad",
@@ -5093,6 +5094,7 @@ const translations: Record<Lang, Record<string, string>> = {
"projects.detail.submissions.action.generate": "Generate",
"projects.detail.submissions.action.no_template": "No template",
"projects.detail.submissions.action.edit": "Edit",
"projects.detail.submissions.action.open": "Open draft",
"projects.detail.submissions.hint": "Submissions are generated as .docx directly from the project. Edit, print, file.",
// t-paliad-238 — dedicated draft editor page.
"submissions.draft.title": "Edit submission — Paliad",

View File

@@ -0,0 +1,143 @@
// Shared kebab (⋯) action menu for submission catalog rows.
//
// t-paliad-370 (docforge UX slice S1). Both catalog surfaces — the project
// "Schriftsätze" tab (client/submissions.ts) and the global picker
// (client/submissions-new.ts) — read consistently: one primary CTA plus a
// ⋯ menu for the secondary/alternate actions. This kills the two-equal-
// buttons confusion (PRD §3 S1 / G1-b) while keeping each surface's own
// context.
//
// The popover is body-attached and position:fixed (positioned in JS at open
// time). The catalog lives inside `.entity-table-wrap`, which sets
// overflow-x:auto — an in-flow absolutely-positioned popover would be
// clipped. This mirrors the existing body-attached event-card choices
// popover.
export interface RowActionItem {
/** Visible menu-item label (already localized by the caller). */
label: string;
/** Invoked on select; not called for disabled items. */
onSelect: () => void;
/** Render greyed-out and non-interactive (e.g. a not-yet-wired stub). */
disabled?: boolean;
/** Optional tooltip — handy on a disabled stub ("Bald verfügbar"). */
title?: string;
}
let openPopover: HTMLElement | null = null;
let openTrigger: HTMLButtonElement | null = null;
let globalWired = false;
function closeOpen(): void {
if (openPopover) {
openPopover.remove();
openPopover = null;
}
if (openTrigger) {
openTrigger.setAttribute("aria-expanded", "false");
openTrigger = null;
}
}
// Document/window listeners wired once: outside-click and Escape close the
// menu; scroll/resize close it (the popover is positioned at open time and
// would otherwise drift away from its trigger).
function wireGlobalOnce(): void {
if (globalWired) return;
globalWired = true;
document.addEventListener("click", (e) => {
if (!openPopover) return;
const target = e.target as Node;
if (openPopover.contains(target)) return;
if (openTrigger && openTrigger.contains(target)) return;
closeOpen();
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && openPopover) {
const trigger = openTrigger;
closeOpen();
trigger?.focus();
}
});
window.addEventListener("scroll", () => closeOpen(), true);
window.addEventListener("resize", () => closeOpen());
}
/**
* Build a kebab trigger + its on-demand popover menu. Returns a single inline
* wrapper element the caller mounts into a row's action cell.
*/
export function createRowActionMenu(
items: RowActionItem[],
opts: { ariaLabel: string },
): HTMLElement {
wireGlobalOnce();
const wrap = document.createElement("span");
wrap.className = "row-action-menu";
const trigger = document.createElement("button");
trigger.type = "button";
trigger.className = "btn-icon row-action-menu__trigger";
trigger.setAttribute("aria-haspopup", "menu");
trigger.setAttribute("aria-expanded", "false");
trigger.setAttribute("aria-label", opts.ariaLabel);
trigger.textContent = "⋯";
wrap.appendChild(trigger);
trigger.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
if (openTrigger === trigger) {
closeOpen();
return;
}
closeOpen();
openMenu(trigger, items);
});
return wrap;
}
function openMenu(trigger: HTMLButtonElement, items: RowActionItem[]): void {
const pop = document.createElement("div");
pop.className = "row-action-menu__popover";
pop.setAttribute("role", "menu");
for (const item of items) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "row-action-menu__item";
btn.setAttribute("role", "menuitem");
btn.textContent = item.label;
if (item.title) btn.title = item.title;
if (item.disabled) {
btn.disabled = true;
btn.classList.add("row-action-menu__item--disabled");
} else {
btn.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
closeOpen();
item.onSelect();
});
}
pop.appendChild(btn);
}
document.body.appendChild(pop);
// Position fixed, right edge aligned under the trigger, clamped to viewport.
const r = trigger.getBoundingClientRect();
pop.style.position = "fixed";
pop.style.top = `${Math.round(r.bottom + 4)}px`;
let left = Math.round(r.right - pop.offsetWidth);
if (left < 8) left = 8;
pop.style.left = `${left}px`;
trigger.setAttribute("aria-expanded", "true");
openPopover = pop;
openTrigger = trigger;
pop.querySelector<HTMLButtonElement>(".row-action-menu__item:not(:disabled)")?.focus();
}

View File

@@ -1,10 +1,15 @@
import { initI18n, getLang } from "./i18n";
import { initSidebar } from "./sidebar";
import { createRowActionMenu } from "./row-action-menu";
// t-paliad-243 — client for /submissions/new. Fetches the
// cross-proceeding submission catalog, groups it by proceeding, filters
// by text + chip, and offers two start paths per row: with project
// (modal picker) or without (project-less draft → /submissions/draft/{id}).
// by text + chip.
//
// t-paliad-370 (docforge UX S1): each row is one primary "Entwurf starten"
// CTA (free-start, project-less — m keeps this first-class) + a ⋯ menu for
// the alternates ("Mit Projekt verknüpfen…" → modal picker, base preview),
// consistent with the project Schriftsätze tab.
interface CatalogEntry {
submission_code: string;
@@ -180,11 +185,29 @@ function renderTable(): void {
if (code) void startDraft(code, null);
});
});
body.querySelectorAll<HTMLButtonElement>(".submissions-new-start-with-project").forEach((btn) => {
btn.addEventListener("click", () => {
const code = btn.dataset.code;
if (code) openProjectPicker(code);
});
// Mount the ⋯ menu per row: "Mit Projekt verknüpfen…" (modal picker) +
// base-preview stub. Fresh each render to avoid stale closures.
body.querySelectorAll<HTMLSpanElement>(".row-action-menu-mount").forEach((mount) => {
const code = mount.dataset.code ?? "";
const menu = createRowActionMenu(
[
{
label: isEN() ? "Link a project…" : "Mit Projekt verknüpfen…",
onSelect: () => { if (code) openProjectPicker(code); },
},
{
// S3 wires this to the truthful base-preview modal (PRD §2).
// Stubbed disabled until then so the kebab structure ships in S1.
label: isEN() ? "Preview template base" : "Vorschau Vorlagenbasis",
disabled: true,
title: isEN() ? "Coming soon" : "Bald verfügbar",
onSelect: () => {},
},
],
{ ariaLabel: isEN() ? "More actions" : "Weitere Aktionen" },
);
mount.replaceWith(menu);
});
}
@@ -194,8 +217,7 @@ function renderRow(entry: CatalogEntry): string {
const templateBadge = entry.has_template
? ""
: ` <span class="submission-template-badge" title="${esc(isEN() ? "Uses the universal style template" : "Verwendet die universelle Stilvorlage")}">${esc(isEN() ? "universal" : "universell")}</span>`;
const withProject = isEN() ? "Mit Projekt…" : "Mit Projekt…";
const noProject = isEN() ? "Ohne Projekt" : "Ohne Projekt";
const startLabel = isEN() ? "Start draft" : "Entwurf starten";
return `<tr class="submission-row">
<td>
@@ -205,8 +227,8 @@ function renderRow(entry: CatalogEntry): string {
<td>${esc(partyLabel(entry.primary_party))}</td>
<td>${esc(source)}</td>
<td class="submission-action-cell">
<button type="button" class="btn-secondary btn-small submissions-new-start-with-project" data-code="${esc(entry.submission_code)}">${esc(withProject)}</button>
<button type="button" class="btn-primary btn-cta-lime btn-small submissions-new-start-no-project" data-code="${esc(entry.submission_code)}">${esc(noProject)}</button>
<button type="button" class="btn-primary btn-cta-lime btn-small submissions-new-start-no-project" data-code="${esc(entry.submission_code)}">${esc(startLabel)}</button>
<span class="row-action-menu-mount" data-code="${esc(entry.submission_code)}"></span>
</td>
</tr>`;
}

View File

@@ -8,6 +8,12 @@
// DE.INF.LG, an Opposition under EPO, etc. — the editor (t-paliad-238)
// handles missing variables gracefully via the [KEIN WERT: …] marker,
// so cross-proceeding picks still render cleanly.
//
// t-paliad-370 (docforge UX S1): each row is one primary "Entwurf öffnen"
// CTA + a ⋯ menu holding the secondary actions (direct export, base
// preview), consistent with the global picker.
import { createRowActionMenu } from "./row-action-menu";
function escapeHtml(s: string): string {
return s
@@ -145,14 +151,29 @@ function render(data: SubmissionListResponse): void {
}
body.innerHTML = html.join("");
// Wire button clicks. One handler per render to avoid stale closures
// Mount the ⋯ menu per row, fresh each render to avoid stale closures
// from the previous render's data.
body.querySelectorAll<HTMLButtonElement>(".submission-generate-btn").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
void onGenerateClick(btn);
});
body.querySelectorAll<HTMLSpanElement>(".row-action-menu-mount").forEach((mount) => {
const code = mount.dataset.code ?? "";
const projectID = mount.dataset.project ?? "";
const menu = createRowActionMenu(
[
{
label: isEN ? "Export directly (.docx)" : "Direkt exportieren (.docx)",
onSelect: () => void generateAndDownload(code, projectID),
},
{
// S3 wires this to the truthful base-preview modal (PRD §2).
// Stubbed disabled until then so the kebab structure ships in S1.
label: isEN ? "Preview template base" : "Vorschau Vorlagenbasis",
disabled: true,
title: isEN ? "Coming soon" : "Bald verfügbar",
onSelect: () => {},
},
],
{ ariaLabel: isEN ? "More actions" : "Weitere Aktionen" },
);
mount.replaceWith(menu);
});
}
@@ -164,14 +185,15 @@ function renderRow(entry: SubmissionEntry, projectID: string, isEN: boolean): st
const templateBadge = entry.has_template
? ""
: ` <span class="submission-template-badge" title="${isEN ? "Uses the universal style template" : "Verwendet die universelle Stilvorlage"}">${isEN ? "universal" : "universell"}</span>`;
const editBtn = `<a href="${escapeHtml(draftHref)}" class="btn-primary btn-cta-lime btn-small submission-edit-btn"
const openBtn = `<a href="${escapeHtml(draftHref)}" class="btn-primary btn-cta-lime btn-small submission-edit-btn"
data-code="${escapeHtml(entry.submission_code)}"
data-i18n="projects.detail.submissions.action.edit">${isEN ? "Edit" : "Bearbeiten"}</a>`;
const generateBtn = `<button type="button" class="btn-secondary btn-small submission-generate-btn"
data-code="${escapeHtml(entry.submission_code)}"
data-project="${escapeHtml(projectID)}"
data-i18n="projects.detail.submissions.action.generate">${isEN ? "Generate" : "Generieren"}</button>`;
const action = `${editBtn} ${generateBtn}`;
data-i18n="projects.detail.submissions.action.open">${isEN ? "Open draft" : "Entwurf öffnen"}</a>`;
// ⋯ menu (direct export, base preview) is mounted into this slot after
// innerHTML in render() — see createRowActionMenu wiring above.
const menuMount = `<span class="row-action-menu-mount"
data-code="${escapeHtml(entry.submission_code)}"
data-project="${escapeHtml(projectID)}"></span>`;
const action = `${openBtn} ${menuMount}`;
return `<tr class="submission-row">
<td>
<span class="submission-name">${escapeHtml(name)}</span>
@@ -206,18 +228,15 @@ function formatParty(role: string | undefined, isEN: boolean): string {
}
}
// onGenerateClick triggers a download. Disables the button while the
// request is in flight to prevent double-submits and surfaces an
// inline error on failure.
async function onGenerateClick(btn: HTMLButtonElement): Promise<void> {
const code = btn.dataset.code;
const projectID = btn.dataset.project;
// generateAndDownload runs the "Direkt exportieren" kebab action: POST the
// generate endpoint and stream the .docx down. The trigger is a menu item
// that closes on select, so progress is signalled via the busy cursor
// (rather than a button label) and failures surface as an alert.
async function generateAndDownload(code: string, projectID: string): Promise<void> {
if (!code || !projectID) return;
const originalLabel = btn.textContent ?? "";
btn.disabled = true;
btn.textContent = document.documentElement.lang === "en" ? "Generating…" : "Wird generiert…";
const prevCursor = document.body.style.cursor;
document.body.style.cursor = "progress";
try {
const url = `/api/projects/${projectID}/submissions/${encodeURIComponent(code)}/generate`;
const resp = await fetch(url, { method: "POST" });
@@ -241,8 +260,7 @@ async function onGenerateClick(btn: HTMLButtonElement): Promise<void> {
?? `${code}.docx`;
triggerDownload(blob, filename);
} finally {
btn.disabled = false;
btn.textContent = originalLabel;
document.body.style.cursor = prevCursor;
}
}

View File

@@ -2617,6 +2617,7 @@ export type I18nKey =
| "projects.detail.submissions.action.edit"
| "projects.detail.submissions.action.generate"
| "projects.detail.submissions.action.no_template"
| "projects.detail.submissions.action.open"
| "projects.detail.submissions.col.action"
| "projects.detail.submissions.col.name"
| "projects.detail.submissions.col.party"

View File

@@ -6052,6 +6052,67 @@ dialog.modal::backdrop {
white-space: nowrap;
}
/* t-paliad-370 (docforge UX S1) — shared kebab (⋯) action menu for catalog
rows (project Schriftsätze tab + global submission picker). The trigger
sits inline next to the primary CTA; the popover is body-attached and
position:fixed (set in JS) because .entity-table-wrap clips with
overflow-x:auto. Mirrors the body-attached event-card choices popover. */
.row-action-menu {
display: inline-flex;
vertical-align: middle;
margin-left: 0.35rem;
}
.row-action-menu__trigger {
width: 32px;
height: 32px;
padding: 0;
font-size: 1.15rem;
line-height: 1;
}
.row-action-menu__popover {
z-index: 1000;
min-width: 220px;
padding: 0.3rem;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 6px;
box-shadow: var(--shadow-md);
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.row-action-menu__item {
display: block;
width: 100%;
text-align: left;
white-space: nowrap;
padding: 0.45rem 0.6rem;
font-size: 0.85rem;
border: none;
border-radius: 4px;
background: transparent;
color: var(--color-text);
cursor: pointer;
}
.row-action-menu__item:hover,
.row-action-menu__item:focus-visible {
background: var(--color-surface-muted);
}
.row-action-menu__item--disabled,
.row-action-menu__item:disabled {
color: var(--color-text-muted);
cursor: default;
}
.row-action-menu__item--disabled:hover {
background: transparent;
}
.submission-no-template {
color: var(--color-text-muted);
font-size: 0.9em;