Files
paliad/frontend/src/client/projects.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

424 lines
14 KiB
TypeScript

import { initI18n, onLangChange, t } from "./i18n";
import { initSidebar } from "./sidebar";
import { initProjectTree, refreshProjectTree, rerenderProjectTree } from "./project-tree";
import { renderFlatList, ProjectFlatRow } from "./projects-flat";
import { renderCardsView, teardownCardsView } from "./projects-cards";
// /projects orchestrator (t-paliad-149).
//
// Owns:
// - chip state (scope + status + type + pinned + has_open_deadlines)
// - search term (in-place filter, server-side)
// - view mode (tree | flat). Cards lands in PR 2.
// - last-view restore + URL params (Q1 lock-in: last-viewed restore).
//
// Delegates rendering to:
// - project-tree.ts for tree mode
// - projects-flat.ts for flat-table mode
type ViewMode = "tree" | "cards" | "flat";
type Scope = "all" | "mine" | "pinned";
interface Chips {
scope: Scope;
status: Set<string>;
type: Set<string>;
hasOpenDeadlines: boolean;
}
interface State {
viewMode: ViewMode;
chips: Chips;
searchQuery: string;
}
const STORAGE_KEY = "paliad.projects.lastView";
const SEARCH_DEBOUNCE_MS = 250;
let state: State = defaultState();
let flatRows: ProjectFlatRow[] | null = null;
let searchDebounce: number | null = null;
function defaultState(): State {
return {
viewMode: "tree",
chips: {
scope: "all",
status: new Set(),
type: new Set(),
hasOpenDeadlines: false,
},
searchQuery: "",
};
}
function loadStoredState(): State | null {
try {
const raw = sessionStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as {
viewMode?: ViewMode;
chips?: { scope?: Scope; status?: string[]; type?: string[]; hasOpenDeadlines?: boolean };
searchQuery?: string;
};
const viewMode: ViewMode =
parsed.viewMode === "flat" ? "flat" :
parsed.viewMode === "cards" ? "cards" :
"tree";
return {
viewMode,
chips: {
scope: parsed.chips?.scope === "mine" || parsed.chips?.scope === "pinned" ? parsed.chips.scope : "all",
status: new Set(parsed.chips?.status || []),
type: new Set(parsed.chips?.type || []),
hasOpenDeadlines: !!parsed.chips?.hasOpenDeadlines,
},
searchQuery: typeof parsed.searchQuery === "string" ? parsed.searchQuery : "",
};
} catch {
return null;
}
}
function saveState() {
try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify({
viewMode: state.viewMode,
chips: {
scope: state.chips.scope,
status: [...state.chips.status],
type: [...state.chips.type],
hasOpenDeadlines: state.chips.hasOpenDeadlines,
},
searchQuery: state.searchQuery,
}));
} catch {
/* private mode, quota — ignore */
}
}
// applyURL overlays ?view=, ?scope=, ?status=, ?type=, ?has_open_deadlines=,
// ?q= onto the current state. URL > sessionStorage > defaults.
function applyURL() {
const url = new URL(window.location.href);
const v = url.searchParams.get("view");
if (v === "tree" || v === "flat" || v === "cards") state.viewMode = v;
const sc = url.searchParams.get("scope");
if (sc === "mine" || sc === "pinned" || sc === "all") state.chips.scope = sc;
const status = url.searchParams.get("status");
if (status !== null) {
state.chips.status = new Set(status.split(",").map((s) => s.trim()).filter(Boolean));
}
const type = url.searchParams.get("type");
if (type !== null) {
state.chips.type = new Set(type.split(",").map((s) => s.trim()).filter(Boolean));
}
const has = url.searchParams.get("has_open_deadlines");
if (has === "true" || has === "false") state.chips.hasOpenDeadlines = has === "true";
const q = url.searchParams.get("q");
if (q !== null) state.searchQuery = q;
}
function syncURL() {
const url = new URL(window.location.href);
// Clear all known params, then re-set only the non-default ones (keeps URLs short).
["view", "scope", "status", "type", "has_open_deadlines", "q"].forEach((k) => url.searchParams.delete(k));
if (state.viewMode !== "tree") url.searchParams.set("view", state.viewMode);
if (state.chips.scope !== "all") url.searchParams.set("scope", state.chips.scope);
if (state.chips.status.size > 0) url.searchParams.set("status", [...state.chips.status].join(","));
if (state.chips.type.size > 0) url.searchParams.set("type", [...state.chips.type].join(","));
if (state.chips.hasOpenDeadlines) url.searchParams.set("has_open_deadlines", "true");
if (state.searchQuery.trim()) url.searchParams.set("q", state.searchQuery.trim());
window.history.replaceState({}, "", url.toString());
}
// Build the query string the tree endpoint expects. Same shape as the URL
// state but always written (we don't omit "all" because the server expects
// ?subtree_counts=true to get the new field).
function treeParams(): URLSearchParams {
const p = new URLSearchParams();
if (state.chips.scope !== "all") p.set("scope", state.chips.scope);
if (state.chips.status.size > 0) p.set("status", [...state.chips.status].join(","));
if (state.chips.type.size > 0) p.set("type", [...state.chips.type].join(","));
if (state.chips.hasOpenDeadlines) p.set("has_open_deadlines", "true");
if (state.searchQuery.trim()) p.set("q", state.searchQuery.trim());
p.set("subtree_counts", "true");
return p;
}
function reflectChipsToDOM() {
// Scope toggles
const scopes: Scope[] = ["all", "mine", "pinned"];
scopes.forEach((s) => {
const btn = document.querySelector<HTMLButtonElement>(`.projects-chip[data-chip="${s}"]`);
btn?.classList.toggle("is-active", state.chips.scope === s);
});
// Has-open-deadlines
const hasBtn = document.querySelector<HTMLButtonElement>(`.projects-chip[data-chip="has_open_deadlines"]`);
hasBtn?.classList.toggle("is-active", state.chips.hasOpenDeadlines);
// Multi-select panels
reflectMulti("status", state.chips.status);
reflectMulti("type", state.chips.type);
// View mode segment-control
document.querySelectorAll<HTMLButtonElement>(".projects-view-btn").forEach((btn) => {
btn.classList.toggle("is-active", btn.dataset.view === state.viewMode);
});
// Search input value (when restoring state on init)
const searchInput = document.getElementById("projects-search") as HTMLInputElement | null;
if (searchInput && searchInput.value !== state.searchQuery) {
searchInput.value = state.searchQuery;
}
}
function reflectMulti(name: string, set: Set<string>) {
const wrap = document.querySelector<HTMLDetailsElement>(`.projects-chip-multi[data-chip-multi="${name}"]`);
if (!wrap) return;
const summary = wrap.querySelector<HTMLElement>("summary");
const inputs = wrap.querySelectorAll<HTMLInputElement>('input[type="checkbox"]');
inputs.forEach((cb) => { cb.checked = set.has(cb.value); });
if (summary) {
summary.classList.toggle("is-active", set.size > 0);
const baseLabel = t(`projects.chip.${name}` as never) || (name === "status" ? "Status" : "Typ");
if (set.size === 0) {
summary.textContent = String(baseLabel);
} else if (set.size === 1) {
const sole = [...set][0];
const labelKey = `projects.chip.${name}.${sole}` as never;
const label = t(labelKey) || sole;
summary.textContent = `${baseLabel}: ${label}`;
} else {
const tmpl = t("projects.chip.multi.count" as never) || "{n} ausgewählt";
summary.textContent = `${baseLabel}: ${String(tmpl).replace("{n}", String(set.size))}`;
}
}
}
function setScope(s: Scope) {
state.chips.scope = s;
postChipChange();
}
function toggleHasOpen() {
state.chips.hasOpenDeadlines = !state.chips.hasOpenDeadlines;
postChipChange();
}
function postChipChange() {
syncURL();
saveState();
reflectChipsToDOM();
void render();
}
function clearAllChips() {
state = { ...state, chips: defaultState().chips, searchQuery: "" };
postChipChange();
const searchInput = document.getElementById("projects-search") as HTMLInputElement | null;
if (searchInput) searchInput.value = "";
}
async function render() {
const treeWrap = document.getElementById("projekt-tree-wrap")!;
const tableWrap = document.getElementById("entity-table-wrap")!;
const cardsWrap = document.getElementById("projects-cards-wrap")!;
const empty = document.getElementById("entity-empty")!;
const emptyFiltered = document.getElementById("entity-empty-filtered")!;
if (state.viewMode === "tree") {
teardownCardsView();
treeWrap.style.display = "block";
tableWrap.style.display = "none";
cardsWrap.style.display = "none";
empty.style.display = "none";
emptyFiltered.style.display = "none";
const container = document.getElementById("projekt-tree-container") as HTMLElement;
await initProjectTree(container, treeParams());
return;
}
if (state.viewMode === "cards") {
treeWrap.style.display = "none";
tableWrap.style.display = "none";
empty.style.display = "none";
emptyFiltered.style.display = "none";
await renderCardsView({ treeParams: treeParams() });
return;
}
// Flat-list mode. Reuses /api/projects (existing flat endpoint).
teardownCardsView();
treeWrap.style.display = "none";
if (!flatRows) {
flatRows = await loadFlatRows();
}
if (!flatRows) {
tableWrap.style.display = "none";
return;
}
const filtered = filterFlatRows(flatRows);
const count = document.getElementById("projects-count")!;
count.textContent = `${filtered.length} / ${flatRows.length}`;
if (flatRows.length === 0) {
tableWrap.style.display = "none";
empty.style.display = "block";
emptyFiltered.style.display = "none";
return;
}
if (filtered.length === 0) {
tableWrap.style.display = "none";
empty.style.display = "none";
emptyFiltered.style.display = "block";
return;
}
tableWrap.style.display = "";
empty.style.display = "none";
emptyFiltered.style.display = "none";
renderFlatList({ rows: filtered });
}
async function loadFlatRows(): Promise<ProjectFlatRow[] | null> {
const unavailable = document.getElementById("entity-unavailable")!;
try {
const resp = await fetch("/api/projects");
if (resp.status === 503) {
unavailable.style.display = "block";
return null;
}
if (!resp.ok) {
unavailable.style.display = "block";
return null;
}
return (await resp.json()) as ProjectFlatRow[];
} catch {
unavailable.style.display = "block";
return null;
}
}
function filterFlatRows(rows: ProjectFlatRow[]): ProjectFlatRow[] {
let out = rows;
if (state.chips.status.size > 0) {
out = out.filter((p) => state.chips.status.has(p.status));
}
if (state.chips.type.size > 0) {
out = out.filter((p) => state.chips.type.has(p.type));
}
// Note: scope=mine / scope=pinned / has_open_deadlines are not applied
// to the flat-list view — those need server-side support and the flat
// endpoint /api/projects is unchanged from pre-redesign. The chips simply
// narrow status + type in flat mode; tree mode honours all chips.
if (state.searchQuery.trim()) {
const q = state.searchQuery.toLowerCase();
out = out.filter((p) => {
const haystack = [
p.title,
p.reference || "",
p.client_number || "",
p.matter_number || "",
]
.join(" ")
.toLowerCase();
return haystack.includes(q);
});
}
return out;
}
function initSearch() {
const input = document.getElementById("projects-search") as HTMLInputElement | null;
if (!input) return;
input.addEventListener("input", () => {
if (searchDebounce !== null) {
window.clearTimeout(searchDebounce);
}
searchDebounce = window.setTimeout(() => {
state.searchQuery = input.value;
syncURL();
saveState();
void render();
}, SEARCH_DEBOUNCE_MS);
});
}
function initChips() {
document.querySelectorAll<HTMLButtonElement>(".projects-chip[data-chip]").forEach((btn) => {
const chip = btn.dataset.chip!;
if (chip === "all") {
btn.addEventListener("click", () => clearAllChips());
} else if (chip === "mine") {
btn.addEventListener("click", () => setScope(state.chips.scope === "mine" ? "all" : "mine"));
} else if (chip === "pinned") {
btn.addEventListener("click", () => setScope(state.chips.scope === "pinned" ? "all" : "pinned"));
} else if (chip === "has_open_deadlines") {
btn.addEventListener("click", () => toggleHasOpen());
}
});
// Multi-select panels — wire each checkbox change.
document.querySelectorAll<HTMLDetailsElement>(".projects-chip-multi").forEach((wrap) => {
const name = wrap.dataset.chipMulti!;
const set = (name === "status" ? state.chips.status : state.chips.type);
wrap.querySelectorAll<HTMLInputElement>('input[type="checkbox"]').forEach((cb) => {
cb.addEventListener("change", () => {
if (cb.checked) set.add(cb.value); else set.delete(cb.value);
postChipChange();
});
});
});
const reset = document.getElementById("projects-reset-filters");
if (reset) reset.addEventListener("click", () => clearAllChips());
}
function initViewSegment() {
document.querySelectorAll<HTMLButtonElement>(".projects-view-btn").forEach((btn) => {
btn.addEventListener("click", () => {
const v = btn.dataset.view as ViewMode;
if (v !== "tree" && v !== "flat" && v !== "cards") return;
if (state.viewMode === v) return;
state.viewMode = v;
syncURL();
saveState();
reflectChipsToDOM();
void render();
});
});
}
document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
// Q1 lock-in: last-viewed restore. URL > sessionStorage > defaults.
const stored = loadStoredState();
if (stored) state = stored;
applyURL();
reflectChipsToDOM();
initSearch();
initChips();
initViewSegment();
onLangChange(() => {
reflectChipsToDOM();
if (state.viewMode === "tree") {
rerenderProjectTree();
} else {
void render();
}
});
void render();
// The pin handler in project-tree.ts mutates the per-node cache and then
// invalidates it, so subsequent chip changes refetch with fresh pin data.
// When the user navigates back to /projects via popstate (in-app links),
// re-apply URL state.
window.addEventListener("popstate", () => {
state = loadStoredState() || defaultState();
applyURL();
reflectChipsToDOM();
refreshProjectTree(treeParams());
flatRows = null;
void render();
});
});