fix(licensing): simplify S08 and reveal linked stage without losing context

This commit is contained in:
2026-11-10 11:00:56 +02:00
parent 95def7f75e
commit 6734441855
3 changed files with 315 additions and 18 deletions
+15
View File
@@ -0,0 +1,15 @@
/** Reveal an existing stage without navigation, remounting fields or changing context. */
export function revealPathStage(root: HTMLElement, hash: string, codes: string[]): boolean {
const code = hash.startsWith("#") ? hash.slice(1) : "";
if (!codes.includes(code)) return false;
const target = root.ownerDocument.getElementById(code);
if (!target || !root.contains(target)) return false;
let current: HTMLElement | null = target;
while (current && current !== root) {
if (current.tagName === "DETAILS") (current as HTMLDetailsElement).open = true;
current = current.parentElement;
}
target.scrollIntoView({ block: "start" });
target.focus({ preventScroll: true });
return true;
}
+45 -18
View File
@@ -16,7 +16,8 @@ import FintechInvestment, {
} from '@/components/fintech-investment';
import { createOperationKey } from '@/lib/operation-key';
import { Head, Link, router, useForm, usePage } from '@inertiajs/react';
import { useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { revealPathStage } from '@/lib/investor-path-stage';
import {
pathStates,
recoverPathResult,
@@ -177,16 +178,26 @@ function Stage({ step, path }: { step: Step; path: Path }) {
'expired',
];
const states = path.canWrite ? ownerStates : reviewStates;
const licensing = step.code === 'S08';
const Container = licensing ? 'section' : 'details';
const Heading = licensing ? 'h3' : 'summary';
const Reference = licensing ? 'div' : 'details';
const ReferenceHeading = licensing ? 'h4' : 'summary';
const Record = licensing ? 'section' : 'details';
const RecordHeading = licensing ? 'h4' : 'summary';
return (
<details
<Container
id={step.code}
open={path.next?.code === step.code}
className="space-y-3 rounded-xl border p-4"
tabIndex={-1}
open={licensing ? undefined : path.next?.code === step.code}
className={`space-y-3 rounded-xl border p-4 ${licensing ? 'md:col-span-2' : ''}`}
>
<summary className="cursor-pointer font-semibold">
<Heading
className={licensing ? 'text-lg font-semibold' : 'cursor-pointer font-semibold'}
>
{step.title} · {pathStates[step.state]}
</summary>
<p>{pathStates[step.state]}</p>
</Heading>
{!licensing && <p>{pathStates[step.state]}</p>}
{step.context_review_required && (
<label className="block rounded border border-amber-600 bg-amber-50 p-3">
<input
@@ -258,10 +269,10 @@ function Stage({ step, path }: { step: Step; path: Path }) {
. Pode continuar a preparar.
</p>
))}
<details className="text-xs">
<summary className="cursor-pointer underline">
<Reference className="text-xs">
<ReferenceHeading className={licensing ? 'font-semibold' : 'cursor-pointer underline'}>
Fonte e referência desta etapa
</summary>
</ReferenceHeading>
<p>
Referência interna {step.code}. Documento original,
parágrafo {step.source_paragraph}.{' '}
@@ -271,12 +282,14 @@ function Stage({ step, path }: { step: Step; path: Path }) {
? 'Acto externo registado a partir de documento.'
: 'Preparação declarada pelo promotor.'}
</p>
</details>
</Reference>
{step.preparation_allowed && (path.canWrite || path.canReview) && (
<details>
<summary className="cursor-pointer underline">
<Record
aria-label={licensing ? 'Registar licenciamento' : undefined}
>
<RecordHeading className={licensing ? 'font-semibold' : 'cursor-pointer underline'}>
Registar avanço ou pendência
</summary>
</RecordHeading>
<form
className="mt-3 grid gap-3"
onSubmit={(event) => {
@@ -424,18 +437,32 @@ function Stage({ step, path }: { step: Step; path: Path }) {
))}
<button
disabled={form.processing}
className="rounded border p-2"
className={licensing
? 'rounded bg-emerald-800 px-4 py-3 font-semibold text-white disabled:opacity-50'
: 'rounded border p-2'}
>
Guardar registo
</button>
</form>
</details>
</Record>
)}
</details>
</Container>
);
}
export default function InvestmentPath(path: Path) {
const root = useRef<HTMLElement>(null);
const stageCodes = path.steps.map((step) => step.code).join(',');
useEffect(() => {
const reveal = () => {
if (root.current) {
revealPathStage(root.current, window.location.hash, stageCodes.split(','));
}
};
reveal();
window.addEventListener('hashchange', reveal);
return () => window.removeEventListener('hashchange', reveal);
}, [path.case.id, path.context_opportunity, stageCodes]);
const { auth, flash } = usePage<{
auth: { user: { id: number } };
flash: { success?: string };
@@ -471,7 +498,7 @@ export default function InvestmentPath(path: Path) {
);
}
return (
<main className="mx-auto max-w-6xl min-w-0 space-y-6 p-4 [overflow-wrap:anywhere] md:p-8 [&_input]:max-w-full [&_input]:min-w-0 [&_label]:min-w-0 [&_select]:max-w-full [&_select]:min-w-0">
<main ref={root} className="mx-auto max-w-6xl min-w-0 space-y-6 p-4 [overflow-wrap:anywhere] md:p-8 [&_input]:max-w-full [&_input]:min-w-0 [&_label]:min-w-0 [&_select]:max-w-full [&_select]:min-w-0">
<Head title={path.case.title} />
<Link className="underline" href="/investimentos">
Os meus investimentos
+255
View File
@@ -0,0 +1,255 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import { compileFunction } from "node:vm";
import { createRequire } from "node:module";
import ts from "typescript";
import React from "react";
import { revealPathStage } from "../../resources/js/lib/investor-path-stage.ts";
const require = createRequire(import.meta.url);
const flatten = (node) =>
Array.isArray(node)
? node.flatMap(flatten)
: node && typeof node === "object"
? [node, ...flatten(node.props?.children)]
: [];
const text = (node) =>
Array.isArray(node)
? node.map(text).join("")
: node && typeof node === "object"
? text(node.props?.children)
: String(node ?? "");
function harness(overrides = {}, react = React) {
const requests = [];
let form;
const module = { exports: {} };
const source = fs.readFileSync(
new URL("../../resources/js/pages/investor-path/show.tsx", import.meta.url),
"utf8",
);
const code = ts.transpileModule(source, {
compilerOptions: {
module: ts.ModuleKind.CommonJS,
jsx: ts.JsxEmit.ReactJSX,
},
}).outputText;
compileFunction(code + "\nmodule.exports.Stage = Stage;", ["require", "module", "exports"])(
(name) =>
name === "react"
? react
: name === "@inertiajs/react"
? {
usePage: () => ({ props: { auth: { user: { id: 1 } }, flash: {} } }),
useForm(initial) {
form ??= {
data: initial,
errors: {},
processing: false,
setData(key, value) {
this.data[key] = value;
},
transform(fn) {
this.convert = fn;
},
post(url, options) {
requests.push({ url, options, data: this.convert(this.data) });
},
};
return form;
},
}
: name === "@/lib/investor-path"
? { pathStates: { preparing: "Em preparação" } }
: name === "@/lib/operation-key" ? { createOperationKey: () => 'qa-key' }
: name === "@/lib/investor-path-stage" ? { revealPathStage }
: name === "@/components/proactive-investment" ? { proactiveView: () => null }
: name.startsWith("@/")
? {}
: require(name),
module,
module.exports,
);
const path = {
case: { id: "dossier-qa", version: 7 },
context_opportunity: "opportunity:42",
investment_context: { revision: 3 },
canWrite: true,
canReview: false,
steps: [],
next: { code: "S02" },
documents: [{ document_id: "doc-qa", file_name: "Prova QA", malware_scan_status: "clean" }],
...overrides,
};
const step = {
code: "S08",
title: "Licenciamento prévio",
state: "preparing",
preparation_allowed: true,
action_gaps: {},
detail: {},
source_paragraph: 8,
};
return {
render: (changes = {}) => module.exports.Stage({ step: { ...step, ...changes }, path }),
renderPage: () => module.exports.default(path),
requests,
form: () => form,
path,
};
}
test("S08 has one current state, a visible primary save action and no nested disclosure", () => {
const h = harness();
const tree = h.render();
const nodes = flatten(tree);
assert.equal(tree.type, "section");
assert.equal(nodes.filter((n) => n.type === "details").length, 0);
assert.equal(
nodes.filter((n) => ["h3", "p"].includes(n.type) && text(n).includes("Em preparação"))
.length,
1,
);
assert.equal(nodes.find((n) => n.type === "button").props.disabled, false);
assert.match(nodes.find((n) => n.type === "button").props.className, /bg-emerald/);
assert.equal(nodes.filter((n) => n.type === "form").length, 1);
assert.equal(tree.props.tabIndex, -1);
});
test("editing and saving retain context, current version, fields and existing endpoint", () => {
const h = harness({ canReview: true });
let tree = h.render();
const form = h.form();
Object.assign(form.data, {
reason: "Fundamento deliberado",
reference: "REF-QA",
authority: "Entidade QA",
document_id: "doc-qa",
decision_date: "2026-11-10",
context_reviewed: true,
});
h.path.case.version = 8;
tree = h.render();
assert.equal(form.data.reason, "Fundamento deliberado");
assert.equal(flatten(tree).filter((n) => n.type === "input").length, 3);
flatten(tree)
.find((n) => n.type === "form")
.props.onSubmit({ preventDefault() {} });
assert.deepEqual(h.requests, [
{
url: "/investimentos/dossier-qa/etapas/S08",
options: { preserveScroll: true },
data: { ...form.data, version: 8 },
},
]);
});
test("read-only and inapplicable stages do not acquire write controls; errors and processing remain visible", () => {
const read = harness({ canWrite: false, canReview: false });
assert.equal(flatten(read.render()).filter((n) => n.type === "form").length, 0);
const h = harness();
h.render();
assert.equal(
flatten(h.render({ preparation_allowed: false })).filter((n) => n.type === "form").length,
0,
);
h.form().processing = true;
h.form().errors = { reason: "Conflito de versão" };
const nodes = flatten(h.render());
assert.equal(nodes.find((n) => n.type === "button").props.disabled, true);
assert.equal(text(nodes.find((n) => n.props.role === "alert")), "Conflito de versão");
});
test("other stages keep their existing disclosure and reviewer fields/action choices", () => {
const h = harness({ canWrite: false, canReview: true });
const tree = h.render({ code: "S07" });
assert.equal(tree.type, "details");
assert.equal(flatten(tree).filter((n) => n.type === "details").length, 3);
assert.equal(h.form().data.action, "review");
assert.match(text(tree), /Registar acto externo/);
assert.match(text(tree), /Documento de suporte/);
});
function targetFixture() {
const calls = [];
const root = {
contains: (node) => node === target,
ownerDocument: { getElementById: (id) => (id === "S08" ? target : null) },
};
const phase = { tagName: "DETAILS", open: false, parentElement: root };
const target = {
tagName: "SECTION",
parentElement: phase,
scrollIntoView: (options) => calls.push(["scroll", options]),
focus: (options) => calls.push(["focus", options]),
};
return { root, phase, target, calls };
}
test("deep-link opens the containing phase and focuses S08 without navigation or changing fields", () => {
const f = targetFixture();
assert.equal(revealPathStage(f.root, "#S08", ["S02", "S08"]), true);
assert.equal(f.phase.open, true);
assert.deepEqual(f.calls, [
["scroll", { block: "start" }],
["focus", { preventScroll: true }],
]);
f.phase.open = false;
assert.equal(revealPathStage(f.root, "#S08", ["S08"]), true);
assert.equal(f.phase.open, true);
});
test("unknown, absent or out-of-scope hash targets have no effect", () => {
const f = targetFixture();
for (const hash of ["", "#unknown", "#S08%22", "#S07"])
assert.equal(revealPathStage(f.root, hash, ["S08"]), false);
f.root.contains = () => false;
assert.equal(revealPathStage(f.root, "#S08", ["S08"]), false);
assert.equal(f.phase.open, false);
assert.deepEqual(f.calls, []);
});
test("page reveals the initial hash, listens for later hash changes and cleans up the listener", () => {
const f = targetFixture(),
effects = [],
listeners = new Map();
const previousWindow = globalThis.window;
globalThis.window = {
location: { hash: "#S08" },
addEventListener: (type, fn) => listeners.set(type, fn),
removeEventListener: (type, fn) => {
assert.equal(listeners.get(type), fn);
listeners.delete(type);
},
};
try {
const h = harness(
{
investment_context: null,
facts: {},
fact_reasons: {},
phases: {},
fintech: null,
sector_contexts: [],
requests: [],
tasks: [],
results: [],
tools: {},
content_sources: [],
services: [],
steps: [{ code: "S08", preparation_allowed: true }],
},
{
...React,
useRef: () => ({ current: f.root }),
useEffect: (effect) => effects.push(effect),
useState: (initial) => [
typeof initial === "function" ? initial() : initial,
() => {},
],
},
);
h.renderPage();
assert.equal(f.phase.open, false);
const cleanup = effects[0]();
assert.equal(f.phase.open, true);
f.phase.open = false;
listeners.get("hashchange")();
assert.equal(f.phase.open, true);
cleanup();
assert.equal(listeners.size, 0);
} finally {
globalThis.window = previousWindow;
}
});