912 lines
42 KiB
TypeScript
912 lines
42 KiB
TypeScript
import { Link, useForm } from '@inertiajs/react';
|
|
import { useState } from 'react';
|
|
import DeskServiceHistory from '@/components/desk-service-history';
|
|
import { Frame, card, button, input } from '@/pages/investment/shared';
|
|
|
|
export type Field = {
|
|
label: string;
|
|
type: string;
|
|
required: boolean;
|
|
min?: number;
|
|
options?: Record<string, string>;
|
|
statement?: string;
|
|
mime_types?: string[];
|
|
max_size_kb?: number;
|
|
max_files?: number;
|
|
when?: { field: string; equals: string; operator?: 'equals' | 'contains' };
|
|
};
|
|
export type Definition = {
|
|
title: string;
|
|
theme: string;
|
|
profile: string;
|
|
stage: string;
|
|
entity: string;
|
|
result: string;
|
|
fields: Record<string, Field>;
|
|
};
|
|
type Service = {
|
|
code: string;
|
|
version: number;
|
|
definition: Definition;
|
|
fixedTypes?: Record<string, string>;
|
|
editable?: boolean;
|
|
effective_from?: string | null;
|
|
activeVersion?: number;
|
|
upcoming?: { version: number; effective_from: string }[];
|
|
};
|
|
const types: Record<string, string> = {
|
|
text: 'Texto curto',
|
|
textarea: 'Texto longo',
|
|
number: 'Número',
|
|
date: 'Data',
|
|
select: 'Lista de opções',
|
|
multiselect: 'Selecção múltipla',
|
|
consent: 'Declaração de consentimento',
|
|
attachment: 'Anexos de documentos autorizados',
|
|
territory: 'Localização do cadastro',
|
|
email: 'Email',
|
|
tel: 'Telefone',
|
|
url: 'Ligação web',
|
|
};
|
|
const labels = {
|
|
title: 'Nome do serviço',
|
|
theme: 'Tema',
|
|
profile: 'Destinatários',
|
|
entity: 'Entidade indicada',
|
|
result: 'Resultado esperado',
|
|
};
|
|
function describeField(field: Field | undefined): string {
|
|
if (!field) return 'Ausente';
|
|
return [
|
|
field.label,
|
|
types[field.type] ?? field.type,
|
|
field.required ? 'Obrigatório' : 'Opcional',
|
|
...(field.statement ? [`Declaração: ${field.statement}`] : []),
|
|
...(field.type === 'attachment'
|
|
? [
|
|
`Formatos: ${field.mime_types?.join(', ')}`,
|
|
`Tamanho por ficheiro: ${field.max_size_kb} KiB`,
|
|
`Máximo de ficheiros: ${field.max_files}`,
|
|
]
|
|
: []),
|
|
...(field.min !== undefined ? [`Mínimo: ${field.min}`] : []),
|
|
...(field.options
|
|
? [
|
|
`Opções: ${Object.entries(field.options)
|
|
.map(([code, label]) => `${label} (${code})`)
|
|
.join('; ')}`,
|
|
]
|
|
: []),
|
|
field.when
|
|
? `Aplica-se quando ${field.when.field} ${field.when.operator === 'contains' ? 'inclui' : '='} ${field.when.equals}`
|
|
: 'Sempre aplicável',
|
|
].join(' · ');
|
|
}
|
|
|
|
export default function ServiceEditor({
|
|
services,
|
|
schedulingAvailable = false,
|
|
today = '',
|
|
calendarTimezone = '',
|
|
}: {
|
|
services: Service[];
|
|
schedulingAvailable?: boolean;
|
|
today?: string;
|
|
calendarTimezone?: string;
|
|
}) {
|
|
const [code, setCode] = useState(services[0]?.code ?? '');
|
|
const service = services.find((row) => row.code === code);
|
|
return (
|
|
<Frame title="Formulários do Balcão — versões">
|
|
<Link href="/management/balcao-install" className="underline">
|
|
Voltar à administração do Balcão
|
|
</Link>
|
|
<p>
|
|
Prepare a definição, confira as alterações e publique uma nova
|
|
versão. Pedidos existentes conservam os campos, respostas e
|
|
regras da versão em que foram criados.
|
|
</p>
|
|
<label className="block">
|
|
Serviço
|
|
<select
|
|
className={input}
|
|
value={code}
|
|
onChange={(event) => setCode(event.target.value)}
|
|
>
|
|
{services.map((row) => (
|
|
<option key={row.code} value={row.code}>
|
|
{row.definition.title} — versão {row.version}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
{service && service.editable === false && (
|
|
<p>
|
|
Este serviço conserva definições históricas fora da
|
|
configuração actual. Neste editor estão disponíveis para
|
|
consulta.
|
|
</p>
|
|
)}
|
|
{service && service.editable !== false ? (
|
|
<Editor
|
|
key={`${service.code}:${service.version}`}
|
|
service={service}
|
|
schedulingAvailable={schedulingAvailable}
|
|
today={today}
|
|
calendarTimezone={calendarTimezone}
|
|
/>
|
|
) : !service ? (
|
|
<p>Não existem serviços disponíveis para edição.</p>
|
|
) : null}
|
|
{service && (
|
|
<DeskServiceHistory
|
|
key={`history:${service.code}:${service.version}`}
|
|
code={service.code}
|
|
formatField={describeField}
|
|
/>
|
|
)}
|
|
</Frame>
|
|
);
|
|
}
|
|
|
|
function Editor({
|
|
service,
|
|
schedulingAvailable = false,
|
|
today = '',
|
|
calendarTimezone = '',
|
|
}: {
|
|
service: Service;
|
|
schedulingAvailable?: boolean;
|
|
today?: string;
|
|
calendarTimezone?: string;
|
|
}) {
|
|
const form = useForm(
|
|
`desk-service-editor:${service.code}:${service.version}`,
|
|
{
|
|
base_version: service.version,
|
|
definition: structuredClone(service.definition),
|
|
reason: '',
|
|
confirm: false,
|
|
effective_from:
|
|
schedulingAvailable &&
|
|
service.effective_from &&
|
|
service.effective_from > today
|
|
? service.effective_from
|
|
: '',
|
|
},
|
|
);
|
|
const [newKey, setNewKey] = useState('');
|
|
const [localError, setLocalError] = useState('');
|
|
const fields = form.data.definition.fields;
|
|
const dateChanged =
|
|
schedulingAvailable &&
|
|
(form.data.effective_from
|
|
? form.data.effective_from !== (service.effective_from ?? '')
|
|
: Boolean(
|
|
service.effective_from && service.effective_from > today,
|
|
));
|
|
const setFields = (value: Record<string, Field>) =>
|
|
form.setData((data) => ({
|
|
...data,
|
|
confirm: false,
|
|
definition: { ...data.definition, fields: value },
|
|
}));
|
|
const update = (key: string, patch: Partial<Field>) =>
|
|
setFields({ ...fields, [key]: { ...fields[key], ...patch } });
|
|
const changed = [
|
|
...new Set([
|
|
...Object.keys(service.definition.fields),
|
|
...Object.keys(fields),
|
|
]),
|
|
].filter(
|
|
(key) =>
|
|
JSON.stringify(service.definition.fields[key]) !==
|
|
JSON.stringify(fields[key]),
|
|
);
|
|
const metaChanges = (Object.keys(labels) as (keyof typeof labels)[]).filter(
|
|
(key) => service.definition[key] !== form.data.definition[key],
|
|
);
|
|
return (
|
|
<form
|
|
className="space-y-5"
|
|
onSubmit={(event) => {
|
|
event.preventDefault();
|
|
if (form.processing) return;
|
|
form.put(
|
|
`/management/balcao/servicos/${encodeURIComponent(service.code)}`,
|
|
{ preserveScroll: true },
|
|
);
|
|
}}
|
|
>
|
|
<fieldset
|
|
disabled={form.processing}
|
|
className={card + ' space-y-4'}
|
|
>
|
|
<legend>Identificação do formulário</legend>
|
|
{(Object.keys(labels) as (keyof typeof labels)[]).map((key) => (
|
|
<label key={key} className="block">
|
|
{labels[key]}
|
|
<input
|
|
required
|
|
maxLength={
|
|
key === 'result'
|
|
? 2000
|
|
: key === 'entity'
|
|
? 300
|
|
: key === 'profile' || key === 'title'
|
|
? 200
|
|
: 150
|
|
}
|
|
className={input}
|
|
value={form.data.definition[key]}
|
|
onChange={(event) =>
|
|
form.setData((data) => ({
|
|
...data,
|
|
confirm: false,
|
|
definition: {
|
|
...data.definition,
|
|
[key]: event.target.value,
|
|
},
|
|
}))
|
|
}
|
|
/>
|
|
</label>
|
|
))}
|
|
<p>
|
|
Etapa do percurso existente: {service.definition.stage}.
|
|
Este editor não altera a sequência de tramitação.
|
|
</p>
|
|
<p>
|
|
Base de edição: última versão publicada, {service.version}.
|
|
Versão actualmente disponível para novos pedidos:{' '}
|
|
{(service.activeVersion ?? service.version) || 'nenhuma'}.
|
|
Entrada em vigor da base:{' '}
|
|
{service.effective_from ||
|
|
'sem data explícita (versão histórica)'}
|
|
.
|
|
</p>
|
|
{Boolean(service.upcoming?.length) && (
|
|
<div>
|
|
<p>
|
|
Edições já agendadas que se aplicarão nas
|
|
respectivas datas:
|
|
</p>
|
|
<ul>
|
|
{service.upcoming!.map((edition) => (
|
|
<li key={edition.version}>
|
|
Versão {edition.version}:{' '}
|
|
{edition.effective_from}.
|
|
</li>
|
|
))}
|
|
</ul>
|
|
<p>
|
|
Uma correcção imediata não cancela estas datas. Para
|
|
substituir uma edição futura, publique a correcção
|
|
com a mesma data de início.
|
|
</p>
|
|
</div>
|
|
)}
|
|
{schedulingAvailable ? (
|
|
<label className="block">
|
|
Data de entrada em vigor da nova versão
|
|
<input
|
|
className={input}
|
|
type="date"
|
|
min={today || undefined}
|
|
value={form.data.effective_from}
|
|
onChange={(event) =>
|
|
form.setData((data) => ({
|
|
...data,
|
|
confirm: false,
|
|
effective_from: event.target.value,
|
|
}))
|
|
}
|
|
/>
|
|
<span className="block text-sm">
|
|
Vazio significa a data da publicação. Calendário do
|
|
portal
|
|
{calendarTimezone ? `: ${calendarTimezone}` : ''}.
|
|
Versões futuras não substituem a versão actual antes
|
|
dessa data.
|
|
</span>
|
|
</label>
|
|
) : (
|
|
<p className="text-sm">
|
|
O agendamento requer a actualização das datas de entrada
|
|
em vigor neste destino. A publicação imediata existente
|
|
continua disponível.
|
|
</p>
|
|
)}
|
|
</fieldset>
|
|
<fieldset disabled={form.processing} className="space-y-4">
|
|
<legend className="text-xl font-bold">
|
|
Campos e condições de preenchimento
|
|
</legend>
|
|
{Object.entries(fields).map(([key, field]) => (
|
|
<section
|
|
key={key}
|
|
className={card + ' space-y-3'}
|
|
aria-label={`Campo ${key}`}
|
|
>
|
|
<h3 className="font-bold">
|
|
{field.label}{' '}
|
|
<span className="font-normal">({key})</span>
|
|
</h3>
|
|
<label className="block">
|
|
Pergunta ou rótulo
|
|
<input
|
|
required
|
|
maxLength={300}
|
|
className={input}
|
|
value={field.label}
|
|
onChange={(event) =>
|
|
update(key, { label: event.target.value })
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="block">
|
|
Tipo de resposta
|
|
<select
|
|
disabled={Boolean(service.fixedTypes?.[key])}
|
|
className={input}
|
|
value={field.type}
|
|
onChange={(event) => {
|
|
const next = {
|
|
...field,
|
|
type: event.target.value,
|
|
};
|
|
if (
|
|
!['select', 'multiselect'].includes(
|
|
next.type,
|
|
)
|
|
)
|
|
delete next.options;
|
|
else
|
|
next.options ??= {
|
|
opcao_1: 'Primeira opção',
|
|
};
|
|
if (next.type !== 'number') delete next.min;
|
|
if (next.type !== 'consent')
|
|
delete next.statement;
|
|
else next.statement ??= '';
|
|
if (next.type === 'attachment') {
|
|
next.mime_types ??= [
|
|
'application/pdf',
|
|
'image/png',
|
|
'image/jpeg',
|
|
'text/plain',
|
|
];
|
|
next.max_size_kb ??= 10240;
|
|
next.max_files ??= 1;
|
|
} else {
|
|
delete next.mime_types;
|
|
delete next.max_size_kb;
|
|
delete next.max_files;
|
|
}
|
|
const changedFields = {
|
|
...fields,
|
|
[key]: next,
|
|
};
|
|
for (const [
|
|
dependentKey,
|
|
dependent,
|
|
] of Object.entries(changedFields)) {
|
|
if (dependent.when?.field !== key)
|
|
continue;
|
|
const when = { ...dependent.when };
|
|
if (next.type === 'multiselect')
|
|
when.operator = 'contains';
|
|
else delete when.operator;
|
|
changedFields[dependentKey] = {
|
|
...dependent,
|
|
when,
|
|
};
|
|
}
|
|
setFields(changedFields);
|
|
}}
|
|
>
|
|
{Object.entries(types).map(([value, label]) => (
|
|
<option key={value} value={value}>
|
|
{label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
{service.fixedTypes?.[key] && (
|
|
<span className="text-sm">
|
|
O tipo deste campo é usado pelo motor de
|
|
validação existente. Para outra resposta,
|
|
acrescente um novo campo.
|
|
</span>
|
|
)}
|
|
</label>
|
|
<label className="flex gap-2">
|
|
<input
|
|
type="checkbox"
|
|
checked={field.required}
|
|
onChange={(event) =>
|
|
update(key, {
|
|
required: event.target.checked,
|
|
})
|
|
}
|
|
/>
|
|
Obrigatório ao submeter, quando aplicável
|
|
</label>
|
|
{field.type === 'number' && (
|
|
<label className="block">
|
|
Valor mínimo
|
|
<input
|
|
className={input}
|
|
type="number"
|
|
min="0"
|
|
step="0.01"
|
|
value={field.min ?? 0}
|
|
onChange={(event) =>
|
|
update(key, {
|
|
min: Number(event.target.value),
|
|
})
|
|
}
|
|
/>
|
|
</label>
|
|
)}
|
|
{field.type === 'attachment' && (
|
|
<fieldset className="space-y-2 rounded border p-3">
|
|
<legend>
|
|
Limites dos documentos desta pergunta
|
|
</legend>
|
|
<p>
|
|
Aplicam-se à versão publicada e aos novos
|
|
pedidos que a utilizarem. Pedidos anteriores
|
|
conservam os seus limites.
|
|
</p>
|
|
{Object.entries({
|
|
'application/pdf': 'PDF',
|
|
'image/png': 'Imagem PNG',
|
|
'image/jpeg': 'Imagem JPEG',
|
|
'text/plain': 'Texto simples',
|
|
}).map(([mime, label]) => (
|
|
<label className="block" key={mime}>
|
|
<input
|
|
type="checkbox"
|
|
checked={
|
|
field.mime_types?.includes(
|
|
mime,
|
|
) ?? false
|
|
}
|
|
onChange={(event) =>
|
|
update(key, {
|
|
mime_types: event.target
|
|
.checked
|
|
? [
|
|
...(field.mime_types ??
|
|
[]),
|
|
mime,
|
|
]
|
|
: (
|
|
field.mime_types ??
|
|
[]
|
|
).filter(
|
|
(value) =>
|
|
value !==
|
|
mime,
|
|
),
|
|
})
|
|
}
|
|
/>{' '}
|
|
{label}
|
|
</label>
|
|
))}
|
|
<label className="block">
|
|
Máximo por ficheiro (KB)
|
|
<input
|
|
className={input}
|
|
type="number"
|
|
required
|
|
min={1}
|
|
max={10240}
|
|
step={1}
|
|
value={field.max_size_kb ?? ''}
|
|
onChange={(event) =>
|
|
update(key, {
|
|
max_size_kb: Number(
|
|
event.target.value,
|
|
),
|
|
})
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="block">
|
|
Máximo de ficheiros seleccionados
|
|
<input
|
|
className={input}
|
|
type="number"
|
|
required
|
|
min={1}
|
|
max={100}
|
|
step={1}
|
|
value={field.max_files ?? ''}
|
|
onChange={(event) =>
|
|
update(key, {
|
|
max_files: Number(
|
|
event.target.value,
|
|
),
|
|
})
|
|
}
|
|
/>
|
|
</label>
|
|
</fieldset>
|
|
)}
|
|
{field.type === 'consent' && (
|
|
<label className="block">
|
|
Texto exacto da declaração
|
|
<textarea
|
|
required
|
|
minLength={3}
|
|
maxLength={5000}
|
|
className={input}
|
|
value={field.statement ?? ''}
|
|
onChange={(event) =>
|
|
update(key, {
|
|
statement: event.target.value,
|
|
})
|
|
}
|
|
/>
|
|
<span className="block text-sm">
|
|
A pessoa escolhe aceitar ou recusar;
|
|
obrigatório exige uma resposta explícita.
|
|
Esta escolha não substitui o consentimento
|
|
da submissão nem concede permissões ou
|
|
validação oficial.
|
|
</span>
|
|
</label>
|
|
)}
|
|
{['select', 'multiselect'].includes(field.type) && (
|
|
<fieldset className="space-y-2">
|
|
<legend>Opções de resposta</legend>
|
|
{Object.entries(field.options ?? {}).map(
|
|
([value, label]) => (
|
|
<div key={value} className="flex gap-2">
|
|
<label className="flex-1">
|
|
Texto da opção ({value})
|
|
<input
|
|
required
|
|
maxLength={300}
|
|
className={input}
|
|
value={label}
|
|
onChange={(event) =>
|
|
update(key, {
|
|
options: {
|
|
...field.options,
|
|
[value]:
|
|
event.target
|
|
.value,
|
|
},
|
|
})
|
|
}
|
|
/>
|
|
</label>
|
|
<button
|
|
type="button"
|
|
className="underline"
|
|
onClick={() => {
|
|
const next = {
|
|
...field.options,
|
|
};
|
|
delete next[value];
|
|
update(key, {
|
|
options: next,
|
|
});
|
|
}}
|
|
>
|
|
Remover opção
|
|
</button>
|
|
</div>
|
|
),
|
|
)}
|
|
<button
|
|
type="button"
|
|
className="underline"
|
|
onClick={() => {
|
|
let index = 1;
|
|
while (
|
|
`opcao_${index}` in
|
|
(field.options ?? {})
|
|
)
|
|
index++;
|
|
update(key, {
|
|
options: {
|
|
...field.options,
|
|
[`opcao_${index}`]:
|
|
'Nova opção',
|
|
},
|
|
});
|
|
}}
|
|
>
|
|
Acrescentar opção
|
|
</button>
|
|
</fieldset>
|
|
)}
|
|
<label className="block">
|
|
Apresentar este campo
|
|
<select
|
|
className={input}
|
|
value={field.when?.field ?? ''}
|
|
onChange={(event) => {
|
|
const next = { ...field };
|
|
if (event.target.value)
|
|
next.when = {
|
|
field: event.target.value,
|
|
equals: '',
|
|
...(fields[event.target.value]
|
|
?.type === 'multiselect'
|
|
? {
|
|
operator:
|
|
'contains' as const,
|
|
}
|
|
: {}),
|
|
};
|
|
else delete next.when;
|
|
setFields({ ...fields, [key]: next });
|
|
}}
|
|
>
|
|
<option value="">Sempre</option>
|
|
{field.when &&
|
|
(!fields[field.when.field] ||
|
|
['number', 'attachment'].includes(
|
|
fields[field.when.field].type,
|
|
)) && (
|
|
<option
|
|
disabled
|
|
value={field.when.field}
|
|
>
|
|
Origem indisponível para esta
|
|
condição; escolha outra resposta ou
|
|
«Sempre»
|
|
</option>
|
|
)}
|
|
{Object.entries(fields)
|
|
.filter(
|
|
([other, source]) =>
|
|
other !== key &&
|
|
!['number', 'attachment'].includes(
|
|
source.type,
|
|
),
|
|
)
|
|
.map(([other, value]) => (
|
|
<option key={other} value={other}>
|
|
Quando a resposta a «{value.label}»
|
|
for…
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
{field.when && (
|
|
<label className="block">
|
|
Resposta que torna o campo aplicável
|
|
{['select', 'multiselect', 'consent'].includes(
|
|
fields[field.when.field]?.type,
|
|
) ? (
|
|
<select
|
|
required
|
|
className={input}
|
|
value={field.when.equals}
|
|
onChange={(event) =>
|
|
update(key, {
|
|
when: {
|
|
...field.when!,
|
|
field: field.when!.field,
|
|
equals: event.target.value,
|
|
},
|
|
})
|
|
}
|
|
>
|
|
<option value="">
|
|
Seleccionar resposta
|
|
</option>
|
|
{Object.entries(
|
|
fields[field.when.field].type ===
|
|
'consent'
|
|
? {
|
|
accepted: 'Aceite',
|
|
declined: 'Recusado',
|
|
}
|
|
: (fields[field.when.field]
|
|
.options ?? {}),
|
|
).map(([value, label]) => (
|
|
<option key={value} value={value}>
|
|
{label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
) : (
|
|
<input
|
|
required
|
|
className={input}
|
|
value={field.when.equals}
|
|
onChange={(event) =>
|
|
update(key, {
|
|
when: {
|
|
field: field.when!.field,
|
|
equals: event.target.value,
|
|
},
|
|
})
|
|
}
|
|
/>
|
|
)}
|
|
</label>
|
|
)}
|
|
<button
|
|
type="button"
|
|
className="underline"
|
|
onClick={() => {
|
|
const next = { ...fields };
|
|
delete next[key];
|
|
setFields(next);
|
|
}}
|
|
>
|
|
Retirar campo da nova versão
|
|
</button>
|
|
</section>
|
|
))}
|
|
<div className={card}>
|
|
<label className="block">
|
|
Identificador do novo campo
|
|
<input
|
|
className={input}
|
|
pattern="[a-z][a-z0-9_]*"
|
|
maxLength={64}
|
|
value={newKey}
|
|
placeholder="ex.: observacoes_adicionais"
|
|
onChange={(event) => setNewKey(event.target.value)}
|
|
/>
|
|
</label>
|
|
<button
|
|
type="button"
|
|
className="underline"
|
|
onClick={() => {
|
|
if (
|
|
!/^[a-z][a-z0-9_]{0,63}$/.test(newKey) ||
|
|
newKey in fields
|
|
) {
|
|
setLocalError(
|
|
'Indique um identificador novo, com letras minúsculas, números ou sublinhado.',
|
|
);
|
|
return;
|
|
}
|
|
setFields({
|
|
...fields,
|
|
[newKey]: {
|
|
label: 'Nova pergunta',
|
|
type: 'text',
|
|
required: false,
|
|
},
|
|
});
|
|
setNewKey('');
|
|
setLocalError('');
|
|
}}
|
|
>
|
|
Acrescentar campo
|
|
</button>
|
|
{localError && <p role="alert">{localError}</p>}
|
|
</div>
|
|
</fieldset>
|
|
<section
|
|
className={card + ' space-y-3'}
|
|
aria-label="Conferir efeito da publicação"
|
|
>
|
|
<h2 className="text-xl font-bold">
|
|
Conferir nova versão {service.version + 1}
|
|
</h2>
|
|
<p>
|
|
Alterações de identificação:{' '}
|
|
{metaChanges.map((key) => labels[key]).join(', ') ||
|
|
'nenhuma'}
|
|
. Campos acrescentados, alterados ou retirados:{' '}
|
|
{changed.join(', ') || 'nenhum'}.
|
|
</p>
|
|
{(changed.length > 0 || metaChanges.length > 0) && (
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-left text-sm">
|
|
<caption>
|
|
Comparação com a versão {service.version}
|
|
</caption>
|
|
<thead>
|
|
<tr>
|
|
<th className="p-2">Elemento</th>
|
|
<th className="p-2">Versão anterior</th>
|
|
<th className="p-2">Nova versão</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{metaChanges.map((key) => (
|
|
<tr key={key}>
|
|
<th className="p-2">{labels[key]}</th>
|
|
<td className="p-2">
|
|
{service.definition[key]}
|
|
</td>
|
|
<td className="p-2">
|
|
{form.data.definition[key]}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
{changed.map((key) => (
|
|
<tr key={key}>
|
|
<th className="p-2">{key}</th>
|
|
<td className="p-2">
|
|
{describeField(
|
|
service.definition.fields[key],
|
|
)}
|
|
</td>
|
|
<td className="p-2">
|
|
{describeField(fields[key])}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
<p>
|
|
Aplica-se aos pedidos criados com esta nova versão. Pedidos
|
|
e submissões anteriores continuam vinculados à sua definição
|
|
original; as respectivas respostas não são reescritas.
|
|
</p>
|
|
{schedulingAvailable && (
|
|
<p>
|
|
Entrada em vigor proposta:{' '}
|
|
{form.data.effective_from ||
|
|
today ||
|
|
'na data da publicação'}
|
|
{dateChanged ? ' (alterada)' : ''}. Para cada serviço,
|
|
aplica-se a data de início já atingida mais recente; na
|
|
mesma data, prevalece a versão mais elevada. As versões
|
|
históricas sem data são usadas enquanto não existir uma
|
|
edição com início aplicável. Uma correcção para hoje não
|
|
cancela uma edição já agendada para uma data posterior.
|
|
</p>
|
|
)}
|
|
<label className="block">
|
|
Motivo da alteração
|
|
<textarea
|
|
required
|
|
minLength={20}
|
|
maxLength={2000}
|
|
className={input}
|
|
value={form.data.reason}
|
|
onChange={(event) =>
|
|
form.setData('reason', event.target.value)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="flex gap-2">
|
|
<input
|
|
type="checkbox"
|
|
checked={form.data.confirm}
|
|
onChange={(event) =>
|
|
form.setData('confirm', event.target.checked)
|
|
}
|
|
/>
|
|
Conferi as alterações e o efeito sobre pedidos futuros.
|
|
</label>
|
|
{Object.entries(form.errors).map(([key, value]) => (
|
|
<p key={key} role="alert">
|
|
{value}
|
|
</p>
|
|
))}
|
|
<button
|
|
className={button}
|
|
disabled={
|
|
form.processing ||
|
|
!form.data.confirm ||
|
|
(service.version > 0 &&
|
|
changed.length === 0 &&
|
|
metaChanges.length === 0 &&
|
|
!dateChanged)
|
|
}
|
|
>
|
|
{form.processing ? 'A publicar…' : 'Publicar nova versão'}
|
|
</button>
|
|
</section>
|
|
</form>
|
|
);
|
|
}
|