Merge remote-tracking branch 'origin/main' into mai/kepler/inventor-profession-vs
This commit is contained in:
@@ -38,6 +38,9 @@ import { renderAdminPartnerUnits } from "./src/admin-partner-units";
|
||||
import { renderAdminEmailTemplates } from "./src/admin-email-templates";
|
||||
import { renderAdminEmailTemplatesEdit } from "./src/admin-email-templates-edit";
|
||||
import { renderAdminEventTypes } from "./src/admin-event-types";
|
||||
import { renderAdminBroadcasts } from "./src/admin-broadcasts";
|
||||
import { renderPaliadin } from "./src/paliadin";
|
||||
import { renderAdminPaliadin } from "./src/admin-paliadin";
|
||||
import { renderNotFound } from "./src/notfound";
|
||||
|
||||
const DIST = join(import.meta.dir, "dist");
|
||||
@@ -264,6 +267,9 @@ async function build() {
|
||||
join(import.meta.dir, "src/client/admin-email-templates.ts"),
|
||||
join(import.meta.dir, "src/client/admin-email-templates-edit.ts"),
|
||||
join(import.meta.dir, "src/client/admin-event-types.ts"),
|
||||
join(import.meta.dir, "src/client/admin-broadcasts.ts"),
|
||||
join(import.meta.dir, "src/client/paliadin.ts"),
|
||||
join(import.meta.dir, "src/client/admin-paliadin.ts"),
|
||||
join(import.meta.dir, "src/client/notfound.ts"),
|
||||
],
|
||||
outdir: join(DIST, "assets"),
|
||||
@@ -379,6 +385,9 @@ async function build() {
|
||||
await Bun.write(join(DIST, "admin-email-templates.html"), renderAdminEmailTemplates());
|
||||
await Bun.write(join(DIST, "admin-email-templates-edit.html"), renderAdminEmailTemplatesEdit());
|
||||
await Bun.write(join(DIST, "admin-event-types.html"), renderAdminEventTypes());
|
||||
await Bun.write(join(DIST, "admin-broadcasts.html"), renderAdminBroadcasts());
|
||||
await Bun.write(join(DIST, "paliadin.html"), renderPaliadin());
|
||||
await Bun.write(join(DIST, "admin-paliadin.html"), renderAdminPaliadin());
|
||||
await Bun.write(join(DIST, "notfound.html"), renderNotFound());
|
||||
|
||||
// Append ?v=<buildVersion> to every /assets/*.js and /assets/*.css URL in
|
||||
|
||||
66
frontend/src/admin-broadcasts.tsx
Normal file
66
frontend/src/admin-broadcasts.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
export function renderAdminBroadcasts(): string {
|
||||
return "<!DOCTYPE html>" + (
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#BFF355" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="admin.broadcasts.title">Broadcasts — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/admin/broadcasts" />
|
||||
<BottomNav currentPath="/admin/broadcasts" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
<div className="container">
|
||||
<div className="tool-header">
|
||||
<div>
|
||||
<h1 data-i18n="admin.broadcasts.heading">Broadcasts</h1>
|
||||
<p className="tool-subtitle" data-i18n="admin.broadcasts.subtitle">
|
||||
Versendete Massen-E-Mails an Teamauswahlen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="entity-table-wrap">
|
||||
<table className="entity-table entity-table--readonly broadcasts-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="admin.broadcasts.col.sent_at">Gesendet</th>
|
||||
<th data-i18n="admin.broadcasts.col.subject">Betreff</th>
|
||||
<th data-i18n="admin.broadcasts.col.sender">Absender:in</th>
|
||||
<th data-i18n="admin.broadcasts.col.count">Empfänger</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="broadcasts-tbody">
|
||||
<tr><td colspan={4} data-i18n="admin.broadcasts.loading">Lade ...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="entity-empty" id="broadcasts-empty" style="display:none">
|
||||
<p data-i18n="admin.broadcasts.empty">Noch keine Broadcasts versandt.</p>
|
||||
</div>
|
||||
|
||||
<div id="broadcast-detail" className="hidden" />
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<script src="/assets/admin-broadcasts.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
109
frontend/src/admin-paliadin.tsx
Normal file
109
frontend/src/admin-paliadin.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
// Paliadin monitoring dashboard (t-paliad-146 PoC).
|
||||
//
|
||||
// global_admin only. The load-bearing artefact for §0.5.7's expansion
|
||||
// gate decision: m looks at this every week or two and decides if
|
||||
// Paliadin earns a production v1 build.
|
||||
export function renderAdminPaliadin(): string {
|
||||
return "<!DOCTYPE html>" + (
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#BFF355" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="admin.paliadin.title">Paliadin Monitor — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/admin/paliadin" />
|
||||
<BottomNav currentPath="/admin/paliadin" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
<div className="container">
|
||||
<div className="tool-header">
|
||||
<div>
|
||||
<h1 data-i18n="admin.paliadin.heading">Paliadin Monitor</h1>
|
||||
<p className="tool-subtitle" data-i18n="admin.paliadin.subtitle">
|
||||
Wie wird Paliadin tatsächlich verwendet?
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="paliadin-stats" id="paliadin-stats">
|
||||
<div className="paliadin-stat-cards">
|
||||
<div className="paliadin-stat-card">
|
||||
<div className="paliadin-stat-label" data-i18n="admin.paliadin.total">Gesamt</div>
|
||||
<div className="paliadin-stat-value" id="stat-total">—</div>
|
||||
</div>
|
||||
<div className="paliadin-stat-card">
|
||||
<div className="paliadin-stat-label" data-i18n="admin.paliadin.last7">Letzte 7 Tage</div>
|
||||
<div className="paliadin-stat-value" id="stat-7d">—</div>
|
||||
</div>
|
||||
<div className="paliadin-stat-card">
|
||||
<div className="paliadin-stat-label" data-i18n="admin.paliadin.median_dur">Median Dauer</div>
|
||||
<div className="paliadin-stat-value" id="stat-median">—</div>
|
||||
</div>
|
||||
<div className="paliadin-stat-card">
|
||||
<div className="paliadin-stat-label" data-i18n="admin.paliadin.tool_rate">Tool-Use Rate</div>
|
||||
<div className="paliadin-stat-value" id="stat-tools">—</div>
|
||||
</div>
|
||||
<div className="paliadin-stat-card">
|
||||
<div className="paliadin-stat-label" data-i18n="admin.paliadin.abandon_rate">Abbruchrate</div>
|
||||
<div className="paliadin-stat-value" id="stat-abandon">—</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 data-i18n="admin.paliadin.classifier_heading">Anfragearten</h2>
|
||||
<div className="paliadin-classifier" id="classifier-bars" />
|
||||
|
||||
<h2 data-i18n="admin.paliadin.daily_heading">Tägliche Nutzung</h2>
|
||||
<div className="paliadin-spark" id="daily-spark" />
|
||||
|
||||
<h2 data-i18n="admin.paliadin.top_heading">Top Anfragen</h2>
|
||||
<table className="entity-table entity-table--readonly">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="admin.paliadin.col.prompt">Anfrage</th>
|
||||
<th data-i18n="admin.paliadin.col.count">Anzahl</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="top-prompts-tbody">
|
||||
<tr><td colspan={2} data-i18n="admin.paliadin.loading">Lade …</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2 data-i18n="admin.paliadin.recent_heading">Letzte Anfragen</h2>
|
||||
<table className="entity-table entity-table--readonly">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="admin.paliadin.col.started">Zeit</th>
|
||||
<th data-i18n="admin.paliadin.col.classifier">Art</th>
|
||||
<th data-i18n="admin.paliadin.col.prompt">Anfrage</th>
|
||||
<th data-i18n="admin.paliadin.col.tools">Tools</th>
|
||||
<th data-i18n="admin.paliadin.col.duration">Dauer</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="recent-turns-tbody">
|
||||
<tr><td colspan={5} data-i18n="admin.paliadin.loading">Lade …</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<script src="/assets/admin-paliadin.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -83,6 +83,11 @@ export function renderAdmin(): string {
|
||||
<h2 data-i18n="admin.card.event_types.title">Event-Typen</h2>
|
||||
<p data-i18n="admin.card.event_types.desc">Firmenweite Event-Typen moderieren: archivieren, zusammenführen, befördern.</p>
|
||||
</a>
|
||||
<a href="/admin/broadcasts" className="card card-link">
|
||||
<div className="card-icon" dangerouslySetInnerHTML={{ __html: ICON_MAIL }} />
|
||||
<h2 data-i18n="admin.card.broadcasts.title">Broadcasts</h2>
|
||||
<p data-i18n="admin.card.broadcasts.desc">Versendete Massen-E-Mails an Teamauswahlen einsehen.</p>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<h3 className="section-heading admin-section-planned" data-i18n="admin.section.planned">Geplant</h3>
|
||||
|
||||
137
frontend/src/client/admin-broadcasts.ts
Normal file
137
frontend/src/client/admin-broadcasts.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
// admin-broadcasts.ts — read-only viewer for paliad.email_broadcasts.
|
||||
//
|
||||
// global_admin sees every row; senders see only their own. Authority is
|
||||
// enforced server-side; this client just renders whatever /api/admin/broadcasts
|
||||
// returns. Click a row → load detail (subject, body, recipient list).
|
||||
|
||||
import { initI18n, onLangChange, t } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
|
||||
interface BroadcastRow {
|
||||
id: string;
|
||||
subject: string;
|
||||
sender_id: string;
|
||||
sender_name: string;
|
||||
sender_email: string;
|
||||
recipient_count: number;
|
||||
sent_at: string;
|
||||
template_key?: string;
|
||||
}
|
||||
|
||||
interface BroadcastDetailRecipient {
|
||||
id: string;
|
||||
email: string;
|
||||
display_name: string;
|
||||
}
|
||||
|
||||
interface BroadcastDetail extends BroadcastRow {
|
||||
body: string;
|
||||
recipient_filter: Record<string, unknown>;
|
||||
send_report: { total: number; sent: number; failed: number };
|
||||
recipients: BroadcastDetailRecipient[];
|
||||
}
|
||||
|
||||
let rows: BroadcastRow[] = [];
|
||||
|
||||
function esc(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function fmtDate(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return iso;
|
||||
return d.toLocaleString();
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
const tbody = document.getElementById("broadcasts-tbody")!;
|
||||
const empty = document.getElementById("broadcasts-empty")!;
|
||||
try {
|
||||
const res = await fetch("/api/admin/broadcasts");
|
||||
if (!res.ok) {
|
||||
if (res.status === 403) {
|
||||
tbody.innerHTML = `<tr><td colspan="4">${esc(t("common.forbidden") || "Zugriff verweigert.")}</td></tr>`;
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = `<tr><td colspan="4">${esc(t("common.load_error") || "Fehler beim Laden.")}</td></tr>`;
|
||||
return;
|
||||
}
|
||||
rows = (await res.json()) as BroadcastRow[];
|
||||
} catch {
|
||||
tbody.innerHTML = `<tr><td colspan="4">${esc(t("common.load_error") || "Fehler beim Laden.")}</td></tr>`;
|
||||
return;
|
||||
}
|
||||
if (!rows.length) {
|
||||
tbody.innerHTML = "";
|
||||
empty.style.display = "block";
|
||||
return;
|
||||
}
|
||||
empty.style.display = "none";
|
||||
tbody.innerHTML = rows
|
||||
.map(
|
||||
(r) => `
|
||||
<tr data-broadcast-id="${esc(r.id)}">
|
||||
<td>${esc(fmtDate(r.sent_at))}</td>
|
||||
<td>${esc(r.subject)}</td>
|
||||
<td>${esc(r.sender_name || r.sender_email || "—")}</td>
|
||||
<td>${r.recipient_count}</td>
|
||||
</tr>
|
||||
`,
|
||||
)
|
||||
.join("");
|
||||
tbody.querySelectorAll<HTMLTableRowElement>("tr[data-broadcast-id]").forEach((tr) => {
|
||||
tr.addEventListener("click", () => loadDetail(tr.dataset.broadcastId!));
|
||||
tr.style.cursor = "pointer";
|
||||
});
|
||||
}
|
||||
|
||||
async function loadDetail(id: string): Promise<void> {
|
||||
const detail = document.getElementById("broadcast-detail")!;
|
||||
detail.classList.remove("hidden");
|
||||
detail.innerHTML = `<p>${esc(t("common.loading") || "Lade…")}</p>`;
|
||||
try {
|
||||
const res = await fetch(`/api/admin/broadcasts/${encodeURIComponent(id)}`);
|
||||
if (!res.ok) {
|
||||
detail.innerHTML = `<p>${esc(t("common.load_error") || "Fehler beim Laden.")}</p>`;
|
||||
return;
|
||||
}
|
||||
const d = (await res.json()) as BroadcastDetail;
|
||||
const recList = (d.recipients || [])
|
||||
.map(
|
||||
(r) =>
|
||||
`<li>${esc(r.display_name || "—")} <span class="broadcast-recip-email"><${esc(r.email)}></span></li>`,
|
||||
)
|
||||
.join("");
|
||||
const report = d.send_report || { total: d.recipient_count, sent: d.recipient_count, failed: 0 };
|
||||
detail.innerHTML = `
|
||||
<article class="card broadcast-detail-card">
|
||||
<header>
|
||||
<h2>${esc(d.subject)}</h2>
|
||||
<p class="muted">
|
||||
${esc(t("admin.broadcasts.detail.sent_by") || "Gesendet von")} <strong>${esc(d.sender_name || d.sender_email)}</strong>
|
||||
• ${esc(fmtDate(d.sent_at))}
|
||||
• ${report.sent}/${report.total} ${esc(t("admin.broadcasts.detail.delivered") || "versandt")}
|
||||
${report.failed > 0 ? ` • ${report.failed} ${esc(t("admin.broadcasts.detail.failed") || "fehlgeschlagen")}` : ""}
|
||||
</p>
|
||||
</header>
|
||||
<div class="broadcast-detail-body">${esc(d.body)}</div>
|
||||
<section class="broadcast-detail-recipients">
|
||||
<h3>${esc(t("admin.broadcasts.detail.recipients") || "Empfänger")} (${d.recipients?.length ?? 0})</h3>
|
||||
<ul>${recList}</ul>
|
||||
</section>
|
||||
</article>
|
||||
`;
|
||||
detail.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
||||
} catch {
|
||||
detail.innerHTML = `<p>${esc(t("common.load_error") || "Fehler beim Laden.")}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
onLangChange(() => load());
|
||||
load();
|
||||
});
|
||||
168
frontend/src/client/admin-paliadin.ts
Normal file
168
frontend/src/client/admin-paliadin.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { initI18n } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
|
||||
// Paliadin admin dashboard client (t-paliad-146 PoC).
|
||||
//
|
||||
// Reads /api/admin/paliadin/stats + /api/admin/paliadin/turns and
|
||||
// renders the cards / bars / sparkline / tables. Pure read-only;
|
||||
// dashboard refreshes on each visit (no live polling — m comes here
|
||||
// every few days, not every few seconds).
|
||||
|
||||
interface Stats {
|
||||
total_turns: number;
|
||||
turns_last_7_days: number;
|
||||
median_duration_ms: number;
|
||||
p90_duration_ms: number;
|
||||
tool_use_rate: number;
|
||||
abandon_rate: number;
|
||||
by_classifier: Record<string, number>;
|
||||
daily_counts: { day: string; count: number }[];
|
||||
top_prompts: { prompt: string; count: number }[];
|
||||
}
|
||||
|
||||
interface Turn {
|
||||
turn_id: string;
|
||||
user_id: string;
|
||||
started_at: string;
|
||||
duration_ms: number | null;
|
||||
user_message: string;
|
||||
used_tools: string[] | null;
|
||||
rows_seen: number[] | null;
|
||||
classifier_tag: string | null;
|
||||
abandoned: boolean;
|
||||
error_code: string | null;
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
|
||||
const [stats, turns] = await Promise.all([
|
||||
fetchJSON<Stats>("/api/admin/paliadin/stats"),
|
||||
fetchJSON<Turn[]>("/api/admin/paliadin/turns"),
|
||||
]);
|
||||
|
||||
if (stats) renderStats(stats);
|
||||
if (turns) renderTurns(turns);
|
||||
});
|
||||
|
||||
async function fetchJSON<T>(url: string): Promise<T | null> {
|
||||
try {
|
||||
const r = await fetch(url, { credentials: "same-origin" });
|
||||
if (!r.ok) return null;
|
||||
return (await r.json()) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function renderStats(s: Stats): void {
|
||||
setText("stat-total", String(s.total_turns));
|
||||
setText("stat-7d", String(s.turns_last_7_days));
|
||||
setText("stat-median", formatMs(s.median_duration_ms));
|
||||
setText("stat-tools", formatPct(s.tool_use_rate));
|
||||
setText("stat-abandon", formatPct(s.abandon_rate));
|
||||
|
||||
// Classifier histogram bars.
|
||||
const cont = document.getElementById("classifier-bars");
|
||||
if (cont) {
|
||||
const entries = Object.entries(s.by_classifier).sort((a, b) => b[1] - a[1]);
|
||||
const max = Math.max(...entries.map((e) => e[1]), 1);
|
||||
cont.innerHTML = entries
|
||||
.map(([tag, n]) => {
|
||||
const pct = (n / max) * 100;
|
||||
return `<div class="paliadin-classifier-row">
|
||||
<div class="paliadin-classifier-label">${escapeHTML(tag)}</div>
|
||||
<div class="paliadin-classifier-bar"><div class="paliadin-classifier-fill" style="width:${pct}%"></div></div>
|
||||
<div class="paliadin-classifier-count">${n}</div>
|
||||
</div>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
// Daily sparkline (last 30 days, vertical bars).
|
||||
const spark = document.getElementById("daily-spark");
|
||||
if (spark) {
|
||||
const days = s.daily_counts;
|
||||
const max = Math.max(...days.map((d) => d.count), 1);
|
||||
spark.innerHTML = days
|
||||
.map((d) => {
|
||||
const h = (d.count / max) * 60;
|
||||
return `<div class="paliadin-spark-bar" style="height:${h}px" title="${d.day}: ${d.count}"></div>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
// Top prompts table.
|
||||
const tbody = document.getElementById("top-prompts-tbody");
|
||||
if (tbody) {
|
||||
if (s.top_prompts.length === 0) {
|
||||
tbody.innerHTML = `<tr><td colspan="2">Noch keine Daten.</td></tr>`;
|
||||
} else {
|
||||
tbody.innerHTML = s.top_prompts
|
||||
.map(
|
||||
(p) =>
|
||||
`<tr><td>${escapeHTML(p.prompt)}</td><td>${p.count}</td></tr>`,
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderTurns(turns: Turn[]): void {
|
||||
const tbody = document.getElementById("recent-turns-tbody");
|
||||
if (!tbody) return;
|
||||
if (turns.length === 0) {
|
||||
tbody.innerHTML = `<tr><td colspan="5">Noch keine Anfragen.</td></tr>`;
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = turns
|
||||
.map((t) => {
|
||||
const tag = t.classifier_tag || "—";
|
||||
const tools = t.used_tools && t.used_tools.length > 0
|
||||
? t.used_tools.join(", ")
|
||||
: "—";
|
||||
const dur = t.duration_ms != null ? formatMs(t.duration_ms) : "—";
|
||||
const errMark = t.error_code ? ` ⚠ ${t.error_code}` : "";
|
||||
return `<tr>
|
||||
<td>${formatTime(t.started_at)}</td>
|
||||
<td>${escapeHTML(tag)}</td>
|
||||
<td>${escapeHTML(truncate(t.user_message, 120))}${errMark}</td>
|
||||
<td>${escapeHTML(tools)}</td>
|
||||
<td>${dur}</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function setText(id: string, val: string): void {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = val;
|
||||
}
|
||||
|
||||
function formatMs(ms: number): string {
|
||||
if (ms < 1000) return `${ms} ms`;
|
||||
return `${(ms / 1000).toFixed(1)} s`;
|
||||
}
|
||||
|
||||
function formatPct(r: number): string {
|
||||
return `${Math.round(r * 100)} %`;
|
||||
}
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString();
|
||||
}
|
||||
|
||||
function truncate(s: string, n: number): string {
|
||||
if (s.length <= n) return s;
|
||||
return s.slice(0, n - 1) + "…";
|
||||
}
|
||||
|
||||
function escapeHTML(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
283
frontend/src/client/broadcast.ts
Normal file
283
frontend/src/client/broadcast.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
// broadcast.ts — bulk team-email compose modal (t-paliad-147 / issue #7).
|
||||
//
|
||||
// Exposes openBroadcastModal({ recipients, projectIDs }) which the /team
|
||||
// page calls when the "E-Mail an Auswahl" button is clicked. The modal
|
||||
// collects subject + body + (optional) template and posts to
|
||||
// /api/team/broadcast. On success it shows a per-recipient send report
|
||||
// and closes.
|
||||
//
|
||||
// Per-recipient privacy: each member receives their own envelope. The
|
||||
// modal lists every addressee so the sender knows exactly who will be
|
||||
// mailed; there is no surprise to-line.
|
||||
|
||||
import { t } from "./i18n";
|
||||
|
||||
export interface BroadcastRecipient {
|
||||
user_id: string;
|
||||
email: string;
|
||||
display_name: string;
|
||||
first_name: string;
|
||||
role_on_project: string;
|
||||
}
|
||||
|
||||
export interface OpenBroadcastModalArgs {
|
||||
recipients: BroadcastRecipient[];
|
||||
projectID?: string | null;
|
||||
projectIDs?: string[];
|
||||
offices?: string[];
|
||||
roles?: string[];
|
||||
}
|
||||
|
||||
interface EmailTemplateOption {
|
||||
key: string;
|
||||
subject: string;
|
||||
body: string;
|
||||
is_default: boolean;
|
||||
}
|
||||
|
||||
const RECIPIENT_CAP = 100;
|
||||
|
||||
function esc(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
// firstName extracts the first whitespace-separated token from a display
|
||||
// name. "Anna von Beispiel" → "Anna". Empty input → "".
|
||||
export function firstName(displayName: string): string {
|
||||
return displayName.trim().split(/\s+/)[0] ?? "";
|
||||
}
|
||||
|
||||
export function openBroadcastModal(args: OpenBroadcastModalArgs): void {
|
||||
if (!args.recipients.length) {
|
||||
alert(t("team.broadcast.error.no_recipients") || "Keine Empfänger ausgewählt.");
|
||||
return;
|
||||
}
|
||||
if (args.recipients.length > RECIPIENT_CAP) {
|
||||
alert(
|
||||
(t("team.broadcast.error.too_many") || "Empfängerlimit ({cap}) überschritten.").replace(
|
||||
"{cap}",
|
||||
String(RECIPIENT_CAP),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Existing modal? Remove. Avoids stacking on rapid double-click.
|
||||
document.getElementById("broadcast-modal")?.remove();
|
||||
|
||||
const overlay = document.createElement("div");
|
||||
overlay.id = "broadcast-modal";
|
||||
overlay.className = "modal-overlay";
|
||||
overlay.innerHTML = renderShell(args);
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
// Close handlers
|
||||
overlay.querySelector("[data-broadcast-close]")?.addEventListener("click", () => overlay.remove());
|
||||
overlay.addEventListener("click", (e) => {
|
||||
if (e.target === overlay) overlay.remove();
|
||||
});
|
||||
document.addEventListener("keydown", function escClose(e) {
|
||||
if (e.key === "Escape") {
|
||||
overlay.remove();
|
||||
document.removeEventListener("keydown", escClose);
|
||||
}
|
||||
});
|
||||
|
||||
// Recipient toggle
|
||||
overlay.querySelector("[data-broadcast-toggle-recipients]")?.addEventListener("click", () => {
|
||||
const list = overlay.querySelector<HTMLDivElement>("[data-broadcast-recipient-list]");
|
||||
if (!list) return;
|
||||
list.classList.toggle("hidden");
|
||||
});
|
||||
|
||||
// Template dropdown
|
||||
const templateSelect = overlay.querySelector<HTMLSelectElement>("[data-broadcast-template]");
|
||||
templateSelect?.addEventListener("change", async () => {
|
||||
const key = templateSelect.value;
|
||||
if (!key) return;
|
||||
const lang = (document.documentElement.lang || "de") as "de" | "en";
|
||||
try {
|
||||
const res = await fetch(`/api/admin/email-templates/${encodeURIComponent(key)}/${lang}`);
|
||||
if (!res.ok) return;
|
||||
const tpl = (await res.json()) as EmailTemplateOption;
|
||||
const subjectInput = overlay.querySelector<HTMLInputElement>("[data-broadcast-subject]");
|
||||
const bodyInput = overlay.querySelector<HTMLTextAreaElement>("[data-broadcast-body]");
|
||||
if (subjectInput) subjectInput.value = stripGoTemplate(tpl.subject);
|
||||
if (bodyInput) bodyInput.value = stripGoTemplate(tpl.body);
|
||||
} catch {
|
||||
/* template load failure is non-fatal — sender keeps freeform mode. */
|
||||
}
|
||||
});
|
||||
|
||||
// Submit
|
||||
const form = overlay.querySelector<HTMLFormElement>("[data-broadcast-form]");
|
||||
form?.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
await onSubmit(form, overlay, args);
|
||||
});
|
||||
}
|
||||
|
||||
function renderShell(args: OpenBroadcastModalArgs): string {
|
||||
const count = args.recipients.length;
|
||||
const previewItems = args.recipients
|
||||
.slice(0, 5)
|
||||
.map((r) => esc(r.display_name) + " <" + esc(r.email) + ">")
|
||||
.join(", ");
|
||||
const more = count > 5 ? ` +${count - 5}` : "";
|
||||
|
||||
const fullList = args.recipients
|
||||
.map(
|
||||
(r) =>
|
||||
`<li><span class="broadcast-recip-name">${esc(r.display_name)}</span> <span class="broadcast-recip-email"><${esc(r.email)}></span>${
|
||||
r.role_on_project ? ` <span class="broadcast-recip-role">${esc(r.role_on_project)}</span>` : ""
|
||||
}</li>`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
return `
|
||||
<div class="modal modal-broadcast" role="dialog" aria-modal="true" aria-labelledby="broadcast-title">
|
||||
<header class="modal-header">
|
||||
<h2 id="broadcast-title">${esc(t("team.broadcast.title") || "E-Mail an Auswahl")}</h2>
|
||||
<button type="button" class="modal-close" data-broadcast-close aria-label="${esc(t("common.close") || "Schließen")}">×</button>
|
||||
</header>
|
||||
<form data-broadcast-form>
|
||||
<div class="modal-body">
|
||||
<div class="broadcast-recipient-summary">
|
||||
<strong>${esc(t("team.broadcast.recipients") || "Empfänger")}: ${count}</strong>
|
||||
<button type="button" class="link-button" data-broadcast-toggle-recipients>${esc(t("team.broadcast.show_all") || "Alle anzeigen")}</button>
|
||||
<div class="broadcast-recipient-preview">${previewItems}${more}</div>
|
||||
<div class="broadcast-recipient-list hidden" data-broadcast-recipient-list>
|
||||
<ul>${fullList}</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label for="broadcast-template-select">${esc(t("team.broadcast.template") || "Vorlage")} <span class="muted">(${esc(t("team.broadcast.template_optional") || "optional")})</span></label>
|
||||
<select id="broadcast-template-select" data-broadcast-template>
|
||||
<option value="">${esc(t("team.broadcast.template_freeform") || "Freitext")}</option>
|
||||
<option value="invitation">${esc(t("team.broadcast.template.invitation") || "Einladung")}</option>
|
||||
<option value="deadline_digest">${esc(t("team.broadcast.template.deadline_digest") || "Frist-Digest")}</option>
|
||||
</select>
|
||||
|
||||
<label for="broadcast-subject">${esc(t("team.broadcast.subject") || "Betreff")}</label>
|
||||
<input type="text" id="broadcast-subject" data-broadcast-subject required maxlength="200" />
|
||||
|
||||
<label for="broadcast-body">${esc(t("team.broadcast.body") || "Nachricht")}</label>
|
||||
<textarea id="broadcast-body" data-broadcast-body required rows="12" placeholder="${esc(t("team.broadcast.body_placeholder") || "Hallo {{first_name}}, …")}"></textarea>
|
||||
|
||||
<p class="broadcast-hint muted">
|
||||
${esc(t("team.broadcast.placeholders_hint") || "Platzhalter: {{name}}, {{first_name}}, {{role_on_project}}")}
|
||||
</p>
|
||||
<p class="broadcast-hint muted">
|
||||
${esc(t("team.broadcast.markdown_hint") || "Markdown unterstützt: **fett**, *kursiv*, [Link](https://...), - Aufzählung.")}
|
||||
</p>
|
||||
|
||||
<div class="broadcast-error hidden" data-broadcast-error></div>
|
||||
<div class="broadcast-success hidden" data-broadcast-success></div>
|
||||
</div>
|
||||
|
||||
<footer class="modal-footer">
|
||||
<button type="button" class="btn btn-ghost" data-broadcast-close>${esc(t("common.cancel") || "Abbrechen")}</button>
|
||||
<button type="submit" class="btn btn-primary" data-broadcast-submit>${esc(t("team.broadcast.send") || "Senden")} (${count})</button>
|
||||
</footer>
|
||||
</form>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async function onSubmit(form: HTMLFormElement, overlay: HTMLElement, args: OpenBroadcastModalArgs): Promise<void> {
|
||||
const subject = (form.querySelector<HTMLInputElement>("[data-broadcast-subject]")?.value ?? "").trim();
|
||||
const body = (form.querySelector<HTMLTextAreaElement>("[data-broadcast-body]")?.value ?? "").trim();
|
||||
const templateKey = form.querySelector<HTMLSelectElement>("[data-broadcast-template]")?.value ?? "";
|
||||
const errEl = overlay.querySelector<HTMLDivElement>("[data-broadcast-error]");
|
||||
const okEl = overlay.querySelector<HTMLDivElement>("[data-broadcast-success]");
|
||||
errEl?.classList.add("hidden");
|
||||
okEl?.classList.add("hidden");
|
||||
|
||||
if (!subject) {
|
||||
showError(errEl, t("team.broadcast.error.subject_required") || "Betreff ist erforderlich.");
|
||||
return;
|
||||
}
|
||||
if (!body) {
|
||||
showError(errEl, t("team.broadcast.error.body_required") || "Nachricht ist erforderlich.");
|
||||
return;
|
||||
}
|
||||
|
||||
const submitBtn = form.querySelector<HTMLButtonElement>("[data-broadcast-submit]");
|
||||
if (submitBtn) {
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = t("team.broadcast.sending") || "Sende…";
|
||||
}
|
||||
|
||||
const recipientFilter: Record<string, unknown> = {};
|
||||
if (args.projectIDs?.length) recipientFilter.project_ids = args.projectIDs;
|
||||
if (args.projectID) recipientFilter.project_id = args.projectID;
|
||||
if (args.offices?.length) recipientFilter.offices = args.offices;
|
||||
if (args.roles?.length) recipientFilter.roles = args.roles;
|
||||
|
||||
const lang = (document.documentElement.lang === "en" ? "en" : "de");
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/team/broadcast", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
project_id: args.projectID ?? null,
|
||||
subject,
|
||||
body,
|
||||
template_key: templateKey || undefined,
|
||||
lang,
|
||||
recipient_filter: recipientFilter,
|
||||
recipients: args.recipients,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const errBody = await res.json().catch(() => ({ error: "Send failed" }));
|
||||
showError(errEl, (errBody as { error?: string }).error || "Send failed");
|
||||
if (submitBtn) {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = (t("team.broadcast.send") || "Senden") + ` (${args.recipients.length})`;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const report = (await res.json()) as { sent: number; failed: number; total: number };
|
||||
if (okEl) {
|
||||
okEl.classList.remove("hidden");
|
||||
const tpl = t("team.broadcast.success") || "{sent} von {total} Mails versandt ({failed} fehlgeschlagen).";
|
||||
okEl.textContent = tpl
|
||||
.replace("{sent}", String(report.sent))
|
||||
.replace("{total}", String(report.total))
|
||||
.replace("{failed}", String(report.failed));
|
||||
}
|
||||
if (submitBtn) {
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = t("team.broadcast.sent") || "Versandt";
|
||||
}
|
||||
setTimeout(() => overlay.remove(), 2500);
|
||||
} catch (e) {
|
||||
showError(errEl, String(e));
|
||||
if (submitBtn) {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = (t("team.broadcast.send") || "Senden") + ` (${args.recipients.length})`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function showError(el: HTMLDivElement | null | undefined, msg: string) {
|
||||
if (!el) return;
|
||||
el.textContent = msg;
|
||||
el.classList.remove("hidden");
|
||||
}
|
||||
|
||||
// stripGoTemplate is best-effort: existing email templates carry
|
||||
// `{{define "content"}}` wrappers and Go-template branches the broadcast
|
||||
// compose form can't honour. The bulk-send pipeline expects plain
|
||||
// Markdown + the placeholder set documented in the modal, so we strip
|
||||
// the template directives before populating the textarea. Senders can
|
||||
// still edit further.
|
||||
function stripGoTemplate(src: string): string {
|
||||
return src
|
||||
.replace(/\{\{\s*(define|end|block|if|else|range|with)\b[^}]*\}\}/g, "")
|
||||
.trim();
|
||||
}
|
||||
@@ -35,6 +35,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"nav.dashboard": "Dashboard",
|
||||
"nav.agenda": "Agenda",
|
||||
"nav.inbox": "Genehmigungen",
|
||||
"nav.paliadin": "Paliadin",
|
||||
"nav.team": "Team",
|
||||
"nav.group.uebersicht": "\u00dcbersicht",
|
||||
"nav.group.arbeit": "Arbeit",
|
||||
@@ -1421,6 +1422,86 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"team.dept.lead": "Lead",
|
||||
"team.dept.unassigned": "Ohne Partner Unit",
|
||||
"team.partner_unit.unassigned": "Ohne Partner Unit",
|
||||
// Project filter (t-paliad-147)
|
||||
"team.filter.project": "Projekt",
|
||||
"team.filter.project.all": "Alle Projekte",
|
||||
"team.filter.project.selected": "ausgewählt",
|
||||
"team.filter.project.clear": "Alle abwählen",
|
||||
// Broadcast modal (t-paliad-147)
|
||||
"team.broadcast.button": "E-Mail an Auswahl",
|
||||
"team.broadcast.title": "E-Mail an Auswahl",
|
||||
"team.broadcast.recipients": "Empfänger",
|
||||
"team.broadcast.show_all": "Alle anzeigen",
|
||||
"team.broadcast.template": "Vorlage",
|
||||
"team.broadcast.template_optional": "optional",
|
||||
"team.broadcast.template_freeform": "Freitext",
|
||||
"team.broadcast.template.invitation": "Einladung",
|
||||
"team.broadcast.template.deadline_digest": "Frist-Digest",
|
||||
"team.broadcast.subject": "Betreff",
|
||||
"team.broadcast.body": "Nachricht",
|
||||
"team.broadcast.body_placeholder": "Hallo {{first_name}}, …",
|
||||
"team.broadcast.placeholders_hint": "Platzhalter: {{name}}, {{first_name}}, {{role_on_project}}",
|
||||
"team.broadcast.markdown_hint": "Markdown unterstützt: **fett**, *kursiv*, [Link](https://...), - Aufzählung.",
|
||||
"team.broadcast.send": "Senden",
|
||||
"team.broadcast.sending": "Sende…",
|
||||
"team.broadcast.sent": "Versandt",
|
||||
"team.broadcast.success": "{sent} von {total} Mails versandt ({failed} fehlgeschlagen).",
|
||||
"team.broadcast.error.no_recipients": "Keine Empfänger ausgewählt.",
|
||||
"team.broadcast.error.too_many": "Empfängerlimit ({cap}) überschritten.",
|
||||
"team.broadcast.error.subject_required": "Betreff ist erforderlich.",
|
||||
"team.broadcast.error.body_required": "Nachricht ist erforderlich.",
|
||||
"common.close": "Schließen",
|
||||
// Admin broadcasts viewer (t-paliad-147)
|
||||
"admin.broadcasts.title": "Broadcasts — Paliad",
|
||||
"admin.broadcasts.heading": "Broadcasts",
|
||||
"admin.broadcasts.subtitle": "Versendete Massen-E-Mails an Teamauswahlen.",
|
||||
"admin.broadcasts.col.sent_at": "Gesendet",
|
||||
"admin.broadcasts.col.subject": "Betreff",
|
||||
"admin.broadcasts.col.sender": "Absender:in",
|
||||
"admin.broadcasts.col.count": "Empfänger",
|
||||
"admin.broadcasts.loading": "Lade…",
|
||||
"admin.broadcasts.empty": "Noch keine Broadcasts versandt.",
|
||||
"admin.broadcasts.detail.sent_by": "Gesendet von",
|
||||
"admin.broadcasts.detail.delivered": "versandt",
|
||||
"admin.broadcasts.detail.failed": "fehlgeschlagen",
|
||||
"admin.broadcasts.detail.recipients": "Empfänger",
|
||||
|
||||
// t-paliad-146: Paliadin in-app AI buddy (PoC)
|
||||
"paliadin.title": "Paliadin — Paliad",
|
||||
"paliadin.heading": "✨ Paliadin",
|
||||
"paliadin.tagline": "Ich kenne deine Akten und Paliads Wissensbasis.",
|
||||
"paliadin.empty": "Was kann ich für dich tun?",
|
||||
"paliadin.starter.today": "Was steht heute an?",
|
||||
"paliadin.starter.week": "Welche Fristen sind diese Woche fällig?",
|
||||
"paliadin.starter.concept": "Erkläre mir Klageerwiderung.",
|
||||
"paliadin.input.placeholder": "Frag den Paliadin…",
|
||||
"paliadin.send": "Senden",
|
||||
"paliadin.stop": "Stop",
|
||||
"paliadin.reset": "Neue Unterhaltung",
|
||||
"nav.admin.paliadin": "Paliadin Monitor",
|
||||
"admin.paliadin.title": "Paliadin Monitor — Paliad",
|
||||
"admin.paliadin.heading": "Paliadin Monitor",
|
||||
"admin.paliadin.subtitle": "Wie wird Paliadin tatsächlich verwendet?",
|
||||
"admin.paliadin.total": "Gesamt",
|
||||
"admin.paliadin.last7": "Letzte 7 Tage",
|
||||
"admin.paliadin.median_dur": "Median Dauer",
|
||||
"admin.paliadin.tool_rate": "Tool-Use Rate",
|
||||
"admin.paliadin.abandon_rate": "Abbruchrate",
|
||||
"admin.paliadin.classifier_heading": "Anfragearten",
|
||||
"admin.paliadin.daily_heading": "Tägliche Nutzung",
|
||||
"admin.paliadin.top_heading": "Top Anfragen",
|
||||
"admin.paliadin.recent_heading": "Letzte Anfragen",
|
||||
"admin.paliadin.col.prompt": "Anfrage",
|
||||
"admin.paliadin.col.count": "Anzahl",
|
||||
"admin.paliadin.col.started": "Zeit",
|
||||
"admin.paliadin.col.classifier": "Art",
|
||||
"admin.paliadin.col.tools": "Tools",
|
||||
"admin.paliadin.col.duration": "Dauer",
|
||||
"admin.paliadin.loading": "Lade…",
|
||||
|
||||
"common.forbidden": "Zugriff verweigert.",
|
||||
"common.load_error": "Fehler beim Laden.",
|
||||
"common.loading": "Lade…",
|
||||
"partner_unit.heading": "Meine Partner Units",
|
||||
"partner_unit.subtitle": "Partner Units sind strukturelle Einheiten — getrennt von Projektteams. Mitgliedschaft wird vom Admin verwaltet.",
|
||||
"partner_unit.none": "Sie sind noch keiner Partner Unit zugeordnet.",
|
||||
@@ -1446,6 +1527,8 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"admin.card.email_templates.desc": "Vorlagen für Einladungen, Erinnerungen und Layout anpassen.",
|
||||
"admin.card.feature_flags.title": "Feature-Flags",
|
||||
"admin.card.feature_flags.desc": "Funktionen pro Standort, Partner Unit oder Rolle aktivieren.",
|
||||
"admin.card.broadcasts.title": "Broadcasts",
|
||||
"admin.card.broadcasts.desc": "Versendete Massen-E-Mails an Teamauswahlen einsehen.",
|
||||
"admin.email_templates.title": "Email-Templates — Paliad",
|
||||
"admin.email_templates.heading": "Email-Templates",
|
||||
"admin.email_templates.subtitle": "Vorlagen für Einladungen, Erinnerungen und das Layout-Wrapper anpassen.",
|
||||
@@ -1877,6 +1960,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"nav.dashboard": "Dashboard",
|
||||
"nav.agenda": "Agenda",
|
||||
"nav.inbox": "Approvals",
|
||||
"nav.paliadin": "Paliadin",
|
||||
"nav.team": "Team",
|
||||
"nav.group.uebersicht": "Overview",
|
||||
"nav.group.arbeit": "Work",
|
||||
@@ -3251,6 +3335,86 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"team.dept.lead": "Lead",
|
||||
"team.dept.unassigned": "No partner unit",
|
||||
"team.partner_unit.unassigned": "No partner unit",
|
||||
// Project filter (t-paliad-147)
|
||||
"team.filter.project": "Project",
|
||||
"team.filter.project.all": "All projects",
|
||||
"team.filter.project.selected": "selected",
|
||||
"team.filter.project.clear": "Deselect all",
|
||||
// Broadcast modal (t-paliad-147)
|
||||
"team.broadcast.button": "Email selection",
|
||||
"team.broadcast.title": "Email selection",
|
||||
"team.broadcast.recipients": "Recipients",
|
||||
"team.broadcast.show_all": "Show all",
|
||||
"team.broadcast.template": "Template",
|
||||
"team.broadcast.template_optional": "optional",
|
||||
"team.broadcast.template_freeform": "Free-form",
|
||||
"team.broadcast.template.invitation": "Invitation",
|
||||
"team.broadcast.template.deadline_digest": "Deadline digest",
|
||||
"team.broadcast.subject": "Subject",
|
||||
"team.broadcast.body": "Message",
|
||||
"team.broadcast.body_placeholder": "Hi {{first_name}}, …",
|
||||
"team.broadcast.placeholders_hint": "Placeholders: {{name}}, {{first_name}}, {{role_on_project}}",
|
||||
"team.broadcast.markdown_hint": "Markdown supported: **bold**, *italic*, [link](https://...), - bullet.",
|
||||
"team.broadcast.send": "Send",
|
||||
"team.broadcast.sending": "Sending…",
|
||||
"team.broadcast.sent": "Sent",
|
||||
"team.broadcast.success": "{sent} of {total} emails sent ({failed} failed).",
|
||||
"team.broadcast.error.no_recipients": "No recipients selected.",
|
||||
"team.broadcast.error.too_many": "Recipient limit ({cap}) exceeded.",
|
||||
"team.broadcast.error.subject_required": "Subject is required.",
|
||||
"team.broadcast.error.body_required": "Message is required.",
|
||||
"common.close": "Close",
|
||||
// Admin broadcasts viewer (t-paliad-147)
|
||||
"admin.broadcasts.title": "Broadcasts — Paliad",
|
||||
"admin.broadcasts.heading": "Broadcasts",
|
||||
"admin.broadcasts.subtitle": "Sent bulk emails to team selections.",
|
||||
"admin.broadcasts.col.sent_at": "Sent",
|
||||
"admin.broadcasts.col.subject": "Subject",
|
||||
"admin.broadcasts.col.sender": "Sender",
|
||||
"admin.broadcasts.col.count": "Recipients",
|
||||
"admin.broadcasts.loading": "Loading…",
|
||||
"admin.broadcasts.empty": "No broadcasts sent yet.",
|
||||
"admin.broadcasts.detail.sent_by": "Sent by",
|
||||
"admin.broadcasts.detail.delivered": "delivered",
|
||||
"admin.broadcasts.detail.failed": "failed",
|
||||
"admin.broadcasts.detail.recipients": "Recipients",
|
||||
|
||||
// t-paliad-146: Paliadin in-app AI buddy (PoC)
|
||||
"paliadin.title": "Paliadin — Paliad",
|
||||
"paliadin.heading": "✨ Paliadin",
|
||||
"paliadin.tagline": "I know your matters and Paliad's knowledge base.",
|
||||
"paliadin.empty": "What can I help you with?",
|
||||
"paliadin.starter.today": "What's on my plate today?",
|
||||
"paliadin.starter.week": "Which deadlines are due this week?",
|
||||
"paliadin.starter.concept": "Explain Klageerwiderung.",
|
||||
"paliadin.input.placeholder": "Ask Paliadin…",
|
||||
"paliadin.send": "Send",
|
||||
"paliadin.stop": "Stop",
|
||||
"paliadin.reset": "New conversation",
|
||||
"nav.admin.paliadin": "Paliadin Monitor",
|
||||
"admin.paliadin.title": "Paliadin Monitor — Paliad",
|
||||
"admin.paliadin.heading": "Paliadin Monitor",
|
||||
"admin.paliadin.subtitle": "How is Paliadin actually being used?",
|
||||
"admin.paliadin.total": "Total",
|
||||
"admin.paliadin.last7": "Last 7 days",
|
||||
"admin.paliadin.median_dur": "Median duration",
|
||||
"admin.paliadin.tool_rate": "Tool-use rate",
|
||||
"admin.paliadin.abandon_rate": "Abandon rate",
|
||||
"admin.paliadin.classifier_heading": "Question types",
|
||||
"admin.paliadin.daily_heading": "Daily usage",
|
||||
"admin.paliadin.top_heading": "Top queries",
|
||||
"admin.paliadin.recent_heading": "Recent queries",
|
||||
"admin.paliadin.col.prompt": "Query",
|
||||
"admin.paliadin.col.count": "Count",
|
||||
"admin.paliadin.col.started": "Time",
|
||||
"admin.paliadin.col.classifier": "Type",
|
||||
"admin.paliadin.col.tools": "Tools",
|
||||
"admin.paliadin.col.duration": "Duration",
|
||||
"admin.paliadin.loading": "Loading…",
|
||||
|
||||
"common.forbidden": "Access denied.",
|
||||
"common.load_error": "Load error.",
|
||||
"common.loading": "Loading…",
|
||||
"partner_unit.heading": "My Partner Units",
|
||||
"partner_unit.subtitle": "Partner Units are structural units — separate from project teams. Membership is admin-managed.",
|
||||
"partner_unit.none": "You are not a member of any Partner Unit yet.",
|
||||
@@ -3276,6 +3440,8 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"admin.card.email_templates.desc": "Customise templates for invitations, reminders and the wrapper layout.",
|
||||
"admin.card.feature_flags.title": "Feature Flags",
|
||||
"admin.card.feature_flags.desc": "Enable features per office, partner unit or role.",
|
||||
"admin.card.broadcasts.title": "Broadcasts",
|
||||
"admin.card.broadcasts.desc": "Inspect bulk emails sent to team selections.",
|
||||
"admin.email_templates.title": "Email Templates — Paliad",
|
||||
"admin.email_templates.heading": "Email Templates",
|
||||
"admin.email_templates.subtitle": "Customise templates for invitations, reminders, and the shared layout wrapper.",
|
||||
|
||||
383
frontend/src/client/paliadin.ts
Normal file
383
frontend/src/client/paliadin.ts
Normal file
@@ -0,0 +1,383 @@
|
||||
import { initI18n, getLang } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
|
||||
// Paliadin chat panel client (t-paliad-146 PoC).
|
||||
//
|
||||
// State machine: empty → typing → sending → streaming → done.
|
||||
// History lives in localStorage under "paliadin:history:<sessionId>"
|
||||
// — design §0.5.4 session-only persistence.
|
||||
//
|
||||
// SSE consumer subscribes to `event: meta`, `event: content`,
|
||||
// `event: end`, `event: error`, `event: ping`. Backend currently
|
||||
// emits one `content` blob per turn (real chunked streaming is
|
||||
// production-v1; PoC simulates with a typewriter effect).
|
||||
|
||||
interface HistoryEntry {
|
||||
role: "user" | "assistant";
|
||||
text: string;
|
||||
meta?: {
|
||||
used_tools?: string[];
|
||||
rows_seen?: number[];
|
||||
classifier_tag?: string;
|
||||
duration_ms?: number;
|
||||
chip_count?: number;
|
||||
};
|
||||
ts: string; // ISO
|
||||
}
|
||||
|
||||
const SESSION_KEY = "paliadin:session";
|
||||
const HISTORY_PREFIX = "paliadin:history:";
|
||||
|
||||
let sessionId: string;
|
||||
let history: HistoryEntry[] = [];
|
||||
let currentEventSource: EventSource | null = null;
|
||||
let currentTurnId: string | null = null;
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
bootSession();
|
||||
wireForm();
|
||||
wireStarters();
|
||||
wireReset();
|
||||
renderHistory();
|
||||
});
|
||||
|
||||
function bootSession(): void {
|
||||
let s = localStorage.getItem(SESSION_KEY);
|
||||
if (!s) {
|
||||
s = crypto.randomUUID();
|
||||
localStorage.setItem(SESSION_KEY, s);
|
||||
}
|
||||
sessionId = s;
|
||||
const stored = localStorage.getItem(HISTORY_PREFIX + sessionId);
|
||||
if (stored) {
|
||||
try {
|
||||
history = JSON.parse(stored);
|
||||
} catch {
|
||||
history = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function wireForm(): void {
|
||||
const form = document.getElementById("paliadin-form") as HTMLFormElement | null;
|
||||
const input = document.getElementById("paliadin-input") as HTMLTextAreaElement | null;
|
||||
if (!form || !input) return;
|
||||
|
||||
form.addEventListener("submit", (e) => {
|
||||
e.preventDefault();
|
||||
const text = input.value.trim();
|
||||
if (!text) return;
|
||||
input.value = "";
|
||||
sendTurn(text);
|
||||
});
|
||||
|
||||
// Enter sends; Shift+Enter inserts newline.
|
||||
input.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
form.dispatchEvent(new Event("submit"));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function wireStarters(): void {
|
||||
const starters = document.querySelectorAll<HTMLButtonElement>(".paliadin-starter");
|
||||
starters.forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const lang = getLang();
|
||||
const promptText = lang === "en"
|
||||
? btn.dataset.promptEn || btn.textContent?.trim() || ""
|
||||
: btn.dataset.promptDe || btn.textContent?.trim() || "";
|
||||
if (promptText) sendTurn(promptText);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function wireReset(): void {
|
||||
const btn = document.getElementById("paliadin-reset");
|
||||
if (!btn) return;
|
||||
btn.addEventListener("click", async () => {
|
||||
history = [];
|
||||
saveHistory();
|
||||
renderHistory();
|
||||
try {
|
||||
await fetch("/api/paliadin/reset", { method: "POST", credentials: "same-origin" });
|
||||
} catch {
|
||||
// Reset failure is non-fatal — the next turn will spin up a fresh pane anyway.
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function sendTurn(text: string): Promise<void> {
|
||||
// Hide empty state on first send.
|
||||
const empty = document.getElementById("paliadin-empty");
|
||||
if (empty) empty.style.display = "none";
|
||||
|
||||
// Append user bubble.
|
||||
history.push({ role: "user", text, ts: new Date().toISOString() });
|
||||
saveHistory();
|
||||
appendBubble("user", text);
|
||||
|
||||
// Insert placeholder assistant bubble.
|
||||
const placeholder = appendBubble("assistant", "");
|
||||
placeholder.dataset.streaming = "true";
|
||||
placeholder.querySelector(".paliadin-bubble-text")!.textContent = "Paliadin denkt nach …";
|
||||
|
||||
toggleStopButton(true);
|
||||
|
||||
// Kick off the turn.
|
||||
let turnRes: { turn_id: string; sse_url: string };
|
||||
try {
|
||||
const r = await fetch("/api/paliadin/turn", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "same-origin",
|
||||
body: JSON.stringify({
|
||||
user_message: text,
|
||||
session_id: sessionId,
|
||||
page_origin: "/paliadin",
|
||||
}),
|
||||
});
|
||||
if (!r.ok) throw new Error("HTTP " + r.status);
|
||||
turnRes = await r.json();
|
||||
} catch (err) {
|
||||
placeholder.querySelector(".paliadin-bubble-text")!.textContent =
|
||||
"Fehler beim Senden: " + String(err);
|
||||
placeholder.dataset.streaming = "false";
|
||||
placeholder.classList.add("paliadin-bubble--error");
|
||||
toggleStopButton(false);
|
||||
return;
|
||||
}
|
||||
|
||||
currentTurnId = turnRes.turn_id;
|
||||
|
||||
// Open SSE.
|
||||
const es = new EventSource(turnRes.sse_url);
|
||||
currentEventSource = es;
|
||||
|
||||
es.addEventListener("meta", () => {
|
||||
// Could surface a "thinking" indicator; placeholder text already does.
|
||||
});
|
||||
|
||||
es.addEventListener("content", (ev) => {
|
||||
const data = JSON.parse((ev as MessageEvent).data);
|
||||
const text = String(data.text || "");
|
||||
typewriter(placeholder, text);
|
||||
});
|
||||
|
||||
es.addEventListener("end", (ev) => {
|
||||
const data = JSON.parse((ev as MessageEvent).data);
|
||||
placeholder.dataset.streaming = "false";
|
||||
finishBubble(placeholder, data);
|
||||
history.push({
|
||||
role: "assistant",
|
||||
text: getBubbleText(placeholder),
|
||||
meta: {
|
||||
used_tools: data.used_tools,
|
||||
rows_seen: data.rows_seen,
|
||||
classifier_tag: data.classifier_tag,
|
||||
duration_ms: data.duration_ms,
|
||||
chip_count: data.chip_count,
|
||||
},
|
||||
ts: new Date().toISOString(),
|
||||
});
|
||||
saveHistory();
|
||||
cleanupTurn();
|
||||
});
|
||||
|
||||
es.addEventListener("error", (ev) => {
|
||||
const msg = (ev as MessageEvent).data
|
||||
? "Fehler: " + (ev as MessageEvent).data
|
||||
: "Verbindung verloren.";
|
||||
placeholder.querySelector(".paliadin-bubble-text")!.textContent = msg;
|
||||
placeholder.classList.add("paliadin-bubble--error");
|
||||
placeholder.dataset.streaming = "false";
|
||||
cleanupTurn();
|
||||
});
|
||||
|
||||
es.addEventListener("ping", () => {
|
||||
// heartbeat — no-op
|
||||
});
|
||||
}
|
||||
|
||||
function cleanupTurn(): void {
|
||||
if (currentEventSource) {
|
||||
currentEventSource.close();
|
||||
currentEventSource = null;
|
||||
}
|
||||
currentTurnId = null;
|
||||
toggleStopButton(false);
|
||||
}
|
||||
|
||||
function toggleStopButton(streaming: boolean): void {
|
||||
const send = document.getElementById("paliadin-send") as HTMLButtonElement | null;
|
||||
const stop = document.getElementById("paliadin-stop") as HTMLButtonElement | null;
|
||||
if (send) send.style.display = streaming ? "none" : "";
|
||||
if (stop) {
|
||||
stop.style.display = streaming ? "" : "none";
|
||||
stop.onclick = () => {
|
||||
cleanupTurn();
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function appendBubble(role: "user" | "assistant", text: string): HTMLElement {
|
||||
const stream = document.getElementById("paliadin-stream")!;
|
||||
const bubble = document.createElement("div");
|
||||
bubble.className = "paliadin-bubble paliadin-bubble--" + role;
|
||||
bubble.innerHTML = `
|
||||
<div class="paliadin-bubble-role">${role === "user" ? "Du" : "Paliadin"}</div>
|
||||
<div class="paliadin-bubble-text"></div>
|
||||
<div class="paliadin-bubble-meta" style="display:none"></div>
|
||||
`;
|
||||
bubble.querySelector(".paliadin-bubble-text")!.textContent = text;
|
||||
stream.appendChild(bubble);
|
||||
stream.scrollTop = stream.scrollHeight;
|
||||
return bubble;
|
||||
}
|
||||
|
||||
// typewriter incrementally fills the bubble's text node so a one-shot
|
||||
// content blob feels like streaming. ~5 ms per character; fast enough
|
||||
// to keep up with even a 4k-char response.
|
||||
function typewriter(bubble: HTMLElement, text: string): void {
|
||||
const node = bubble.querySelector(".paliadin-bubble-text")!;
|
||||
node.textContent = "";
|
||||
let i = 0;
|
||||
const speed = 6;
|
||||
const tick = () => {
|
||||
if (bubble.dataset.streaming !== "true") {
|
||||
// Aborted — flush remaining text instantly.
|
||||
node.textContent = text;
|
||||
return;
|
||||
}
|
||||
if (i >= text.length) return;
|
||||
const next = Math.min(i + 8, text.length);
|
||||
node.textContent = text.slice(0, next);
|
||||
i = next;
|
||||
const stream = document.getElementById("paliadin-stream")!;
|
||||
stream.scrollTop = stream.scrollHeight;
|
||||
setTimeout(tick, speed);
|
||||
};
|
||||
tick();
|
||||
}
|
||||
|
||||
function getBubbleText(bubble: HTMLElement): string {
|
||||
return bubble.querySelector(".paliadin-bubble-text")?.textContent || "";
|
||||
}
|
||||
|
||||
// finishBubble parses the response for citation markers + tool-use
|
||||
// evidence and renders both. Markers found in the text get replaced
|
||||
// by anchor buttons; the meta row at the bottom shows
|
||||
// "ran search_my_deadlines (3 results)".
|
||||
function finishBubble(bubble: HTMLElement, data: any): void {
|
||||
const textNode = bubble.querySelector(".paliadin-bubble-text")! as HTMLElement;
|
||||
const raw = textNode.textContent || "";
|
||||
textNode.innerHTML = renderResponseHTML(raw);
|
||||
|
||||
const metaEl = bubble.querySelector(".paliadin-bubble-meta") as HTMLElement | null;
|
||||
if (metaEl) {
|
||||
const tools = (data.used_tools || []) as string[];
|
||||
const rows = (data.rows_seen || []) as number[];
|
||||
if (tools.length > 0) {
|
||||
const parts = tools.map((t, i) => {
|
||||
const r = rows[i];
|
||||
return r != null ? `${t} (${r})` : t;
|
||||
});
|
||||
metaEl.innerHTML = "▸ " + parts.join(" · ");
|
||||
metaEl.style.display = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Marker → button render. Mirrors §4.4 of the design.
|
||||
const CHIP_RE = /\[(?:#([a-z]+)-OPEN:([A-Za-z0-9\-_]+)|chip:([a-z]+):([^\]]+))\]/g;
|
||||
function renderResponseHTML(raw: string): string {
|
||||
// First escape any HTML in the raw text (simple textContent → innerHTML
|
||||
// would have been fine but we then need to inject anchors, so the
|
||||
// manual escape is unavoidable).
|
||||
const esc = raw
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
|
||||
// Walk markers; replace each with a paliadin-chip anchor.
|
||||
return esc.replace(CHIP_RE, (_match, kind, id, chipKind, chipArg) => {
|
||||
if (kind && id) {
|
||||
const url = chipURL(kind, id);
|
||||
const label = chipLabel(kind);
|
||||
return `<a class="paliadin-chip" href="${url}">${label}</a>`;
|
||||
}
|
||||
if (chipKind === "nav") {
|
||||
return `<a class="paliadin-chip" href="${chipArg}">öffnen</a>`;
|
||||
}
|
||||
if (chipKind === "filter") {
|
||||
return `<a class="paliadin-chip" href="/inbox?${chipArg}">Filter anwenden</a>`;
|
||||
}
|
||||
return "";
|
||||
});
|
||||
}
|
||||
|
||||
function chipURL(kind: string, id: string): string {
|
||||
switch (kind) {
|
||||
case "deadline":
|
||||
case "frist":
|
||||
return "/deadlines/" + id;
|
||||
case "projekt":
|
||||
case "project":
|
||||
return "/projects/" + id;
|
||||
case "termin":
|
||||
case "appointment":
|
||||
return "/appointments/" + id;
|
||||
default:
|
||||
return "#";
|
||||
}
|
||||
}
|
||||
|
||||
function chipLabel(kind: string): string {
|
||||
switch (kind) {
|
||||
case "deadline":
|
||||
case "frist":
|
||||
return "Frist öffnen";
|
||||
case "projekt":
|
||||
case "project":
|
||||
return "Akte ansehen";
|
||||
case "termin":
|
||||
case "appointment":
|
||||
return "Termin öffnen";
|
||||
default:
|
||||
return "öffnen";
|
||||
}
|
||||
}
|
||||
|
||||
function saveHistory(): void {
|
||||
localStorage.setItem(HISTORY_PREFIX + sessionId, JSON.stringify(history));
|
||||
}
|
||||
|
||||
function renderHistory(): void {
|
||||
const stream = document.getElementById("paliadin-stream");
|
||||
if (!stream) return;
|
||||
// Clear non-empty bubbles, keep the empty-state.
|
||||
Array.from(stream.children).forEach((el) => {
|
||||
if (!el.classList.contains("paliadin-empty")) el.remove();
|
||||
});
|
||||
if (history.length === 0) {
|
||||
const empty = document.getElementById("paliadin-empty");
|
||||
if (empty) empty.style.display = "";
|
||||
return;
|
||||
}
|
||||
const empty = document.getElementById("paliadin-empty");
|
||||
if (empty) empty.style.display = "none";
|
||||
history.forEach((h) => {
|
||||
const bubble = appendBubble(h.role, h.text);
|
||||
if (h.role === "assistant" && h.meta) {
|
||||
bubble.dataset.streaming = "false";
|
||||
finishBubble(bubble, {
|
||||
used_tools: h.meta.used_tools,
|
||||
rows_seen: h.meta.rows_seen,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -72,6 +72,7 @@ export function initSidebar() {
|
||||
initChangelogBadge();
|
||||
initInboxBadge();
|
||||
initAdminGroup();
|
||||
initPaliadinLinks();
|
||||
initUserViewsGroup();
|
||||
initThemeToggle();
|
||||
const sidebar = document.querySelector<HTMLElement>(".sidebar");
|
||||
@@ -517,6 +518,32 @@ function userViewIconSvg(icon?: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
// PALIADIN_OWNER_EMAIL must match services.PaliadinOwnerEmail (Go side).
|
||||
// PoC scope — see docs/design-paliadin-2026-05-07.md §0.5.
|
||||
const PALIADIN_OWNER_EMAIL = "matthias.siebels@hoganlovells.com";
|
||||
|
||||
// initPaliadinLinks reveals the Paliadin sidebar entries (under Übersicht
|
||||
// + Admin) when /api/me confirms the caller is the Paliadin owner. Same
|
||||
// fail-closed display:none pattern as initAdminGroup. Non-owners never
|
||||
// see the entries; the routes themselves return 404 if they navigate
|
||||
// to /paliadin or /admin/paliadin manually anyway.
|
||||
function initPaliadinLinks(): void {
|
||||
const top = document.getElementById("sidebar-paliadin-link") as HTMLElement | null;
|
||||
const admin = document.getElementById("sidebar-admin-paliadin-link") as HTMLElement | null;
|
||||
if (!top && !admin) return;
|
||||
fetch("/api/me", { credentials: "same-origin" })
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((me: { email?: string } | null) => {
|
||||
if (me && me.email && me.email.toLowerCase() === PALIADIN_OWNER_EMAIL) {
|
||||
if (top) top.style.display = "";
|
||||
if (admin) admin.style.display = "";
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// silent: failing closed is the safe default.
|
||||
});
|
||||
}
|
||||
|
||||
// initAdminGroup reveals the Admin section in the sidebar when the caller's
|
||||
// /api/me lookup confirms global_role='global_admin'. The markup is in the
|
||||
// DOM with display:none for everyone — flipping it on after the fetch lands
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { initI18n, onLangChange, t, tDyn } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import { openBroadcastModal, firstName, type BroadcastRecipient } from "./broadcast";
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
@@ -10,6 +11,25 @@ interface User {
|
||||
job_title?: string | null;
|
||||
}
|
||||
|
||||
interface MembershipEntry {
|
||||
user_id: string;
|
||||
project_ids: string[];
|
||||
lead_project_ids: string[];
|
||||
roles: string[];
|
||||
}
|
||||
|
||||
interface ProjectSummary {
|
||||
id: string;
|
||||
title: string;
|
||||
type: string;
|
||||
reference?: string | null;
|
||||
}
|
||||
|
||||
interface MeUser {
|
||||
id: string;
|
||||
global_role: string;
|
||||
}
|
||||
|
||||
interface DepartmentMember {
|
||||
user_id: string;
|
||||
email: string;
|
||||
@@ -48,9 +68,13 @@ const ROLE_ORDER = [
|
||||
|
||||
let users: User[] = [];
|
||||
let departments: Department[] = [];
|
||||
let memberships: MembershipEntry[] = [];
|
||||
let projectsList: ProjectSummary[] = [];
|
||||
let me: MeUser | null = null;
|
||||
let groupBy: "office" | "department" = "office";
|
||||
let activeOffice = "all";
|
||||
let activeRole = "all";
|
||||
let activeProjectIDs: Set<string> = new Set();
|
||||
let searchQuery = "";
|
||||
|
||||
const ICON_MAIL = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>';
|
||||
@@ -87,15 +111,26 @@ function initials(name: string): string {
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
const [usersResp, deptsResp] = await Promise.all([
|
||||
const [usersResp, deptsResp, membershipsResp, projectsResp, meResp] = await Promise.all([
|
||||
fetch("/api/users"),
|
||||
fetch("/api/partner-units?include=members"),
|
||||
fetch("/api/team/memberships"),
|
||||
fetch("/api/projects"),
|
||||
fetch("/api/me"),
|
||||
]);
|
||||
if (usersResp.ok) users = (await usersResp.json()) as User[];
|
||||
if (deptsResp.ok) departments = (await deptsResp.json()) as Department[];
|
||||
if (membershipsResp.ok) memberships = (await membershipsResp.json()) as MembershipEntry[];
|
||||
if (projectsResp.ok) {
|
||||
const raw = (await projectsResp.json()) as ProjectSummary[];
|
||||
projectsList = raw;
|
||||
}
|
||||
if (meResp.ok) me = (await meResp.json()) as MeUser;
|
||||
buildOfficeFilters();
|
||||
buildRoleFilters();
|
||||
buildProjectFilter();
|
||||
render();
|
||||
updateBroadcastButton();
|
||||
}
|
||||
|
||||
function presentOffices(): string[] {
|
||||
@@ -191,6 +226,176 @@ function userMatchesRole(u: User): boolean {
|
||||
return roleKey(u.job_title) === activeRole.toLowerCase();
|
||||
}
|
||||
|
||||
// userMatchesProject returns true when the project filter is empty or
|
||||
// when the user is a direct member of at least one selected project.
|
||||
// Inherited memberships intentionally don't qualify here — users want
|
||||
// "people I can mail on this matter", which means direct membership.
|
||||
function userMatchesProject(u: User): boolean {
|
||||
if (activeProjectIDs.size === 0) return true;
|
||||
const m = memberships.find((m) => m.user_id === u.id);
|
||||
if (!m) return false;
|
||||
for (const pid of m.project_ids) {
|
||||
if (activeProjectIDs.has(pid)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// canBroadcast reports whether the current user is allowed to send a
|
||||
// broadcast given the active project filter. global_admin always wins.
|
||||
// Otherwise the user must be a 'lead' on every project they have
|
||||
// selected (or, when no project is selected, on at least one of their
|
||||
// own projects).
|
||||
function canBroadcast(): boolean {
|
||||
if (!me) return false;
|
||||
if (me.global_role === "global_admin") return true;
|
||||
const myMembership = memberships.find((m) => m.user_id === me?.id);
|
||||
if (!myMembership || !myMembership.lead_project_ids.length) return false;
|
||||
if (activeProjectIDs.size === 0) {
|
||||
// No project filter — allow when caller leads at least one project.
|
||||
// Server-side check still runs per-broadcast so a non-lead can never
|
||||
// actually send.
|
||||
return true;
|
||||
}
|
||||
for (const pid of activeProjectIDs) {
|
||||
if (!myMembership.lead_project_ids.includes(pid)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function buildProjectFilter() {
|
||||
const container = document.getElementById("team-project-filter");
|
||||
if (!container) return;
|
||||
// Show only projects the caller can see — projectsList already does
|
||||
// that via the visibility-gated /api/projects endpoint.
|
||||
const sortedProjects = [...projectsList].sort((a, b) =>
|
||||
(a.title || "").localeCompare(b.title || ""),
|
||||
);
|
||||
const options = sortedProjects
|
||||
.map(
|
||||
(p) =>
|
||||
`<label class="filter-checkbox"><input type="checkbox" data-project-id="${esc(p.id)}" ${
|
||||
activeProjectIDs.has(p.id) ? "checked" : ""
|
||||
} /> <span>${esc(p.title)}</span></label>`,
|
||||
)
|
||||
.join("");
|
||||
const summary = activeProjectIDs.size === 0
|
||||
? (t("team.filter.project.all") || "Alle Projekte")
|
||||
: `${activeProjectIDs.size} ${t("team.filter.project.selected") || "ausgewählt"}`;
|
||||
container.innerHTML = `
|
||||
<button type="button" class="filter-pill team-project-trigger" data-project-trigger>
|
||||
<span class="team-project-summary">${esc(t("team.filter.project") || "Projekt")}: ${esc(summary)}</span>
|
||||
</button>
|
||||
<div class="team-project-panel hidden" data-project-panel>
|
||||
<div class="team-project-actions">
|
||||
<button type="button" class="link-button" data-project-clear>${esc(t("team.filter.project.clear") || "Alle abwählen")}</button>
|
||||
</div>
|
||||
<div class="team-project-options">${options}</div>
|
||||
</div>
|
||||
`;
|
||||
const trigger = container.querySelector<HTMLButtonElement>("[data-project-trigger]");
|
||||
const panel = container.querySelector<HTMLDivElement>("[data-project-panel]");
|
||||
trigger?.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
panel?.classList.toggle("hidden");
|
||||
});
|
||||
document.addEventListener("click", (e) => {
|
||||
if (!container.contains(e.target as Node)) panel?.classList.add("hidden");
|
||||
});
|
||||
container.querySelectorAll<HTMLInputElement>("input[data-project-id]").forEach((cb) => {
|
||||
cb.addEventListener("change", () => {
|
||||
const pid = cb.dataset.projectId!;
|
||||
if (cb.checked) activeProjectIDs.add(pid);
|
||||
else activeProjectIDs.delete(pid);
|
||||
buildProjectFilter();
|
||||
render();
|
||||
updateBroadcastButton();
|
||||
});
|
||||
});
|
||||
container.querySelector<HTMLButtonElement>("[data-project-clear]")?.addEventListener("click", () => {
|
||||
activeProjectIDs.clear();
|
||||
buildProjectFilter();
|
||||
render();
|
||||
updateBroadcastButton();
|
||||
});
|
||||
}
|
||||
|
||||
function buildBroadcastButton() {
|
||||
const wrap = document.getElementById("team-broadcast-wrap");
|
||||
if (!wrap) return;
|
||||
if (!canBroadcast()) {
|
||||
wrap.innerHTML = "";
|
||||
wrap.style.display = "none";
|
||||
return;
|
||||
}
|
||||
wrap.style.display = "";
|
||||
wrap.innerHTML = `
|
||||
<button type="button" class="btn btn-primary" id="team-broadcast-btn">
|
||||
${esc(t("team.broadcast.button") || "E-Mail an Auswahl")} <span class="team-broadcast-count" id="team-broadcast-count">0</span>
|
||||
</button>
|
||||
`;
|
||||
document.getElementById("team-broadcast-btn")?.addEventListener("click", () => onBroadcastClick());
|
||||
}
|
||||
|
||||
function updateBroadcastButton() {
|
||||
buildBroadcastButton();
|
||||
const countEl = document.getElementById("team-broadcast-count");
|
||||
if (countEl) {
|
||||
const n = displayedRecipients().length;
|
||||
countEl.textContent = String(n);
|
||||
const btn = document.getElementById("team-broadcast-btn") as HTMLButtonElement | null;
|
||||
if (btn) btn.disabled = n === 0;
|
||||
}
|
||||
}
|
||||
|
||||
// displayedRecipients returns the currently visible users as broadcast
|
||||
// recipients. Personal placeholder fields are sourced from each user
|
||||
// (display_name / first_name) and from the membership index when a
|
||||
// project filter is set (role_on_project = the role on the selected
|
||||
// project; falls back to first available role).
|
||||
function displayedRecipients(): BroadcastRecipient[] {
|
||||
const filtered = users.filter(
|
||||
(u) => userMatchesOffice(u) && userMatchesRole(u) && userMatchesProject(u) && userMatchesSearch(u),
|
||||
);
|
||||
return filtered.map((u) => {
|
||||
const m = memberships.find((m) => m.user_id === u.id);
|
||||
let role = "";
|
||||
if (m) {
|
||||
if (activeProjectIDs.size > 0) {
|
||||
const idx = m.project_ids.findIndex((pid) => activeProjectIDs.has(pid));
|
||||
if (idx >= 0) role = m.roles[idx];
|
||||
} else if (m.roles.length > 0) {
|
||||
role = m.roles[0];
|
||||
}
|
||||
}
|
||||
return {
|
||||
user_id: u.id,
|
||||
email: u.email,
|
||||
display_name: u.display_name,
|
||||
first_name: firstName(u.display_name),
|
||||
role_on_project: role,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function onBroadcastClick() {
|
||||
const recipients = displayedRecipients();
|
||||
const selectedProjectIDs = Array.from(activeProjectIDs);
|
||||
// When exactly one project is selected we pass it as project_id so
|
||||
// the backend can verify lead-ship on that project. With multi-
|
||||
// select we leave project_id null and rely on global_admin (the
|
||||
// service rejects non-admin senders without a project_id).
|
||||
const projectID = selectedProjectIDs.length === 1 ? selectedProjectIDs[0] : null;
|
||||
const offices = activeOffice === "all" ? [] : [activeOffice];
|
||||
const roles = activeRole === "all" ? [] : [activeRole];
|
||||
openBroadcastModal({
|
||||
recipients,
|
||||
projectID,
|
||||
projectIDs: selectedProjectIDs,
|
||||
offices,
|
||||
roles,
|
||||
});
|
||||
}
|
||||
|
||||
function memberAsUser(m: DepartmentMember): User | undefined {
|
||||
return users.find((u) => u.id === m.user_id);
|
||||
}
|
||||
@@ -297,8 +502,11 @@ function render() {
|
||||
const empty = document.getElementById("team-empty")!;
|
||||
const count = document.getElementById("team-count")!;
|
||||
|
||||
const filtered = users.filter((u) => userMatchesOffice(u) && userMatchesRole(u) && userMatchesSearch(u));
|
||||
const filtered = users.filter(
|
||||
(u) => userMatchesOffice(u) && userMatchesRole(u) && userMatchesProject(u) && userMatchesSearch(u),
|
||||
);
|
||||
count.textContent = `${filtered.length} / ${users.length}`;
|
||||
updateBroadcastButton();
|
||||
|
||||
if (filtered.length === 0) {
|
||||
list.innerHTML = "";
|
||||
|
||||
@@ -116,6 +116,14 @@ export function Sidebar({ currentPath, authenticated = true }: SidebarProps): st
|
||||
navItem("/dashboard", ICON_GAUGE, "nav.dashboard", "Dashboard", currentPath) +
|
||||
navItem("/agenda", ICON_AGENDA, "nav.agenda", "Agenda", currentPath) +
|
||||
navItem("/inbox", ICON_BELL, "nav.inbox", "Inbox", currentPath, "sidebar-inbox-badge") +
|
||||
// Paliadin entry \u2014 owner-only, hidden by default. sidebar.ts
|
||||
// reveals it after /api/me confirms the caller is the
|
||||
// Paliadin owner (t-paliad-146 PoC scope). Same fail-closed
|
||||
// pattern as the admin group below.
|
||||
`<a href="/paliadin" class="sidebar-item sidebar-paliadin${currentPath === "/paliadin" ? " active" : ""}" id="sidebar-paliadin-link" style="display:none">` +
|
||||
`<span class="sidebar-icon">${ICON_SPARKLE}</span>` +
|
||||
`<span class="sidebar-label" data-i18n="nav.paliadin">Paliadin</span>` +
|
||||
`</a>` +
|
||||
navItem("/team", ICON_USERS, "nav.team", "Team", currentPath),
|
||||
)}
|
||||
|
||||
@@ -171,6 +179,13 @@ export function Sidebar({ currentPath, authenticated = true }: SidebarProps): st
|
||||
{navItem("/admin/partner-units", ICON_BUILDING, "nav.admin.partner_units", "Partner Units", currentPath)}
|
||||
{navItem("/admin/event-types", ICON_TABLE, "nav.admin.event_types", "Event-Typen", currentPath)}
|
||||
{navItem("/admin/audit-log", ICON_AUDIT_LOG, "nav.admin.audit", "Audit-Log", currentPath)}
|
||||
{/* Paliadin Monitor — owner-only sub-entry; revealed by sidebar.ts together with the /paliadin link. */}
|
||||
<a href="/admin/paliadin" id="sidebar-admin-paliadin-link"
|
||||
className={`sidebar-item${currentPath === "/admin/paliadin" ? " active" : ""}`}
|
||||
style="display:none">
|
||||
<span className="sidebar-icon" dangerouslySetInnerHTML={{ __html: ICON_SPARKLE }} />
|
||||
<span className="sidebar-label" data-i18n="nav.admin.paliadin">Paliadin Monitor</span>
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
|
||||
@@ -46,8 +46,23 @@ export type I18nKey =
|
||||
| "admin.audit.source.reminder_log"
|
||||
| "admin.audit.subtitle"
|
||||
| "admin.audit.title"
|
||||
| "admin.broadcasts.col.count"
|
||||
| "admin.broadcasts.col.sender"
|
||||
| "admin.broadcasts.col.sent_at"
|
||||
| "admin.broadcasts.col.subject"
|
||||
| "admin.broadcasts.detail.delivered"
|
||||
| "admin.broadcasts.detail.failed"
|
||||
| "admin.broadcasts.detail.recipients"
|
||||
| "admin.broadcasts.detail.sent_by"
|
||||
| "admin.broadcasts.empty"
|
||||
| "admin.broadcasts.heading"
|
||||
| "admin.broadcasts.loading"
|
||||
| "admin.broadcasts.subtitle"
|
||||
| "admin.broadcasts.title"
|
||||
| "admin.card.audit.desc"
|
||||
| "admin.card.audit.title"
|
||||
| "admin.card.broadcasts.desc"
|
||||
| "admin.card.broadcasts.title"
|
||||
| "admin.card.email_templates.desc"
|
||||
| "admin.card.email_templates.title"
|
||||
| "admin.card.event_types.desc"
|
||||
@@ -150,6 +165,25 @@ export type I18nKey =
|
||||
| "admin.event_types.subtitle"
|
||||
| "admin.event_types.title"
|
||||
| "admin.heading"
|
||||
| "admin.paliadin.abandon_rate"
|
||||
| "admin.paliadin.classifier_heading"
|
||||
| "admin.paliadin.col.classifier"
|
||||
| "admin.paliadin.col.count"
|
||||
| "admin.paliadin.col.duration"
|
||||
| "admin.paliadin.col.prompt"
|
||||
| "admin.paliadin.col.started"
|
||||
| "admin.paliadin.col.tools"
|
||||
| "admin.paliadin.daily_heading"
|
||||
| "admin.paliadin.heading"
|
||||
| "admin.paliadin.last7"
|
||||
| "admin.paliadin.loading"
|
||||
| "admin.paliadin.median_dur"
|
||||
| "admin.paliadin.recent_heading"
|
||||
| "admin.paliadin.subtitle"
|
||||
| "admin.paliadin.title"
|
||||
| "admin.paliadin.tool_rate"
|
||||
| "admin.paliadin.top_heading"
|
||||
| "admin.paliadin.total"
|
||||
| "admin.partner_units.action.delete"
|
||||
| "admin.partner_units.action.edit"
|
||||
| "admin.partner_units.action.members"
|
||||
@@ -515,6 +549,10 @@ export type I18nKey =
|
||||
| "checklisten.tab.templates"
|
||||
| "checklisten.title"
|
||||
| "common.cancel"
|
||||
| "common.close"
|
||||
| "common.forbidden"
|
||||
| "common.load_error"
|
||||
| "common.loading"
|
||||
| "dashboard.action.short.akte_archived"
|
||||
| "dashboard.action.short.akte_created"
|
||||
| "dashboard.action.short.appointment_approval_approved"
|
||||
@@ -1278,6 +1316,7 @@ export type I18nKey =
|
||||
| "nav.admin.audit"
|
||||
| "nav.admin.bereich"
|
||||
| "nav.admin.event_types"
|
||||
| "nav.admin.paliadin"
|
||||
| "nav.admin.partner_units"
|
||||
| "nav.admin.team"
|
||||
| "nav.agenda"
|
||||
@@ -1306,6 +1345,7 @@ export type I18nKey =
|
||||
| "nav.links"
|
||||
| "nav.logout"
|
||||
| "nav.neuigkeiten"
|
||||
| "nav.paliadin"
|
||||
| "nav.projekte"
|
||||
| "nav.soon.tooltip"
|
||||
| "nav.team"
|
||||
@@ -1380,6 +1420,17 @@ export type I18nKey =
|
||||
| "palette.footer.navigate"
|
||||
| "palette.footer.open"
|
||||
| "palette.section.actions"
|
||||
| "paliadin.empty"
|
||||
| "paliadin.heading"
|
||||
| "paliadin.input.placeholder"
|
||||
| "paliadin.reset"
|
||||
| "paliadin.send"
|
||||
| "paliadin.starter.concept"
|
||||
| "paliadin.starter.today"
|
||||
| "paliadin.starter.week"
|
||||
| "paliadin.stop"
|
||||
| "paliadin.tagline"
|
||||
| "paliadin.title"
|
||||
| "partner_unit.heading"
|
||||
| "partner_unit.members_label"
|
||||
| "partner_unit.none"
|
||||
@@ -1608,10 +1659,36 @@ export type I18nKey =
|
||||
| "search.no_results"
|
||||
| "search.placeholder"
|
||||
| "sidebar.resize.title"
|
||||
| "team.broadcast.body"
|
||||
| "team.broadcast.body_placeholder"
|
||||
| "team.broadcast.button"
|
||||
| "team.broadcast.error.body_required"
|
||||
| "team.broadcast.error.no_recipients"
|
||||
| "team.broadcast.error.subject_required"
|
||||
| "team.broadcast.error.too_many"
|
||||
| "team.broadcast.markdown_hint"
|
||||
| "team.broadcast.placeholders_hint"
|
||||
| "team.broadcast.recipients"
|
||||
| "team.broadcast.send"
|
||||
| "team.broadcast.sending"
|
||||
| "team.broadcast.sent"
|
||||
| "team.broadcast.show_all"
|
||||
| "team.broadcast.subject"
|
||||
| "team.broadcast.success"
|
||||
| "team.broadcast.template"
|
||||
| "team.broadcast.template.deadline_digest"
|
||||
| "team.broadcast.template.invitation"
|
||||
| "team.broadcast.template_freeform"
|
||||
| "team.broadcast.template_optional"
|
||||
| "team.broadcast.title"
|
||||
| "team.dept.lead"
|
||||
| "team.dept.unassigned"
|
||||
| "team.empty"
|
||||
| "team.filter.all"
|
||||
| "team.filter.project"
|
||||
| "team.filter.project.all"
|
||||
| "team.filter.project.clear"
|
||||
| "team.filter.project.selected"
|
||||
| "team.filter.role"
|
||||
| "team.group.department"
|
||||
| "team.group.office"
|
||||
|
||||
97
frontend/src/paliadin.tsx
Normal file
97
frontend/src/paliadin.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
// Paliadin chat panel page (t-paliad-146 PoC).
|
||||
//
|
||||
// Single full-page surface; m types into an input at the bottom, sees
|
||||
// a stream of bubbles above. Server-side hydration is deliberately
|
||||
// minimal — the panel boots empty, the client manages state in
|
||||
// localStorage (per design §0.5.4 session-only history).
|
||||
export function renderPaliadin(): string {
|
||||
return "<!DOCTYPE html>" + (
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#BFF355" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="paliadin.title">Paliadin — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/paliadin" />
|
||||
<BottomNav currentPath="/paliadin" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page paliadin-page">
|
||||
<div className="container paliadin-container">
|
||||
<div className="tool-header paliadin-header">
|
||||
<div>
|
||||
<h1 data-i18n="paliadin.heading">✨ Paliadin</h1>
|
||||
<p className="tool-subtitle paliadin-tagline" data-i18n="paliadin.tagline">
|
||||
Ich kenne deine Akten und Paliads Wissensbasis.
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" className="btn-secondary paliadin-reset" id="paliadin-reset"
|
||||
data-i18n="paliadin.reset">
|
||||
Neue Unterhaltung
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="paliadin-stream" id="paliadin-stream" aria-live="polite">
|
||||
<div className="paliadin-empty" id="paliadin-empty">
|
||||
<p data-i18n="paliadin.empty">Was kann ich für dich tun?</p>
|
||||
<div className="paliadin-starters" id="paliadin-starters">
|
||||
<button type="button" className="paliadin-starter"
|
||||
data-prompt-de="Was steht heute an?"
|
||||
data-prompt-en="What's on my plate today?"
|
||||
data-i18n="paliadin.starter.today">
|
||||
Was steht heute an?
|
||||
</button>
|
||||
<button type="button" className="paliadin-starter"
|
||||
data-prompt-de="Welche Fristen sind diese Woche fällig?"
|
||||
data-prompt-en="Which deadlines are due this week?"
|
||||
data-i18n="paliadin.starter.week">
|
||||
Welche Fristen sind diese Woche fällig?
|
||||
</button>
|
||||
<button type="button" className="paliadin-starter"
|
||||
data-prompt-de="Erkläre mir Klageerwiderung."
|
||||
data-prompt-en="Explain Klageerwiderung."
|
||||
data-i18n="paliadin.starter.concept">
|
||||
Erkläre mir Klageerwiderung.
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form className="paliadin-form" id="paliadin-form">
|
||||
<textarea className="paliadin-input" id="paliadin-input"
|
||||
rows={2}
|
||||
data-i18n-placeholder="paliadin.input.placeholder"
|
||||
placeholder="Frag den Paliadin…"
|
||||
required></textarea>
|
||||
<button type="submit" className="btn-primary paliadin-send" id="paliadin-send"
|
||||
data-i18n="paliadin.send">
|
||||
Senden
|
||||
</button>
|
||||
<button type="button" className="btn-secondary paliadin-stop" id="paliadin-stop"
|
||||
style="display:none"
|
||||
data-i18n="paliadin.stop">
|
||||
Stop
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<script src="/assets/paliadin.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -10593,7 +10593,7 @@ dialog.quick-add-sheet::backdrop {
|
||||
flex-direction: column;
|
||||
}
|
||||
.sidebar-views-group .sidebar-views-new {
|
||||
color: var(--text-muted);
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
.sidebar-views-group .sidebar-user-view-item {
|
||||
@@ -10621,9 +10621,10 @@ dialog.quick-add-sheet::backdrop {
|
||||
.views-onboarding {
|
||||
margin: 24px 0;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--border-subtle, rgba(0,0,0,0.08));
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
background: var(--surface-subtle, rgba(0,0,0,0.02));
|
||||
background: var(--color-surface-muted);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.views-onboarding-actions {
|
||||
margin-top: 12px;
|
||||
@@ -10634,10 +10635,13 @@ dialog.quick-add-sheet::backdrop {
|
||||
gap: 12px;
|
||||
margin: 12px 0;
|
||||
padding: 10px 14px;
|
||||
background: #fff8db;
|
||||
border: 1px solid #f3d27a;
|
||||
background: var(--color-bg-lime-tint);
|
||||
border: 1px solid var(--color-border-strong);
|
||||
border-radius: 6px;
|
||||
color: #5b4304;
|
||||
color: var(--color-text);
|
||||
}
|
||||
.views-toast[hidden] {
|
||||
display: none;
|
||||
}
|
||||
.views-toast-close {
|
||||
background: transparent;
|
||||
@@ -10669,21 +10673,22 @@ dialog.quick-add-sheet::backdrop {
|
||||
gap: 12px;
|
||||
align-items: baseline;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--border-subtle, rgba(0,0,0,0.06));
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
font-size: 14px;
|
||||
color: var(--color-text);
|
||||
}
|
||||
.views-list-row:hover {
|
||||
background: var(--surface-hover, rgba(0,0,0,0.03));
|
||||
background: var(--color-bg-subtle);
|
||||
}
|
||||
.views-list-time {
|
||||
color: var(--text-muted);
|
||||
color: var(--color-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.views-list-kind {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-muted);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.views-list-title {
|
||||
font-weight: 500;
|
||||
@@ -10691,11 +10696,11 @@ dialog.quick-add-sheet::backdrop {
|
||||
.views-list-project,
|
||||
.views-list-actor {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.views-list-subtitle {
|
||||
grid-column: 3 / -1;
|
||||
color: var(--text-muted);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@@ -10707,7 +10712,7 @@ dialog.quick-add-sheet::backdrop {
|
||||
font-size: 14px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-muted);
|
||||
color: var(--color-text-muted);
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
.views-cards-list {
|
||||
@@ -10720,9 +10725,10 @@ dialog.quick-add-sheet::backdrop {
|
||||
}
|
||||
.views-card {
|
||||
padding: 14px;
|
||||
border: 1px solid var(--border-subtle, rgba(0,0,0,0.08));
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
background: var(--surface, #fff);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.views-card-head {
|
||||
display: flex;
|
||||
@@ -10733,7 +10739,7 @@ dialog.quick-add-sheet::backdrop {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-muted);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.views-card-title {
|
||||
font-size: 16px;
|
||||
@@ -10745,17 +10751,17 @@ dialog.quick-add-sheet::backdrop {
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.views-card-meta > * + *::before {
|
||||
content: "·";
|
||||
margin-right: 8px;
|
||||
color: var(--text-muted);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.views-card-subtitle {
|
||||
margin: 8px 0 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* shape=calendar. */
|
||||
@@ -10773,7 +10779,7 @@ dialog.quick-add-sheet::backdrop {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-muted);
|
||||
color: var(--color-text-muted);
|
||||
padding: 4px;
|
||||
}
|
||||
.views-calendar-grid {
|
||||
@@ -10784,17 +10790,18 @@ dialog.quick-add-sheet::backdrop {
|
||||
.views-calendar-cell {
|
||||
min-height: 80px;
|
||||
padding: 6px;
|
||||
border: 1px solid var(--border-subtle, rgba(0,0,0,0.06));
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--surface, #fff);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.views-calendar-cell--out {
|
||||
background: transparent;
|
||||
border: 1px dashed var(--border-subtle, rgba(0,0,0,0.04));
|
||||
border: 1px dashed var(--color-border);
|
||||
}
|
||||
.views-calendar-cell-day {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.views-calendar-pills {
|
||||
@@ -10809,20 +10816,494 @@ dialog.quick-add-sheet::backdrop {
|
||||
font-size: 11px;
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
background: var(--surface-subtle, rgba(0,0,0,0.04));
|
||||
background: var(--color-surface-muted);
|
||||
color: var(--color-text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.views-calendar-pill--more {
|
||||
color: var(--text-muted);
|
||||
color: var(--color-text-muted);
|
||||
text-align: center;
|
||||
background: transparent;
|
||||
}
|
||||
.views-calendar-mobile-notice {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* === Bulk team-email broadcast (t-paliad-147) === */
|
||||
|
||||
/* Project multi-select filter on /team. */
|
||||
.team-filter-row-project {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.team-project-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.team-project-summary {
|
||||
font-weight: 500;
|
||||
}
|
||||
.team-project-panel {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
z-index: 20;
|
||||
min-width: 280px;
|
||||
max-width: 420px;
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
margin-top: 4px;
|
||||
padding: 12px;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
.team-project-panel.hidden {
|
||||
display: none;
|
||||
}
|
||||
.team-project-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 8px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.team-project-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.team-project-options .filter-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 6px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
.team-project-options .filter-checkbox:hover {
|
||||
background: var(--color-bg-muted);
|
||||
}
|
||||
.team-broadcast-wrap {
|
||||
margin: 12px 0 0 0;
|
||||
}
|
||||
.team-broadcast-count {
|
||||
display: inline-block;
|
||||
margin-left: 6px;
|
||||
padding: 1px 8px;
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Broadcast compose modal — extends .modal-overlay / .modal pattern. */
|
||||
.modal-broadcast {
|
||||
width: 720px;
|
||||
max-width: 92vw;
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.modal-broadcast .modal-body {
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
padding: 16px 20px;
|
||||
}
|
||||
.modal-broadcast label {
|
||||
display: block;
|
||||
margin-top: 12px;
|
||||
margin-bottom: 4px;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
}
|
||||
.modal-broadcast input[type="text"],
|
||||
.modal-broadcast textarea,
|
||||
.modal-broadcast select {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
}
|
||||
.modal-broadcast textarea {
|
||||
resize: vertical;
|
||||
min-height: 200px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.broadcast-recipient-summary {
|
||||
padding: 10px 12px;
|
||||
background: var(--color-bg-muted);
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.broadcast-recipient-preview {
|
||||
margin-top: 4px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.broadcast-recipient-list {
|
||||
margin-top: 8px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
.broadcast-recipient-list.hidden {
|
||||
display: none;
|
||||
}
|
||||
.broadcast-recipient-list ul {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
}
|
||||
.broadcast-recipient-list li {
|
||||
margin: 4px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
.broadcast-recip-email {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.broadcast-recip-role {
|
||||
margin-left: 6px;
|
||||
padding: 0 6px;
|
||||
background: var(--color-bg-muted);
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.broadcast-hint {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.broadcast-error {
|
||||
margin-top: 12px;
|
||||
padding: 8px 10px;
|
||||
background: rgba(220, 38, 38, 0.08);
|
||||
color: rgb(185, 28, 28);
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.broadcast-error.hidden,
|
||||
.broadcast-success.hidden {
|
||||
display: none;
|
||||
}
|
||||
.broadcast-success {
|
||||
margin-top: 12px;
|
||||
padding: 8px 10px;
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
color: rgb(21, 128, 61);
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.link-button {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
color: var(--color-link, #2563eb);
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
/* /admin/broadcasts viewer */
|
||||
.broadcasts-table td {
|
||||
vertical-align: top;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
.broadcast-detail-body {
|
||||
margin-top: 12px;
|
||||
padding: 12px 16px;
|
||||
background: var(--color-bg-muted);
|
||||
border-radius: 4px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 13px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.broadcast-detail-recipients ul {
|
||||
margin: 8px 0;
|
||||
padding-left: 18px;
|
||||
columns: 2;
|
||||
}
|
||||
.broadcast-detail-recipients li {
|
||||
break-inside: avoid;
|
||||
font-size: 13px;
|
||||
margin: 2px 0;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.broadcast-detail-recipients ul {
|
||||
columns: 1;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================================
|
||||
* t-paliad-146 — Paliadin in-app AI buddy (PoC).
|
||||
* ========================================================================== */
|
||||
|
||||
.paliadin-page {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.paliadin-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - var(--page-chrome-h, 80px));
|
||||
max-height: calc(100vh - 80px);
|
||||
}
|
||||
|
||||
.paliadin-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.paliadin-tagline {
|
||||
font-size: 0.95rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.paliadin-reset {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.paliadin-stream {
|
||||
flex: 1 1 auto;
|
||||
overflow-y: auto;
|
||||
padding: 1rem 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.paliadin-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
color: var(--color-text-muted);
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.paliadin-empty p {
|
||||
font-size: 1.2rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.paliadin-starters {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
.paliadin-starter {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text);
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.paliadin-starter:hover {
|
||||
background: var(--color-surface-hover, var(--color-surface));
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.paliadin-bubble {
|
||||
max-width: 85%;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 12px;
|
||||
line-height: 1.5;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.paliadin-bubble--user {
|
||||
align-self: flex-end;
|
||||
background: var(--color-accent-tint, #e8fbb2);
|
||||
border: 1px solid var(--color-accent);
|
||||
}
|
||||
|
||||
.paliadin-bubble--assistant {
|
||||
align-self: flex-start;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.paliadin-bubble--error {
|
||||
border-color: var(--color-status-red, #c54);
|
||||
background: var(--color-status-red-tint, #fee);
|
||||
}
|
||||
|
||||
.paliadin-bubble-role {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.paliadin-bubble-text {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.paliadin-bubble-meta {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.paliadin-chip {
|
||||
display: inline-block;
|
||||
background: var(--color-accent-tint, #e8fbb2);
|
||||
border: 1px solid var(--color-accent);
|
||||
color: var(--color-text);
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.85rem;
|
||||
text-decoration: none;
|
||||
margin: 0 0.15rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.paliadin-chip:hover {
|
||||
background: var(--color-accent);
|
||||
}
|
||||
|
||||
.paliadin-form {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 0;
|
||||
flex-shrink: 0;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.paliadin-input {
|
||||
flex: 1;
|
||||
resize: vertical;
|
||||
min-height: 2.5rem;
|
||||
max-height: 12rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
font-family: inherit;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.paliadin-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
/* /admin/paliadin dashboard. */
|
||||
|
||||
.paliadin-stat-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 0.75rem;
|
||||
margin: 1.5rem 0 2rem;
|
||||
}
|
||||
|
||||
.paliadin-stat-card {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.paliadin-stat-label {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.paliadin-stat-value {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.paliadin-classifier {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin: 1rem 0 2rem;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.paliadin-classifier-row {
|
||||
display: grid;
|
||||
grid-template-columns: 6rem 1fr 3rem;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.paliadin-classifier-label {
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.paliadin-classifier-bar {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
height: 1.25rem;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.paliadin-classifier-fill {
|
||||
height: 100%;
|
||||
background: var(--color-accent);
|
||||
}
|
||||
|
||||
.paliadin-classifier-count {
|
||||
font-family: monospace;
|
||||
text-align: right;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.paliadin-spark {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
height: 60px;
|
||||
margin: 1rem 0 2rem;
|
||||
padding: 0 0.5rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.paliadin-spark-bar {
|
||||
flex: 1;
|
||||
background: var(--color-accent);
|
||||
min-height: 1px;
|
||||
max-width: 12px;
|
||||
}
|
||||
|
||||
@@ -68,6 +68,12 @@ export function renderTeam(): string {
|
||||
<button className="filter-pill active" data-role="all" type="button" data-i18n="team.filter.all">Alle</button>
|
||||
</div>
|
||||
|
||||
<div className="team-filter-row team-filter-row-project" id="team-project-filter" aria-label="Projekt">
|
||||
</div>
|
||||
|
||||
<div className="team-broadcast-wrap" id="team-broadcast-wrap" style="display:none">
|
||||
</div>
|
||||
|
||||
<div className="team-list" id="team-list" />
|
||||
|
||||
<div className="glossar-empty" id="team-empty" style="display:none">
|
||||
|
||||
Reference in New Issue
Block a user