Files
compras/resources/js/pages/company/parameters.tsx
T
nala c6373365bf
tests / ci (push) Canceled after 0s
commit9
2026-10-30 20:02:30 +02:00

1017 lines
51 KiB
TypeScript

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,
FormErrors,
controlClass,
} from '@/components/procurement';
import { Button } from '@/components/ui/button';
type Profile = { id: number; name: string; permissions: string[] };
type Member = {
user_id: number;
profile_id: number;
maintenance: boolean;
expires_at: string | null;
reason: string | null;
};
type Person = {
id: number;
name: string;
email: string;
department_id: number;
};
const base = '/administracao/parametrizacao';
function Choices({
profiles,
selected,
change,
}: {
profiles: Profile[];
selected: number[];
change: (ids: number[]) => void;
}) {
return (
<div className="flex flex-wrap gap-4">
{profiles.map((p) => (
<label className="flex items-center gap-2 text-sm" key={p.id}>
<input
type="checkbox"
checked={selected.includes(p.id)}
onChange={(e) =>
change(
e.target.checked
? [...selected, p.id]
: selected.filter((id) => id !== p.id),
)
}
/>
{p.name}
</label>
))}
</div>
);
}
function Assignment({
person,
profiles,
members,
version,
}: {
person: Person;
profiles: Profile[];
members: Member[];
version: number;
}) {
const assigned = members.filter((m) => m.user_id === person.id);
const form = useForm({
version,
user_id: person.id,
profile_ids: assigned.map((m) => m.profile_id),
maintenance: assigned.some((m) => m.maintenance),
expires_at: '',
reason: '',
});
return (
<details className="rounded border p-4">
<summary className="cursor-pointer font-medium">
{person.name} · {person.email} · {assigned.length} perfil(is)
</summary>
<form
className="mt-4 space-y-4"
onSubmit={(e) => {
e.preventDefault();
form.transform((values) => ({
...values,
expires_at: values.expires_at
? new Date(values.expires_at).toISOString()
: null,
}));
form.post(base + '/assign', {
preserveState: 'errors',
preserveScroll: true,
});
}}
>
<FormErrors errors={form.errors} />
<Choices
profiles={profiles}
selected={form.data.profile_ids}
change={(ids) => form.setData('profile_ids', ids)}
/>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={form.data.maintenance}
onChange={(e) =>
form.setData('maintenance', e.target.checked)
}
/>
Manutenção TEMPO — diagnóstico e consulta temporários
</label>
<label className="block space-y-1">
Expiração (obrigatória para manutenção)
<input
className={controlClass}
type="datetime-local"
required={form.data.maintenance}
value={form.data.expires_at}
onChange={(e) =>
form.setData('expires_at', e.target.value)
}
/>
</label>
{assigned.some((m) => m.expires_at) && (
<p className="text-sm">
Expiração anterior:{' '}
{assigned.find((m) => m.expires_at)?.expires_at}.
Indique a expiração para manter um acesso temporário.
</p>
)}
<label className="block space-y-1">
Fundamento da atribuição ou revogação
<textarea
required
minLength={10}
maxLength={4000}
className={controlClass}
value={form.data.reason}
onChange={(e) => form.setData('reason', e.target.value)}
/>
</label>
<p className="text-sm text-muted-foreground">
Sem perfis seleccionados, o acesso funcional será revogado
quando a parametrização estiver activa. Os dois
administradores permanentes devem ser mantidos.
</p>
<Button disabled={form.processing}>Guardar atribuição</Button>
</form>
</details>
);
}
export default function Parameters({
ready,
active,
version,
permissions,
profiles,
memberships,
users,
departments,
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 };
profiles: Profile[];
memberships: Member[];
users: Person[];
departments: { id: number; name: string }[];
costCenters: { id: number; code: string; name: string }[];
notice?: string;
companyRules: {
first_limit: string;
second_limit: string;
includes_tax: boolean;
provisional: boolean;
default_tax_rate: string;
};
}) {
const profile = useForm({
version,
id: '',
name: '',
permissions: [] as string[],
});
const user = useForm({
version,
name: '',
email: '',
password: '',
password_confirmation: '',
department_id: '',
profile_ids: [] as number[],
reason: '',
});
const activate = useForm({ version, confirm: false, reason: '' });
const rules = useForm({
version,
first_limit: companyRules.first_limit,
second_limit: companyRules.second_limit,
includes_tax: companyRules.includes_tax,
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"
title="Parametrização da aplicação"
description="Defina os perfis da empresa, atribua responsabilidades e mantenha dois administradores permanentes."
/>
{notice && (
<p
role="status"
className="rounded border border-teal-300 p-3"
>
{notice}
</p>
)}
{!ready ? (
<Panel title="Instalação pendente">
<p>
A parametrização ainda não foi instalada neste
ambiente.
</p>
</Panel>
) : (
<>
<Panel
title={
active
? 'Parametrização activa'
: 'Preparar a configuração da empresa'
}
>
<p className="text-sm">
{active
? 'Os acessos são determinados pelos perfis e atribuições abaixo.'
: 'Crie os perfis e associe os utilizadores antes de activar. A activação substitui os perfis anteriores; utilizadores sem atribuições válidas deixam de ter acesso funcional.'}{' '}
Versão {version}.
</p>
{!active && (
<form
className="mt-4 space-y-3"
onSubmit={(e) => {
e.preventDefault();
activate.post(base + '/activate', {
preserveState: 'errors',
preserveScroll: true,
});
}}
>
<FormErrors errors={activate.errors} />
<label className="block">
Motivo da activação
<textarea
required
minLength={10}
className={controlClass}
value={activate.data.reason}
onChange={(e) =>
activate.setData(
'reason',
e.target.value,
)
}
/>
</label>
<label className="flex items-center gap-2">
<input
required
type="checkbox"
checked={activate.data.confirm}
onChange={(e) =>
activate.setData(
'confirm',
e.target.checked,
)
}
/>
Conferi os dois administradores e o
efeito das atribuições sobre todos os
utilizadores.
</label>
<Button
disabled={
activate.processing ||
!activate.data.confirm
}
>
Activar configuração da empresa
</Button>
</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
? 'Os valores actuais são provisórios; confirme e guarde as regras da empresa.'
: 'Regras definidas pela empresa.'}{' '}
Alterações aplicam-se a novas submissões. Os
processos já submetidos conservam as regras
anteriores.
</p>
<form
className="grid gap-4 sm:grid-cols-2"
onSubmit={(e) => {
e.preventDefault();
rules.post(base + '/rules', {
preserveState: 'errors',
preserveScroll: true,
});
}}
>
<div className="sm:col-span-2">
<FormErrors errors={rules.errors} />
</div>
<label className="space-y-1">
Primeiro limite (MZN)
<input
required
inputMode="decimal"
pattern="[0-9]+(\.[0-9]{1,2})?"
className={controlClass}
value={rules.data.first_limit}
onChange={(e) =>
rules.setData(
'first_limit',
e.target.value,
)
}
/>
</label>
<label className="space-y-1">
Segundo limite (MZN)
<input
required
inputMode="decimal"
pattern="[0-9]+(\.[0-9]{1,2})?"
className={controlClass}
value={rules.data.second_limit}
onChange={(e) =>
rules.setData(
'second_limit',
e.target.value,
)
}
/>
</label>
<label className="flex items-center gap-2 sm:col-span-2">
<input
type="checkbox"
checked={rules.data.includes_tax}
onChange={(e) =>
rules.setData(
'includes_tax',
e.target.checked,
)
}
/>
Aplicar os limites ao valor com imposto
</label>
<p className="text-sm sm:col-span-2">
Supervisão do departamento na primeira
etapa; acima do primeiro limite
acrescenta-se Compras; acima do segundo
acrescenta-se Finanças. Equipamentos mantêm
a conferência financeira e técnica. Atribua
estas funções às pessoas através dos perfis.
</p>
<label className="space-y-1 sm:col-span-2">
Fundamentação
<textarea
required
minLength={10}
className={controlClass}
value={rules.data.reason}
onChange={(e) =>
rules.setData(
'reason',
e.target.value,
)
}
/>
</label>
<Button disabled={rules.processing}>
Guardar regras da empresa
</Button>
</form>
</Panel>
<Panel title="Perfis e permissões da empresa">
<div className="mb-4 flex flex-wrap gap-3">
{profiles.map((p) => (
<Button
key={p.id}
variant="outline"
onClick={() =>
profile.setData({
version,
id: String(p.id),
name: p.name,
permissions: p.permissions,
})
}
>
{p.name}
</Button>
))}
</div>
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
profile.transform((values) => ({
...values,
id: values.id || null,
}));
profile.post(base + '/profile', {
preserveState: 'errors',
preserveScroll: true,
});
}}
>
<FormErrors errors={profile.errors} />
<label className="block space-y-1">
Nome do perfil
<input
required
maxLength={120}
className={controlClass}
value={profile.data.name}
onChange={(e) =>
profile.setData(
'name',
e.target.value,
)
}
/>
</label>
<fieldset className="grid gap-3 sm:grid-cols-2">
<legend className="mb-2 font-medium">
Permissões funcionais
</legend>
{Object.entries(permissions).map(
([key, label]) => (
<label
key={key}
className="flex items-center gap-2 text-sm"
>
<input
type="checkbox"
checked={profile.data.permissions.includes(
key,
)}
onChange={(e) =>
profile.setData(
'permissions',
e.target.checked
? [
...profile
.data
.permissions,
key,
]
: profile.data.permissions.filter(
(p) =>
p !==
key,
),
)
}
/>
{label}
</label>
),
)}
</fieldset>
<div className="flex gap-3">
<Button disabled={profile.processing}>
{profile.data.id
? 'Actualizar perfil'
: 'Criar perfil'}
</Button>
<Button
type="button"
variant="outline"
onClick={() =>
profile.setData({
version,
id: '',
name: '',
permissions: [],
})
}
>
Novo perfil
</Button>
</div>
</form>
</Panel>
<Panel title="Utilizadores e atribuições">
<div className="space-y-3">
{users.map((person) => (
<Assignment
key={`${person.id}-${version}`}
person={person}
profiles={profiles}
members={memberships}
version={version}
/>
))}
</div>
<details className="mt-5 rounded border p-4">
<summary className="cursor-pointer font-medium">
Criar utilizador
</summary>
<form
className="mt-4 grid gap-4 sm:grid-cols-2"
onSubmit={(e) => {
e.preventDefault();
user.post(base + '/user', {
preserveState: 'errors',
preserveScroll: true,
onSuccess: () =>
user.reset(
'password',
'password_confirmation',
),
});
}}
>
<div className="sm:col-span-2">
<FormErrors errors={user.errors} />
</div>
{(
[
'name',
'email',
'password',
'password_confirmation',
] as const
).map((key) => (
<label className="space-y-1" key={key}>
{
{
name: 'Nome',
email: 'Email',
password:
'Palavra-passe inicial',
password_confirmation:
'Confirmar palavra-passe',
}[key]
}
<input
required
type={
key.includes('password')
? 'password'
: key === 'email'
? 'email'
: 'text'
}
minLength={
key.includes('password')
? 12
: undefined
}
autoComplete={
key.includes('password')
? 'new-password'
: undefined
}
className={controlClass}
value={user.data[key]}
onChange={(e) =>
user.setData(
key,
e.target.value,
)
}
/>
</label>
))}
<label className="space-y-1">
Departamento
<select
required
className={controlClass}
value={user.data.department_id}
onChange={(e) =>
user.setData(
'department_id',
e.target.value,
)
}
>
<option value="">Escolher</option>
{departments.map((d) => (
<option key={d.id} value={d.id}>
{d.name}
</option>
))}
</select>
</label>
<div className="space-y-2 sm:col-span-2">
<p>Perfis atribuídos</p>
<Choices
profiles={profiles}
selected={user.data.profile_ids}
change={(ids) =>
user.setData('profile_ids', ids)
}
/>
</div>
<label className="space-y-1 sm:col-span-2">
Fundamento
<textarea
required
minLength={10}
className={controlClass}
value={user.data.reason}
onChange={(e) =>
user.setData(
'reason',
e.target.value,
)
}
/>
</label>
<Button disabled={user.processing}>
Criar utilizador
</Button>
</form>
</details>
</Panel>
<Panel title="Organização e centros de custo">
<div className="grid gap-5 sm:grid-cols-2">
<form
className="space-y-3"
onSubmit={(e) => {
e.preventDefault();
department.post(
'/empresa/departamentos',
{ preserveScroll: true },
);
}}
>
<FormErrors errors={department.errors} />
<label className="block">
Novo departamento
<input
required
className={controlClass}
value={department.data.name}
onChange={(e) =>
department.setData(
'name',
e.target.value,
)
}
/>
</label>
<Button disabled={department.processing}>
Adicionar departamento
</Button>
<p className="text-sm">
{departments
.map((d) => d.name)
.join(' · ')}
</p>
</form>
<form
className="space-y-3"
onSubmit={(e) => {
e.preventDefault();
center.post(
'/empresa/centros-de-custo',
{ preserveScroll: true },
);
}}
>
<FormErrors errors={center.errors} />
<label className="block">
Departamento
<select
required
className={controlClass}
value={center.data.department_id}
onChange={(e) =>
center.setData(
'department_id',
e.target.value,
)
}
>
<option value="">Escolher</option>
{departments.map((d) => (
<option key={d.id} value={d.id}>
{d.name}
</option>
))}
</select>
</label>
<label className="block">
Código
<input
required
className={controlClass}
value={center.data.code}
onChange={(e) =>
center.setData(
'code',
e.target.value,
)
}
/>
</label>
<label className="block">
Centro de custo
<input
required
className={controlClass}
value={center.data.name}
onChange={(e) =>
center.setData(
'name',
e.target.value,
)
}
/>
</label>
<Button disabled={center.processing}>
Adicionar centro
</Button>
<p className="text-sm">
{costCenters
.map((c) => c.code + ' · ' + c.name)
.join(' / ')}
</p>
</form>
</div>
</Panel>
<Panel title="Manutenção TEMPO">
<p className="text-sm">
Atribua a uma conta nominativa um perfil sem
administração, assinale manutenção e defina a
expiração. O acesso fica limitado a diagnóstico
e consulta, é revogado removendo as atribuições
e termina automaticamente na data indicada. As
atribuições, alterações e operações mantêm
autoria e auditoria; não existe palavra-passe
universal.
</p>
</Panel>
</>
)}
</div>
</>
);
}