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.
This commit is contained in:
@@ -1210,6 +1210,55 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"projects.search.match.self": "Treffer",
|
||||
"projects.search.match.ancestor": "\u00dcber-Projekt eines Treffers",
|
||||
"projects.search.match.descendant": "Unterprojekt eines Treffers",
|
||||
"projects.cards.next_events": "N\u00e4chste Termine",
|
||||
"projects.cards.recent_verlauf": "Zuletzt",
|
||||
"projects.cards.no_next_events": "\u2014 keine bevorstehenden Termine",
|
||||
"projects.cards.no_recent": "\u2014 noch nichts passiert",
|
||||
"projects.cards.team": "Team",
|
||||
"projects.cards.deadline_open": "offen",
|
||||
"projects.cards.deadline_overdue": "\u00fcberf\u00e4llig",
|
||||
"projects.cards.show_all_levels": "Alle Ebenen anzeigen",
|
||||
"projects.cards.show_all_levels.hint": "Mandanten + Streitsachen als eigene Karten zeigen",
|
||||
"projects.cards.layout.label": "Ansicht",
|
||||
"projects.cards.layout.new": "Neue Ansicht",
|
||||
"projects.cards.layout.edit": "Bearbeiten",
|
||||
"projects.cards.layout.save": "Speichern",
|
||||
"projects.cards.layout.discard": "Verwerfen",
|
||||
"projects.cards.layout.set_default": "Als Standard festlegen",
|
||||
"projects.cards.layout.delete": "L\u00f6schen",
|
||||
"projects.cards.layout.rename": "Umbenennen",
|
||||
"projects.cards.layout.is_default": "Standard",
|
||||
"projects.cards.layout.new.prompt": "Name der neuen Ansicht",
|
||||
"projects.cards.layout.delete.confirm": "Diese Ansicht wirklich l\u00f6schen?",
|
||||
"projects.cards.layout.delete.default_blocked": "Die aktive Standardansicht kann nicht gel\u00f6scht werden \u2014 bitte zuerst eine andere Standardansicht w\u00e4hlen.",
|
||||
"projects.cards.layout.fact.title-row": "Titelzeile",
|
||||
"projects.cards.layout.fact.type-chip": "Typ-Badge",
|
||||
"projects.cards.layout.fact.status-chip": "Status-Badge",
|
||||
"projects.cards.layout.fact.client-matter": "ClientMatter",
|
||||
"projects.cards.layout.fact.parent-path": "Pfad zum \u00dcberprojekt",
|
||||
"projects.cards.layout.fact.deadline-counts": "Frist-Z\u00e4hler",
|
||||
"projects.cards.layout.fact.next-events": "N\u00e4chste Termine",
|
||||
"projects.cards.layout.fact.recent-verlauf": "Verlauf-Eintr\u00e4ge",
|
||||
"projects.cards.layout.fact.team-chips": "Team-Chips",
|
||||
"projects.cards.layout.fact.reference": "Referenz",
|
||||
"projects.cards.layout.fact.last-activity-at": "Letzte Aktivit\u00e4t",
|
||||
"projects.cards.layout.density": "Dichte",
|
||||
"projects.cards.layout.density.compact": "Kompakt",
|
||||
"projects.cards.layout.density.roomy": "Ger\u00e4umig",
|
||||
"projects.cards.layout.grid": "Spalten",
|
||||
"projects.cards.layout.grid.auto": "Auto",
|
||||
"projects.cards.layout.grid.2": "2",
|
||||
"projects.cards.layout.grid.3": "3",
|
||||
"projects.cards.layout.grid.4": "4",
|
||||
"projects.cards.layout.fact.toggle.hide": "Ausblenden",
|
||||
"projects.cards.layout.fact.toggle.show": "Anzeigen",
|
||||
"projects.cards.layout.fact.move_up": "Nach oben",
|
||||
"projects.cards.layout.fact.move_down": "Nach unten",
|
||||
"projects.cards.layout.fact.count": "Anzahl",
|
||||
"projects.cards.event.kind.deadline": "Frist",
|
||||
"projects.cards.event.kind.appointment": "Termin",
|
||||
"projects.cards.event.kind.project_event": "Verlauf",
|
||||
"projects.cards.empty": "Keine Projekte zum Anzeigen.",
|
||||
"projects.detail.clientmatter.inherited": "Vom \u00dcberprojekt vererbt",
|
||||
"einstellungen.profil.email": "E-Mail",
|
||||
"einstellungen.profil.email.hint": "E-Mail kann nicht ge\u00e4ndert werden.",
|
||||
@@ -3159,6 +3208,55 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"projects.search.match.self": "Match",
|
||||
"projects.search.match.ancestor": "Parent of a match",
|
||||
"projects.search.match.descendant": "Child of a match",
|
||||
"projects.cards.next_events": "Upcoming",
|
||||
"projects.cards.recent_verlauf": "Recent",
|
||||
"projects.cards.no_next_events": "— nothing upcoming",
|
||||
"projects.cards.no_recent": "— nothing recent",
|
||||
"projects.cards.team": "Team",
|
||||
"projects.cards.deadline_open": "open",
|
||||
"projects.cards.deadline_overdue": "overdue",
|
||||
"projects.cards.show_all_levels": "Show all levels",
|
||||
"projects.cards.show_all_levels.hint": "Include Clients + Litigations as their own cards",
|
||||
"projects.cards.layout.label": "View",
|
||||
"projects.cards.layout.new": "New view",
|
||||
"projects.cards.layout.edit": "Edit",
|
||||
"projects.cards.layout.save": "Save",
|
||||
"projects.cards.layout.discard": "Discard",
|
||||
"projects.cards.layout.set_default": "Set as default",
|
||||
"projects.cards.layout.delete": "Delete",
|
||||
"projects.cards.layout.rename": "Rename",
|
||||
"projects.cards.layout.is_default": "Default",
|
||||
"projects.cards.layout.new.prompt": "Name of the new view",
|
||||
"projects.cards.layout.delete.confirm": "Really delete this view?",
|
||||
"projects.cards.layout.delete.default_blocked": "Cannot delete the active default view — switch defaults first.",
|
||||
"projects.cards.layout.fact.title-row": "Title row",
|
||||
"projects.cards.layout.fact.type-chip": "Type badge",
|
||||
"projects.cards.layout.fact.status-chip": "Status badge",
|
||||
"projects.cards.layout.fact.client-matter": "ClientMatter",
|
||||
"projects.cards.layout.fact.parent-path": "Parent path",
|
||||
"projects.cards.layout.fact.deadline-counts": "Deadline counts",
|
||||
"projects.cards.layout.fact.next-events": "Upcoming events",
|
||||
"projects.cards.layout.fact.recent-verlauf": "Recent entries",
|
||||
"projects.cards.layout.fact.team-chips": "Team chips",
|
||||
"projects.cards.layout.fact.reference": "Reference",
|
||||
"projects.cards.layout.fact.last-activity-at": "Last activity",
|
||||
"projects.cards.layout.density": "Density",
|
||||
"projects.cards.layout.density.compact": "Compact",
|
||||
"projects.cards.layout.density.roomy": "Roomy",
|
||||
"projects.cards.layout.grid": "Columns",
|
||||
"projects.cards.layout.grid.auto": "Auto",
|
||||
"projects.cards.layout.grid.2": "2",
|
||||
"projects.cards.layout.grid.3": "3",
|
||||
"projects.cards.layout.grid.4": "4",
|
||||
"projects.cards.layout.fact.toggle.hide": "Hide",
|
||||
"projects.cards.layout.fact.toggle.show": "Show",
|
||||
"projects.cards.layout.fact.move_up": "Move up",
|
||||
"projects.cards.layout.fact.move_down": "Move down",
|
||||
"projects.cards.layout.fact.count": "Count",
|
||||
"projects.cards.event.kind.deadline": "Deadline",
|
||||
"projects.cards.event.kind.appointment": "Appointment",
|
||||
"projects.cards.event.kind.project_event": "Verlauf",
|
||||
"projects.cards.empty": "No projects to show.",
|
||||
"projects.detail.clientmatter.inherited": "Inherited from parent",
|
||||
"einstellungen.profil.email": "Email",
|
||||
"einstellungen.profil.email.hint": "Email cannot be changed.",
|
||||
|
||||
796
frontend/src/client/projects-cards.ts
Normal file
796
frontend/src/client/projects-cards.ts
Normal file
@@ -0,0 +1,796 @@
|
||||
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;
|
||||
@@ -2,6 +2,7 @@ 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).
|
||||
//
|
||||
@@ -15,7 +16,7 @@ import { renderFlatList, ProjectFlatRow } from "./projects-flat";
|
||||
// - project-tree.ts for tree mode
|
||||
// - projects-flat.ts for flat-table mode
|
||||
|
||||
type ViewMode = "tree" | "flat";
|
||||
type ViewMode = "tree" | "cards" | "flat";
|
||||
type Scope = "all" | "mine" | "pinned";
|
||||
|
||||
interface Chips {
|
||||
@@ -60,8 +61,12 @@ function loadStoredState(): State | null {
|
||||
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: parsed.viewMode === "flat" ? "flat" : "tree",
|
||||
viewMode,
|
||||
chips: {
|
||||
scope: parsed.chips?.scope === "mine" || parsed.chips?.scope === "pinned" ? parsed.chips.scope : "all",
|
||||
status: new Set(parsed.chips?.status || []),
|
||||
@@ -97,7 +102,7 @@ function saveState() {
|
||||
function applyURL() {
|
||||
const url = new URL(window.location.href);
|
||||
const v = url.searchParams.get("view");
|
||||
if (v === "tree" || v === "flat") state.viewMode = v;
|
||||
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");
|
||||
@@ -218,12 +223,15 @@ function clearAllChips() {
|
||||
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;
|
||||
@@ -231,7 +239,17 @@ async function render() {
|
||||
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();
|
||||
@@ -359,7 +377,7 @@ 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") return;
|
||||
if (v !== "tree" && v !== "flat" && v !== "cards") return;
|
||||
if (state.viewMode === v) return;
|
||||
state.viewMode = v;
|
||||
syncURL();
|
||||
|
||||
@@ -1439,6 +1439,55 @@ export type I18nKey =
|
||||
| "partner_unit.none"
|
||||
| "partner_unit.subtitle"
|
||||
| "projects.cancel"
|
||||
| "projects.cards.deadline_open"
|
||||
| "projects.cards.deadline_overdue"
|
||||
| "projects.cards.empty"
|
||||
| "projects.cards.event.kind.appointment"
|
||||
| "projects.cards.event.kind.deadline"
|
||||
| "projects.cards.event.kind.project_event"
|
||||
| "projects.cards.layout.delete"
|
||||
| "projects.cards.layout.delete.confirm"
|
||||
| "projects.cards.layout.delete.default_blocked"
|
||||
| "projects.cards.layout.density"
|
||||
| "projects.cards.layout.density.compact"
|
||||
| "projects.cards.layout.density.roomy"
|
||||
| "projects.cards.layout.discard"
|
||||
| "projects.cards.layout.edit"
|
||||
| "projects.cards.layout.fact.client-matter"
|
||||
| "projects.cards.layout.fact.count"
|
||||
| "projects.cards.layout.fact.deadline-counts"
|
||||
| "projects.cards.layout.fact.last-activity-at"
|
||||
| "projects.cards.layout.fact.move_down"
|
||||
| "projects.cards.layout.fact.move_up"
|
||||
| "projects.cards.layout.fact.next-events"
|
||||
| "projects.cards.layout.fact.parent-path"
|
||||
| "projects.cards.layout.fact.recent-verlauf"
|
||||
| "projects.cards.layout.fact.reference"
|
||||
| "projects.cards.layout.fact.status-chip"
|
||||
| "projects.cards.layout.fact.team-chips"
|
||||
| "projects.cards.layout.fact.title-row"
|
||||
| "projects.cards.layout.fact.toggle.hide"
|
||||
| "projects.cards.layout.fact.toggle.show"
|
||||
| "projects.cards.layout.fact.type-chip"
|
||||
| "projects.cards.layout.grid"
|
||||
| "projects.cards.layout.grid.2"
|
||||
| "projects.cards.layout.grid.3"
|
||||
| "projects.cards.layout.grid.4"
|
||||
| "projects.cards.layout.grid.auto"
|
||||
| "projects.cards.layout.is_default"
|
||||
| "projects.cards.layout.label"
|
||||
| "projects.cards.layout.new"
|
||||
| "projects.cards.layout.new.prompt"
|
||||
| "projects.cards.layout.rename"
|
||||
| "projects.cards.layout.save"
|
||||
| "projects.cards.layout.set_default"
|
||||
| "projects.cards.next_events"
|
||||
| "projects.cards.no_next_events"
|
||||
| "projects.cards.no_recent"
|
||||
| "projects.cards.recent_verlauf"
|
||||
| "projects.cards.show_all_levels"
|
||||
| "projects.cards.show_all_levels.hint"
|
||||
| "projects.cards.team"
|
||||
| "projects.chip.all"
|
||||
| "projects.chip.has_open_deadlines"
|
||||
| "projects.chip.mine"
|
||||
|
||||
@@ -65,10 +65,48 @@ export function renderProjects(): string {
|
||||
|
||||
<div className="projects-view-segment" role="tablist" aria-label="Ansicht">
|
||||
<button type="button" className="projects-view-btn" data-view="tree" data-i18n="projects.toolbar.view.tree">Baum</button>
|
||||
<button type="button" className="projects-view-btn" data-view="cards" data-i18n="projects.toolbar.view.cards">Karten</button>
|
||||
<button type="button" className="projects-view-btn" data-view="flat" data-i18n="projects.toolbar.view.flat">Liste</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="projects-cards-toolbar" id="projects-cards-toolbar" style="display:none">
|
||||
<div className="projects-cards-layout-picker">
|
||||
<label className="projects-cards-layout-label" data-i18n="projects.cards.layout.label">Ansicht</label>
|
||||
<select id="projects-cards-layout-select" className="entity-select" />
|
||||
<button type="button" id="projects-cards-layout-edit" className="btn-secondary" data-i18n="projects.cards.layout.edit">Bearbeiten</button>
|
||||
<button type="button" id="projects-cards-layout-new" className="btn-secondary" data-i18n="projects.cards.layout.new">Neue Ansicht</button>
|
||||
</div>
|
||||
<label className="projects-cards-show-all-levels">
|
||||
<input type="checkbox" id="projects-cards-show-all-levels" />
|
||||
<span data-i18n="projects.cards.show_all_levels">Alle Ebenen anzeigen</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="projects-cards-edit-toolbar" id="projects-cards-edit-toolbar" style="display:none">
|
||||
<div className="projects-cards-edit-controls">
|
||||
<label data-i18n="projects.cards.layout.density">Dichte</label>
|
||||
<select id="projects-cards-edit-density">
|
||||
<option value="roomy" data-i18n="projects.cards.layout.density.roomy">Geräumig</option>
|
||||
<option value="compact" data-i18n="projects.cards.layout.density.compact">Kompakt</option>
|
||||
</select>
|
||||
<label data-i18n="projects.cards.layout.grid">Spalten</label>
|
||||
<select id="projects-cards-edit-grid">
|
||||
<option value="auto" data-i18n="projects.cards.layout.grid.auto">Auto</option>
|
||||
<option value="2" data-i18n="projects.cards.layout.grid.2">2</option>
|
||||
<option value="3" data-i18n="projects.cards.layout.grid.3">3</option>
|
||||
<option value="4" data-i18n="projects.cards.layout.grid.4">4</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="projects-cards-edit-actions">
|
||||
<button type="button" id="projects-cards-edit-rename" className="btn-secondary" data-i18n="projects.cards.layout.rename">Umbenennen</button>
|
||||
<button type="button" id="projects-cards-edit-delete" className="btn-secondary" data-i18n="projects.cards.layout.delete">Löschen</button>
|
||||
<button type="button" id="projects-cards-edit-set-default" className="btn-secondary" data-i18n="projects.cards.layout.set_default">Als Standard festlegen</button>
|
||||
<button type="button" id="projects-cards-edit-discard" className="btn-secondary" data-i18n="projects.cards.layout.discard">Verwerfen</button>
|
||||
<button type="button" id="projects-cards-edit-save" className="btn-primary" data-i18n="projects.cards.layout.save">Speichern</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="projects-chip-row" id="projects-chip-row" role="group" aria-label="Filter">
|
||||
<button type="button" className="projects-chip" data-chip="all" data-i18n="projects.chip.all">Alle</button>
|
||||
<button type="button" className="projects-chip" data-chip="mine" data-i18n="projects.chip.mine">Nur meine</button>
|
||||
@@ -120,6 +158,10 @@ export function renderProjects(): string {
|
||||
<div id="projekt-tree-container" />
|
||||
</div>
|
||||
|
||||
<div className="projects-cards-wrap" id="projects-cards-wrap" style="display:none">
|
||||
<div id="projects-cards-grid" className="projects-cards-grid" />
|
||||
</div>
|
||||
|
||||
<div className="entity-empty" id="entity-empty" style="display:none">
|
||||
<h2 data-i18n="projects.empty.title">Noch kein Projekt angelegt</h2>
|
||||
<p data-i18n="projects.empty.hint">
|
||||
|
||||
@@ -11533,3 +11533,293 @@ dialog.quick-add-sheet::backdrop {
|
||||
}
|
||||
.projects-view-segment { align-self: flex-start; }
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
/projects Cards view (t-paliad-149 PR 2)
|
||||
============================================================================ */
|
||||
|
||||
.projects-cards-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.projects-cards-layout-picker {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.projects-cards-layout-label {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.projects-cards-show-all-levels {
|
||||
display: inline-flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
font-size: 0.9rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.projects-cards-edit-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
padding: 0.6rem 0.85rem;
|
||||
margin: 0.5rem 0;
|
||||
background: rgb(var(--hlc-lime-rgb) / 0.15);
|
||||
border: 1px dashed var(--color-accent);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.projects-cards-edit-controls,
|
||||
.projects-cards-edit-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.projects-cards-edit-actions {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* Cards grid */
|
||||
.projects-cards-grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.projects-cards-grid.is-grid-2 { grid-template-columns: repeat(2, 1fr); }
|
||||
.projects-cards-grid.is-grid-3 { grid-template-columns: repeat(3, 1fr); }
|
||||
.projects-cards-grid.is-grid-4 { grid-template-columns: repeat(4, 1fr); }
|
||||
|
||||
.projects-cards-grid.is-density-compact .projects-card {
|
||||
padding: 0.6rem 0.75rem;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.projects-cards-grid.is-density-compact .projects-card-section {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.projects-cards-empty {
|
||||
grid-column: 1 / -1;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* One card */
|
||||
.projects-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.85rem 1rem;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.projects-card.is-edit-mode {
|
||||
border-style: dashed;
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.projects-card-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.projects-card-icon {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.projects-card-title {
|
||||
flex: 1;
|
||||
color: var(--color-text);
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.projects-card-title:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.projects-card-pin {
|
||||
flex: 0 0 auto;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1;
|
||||
padding: 0.1rem 0.25rem;
|
||||
}
|
||||
|
||||
.projects-card-pin.is-pinned {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.projects-card-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
align-items: center;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.projects-card-cm {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.projects-card-section {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.projects-card-section-title {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.projects-card-empty {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.projects-card-event-row {
|
||||
display: grid;
|
||||
grid-template-columns: 80px auto 1fr auto auto;
|
||||
gap: 0.4rem;
|
||||
align-items: center;
|
||||
padding: 0.2rem 0.3rem;
|
||||
margin: 0.1rem 0;
|
||||
text-decoration: none;
|
||||
color: var(--color-text);
|
||||
border-radius: 4px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.projects-card-event-row:hover {
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
|
||||
.projects-card-event-date {
|
||||
color: var(--color-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.projects-card-event-kind {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.projects-card-event-title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.projects-card-team {
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
|
||||
.projects-card-team-initial {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-surface-muted);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
margin-right: -4px;
|
||||
border: 2px solid var(--color-surface);
|
||||
}
|
||||
|
||||
.projects-card-team-overflow {
|
||||
margin-left: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* Edit-mode fact list inside each card */
|
||||
.projects-cards-edit-facts {
|
||||
list-style: none;
|
||||
margin: 0.5rem 0 0 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
border-top: 1px dashed var(--color-border);
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
.projects-cards-edit-fact {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
padding: 0.25rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
background: var(--color-surface-muted);
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.projects-cards-edit-fact.is-dragging {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.projects-cards-edit-fact.is-drop-target {
|
||||
outline: 2px dashed var(--color-accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.projects-cards-edit-handle {
|
||||
color: var(--color-text-muted);
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.projects-cards-edit-label {
|
||||
flex: 1;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.projects-cards-edit-count {
|
||||
width: 50px;
|
||||
padding: 0.15rem 0.3rem;
|
||||
font-size: 0.85rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.projects-cards-grid {
|
||||
grid-template-columns: 1fr !important;
|
||||
}
|
||||
.projects-cards-edit-toolbar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user