m/paliad#61 Slice B frontend pass. Detail page (/checklists/{slug}) gains: - Provenance line ("Erstellt von <author>") for authored templates, populated from the catalog response's owner_display_name. - Owner action buttons: Bearbeiten (links to /checklists/templates/{slug}/edit per the Slice A hotfix), Teilen, Löschen. Reveal driven by /api/me email match against the catalog response's owner_email. - global_admin action buttons: "Als Firmen-Vorlage hinterlegen" (promote) when visibility != 'global'; "Aus Katalog entfernen" (demote) when visibility == 'global'. Reveal driven by /api/me global_role. Share modal: - Single modal with a kind-picker (Kollege / Office / Dezernat / Projekt) and a matching select per kind — sections toggle on the active kind. - Recipient pickers populated from /api/users, /api/partner-units, /api/projects (loaded in parallel on open). Office options use the canonical 8-key set from internal/offices. - Existing grants surface in a list under the form with per-row Entfernen buttons; Revoke confirms before DELETE. - Errors surface inline (recipient-required, generic share failure). i18n: 32 new keys per language (DE+EN) under checklisten.share.* and checklisten.detail.promote/demote/delete.*. Total 2653 keys. Build hygiene: go build/vet/test ./internal/... + ./cmd/server/ all green; bun run build clean.
722 lines
25 KiB
TypeScript
722 lines
25 KiB
TypeScript
import { initI18n, onLangChange, t, getLang } from "./i18n";
|
|
import { initSidebar } from "./sidebar";
|
|
import { projectIndent } from "./project-indent";
|
|
|
|
interface ChecklistItem {
|
|
labelDE: string;
|
|
labelEN: string;
|
|
noteDE?: string;
|
|
noteEN?: string;
|
|
rule?: string;
|
|
}
|
|
|
|
interface ChecklistGroup {
|
|
titleDE: string;
|
|
titleEN: string;
|
|
items: ChecklistItem[];
|
|
}
|
|
|
|
interface Checklist {
|
|
slug: string;
|
|
titleDE: string;
|
|
titleEN: string;
|
|
descriptionDE: string;
|
|
descriptionEN: string;
|
|
regime: string;
|
|
courtDE: string;
|
|
courtEN: string;
|
|
deadlineDE?: string;
|
|
deadlineEN?: string;
|
|
referenceDE?: string;
|
|
referenceEN?: string;
|
|
groups: ChecklistGroup[];
|
|
// Slice B fields — present on authored entries via the merged
|
|
// catalog response. 'static' templates don't carry these.
|
|
origin?: "static" | "authored";
|
|
visibility?: string;
|
|
owner_email?: string;
|
|
owner_display_name?: string;
|
|
}
|
|
|
|
interface Me {
|
|
id: string;
|
|
email: string;
|
|
display_name: string;
|
|
global_role?: string;
|
|
}
|
|
|
|
interface UserSummary {
|
|
id: string;
|
|
email: string;
|
|
display_name: string;
|
|
}
|
|
|
|
interface PartnerUnit {
|
|
id: string;
|
|
name: string;
|
|
}
|
|
|
|
interface Share {
|
|
id: string;
|
|
checklist_id: string;
|
|
recipient_kind: "user" | "office" | "partner_unit" | "project";
|
|
recipient_label: string;
|
|
}
|
|
|
|
interface ChecklistInstance {
|
|
id: string;
|
|
template_slug: string;
|
|
name: string;
|
|
project_id?: string | null;
|
|
state: Record<string, boolean>;
|
|
created_by: string;
|
|
created_at: string;
|
|
updated_at: string;
|
|
project_reference?: string | null;
|
|
project_title?: string | null;
|
|
}
|
|
|
|
interface AkteSummary {
|
|
id: string;
|
|
reference?: string | null;
|
|
title: string;
|
|
path: string;
|
|
}
|
|
|
|
let template: Checklist | null = null;
|
|
let instances: ChecklistInstance[] = [];
|
|
let projects: AkteSummary[] = [];
|
|
let totalItems = 0;
|
|
|
|
function esc(s: string): string {
|
|
const d = document.createElement("div");
|
|
d.textContent = s;
|
|
return d.innerHTML;
|
|
}
|
|
|
|
function templateSlug(): string {
|
|
// /checklisten/{slug}
|
|
const parts = window.location.pathname.split("/").filter(Boolean);
|
|
return parts[1] ?? "";
|
|
}
|
|
|
|
async function loadTemplate() {
|
|
const slug = templateSlug();
|
|
const resp = await fetch(`/api/checklists/${encodeURIComponent(slug)}`);
|
|
if (!resp.ok) {
|
|
document.title = "404 — Paliad";
|
|
document.getElementById("checklist-title")!.textContent = t("checklisten.notfound");
|
|
return;
|
|
}
|
|
template = await resp.json();
|
|
if (template) {
|
|
totalItems = template.groups.reduce((n, g) => n + g.items.length, 0);
|
|
}
|
|
renderHeader();
|
|
}
|
|
|
|
async function loadInstances() {
|
|
const slug = templateSlug();
|
|
try {
|
|
const resp = await fetch(`/api/checklists/${encodeURIComponent(slug)}/instances`);
|
|
if (!resp.ok) {
|
|
instances = [];
|
|
} else {
|
|
instances = await resp.json();
|
|
}
|
|
} catch {
|
|
instances = [];
|
|
}
|
|
renderInstances();
|
|
}
|
|
|
|
async function loadAkten() {
|
|
try {
|
|
const resp = await fetch("/api/projects");
|
|
if (resp.ok) projects = await resp.json();
|
|
} catch {
|
|
projects = [];
|
|
}
|
|
renderAkteOptions();
|
|
}
|
|
|
|
function renderHeader() {
|
|
if (!template) return;
|
|
const isEN = getLang() === "en";
|
|
const title = isEN ? template.titleEN : template.titleDE;
|
|
const desc = isEN ? template.descriptionEN : template.descriptionDE;
|
|
const court = isEN ? template.courtEN : template.courtDE;
|
|
const deadline = isEN ? template.deadlineEN : template.deadlineDE;
|
|
const reference = isEN ? template.referenceEN : template.referenceDE;
|
|
|
|
document.title = `${title} — Paliad`;
|
|
document.getElementById("checklist-title")!.textContent = title;
|
|
document.getElementById("checklist-subtitle")!.textContent = desc;
|
|
|
|
const courtLabel = isEN ? "Court / Authority" : "Gericht / Behörde";
|
|
const deadlineLabel = isEN ? "Deadline" : "Deadline";
|
|
const refLabel = isEN ? "Reference" : "Rechtsgrundlage";
|
|
const regimeLabel = isEN ? "Regime" : "Bereich";
|
|
const itemsLabel = isEN ? "Items" : "Punkte";
|
|
|
|
const parts: string[] = [];
|
|
parts.push(`<div class="checklist-meta-item"><dt>${regimeLabel}</dt><dd><span class="checklist-regime checklist-regime-${esc(template.regime)}">${esc(template.regime)}</span></dd></div>`);
|
|
parts.push(`<div class="checklist-meta-item"><dt>${courtLabel}</dt><dd>${esc(court)}</dd></div>`);
|
|
if (deadline) {
|
|
parts.push(`<div class="checklist-meta-item"><dt>${deadlineLabel}</dt><dd>${esc(deadline)}</dd></div>`);
|
|
}
|
|
if (reference) {
|
|
parts.push(`<div class="checklist-meta-item"><dt>${refLabel}</dt><dd>${esc(reference)}</dd></div>`);
|
|
}
|
|
parts.push(`<div class="checklist-meta-item"><dt>${itemsLabel}</dt><dd>${totalItems}</dd></div>`);
|
|
document.getElementById("checklist-meta")!.innerHTML = parts.join("");
|
|
}
|
|
|
|
function progress(inst: ChecklistInstance): { done: number; pct: number } {
|
|
const done = Object.values(inst.state || {}).filter(Boolean).length;
|
|
const pct = totalItems === 0 ? 0 : Math.round((done / totalItems) * 100);
|
|
return { done, pct };
|
|
}
|
|
|
|
function formatDate(iso: string): string {
|
|
const d = new Date(iso);
|
|
if (isNaN(d.getTime())) return "";
|
|
const lang = getLang();
|
|
return d.toLocaleDateString(lang === "en" ? "en-GB" : "de-DE", {
|
|
year: "numeric", month: "2-digit", day: "2-digit",
|
|
});
|
|
}
|
|
|
|
function renderInstances() {
|
|
const loading = document.getElementById("instances-loading")!;
|
|
const empty = document.getElementById("instances-empty")!;
|
|
const wrap = document.getElementById("instances-tablewrap")!;
|
|
const body = document.getElementById("instances-body")!;
|
|
|
|
loading.style.display = "none";
|
|
|
|
if (instances.length === 0) {
|
|
empty.style.display = "";
|
|
wrap.style.display = "none";
|
|
return;
|
|
}
|
|
empty.style.display = "none";
|
|
wrap.style.display = "";
|
|
|
|
const isEN = getLang() === "en";
|
|
const deleteLabel = isEN ? "Delete" : "Löschen";
|
|
const openLabel = isEN ? "Öffnen" : "Öffnen";
|
|
const personalLabel = isEN ? "personal" : "persönlich";
|
|
|
|
body.innerHTML = instances.map((inst) => {
|
|
const { done, pct } = progress(inst);
|
|
const akteCell = inst.project_id && inst.project_reference
|
|
? `<a href="/projects/${esc(inst.project_id)}" class="checklist-instance-project-link">${esc(inst.project_reference)}</a>`
|
|
: `<span class="entity-muted">${personalLabel}</span>`;
|
|
return `<tr data-id="${esc(inst.id)}" class="checklist-instance-row">
|
|
<td><a href="/checklists/instances/${esc(inst.id)}" class="checklist-instance-name">${esc(inst.name)}</a></td>
|
|
<td>
|
|
<div class="checklist-progress-inline">
|
|
<div class="checklist-progress-bar">
|
|
<div class="checklist-progress-fill" style="width:${pct}%"></div>
|
|
</div>
|
|
<span class="checklist-progress-label">${done} / ${totalItems}</span>
|
|
</div>
|
|
</td>
|
|
<td>${akteCell}</td>
|
|
<td>${esc(formatDate(inst.created_at))}</td>
|
|
<td class="checklist-instance-actions">
|
|
<a class="btn-small btn-ghost" href="/checklists/instances/${esc(inst.id)}">${esc(openLabel)}</a>
|
|
<button type="button" class="btn-small btn-ghost btn-delete-instance" data-id="${esc(inst.id)}" data-name="${esc(inst.name)}">${esc(deleteLabel)}</button>
|
|
</td>
|
|
</tr>`;
|
|
}).join("");
|
|
|
|
body.querySelectorAll<HTMLButtonElement>(".btn-delete-instance").forEach((btn) => {
|
|
btn.addEventListener("click", (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const id = btn.dataset.id!;
|
|
const name = btn.dataset.name ?? "";
|
|
const msg = (t("checklisten.instances.delete.confirm") || "").replace("{name}", name);
|
|
if (!confirm(msg)) return;
|
|
void deleteInstance(id);
|
|
});
|
|
});
|
|
|
|
body.querySelectorAll<HTMLTableRowElement>(".checklist-instance-row").forEach((row) => {
|
|
const id = row.dataset.id!;
|
|
row.addEventListener("click", (e) => {
|
|
const target = e.target as HTMLElement;
|
|
if (target.closest("a") || target.closest("button")) return;
|
|
window.location.href = `/checklists/instances/${id}`;
|
|
});
|
|
});
|
|
}
|
|
|
|
function renderAkteOptions() {
|
|
const sel = document.getElementById("new-instance-project") as HTMLSelectElement;
|
|
if (!sel) return;
|
|
const none = sel.querySelector('option[value=""]');
|
|
sel.innerHTML = "";
|
|
if (none) sel.appendChild(none);
|
|
projects.forEach((a) => {
|
|
const opt = document.createElement("option");
|
|
opt.value = a.id;
|
|
opt.textContent = `${projectIndent(a.path)}${a.reference || ""} — ${a.title}`;
|
|
sel.appendChild(opt);
|
|
});
|
|
}
|
|
|
|
function initNewInstance() {
|
|
const modal = document.getElementById("new-instance-modal")!;
|
|
const form = document.getElementById("new-instance-form")! as HTMLFormElement;
|
|
const msg = document.getElementById("new-instance-msg")!;
|
|
const nameInput = document.getElementById("new-instance-name") as HTMLInputElement;
|
|
const akteSel = document.getElementById("new-instance-project") as HTMLSelectElement;
|
|
|
|
const open = () => {
|
|
msg.textContent = "";
|
|
msg.className = "form-msg";
|
|
nameInput.value = "";
|
|
akteSel.value = "";
|
|
modal.style.display = "flex";
|
|
nameInput.focus();
|
|
};
|
|
const close = () => { modal.style.display = "none"; };
|
|
|
|
document.getElementById("btn-new-instance")!.addEventListener("click", open);
|
|
document.getElementById("new-instance-close")!.addEventListener("click", close);
|
|
document.getElementById("new-instance-cancel")!.addEventListener("click", close);
|
|
modal.addEventListener("click", (e) => { if (e.target === e.currentTarget) close(); });
|
|
|
|
form.addEventListener("submit", async (e) => {
|
|
e.preventDefault();
|
|
const name = nameInput.value.trim();
|
|
if (!name) {
|
|
msg.textContent = t("checklisten.newInstance.error.name");
|
|
msg.className = "form-msg form-msg-error";
|
|
return;
|
|
}
|
|
const akteID = akteSel.value || null;
|
|
const payload: { name: string; project_id?: string } = { name };
|
|
if (akteID) payload.project_id = akteID;
|
|
|
|
const slug = templateSlug();
|
|
const submitBtn = form.querySelector(".btn-primary") as HTMLButtonElement;
|
|
submitBtn.disabled = true;
|
|
try {
|
|
const resp = await fetch(`/api/checklists/${encodeURIComponent(slug)}/instances`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
if (!resp.ok) {
|
|
msg.textContent = t("checklisten.newInstance.error.generic");
|
|
msg.className = "form-msg form-msg-error";
|
|
return;
|
|
}
|
|
const created = await resp.json() as ChecklistInstance;
|
|
window.location.href = `/checklists/instances/${encodeURIComponent(created.id)}`;
|
|
} catch {
|
|
msg.textContent = t("checklisten.newInstance.error.generic");
|
|
msg.className = "form-msg form-msg-error";
|
|
} finally {
|
|
submitBtn.disabled = false;
|
|
}
|
|
});
|
|
}
|
|
|
|
async function deleteInstance(id: string) {
|
|
try {
|
|
const resp = await fetch(`/api/checklist-instances/${encodeURIComponent(id)}`, { method: "DELETE" });
|
|
if (!resp.ok && resp.status !== 204) {
|
|
alert(t("checklisten.instances.delete.error"));
|
|
return;
|
|
}
|
|
instances = instances.filter((i) => i.id !== id);
|
|
renderInstances();
|
|
} catch {
|
|
alert(t("checklisten.instances.delete.error"));
|
|
}
|
|
}
|
|
|
|
function initFeedback() {
|
|
const modal = document.getElementById("feedback-modal")!;
|
|
const form = document.getElementById("feedback-form")!;
|
|
const msg = document.getElementById("feedback-msg")!;
|
|
|
|
document.getElementById("btn-feedback")!.addEventListener("click", () => {
|
|
msg.textContent = "";
|
|
msg.className = "form-msg";
|
|
modal.style.display = "flex";
|
|
});
|
|
|
|
document.getElementById("modal-close")!.addEventListener("click", () => { modal.style.display = "none"; });
|
|
document.getElementById("modal-cancel")!.addEventListener("click", () => { modal.style.display = "none"; });
|
|
modal.addEventListener("click", (e) => { if (e.target === e.currentTarget) modal.style.display = "none"; });
|
|
|
|
form.addEventListener("submit", async (e) => {
|
|
e.preventDefault();
|
|
if (!template) return;
|
|
const submitBtn = form.querySelector(".btn-submit") as HTMLButtonElement;
|
|
const payload = {
|
|
feedback_type: (document.getElementById("feedback-type") as HTMLSelectElement).value,
|
|
checklist: template.slug,
|
|
message: (document.getElementById("feedback-message") as HTMLTextAreaElement).value.trim(),
|
|
};
|
|
|
|
if (!payload.message) {
|
|
msg.textContent = t("checklisten.feedback.error.required");
|
|
msg.className = "form-msg form-msg-error";
|
|
return;
|
|
}
|
|
|
|
submitBtn.disabled = true;
|
|
try {
|
|
const resp = await fetch("/api/checklists/feedback", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
if (!resp.ok) {
|
|
msg.textContent = t("checklisten.feedback.error.generic");
|
|
msg.className = "form-msg form-msg-error";
|
|
return;
|
|
}
|
|
msg.textContent = t("checklisten.feedback.success");
|
|
msg.className = "form-msg form-msg-success";
|
|
(document.getElementById("feedback-message") as HTMLTextAreaElement).value = "";
|
|
setTimeout(() => { modal.style.display = "none"; }, 1500);
|
|
} catch {
|
|
msg.textContent = t("checklisten.feedback.error.generic");
|
|
msg.className = "form-msg form-msg-error";
|
|
} finally {
|
|
submitBtn.disabled = false;
|
|
}
|
|
});
|
|
}
|
|
|
|
function rerenderAll() {
|
|
renderHeader();
|
|
renderInstances();
|
|
}
|
|
|
|
// --- Slice B: owner actions + admin promote + share modal ----------------
|
|
|
|
let me: Me | null = null;
|
|
let isOwner = false;
|
|
let isAdmin = false;
|
|
let shareUsers: UserSummary[] = [];
|
|
let sharePartnerUnits: PartnerUnit[] = [];
|
|
let shareProjects: AkteSummary[] = [];
|
|
let activeShareKind: "user" | "office" | "partner_unit" | "project" = "user";
|
|
|
|
async function loadMe(): Promise<Me | null> {
|
|
try {
|
|
const resp = await fetch("/api/me");
|
|
if (!resp.ok) return null;
|
|
return await resp.json();
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function templateOriginInfo() {
|
|
return template as unknown as {
|
|
origin?: string;
|
|
visibility?: string;
|
|
owner_email?: string;
|
|
owner_display_name?: string;
|
|
} | null;
|
|
}
|
|
|
|
function applyOwnerControls() {
|
|
const info = templateOriginInfo();
|
|
const isAuthored = info?.origin === "authored";
|
|
const provenance = document.getElementById("checklist-provenance")!;
|
|
if (isAuthored && info?.owner_display_name) {
|
|
provenance.style.display = "";
|
|
provenance.textContent = t("checklisten.detail.authored.by").replace("{author}", info.owner_display_name);
|
|
} else {
|
|
provenance.style.display = "none";
|
|
}
|
|
|
|
isOwner = !!(isAuthored && me && info?.owner_email && me.email.toLowerCase() === info.owner_email.toLowerCase());
|
|
isAdmin = !!(me && me.global_role === "global_admin");
|
|
const ownerOnly = (id: string, show: boolean) => {
|
|
const el = document.getElementById(id);
|
|
if (el) (el as HTMLElement).style.display = show ? "" : "none";
|
|
};
|
|
if (template) {
|
|
(document.getElementById("btn-edit-template") as HTMLAnchorElement | null)?.setAttribute(
|
|
"href",
|
|
`/checklists/templates/${encodeURIComponent(template.slug)}/edit`,
|
|
);
|
|
}
|
|
ownerOnly("btn-edit-template", isOwner);
|
|
ownerOnly("btn-share-template", isOwner);
|
|
ownerOnly("btn-delete-template", isOwner);
|
|
|
|
// Admin promote/demote — only when an authored template is visible to
|
|
// an admin, and only the appropriate one for the current visibility.
|
|
if (isAuthored && isAdmin) {
|
|
const isGlobal = info?.visibility === "global";
|
|
ownerOnly("btn-promote-template", !isGlobal);
|
|
ownerOnly("btn-demote-template", isGlobal);
|
|
} else {
|
|
ownerOnly("btn-promote-template", false);
|
|
ownerOnly("btn-demote-template", false);
|
|
}
|
|
}
|
|
|
|
function initOwnerActions() {
|
|
document.getElementById("btn-delete-template")?.addEventListener("click", async () => {
|
|
if (!template) return;
|
|
const isEN = getLang() === "en";
|
|
const title = isEN ? template.titleEN : template.titleDE;
|
|
const msg = t("checklisten.detail.delete.confirm").replace("{title}", title);
|
|
if (!window.confirm(msg)) return;
|
|
const resp = await fetch(`/api/checklists/templates/${encodeURIComponent(template.slug)}`, { method: "DELETE" });
|
|
if (!resp.ok) {
|
|
window.alert(t("checklisten.detail.delete.error"));
|
|
return;
|
|
}
|
|
window.location.href = "/checklists?tab=mine";
|
|
});
|
|
|
|
document.getElementById("btn-promote-template")?.addEventListener("click", async () => {
|
|
if (!template) return;
|
|
if (!window.confirm(t("checklisten.detail.promote.confirm"))) return;
|
|
const resp = await fetch(`/api/admin/checklists/${encodeURIComponent(template.slug)}/promote`, { method: "POST" });
|
|
if (!resp.ok) {
|
|
window.alert(t("checklisten.detail.promote.error"));
|
|
return;
|
|
}
|
|
window.location.reload();
|
|
});
|
|
|
|
document.getElementById("btn-demote-template")?.addEventListener("click", async () => {
|
|
if (!template) return;
|
|
if (!window.confirm(t("checklisten.detail.demote.confirm"))) return;
|
|
const resp = await fetch(`/api/admin/checklists/${encodeURIComponent(template.slug)}/demote`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ target: "firm" }),
|
|
});
|
|
if (!resp.ok) {
|
|
window.alert(t("checklisten.detail.promote.error"));
|
|
return;
|
|
}
|
|
window.location.reload();
|
|
});
|
|
}
|
|
|
|
async function loadSharePickerData() {
|
|
// Fire all three lookups in parallel — the share modal needs all of
|
|
// them but doesn't depend on their order.
|
|
try {
|
|
const [usersResp, unitsResp, projectsResp] = await Promise.all([
|
|
fetch("/api/users"),
|
|
fetch("/api/partner-units"),
|
|
fetch("/api/projects"),
|
|
]);
|
|
shareUsers = usersResp.ok ? await usersResp.json() : [];
|
|
sharePartnerUnits = unitsResp.ok ? await unitsResp.json() : [];
|
|
shareProjects = projectsResp.ok ? await projectsResp.json() : [];
|
|
} catch {
|
|
/* leave whatever loaded */
|
|
}
|
|
populateSharePickerOptions();
|
|
}
|
|
|
|
function populateSharePickerOptions() {
|
|
const userSel = document.getElementById("share-user") as HTMLSelectElement;
|
|
if (userSel) {
|
|
userSel.innerHTML = `<option value="">${esc(t("checklisten.share.pick"))}</option>`;
|
|
shareUsers
|
|
.slice()
|
|
.sort((a, b) => a.display_name.localeCompare(b.display_name))
|
|
.forEach((u) => {
|
|
if (me && u.id === me.id) return; // can't share with self
|
|
const opt = document.createElement("option");
|
|
opt.value = u.id;
|
|
opt.textContent = `${u.display_name} (${u.email})`;
|
|
userSel.appendChild(opt);
|
|
});
|
|
}
|
|
const officeSel = document.getElementById("share-office") as HTMLSelectElement;
|
|
if (officeSel) {
|
|
const officeKeys = ["munich", "duesseldorf", "hamburg", "amsterdam", "london", "paris", "milan", "madrid"];
|
|
officeSel.innerHTML = `<option value="">${esc(t("checklisten.share.pick"))}</option>`;
|
|
officeKeys.forEach((k) => {
|
|
const opt = document.createElement("option");
|
|
opt.value = k;
|
|
opt.textContent = k.charAt(0).toUpperCase() + k.slice(1);
|
|
officeSel.appendChild(opt);
|
|
});
|
|
}
|
|
const puSel = document.getElementById("share-partner-unit") as HTMLSelectElement;
|
|
if (puSel) {
|
|
puSel.innerHTML = `<option value="">${esc(t("checklisten.share.pick"))}</option>`;
|
|
sharePartnerUnits
|
|
.slice()
|
|
.sort((a, b) => a.name.localeCompare(b.name))
|
|
.forEach((u) => {
|
|
const opt = document.createElement("option");
|
|
opt.value = u.id;
|
|
opt.textContent = u.name;
|
|
puSel.appendChild(opt);
|
|
});
|
|
}
|
|
const prSel = document.getElementById("share-project") as HTMLSelectElement;
|
|
if (prSel) {
|
|
prSel.innerHTML = `<option value="">${esc(t("checklisten.share.pick"))}</option>`;
|
|
shareProjects
|
|
.slice()
|
|
.sort((a, b) => (a.reference || a.title).localeCompare(b.reference || b.title))
|
|
.forEach((p) => {
|
|
const opt = document.createElement("option");
|
|
opt.value = p.id;
|
|
opt.textContent = `${p.reference || ""} — ${p.title}`;
|
|
prSel.appendChild(opt);
|
|
});
|
|
}
|
|
}
|
|
|
|
function switchShareKind(kind: "user" | "office" | "partner_unit" | "project") {
|
|
activeShareKind = kind;
|
|
document.querySelectorAll<HTMLButtonElement>("#share-kind-pills .filter-pill").forEach((p) => {
|
|
p.classList.toggle("active", p.dataset.kind === kind);
|
|
});
|
|
document.querySelectorAll<HTMLElement>(".share-kind-section").forEach((s) => {
|
|
s.style.display = s.dataset.kind === kind ? "" : "none";
|
|
});
|
|
}
|
|
|
|
function initShareModal() {
|
|
const modal = document.getElementById("share-modal")!;
|
|
const msg = document.getElementById("share-msg")!;
|
|
const close = () => { modal.style.display = "none"; };
|
|
|
|
document.getElementById("btn-share-template")?.addEventListener("click", async () => {
|
|
if (!template) return;
|
|
msg.textContent = "";
|
|
msg.className = "form-msg";
|
|
switchShareKind("user");
|
|
modal.style.display = "flex";
|
|
await loadSharePickerData();
|
|
await renderGrants();
|
|
});
|
|
|
|
document.getElementById("share-close")?.addEventListener("click", close);
|
|
document.getElementById("share-cancel")?.addEventListener("click", close);
|
|
modal.addEventListener("click", (e) => { if (e.target === e.currentTarget) close(); });
|
|
|
|
document.getElementById("share-kind-pills")?.addEventListener("click", (e) => {
|
|
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>(".filter-pill[data-kind]");
|
|
if (!btn) return;
|
|
switchShareKind(btn.dataset.kind as typeof activeShareKind);
|
|
});
|
|
|
|
document.getElementById("share-submit")?.addEventListener("click", async () => {
|
|
if (!template) return;
|
|
const input: Record<string, unknown> = { recipient_kind: activeShareKind };
|
|
switch (activeShareKind) {
|
|
case "user": {
|
|
const v = (document.getElementById("share-user") as HTMLSelectElement).value;
|
|
if (!v) { msg.textContent = t("checklisten.share.error.pick"); msg.className = "form-msg form-msg-error"; return; }
|
|
input["recipient_user_id"] = v;
|
|
break;
|
|
}
|
|
case "office": {
|
|
const v = (document.getElementById("share-office") as HTMLSelectElement).value;
|
|
if (!v) { msg.textContent = t("checklisten.share.error.pick"); msg.className = "form-msg form-msg-error"; return; }
|
|
input["recipient_office"] = v;
|
|
break;
|
|
}
|
|
case "partner_unit": {
|
|
const v = (document.getElementById("share-partner-unit") as HTMLSelectElement).value;
|
|
if (!v) { msg.textContent = t("checklisten.share.error.pick"); msg.className = "form-msg form-msg-error"; return; }
|
|
input["recipient_partner_unit_id"] = v;
|
|
break;
|
|
}
|
|
case "project": {
|
|
const v = (document.getElementById("share-project") as HTMLSelectElement).value;
|
|
if (!v) { msg.textContent = t("checklisten.share.error.pick"); msg.className = "form-msg form-msg-error"; return; }
|
|
input["recipient_project_id"] = v;
|
|
break;
|
|
}
|
|
}
|
|
const resp = await fetch(`/api/checklists/templates/${encodeURIComponent(template.slug)}/shares`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(input),
|
|
});
|
|
if (!resp.ok) {
|
|
let errMsg = t("checklisten.share.error.generic");
|
|
try {
|
|
const j = await resp.json();
|
|
if (j?.error) errMsg = j.error;
|
|
} catch { /* keep generic */ }
|
|
msg.textContent = errMsg;
|
|
msg.className = "form-msg form-msg-error";
|
|
return;
|
|
}
|
|
msg.textContent = t("checklisten.share.success");
|
|
msg.className = "form-msg form-msg-success";
|
|
await renderGrants();
|
|
});
|
|
}
|
|
|
|
async function renderGrants() {
|
|
if (!template) return;
|
|
const list = document.getElementById("share-grants-list")!;
|
|
const empty = document.getElementById("share-grants-empty")!;
|
|
const resp = await fetch(`/api/checklists/templates/${encodeURIComponent(template.slug)}/shares`);
|
|
const rows: Share[] = resp.ok ? await resp.json() : [];
|
|
if (rows.length === 0) {
|
|
list.innerHTML = "";
|
|
list.appendChild(empty);
|
|
empty.style.display = "";
|
|
return;
|
|
}
|
|
empty.style.display = "none";
|
|
list.innerHTML = rows.map((s) => {
|
|
const kindLabel = esc(t(("checklisten.share.grants.recipient." + s.recipient_kind) as never) || s.recipient_kind);
|
|
return `<li class="share-grant-row" data-id="${esc(s.id)}">
|
|
<span class="share-grant-kind">${kindLabel}</span>
|
|
<span class="share-grant-label">${esc(s.recipient_label || "")}</span>
|
|
<button type="button" class="btn-small btn-ghost" data-action="revoke" data-id="${esc(s.id)}">${esc(t("checklisten.share.grants.revoke"))}</button>
|
|
</li>`;
|
|
}).join("");
|
|
list.querySelectorAll<HTMLButtonElement>("button[data-action=revoke]").forEach((btn) => {
|
|
btn.addEventListener("click", async () => {
|
|
if (!window.confirm(t("checklisten.share.grants.revoke.confirm"))) return;
|
|
const resp = await fetch(`/api/checklists/shares/${encodeURIComponent(btn.dataset.id!)}`, { method: "DELETE" });
|
|
if (!resp.ok && resp.status !== 204) {
|
|
window.alert(t("checklisten.share.grants.revoke.error"));
|
|
return;
|
|
}
|
|
await renderGrants();
|
|
});
|
|
});
|
|
}
|
|
|
|
document.addEventListener("DOMContentLoaded", () => {
|
|
initI18n();
|
|
initSidebar();
|
|
initNewInstance();
|
|
initFeedback();
|
|
initOwnerActions();
|
|
initShareModal();
|
|
onLangChange(rerenderAll);
|
|
void (async () => {
|
|
me = await loadMe();
|
|
await loadTemplate();
|
|
applyOwnerControls();
|
|
})();
|
|
void loadInstances();
|
|
void loadAkten();
|
|
});
|