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.
797 lines
30 KiB
TypeScript
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 "&";
|
|
case "<": return "<";
|
|
case ">": return ">";
|
|
case '"': return """;
|
|
case "'": return "'";
|
|
}
|
|
return c;
|
|
});
|
|
}
|
|
|
|
// Avoid an "unused" warning from the type-only import.
|
|
const _unusedFactKeys: FactKey[] = ALL_FACT_KEYS;
|
|
void _unusedFactKeys;
|