feat(t-paliad-129): Fristenrechner polish — date-aligned columns + Drucken icon
Three changes to the columns view + the Drucken button, per m's 2026-05-04 polish round on top of t-paliad-127 / t-paliad-126: 1. Date-aligned grid timeline. The columns view used to render three independent vertical stacks; now each distinct dueDate gets a grid row so a Court hearing on the 15th lines up beside a Proactive Antrag on the 15th (and an empty cell where the third party has nothing to do). Court-set / dateless rows collapse into a final trailing row. 2. "both"-party deadlines are mirrored, not spanned. Previously they rendered as a full-width row beneath the columns; now they appear in BOTH the Proactive AND Reactive cell of their date-row, with a "↔ beide Seiten" / "↔ both parties" caption so the duplication reads as deliberate. The Court column at that row stays empty unless a court-party deadline also lands on the same date. The full-width spans block (.fr-columns-spans) is gone. 3. Drucken button restyle. The grey-square default-button look is replaced with a tertiary-action treatment: hairline border, accent on hover, subtle lift, inline 16px printer SVG. To keep the icon from being wiped by [data-i18n] (which sets textContent), the label moved into a child <span data-i18n="deadlines.print"> while the SVG sits as its sibling. Both fristen-print-btn and event-print-btn pick up the new style via the shared .print-btn class.
This commit is contained in:
@@ -494,82 +494,92 @@ function renderTimelineBody(data: DeadlineResponse): string {
|
||||
return html;
|
||||
}
|
||||
|
||||
// Three-column lane layout: Proactive (claimant) | Court | Reactive
|
||||
// (defendant). Rows with party=both render as a full-width row beneath each
|
||||
// column block, since they apply to all sides and read awkwardly inside any
|
||||
// single lane. Each column is independently date-ordered (the backend
|
||||
// already sorts the array, but we re-derive per-party order so future
|
||||
// re-renders stay sane).
|
||||
// Three-column timeline layout: Proactive (claimant) | Court | Reactive
|
||||
// (defendant). Each grid row corresponds to a distinct dueDate, so events on
|
||||
// the same day line up across columns. Deadlines with party=both render in
|
||||
// BOTH the Proactive and Reactive cells of their row with a "beide Seiten"
|
||||
// caption so the duplication is legible as intentional. Court-set / dateless
|
||||
// rows collapse into a single trailing row at the bottom.
|
||||
function renderColumnsBody(data: DeadlineResponse): string {
|
||||
const proactive: CalculatedDeadline[] = [];
|
||||
const court: CalculatedDeadline[] = [];
|
||||
const reactive: CalculatedDeadline[] = [];
|
||||
const both: CalculatedDeadline[] = [];
|
||||
type Cell = CalculatedDeadline[];
|
||||
type Row = { proactive: Cell; court: Cell; reactive: Cell };
|
||||
|
||||
const NO_DATE = "";
|
||||
const rowsMap = new Map<string, Row>();
|
||||
const ensureRow = (key: string): Row => {
|
||||
let r = rowsMap.get(key);
|
||||
if (!r) {
|
||||
r = { proactive: [], court: [], reactive: [] };
|
||||
rowsMap.set(key, r);
|
||||
}
|
||||
return r;
|
||||
};
|
||||
|
||||
for (const dl of data.deadlines) {
|
||||
const key = dl.dueDate || NO_DATE;
|
||||
const row = ensureRow(key);
|
||||
switch (dl.party) {
|
||||
case "claimant":
|
||||
proactive.push(dl);
|
||||
row.proactive.push(dl);
|
||||
break;
|
||||
case "defendant":
|
||||
reactive.push(dl);
|
||||
row.reactive.push(dl);
|
||||
break;
|
||||
case "court":
|
||||
court.push(dl);
|
||||
row.court.push(dl);
|
||||
break;
|
||||
case "both":
|
||||
both.push(dl);
|
||||
// Mirrored: same card lands in Proactive AND Reactive at this date.
|
||||
row.proactive.push(dl);
|
||||
row.reactive.push(dl);
|
||||
break;
|
||||
default:
|
||||
// Unknown party: treat as court-neutral so it still appears
|
||||
// somewhere instead of getting swallowed silently.
|
||||
court.push(dl);
|
||||
// Unknown party: keep visible by parking in the Court column.
|
||||
row.court.push(dl);
|
||||
}
|
||||
}
|
||||
|
||||
const byDueDate = (a: CalculatedDeadline, b: CalculatedDeadline) => {
|
||||
// Court-set rows have no dueDate; sink them to the bottom of their lane.
|
||||
const aKey = a.dueDate || "9999-12-31";
|
||||
const bKey = b.dueDate || "9999-12-31";
|
||||
return aKey < bKey ? -1 : aKey > bKey ? 1 : 0;
|
||||
};
|
||||
proactive.sort(byDueDate);
|
||||
court.sort(byDueDate);
|
||||
reactive.sort(byDueDate);
|
||||
both.sort(byDueDate);
|
||||
// Sort row keys chronologically; the dateless bucket (court-set rows) sinks
|
||||
// to the bottom because it has no temporal anchor.
|
||||
const keys = Array.from(rowsMap.keys()).sort((a, b) => {
|
||||
if (a === NO_DATE) return 1;
|
||||
if (b === NO_DATE) return -1;
|
||||
return a < b ? -1 : a > b ? 1 : 0;
|
||||
});
|
||||
|
||||
const renderColumn = (
|
||||
title: string,
|
||||
cls: string,
|
||||
items: CalculatedDeadline[],
|
||||
): string => {
|
||||
const rows = items.length === 0
|
||||
? `<li class="fr-col-empty">\u2014</li>`
|
||||
: items
|
||||
.map((dl) => `<li class="fr-col-item ${dl.isRootEvent ? "fr-col-root" : ""}">${deadlineCardHtml(dl, { showParty: false })}</li>`)
|
||||
.join("");
|
||||
return `<div class="fr-col ${cls}">
|
||||
<h3 class="fr-col-heading">${title}</h3>
|
||||
<ol class="fr-col-list">${rows}</ol>
|
||||
</div>`;
|
||||
const renderCell = (items: CalculatedDeadline[]): string => {
|
||||
if (items.length === 0) {
|
||||
return `<div class="fr-col-cell fr-col-cell--empty"></div>`;
|
||||
}
|
||||
const cards = items
|
||||
.map((dl) => {
|
||||
const mirrorTag = dl.party === "both"
|
||||
? `<div class="fr-col-mirror">\u2194 ${escHtml(t("deadlines.party.both.label"))}</div>`
|
||||
: "";
|
||||
return `<div class="fr-col-item ${dl.isRootEvent ? "fr-col-root" : ""}">
|
||||
${deadlineCardHtml(dl, { showParty: false })}
|
||||
${mirrorTag}
|
||||
</div>`;
|
||||
})
|
||||
.join("");
|
||||
return `<div class="fr-col-cell">${cards}</div>`;
|
||||
};
|
||||
|
||||
const headerCell = (label: string, cls: string) =>
|
||||
`<div class="fr-col-header ${cls}">${escHtml(label)}</div>`;
|
||||
|
||||
let html = '<div class="fr-columns-view">';
|
||||
html += renderColumn(t("deadlines.col.proactive"), "fr-col-proactive", proactive);
|
||||
html += renderColumn(t("deadlines.col.court"), "fr-col-court", court);
|
||||
html += renderColumn(t("deadlines.col.reactive"), "fr-col-reactive", reactive);
|
||||
html += "</div>";
|
||||
html += headerCell(t("deadlines.col.proactive"), "fr-col-proactive");
|
||||
html += headerCell(t("deadlines.col.court"), "fr-col-court");
|
||||
html += headerCell(t("deadlines.col.reactive"), "fr-col-reactive");
|
||||
|
||||
if (both.length > 0) {
|
||||
const bothRows = both
|
||||
.map((dl) => `<div class="fr-span-row ${dl.isRootEvent ? "fr-span-root" : ""}">${deadlineCardHtml(dl, { showParty: false })}</div>`)
|
||||
.join("");
|
||||
html += `<div class="fr-columns-spans" aria-label="${t("deadlines.col.both")}">
|
||||
<div class="fr-spans-label">${t("deadlines.col.both")}</div>
|
||||
${bothRows}
|
||||
</div>`;
|
||||
for (const key of keys) {
|
||||
const row = rowsMap.get(key)!;
|
||||
html += renderCell(row.proactive);
|
||||
html += renderCell(row.court);
|
||||
html += renderCell(row.reactive);
|
||||
}
|
||||
|
||||
html += "</div>";
|
||||
return html;
|
||||
}
|
||||
|
||||
|
||||
@@ -228,6 +228,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.party.defendant": "Beklagter",
|
||||
"deadlines.party.court": "Gericht",
|
||||
"deadlines.party.both": "Beide",
|
||||
"deadlines.party.both.label": "beide Seiten",
|
||||
"deadlines.court.set": "vom Gericht bestimmt",
|
||||
"deadlines.view.label": "Ansicht:",
|
||||
"deadlines.view.timeline": "Zeitstrahl",
|
||||
@@ -1746,6 +1747,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.party.defendant": "Defendant",
|
||||
"deadlines.party.court": "Court",
|
||||
"deadlines.party.both": "Both",
|
||||
"deadlines.party.both.label": "both parties",
|
||||
"deadlines.court.set": "set by court",
|
||||
"deadlines.view.label": "View:",
|
||||
"deadlines.view.timeline": "Timeline",
|
||||
|
||||
@@ -159,8 +159,13 @@ export function renderFristenrechner(): string {
|
||||
<button type="button" id="fristen-save-cta" className="btn-primary btn-cta-lime" style="display:none" data-i18n="deadlines.save.cta">
|
||||
Als Frist(en) speichern
|
||||
</button>
|
||||
<button type="button" id="fristen-print-btn" className="print-btn" style="display:none" data-i18n="deadlines.print">
|
||||
Drucken
|
||||
<button type="button" id="fristen-print-btn" className="print-btn" style="display:none">
|
||||
<svg className="print-btn-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<polyline points="6 9 6 2 18 2 18 9"></polyline>
|
||||
<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"></path>
|
||||
<rect x="6" y="14" width="12" height="8"></rect>
|
||||
</svg>
|
||||
<span data-i18n="deadlines.print">Drucken</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -220,8 +225,13 @@ export function renderFristenrechner(): string {
|
||||
</h3>
|
||||
<div id="event-results-container"></div>
|
||||
<div className="fristen-result-actions">
|
||||
<button type="button" id="event-print-btn" className="print-btn" style="display:none" data-i18n="deadlines.print">
|
||||
Drucken
|
||||
<button type="button" id="event-print-btn" className="print-btn" style="display:none">
|
||||
<svg className="print-btn-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<polyline points="6 9 6 2 18 2 18 9"></polyline>
|
||||
<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"></path>
|
||||
<rect x="6" y="14" width="12" height="8"></rect>
|
||||
</svg>
|
||||
<span data-i18n="deadlines.print">Drucken</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -658,6 +658,7 @@ export type I18nKey =
|
||||
| "deadlines.neu.subtitle"
|
||||
| "deadlines.neu.title"
|
||||
| "deadlines.party.both"
|
||||
| "deadlines.party.both.label"
|
||||
| "deadlines.party.claimant"
|
||||
| "deadlines.party.court"
|
||||
| "deadlines.party.defendant"
|
||||
|
||||
@@ -2080,55 +2080,60 @@ input[type="range"]::-moz-range-thumb {
|
||||
Each lane is independently date-ordered; party=both rows render below
|
||||
as full-width spans because they apply to all sides. */
|
||||
|
||||
/* Columns view: a CSS grid timeline. The first three children are sticky
|
||||
header cells (Proactive / Court / Reactive). After that, each distinct
|
||||
dueDate produces three sibling cells (one per column) so deadlines on the
|
||||
same day line up across columns. party=both deadlines appear in BOTH the
|
||||
proactive and reactive cell of their row, marked with .fr-col-mirror. */
|
||||
|
||||
.fr-columns-view {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 0.75rem;
|
||||
column-gap: 1rem;
|
||||
row-gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.fr-col {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--color-surface);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.fr-col-heading {
|
||||
.fr-col-header {
|
||||
margin: 0;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
text-align: center;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.fr-col-proactive .fr-col-heading {
|
||||
.fr-col-header.fr-col-proactive {
|
||||
background: var(--status-blue-bg);
|
||||
color: var(--status-blue-fg);
|
||||
}
|
||||
|
||||
.fr-col-court .fr-col-heading {
|
||||
.fr-col-header.fr-col-court {
|
||||
background: var(--status-blue-soft-bg);
|
||||
color: var(--status-blue-soft-fg);
|
||||
}
|
||||
|
||||
.fr-col-reactive .fr-col-heading {
|
||||
.fr-col-header.fr-col-reactive {
|
||||
background: var(--status-amber-bg);
|
||||
color: var(--status-amber-fg);
|
||||
}
|
||||
|
||||
.fr-col-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0.5rem 0.75rem;
|
||||
.fr-col-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
flex: 1;
|
||||
gap: 0.5rem;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.fr-col-cell--empty {
|
||||
/* No content: still occupies its grid track so siblings stay aligned. */
|
||||
}
|
||||
|
||||
.fr-col-item {
|
||||
@@ -2143,47 +2148,68 @@ input[type="range"]::-moz-range-thumb {
|
||||
box-shadow: 0 0 0 1px var(--color-accent) inset;
|
||||
}
|
||||
|
||||
.fr-col-empty {
|
||||
.fr-col-mirror {
|
||||
margin-top: 0.4rem;
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
padding: 0.75rem 0;
|
||||
}
|
||||
|
||||
.fr-columns-spans {
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--color-surface);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.fr-spans-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.fr-span-row {
|
||||
padding: 0.5rem 0.6rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
.fr-span-root {
|
||||
border-color: var(--color-accent);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.fr-columns-view {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.fr-col-header {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
|
||||
/* Drucken / Print button — used by the procedure and event Fristenrechner
|
||||
tabs. Tertiary action: hairline border, accent on hover, printer glyph. */
|
||||
|
||||
.print-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
padding: 0.5rem 0.875rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s ease, color 0.15s ease, transform 0.15s ease, box-shadow 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
|
||||
.print-btn:hover {
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-accent-fg);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.print-btn:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 2px var(--color-accent-light);
|
||||
}
|
||||
|
||||
.print-btn:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.print-btn-icon {
|
||||
flex-shrink: 0;
|
||||
color: var(--color-text-muted);
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.print-btn:hover .print-btn-icon {
|
||||
color: var(--color-accent-fg);
|
||||
}
|
||||
|
||||
/* --- Responsive: Sidebar --- */
|
||||
|
||||
Reference in New Issue
Block a user