Files
Investidor-V2/resources/js/pages/desk/service-editor.tsx
T

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>
);
}