Merge: t-paliad-370 S1 — catalog rows = one primary CTA + ⋯ menu (consistent across both entry points)
This commit is contained in:
@@ -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",
|
||||
|
||||
143
frontend/src/client/row-action-menu.ts
Normal file
143
frontend/src/client/row-action-menu.ts
Normal 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();
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user