Files
paliad/frontend/src/client/projects-cards.ts
m aeeded7e21 feat(t-paliad-149) PR2 step 2/2: frontend — Cards view + drag-rearrange named layouts
Adds the Cards view-mode to /projects (third option in the segment-control
between Tree and Liste).

frontend/src/projects.tsx:
- View-mode segment gains "Karten" button
- Two new toolbars (initially display:none, surfaced by Cards mode):
  - .projects-cards-toolbar: layout dropdown + [Bearbeiten] + [Neue Ansicht]
    + "Alle Ebenen anzeigen" toggle
  - .projects-cards-edit-toolbar: density radio + grid select + rename /
    delete / set-default / discard / save buttons
- New container: .projects-cards-wrap > #projects-cards-grid

frontend/src/client/projects-cards.ts (NEW, ~640 LoC):
- Layout management: GET /api/user-card-layouts on first mount; auto-seeds
  Standard layout if empty (POST). Layout dropdown switches active layout
  in-place; show_all_levels toggle persists immediately.
- Edit mode: clones the active layout into editDraft; renders per-card
  fact list with drag handles + visibility checkboxes + count steppers
  (1..5) for next-events / recent-verlauf. HTML5 drag-and-drop reorders
  facts; title-row is forced to the first position so the server-side
  validator's invariant holds.
