@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -36,16 +36,21 @@ export function PageTitle({
|
||||
);
|
||||
}
|
||||
export function Panel({
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
}: {
|
||||
id?: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="rounded-xl border bg-card p-5 shadow-xs">
|
||||
<section
|
||||
id={id}
|
||||
className="scroll-mt-24 rounded-xl border bg-card p-5 shadow-xs"
|
||||
>
|
||||
<h2 className="font-semibold">{title}</h2>
|
||||
{description && (
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { money } from '@/types/procurement';
|
||||
|
||||
export type CommercialTerms = {
|
||||
lines: {
|
||||
requisition_item_id: number;
|
||||
gross_unit_minor: number;
|
||||
discount_type: string;
|
||||
discount_value: string;
|
||||
net_unit_minor: number;
|
||||
}[];
|
||||
};
|
||||
|
||||
export function QuotationCommercialTerms({
|
||||
terms,
|
||||
}: {
|
||||
terms?: CommercialTerms | null;
|
||||
}) {
|
||||
if (!terms) return null;
|
||||
return (
|
||||
<div className="my-3 space-y-2 text-sm">
|
||||
<p>
|
||||
Desconto por unidade, antes do IVA. Preço líquido arredondado ao
|
||||
centavo.
|
||||
</p>
|
||||
{terms.lines.map((line) => (
|
||||
<p key={line.requisition_item_id}>
|
||||
Linha #{line.requisition_item_id}: preço inicial{' '}
|
||||
{money(line.gross_unit_minor)} · desconto{' '}
|
||||
{line.discount_type === 'none'
|
||||
? 'sem desconto'
|
||||
: line.discount_type === 'percent'
|
||||
? `${line.discount_value}%`
|
||||
: `${line.discount_value} MZN/unidade`}{' '}
|
||||
· preço líquido {money(line.net_unit_minor)}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { router, usePage } from '@inertiajs/react';
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { controlClass } from '@/components/procurement';
|
||||
|
||||
export type CatalogueEntry = {
|
||||
id: number;
|
||||
version: number;
|
||||
stock_item_id: number;
|
||||
code: string;
|
||||
description: string;
|
||||
unit: string;
|
||||
category: string;
|
||||
purchase_type: string;
|
||||
};
|
||||
export type CatalogueOptions = {
|
||||
ready: boolean;
|
||||
search: string;
|
||||
items: CatalogueEntry[];
|
||||
more: boolean;
|
||||
};
|
||||
export function CatalogueSearch({ data }: { data: CatalogueOptions }) {
|
||||
const [search, setSearch] = useState(data.search);
|
||||
const path = usePage().url.split('?')[0];
|
||||
function lookup() {
|
||||
router.get(
|
||||
path,
|
||||
{ catalogo: search },
|
||||
{ only: ['catalogue'], preserveState: true, preserveScroll: true },
|
||||
);
|
||||
}
|
||||
return (
|
||||
<fieldset className="mb-4 space-y-3 rounded-lg border p-4">
|
||||
<legend>Artigos do catálogo da empresa</legend>
|
||||
<p className="text-sm">
|
||||
Seleccione uma fonte em cada linha. Descrição, categoria,
|
||||
unidade e tipo são reutilizados; quantidade, estimativa e IVA
|
||||
são dados deste pedido.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<input
|
||||
aria-label="Pesquisar catálogo"
|
||||
className={controlClass}
|
||||
value={search}
|
||||
maxLength={100}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
lookup();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button type="button" variant="outline" onClick={lookup}>
|
||||
Pesquisar catálogo
|
||||
</Button>
|
||||
</div>
|
||||
{data.more && <p>Há mais artigos; refine a pesquisa.</p>}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
@@ -197,7 +197,7 @@ function SgcPreviewShell({
|
||||
</span>
|
||||
<span>
|
||||
<span className="block text-sm font-semibold">
|
||||
Sistema de Gestão de Compras
|
||||
Sistema de Gestão de Contratações
|
||||
</span>
|
||||
<span className="mt-1 block text-xs text-slate-400">
|
||||
Compras com controlo e transparência
|
||||
@@ -302,7 +302,10 @@ function SgcPreviewShell({
|
||||
<strong className="font-semibold text-white">
|
||||
SGC
|
||||
</strong>{' '}
|
||||
· Sistema de Gestão de Compras
|
||||
· Sistema de Gestão de Contratações
|
||||
<span className="block">
|
||||
Aquisições, serviços e pessoas · Uma solução TEMPO
|
||||
</span>
|
||||
</span>
|
||||
<span>
|
||||
{demo
|
||||
|
||||
@@ -7,13 +7,14 @@ import {
|
||||
Search,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Fragment, useEffect, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
@@ -69,7 +70,13 @@ export function SgcWorkspaceShell({ children }: { children: ReactNode }) {
|
||||
entries: group.entries.filter(
|
||||
(entry) =>
|
||||
!query ||
|
||||
normalize(group.label + ' ' + entry.label).includes(query),
|
||||
normalize(
|
||||
group.label +
|
||||
' ' +
|
||||
(entry.section ?? '') +
|
||||
' ' +
|
||||
entry.label,
|
||||
).includes(query),
|
||||
),
|
||||
}))
|
||||
.filter((group) => group.entries.length);
|
||||
@@ -88,10 +95,10 @@ export function SgcWorkspaceShell({ children }: { children: ReactNode }) {
|
||||
</span>
|
||||
<span>
|
||||
<span className="block text-lg font-semibold tracking-tight text-white">
|
||||
Compras
|
||||
SGC
|
||||
</span>
|
||||
<span className="block text-xs text-slate-400">
|
||||
Sistema de Gestão de Compras
|
||||
Sistema de Gestão de Contratações
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
@@ -186,10 +193,18 @@ export function SgcWorkspaceShell({ children }: { children: ReactNode }) {
|
||||
/>
|
||||
</summary>
|
||||
<ul className="mt-1 mb-3 ml-5 space-y-1 border-l border-white/10 pl-3">
|
||||
{group.entries.map((entry) => {
|
||||
{group.entries.map((entry, index) => {
|
||||
const className = `block rounded-md px-3 py-2 text-[13px] leading-5 transition-colors focus-visible:outline-2 focus-visible:outline-white ${activeId === entry.id ? 'bg-red-500/15 font-medium text-red-300' : 'text-slate-400 hover:bg-white/5 hover:text-white'}`;
|
||||
return (
|
||||
<li key={entry.id}>
|
||||
{entry.section &&
|
||||
entry.section !==
|
||||
group.entries[index - 1]
|
||||
?.section && (
|
||||
<p className="px-3 pt-3 pb-1 text-xs font-semibold text-slate-500">
|
||||
{entry.section}
|
||||
</p>
|
||||
)}
|
||||
{entry.fullPage ? (
|
||||
<a
|
||||
href={entry.path}
|
||||
@@ -271,7 +286,7 @@ export function SgcWorkspaceShell({ children }: { children: ReactNode }) {
|
||||
className="flex w-[min(90vw,20rem)] max-w-none flex-col gap-0 border-white/10 bg-[#101715] p-0 text-white [&>button]:text-white"
|
||||
>
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>Menu de Compras</SheetTitle>
|
||||
<SheetTitle>Menu do SGC</SheetTitle>
|
||||
<SheetDescription>
|
||||
Escolhe uma área da aplicação ou procura pelo nome.
|
||||
</SheetDescription>
|
||||
@@ -334,40 +349,50 @@ export function SgcWorkspaceShell({ children }: { children: ReactNode }) {
|
||||
sideOffset={8}
|
||||
className="max-h-[min(70vh,32rem)] w-72 overflow-y-auto border-white/10 bg-[#101715] p-2 text-white"
|
||||
>
|
||||
{group.entries.map((entry) => (
|
||||
<DropdownMenuItem
|
||||
key={entry.id}
|
||||
asChild
|
||||
className={`rounded-md px-3 py-2 text-[13px] leading-5 focus:bg-white/5 focus:text-white ${activeId === entry.id ? 'bg-red-500/15 font-medium text-red-300' : 'text-slate-400'}`}
|
||||
>
|
||||
{entry.fullPage ? (
|
||||
<a
|
||||
href={entry.path}
|
||||
onClick={navigate}
|
||||
aria-current={
|
||||
activeId ===
|
||||
entry.id
|
||||
? 'page'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{entry.label}
|
||||
</a>
|
||||
) : (
|
||||
<Link
|
||||
href={entry.path}
|
||||
onClick={navigate}
|
||||
aria-current={
|
||||
activeId ===
|
||||
entry.id
|
||||
? 'page'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{entry.label}
|
||||
</Link>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
{group.entries.map((entry, index) => (
|
||||
<Fragment key={entry.id}>
|
||||
{entry.section &&
|
||||
entry.section !==
|
||||
group.entries[index - 1]
|
||||
?.section && (
|
||||
<DropdownMenuLabel className="px-3 pt-3 pb-1 text-xs text-slate-500">
|
||||
{entry.section}
|
||||
</DropdownMenuLabel>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
key={entry.id}
|
||||
asChild
|
||||
className={`rounded-md px-3 py-2 text-[13px] leading-5 focus:bg-white/5 focus:text-white ${activeId === entry.id ? 'bg-red-500/15 font-medium text-red-300' : 'text-slate-400'}`}
|
||||
>
|
||||
{entry.fullPage ? (
|
||||
<a
|
||||
href={entry.path}
|
||||
onClick={navigate}
|
||||
aria-current={
|
||||
activeId ===
|
||||
entry.id
|
||||
? 'page'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{entry.label}
|
||||
</a>
|
||||
) : (
|
||||
<Link
|
||||
href={entry.path}
|
||||
onClick={navigate}
|
||||
aria-current={
|
||||
activeId ===
|
||||
entry.id
|
||||
? 'page'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{entry.label}
|
||||
</Link>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
</Fragment>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -388,7 +413,7 @@ export function SgcWorkspaceShell({ children }: { children: ReactNode }) {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
aria-label="Abrir menu de Compras"
|
||||
aria-label="Abrir menu do SGC"
|
||||
onClick={() => setMobileOpen(true)}
|
||||
className="shrink-0 lg:hidden"
|
||||
>
|
||||
@@ -478,7 +503,10 @@ export function SgcWorkspaceShell({ children }: { children: ReactNode }) {
|
||||
{children}
|
||||
</main>
|
||||
<footer className="mt-auto border-t border-border px-5 py-4 text-xs text-muted-foreground lg:px-8 print:hidden">
|
||||
SGC · Sistema de Gestão de Compras
|
||||
SGC · Sistema de Gestão de Contratações
|
||||
<span className="block">
|
||||
Aquisições, serviços e pessoas · Uma solução TEMPO
|
||||
</span>
|
||||
<Link
|
||||
href="/settings/appearance"
|
||||
className="ml-4 hover:text-foreground"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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(
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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),
|
||||
])
|
||||
}
|
||||
>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
@viteReactRefresh
|
||||
@vite(['resources/css/app.css', 'resources/js/app.tsx', "resources/js/pages/{$page['component']}.tsx"])
|
||||
<x-inertia::head>
|
||||
<title>Sistema de Gestão de Compras (SGC)</title>
|
||||
<title>Sistema de Gestão de Contratações (SGC)</title>
|
||||
</x-inertia::head>
|
||||
</head>
|
||||
<body class="font-sans antialiased">
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
SGC — Sistema de Gestão de Contratações
|
||||
|
||||
{{ $purpose === 'verify' ? 'Para confirmar o seu endereço de email, abra a ligação e confirme a operação.' : 'Para definir uma nova palavra-passe, abra a ligação abaixo.' }}
|
||||
|
||||
{{ $link }}
|
||||
|
||||
A ligação é temporária e só pode ser utilizada uma vez. Se não pediu esta operação, ignore esta mensagem.
|
||||
Confirmar o email não constitui verificação da identidade, candidatura ou contratação.
|
||||
@@ -0,0 +1,6 @@
|
||||
@if(!empty($terms))
|
||||
<p class="small">Desconto por unidade antes do IVA; preço líquido arredondado ao centavo.</p>
|
||||
@foreach($terms['lines'] as $line)
|
||||
<p class="small">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>
|
||||
@endforeach
|
||||
@endif
|
||||
@@ -21,7 +21,7 @@
|
||||
</head>
|
||||
<body>
|
||||
<button type="button" class="no-print" onclick="window.print()">Imprimir acta</button>
|
||||
<header><strong>SGC · Sistema de Gestão de Compras</strong><p>{{ $snapshot['company']['name'] }}</p></header>
|
||||
<header><strong>SGC · Sistema de Gestão de Contratações</strong><p>{{ $snapshot['company']['name'] }}</p></header>
|
||||
<h1>Acta de sessão de abertura de propostas</h1>
|
||||
<p><strong>{{ $opening->reference }}</strong> · Versão {{ $opening->revision }} · {{ $snapshot['requisition']['number'] }}</p>
|
||||
<p class="pre">Objecto: {{ $snapshot['requisition']['justification'] }}</p>
|
||||
@@ -42,6 +42,6 @@
|
||||
@if(count($opening->annexes))<ul>@foreach($opening->annexes as $annex)<li>{{ $annex }}</li>@endforeach</ul>@else<p>Sem referências de anexos registadas.</p>@endif
|
||||
<p class="small">As referências acima não significam que os ficheiros ou assinaturas foram carregados ou verificados no SGC.</p>
|
||||
<h2>Fundamento do registo desta versão</h2><p class="pre">{{ $opening->reason }}</p>
|
||||
<footer class="small">Registado por {{ $snapshot['author']['name'] }} em {{ \Carbon\CarbonImmutable::parse($opening->created_at, 'UTC')->timezone($opening->timezone)->format('d/m/Y H:i') }} · {{ $opening->timezone }}.<br>SGC · Sistema de Gestão de Compras · Documento do processo {{ $snapshot['requisition']['number'] }}.</footer>
|
||||
<footer class="small">Registado por {{ $snapshot['author']['name'] }} em {{ \Carbon\CarbonImmutable::parse($opening->created_at, 'UTC')->timezone($opening->timezone)->format('d/m/Y H:i') }} · {{ $opening->timezone }}.<br>SGC · Sistema de Gestão de Contratações · Documento do processo {{ $snapshot['requisition']['number'] }}.</footer>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -40,9 +40,18 @@
|
||||
<tr><td class="pre">{{ $item->description }}</td><td>{{ $item->quantity }} {{ $item->unit }}</td><td>{{ $money($item->unit_price_minor) }}</td><td>{{ $item->tax_rate }}%</td><td>{{ $money($item->total_minor) }}</td></tr>
|
||||
@endforeach
|
||||
</tbody></table>
|
||||
@foreach($snapshot['decision_matrix'] as $row)
|
||||
@if(!empty($row['commercial_terms']))
|
||||
<h3>{{ $row['supplier']['name'] }} · {{ $row['reference'] }}</h3>
|
||||
@include('purchases.commercial-terms', ['terms' => $row['commercial_terms']])
|
||||
@endif
|
||||
@endforeach
|
||||
<table class="totals"><tr><td>Subtotal sem IVA</td><td>{{ $money($order->subtotal_minor) }}</td></tr><tr><td>IVA</td><td>{{ $money($order->tax_minor) }}</td></tr><tr><th>Total com IVA</th><th>{{ $money($order->total_minor) }}</th></tr></table>
|
||||
<h2>Justificação da adjudicação</h2><p class="pre">{{ $snapshot['award']['justification'] }}</p>
|
||||
<h2>Matriz de avaliação congelada</h2>
|
||||
@if(!empty($snapshot['comparison_policy']))
|
||||
<p class="small">Regra de comparação preservada: revisão {{ $snapshot['comparison_policy']['revision'] }} · mínimo de {{ $snapshot['comparison_policy']['minimum_proposals'] }} fornecedores com propostas elegíveis e {{ $snapshot['comparison_policy']['minimum_evaluators'] }} avaliações independentes por proposta.</p>
|
||||
@endif
|
||||
<table><thead><tr><th>Fornecedor / proposta</th><th>Total com IVA</th><th>Avaliações elegíveis</th><th>Pontuação</th></tr></thead><tbody>
|
||||
@foreach($snapshot['decision_matrix'] as $row)
|
||||
<tr><td>{{ $row['supplier']['name'] }} · {{ $row['reference'] }}<br>@foreach($row['evaluations'] as $evaluation)<span class="small">Avaliador #{{ $evaluation['evaluator_id'] }}: qualidade {{ $evaluation['quality'] }}, histórico {{ $evaluation['history'] }}, pagamento {{ $evaluation['payment'] }}. {{ $evaluation['comment'] }}</span><br>@endforeach</td><td>{{ $money($row['total_minor']) }}</td><td>{{ $row['evaluation_count'] }}</td><td>{{ $row['score'] === null ? 'Não elegível / incompleta' : number_format($row['score'], 2, ',', '.') . ' / 10' }}</td></tr>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Recomendação · {{ $requisition->number }} · Sistema de Gestão de Compras (SGC)</title>
|
||||
<title>Recomendação · {{ $requisition->number }} · Sistema de Gestão de Contratações (SGC)</title>
|
||||
<style>
|
||||
@page { size: A4 landscape; margin: 14mm; }
|
||||
body { font: 12px Arial, sans-serif; color: #111; margin: 24px; }
|
||||
@@ -21,7 +21,7 @@
|
||||
</head>
|
||||
<body>
|
||||
<nav class="controls"><a href="{{ route('recommendations.show', $requisition) }}">Voltar ao processo</a> · <button onclick="window.print()">Imprimir</button></nav>
|
||||
<header><strong>Sistema de Gestão de Compras (SGC)</strong><h1>Recomendação antes da adjudicação · {{ $requisition->number }}</h1><p>Adopção {{ $adoption->revision }} · política {{ $adoption->snapshot['policy_revision'] }} · tentativa {{ $attempt->revision }}</p></header>
|
||||
<header><strong>Sistema de Gestão de Contratações (SGC)</strong><h1>Recomendação antes da adjudicação · {{ $requisition->number }}</h1><p>Adopção {{ $adoption->revision }} · política {{ $adoption->snapshot['policy_revision'] }} · tentativa {{ $attempt->revision }}</p></header>
|
||||
@php
|
||||
$states = ['pending' => 'A aguardar conferência', 'confirmed' => 'Recomendação confirmada', 'returned' => 'Tentativa devolvida'];
|
||||
$rows = $attempt->snapshot['basis']['rows'];
|
||||
@@ -52,6 +52,6 @@
|
||||
<article><strong>Tentativa {{ $entry['attempt']->revision }} · proposta #{{ $entry['attempt']->quotation_id }} · {{ $states[$entry['state']] }} · histórica</strong><p class="reason">{{ $entry['attempt']->reason }}</p>
|
||||
@foreach($entry['reviews'] as $review)<p class="reason">{{ $review->snapshot['name'] }} · {{ $review->verdict === 'approved' ? 'Confirmou' : 'Devolveu' }} · {{ $review->created_at }} UTC · {{ $review->reason }}</p>@endforeach</article>
|
||||
@empty<p>Sem tentativas anteriores nesta adopção.</p>@endforelse
|
||||
<footer>Sistema de Gestão de Compras (SGC) · Registos preservados para conferência. Outras adopções são consultáveis no histórico autenticado do processo.</footer>
|
||||
<footer>Sistema de Gestão de Contratações (SGC) · Registos preservados para conferência. Outras adopções são consultáveis no histórico autenticado do processo.</footer>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user