commit9
tests / ci (push) Canceled after 0s

This commit is contained in:
2026-10-30 20:02:30 +02:00
parent 6d14b4e6f0
commit c6373365bf
225 changed files with 15365 additions and 285 deletions
@@ -0,0 +1,237 @@
import { useForm } from '@inertiajs/react';
import { Panel, FormErrors, controlClass } from '@/components/procurement';
import { Button } from '@/components/ui/button';
export type AiPlan = {
state: 'draft';
provider: 'none' | 'openai';
model: string;
domains: string[];
data_scopes: string[];
monthly_budget_usd: string;
monthly_requests: number;
max_input_bytes: number;
max_output_tokens: number;
};
export function AiPlanSettings({
plan,
domains,
scopes,
version,
ready,
}: {
plan: AiPlan;
domains: Record<string, string>;
scopes: Record<string, string>;
version: number;
ready: boolean;
}) {
const form = useForm({
version,
provider: plan.provider,
model: plan.model,
domains: plan.domains,
data_scopes: plan.data_scopes,
monthly_budget_usd: plan.monthly_budget_usd,
monthly_requests: plan.monthly_requests,
max_input_bytes: plan.max_input_bytes,
max_output_tokens: plan.max_output_tokens,
reason: '',
});
function choices(
field: 'domains' | 'data_scopes',
labels: Record<string, string>,
) {
return Object.entries(labels).map(([id, label]) => (
<label key={id} className="flex items-center gap-2">
<input
type="checkbox"
checked={form.data[field].includes(id)}
onChange={(event) =>
form.setData(
field,
event.target.checked
? [...form.data[field], id]
: form.data[field].filter(
(value) => value !== id,
),
)
}
/>
{label}
</label>
));
}
return (
<Panel title="Plano de IA externa — preparação">
<div className="space-y-4 text-sm">
<p>
Prepare o fornecedor, o âmbito e os limites pretendidos.
Guardar este plano permite pré-visualizar os factos
autorizados nos relatórios; não envia dados nem activa
chamadas externas.
</p>
{!ready ? (
<p>Instalação da configuração pendente.</p>
) : (
<form
className="space-y-4"
onSubmit={(event) => {
event.preventDefault();
form.post('/administracao/parametrizacao/ai_plan', {
preserveScroll: true,
});
}}
>
<FormErrors errors={form.errors} />
<div className="grid gap-4 md:grid-cols-2">
<label className="grid gap-1">
Fornecedor pretendido
<select
className={controlClass}
value={form.data.provider}
onChange={(event) =>
form.setData(
'provider',
event.target
.value as AiPlan['provider'],
)
}
>
<option value="none">
Sem fornecedor planeado
</option>
<option value="openai">OpenAI</option>
</select>
</label>
<label className="grid gap-1">
Modelo pretendido
<input
className={controlClass}
value={form.data.model}
maxLength={120}
onChange={(event) =>
form.setData(
'model',
event.target.value,
)
}
/>
<span>
Identificador do modelo; compatibilidade
ainda sujeita a validação.
</span>
</label>
</div>
<fieldset className="space-y-2">
<legend className="mb-2 font-medium">
Percursos abrangidos
</legend>
{choices('domains', domains)}
</fieldset>
<fieldset className="space-y-2">
<legend className="mb-2 font-medium">
Categorias de factos permitidas
</legend>
{choices('data_scopes', scopes)}
</fieldset>
<p>
As permissões dos documentos continuam a aplicar-se.
Esta escolha não concede acesso a candidaturas,
entrevistas ou finanças.
</p>
<div className="grid gap-4 md:grid-cols-2">
<label className="grid gap-1">
Orçamento mensal planeado (USD)
<input
className={controlClass}
type="number"
min="0"
step="0.01"
max="99999.99"
value={form.data.monthly_budget_usd}
onChange={(event) =>
form.setData(
'monthly_budget_usd',
event.target.value,
)
}
/>
</label>
<label className="grid gap-1">
Número mensal planeado de pedidos
<input
className={controlClass}
type="number"
min={0}
max={100000}
value={form.data.monthly_requests}
onChange={(event) =>
form.setData(
'monthly_requests',
Number(event.target.value),
)
}
/>
</label>
<label className="grid gap-1">
Limite de dados por pedido (bytes)
<input
className={controlClass}
type="number"
min={512}
max={64000}
value={form.data.max_input_bytes}
onChange={(event) =>
form.setData(
'max_input_bytes',
Number(event.target.value),
)
}
/>
</label>
<label className="grid gap-1">
Limite de saída pretendido (tokens)
<input
className={controlClass}
type="number"
min={64}
max={4000}
value={form.data.max_output_tokens}
onChange={(event) =>
form.setData(
'max_output_tokens',
Number(event.target.value),
)
}
/>
</label>
</div>
<p>
Limites de planeamento: ainda não existe contador
mensal, reserva de orçamento ou cobrança por este
plano.
</p>
<label className="grid gap-1">
Motivo e efeito pretendido
<textarea
className={controlClass}
required
minLength={10}
maxLength={4000}
value={form.data.reason}
onChange={(event) =>
form.setData('reason', event.target.value)
}
/>
</label>
<Button disabled={form.processing}>
Guardar plano para pré-visualização
</Button>
</form>
)}
</div>
</Panel>
);
}
@@ -0,0 +1,68 @@
export type AiPreparation = {
status:
| 'not_configured'
| 'outside_scope'
| 'no_permitted_facts'
| 'input_exceeds_limit'
| 'draft';
external_execution_enabled: false;
usage_checked: false;
policy_revision: number;
provider: string;
model: string;
max_input_bytes: number;
max_output_tokens: number;
input_bytes: number;
facts: {
id: string;
text: string;
source: { label: string; href: string; pointer: string };
}[];
};
export function AiPreparationPreview({ data }: { data: AiPreparation }) {
if (data.status === 'not_configured') return null;
const messages = {
outside_scope:
'Este percurso não está abrangido pelo plano da empresa.',
no_permitted_facts:
'Não há factos autorizados pelas categorias escolhidas para este relatório.',
input_exceeds_limit:
'Os dados excedem o limite planeado por pedido. Reveja o âmbito em Administração.',
draft: 'Pré-visualização do âmbito planeado. Nenhum dado foi enviado.',
};
return (
<details className="rounded-xl border p-4 text-sm print:hidden">
<summary className="cursor-pointer font-medium">
Pré-visualização de IA — plano da empresa
</summary>
<div className="mt-4 space-y-3">
<p>{messages[data.status]}</p>
<p>
Revisão {data.policy_revision} · Fornecedor pretendido:{' '}
{data.provider} · Modelo: {data.model}
</p>
<p>
Dados pré-visualizados: {data.input_bytes} /{' '}
{data.max_input_bytes} bytes. Limite de saída pretendido:{' '}
{data.max_output_tokens} tokens.
</p>
<ul className="space-y-3">
{data.facts.map((fact) => (
<li key={fact.id}>
<p>{fact.text}</p>
<a className="underline" href={fact.source.href}>
Fonte: {fact.source.label}
</a>
</li>
))}
</ul>
<p>
Os limites mensais de consumo ainda não foram conferidos. O
plano não autoriza execução externa; os relatórios e
decisões mantêm o seu circuito.
</p>
</div>
</details>
);
}
@@ -0,0 +1,46 @@
export type ComparisonPolicy = {
revision: number;
configured: boolean;
minimum_proposals: number;
minimum_evaluators: number;
weights: Record<
'price' | 'quality' | 'delivery' | 'history' | 'payment',
number
>;
};
export const comparisonCriteria = {
price: 'Preço',
quality: 'Qualidade',
delivery: 'Prazo de entrega',
history: 'Histórico',
payment: 'Condições de pagamento',
};
export function ComparisonPolicySummary({
policy,
}: {
policy: ComparisonPolicy;
}) {
return (
<div className="space-y-2 text-sm">
<p>
{policy.configured
? `Regra da empresa · revisão ${policy.revision}`
: 'Regras iniciais de compatibilidade'}
: mínimo de {policy.minimum_proposals} propostas elegíveis de
fornecedores distintos e {policy.minimum_evaluators} avaliações
independentes por proposta.
</p>
<p>
{Object.entries(comparisonCriteria)
.map(
([key, label]) =>
`${label} ${policy.weights[key as keyof typeof comparisonCriteria]}%`,
)
.join(' · ')}
.
</p>
</div>
);
}
@@ -0,0 +1,57 @@
import { Panel } from '@/components/procurement';
export type EvidenceBriefData = {
method: 'procurement_evidence_brief_v1' | 'people_evidence_brief_v1';
generated_at: string;
basis_sha256: string;
finance_visible: boolean;
claims: {
id: string;
text: string;
basis_sha256: string;
source: { label: string; href: string; pointer: string };
}[];
};
export function EvidenceBrief({ data }: { data: EvidenceBriefData }) {
return (
<Panel title="Síntese com fontes">
<div className="space-y-4 text-sm">
<p>
Síntese factual desta consulta. Cada ponto remete para a
secção de origem; confirme os registos antes de os usar numa
decisão.
</p>
<ul className="space-y-3">
{data.claims.map((claim) => (
<li key={claim.id}>
<p>{claim.text}</p>
<a className="underline" href={claim.source.href}>
Fonte: {claim.source.label}
</a>
</li>
))}
</ul>
{!data.finance_visible && (
<p>
O âmbito financeiro está reservado ao seu perfil e não
integra esta síntese.
</p>
)}
<details>
<summary className="cursor-pointer">
Método e referência da base consultada
</summary>
<p className="mt-2">
Método: síntese por regras, versão 1, sem modelo de IA
activo. O identificador permite comparar a base desta
síntese; não é uma assinatura ou aprovação.
</p>
<p className="mt-2 font-mono text-xs break-all">
SHA-256: {data.basis_sha256}
</p>
</details>
</div>
</Panel>
);
}
@@ -0,0 +1,108 @@
import { useForm } from '@inertiajs/react';
import { Panel, FormErrors, controlClass } from '@/components/procurement';
import { Button } from '@/components/ui/button';
export type IntelligencePolicy = {
revision: number;
configured: boolean;
procurement_brief: boolean;
people_brief: boolean;
};
export function IntelligenceSettings({
policy,
version,
ready,
}: {
policy: IntelligencePolicy;
version: number;
ready: boolean;
}) {
const form = useForm({
version,
procurement_brief: policy.procurement_brief,
people_brief: policy.people_brief,
reason: '',
});
return (
<Panel title="Inteligência e sínteses com fontes">
<div className="space-y-4 text-sm">
<p>
{policy.configured
? `Configuração da empresa · revisão ${policy.revision}.`
: 'Configuração inicial: sínteses locais disponíveis nos dois percursos.'}
</p>
<p>
Defina onde apresentar as sínteses factuais. A mudança
aplica-se às próximas consultas e não altera processos,
avaliações, decisões ou permissões de acesso aos documentos.
</p>
{!ready ? (
<p>
Instalação da configuração de inteligência pendente
neste ambiente.
</p>
) : (
<form
className="space-y-4"
onSubmit={(event) => {
event.preventDefault();
form.post(
'/administracao/parametrizacao/intelligence',
{ preserveScroll: true },
);
}}
>
<FormErrors errors={form.errors} />
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={form.data.procurement_brief}
onChange={(event) =>
form.setData(
'procurement_brief',
event.target.checked,
)
}
/>
Síntese com fontes em Compras
</label>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={form.data.people_brief}
onChange={(event) =>
form.setData(
'people_brief',
event.target.checked,
)
}
/>
Síntese com fontes em Pessoas e contratações
</label>
<label className="grid gap-1">
Motivo e efeito pretendido
<textarea
className={controlClass}
minLength={10}
maxLength={4000}
required
value={form.data.reason}
onChange={(event) =>
form.setData('reason', event.target.value)
}
/>
</label>
<Button disabled={form.processing}>
Guardar configuração das sínteses
</Button>
</form>
)}
<p>
Sínteses factuais calculadas localmente, com fontes e sem
envio externo de documentos.
</p>
</div>
</Panel>
);
}
@@ -0,0 +1,427 @@
import { useForm } from '@inertiajs/react';
import { FormErrors, Panel, controlClass } from '@/components/procurement';
import { Button } from '@/components/ui/button';
type Person = { id: number; name: string };
type Source = {
review_id: number;
criterion_index: number;
application_id: number;
question: string;
};
type Clarification = {
id: number;
application_id: number;
version: number;
status: string;
due_on: string;
outcome: string | null;
question?: string;
responsible_id?: number;
canRespond: boolean;
canReview: boolean;
canReassign: boolean;
responses?: {
answer: string;
reference?: string | null;
evidence_type: string;
by: number;
at: string;
}[];
decisions?: { outcome: string; reason: string; by: number; at: string }[];
};
export type ClarificationData = {
preservedDecision?: boolean;
ready: boolean;
sources: Source[];
items: Clarification[];
users: Person[];
};
const outcomes: Record<string, string> = {
accepted: 'Resposta acolhida',
insufficient: 'Informação permanece por comprovar',
not_required: 'Seguimento dispensado com fundamento',
returned: 'Devolvido para esclarecimentos',
};
const evidence: Record<string, string> = {
reported: 'Relatada',
observed: 'Observada',
verified: 'Comprovada por fonte indicada',
};
function Assignment({
processId,
users,
source,
item,
}: {
processId: number;
users: Person[];
source?: Source;
item?: Clarification;
}) {
const form = useForm({
operation_key: crypto.randomUUID(),
clarification_id: item?.id,
version: item?.version,
review_id: source?.review_id,
criterion_index: source?.criterion_index,
responsible_id: item?.responsible_id ? String(item.responsible_id) : '',
due_on: item?.due_on ?? '',
reason: source
? 'Seguimento do esclarecimento registado na avaliação.'
: '',
});
return (
<form
id={
source
? `clarification-source-${source.review_id}-${source.criterion_index}`
: undefined
}
className="scroll-mt-24 space-y-3 rounded border p-4"
onSubmit={(event) => {
event.preventDefault();
form.post(
`/pessoas/${processId}/esclarecimentos/${source ? 'create' : 'reassign'}`,
{ preserveState: 'errors', preserveScroll: true },
);
}}
>
<FormErrors errors={form.errors} />
{source && (
<>
<strong>
Candidatura #{source.application_id} · avaliação #
{source.review_id} · critério{' '}
{source.criterion_index + 1}
</strong>
<p className="whitespace-pre-wrap">{source.question}</p>
</>
)}
<label className="block">
Responsável pela resposta
<select
className={controlClass}
required
value={form.data.responsible_id}
onChange={(e) =>
form.setData('responsible_id', e.target.value)
}
>
<option value="">Seleccione</option>
{users.map((person) => (
<option key={person.id} value={person.id}>
{person.name}
</option>
))}
</select>
</label>
<label className="block">
Prazo
<input
className={controlClass}
type="date"
required
value={form.data.due_on}
onChange={(e) => form.setData('due_on', e.target.value)}
/>
</label>
<label className="block">
Motivo
<textarea
className={controlClass}
required
minLength={10}
maxLength={4000}
value={form.data.reason}
onChange={(e) => form.setData('reason', e.target.value)}
/>
</label>
<Button disabled={form.processing}>
{source ? 'Atribuir seguimento' : 'Guardar responsável e prazo'}
</Button>
</form>
);
}
function Response({
processId,
item,
}: {
processId: number;
item: Clarification;
}) {
const form = useForm({
clarification_id: item.id,
version: item.version,
answer: '',
evidence_type: '',
reference: '',
reason: '',
});
return (
<form
className="mt-4 space-y-3"
onSubmit={(event) => {
event.preventDefault();
form.transform((values) => ({
...values,
reason: values.answer,
}));
form.post(`/pessoas/${processId}/esclarecimentos/respond`, {
preserveState: 'errors',
preserveScroll: true,
});
}}
>
<FormErrors errors={form.errors} />
<label className="block">
Resposta e fundamento
<textarea
className={controlClass}
required
minLength={10}
maxLength={4000}
value={form.data.answer}
onChange={(e) => form.setData('answer', e.target.value)}
/>
</label>
<label className="block">
Tipo de evidência
<select
className={controlClass}
required
value={form.data.evidence_type}
onChange={(e) =>
form.setData('evidence_type', e.target.value)
}
>
<option value="">Seleccione</option>
{Object.entries(evidence).map(([key, label]) => (
<option key={key} value={key}>
{label}
</option>
))}
</select>
</label>
<label className="block">
Fonte ou referência documental
<input
className={controlClass}
required={form.data.evidence_type === 'verified'}
maxLength={2000}
value={form.data.reference}
onChange={(e) => form.setData('reference', e.target.value)}
/>
</label>
<Button disabled={form.processing}>Registar resposta</Button>
</form>
);
}
function Review({
processId,
item,
}: {
processId: number;
item: Clarification;
}) {
const form = useForm({
clarification_id: item.id,
version: item.version,
outcome: item.status === 'open' ? 'not_required' : 'accepted',
reason: '',
no_conflict: false,
});
return (
<form
className="mt-4 space-y-3"
onSubmit={(event) => {
event.preventDefault();
form.post(`/pessoas/${processId}/esclarecimentos/review`, {
preserveState: 'errors',
preserveScroll: true,
});
}}
>
<FormErrors errors={form.errors} />
<label className="block">
Conclusão
<select
className={controlClass}
value={form.data.outcome}
onChange={(e) => form.setData('outcome', e.target.value)}
>
{Object.entries(outcomes)
.filter(
([key]) =>
item.status === 'responded' ||
key === 'not_required',
)
.map(([key, label]) => (
<option key={key} value={key}>
{label}
</option>
))}
</select>
</label>
<label className="block">
Fundamento da conferência
<textarea
className={controlClass}
required
minLength={10}
maxLength={4000}
value={form.data.reason}
onChange={(e) => form.setData('reason', e.target.value)}
/>
</label>
<label className="flex gap-3">
<input
type="checkbox"
required
checked={form.data.no_conflict}
onChange={(e) =>
form.setData('no_conflict', e.target.checked)
}
/>
Declaro não ter conflito de interesses nesta conferência.
</label>
<Button disabled={form.processing}>Registar conferência</Button>
</form>
);
}
export function PeopleClarifications({
processId,
data,
}: {
processId: number;
data?: ClarificationData;
}) {
if (!data?.ready) return null;
return (
<div id="people-clarifications" className="scroll-mt-24">
<Panel
title="Esclarecimentos e seguimento"
description="A questão da avaliação é preservada. Respostas, conferências e prazos são registos próprios; as notas e os requisitos não são alterados automaticamente."
>
{data.preservedDecision && (
<p className="mb-4 text-sm">
A selecção aprovada conserva a base registada na
decisão. Este painel apresenta o seguimento actual.
</p>
)}
{data.items.length === 0 && data.sources.length === 0 && (
<p>
Sem esclarecimentos disponíveis para a sua intervenção
neste processo.
</p>
)}
<div className="space-y-4">
{data.items.map((item) => (
<section
id={`clarification-${item.id}`}
key={`${item.id}-${item.version}`}
className="scroll-mt-24 rounded-lg border p-4"
>
<h3 className="font-semibold">
Esclarecimento #{item.id} · candidatura #
{item.application_id}
</h3>
<p className="text-sm">
{item.status === 'open'
? 'Resposta pendente'
: item.status === 'responded'
? 'Resposta por conferir'
: 'Concluído'}{' '}
· prazo {item.due_on}
{item.outcome
? ` · Última conferência: ${outcomes[item.outcome]}`
: ''}
</p>
{item.question ? (
<p className="mt-3 whitespace-pre-wrap">
{item.question}
</p>
) : (
<p className="mt-3 text-sm text-muted-foreground">
Conteúdo reservado às pessoas autorizadas.
</p>
)}
{item.responses?.map((response, index) => (
<div
key={index}
className="mt-3 rounded border p-3 text-sm"
>
<strong>
Resposta {index + 1} · pessoa #
{response.by} · {response.at}
</strong>
<p className="whitespace-pre-wrap">
{response.answer}
</p>
<p>
{evidence[response.evidence_type]}
{response.reference
? ` · Fonte: ${response.reference}`
: ''}
</p>
</div>
))}
{item.decisions?.map((decision, index) => (
<p
key={index}
className="mt-3 text-sm whitespace-pre-wrap"
>
{outcomes[decision.outcome]} · pessoa #
{decision.by} · {decision.at} ·{' '}
{decision.reason}
</p>
))}
<div className="print:hidden">
{item.canRespond && (
<Response
processId={processId}
item={item}
/>
)}
{item.canReview && (
<Review processId={processId} item={item} />
)}
{item.canReassign && (
<details className="mt-4">
<summary className="cursor-pointer">
Alterar responsável ou prazo
</summary>
<Assignment
processId={processId}
users={data.users}
item={item}
/>
</details>
)}
</div>
</section>
))}
</div>
{data.sources.length > 0 && (
<div className="mt-5 space-y-4 print:hidden">
<h3 className="font-semibold">
Esclarecimentos da avaliação por atribuir
</h3>
{data.sources.map((source) => (
<Assignment
key={`${source.review_id}-${source.criterion_index}`}
processId={processId}
users={data.users}
source={source}
/>
))}
</div>
)}
</Panel>
</div>
);
}
@@ -0,0 +1,121 @@
import { Link } from '@inertiajs/react';
import { Panel } from '@/components/procurement';
export type OperationalInsights = {
method: 'operational_facts_v1';
generated_at: string;
coverage: { processes: number; tasks: number; approvals: number };
overdue: {
items: number;
processes: number;
maximum_days: number | null;
oldest: {
id: string;
reference: string;
label: string;
due_on: string;
days_overdue: number;
href: string;
}[];
};
task_actions: Record<string, number>;
tasks_without_deadline: number;
};
const actions: Record<string, string> = {
triage: 'Triagem de candidaturas electrónicas',
apply: 'Recepção de candidaturas',
close: 'Encerramento da recepção',
evaluate: 'Avaliação de candidaturas',
select: 'Preparação da selecção',
contract: 'Preparação da minuta',
clarification_assign: 'Atribuição de esclarecimentos',
clarification_respond: 'Resposta a esclarecimentos',
clarification_review: 'Conferência de esclarecimentos',
};
export function PeopleOperationalInsights({
data,
}: {
data: OperationalInsights;
}) {
return (
<Panel title="Leitura operacional">
<div className="space-y-4 text-sm">
<p>
Base: {data.coverage.processes} processos autorizados nos
filtros actuais. Indicadores calculados a partir das tarefas
e dos prazos registados.
</p>
{data.coverage.processes === 0 ? (
<p>Sem processos neste âmbito para analisar.</p>
) : (
<>
<p>
{data.overdue.items} prazos vencidos em{' '}
{data.overdue.processes} processos.{' '}
{data.coverage.approvals} aprovações aguardam a sua
intervenção independente.
</p>
{data.overdue.maximum_days !== null && (
<p>
Prazo vencido há mais tempo:{' '}
{data.overdue.maximum_days} dias de calendário.
</p>
)}
{data.overdue.oldest.length > 0 && (
<div>
<p className="font-medium">
Prazos vencidos há mais tempo — até cinco
ocorrências
</p>
<ul className="list-disc space-y-1 pl-5">
{data.overdue.oldest.map((item) => (
<li key={item.id}>
<Link
className="underline"
href={item.href}
>
{item.reference} · {item.label}
</Link>{' '}
· {item.days_overdue} dias · prazo{' '}
{item.due_on}
</li>
))}
</ul>
</div>
)}
{Object.keys(data.task_actions).length > 0 && (
<div>
<p className="font-medium">
Distribuição das suas tarefas
</p>
<ul className="list-disc pl-5">
{Object.entries(data.task_actions).map(
([action, count]) => (
<li key={action}>
{actions[action] ??
'Outra intervenção'}
: {count}
</li>
),
)}
</ul>
</div>
)}
<p>
{data.tasks_without_deadline} tarefas sem prazo
próprio registado; esta ausência não indica
incumprimento.
</p>
</>
)}
<p className="text-muted-foreground">
Leitura factual dos registos, sem previsão por machine
learning. Um atraso pede análise do contexto e não determina
o resultado de uma candidatura.
</p>
</div>
</Panel>
);
}
@@ -0,0 +1,92 @@
import { router } from '@inertiajs/react';
import { useState } from 'react';
import { controlClass } from '@/components/procurement';
import { Button } from '@/components/ui/button';
import type { RegistryPerson } from '@/pages/people/registry';
export type RegistryChoice = {
ready: boolean;
canCreate: boolean;
search: string;
items: RegistryPerson[];
};
export function PeopleRegistryChoice({
processId,
data,
selected,
choose,
}: {
processId: number;
data: RegistryChoice;
selected: string;
choose: (person: RegistryPerson | null) => void;
}) {
const [search, setSearch] = useState(data.search);
function lookup() {
router.get(
`/pessoas/${processId}`,
{ cadastro: search },
{ only: ['registry'], preserveState: true, preserveScroll: true },
);
}
return (
<fieldset className="space-y-3 rounded-lg border p-4">
<legend>Reutilizar pessoa do cadastro</legend>
<p className="text-sm">
Pesquise pelo menos dois caracteres. A candidatura conserva a
versão seleccionada, mesmo que o cadastro mude depois.
</p>
<div className="flex flex-wrap gap-2">
<input
className={controlClass}
aria-label="Pesquisar pessoa no cadastro"
value={search}
maxLength={100}
onChange={(event) => setSearch(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
lookup();
}
}}
/>
<Button type="button" variant="outline" onClick={lookup}>
Pesquisar cadastro
</Button>
</div>
<select
className={controlClass}
aria-label="Pessoa do cadastro"
value={selected}
onChange={(event) =>
choose(
data.items.find(
(item) => String(item.id) === event.target.value,
) ?? null,
)
}
>
<option value="">Registar pessoa nova</option>
{selected &&
!data.items.some(
(item) => String(item.id) === selected,
) && (
<option value={selected}>
Pessoa seleccionada anteriormente
</option>
)}
{data.items.map((item) => (
<option key={item.id} value={item.id}>
{item.name} · {item.reference} · v{item.version}
</option>
))}
</select>
{data.search.length >= 2 && !data.items.length && (
<p className="text-sm">
Sem registos disponíveis neste âmbito.
</p>
)}
</fieldset>
);
}
@@ -0,0 +1,103 @@
import { Link } from '@inertiajs/react';
import { Panel } from '@/components/procurement';
export type ProcurementInsights = {
method: 'procurement_facts_v1';
generated_at: string;
processes: number;
pending: {
count: number;
unknown_submission_age: number;
oldest: {
id: number;
number: string;
submitted_at: string;
days_since_submission: number;
href: string;
}[];
};
unapproved_past_needed_date: number;
approved: {
count: number;
without_registered_quotes: number;
without_visible_order: number;
};
};
export function ProcurementOperationalInsights({
data,
}: {
data: ProcurementInsights;
}) {
return (
<Panel title="Leitura operacional de Compras">
<div className="space-y-3 text-sm">
<p>
Base: {data.processes} requisições autorizadas nos filtros
actuais, incluindo todas as páginas.
</p>
{data.processes === 0 ? (
<p>Sem requisições neste âmbito para analisar.</p>
) : (
<>
<p>
{data.pending.count} requisições em aprovação.{' '}
{data.unapproved_past_needed_date} pedidos em
preparação ou aprovação têm a data de necessidade
ultrapassada.
</p>
<p>
A data de necessidade exprime quando o requisitante
precisa do bem ou serviço; não comprova atraso de
entrega do fornecedor.
</p>
{data.pending.oldest.length > 0 && (
<div>
<p className="font-medium">
Em aprovação — até cinco submissões mais
antigas
</p>
<ul className="list-disc space-y-1 pl-5">
{data.pending.oldest.map((item) => (
<li key={item.id}>
<Link
className="underline"
href={item.href}
>
{item.number}
</Link>{' '}
· {item.days_since_submission} dias
de calendário desde a submissão
</li>
))}
</ul>
</div>
)}
<p>
{data.pending.unknown_submission_age} pedidos em
aprovação sem data de submissão utilizável para
calcular antiguidade.
</p>
<p>
Das {data.approved.count} requisições aprovadas:{' '}
{data.approved.without_registered_quotes} sem
propostas registadas e{' '}
{data.approved.without_visible_order} sem ordem de
compra visível.
</p>
<p>
Estas contagens podem sobrepor-se. Propostas
registadas não significam propostas válidas ou
comparáveis; ausência de ordem visível não comprova
incumprimento.
</p>
</>
)}
<p className="text-muted-foreground">
Indicadores factuais para conferência dos processos, sem
previsões por machine learning ou decisões automáticas.
</p>
</div>
</Panel>
);
}
+6 -1
View File
@@ -36,16 +36,21 @@ export function PageTitle({
);
}
export function Panel({
id,
title,
description,
children,
}: {
id?: string;
title: string;
description?: string;
children: ReactNode;
}) {
return (
<section className="rounded-xl border bg-card p-5 shadow-xs">
<section
id={id}
className="scroll-mt-24 rounded-xl border bg-card p-5 shadow-xs"
>
<h2 className="font-semibold">{title}</h2>
{description && (
<p className="mt-1 text-sm text-muted-foreground">
@@ -0,0 +1,39 @@
import { money } from '@/types/procurement';
export type CommercialTerms = {
lines: {
requisition_item_id: number;
gross_unit_minor: number;
discount_type: string;
discount_value: string;
net_unit_minor: number;
}[];
};
export function QuotationCommercialTerms({
terms,
}: {
terms?: CommercialTerms | null;
}) {
if (!terms) return null;
return (
<div className="my-3 space-y-2 text-sm">
<p>
Desconto por unidade, antes do IVA. Preço líquido arredondado ao
centavo.
</p>
{terms.lines.map((line) => (
<p key={line.requisition_item_id}>
Linha #{line.requisition_item_id}: preço inicial{' '}
{money(line.gross_unit_minor)} · desconto{' '}
{line.discount_type === 'none'
? 'sem desconto'
: line.discount_type === 'percent'
? `${line.discount_value}%`
: `${line.discount_value} MZN/unidade`}{' '}
· preço líquido {money(line.net_unit_minor)}
</p>
))}
</div>
);
}
@@ -0,0 +1,61 @@
import { router, usePage } from '@inertiajs/react';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { controlClass } from '@/components/procurement';
export type CatalogueEntry = {
id: number;
version: number;
stock_item_id: number;
code: string;
description: string;
unit: string;
category: string;
purchase_type: string;
};
export type CatalogueOptions = {
ready: boolean;
search: string;
items: CatalogueEntry[];
more: boolean;
};
export function CatalogueSearch({ data }: { data: CatalogueOptions }) {
const [search, setSearch] = useState(data.search);
const path = usePage().url.split('?')[0];
function lookup() {
router.get(
path,
{ catalogo: search },
{ only: ['catalogue'], preserveState: true, preserveScroll: true },
);
}
return (
<fieldset className="mb-4 space-y-3 rounded-lg border p-4">
<legend>Artigos do catálogo da empresa</legend>
<p className="text-sm">
Seleccione uma fonte em cada linha. Descrição, categoria,
unidade e tipo são reutilizados; quantidade, estimativa e IVA
são dados deste pedido.
</p>
<div className="flex flex-wrap gap-2">
<input
aria-label="Pesquisar catálogo"
className={controlClass}
value={search}
maxLength={100}
onChange={(event) => setSearch(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
lookup();
}
}}
/>
<Button type="button" variant="outline" onClick={lookup}>
Pesquisar catálogo
</Button>
</div>
{data.more && <p>Há mais artigos; refine a pesquisa.</p>}
</fieldset>
);
}
+5 -2
View File
@@ -197,7 +197,7 @@ function SgcPreviewShell({
</span>
<span>
<span className="block text-sm font-semibold">
Sistema de Gestão de Compras
Sistema de Gestão de Contratações
</span>
<span className="mt-1 block text-xs text-slate-400">
Compras com controlo e transparência
@@ -302,7 +302,10 @@ function SgcPreviewShell({
<strong className="font-semibold text-white">
SGC
</strong>{' '}
· Sistema de Gestão de Compras
· Sistema de Gestão de Contratações
<span className="block">
Aquisições, serviços e pessoas · Uma solução TEMPO
</span>
</span>
<span>
{demo
+70 -42
View File
@@ -7,13 +7,14 @@ import {
Search,
X,
} from 'lucide-react';
import { useEffect, useState } from 'react';
import { Fragment, useEffect, useState } from 'react';
import type { ReactNode } from 'react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
@@ -69,7 +70,13 @@ export function SgcWorkspaceShell({ children }: { children: ReactNode }) {
entries: group.entries.filter(
(entry) =>
!query ||
normalize(group.label + ' ' + entry.label).includes(query),
normalize(
group.label +
' ' +
(entry.section ?? '') +
' ' +
entry.label,
).includes(query),
),
}))
.filter((group) => group.entries.length);
@@ -88,10 +95,10 @@ export function SgcWorkspaceShell({ children }: { children: ReactNode }) {
</span>
<span>
<span className="block text-lg font-semibold tracking-tight text-white">
Compras
SGC
</span>
<span className="block text-xs text-slate-400">
Sistema de Gestão de Compras
Sistema de Gestão de Contratações
</span>
</span>
</Link>
@@ -186,10 +193,18 @@ export function SgcWorkspaceShell({ children }: { children: ReactNode }) {
/>
</summary>
<ul className="mt-1 mb-3 ml-5 space-y-1 border-l border-white/10 pl-3">
{group.entries.map((entry) => {
{group.entries.map((entry, index) => {
const className = `block rounded-md px-3 py-2 text-[13px] leading-5 transition-colors focus-visible:outline-2 focus-visible:outline-white ${activeId === entry.id ? 'bg-red-500/15 font-medium text-red-300' : 'text-slate-400 hover:bg-white/5 hover:text-white'}`;
return (
<li key={entry.id}>
{entry.section &&
entry.section !==
group.entries[index - 1]
?.section && (
<p className="px-3 pt-3 pb-1 text-xs font-semibold text-slate-500">
{entry.section}
</p>
)}
{entry.fullPage ? (
<a
href={entry.path}
@@ -271,7 +286,7 @@ export function SgcWorkspaceShell({ children }: { children: ReactNode }) {
className="flex w-[min(90vw,20rem)] max-w-none flex-col gap-0 border-white/10 bg-[#101715] p-0 text-white [&>button]:text-white"
>
<SheetHeader className="sr-only">
<SheetTitle>Menu de Compras</SheetTitle>
<SheetTitle>Menu do SGC</SheetTitle>
<SheetDescription>
Escolhe uma área da aplicação ou procura pelo nome.
</SheetDescription>
@@ -334,40 +349,50 @@ export function SgcWorkspaceShell({ children }: { children: ReactNode }) {
sideOffset={8}
className="max-h-[min(70vh,32rem)] w-72 overflow-y-auto border-white/10 bg-[#101715] p-2 text-white"
>
{group.entries.map((entry) => (
<DropdownMenuItem
key={entry.id}
asChild
className={`rounded-md px-3 py-2 text-[13px] leading-5 focus:bg-white/5 focus:text-white ${activeId === entry.id ? 'bg-red-500/15 font-medium text-red-300' : 'text-slate-400'}`}
>
{entry.fullPage ? (
<a
href={entry.path}
onClick={navigate}
aria-current={
activeId ===
entry.id
? 'page'
: undefined
}
>
{entry.label}
</a>
) : (
<Link
href={entry.path}
onClick={navigate}
aria-current={
activeId ===
entry.id
? 'page'
: undefined
}
>
{entry.label}
</Link>
)}
</DropdownMenuItem>
{group.entries.map((entry, index) => (
<Fragment key={entry.id}>
{entry.section &&
entry.section !==
group.entries[index - 1]
?.section && (
<DropdownMenuLabel className="px-3 pt-3 pb-1 text-xs text-slate-500">
{entry.section}
</DropdownMenuLabel>
)}
<DropdownMenuItem
key={entry.id}
asChild
className={`rounded-md px-3 py-2 text-[13px] leading-5 focus:bg-white/5 focus:text-white ${activeId === entry.id ? 'bg-red-500/15 font-medium text-red-300' : 'text-slate-400'}`}
>
{entry.fullPage ? (
<a
href={entry.path}
onClick={navigate}
aria-current={
activeId ===
entry.id
? 'page'
: undefined
}
>
{entry.label}
</a>
) : (
<Link
href={entry.path}
onClick={navigate}
aria-current={
activeId ===
entry.id
? 'page'
: undefined
}
>
{entry.label}
</Link>
)}
</DropdownMenuItem>
</Fragment>
))}
</DropdownMenuContent>
</DropdownMenu>
@@ -388,7 +413,7 @@ export function SgcWorkspaceShell({ children }: { children: ReactNode }) {
<Button
variant="outline"
size="icon"
aria-label="Abrir menu de Compras"
aria-label="Abrir menu do SGC"
onClick={() => setMobileOpen(true)}
className="shrink-0 lg:hidden"
>
@@ -478,7 +503,10 @@ export function SgcWorkspaceShell({ children }: { children: ReactNode }) {
{children}
</main>
<footer className="mt-auto border-t border-border px-5 py-4 text-xs text-muted-foreground lg:px-8 print:hidden">
SGC · Sistema de Gestão de Compras
SGC · Sistema de Gestão de Contratações
<span className="block">
Aquisições, serviços e pessoas · Uma solução TEMPO
</span>
<Link
href="/settings/appearance"
className="ml-4 hover:text-foreground"