@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user