feat(t-paliad-171): mount SmartTimeline + "+ Eintrag" modal in /projects/<id> Verlauf
Replaces the legacy <ul.entity-events> Verlauf rendering with the new
SmartTimeline. Slice 1 wiring:
- loadTimeline(id) calls /api/projects/{id}/timeline (the new
endpoint backed by ProjectionService) and renderSmartTimeline
paints into <div#project-smart-timeline>.
- "Audit-Log anzeigen" header toggle re-fetches with
?include=audit_full, broadening the project_events filter to
every audit row (legacy Verlauf chronological view). State
persists per-project in localStorage so flipping it on for one
case doesn't carry across to others.
- "+ Eintrag" CTA opens a modal. "Eigener Meilenstein" submits
via POST /api/projects/{id}/timeline/milestone and re-renders;
Frist + Termin route to the existing /deadlines/new and
/appointments/new flows; CCR + R.30 are disabled-with-tooltip
"kommt mit Slice 3" per the design.
- Subtree toggle now also drives the timeline (passes
?direct_only=true when the user flips off "Inkl. Unterprojekte").
- Project-appointment add path also re-fetches the timeline so the
new appointment surfaces immediately.
The legacy renderEvents() rendering path stays as-is (dead, but the
function is still called in places). It will be removed once
/timeline?include=audit_full has had a deploy of soak time and the
audit-toggle is the only path that feeds the legacy markup. Slice 2
revisits.
The FilterBar from t-paliad-170 (riemann's port) keeps mounting and
driving its customRunner — facets still narrow the legacy `events`
array. The bar gaining timeline_* axes lands later in the slice
sequence (design §8); Slice 1 ships the timeline beneath the existing
bar untouched.
Design ref: docs/design-smart-timeline-2026-05-08.md §10 Slice 1.
This commit is contained in:
@@ -11,6 +11,7 @@ import {
|
||||
} from "./project-form";
|
||||
import { mountFilterBar, type BarHandle } from "./filter-bar";
|
||||
import type { FilterSpec, RenderSpec } from "./views/types";
|
||||
import { renderSmartTimeline, type TimelineEvent as SmartTimelineEvent } from "./views/shape-timeline";
|
||||
|
||||
interface Project {
|
||||
id: string;
|
||||
@@ -224,6 +225,13 @@ const EVENTS_PAGE_SIZE = 50;
|
||||
let eventsHasMore = false;
|
||||
let eventsLoadingMore = false;
|
||||
|
||||
// SmartTimeline (t-paliad-171) — separate row set + audit-toggle state.
|
||||
// The legacy `events` array drives the old chronological rendering only
|
||||
// when the user flips on "Audit-Log anzeigen"; default mode renders
|
||||
// `timelineRows` from /api/projects/{id}/timeline (the new endpoint).
|
||||
let timelineRows: SmartTimelineEvent[] = [];
|
||||
let timelineAuditFull = parseAuditFullPersisted();
|
||||
|
||||
// t-paliad-170 — Verlauf FilterBar state.
|
||||
//
|
||||
// The bar mounts once, owns the URL params (?time=, ?pe_kind=, …), and
|
||||
@@ -387,6 +395,66 @@ async function loadEvents(id: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// SmartTimeline (t-paliad-171) — fetches the merged timeline from the
|
||||
// new /api/projects/{id}/timeline endpoint. Slice 1 returns actuals
|
||||
// (deadlines + appointments + opted-in project_events); future slices
|
||||
// add projected rows additively. The audit-full toggle broadens the
|
||||
// project_events filter to include rows without timeline_kind set.
|
||||
async function loadTimeline(id: string): Promise<void> {
|
||||
const params = new URLSearchParams();
|
||||
if (timelineAuditFull) params.set("include", "audit_full");
|
||||
if (!subtreeMode) params.set("direct_only", "true");
|
||||
const qs = params.toString();
|
||||
const url = `/api/projects/${encodeURIComponent(id)}/timeline${qs ? "?" + qs : ""}`;
|
||||
try {
|
||||
const resp = await fetch(url);
|
||||
if (resp.ok) {
|
||||
timelineRows = (await resp.json()) ?? [];
|
||||
} else {
|
||||
timelineRows = [];
|
||||
}
|
||||
} catch {
|
||||
timelineRows = [];
|
||||
}
|
||||
}
|
||||
|
||||
function renderTimeline() {
|
||||
const host = document.getElementById("project-smart-timeline");
|
||||
if (!host) return;
|
||||
renderSmartTimeline(host, timelineRows);
|
||||
}
|
||||
|
||||
// Audit-full toggle persistence: per-project flag in localStorage so a
|
||||
// user who flips the legacy view on for one project doesn't see the
|
||||
// audit clutter on every other project they open.
|
||||
function auditFullStorageKey(): string {
|
||||
const id = project?.id ?? "_";
|
||||
return `paliad.smarttimeline.audit_full.${id}`;
|
||||
}
|
||||
|
||||
function parseAuditFullPersisted(): boolean {
|
||||
// Project ID isn't known yet at module init; fall back to false here
|
||||
// and re-read in initSmartTimelineAuditToggle once project is loaded.
|
||||
return false;
|
||||
}
|
||||
|
||||
function readPersistedAuditFull(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(auditFullStorageKey()) === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function writePersistedAuditFull(on: boolean) {
|
||||
try {
|
||||
if (on) localStorage.setItem(auditFullStorageKey(), "1");
|
||||
else localStorage.removeItem(auditFullStorageKey());
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMoreEvents(id: string) {
|
||||
if (eventsLoadingMore || !eventsHasMore || !rawEventsLastID) return;
|
||||
const cursor = rawEventsLastID;
|
||||
@@ -600,6 +668,8 @@ function initProjectAppointmentForm() {
|
||||
renderAppointments();
|
||||
await loadEvents(project.id);
|
||||
renderEvents();
|
||||
await loadTimeline(project.id);
|
||||
renderTimeline();
|
||||
} else {
|
||||
const data = await resp.json().catch(() => ({}) as { error?: string });
|
||||
msg.textContent = data.error || t("projects.error.generic");
|
||||
@@ -904,6 +974,141 @@ function initEventsLoadMore() {
|
||||
});
|
||||
}
|
||||
|
||||
// initSmartTimelineAuditToggle — wires the "Audit-Log anzeigen" button
|
||||
// in the Verlauf tab header. When ON, the next /timeline fetch passes
|
||||
// ?include=audit_full so every paliad.project_events row surfaces (the
|
||||
// legacy chronological Verlauf view); OFF only shows rows that opted
|
||||
// into timeline_kind. State persists in localStorage per project.
|
||||
function initSmartTimelineAuditToggle(id: string) {
|
||||
const btn = document.getElementById("smart-timeline-audit-toggle") as HTMLButtonElement | null;
|
||||
if (!btn) return;
|
||||
// Re-read from localStorage now that project is loaded.
|
||||
timelineAuditFull = readPersistedAuditFull();
|
||||
refreshAuditToggleLabel();
|
||||
|
||||
btn.addEventListener("click", async () => {
|
||||
timelineAuditFull = !timelineAuditFull;
|
||||
writePersistedAuditFull(timelineAuditFull);
|
||||
refreshAuditToggleLabel();
|
||||
await loadTimeline(id);
|
||||
renderTimeline();
|
||||
});
|
||||
}
|
||||
|
||||
function refreshAuditToggleLabel() {
|
||||
const btn = document.getElementById("smart-timeline-audit-toggle") as HTMLButtonElement | null;
|
||||
if (!btn) return;
|
||||
btn.setAttribute("aria-pressed", timelineAuditFull ? "true" : "false");
|
||||
btn.textContent = timelineAuditFull
|
||||
? t("projects.detail.smarttimeline.audit.toggle.hide")
|
||||
: t("projects.detail.smarttimeline.audit.toggle.show");
|
||||
btn.classList.toggle("subtree-toggle--active", timelineAuditFull);
|
||||
}
|
||||
|
||||
// initSmartTimelineAddModal — wires the "+ Eintrag" CTA + modal. Only
|
||||
// the "Eigener Meilenstein" route is fully wired in Slice 1 (writes
|
||||
// to /api/projects/{id}/timeline/milestone); Frist + Termin are link
|
||||
// buttons to the existing flows; CCR + R.30 are disabled with a
|
||||
// "Slice 3" tooltip per the brief.
|
||||
function initSmartTimelineAddModal(id: string) {
|
||||
const cta = document.getElementById("smart-timeline-add-btn") as HTMLButtonElement | null;
|
||||
const modal = document.getElementById("smart-timeline-add-modal") as HTMLDivElement | null;
|
||||
if (!cta || !modal) return;
|
||||
|
||||
const choices = document.querySelector<HTMLDivElement>(".smart-timeline-add-choices");
|
||||
const form = document.getElementById("smart-timeline-milestone-form") as HTMLFormElement | null;
|
||||
const milestoneBtn = document.getElementById("smart-timeline-add-milestone") as HTMLButtonElement | null;
|
||||
const cancelBtn = document.getElementById("smart-timeline-milestone-cancel") as HTMLButtonElement | null;
|
||||
const closeBtn = document.getElementById("smart-timeline-modal-close") as HTMLButtonElement | null;
|
||||
const titleInput = document.getElementById("smart-timeline-milestone-title") as HTMLInputElement | null;
|
||||
const dateInput = document.getElementById("smart-timeline-milestone-date") as HTMLInputElement | null;
|
||||
const descInput = document.getElementById("smart-timeline-milestone-desc") as HTMLTextAreaElement | null;
|
||||
const msg = document.getElementById("smart-timeline-milestone-msg") as HTMLDivElement | null;
|
||||
const dlLink = document.getElementById("smart-timeline-add-deadline") as HTMLAnchorElement | null;
|
||||
const apptLink = document.getElementById("smart-timeline-add-appointment") as HTMLAnchorElement | null;
|
||||
|
||||
if (dlLink) dlLink.href = `/deadlines/new?project=${encodeURIComponent(id)}`;
|
||||
if (apptLink) apptLink.href = `/appointments/new?project=${encodeURIComponent(id)}`;
|
||||
|
||||
const open = () => {
|
||||
modal.style.display = "";
|
||||
if (choices) choices.style.display = "";
|
||||
if (form) form.style.display = "none";
|
||||
if (msg) {
|
||||
msg.textContent = "";
|
||||
msg.className = "form-msg";
|
||||
}
|
||||
};
|
||||
const close = () => {
|
||||
modal.style.display = "none";
|
||||
if (form) form.reset();
|
||||
};
|
||||
|
||||
cta.addEventListener("click", open);
|
||||
if (closeBtn) closeBtn.addEventListener("click", close);
|
||||
if (cancelBtn) cancelBtn.addEventListener("click", close);
|
||||
|
||||
// Click outside the card → close.
|
||||
modal.addEventListener("click", (e) => {
|
||||
if (e.target === modal) close();
|
||||
});
|
||||
|
||||
if (milestoneBtn && form) {
|
||||
milestoneBtn.addEventListener("click", () => {
|
||||
if (choices) choices.style.display = "none";
|
||||
form.style.display = "";
|
||||
titleInput?.focus();
|
||||
});
|
||||
}
|
||||
|
||||
if (form && titleInput) {
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const title = titleInput.value.trim();
|
||||
if (!title) {
|
||||
if (msg) {
|
||||
msg.textContent = t("projects.detail.smarttimeline.error.title_required");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
}
|
||||
return;
|
||||
}
|
||||
const payload: Record<string, unknown> = { title };
|
||||
const desc = descInput?.value.trim();
|
||||
if (desc) payload.description = desc;
|
||||
const date = dateInput?.value;
|
||||
if (date) payload.occurred_at = date;
|
||||
|
||||
const submitBtn = form.querySelector<HTMLButtonElement>("button[type=submit]")!;
|
||||
submitBtn.disabled = true;
|
||||
try {
|
||||
const resp = await fetch(`/api/projects/${encodeURIComponent(id)}/timeline/milestone`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (resp.ok) {
|
||||
close();
|
||||
await loadTimeline(id);
|
||||
renderTimeline();
|
||||
} else {
|
||||
const data = (await resp.json().catch(() => ({}))) as { error?: string };
|
||||
if (msg) {
|
||||
msg.textContent = data.error || t("projects.detail.smarttimeline.error.generic");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
if (msg) {
|
||||
msg.textContent = t("projects.detail.smarttimeline.error.generic");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
}
|
||||
} finally {
|
||||
submitBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function renderParties() {
|
||||
const tbody = document.getElementById("parties-body")!;
|
||||
const empty = document.getElementById("parties-empty")!;
|
||||
@@ -1371,6 +1576,7 @@ async function main() {
|
||||
await Promise.all([
|
||||
loadParties(id),
|
||||
loadEvents(id),
|
||||
loadTimeline(id),
|
||||
loadDeadlines(id),
|
||||
loadAppointments(id),
|
||||
loadAncestors(id),
|
||||
@@ -1389,6 +1595,7 @@ async function main() {
|
||||
renderBreadcrumb();
|
||||
renderParties();
|
||||
renderEvents();
|
||||
renderTimeline();
|
||||
renderDeadlines();
|
||||
renderAppointments();
|
||||
renderChildren();
|
||||
@@ -1403,6 +1610,8 @@ async function main() {
|
||||
initDelete();
|
||||
initEventsLoadMore();
|
||||
initSubtreeToggles(id);
|
||||
initSmartTimelineAuditToggle(id);
|
||||
initSmartTimelineAddModal(id);
|
||||
initAttachUnitForm(id);
|
||||
initNotesContainer(id);
|
||||
mountVerlaufFilterBar(id);
|
||||
@@ -1560,10 +1769,18 @@ function initSubtreeToggles(id: string) {
|
||||
// customRunner (so the current filter state stays applied).
|
||||
// Falls back to a direct loadEvents call when the bar hasn't
|
||||
// mounted yet (e.g. on a project with rendering errors).
|
||||
// Also refresh the SmartTimeline since direct_only is mirrored
|
||||
// onto its read path (t-paliad-171).
|
||||
const eventsRefresh = verlaufBar
|
||||
? verlaufBar.refresh()
|
||||
: loadEvents(id).then(() => renderEvents());
|
||||
await Promise.all([eventsRefresh, loadDeadlines(id), loadAppointments(id)]);
|
||||
await Promise.all([
|
||||
eventsRefresh,
|
||||
loadTimeline(id),
|
||||
loadDeadlines(id),
|
||||
loadAppointments(id),
|
||||
]);
|
||||
renderTimeline();
|
||||
renderDeadlines();
|
||||
renderAppointments();
|
||||
});
|
||||
|
||||
@@ -82,24 +82,79 @@ export function renderProjectsDetail(): string {
|
||||
<a className="entity-tab" data-tab="checklists" href="#" data-i18n="projects.detail.tab.checklisten">Checklisten</a>
|
||||
</nav>
|
||||
|
||||
{/* History (Verlauf) */}
|
||||
{/* History (Verlauf) — t-paliad-171 SmartTimeline Slice 1.
|
||||
The legacy <ul.entity-events> + Mehr-laden controls are
|
||||
replaced by the vertical timeline (rendered by
|
||||
client/views/shape-timeline.ts). The bar from t-paliad-170
|
||||
keeps driving filter state via its customRunner. */}
|
||||
<section className="entity-tab-panel" id="tab-history">
|
||||
<div className="party-controls">
|
||||
<div className="smart-timeline-controls">
|
||||
<button type="button" className="btn-secondary btn-small subtree-toggle" aria-pressed="false">
|
||||
Inkl. Unterprojekte
|
||||
</button>
|
||||
</div>
|
||||
{/* t-paliad-170 — FilterBar Phase 2 slice. Mounted by
|
||||
projects-detail.ts when the Verlauf tab is active. */}
|
||||
<div id="project-events-filter-bar" />
|
||||
<ul className="entity-events" id="project-events-list" />
|
||||
<p className="entity-events-empty" id="project-events-empty" style="display:none" data-i18n="projects.detail.verlauf.empty">
|
||||
Noch keine Ereignisse aufgezeichnet.
|
||||
</p>
|
||||
<div className="entity-events-loadmore" id="project-events-loadmore-wrap" style="display:none">
|
||||
<button type="button" className="btn-secondary" id="project-events-loadmore" data-i18n="projects.detail.verlauf.loadMore">
|
||||
Mehr laden
|
||||
<button type="button" className="btn-secondary btn-small" id="smart-timeline-audit-toggle" aria-pressed="false" data-i18n="projects.detail.smarttimeline.audit.toggle.show">
|
||||
Audit-Log anzeigen
|
||||
</button>
|
||||
<button type="button" className="btn-primary btn-cta-lime btn-small" id="smart-timeline-add-btn" data-i18n="projects.detail.smarttimeline.add.cta">
|
||||
+ Eintrag
|
||||
</button>
|
||||
</div>
|
||||
<div id="project-events-filter-bar" />
|
||||
<div id="project-smart-timeline" className="smart-timeline" />
|
||||
|
||||
{/* "Eigener Meilenstein" modal. Hidden by default; opened
|
||||
by the "+ Eintrag" CTA above. The other modal options
|
||||
route to existing flows (see client wiring). */}
|
||||
<div id="smart-timeline-add-modal" className="smart-timeline-modal" style="display:none" role="dialog" aria-modal="true">
|
||||
<div className="smart-timeline-modal-card">
|
||||
<h3 data-i18n="projects.detail.smarttimeline.add.modal.title">
|
||||
Neuer Eintrag im SmartTimeline
|
||||
</h3>
|
||||
|
||||
<div className="smart-timeline-add-choices">
|
||||
<a id="smart-timeline-add-deadline" className="smart-timeline-add-choice" href="#" data-i18n="projects.detail.smarttimeline.add.choice.deadline">
|
||||
Frist anlegen
|
||||
</a>
|
||||
<a id="smart-timeline-add-appointment" className="smart-timeline-add-choice" href="#" data-i18n="projects.detail.smarttimeline.add.choice.appointment">
|
||||
Termin anlegen
|
||||
</a>
|
||||
<button type="button" className="smart-timeline-add-choice smart-timeline-add-choice--disabled" disabled title="Slice 3" data-i18n="projects.detail.smarttimeline.add.choice.counterclaim">
|
||||
Widerklage (CCR)
|
||||
</button>
|
||||
<button type="button" className="smart-timeline-add-choice smart-timeline-add-choice--disabled" disabled title="Slice 3" data-i18n="projects.detail.smarttimeline.add.choice.amend">
|
||||
Antrag auf Änderung (R.30)
|
||||
</button>
|
||||
<button type="button" id="smart-timeline-add-milestone" className="smart-timeline-add-choice smart-timeline-add-choice--primary" data-i18n="projects.detail.smarttimeline.add.choice.milestone">
|
||||
Eigener Meilenstein
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form id="smart-timeline-milestone-form" className="entity-form" style="display:none" autocomplete="off">
|
||||
<div className="form-field">
|
||||
<label htmlFor="smart-timeline-milestone-title" data-i18n="projects.detail.smarttimeline.milestone.title">Titel</label>
|
||||
<input type="text" id="smart-timeline-milestone-title" required maxLength={200} />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="smart-timeline-milestone-date" data-i18n="projects.detail.smarttimeline.milestone.date">Datum (optional)</label>
|
||||
<input type="date" id="smart-timeline-milestone-date" />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="smart-timeline-milestone-desc" data-i18n="projects.detail.smarttimeline.milestone.description">Beschreibung (optional)</label>
|
||||
<textarea id="smart-timeline-milestone-desc" rows={3} />
|
||||
</div>
|
||||
<div id="smart-timeline-milestone-msg" className="form-msg" />
|
||||
<div className="form-field-row">
|
||||
<button type="button" className="btn-secondary" id="smart-timeline-milestone-cancel" data-i18n="projects.detail.smarttimeline.add.cancel">Abbrechen</button>
|
||||
<button type="submit" className="btn-primary btn-cta-lime" data-i18n="projects.detail.smarttimeline.add.submit">Speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="smart-timeline-modal-close-row">
|
||||
<button type="button" className="btn-secondary btn-small" id="smart-timeline-modal-close" data-i18n="projects.detail.smarttimeline.add.cancel">
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user