frontend/src/projects.tsx — strip the legacy 3-select toolbar; replace with
search input + view-mode segment-control (Tree | Liste) + chip filter row
(Alle / Nur meine / Angepinnt / Status / Typ / Mit aktiven Fristen). Tree
container is the default visible mount; flat-table hidden until view mode
toggles.
frontend/src/client/projects.ts — orchestrator. Owns chip + search + view-
mode state. Last-viewed restore from sessionStorage (Q1 lock-in), URL params
override on load, syncURL on every state change. Debounced search (250ms).
Multi-select panels via <details> for status/type. Delegates rendering to
project-tree.ts (tree mode) or projects-flat.ts (flat mode).
frontend/src/client/projects-flat.ts (NEW) — extracted table render from the
old projects.ts so the orchestrator can mount/unmount cleanly.
frontend/src/client/project-tree.ts — extends ProjectTreeNode shape with
pinned, inherited_visibility, match_kind, *_subtree fields. Renders pin
star button (always-visible per design §4.6 — touch-friendly), greyed-
ancestor opacity for InheritedVisibility=true, lime backdrop on
match_kind=self. Pin click does optimistic toggle + POST/DELETE
/api/projects/{id}/pin then invalidates the tree cache.
frontend/src/styles/global.css — toolbar + chips + pin star + greyed-
ancestor + match highlighting. ~200 LoC appended.
frontend/src/client/i18n.ts — 29 new keys DE+EN under projects.toolbar.*,
projects.chip.*, projects.tree.deadlines.*, projects.tree.pin/unpin,
projects.search.match.*, projects.empty.filtered.action.
internal/services/pin_service_test.go (NEW) — live-DB tests for PinService
(pin/unpin/idempotent/owner-scope/visibility-gate) + 2 BuildTreeWithOptions
cases (PinnedSet surfaces, ScopeMine greys ancestors). Skips without
TEST_DATABASE_URL; pure-Go path runs clean.
Frontend bun build clean. go build / vet / test (short) clean.
82 lines
2.8 KiB
TypeScript
82 lines
2.8 KiB
TypeScript
import { tDyn, getLang } from "./i18n";
|
|
|
|
// Flat-list (table) rendering for /projects.
|
|
// Extracted from the pre-t-paliad-149 client/projects.ts so the orchestrator
|
|
// can mount/unmount table view alongside the tree view without code duplication.
|
|
|
|
export interface ProjectFlatRow {
|
|
id: string;
|
|
type: string;
|
|
parent_id?: string | null;
|
|
path: string;
|
|
title: string;
|
|
reference?: string | null;
|
|
status: string;
|
|
client_number?: string | null;
|
|
matter_number?: string | null;
|
|
updated_at: string;
|
|
}
|
|
|
|
interface RenderOpts {
|
|
rows: ProjectFlatRow[];
|
|
}
|
|
|
|
// renderFlatList writes the table rows + wires row-click navigation.
|
|
// Caller is responsible for showing/hiding the wrapping table element.
|
|
export function renderFlatList(opts: RenderOpts) {
|
|
const tbody = document.getElementById("projects-body")!;
|
|
tbody.innerHTML = opts.rows
|
|
.map((p) => {
|
|
const typeLabel = tDyn(`projects.type.${p.type}`) || p.type;
|
|
const statusLabel = tDyn(`projects.filter.status.${p.status}`) || p.status;
|
|
const clientMatter =
|
|
p.client_number && p.matter_number
|
|
? `${p.client_number}.${p.matter_number}`
|
|
: p.client_number || p.matter_number || "";
|
|
const refCell = p.reference ? esc(p.reference) : "—";
|
|
const clientMatterCell = clientMatter ? esc(clientMatter) : "—";
|
|
return `<tr class="entity-row" data-id="${esc(p.id)}">
|
|
<td class="entity-col-title">${esc(p.title)}</td>
|
|
<td><span class="entity-type-chip entity-type-${esc(p.type)}">${esc(typeLabel)}</span></td>
|
|
<td class="entity-col-ref">${refCell}</td>
|
|
<td class="entity-col-ref">${clientMatterCell}</td>
|
|
<td class="entity-col-status"><span class="entity-status-chip entity-status-${esc(p.status)}">${esc(statusLabel)}</span></td>
|
|
<td class="entity-col-updated">${fmtDate(p.updated_at)}</td>
|
|
</tr>`;
|
|
})
|
|
.join("");
|
|
|
|
// F-23: when every visible row shares the same status, hide the column to
|
|
// cut redundant noise. The toggle re-runs on every filter change, so the
|
|
// column comes back as soon as the rows mix again.
|
|
const statusUnique = new Set(opts.rows.map((p) => p.status)).size;
|
|
const table = document.getElementById("entity-table");
|
|
table?.classList.toggle("entity-table--hide-status", statusUnique <= 1);
|
|
|
|
tbody.querySelectorAll<HTMLTableRowElement>(".entity-row").forEach((row) => {
|
|
row.addEventListener("click", () => {
|
|
const id = row.dataset.id!;
|
|
window.location.href = `/projects/${id}`;
|
|
});
|
|
});
|
|
}
|
|
|
|
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 esc(s: string): string {
|
|
const d = document.createElement("div");
|
|
d.textContent = s;
|
|
return d.innerHTML;
|
|
}
|