Files
Investidor-V2/resources/js/pages/management/product-usage.tsx
T

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>
);
}