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.
424 lines
14 KiB
TypeScript
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();
|
|
});
|
|
});
|