Compare commits
2 Commits
e091716f48
...
28d860a07d
| Author | SHA1 | Date | |
|---|---|---|---|
| 28d860a07d | |||
| d913f4fc30 |
@@ -256,7 +256,7 @@ func main() {
|
||||
// Akte-mode dual-write: project-backed scenarios write through
|
||||
// to paliad.projects.scenario_flags + paliad.deadlines via the
|
||||
// injected project + scenarioFlags services.
|
||||
ScenarioBuilder: services.NewScenarioBuilderService(pool, projectSvc, services.NewScenarioFlagsService(pool, projectSvc)),
|
||||
ScenarioBuilder: services.NewScenarioBuilderService(pool, projectSvc, services.NewScenarioFlagsService(pool, projectSvc), services.NewFristenrechnerService(rules, holidays, courts)),
|
||||
}
|
||||
|
||||
// t-paliad-246 Slice A — Backup Mode runner. Wired only when
|
||||
|
||||
370
frontend/src/client/builder-promote.ts
Normal file
370
frontend/src/client/builder-promote.ts
Normal file
@@ -0,0 +1,370 @@
|
||||
// Litigation Builder — promote-to-project wizard (m/paliad#153 PRD §2.4
|
||||
// + §5.4, B5).
|
||||
//
|
||||
// 3 steps: Bestätigen (read-only summary) → Parteien ergänzen (party
|
||||
// names) → Akte-Metadaten (title, reference, case number, our_side,
|
||||
// litigation parent, team). Commit POSTs the merged payload to
|
||||
// /api/builder/scenarios/{id}/promote — a single server-side transaction
|
||||
// (no partial promotions) that creates the paliad.projects 'case' row,
|
||||
// cascades deadlines, and flips the scenario to 'promoted'. On success
|
||||
// the wizard navigates to /projects/{new-id}.
|
||||
|
||||
import { t } from "./i18n";
|
||||
|
||||
interface ProjectOption {
|
||||
id: string;
|
||||
title: string;
|
||||
type: string;
|
||||
reference?: string;
|
||||
}
|
||||
|
||||
interface UserOption {
|
||||
id: string;
|
||||
email: string;
|
||||
display_name?: string;
|
||||
office?: string;
|
||||
}
|
||||
|
||||
interface PartyRow {
|
||||
name: string;
|
||||
role: string;
|
||||
representative: string;
|
||||
}
|
||||
|
||||
export interface PromoteContext {
|
||||
scenarioId: string;
|
||||
ownerId?: string;
|
||||
proceedingLabel: string;
|
||||
filedCount: number;
|
||||
plannedCount: number;
|
||||
flagCount: number;
|
||||
extraTopLevel: number;
|
||||
defaultOurSide: "claimant" | "defendant" | null;
|
||||
defaultTitle: string;
|
||||
onSuccess: (projectId: string) => void;
|
||||
}
|
||||
|
||||
export async function openPromoteWizard(ctx: PromoteContext): Promise<void> {
|
||||
// Parallel fetch: litigation parents + HLC users (both optional pickers).
|
||||
const [parents, users] = await Promise.all([
|
||||
fetchProjects("litigation"),
|
||||
fetchUsers(),
|
||||
]);
|
||||
|
||||
let step = 1;
|
||||
const parties: PartyRow[] = [];
|
||||
const meta = {
|
||||
title: ctx.defaultTitle || "",
|
||||
reference: "",
|
||||
caseNumber: "",
|
||||
clientNumber: "",
|
||||
ourSide: (ctx.defaultOurSide ?? "") as "" | "claimant" | "defendant",
|
||||
parentId: "",
|
||||
teamIds: new Set<string>(),
|
||||
};
|
||||
|
||||
const backdrop = document.createElement("div");
|
||||
backdrop.className = "builder-modal-backdrop";
|
||||
const modal = document.createElement("div");
|
||||
modal.className = "builder-modal builder-promote-modal";
|
||||
modal.setAttribute("role", "dialog");
|
||||
modal.setAttribute("aria-modal", "true");
|
||||
modal.setAttribute("aria-label", t("builder.promote.title"));
|
||||
backdrop.appendChild(modal);
|
||||
|
||||
const close = () => {
|
||||
document.removeEventListener("keydown", onEsc, true);
|
||||
backdrop.remove();
|
||||
};
|
||||
const onEsc = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") close();
|
||||
};
|
||||
backdrop.addEventListener("click", (e) => {
|
||||
if (e.target === backdrop) close();
|
||||
});
|
||||
document.addEventListener("keydown", onEsc, true);
|
||||
|
||||
function stepHeader(): string {
|
||||
const steps = [
|
||||
t("builder.promote.step1"),
|
||||
t("builder.promote.step2"),
|
||||
t("builder.promote.step3"),
|
||||
];
|
||||
const dots = steps.map((label, i) => {
|
||||
const n = i + 1;
|
||||
const cls = n === step ? " is-active" : n < step ? " is-done" : "";
|
||||
return `<li class="builder-promote-step${cls}"><span class="builder-promote-step-n">${n}</span>` +
|
||||
`<span class="builder-promote-step-label">${escHtml(label)}</span></li>`;
|
||||
}).join("");
|
||||
return `<ol class="builder-promote-steps">${dots}</ol>`;
|
||||
}
|
||||
|
||||
function renderStep1(): string {
|
||||
const rows = [
|
||||
`<li><span>${escHtml(t("builder.promote.summary.proceeding"))}</span><strong>${escHtml(ctx.proceedingLabel)}</strong></li>`,
|
||||
`<li><span>${escHtml(t("builder.promote.summary.events_filed"))}</span><strong>${ctx.filedCount}</strong></li>`,
|
||||
`<li><span>${escHtml(t("builder.promote.summary.events_planned"))}</span><strong>${ctx.plannedCount}</strong></li>`,
|
||||
`<li><span>${escHtml(t("builder.promote.summary.flags"))}</span><strong>${ctx.flagCount}</strong></li>`,
|
||||
].join("");
|
||||
const extra = ctx.extraTopLevel > 0
|
||||
? `<p class="builder-promote-note">${escHtml(
|
||||
t("builder.promote.summary.note_extra").replace("{n}", String(ctx.extraTopLevel)),
|
||||
)}</p>`
|
||||
: "";
|
||||
return (
|
||||
`<h3 class="builder-promote-section-title">${escHtml(t("builder.promote.summary.heading"))}</h3>` +
|
||||
`<ul class="builder-promote-summary">${rows}</ul>${extra}`
|
||||
);
|
||||
}
|
||||
|
||||
function renderStep2(): string {
|
||||
const list = parties.length === 0
|
||||
? `<p class="builder-promote-empty">${escHtml(t("builder.promote.parties.empty"))}</p>`
|
||||
: parties.map((p, i) => (
|
||||
`<div class="builder-promote-party" data-idx="${i}">` +
|
||||
`<input class="builder-promote-party-name" placeholder="${escAttr(t("builder.promote.parties.name"))}" value="${escAttr(p.name)}" />` +
|
||||
`<input class="builder-promote-party-role" placeholder="${escAttr(t("builder.promote.parties.role"))}" value="${escAttr(p.role)}" />` +
|
||||
`<input class="builder-promote-party-rep" placeholder="${escAttr(t("builder.promote.parties.representative"))}" value="${escAttr(p.representative)}" />` +
|
||||
`<button type="button" class="builder-promote-party-remove" aria-label="${escAttr(t("builder.promote.parties.remove"))}">×</button>` +
|
||||
`</div>`
|
||||
)).join("");
|
||||
return (
|
||||
`<p class="builder-promote-hint">${escHtml(t("builder.promote.parties.hint"))}</p>` +
|
||||
`<div class="builder-promote-parties">${list}</div>` +
|
||||
`<button type="button" class="builder-promote-party-add">${escHtml(t("builder.promote.parties.add"))}</button>`
|
||||
);
|
||||
}
|
||||
|
||||
function renderStep3(): string {
|
||||
const parentOpts = [`<option value="">${escHtml(t("builder.promote.meta.parent.none"))}</option>`]
|
||||
.concat(parents.map((p) => {
|
||||
const sel = p.id === meta.parentId ? " selected" : "";
|
||||
const label = p.reference ? `${p.title} (${p.reference})` : p.title;
|
||||
return `<option value="${escAttr(p.id)}"${sel}>${escHtml(label)}</option>`;
|
||||
})).join("");
|
||||
const sideSel = (v: string) => (meta.ourSide === v ? " selected" : "");
|
||||
const team = users
|
||||
.filter((u) => u.id !== ctx.ownerId)
|
||||
.slice(0, 40)
|
||||
.map((u) => {
|
||||
const checked = meta.teamIds.has(u.id) ? " checked" : "";
|
||||
const label = (u.display_name || "").trim()
|
||||
? ((u.office ? `${u.display_name} · ${u.office}` : u.display_name) as string)
|
||||
: u.email;
|
||||
return (
|
||||
`<label class="builder-promote-team-item">` +
|
||||
`<input type="checkbox" class="builder-promote-team-cb" data-user-id="${escAttr(u.id)}"${checked} />` +
|
||||
`<span>${escHtml(label)}</span></label>`
|
||||
);
|
||||
}).join("");
|
||||
return (
|
||||
`<label class="builder-promote-field"><span>${escHtml(t("builder.promote.meta.title"))}</span>` +
|
||||
`<input class="builder-promote-title" placeholder="${escAttr(t("builder.promote.meta.title.placeholder"))}" value="${escAttr(meta.title)}" /></label>` +
|
||||
`<div class="builder-promote-field-row">` +
|
||||
`<label class="builder-promote-field"><span>${escHtml(t("builder.promote.meta.reference"))}</span>` +
|
||||
`<input class="builder-promote-reference" value="${escAttr(meta.reference)}" /></label>` +
|
||||
`<label class="builder-promote-field"><span>${escHtml(t("builder.promote.meta.case_number"))}</span>` +
|
||||
`<input class="builder-promote-casenumber" value="${escAttr(meta.caseNumber)}" /></label>` +
|
||||
`</div>` +
|
||||
`<div class="builder-promote-field-row">` +
|
||||
`<label class="builder-promote-field"><span>${escHtml(t("builder.promote.meta.client_number"))}</span>` +
|
||||
`<input class="builder-promote-clientnumber" value="${escAttr(meta.clientNumber)}" /></label>` +
|
||||
`<label class="builder-promote-field"><span>${escHtml(t("builder.promote.meta.our_side"))}</span>` +
|
||||
`<select class="builder-promote-ourside">` +
|
||||
`<option value=""${sideSel("")}>${escHtml(t("builder.promote.meta.our_side.none"))}</option>` +
|
||||
`<option value="claimant"${sideSel("claimant")}>${escHtml(t("builder.promote.meta.our_side.claimant"))}</option>` +
|
||||
`<option value="defendant"${sideSel("defendant")}>${escHtml(t("builder.promote.meta.our_side.defendant"))}</option>` +
|
||||
`</select></label>` +
|
||||
`</div>` +
|
||||
`<label class="builder-promote-field"><span>${escHtml(t("builder.promote.meta.parent"))}</span>` +
|
||||
`<select class="builder-promote-parent">${parentOpts}</select></label>` +
|
||||
`<div class="builder-promote-field"><span>${escHtml(t("builder.promote.meta.team"))}</span>` +
|
||||
`<p class="builder-promote-team-hint">${escHtml(t("builder.promote.meta.team.hint"))}</p>` +
|
||||
`<div class="builder-promote-team">${team}</div></div>` +
|
||||
`<p class="builder-promote-error" hidden></p>`
|
||||
);
|
||||
}
|
||||
|
||||
function render(): void {
|
||||
let body = "";
|
||||
if (step === 1) body = renderStep1();
|
||||
else if (step === 2) body = renderStep2();
|
||||
else body = renderStep3();
|
||||
|
||||
const backLabel = t("builder.promote.back");
|
||||
const cancelLabel = t("builder.promote.cancel");
|
||||
const nextLabel = step < 3 ? t("builder.promote.next") : t("builder.promote.commit");
|
||||
|
||||
modal.innerHTML = `
|
||||
<header class="builder-modal-header">
|
||||
<h2 class="builder-modal-title">${escHtml(t("builder.promote.title"))}</h2>
|
||||
<button type="button" class="builder-modal-close" aria-label="${escAttr(cancelLabel)}">×</button>
|
||||
</header>
|
||||
${stepHeader()}
|
||||
<div class="builder-promote-body">${body}</div>
|
||||
<footer class="builder-promote-footer">
|
||||
<button type="button" class="builder-promote-cancel">${escHtml(cancelLabel)}</button>
|
||||
<span class="builder-promote-footer-spacer"></span>
|
||||
${step > 1 ? `<button type="button" class="builder-promote-backbtn">${escHtml(backLabel)}</button>` : ""}
|
||||
<button type="button" class="builder-promote-nextbtn builder-action-btn--primary">${escHtml(nextLabel)}</button>
|
||||
</footer>`;
|
||||
wire();
|
||||
}
|
||||
|
||||
function captureStep2(): void {
|
||||
modal.querySelectorAll<HTMLElement>(".builder-promote-party").forEach((row) => {
|
||||
const idx = Number(row.getAttribute("data-idx"));
|
||||
if (Number.isNaN(idx) || !parties[idx]) return;
|
||||
parties[idx].name = (row.querySelector(".builder-promote-party-name") as HTMLInputElement).value;
|
||||
parties[idx].role = (row.querySelector(".builder-promote-party-role") as HTMLInputElement).value;
|
||||
parties[idx].representative = (row.querySelector(".builder-promote-party-rep") as HTMLInputElement).value;
|
||||
});
|
||||
}
|
||||
|
||||
function captureStep3(): void {
|
||||
const get = (sel: string) => (modal.querySelector(sel) as HTMLInputElement | null)?.value ?? "";
|
||||
meta.title = get(".builder-promote-title");
|
||||
meta.reference = get(".builder-promote-reference");
|
||||
meta.caseNumber = get(".builder-promote-casenumber");
|
||||
meta.clientNumber = get(".builder-promote-clientnumber");
|
||||
meta.ourSide = ((modal.querySelector(".builder-promote-ourside") as HTMLSelectElement)?.value || "") as typeof meta.ourSide;
|
||||
meta.parentId = (modal.querySelector(".builder-promote-parent") as HTMLSelectElement)?.value || "";
|
||||
meta.teamIds = new Set(
|
||||
Array.from(modal.querySelectorAll<HTMLInputElement>(".builder-promote-team-cb:checked"))
|
||||
.map((cb) => cb.getAttribute("data-user-id") || "")
|
||||
.filter(Boolean),
|
||||
);
|
||||
}
|
||||
|
||||
function wire(): void {
|
||||
modal.querySelector(".builder-modal-close")?.addEventListener("click", close);
|
||||
modal.querySelector(".builder-promote-cancel")?.addEventListener("click", close);
|
||||
modal.querySelector(".builder-promote-backbtn")?.addEventListener("click", () => {
|
||||
if (step === 2) captureStep2();
|
||||
if (step === 3) captureStep3();
|
||||
step = Math.max(1, step - 1);
|
||||
render();
|
||||
});
|
||||
modal.querySelector(".builder-promote-nextbtn")?.addEventListener("click", () => {
|
||||
if (step === 2) captureStep2();
|
||||
if (step < 3) {
|
||||
step += 1;
|
||||
render();
|
||||
return;
|
||||
}
|
||||
captureStep3();
|
||||
void commit();
|
||||
});
|
||||
if (step === 2) {
|
||||
modal.querySelector(".builder-promote-party-add")?.addEventListener("click", () => {
|
||||
captureStep2();
|
||||
parties.push({ name: "", role: "", representative: "" });
|
||||
render();
|
||||
});
|
||||
modal.querySelectorAll<HTMLElement>(".builder-promote-party-remove").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
captureStep2();
|
||||
const row = btn.closest(".builder-promote-party") as HTMLElement;
|
||||
const idx = Number(row?.getAttribute("data-idx"));
|
||||
if (!Number.isNaN(idx)) parties.splice(idx, 1);
|
||||
render();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function commit(): Promise<void> {
|
||||
const errEl = modal.querySelector(".builder-promote-error") as HTMLElement | null;
|
||||
const showErr = (msg: string) => {
|
||||
if (errEl) {
|
||||
errEl.textContent = msg;
|
||||
errEl.hidden = false;
|
||||
}
|
||||
};
|
||||
if (!meta.title.trim()) {
|
||||
showErr(t("builder.promote.error.title_required"));
|
||||
return;
|
||||
}
|
||||
const nextBtn = modal.querySelector(".builder-promote-nextbtn") as HTMLButtonElement | null;
|
||||
if (nextBtn) nextBtn.disabled = true;
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
title: meta.title.trim(),
|
||||
reference: meta.reference.trim() || undefined,
|
||||
case_number: meta.caseNumber.trim() || undefined,
|
||||
client_number: meta.clientNumber.trim() || undefined,
|
||||
our_side: meta.ourSide || undefined,
|
||||
parent_id: meta.parentId || undefined,
|
||||
parties: parties
|
||||
.filter((p) => p.name.trim())
|
||||
.map((p) => ({
|
||||
name: p.name.trim(),
|
||||
role: p.role.trim() || undefined,
|
||||
representative: p.representative.trim() || undefined,
|
||||
})),
|
||||
team_members: Array.from(meta.teamIds).map((id) => ({ user_id: id })),
|
||||
};
|
||||
|
||||
try {
|
||||
const resp = await fetch(
|
||||
"/api/builder/scenarios/" + encodeURIComponent(ctx.scenarioId) + "/promote",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
);
|
||||
if (!resp.ok) {
|
||||
if (nextBtn) nextBtn.disabled = false;
|
||||
showErr(t("builder.promote.error.generic"));
|
||||
return;
|
||||
}
|
||||
const out = (await resp.json()) as { project_id: string };
|
||||
const body = modal.querySelector(".builder-promote-body") as HTMLElement;
|
||||
if (body) body.innerHTML = `<p class="builder-promote-success">${escHtml(t("builder.promote.success"))}</p>`;
|
||||
ctx.onSuccess(out.project_id);
|
||||
} catch {
|
||||
if (nextBtn) nextBtn.disabled = false;
|
||||
showErr(t("builder.promote.error.generic"));
|
||||
}
|
||||
}
|
||||
|
||||
render();
|
||||
document.body.appendChild(backdrop);
|
||||
(modal.querySelector(".builder-promote-nextbtn") as HTMLElement | null)?.focus();
|
||||
}
|
||||
|
||||
async function fetchProjects(type: string): Promise<ProjectOption[]> {
|
||||
try {
|
||||
const resp = await fetch("/api/projects?type=" + encodeURIComponent(type));
|
||||
if (!resp.ok) return [];
|
||||
const data = (await resp.json()) as ProjectOption[];
|
||||
return Array.isArray(data) ? data : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchUsers(): Promise<UserOption[]> {
|
||||
try {
|
||||
const resp = await fetch("/api/users");
|
||||
if (!resp.ok) return [];
|
||||
const data = (await resp.json()) as UserOption[];
|
||||
return Array.isArray(data) ? data : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function escHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function escAttr(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/"/g, """);
|
||||
}
|
||||
229
frontend/src/client/builder-shares.ts
Normal file
229
frontend/src/client/builder-shares.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
// Litigation Builder — share-with-team UI (m/paliad#153 PRD §2.5, B5).
|
||||
//
|
||||
// "Teilen" opens a modal with an HLC user picker. Picking a colleague +
|
||||
// "Schreibgeschützt teilen" POSTs a paliad.scenario_shares row; the owner
|
||||
// stays sole editor. Existing shares are listed with a revoke affordance.
|
||||
// The sharee sees the scenario in their "Geteilt mit mir" bucket (read-
|
||||
// only) — that side is handled by builder.ts.
|
||||
|
||||
import { t } from "./i18n";
|
||||
|
||||
export interface ShareUser {
|
||||
id: string;
|
||||
email: string;
|
||||
display_name?: string;
|
||||
office?: string;
|
||||
}
|
||||
|
||||
export interface BuilderShareRow {
|
||||
id: string;
|
||||
scenario_id: string;
|
||||
shared_with_user_id: string;
|
||||
created_by: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface ShareModalOpts {
|
||||
scenarioId: string;
|
||||
ownerId?: string;
|
||||
currentShares: BuilderShareRow[];
|
||||
// Called after a successful add/revoke with the fresh share list so the
|
||||
// caller can update state.active.shares + re-render side panel buckets.
|
||||
onChanged: (shares: BuilderShareRow[]) => void;
|
||||
}
|
||||
|
||||
let allUsers: ShareUser[] | null = null;
|
||||
|
||||
async function fetchUsers(): Promise<ShareUser[]> {
|
||||
if (allUsers) return allUsers;
|
||||
try {
|
||||
const resp = await fetch("/api/users");
|
||||
if (!resp.ok) return [];
|
||||
const data = (await resp.json()) as ShareUser[];
|
||||
allUsers = Array.isArray(data) ? data : [];
|
||||
return allUsers;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function userLabel(u: ShareUser): string {
|
||||
const name = (u.display_name || "").trim();
|
||||
if (name) return u.office ? `${name} · ${u.office}` : name;
|
||||
return u.email;
|
||||
}
|
||||
|
||||
export async function openShareModal(opts: ShareModalOpts): Promise<void> {
|
||||
const users = await fetchUsers();
|
||||
let shares = [...opts.currentShares];
|
||||
|
||||
const backdrop = document.createElement("div");
|
||||
backdrop.className = "builder-modal-backdrop";
|
||||
backdrop.innerHTML = `
|
||||
<div class="builder-modal builder-share-modal" role="dialog" aria-modal="true"
|
||||
aria-label="${escAttr(t("builder.share.title"))}">
|
||||
<header class="builder-modal-header">
|
||||
<h2 class="builder-modal-title">${escHtml(t("builder.share.title"))}</h2>
|
||||
<button type="button" class="builder-modal-close" aria-label="${escAttr(t("builder.share.close"))}">×</button>
|
||||
</header>
|
||||
<p class="builder-modal-subtitle">${escHtml(t("builder.share.subtitle"))}</p>
|
||||
<div class="builder-share-pickerbox">
|
||||
<input type="search" class="builder-share-search" autocomplete="off" spellcheck="false"
|
||||
placeholder="${escAttr(t("builder.share.search.placeholder"))}" />
|
||||
<ul class="builder-share-results" aria-label="${escAttr(t("builder.share.title"))}"></ul>
|
||||
</div>
|
||||
<div class="builder-share-current">
|
||||
<h3 class="builder-share-current-title">${escHtml(t("builder.share.current.title"))}</h3>
|
||||
<ul class="builder-share-current-list"></ul>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const close = () => {
|
||||
document.removeEventListener("keydown", onEsc, true);
|
||||
backdrop.remove();
|
||||
};
|
||||
const onEsc = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") close();
|
||||
};
|
||||
backdrop.addEventListener("click", (e) => {
|
||||
if (e.target === backdrop) close();
|
||||
});
|
||||
backdrop.querySelector(".builder-modal-close")?.addEventListener("click", close);
|
||||
document.addEventListener("keydown", onEsc, true);
|
||||
|
||||
const searchEl = backdrop.querySelector(".builder-share-search") as HTMLInputElement;
|
||||
const resultsEl = backdrop.querySelector(".builder-share-results") as HTMLElement;
|
||||
const currentEl = backdrop.querySelector(".builder-share-current-list") as HTMLElement;
|
||||
|
||||
function renderCurrent(): void {
|
||||
if (shares.length === 0) {
|
||||
currentEl.innerHTML = `<li class="builder-share-current-empty">${escHtml(t("builder.share.current.empty"))}</li>`;
|
||||
return;
|
||||
}
|
||||
currentEl.innerHTML = shares.map((sh) => {
|
||||
const u = users.find((x) => x.id === sh.shared_with_user_id);
|
||||
const label = u ? userLabel(u) : sh.shared_with_user_id;
|
||||
return (
|
||||
`<li class="builder-share-current-item" data-share-id="${escAttr(sh.id)}">` +
|
||||
`<span class="builder-share-current-name">${escHtml(label)}</span>` +
|
||||
`<button type="button" class="builder-share-revoke">${escHtml(t("builder.share.revoke"))}</button>` +
|
||||
`</li>`
|
||||
);
|
||||
}).join("");
|
||||
currentEl.querySelectorAll<HTMLElement>(".builder-share-current-item").forEach((li) => {
|
||||
const id = li.getAttribute("data-share-id");
|
||||
if (!id) return;
|
||||
li.querySelector(".builder-share-revoke")?.addEventListener("click", () => {
|
||||
void revoke(id);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderResults(): void {
|
||||
const q = searchEl.value.trim().toLowerCase();
|
||||
const sharedIds = new Set(shares.map((s) => s.shared_with_user_id));
|
||||
const matches = users
|
||||
.filter((u) => u.id !== opts.ownerId && !sharedIds.has(u.id))
|
||||
.filter((u) => {
|
||||
if (!q) return true;
|
||||
return (
|
||||
(u.display_name || "").toLowerCase().includes(q) ||
|
||||
u.email.toLowerCase().includes(q) ||
|
||||
(u.office || "").toLowerCase().includes(q)
|
||||
);
|
||||
})
|
||||
.slice(0, 12);
|
||||
if (matches.length === 0) {
|
||||
resultsEl.innerHTML = `<li class="builder-share-result-empty">${escHtml(t("builder.share.no_results"))}</li>`;
|
||||
return;
|
||||
}
|
||||
resultsEl.innerHTML = matches.map((u) => (
|
||||
`<li class="builder-share-result" data-user-id="${escAttr(u.id)}">` +
|
||||
`<span class="builder-share-result-name">${escHtml(userLabel(u))}</span>` +
|
||||
`<button type="button" class="builder-share-add">${escHtml(t("builder.share.button"))}</button>` +
|
||||
`</li>`
|
||||
)).join("");
|
||||
resultsEl.querySelectorAll<HTMLElement>(".builder-share-result").forEach((li) => {
|
||||
const uid = li.getAttribute("data-user-id");
|
||||
if (!uid) return;
|
||||
li.querySelector(".builder-share-add")?.addEventListener("click", () => {
|
||||
void add(uid);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function add(userId: string): Promise<void> {
|
||||
try {
|
||||
const resp = await fetch(
|
||||
"/api/builder/scenarios/" + encodeURIComponent(opts.scenarioId) + "/shares",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ shared_with_user_id: userId }),
|
||||
},
|
||||
);
|
||||
if (!resp.ok) {
|
||||
flashError();
|
||||
return;
|
||||
}
|
||||
const row = (await resp.json()) as BuilderShareRow;
|
||||
shares = [...shares.filter((s) => s.id !== row.id), row];
|
||||
searchEl.value = "";
|
||||
renderResults();
|
||||
renderCurrent();
|
||||
opts.onChanged(shares);
|
||||
} catch {
|
||||
flashError();
|
||||
}
|
||||
}
|
||||
|
||||
async function revoke(shareId: string): Promise<void> {
|
||||
try {
|
||||
const resp = await fetch(
|
||||
"/api/builder/scenarios/" + encodeURIComponent(opts.scenarioId) +
|
||||
"/shares/" + encodeURIComponent(shareId),
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
if (!resp.ok && resp.status !== 204) {
|
||||
flashError();
|
||||
return;
|
||||
}
|
||||
shares = shares.filter((s) => s.id !== shareId);
|
||||
renderResults();
|
||||
renderCurrent();
|
||||
opts.onChanged(shares);
|
||||
} catch {
|
||||
flashError();
|
||||
}
|
||||
}
|
||||
|
||||
function flashError(): void {
|
||||
const box = backdrop.querySelector(".builder-share-pickerbox") as HTMLElement;
|
||||
let err = box.querySelector(".builder-share-error") as HTMLElement | null;
|
||||
if (!err) {
|
||||
err = document.createElement("p");
|
||||
err.className = "builder-share-error";
|
||||
box.appendChild(err);
|
||||
}
|
||||
err.textContent = t("builder.share.error");
|
||||
}
|
||||
|
||||
searchEl.addEventListener("input", renderResults);
|
||||
renderResults();
|
||||
renderCurrent();
|
||||
document.body.appendChild(backdrop);
|
||||
searchEl.focus();
|
||||
}
|
||||
|
||||
function escHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function escAttr(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/"/g, """);
|
||||
}
|
||||
@@ -44,6 +44,8 @@ import {
|
||||
SCENARIO_FLAG_CHANGED_EVENT,
|
||||
type ScenarioFlagChangedDetail,
|
||||
} from "./scenario-flags";
|
||||
import { openShareModal, type BuilderShareRow } from "./builder-shares";
|
||||
import { openPromoteWizard } from "./builder-promote";
|
||||
|
||||
// Spawn map (PRD §3.6). When a scenario_flag transitions OFF → ON on a
|
||||
// parent proceeding, the builder auto-creates a child proceeding row
|
||||
@@ -125,6 +127,14 @@ type EntryMode = "cold" | "event" | "akte";
|
||||
interface State {
|
||||
active: BuilderScenarioDeep | null;
|
||||
list: BuilderScenario[];
|
||||
// B5 — scenarios shared read-only with me (the "Geteilt mit mir"
|
||||
// bucket). Disjoint from `list` (which is owner-scoped). readonly is
|
||||
// true when the active scenario is one of these OR is promoted —
|
||||
// either way every mutating affordance is disabled + a watermark shows.
|
||||
shared: BuilderScenario[];
|
||||
readonly: boolean;
|
||||
// owner display-name cache for the read-only watermark.
|
||||
ownerNameById: Map<string, string>;
|
||||
procTypes: ProceedingTypeMeta[];
|
||||
procTypesById: Map<number, ProceedingTypeMeta>;
|
||||
procTypesByCode: Map<string, ProceedingTypeMeta>;
|
||||
@@ -151,6 +161,9 @@ interface State {
|
||||
const state: State = {
|
||||
active: null,
|
||||
list: [],
|
||||
shared: [],
|
||||
readonly: false,
|
||||
ownerNameById: new Map(),
|
||||
procTypes: [],
|
||||
procTypesById: new Map(),
|
||||
procTypesByCode: new Map(),
|
||||
@@ -184,10 +197,29 @@ async function fetchJSON<T>(input: RequestInfo, init?: RequestInit): Promise<T |
|
||||
}
|
||||
|
||||
async function fetchScenarios(): Promise<BuilderScenario[]> {
|
||||
const out = await fetchJSON<BuilderScenario[]>("/api/builder/scenarios?status=active");
|
||||
// B5 — pull every status so the side panel can bucket into Aktiv /
|
||||
// Promoted / Archiviert. The picker + recent list filter to active.
|
||||
const out = await fetchJSON<BuilderScenario[]>("/api/builder/scenarios?status=all");
|
||||
return Array.isArray(out) ? out : [];
|
||||
}
|
||||
|
||||
async function fetchSharedScenarios(): Promise<BuilderScenario[]> {
|
||||
const out = await fetchJSON<BuilderScenario[]>("/api/builder/scenarios/shared");
|
||||
return Array.isArray(out) ? out : [];
|
||||
}
|
||||
|
||||
// fetchOwnerNames lazily loads the user directory once so the read-only
|
||||
// watermark can render "Geteilt von <Name>". Failures degrade to showing
|
||||
// the owner uuid; the watermark is informational, not load-bearing.
|
||||
async function ensureOwnerNames(): Promise<void> {
|
||||
if (state.ownerNameById.size > 0) return;
|
||||
const users = await fetchJSON<Array<{ id: string; display_name?: string; email: string }>>("/api/users");
|
||||
if (!Array.isArray(users)) return;
|
||||
for (const u of users) {
|
||||
state.ownerNameById.set(u.id, (u.display_name || "").trim() || u.email);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchScenarioDeep(id: string): Promise<BuilderScenarioDeep | null> {
|
||||
return await fetchJSON<BuilderScenarioDeep>("/api/builder/scenarios/" + encodeURIComponent(id));
|
||||
}
|
||||
@@ -396,20 +428,32 @@ async function flushAutoSave(): Promise<void> {
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function refreshScenarioList(): Promise<void> {
|
||||
state.list = await fetchScenarios();
|
||||
// Owned (all statuses) + shared-with-me run in parallel.
|
||||
const [owned, shared] = await Promise.all([fetchScenarios(), fetchSharedScenarios()]);
|
||||
state.list = owned;
|
||||
state.shared = shared;
|
||||
renderScenarioList();
|
||||
renderScenarioPicker();
|
||||
}
|
||||
|
||||
function renderScenarioList(): void {
|
||||
const ul = document.getElementById("builder-scenario-list-active");
|
||||
// renderBucket paints one side-panel bucket UL + toggles its wrapper's
|
||||
// hidden attribute when empty. The Aktiv bucket always renders (shows the
|
||||
// empty hint); the others hide when they have no rows.
|
||||
function renderBucket(listId: string, wrapId: string | null, scenarios: BuilderScenario[], alwaysShow: boolean): void {
|
||||
const ul = document.getElementById(listId);
|
||||
if (!ul) return;
|
||||
if (state.list.length === 0) {
|
||||
ul.innerHTML = `<li class="builder-scenario-list-empty" data-i18n="builder.panel.empty">Noch keine Szenarien.</li>`;
|
||||
if (wrapId) {
|
||||
const wrap = document.getElementById(wrapId);
|
||||
if (wrap) wrap.hidden = !alwaysShow && scenarios.length === 0;
|
||||
}
|
||||
if (scenarios.length === 0) {
|
||||
ul.innerHTML = alwaysShow
|
||||
? `<li class="builder-scenario-list-empty" data-i18n="builder.panel.empty">Noch keine Szenarien.</li>`
|
||||
: "";
|
||||
return;
|
||||
}
|
||||
const activeId = state.active?.id;
|
||||
ul.innerHTML = state.list.map((sc) => {
|
||||
ul.innerHTML = scenarios.map((sc) => {
|
||||
const isActive = sc.id === activeId;
|
||||
return (
|
||||
`<li class="builder-scenario-list-item${isActive ? " is-active" : ""}"` +
|
||||
@@ -428,12 +472,32 @@ function renderScenarioList(): void {
|
||||
});
|
||||
}
|
||||
|
||||
function renderScenarioList(): void {
|
||||
renderBucket("builder-scenario-list-active", null,
|
||||
state.list.filter((s) => s.status === "active"), true);
|
||||
renderBucket("builder-scenario-list-shared", "builder-bucket-shared", state.shared, false);
|
||||
renderBucket("builder-scenario-list-promoted", "builder-bucket-promoted",
|
||||
state.list.filter((s) => s.status === "promoted"), false);
|
||||
renderBucket("builder-scenario-list-archived", "builder-bucket-archived",
|
||||
state.list.filter((s) => s.status === "archived"), false);
|
||||
}
|
||||
|
||||
function renderScenarioPicker(): void {
|
||||
const sel = document.getElementById("builder-scenario-picker") as HTMLSelectElement | null;
|
||||
if (!sel) return;
|
||||
const placeholderText = t("builder.picker.placeholder");
|
||||
const opts: string[] = [`<option value="">${escHtml(placeholderText)}</option>`];
|
||||
for (const sc of state.list) {
|
||||
// Picker shows openable scenarios: active owned + shared-with-me.
|
||||
const pickable = [
|
||||
...state.list.filter((s) => s.status === "active"),
|
||||
...state.shared,
|
||||
];
|
||||
// Ensure the currently-active scenario is selectable even if promoted/
|
||||
// archived (so the dropdown reflects reality when one is open).
|
||||
if (state.active && !pickable.some((s) => s.id === state.active!.id)) {
|
||||
pickable.unshift(state.active);
|
||||
}
|
||||
for (const sc of pickable) {
|
||||
const selected = sc.id === state.active?.id ? " selected" : "";
|
||||
opts.push(`<option value="${escAttr(sc.id)}"${selected}>${escHtml(sc.name)}</option>`);
|
||||
}
|
||||
@@ -977,13 +1041,16 @@ async function loadScenario(id: string): Promise<void> {
|
||||
if (!Array.isArray(deep.shares)) deep.shares = [];
|
||||
state.active = deep;
|
||||
state.pending = {};
|
||||
// B5 — read-only when the scenario is shared with me (I'm not the
|
||||
// owner) or already promoted (server blocks mutations either way).
|
||||
const isShared = state.shared.some((s) => s.id === id);
|
||||
state.readonly = isShared || deep.status === "promoted";
|
||||
writeScenarioToUrl(id);
|
||||
setSaveState("saved");
|
||||
// Sync header inputs to scenario state.
|
||||
const stichtagInput = document.getElementById("builder-stichtag-input") as HTMLInputElement | null;
|
||||
if (stichtagInput && deep.stichtag) stichtagInput.value = deep.stichtag.slice(0, 10);
|
||||
const rename = document.getElementById("builder-rename-btn") as HTMLButtonElement | null;
|
||||
if (rename) rename.disabled = false;
|
||||
await applyScenarioChrome(deep, isShared);
|
||||
// B4 — reflect the scenario's Akte link on the page-header picker
|
||||
// + banner. Project-backed scenarios reveal the source project so
|
||||
// the user knows the builder writes feed into that Akte; non-Akte
|
||||
@@ -1040,6 +1107,114 @@ function openAddProceedingPicker(anchor: HTMLElement): void {
|
||||
});
|
||||
}
|
||||
|
||||
// applyScenarioChrome sets the page-header action buttons + read-only
|
||||
// watermark + body class for the freshly-loaded scenario. Editable
|
||||
// scenarios get rename / share / promote enabled; read-only ones (shared
|
||||
// with me, or promoted) lock all three and show the watermark. The body
|
||||
// class drives the CSS that neutralises in-canvas mutating affordances.
|
||||
async function applyScenarioChrome(deep: BuilderScenarioDeep, isShared: boolean): Promise<void> {
|
||||
document.body.classList.toggle("builder-readonly", state.readonly);
|
||||
const rename = document.getElementById("builder-rename-btn") as HTMLButtonElement | null;
|
||||
const share = document.getElementById("builder-share-btn") as HTMLButtonElement | null;
|
||||
const promote = document.getElementById("builder-promote-btn") as HTMLButtonElement | null;
|
||||
if (rename) rename.disabled = state.readonly;
|
||||
if (share) share.disabled = state.readonly;
|
||||
if (promote) promote.disabled = state.readonly;
|
||||
|
||||
const wm = document.getElementById("builder-readonly-watermark");
|
||||
if (!wm) return;
|
||||
if (!state.readonly) {
|
||||
wm.hidden = true;
|
||||
wm.textContent = "";
|
||||
return;
|
||||
}
|
||||
if (isShared) {
|
||||
await ensureOwnerNames();
|
||||
const owner = (deep.owner_id && state.ownerNameById.get(deep.owner_id)) || deep.owner_id || "?";
|
||||
wm.textContent = t("builder.readonly.watermark").replace("{owner}", owner);
|
||||
} else {
|
||||
// Promoted (owned) scenario — read-only reference.
|
||||
wm.textContent = t("builder.bucket.promoted");
|
||||
}
|
||||
wm.hidden = false;
|
||||
}
|
||||
|
||||
// resetScenarioChrome clears the page-header action state + watermark
|
||||
// when no scenario is active (cold-open / picker cleared).
|
||||
function resetScenarioChrome(): void {
|
||||
document.body.classList.remove("builder-readonly");
|
||||
for (const id of ["builder-rename-btn", "builder-share-btn", "builder-promote-btn"]) {
|
||||
const b = document.getElementById(id) as HTMLButtonElement | null;
|
||||
if (b) b.disabled = true;
|
||||
}
|
||||
const wm = document.getElementById("builder-readonly-watermark");
|
||||
if (wm) {
|
||||
wm.hidden = true;
|
||||
wm.textContent = "";
|
||||
}
|
||||
}
|
||||
|
||||
// onShareClick opens the share modal for the active (owned, editable)
|
||||
// scenario. PRD §2.5.
|
||||
function onShareClick(): void {
|
||||
if (!state.active || state.readonly) return;
|
||||
void openShareModal({
|
||||
scenarioId: state.active.id,
|
||||
ownerId: state.active.owner_id,
|
||||
currentShares: (state.active.shares as BuilderShareRow[]) ?? [],
|
||||
onChanged: (shares) => {
|
||||
if (state.active) state.active.shares = shares;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// onPromoteClick gathers the summary numbers for wizard step 1 and opens
|
||||
// the promote-to-project wizard. PRD §2.4. The primary proceeding (lowest-
|
||||
// ordinal top-level) + its spawned descendants are what the server
|
||||
// promotes into one case file; additional standalone proceedings are
|
||||
// reported in the summary as staying behind.
|
||||
function onPromoteClick(): void {
|
||||
if (!state.active || state.readonly) return;
|
||||
const sc = state.active;
|
||||
const topLevel = sc.proceedings
|
||||
.filter((p) => !p.parent_scenario_proceeding_id)
|
||||
.sort((a, b) => a.ordinal - b.ordinal);
|
||||
const primary = topLevel[0];
|
||||
if (!primary) {
|
||||
setSaveState("error");
|
||||
return;
|
||||
}
|
||||
// Collect primary + descendants to scope the event counts.
|
||||
const subtree = new Set<string>([primary.id]);
|
||||
for (let changed = true; changed; ) {
|
||||
changed = false;
|
||||
for (const p of sc.proceedings) {
|
||||
if (p.parent_scenario_proceeding_id && subtree.has(p.parent_scenario_proceeding_id) && !subtree.has(p.id)) {
|
||||
subtree.add(p.id);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
const evs = sc.events.filter((e) => subtree.has(e.scenario_proceeding_id));
|
||||
const meta = state.procTypesById.get(primary.proceeding_type_id);
|
||||
const label = meta ? meta.name || meta.code : "?";
|
||||
const defaultParty = (primary.primary_party as "claimant" | "defendant" | undefined) ?? null;
|
||||
void openPromoteWizard({
|
||||
scenarioId: sc.id,
|
||||
ownerId: sc.owner_id,
|
||||
proceedingLabel: label,
|
||||
filedCount: evs.filter((e) => e.state === "filed").length,
|
||||
plannedCount: evs.filter((e) => e.state === "planned").length,
|
||||
flagCount: Object.values(primary.scenario_flags).filter((v) => v === true).length,
|
||||
extraTopLevel: topLevel.length - 1,
|
||||
defaultOurSide: defaultParty,
|
||||
defaultTitle: sc.name && sc.name !== "Unbenanntes Szenario" ? sc.name : "",
|
||||
onSuccess: (projectId) => {
|
||||
window.location.href = "/projects/" + encodeURIComponent(projectId);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function onRenameClick(): Promise<void> {
|
||||
if (!state.active) return;
|
||||
const current = state.active.name;
|
||||
@@ -1194,6 +1369,12 @@ function wirePageHeader(): void {
|
||||
document.getElementById("builder-rename-btn")?.addEventListener("click", () => {
|
||||
void onRenameClick();
|
||||
});
|
||||
document.getElementById("builder-share-btn")?.addEventListener("click", () => {
|
||||
onShareClick();
|
||||
});
|
||||
document.getElementById("builder-promote-btn")?.addEventListener("click", () => {
|
||||
onPromoteClick();
|
||||
});
|
||||
document.getElementById("builder-new-scenario-btn")?.addEventListener("click", () => {
|
||||
void onNewScenarioClick();
|
||||
});
|
||||
@@ -1206,7 +1387,9 @@ function wirePageHeader(): void {
|
||||
if (id) void loadScenario(id);
|
||||
else {
|
||||
state.active = null;
|
||||
state.readonly = false;
|
||||
writeScenarioToUrl(null);
|
||||
resetScenarioChrome();
|
||||
renderCanvas();
|
||||
}
|
||||
});
|
||||
@@ -1277,8 +1460,15 @@ export async function mountBuilder(): Promise<void> {
|
||||
});
|
||||
|
||||
const requested = readScenarioFromUrl();
|
||||
if (requested && state.list.some((s) => s.id === requested)) {
|
||||
await loadScenario(requested);
|
||||
// Deep-link auto-load covers both owned scenarios and ones shared with
|
||||
// me (so a "Geteilt mit mir" link opens straight into the read-only
|
||||
// view, not the cold-open canvas). loadScenario derives read-only from
|
||||
// state.shared, so the share watermark + locked affordances apply.
|
||||
const isKnown =
|
||||
requested != null &&
|
||||
(state.list.some((s) => s.id === requested) || state.shared.some((s) => s.id === requested));
|
||||
if (isKnown) {
|
||||
await loadScenario(requested as string);
|
||||
} else {
|
||||
renderCanvas();
|
||||
}
|
||||
|
||||
@@ -294,6 +294,62 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"builder.search.summary.projects.other": "{n} Akten",
|
||||
"builder.search.anchor.divider": "\u2501\u2501\u2501\u2501 DU BIST HIER \u2501\u2501\u2501\u2501",
|
||||
|
||||
// B5 \u2014 side-panel buckets, sharing, promote-to-project wizard.
|
||||
"builder.bucket.shared": "Geteilt mit mir",
|
||||
"builder.bucket.promoted": "Als Projekt angelegt",
|
||||
"builder.bucket.archived": "Archiviert",
|
||||
"builder.bucket.empty": "\u2014",
|
||||
"builder.readonly.watermark": "Geteilt von {owner} \u00b7 schreibgesch\u00fctzt",
|
||||
"builder.readonly.blocked": "Schreibgesch\u00fctzt \u2014 Bearbeiten ist nur f\u00fcr die Eigent\u00fcmer:in m\u00f6glich.",
|
||||
"builder.share.title": "Szenario teilen",
|
||||
"builder.share.subtitle": "Schreibgesch\u00fctzt mit HLC-Kolleg:innen teilen. Du bleibst alleinige Bearbeiter:in.",
|
||||
"builder.share.search.placeholder": "Name oder E-Mail suchen \u2026",
|
||||
"builder.share.button": "Schreibgesch\u00fctzt teilen",
|
||||
"builder.share.current.title": "Bereits geteilt mit:",
|
||||
"builder.share.current.empty": "Noch mit niemandem geteilt.",
|
||||
"builder.share.revoke": "Entfernen",
|
||||
"builder.share.close": "Schlie\u00dfen",
|
||||
"builder.share.no_results": "Keine Nutzer:innen gefunden.",
|
||||
"builder.share.error": "Teilen fehlgeschlagen. Erneut versuchen.",
|
||||
"builder.promote.title": "Als Projekt anlegen",
|
||||
"builder.promote.step1": "Best\u00e4tigen",
|
||||
"builder.promote.step2": "Parteien erg\u00e4nzen",
|
||||
"builder.promote.step3": "Akte-Metadaten",
|
||||
"builder.promote.next": "Weiter",
|
||||
"builder.promote.back": "Zur\u00fcck",
|
||||
"builder.promote.commit": "Anlegen",
|
||||
"builder.promote.cancel": "Abbrechen",
|
||||
"builder.promote.summary.heading": "Das wird angelegt:",
|
||||
"builder.promote.summary.proceeding": "Hauptverfahren",
|
||||
"builder.promote.summary.events_filed": "eingereichte Ereignisse",
|
||||
"builder.promote.summary.events_planned": "geplante Ereignisse",
|
||||
"builder.promote.summary.flags": "aktive Optionen",
|
||||
"builder.promote.summary.note_extra": "{n} weitere(s) eigenst\u00e4ndige(s) Verfahren bleibt im Szenario und wird nicht automatisch \u00fcbernommen.",
|
||||
"builder.promote.parties.hint": "Trage die echten Parteinamen ein \u2014 oder erg\u00e4nze sie sp\u00e4ter in der Akte.",
|
||||
"builder.promote.parties.add": "+ Partei hinzuf\u00fcgen",
|
||||
"builder.promote.parties.name": "Name",
|
||||
"builder.promote.parties.role": "Rolle (z. B. Kl\u00e4ger)",
|
||||
"builder.promote.parties.representative": "Vertreter:in",
|
||||
"builder.promote.parties.remove": "Entfernen",
|
||||
"builder.promote.parties.empty": "Noch keine Parteien.",
|
||||
"builder.promote.meta.title": "Aktentitel / Mandat",
|
||||
"builder.promote.meta.title.placeholder": "z. B. Becker ./. X \u2014 UPC Verletzung",
|
||||
"builder.promote.meta.reference": "Referenz (optional)",
|
||||
"builder.promote.meta.case_number": "Aktenzeichen (optional)",
|
||||
"builder.promote.meta.client_number": "Mandantennummer (optional)",
|
||||
"builder.promote.meta.our_side": "Unsere Seite",
|
||||
"builder.promote.meta.our_side.claimant": "Kl\u00e4ger",
|
||||
"builder.promote.meta.our_side.defendant": "Beklagter",
|
||||
"builder.promote.meta.our_side.none": "\u2014 offen \u2014",
|
||||
"builder.promote.meta.parent": "\u00dcbergeordnetes Verfahren (optional)",
|
||||
"builder.promote.meta.parent.none": "\u2014 keines \u2014",
|
||||
"builder.promote.meta.team": "Team (optional)",
|
||||
"builder.promote.meta.team.hint": "Du wirst automatisch als Lead hinzugef\u00fcgt.",
|
||||
"builder.promote.error.title_required": "Bitte einen Aktentitel eingeben.",
|
||||
"builder.promote.error.generic": "Anlegen fehlgeschlagen. Erneut versuchen.",
|
||||
"builder.promote.success": "Akte angelegt \u2014 Weiterleitung \u2026",
|
||||
"builder.mobile.blocked": "Auf gr\u00f6\u00dferem Bildschirm \u00f6ffnen, um zu bearbeiten.",
|
||||
|
||||
"deadlines.step1": "Verfahrensart w\u00e4hlen",
|
||||
"deadlines.step2": "Ausgangsdatum eingeben",
|
||||
"deadlines.step2.perspective": "Perspektive und Datum",
|
||||
@@ -3591,6 +3647,62 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"builder.search.summary.projects.other": "{n} matters",
|
||||
"builder.search.anchor.divider": "━━━━ YOU ARE HERE ━━━━",
|
||||
|
||||
// B5 — side-panel buckets, sharing, promote-to-project wizard.
|
||||
"builder.bucket.shared": "Shared with me",
|
||||
"builder.bucket.promoted": "Promoted to project",
|
||||
"builder.bucket.archived": "Archived",
|
||||
"builder.bucket.empty": "—",
|
||||
"builder.readonly.watermark": "Shared by {owner} · read-only",
|
||||
"builder.readonly.blocked": "Read-only — only the owner can edit.",
|
||||
"builder.share.title": "Share scenario",
|
||||
"builder.share.subtitle": "Share read-only with HLC colleagues. You remain the sole editor.",
|
||||
"builder.share.search.placeholder": "Search name or email …",
|
||||
"builder.share.button": "Share read-only",
|
||||
"builder.share.current.title": "Already shared with:",
|
||||
"builder.share.current.empty": "Not shared with anyone yet.",
|
||||
"builder.share.revoke": "Remove",
|
||||
"builder.share.close": "Close",
|
||||
"builder.share.no_results": "No users found.",
|
||||
"builder.share.error": "Sharing failed. Please try again.",
|
||||
"builder.promote.title": "Create as project",
|
||||
"builder.promote.step1": "Confirm",
|
||||
"builder.promote.step2": "Add parties",
|
||||
"builder.promote.step3": "Case metadata",
|
||||
"builder.promote.next": "Next",
|
||||
"builder.promote.back": "Back",
|
||||
"builder.promote.commit": "Create",
|
||||
"builder.promote.cancel": "Cancel",
|
||||
"builder.promote.summary.heading": "What will be created:",
|
||||
"builder.promote.summary.proceeding": "Primary proceeding",
|
||||
"builder.promote.summary.events_filed": "filed events",
|
||||
"builder.promote.summary.events_planned": "planned events",
|
||||
"builder.promote.summary.flags": "active options",
|
||||
"builder.promote.summary.note_extra": "{n} further standalone proceeding(s) stay in the scenario and are not carried over automatically.",
|
||||
"builder.promote.parties.hint": "Enter the real party names — or add them later in the case file.",
|
||||
"builder.promote.parties.add": "+ Add party",
|
||||
"builder.promote.parties.name": "Name",
|
||||
"builder.promote.parties.role": "Role (e.g. claimant)",
|
||||
"builder.promote.parties.representative": "Representative",
|
||||
"builder.promote.parties.remove": "Remove",
|
||||
"builder.promote.parties.empty": "No parties yet.",
|
||||
"builder.promote.meta.title": "Case title / matter",
|
||||
"builder.promote.meta.title.placeholder": "e.g. Becker v. X — UPC infringement",
|
||||
"builder.promote.meta.reference": "Reference (optional)",
|
||||
"builder.promote.meta.case_number": "Case number (optional)",
|
||||
"builder.promote.meta.client_number": "Client number (optional)",
|
||||
"builder.promote.meta.our_side": "Our side",
|
||||
"builder.promote.meta.our_side.claimant": "Claimant",
|
||||
"builder.promote.meta.our_side.defendant": "Defendant",
|
||||
"builder.promote.meta.our_side.none": "— open —",
|
||||
"builder.promote.meta.parent": "Parent litigation (optional)",
|
||||
"builder.promote.meta.parent.none": "— none —",
|
||||
"builder.promote.meta.team": "Team (optional)",
|
||||
"builder.promote.meta.team.hint": "You are added as lead automatically.",
|
||||
"builder.promote.error.title_required": "Please enter a case title.",
|
||||
"builder.promote.error.generic": "Creation failed. Please try again.",
|
||||
"builder.promote.success": "Case created — redirecting …",
|
||||
"builder.mobile.blocked": "Open on a larger screen to edit.",
|
||||
|
||||
"deadlines.step1": "Select Proceeding Type",
|
||||
"deadlines.step2": "Enter Trigger Date",
|
||||
"deadlines.step2.perspective": "Perspective and Date",
|
||||
|
||||
@@ -735,6 +735,10 @@ export type I18nKey =
|
||||
| "builder.akte.banner.prefix"
|
||||
| "builder.akte.none"
|
||||
| "builder.bucket.active"
|
||||
| "builder.bucket.archived"
|
||||
| "builder.bucket.empty"
|
||||
| "builder.bucket.promoted"
|
||||
| "builder.bucket.shared"
|
||||
| "builder.canvas.add_proceeding"
|
||||
| "builder.empty.cta"
|
||||
| "builder.empty.headline"
|
||||
@@ -754,6 +758,7 @@ export type I18nKey =
|
||||
| "builder.header.scenario"
|
||||
| "builder.header.search"
|
||||
| "builder.header.stichtag"
|
||||
| "builder.mobile.blocked"
|
||||
| "builder.mode.akte"
|
||||
| "builder.mode.cold"
|
||||
| "builder.mode.event"
|
||||
@@ -768,6 +773,45 @@ export type I18nKey =
|
||||
| "builder.picker.future_jurisdiction"
|
||||
| "builder.picker.placeholder"
|
||||
| "builder.picker.title"
|
||||
| "builder.promote.back"
|
||||
| "builder.promote.cancel"
|
||||
| "builder.promote.commit"
|
||||
| "builder.promote.error.generic"
|
||||
| "builder.promote.error.title_required"
|
||||
| "builder.promote.meta.case_number"
|
||||
| "builder.promote.meta.client_number"
|
||||
| "builder.promote.meta.our_side"
|
||||
| "builder.promote.meta.our_side.claimant"
|
||||
| "builder.promote.meta.our_side.defendant"
|
||||
| "builder.promote.meta.our_side.none"
|
||||
| "builder.promote.meta.parent"
|
||||
| "builder.promote.meta.parent.none"
|
||||
| "builder.promote.meta.reference"
|
||||
| "builder.promote.meta.team"
|
||||
| "builder.promote.meta.team.hint"
|
||||
| "builder.promote.meta.title"
|
||||
| "builder.promote.meta.title.placeholder"
|
||||
| "builder.promote.next"
|
||||
| "builder.promote.parties.add"
|
||||
| "builder.promote.parties.empty"
|
||||
| "builder.promote.parties.hint"
|
||||
| "builder.promote.parties.name"
|
||||
| "builder.promote.parties.remove"
|
||||
| "builder.promote.parties.representative"
|
||||
| "builder.promote.parties.role"
|
||||
| "builder.promote.step1"
|
||||
| "builder.promote.step2"
|
||||
| "builder.promote.step3"
|
||||
| "builder.promote.success"
|
||||
| "builder.promote.summary.events_filed"
|
||||
| "builder.promote.summary.events_planned"
|
||||
| "builder.promote.summary.flags"
|
||||
| "builder.promote.summary.heading"
|
||||
| "builder.promote.summary.note_extra"
|
||||
| "builder.promote.summary.proceeding"
|
||||
| "builder.promote.title"
|
||||
| "builder.readonly.blocked"
|
||||
| "builder.readonly.watermark"
|
||||
| "builder.save.error"
|
||||
| "builder.save.idle"
|
||||
| "builder.save.saved"
|
||||
@@ -789,6 +833,16 @@ export type I18nKey =
|
||||
| "builder.search.summary.projects.other"
|
||||
| "builder.search.summary.scenarios.one"
|
||||
| "builder.search.summary.scenarios.other"
|
||||
| "builder.share.button"
|
||||
| "builder.share.close"
|
||||
| "builder.share.current.empty"
|
||||
| "builder.share.current.title"
|
||||
| "builder.share.error"
|
||||
| "builder.share.no_results"
|
||||
| "builder.share.revoke"
|
||||
| "builder.share.search.placeholder"
|
||||
| "builder.share.subtitle"
|
||||
| "builder.share.title"
|
||||
| "builder.subtitle"
|
||||
| "builder.triplet.collapse"
|
||||
| "builder.triplet.detailgrad.all_options"
|
||||
|
||||
@@ -68,12 +68,10 @@ export function renderProcedures(): string {
|
||||
<button type="button" id="builder-share-btn"
|
||||
className="builder-action-btn builder-action-btn--secondary"
|
||||
disabled
|
||||
title="In B5 verfügbar"
|
||||
data-i18n="builder.action.share">Teilen</button>
|
||||
<button type="button" id="builder-promote-btn"
|
||||
className="builder-action-btn builder-action-btn--primary"
|
||||
disabled
|
||||
title="In B5 verfügbar"
|
||||
data-i18n="builder.action.promote">Als Projekt anlegen</button>
|
||||
</div>
|
||||
<div className="builder-pageheader-row">
|
||||
@@ -141,10 +139,28 @@ export function renderProcedures(): string {
|
||||
<h3 className="builder-bucket-label" data-i18n="builder.bucket.active">Aktiv</h3>
|
||||
<ul className="builder-scenario-list" id="builder-scenario-list-active" aria-label="Aktive Szenarien"></ul>
|
||||
</div>
|
||||
{/* Geteilt / Promoted / Archiviert buckets land in B5+. */}
|
||||
{/* B5 — Geteilt mit mir / Als Projekt angelegt / Archiviert.
|
||||
Each bucket hides itself when empty (builder.ts toggles
|
||||
the hidden attribute). */}
|
||||
<div className="builder-sidepanel-bucket" data-bucket="shared" id="builder-bucket-shared" hidden>
|
||||
<h3 className="builder-bucket-label" data-i18n="builder.bucket.shared">Geteilt mit mir</h3>
|
||||
<ul className="builder-scenario-list" id="builder-scenario-list-shared" aria-label="Mit mir geteilte Szenarien"></ul>
|
||||
</div>
|
||||
<div className="builder-sidepanel-bucket" data-bucket="promoted" id="builder-bucket-promoted" hidden>
|
||||
<h3 className="builder-bucket-label" data-i18n="builder.bucket.promoted">Als Projekt angelegt</h3>
|
||||
<ul className="builder-scenario-list" id="builder-scenario-list-promoted" aria-label="Promotete Szenarien"></ul>
|
||||
</div>
|
||||
<div className="builder-sidepanel-bucket" data-bucket="archived" id="builder-bucket-archived" hidden>
|
||||
<h3 className="builder-bucket-label" data-i18n="builder.bucket.archived">Archiviert</h3>
|
||||
<ul className="builder-scenario-list" id="builder-scenario-list-archived" aria-label="Archivierte Szenarien"></ul>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section className="builder-canvas-wrap" aria-label="Builder-Canvas">
|
||||
{/* B5 — read-only watermark for shared / promoted scenarios.
|
||||
builder.ts fills + unhides it when the active scenario
|
||||
is not editable by the current user. */}
|
||||
<div id="builder-readonly-watermark" className="builder-readonly-watermark" hidden></div>
|
||||
<div id="builder-canvas" className="builder-canvas">
|
||||
{/* Cold-open placeholder — replaced by triplet stack once a
|
||||
scenario is loaded. */}
|
||||
|
||||
@@ -20648,3 +20648,383 @@ a.fristen-overhaul-rule-source {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===================================================================
|
||||
B5 — side-panel buckets, read-only watermark, share modal,
|
||||
promote-to-project wizard (m/paliad#153 PRD §2.4 + §2.5).
|
||||
=================================================================== */
|
||||
|
||||
.builder-sidepanel-bucket + .builder-sidepanel-bucket {
|
||||
margin-top: 0.85rem;
|
||||
padding-top: 0.65rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
.builder-bucket-label {
|
||||
margin: 0 0 0.35rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* Read-only watermark banner above the canvas. */
|
||||
.builder-readonly-watermark {
|
||||
display: block;
|
||||
margin-bottom: 0.6rem;
|
||||
padding: 0.4rem 0.75rem;
|
||||
border-radius: 0.4rem;
|
||||
background: repeating-linear-gradient(
|
||||
45deg,
|
||||
var(--color-surface-muted),
|
||||
var(--color-surface-muted) 10px,
|
||||
var(--color-surface-2) 10px,
|
||||
var(--color-surface-2) 20px
|
||||
);
|
||||
border: 1px dashed var(--color-border);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Read-only mode: neutralise every mutating affordance in the canvas
|
||||
while keeping text selectable + read interactions working. PRD §2.5 /
|
||||
§10 — pointer-events:none on the controls, not the cards. */
|
||||
body.builder-readonly .builder-triplet-host button,
|
||||
body.builder-readonly .builder-triplet-host input,
|
||||
body.builder-readonly .builder-triplet-host select,
|
||||
body.builder-readonly .builder-add-proceeding-btn {
|
||||
pointer-events: none;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Generic modal scaffold (shared by share modal + promote wizard). */
|
||||
.builder-modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
.builder-modal {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.6rem;
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.25);
|
||||
width: 100%;
|
||||
max-width: 540px;
|
||||
max-height: calc(100vh - 2rem);
|
||||
overflow: auto;
|
||||
padding: 1.1rem 1.25rem 1.25rem;
|
||||
}
|
||||
.builder-modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
.builder-modal-title {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.builder-modal-close {
|
||||
font: inherit;
|
||||
font-size: 1.4rem;
|
||||
line-height: 1;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-muted);
|
||||
padding: 0 0.3rem;
|
||||
}
|
||||
.builder-modal-subtitle {
|
||||
margin: 0 0 0.85rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* Share modal. */
|
||||
.builder-share-pickerbox {
|
||||
position: relative;
|
||||
}
|
||||
.builder-share-search {
|
||||
width: 100%;
|
||||
font: inherit;
|
||||
padding: 0.4rem 0.6rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.35rem;
|
||||
background: var(--color-surface-2);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.builder-share-results {
|
||||
list-style: none;
|
||||
margin: 0.4rem 0 0;
|
||||
padding: 0;
|
||||
max-height: 220px;
|
||||
overflow: auto;
|
||||
}
|
||||
.builder-share-result,
|
||||
.builder-share-current-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding: 0.35rem 0.4rem;
|
||||
border-radius: 0.3rem;
|
||||
}
|
||||
.builder-share-result:hover {
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
.builder-share-result-empty,
|
||||
.builder-share-current-empty {
|
||||
padding: 0.4rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted);
|
||||
list-style: none;
|
||||
}
|
||||
.builder-share-add,
|
||||
.builder-share-revoke {
|
||||
font: inherit;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 0.3rem;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-surface-2);
|
||||
color: var(--color-text);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.builder-share-add {
|
||||
background: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-accent-dark);
|
||||
font-weight: 500;
|
||||
}
|
||||
.builder-share-current {
|
||||
margin-top: 1rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
.builder-share-current-title {
|
||||
margin: 0 0 0.4rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.builder-share-current-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.builder-share-error {
|
||||
margin: 0.5rem 0 0;
|
||||
font-size: 0.82rem;
|
||||
color: #c0392b;
|
||||
}
|
||||
|
||||
/* Promote wizard. */
|
||||
.builder-promote-modal {
|
||||
max-width: 600px;
|
||||
}
|
||||
.builder-promote-steps {
|
||||
display: flex;
|
||||
list-style: none;
|
||||
margin: 0 0 1rem;
|
||||
padding: 0;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.builder-promote-step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
flex: 1;
|
||||
}
|
||||
.builder-promote-step-n {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.4rem;
|
||||
height: 1.4rem;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--color-border);
|
||||
font-size: 0.78rem;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.builder-promote-step.is-active {
|
||||
color: var(--color-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
.builder-promote-step.is-active .builder-promote-step-n {
|
||||
background: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-accent-dark);
|
||||
}
|
||||
.builder-promote-step.is-done .builder-promote-step-n {
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
.builder-promote-body {
|
||||
min-height: 160px;
|
||||
}
|
||||
.builder-promote-section-title {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.builder-promote-summary {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.builder-promote-summary li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.3rem 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.builder-promote-summary li span {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.builder-promote-note {
|
||||
margin: 0.75rem 0 0;
|
||||
padding: 0.5rem 0.65rem;
|
||||
border-radius: 0.35rem;
|
||||
background: var(--color-surface-muted);
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.builder-promote-hint,
|
||||
.builder-promote-empty,
|
||||
.builder-promote-team-hint {
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-text-muted);
|
||||
margin: 0 0 0.6rem;
|
||||
}
|
||||
.builder-promote-parties {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.builder-promote-party {
|
||||
display: grid;
|
||||
grid-template-columns: 1.3fr 1fr 1fr auto;
|
||||
gap: 0.35rem;
|
||||
align-items: center;
|
||||
}
|
||||
.builder-promote-party input,
|
||||
.builder-promote-field input,
|
||||
.builder-promote-field select {
|
||||
font: inherit;
|
||||
padding: 0.35rem 0.5rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.3rem;
|
||||
background: var(--color-surface-2);
|
||||
color: var(--color-text);
|
||||
width: 100%;
|
||||
}
|
||||
.builder-promote-party-remove {
|
||||
font: inherit;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.builder-promote-party-add {
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.3rem 0.7rem;
|
||||
border-radius: 0.3rem;
|
||||
cursor: pointer;
|
||||
border: 1px dashed var(--color-border);
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
}
|
||||
.builder-promote-field {
|
||||
display: block;
|
||||
margin-bottom: 0.7rem;
|
||||
}
|
||||
.builder-promote-field > span {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
.builder-promote-field-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.builder-promote-team {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
max-height: 160px;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.35rem;
|
||||
padding: 0.4rem;
|
||||
}
|
||||
.builder-promote-team-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.builder-promote-error {
|
||||
margin: 0.4rem 0 0;
|
||||
font-size: 0.85rem;
|
||||
color: #c0392b;
|
||||
}
|
||||
.builder-promote-success {
|
||||
text-align: center;
|
||||
padding: 2rem 0;
|
||||
font-size: 1rem;
|
||||
color: var(--color-text);
|
||||
}
|
||||
.builder-promote-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
padding-top: 0.8rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
.builder-promote-footer-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
.builder-promote-cancel,
|
||||
.builder-promote-backbtn,
|
||||
.builder-promote-nextbtn {
|
||||
font: inherit;
|
||||
padding: 0.4rem 0.95rem;
|
||||
border-radius: 0.35rem;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-surface-2);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.builder-promote-nextbtn {
|
||||
background: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-accent-dark);
|
||||
font-weight: 500;
|
||||
}
|
||||
.builder-promote-nextbtn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.builder-promote-party,
|
||||
.builder-promote-field-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -542,6 +542,9 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
// scenario from a paliad.projects row; subsequent edits dual-write
|
||||
// through to paliad.deadlines + paliad.projects.scenario_flags.
|
||||
protected.HandleFunc("POST /api/builder/scenarios/from-project", handleBuilderScenarioFromProject)
|
||||
// m/paliad#153 B5 — "Geteilt mit mir" bucket. Literal segment wins
|
||||
// over {id} in Go 1.22+ ServeMux precedence, so this never shadows GET .../{id}.
|
||||
protected.HandleFunc("GET /api/builder/scenarios/shared", handleBuilderScenariosShared)
|
||||
protected.HandleFunc("GET /api/builder/scenarios/{id}", handleBuilderScenarioGet)
|
||||
protected.HandleFunc("PATCH /api/builder/scenarios/{id}", handleBuilderScenarioPatch)
|
||||
protected.HandleFunc("POST /api/builder/scenarios/{id}/proceedings", handleBuilderProceedingCreate)
|
||||
@@ -552,6 +555,8 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
protected.HandleFunc("DELETE /api/builder/scenarios/{id}/events/{eid}", handleBuilderEventDelete)
|
||||
protected.HandleFunc("POST /api/builder/scenarios/{id}/shares", handleBuilderShareCreate)
|
||||
protected.HandleFunc("DELETE /api/builder/scenarios/{id}/shares/{sid}", handleBuilderShareDelete)
|
||||
// m/paliad#153 B5 — transactional promote-to-project wizard commit.
|
||||
protected.HandleFunc("POST /api/builder/scenarios/{id}/promote", handleBuilderScenarioPromote)
|
||||
// m/paliad#153 B2 — read-only passthrough so the builder can render
|
||||
// per-triplet flag toggles without a per-project round-trip.
|
||||
protected.HandleFunc("GET /api/builder/scenario-flag-catalog", handleBuilderFlagCatalog)
|
||||
|
||||
@@ -433,6 +433,62 @@ func handleBuilderShareDelete(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared-with-me + Promote (B5, m/paliad#153)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// handleBuilderScenariosShared — GET /api/builder/scenarios/shared
|
||||
//
|
||||
// Lists scenarios shared read-only with the caller (the "Geteilt mit mir"
|
||||
// side-panel bucket, PRD §2.5). The caller's own scenarios are excluded.
|
||||
func handleBuilderScenariosShared(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireScenarioBuilderService(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
out, err := dbSvc.scenarioBuilder.ListSharedWithMe(r.Context(), uid)
|
||||
if err != nil {
|
||||
writeBuilderError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// handleBuilderScenarioPromote — POST /api/builder/scenarios/{id}/promote
|
||||
//
|
||||
// Body: PromoteScenarioInput (wizard steps 2 + 3). Promotes the scenario
|
||||
// into a real paliad.projects 'case' row transactionally (PRD §10 — no
|
||||
// partial promotions) and returns PromoteResult with the new project id
|
||||
// the wizard navigates to (/projects/{project_id}).
|
||||
func handleBuilderScenarioPromote(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireScenarioBuilderService(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
sid, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Szenario-ID"})
|
||||
return
|
||||
}
|
||||
var input services.PromoteScenarioInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Anfrage"})
|
||||
return
|
||||
}
|
||||
out, err := dbSvc.scenarioBuilder.PromoteScenario(r.Context(), uid, sid, input)
|
||||
if err != nil {
|
||||
writeBuilderError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, out)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scenario flag catalog passthrough (m/paliad#153 B2)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -37,16 +37,24 @@ type ScenarioBuilderService struct {
|
||||
db *sqlx.DB
|
||||
projects *ProjectService
|
||||
flags *ScenarioFlagsService
|
||||
// fristenrechner computes planned-deadline due dates during the B5
|
||||
// promote-to-project cascade (PRD §5.4 — "due_date=computed"). nil in
|
||||
// test setups that don't exercise promotion; the promote path then
|
||||
// skips planned events that have no actual_date (it can't assert a
|
||||
// date it didn't compute) and reports them via DeadlinesSkipped.
|
||||
fristenrechner *FristenrechnerService
|
||||
}
|
||||
|
||||
// NewScenarioBuilderService wires the service to the shared pool plus
|
||||
// the project + scenario-flags services it leans on for the Akte-mode
|
||||
// dual-write. projects + flags are optional in test setups (nil → the
|
||||
// dual-write hooks short-circuit), but a production wiring should
|
||||
// always pass them so Akte-backed scenarios stay in sync with project
|
||||
// surfaces.
|
||||
func NewScenarioBuilderService(db *sqlx.DB, projects *ProjectService, flags *ScenarioFlagsService) *ScenarioBuilderService {
|
||||
return &ScenarioBuilderService{db: db, projects: projects, flags: flags}
|
||||
// dual-write, and the Fristenrechner calc service the B5 promote path
|
||||
// uses to compute planned-deadline dates. projects / flags / frist are
|
||||
// optional in test setups (nil → the dual-write + promote-compute hooks
|
||||
// short-circuit), but a production wiring should always pass them so
|
||||
// Akte-backed scenarios stay in sync with project surfaces and
|
||||
// promotion cascades real dates.
|
||||
func NewScenarioBuilderService(db *sqlx.DB, projects *ProjectService, flags *ScenarioFlagsService, frist *FristenrechnerService) *ScenarioBuilderService {
|
||||
return &ScenarioBuilderService{db: db, projects: projects, flags: flags, fristenrechner: frist}
|
||||
}
|
||||
|
||||
// ErrScenarioBuilderNotVisible is returned when the caller is neither
|
||||
@@ -928,6 +936,438 @@ func (s *ScenarioBuilderService) DeleteShare(ctx context.Context, userID, scenar
|
||||
return nil
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Shared-with-me listing (B5)
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// ListSharedWithMe returns scenarios shared read-only with the caller
|
||||
// (a paliad.scenario_shares row exists for shared_with_user_id = caller).
|
||||
// The caller's own scenarios are excluded — they live in ListMyScenarios.
|
||||
// Sorted by the share's created_at desc so the most-recently-shared sits
|
||||
// on top. Promoted scenarios stay visible (read-only reference) just like
|
||||
// in the owner's own list.
|
||||
func (s *ScenarioBuilderService) ListSharedWithMe(ctx context.Context, userID uuid.UUID) ([]BuilderScenario, error) {
|
||||
out := []BuilderScenario{}
|
||||
if err := s.db.SelectContext(ctx, &out,
|
||||
`SELECT sc.id, sc.owner_id, sc.name, sc.status, sc.origin_project_id,
|
||||
sc.promoted_project_id, sc.stichtag, sc.notes,
|
||||
sc.project_id, sc.description, sc.created_by,
|
||||
sc.created_at, sc.updated_at
|
||||
FROM paliad.scenarios sc
|
||||
JOIN paliad.scenario_shares sh ON sh.scenario_id = sc.id
|
||||
WHERE sh.shared_with_user_id = $1
|
||||
AND (sc.owner_id IS NULL OR sc.owner_id <> $1)
|
||||
ORDER BY sh.created_at DESC`, userID); err != nil {
|
||||
return nil, fmt.Errorf("list shared scenarios: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Promote-to-project (B5, PRD §2.4 + §5.4 + §10)
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// PromotePartyInput is one party row the wizard's "Parteien ergänzen"
|
||||
// step contributes. Mirrors CreatePartyInput minus contact_info (the
|
||||
// wizard collects names + roles; full contact data is filled in the Akte
|
||||
// later).
|
||||
type PromotePartyInput struct {
|
||||
Name string `json:"name"`
|
||||
Role *string `json:"role,omitempty"`
|
||||
Representative *string `json:"representative,omitempty"`
|
||||
}
|
||||
|
||||
// PromoteTeamMemberInput grants a colleague access to the new project at
|
||||
// promote time. Responsibility defaults to 'member' when blank.
|
||||
type PromoteTeamMemberInput struct {
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
Responsibility string `json:"responsibility,omitempty"`
|
||||
}
|
||||
|
||||
// PromoteScenarioInput is the POST /api/builder/scenarios/{id}/promote
|
||||
// body — the merged payload from wizard steps 2 (Parteien) + 3
|
||||
// (Akte-Metadaten). The procedural shape (proceeding type, flags,
|
||||
// perspective) + event states come from the scenario itself; the wizard
|
||||
// only supplies the client-bound metadata the scenario can't know.
|
||||
type PromoteScenarioInput struct {
|
||||
Title string `json:"title"`
|
||||
Reference *string `json:"reference,omitempty"`
|
||||
CaseNumber *string `json:"case_number,omitempty"`
|
||||
ClientNumber *string `json:"client_number,omitempty"`
|
||||
ParentID *uuid.UUID `json:"parent_id,omitempty"`
|
||||
OurSide *string `json:"our_side,omitempty"`
|
||||
Parties []PromotePartyInput `json:"parties,omitempty"`
|
||||
TeamMembers []PromoteTeamMemberInput `json:"team_members,omitempty"`
|
||||
}
|
||||
|
||||
// PromoteResult is the outcome the wizard navigates on.
|
||||
type PromoteResult struct {
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
DeadlinesCreated int `json:"deadlines_created"`
|
||||
DeadlinesSkipped int `json:"deadlines_skipped"`
|
||||
PartiesCreated int `json:"parties_created"`
|
||||
ProceedingsSkipped int `json:"proceedings_skipped"`
|
||||
}
|
||||
|
||||
// PromoteScenario turns a scenario into a real paliad.projects 'case' row
|
||||
// in a single transaction (PRD §10 — no partial promotions). It promotes
|
||||
// the scenario's primary proceeding (the lowest-ordinal top-level
|
||||
// triplet) plus its spawned descendants (the CCR child etc., whose rules
|
||||
// fold into the primary's timeline under the active flags). Additional
|
||||
// unrelated top-level proceedings are left in the scenario and reported
|
||||
// via ProceedingsSkipped — v1 promotes one case file per call, matching
|
||||
// the singular acceptance criterion (one project, navigate to one id);
|
||||
// the scenario stays visible as 'promoted' for historical reference and
|
||||
// can seed a second promotion later.
|
||||
//
|
||||
// The cascade, all inside the tx:
|
||||
// 1. INSERT paliad.projects (type='case', client metadata from the
|
||||
// wizard, proceeding_type_id + scenario_flags from the primary
|
||||
// triplet, origin_scenario_id = scenario.id).
|
||||
// 2. INSERT the creator as team lead + any wizard-selected colleagues.
|
||||
// 3. INSERT parties from the wizard's step-2 payload.
|
||||
// 4. For each event under the promoted proceedings: filed → a completed
|
||||
// deadline (due_date + completed_at = actual_date); planned → an open
|
||||
// ('pending') deadline with the computed due_date; skipped → no row.
|
||||
// Planned events with no computable date (court-set / conditional /
|
||||
// no actual_date) are skipped and counted.
|
||||
// 5. UPDATE the scenario: status='promoted', promoted_project_id = new.
|
||||
//
|
||||
// Any error rolls the whole transaction back.
|
||||
func (s *ScenarioBuilderService) PromoteScenario(ctx context.Context, userID, scenarioID uuid.UUID, input PromoteScenarioInput) (*PromoteResult, error) {
|
||||
sc, err := s.requireOwnerOrLegacyEditor(ctx, userID, scenarioID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if sc.Status == "promoted" {
|
||||
return nil, fmt.Errorf("%w: scenario is already promoted", ErrInvalidInput)
|
||||
}
|
||||
title := strings.TrimSpace(input.Title)
|
||||
if title == "" {
|
||||
return nil, fmt.Errorf("%w: title is required", ErrInvalidInput)
|
||||
}
|
||||
if input.OurSide != nil {
|
||||
if err := validateOurSide(*input.OurSide); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
for i := range input.Parties {
|
||||
if strings.TrimSpace(input.Parties[i].Name) == "" {
|
||||
return nil, fmt.Errorf("%w: party %d has a blank name", ErrInvalidInput, i+1)
|
||||
}
|
||||
}
|
||||
for _, tm := range input.TeamMembers {
|
||||
if tm.UserID == uuid.Nil {
|
||||
return nil, fmt.Errorf("%w: team member has an empty user_id", ErrInvalidInput)
|
||||
}
|
||||
if tm.Responsibility != "" && !IsValidResponsibility(tm.Responsibility) {
|
||||
return nil, fmt.Errorf("%w: invalid responsibility %q", ErrInvalidInput, tm.Responsibility)
|
||||
}
|
||||
}
|
||||
|
||||
// Parent visibility (mirrors ProjectService.Create): a litigation
|
||||
// parent the caller can't see would leak the new sub-tree.
|
||||
if input.ParentID != nil && s.projects != nil {
|
||||
if _, perr := s.projects.GetByID(ctx, userID, *input.ParentID); perr != nil {
|
||||
return nil, fmt.Errorf("%w: litigation parent not visible", ErrForbidden)
|
||||
}
|
||||
}
|
||||
|
||||
// Load the proceeding + event tree.
|
||||
proceedings := []BuilderProceeding{}
|
||||
if err := s.db.SelectContext(ctx, &proceedings, `
|
||||
SELECT id, scenario_id, proceeding_type_id, primary_party, scenario_flags,
|
||||
parent_scenario_proceeding_id, spawn_anchor_event_id, ordinal,
|
||||
stichtag, detailgrad, appeal_target, collapsed, created_at, updated_at
|
||||
FROM paliad.scenario_proceedings
|
||||
WHERE scenario_id = $1
|
||||
ORDER BY ordinal ASC, created_at ASC`, scenarioID); err != nil {
|
||||
return nil, fmt.Errorf("load proceedings: %w", err)
|
||||
}
|
||||
if len(proceedings) == 0 {
|
||||
return nil, fmt.Errorf("%w: scenario has no proceedings to promote", ErrInvalidInput)
|
||||
}
|
||||
|
||||
// Primary = first top-level proceeding (lowest ordinal). Collect it +
|
||||
// its spawned descendants; those form the one case file we promote.
|
||||
var primary *BuilderProceeding
|
||||
for i := range proceedings {
|
||||
if proceedings[i].ParentScenarioProceedingID == nil {
|
||||
primary = &proceedings[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if primary == nil {
|
||||
return nil, fmt.Errorf("%w: scenario has no top-level proceeding", ErrInvalidInput)
|
||||
}
|
||||
promoteSet := collectProceedingSubtree(proceedings, primary.ID)
|
||||
topLevelCount := 0
|
||||
for i := range proceedings {
|
||||
if proceedings[i].ParentScenarioProceedingID == nil {
|
||||
topLevelCount++
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the primary proceeding's catalog code (the calc engine keys
|
||||
// off code, not id).
|
||||
var primaryCode string
|
||||
if err := s.db.GetContext(ctx, &primaryCode,
|
||||
`SELECT code FROM paliad.proceeding_types WHERE id = $1`, primary.ProceedingTypeID); err != nil {
|
||||
return nil, fmt.Errorf("resolve proceeding code: %w", err)
|
||||
}
|
||||
|
||||
// Resolve our_side: explicit wizard value wins; otherwise fold the
|
||||
// primary triplet's perspective down to the project axis.
|
||||
ourSide := input.OurSide
|
||||
if ourSide == nil {
|
||||
ourSide = primary.PrimaryParty
|
||||
}
|
||||
|
||||
// Compute the primary proceeding's timeline so planned events get real
|
||||
// dates. The CCR child's rules fold into this timeline under the
|
||||
// primary's flags (sub-track routing), so one calc covers the whole
|
||||
// promoted subtree. Keyed by lowercased rule id → display name/code/date.
|
||||
type computed struct {
|
||||
name string
|
||||
code string
|
||||
dueDate string
|
||||
}
|
||||
timelineByRule := map[string]computed{}
|
||||
if s.fristenrechner != nil {
|
||||
stichtag := promoteStichtag(primary, sc)
|
||||
opts := CalcOptions{Flags: scenarioFlagsTruthyKeys(primary.ScenarioFlags)}
|
||||
tl, cerr := s.fristenrechner.Calculate(ctx, primaryCode, stichtag, opts)
|
||||
if cerr != nil {
|
||||
// A calc failure is not fatal — filed events still carry their
|
||||
// own actual_date. Planned events then fall to DeadlinesSkipped.
|
||||
tl = nil
|
||||
}
|
||||
if tl != nil {
|
||||
for _, e := range tl.Deadlines {
|
||||
if e.RuleID == "" {
|
||||
continue
|
||||
}
|
||||
timelineByRule[strings.ToLower(e.RuleID)] = computed{
|
||||
name: e.Name, code: e.Code, dueDate: e.DueDate,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load events for the promoted proceedings only.
|
||||
events := []BuilderEvent{}
|
||||
if err := s.db.SelectContext(ctx, &events, `
|
||||
SELECT e.id, e.scenario_proceeding_id, e.sequencing_rule_id,
|
||||
e.procedural_event_id, e.custom_label, e.state, e.actual_date,
|
||||
e.skip_reason, e.notes, e.horizon_optional, e.created_at, e.updated_at
|
||||
FROM paliad.scenario_events e
|
||||
JOIN paliad.scenario_proceedings sp ON sp.id = e.scenario_proceeding_id
|
||||
WHERE sp.scenario_id = $1
|
||||
ORDER BY e.created_at ASC`, scenarioID); err != nil {
|
||||
return nil, fmt.Errorf("load events: %w", err)
|
||||
}
|
||||
|
||||
result := &PromoteResult{
|
||||
ProceedingsSkipped: topLevelCount - 1,
|
||||
}
|
||||
newProjectID := uuid.New()
|
||||
|
||||
err = s.withAuditTx(ctx, "scenario_builder: promote scenario", func(tx *sqlx.Tx) error {
|
||||
now := time.Now().UTC()
|
||||
|
||||
// 1. Project row. path is filled by the BEFORE INSERT trigger
|
||||
// (projects_sync_path); '' satisfies the NOT NULL constraint.
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO paliad.projects
|
||||
(id, type, parent_id, path, title, reference, status, created_by,
|
||||
case_number, client_number, proceeding_type_id, our_side,
|
||||
scenario_flags, origin_scenario_id, metadata, created_at, updated_at)
|
||||
VALUES ($1, 'case', $2, '', $3, $4, 'active', $5,
|
||||
$6, $7, $8, $9, $10::jsonb, $11, '{}'::jsonb, $12, $12)`,
|
||||
newProjectID, input.ParentID, title, input.Reference, userID,
|
||||
nullableTrimmed(stringPtrOrNil(input.CaseNumber)),
|
||||
nullableTrimmed(input.ClientNumber),
|
||||
primary.ProceedingTypeID, nullableOurSide(ourSide),
|
||||
[]byte(primary.ScenarioFlags), scenarioID, now); err != nil {
|
||||
return fmt.Errorf("insert project: %w", err)
|
||||
}
|
||||
|
||||
// 2a. Creator as team lead (RLS visibility, matches Create).
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO paliad.project_teams (project_id, user_id, role, responsibility, inherited, added_by)
|
||||
VALUES ($1, $2, 'lead', 'lead', false, $2)`, newProjectID, userID); err != nil {
|
||||
return fmt.Errorf("insert creator team row: %w", err)
|
||||
}
|
||||
// 2b. Wizard-selected colleagues.
|
||||
for _, tm := range input.TeamMembers {
|
||||
if tm.UserID == userID {
|
||||
continue // creator already added as lead
|
||||
}
|
||||
resp := tm.Responsibility
|
||||
if resp == "" {
|
||||
resp = ResponsibilityMember
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO paliad.project_teams (project_id, user_id, role, responsibility, inherited, added_by)
|
||||
VALUES ($1, $2, $3, $4, false, $5)
|
||||
ON CONFLICT (project_id, user_id) DO UPDATE
|
||||
SET role = EXCLUDED.role, responsibility = EXCLUDED.responsibility`,
|
||||
newProjectID, tm.UserID, legacyRoleFromResponsibility(resp), resp, userID); err != nil {
|
||||
return fmt.Errorf("insert team member: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Parties.
|
||||
for _, p := range input.Parties {
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO paliad.parties (project_id, name, role, representative, contact_info)
|
||||
VALUES ($1, $2, $3, $4, '{}'::jsonb)`,
|
||||
newProjectID, strings.TrimSpace(p.Name), p.Role, p.Representative); err != nil {
|
||||
return fmt.Errorf("insert party: %w", err)
|
||||
}
|
||||
result.PartiesCreated++
|
||||
}
|
||||
|
||||
// 4. Deadlines from the promoted proceedings' events.
|
||||
for _, ev := range events {
|
||||
if !promoteSet[ev.ScenarioProceedingID] {
|
||||
continue
|
||||
}
|
||||
if ev.State == "skipped" {
|
||||
continue
|
||||
}
|
||||
if ev.SequencingRuleID == nil {
|
||||
// Free-form / procedural-event-only cards have no rule to
|
||||
// anchor a deadline on in v1 — skip (counts as skipped only
|
||||
// when it was a dated plan; here just leave it out).
|
||||
continue
|
||||
}
|
||||
ruleKey := strings.ToLower(ev.SequencingRuleID.String())
|
||||
comp := timelineByRule[ruleKey]
|
||||
title := comp.name
|
||||
if strings.TrimSpace(title) == "" {
|
||||
title = "Litigation-Builder Frist"
|
||||
}
|
||||
ruleCode := comp.code
|
||||
|
||||
if ev.State == "filed" && ev.ActualDate != nil {
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO paliad.deadlines
|
||||
(project_id, title, rule_code, due_date, sequencing_rule_id,
|
||||
status, completed_at, source, approval_status)
|
||||
VALUES ($1, $2, $3, $4::date, $5, 'completed', $6::timestamptz, 'rule', 'legacy')`,
|
||||
newProjectID, title, nullableTrimmed(&ruleCode), *ev.ActualDate,
|
||||
*ev.SequencingRuleID, *ev.ActualDate); err != nil {
|
||||
return fmt.Errorf("insert filed deadline: %w", err)
|
||||
}
|
||||
result.DeadlinesCreated++
|
||||
continue
|
||||
}
|
||||
|
||||
// planned — need a date. Prefer an explicit actual_date
|
||||
// (court-set override the user pinned), else the computed date.
|
||||
var dueDate *time.Time
|
||||
if ev.ActualDate != nil {
|
||||
dueDate = ev.ActualDate
|
||||
} else if comp.dueDate != "" {
|
||||
if d, perr := time.Parse("2006-01-02", comp.dueDate); perr == nil {
|
||||
dueDate = &d
|
||||
}
|
||||
}
|
||||
if dueDate == nil {
|
||||
result.DeadlinesSkipped++
|
||||
continue
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO paliad.deadlines
|
||||
(project_id, title, rule_code, due_date, sequencing_rule_id,
|
||||
status, source, approval_status)
|
||||
VALUES ($1, $2, $3, $4::date, $5, 'pending', 'rule', 'legacy')`,
|
||||
newProjectID, title, nullableTrimmed(&ruleCode), *dueDate,
|
||||
*ev.SequencingRuleID); err != nil {
|
||||
return fmt.Errorf("insert planned deadline: %w", err)
|
||||
}
|
||||
result.DeadlinesCreated++
|
||||
}
|
||||
|
||||
// 5. Flip the scenario to promoted.
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE paliad.scenarios
|
||||
SET status = 'promoted', promoted_project_id = $1, updated_at = now()
|
||||
WHERE id = $2`, newProjectID, scenarioID); err != nil {
|
||||
return fmt.Errorf("mark scenario promoted: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("promote scenario: %w", err)
|
||||
}
|
||||
result.ProjectID = newProjectID
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// collectProceedingSubtree returns the set of proceeding ids rooted at
|
||||
// rootID (inclusive), walking parent_scenario_proceeding_id downwards.
|
||||
func collectProceedingSubtree(all []BuilderProceeding, rootID uuid.UUID) map[uuid.UUID]bool {
|
||||
set := map[uuid.UUID]bool{rootID: true}
|
||||
// Iterate to a fixpoint; depth is tiny (<=2 today) so a few passes suffice.
|
||||
for changed := true; changed; {
|
||||
changed = false
|
||||
for i := range all {
|
||||
p := &all[i]
|
||||
if p.ParentScenarioProceedingID != nil && set[*p.ParentScenarioProceedingID] && !set[p.ID] {
|
||||
set[p.ID] = true
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
// promoteStichtag picks the calc anchor for the promote timeline: the
|
||||
// primary proceeding's own stichtag, else the scenario default, else today.
|
||||
func promoteStichtag(primary *BuilderProceeding, sc *BuilderScenario) string {
|
||||
if primary.Stichtag != nil {
|
||||
return primary.Stichtag.Format("2006-01-02")
|
||||
}
|
||||
if sc.Stichtag != nil {
|
||||
return sc.Stichtag.Format("2006-01-02")
|
||||
}
|
||||
return time.Now().UTC().Format("2006-01-02")
|
||||
}
|
||||
|
||||
// scenarioFlagsTruthyKeys returns the flag keys set to boolean true in the
|
||||
// builder's scenario_flags jsonb — the array shape the calc engine's
|
||||
// CalcOptions.Flags consumes.
|
||||
func scenarioFlagsTruthyKeys(raw json.RawMessage) []string {
|
||||
if len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return nil
|
||||
}
|
||||
out := []string{}
|
||||
for k, v := range m {
|
||||
if b, ok := v.(bool); ok && b {
|
||||
out = append(out, k)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// stringPtrOrNil normalises a *string so an all-whitespace value becomes
|
||||
// nil before nullableTrimmed sees it (case_number empty → NULL column).
|
||||
func stringPtrOrNil(p *string) *string {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(*p) == "" {
|
||||
return nil
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
@@ -83,7 +83,7 @@ func TestScenarioBuilderService(t *testing.T) {
|
||||
t.Fatalf("look up upc.inf id: %v", err)
|
||||
}
|
||||
|
||||
svc := NewScenarioBuilderService(pool, nil, nil)
|
||||
svc := NewScenarioBuilderService(pool, nil, nil, nil)
|
||||
|
||||
// 1. Create a scenario for the owner. Empty name should default.
|
||||
sc, err := svc.CreateScenario(ctx, owner, CreateBuilderScenarioInput{})
|
||||
@@ -318,7 +318,7 @@ func TestScenarioBuilderAkteDualWrite(t *testing.T) {
|
||||
userSvc := NewUserService(pool)
|
||||
projSvc := NewProjectService(pool, userSvc)
|
||||
flagsSvc := NewScenarioFlagsService(pool, projSvc)
|
||||
svc := NewScenarioBuilderService(pool, projSvc, flagsSvc)
|
||||
svc := NewScenarioBuilderService(pool, projSvc, flagsSvc, nil)
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// Phase A — Akte-backed scenario writes through to project tables.
|
||||
@@ -459,6 +459,190 @@ func TestScenarioBuilderAkteDualWrite(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestScenarioBuilderPromote pins B5's load-bearing contract
|
||||
// (m/paliad#153 / t-paliad-350 / PRD §2.4 + §5.4 + §10): PromoteScenario
|
||||
// creates a paliad.projects 'case' row transactionally, cascades parties
|
||||
// + deadlines, flips the scenario to 'promoted' with a back-ref, and
|
||||
// makes the original scenario read-only afterwards.
|
||||
func TestScenarioBuilderPromote(t *testing.T) {
|
||||
url := os.Getenv("TEST_DATABASE_URL")
|
||||
if url == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
|
||||
}
|
||||
if err := db.ApplyMigrations(url); err != nil {
|
||||
t.Fatalf("apply migrations: %v", err)
|
||||
}
|
||||
pool, err := sqlx.Connect("postgres", url)
|
||||
if err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
ctx := context.Background()
|
||||
|
||||
owner := uuid.New()
|
||||
var createdProjectID uuid.UUID
|
||||
cleanup := func() {
|
||||
if createdProjectID != uuid.Nil {
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id = $1`, createdProjectID)
|
||||
}
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.scenarios WHERE owner_id = $1`, owner)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.users WHERE id = $1`, owner)
|
||||
pool.ExecContext(ctx, `DELETE FROM auth.users WHERE id = $1`, owner)
|
||||
}
|
||||
cleanup()
|
||||
defer cleanup()
|
||||
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO auth.users (id, email) VALUES ($1, 'promote-owner-test@hlc.com')`, owner); err != nil {
|
||||
t.Fatalf("seed auth.users: %v", err)
|
||||
}
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO paliad.users (id, email, display_name, office, lang)
|
||||
VALUES ($1, 'promote-owner-test@hlc.com', 'Promote Owner', 'munich', 'de')`, owner); err != nil {
|
||||
t.Fatalf("seed paliad.users: %v", err)
|
||||
}
|
||||
|
||||
var ptID int
|
||||
if err := pool.GetContext(ctx, &ptID,
|
||||
`SELECT id FROM paliad.proceeding_types WHERE code = $1 AND is_active = true LIMIT 1`,
|
||||
CodeUPCInfringement); err != nil {
|
||||
t.Fatalf("look up upc.inf id: %v", err)
|
||||
}
|
||||
// Two distinct rule ids: one filed, one planned (with an explicit
|
||||
// actual_date so the planned deadline lands even without a calc engine).
|
||||
var ruleIDs []uuid.UUID
|
||||
if err := pool.SelectContext(ctx, &ruleIDs,
|
||||
`SELECT id FROM paliad.sequencing_rules
|
||||
WHERE proceeding_type_id = $1 AND is_active = true AND lifecycle_state = 'published'
|
||||
ORDER BY sequence_order NULLS LAST, id LIMIT 2`, ptID); err != nil {
|
||||
t.Fatalf("look up sequencing_rules: %v", err)
|
||||
}
|
||||
if len(ruleIDs) < 2 {
|
||||
t.Skipf("need >=2 published rules for upc.inf; got %d", len(ruleIDs))
|
||||
}
|
||||
|
||||
userSvc := NewUserService(pool)
|
||||
projSvc := NewProjectService(pool, userSvc)
|
||||
flagsSvc := NewScenarioFlagsService(pool, projSvc)
|
||||
// fristenrechner nil — planned events carry an explicit actual_date in
|
||||
// this test, so the cascade doesn't need computed dates.
|
||||
svc := NewScenarioBuilderService(pool, projSvc, flagsSvc, nil)
|
||||
|
||||
sc, err := svc.CreateScenario(ctx, owner, CreateBuilderScenarioInput{Name: "Promote-Test"})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateScenario: %v", err)
|
||||
}
|
||||
pr, err := svc.AddProceeding(ctx, owner, sc.ID, AddProceedingInput{
|
||||
ProceedingTypeID: ptID,
|
||||
PrimaryParty: ptrString("defendant"),
|
||||
ScenarioFlags: json.RawMessage(`{"with_ccr": true}`),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("AddProceeding: %v", err)
|
||||
}
|
||||
filedDate := mustDate(t, "2026-01-15")
|
||||
if _, err := svc.AddEvent(ctx, owner, sc.ID, pr.ID, AddEventInput{
|
||||
SequencingRuleID: &ruleIDs[0], State: ptrString("filed"), ActualDate: &filedDate,
|
||||
}); err != nil {
|
||||
t.Fatalf("AddEvent filed: %v", err)
|
||||
}
|
||||
plannedDate := mustDate(t, "2026-04-01")
|
||||
if _, err := svc.AddEvent(ctx, owner, sc.ID, pr.ID, AddEventInput{
|
||||
SequencingRuleID: &ruleIDs[1], State: ptrString("planned"), ActualDate: &plannedDate,
|
||||
}); err != nil {
|
||||
t.Fatalf("AddEvent planned: %v", err)
|
||||
}
|
||||
|
||||
res, err := svc.PromoteScenario(ctx, owner, sc.ID, PromoteScenarioInput{
|
||||
Title: "Becker ./. X — UPC",
|
||||
CaseNumber: ptrString("UPC_CFI_123/2026"),
|
||||
OurSide: ptrString("defendant"),
|
||||
Parties: []PromotePartyInput{
|
||||
{Name: "Becker GmbH", Role: ptrString("defendant")},
|
||||
{Name: "X Corp", Role: ptrString("claimant")},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PromoteScenario: %v", err)
|
||||
}
|
||||
createdProjectID = res.ProjectID
|
||||
if res.ProjectID == uuid.Nil {
|
||||
t.Fatal("PromoteScenario returned nil project id")
|
||||
}
|
||||
if res.PartiesCreated != 2 {
|
||||
t.Errorf("PartiesCreated = %d, want 2", res.PartiesCreated)
|
||||
}
|
||||
if res.DeadlinesCreated != 2 {
|
||||
t.Errorf("DeadlinesCreated = %d, want 2 (1 filed + 1 planned-with-date)", res.DeadlinesCreated)
|
||||
}
|
||||
|
||||
// Project row exists, is a 'case', carries origin_scenario_id + flags.
|
||||
var proj struct {
|
||||
Type string `db:"type"`
|
||||
OriginScenario *uuid.UUID `db:"origin_scenario_id"`
|
||||
ProceedingType *int `db:"proceeding_type_id"`
|
||||
OurSide *string `db:"our_side"`
|
||||
ScenarioFlags json.RawMessage `db:"scenario_flags"`
|
||||
CaseNumber *string `db:"case_number"`
|
||||
}
|
||||
if err := pool.GetContext(ctx, &proj,
|
||||
`SELECT type, origin_scenario_id, proceeding_type_id, our_side, scenario_flags, case_number
|
||||
FROM paliad.projects WHERE id = $1`, res.ProjectID); err != nil {
|
||||
t.Fatalf("load promoted project: %v", err)
|
||||
}
|
||||
if proj.Type != "case" {
|
||||
t.Errorf("project type = %q, want case", proj.Type)
|
||||
}
|
||||
if proj.OriginScenario == nil || *proj.OriginScenario != sc.ID {
|
||||
t.Errorf("origin_scenario_id = %v, want %v", proj.OriginScenario, sc.ID)
|
||||
}
|
||||
if proj.ProceedingType == nil || *proj.ProceedingType != ptID {
|
||||
t.Errorf("proceeding_type_id = %v, want %d", proj.ProceedingType, ptID)
|
||||
}
|
||||
if proj.OurSide == nil || *proj.OurSide != "defendant" {
|
||||
t.Errorf("our_side = %v, want defendant", proj.OurSide)
|
||||
}
|
||||
|
||||
// Scenario flipped to promoted with the back-ref.
|
||||
var after BuilderScenario
|
||||
if err := pool.GetContext(ctx, &after,
|
||||
`SELECT id, owner_id, name, status, origin_project_id, promoted_project_id,
|
||||
stichtag, notes, project_id, description, created_by, created_at, updated_at
|
||||
FROM paliad.scenarios WHERE id = $1`, sc.ID); err != nil {
|
||||
t.Fatalf("reload scenario: %v", err)
|
||||
}
|
||||
if after.Status != "promoted" {
|
||||
t.Errorf("scenario status = %q, want promoted", after.Status)
|
||||
}
|
||||
if after.PromotedProjectID == nil || *after.PromotedProjectID != res.ProjectID {
|
||||
t.Errorf("promoted_project_id = %v, want %v", after.PromotedProjectID, res.ProjectID)
|
||||
}
|
||||
|
||||
// Deadlines + parties physically present.
|
||||
var deadlineCount, partyCount int
|
||||
pool.GetContext(ctx, &deadlineCount,
|
||||
`SELECT COUNT(*) FROM paliad.deadlines WHERE project_id = $1`, res.ProjectID)
|
||||
pool.GetContext(ctx, &partyCount,
|
||||
`SELECT COUNT(*) FROM paliad.parties WHERE project_id = $1`, res.ProjectID)
|
||||
if deadlineCount != 2 {
|
||||
t.Errorf("deadlines in DB = %d, want 2", deadlineCount)
|
||||
}
|
||||
if partyCount != 2 {
|
||||
t.Errorf("parties in DB = %d, want 2", partyCount)
|
||||
}
|
||||
|
||||
// Promoted scenario is now read-only: further PATCH is rejected.
|
||||
if _, err := svc.PatchScenario(ctx, owner, sc.ID, PatchBuilderScenarioInput{
|
||||
Name: ptrString("rename-after-promote"),
|
||||
}); !errors.Is(err, ErrInvalidInput) {
|
||||
t.Errorf("PatchScenario after promote = %v, want ErrInvalidInput", err)
|
||||
}
|
||||
// Re-promoting is rejected.
|
||||
if _, err := svc.PromoteScenario(ctx, owner, sc.ID, PromoteScenarioInput{Title: "again"}); !errors.Is(err, ErrInvalidInput) {
|
||||
t.Errorf("re-promote = %v, want ErrInvalidInput", err)
|
||||
}
|
||||
}
|
||||
|
||||
// mustDate parses an ISO date or fails the test. Helper for the
|
||||
// dual-write test above.
|
||||
func mustDate(t *testing.T, s string) time.Time {
|
||||
|
||||
Reference in New Issue
Block a user