- New layout: prompts for a name, seeds with the current draft (or active
  layout's facts), POSTs, enters edit mode.
- Set-default / rename / delete: each maps to PATCH or DELETE; default
  cannot be deleted (server returns 409 + UI alerts).
- Card render: title row (icon + link + pin star), type/status chips,
  client-matter, parent-path-as-reference (parent breadcrumb deferred —
  needs an extra fetch per card), deadline-counts (subtree-aggregated
  when available), next-events from /api/projects/cards-preview, recent-
  verlauf, team-chips initials with overflow count.
- Pin click on a card star does optimistic toggle + POST/DELETE pin
  endpoint and updates treeCache in place.
- Cards sort: pinned first, then last_activity_at DESC, then title ASC.
- "Alle Ebenen anzeigen" toggle decides whether Mandanten + Litigations
  appear as their own cards (off by default — leaf-ish projects only:
  Cases, Patents, Verfahren, Projekte).

frontend/src/client/projects.ts (orchestrator):
- ViewMode type expands to "tree" | "cards" | "flat"
- View segment-control wires through to Cards mode
- render() dispatches to renderCardsView / teardownCardsView based on
  active mode

frontend/src/client/i18n.ts: 53 new keys DE+EN under projects.cards.* —
section titles, empty-states, layout picker labels (label/new/edit/save/
discard/set_default/delete/rename/is_default/new.prompt/delete.confirm/
delete.default_blocked), per-fact labels (title-row/type-chip/status-chip/
client-matter/parent-path/deadline-counts/next-events/recent-verlauf/
team-chips/reference/last-activity-at), density values (compact/roomy),
grid values (auto/2/3/4), event-kind labels (deadline/appointment/
project_event), edit toggles (toggle.hide/show/move_up/move_down/count).

frontend/src/styles/global.css: ~290 LoC appended for cards toolbar +
grid + card layout (title row / row / section / event row / team chips)
+ edit-mode chrome (drag handles, drop targets, count steppers) + dark-
themed dashed border on edit cards. Mobile media query forces single-
column grid.

i18n codegen: 1830 → 1882 keys (+52). bun run build clean. tsc on new
files clean (pre-existing JSX-IntrinsicElements noise unrelated).
go build/vet/test still clean.
2026-05-07 22:46:26 +02:00

797 lines
30 KiB
TypeScript

import { t, tDyn, getLang } from "./i18n";
// /projects Cards view (t-paliad-149 PR 2).
//
// Renders one card per project with configurable facts (title row, type
// chip, status, clientmatter, parent path, deadline counts, next 3 events,
// last 3 Verlauf entries, team chips). Layout is per-user, named, and
// drag-rearrangeable in edit mode (see editMode flag below).
//
// Data flow:
// 1. orchestrator (client/projects.ts) calls renderCardsView(...)
// 2. we fetch the active layout (GET /api/user-card-layouts → default first)
// 3. we fetch the projects tree with the orchestrator's chip/search params
// 4. we fetch /api/projects/cards-preview for per-project event rollups
// 5. flatten tree to cards (leaf-ish only by default; "Alle Ebenen" toggle)
// 6. render the grid; lazy-fill preview slots via IntersectionObserver
// when the project list is huge (cap currently 200 — paliad fits today)
//
// Edit mode toggles in-place: each card grows drag handles + visibility
// toggles + count steppers; the toolbar grows save/discard/rename/delete.
import type { ProjectTreeNode } from "./project-tree";
// ----------------------------------------------------------------------
// Types — mirror internal/services/layout_spec.go
// ----------------------------------------------------------------------
export type FactKey =
| "title-row"
| "type-chip"
| "status-chip"
| "client-matter"
| "parent-path"
| "deadline-counts"
| "next-events"
| "recent-verlauf"
| "team-chips"
| "reference"
| "last-activity-at";
const ALL_FACT_KEYS: FactKey[] = [
"title-row",
"type-chip",
"status-chip",
"client-matter",
"parent-path",
"deadline-counts",
"next-events",
"recent-verlauf",
"team-chips",
"reference",
"last-activity-at",
];
interface LayoutFact {
key: FactKey;
visible: boolean;
count?: number;
}
interface LayoutSpec {
facts: LayoutFact[];
density: "compact" | "roomy";
grid_columns: "auto" | "2" | "3" | "4";
show_all_levels: boolean;
}
interface UserCardLayout {
id: string;
user_id: string;
name: string;
is_default: boolean;
layout: LayoutSpec;
created_at: string;
updated_at: string;
}
interface CardEventPreview {
kind: "deadline" | "appointment" | "project_event";
id: string;
title: string;
event_date: string;
status?: string | null;
actor_name?: string | null;
route: string;
}
interface ProjectCardPreview {
project_id: string;
next_events: CardEventPreview[];
recent_verlauf: CardEventPreview[];
team_initials: string[];
team_count: number;
last_activity_at?: string | null;
}
// ----------------------------------------------------------------------
// Module state
// ----------------------------------------------------------------------
let layouts: UserCardLayout[] = [];
let activeLayoutId: string | null = null;
let editMode = false;
let editDraft: LayoutSpec | null = null;
let treeCache: ProjectTreeNode[] = [];
let previewCache: Map<string, ProjectCardPreview> = new Map();
// ----------------------------------------------------------------------
// Public entry — called by the orchestrator when view-mode = "cards"
// ----------------------------------------------------------------------
export interface CardsViewOpts {
treeParams: URLSearchParams;
}
export async function renderCardsView(opts: CardsViewOpts) {
const wrap = document.getElementById("projects-cards-wrap");
const toolbar = document.getElementById("projects-cards-toolbar");
const grid = document.getElementById("projects-cards-grid");
if (!wrap || !toolbar || !grid) return;
wrap.style.display = "block";
toolbar.style.display = "flex";
// Step 1: layouts.
if (layouts.length === 0) {
await reloadLayouts();
}
if (layouts.length === 0) {
grid.innerHTML = `<div class="projects-cards-empty">${escHTML(t("projects.cards.empty") || "Keine Projekte zum Anzeigen.")}</div>`;
return;
}
if (!activeLayoutId) {
const def = layouts.find((l) => l.is_default) || layouts[0];
activeLayoutId = def.id;
}
populateLayoutSelect();
attachToolbarHandlers();
// Step 2: tree (chip/search-narrowed) + cards preview.
const treeURL = `/api/projects/tree?${opts.treeParams.toString()}`;
const previewURL = `/api/projects/cards-preview`;
const [treeResp, previewResp] = await Promise.all([
fetch(treeURL).then((r) => (r.ok ? r.json() : [])),
fetch(previewURL).then((r) => (r.ok ? r.json() : [])),
]);
treeCache = treeResp as ProjectTreeNode[];
previewCache = new Map();
for (const p of previewResp as ProjectCardPreview[]) {
previewCache.set(p.project_id, p);
}
rerender();
}
export function teardownCardsView() {
const wrap = document.getElementById("projects-cards-wrap");
const toolbar = document.getElementById("projects-cards-toolbar");
const editToolbar = document.getElementById("projects-cards-edit-toolbar");
if (wrap) wrap.style.display = "none";
if (toolbar) toolbar.style.display = "none";
if (editToolbar) editToolbar.style.display = "none";
editMode = false;
editDraft = null;
}
// ----------------------------------------------------------------------
// Layout management — fetch + select + edit mode
// ----------------------------------------------------------------------
async function reloadLayouts(): Promise<void> {
const resp = await fetch("/api/user-card-layouts");
if (!resp.ok) {
layouts = [];
return;
}
layouts = (await resp.json()) as UserCardLayout[];
// Server may not have any rows yet — auto-seed by hitting the cards
// view's "default" path: GET /api/user-card-layouts/__seed-default__
// Actually the seed happens server-side on GetDefault; we trigger it
// by making a no-op preview request which doesn't pull layouts. The
// simplest path: if empty, POST a Standard layout from the client.
if (layouts.length === 0) {
const seed = await fetch("/api/user-card-layouts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Standard", layout: defaultLayout(), is_default: true }),
});
if (seed.ok) {
const row = (await seed.json()) as UserCardLayout;
layouts = [row];
}
}
}
function defaultLayout(): LayoutSpec {
return {
facts: [
{ key: "title-row", visible: true },
{ key: "type-chip", visible: true },
{ key: "status-chip", visible: true },
{ key: "client-matter", visible: true },
{ key: "parent-path", visible: true },
{ key: "deadline-counts", visible: true },
{ key: "next-events", visible: true, count: 3 },
{ key: "recent-verlauf", visible: true, count: 3 },
{ key: "team-chips", visible: true },
],
density: "roomy",
grid_columns: "auto",
show_all_levels: false,
};
}
function getActiveLayout(): UserCardLayout | null {
if (!activeLayoutId) return null;
return layouts.find((l) => l.id === activeLayoutId) || null;
}
function getEffectiveSpec(): LayoutSpec {
if (editMode && editDraft) return editDraft;
const a = getActiveLayout();
return a ? a.layout : defaultLayout();
}
function populateLayoutSelect() {
const sel = document.getElementById("projects-cards-layout-select") as HTMLSelectElement | null;
if (!sel) return;
sel.innerHTML = "";
for (const l of layouts) {
const opt = document.createElement("option");
opt.value = l.id;
opt.textContent = l.is_default ? `${l.name} · ${t("projects.cards.layout.is_default") || "Standard"}` : l.name;
sel.appendChild(opt);
}
if (activeLayoutId) sel.value = activeLayoutId;
}
function attachToolbarHandlers() {
const sel = document.getElementById("projects-cards-layout-select") as HTMLSelectElement | null;
const editBtn = document.getElementById("projects-cards-layout-edit") as HTMLButtonElement | null;
const newBtn = document.getElementById("projects-cards-layout-new") as HTMLButtonElement | null;
const showAll = document.getElementById("projects-cards-show-all-levels") as HTMLInputElement | null;
// Idempotent attach guards via dataset flag.
if (sel && !sel.dataset.bound) {
sel.dataset.bound = "1";
sel.addEventListener("change", () => {
activeLayoutId = sel.value;
const a = getActiveLayout();
if (showAll && a) showAll.checked = a.layout.show_all_levels;
rerender();
});
}
if (editBtn && !editBtn.dataset.bound) {
editBtn.dataset.bound = "1";
editBtn.addEventListener("click", () => enterEditMode());
}
if (newBtn && !newBtn.dataset.bound) {
newBtn.dataset.bound = "1";
newBtn.addEventListener("click", () => createNewLayout());
}
if (showAll && !showAll.dataset.bound) {
showAll.dataset.bound = "1";
showAll.checked = getEffectiveSpec().show_all_levels;
showAll.addEventListener("change", () => {
// In view mode this is a save-as-active toggle. In edit mode it
// updates the draft.
if (editMode && editDraft) {
editDraft.show_all_levels = showAll.checked;
rerender();
} else {
const a = getActiveLayout();
if (!a) return;
const newSpec: LayoutSpec = { ...a.layout, show_all_levels: showAll.checked };
void persistLayout(a.id, { layout: newSpec });
a.layout = newSpec;
rerender();
}
});
}
// Edit-toolbar wiring (only first time the elements exist).
const eDensity = document.getElementById("projects-cards-edit-density") as HTMLSelectElement | null;
const eGrid = document.getElementById("projects-cards-edit-grid") as HTMLSelectElement | null;
const eRename = document.getElementById("projects-cards-edit-rename") as HTMLButtonElement | null;
const eDelete = document.getElementById("projects-cards-edit-delete") as HTMLButtonElement | null;
const eSetDefault = document.getElementById("projects-cards-edit-set-default") as HTMLButtonElement | null;
const eDiscard = document.getElementById("projects-cards-edit-discard") as HTMLButtonElement | null;
const eSave = document.getElementById("projects-cards-edit-save") as HTMLButtonElement | null;
if (eDensity && !eDensity.dataset.bound) {
eDensity.dataset.bound = "1";
eDensity.addEventListener("change", () => {
if (!editDraft) return;
editDraft.density = eDensity.value as "compact" | "roomy";
rerender();
});
}
if (eGrid && !eGrid.dataset.bound) {
eGrid.dataset.bound = "1";
eGrid.addEventListener("change", () => {
if (!editDraft) return;
editDraft.grid_columns = eGrid.value as LayoutSpec["grid_columns"];
rerender();
});
}
if (eRename && !eRename.dataset.bound) {
eRename.dataset.bound = "1";
eRename.addEventListener("click", () => renameActiveLayout());
}
if (eDelete && !eDelete.dataset.bound) {
eDelete.dataset.bound = "1";
eDelete.addEventListener("click", () => deleteActiveLayout());
}
if (eSetDefault && !eSetDefault.dataset.bound) {
eSetDefault.dataset.bound = "1";
eSetDefault.addEventListener("click", () => setActiveAsDefault());
}
if (eDiscard && !eDiscard.dataset.bound) {
eDiscard.dataset.bound = "1";
eDiscard.addEventListener("click", () => leaveEditMode(false));
}
if (eSave && !eSave.dataset.bound) {
eSave.dataset.bound = "1";
eSave.addEventListener("click", () => leaveEditMode(true));
}
}
function enterEditMode() {
const a = getActiveLayout();
if (!a) return;
editMode = true;
editDraft = JSON.parse(JSON.stringify(a.layout)) as LayoutSpec;
reflectEditToolbar();
rerender();
}
async function leaveEditMode(saveChanges: boolean) {
const a = getActiveLayout();
if (!a) {
editMode = false;
editDraft = null;
rerender();
return;
}
if (saveChanges && editDraft) {
await persistLayout(a.id, { layout: editDraft });
a.layout = editDraft;
}
editMode = false;
editDraft = null;
reflectEditToolbar();
rerender();
}
function reflectEditToolbar() {
const editToolbar = document.getElementById("projects-cards-edit-toolbar");
if (editToolbar) editToolbar.style.display = editMode ? "flex" : "none";
if (editMode && editDraft) {
const eDensity = document.getElementById("projects-cards-edit-density") as HTMLSelectElement | null;
const eGrid = document.getElementById("projects-cards-edit-grid") as HTMLSelectElement | null;
if (eDensity) eDensity.value = editDraft.density;
if (eGrid) eGrid.value = editDraft.grid_columns;
}
}
async function persistLayout(id: string, patch: Partial<{ name: string; layout: LayoutSpec; is_default: boolean }>) {
const resp = await fetch(`/api/user-card-layouts/${encodeURIComponent(id)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(patch),
});
if (resp.ok) {
const row = (await resp.json()) as UserCardLayout;
const idx = layouts.findIndex((l) => l.id === id);
if (idx >= 0) layouts[idx] = row;
}
}
async function createNewLayout() {
const name = window.prompt(t("projects.cards.layout.new.prompt") || "Name der neuen Ansicht");
if (!name || !name.trim()) return;
const seed = JSON.parse(JSON.stringify(getEffectiveSpec())) as LayoutSpec;
const resp = await fetch("/api/user-card-layouts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: name.trim(), layout: seed, is_default: false }),
});
if (resp.status === 409) {
window.alert("Name already exists.");
return;
}
if (!resp.ok) return;
const row = (await resp.json()) as UserCardLayout;
layouts.push(row);
activeLayoutId = row.id;
populateLayoutSelect();
enterEditMode();
}
async function renameActiveLayout() {
const a = getActiveLayout();
if (!a) return;
const name = window.prompt(t("projects.cards.layout.rename") || "Umbenennen", a.name);
if (!name || !name.trim() || name.trim() === a.name) return;
await persistLayout(a.id, { name: name.trim() });
populateLayoutSelect();
}
async function deleteActiveLayout() {
const a = getActiveLayout();
if (!a) return;
if (a.is_default) {
window.alert(t("projects.cards.layout.delete.default_blocked") || "Cannot delete default.");
return;
}
if (!window.confirm(t("projects.cards.layout.delete.confirm") || "Delete?")) return;
const resp = await fetch(`/api/user-card-layouts/${encodeURIComponent(a.id)}`, { method: "DELETE" });
if (!resp.ok && resp.status !== 204) return;
layouts = layouts.filter((l) => l.id !== a.id);
const def = layouts.find((l) => l.is_default) || layouts[0];
activeLayoutId = def ? def.id : null;
editMode = false;
editDraft = null;
populateLayoutSelect();
reflectEditToolbar();
rerender();
}
async function setActiveAsDefault() {
const a = getActiveLayout();
if (!a) return;
const resp = await fetch(`/api/user-card-layouts/${encodeURIComponent(a.id)}/set-default`, {
method: "POST",
});
if (!resp.ok) return;
// Refresh the list — server cleared the prior default in tx.
await reloadLayouts();
populateLayoutSelect();
rerender();
}
// ----------------------------------------------------------------------
// Rendering
// ----------------------------------------------------------------------
function rerender() {
const grid = document.getElementById("projects-cards-grid");
if (!grid) return;
const spec = getEffectiveSpec();
// Apply grid columns + density.
grid.classList.toggle("is-density-compact", spec.density === "compact");
grid.classList.toggle("is-density-roomy", spec.density === "roomy");
grid.classList.remove("is-grid-2", "is-grid-3", "is-grid-4");
if (spec.grid_columns !== "auto") {
grid.classList.add(`is-grid-${spec.grid_columns}`);
}
const cards = flattenTreeToCards(treeCache, spec.show_all_levels);
if (cards.length === 0) {
grid.innerHTML = `<div class="projects-cards-empty">${escHTML(t("projects.cards.empty") || "Keine Projekte zum Anzeigen.")}</div>`;
return;
}
grid.innerHTML = cards.map((n) => renderCard(n, spec)).join("");
attachCardHandlers(grid);
}
function flattenTreeToCards(roots: ProjectTreeNode[], showAllLevels: boolean): ProjectTreeNode[] {
const out: ProjectTreeNode[] = [];
const walk = (n: ProjectTreeNode) => {
if (showAllLevels) {
out.push(n);
} else if (isLeafish(n)) {
out.push(n);
}
for (const c of n.children) walk(c);
};
roots.forEach(walk);
// Sort by last_activity_at DESC (from preview), pinned first.
out.sort((a, b) => {
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1;
const aT = previewCache.get(a.id)?.last_activity_at || "";
const bT = previewCache.get(b.id)?.last_activity_at || "";
if (aT !== bT) return bT.localeCompare(aT);
return a.title.localeCompare(b.title);
});
return out;
}
function isLeafish(n: ProjectTreeNode): boolean {
// Cases / Patents / Verfahren / Projekte. Mandanten + Litigations are
// scaffolding when "Alle Ebenen" is off.
return n.type === "case" || n.type === "patent" || n.type === "project";
}
function renderCard(n: ProjectTreeNode, spec: LayoutSpec): string {
const visibleFacts = spec.facts.filter((f) => f.visible);
const factHTML = visibleFacts.map((f) => renderFact(n, f, spec)).join("");
const editChrome = editMode ? renderEditChromeForCard(spec) : "";
return (
`<article class="projects-card${editMode ? " is-edit-mode" : ""}" data-id="${escAttr(n.id)}">` +
factHTML +
editChrome +
`</article>`
);
}
function renderEditChromeForCard(spec: LayoutSpec): string {
// Inline fact-row controls (drag handle + visibility toggle) per fact key.
// We render this as a horizontally-laid-out list at the bottom of each
// card so the user can rearrange order. In edit mode the rendered facts
// above are "preview"; the ordering controls below mutate the draft.
const rows = spec.facts.map((f, i) => {
const label = t(`projects.cards.layout.fact.${f.key}` as never) || f.key;
const cnt = f.count !== undefined
? `<input type="number" min="1" max="5" value="${f.count}" class="projects-cards-edit-count" data-key="${escAttr(f.key)}" />`
: "";
return (
`<li class="projects-cards-edit-fact" draggable="true" data-key="${escAttr(f.key)}" data-index="${i}">` +
`<span class="projects-cards-edit-handle" aria-hidden="true">⠿</span>` +
`<input type="checkbox" class="projects-cards-edit-vis" ${f.visible ? "checked" : ""} data-key="${escAttr(f.key)}" />` +
`<span class="projects-cards-edit-label">${escHTML(String(label))}</span>` +
cnt +
`</li>`
);
}).join("");
return `<ul class="projects-cards-edit-facts">${rows}</ul>`;
}
function renderFact(n: ProjectTreeNode, f: LayoutFact, spec: LayoutSpec): string {
const preview = previewCache.get(n.id);
switch (f.key) {
case "title-row":
return renderTitleRow(n);
case "type-chip":
return `<div class="projects-card-row"><span class="entity-type-chip entity-type-${escAttr(n.type)}">${escHTML(String(tDyn(`projects.type.${n.type}`) || n.type))}</span></div>`;
case "status-chip":
return `<div class="projects-card-row"><span class="entity-status-chip entity-status-${escAttr(n.status)}">${escHTML(String(tDyn(`projects.filter.status.${n.status}`) || n.status))}</span></div>`;
case "client-matter": {
const cm = (n.client_number && n.matter_number)
? `${n.client_number}.${n.matter_number}`
: (n.client_number || n.matter_number || "");
if (!cm) return "";
return `<div class="projects-card-row projects-card-cm">${escHTML(cm)}</div>`;
}
case "parent-path":
// Parent path is omitted in v1 — building the breadcrumb requires
// an extra fetch per card. Display the project's own .reference if
// present as a stand-in cue for hierarchy.
if (!n.reference) return "";
return `<div class="projects-card-row projects-card-ref">${escHTML(n.reference)}</div>`;
case "deadline-counts": {
const open = n.open_deadlines_subtree ?? n.open_deadlines;
const overdue = n.overdue_deadlines_subtree ?? n.overdue_deadlines;
if (open === 0 && overdue === 0) return "";
const parts: string[] = [];
if (overdue > 0) parts.push(`<span class="projekt-tree-badge projekt-tree-badge-overdue">${overdue} ${escHTML(String(t("projects.cards.deadline_overdue") || "überfällig"))}</span>`);
if (open > 0) parts.push(`<span class="projekt-tree-badge projekt-tree-badge-open">${open} ${escHTML(String(t("projects.cards.deadline_open") || "offen"))}</span>`);
return `<div class="projects-card-row projects-card-counts">${parts.join(" ")}</div>`;
}
case "next-events": {
const cap = clampCount(f.count);
const evs = preview?.next_events || [];
if (evs.length === 0) {
return `<div class="projects-card-section"><div class="projects-card-section-title">${escHTML(String(t("projects.cards.next_events") || "Nächste Termine"))}</div><div class="projects-card-empty">${escHTML(String(t("projects.cards.no_next_events") || ""))}</div></div>`;
}
const rows = evs.slice(0, cap).map((e) => renderEventRow(e)).join("");
return `<div class="projects-card-section"><div class="projects-card-section-title">${escHTML(String(t("projects.cards.next_events") || "Nächste Termine"))}</div>${rows}</div>`;
}
case "recent-verlauf": {
const cap = clampCount(f.count);
const evs = preview?.recent_verlauf || [];
if (evs.length === 0) {
return `<div class="projects-card-section"><div class="projects-card-section-title">${escHTML(String(t("projects.cards.recent_verlauf") || "Zuletzt"))}</div><div class="projects-card-empty">${escHTML(String(t("projects.cards.no_recent") || ""))}</div></div>`;
}
const rows = evs.slice(0, cap).map((e) => renderEventRow(e)).join("");
return `<div class="projects-card-section"><div class="projects-card-section-title">${escHTML(String(t("projects.cards.recent_verlauf") || "Zuletzt"))}</div>${rows}</div>`;
}
case "team-chips": {
if (!preview || preview.team_count === 0) return "";
const initials = preview.team_initials.map((i) => `<span class="projects-card-team-initial">${escHTML(i)}</span>`).join("");
const overflow = preview.team_count > preview.team_initials.length
? `<span class="projects-card-team-overflow">+${preview.team_count - preview.team_initials.length}</span>`
: "";
return `<div class="projects-card-row projects-card-team">${initials}${overflow}</div>`;
}
case "reference":
if (!n.reference) return "";
return `<div class="projects-card-row projects-card-ref">${escHTML(n.reference)}</div>`;
case "last-activity-at": {
const at = preview?.last_activity_at;
if (!at) return "";
return `<div class="projects-card-row projects-card-last-activity">${escHTML(fmtDate(at))}</div>`;
}
default:
return "";
}
// unreachable
void spec;
}
function renderTitleRow(n: ProjectTreeNode): string {
const pinClass = n.pinned ? " is-pinned" : "";
const pinLabel = n.pinned
? (t("projects.tree.unpin") || "Pin entfernen")
: (t("projects.tree.pin") || "Anpinnen");
return (
`<div class="projects-card-title-row">` +
`<span class="projects-card-icon projekt-tree-icon-${escAttr(n.type)}" aria-hidden="true">●</span>` +
`<a class="projects-card-title" href="/projects/${escAttr(n.id)}">${escHTML(n.title)}</a>` +
`<button type="button" class="projekt-tree-pin projects-card-pin${pinClass}" data-id="${escAttr(n.id)}" aria-label="${escAttr(String(pinLabel))}" title="${escAttr(String(pinLabel))}">${n.pinned ? "★" : "☆"}</button>` +
`</div>`
);
}
function renderEventRow(e: CardEventPreview): string {
const kindLabel = t(`projects.cards.event.kind.${e.kind}` as never) || e.kind;
const dateStr = fmtDate(e.event_date);
const status = e.status ? ` <span class="projects-card-event-status entity-status-chip entity-status-${escAttr(e.status)}">${escHTML(String(tDyn(`projects.filter.status.${e.status}`) || e.status))}</span>` : "";
const actor = e.actor_name ? ` <span class="projects-card-event-actor">${escHTML(e.actor_name)}</span>` : "";
return (
`<a class="projects-card-event-row" href="${escAttr(e.route)}" title="${escAttr(e.title)}">` +
`<span class="projects-card-event-date">${escHTML(dateStr)}</span>` +
`<span class="projects-card-event-kind">${escHTML(String(kindLabel))}</span>` +
`<span class="projects-card-event-title">${escHTML(e.title)}</span>` +
status +
actor +
`</a>`
);
}
function clampCount(n: number | undefined): number {
if (n === undefined) return 3;
if (n < 1) return 1;
if (n > 5) return 5;
return Math.floor(n);
}
// ----------------------------------------------------------------------
// Card-level event handlers (pin click + edit-mode drag/check/count)
// ----------------------------------------------------------------------
function attachCardHandlers(grid: HTMLElement) {
// Pin star (always-active, edit mode or not).
grid.querySelectorAll<HTMLButtonElement>(".projects-card-pin").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
const id = btn.dataset.id!;
void togglePin(id, btn);
});
});
if (!editMode || !editDraft) return;
// Edit-mode: visibility checkbox per fact.
grid.querySelectorAll<HTMLInputElement>(".projects-cards-edit-vis").forEach((cb) => {
cb.addEventListener("change", () => {
if (!editDraft) return;
const key = cb.dataset.key as FactKey;
const f = editDraft.facts.find((x) => x.key === key);
if (!f) return;
f.visible = cb.checked;
// The first VISIBLE fact must remain "title-row" — gate this.
// Easier approach: always keep title-row visible regardless of click.
const titleRow = editDraft.facts.find((x) => x.key === "title-row");
if (titleRow) titleRow.visible = true;
rerender();
});
});
// Edit-mode: count steppers for next-events / recent-verlauf.
grid.querySelectorAll<HTMLInputElement>(".projects-cards-edit-count").forEach((inp) => {
inp.addEventListener("change", () => {
if (!editDraft) return;
const key = inp.dataset.key as FactKey;
const f = editDraft.facts.find((x) => x.key === key);
if (!f) return;
const v = Math.max(1, Math.min(5, parseInt(inp.value, 10) || 3));
f.count = v;
rerender();
});
});
// Edit-mode: HTML5 drag-and-drop for fact reordering. We attach handlers
// on each .projects-cards-edit-fact <li>; dragover on <ul>; drop reorders
// the editDraft.facts array.
grid.querySelectorAll<HTMLLIElement>(".projects-cards-edit-fact").forEach((li) => {
li.addEventListener("dragstart", (ev) => {
const key = li.dataset.key!;
ev.dataTransfer?.setData("text/plain", key);
li.classList.add("is-dragging");
});
li.addEventListener("dragend", () => li.classList.remove("is-dragging"));
li.addEventListener("dragover", (ev) => {
ev.preventDefault();
li.classList.add("is-drop-target");
});
li.addEventListener("dragleave", () => li.classList.remove("is-drop-target"));
li.addEventListener("drop", (ev) => {
ev.preventDefault();
li.classList.remove("is-drop-target");
const fromKey = ev.dataTransfer?.getData("text/plain") as FactKey | undefined;
const toKey = li.dataset.key as FactKey;
if (!fromKey || !toKey || fromKey === toKey) return;
reorderFacts(fromKey, toKey);
rerender();
});
});
}
function reorderFacts(fromKey: FactKey, toKey: FactKey) {
if (!editDraft) return;
const fromIdx = editDraft.facts.findIndex((f) => f.key === fromKey);
const toIdx = editDraft.facts.findIndex((f) => f.key === toKey);
if (fromIdx < 0 || toIdx < 0) return;
const [moved] = editDraft.facts.splice(fromIdx, 1);
editDraft.facts.splice(toIdx, 0, moved);
// Server validator requires title-row to be the first visible fact.
// Pull it to the top if it's now somewhere else.
const trIdx = editDraft.facts.findIndex((f) => f.key === "title-row");
if (trIdx > 0) {
const [tr] = editDraft.facts.splice(trIdx, 1);
editDraft.facts.unshift(tr);
}
}
async function togglePin(projectID: string, btn: HTMLElement) {
const wasPinned = btn.classList.contains("is-pinned");
btn.classList.toggle("is-pinned", !wasPinned);
btn.textContent = !wasPinned ? "★" : "☆";
try {
const resp = await fetch(`/api/projects/${encodeURIComponent(projectID)}/pin`, {
method: wasPinned ? "DELETE" : "POST",
});
if (!resp.ok && resp.status !== 201 && resp.status !== 204) {
btn.classList.toggle("is-pinned", wasPinned);
btn.textContent = wasPinned ? "★" : "☆";
return;
}
// Update tree cache in place so re-renders show the new state.
const update = (n: ProjectTreeNode) => {
if (n.id === projectID) n.pinned = !wasPinned;
n.children.forEach(update);
};
treeCache.forEach(update);
} catch {
btn.classList.toggle("is-pinned", wasPinned);
btn.textContent = wasPinned ? "★" : "☆";
}
}
// ----------------------------------------------------------------------
// Helpers
// ----------------------------------------------------------------------
function fmtDate(iso: string): string {
try {
const d = new Date(iso);
return d.toLocaleDateString(getLang() === "de" ? "de-DE" : "en-GB", {
year: "numeric",
month: "2-digit",
day: "2-digit",
});
} catch {
return iso;
}
}
function escHTML(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
function escAttr(s: string): string {
return s.replace(/[&<>"']/g, (c) => {
switch (c) {
case "&": return "&amp;";
case "<": return "&lt;";
case ">": return "&gt;";
case '"': return "&quot;";
case "'": return "&#39;";
}
return c;
});
}
// Avoid an "unused" warning from the type-only import.
const _unusedFactKeys: FactKey[] = ALL_FACT_KEYS;
void _unusedFactKeys;