feat(checklists): t-paliad-225 Slice B frontend — share modal + admin promote/demote on detail page

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.
This commit is contained in:
mAi
2026-05-20 15:38:43 +02:00
parent c3cd51eb85
commit a93277a072
4 changed files with 501 additions and 1 deletions

View File

@@ -39,12 +39,28 @@ export function renderChecklistsDetail(): string {
<div> <div>
<h1 id="checklist-title">&nbsp;</h1> <h1 id="checklist-title">&nbsp;</h1>
<p className="tool-subtitle" id="checklist-subtitle">&nbsp;</p> <p className="tool-subtitle" id="checklist-subtitle">&nbsp;</p>
{/* Provenance line — visible only for authored
templates; populated by the client from the
catalog response's owner_display_name. */}
<p className="checklist-provenance" id="checklist-provenance" style="display:none" />
<dl className="checklist-meta" id="checklist-meta" /> <dl className="checklist-meta" id="checklist-meta" />
</div> </div>
<div className="checklist-actions"> <div className="checklist-actions">
<button type="button" id="btn-new-instance" className="btn-primary btn-cta-lime" data-i18n="checklisten.newInstance"> <button type="button" id="btn-new-instance" className="btn-primary btn-cta-lime" data-i18n="checklisten.newInstance">
Neue Instanz Neue Instanz
</button> </button>
{/* Owner controls (Slice B) — toggled on by the
client once /api/checklists/{slug} returns
origin='authored' AND owner_email matches the
logged-in user. Kept hidden by default so
guests / non-owners never see them. */}
<a id="btn-edit-template" className="btn-cta-lime btn-outline" style="display:none" data-i18n="checklisten.detail.edit">Bearbeiten</a>
<button type="button" id="btn-share-template" className="btn-cta-lime btn-outline" style="display:none" data-i18n="checklisten.detail.share">Teilen</button>
<button type="button" id="btn-delete-template" className="btn-cta-lime btn-outline" style="display:none" data-i18n="checklisten.mine.delete">L&ouml;schen</button>
{/* global_admin controls — revealed by the client
when /api/me reports global_role='global_admin'. */}
<button type="button" id="btn-promote-template" className="btn-cta-lime btn-outline" style="display:none" data-i18n="checklisten.detail.promote">Als Firmen-Vorlage hinterlegen</button>
<button type="button" id="btn-demote-template" className="btn-cta-lime btn-outline" style="display:none" data-i18n="checklisten.detail.demote">Aus Katalog entfernen</button>
<button type="button" id="btn-feedback" className="btn-cta-lime btn-outline"> <button type="button" id="btn-feedback" className="btn-cta-lime btn-outline">
<span data-i18n="checklisten.feedback.btn">Feedback</span> <span data-i18n="checklisten.feedback.btn">Feedback</span>
</button> </button>
@@ -122,6 +138,65 @@ export function renderChecklistsDetail(): string {
</div> </div>
</div> </div>
{/* Share modal (Slice B) — owner-only, hidden until btn-share-template
opens it. Four recipient kinds in a single modal: pick the kind,
then the matching entity (user / office / partner_unit / project). */}
<div className="modal-overlay" id="share-modal" style="display:none">
<div className="modal-card modal-card-wide">
<div className="modal-header">
<h2 data-i18n="checklisten.share.title">Vorlage teilen</h2>
<button className="modal-close" id="share-close" type="button">&times;</button>
</div>
<div className="form-field">
<label data-i18n="checklisten.share.kind">Empf&auml;ngertyp</label>
<div className="filter-pills" id="share-kind-pills">
<button type="button" className="filter-pill active" data-kind="user" data-i18n="checklisten.share.kind.user">Kollege</button>
<button type="button" className="filter-pill" data-kind="office" data-i18n="checklisten.share.kind.office">Office</button>
<button type="button" className="filter-pill" data-kind="partner_unit" data-i18n="checklisten.share.kind.partner_unit">Dezernat</button>
<button type="button" className="filter-pill" data-kind="project" data-i18n="checklisten.share.kind.project">Projekt</button>
</div>
</div>
<div className="form-field share-kind-section" data-kind="user">
<label htmlFor="share-user" data-i18n="checklisten.share.kind.user">Kollege</label>
<select id="share-user">
<option value="" data-i18n="checklisten.share.pick">&mdash; ausw&auml;hlen &mdash;</option>
</select>
</div>
<div className="form-field share-kind-section" data-kind="office" style="display:none">
<label htmlFor="share-office" data-i18n="checklisten.share.kind.office">Office</label>
<select id="share-office">
<option value="" data-i18n="checklisten.share.pick">&mdash; ausw&auml;hlen &mdash;</option>
</select>
</div>
<div className="form-field share-kind-section" data-kind="partner_unit" style="display:none">
<label htmlFor="share-partner-unit" data-i18n="checklisten.share.kind.partner_unit">Dezernat</label>
<select id="share-partner-unit">
<option value="" data-i18n="checklisten.share.pick">&mdash; ausw&auml;hlen &mdash;</option>
</select>
</div>
<div className="form-field share-kind-section" data-kind="project" style="display:none">
<label htmlFor="share-project" data-i18n="checklisten.share.kind.project">Projekt</label>
<select id="share-project">
<option value="" data-i18n="checklisten.share.pick">&mdash; ausw&auml;hlen &mdash;</option>
</select>
</div>
<div className="form-actions">
<button type="button" className="btn-cancel" id="share-cancel" data-i18n="checklisten.share.cancel">Abbrechen</button>
<button type="button" className="btn-primary btn-cta-lime" id="share-submit" data-i18n="checklisten.share.submit">Freigeben</button>
</div>
<p className="form-msg" id="share-msg" />
{/* Existing grants — populated on open from
/api/checklists/templates/{slug}/shares. */}
<h3 className="share-grants-heading" data-i18n="checklisten.share.grants.heading">Bestehende Freigaben</h3>
<ul className="share-grants-list" id="share-grants-list">
<li className="entity-events-empty" id="share-grants-empty" data-i18n="checklisten.share.grants.empty">Keine Freigaben.</li>
</ul>
</div>
</div>
{/* Feedback modal */} {/* Feedback modal */}
<div className="modal-overlay" id="feedback-modal" style="display:none"> <div className="modal-overlay" id="feedback-modal" style="display:none">
<div className="modal-card"> <div className="modal-card">

View File

@@ -30,6 +30,37 @@ interface Checklist {
referenceDE?: string; referenceDE?: string;
referenceEN?: string; referenceEN?: string;
groups: ChecklistGroup[]; 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 { interface ChecklistInstance {
@@ -371,13 +402,320 @@ function rerenderAll() {
renderInstances(); 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", () => { document.addEventListener("DOMContentLoaded", () => {
initI18n(); initI18n();
initSidebar(); initSidebar();
initNewInstance(); initNewInstance();
initFeedback(); initFeedback();
initOwnerActions();
initShareModal();
onLangChange(rerenderAll); onLangChange(rerenderAll);
void loadTemplate(); void (async () => {
me = await loadMe();
await loadTemplate();
applyOwnerControls();
})();
void loadInstances(); void loadInstances();
void loadAkten(); void loadAkten();
}); });

View File

@@ -603,11 +603,40 @@ const translations: Record<Lang, Record<string, string>> = {
"checklisten.author.error.notfound": "Diese Vorlage existiert nicht oder Sie haben keine Berechtigung sie zu bearbeiten.", "checklisten.author.error.notfound": "Diese Vorlage existiert nicht oder Sie haben keine Berechtigung sie zu bearbeiten.",
"checklisten.detail.edit": "Bearbeiten", "checklisten.detail.edit": "Bearbeiten",
"checklisten.detail.delete": "Löschen", "checklisten.detail.delete": "Löschen",
"checklisten.detail.share": "Teilen",
"checklisten.detail.promote": "Als Firmen-Vorlage hinterlegen",
"checklisten.detail.demote": "Aus Katalog entfernen",
"checklisten.detail.promote.confirm": "Diese Vorlage in den Firmen-Katalog übernehmen? Alle Kolleg:innen sehen sie dann unter Vorlagen.",
"checklisten.detail.demote.confirm": "Vorlage aus dem Firmen-Katalog entfernen? Sie bleibt firmenweit sichtbar.",
"checklisten.detail.promote.error": "Übernahme fehlgeschlagen.",
"checklisten.detail.delete.confirm": "Vorlage „{title}\" wirklich löschen? Bestehende Instanzen bleiben erhalten.",
"checklisten.detail.delete.error": "Löschen fehlgeschlagen.",
"checklisten.detail.authored.by": "Erstellt von {author}", "checklisten.detail.authored.by": "Erstellt von {author}",
"checklisten.detail.visibility": "Sichtbarkeit: {state}", "checklisten.detail.visibility": "Sichtbarkeit: {state}",
"checklisten.detail.visibility.set.firm": "Für Firma freigeben", "checklisten.detail.visibility.set.firm": "Für Firma freigeben",
"checklisten.detail.visibility.set.private": "Privat schalten", "checklisten.detail.visibility.set.private": "Privat schalten",
"checklisten.detail.visibility.error": "Sichtbarkeit konnte nicht geändert werden.", "checklisten.detail.visibility.error": "Sichtbarkeit konnte nicht geändert werden.",
"checklisten.share.title": "Vorlage teilen",
"checklisten.share.kind": "Empfängertyp",
"checklisten.share.kind.user": "Kollege",
"checklisten.share.kind.office": "Office",
"checklisten.share.kind.partner_unit": "Dezernat",
"checklisten.share.kind.project": "Projekt",
"checklisten.share.pick": "— auswählen —",
"checklisten.share.submit": "Freigeben",
"checklisten.share.cancel": "Abbrechen",
"checklisten.share.error.pick": "Bitte einen Empfänger auswählen.",
"checklisten.share.error.generic": "Freigeben fehlgeschlagen.",
"checklisten.share.success": "Freigegeben.",
"checklisten.share.grants.heading": "Bestehende Freigaben",
"checklisten.share.grants.empty": "Keine Freigaben.",
"checklisten.share.grants.revoke": "Entfernen",
"checklisten.share.grants.revoke.confirm": "Freigabe entfernen?",
"checklisten.share.grants.revoke.error": "Entfernen fehlgeschlagen.",
"checklisten.share.grants.recipient.user": "Kollege",
"checklisten.share.grants.recipient.office": "Office",
"checklisten.share.grants.recipient.partner_unit": "Dezernat",
"checklisten.share.grants.recipient.project": "Projekt",
"checklisten.instances.all.loading": "L\u00e4dt\u2026", "checklisten.instances.all.loading": "L\u00e4dt\u2026",
"checklisten.instances.all.empty": "Noch keine Checklisten-Instanzen erfasst. Legen Sie eine \u00fcber den Vorlagen-Tab an.", "checklisten.instances.all.empty": "Noch keine Checklisten-Instanzen erfasst. Legen Sie eine \u00fcber den Vorlagen-Tab an.",
"checklisten.instances.all.col.template": "Vorlage", "checklisten.instances.all.col.template": "Vorlage",
@@ -3384,11 +3413,40 @@ const translations: Record<Lang, Record<string, string>> = {
"checklisten.author.error.notfound": "Template not found or you don't have permission to edit it.", "checklisten.author.error.notfound": "Template not found or you don't have permission to edit it.",
"checklisten.detail.edit": "Edit", "checklisten.detail.edit": "Edit",
"checklisten.detail.delete": "Delete", "checklisten.detail.delete": "Delete",
"checklisten.detail.share": "Share",
"checklisten.detail.promote": "Add to firm catalog",
"checklisten.detail.demote": "Remove from catalog",
"checklisten.detail.promote.confirm": "Add this template to the firm catalog? Every colleague will see it under Templates.",
"checklisten.detail.demote.confirm": "Remove this template from the firm catalog? It stays firm-visible.",
"checklisten.detail.promote.error": "Promotion failed.",
"checklisten.detail.delete.confirm": "Delete template \"{title}\"? Existing instances remain.",
"checklisten.detail.delete.error": "Delete failed.",
"checklisten.detail.authored.by": "Authored by {author}", "checklisten.detail.authored.by": "Authored by {author}",
"checklisten.detail.visibility": "Visibility: {state}", "checklisten.detail.visibility": "Visibility: {state}",
"checklisten.detail.visibility.set.firm": "Share with firm", "checklisten.detail.visibility.set.firm": "Share with firm",
"checklisten.detail.visibility.set.private": "Make private", "checklisten.detail.visibility.set.private": "Make private",
"checklisten.detail.visibility.error": "Couldn't change visibility.", "checklisten.detail.visibility.error": "Couldn't change visibility.",
"checklisten.share.title": "Share template",
"checklisten.share.kind": "Recipient type",
"checklisten.share.kind.user": "Colleague",
"checklisten.share.kind.office": "Office",
"checklisten.share.kind.partner_unit": "Practice unit",
"checklisten.share.kind.project": "Project",
"checklisten.share.pick": "— pick —",
"checklisten.share.submit": "Share",
"checklisten.share.cancel": "Cancel",
"checklisten.share.error.pick": "Please pick a recipient.",
"checklisten.share.error.generic": "Share failed.",
"checklisten.share.success": "Shared.",
"checklisten.share.grants.heading": "Existing grants",
"checklisten.share.grants.empty": "No grants.",
"checklisten.share.grants.revoke": "Remove",
"checklisten.share.grants.revoke.confirm": "Remove this grant?",
"checklisten.share.grants.revoke.error": "Revoke failed.",
"checklisten.share.grants.recipient.user": "Colleague",
"checklisten.share.grants.recipient.office": "Office",
"checklisten.share.grants.recipient.partner_unit": "Practice unit",
"checklisten.share.grants.recipient.project": "Project",
"checklisten.instances.all.loading": "Loading…", "checklisten.instances.all.loading": "Loading…",
"checklisten.instances.all.empty": "No checklist instances yet. Create one from the Templates tab.", "checklisten.instances.all.empty": "No checklist instances yet. Create one from the Templates tab.",
"checklisten.instances.all.col.template": "Template", "checklisten.instances.all.col.template": "Template",

View File

@@ -835,7 +835,15 @@ export type I18nKey =
| "checklisten.back" | "checklisten.back"
| "checklisten.detail.authored.by" | "checklisten.detail.authored.by"
| "checklisten.detail.delete" | "checklisten.detail.delete"
| "checklisten.detail.delete.confirm"
| "checklisten.detail.delete.error"
| "checklisten.detail.demote"
| "checklisten.detail.demote.confirm"
| "checklisten.detail.edit" | "checklisten.detail.edit"
| "checklisten.detail.promote"
| "checklisten.detail.promote.confirm"
| "checklisten.detail.promote.error"
| "checklisten.detail.share"
| "checklisten.detail.visibility" | "checklisten.detail.visibility"
| "checklisten.detail.visibility.error" | "checklisten.detail.visibility.error"
| "checklisten.detail.visibility.set.firm" | "checklisten.detail.visibility.set.firm"
@@ -912,6 +920,27 @@ export type I18nKey =
| "checklisten.reset" | "checklisten.reset"
| "checklisten.reset.confirm" | "checklisten.reset.confirm"
| "checklisten.reset.error" | "checklisten.reset.error"
| "checklisten.share.cancel"
| "checklisten.share.error.generic"
| "checklisten.share.error.pick"
| "checklisten.share.grants.empty"
| "checklisten.share.grants.heading"
| "checklisten.share.grants.recipient.office"
| "checklisten.share.grants.recipient.partner_unit"
| "checklisten.share.grants.recipient.project"
| "checklisten.share.grants.recipient.user"
| "checklisten.share.grants.revoke"
| "checklisten.share.grants.revoke.confirm"
| "checklisten.share.grants.revoke.error"
| "checklisten.share.kind"
| "checklisten.share.kind.office"
| "checklisten.share.kind.partner_unit"
| "checklisten.share.kind.project"
| "checklisten.share.kind.user"
| "checklisten.share.pick"
| "checklisten.share.submit"
| "checklisten.share.success"
| "checklisten.share.title"
| "checklisten.subtitle" | "checklisten.subtitle"
| "checklisten.tab.instances" | "checklisten.tab.instances"
| "checklisten.tab.mine" | "checklisten.tab.mine"