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
+3 -3
View File
@@ -1,12 +1,11 @@
import { createInertiaApp } from '@inertiajs/react';
import type { ReactNode } from 'react';
import { Toaster } from '@/components/ui/sonner';
import { SgcShell } from '@/components/sgc-shell';
import { TooltipProvider } from '@/components/ui/tooltip';
import { initializeTheme } from '@/hooks/use-appearance';
import AppLayout from '@/layouts/app-layout';
import AuthLayout from '@/layouts/auth-layout';
import SettingsLayout from '@/layouts/settings/layout';
import PreviewLayout from '@/layouts/preview-layout';
const appName = 'SGC';
@@ -16,9 +15,10 @@ void createInertiaApp({
switch (true) {
case name === 'welcome':
case name === 'home':
case name.startsWith('candidate/'):
return null;
case name.startsWith('preview/'):
return (page: ReactNode) => <SgcShell demo>{page}</SgcShell>;
return PreviewLayout;
case name.startsWith('auth/'):
return AuthLayout;
case name.startsWith('settings/'):
@@ -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"
@@ -31,6 +31,11 @@ export default function AuthSimpleLayout({
</div>
</div>
{children}
<p className="text-center text-xs text-muted-foreground">
SGC · Aquisições, serviços e pessoas
<br />
Uma solução TEMPO
</p>
</div>
</div>
</div>
+6
View File
@@ -0,0 +1,6 @@
import type { PropsWithChildren } from 'react';
import { SgcShell } from '@/components/sgc-shell';
export default function PreviewLayout({ children }: PropsWithChildren) {
return <SgcShell demo>{children}</SgcShell>;
}
+8
View File
@@ -0,0 +1,8 @@
export const peopleStates: Record<string, string> = {
draft: 'Abertura por conferir',
open: 'Recepção de candidaturas',
assessment: 'Avaliação',
selection_review: 'Selecção por conferir',
selected: 'Selecção aprovada',
contract_draft: 'Minuta contratual preparada',
};
+88 -1
View File
@@ -16,6 +16,7 @@ export type ProcurementEntry = {
path: string;
roles?: string[];
fullPage?: boolean;
section?: string;
};
export type ProcurementGroup = {
id: string;
@@ -122,6 +123,57 @@ export function procurementNavigation(
},
],
},
{
id: 'people',
label: 'Pessoas e contratações',
icon: Users,
entries: [
{
id: 'people_registry',
label: 'Cadastro reutilizável de pessoas',
path: '/pessoas/cadastro',
roles: [
'admin',
'people_service_prepare',
'people_staff_prepare',
'people_registry_read',
'people_registry_manage',
],
},
{
id: 'people_hiring',
label: 'Processos e equipas',
path: '/pessoas',
roles: [
'admin',
'people_service_prepare',
'people_service_review',
'people_staff_prepare',
'people_staff_review',
],
},
{
id: 'people_services',
label: 'Serviços individuais',
path: '/pessoas?kind=service',
roles: [
'admin',
'people_service_prepare',
'people_service_review',
],
},
{
id: 'people_staff',
label: 'Recrutamento e selecção',
path: '/pessoas?kind=staff',
roles: [
'admin',
'people_staff_prepare',
'people_staff_review',
],
},
],
},
{
id: 'receiving',
label: 'Recepções e armazém',
@@ -251,7 +303,42 @@ export function procurementNavigation(
],
},
];
return groups
const purchasing = groups.filter((group) =>
['requests', 'approvals', 'sourcing', 'suppliers'].includes(group.id),
);
const compact = [
{
id: 'purchasing',
label: 'Compras',
icon: ClipboardList,
entries: purchasing.flatMap((group) =>
group.entries.map((entry) => ({
...entry,
section: group.label,
})),
),
},
...groups
.filter(
(group) =>
![
'requests',
'approvals',
'sourcing',
'suppliers',
].includes(group.id),
)
.map((group) => ({
...group,
label:
group.id === 'administration'
? 'Administração'
: group.id === 'receiving'
? 'Armazém e activos'
: group.label,
})),
];
return compact
.map((group) => ({
...group,
entries: group.entries.filter(
+1 -1
View File
@@ -117,6 +117,6 @@ export default function Login({ status, canResetPassword }: Props) {
}
Login.layout = {
title: 'Entrar no Sistema de Gestão de Compras',
title: 'Entrar no Sistema de Gestão de Contratações',
description: 'Introduza o seu email e a palavra-passe',
};
+178
View File
@@ -0,0 +1,178 @@
import { Head, Link, useForm } from '@inertiajs/react';
import { useEffect } from 'react';
import { FormErrors, controlClass } from '@/components/procurement';
import { Button } from '@/components/ui/button';
export default function Access({
companyId,
purpose,
accessStatus,
emailDeliveryAvailable,
}: {
companyId: number;
purpose: string;
accessStatus: string | null;
emailDeliveryAvailable: boolean;
}) {
const recovery = useForm({ email: '' });
const confirmation = useForm({
token: '',
password: '',
password_confirmation: '',
});
const { setData } = confirmation;
useEffect(() => {
const token = new URLSearchParams(window.location.hash.slice(1)).get(
'token',
);
if (token) setData('token', token);
}, [setData]);
const verify = purpose === 'confirmar-email';
return (
<>
<Head title={verify ? 'Confirmar email' : 'Recuperar acesso'} />
<main className="mx-auto max-w-xl space-y-5 p-6">
<h1 className="text-3xl font-semibold">
{verify ? 'Confirmar email' : 'Recuperar acesso'}
</h1>
<Link className="underline" href={`/candidaturas/${companyId}`}>
Entrada de candidatos
</Link>
{accessStatus === 'completed' ? (
<p>
{verify
? 'Email confirmado. Esta confirmação não verifica a identidade nem constitui candidatura.'
: 'Palavra-passe actualizada. Entre novamente; as sessões anteriores perderam o acesso.'}
</p>
) : (
<>
{accessStatus === 'requested' && (
<p>
Se existir uma conta e o envio estiver
disponível, receberá uma ligação temporária no
email indicado.
</p>
)}
{!emailDeliveryAvailable && (
<p>
O envio de email ainda não está configurado. O
perfil e a entrada com a palavra-passe actual
continuam disponíveis.
</p>
)}
{confirmation.data.token ? (
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
confirmation.post(
`/candidaturas/${companyId}/${purpose}`,
{
preserveState: false,
onFinish: () =>
confirmation.reset(
'password',
'password_confirmation',
),
},
);
}}
>
<FormErrors errors={confirmation.errors} />
{!verify && (
<>
<label className="grid gap-2">
Nova palavra-passe
<input
className={controlClass}
type="password"
autoComplete="new-password"
required
minLength={12}
maxLength={128}
value={
confirmation.data.password
}
onChange={(e) =>
confirmation.setData(
'password',
e.target.value,
)
}
/>
</label>
<label className="grid gap-2">
Confirmar palavra-passe
<input
className={controlClass}
type="password"
autoComplete="new-password"
required
value={
confirmation.data
.password_confirmation
}
onChange={(e) =>
confirmation.setData(
'password_confirmation',
e.target.value,
)
}
/>
</label>
</>
)}
<Button disabled={confirmation.processing}>
{verify
? 'Confirmar o meu email'
: 'Definir nova palavra-passe'}
</Button>
</form>
) : !verify ? (
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
recovery.post(
`/candidaturas/${companyId}/pedir-recuperacao`,
);
}}
>
<FormErrors errors={recovery.errors} />
<label className="grid gap-2">
Email da conta
<input
className={controlClass}
type="email"
required
autoComplete="email"
value={recovery.data.email}
onChange={(e) =>
recovery.setData(
'email',
e.target.value,
)
}
/>
</label>
<Button
disabled={
recovery.processing ||
!emailDeliveryAvailable
}
>
Pedir ligação de recuperação
</Button>
</form>
) : (
<p>
Abra a ligação recebida no seu email. Pode pedir
outra na sua área de perfil.
</p>
)}
</>
)}
</main>
</>
);
}
+189
View File
@@ -0,0 +1,189 @@
import { Head, Link, useForm } from '@inertiajs/react';
import { FormErrors, controlClass } from '@/components/procurement';
import { Button } from '@/components/ui/button';
export default function Entry({
company,
portal,
}: {
company: { id: number; name: string };
portal: { version: number; registration_open: boolean; notice: string };
}) {
const login = useForm({ email: '', password: '' });
const register = useForm({
email: '',
password: '',
password_confirmation: '',
portal_version: portal.version,
notice_acknowledged: false,
});
const credentials = (form: typeof login, prefix: string) => (
<>
<label className="grid gap-2">
Email
<input
className={controlClass}
type="email"
required
autoComplete="email"
value={form.data.email}
onChange={(e) => form.setData('email', e.target.value)}
/>
</label>
<label className="grid gap-2">
Palavra-passe
<input
className={controlClass}
type="password"
required
autoComplete={
prefix === 'login' ? 'current-password' : 'new-password'
}
value={form.data.password}
onChange={(e) => form.setData('password', e.target.value)}
/>
</label>
</>
);
return (
<>
<Head title="Área do candidato" />
<Link
href={`/candidaturas/${company.id}/recuperar`}
className="m-5 inline-block underline"
>
Recuperar acesso
</Link>
<main className="mx-auto max-w-3xl space-y-6 p-6">
<p>SGC · Uma solução TEMPO</p>
<h1 className="text-3xl font-semibold">
Área do candidato · {company.name}
</h1>
<p>
Prepare o seu perfil e candidate-se às oportunidades
publicadas. O envio de anexos ainda está em desenvolvimento.
Criar um perfil não constitui candidatura ou contratação.
</p>
<section className="space-y-3">
<h2 className="text-xl font-semibold">Entrar</h2>
<form
className="space-y-3"
onSubmit={(e) => {
e.preventDefault();
login.post(`/candidaturas/${company.id}/entrar`, {
onFinish: () => login.reset('password'),
});
}}
>
<FormErrors errors={login.errors} />
{credentials(login, 'login')}
<Button disabled={login.processing}>
Entrar na minha área
</Button>
</form>
</section>
<section className="space-y-3">
<h2 className="text-xl font-semibold">Criar conta</h2>
<p className="whitespace-pre-wrap">{portal.notice}</p>
{portal.registration_open ? (
<form
className="space-y-3"
onSubmit={(e) => {
e.preventDefault();
register.post(
`/candidaturas/${company.id}/registar`,
{
onFinish: () =>
register.reset(
'password',
'password_confirmation',
),
},
);
}}
>
<FormErrors errors={register.errors} />
<label className="grid gap-2">
Email
<input
className={controlClass}
type="email"
autoComplete="email"
required
value={register.data.email}
onChange={(e) =>
register.setData(
'email',
e.target.value,
)
}
/>
</label>
<label className="grid gap-2">
Palavra-passe (mínimo 12 caracteres)
<input
className={controlClass}
type="password"
autoComplete="new-password"
minLength={12}
maxLength={128}
required
value={register.data.password}
onChange={(e) =>
register.setData(
'password',
e.target.value,
)
}
/>
</label>
<label className="grid gap-2">
Confirmar palavra-passe
<input
className={controlClass}
type="password"
autoComplete="new-password"
required
value={register.data.password_confirmation}
onChange={(e) =>
register.setData(
'password_confirmation',
e.target.value,
)
}
/>
</label>
<label className="flex gap-2">
<input
type="checkbox"
required
checked={register.data.notice_acknowledged}
onChange={(e) =>
register.setData(
'notice_acknowledged',
e.target.checked,
)
}
/>
Tomei conhecimento do aviso apresentado, versão{' '}
{portal.version}.
</label>
<p className="text-sm">
Este registo não autoriza a partilha do perfil
para outras finalidades ou empresas.
</p>
<Button disabled={register.processing}>
Criar a minha conta
</Button>
</form>
) : (
<p>
Novos registos estão encerrados. As contas
existentes podem continuar a entrar.
</p>
)}
</section>
</main>
</>
);
}
@@ -0,0 +1,215 @@
import { Head, Link, useForm } from '@inertiajs/react';
import { FormErrors, controlClass } from '@/components/procurement';
import { Button } from '@/components/ui/button';
type Field = { key: string; label: string; type: string; required: boolean };
type Location = { province: string; district: string; locality: string };
const answerText = (value: string | Location) =>
typeof value === 'string' ? value : '';
export default function Opportunity({
companyId,
opportunity,
profile,
email,
emailVerified,
}: {
companyId: number;
opportunity: {
id: number;
title: string;
description: string;
closes_on: string;
publication_version: number;
form: {
version: number;
definition: {
fields: Field[];
documents: { key: string; label: string; required: boolean }[];
};
};
};
profile: {
version: number;
data: {
name: string;
phone?: string;
summary: string;
availability: Location;
};
};
email: string;
emailVerified: boolean;
}) {
const source: Record<string, string | Location> = {
name: profile.data.name,
full_name: profile.data.name,
email,
phone: profile.data.phone ?? '',
summary: profile.data.summary,
availability_location: profile.data.availability,
};
const form = useForm({
operation_key: crypto.randomUUID(),
profile_version: profile.version,
publication_version: opportunity.publication_version,
answers: Object.fromEntries(
opportunity.form.definition.fields.map((field) => [
field.key,
field.type === 'location'
? typeof source[field.key] === 'object'
? source[field.key]
: { province: '', district: '', locality: '' }
: typeof source[field.key] === 'string'
? source[field.key]
: '',
]),
) as Record<string, string | Location>,
});
return (
<>
<Head title={opportunity.title} />
<main className="mx-auto max-w-3xl space-y-5 p-6">
<Link
href={`/candidaturas/${companyId}/perfil`}
className="underline"
>
O meu perfil
</Link>
<h1 className="text-3xl font-semibold">{opportunity.title}</h1>
<p className="whitespace-pre-wrap">{opportunity.description}</p>
<p>
Fecho: {opportunity.closes_on} · formulário v
{opportunity.form.version} · perfil v{profile.version}
</p>
<p>
Dados existentes foram preenchidos a partir do perfil. As
respostas específicas não alteram o perfil. A recepção não
constitui aceitação, selecção ou contratação.
</p>
{!emailVerified && (
<p>Confirme o email na sua área antes de submeter.</p>
)}
{profile.version === 0 && <p>Guarde primeiro o seu perfil.</p>}
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
form.post(
`/candidaturas/${companyId}/oportunidades/${opportunity.id}`,
);
}}
>
<FormErrors errors={form.errors} />
{opportunity.form.definition.fields.map((field) =>
field.type === 'location' ? (
<fieldset
key={field.key}
className="grid gap-3 sm:grid-cols-3"
>
<legend>
{field.label}
{field.required ? ' *' : ''}
</legend>
{(
[
'province',
'district',
'locality',
] as const
).map((part) => (
<label key={part}>
{
{
province: 'Província',
district: 'Distrito',
locality: 'Localidade',
}[part]
}
<input
className={controlClass}
required={field.required}
value={
(typeof form.data.answers[
field.key
] === 'object'
? (form.data.answers[
field.key
] as Location)
: {
province: '',
district: '',
locality: '',
})[part]
}
onChange={(e) =>
form.setData('answers', {
...form.data.answers,
[field.key]: {
...(form.data.answers[
field.key
] as Location),
[part]: e.target.value,
},
})
}
/>
</label>
))}
</fieldset>
) : (
<label className="grid gap-2" key={field.key}>
{field.label}
{field.required ? ' *' : ''}
{field.type === 'textarea' ? (
<textarea
className={controlClass}
required={field.required}
value={answerText(
form.data.answers[field.key],
)}
onChange={(e) =>
form.setData('answers', {
...form.data.answers,
[field.key]: e.target.value,
})
}
/>
) : (
<input
className={controlClass}
type={field.type}
required={field.required}
value={answerText(
form.data.answers[field.key],
)}
onChange={(e) =>
form.setData('answers', {
...form.data.answers,
[field.key]: e.target.value,
})
}
/>
)}
</label>
),
)}
{opportunity.form.definition.documents.length > 0 && (
<p>
O envio de anexos ainda está em desenvolvimento. Não
é possível entregar documentos neste formulário.
</p>
)}
<Button
disabled={
form.processing ||
!emailVerified ||
profile.version === 0
}
>
Submeter candidatura e obter comprovativo
</Button>
</form>
</main>
</>
);
}
+217
View File
@@ -0,0 +1,217 @@
import { Head, Link, router, useForm } from '@inertiajs/react';
import { FormErrors, controlClass } from '@/components/procurement';
import { Button } from '@/components/ui/button';
type Data = {
name: string;
phone: string | null;
summary: string;
availability: { province: string; district: string; locality: string };
};
export default function Profile({
companyId,
email,
profile,
emailVerified = false,
emailDeliveryAvailable = false,
accessStatus,
opportunities = [],
submissions = [],
}: {
companyId: number;
email: string;
profile: { version: number; data: Data };
emailVerified?: boolean;
emailDeliveryAvailable?: boolean;
accessStatus?: string | null;
opportunities?: { id: number; title: string; closes_on: string }[];
submissions?: { id: number; reference: string; created_at: string }[];
}) {
const form = useForm({
version: profile.version,
...profile.data,
phone: profile.data.phone ?? '',
reason: '',
});
return (
<>
<Head title="O meu perfil" />
<section className="mx-auto max-w-3xl space-y-3 p-6">
<h2 className="text-xl font-semibold">
Oportunidades publicadas
</h2>
{opportunities.map((item) => (
<p key={item.id}>
<Link
className="underline"
href={`/candidaturas/${companyId}/oportunidades/${item.id}`}
>
{item.title} · {item.closes_on}
</Link>
</p>
))}
<h2 className="text-xl font-semibold">
As minhas candidaturas
</h2>
{submissions.map((item) => (
<p key={item.id}>
<Link
className="underline"
href={`/candidaturas/${companyId}/submissoes/${item.id}`}
>
{item.reference} · {item.created_at}
</Link>
</p>
))}
</section>
<main className="mx-auto max-w-3xl space-y-6 p-6">
<p>SGC · Área do candidato</p>
<h1 className="text-3xl font-semibold">O meu perfil</h1>
<p>
{email} · versão {profile.version}
</p>
<p>
{emailVerified
? 'Email confirmado. A identidade ainda depende de verificação própria.'
: 'Email ainda não confirmado. A entrada comprova apenas o acesso à conta.'}{' '}
Pode candidatar-se às oportunidades publicadas. O envio de
anexos ainda está em desenvolvimento.
</p>
{!emailVerified && (
<Button
variant="outline"
disabled={!emailDeliveryAvailable}
onClick={() =>
router.post(
`/candidaturas/${companyId}/pedir-confirmacao`,
)
}
>
Pedir confirmação do email
</Button>
)}
{!emailVerified && !emailDeliveryAvailable && (
<p>
O envio de email ainda não está configurado; pode
continuar a preparar o perfil.
</p>
)}
{accessStatus === 'requested' && (
<p>
Se o envio estiver disponível, receberá uma ligação
temporária de confirmação.
</p>
)}
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
form.post(`/candidaturas/${companyId}/perfil`, {
preserveScroll: true,
preserveState: false,
});
}}
>
<FormErrors errors={form.errors} />
<label className="grid gap-2">
Nome completo
<input
className={controlClass}
required
minLength={3}
maxLength={200}
value={form.data.name}
onChange={(e) =>
form.setData('name', e.target.value)
}
/>
</label>
<label className="grid gap-2">
Telefone
<input
className={controlClass}
maxLength={50}
value={form.data.phone}
onChange={(e) =>
form.setData('phone', e.target.value)
}
/>
</label>
<label className="grid gap-2">
Resumo profissional
<textarea
className={controlClass}
required
minLength={10}
maxLength={5000}
value={form.data.summary}
onChange={(e) =>
form.setData('summary', e.target.value)
}
/>
</label>
<fieldset className="grid gap-3 sm:grid-cols-3">
<legend>
Local de disponibilidade para trabalhar ou prestar
serviço
</legend>
{(['province', 'district', 'locality'] as const).map(
(key) => (
<label className="grid gap-2" key={key}>
{
{
province: 'Província',
district: 'Distrito',
locality: 'Localidade',
}[key]
}
<input
className={controlClass}
required
maxLength={150}
value={form.data.availability[key]}
onChange={(e) =>
form.setData('availability', {
...form.data.availability,
[key]: e.target.value,
})
}
/>
</label>
),
)}
</fieldset>
<p className="text-sm">
Disponibilidade é distinta da morada. Actualizar o
perfil cria uma versão para usos futuros, preservando as
anteriores.
</p>
<label className="grid gap-2">
Motivo da actualização
<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}>
Guardar nova versão do perfil
</Button>
</form>
<Button
variant="outline"
onClick={() =>
router.post(`/candidaturas/${companyId}/sair`)
}
>
Sair da área do candidato
</Button>
</main>
</>
);
}
+99
View File
@@ -0,0 +1,99 @@
import { Head, Link } from '@inertiajs/react';
export default function Receipt({
companyId,
receipt,
decision,
}: {
companyId: number;
decision?: null | { outcome: string; message: string; decided_at: string };
receipt: {
reference: string;
received_at: string;
snapshot: {
opportunity: {
title: string;
form?: {
definition: { fields: { key: string; label: string }[] };
};
};
profile: { version: number };
answers: Record<
string,
string | number | null | Record<string, string>
>;
};
};
}) {
return (
<>
<Head title="Comprovativo de candidatura" />
<main className="mx-auto max-w-3xl space-y-5 p-6">
<h1 className="text-3xl font-semibold">Candidatura recebida</h1>
{decision && (
<section className="space-y-2">
<h2 className="text-xl font-semibold">
{decision.outcome === 'accepted_for_analysis'
? 'Aceite para análise'
: 'Não aceite para análise'}
</h2>
<p>{decision.message}</p>
<p>{decision.decided_at}</p>
</section>
)}
<p>{receipt.reference}</p>
<p>
{receipt.received_at} · {receipt.snapshot.opportunity.title}{' '}
· perfil v{receipt.snapshot.profile.version}
</p>
<p>
A recepção não significa aceitação para análise, inclusão na
base, selecção ou contratação.
</p>
<dl>
{Object.entries(receipt.snapshot.answers).map(
([key, value]) => (
<div key={key} className="my-3">
<dt className="font-semibold">
{receipt.snapshot.opportunity.form?.definition.fields.find(
(field) => field.key === key,
)?.label ?? 'Resposta'}
</dt>
<dd>
{value !== null && typeof value === 'object'
? Object.entries(value).map(
([part, text]) => (
<p key={part}>
{(
{
province:
'Província',
district:
'Distrito',
locality:
'Localidade',
} as Record<
string,
string
>
)[part] ?? 'Local'}
: {text}
</p>
),
)
: (value ?? 'Não indicado')}
</dd>
</div>
),
)}
</dl>
<Link
className="underline"
href={`/candidaturas/${companyId}/perfil`}
>
O meu perfil e candidaturas
</Link>
</main>
</>
);
}
@@ -0,0 +1,119 @@
import { Head, Link, useForm } from '@inertiajs/react';
import { FormErrors, PageTitle, controlClass } from '@/components/procurement';
import { Button } from '@/components/ui/button';
export default function Portal({
portal,
portalUrl,
canManage,
emailDeliveryAvailable = false,
}: {
portal: { version: number; registration_open: boolean; notice: string };
portalUrl: string;
canManage: boolean;
emailDeliveryAvailable?: boolean;
}) {
const form = useForm({
version: portal.version,
registration_open: portal.registration_open,
notice: portal.notice,
reason: '',
});
return (
<>
<Head title="Área externa do candidato" />
<main className="mx-auto max-w-3xl space-y-6 p-6">
<PageTitle
title="Área externa do candidato"
eyebrow="Administração"
description="Configure o acesso a perfis de candidatos. Cada oportunidade tem publicação própria; a recepção de anexos ainda está em desenvolvimento."
/>
<p>
{emailDeliveryAvailable
? 'Transporte de email configurado; a entrega ainda deve ser conferida no servidor.'
: 'Envio de email não configurado. Confirmação e recuperação estão implementadas, sem emissão de ligações para logs.'}
</p>
<Link
className="underline"
href="/administracao/candidaturas/formularios"
>
Formulários de candidaturas
</Link>
<p>
Versão {portal.version}. A abertura permite criar contas e
preencher perfis. As candidaturas dependem da publicação de
cada oportunidade. Não cria autorizações de partilha entre
empresas.
</p>
{portal.version > 0 && (
<Link className="underline" href={portalUrl}>
Abrir área do candidato
</Link>
)}
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
form.post('/administracao/candidaturas/portal', {
preserveScroll: true,
preserveState: false,
});
}}
>
<FormErrors errors={form.errors} />
<fieldset
disabled={!canManage || form.processing}
className="space-y-4"
>
<label className="flex gap-2">
<input
type="checkbox"
checked={form.data.registration_open}
onChange={(e) =>
form.setData(
'registration_open',
e.target.checked,
)
}
/>
Permitir novos registos de candidatos
</label>
<label className="grid gap-2">
Aviso apresentado antes do registo
<textarea
className={controlClass}
required
minLength={30}
maxLength={15000}
value={form.data.notice}
onChange={(e) =>
form.setData('notice', e.target.value)
}
/>
</label>
<p className="text-sm">
A empresa define finalidades, fundamento aplicável,
conservação e contacto no aviso. A confirmação de
leitura não equivale a consentimento para bolsa ou
partilha.
</p>
<label className="grid gap-2">
Motivo da configuração
<textarea
className={controlClass}
required
minLength={10}
maxLength={4000}
value={form.data.reason}
onChange={(e) =>
form.setData('reason', e.target.value)
}
/>
</label>
{canManage && <Button>Guardar configuração</Button>}
</fieldset>
</form>
</main>
</>
);
}
+252 -1
View File
@@ -1,4 +1,10 @@
import { Head, useForm } from '@inertiajs/react';
import { Head, Link, useForm } from '@inertiajs/react';
import { AiPlanSettings } from '@/components/ai-plan-settings';
import type { AiPlan } from '@/components/ai-plan-settings';
import { IntelligenceSettings } from '@/components/intelligence-settings';
import type { IntelligencePolicy } from '@/components/intelligence-settings';
import { comparisonCriteria } from '@/components/comparison-policy-summary';
import type { ComparisonPolicy } from '@/components/comparison-policy-summary';
import {
PageTitle,
Panel,
@@ -161,8 +167,20 @@ export default function Parameters({
costCenters,
notice,
companyRules,
comparisonPolicy,
intelligencePolicy,
intelligenceReady = false,
aiPlan,
aiDomains = {},
aiDataScopes = {},
}: {
ready: boolean;
comparisonPolicy: ComparisonPolicy;
intelligencePolicy?: IntelligencePolicy;
intelligenceReady?: boolean;
aiPlan?: AiPlan;
aiDomains?: Record<string, string>;
aiDataScopes?: Record<string, string>;
active: boolean;
version: number;
permissions: { [key: string]: string };
@@ -177,6 +195,7 @@ export default function Parameters({
second_limit: string;
includes_tax: boolean;
provisional: boolean;
default_tax_rate: string;
};
}) {
const profile = useForm({
@@ -204,10 +223,28 @@ export default function Parameters({
reason: '',
});
const department = useForm({ name: '' });
const tax = useForm({
version,
default_tax_rate: companyRules.default_tax_rate,
reason: '',
});
const center = useForm({ department_id: '', code: '', name: '' });
const comparison = useForm({
version,
minimum_proposals: comparisonPolicy.minimum_proposals,
minimum_evaluators: comparisonPolicy.minimum_evaluators,
weights: { ...comparisonPolicy.weights },
reason: '',
});
return (
<>
<Head title="Parametrização da aplicação" />
<Link
href="/administracao/candidaturas/formularios"
className="m-5 inline-block font-semibold underline"
>
Formulários de inscrições e candidaturas
</Link>
<div className="mx-auto max-w-6xl space-y-6 px-5 py-8 lg:px-8">
<PageTitle
eyebrow="Administração"
@@ -298,6 +335,220 @@ export default function Parameters({
</form>
)}
</Panel>
{intelligencePolicy && (
<IntelligenceSettings
key={`intelligence-${version}`}
policy={intelligencePolicy}
version={version}
ready={intelligenceReady}
/>
)}
{aiPlan && (
<AiPlanSettings
key={`ai-plan-${version}`}
plan={aiPlan}
domains={aiDomains}
scopes={aiDataScopes}
version={version}
ready={intelligenceReady}
/>
)}
<Panel title="Regras de comparação de propostas">
<p className="mb-4 text-sm">
{comparisonPolicy.configured
? `Revisão ${comparisonPolicy.revision} da empresa.`
: 'Valores iniciais de compatibilidade.'}{' '}
A alteração aplica-se apenas a requisições
criadas depois de guardar. Rascunhos e processos
existentes conservam a regra anterior. Os
avaliadores devem continuar independentes e sem
conflito de interesses.
</p>
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
comparison.post(base + '/comparison', {
preserveState: 'errors',
preserveScroll: true,
});
}}
>
<FormErrors errors={comparison.errors} />
<div className="grid gap-4 md:grid-cols-2">
<label className="block space-y-1">
Mínimo de fornecedores com proposta
elegível
<input
className={controlClass}
type="number"
min="1"
max="100"
step="1"
required
value={
comparison.data
.minimum_proposals
}
onChange={(e) =>
comparison.setData(
'minimum_proposals',
Number(e.target.value),
)
}
/>
</label>
<label className="block space-y-1">
Mínimo de avaliadores independentes por
proposta
<input
className={controlClass}
type="number"
min="1"
max="100"
step="1"
required
value={
comparison.data
.minimum_evaluators
}
onChange={(e) =>
comparison.setData(
'minimum_evaluators',
Number(e.target.value),
)
}
/>
</label>
</div>
<fieldset className="grid gap-4 md:grid-cols-3">
<legend className="mb-3 font-medium">
Ponderações — soma obrigatória de 100%
</legend>
{Object.entries(comparisonCriteria).map(
([key, label]) => (
<label
key={key}
className="block space-y-1"
>
{label} (%)
<input
className={controlClass}
type="number"
min="0"
max="100"
step="1"
required
value={
comparison.data.weights[
key as keyof typeof comparisonCriteria
]
}
onChange={(e) =>
comparison.setData(
'weights',
{
...comparison
.data
.weights,
[key]: Number(
e.target
.value,
),
},
)
}
/>
</label>
),
)}
</fieldset>
<p className="text-sm">
Soma actual:{' '}
{Object.values(
comparison.data.weights,
).reduce((sum, weight) => sum + weight, 0)}
%.
</p>
<label className="block space-y-1">
Fundamento e âmbito da alteração
<textarea
className={controlClass}
required
minLength={10}
maxLength={4000}
value={comparison.data.reason}
onChange={(e) =>
comparison.setData(
'reason',
e.target.value,
)
}
/>
</label>
<Button disabled={comparison.processing}>
Guardar para novos processos
</Button>
</form>
</Panel>
<Panel title="IVA para novas linhas">
<p className="mb-4 text-sm">
Taxa inicial para novas linhas de requisição.
Pode ser ajustada em cada artigo conforme o
enquadramento fiscal. As linhas e propostas
anteriores conservam a taxa registada. Os
descontos unitários incidem antes do IVA.
</p>
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
tax.post(base + '/tax', {
preserveState: 'errors',
preserveScroll: true,
});
}}
>
<FormErrors errors={tax.errors} />
<label className="block space-y-1">
Taxa de IVA inicial (%)
<input
className={controlClass}
type="number"
min="0"
max="100"
step="0.01"
required
value={tax.data.default_tax_rate}
onChange={(e) =>
tax.setData(
'default_tax_rate',
e.target.value,
)
}
/>
</label>
<label className="block space-y-1">
Fundamento e âmbito fiscal
<textarea
className={controlClass}
required
minLength={10}
maxLength={4000}
value={tax.data.reason}
onChange={(e) =>
tax.setData(
'reason',
e.target.value,
)
}
/>
</label>
<Button disabled={tax.processing}>
Guardar taxa inicial
</Button>
</form>
</Panel>
<Panel title="Regras de aprovação da empresa">
<p className="mb-4 text-sm">
{companyRules.provisional
@@ -0,0 +1,478 @@
import { Head, Link, useForm } from '@inertiajs/react';
import {
FormErrors,
PageTitle,
Panel,
controlClass,
} from '@/components/procurement';
import { Button } from '@/components/ui/button';
type Field = { key: string; label: string; type: string; required: boolean };
type Document = {
key: string;
label: string;
required: boolean;
formats: string[];
max_kb: number;
};
type Definition = {
available: boolean;
fields: Field[];
documents: Document[];
};
type Revision = {
id: number;
version: number;
created_by: number;
created_at: string;
reason: string;
definition: Definition;
};
export type SubmissionForm = {
audience: string;
modality: string;
version: number;
definition: Definition;
history: Revision[];
};
const audiences: Record<string, string> = {
staff: 'Recrutamento',
service: 'Prestadores individuais',
supplier: 'Fornecedores',
};
function Editor({
record,
canManage,
}: {
record: SubmissionForm;
canManage: boolean;
}) {
const form = useForm({
audience: record.audience,
modality: record.modality,
version: record.version,
definition: record.definition,
reason: '',
});
const updateField = (index: number, patch: Partial<Field>) =>
form.setData('definition', {
...form.data.definition,
fields: form.data.definition.fields.map((field, i) =>
i === index ? { ...field, ...patch } : field,
),
});
const updateDocument = (index: number, patch: Partial<Document>) =>
form.setData('definition', {
...form.data.definition,
documents: form.data.definition.documents.map((document, i) =>
i === index ? { ...document, ...patch } : document,
),
});
return (
<Panel
title={`${audiences[record.audience]} · ${record.modality === 'competition' ? 'Com concurso' : 'Espontânea'} · v${record.version}`}
>
<form
className="space-y-5"
onSubmit={(event) => {
event.preventDefault();
form.post('/administracao/candidaturas/formularios', {
preserveScroll: true,
});
}}
>
<FormErrors errors={form.errors} />
<fieldset
disabled={!canManage || form.processing}
className="space-y-5"
>
<label className="flex gap-2">
<input
type="checkbox"
checked={form.data.definition.available}
onChange={(event) =>
form.setData('definition', {
...form.data.definition,
available: event.target.checked,
})
}
/>
Modalidade prevista para recepção
</label>
<p className="text-sm text-muted-foreground">
Esta configuração prepara os próximos formulários. Ainda
não abre candidaturas externas. Cada submissão
conservará a versão utilizada.
</p>
<h3 className="font-semibold">Campos do formulário</h3>
{form.data.definition.fields.map((field, index) => (
<fieldset
key={index}
className="grid gap-3 rounded border p-3 sm:grid-cols-2"
>
<legend>Campo {index + 1}</legend>
<label>
Identificador estável
<input
className={controlClass}
required
pattern="[a-z][a-z0-9_]{0,49}"
value={field.key}
onChange={(e) =>
updateField(index, {
key: e.target.value,
})
}
/>
</label>
<label>
Designação
<input
className={controlClass}
required
maxLength={200}
value={field.label}
onChange={(e) =>
updateField(index, {
label: e.target.value,
})
}
/>
</label>
<label>
Tipo
<select
className={controlClass}
value={field.type}
onChange={(e) =>
updateField(index, {
type: e.target.value,
})
}
>
{Object.entries({
text: 'Texto',
textarea: 'Texto longo',
email: 'Email',
date: 'Data',
number: 'Número',
location:
'Disponibilidade: província, distrito e localidade',
}).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</label>
{field.type === 'location' && (
<fieldset
disabled
className="grid gap-2 sm:col-span-2 sm:grid-cols-3"
>
<legend>
Pré-visualização da disponibilidade
</legend>
{[
'Província',
'Distrito',
'Localidade',
].map((label) => (
<label key={label}>
{label}
<input
className={controlClass}
placeholder={label}
/>
</label>
))}
</fieldset>
)}
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={field.required}
onChange={(e) =>
updateField(index, {
required: e.target.checked,
})
}
/>
Obrigatório
</label>
<Button
type="button"
variant="outline"
onClick={() =>
form.setData('definition', {
...form.data.definition,
fields: form.data.definition.fields.filter(
(_, i) => i !== index,
),
})
}
>
Retirar campo desta versão
</Button>
</fieldset>
))}
<Button
type="button"
variant="outline"
disabled={form.data.definition.fields.length >= 40}
onClick={() =>
form.setData('definition', {
...form.data.definition,
fields: [
...form.data.definition.fields,
{
key: '',
label: '',
type: 'text',
required: false,
},
],
})
}
>
Adicionar campo
</Button>
<p className="text-sm text-muted-foreground">
Disponibilidade geográfica usa três campos próprios:
província, distrito e localidade. É o local onde a
pessoa pode trabalhar ou prestar serviço, distinto da
morada. A obrigatoriedade é definida neste formulário.
</p>
<h3 className="font-semibold">Documentos pedidos</h3>
{form.data.definition.documents.map((document, index) => (
<fieldset
key={index}
className="grid gap-3 rounded border p-3 sm:grid-cols-2"
>
<legend>Documento {index + 1}</legend>
<label>
Identificador estável
<input
className={controlClass}
required
pattern="[a-z][a-z0-9_]{0,49}"
value={document.key}
onChange={(e) =>
updateDocument(index, {
key: e.target.value,
})
}
/>
</label>
<label>
Designação
<input
className={controlClass}
required
maxLength={200}
value={document.label}
onChange={(e) =>
updateDocument(index, {
label: e.target.value,
})
}
/>
</label>
<label>
Limite por ficheiro (KB)
<input
className={controlClass}
type="number"
required
min={1}
max={51200}
value={document.max_kb || ''}
onChange={(e) =>
updateDocument(index, {
max_kb: Number(e.target.value),
})
}
/>
</label>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={document.required}
onChange={(e) =>
updateDocument(index, {
required: e.target.checked,
})
}
/>
Obrigatório
</label>
<div className="flex flex-wrap gap-4">
{['pdf', 'docx', 'jpg', 'png'].map((format) => (
<label key={format}>
<input
type="checkbox"
checked={document.formats.includes(
format,
)}
onChange={(e) =>
updateDocument(index, {
formats: e.target.checked
? [
...document.formats,
format,
]
: document.formats.filter(
(v) =>
v !== format,
),
})
}
/>{' '}
{format.toUpperCase()}
</label>
))}
</div>
<Button
type="button"
variant="outline"
onClick={() =>
form.setData('definition', {
...form.data.definition,
documents:
form.data.definition.documents.filter(
(_, i) => i !== index,
),
})
}
>
Retirar documento desta versão
</Button>
</fieldset>
))}
<Button
type="button"
variant="outline"
disabled={form.data.definition.documents.length >= 20}
onClick={() =>
form.setData('definition', {
...form.data.definition,
documents: [
...form.data.definition.documents,
{
key: '',
label: '',
required: false,
formats: [],
max_kb: 0,
},
],
})
}
>
Adicionar documento
</Button>
<label className="grid gap-2">
Motivo da alteração
<textarea
className={controlClass}
required
minLength={10}
maxLength={4000}
value={form.data.reason}
onChange={(e) =>
form.setData('reason', e.target.value)
}
/>
</label>
{canManage && <Button>Guardar nova versão</Button>}
</fieldset>
</form>
<details className="mt-5">
<summary>
Histórico e definições anteriores ({record.history.length})
</summary>
{record.history.map((revision) => (
<div key={revision.id} className="my-3 border-t py-3">
<p>
Versão {revision.version} · {revision.created_at} ·
utilizador {revision.created_by}
</p>
<p>{revision.reason}</p>
<p>
{revision.definition.available
? 'Prevista para recepção'
: 'Indisponível para novas submissões'}
</p>
<ul>
{revision.definition.fields.map((field) => (
<li key={field.key}>
{field.label} · {field.type} ·{' '}
{field.required
? 'Obrigatório'
: 'Opcional'}
</li>
))}
{revision.definition.documents.map((document) => (
<li key={document.key}>
{document.label} ·{' '}
{document.formats.join(', ')} ·{' '}
{document.max_kb} KB ·{' '}
{document.required
? 'Obrigatório'
: 'Opcional'}
</li>
))}
</ul>
</div>
))}
</details>
</Panel>
);
}
export default function SubmissionForms({
forms,
canManage,
ready,
}: {
forms: SubmissionForm[];
canManage: boolean;
ready: boolean;
externalPortalAvailable: boolean;
}) {
return (
<>
<Head title="Formulários de candidaturas" />
<Link className="m-5 inline-block underline" href="/administracao/candidaturas/portal">Configurar área externa do candidato</Link>
<div className="mx-auto max-w-5xl space-y-6 p-5 lg:p-8">
<PageTitle
title="Inscrições e candidaturas electrónicas"
eyebrow="Administração"
description="Configure separadamente recrutamento, prestadores individuais e fornecedores, com concurso ou por inscrição espontânea."
/>
<Link
className="underline"
href="/administracao/parametrizacao"
>
Parametrização da aplicação
</Link>
<p>
Portal externo em desenvolvimento. Guardar estas definições
não publica oportunidades nem altera processos existentes.
</p>
{!ready ? (
<p>Instalação dos formulários pendente.</p>
) : (
forms.map((record) => (
<Editor
key={`${record.audience}:${record.modality}:${record.version}`}
record={record}
canManage={canManage}
/>
))
)}
</div>
</>
);
}
+12 -8
View File
@@ -43,7 +43,7 @@ const areas = [
export default function Home() {
return (
<>
<Head title="Gestão de compras" />
<Head title="Gestão de contratações" />
<div className="min-h-screen bg-background text-foreground">
<header className="border-b">
<div className="mx-auto flex max-w-7xl flex-wrap items-center justify-between gap-4 px-6 py-5 lg:px-10">
@@ -56,7 +56,7 @@ export default function Home() {
SGC
</span>
<span className="block text-xs text-muted-foreground">
Sistema de Gestão de Compras
Sistema de Gestão de Contratações
</span>
</div>
</Link>
@@ -93,17 +93,18 @@ export default function Home() {
Cada etapa no seu lugar
</p>
<h1 className="max-w-2xl text-4xl leading-tight font-semibold tracking-tight sm:text-5xl">
Gestão de compras,
Gestão de contratações,
<br />
<span className="text-teal-800 dark:text-teal-400">
do pedido ao stock.
aquisições, serviços e pessoas.
</span>
</h1>
<p className="mt-6 max-w-xl text-base leading-7 text-muted-foreground">
Um percurso organizado para a sua equipa:
necessidades, fornecedores, decisões, entregas e
armazém. Escolha a área em que quer trabalhar ou
conheça primeiro os exemplos de demonstração.
necessidades, fornecedores, prestadores,
recrutamento, decisões e entregas. Escolha a
área em que quer trabalhar ou conheça primeiro
os exemplos de demonstração.
</p>
<div className="mt-8 flex flex-wrap gap-3">
<Button
@@ -254,7 +255,10 @@ export default function Home() {
</main>
<footer className="border-t">
<div className="mx-auto flex max-w-7xl flex-wrap justify-between gap-4 px-6 py-6 text-xs leading-5 text-muted-foreground lg:px-10">
<span>SGC · Ambiente de desenvolvimento</span>
<span>
SGC · Aquisições, serviços e pessoas · Uma solução
TEMPO
</span>
<span>
O orçamento ainda não é validado. Os documentos não
autorizam compras reais.
+182
View File
@@ -0,0 +1,182 @@
import { Head, Link, useForm } from '@inertiajs/react';
import {
FormErrors,
PageTitle,
Panel,
controlClass,
} from '@/components/procurement';
import { Button } from '@/components/ui/button';
type Assignment = {
user_id: number;
scopes: string[];
expires_at: string | null;
};
function Person({
person,
scopes,
assigned,
version,
processId,
}: {
person: { id: number; name: string };
scopes: Record<string, string>;
assigned?: Assignment;
version: number;
processId: number;
}) {
const expiry = assigned?.expires_at
? new Date(assigned.expires_at.replace(' ', 'T') + 'Z')
: null;
const localExpiry = expiry
? new Date(expiry.getTime() - expiry.getTimezoneOffset() * 60000)
.toISOString()
.slice(0, 16)
: '';
const form = useForm({
version,
user_id: person.id,
scopes: assigned?.scopes ?? [],
expires_at: localExpiry,
reason: '',
});
return (
<details className="rounded-lg border p-4">
<summary className="cursor-pointer font-medium">
{person.name} · {assigned?.scopes.length ?? 0} atribuição(ões)
</summary>
<form
className="mt-4 space-y-4"
onSubmit={(e) => {
e.preventDefault();
form.transform((data) => ({
...data,
expires_at: data.expires_at
? new Date(data.expires_at).toISOString()
: null,
}));
form.post(`/pessoas/${processId}/acessos`, {
preserveState: 'errors',
preserveScroll: true,
});
}}
>
<FormErrors errors={form.errors} />
<div className="grid gap-3 md:grid-cols-2">
{Object.entries(scopes).map(([key, label]) => (
<label className="flex gap-3" key={key}>
<input
type="checkbox"
checked={form.data.scopes.includes(key)}
onChange={(e) =>
form.setData(
'scopes',
e.target.checked
? [...form.data.scopes, key]
: form.data.scopes.filter(
(s) => s !== key,
),
)
}
/>
{label}
</label>
))}
</div>
<label className="block">
Validade até (opcional)
<input
className={controlClass}
type="datetime-local"
value={form.data.expires_at}
onChange={(e) =>
form.setData('expires_at', 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>
<p className="text-sm">
Retirar todas as opções revoga o acesso. Seleccionar uma
intervenção inclui a consulta do processo; dados
financeiros, entrevistas e minuta têm permissões próprias. A
atribuição não elimina as regras de independência.
</p>
<Button disabled={form.processing}>Guardar atribuição</Button>
</form>
</details>
);
}
export default function Access({
process,
version,
scopes,
users,
assignments,
canView,
}: {
process: { id: number; kind: string; status: string };
version: number;
scopes: Record<string, string>;
users: { id: number; name: string }[];
assignments: Assignment[];
canView: boolean;
}) {
return (
<>
<Head title="Equipa do processo" />
<div className="mx-auto max-w-5xl space-y-6 p-5 lg:p-8">
<PageTitle
eyebrow={
process.kind === 'staff'
? 'Recrutamento'
: 'Serviços individuais'
}
title={`Equipa do processo #${process.id}`}
description="Configurar a equipa não concede acesso automático a candidaturas, entrevistas ou condições financeiras."
/>
<Link href="/pessoas" className="underline">
Voltar a Pessoas e contratações
</Link>
{canView && (
<Link
href={`/pessoas/${process.id}`}
className="ml-4 underline"
>
Consultar processo
</Link>
)}
<Panel title="Atribuições nominativas">
<p className="mb-4 text-sm">
Escolha pessoas com perfil compatível na parametrização
da empresa. Separe preparação, avaliação e decisão.
Versão {version}.
</p>
<div className="space-y-3">
{users.map((person) => (
<Person
key={`${person.id}-${version}`}
person={person}
scopes={scopes}
assigned={assignments.find(
(a) => a.user_id === person.id,
)}
version={version}
processId={process.id}
/>
))}
</div>
</Panel>
</div>
</>
);
}
+938
View File
@@ -0,0 +1,938 @@
import { Head, Link, router, useForm } from '@inertiajs/react';
import {
PageTitle,
Panel,
FormErrors,
controlClass,
} from '@/components/procurement';
import { Button } from '@/components/ui/button';
import { peopleStates } from '@/lib/people-hiring';
import { PeopleOperationalInsights } from '@/components/people-operational-insights';
import type { OperationalInsights } from '@/components/people-operational-insights';
const kinds: Record<string, string> = {
service: 'Prestador individual',
staff: 'Trabalhador para o quadro',
};
type WorkItem = {
id: string;
process_id: number;
reference: string;
title: string;
kind: string;
status: string;
action: string;
label: string;
due_on: string | null;
href: string;
};
type Workdesk = {
updated_at: string;
counts: Record<
| 'processes'
| 'active'
| 'contract_drafts'
| 'tasks'
| 'approvals'
| 'overdue',
number
>;
tasks: WorkItem[];
approvals: WorkItem[];
alerts: {
id?: string;
process_id: number;
title: string;
label: string;
due_on: string;
href: string;
}[];
};
type Filters = {
kind?: string;
search?: string;
status?: string;
from?: string;
to?: string;
};
function Queue({
id,
title,
items,
empty,
}: {
id: string;
title: string;
items: WorkItem[];
empty: string;
}) {
return (
<div id={id} className="scroll-mt-24">
<Panel title={title}>
{items.length ? (
<div className="overflow-auto">
<table className="w-full text-left text-sm">
<thead>
<tr>
<th className="p-2">Acção pendente</th>
<th className="p-2">Processo</th>
<th className="p-2">Percurso e estado</th>
<th className="p-2">Prazo</th>
</tr>
</thead>
<tbody>
{items.map((item) => (
<tr key={item.id} className="border-t">
<td className="p-2">
<Link
className="font-medium underline"
href={item.href}
>
{item.label}
</Link>
</td>
<td className="p-2">
{item.reference} · {item.title}
</td>
<td className="p-2">
{kinds[item.kind]} ·{' '}
{peopleStates[item.status]}
</td>
<td className="p-2">
{item.due_on ?? 'Não definido'}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<p>{empty}</p>
)}
</Panel>
</div>
);
}
export default function PeopleIndex({
ready,
kinds: allowed,
canPrepare,
processes,
administration = [],
formFields = {},
availableKinds = allowed,
workdesk,
insights,
filters = {},
company,
}: {
ready: boolean;
availableKinds?: string[];
workdesk?: Workdesk | null;
insights?: OperationalInsights | null;
filters?: Filters;
company?: { name: string };
administration?: { id: number; kind: string; status: string }[];
formFields?: Record<string, Record<string, string>>;
kinds: string[];
canPrepare: string[];
processes: null | {
data: {
id: number;
title: string;
kind: string;
status: string;
closes_on: string;
}[];
links: { url: string | null; label: string; active: boolean }[];
};
}) {
const form = useForm({
operation_key: crypto.randomUUID(),
kind:
filters.kind && canPrepare.includes(filters.kind)
? filters.kind
: (canPrepare[0] ?? allowed[0]),
title: '',
purpose: '',
closes_on: '',
minimum_reviews: '1',
criteria: [{ label: '', weight: '100' }],
annex: {
fields: {} as Record<string, string>,
requirements: [] as {
description: string;
classification: string;
verification: string;
equivalences: string;
}[],
},
});
const filtering = useForm({
kind: filters.kind ?? '',
search: filters.search ?? '',
status: filters.status ?? '',
from: filters.from ?? '',
to: filters.to ?? '',
});
return (
<>
<Head title="Pessoas e contratações" />
<div className="mx-auto max-w-6xl space-y-6 p-5 lg:p-8">
<PageTitle
eyebrow="Organização"
title="Pessoas e contratações"
description="Contratação de prestadores individuais e recrutamento de trabalhadores, com critérios próprios e decisão independente."
action={
ready ? (
<Button
variant="outline"
onClick={() => router.reload()}
>
Actualizar
</Button>
) : undefined
}
/>
{!ready ? (
<Panel title="Instalação pendente">
<p>O módulo ainda não foi instalado neste ambiente.</p>
</Panel>
) : (
<>
{company && (
<p className="text-sm font-medium">
Entidade activa: {company.name}
</p>
)}
<Panel title="Pesquisar e filtrar processos autorizados">
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
filtering.get('/pessoas', {
preserveState: true,
preserveScroll: true,
});
}}
>
<FormErrors errors={filtering.errors} />
<div className="grid gap-4 md:grid-cols-3">
<label>
Pesquisa por título ou referência
<input
className={controlClass}
type="search"
maxLength={100}
value={filtering.data.search}
onChange={(e) =>
filtering.setData(
'search',
e.target.value,
)
}
/>
</label>
<label>
Percurso
<select
className={controlClass}
value={filtering.data.kind}
onChange={(e) =>
filtering.setData(
'kind',
e.target.value,
)
}
>
<option value="">
Todos os autorizados
</option>
{availableKinds.map((kind) => (
<option key={kind} value={kind}>
{kinds[kind]}
</option>
))}
</select>
</label>
<label>
Estado
<select
className={controlClass}
value={filtering.data.status}
onChange={(e) =>
filtering.setData(
'status',
e.target.value,
)
}
>
<option value="">Todos</option>
{Object.entries(peopleStates).map(
([key, label]) => (
<option
key={key}
value={key}
>
{label}
</option>
),
)}
</select>
</label>
<label>
Criado desde
<input
type="date"
className={controlClass}
value={filtering.data.from}
onChange={(e) =>
filtering.setData(
'from',
e.target.value,
)
}
/>
</label>
<label>
Criado até
<input
type="date"
className={controlClass}
min={
filtering.data.from || undefined
}
value={filtering.data.to}
onChange={(e) =>
filtering.setData(
'to',
e.target.value,
)
}
/>
</label>
</div>
<div className="flex items-center gap-4">
<Button disabled={filtering.processing}>
Aplicar filtros
</Button>
<Link className="underline" href="/pessoas">
Limpar filtros
</Link>
</div>
</form>
</Panel>
{insights && (
<PeopleOperationalInsights data={insights} />
)}
{workdesk && (
<>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
{(
[
[
'processes',
'Processos autorizados',
],
[
'contract_drafts',
'Minutas preparadas',
],
['tasks', 'As minhas tarefas'],
['approvals', 'Aprovações'],
['overdue', 'Prazos vencidos'],
] as const
).map(([key, label]) => (
<div
className="rounded-xl border p-4"
key={key}
>
<p className="text-sm text-muted-foreground">
{label}
</p>
<strong className="text-2xl">
{workdesk.counts[key]}
</strong>
</div>
))}
</div>
<p className="text-xs text-muted-foreground">
Actualizado em{' '}
{new Date(
workdesk.updated_at,
).toLocaleString('pt-MZ')}
. Contagens limitadas aos filtros e aos seus
acessos. Tarefas e aprovações contam acções
distintas, não pessoas nem processos
duplicados.
</p>
<Queue
id="people-tasks"
title="As minhas tarefas"
items={workdesk.tasks}
empty="Sem tarefas operacionais pendentes para os filtros e atribuições actuais."
/>
<Queue
id="people-approvals"
title="Aprovações"
items={workdesk.approvals}
empty="Sem decisões formais pendentes para os filtros e atribuições actuais."
/>
{workdesk.alerts.length > 0 && (
<Panel title="Prazos a acompanhar">
<ul className="space-y-2">
{workdesk.alerts.map((alert) => (
<li
key={
alert.id ??
`${alert.process_id}-${alert.label}`
}
>
<Link
className="underline"
href={alert.href}
>
{alert.title} ·{' '}
{alert.label} ·{' '}
{alert.due_on}
</Link>
</li>
))}
</ul>
</Panel>
)}
</>
)}
{administration.length > 0 && (
<Panel
title="Organizar equipas"
description="Acesso à configuração das atribuições, sem consultar conteúdo dos processos."
>
<div className="flex flex-wrap gap-4">
{administration.map((p) => (
<Link
key={p.id}
href={`/pessoas/${p.id}/acessos`}
className="underline"
>
Processo #{p.id} · {kinds[p.kind]}
</Link>
))}
</div>
</Panel>
)}
<Panel title="Processos">
<div className="space-y-3">
{processes?.data.map((p) => (
<Link
className="block rounded-lg border p-4"
key={p.id}
href={`/pessoas/${p.id}`}
>
<strong>
PES-{p.id} · {p.title}
</strong>
<p>
{kinds[p.kind]} ·{' '}
{peopleStates[p.status] ?? p.status}{' '}
· candidaturas até {p.closes_on}
</p>
</Link>
))}
{!processes?.data.length && (
<p>Sem processos acessíveis.</p>
)}
</div>
<nav
aria-label="Páginas de processos"
className="mt-4 flex gap-3"
>
{processes?.links.map(
(link, i) =>
link.url && (
<Link
key={i}
href={link.url}
aria-current={
link.active
? 'page'
: undefined
}
className="underline"
dangerouslySetInnerHTML={{
__html: link.label,
}}
/>
),
)}
</nav>
</Panel>
{canPrepare.length > 0 && (
<Panel
title="Preparar novo processo"
description="A abertura exige outra pessoa. Critérios e mínimo de avaliações ficam preservados neste processo; não são herdados das compras."
>
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
form.transform((values) => ({
...values,
annex: {
...values.annex,
fields: Object.fromEntries(
Object.keys(
formFields[
values.kind
] ?? {},
).map((key) => [
key,
values.annex.fields[
key
] ?? '',
]),
),
},
}));
form.post('/pessoas', {
onSuccess: () => form.reset(),
});
}}
>
<FormErrors errors={form.errors} />
<label className="block">
Percurso
<select
className={controlClass}
value={form.data.kind}
onChange={(e) =>
form.setData(
'kind',
e.target.value,
)
}
>
{canPrepare.map((kind) => (
<option key={kind} value={kind}>
{kinds[kind]}
</option>
))}
</select>
</label>
<label className="block">
Título da necessidade ou vaga
<input
className={controlClass}
required
minLength={3}
maxLength={200}
value={form.data.title}
onChange={(e) =>
form.setData(
'title',
e.target.value,
)
}
/>
</label>
<label className="block">
Objectivo, perfil e âmbito
<textarea
className={controlClass}
required
minLength={10}
maxLength={5000}
value={form.data.purpose}
onChange={(e) =>
form.setData(
'purpose',
e.target.value,
)
}
/>
</label>
<div className="grid gap-4 md:grid-cols-2">
<label>
Fecho de candidaturas
<input
className={controlClass}
type="date"
required
value={form.data.closes_on}
onChange={(e) =>
form.setData(
'closes_on',
e.target.value,
)
}
/>
</label>
<label>
Avaliadores independentes por
candidatura
<input
className={controlClass}
type="number"
min="1"
max="20"
required
value={
form.data.minimum_reviews
}
onChange={(e) =>
form.setData(
'minimum_reviews',
e.target.value,
)
}
/>
</label>
</div>
<fieldset className="space-y-3">
<legend className="font-medium">
Critérios de avaliação — soma de
100%
</legend>
{form.data.criteria.map(
(criterion, index) => (
<div
key={index}
className="grid gap-3 md:grid-cols-[1fr_8rem_auto]"
>
<label>
Critério
<input
className={
controlClass
}
required
maxLength={150}
value={
criterion.label
}
onChange={(e) =>
form.setData(
'criteria',
form.data.criteria.map(
(
row,
i,
) =>
i ===
index
? {
...row,
label: e
.target
.value,
}
: row,
),
)
}
/>
</label>
<label>
Peso (%)
<input
className={
controlClass
}
type="number"
min="1"
max="100"
required
value={
criterion.weight
}
onChange={(e) =>
form.setData(
'criteria',
form.data.criteria.map(
(
row,
i,
) =>
i ===
index
? {
...row,
weight: e
.target
.value,
}
: row,
),
)
}
/>
</label>
<Button
type="button"
variant="outline"
disabled={
form.data.criteria
.length === 1
}
onClick={() =>
form.setData(
'criteria',
form.data.criteria.filter(
(_, i) =>
i !==
index,
),
)
}
>
Retirar
</Button>
</div>
),
)}
<Button
type="button"
variant="outline"
disabled={
form.data.criteria.length >= 20
}
onClick={() =>
form.setData('criteria', [
...form.data.criteria,
{ label: '', weight: '' },
])
}
>
Adicionar critério
</Button>
</fieldset>
<fieldset className="space-y-4">
<legend className="font-semibold">
{form.data.kind === 'staff'
? 'Anexo A — Ficha da função'
: 'Anexo C — Necessidade de serviço'}
</legend>
<p className="text-sm">
Complete os campos aplicáveis. O
título e o objectivo acima
identificam a necessidade. Ao criar,
assume a preparação deste processo;
atribua depois a equipa.
</p>
{Object.entries(
formFields[form.data.kind] ?? {},
).map(([key, label]) => (
<label key={key} className="block">
{label}
<textarea
className={controlClass}
maxLength={5000}
value={
form.data.annex.fields[
key
] ?? ''
}
onChange={(e) =>
form.setData('annex', {
...form.data.annex,
fields: {
...form.data
.annex
.fields,
[key]: e.target
.value,
},
})
}
/>
</label>
))}
</fieldset>
<fieldset className="space-y-4">
<legend className="font-semibold">
Requisitos obrigatórios e desejáveis
</legend>
<p className="text-sm">
Registe como verificar cada
requisito e as equivalências
aceites. A falta de comprovativo não
gera exclusão automática.
</p>
{form.data.annex.requirements.map(
(r, index) => (
<div
className="space-y-3 rounded border p-4"
key={index}
>
{(
[
'description',
'verification',
'equivalences',
] as const
).map((key) => (
<label
key={key}
className="block"
>
{
{
description:
'Requisito e relação com a função ou serviço',
verification:
'Método de verificação',
equivalences:
'Equivalências e adaptações aplicáveis',
}[key]
}
<textarea
className={
controlClass
}
required={
key !==
'equivalences'
}
maxLength={
key ===
'description'
? 1000
: 2000
}
value={r[key]}
onChange={(e) =>
form.setData(
'annex',
{
...form
.data
.annex,
requirements:
form.data.annex.requirements.map(
(
row,
i,
) =>
i ===
index
? {
...row,
[key]: e
.target
.value,
}
: row,
),
},
)
}
/>
</label>
))}
<label>
Classificação
<select
className={
controlClass
}
value={
r.classification
}
onChange={(e) =>
form.setData(
'annex',
{
...form
.data
.annex,
requirements:
form.data.annex.requirements.map(
(
row,
i,
) =>
i ===
index
? {
...row,
classification:
e
.target
.value,
}
: row,
),
},
)
}
>
<option value="mandatory">
Obrigatório
</option>
<option value="desirable">
Desejável
</option>
</select>
</label>
<Button
type="button"
variant="outline"
onClick={() =>
form.setData(
'annex',
{
...form.data
.annex,
requirements:
form.data.annex.requirements.filter(
(
_,
i,
) =>
i !==
index,
),
},
)
}
>
Retirar requisito
</Button>
</div>
),
)}
<Button
type="button"
variant="outline"
onClick={() =>
form.setData('annex', {
...form.data.annex,
requirements: [
...form.data.annex
.requirements,
{
description: '',
classification:
'mandatory',
verification: '',
equivalences: '',
},
],
})
}
>
Adicionar requisito
</Button>
</fieldset>
<Button disabled={form.processing}>
Guardar para conferência da abertura
</Button>
</form>
</Panel>
)}
</>
)}
</div>
</>
);
}
+248
View File
@@ -0,0 +1,248 @@
import { Head, Link, useForm } from '@inertiajs/react';
import {
PageTitle,
Panel,
FormErrors,
controlClass,
} from '@/components/procurement';
import { Button } from '@/components/ui/button';
export type RegistryPerson = {
id: number;
name: string;
reference: string;
profile_summary: string;
active: boolean;
version: number;
canEdit: boolean;
};
function PersonForm({ person }: { person?: RegistryPerson }) {
const form = useForm({
operation_key: crypto.randomUUID(),
version: person?.version ?? 1,
name: person?.name ?? '',
reference: person?.reference ?? '',
profile_summary: person?.profile_summary ?? '',
active: person?.active ?? true,
reason: '',
});
return (
<form
className="space-y-3"
onSubmit={(event) => {
event.preventDefault();
form.post(`/pessoas/cadastro${person ? `/${person.id}` : ''}`, {
preserveScroll: true,
onSuccess: () => {
if (!person) {
form.reset();
form.setData('operation_key', crypto.randomUUID());
}
},
});
}}
>
<FormErrors errors={form.errors} />
<label className="grid gap-1">
Nome
<input
className={controlClass}
required
minLength={3}
maxLength={200}
value={form.data.name}
onChange={(event) =>
form.setData('name', event.target.value)
}
/>
</label>
<label className="grid gap-1">
Referência do cadastro
{!person && ' (opcional; gerada se vazia)'}
<input
className={controlClass}
maxLength={120}
required={!!person}
value={form.data.reference}
onChange={(event) =>
form.setData('reference', event.target.value)
}
/>
</label>
<label className="grid gap-1">
Resumo do perfil reutilizável
<textarea
className={controlClass}
required
minLength={10}
maxLength={5000}
value={form.data.profile_summary}
onChange={(event) =>
form.setData('profile_summary', event.target.value)
}
/>
</label>
{person && (
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={form.data.active}
onChange={(event) =>
form.setData('active', event.target.checked)
}
/>
Disponível para novas candidaturas
</label>
)}
<label className="grid gap-1">
Motivo
<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}>
{person ? 'Guardar nova versão' : 'Registar pessoa'}
</Button>
</form>
);
}
export default function PeopleRegistry({
ready,
canCreate,
records,
filters,
}: {
ready: boolean;
canCreate: boolean;
records: {
data: RegistryPerson[];
total: number;
prev_page_url: string | null;
next_page_url: string | null;
} | null;
filters: { search?: string; status?: string };
}) {
const search = useForm({
search: filters.search ?? '',
status: filters.status ?? 'active',
});
return (
<>
<Head title="Cadastro de pessoas" />
<div className="mx-auto max-w-5xl space-y-6 p-5 lg:p-8">
<PageTitle
eyebrow="Pessoas e contratações"
title="Cadastro reutilizável de pessoas"
description="Registe o perfil uma vez e seleccione-o nas candidaturas seguintes. Cada processo conserva a versão utilizada."
/>
<p className="text-sm">
São apresentados os seus registos e o cadastro partilhado
que a empresa o autorizou a consultar. Actualizar ou
arquivar não altera candidaturas e decisões anteriores.
Nomes iguais não são fundidos automaticamente.
</p>
{!ready ? (
<Panel title="Instalação pendente">
<p>
O cadastro ainda não foi instalado neste ambiente.
</p>
</Panel>
) : (
<>
<Panel title="Pesquisar cadastro">
<form
className="flex flex-wrap gap-3"
onSubmit={(event) => {
event.preventDefault();
search.get('/pessoas/cadastro', {
preserveState: true,
});
}}
>
<input
aria-label="Nome ou referência"
className={controlClass}
maxLength={100}
value={search.data.search}
onChange={(event) =>
search.setData(
'search',
event.target.value,
)
}
/>
<select
aria-label="Estado"
className={controlClass}
value={search.data.status}
onChange={(event) =>
search.setData(
'status',
event.target.value,
)
}
>
<option value="active">Disponíveis</option>
<option value="archived">Arquivados</option>
<option value="all">Todos</option>
</select>
<Button>Pesquisar</Button>
</form>
</Panel>
{records?.data.map((person) => (
<Panel
key={`${person.id}-${person.version}`}
title={`${person.name} · versão ${person.version}`}
>
<p className="mb-4 text-sm break-all">
{person.reference} ·{' '}
{person.active ? 'Disponível' : 'Arquivado'}
</p>
{person.canEdit ? (
<PersonForm person={person} />
) : (
<p className="whitespace-pre-wrap">
{person.profile_summary}
</p>
)}
</Panel>
))}
{!records?.total && <p>Sem pessoas neste âmbito.</p>}
<div className="flex gap-4">
{records?.prev_page_url && (
<Link
className="underline"
href={records.prev_page_url}
>
Anterior
</Link>
)}
{records?.next_page_url && (
<Link
className="underline"
href={records.next_page_url}
>
Seguinte
</Link>
)}
</div>
{canCreate && (
<Panel title="Nova pessoa">
<PersonForm />
</Panel>
)}
</>
)}
</div>
</>
);
}
+852
View File
@@ -0,0 +1,852 @@
import { Head, Link, useForm, usePage } from '@inertiajs/react';
import {
PageTitle,
Panel,
FormErrors,
controlClass,
} from '@/components/procurement';
import { Button } from '@/components/ui/button';
import { PeopleClarifications } from '@/components/people-clarifications';
import type { ClarificationData } from '@/components/people-clarifications';
import { EvidenceBrief } from '@/components/evidence-brief';
import type { EvidenceBriefData } from '@/components/evidence-brief';
import { AiPreparationPreview } from '@/components/ai-preparation-preview';
import type { AiPreparation } from '@/components/ai-preparation-preview';
import { PeopleRegistryChoice } from '@/components/people-registry-choice';
import type { RegistryChoice } from '@/components/people-registry-choice';
type Process = {
id: number;
title: string;
purpose: string;
kind: string;
status: string;
version: number;
closes_on: string;
rules: {
submission_form?: {
id: number;
version: number;
definition: {
available: boolean;
fields: { key: string; label: string; required: boolean }[];
documents: { key: string; label: string; required: boolean }[];
};
};
minimum_reviews: number;
criteria: { label: string; weight: number }[];
annex?: {
version: number;
template: string;
fields: Record<string, string | null>;
requirements: {
description: string;
classification: string;
verification: string;
equivalences?: string | null;
}[];
};
};
selection: null | {
rows: Candidate[];
reason: string;
outcome?: string;
decision_reason?: string;
};
selected_application_id: number | null;
contract: null | {
reference: string;
starts_on: string;
terms: string;
status: string;
};
};
type Candidate = {
electronic_source?: {
submission_id: number;
reference: string;
profile_version: number;
};
registry_source?: { id: number; reference: string; version: number };
id: number;
name: string;
reference: string;
summary: string;
received_on: string;
evaluation_count: number;
score_minor: number | null;
reviews: {
id: number;
reviewed_by: number;
reason: string;
created_at: string;
answers: {
score: string;
evidence: string;
evidence_type?: string;
clarification?: string | null;
}[];
}[];
};
const states: Record<string, string> = {
draft: 'Abertura por conferir',
open: 'Recepção de candidaturas',
assessment: 'Avaliação',
selection_review: 'Selecção por conferir',
selected: 'Selecção aprovada',
contract_draft: 'Minuta contratual preparada',
};
const commands: Record<string, string> = {
open: 'Conferir e abrir',
apply: 'Registar candidatura',
close: 'Encerrar recepção e iniciar avaliação',
evaluate: 'Registar avaliação',
select: 'Propor selecção',
confirm: 'Conferir selecção',
contract: 'Preparar minuta contratual',
};
function Action({
process: p,
candidates,
command,
registry,
}: {
process: Process;
candidates: Candidate[];
command: string;
registry?: RegistryChoice;
}) {
const requestedApplication =
new URL(usePage().url, 'http://local').searchParams.get(
'candidatura',
) ?? '';
const form = useForm({
version: p.version,
operation_key: crypto.randomUUID(),
reason: '',
no_conflict: false,
name: '',
person_id: '',
person_version: '',
save_person: !!registry?.ready && registry.canCreate,
person_reference: '',
reference: '',
summary: '',
received_on: '',
application_id: candidates.some(
(candidate) => String(candidate.id) === requestedApplication,
)
? requestedApplication
: '',
outcome: 'approved',
starts_on: '',
terms: '',
answers: p.rules.criteria.map(() => ({
score: '',
evidence: '',
evidence_type: '',
clarification: '',
})),
});
const candidateChoice = ['evaluate', 'select'].includes(command);
return (
<Panel title={commands[command]}>
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
form.post(`/pessoas/${p.id}/${command}`, {
preserveState: 'errors',
preserveScroll: true,
});
}}
>
<FormErrors errors={form.errors} />
{command === 'apply' && (
<>
{registry?.ready && (
<PeopleRegistryChoice
processId={p.id}
data={registry}
selected={form.data.person_id}
choose={(person) => {
form.setData((data) => ({
...data,
person_id: person
? String(person.id)
: '',
person_version: person
? String(person.version)
: '',
name: person?.name ?? '',
summary: person?.profile_summary ?? '',
}));
}}
/>
)}
<label className="block">
Nome da pessoa
<input
className={controlClass}
required
minLength={3}
maxLength={200}
value={form.data.name}
readOnly={!!form.data.person_id}
onChange={(e) =>
form.setData('name', e.target.value)
}
/>
</label>
{registry?.ready &&
registry.canCreate &&
!form.data.person_id && (
<>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={form.data.save_person}
onChange={(event) =>
form.setData(
'save_person',
event.target.checked,
)
}
/>
Guardar também no meu cadastro para
reutilizar
</label>
{form.data.save_person && (
<label className="block">
Referência do cadastro (opcional)
<input
className={controlClass}
maxLength={120}
value={
form.data.person_reference
}
onChange={(event) =>
form.setData(
'person_reference',
event.target.value,
)
}
/>
</label>
)}
</>
)}
<label className="block">
Referência documental da candidatura
<input
className={controlClass}
required
maxLength={200}
value={form.data.reference}
onChange={(e) =>
form.setData('reference', e.target.value)
}
/>
</label>
<label className="block">
Recebida em
<input
className={controlClass}
type="date"
required
value={form.data.received_on}
onChange={(e) =>
form.setData('received_on', e.target.value)
}
/>
</label>
<label className="block">
Resumo profissional pertinente
<textarea
className={controlClass}
required
minLength={10}
maxLength={5000}
value={form.data.summary}
onChange={(e) =>
form.setData('summary', e.target.value)
}
/>
</label>
</>
)}
{candidateChoice && (
<label className="block">
Candidatura
<select
className={controlClass}
required
value={form.data.application_id}
onChange={(e) =>
form.setData('application_id', e.target.value)
}
>
<option value="">Seleccione</option>
{candidates.map((c) => (
<option key={c.id} value={c.id}>
{c.name} · {c.reference}
</option>
))}
</select>
</label>
)}
{command === 'evaluate' &&
p.rules.criteria.map((criterion, index) => (
<fieldset
key={index}
className="space-y-3 rounded-lg border p-4"
>
<legend>
{criterion.label} · {criterion.weight}%
</legend>
<label className="block">
Pontuação (0 a 10)
<input
className={controlClass}
type="number"
min="0"
max="10"
step="0.01"
required
value={form.data.answers[index].score}
onChange={(e) =>
form.setData(
'answers',
form.data.answers.map((a, i) =>
i === index
? {
...a,
score: e.target.value,
}
: a,
),
)
}
/>
</label>
<label className="block">
Evidência e fundamento
<textarea
className={controlClass}
required
minLength={10}
maxLength={2000}
value={form.data.answers[index].evidence}
onChange={(e) =>
form.setData(
'answers',
form.data.answers.map((a, i) =>
i === index
? {
...a,
evidence:
e.target.value,
}
: a,
),
)
}
/>
</label>
<label className="block">
Tipo de evidência
<select
className={controlClass}
required
value={
form.data.answers[index].evidence_type
}
onChange={(e) =>
form.setData(
'answers',
form.data.answers.map((a, i) =>
i === index
? {
...a,
evidence_type:
e.target.value,
}
: a,
),
)
}
>
<option value="">Seleccione</option>
<option value="reported">
Relatada pela pessoa candidata
</option>
<option value="observed">
Observada na entrevista ou exercício
</option>
<option value="verified">
Comprovada por fonte verificável
</option>
</select>
</label>
<label className="block">
Esclarecimentos necessários
<textarea
className={controlClass}
maxLength={2000}
value={
form.data.answers[index].clarification
}
onChange={(e) =>
form.setData(
'answers',
form.data.answers.map((a, i) =>
i === index
? {
...a,
clarification:
e.target.value,
}
: a,
),
)
}
/>
</label>
</fieldset>
))}
{command === 'confirm' && (
<label className="block">
Decisão
<select
className={controlClass}
value={form.data.outcome}
onChange={(e) =>
form.setData('outcome', e.target.value)
}
>
<option value="approved">Aprovar selecção</option>
<option value="returned">
Devolver para análise
</option>
</select>
</label>
)}
{command === 'contract' && (
<>
<p className="text-sm">
Preparação interna de minuta. Não comprova
assinatura, admissão efectiva nem prestação de
serviço.
</p>
<label className="block">
Referência da minuta
<input
className={controlClass}
required
maxLength={200}
value={form.data.reference}
onChange={(e) =>
form.setData('reference', e.target.value)
}
/>
</label>
<label className="block">
Início proposto
<input
className={controlClass}
type="date"
required
value={form.data.starts_on}
onChange={(e) =>
form.setData('starts_on', e.target.value)
}
/>
</label>
<label className="block">
Condições propostas
<textarea
className={controlClass}
required
minLength={20}
maxLength={10000}
value={form.data.terms}
onChange={(e) =>
form.setData('terms', e.target.value)
}
/>
</label>
</>
)}
<label className="block">
Fundamento{' '}
{command === 'select'
? '(inclua a justificação de eventual empate)'
: ''}
<textarea
className={controlClass}
required
minLength={10}
maxLength={5000}
value={form.data.reason}
onChange={(e) => form.setData('reason', e.target.value)}
/>
</label>
{['open', 'evaluate', 'select', 'confirm', 'contract'].includes(
command,
) && (
<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 neste processo e
nesta intervenção.
</label>
)}
<Button disabled={form.processing}>{commands[command]}</Button>
</form>
</Panel>
);
}
export default function PeopleShow({
process: p,
candidates,
canPrepare,
canViewElectronicSources = false,
canReview,
canApprove = false,
canContract = false,
canManage = false,
formFields = {},
clarifications,
brief,
aiPreparation,
registry,
history,
}: {
process: Process;
candidates: Candidate[];
canPrepare: boolean;
canViewElectronicSources?: boolean;
canReview: boolean;
canApprove?: boolean;
canContract?: boolean;
canManage?: boolean;
formFields?: Record<string, string>;
clarifications?: ClarificationData;
brief?: EvidenceBriefData | null;
aiPreparation?: AiPreparation;
registry?: RegistryChoice;
history: {
id: number;
action: string;
user_id: number;
created_at: string;
}[];
}) {
const actions = [
...(canReview && p.status === 'draft' ? ['open'] : []),
...(canPrepare && p.status === 'open' ? ['apply', 'close'] : []),
...(canReview && p.status === 'assessment' ? ['evaluate'] : []),
...(canPrepare && p.status === 'assessment' ? ['select'] : []),
...(canApprove && p.status === 'selection_review' ? ['confirm'] : []),
...(canContract && p.status === 'selected' ? ['contract'] : []),
];
const rows =
p.selection &&
['selection_review', 'selected', 'contract_draft'].includes(p.status)
? p.selection.rows
: candidates;
return (
<>
<Head title={p.title} />
<div className="mx-auto max-w-6xl space-y-6 p-5 lg:p-8">
<PageTitle
eyebrow={
p.kind === 'service'
? 'Prestador individual'
: 'Recrutamento de trabalhador'
}
title={p.title}
description={`${states[p.status] ?? p.status} · PES-${p.id}`}
action={
<Button
variant="outline"
onClick={() => window.print()}
>
Imprimir relatório
</Button>
}
/>
<Link className="underline" href="/pessoas">
Voltar a Pessoas e contratações
</Link>
{(canPrepare || canViewElectronicSources) && (
<Link
className="block underline"
href={`/pessoas/${p.id}/recebidas`}
>
Publicação e candidaturas electrónicas recebidas
</Link>
)}
{canManage && (
<Link
className="underline print:hidden"
href={`/pessoas/${p.id}/acessos`}
>
Organizar equipa e permissões deste processo
</Link>
)}
{p.rules.submission_form && (
<Panel
title={`Formulário de candidatura — versão ${p.rules.submission_form.version}`}
>
<p>
Definição preservada na preparação deste processo.
Portal externo em desenvolvimento.
</p>
<p>
{p.rules.submission_form.definition.available
? 'Modalidade prevista para recepção.'
: 'Modalidade indisponível para novas submissões externas.'}
</p>
<ul>
{p.rules.submission_form.definition.fields.map(
(field) => (
<li key={field.key}>
{field.label} ·{' '}
{field.required
? 'Obrigatório'
: 'Opcional'}
</li>
),
)}
{p.rules.submission_form.definition.documents.map(
(document) => (
<li key={document.key}>
{document.label} ·{' '}
{document.required
? 'Obrigatório'
: 'Opcional'}
</li>
),
)}
</ul>
</Panel>
)}
{p.rules.annex && (
<Panel
title={`Anexo ${p.rules.annex.template} — versão ${p.rules.annex.version}`}
>
<dl className="space-y-3">
{Object.entries(p.rules.annex.fields)
.filter(([, value]) => value)
.map(([key, value]) => (
<div key={key}>
<dt className="font-medium">
{formFields[key] ?? key}
</dt>
<dd className="whitespace-pre-wrap">
{value}
</dd>
</div>
))}
</dl>
<h3 className="mt-5 font-semibold">
Requisitos e verificação
</h3>
{p.rules.annex.requirements.map((r, index) => (
<section
key={index}
className="mt-3 rounded border p-3"
>
<strong>
{r.classification === 'mandatory'
? 'Obrigatório'
: 'Desejável'}{' '}
· {r.description}
</strong>
<p>Verificação: {r.verification}</p>
{r.equivalences && (
<p>
Equivalências e adaptações:{' '}
{r.equivalences}
</p>
)}
</section>
))}
</Panel>
)}
{brief && <EvidenceBrief data={brief} />}
{aiPreparation && <AiPreparationPreview data={aiPreparation} />}
<Panel
id="people-report-scope"
title="Âmbito e critérios preservados"
>
<p className="whitespace-pre-wrap">{p.purpose}</p>
<p className="my-3">
Candidaturas até {p.closes_on} ·{' '}
{p.rules.minimum_reviews} avaliador(es) independente(s)
por pessoa.
</p>
<ul className="list-disc pl-5">
{p.rules.criteria.map((c, i) => (
<li key={i}>
{c.label} · {c.weight}%
</li>
))}
</ul>
</Panel>
<Panel
id="people-report-candidates"
title="Candidaturas e avaliação"
description="Pontuações pela média das avaliações elegíveis. Empates exigem escolha fundamentada, sem selecção automática."
>
<div className="space-y-4">
{rows.map((c) => (
<section
className="rounded-lg border p-4"
key={c.id}
>
{c.registry_source && (
<p className="text-sm text-muted-foreground">
Cadastro {c.registry_source.reference} ·
versão utilizada{' '}
{c.registry_source.version}
</p>
)}
{c.electronic_source && (
<p className="text-sm">
Submissão electrónica{' '}
{c.electronic_source.reference} · perfil
v{c.electronic_source.profile_version}
{canViewElectronicSources && (
<Link
className="ml-2 underline"
href={`/pessoas/${p.id}/recebidas?submission=${c.electronic_source.submission_id}`}
>
Consultar dados enviados
</Link>
)}
</p>
)}
<strong>
{c.name} · {c.reference}
</strong>
<p>
Recebida em {c.received_on} ·{' '}
{c.evaluation_count} avaliação(ões) ·{' '}
{c.score_minor === null
? 'Por concluir'
: `${(c.score_minor / 100).toFixed(2)} / 10`}
{c.id === p.selected_application_id
? p.status === 'selection_review'
? ' · Candidatura proposta'
: ' · Pessoa seleccionada'
: ''}
</p>
<p className="text-sm whitespace-pre-wrap">
{c.summary}
</p>
{c.reviews.map((r) => (
<details key={r.id} className="mt-3">
<summary>
Avaliação #{r.id} · pessoa #
{r.reviewed_by} · {r.created_at}
</summary>
<p>{r.reason}</p>
{r.answers.map((a, i) => (
<p key={i}>
{p.rules.criteria[i]?.label}:{' '}
{a.score}/10 · {a.evidence}
<span className="block">
Evidência:{' '}
{{
reported: 'relatada',
observed: 'observada',
verified: 'comprovada',
}[a.evidence_type ?? ''] ??
'não classificada no registo inicial'}
{a.clarification
? ` · Esclarecimento registado na avaliação: ${a.clarification}`
: ''}
</span>
</p>
))}
</details>
))}
</section>
))}
{!rows.length && <p>Sem candidaturas registadas.</p>}
</div>
</Panel>
{p.selection && (
<Panel
id="people-report-selection"
title="Fundamento da selecção"
>
<p className="whitespace-pre-wrap">
{p.selection.reason}
</p>
{p.selection.decision_reason && (
<p className="mt-3 whitespace-pre-wrap">
Conferência: {p.selection.decision_reason}
</p>
)}
</Panel>
)}
{p.contract && (
<Panel
id="people-report-contract"
title="Minuta contratual — preparação interna"
>
<p>
{p.contract.reference} · início proposto{' '}
{p.contract.starts_on}
</p>
<p className="whitespace-pre-wrap">
{p.contract.terms}
</p>
<p className="mt-3 text-sm">
Não comprova assinatura ou contratação concluída.
</p>
</Panel>
)}
<PeopleClarifications processId={p.id} data={clarifications} />
<div className="space-y-6 print:hidden">
{actions.map((command) => (
<div
id={`people-${command}`}
className="scroll-mt-24"
key={`${command}-${p.version}`}
>
<Action
key={`${command}-${p.version}`}
registry={registry}
process={p}
candidates={candidates}
command={command}
/>
</div>
))}
</div>
<Panel title="Histórico">
<ol className="space-y-2 text-sm">
{history.map((h) => (
<li key={h.id}>
{h.created_at} · {h.action} · pessoa #
{h.user_id}
</li>
))}
</ol>
</Panel>
</div>
</>
);
}
+300
View File
@@ -0,0 +1,300 @@
import { Head, Link, useForm } from '@inertiajs/react';
import { FormErrors, PageTitle, controlClass } from '@/components/procurement';
import { Button } from '@/components/ui/button';
function TriageForm({
processId,
processVersion,
submissionId,
}: {
processId: number;
processVersion: number;
submissionId: number;
}) {
const form = useForm({
process_version: processVersion,
operation_key: crypto.randomUUID(),
outcome: 'accepted_for_analysis',
reason: '',
public_message: '',
no_conflict: false,
});
return (
<form
className="space-y-3 border-t pt-3"
onSubmit={(e) => {
e.preventDefault();
form.post(
`/pessoas/${processId}/recebidas/${submissionId}/triagem`,
{ preserveState: false, preserveScroll: true },
);
}}
>
<FormErrors errors={form.errors} />
<label className="grid gap-2">
Decisão de triagem
<select
className={controlClass}
value={form.data.outcome}
onChange={(e) => form.setData('outcome', e.target.value)}
>
<option value="accepted_for_analysis">
Aceite para análise
</option>
<option value="not_accepted">
Não aceite para análise
</option>
</select>
</label>
<p>
Aceitar para análise aproveita os dados recebidos na avaliação.
Não inclui automaticamente na base nem selecciona ou contrata a
pessoa.
</p>
<label className="grid gap-2">
Motivo interno (reservado)
<textarea
className={controlClass}
required
minLength={10}
maxLength={4000}
value={form.data.reason}
onChange={(e) => form.setData('reason', e.target.value)}
/>
</label>
<label className="grid gap-2">
Comunicação destinada ao candidato
<textarea
className={controlClass}
required
minLength={10}
maxLength={2000}
value={form.data.public_message}
onChange={(e) =>
form.setData('public_message', e.target.value)
}
/>
</label>
<label className="flex gap-2">
<input
type="checkbox"
required
checked={form.data.no_conflict}
onChange={(e) =>
form.setData('no_conflict', e.target.checked)
}
/>
Declaro não ter conflito para esta intervenção.
</label>
<Button disabled={form.processing}>
Registar decisão de triagem
</Button>
</form>
);
}
export default function Submissions({
process,
publication,
submissions,
publicUrl,
canPrepare = false,
canTriage = false,
}: {
process: { id: number; title: string; version: number };
canPrepare?: boolean;
canTriage?: boolean;
publication: null | {
version: number;
active: boolean;
description: string;
};
submissions: {
data: {
id: number;
decision?: null | {
outcome: string;
reason: string;
public_message: string;
created_at: string;
people_application_id: number | null;
};
reference: string;
received_at: string;
snapshot: {
opportunity: {
form: {
definition: {
fields: { key: string; label: string }[];
};
};
};
profile: { data: { name: string; summary: string } };
answers: Record<string, unknown>;
};
}[];
prev_page_url: string | null;
next_page_url: string | null;
};
publicUrl: string;
}) {
const form = useForm({
version: publication?.version ?? 0,
active: publication?.active ?? false,
description: publication?.description ?? '',
reason: '',
});
return (
<>
<Head title="Candidaturas electrónicas recebidas" />
<main className="mx-auto max-w-4xl space-y-6 p-6">
<PageTitle
title={process.title}
eyebrow="Recepção electrónica"
description="Publicação, recepção e triagem. Aceitação para análise não equivale a inclusão na base, selecção ou contratação."
/>
<Link className="underline" href={`/pessoas/${process.id}`}>
Processo interno
</Link>
{canPrepare && (
<form
className="space-y-3"
onSubmit={(e) => {
e.preventDefault();
form.post(`/pessoas/${process.id}/publicacao`, {
preserveState: false,
});
}}
>
<FormErrors errors={form.errors} />
<label className="flex gap-2">
<input
type="checkbox"
checked={form.data.active}
onChange={(e) =>
form.setData('active', e.target.checked)
}
/>
Disponibilizar oportunidade aos candidatos
</label>
<label className="grid gap-2">
Descrição pública
<textarea
className={controlClass}
required
minLength={10}
maxLength={5000}
value={form.data.description}
onChange={(e) =>
form.setData('description', e.target.value)
}
/>
</label>
<label className="grid gap-2">
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}>
Guardar publicação
</Button>
</form>
)}
{canPrepare && publication?.active && (
<Link className="underline" href={publicUrl}>
Ver oportunidade na área do candidato
</Link>
)}
<h2 className="text-xl font-semibold">
Candidaturas recebidas
</h2>
{submissions.data.map((row) => (
<article
className="space-y-2 border-t py-4"
key={row.reference}
>
<h3>
{row.reference} · {row.snapshot.profile.data.name}
</h3>
<p>
{row.received_at} ·{' '}
{row.decision
? row.decision.outcome ===
'accepted_for_analysis'
? 'Aceite para análise'
: 'Não aceite para análise'
: 'Recebida, por triar'}
</p>
<p>{row.snapshot.profile.data.summary}</p>
<dl>
{Object.entries(row.snapshot.answers).map(
([key, value]) => (
<div key={key}>
<dt>
{row.snapshot.opportunity.form.definition.fields.find(
(field) => field.key === key,
)?.label ?? 'Resposta'}
</dt>
<dd>
{typeof value === 'object'
? Object.values(
value ?? {},
).join(' · ')
: typeof value === 'string' ||
typeof value === 'number'
? value
: 'Não indicado'}
</dd>
</div>
),
)}
</dl>
{row.decision && (
<section className="space-y-2">
<p>Motivo interno: {row.decision.reason}</p>
<p>
Comunicação ao candidato:{' '}
{row.decision.public_message}
</p>
{row.decision.people_application_id && (
<Link
className="underline"
href={`/pessoas/${process.id}?candidatura=${row.decision.people_application_id}`}
>
Abrir candidatura na avaliação
</Link>
)}
</section>
)}
{canTriage && !row.decision && (
<TriageForm
processId={process.id}
processVersion={process.version}
submissionId={row.id}
/>
)}
</article>
))}
{submissions.data.length === 0 && (
<p>Nenhuma candidatura electrónica recebida.</p>
)}
<nav className="flex gap-4">
{submissions.prev_page_url && (
<Link href={submissions.prev_page_url}>Anterior</Link>
)}
{submissions.next_page_url && (
<Link href={submissions.next_page_url}>Seguinte</Link>
)}
</nav>
</main>
</>
);
}
@@ -165,7 +165,7 @@ export default function RecommendationProposal() {
<PageTitle
eyebrow="Proposta para validação"
title="Recomendação antes da adjudicação"
description="Sistema de Gestão de Compras (SGC) · Percurso proposto por empresa, com decisão manual e conferência independente."
description="Sistema de Gestão de Contratações (SGC) · Percurso proposto por empresa, com decisão manual e conferência independente."
/>
<p className="rounded-xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-950">
Demonstração com dados fictícios. As acções apenas simulam o
@@ -43,7 +43,7 @@ function PolicyForm({ latest, policies }: Props) {
<PageTitle
eyebrow="Configuração da empresa"
title="Política de admissão"
description="Sistema de Gestão de Compras (SGC) · Configuração da empresa"
description="Sistema de Gestão de Contratações (SGC) · Configuração da empresa"
/>
<Panel title="Nova versão da política">
<p className="mb-4 text-sm text-muted-foreground">
+1 -1
View File
@@ -254,7 +254,7 @@ export default function Admission({
<PageTitle
eyebrow="Compras"
title={`Admissão de propostas · ${requisition.number}`}
description="Sistema de Gestão de Compras (SGC) · Decisões e conferências preservadas"
description="Sistema de Gestão de Contratações (SGC) · Decisões e conferências preservadas"
/>
<nav className="mb-5 flex flex-wrap gap-4 text-sm">
<Link href={`${base}/processo`}>Processo completo</Link>
+17 -5
View File
@@ -1,4 +1,6 @@
import { Head, Link, useForm } from '@inertiajs/react';
import { ComparisonPolicySummary } from '@/components/comparison-policy-summary';
import type { ComparisonPolicy } from '@/components/comparison-policy-summary';
import {
FormErrors,
PageTitle,
@@ -13,7 +15,12 @@ import type { MatrixRow, PurchaseAward } from '@/types/purchase';
type Props = {
requisition: { id: number; number: string };
comparison: { rows: MatrixRow[]; ready: boolean; top_ids: number[] };
comparison: {
rows: MatrixRow[];
ready: boolean;
top_ids: number[];
policy: ComparisonPolicy;
};
award: PurchaseAward | null;
canSelect: boolean;
canDecide: boolean;
@@ -60,17 +67,18 @@ export default function Award({
<div className="rounded-xl border border-amber-200 bg-amber-50 p-5 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-100">
Ambiente de desenvolvimento: os documentos não autorizam
compras reais. O orçamento ainda não é verificado. Excepções
às três propostas ou à maior pontuação permanecem bloqueadas
enquanto não existir política aprovada.
à regra preservada neste processo ou à maior pontuação
exigem um circuito próprio de revisão.
</div>
<Panel
title="Comparação actual"
description={
comparison.ready
? 'Matriz completa para análise. O servidor volta a verificar tudo em cada operação.'
: 'A matriz ainda não reúne três propostas elegíveis, cada uma com duas avaliações elegíveis.'
: 'A matriz ainda não reúne os mínimos de propostas e avaliações da regra deste processo.'
}
>
<ComparisonPolicySummary policy={comparison.policy} />
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className="border-b text-muted-foreground">
@@ -109,7 +117,11 @@ export default function Award({
{money(q.total_minor)}
</td>
<td className="pr-4">
{q.evaluation_count} / 2
{q.evaluation_count} /{' '}
{
comparison.policy
.minimum_evaluators
}
</td>
<td>
{q.score === null
@@ -43,7 +43,7 @@ function PolicyForm({ latest, policies }: Props) {
<PageTitle
eyebrow="Configuração da empresa"
title="Política de recomendação"
description="Sistema de Gestão de Compras (SGC) · Configuração da empresa"
description="Sistema de Gestão de Contratações (SGC) · Configuração da empresa"
/>
<Panel title="Nova versão da política">
<p className="mb-4 text-sm text-muted-foreground">
@@ -319,7 +319,7 @@ export default function Recommendation(props: Props) {
<PageTitle
eyebrow="Compras"
title={`Recomendação · ${requisition.number}`}
description="Sistema de Gestão de Compras (SGC) · Decisão fundamentada antes da adjudicação"
description="Sistema de Gestão de Contratações (SGC) · Decisão fundamentada antes da adjudicação"
/>
<nav className="flex flex-wrap gap-4 text-sm underline">
<Link href={`${base}/processo`}>Processo completo</Link>
+52 -8
View File
@@ -1,5 +1,13 @@
import { Head, Link } from '@inertiajs/react';
import { Printer } from 'lucide-react';
import { EvidenceBrief } from '@/components/evidence-brief';
import type { EvidenceBriefData } from '@/components/evidence-brief';
import { AiPreparationPreview } from '@/components/ai-preparation-preview';
import type { AiPreparation } from '@/components/ai-preparation-preview';
import { ComparisonPolicySummary } from '@/components/comparison-policy-summary';
import type { ComparisonPolicy } from '@/components/comparison-policy-summary';
import { QuotationCommercialTerms } from '@/components/quotation-commercial-terms';
import type { CommercialTerms } from '@/components/quotation-commercial-terms';
import { PageTitle, Panel, Status } from '@/components/procurement';
import { Button } from '@/components/ui/button';
import { date, roleLabels, statusLabels } from '@/types/procurement';
@@ -25,6 +33,7 @@ type Analysis = {
answers: Answer[];
};
type ComparisonRow = {
commercial_terms?: CommercialTerms | null;
id: number;
reference: string;
supplier: string;
@@ -53,7 +62,12 @@ type Report = {
department: string;
requester: string;
};
comparison: { ready: boolean; top_count: number; rows: ComparisonRow[] };
comparison: {
ready: boolean;
top_count: number;
rows: ComparisonRow[];
policy: ComparisonPolicy;
};
recommendation: { id: number; revision: number } | null;
admission: {
id: number;
@@ -149,7 +163,15 @@ const categories: Record<string, string> = {
administrative: 'Administrativa',
};
export default function ReviewReport({ report }: { report: Report }) {
export default function ReviewReport({
report,
brief,
aiPreparation,
}: {
report: Report;
brief?: EvidenceBriefData | null;
aiPreparation?: AiPreparation;
}) {
const r = report.requisition;
const qualification = report.qualification;
const generated = new Date(report.generated_at).toLocaleString('pt-PT', {
@@ -162,7 +184,7 @@ export default function ReviewReport({ report }: { report: Report }) {
<div className="review-report mx-auto flex max-w-7xl flex-col gap-6 p-5 lg:p-8">
<header className="hidden border-b-2 border-[#ed1c24] pb-3 print:block">
<p className="font-semibold">
SGC · Sistema de Gestão de Compras
SGC · Sistema de Gestão de Contratações
</p>
<p>{report.company}</p>
</header>
@@ -206,7 +228,9 @@ export default function ReviewReport({ report }: { report: Report }) {
evidências ou autorização financeira. A comparação actual e
a qualificação documental são apresentadas separadamente.
</div>
<Panel title="Identificação do pedido">
{brief && <EvidenceBrief data={brief} />}
{aiPreparation && <AiPreparationPreview data={aiPreparation} />}
<Panel id="report-request" title="Identificação do pedido">
<p className="mb-4 text-sm">
{report.recommendation
? 'Regime de recomendação adoptado. A adjudicação exige recomendação confirmada e actual.'
@@ -244,9 +268,13 @@ export default function ReviewReport({ report }: { report: Report }) {
</div>
</Panel>
<Panel
id="report-comparison"
title="Comparação actual das propostas"
description="Dados e pontuações devolvidos pelo método de comparação existente. A ordem abaixo é a de registo, sem nova classificação."
>
<ComparisonPolicySummary
policy={report.comparison.policy}
/>
<p className="mb-4 text-sm font-medium">
{report.comparison.ready
? 'A comparação está pronta segundo o método existente. Isto não confirma orçamento, aprovações ou autorização para emitir uma ordem.'
@@ -364,6 +392,16 @@ export default function ReviewReport({ report }: { report: Report }) {
</table>
</div>
<div className="mt-4 space-y-2">
{report.comparison.rows.map((row) => (
<section key={row.id}>
<strong className="text-sm">
{row.supplier} · {row.reference}
</strong>
<QuotationCommercialTerms
terms={row.commercial_terms}
/>
</section>
))}
{report.comparison.rows.map((row) => (
<p
key={row.id}
@@ -388,6 +426,7 @@ export default function ReviewReport({ report }: { report: Report }) {
)}
</Panel>
<Panel
id="report-qualification"
title="Qualificação documental"
description="Apenas a versão mais recente dos requisitos e o último registo de cada analista por proposta. As correcções anteriores mantêm-se no histórico do módulo."
>
@@ -590,7 +629,10 @@ export default function ReviewReport({ report }: { report: Report }) {
</p>
)}
</Panel>
<Panel title="Registos do processo para consulta">
<Panel
id="report-records"
title="Registos do processo para consulta"
>
<div className="space-y-3 text-sm">
{report.opening ? (
<div>
@@ -650,6 +692,7 @@ export default function ReviewReport({ report }: { report: Report }) {
</Panel>
<Panel
title="Recepção, facturas e registos de pagamento"
id="report-fulfilment"
description="Factos registados em cada etapa. A presença destes documentos não confirma entrega integral, liquidação ou execução bancária."
>
<div className="space-y-4">
@@ -774,9 +817,10 @@ export default function ReviewReport({ report }: { report: Report }) {
</div>
</Panel>
<footer className="border-t pt-4 text-xs text-muted-foreground">
SGC · Sistema de Gestão de Compras · {r.number} · Consulta
de {generated}, Africa/Johannesburg (UTC+2). Decisão formal
e autorização financeira seguem os seus circuitos próprios.
SGC · Sistema de Gestão de Contratações · {r.number} ·
Consulta de {generated}, Africa/Johannesburg (UTC+2).
Decisão formal e autorização financeira seguem os seus
circuitos próprios.
</footer>
</div>
</>
+10
View File
@@ -1,5 +1,7 @@
import { Head, Link } from '@inertiajs/react';
import { Printer } from 'lucide-react';
import { ComparisonPolicySummary } from '@/components/comparison-policy-summary';
import { QuotationCommercialTerms } from '@/components/quotation-commercial-terms';
import { PageTitle, Panel } from '@/components/procurement';
import { Button } from '@/components/ui/button';
import { date, money, roleLabels } from '@/types/procurement';
@@ -208,6 +210,11 @@ export default function OrderShow({
</dl>
</Panel>
<Panel title="Fundamento e matriz congelada">
{snapshot.comparison_policy && (
<ComparisonPolicySummary
policy={snapshot.comparison_policy}
/>
)}
<p className="text-sm whitespace-pre-wrap">
{snapshot.award.justification}
</p>
@@ -227,6 +234,9 @@ export default function OrderShow({
{money(row.total_minor)} com IVA ·{' '}
{row.evaluation_count} avaliações elegíveis
</p>
<QuotationCommercialTerms
terms={row.commercial_terms}
/>
{row.evaluations.map((e) => (
<p
key={e.evaluator_id}
+153 -7
View File
@@ -1,5 +1,9 @@
import { Head, Link, useForm, usePage } from '@inertiajs/react';
import { useState } from 'react';
import { ComparisonPolicySummary } from '@/components/comparison-policy-summary';
import type { ComparisonPolicy } from '@/components/comparison-policy-summary';
import { QuotationCommercialTerms } from '@/components/quotation-commercial-terms';
import type { CommercialTerms } from '@/components/quotation-commercial-terms';
import {
FormErrors,
PageTitle,
@@ -26,6 +30,7 @@ type Evaluation = {
history: string;
payment: string;
comment: string;
created_at: string;
};
type Quote = {
id: number;
@@ -43,6 +48,8 @@ type Quote = {
conflict_blocked: boolean;
score: number | null;
items: Item[];
ineligibility_reasons: string[];
commercial_terms: CommercialTerms | null;
};
export default function Comparison({
requisition,
@@ -51,6 +58,8 @@ export default function Comparison({
canCreate,
canEvaluate,
ready,
topIds,
comparisonPolicy,
}: {
requisition: {
id: number;
@@ -63,6 +72,8 @@ export default function Comparison({
canCreate: boolean;
canEvaluate: boolean;
ready: boolean;
topIds: number[];
comparisonPolicy: ComparisonPolicy;
}) {
const { auth } = usePage().props;
const [adding, setAdding] = useState(false);
@@ -77,6 +88,8 @@ export default function Comparison({
items: requisition.items.map((i) => ({
requisition_item_id: i.id,
unit_price: '',
discount_type: 'none',
discount_value: '0',
})),
});
const evaluation = useForm({
@@ -157,14 +170,28 @@ export default function Comparison({
? 'Comparação completa para revisão.'
: 'Comparação ainda incompleta.'}
</strong>{' '}
São necessárias três propostas elegíveis de fornecedores
distintos e duas avaliações independentes por proposta. Esta
etapa prepara a adjudicação e a emissão numa fase própria.
Esta etapa prepara a adjudicação e a emissão numa fase
própria.
<ComparisonPolicySummary policy={comparisonPolicy} />
</div>
{topIds.length > 1 && (
<p
role="status"
className="rounded-xl border border-amber-200 bg-amber-50 p-5 text-sm text-amber-900"
>
Empate na maior pontuação:{' '}
{quotations
.filter((q) => topIds.includes(q.id))
.map((q) => q.supplier.name)
.join(', ')}
. Nenhuma proposta é escolhida automaticamente. A
selecção deve ficar fundamentada na adjudicação.
</p>
)}
{adding && (
<Panel
title="Nova proposta"
description="MZN · Quantidades e IVA herdados da requisição. Os preços são calculados no servidor."
description="MZN · Quantidades e IVA herdados da requisição. Desconto por unidade antes do IVA; preço líquido arredondado ao centavo antes de multiplicar pela quantidade. Preencha todas as linhas."
>
<form
className="space-y-5"
@@ -266,7 +293,8 @@ export default function Comparison({
</div>
<div className="space-y-2">
<Label htmlFor={`price-${i.id}`}>
Preço unitário sem IVA (MZN)
Preço unitário antes de desconto
e IVA (MZN)
</Label>
<Input
id={`price-${i.id}`}
@@ -300,6 +328,105 @@ export default function Comparison({
</div>
))}
</div>
<fieldset className="space-y-4 rounded-lg border p-4">
<legend className="px-2 font-medium">
Descontos unitários antes do IVA
</legend>
{requisition.items.map((i, index) => (
<div
key={i.id}
className="grid gap-3 md:grid-cols-3"
>
<span className="text-sm">
{i.description}
</span>
<label className="text-sm">
Tipo de desconto
<select
className={controlClass}
value={
form.data.items[index]
.discount_type
}
onChange={(e) =>
form.setData(
'items',
form.data.items.map(
(line, k) =>
k === index
? {
...line,
discount_type:
e
.target
.value,
discount_value:
'0',
}
: line,
),
)
}
>
<option value="none">
Sem desconto
</option>
<option value="percent">
Percentagem (%)
</option>
<option value="amount">
Valor por unidade (MZN)
</option>
</select>
</label>
<label className="text-sm">
{form.data.items[index]
.discount_type === 'percent'
? 'Percentagem (%)'
: 'Valor unitário (MZN)'}
<Input
type="number"
min="0"
step="0.01"
required
disabled={
form.data.items[index]
.discount_type ===
'none'
}
max={
form.data.items[index]
.discount_type ===
'percent'
? '100'
: '1000000000'
}
value={
form.data.items[index]
.discount_value
}
onChange={(e) =>
form.setData(
'items',
form.data.items.map(
(line, k) =>
k === index
? {
...line,
discount_value:
e
.target
.value,
}
: line,
),
)
}
/>
</label>
</div>
))}
</fieldset>
<label className="flex items-start gap-3 text-sm">
<input
type="checkbox"
@@ -323,7 +450,7 @@ export default function Comparison({
)}
<Panel
title="Matriz de comparação"
description="Pesos provisórios: preço 40%, qualidade 20%, prazo 15%, histórico 15%, pagamento 10%. Preço e prazo calculados automaticamente entre propostas elegíveis."
description="Ponderações da regra deste processo. Preço e prazo calculados automaticamente entre propostas elegíveis."
>
<div className="overflow-auto">
<table className="w-full text-left text-sm">
@@ -381,7 +508,10 @@ export default function Comparison({
{q.delivery_days} dias
</td>
<td className="pr-4">
{q.evaluation_count} / 2
{q.evaluation_count} /{' '}
{
comparisonPolicy.minimum_evaluators
}
</td>
<td className="pr-4">
{q.score === null
@@ -419,6 +549,16 @@ export default function Comparison({
title={`${q.supplier.name} · ${q.reference}`}
description={`Pagamento: ${q.payment_terms}`}
>
{q.ineligibility_reasons.length > 0 && (
<ul className="mb-4 list-disc pl-5 text-sm text-amber-800 dark:text-amber-300">
{q.ineligibility_reasons.map((reason) => (
<li key={reason}>{reason}</li>
))}
</ul>
)}
<QuotationCommercialTerms
terms={q.commercial_terms}
/>
<div className="space-y-3">
{q.items.map((i) => (
<p className="text-sm" key={i.id}>
@@ -439,6 +579,12 @@ export default function Comparison({
<strong>
Avaliador #{ev.evaluator_id}
</strong>
<p className="text-xs text-muted-foreground">
Registada em{' '}
{new Date(ev.created_at).toLocaleString(
'pt-MZ',
)}
</p>
<p>
Qualidade {ev.quality} · histórico{' '}
{ev.history} · pagamento {ev.payment}
+1 -1
View File
@@ -56,7 +56,7 @@ export default function ReceiveOrder({
accepted_quantity: '',
quality_status: 'compliant',
notes: '',
stock_item_id: '',
stock_item_id: line.stock_item_id ? String(line.stock_item_id) : '',
})),
});
const change = (index: number, updates: Partial<Line>) =>
@@ -8,6 +8,8 @@ import {
} from '@/components/procurement';
import { Button } from '@/components/ui/button';
import { date, statusLabels } from '@/types/procurement';
import { ProcurementOperationalInsights } from '@/components/procurement-operational-insights';
import type { ProcurementInsights } from '@/components/procurement-operational-insights';
type Row = {
id: number;
@@ -37,6 +39,7 @@ export default function ProcurementReports({
filters,
departments,
generatedAt,
insights,
}: {
records: {
data: Row[];
@@ -50,6 +53,7 @@ export default function ProcurementReports({
filters: Filters;
departments: { id: number; name: string }[];
generatedAt: string;
insights?: ProcurementInsights;
}) {
const form = useForm({
search: filters.search ?? '',
@@ -79,6 +83,7 @@ export default function ProcurementReports({
</Button>
}
/>
{insights && <ProcurementOperationalInsights data={insights} />}
<Panel
title="Filtrar requisições"
description="O período refere-se à criação da requisição. Os resultados respeitam o seu acesso à empresa e ao departamento."
+155 -4
View File
@@ -11,8 +11,14 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { money } from '@/types/procurement';
import type { CostCenter, Requisition } from '@/types/procurement';
import { CatalogueSearch } from '@/components/requisition-catalogue';
import type { CatalogueOptions } from '@/components/requisition-catalogue';
type Line = {
source_line_id: string;
catalogue_entry_id: string;
catalogue_version: string;
catalogue_code: string;
key: string;
description: string;
category: string;
@@ -26,7 +32,11 @@ type Line = {
production_area: string;
downtime_started_at: string;
};
const emptyLine = (): Line => ({
const emptyLine = (taxRate: string): Line => ({
source_line_id: '',
catalogue_entry_id: '',
catalogue_version: '',
catalogue_code: '',
key: crypto.randomUUID(),
description: '',
category: '',
@@ -34,7 +44,7 @@ const emptyLine = (): Line => ({
quantity: '1',
unit: 'un',
unit_price: '',
tax_rate: '0',
tax_rate: taxRate,
equipment_id: '',
spare_part_id: '',
production_area: '',
@@ -76,12 +86,16 @@ export default function RequisitionForm({
equipment = [],
spareParts = [],
stockItems = [],
defaultTaxRate = '16.00',
catalogue,
}: {
costCenters: CostCenter[];
requisition: Requisition | null;
equipment?: { id: number; code: string; description: string }[];
spareParts?: { id: number; stock_item_id: number }[];
stockItems?: { id: number; code: string; name: string; unit: string }[];
defaultTaxRate?: string;
catalogue?: CatalogueOptions;
}) {
const form = useForm({
cost_center_id: requisition ? String(requisition.cost_center_id) : '',
@@ -89,6 +103,14 @@ export default function RequisitionForm({
needed_by: requisition?.needed_by ?? '',
justification: requisition?.justification ?? '',
items: requisition?.items.map((i) => ({
source_line_id: i.id ? String(i.id) : '',
catalogue_entry_id: i.catalogue_entry_id
? String(i.catalogue_entry_id)
: '',
catalogue_version: i.catalogue_snapshot
? String(i.catalogue_snapshot.version)
: '',
catalogue_code: i.catalogue_snapshot?.code ?? '',
key: String(i.id),
description: i.description,
category: i.category,
@@ -101,7 +123,7 @@ export default function RequisitionForm({
spare_part_id: i.spare_part_id ? String(i.spare_part_id) : '',
production_area: i.production_area ?? '',
downtime_started_at: i.downtime_started_at?.slice(0, 16) ?? '',
})) ?? [emptyLine()],
})) ?? [emptyLine(defaultTaxRate)],
});
const changeLine = (index: number, field: keyof Line, value: string) =>
form.setData(
@@ -242,6 +264,9 @@ export default function RequisitionForm({
description="Preços em MZN. Indique a taxa de IVA aplicável; o valor inicial de 0% deve ser revisto por Finanças."
>
<div className="space-y-5">
{catalogue?.ready && (
<CatalogueSearch data={catalogue} />
)}
{form.data.items.map((line, index) => (
<fieldset
className="rounded-lg border bg-muted/20 p-4"
@@ -251,6 +276,121 @@ export default function RequisitionForm({
LINHA {index + 1}
</legend>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{catalogue?.ready && (
<label className="space-y-2 sm:col-span-3">
Fonte do artigo
<select
className={controlClass}
value={
line.catalogue_entry_id
? `${line.catalogue_entry_id}:${line.catalogue_version}`
: ''
}
onChange={(event) => {
const entry =
catalogue.items.find(
(item) =>
`${item.id}:${item.version}` ===
event.target
.value,
);
form.setData(
'items',
form.data.items.map(
(item, i) =>
i === index
? entry
? {
...item,
catalogue_entry_id:
String(
entry.id,
),
catalogue_version:
String(
entry.version,
),
catalogue_code:
entry.code,
description:
entry.description,
category:
entry.category,
unit: entry.unit,
purchase_type:
entry.purchase_type,
}
: {
...item,
catalogue_entry_id:
'',
catalogue_version:
'',
catalogue_code:
'',
}
: item,
),
);
}}
>
<option value="">
Preenchimento livre
</option>
{line.catalogue_entry_id &&
!catalogue.items.some(
(item) =>
String(
item.id,
) ===
line.catalogue_entry_id &&
String(
item.version,
) ===
line.catalogue_version,
) && (
<option
value={`${line.catalogue_entry_id}:${line.catalogue_version}`}
>
{
line.catalogue_code
}{' '}
· versão
preservada{' '}
{
line.catalogue_version
}
</option>
)}
{catalogue.items.map(
(entry) => (
<option
key={`${entry.id}:${entry.version}`}
value={`${entry.id}:${entry.version}`}
>
{entry.code} ·{' '}
{
entry.description
}{' '}
· v
{entry.version}
</option>
),
)}
</select>
{line.catalogue_entry_id && (
<p className="text-sm">
Fonte{' '}
{line.catalogue_code} ·
versão utilizada{' '}
{line.catalogue_version}
. Escolha preenchimento
livre para descrever uma
necessidade diferente.
</p>
)}
</label>
)}
{lineFields.map(
({ key, label, ...attributes }) => (
<div
@@ -265,6 +405,14 @@ export default function RequisitionForm({
<Input
id={`${key}-${line.key}`}
{...attributes}
readOnly={
!!line.catalogue_entry_id &&
[
'description',
'category',
'unit',
].includes(key)
}
required
value={line[key]}
onChange={(e) =>
@@ -286,6 +434,9 @@ export default function RequisitionForm({
id={`type-${line.key}`}
className={controlClass}
value={line.purchase_type}
disabled={
!!line.catalogue_entry_id
}
onChange={(e) =>
changeLine(
index,
@@ -500,7 +651,7 @@ export default function RequisitionForm({
onClick={() =>
form.setData('items', [
...form.data.items,
emptyLine(),
emptyLine(defaultTaxRate),
])
}
>
+7
View File
@@ -1,5 +1,7 @@
import { Head, Link, useForm } from '@inertiajs/react';
import { Check, Circle, Pencil, Send } from 'lucide-react';
import { ComparisonPolicySummary } from '@/components/comparison-policy-summary';
import type { ComparisonPolicy } from '@/components/comparison-policy-summary';
import {
FormErrors,
PageTitle,
@@ -33,8 +35,10 @@ export default function Show({
conflictBlocked,
audit,
technical,
comparisonPolicy,
}: {
requisition: Requisition;
comparisonPolicy: ComparisonPolicy;
canEdit: boolean;
canDecide: boolean;
conflictBlocked: boolean;
@@ -81,6 +85,9 @@ export default function Show({
}
/>
<FormErrors errors={submission.errors} />
<Panel title="Regra de comparação deste processo">
<ComparisonPolicySummary policy={comparisonPolicy} />
</Panel>
{r.items.some((i) => i.purchase_type === 'equipment') && (
<Panel title="Validação técnica do equipamento">
<p className="mb-4 text-sm">
+200
View File
@@ -0,0 +1,200 @@
import { Head, Link, useForm } from '@inertiajs/react';
import {
PageTitle,
Panel,
FormErrors,
controlClass,
} from '@/components/procurement';
import { Button } from '@/components/ui/button';
type Entry = {
id: number;
code: string;
name: string;
unit: string;
stock_basis: string;
version: number;
category: string;
purchase_type: string;
published: boolean;
source_current: boolean;
};
function EntryForm({ entry }: { entry: Entry }) {
const form = useForm({
version: entry.version,
stock_basis: entry.stock_basis,
category: entry.category,
purchase_type: entry.purchase_type,
published: entry.published,
reason: '',
});
return (
<form
className="space-y-3"
onSubmit={(event) => {
event.preventDefault();
form.post(`/stock/catalogo/${entry.id}`, {
preserveScroll: true,
});
}}
>
<FormErrors errors={form.errors} />
<label className="grid gap-1">
Categoria
<input
className={controlClass}
required
maxLength={100}
value={form.data.category}
onChange={(event) =>
form.setData('category', event.target.value)
}
/>
</label>
<label className="grid gap-1">
Tipo de compra
<select
className={controlClass}
value={form.data.purchase_type}
onChange={(event) =>
form.setData('purchase_type', event.target.value)
}
>
<option value="consumable">Insumos / matéria-prima</option>
<option value="other">Outro bem</option>
</select>
</label>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={form.data.published}
onChange={(event) =>
form.setData('published', event.target.checked)
}
/>
Disponível para seleccionar nas requisições da empresa
</label>
<label className="grid gap-1">
Motivo
<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 versão do catálogo
</Button>
</form>
);
}
export default function Catalogue({
records,
filters,
ready,
canManage,
}: {
records: {
data: Entry[];
total: number;
prev_page_url: string | null;
next_page_url: string | null;
};
filters: { search?: string };
ready: boolean;
canManage: boolean;
}) {
const search = useForm({ search: filters.search ?? '' });
return (
<>
<Head title="Catálogo para requisições" />
<div className="mx-auto max-w-5xl space-y-6 p-5 lg:p-8">
<PageTitle
eyebrow="Armazém e compras"
title="Catálogo para requisições"
description="Reutilize o cadastro de artigos. Publique apenas os bens que os solicitantes podem seleccionar, preservando os dados de cada pedido."
/>
<Link className="underline" href="/stock">
Cadastro e existências
</Link>
<Panel title="Pesquisar artigos">
<form
className="flex gap-3"
onSubmit={(event) => {
event.preventDefault();
search.get('/stock/catalogo', {
preserveState: true,
});
}}
>
<input
className={controlClass}
aria-label="Código ou nome"
maxLength={100}
value={search.data.search}
onChange={(event) =>
search.setData('search', event.target.value)
}
/>
<Button>Pesquisar</Button>
</form>
</Panel>
{!ready && (
<p>Instalação do catálogo pendente neste ambiente.</p>
)}
{records.data.map((entry) => (
<Panel
key={`${entry.id}-${entry.version}-${entry.stock_basis}`}
title={`${entry.code} · ${entry.name}`}
>
<p className="mb-3 text-sm">
Unidade: {entry.unit} · versão {entry.version} ·{' '}
{entry.published ? 'Publicado' : 'Não publicado'}
</p>
{!entry.source_current && (
<p className="mb-3 text-sm">
O cadastro mudou. Confira e guarde uma nova
versão antes de novas utilizações.
</p>
)}
{ready && canManage && <EntryForm entry={entry} />}
</Panel>
))}
{!records.total && (
<p>
Sem artigos neste âmbito. Registe o artigo no cadastro
de stock antes de o publicar.
</p>
)}
<div className="flex gap-4">
{records.prev_page_url && (
<Link
className="underline"
href={records.prev_page_url}
>
Anterior
</Link>
)}
{records.next_page_url && (
<Link
className="underline"
href={records.next_page_url}
>
Seguinte
</Link>
)}
</div>
<p className="text-sm">
A publicação não divulga saldos ou movimentos. Equipamentos
e sobressalentes mantêm o seu roteiro técnico; serviços
continuam no preenchimento próprio.
</p>
</div>
</>
);
}
+8
View File
@@ -54,6 +54,14 @@ export default function Stock({
description="Saldo por artigo e armazém, calculado a partir de entradas, saídas, transferências e ajustes confirmados. O histórico permanece preservado."
/>
<InventoryNotice />
{canManage && (
<Link
className="font-semibold text-teal-700"
href="/stock/catalogo"
>
Catálogo para requisições →
</Link>
)}
<Link
href="/stock/operacoes"
className="font-semibold text-teal-700"
+1
View File
@@ -6,6 +6,7 @@ export type StockItem = {
};
export type Warehouse = { id: number; code: string; name: string };
export type ReceiptLineProgress = {
stock_item_id?: number;
id: number;
description: string;
unit: string;
+11
View File
@@ -14,6 +14,17 @@ export type Company = {
rules_provisional: boolean;
};
export type RequisitionItem = {
catalogue_entry_id?: number | null;
catalogue_snapshot?: {
entry_id: number;
version: number;
stock_item_id: number;
code: string;
description: string;
unit: string;
category: string;
purchase_type: string;
} | null;
id?: number;
description: string;
category: string;
+5
View File
@@ -1,4 +1,8 @@
import type { CommercialTerms } from '@/components/quotation-commercial-terms';
import type { ComparisonPolicy } from '@/components/comparison-policy-summary';
export type MatrixRow = {
commercial_terms?: CommercialTerms | null;
id: number;
reference: string;
supplier: { name: string };
@@ -66,6 +70,7 @@ export type PurchaseOrder = {
total_minor: number;
items: OrderItem[];
snapshot: {
comparison_policy?: ComparisonPolicy;
investment_rules?: {
id: number;
revision: number;