268 lines
11 KiB
TypeScript
268 lines
11 KiB
TypeScript
import { Link, usePage } from '@inertiajs/react';
|
|
import { Frame, card, input, button } from '@/pages/investment/shared';
|
|
import { usageExport, usageValue, type UsageReport } from '@/lib/product-usage';
|
|
import SearchUsage from '@/components/search-usage-report';
|
|
|
|
const coverageLabels: Record<string, string> = {
|
|
cohort_requests: 'Pedidos na coorte',
|
|
complete_histories: 'Com historial completo',
|
|
unknown_histories: 'Sem historial suficiente',
|
|
classified_returned_requests: 'Devolvidos com causa determinada',
|
|
unclassified_returned_requests: 'Devolvidos com causa por classificar',
|
|
valid_duration_requests: 'Com duração válida',
|
|
invalid_duration_requests: 'Com intervalo negativo excluído',
|
|
};
|
|
|
|
export default function ProductUsage({ report }: { report: UsageReport }) {
|
|
const { errors } = usePage().props;
|
|
function download() {
|
|
const url = URL.createObjectURL(
|
|
new Blob([usageExport(report)], { type: 'application/json' }),
|
|
);
|
|
const anchor = document.createElement('a');
|
|
anchor.href = url;
|
|
anchor.download = `indicadores-${report.filters.from}-${report.filters.to}.json`;
|
|
anchor.click();
|
|
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
}
|
|
return (
|
|
<Frame title="Indicadores de utilização">
|
|
<nav
|
|
className="flex flex-wrap gap-4"
|
|
aria-label="Navegação de indicadores"
|
|
>
|
|
<Link className="underline" href="/dashboard">
|
|
Voltar ao painel
|
|
</Link>
|
|
<Link
|
|
className="underline"
|
|
href="/management/product-experiments"
|
|
>
|
|
Registo de experiências e decisões
|
|
</Link>
|
|
</nav>
|
|
<p>
|
|
Leitura dos registos operacionais disponíveis. Cada indicador
|
|
apresenta a sua base e as limitações de cobertura.
|
|
</p>
|
|
<form
|
|
key={JSON.stringify(report.filters)}
|
|
action="/management/product-usage"
|
|
method="get"
|
|
className={card + ' grid gap-4 md:grid-cols-4'}
|
|
>
|
|
<label>
|
|
Desde (UTC)
|
|
<input
|
|
className={input}
|
|
type="date"
|
|
name="from"
|
|
required
|
|
defaultValue={report.filters.from}
|
|
max={report.generated_at.slice(0, 10)}
|
|
/>
|
|
</label>
|
|
<label>
|
|
Até (UTC)
|
|
<input
|
|
className={input}
|
|
type="date"
|
|
name="to"
|
|
required
|
|
defaultValue={report.filters.to}
|
|
max={report.generated_at.slice(0, 10)}
|
|
/>
|
|
</label>
|
|
<label>
|
|
Serviço
|
|
<select
|
|
className={input}
|
|
name="service_code"
|
|
defaultValue={report.filters.service_code ?? ''}
|
|
>
|
|
<option value="">Todos os serviços</option>
|
|
{report.services.map((s) => (
|
|
<option key={s.code} value={s.code}>
|
|
{s.title} ({s.code})
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
{report.search && (
|
|
<label>
|
|
Edição da recolha de pesquisa
|
|
<select
|
|
className={input}
|
|
name="search_definition"
|
|
defaultValue={
|
|
report.filters.search_definition ??
|
|
report.search.definition_version
|
|
}
|
|
>
|
|
{report.search.available_definitions.map(
|
|
(version) => (
|
|
<option key={version} value={version}>
|
|
{version}
|
|
</option>
|
|
),
|
|
)}
|
|
</select>
|
|
</label>
|
|
)}
|
|
<button className={button}>Aplicar filtros</button>
|
|
{Object.entries(errors).map(([k, v]) => (
|
|
<p role="alert" key={k}>
|
|
{String(v)}
|
|
</p>
|
|
))}
|
|
</form>
|
|
<section
|
|
className={card + ' space-y-3'}
|
|
aria-label="Período aplicado"
|
|
>
|
|
<p>
|
|
Período aplicado: {report.filters.from} a{' '}
|
|
{report.filters.to}, em {report.timezone}. Serviço:{' '}
|
|
{report.filters.service_code ?? 'todos'}.
|
|
</p>
|
|
<p>
|
|
{report.search ? (
|
|
<>
|
|
Eventos do Balcão anteriores a{' '}
|
|
{report.observed_before}. Pesquisas usam as
|
|
contagens diárias na leitura indicada abaixo.
|
|
</>
|
|
) : (
|
|
<>Observações anteriores a {report.observed_before}.</>
|
|
)}{' '}
|
|
Relatório gerado em {report.generated_at}.
|
|
</p>
|
|
<button className={button} onClick={download}>
|
|
Exportar este relatório (JSON)
|
|
</button>
|
|
</section>
|
|
{report.search && <SearchUsage search={report.search} />}
|
|
<section className={card} aria-labelledby="coverage-title">
|
|
<h2 id="coverage-title" className="text-xl font-semibold">
|
|
Cobertura da coorte de pedidos
|
|
</h2>
|
|
<dl className="grid gap-3 md:grid-cols-3">
|
|
{Object.entries(report.coverage).map(([key, value]) => (
|
|
<div key={key}>
|
|
<dt>{coverageLabels[key] ?? key}</dt>
|
|
<dd className="font-semibold">
|
|
{usageValue(value)}
|
|
</dd>
|
|
</div>
|
|
))}
|
|
</dl>
|
|
<p>
|
|
Pedidos sem historial suficiente ficam fora das taxas. Uma
|
|
base vazia ou desconhecida não produz uma taxa de 0%.
|
|
</p>
|
|
</section>
|
|
<div className={card + ' overflow-x-auto'}>
|
|
<table className="w-full text-left">
|
|
<caption className="mb-3 text-left text-xl font-semibold">
|
|
Indicadores observáveis
|
|
</caption>
|
|
<thead>
|
|
<tr>
|
|
<th className="p-2">Indicador</th>
|
|
<th className="p-2">Valor</th>
|
|
<th className="p-2">Base</th>
|
|
<th className="p-2">Definição e limites</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{report.metrics.map((m) => (
|
|
<tr key={m.id} className="border-t align-top">
|
|
<th scope="row" className="p-2">
|
|
{m.title}
|
|
</th>
|
|
<td className="p-2">
|
|
{usageValue(m.value, m.unit)}
|
|
<p>
|
|
{m.state === 'partial'
|
|
? 'Cobertura parcial'
|
|
: m.state === 'unmeasured'
|
|
? 'Sem base para calcular'
|
|
: 'Observado'}
|
|
</p>
|
|
</td>
|
|
<td className="p-2">
|
|
{m.numerator !== null && (
|
|
<p>
|
|
Numerador: {usageValue(m.numerator)}
|
|
</p>
|
|
)}
|
|
{m.denominator !== null && (
|
|
<p>
|
|
Denominador:{' '}
|
|
{usageValue(m.denominator)}
|
|
</p>
|
|
)}
|
|
{m.numerator === null &&
|
|
m.denominator === null &&
|
|
'Contagem directa'}
|
|
</td>
|
|
<td className="p-2">
|
|
<details>
|
|
<summary className="cursor-pointer underline">
|
|
Consultar definição
|
|
</summary>
|
|
<p>{m.source}</p>
|
|
<p>{m.limits}</p>
|
|
</details>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<section
|
|
className={card + ' space-y-3'}
|
|
aria-labelledby="unmeasured-title"
|
|
>
|
|
<h2 id="unmeasured-title" className="text-xl font-semibold">
|
|
Indicadores ainda sem medição
|
|
</h2>
|
|
{report.unmeasured.map((m) => (
|
|
<details key={m.id}>
|
|
<summary className="cursor-pointer underline">
|
|
{m.title}
|
|
</summary>
|
|
<p>{m.reason}</p>
|
|
{m.href && (
|
|
<Link className="underline" href={m.href}>
|
|
Consultar registo
|
|
</Link>
|
|
)}
|
|
</details>
|
|
))}
|
|
</section>
|
|
<section
|
|
className={card + ' space-y-3'}
|
|
aria-labelledby="limits-title"
|
|
>
|
|
<h2 id="limits-title" className="text-xl font-semibold">
|
|
Como interpretar
|
|
</h2>
|
|
<ul className="list-disc pl-5">
|
|
{report.limits.map((l) => (
|
|
<li key={l}>{l}</li>
|
|
))}
|
|
</ul>
|
|
<details>
|
|
<summary className="cursor-pointer underline">
|
|
Identificação do relatório
|
|
</summary>
|
|
<p>Definição: {report.definition_version}</p>
|
|
<p className="break-all">SHA-256: {report.fingerprint}</p>
|
|
<p>Consistência da leitura: {report.read_consistency}</p>
|
|
</details>
|
|
</section>
|
|
</Frame>
|
|
);
|
|
}
|