Files
Investidor-V2/resources/js/pages/management/experiments/edit.tsx
T

723 lines
32 KiB
TypeScript

import { Link, router } from '@inertiajs/react';
import { useState } from 'react';
import { Frame, card, input, button } from '@/pages/investment/shared';
import { profileJson, ProfileValidationError } from '@/lib/profile-json';
import { createOperationKey } from '@/lib/operation-key';
import {
experimentDifferences,
mergeExperimentValues,
type ExperimentView,
} from '@/lib/product-experiments';
import { usageValue } from '@/lib/product-usage';
export default function ExperimentPage(props: ExperimentView) {
return (
<Editor
key={`${props.id ?? 'new'}:${props.document?.version ?? 0}:${props.historical}`}
{...props}
/>
);
}
function Editor(props: ExperimentView) {
const [values, setValues] = useState(props.values);
const [base, setBase] = useState(props.values);
const [editedKeys, setEditedKeys] = useState<string[]>([]);
const [referenceDocument, setReferenceDocument] = useState(props.document);
const [catalogue, setCatalogue] = useState(props.catalogue);
const [version, setVersion] = useState(
props.document?.version ?? props.currentVersion,
);
const [operation, setOperation] = useState(createOperationKey);
const [reason, setReason] = useState('');
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState('');
const [errors, setErrors] = useState<Record<string, string>>({});
const [current, setCurrent] = useState<ExperimentView | null>(null);
const [resolved, setResolved] = useState<string[]>([]);
const [confirmPlan, setConfirmPlan] = useState(false);
const [confirmObservation, setConfirmObservation] = useState(false);
const [refreshIndicator, setRefreshIndicator] = useState(false);
const [attachReport, setAttachReport] = useState(false);
const [removeReport, setRemoveReport] = useState(false);
const readonly = props.historical || !props.installed;
const document = referenceDocument;
const differences = current
? experimentDifferences(
base,
values,
current.values,
editedKeys,
).filter((k) => !resolved.includes(k))
: [];
const planComplete = !['draft', 'cancelled'].includes(values.state);
const showObservation =
['observing', 'reviewed'].includes(values.state) ||
[
'observed_from',
'observed_to',
'control_result',
'variant_result',
'result',
'decision',
].some((k) => values[k]);
const frozenIndicator =
document?.indicator?.id === values.indicator_id && !refreshIndicator
? document.indicator
: null;
const selectedIndicator =
frozenIndicator ??
catalogue.items.find((m) => m.id === values.indicator_id);
function change(key: string, value: string) {
setEditedKeys((keys) => (keys.includes(key) ? keys : [...keys, key]));
setValues((v) => ({ ...v, [key]: value }));
}
function field(key: string, required = false, type = 'textarea') {
const error = errors['values.' + key];
return (
<label className="block space-y-1" key={key}>
<span>{props.labels[key]}</span>
{type === 'textarea' ? (
<textarea
className={input}
rows={3}
maxLength={5000}
readOnly={readonly}
required={required}
value={values[key] ?? ''}
onChange={(e) => change(key, e.target.value)}
/>
) : (
<input
className={input}
type={type}
readOnly={readonly}
required={required}
value={values[key] ?? ''}
maxLength={key === 'title' ? 200 : 1500}
onChange={(e) => change(key, e.target.value)}
/>
)}
{error && (
<span role="alert" className="block">
{error}
</span>
)}
</label>
);
}
async function save() {
if (readonly || current !== null) return;
setBusy(true);
setMessage('');
setErrors({});
try {
const result = await profileJson(
props.id
? `/management/product-experiments/${props.id}/revisions`
: '/management/product-experiments',
{
version,
operation_key: operation,
reason,
values,
confirm_plan_change: confirmPlan,
confirm_observation_plan: confirmObservation,
refresh_indicator: refreshIndicator,
attach_report: attachReport,
remove_report: removeReport,
indicator_definition_version: catalogue.definition_version,
},
);
router.visit('/management/product-experiments/' + result.id, {
preserveState: false,
});
} catch (error) {
setMessage(
error instanceof Error
? error.message
: 'Não foi possível guardar. As suas alterações continuam neste formulário.',
);
if (error instanceof ProfileValidationError)
setErrors(error.fields);
} finally {
setBusy(false);
}
}
async function compare() {
if (!props.id) return;
setBusy(true);
try {
setCurrent(
await profileJson(
'/management/product-experiments/' + props.id,
{},
'GET',
),
);
setResolved([]);
} catch (error) {
setMessage(
error instanceof Error
? error.message
: 'Não foi possível consultar a versão actual.',
);
} finally {
setBusy(false);
}
}
function adoptBase() {
if (!current || differences.length > 0) return;
const merged = mergeExperimentValues(
base,
values,
current.values,
editedKeys,
);
setValues(merged);
setEditedKeys(
Object.keys(merged).filter((k) => merged[k] !== current.values[k]),
);
setBase(current.values);
setReferenceDocument(current.document);
setCatalogue(current.catalogue);
setVersion(current.currentVersion);
setOperation(createOperationKey());
setCurrent(null);
setMessage(
'Versão actual usada como base. Confira o formulário e guarde quando estiver pronto.',
);
}
function download() {
if (!document) return;
const url = URL.createObjectURL(
new Blob([JSON.stringify(document, null, 2)], {
type: 'application/json',
}),
);
const anchor = window.document.createElement('a');
anchor.href = url;
anchor.download = `experiencia-${props.id}-v${document.version}.json`;
anchor.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
return (
<Frame
title={
props.historical
? `Experiência · versão histórica ${document?.version}`
: 'Preparar e acompanhar experiência'
}
>
<Link className="underline" href="/management/product-experiments">
Registo de experiências
</Link>
<p>
Os dados deste registo são declarações e referências do autor. A
distribuição de grupos e a execução da alteração ocorrem nos
percursos autorizados correspondentes. Este formulário não
altera consentimentos, permissões ou decisões de investimento.
</p>
{!props.installed && (
<p role="status">
O registo ainda não está disponível neste destino.
</p>
)}
{props.historical && (
<p role="status">
Consulta da versão {document?.version}.{' '}
<Link
className="underline"
href={'/management/product-experiments/' + props.id}
>
Abrir versão actual
</Link>
</p>
)}
{document && (
<section
className={card + ' space-y-2'}
aria-label="Versão consultada"
>
<p>
Versão {document.version}, registada em {document.at}{' '}
por {document.actor.name}.
</p>
<p>Motivo: {document.reason || 'Primeiro registo.'}</p>
{document.registration.first_at && (
<p>
Primeiro plano registado em{' '}
{document.registration.first_at}. Alterações
posteriores do plano:{' '}
{document.registration.amendments}. A data de
registo é conservada; não demonstra, por si, registo
anterior à execução.
</p>
)}
{document.registration.amended_after_observation && (
<p>
O plano foi alterado após uma observação declarada.
Consulte as versões e as limitações antes de
comparar resultados.
</p>
)}
<button type="button" className={button} onClick={download}>
Exportar versão consultada (JSON)
</button>
</section>
)}
{message && <p role="alert">{message}</p>}
{!readonly && props.id && (
<button
type="button"
className={button}
disabled={busy}
onClick={compare}
>
Consultar versão actual para comparar
</button>
)}
{current && (
<section
className={card + ' space-y-3'}
aria-label="Comparação com a versão actual"
>
<h2 className="text-xl font-semibold">
Comparar com versão {current.currentVersion}
</h2>
<p>
As suas edições foram conservadas. Escolha como tratar
cada diferença; os campos que não editou serão
actualizados a partir da versão consultada.
</p>
{differences.map((k) => (
<div className="rounded border p-3" key={k}>
<h3 className="font-semibold">
{props.labels[k] ?? k}
</h3>
<p className="whitespace-pre-wrap">
Sua edição: {values[k] || '(vazio)'}
</p>
<p className="whitespace-pre-wrap">
Versão actual: {current.values[k] || '(vazio)'}
</p>
<div className="flex gap-3">
<button
type="button"
className={button}
onClick={() =>
setResolved((r) => [...r, k])
}
>
Manter a minha edição
</button>
<button
type="button"
className={button}
onClick={() => {
change(k, current.values[k] ?? '');
setResolved((r) => [...r, k]);
}}
>
Usar valor actual
</button>
</div>
</div>
))}
<button
type="button"
className={button}
disabled={differences.length > 0}
onClick={adoptBase}
>
Usar esta versão como base e rever
</button>
</section>
)}
<form
className="space-y-5"
onSubmit={(e) => {
e.preventDefault();
void save();
}}
>
<fieldset className={card + ' space-y-3'} disabled={readonly}>
<legend className="text-xl font-semibold">
Plano e alteração
</legend>
{field('title', planComplete, 'text')}
<label>
Estado do registo
<select
className={input}
value={values.state}
onChange={(e) => change('state', e.target.value)}
>
{Object.entries(props.states).map(([k, label]) => (
<option key={k} value={k}>
{label}
</option>
))}
</select>
</label>
<label>
Tipo de alteração
<select
className={input}
value={values.change_kind}
onChange={(e) =>
change('change_kind', e.target.value)
}
>
<option value="">Ainda por definir</option>
{Object.entries(props.changeKinds).map(
([k, label]) => (
<option key={k} value={k}>
{label}
</option>
),
)}
</select>
</label>
{field('reference', planComplete)}
{field('hypothesis', planComplete)}
<label>
Indicador
<select
className={input}
value={values.indicator_id}
onChange={(e) =>
change('indicator_id', e.target.value)
}
>
<option value="">Ainda por definir</option>
{catalogue.items.map((m) => (
<option key={m.id} value={m.id}>
{m.title}
</option>
))}
{document?.indicator &&
document.indicator.id !== 'manual' &&
!catalogue.items.some(
(m) => m.id === document.indicator?.id,
) && (
<option value={document.indicator.id}>
{document.indicator.title} · definição
conservada
</option>
)}
<option value="manual">
Documentar indicador próprio
</option>
</select>
</label>
{values.indicator_id === 'manual' ? (
<>
{field('indicator_title', planComplete, 'text')}
{field('indicator_definition', planComplete)}
{field('indicator_unit', planComplete, 'text')}
</>
) : (
selectedIndicator && (
<details>
<summary className="cursor-pointer underline">
Definição do indicador seleccionado
</summary>
<p>{selectedIndicator.source}</p>
<p>{selectedIndicator.limits}</p>
<p>
Unidade: {selectedIndicator.unit}. Edição:{' '}
{frozenIndicator?.definition_version ??
catalogue.definition_version}
.
</p>
</details>
)
)}
{document?.indicator &&
document.indicator.id !== 'manual' &&
document.indicator.id === values.indicator_id &&
document.indicator.definition_version !==
catalogue.definition_version && (
<label className="block">
<input
type="checkbox"
checked={refreshIndicator}
onChange={(e) =>
setRefreshIndicator(e.target.checked)
}
/>{' '}
Adoptar explicitamente a definição actual do
indicador
</label>
)}
{field('period_from', planComplete, 'date')}
{field('period_to', planComplete, 'date')}
{field('segment', planComplete)}
{field('control', planComplete)}
{field('variant', planComplete)}
{field('assignment', planComplete)}
{field('criterion', planComplete)}
</fieldset>
<fieldset className={card + ' space-y-3'} disabled={readonly}>
<legend className="text-xl font-semibold">
Preservação dos requisitos
</legend>
{['transparency', 'consent', 'security', 'equality'].map(
(k) => field(k, planComplete),
)}
<label className="block">
<input
type="checkbox"
checked={values.preserves_requirements === 'yes'}
onChange={(e) =>
change(
'preserves_requirements',
e.target.checked ? 'yes' : '',
)
}
/>{' '}
O plano mantém a transparência, o consentimento, a
segurança e a igualdade de tratamento.
</label>
</fieldset>
{showObservation && (
<fieldset
className={card + ' space-y-3'}
disabled={readonly}
>
<legend className="text-xl font-semibold">
Observação e decisão
</legend>
<p>
Identifique as evidências e descreva as bases de
comparação. Não inclua listas de pessoas ou dados de
visitantes.
</p>
{field(
'observed_from',
values.state === 'reviewed' ||
values.state === 'observing',
'date',
)}
{field(
'observed_to',
values.state === 'reviewed',
'date',
)}
{[
'control_result',
'variant_result',
'result',
'limitations',
'evidence',
].map((k) => field(k, values.state === 'reviewed'))}
<label>
Decisão declarada
<select
className={input}
value={values.decision}
onChange={(e) =>
change('decision', e.target.value)
}
>
<option value="">Ainda sem decisão</option>
{Object.entries(props.decisions).map(
([k, label]) => (
<option key={k} value={k}>
{label}
</option>
),
)}
</select>
</label>
{field('decision_reason', values.state === 'reviewed')}
<label className="block">
<input
type="checkbox"
checked={confirmObservation}
onChange={(e) =>
setConfirmObservation(e.target.checked)
}
/>{' '}
Confirmei que estas observações se referem ao plano
desta versão
</label>
<label>
Serviço do relatório de contexto
<select
className={input}
value={values.report_service}
onChange={(e) =>
change('report_service', e.target.value)
}
>
<option value="">Todos os serviços</option>
{catalogue.services.map((s) => (
<option key={s.code} value={s.code}>
{s.title} ({s.code})
</option>
))}
</select>
</label>
<label className="block">
<input
type="checkbox"
checked={attachReport}
onChange={(e) => {
setAttachReport(e.target.checked);
if (e.target.checked)
setRemoveReport(false);
}}
/>{' '}
Anexar relatório operacional do período observado ao
guardar
</label>
<p>
Este relatório dá contexto à coorte operacional. Não
identifica controlo/variante, não mede o segmento
livre acima e não comprova o efeito da alteração.
</p>
{document?.operational_report && (
<label className="block">
<input
type="checkbox"
checked={removeReport}
onChange={(e) => {
setRemoveReport(e.target.checked);
if (e.target.checked)
setAttachReport(false);
}}
/>{' '}
Retirar o relatório da próxima versão; conservar
no histórico
</label>
)}
</fieldset>
)}
{!readonly && (
<section className={card + ' space-y-3'}>
{document?.registration.first_at && (
<label className="block">
<input
type="checkbox"
checked={confirmPlan}
onChange={(e) =>
setConfirmPlan(e.target.checked)
}
/>{' '}
Confirmo a alteração do plano já registado; as
versões e observações anteriores permanecem no
histórico
</label>
)}
<label className="block">
Motivo desta versão
<textarea
className={input}
required={
props.id !== null ||
values.state === 'cancelled'
}
minLength={3}
maxLength={3000}
value={reason}
onChange={(e) => setReason(e.target.value)}
/>
</label>
{Object.entries(errors).map(([key, value]) => (
<p role="alert" key={key}>
{props.labels[key.replace('values.', '')] ??
key}
: {value}
</p>
))}
<button
className={button}
disabled={busy || current !== null}
>
{busy ? 'A guardar…' : 'Guardar versão do registo'}
</button>
</section>
)}
</form>
{document?.operational_report && (
<section
className={card + ' space-y-3'}
aria-label="Relatório de contexto conservado"
>
<h2 className="text-xl font-semibold">
Relatório operacional conservado nesta versão
</h2>
<p>
Período {document.operational_report.filters.from} a{' '}
{document.operational_report.filters.to}; serviço{' '}
{document.operational_report.filters.service_code ??
'todos'}
. Gerado em {document.operational_report.generated_at}.
</p>
{!document.report_matches_observation && (
<p>
Os filtros deste relatório diferem da observação
actual. O relatório anterior foi conservado; pode
anexar outro numa nova versão.
</p>
)}
<p>
Não separa grupos da experiência nem comprova
causalidade.
</p>
<ul>
{document.operational_report.metrics.map((m) => (
<li key={m.id}>
{m.title}: {usageValue(m.value, m.unit)} ·{' '}
{m.state === 'partial'
? 'cobertura parcial'
: m.state === 'unmeasured'
? 'sem base para calcular'
: 'observado'}
</li>
))}
</ul>
</section>
)}
{document &&
document.observation_plan_sha256 !== document.plan_sha256 &&
showObservation && (
<p role="status">
As observações conservadas referem-se a um plano
anterior. Reveja as diferenças e as evidências antes de
as reafirmar para o plano actual.
</p>
)}
{props.history.length > 0 && (
<section
className={card + ' space-y-3'}
aria-label="Histórico de versões"
>
<h2 className="text-xl font-semibold">Histórico</h2>
{props.history.map((h) => (
<p key={h.version}>
<Link
className="underline"
href={`/management/product-experiments/${props.id}?revision=${h.version}`}
>
Versão {h.version}
</Link>{' '}
· {h.at} · {h.actor} ·{' '}
{props.states[h.state] ?? h.state}. {h.reason}
</p>
))}
{props.historyNext !== null && (
<Link
className="underline"
href={`/management/product-experiments/${props.id}?history_before=${props.historyNext}`}
>
Versões anteriores
</Link>
)}
</section>
)}
</Frame>
);
}